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.
Delta semántico · 1099
03_ukupacha/agora/agora-app/examples/pantallazo_agora.rs
03_ukupacha/agora/agora-app/src/chrome.rs
- nueva def PERFIL_W
- nueva def RAIL_W
- nueva fn canvas
- nueva def multiselect_overlay
- nueva def shell_view
- nueva fn sidebar_widget
03_ukupacha/agora/agora-app/src/main.rs
- nueva struct AgoraApp
- nueva type Model
- nueva type Msg
- nueva def PASS_DEV
- nueva fn ahora
- nueva fn app_menu
- nueva fn arranca_watcher
- nueva mod chrome
- nueva fn init
- nueva fn initial_size
- nueva fn main
- nueva fn menu_command_to_msg
- nueva fn menubar_spec
- nueva mod model
- nueva mod modulos
- nueva fn on_key
- nueva mod perfil
- nueva fn recargar_grafo
- nueva fn tema
- nueva mod tiles
- nueva fn title
- nueva mod ui
- nueva fn update
- nueva fn view
- nueva fn view_overlay
03_ukupacha/agora/agora-app/src/model.rs
- nueva def ComposeField
- nueva def FocusedInput
- nueva def Model
- nueva def Msg
- nueva def Perfil
- nueva def Screen
- nueva def StatusBanner
- nueva def StatusLevel
- nueva def TILES_INICIALES
- nueva def Tile
- nueva fn acta_verificada pub
- nueva fn actas_tiene_autor pub
- nueva fn apply_edit_menu_action pub
- nueva fn campo_evento pub
- nueva fn confianza_en pub
- nueva fn desbloquear_seeds_silencioso pub
- nueva fn edit_focused pub
- nueva fn focused_input_ref pub
- nueva fn guardar_actas pub
- nueva fn guardar_libreta pub
- nueva fn huella_corta pub
- nueva fn intentar_unlock pub
- nueva fn invalidar_derivado
- nueva fn is_mine pub
- nueva fn libro_actas_vacio pub
- nueva fn limpiar_form_mesa pub
- nueva fn msg_asentar_mesa pub
- nueva fn multi_firmas_validas pub
- nueva fn multi_firmo pub
- nueva fn nombre_humano pub
- nueva fn nuevo pub
- nueva fn perfil pub
- nueva fn perfil_mut pub
- nueva fn registrar_identidades_huerfanas pub
- nueva fn save_graph pub
- nueva fn set_status pub
- nueva fn signer_keypair pub
- nueva fn sincronizar_perfil pub
- nueva fn tiene_petname pub
03_ukupacha/agora/agora-app/src/modulos/agora.rs
- nueva fn bienvenida_view
- nueva def canvas_atestaciones
- nueva def canvas_identidades
- nueva fn crear_identidad_view
- nueva fn lista
- nueva fn nombrar_view
- nueva fn seccion
- nueva def sidebar_actas
- nueva def sidebar_atestaciones
- nueva def sidebar_identidades
- nueva def sidebar_meta
- nueva def sidebar_multifirma
- nueva def sidebar_politica
03_ukupacha/agora/agora-app/src/modulos/mod.rs
03_ukupacha/agora/agora-app/src/modulos/qillqa/autoridad.rs
- nueva def ALCALDE
- nueva def CONCEJAL
- nueva def DURACION_EPOCA
- nueva def Fase
- nueva def INICIO_GENESIS
- nueva def Msg
- nueva type Pk
- nueva def REGIDOR
- nueva def SECRETARIO
- nueva def State
- nueva fn abre_en_el_traspaso_y_el_genesis_sigue_alcanzable
- nueva fn autorizantes
- nueva fn boton_chico
- nueva fn candidata
- nueva def canvas
- nueva fn ceremonia_rotante_cambia_la_llave_vigente
- nueva fn ceremonia_sin_rotar_es_aditiva
- nueva fn columna
- nueva fn drive
- nueva fn el_stepper_de_umbral_lo_gobierna_gestion_valida
- nueva fn epoca_entrante
- nueva fn fila
- nueva fn firmas_validas
- nueva fn fundar
- nueva fn fundar_desde_genesis
- nueva fn genesis_funda_con_autofirma_de_los_entrantes
- nueva fn golpe_de_los_entrantes_no_rota
- nueva fn inicio_entrante
- nueva fn kp
- nueva fn linea
- nueva fn nombre_de
- nueva def nueva
- nueva fn pk_de
- nueva fn propuesta_valida
- nueva fn seleccionados
- nueva fn semilla_aleatoria
- nueva def sidebar
- nueva mod tests
- nueva def update
- nueva fn verifica
03_ukupacha/agora/agora-app/src/modulos/qillqa/compras.rs
- nueva def CABE_CENTAVOS
- nueva def CELDA
- nueva def CELDA_CLON
- nueva def Msg
- nueva struct Oficinas
- nueva def PAGO_CENTAVOS
- nueva def PROVEEDOR_EXTRA
- nueva def SOBREGIRO_CENTAVOS
- nueva def State
- nueva def TESIS
- nueva def TESIS_CLON
- nueva def Vista
- nueva fn acciones
- nueva fn acciones_clon
- nueva fn acciones_reparto
- nueva fn adjudicar_cabe_asienta_y_baja_el_disponible
- nueva fn adjudicar_desde_la_oficina_1_gasta_la_celda_1
- nueva fn alternar_conexion_reabre_el_escenario_limpio
- nueva fn arranca_sobre_el_fixture_del_core
- nueva fn asentar
- nueva fn boton
- nueva fn botones
- nueva fn cada_control_de_oficinas_tiene_boton_de_verdad
- nueva def canvas
- nueva fn canvas_oficinas
- nueva fn canvas_proceso
- nueva fn celdas_row
- nueva fn clicables
- nueva fn compra_visible
- nueva fn despacha
- nueva fn despacha_exacto
- nueva fn el_clon_se_delata_al_reconciliar_con_las_epocas_exactas
- nueva fn el_escenario_de_oficinas_arranca_particionado_y_repartido
- nueva fn el_mismo_monto_rebota_particionado_y_entra_conectado
- nueva fn el_pago_no_re_gasta_la_partida
- nueva fn el_rechazo_de_la_celda_se_pinta_con_la_frase_del_core
- nueva fn el_sidebar_lista_los_hitos_de_la_vista_activa
- nueva fn el_tercer_pago_alcanza_pago_excede_adjudicado
- nueva fn el_toggle_cambia_la_regla_que_se_pinta
- nueva fn estado
- nueva fn fila
- nueva fn fila_alta
- nueva fn la_delacion_del_clon_no_se_borra_al_reconciliar_de_nuevo
- nueva fn la_vista_proceso_sigue_entera_detras_del_tab
- nueva fn los_cuatro_estados_del_veredicto_son_alcanzables_y_se_pintan
- nueva fn los_tabs_despachan_su_vista_desde_las_dos_vistas
- nueva def nueva
- nueva fn nueva_compra
- nueva fn nuevas_oficinas
- nueva fn oficinas_pinta_el_disponible_de_cada_celda_y_el_de_la_partida
- nueva fn pinta
- nueva fn reconciliar_sin_clon_da_ok
- nueva fn regla_del_reparto
- nueva fn reiniciar_oficinas_limpia_el_episodio_y_conserva_el_modo
- nueva fn reiniciar_vuelve_al_estado_inicial
- nueva fn rotulo
- nueva fn seleccionar_alterna
- nueva def sidebar
- nueva fn sobregiro_no_asienta_y_deja_el_disponible_intacto
- nueva fn stats_row
- nueva fn tal_cual
- nueva mod tests
- nueva fn textos
- nueva fn timeline
- nueva def update
- nueva fn ver_cambia_la_vista_y_el_sidebar_la_sigue
- nueva fn veredicto_view
- nueva fn vistas
03_ukupacha/agora/agora-app/src/modulos/qillqa/mesa.rs
- nueva def Msg
- nueva def State
- nueva fn abrir_o_sembrar
- nueva fn ahora
- nueva fn alta
- nueva fn arranca_con_la_cola_del_demo
- nueva fn asentar
- nueva fn campo
- nueva def canvas
- nueva fn cola_del
- nueva fn desde
- nueva fn dir_tmp
- nueva fn el_alta_asienta_y_aparece_en_la_cola
- nueva fn el_ancla_es_viva_no_una_constante
- nueva fn el_picker_elige_quien_firma
- nueva fn el_qr_se_genera_para_una_constancia_real
- nueva fn el_tipo_vacio_no_asienta
- nueva def error_view
- nueva fn fila
- nueva fn la_cola_se_reproyecta_desde_un_registro_leido_de_disco
- nueva def nueva
- nueva fn partes_de
- nueva def picker
- nueva fn qr_painter
- nueva fn seleccionado
- nueva fn seleccionar
- nueva def sidebar
- nueva def sin_error
- nueva fn sin_funcionario_con_llave_el_alta_se_rechaza
- nueva mod tests
- nueva fn token_corto
- nueva fn un_estado_corrupto_cae_al_demo_y_lo_avisa
- nueva fn un_refs_invalido_da_error_legible_y_no_asienta
- nueva def update
03_ukupacha/agora/agora-app/src/modulos/qillqa/mod.rs
03_ukupacha/agora/agora-app/src/modulos/qillqa/registro.rs
- nueva struct EntradaVista
- nueva def Msg
- nueva def SEMILLA_AUTORIDAD
- nueva def SEMILLA_FUNCIONARIO
- nueva def SERIE
- nueva def State
- nueva fn campo
- nueva def canvas
- nueva fn contenedor
- nueva fn detalle_card
- nueva fn entradas_vista
- nueva fn nota
- nueva def nueva
- nueva def sidebar
- nueva fn titulo
- nueva def update
- nueva fn verde
- nueva fn veredicto_card
03_ukupacha/agora/agora-app/src/modulos/qillqa/veeduria.rs
- nueva def Msg
- nueva def State
- nueva fn arranca_sobre_la_bitacora_del_core_y_ve_la_alerta
- nueva def canvas
- nueva fn conocer_al_observador_sube_la_credibilidad
- nueva fn detalle
- nueva fn el_desconocido_deja_opinion_vacua_no_descreencia
- nueva fn fila_style
- nueva fn frase_de_confianza
- nueva fn la_frase_distingue_no_conozco_de_no_le_creo
- nueva def nueva
- nueva fn observadores_conocidos
- nueva fn seleccionar_fuera_de_rango_no_revienta
- nueva def sidebar
- nueva fn stats_row
- nueva mod tests
- nueva def update
03_ukupacha/agora/agora-app/src/modulos/qillqa/verificar.rs
- nueva def Msg
- nueva def State
- nueva fn arranca_en_verde
- nueva fn banner_error
- nueva fn boton
- nueva fn boton_estado
- nueva fn cada_interruptor_alcanza_su_rojo
- nueva fn campo
- nueva def canvas
- nueva fn columna
- nueva fn el_pegado_invalido_explica_en_castellano
- nueva fn entradas
- nueva fn fila
- nueva fn hex_a_bytes
- nueva fn nota
- nueva def nueva
- nueva fn parsear_pegado
- nueva def sidebar
- nueva mod tests
- nueva fn titulo
- nueva fn una_constancia_ajena_entra_pegada
- nueva def update
- nueva fn verde
- nueva fn veredicto
03_ukupacha/agora/agora-app/src/perfil.rs
- nueva fn cabecera
- nueva fn config_identidad
- nueva fn divisor
- nueva fn experto_row
- nueva fn registros
- nueva def sidebar_view
- nueva fn switcher
- nueva fn tema_row
03_ukupacha/agora/agora-app/src/tiles/actas.rs
- nueva def actas_view
- nueva fn detalle_view
03_ukupacha/agora/agora-app/src/tiles/capacidad.rs
- nueva def CATALOGO
- nueva def capacidad_view
- nueva def resumen_permisos
03_ukupacha/agora/agora-app/src/tiles/compositor.rs
- nueva def compositor_view
03_ukupacha/agora/agora-app/src/tiles/mod.rs
- nueva def actas
- nueva def capacidad
- nueva def compositor
- nueva fn decision_incremental_firma_de_a_una_hasta_el_umbral
- nueva def fecha_humana
- nueva fn fecha_humana_epocas_conocidas
- nueva def format_duration
- nueva def format_max_age
- nueva def kind_str
- nueva fn libro_de_actas_firma_encadena_y_verifica
- nueva def multifirma
- nueva def politica
- nueva def release
- nueva mod tests
03_ukupacha/agora/agora-app/src/tiles/multifirma.rs
- nueva def multifirma_view
- nueva fn veredicto_view
03_ukupacha/agora/agora-app/src/tiles/politica.rs
- nueva fn att_dummy
- nueva def politica_view
- nueva fn razon_distingue_los_tres_estados
- nueva fn razon_humana
- nueva mod tests
03_ukupacha/agora/agora-app/src/tiles/release.rs
- nueva def release_view
03_ukupacha/agora/agora-app/src/ui.rs
- nueva def boton_frac
- nueva def button_palette_primary
- nueva def button_palette_secondary
- nueva def bytes_to_hex
- nueva def column
- nueva def edge_padding
- nueva def empty
- nueva def grow
- nueva def hex_to_bytes
- nueva def input_row
- nueva def label_line
- nueva def parse_hash32
- nueva def row
- nueva def spacer
- nueva def status_layout
- nueva def unlock_view
03_ukupacha/agora/agora-ayllu/src/lib.rs
- nueva struct ActaConstitucional pub
- nueva enum AylluError pub
- nueva struct Constitucion pub
- nueva enum ErrorSelloAyllu pub
- nueva def PREDICATE_CONSTITUCION pub
- nueva struct Tarifa pub
- nueva struct Turno pub
- nueva fn acta_round_trippea_postcard
- nueva fn anclas_constitucionales pub
- nueva fn anclas_verifican_y_apuntan_al_sello
- nueva fn ayllu_firma_al_umbral_y_verifica
- nueva fn ayllu_por_debajo_del_umbral_no_firma
- nueva fn canonical_bytes pub
- nueva fn comunidad
- nueva fn comunidad_pk
- nueva fn constitucion_genesis
- nueva fn enmendar pub
- nueva fn enmienda_al_umbral_previo_encadena_y_verifica
- nueva fn enmienda_no_encadenada_es_enlace_roto
- nueva fn enmienda_sin_alcanzar_umbral_de_enmienda_falla
- nueva fn es_genesis pub
- nueva fn exigir_conjunto_exacto
- nueva fn firma_faltante_hace_fallar_la_verificacion
- nueva fn firmante_ajeno_no_ayuda_a_alcanzar_el_umbral
- nueva fn firmar_como_ayllu pub
- nueva fn genesis_con_intruso_no_sella
- nueva fn genesis_firmada_por_todos_verifica
- nueva fn genesis_sin_un_fundador_no_sella
- nueva fn hex32
- nueva fn ids
- nueva fn kps
- nueva mod mallki pub
- nueva fn miembro_revocado_no_cuenta_para_el_umbral
- nueva fn refs
- nueva fn revocaciones_dejan_el_umbral_inalcanzable
- nueva fn rotacion_entra_al_sello_y_se_consulta
- nueva fn sellar pub
- nueva fn sellar_sello_del_ayllu pub
- nueva fn sello pub
- nueva fn sello_es_determinista_y_sensible
- nueva fn sello_plural_verifica_contra_la_constitucion
- nueva fn tarifa_de pub
- nueva fn tarifa_duplicada_o_vacia_se_rechaza
- nueva fn tarifas_entran_al_sello_y_se_consultan
- nueva mod tests
- nueva fn turno_de pub
- nueva fn turno_de_no_miembro_o_duplicado_se_rechaza
- nueva fn umbral_cero_o_excesivo_se_rechaza
- nueva fn validar pub
- nueva fn verificar pub
- nueva fn verificar_enmienda pub
- nueva fn verificar_firma_del_ayllu pub
- nueva fn verificar_sello_del_ayllu pub
03_ukupacha/agora/agora-ayllu/src/mallki.rs
- nueva struct ActaHerencia pub
- nueva struct ActaSucesion pub
- nueva struct ActaTestamento pub
- nueva struct Herencia pub
- nueva enum MallkiError pub
- nueva struct Sucesion pub
- nueva struct Testamento pub
- nueva fn acta_sucesion_roundtrippea_postcard
- nueva fn canonical_bytes pub
- nueva fn canonical_bytes pub
- nueva fn canonical_bytes pub
- nueva fn deserialize pub
- nueva fn ejecutar pub
- nueva fn guardian_revocado_no_cuenta_para_la_sucesion
- nueva fn herencia_avalada_por_guardianes_verifica
- nueva fn herencia_sin_umbral_falla
- nueva fn ids
- nueva fn kp
- nueva fn otorgar pub
- nueva fn sellar pub
- nueva fn sello pub
- nueva fn sello pub
- nueva fn sello pub
- nueva fn serialize pub
- nueva mod sig_serde
- nueva fn silencio_cumplido pub
- nueva fn silencio_habilita_recien_tras_n_epocas
- nueva fn sucederse_a_si_mismo_se_rechaza
- nueva fn sucesion_al_umbral_tras_silencio_verifica
- nueva fn sucesion_antes_del_silencio_falla
- nueva fn sucesion_de_otro_testamento_no_corresponde
- nueva fn sucesion_sin_umbral_de_guardianes_falla
- nueva fn sucesor_ajeno_no_ejecuta
- nueva fn testamento
- nueva fn testamento_de_otro_no_sella
- nueva fn testamento_firmado_por_el_operador_verifica
- nueva fn testamento_manipulado_no_verifica
- nueva mod tests
- nueva fn validar pub
- nueva fn verificar pub
- nueva fn verificar pub
- nueva fn verificar pub
03_ukupacha/agora/agora-channel/src/lib.rs
- nueva enum CanalError pub
- nueva fn autor_como_identity_id pub
- nueva fn autor_como_identity_id_coincide_con_keypair
- nueva fn canal_con_firma_rota_se_detecta_en_el_historial
- nueva fn canal_con_historial_valido_pasa
- nueva fn canal_con_timestamps_iguales_se_rechaza
- nueva fn canal_con_timestamps_no_monotonicos_se_rechaza
- nueva fn canal_vacio_es_valido
- nueva fn concesion_con_autor_ajeno_falla
- nueva fn concesion_con_bytecode_manipulado_se_detecta
- nueva fn concesion_con_permisos_manipulados_se_detecta
- nueva fn firma_con_autor_ajeno_falla
- nueva fn firma_de_canal_dev_no_replica_en_estable
- nueva fn firmar_capacidad pub
- nueva fn firmar_manifiesto pub
- nueva fn firmar_para_anuncio pub
- nueva fn firmar_para_anuncio_coincide_con_la_firma_de_raiz
- nueva fn firmar_raiz pub
- nueva fn firmar_y_verificar_capacidad
- nueva fn firmar_y_verificar_manifiesto
- nueva fn firmar_y_verificar_una_raiz
- nueva fn from
- nueva fn hash_de
- nueva fn manifiesto_con_hash_manipulado_falla
- nueva fn manifiesto_firmado_layout_es_128_bytes_raw
- nueva fn pipeline_concesion_ata_el_objeto_bytecode_y_resuelve_por_interseccion
- nueva fn raiz_manipulada_se_detecta
- nueva mod release pub
- nueva mod tests
- nueva fn timestamp_manipulado_se_detecta
- nueva fn verificar_canal pub
- nueva fn verificar_capacidad pub
- nueva fn verificar_firma_dalek
- nueva fn verificar_manifiesto pub
- nueva fn verificar_raiz pub
03_ukupacha/agora/agora-channel/src/release.rs
- nueva struct AppSpec pub
- nueva struct ObjetoEmitido pub
- nueva struct Release pub
- nueva enum ReleaseError pub
- nueva fn app
- nueva fn app_con_permisos_emite_concesion_firmada_que_verifica
- nueva fn app_sin_bytecode_es_error
- nueva fn bytecode_identico_se_deduplica
- nueva fn canal_y_sobre_firmados_verifican
- nueva fn construir_release pub
- nueva fn emitir_objeto
- nueva fn fmt
- nueva fn manifiesto_deserializa_y_lista_las_apps
- nueva fn manifiesto_hash_coincide_con_su_objeto_emitido
- nueva fn nombre_de_canal_distinto_invalida_la_firma_del_anuncio
- nueva fn release_vacio_es_error
- nueva mod tests
03_ukupacha/agora/agora-channel/tests/e2e_mudanza_demo.rs
- nueva def FIXTURE
- nueva fn fixture_demo_parsea_como_mudanza_y_verifica_como_el_kernel
03_ukupacha/agora/agora-cli/src/atestacion.rs
- nueva struct GraphBundle pub
- nueva fn atestacion_listar pub
- nueva fn atestar pub
- nueva fn exportar pub
- nueva fn grafo_resumen pub
- nueva fn importar pub
- nueva fn verificar pub
03_ukupacha/agora/agora-cli/src/canal.rs
- nueva fn canal_extender pub
- nueva fn canal_mostrar pub
- nueva fn canal_nuevo pub
- nueva fn canal_verificar pub
03_ukupacha/agora/agora-cli/src/cmd.rs
- nueva enum AtestacionOp pub
- nueva enum CanalOp pub
- nueva enum Cmd pub
- nueva enum DispositivoOp pub
- nueva enum IdentidadOp pub
- nueva enum KindArg pub
- nueva enum MotivoArg pub
- nueva enum WawaOp pub
- nueva fn from
- nueva fn from
03_ukupacha/agora/agora-cli/src/dispositivo.rs
- nueva def CHARLA
- nueva def SERVIDOR
- nueva def VIGENCIA_DEFAULT_SEG
- nueva enum Veredicto pub
- nueva fn corto
- nueva fn dispositivo_delegar pub
- nueva fn dispositivo_listar pub
- nueva fn dispositivo_revocar pub
- nueva fn el_veredicto_le_dice_al_humano_por_que_no_rige
- nueva fn etiqueta_id
- nueva fn explicacion
- nueva fn fecha_utc pub
- nueva fn fecha_utc_formatea_sin_dependencias
- nueva fn la_revocacion_de_un_tercero_no_cambia_el_veredicto
- nueva fn otras_potestades_vivas
- nueva fn palabra pub
- nueva fn parse_device pub
- nueva fn parse_device_acepta_64_hex_y_rechaza_lo_demas
- nueva fn revocada_se_distingue_de_vencida
- nueva mod tests
- nueva fn veredicto pub
03_ukupacha/agora/agora-cli/src/identidad.rs
- nueva fn desbloquear pub
- nueva fn identidad_exportar pub
- nueva fn identidad_listar pub
- nueva fn identidad_nueva pub
- nueva fn identidad_remove pub
- nueva fn identidad_rename pub
- nueva fn identidad_revocar pub
- nueva fn identidad_rotar pub
03_ukupacha/agora/agora-cli/src/main.rs
- nueva struct Cli
- nueva mod atestacion
- nueva mod canal
- nueva mod cmd
- nueva mod dispositivo
- nueva fn hash_objeto_bytecode_coincide_con_construir_release
- nueva mod identidad
- nueva fn main
- nueva fn overlay_revocacion_wire_verifica_bajo_el_canonico_del_kernel
- nueva fn parse_permisos_cubre_todas_las_constantes
- nueva fn parse_permisos_numerico_y_por_nombre_coinciden
- nueva fn parse_permisos_rechaza_nombre_desconocido
- nueva fn run
- nueva mod sesion
- nueva mod tests
- nueva mod wawa
03_ukupacha/agora/agora-cli/src/sesion.rs
- nueva type CliResult pub
- nueva enum Error pub
- nueva struct Sesion pub
- nueva fn abrir pub
- nueva fn ahora_unix pub
- nueva fn cargar_keypair pub
- nueva fn es_mia pub
- nueva fn guardar pub
- nueva fn hex_de pub
- nueva fn kind_label pub
- nueva fn leer_seed_de_stdin pub
- nueva fn parse_hash pub
- nueva fn parse_hex_32 pub
- nueva fn parse_id pub
- nueva fn resolver_id pub
03_ukupacha/agora/agora-cli/src/wawa.rs
- nueva struct SpecApp
- nueva struct SpecRelease
- nueva def TAMANO_TROZO
- nueva fn canal_por_defecto
- nueva fn cargar_anuncio_bundle
- nueva fn cargar_objetos_bundle
- nueva fn emitir_objeto
- nueva fn exportar_arbol
- nueva fn importar_archivo
- nueva fn importar_dir
- nueva fn leer_objeto
- nueva fn parse_permisos pub
- nueva fn reconstruir_archivo
- nueva fn techo_por_defecto
- nueva fn umbral_fragmento
- nueva fn wawa_anunciar pub
- nueva fn wawa_concesion pub
- nueva fn wawa_exportar pub
- nueva fn wawa_forjar_clave pub
- nueva fn wawa_forjar_propuesta pub
- nueva fn wawa_importar pub
- nueva fn wawa_importar_imagen pub
- nueva fn wawa_publicar pub
- nueva fn wawa_revocar pub
03_ukupacha/agora/agora-confianza/src/lib.rs
- nueva struct Confianza pub
- nueva struct Veredicto pub
- nueva fn acepta pub
- nueva fn acepta_rol pub
- nueva fn acepta_rol_cierra_la_puerta_al_sujeto_revocado
- nueva fn avalado_es_creencia_igual_al_grado
- nueva fn con_defecto pub
- nueva fn desconocido_es_vacua_no_descreencia
- nueva fn esta_viva pub
- nueva fn esta_viva_sigue_la_revocacion
- nueva fn evaluar pub
- nueva fn evaluar_clave pub
- nueva fn grafo_avalado
- nueva fn kp
- nueva fn nueva pub
- nueva fn opinion pub
- nueva fn peso pub
- nueva fn peso_igual_creencia_en_los_tres_estados
- nueva fn revocado_colapsa_a_descreencia_pese_a_avales_vivos
- nueva mod tests
03_ukupacha/agora/agora-core/src/attest.rs
- nueva struct Attestation pub
- nueva fn create pub
- nueva fn created_attestation_verifies
- nueva fn deserialize pub
- nueva fn is_self_attested pub
- nueva fn self_attestation_is_flagged
- nueva fn serialize pub
- nueva mod sig_serde
- nueva fn spoofed_attester_is_rejected
- nueva fn stable_hash pub
- nueva fn stable_hash_changes_when_value_changes
- nueva fn stable_hash_is_deterministic_per_attestation
- nueva fn tampered_claim_fails_verification
- nueva mod tests
- nueva fn third_party_attestation_is_not_self
- nueva fn verify pub
03_ukupacha/agora/agora-core/src/claim.rs
- nueva struct Claim pub
- nueva fn canonical_bytes pub
- nueva fn canonical_bytes_are_deterministic
- nueva fn distinct_claims_have_distinct_canonical_bytes
- nueva fn field_boundaries_are_unambiguous
- nueva fn new pub
- nueva fn subject
- nueva mod tests
03_ukupacha/agora/agora-core/src/identity.rs
- nueva struct Identity pub
- nueva struct IdentityId pub
- nueva enum IdentityKind pub
- nueva struct Keypair pub
- nueva fn as_bytes pub
- nueva fn different_seeds_yield_different_identities
- nueva fn fmt
- nueva fn from_bytes pub
- nueva fn from_public_key pub
- nueva fn from_seed pub
- nueva fn id pub
- nueva fn id_display_is_abbreviated_hex
- nueva fn id_is_stable_for_a_key
- nueva fn identity pub
- nueva fn identity_id pub
- nueva fn public_key pub
- nueva fn sign pub
- nueva fn signature_roundtrips
- nueva fn tampered_message_fails_verification
- nueva mod tests
- nueva fn verify_signature pub
- nueva fn wrong_key_fails_verification
03_ukupacha/agora/agora-core/src/lib.rs
03_ukupacha/agora/agora-core/src/lifecycle.rs
- nueva struct KeyRotation pub
- nueva enum RevReason pub
- nueva struct Revocation pub
- nueva fn byte
- nueva fn canonical_bytes pub
- nueva fn canonical_bytes pub
- nueva fn create pub
- nueva fn create pub
- nueva fn deserialize pub
- nueva fn is_active_at pub
- nueva fn kp
- nueva fn new_id pub
- nueva fn old_id pub
- nueva fn revocacion_firmante_fuera_del_set_no_cuenta
- nueva fn revocacion_mensaje_alterado_invalida_firmas
- nueva fn revocacion_none_y_some_cero_no_colisionan
- nueva fn revocacion_permanente_vs_temporal
- nueva fn revocacion_quorum_2_de_3_verifica
- nueva fn rotacion_con_una_firma_rota_falla
- nueva fn rotacion_distinto_timestamp_distinto_canonico
- nueva fn rotacion_doble_firmada_verifica
- nueva fn rotacion_no_acepta_sucesora_ajena_sin_su_firma
- nueva fn serialize pub
- nueva mod sig_serde
- nueva fn signatures_valid pub
- nueva fn signatures_valid_acepta_firmas_reales_rechaza_forjadas
- nueva fn target_id pub
- nueva mod tests
- nueva fn verify pub
- nueva fn verify pub
03_ukupacha/agora/agora-core/src/multisig.rs
- nueva enum MultiSigError pub
- nueva struct MultiSigVerdict pub
- nueva struct MultiSignature pub
- nueva struct SingleSig pub
- nueva fn create pub
- nueva fn deserialize pub
- nueva fn dos_de_tres_acepta_con_dos_firmas_validas
- nueva fn dos_de_tres_rechaza_con_una_sola_valida
- nueva fn firma_valida
- nueva fn from
- nueva fn kps
- nueva fn mensaje_distinto_invalida_todas
- nueva fn multifirma_serializa_y_round_trippea_postcard
- nueva fn replay_de_la_misma_firma_no_infla_el_conteo
- nueva fn serialize pub
- nueva mod sig_serde
- nueva fn signer_mismatch_es_invalida
- nueva mod tests
- nueva fn threshold_en_lista_blanca_solo_cuenta_firmantes_permitidos
- nueva fn verdict pub
- nueva fn verify_threshold pub
- nueva fn verify_threshold_in pub
03_ukupacha/agora/agora-dispositivo/src/lib.rs
- nueva def ALCANCE_PLUMA_HISTORIA pub
- nueva def ANA
- nueva def CHARLA
- nueva struct Delegacion pub
- nueva struct DispositivoAtestado pub
- nueva enum ErrorDispositivo pub
- nueva def H
- nueva def LAPTOP
- nueva struct MaquinaAtestada pub
- nueva def PREDICATE_DELEGACION pub
- nueva def PREDICATE_DELEGACION_REVOCADA pub
- nueva def PREDICATE_DISPOSITIVO pub
- nueva def PREDICATE_MAQUINA pub
- nueva struct RevocacionDelegacion pub
- nueva def SERVIDOR
- nueva fn ana
- nueva fn armar_valor
- nueva fn atestar_dispositivo pub
- nueva fn atestar_maquina pub
- nueva fn atesto_mi_maquina_y_se_sabe_quien_la_opera
- nueva fn byte_hex
- nueva fn de_atestacion pub
- nueva fn de_atestacion pub
- nueva fn de_atestacion pub
- nueva fn de_atestacion pub
- nueva fn delegacion_y_binding_no_se_confunden
- nueva fn delegar_en_dispositivo pub
- nueva fn desarmar_valor
- nueva fn deshex32
- nueva fn dev
- nueva fn el_alcance_admite_dos_puntos_y_espacios
- nueva fn el_servidor_publica_en_nombre_de_ana_solo_para_lo_que_le_dieron
- nueva fn hex
- nueva fn hex_round_trip
- nueva fn la_delegacion_vence
- nueva fn liga pub
- nueva fn maquina_sin_operador_declarado_no_tiene_a_quien_imputar
- nueva fn maquinas_de pub
- nueva fn nadie_declara_la_maquina_de_otro
- nueva fn nadie_delega_ni_revoca_potestades_de_otro
- nueva fn nibble
- nueva fn no_puedo_declarar_el_device_de_otro
- nueva fn no_se_puede_delegar_sin_alcance_ni_con_rango_muerto
- nueva fn operador_de pub
- nueva fn otra_atestacion_no_es_un_binding_de_device
- nueva fn quien_actua pub
- nueva fn revocar_corta_una_potestad_y_deja_vivas_las_demas
- nueva fn revocar_delegacion pub
- nueva fn round_trip_atesto_y_verifico_mi_device
- nueva mod tests
- nueva fn una_firma_adulterada_no_verifica
- nueva fn una_maquina_no_es_un_device
- nueva fn vigente_en pub
03_ukupacha/agora/agora-gossip/examples/two_node_sync.rs
- nueva def T0
- nueva fn attest
- nueva fn main
- nueva fn print_digest
03_ukupacha/agora/agora-gossip/src/lib.rs
- nueva type AttestationHash pub
- nueva struct Digest pub
- nueva struct GossipStats pub
- nueva enum Message pub
- nueva fn al_recibir_announce pub
- nueva fn al_recibir_bundle pub
- nueva fn al_recibir_request pub
- nueva fn announce_sin_diferencias_no_genera_request
- nueva fn bundle_corrupto_es_rechazado_y_contado
- nueva fn convergencia_es_idempotente_en_una_segunda_ronda
- nueva fn diff_against pub
- nueva fn diff_devuelve_los_que_le_faltan_al_otro
- nueva fn digest_de_grafo_vacio_es_vacio
- nueva fn digest_es_estable_aunque_cambie_el_orden_de_insercion
- nueva fn digest_recoge_un_hash_por_atestacion
- nueva fn dos_nodos_convergen_en_una_ronda_completa
- nueva fn from_graph pub
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn make_attestation
- nueva fn poblar_grafo
- nueva fn pull_de_grafo_vacio_devuelve_announce_vacio
- nueva fn pull_dispara_announce_del_receptor
- nueva fn request_pide_hashes_que_no_tenemos_y_se_omiten_silenciosamente
- nueva fn responder pub
- nueva mod tests
03_ukupacha/agora/agora-graph/src/forkproof.rs
- nueva struct RevocacionPorBifurcacion pub
- nueva fn nueva pub
- nueva fn objetivo pub
- nueva fn verificar pub
03_ukupacha/agora/agora-graph/src/lib.rs
- nueva struct Corroboration pub
- nueva def PREDICATE_GUARDIAN pub
- nueva struct RemoveStats pub
- nueva struct TrustGraph pub
- nueva struct TrustPolicy pub
- nueva struct VeredictoLeCreo pub
- nueva fn accepts pub
- nueva fn actors
- nueva fn add_attestation pub
- nueva fn add_revocation pub
- nueva fn add_revocation_rechaza_quorum_insuficiente
- nueva fn add_rotation pub
- nueva fn attest
- nueva fn attestation_count pub
- nueva fn attestations pub
- nueva fn attestations_about pub
- nueva fn attestations_by pub
- nueva fn autoatestarse_no_da_grado
- nueva fn compromiso_corta_la_cadena_de_rotacion
- nueva fn corroboration pub
- nueva fn corroboration_at pub
- nueva fn corroboration_counts_distinct_attesters
- nueva fn corroboration_of_at pub
- nueva fn current_key_at pub
- nueva fn default
- nueva fn duplicate_attestation_is_ignored
- nueva fn el_grado_satura_en_el_umbral_y_no_contradice_al_veredicto
- nueva fn empty_corroboration_for_unknown_claim
- nueva fn evidence_for pub
- nueva fn evidence_for_at pub
- nueva mod forkproof pub
- nueva fn grado pub
- nueva fn guardians_of pub
- nueva fn guardians_of_resuelve_autodeclaracion
- nueva fn id_hex
- nueva fn identities pub
- nueva fn identity pub
- nueva fn identity_count pub
- nueva fn ingest_revocation pub
- nueva fn is_accepted pub
- nueva fn is_accepted_at pub
- nueva fn is_accepted_legacy_ignora_kind_y_edad
- nueva fn is_empty pub
- nueva fn is_revoked_at pub
- nueva fn la_corroboracion_de_identidad_cruza_todos_los_claims
- nueva fn le_creo pub
- nueva fn le_creo_m_de_n_per_persona
- nueva fn negotiated_policy_decides_acceptance
- nueva fn new pub
- nueva fn parse_id_hex
- nueva fn policy_can_accept_self_attestation_when_negotiated
- nueva fn policy_max_age_rechaza_claims_viejos
- nueva fn policy_min_attesters_of_kind_exige_aval_de_tipo
- nueva fn register pub
- nueva fn rejects_attestation_with_broken_signature
- nueva fn remove_identity pub
- nueva fn remove_identity_purges_related_attestations
- nueva fn revocacion_suprime_evidencia_en_is_accepted_at
- nueva fn revocacion_temporal_caduca_y_la_evidencia_revive
- nueva fn revocations pub
- nueva fn rotacion_resuelve_clave_actual_en_cadena
- nueva fn rotations pub
- nueva fn self_attestation_is_distinguished_from_third_party
- nueva fn set_display_name pub
- nueva fn set_display_name_replaces_label_keeping_id
- nueva fn sin_umbral_todo_vale_uno
- nueva fn stores_and_queries_verified_attestations
- nueva fn strict pub
- nueva mod tests
- nueva fn third_party pub
- nueva fn total pub
- nueva fn un_atestador_revocado_deja_de_dar_confianza
- nueva fn with_max_age pub
- nueva fn with_min_of_kind pub
03_ukupacha/agora/agora-graph/tests/forkproof.rs
- nueva fn anillo
- nueva fn apuntar_a_otro_device_no_cuela
- nueva fn fork_de
- nueva fn guardian
- nueva fn revocacion_con_prueba_verifica_con_quorum
- nueva fn un_forastero_no_completa_el_quorum
- nueva fn un_motivo_que_no_sea_compromiso_no_cuela
- nueva fn una_prueba_manipulada_no_cuela
03_ukupacha/agora/agora-keystore/src/lib.rs
- nueva def BLOB_LEN pub
- nueva def CIPHERTEXT_LEN
- nueva struct EncryptedSeed pub
- nueva enum Error pub
- nueva struct Keystore pub
- nueva def MAGIC
- nueva def NONCE_LEN
- nueva type Result pub
- nueva def SALT_LEN
- nueva def VERSION
- nueva fn as_bytes pub
- nueva fn ciphertext_manipulado_falla
- nueva fn decrypt_seed pub
- nueva fn derive_key
- nueva fn encrypt_decrypt_roundtrip
- nueva fn encrypt_seed pub
- nueva fn exists pub
- nueva fn file_for
- nueva fn from_bytes pub
- nueva fn id_from_hex
- nueva fn into_bytes pub
- nueva fn keystore_list_ordenado_y_completo
- nueva fn keystore_load_identidad_ausente
- nueva fn keystore_load_passphrase_incorrecta
- nueva fn keystore_remove_es_idempotente
- nueva fn keystore_save_load_roundtrip
- nueva fn keystore_save_sobreescribe
- nueva fn largo_invalido_se_detecta
- nueva fn list pub
- nueva fn load pub
- nueva fn magic_invalido_se_detecta
- nueva fn open pub
- nueva fn open_default pub
- nueva fn path pub
- nueva fn remove pub
- nueva fn salt_manipulado_falla
- nueva fn salt_y_nonce_frescos_por_cifrado
- nueva fn save pub
- nueva fn seed_de
- nueva mod tests
- nueva fn version_desconocida_se_detecta
- nueva fn with_ext
- nueva fn wrong_passphrase_falla
03_ukupacha/agora/agora-net-brahman/examples/convergencia_minga.rs
- nueva fn main
03_ukupacha/agora/agora-net-brahman/src/lib.rs
- nueva struct AgoraNet pub
- nueva enum AgoraNetError pub
- nueva def GOSSIP_DHT_KEY pub
- nueva def GOSSIP_PROTOCOL pub
- nueva def MAX_FRAME_SIZE
- nueva struct PullStats pub
- nueva struct PushStats pub
- nueva struct SyncStats pub
- nueva fn add_dht_peer pub
- nueva fn anunciar_gossip pub
- nueva fn anunciar_mis_personas pub
- nueva fn anunciar_persona pub
- nueva fn anunciar_y_descubrir_peers_gossip_via_dht
- nueva fn anunciar_y_descubrir_persona_via_dht
- nueva fn atender_pull
- nueva fn atender_push
- nueva fn clave_persona
- nueva fn dejar_de_anunciar_gossip pub
- nueva fn dejar_de_anunciar_persona pub
- nueva fn descubrir_peers_gossip pub
- nueva fn descubrir_proveedores_de_persona pub
- nueva fn dial pub
- nueva fn from
- nueva fn gossip_push_converges_two_graphs
- nueva fn gossip_to_up_to_date_peer_pushes_nothing
- nueva fn gossip_with pub
- nueva fn graph pub
- nueva fn handle_incoming
- nueva fn listen pub
- nueva fn make_attestation
- nueva fn peer_id pub
- nueva fn pull_from pub
- nueva fn pull_from_converges_two_graphs
- nueva mod rate_limit pub
- nueva fn rate_limit_descarta_sesiones_extra_de_un_mismo_peer
- nueva fn rate_limiter pub
- nueva fn read_frame_optional
- nueva fn run_passive_accept pub
- nueva fn run_sync_loop pub
- nueva fn run_sync_loop_converges_after_several_ticks
- nueva fn run_sync_loop_with pub
- nueva fn run_sync_loop_with_alimenta_el_sink_de_stats
- nueva fn send_frame
- nueva fn sharing pub
- nueva fn snapshot pub
- nueva fn standalone pub
- nueva fn sync_with pub
- nueva fn sync_with_converges_bidirectionally
- nueva mod tests
- nueva fn wait_for_dial
- nueva fn with_rate_limit pub
03_ukupacha/agora/agora-net-brahman/src/rate_limit.rs
- nueva struct Bucket
- nueva struct RateLimitConfig pub
- nueva struct RateLimiter pub
- nueva fn burst_drena_balde_luego_refill_lo_repone
- nueva fn config pub
- nueva fn estricto pub
- nueva fn fake_peer
- nueva fn new pub
- nueva fn peers_distintos_tienen_baldes_independientes
- nueva fn permisivo pub
- nueva fn refill_no_supera_el_burst
- nueva mod tests
- nueva fn tokens_for pub
- nueva fn try_take pub
- nueva fn try_take_at pub
03_ukupacha/agora/agora-petnames/src/lib.rs
- nueva enum ErrorPresentacion pub
- nueva struct Libreta pub
- nueva struct NombreOcupado pub
- nueva def PREDICATE_PETNAME pub
- nueva struct Presentacion pub
- nueva fn aceptar pub
- nueva fn de_atestacion pub
- nueva fn id_de pub
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn nombrar pub
- nueva fn nombre_de pub
- nueva fn nueva pub
- nueva fn olvidar pub
- nueva fn presentar pub
03_ukupacha/agora/agora-petnames/tests/petnames.rs
- nueva fn el_id_es_global_el_petname_es_local
- nueva fn el_petname_es_sugerencia_no_verdad_global
- nueva fn la_libreta_persiste_por_serde
- nueva fn los_petnames_son_localmente_unicos
- nueva fn persona
- nueva fn renombrar_una_identidad_retira_el_nombre_viejo
- nueva fn una_atestacion_de_otro_tipo_no_es_petname
- nueva fn una_presentacion_falsificada_se_rechaza
- nueva fn una_presentacion_firmada_verifica_y_se_puede_aceptar
03_ukupacha/agora/agora-store/src/lib.rs
- nueva struct Envelope
- nueva enum Error pub
- nueva struct GraphSnapshot
- nueva def POSTCARD_MAGIC pub
- nueva type Result pub
- nueva def SCHEMA pub
- nueva fn append_attestation pub
- nueva fn append_sin_snapshot_y_load_funciona
- nueva fn append_y_load_replayan_el_log
- nueva fn compact pub
- nueva fn compact_funde_el_log_al_snapshot
- nueva fn graph_con_tombstones
- nueva fn graph_ejemplo
- nueva fn load pub
- nueva fn load_autodetecta_json_y_postcard
- nueva fn load_re_verifica_evidencia
- nueva fn log_path
- nueva fn log_truncado_se_ignora_silencioso
- nueva fn postcard_es_significativamente_mas_chico_que_json
- nueva fn read_envelope
- nueva fn replay_log pub
- nueva fn revocacion_con_firma_forjada_falla_load
- nueva fn rotacion_con_firma_forjada_falla_load
- nueva fn save pub
- nueva fn save_load_roundtrip_preserva_conteos
- nueva fn save_no_deja_tmp
- nueva fn save_postcard pub
- nueva fn save_postcard_y_load_roundtrip
- nueva fn schema_desconocida_falla
- nueva fn snapshot_of
- nueva fn snapshot_viejo_sin_tombstones_carga_igual
- nueva fn tampered_attestation_falla_load
- nueva mod tests
- nueva fn tmp_path
- nueva fn tombstones_roundtrip_json_y_postcard
108 archivos · +35014 −0
- 03_ukupacha/agora/APLICACIONES.md +150 −0
- 03_ukupacha/agora/ARQUITECTURA.md +189 −0
- 03_ukupacha/agora/LEEME.md +46 −0
- 03_ukupacha/agora/README.md +60 −0
- 03_ukupacha/agora/README.qu.md +40 −0
- 03_ukupacha/agora/SDD-rotacion-revocacion.md +173 −0
- 03_ukupacha/agora/SDD.md +449 −0
- 03_ukupacha/agora/agora-app/Cargo.toml +59 −0
- 03_ukupacha/agora/agora-app/LEEME.md +25 −0
- 03_ukupacha/agora/agora-app/README.md +23 −0
- 03_ukupacha/agora/agora-app/examples/pantallazo_agora.rs +453 −0
- 03_ukupacha/agora/agora-app/src/chrome.rs +258 −0
- 03_ukupacha/agora/agora-app/src/main.rs +1245 −0
- 03_ukupacha/agora/agora-app/src/model.rs +837 −0
- 03_ukupacha/agora/agora-app/src/modulos/agora.rs +422 −0
- 03_ukupacha/agora/agora-app/src/modulos/mod.rs +404 −0
- 03_ukupacha/agora/agora-app/src/modulos/qillqa/autoridad.rs +1114 −0
- 03_ukupacha/agora/agora-app/src/modulos/qillqa/compras.rs +1675 −0
- 03_ukupacha/agora/agora-app/src/modulos/qillqa/mesa.rs +971 −0
- 03_ukupacha/agora/agora-app/src/modulos/qillqa/mod.rs +82 −0
- 03_ukupacha/agora/agora-app/src/modulos/qillqa/registro.rs +490 −0
- 03_ukupacha/agora/agora-app/src/modulos/qillqa/veeduria.rs +553 −0
- 03_ukupacha/agora/agora-app/src/modulos/qillqa/verificar.rs +631 −0
- 03_ukupacha/agora/agora-app/src/perfil.rs +258 −0
- 03_ukupacha/agora/agora-app/src/tiles/actas.rs +137 −0
- 03_ukupacha/agora/agora-app/src/tiles/capacidad.rs +198 −0
- 03_ukupacha/agora/agora-app/src/tiles/compositor.rs +85 −0
- 03_ukupacha/agora/agora-app/src/tiles/mod.rs +156 −0
- 03_ukupacha/agora/agora-app/src/tiles/multifirma.rs +228 −0
- 03_ukupacha/agora/agora-app/src/tiles/politica.rs +272 −0
- 03_ukupacha/agora/agora-app/src/tiles/release.rs +151 −0
- 03_ukupacha/agora/agora-app/src/ui.rs +428 −0
- 03_ukupacha/agora/agora-ayllu/Cargo.toml +18 −0
- 03_ukupacha/agora/agora-ayllu/LEEME.md +34 −0
- 03_ukupacha/agora/agora-ayllu/README.md +12 −0
- 03_ukupacha/agora/agora-ayllu/src/lib.rs +882 −0
- 03_ukupacha/agora/agora-ayllu/src/mallki.rs +622 −0
- 03_ukupacha/agora/agora-channel/Cargo.toml +16 −0
- 03_ukupacha/agora/agora-channel/LEEME.md +30 −0
- 03_ukupacha/agora/agora-channel/README.md +28 −0
- 03_ukupacha/agora/agora-channel/examples/forjar_propuesta_mudanza_demo.rs +62 −0
- 03_ukupacha/agora/agora-channel/src/lib.rs +580 −0
- 03_ukupacha/agora/agora-channel/src/release.rs +398 −0
- 03_ukupacha/agora/agora-channel/tests/e2e_mudanza_demo.rs +41 −0
- 03_ukupacha/agora/agora-cli/Cargo.toml +41 −0
- 03_ukupacha/agora/agora-cli/LEEME.md +30 −0
- 03_ukupacha/agora/agora-cli/README.md +0 −0
- 03_ukupacha/agora/agora-cli/examples/verificar_concesiones.rs +61 −0
- 03_ukupacha/agora/agora-cli/src/atestacion.rs +187 −0
- 03_ukupacha/agora/agora-cli/src/canal.rs +134 −0
- 03_ukupacha/agora/agora-cli/src/cmd.rs +551 −0
- 03_ukupacha/agora/agora-cli/src/dispositivo.rs +420 −0
- 03_ukupacha/agora/agora-cli/src/identidad.rs +312 −0
- 03_ukupacha/agora/agora-cli/src/main.rs +282 −0
- 03_ukupacha/agora/agora-cli/src/sesion.rs +258 −0
- 03_ukupacha/agora/agora-cli/src/wawa.rs +825 −0
- 03_ukupacha/agora/agora-confianza/Cargo.toml +14 −0
- 03_ukupacha/agora/agora-confianza/LEEME.md +56 −0
- 03_ukupacha/agora/agora-confianza/README.md +11 −0
- 03_ukupacha/agora/agora-confianza/src/lib.rs +316 −0
- 03_ukupacha/agora/agora-core/Cargo.toml +22 −0
- 03_ukupacha/agora/agora-core/LEEME.md +15 −0
- 03_ukupacha/agora/agora-core/README.md +13 −0
- 03_ukupacha/agora/agora-core/src/attest.rs +175 −0
- 03_ukupacha/agora/agora-core/src/claim.rs +87 −0
- 03_ukupacha/agora/agora-core/src/identity.rs +189 −0
- 03_ukupacha/agora/agora-core/src/lib.rs +40 −0
- 03_ukupacha/agora/agora-core/src/lifecycle.rs +396 −0
- 03_ukupacha/agora/agora-core/src/multisig.rs +297 −0
- 03_ukupacha/agora/agora-dispositivo/Cargo.toml +23 −0
- 03_ukupacha/agora/agora-dispositivo/LEEME.md +40 −0
- 03_ukupacha/agora/agora-dispositivo/README.md +13 −0
- 03_ukupacha/agora/agora-dispositivo/src/lib.rs +762 −0
- 03_ukupacha/agora/agora-gossip/Cargo.toml +13 −0
- 03_ukupacha/agora/agora-gossip/LEEME.md +13 −0
- 03_ukupacha/agora/agora-gossip/README.md +11 −0
- 03_ukupacha/agora/agora-gossip/examples/two_node_sync.rs +141 −0
- 03_ukupacha/agora/agora-gossip/src/lib.rs +461 −0
- 03_ukupacha/agora/agora-graph/Cargo.toml +16 −0
- 03_ukupacha/agora/agora-graph/LEEME.md +13 −0
- 03_ukupacha/agora/agora-graph/README.md +11 −0
- 03_ukupacha/agora/agora-graph/src/forkproof.rs +51 −0
- 03_ukupacha/agora/agora-graph/src/lib.rs +1274 −0
- 03_ukupacha/agora/agora-graph/tests/forkproof.rs +117 −0
- 03_ukupacha/agora/agora-keystore/Cargo.toml +23 −0
- 03_ukupacha/agora/agora-keystore/LEEME.md +48 −0
- 03_ukupacha/agora/agora-keystore/README.md +46 −0
- 03_ukupacha/agora/agora-keystore/src/lib.rs +490 −0
- 03_ukupacha/agora/agora-net-brahman/Cargo.toml +27 −0
- 03_ukupacha/agora/agora-net-brahman/LEEME.md +64 −0
- 03_ukupacha/agora/agora-net-brahman/README.md +62 −0
- 03_ukupacha/agora/agora-net-brahman/examples/convergencia_minga.rs +83 −0
- 03_ukupacha/agora/agora-net-brahman/src/lib.rs +1232 −0
- 03_ukupacha/agora/agora-net-brahman/src/rate_limit.rs +183 −0
- 03_ukupacha/agora/agora-petnames/Cargo.toml +31 −0
- 03_ukupacha/agora/agora-petnames/LEEME.md +20 −0
- 03_ukupacha/agora/agora-petnames/README.md +11 −0
- 03_ukupacha/agora/agora-petnames/src/lib.rs +168 −0
- 03_ukupacha/agora/agora-petnames/tests/petnames.rs +142 −0
- 03_ukupacha/agora/agora-store/Cargo.toml +20 −0
- 03_ukupacha/agora/agora-store/LEEME.md +13 −0
- 03_ukupacha/agora/agora-store/README.md +11 −0
- 03_ukupacha/agora/agora-store/src/lib.rs +676 −0
- 03_ukupacha/agora/pantallazo.png +— −—
- Cargo.lock +8258 −0
- Cargo.toml +658 −0
- LICENSE +21 −0
- README.md +51 −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` 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` 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>(<, 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>(<, 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(®istro);
+ 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.