Saltar a contenido

Changelog

Todos los cambios notables se documentan aquí.

Formato: Keep a Changelog

Este es el registro completo, entrada a entrada, en la lengua de trabajo del proyecto. La versión en inglés (docs/en/changelog.md) no es una traducción: encabeza con un resumen de qué es la línea base v0.01 —lo que necesita un evaluador externo— y recoge después una selección de cambios. La decisión es deliberada: mantener 300+ entradas de proceso interno duplicadas en dos idiomas fue lo que produjo el desfase que arrastrábamos, y perseguirlo cada bloque costaba más de lo que aportaba.


[Unreleased]

Fixed

  • docs: la documentación en inglés y la de despliegue se habían quedado atrás de B9/B10. en/getting-started.md no tenía la pantalla de entidad suspendida; deployment.md (es/en) listaba Kubernetes como requisito mientras su §Opción 2 lo declara no soportado; architecture.md (es/en) no decía que el asistente llama directo a la API de Anthropic con la barrera de residencia, ni la identidad firmada del gateway (ADR-036) ni el estado de la entidad en el borde (ADR-035), y describía la firma como XAdES-B/T.
  • fix(document,notification,signature,knowledge,auth,storage,archive)!: Bloque B10 — una devolución postal ya avisa y no se pierde con Redis caído, el callback de una entidad suspendida se encola en vez de rechazarse, editar un documento invalida por evento los circuitos de firma sobre el texto viejo, el fragmento semántico se gatea con el nivel vigente del radicado, la firma personal no gasta el TOTP con una firma que no verifica y el ArchiveTimeStamp de XAdES-LTA se renueva. Qué cambia: 1 (document + notification) — los eventos document.envio.* salen por outbox (en la tx del cambio), también la devolución manual, que no emitía nada; notification avisa por correo la devolución a la dependencia productora y a quien radicó, con el gate de clearance en el WHERE por destinatario: sin clearance el correo no lleva número, asunto, destinatario, dirección ni motivo; un radicado reservado sale como correo sensible. 2 (signature + document) — signature consume documento.contenido_cambiado e invalida las solicitudes cuyo payload_sha256 no es la huella vigente (preguntada a document en ese momento: un evento tardío o repetido no tumba el circuito abierto sobre el texto vigente), con asiento firma.circuito_invalidado en la misma tx, solo si invalidó alguna; el endpoint interno síncrono exige {cuerpo_sha256_vigente} (422 sin él); el replay de la DLQ de signature despacha por event_type (antes aplicaba siempre la reclasificación). 3 (knowledge) — search y get_pivot_chunk gatean con GREATEST(nivel del fragmento, nivel vigente del radicado) en el WHERE; barrido horario monótono reconciliacion_nivel (solo sube; tombstonea lo de proveedor externo ≥ RESERVADA) con asiento knowledge.nivel_reconciliado (canal system); el docstring falso de ingestion.py se corrige. 4 (document) — un callback postal con firma válida de una entidad suspendida responde 202 deferred y queda en cola (migración tenant 048), que el barrido aplica al reactivar (fase 5) con la fecha de recepción; cola llena (POSTAL_DIFERIDOS_MAX=10000) → 503 con Retry-After; cuerpo > 64 KB → 413 cuerpo_demasiado_grande. 6 (auth + signature) — la firma personal ya no gasta el TOTP con una firma que no verifica: ensayo XMLDSig efímero previo, huella del certificado cotejada antes del TOTP (409 certificado_cambiado) y verificación RSA dentro de la tx del TOTP (503 firma_digest_no_verifica, el código sigue sirviendo). Residuo: la autoverificación final tras remote_sign responde 503 con totp_posiblemente_consumido y asienta firma.autoverificacion_fallida_tras_totp (también en la firma por cadena). 7 (signature + storage + archive) — renovación del ArchiveTimeStamp de XAdES-LTA (TimeStampValidationData + ATS nuevo sobre todo lo previo, EN 319 132-1 §5.5.2): cadena firma_sello_archivo (signature, migración 026), storage migración 022 (firma_lta_renovada, renueva_a) con herencia WORM y evento PREMIS RENOVACION; /verify valida cada ATS (regla archive_timestamp_cadena_rota) y acepta el original o cualquier XML vigente de la cadena; GET /firmas/{id} expone minio_key_vigente y archive_timestamp_ordinal; evento firma.lta_renovada (best-effort, sin consumidor hoy); job diario (LTA_RENOVACION_MARGEN_DIAS=90, LTA_RENOVACION_INTERVALO_S=86400, 0 lo apaga). El AIP embebe el XML vigente (renovado) del índice; el export no lleva XML firmado (sin cambio). Contra EU DSS 6.5: una LTA renovada dos veces da 3 ATS íntegros; el control negativo, DataIntact=false. 8 (auth, prueba) — test_verify_audit_chains_job acotado con --slug (1,35 s). Pruebas: nuevas test_b10_* en document (diferido, outbox, cliente de invalidación), notification (aviso y gate SQL), signature (contenido cambiado, invalidación SQL, orden de la firma personal, renovación LTA y su API), knowledge (nivel vivo SQL, reconciliación), storage (renovar artefacto SQL), auth (sign-digest autoverifica SQL) y archive (AIP con el artefacto vigente). Impacto: aplicar las migraciones tenant document 048, signature 026 y storage 022 (la 048 antes del barrido nuevo). Desplegar auth y signature juntos (cert_fingerprint obligatorio en sign-digest), storage antes o junto con signature (renovar-artefacto-internal) y signature con document (cuerpo obligatorio en /invalidar). Orden aceptado: un callback vivo posterior a la reactivación puede dejar recorded_no_change a uno diferido más viejo. Deuda: reempaquetar los AIP existentes al renovar el sello; el residuo del TOTP gastado en la autoverificación final; un consumidor de firma.lta_renovada; la ventana de 120 s de la identidad firmada no es un nonce. Correcciones de auditoría de B10 (2026-10-17): P (gateway, document, notification, knowledge) — una ruta que no estabiliza al canonizar responde 404 route_not_found y no 500 (residuos declarados en ADR-036: sin query ni cuerpo, equivalencia %2F); el cuerpo del callback postal admite ocurrido_at (ISO 8601 con zona, dentro del HMAC), que queda en ocurrido_operador_at: ts es la recepción del aviso del operador, no la fecha del hecho; 413 cuerpo_demasiado_grande a 64 KiB también con la entidad activa (tras el HMAC); el tope de la cola de diferidos se comprueba bajo advisory lock por tenant; el drenaje coteja el sha256 del cuerpo (descarta huella_no_coincide) y tras POSTAL_DIFERIDO_MAX_INTENTOS=10 descarta con asiento document.envio_callback_diferido_descartado sin bloquear la cola; el aviso de devolución llega también al tenedor actual (buzón de la ubicación + flujo_usuario) y, si hay responde_a, al buzón y al poseedor de la entrada, con dedupe por dirección y gate en el WHERE por destinatario; el pivote de /antecedentes gatea en el WHERE (max_clearance). L (signature, storage; ADR-034 §5) — no se renueva sobre una TSA ya caducada (eslabón caducado, asiento firma.lta_cadena_caducada); /verify juzga el último ATS a la fecha actual (lta_vigente; si ya no prueba, baja a XAdES-LT con la advertencia archive_timestamp_caducado y sin vigencia diferida); el TimeStampValidationData lleva la CRL de la CA de la TSA anterior y sin ella no se renueva (firma.lta_renovacion_sin_revocacion_tsa; la TSA autofirmada de dev va sin RevocationValues; solo CRL, no OCSP); los asientos de rechazo, TSA por vencer y sin revocación salen una vez por motivo y eslabón (ultimo_aviso); el paso a vigente deja firma.lta_sello_vigente; el job recorre entidades active y suspended; solo un 404 borrador_not_found cuenta como borrador desaparecido; el vencimiento del eslabón 0 sale de la huella de la TSA; un token sin certificados deja firma.lta_sin_eslabon sin tumbar la firma (migración signature 026 editada: caducado, ultimo_aviso, vista). storage renueva la retención en cascada a los firma_lta_renovada (renueva_a) con PREMIS RENOVACION retencion_en_cascada, y renovar-artefacto-internal exige que el anterior sea de firma y el nuevo XML (422 artefacto_no_es_de_firma) y hereda el mayor retain_until de la cadena. L-H3 (storage, archive; ADR-035 §8) — una entidad suspendida sigue conservando: get_db_conn de storage admite, con X-Internal-Token y solo para la lista explícita RUTAS_DE_CONSERVACION (app/core/conservacion.py), las operaciones de conservación, con asiento entidad.conservacion_admitida; inactive y todo lo demás siguen vetados; las fases de conservación de archive (retención WORM, fixity) recorren también las suspendidas (list_tenants_conservacion); el AIP nuevo espera a la reactivación (necesita los anexos, que document veta a la suspendida), mientras la retención del AIP existente sigue; POST /indice/verify devuelve firma.lta_vigente. R2 — el asiento document.envio_estado_operador (y _no_aplicada) registra ocurrido_operador_at y recibido_at; un ocurrido_at posterior a la recepción + 5 min o anterior a la creación del envío → 422 ocurrido_at_inverosimil (también con la entidad suspendida, al diferir); signature no renueva si la TSA anterior figura revocada en la CRL de su CA (firma.lta_renovacion_tsa_revocada) ni si la CRL no se verifica contra la CA (firma.lta_renovacion_sin_revocacion_tsa; variable nueva TSA_CA_PEM_PATHS, PEM separados por comas), y una cadena caducada deja firma.lta_cadena_caducada una sola vez aunque tenga varios eslabones; GET /expedientes/{id}/indice/verify verifica el XML vigente (renovado) y, si signature no lo resuelve, cae al original con la advertencia artefacto_vigente_no_resuelto; la excepción de conservación de storage compara la ruta exacta (fullmatch sobre scope["path"]) y el token en bytes (no ASCII → 403), veta si el asiento falla y asienta una vez por entidad + método + ruta + día y proceso. Residuo: radicados/auth_users sin calificar con public en el search_path (SEG #12).
  • fix(shared,auth,gateway,document,archive,workflow,storage,mcp)!: tanda B9 (cierre de la auditoría archivística y de seguridad, Bloque N) — una entidad suspendida conserva las lecturas públicas y cierra sus escrituras con un 503 claro, el callback postal verifica la firma antes del estado, la identidad firmada caduca, archive/workflow/storage miran el estado en su propia puerta y el login no entrega token a una entidad que no se atiende (ADR-035). Por qué: la auditoría archivística (AUD-ARCH #1, Alta) encontró que el bloqueo total de ADR-035 incumplía la norma: el plazo de oposición del inventario de eliminación (Acuerdo AGN 004/2019; Ley 594/2000 arts. 14 y 27) corría con el inventario invisible y el acta sellada después certificaba una publicidad que no fue efectiva; apagar /verify quitaba al ciudadano la prueba de integridad (Ley 527/1999 art. 12 num. 1) y el OAI/CMIS el acceso a la información pública (Ley 1712/2014 arts. 9-11). La auditoría de seguridad (AUD-SEG #2) añadió que la identidad firmada no llevaba instante —un juego de cabeceras capturado valía para siempre y, reproducido contra archive/workflow/storage, evadía la suspensión—; y (AUD-SEG #7) que el mcp-server plegaba 403 tenant_inactivo a 503 auth_service_error y que el login seguía entregando token a una entidad suspendida. Al probarlo en vivo apareció además que el primer diseño del gate (en require_permission) dejaba abiertas las lecturas que no piden permiso: GET /workflows/inbox respondía 200 con la entidad suspendida. Qué cambia: N1 — orpycamcp_common.tenant.atencion_publica (COMPLETA | SOLO_LECTURA | NINGUNA): una entidad suspended con schema aprovisionado sirve GET /public/{t}/verify/{code}, la lista y el detalle de la eliminación pública de archive, el OAI de document y archive y el CMIS de lectura; sus escrituras públicas (POST /public/{t}/pqrs, POST /public/{t}/ventanilla y las tres consultas previas ventanilla/tipos|politica|estado) responden 503 canal_no_disponible + Retry-After antes de fijar su schema. Inexistente, dada de baja (inactive) y sin aprovisionar siguen con el 404 not_found neutro (la existencia de una entidad operativa o suspendida no es secreta). El gateway reenvía ahora Retry-After. N2 — el callback postal verifica el HMAC con el secreto del schema antes de decidir por el estado: firma válida + suspendida = 503 + Retry-After (el operador reintenta y no pierde el evento); firma inválida, operador sin credencial o cuerpo ilegible = 404 neutro; con la entidad operativa se conserva el 401 firma_invalida. N3 — X-Identity-Ts entra en CAMPOS_FIRMADOS (lo estampa identidad.firmar) y identidad.verificar rechaza una identidad de más de IDENTITY_MAX_AGE_SECONDS (120 s), de más de IDENTITY_MAX_SKEW_SECONDS (30 s) en el futuro o sin instante entero; y get_db_conn de archive, workflow y storage llama a veredicto_entidad antes de ceder la conexión (solo si la petición trae un X-Tenant-Slug válido: las rutas públicas y el slug mal formado conservan su camino). N4 — POST /auth/token y /auth/refresh no entregan token si la entidad del claim del token no es operativa (403 tenant_inactivo, 503 tenant_no_provisionado|tenant_estado_no_disponible, los mismos códigos del gateway), y resolve_identity del mcp-server propaga esos códigos desde /auth/me en lugar de 503 auth_service_error. Pruebas: document-service 1172 passed (RUN_INTEGRATION=1); archive-service 1692 passed; workflow-service 522 passed; storage-service 625 passed (15 skipped); auth-service 444 passed (RUN_INTEGRATION=1); mcp-server 276 passed; api-gateway 187 passed; shared/tests 540 passed (identidad con instante, atencion_publica, guardián por forma de get_db_conn de los tres servicios). Lint (ruff + mypy) limpio en los servicios tocados. Mutaciones, todas caen: suspendida tratada como inexistente (6 tests), escritura pública sin cierre (5), atencion_publica invertida en CMIS, OAI de document, OAI de archive y eliminación pública (1 cada una), callback sin verificar la firma (1) y aplicando el efecto (2), identidad sin comprobar la edad (2), sin comprobar el futuro (1) y con el instante fuera de lo firmado (9), get_db_conn sin gate y con gate para todo (3 servicios), login sin comprobar la entidad (7), refresh sin comprobarla (1), la del cuerpo y no la del token (8), mapeo de errores del login (1), mcp-server sin propagar el código (5) y gateway sin Retry-After (1). En vivo con demo suspendida: login 403; verify 200; eliminación pública 200; PQRS y ventanilla/estado 503 con Retry-After (a través del gateway); callback postal con firma inválida o ilegible 404; el mcp-server directo 403 tenant_inactivo; archive, workflow y storage con una identidad fresca 403 y con una capturada hace 10 minutos, con el instante rejuvenecido o sin instante 401 (workflow y archive); demo restaurada a active/active. Impacto: toda la pila verifica la identidad firmada con el instante: hay que reconstruir y desplegar JUNTOS el gateway y los nueve servicios que montan IdentidadFirmadaMiddleware (auth, document, archive, workflow, storage, knowledge, notification, signature, tenant); uno rezagado responde 401 identidad_no_autentica a todo lo que le llegue del gateway. El OAI/CMIS siguen apagados en desarrollo por public_interop_enabled (el mismo 404 neutro). Hallazgo aparte: docker-compose.yml no define IDENTITY_SIGNING_SECRET para storage-service, que por eso no verifica la identidad (ni con N3). El frontend debe mostrar «Entidad suspendida» ante el 403 tenant_inactivo del login y del refresh, y tratar 503 canal_no_disponible de la ventanilla como «canal cerrado». Re-auditoría de la tanda B9 (2026-10-16, hallazgos A-H): A (Alta, archive) el aval de un lote de eliminación cuenta el plazo de oposición en días operativos: el WHERE de avalar_lote_eliminacion reconstruye de los asientos tenant.updated de public.audit_log los intervalos en que la entidad no fue active (la ventanilla, único canal de oposición, daba 503) y exige que los días operativos entre publicado_at y ayer alcancen el plazo original; si no, 409 plazo_oposicion_interrumpido con dias_suspendidos y plazo_efectivo_hasta, y el acta declara plazoOposicionEfectivoHasta/diasPlazoInterrumpido solo si hubo interrupción (sin endpoint nuevo: el plazo efectivo se deriva). D (document) GET /public/{t}/ventanilla/politica pasa a lectura (get_public_tenant_conn) y se sirve con la entidad suspendida; se lee directo de sgd_param_admin porque tenant-service veta a la suspendida (comprobado en vivo: por él daba 503 politica_no_verificable). E (signature) el fixture autouse _nivel_vigente_neutro ya no alcanza a tests/integration. F el test de la 025 filtra pg_constraint por conrelid = 'firma'::regclass. G (auth) el login/refresh rechazado por entidad no operativa revoca en Keycloak el refresh token recién emitido (defensa en profundidad; la barrera real es el gateway). H (signature) nivel_vigente_for_update devuelve (nivel, copia_bloqueada) y el nivel_anterior del asiento firma.solicitud_reclasificada sale de la copia bloqueada. Deuda declarada (B, Media): el callback postal con la entidad suspendida responde 503 con Retry-After fijo; el evento diferido queda pendiente (ADR-035 §7). Pruebas (re-auditoría): archive-service 1700 passed (RUN_INTEGRATION=1; 7 integración nuevas con mutación demostrada: sin el conjunto del WHERE caen 2); document-service 1173 passed; signature-service 754 passed (7 skipped); auth-service 381 passed (66 skipped; +4 de revocación, mutación: sin logout caen 3).
  • fix(shared,auth,gateway,document,archive,knowledge,notification,signature,tenant,mcp)!: tanda B9 (parte estado de la entidad y canal MCP) — suspender una entidad ya le retira el acceso en toda la API, un schema inexistente es 503 y no 500, y la marca de canal MCP se ata al tenant del token (ADR-035). Por qué: el triage de B7 decía que ningún servicio hacía cumplir un tenant suspended; era parcialmente falso (document-service sí lo hacía, sin ningún test) pero cierto para los otros diez: los get_tenant_conn de auth, knowledge, notification, signature y tenant solo validaban la forma del slug, archive/workflow/storage fijan el search_path en más de veinte puntos, un schema inexistente acababa en UndefinedTableError (500) y cada ruta pública (/public/*, CMIS, OAI, eliminación de archive) usaba su propio predicado de existencia, o ninguno (public_eliminacion ignoraba status). Aparte, el gateway ataba la marca X-Origen-Canal a la cabecera X-Tenant-Slug del cliente y no al tenant del token (la marca solo afecta a la etiqueta canal de un asiento, pero esa etiqueta es probatoria). Qué cambia: B9-1 — orpycamcp_common.tenant.estado_entidad (OPERATIVA | INACTIVA | NO_PROVISIONADA, una consulta con to_regnamespace) y veredicto_entidad (fail-closed: el registro caído es 503). /auth/validate devuelve tenant_estado (operativa | inactiva | no_provisionada | desconocido) y no aprovisiona auth_users en una entidad no operativa; el api-gateway lo hace cumplir al principio de _continuar_con_identidad (403 tenant_inactivo, 503 tenant_no_provisionado, 503 tenant_estado_no_disponible si el campo falta o es desconocido; desconocido no se cachea; la suspensión surte efecto como mucho tras el TTL de 15 s de la caché de validación); los seis get_tenant_conn (auth, document, knowledge, notification, signature, tenant) llaman a veredicto_entidad antes del SET search_path (en document sustituye a _tenant_activo). Rutas públicas: document usa get_public_tenant_conn/public_tenant_conn (slug mal formado, entidad inexistente, suspendida o sin aprovisionar = el MISMO 404 not_found (sustituido por el Bloque N, arriba: la suspendida pasa a solo lectura pública) con el mismo cuerpo; ya no hay 400 invalid_tenant ni lista vacía/habilitada=false para el inexistente), CMIS y OAI (document y archive) y la eliminación pública de archive sustituyen to_regclass/SELECT 1 FROM tenants por estado_entidad. Consumidores de eventos y tareas en segundo plano no se bloquean (cerrar la convergencia de reclassified abriría una fuga; ver ADR-035). B9-4 — derive_canal recibe payload["tenant_slug"] y no request.headers["x-tenant-slug"]; el mcp-server no cambia (ya firmaba con el tenant del token); corregido el docstring de origen.py (se firma con MCP_ORIGIN_SECRET). Pruebas: api-gateway 183 passed; auth-service 432 passed (RUN_INTEGRATION=1; 5 de estado_entidad sobre Postgres real y 2 de validate); document-service 1166 passed (RUN_INTEGRATION=1; incluye el 403 tenant_inactivo de get_tenant_conn, que no tenía ningún test, y los 404 idénticos de las rutas públicas); archive-service 1681 passed; knowledge-service 336, notification-service 291, tenant-service 325, signature-service 717 (7 skipped), mcp-server 266 passed; shared/tests 506 passed (guardián por forma de los seis get_tenant_conn y estado_entidad/veredicto_entidad; el único rojo del arnés es un symlink de CLAUDE.md fuera del montaje). Lint (ruff + mypy) limpio en los ocho servicios tocados. Mutaciones, todas caen: canal por cabecera del cliente (2 tests), campo ausente = operativa, desconocido cacheado, gate solo sin caché (2), sin to_regnamespace, status IN ('active','suspended'), aprovisionar antes del estado, quitar veredicto_entidad de cada uno de los seis get_tenant_conn (más el guardián por forma), dependencia pública sin estado, callback postal sin estado, y to_regclass en lugar de estado_entidad en OAI/CMIS de document y OAI/eliminación de archive. Impacto: reconstruir y desplegar api-gateway, auth-service y document-service (y el resto de servicios con get_tenant_conn: knowledge, notification, signature, tenant; archive por su OAI y su eliminación pública). El frontend debe tratar un 403 tenant_inactivo en /auth/me y en el login como «Entidad suspendida», y las pantallas de la ventanilla pública un 404 también para la entidad inexistente. Riesgo conocido: un token de superadmin con el tenant_slug de la entidad suspendida no puede reactivarla por el gateway.
  • fix(frontend): tanda B9 (carril frontend) — el front distingue una entidad suspendida o no lista, la ventanilla pública dice «no tiene ventanilla disponible» y se retira el proxy muerto de sign-xml. Qué cambia: (app)/+layout.server.js clasifica el rechazo de /auth/me (utils/estadoEntidad.js: 403 tenant_inactivo = «La entidad está suspendida»; 503 tenant_no_provisionado = «La entidad aún no está lista»; 503 tenant_estado_no_disponible = «No se pudo comprobar el estado de la entidad») y devuelve entidadNoOperativa; el layout pinta EntidadNoOperativa.svelte (role="alert", Reintentar solo si el estado puede cambiar, Cerrar sesión por POST) en vez del menú vacío, sin redirigir al login ni borrar la sesión. Ventanilla pública: el 404 not_found de /radicacion, /eliminacion y los proxies de radicación y consulta se muestra como «Esta entidad no tiene ventanilla disponible» sin distinguir inexistente, suspendida o sin aprovisionar. Se borra firmas/api/sign-xml (sin consumidor) y su suite; solicitud_not_found no tenía consumidor en el front. Impacto: reconstruir el frontend. Además: con la entidad suspendida la ventanilla POST muestra «El canal de atención de esta entidad no está disponible en este momento» (503 canal_no_disponible), y CertificadoDrawer muestra «Firma emitida con un esquema retirado: el sistema ya no puede verificarla. No indica que el documento haya sido alterado» para esquema_hmac_retirado (firma_criptograficamente_valida puede ser null). Corrección tras la revisión UX: con la entidad suspendida los GET de /public/{t}/ventanilla/{tipos|politica} y el POST dan 503 canal_no_disponible; /radicacion muestra «Esta entidad tiene suspendida temporalmente la recepción de comunicaciones. Las consultas y verificaciones siguen disponibles» con enlace a /consulta; admin/+layout.server.js ya no manda a /403 cuando la entidad no está operativa, (app)/+error.svelte pinta los error(403) de páginas hijas dentro del layout, y EntidadNoOperativa usa role="status", avisa «Sigue sin estar disponible (hh:mm)» al reintentar y devuelve el foco al título.
  • fix(signature)!: tanda B9 (parte signature) — el nivel que rige la firma se relee bajo bloqueo, /sign-xml deja de firmar radicados y el esquema HMAC key_version=0 se retira Por qué: (a) firmar y /challenge decidían el gate XAdES, el 2FA obligatorio de nivel ≥ 2, el veto del correo y los asientos con la copia firma_solicitud.nivel_seguridad leída SIN bloqueo; esa copia se sincroniza por evento (con retraso, DLQ, o nunca si la cadena se declaró borrador sobre el UUID de un radicado) y se firmaba con el nivel viejo: sin 2FA, sin gate XAdES y con un actor de clearance menor (SEG-B4). (b) /sign-xml aceptaba radicado|expediente|borrador y, con solo PERM_FIRMA crud ≥ 2, daba una fila firma VERIFICABLE («X firmó el radicado R») sin cadena, sin turno, sin 2FA y sin gate XAdES; nadie la usa (solo archive, con sellos). (c) key_version=0 seguía vivo como «lo anterior sigue verificando». Qué cambia (signature): B9-2 CadenaRepository.nivel_vigente_for_update (FOR UPDATE de la solicitud + max(copia, nivel autoritativo vivo), sin mirar objeto_tipo) y CadenaFirmaService._sol_con_nivel_vigente, llamado dentro de la transacción de firmar y de /challenge: reevalúa el clearance (404 neutro sin consumir nada), el gate XAdES (en /challenge pasa DENTRO de la transacción, detrás de la relectura), el 2FA, el veto del correo y los asientos, y corrige la copia en la misma transacción. firmar ahora toma la fila de la solicitud ANTES que la del turno (el mismo orden que /challenge: antes se cruzaban). resolver_nivel_por_objeto usa un savepoint: dentro de una transacción, un UndefinedTableError (tenant sin expedientes) la dejaba abortada. B9-3 SignXmlRequest.objeto_tipo admite solo XADES_ELIGIBLE_OBJETO_TIPOS (422 para el resto, también los tipos de cadena) y /sign-xml exige X-Internal-Token SIEMPRE; se borra la rama de clearance y CadenaRepository.objeto_visible. B9-5 KEY_VERSION_LEGACY pasa a KEY_VERSION_SIN_HMAC (relleno de las filas no nativas), se borran _mensaje_v0 y las ramas v0 de signer y otp, /verify responde valida=false, motivo=esquema_hmac_retirado (no hmac_no_coincide) sobre una fila nativa con key_version≠1, y la migración 024 quita el DEFAULT 0 de firma.key_version y firma_challenge.key_version y reescribe sus COMMENT. Unificación: el 404 de no-read-up de la cadena es solicitud_not_found en /sign y /challenge (antes /challenge decía solicitud_no_encontrada; es el sufijo que usa el resto de los servicios, 5 de 6 usos en signature); ningún consumidor del frontend ni de mcp-server lo leía. Correcciones de auditoría (signature, S1-S11): S1 las lecturas de la cadena (GET /requests/{id}, por-objeto, /pending con fila y X-Total-Count bajo la misma condición) se gatean con GREATEST(copia, nivel vivo) y no con la copia atrasada (un read-up de metadatos); con una desclasificación pendiente gana el nivel mayor hasta converger (S9, documentado). S2 la relectura que corrige la copia deja el asiento firma.solicitud_reclasificada (origen=relectura_al_firmar, canal=system) en la misma transacción, solo si tocó filas. S3 esa corrección solo SUBE (nivel_seguridad < $1, solo_subir=True): ya no baja cadenas hermanas del mismo objeto; el evento reclassified conserva su semántica. S4 firma.turno_firmado lleva nivel_seguridad (vigente) y nivel_copia, y firma.creada de /sign-personal lleva nivel_seguridad. S5/S10 rechazar y dar_visto_bueno releen el nivel bajo bloqueo y toman solicitud antes que turno (el mismo orden de los cuatro caminos). S6 /verify con esquema HMAC retirado devuelve firma_criptograficamente_valida=null (bool | None), no false. S7 FirmaRepository.insert exige key_version=1 explícito a la nativa y la migración tenant 025 borra la fila sintética v0 y añade CHECK (provider <> 'nativa' OR key_version = 1). S8 docstring de providers/native.py. S11 las 7 copias del cuerpo 404 neutro de cadena_service.py pasan a un helper único _solicitud_no_encontrada(). Pruebas S: signature-service 752 passed, 7 skipped (RUN_INTEGRATION=1) y 697 passed, 62 skipped sin integración; nuevas tests/test_correcciones_b9_signature.py (15) y tests/integration/test_correcciones_b9_integration.py (8), cada una con su mutación demostrada (13 mutaciones, todas caen). Fixture autouse nuevo lectura_vigente_real (neutraliza S1 en las suites de mocks). Impacto: aplicar la migración 025 por tenant. Pruebas: signature-service 717 passed, 7 skipped (RUN_INTEGRATION=1) y 670 passed, 54 skipped sin integración; nuevas: test_nivel_vigente_bajo_bloqueo.py (5 unit con la relectura real), tests/integration/test_nivel_vigente_bajo_bloqueo.py (5, incluida la concurrencia con dos conexiones), tests/integration/test_key_version_sin_legado_integration.py (3), paridad /challenge↔/sign con nivel vivo mayor, y los casos «v0 ya no verifica» de signer, otp, cadena y /verify. Mutaciones demostradas: sin reemplazar el nivel (cae), sin FOR UPDATE (cae la concurrencia), solo copia (caen 2), min en vez de max (caen 3), sin savepoint (caen 2), validador con tipos de cadena (caen 7), rama v0 en signer (caen 2), en otp (caen 2), /verify sin corte (cae), sin DROP DEFAULT (caen 3), código distinto en /challenge (cae). Impacto: aplicar la migración 024 por tenant (python migrations/runner.py); las firmas nativas y desafíos anteriores a la 020 (key_version=0) dejan de verificar (aceptado: entorno de pruebas, sin datos viejos); quien llame a /sign-xml con un tipo de cadena recibe 422. Carril frontend pendiente: borrar el proxy frontend/src/routes/(app)/firmas/api/sign-xml/ y su describe en firmas-fase3-server.test.js (no tiene consumidor).

  • fix(signature,auth,shared,infra)!: tanda B7 — /challenge exige lo mismo que /sign, /auth/me de una entidad sin schema ya no es 500, la restauración falla cerrado y la pila de producción tiene por fin una entrada (ADR-033). Por qué: la verificación en vivo de la pila de producción halló que (a) POST /challenge emitía y ENVIABA el OTP por correo a quien firma con certificado personal activo (su firma exige TOTP y el correo nunca sirve) y sobre un documento ya modificado (/sign responde 409 documento_modificado); (b) GET /auth/me con el X-Tenant-Slug de una entidad sin schema daba 500 UndefinedTableError; (c) restore.sh tomaba por inocuo un pg_restore caído sin líneas de error y backup.sh daba por bueno un espejo vacío; y (d) el compose de producción no publicaba ningún puerto mientras la plantilla de nginx apuntaba a 127.0.0.1, sin entrada para Keycloak, con imágenes de terceros por un tag móvil y con dos variables obligatorias ausentes del ejemplo. Qué cambia:

  • F1/F2 — signature: _precondiciones_de_turno es el código único de pertenencia, estado, turno, rol de firma y radicado anulado para firmar y /challenge; /challenge pasa a leer ANTES de la transacción (como firmar; dentro solo la relectura con FOR UPDATE) y añade 409 turno_es_de_visto_bueno, 409 radicado_anulado, el gate de huella vigente (409 documento_modificado…, 503 verificacion_documento_no_disponible, después de pertenencia y turno) y la credencial personal (409 totp_requerido sin correo, 403 identidad_incoherente con asiento via=signing_key_status, 422 firma_xades_requerida / 503 canal_firma_personal_no_disponible bajo el gate). Sin turno activo, /challenge responde ahora 409 solicitud_no_pendiente (como /sign) y no 403 no_es_su_turno. Se retira del contrato 422 payload_no_fijado, que ya no existía.
  • F3 — auth: get_optional_tenant_conn comprueba to_regclass(format('%I.auth_users', schema)) antes del SET search_path; una entidad sin schema (pending o inexistente) es 200 con permissions=[], permission_levels={} y user_id=sub. El 400 por slug malformado se conserva.
  • P1/P2 — infra: servicio edge (nginx:1.30-alpine@sha256:…) como ÚNICO con ports: (80:80, 443:443), plantilla infra/nginx/orpyca.conf.template (sustituye a infra/nginx.conf.example) con server TLS para frontend, API y Keycloak (solo /realms/ y /resources/; /admin/ y /realms/master/ denegados; búferes de cabecera ampliados), proxy_pass a variables con resolver 127.0.0.11 y alias de red para los tres dominios. La red declara ip_range 172.20.128.0/17 y gateway 172.20.0.1; TRUSTED_PROXIES por defecto pasa de 172.20.0.0/16 a 172.20.128.0/17 para que el gateway de la red no sea de confianza y la IP sellada en audit_log no pueda ser la que forjó el cliente.
  • P3/P4/P5: postgres, redis, keycloak, edge y amazon/aws-cli (antes :latest en backup.sh/restore.sh) por tag y digest, también en docker-compose.yml; infra/.env.example trae ORPYCA_REGISTRY y ORPYCA_TAG (activas) y PDFA_PROFILE, SIGNER_LT_REQUIRE, SIGNER_OCSP_REQUIRE, WEBHOOK_SECRET_KEKS, WEBHOOK_SECRET_KEK_ACTIVE_VERSION (comentadas); whisper-service queda fuera del manifiesto de producción en v1 (intencional, declarado).
  • R2/R5/R5b — respaldo: restore.sh solo admite un pg_restore fallido si hay al menos un error y todos son la PK heredada de las particiones de audit_log; tras recrear cada bucket protegido comprueba con get-object-lock-configuration que el bloqueo quedó Enabled y aborta antes de tocar datos si no. backup.sh ya no usa || true al listar buckets y exige orpycamcp-<slug>-documents de cada tenant active en el espejo.
  • Corregido antes de la tanda, por el orquestador: R1 restore.sh llamaba restaurar_versiones_previas antes de cargar lib-retencion-s3.sh y de definir s3api («command not found»: toda restauración moría tras restaurar Postgres); R3 s3 sync ... || true pasa a fail-closed; R4 limpiar_descifrado con [ ] && rm devolvía 1 en la trampa EXIT bajo set -e (una restauración completa salía con rc=1); R5 de red: backup.sh/restore.sh tomaban la PRIMERA red del compose (en producción default, sin el almacén ⇒ espejo vacío) y ahora la del servicio minio; P0 docker-compose.prod.yml: el ancla x-migracion no declaraba red y los nueve migrate-* caían en default, sin postgres (ninguna migración terminaba y ningún servicio arrancaba); M1 mailhog:latest (abandonado) sustituido por axllent/mailpit:v1.31.4@sha256:b68349e3… con puertos en 127.0.0.1 y SMTP_HOST=mailpit (red_interna.py y la configuración de notification actualizados).
  • XMLDSig conforme (ADR-034) — signature: la verificación en vivo contra EU DSS dio TOTAL_FAILED / HASH_FAILURE en TODO lo que firmamos: signxml deja sin ds:Transforms las referencias a SignedProperties y KeyInfo pero las digiere en exc-c14n, y XMLDSig §4.4.3.2 manda canonicalizarlas con C14N 1.0 inclusiva. Era un defecto nuestro (no «un bug de signxml»), escondido por un atajo en verify_xades (default_reference_c14n_method=EXCLUSIVE) que hacía que solo OrpycaMCP verificara lo firmado. Ahora _declarar_transforms añade exc-c14n a cada referencia sin Transforms (los digests no cambian), _firmar_signed_info firma el SignedInfo ya parcheado —un solo camino para el sello y la firma personal; el digest que recibe remote_sign sale del SignedInfo final— y _autoverificar lo comprueba con XAdESVerifier() por defecto antes del sello de tiempo (503 firma_no_conforme_xmldsig si no pasa; nada se persiste). verify_xades verifica solo conforme: se elimina el atajo; las firmas anteriores del corpus de prueba dejan de verificar (sin migración, sin rama «legado»). _archive_ts_covered_bytes deja de ser una concatenación propia y sigue EN 319 132-1 §5.5.2 (octetos de cada referencia + SignedInfo + SignatureValue + KeyInfo + propiedades no firmadas previas): DSS reportaba su MESSAGE_IMPRINT como no íntegro; de ocho composiciones probadas, solo la de la norma lo valida. Docstrings falsos corregidos (local.py llamaba «bug de signxml» a lo nuestro). Retirada la promesa «verificable por terceros» de ADR-016/ADR-030, que no era cierta hasta ahora. Pruebas: signature-service 668 passed, 3 skipped (RUN_INTEGRATION=1; 15 nuevas de precondiciones y paridad /challenge↔/sign, más las secuencias de los tests de challenge existentes); auth-service 409 passed (RUN_INTEGRATION=1; 3 unitarias y 3 de integración con Postgres real y slug itest-… para /auth/me sin schema); shared/tests 479 passed (11 nuevas de R2/R5/R5b que ejecutan las funciones de shell, y guardianes nuevos o endurecidos del manifiesto de producción). Una mutación por arreglo, todas caen. Lint limpio (ruff + mypy) en signature-service y auth-service. Se sanea además test_create_challenge_cooldown_returns_429, que medía contra una hora fijada al importar el módulo y fallaba al correr tras el tier de integración. Pruebas B7-XMLDSig: signature-service 685 passed, 7 skipped (RUN_INTEGRATION=1; 17 nuevas en test_xmldsig_conformidad.py —firma nueva verificada con signxml por defecto y con CANONICAL_XML_1_0, verificador independiente lxml+cryptography, digest enviado a remote_sign == SignedInfo final, entrada del ArchiveTimeStamp reconstruida aparte, control negativo con un artefacto real anterior al arreglo— y 4 de test_dss_smoke.py, que solo corren con DSS_URL). Mutaciones que cada una tumba su prueba: parcheo no-op, digest antes de parchear, reintroducir el atajo en verify_xades, LTA sin octetos de referencias. Validado en vivo con EU DSS 6.5: índice de expediente sellado por la pila real y firma personal de JEFE: SignatureIntact y todos los DataIntact, INDETERMINATE/NO_CERTIFICATE_CHAIN_FOUND (CA de desarrollo); el control negativo (firma anterior): TOTAL_FAILED/HASH_FAILURE.
  • Ronda de corrección tras las auditorías (apto con reservas) — infra: SEG-M1 Keycloak por lista de admisión (/realms/orpycamcp/ y /resources/; /realms/master;x/… y /realms/master daban 200 con la denegación anterior; ahora 404, y una ruta con ; también); SEG-B1 /health/ready solo desde 127.0.0.1 (se quita 172.20.0.1; sondeo con docker exec); SEG-M2 pg_restore solo es inocuo con código 1 y errors ignored on restore: N == PK heredadas (> 0); ARC-M3 el pre-vuelo del Object-Lock corre ANTES de parar servicios y de pg_restore (en el código estaba DESPUÉS: «no se ha tocado ningún dato» era falso); ARC-M4 RESTORE_OPERADOR obligatorio (fail-closed antes de confirmar) y el acta storage.restauracion nombra al operador (actor=operador:<nombre>) y declara la ventana de traza perdida por tenant (extremo de audit_log anotado antes de pg_restore frente al restaurado; sin la captura no hay acta); ARC-B3 backup.sh exige también los tenants suspended (status <> 'inactive'); ARC-B4 api-gateway no arranca con APP_ENV=production si TRUSTED_PROXIES cubre la puerta de enlace de la red (GATEWAY_RED_DOCKER_GATEWAY o ruta por defecto del contenedor; en desarrollo no se comprueba). signature: ARC-M2/SEG-B3 no se firma con un certificado caducado o aún no vigente (sello: 503 sello_institucional_vencido; personal: 422 certificado_personal_vencido antes de remote_sign, sin consumir el TOTP) y _autoverificar ya no acota verification_time; ARC-M1 /verify de un XAdES-LTA con ArchiveTimeStamp válido y certificado caducado tras firmar juzga la vigencia a la fecha del SignatureTimeStamp (con advertencia; sin LTA válido sigue certificado_fuera_de_vigencia); SEG-B2/ARC-B1 XmldsigNoConformeError en las rutas personales es 503 firma_no_conforme_xmldsig (declarado: ahí el TOTP ya está consumido); ARC-B2 los sellos de tiempo se verifican con la canonicalización que declaran. ADR-016, ADR-033 y ADR-034 corregidos. Pruebas de la ronda: signature-service 698 passed, 7 skipped (RUN_INTEGRATION=1; 13 nuevas en test_vigencia_del_certificado_y_sellos.py, una reescrita), auth-service 414 passed (6 nuevas de integración de registrar_restauracion con Postgres real), api-gateway 170 passed (11 nuevas), shared/tests 489 passed (guardianes de lista de admisión de Keycloak, sonda, IPv6, rc/N de pg_restore, pre-vuelo, operador, extremo previo y suspended). Lint (ruff + mypy) limpio en los tres. Una mutación por arreglo, todas caen. EU DSS: tests/test_dss_smoke.py 4 passed contra DSS en 127.0.0.1:18765, tras desplegar signature-service. Deuda declarada de la ronda: SEG-M3 (un tenant suspended no se hace cumplir en ningún servicio: funcionalidad nueva en el gateway), SEG-B4 (releer nivel_seguridad bajo bloqueo al firmar), renovación del ArchiveTimeStamp, autoverificación de la firma personal antes de consumir el TOTP, y nginx -t real (también confirma los listen [::], ya escritos; la imagen no se descargó por el disco). Impacto: un infra/.env heredado con TRUSTED_PROXIES=172.20.0.0/16 reabre el defecto (cámbielo a 172.20.128.0/17 o bórrelo); hay que emitir un certificado por dominio antes del primer up y las renovaciones pasan a webroot; la consola de Keycloak deja de ser pública (túnel SSH). infra/nginx.conf.example desaparece. Desplegar signature-service y auth-service. No verificado aquí: nginx -t real ni edge levantado (imagen no descargada por el estado del disco); correr exec edge nginx -t en el primer despliegue. Deuda declarada: Keycloak 24.0.5 fuera de soporte; get_tenant_conn sigue dando 500 con un tenant sin schema en los demás endpoints de auth, y un tenant suspended con schema intacto resuelve permisos en /auth/me.

  • fix(document): B6 / residuales — el origen de una Salida se limita en un solo punto, reenviar el mismo tipo ya inactivo no da 422 y la importación no admite procedencia ajena — DocumentService.create exige, para doc_type=S con actor humano, que origin_dept_code sea una dependencia con URD activa del actor (422 origen_fuera_de_sus_dependencias); cubre POST /documents, respuesta rápida, borrador (comprobación duplicada retirada) y cada ítem del lote (falla antes del consecutivo). El PATCH solo valida el tipo si doc_class cambia. INT-05 mueve from_borrador/canal/consentimiento del ZIP a metadata.procedencia_origen. Seeds: scripts/seed_corpus.py radica Salidas con origen arbitrario (nombres de DEPENDENCIAS_EXTRA); su actor debe tener URD ahí o esas Salidas darán 422.

  • fix(document,auth,tenant,shared): B6 / ronda de corrección del backend — un radicado ya no nace con un tipo documental inventado, las claves de procedencia dejaron de ser del cliente y las claves inertes de la 002 salen de la allowlist. Por qué: la auditoría de B6 halló que solo la ventanilla validaba doc_class contra tipos_documentales (por API/MCP nacía un radicado sin serie TRD, es decir sin retención), que canal (alimenta el gate de publicación de la Ley 1581) y consentimiento (constancia con valor probatorio) podían aportarse en metadata, que from_borrador no iba en el asiento y el PATCH de metadata lo podía borrar, que una Salida desde borrador aceptaba cualquier origin_dept_code, y que la allowlist de parámetros admitía 10 claves de la 002 que ningún servicio lee. Qué cambia: document — app/core/tipos.py es el ÚNICO sitio que consulta el catálogo (la tabla vive en el mismo schema del tenant: sin cliente HTTP, como urd_usuaroldep): create (antes de next_sequence), el PATCH que retipifica, cada ítem del lote y la ventanilla (que sigue exigiendo además admite_ventanilla_publica) → 422 tipo_documental_desconocido; CLAVES_RESERVADAS = {from_borrador, canal, consentimiento} se rechaza en DocumentCreate, DocumentUpdate, la respuesta rápida y el borrador, y el sistema las pone por procedencia (la ventanilla ya no las manda en metadata; ya no pasan por la plantilla del tipo); el PATCH de metadata las preserva; document.radicado_created lleva from_borrador; Salida desde borrador: origin_dept_code limitado a las dependencias con URD activa (422 origen_fuera_de_sus_dependencias); el borrador valida tipo y metadatos ANTES de renderizar y subir (DocumentService.validar_tipo_y_metadata), sin binario huérfano. auth/shared — el gate any-of pasa por get_caller (make_require_any_permission(identity=)): 401 sin identidad y asiento usuario_no_aprovisionado, como require_permission; el guardián test_escritura_exige_nivel.py reconoce require_any_permission( (una escritura any-of también exige min_crud>=2), con control positivo. tenant — las 10 claves inertes de la 002 salen de PARAMS_ADMITIDOS (PUT 422) y pasan a PARAMS_INERTES; el guardián exige sembradas = admitidas ∪ inertes y que ninguna inerte la lea un servicio. Pruebas: cada ítem con mutación (12 mutaciones, todas mueren). Impacto: reconstruir y desplegar document, auth y tenant; un cliente que mande canal/consentimiento/from_borrador en metadata recibe 422. Pendiente (roadmap): los tipos sin plantilla activa guardan metadata sin validar, y las etiquetas del frontend usan la plantilla ACTIVA y no la versión guardada (metadata_template_id).

  • fix(auth,document,tenant,frontend): B6 / auditoría del guardián de /admin — tres pantallas de administración daban 403 al administrador que no radica, PUT /config/params/{key} aceptaba cualquier clave y el guardián del frontend no leía gates any-of. Por qué: el auditor de aislamiento halló 0 fugas pero 3 desalineaciones UI→backend: GET /auth/users/pickable, GET /email/cuentas, GET /plantillas/campos y GET /plantillas exigían solo PERM_RADI mientras /admin/flujos, /admin/grupos, /admin/correo y /admin/plantillas abren con USUA_PERM_ADMIN; y el upsert de PUT /config/params/{key} guardaba cualquier clave en una tabla que todo el tenant puede leer. Qué cambia: orpycamcp_common.rbac gana check_any_permission (una sola consulta = ANY, fail-closed) y make_require_any_permission (asiento de denegación igual que require_permission, con perm_name="A|B"); auth y document la instancian como require_any_permission. Las cuatro lecturas pasan a any-of {PERM_RADI, USUA_PERM_ADMIN} sin ganar exposición (los WHERE existentes deciden las filas; ?todas=true sigue exigiendo USUA_PERM_ADMIN en el cuerpo). PUT /config/params/{key} admite solo las 21 claves sembradas por las migraciones de tenant (422 config_param_desconocido para el resto); un guardián (shared/tests/test_params_admitidos_son_los_sembrados.py) las cruza con la siembra en las dos direcciones. Guardián del frontend: gatesFastapi.js lee require_any_permission (cada Gate lleva anyOf), contratoAdmin.js lo evalúa por brazos, y se retiran las excepciones obsoletas (pickable, email/cuentas, plantillas/campos); flujos GET /workflows/pending se reetiqueta como gate de alcance en el cuerpo y el motivo de indice/firmar se corrige (la UI ya oculta el botón sin ambos permisos). Pruebas: any-of con mutación (admin sin RADI 200, RADI solo 200, ni uno ni otro 403, sin identidad 403, ?todas=true sigue 403 con solo RADI), check_any_permission contra Postgres real, allowlist 422/200 y guardianes de shared/ (require_any_permission cuenta como gate; no se redefine fuera de la librería). Impacto: desplegar auth, document y tenant (solo esos tres usan lo nuevo de orpycamcp_common; el resto lo recoge al reconstruirse). La pantalla /admin/parametros aún ofrece «nueva clave» libre: su PUT ahora responde 422 (queda para el frontend).

  • fix(document): B6 / ítem 23 — radicar un borrador cuyo tipo documental tiene plantilla de metadatos con obligatorios daba 500. POST /borradores/{id}/radicar no capturaba MetadataValidationError: ahora es 422 metadata_validation_failed con field, antes de pedir el consecutivo (no se gasta número y el asiento sigue atómico con el radicado). El cuerpo admite metadata (BorradorRadicar); la procedencia from_borrador ya no pasa por el JSON Schema del tipo: DocumentService.create(..., procedencia=) la une después de validar, y el cliente no puede enviarla. Pruebas unitarias y de integración (Postgres real) con mutación.

  • feat(frontend): B6 / ítems 23, 24 y 25 — radicar un borrador captura sus datos mínimos, los metadatos guardados se leen con su plantilla y el error del acuse va en su campo. El modal de radicar pide tipo documental (catálogo; la plantilla solo precarga si su tipo está en él), origen (Salida; sin dependencia activa no se ofrece radicar) y los metadatos del tipo con SchemaField; el 422 deja el modal abierto con el error en su campo (antes se cerraba antes del fetch y se perdía lo capturado). Proxy con lista cerrada de 4 campos validados, regla espejo de REQUERIDOS_POR_TIPO con guardián de paridad contra datos_minimos.py. Nuevo SchemaValue (solo lectura) en /expedientes/[id] (antes [object Object] y clave cruda) y en el detalle de /bandeja (cargaba metadata y no la mostraba). /envios: el error del acuse se asocia al campo del archivo (aria-describedby, aria-invalid, foco) y 4 casos nuevos del modal. EditarMetadatosModal y TransaccionModal no se migran: el backend no admite editar esos campos. Pruebas con mutación.

  • feat(tenant,document,workflow)!: tanda B5 — la dependencia se correlaciona por CÓDIGO y no por nombre: renombrar con trámite vivo ya no rompe la bandeja, codigo es inmutable y un nombre ambiguo nunca se resuelve «a la de menor código» (ADR-032). Por qué: el flujo (flow_steps.to_dept, workflow_rules.assign_to_dept) y la bandeja (to_dept IN (SELECT d.nombre …)) se unían a dependencias por NOMBRE: renombrar una dependencia con trámite vivo dejaba sus pasos fuera de toda bandeja y de la ubicación (tenant-service lo prohibía con un 409, divergencia del legado, donde todo se une por DEPE_CODI); dos homónimas se confundían (borrador_service radicaba hacia ORDER BY codigo LIMIT 1 en silencio); codigo era editable y lo citan 8+ tablas de 5 servicios sin FK; nombre admitía 500 caracteres y las instantáneas son VARCHAR(100). Qué cambia: tenant — migración 013: trigger BEFORE UPDATE OF codigo (inmutable), CHECK nombre ≤ 100, dependencia_nombre_historico (solo inserción) y la función única resolver_codigo_dependencia(nombre, historico) (el código sale solo con un candidato). PATCH: 422 dependencia_codigo_inmutable (el mismo valor es no-op); renombrar ya no se bloquea (el nombre anterior va al historial en la misma transacción que el asiento); desactivar conserva 409 dependencia_con_tramite_en_curso, ahora por código; eliminar da 409 dependencia_con_historia si cualquier tabla la cita; nuevo POST /dependencias/{id}/mapeos-nombre (admin nivel 3, asentado). document — migración 046: borrador.dest_dept_code y relleno de radicados.dest/origin_dept_code y borrador (solo con un candidato; evidencia de solo inserción anclada en audit_log por el runner); PATCH/creación/borradores aceptan el código y el nombre como alias; la tenencia del borrador del padre es por código; la anulación copia el código al evento; el reporte por dependencia agrupa por código (by_dependencia_codigo/by_ubicacion_codigo); el evento radicado.created añade origin_dept_code. workflow — migración 019: *_code en pasos, eventos y reglas, relleno con función única (ambiguo/huérfano en paso vivo, regla o condición eq/ne/in ⇒ la transacción aborta y la migración no queda en el libro; historia ⇒ NULL con evidencia), condiciones dest_dept/origin_dept reescritas a *_code, ubicación (flujo_dept_code) desde el código del paso, CHECK de paso vivo con código y de regla con destino, índices por código; flow_events (append-only) solo recibe las dos columnas nuevas con el trigger apartado dentro de la transacción y verificado al volver; el runner ancla las dos evidencias (_ANCLAS admite varias por migración). API: to_dept_code/from_dept_code/assign_to_dept_code/?dept_code= canónicos, nombre como alias deprecado (Deprecation+Link), 422 dependencia_desconocida|ambigua|inconsistente, origen derivado por el servidor (409 origen_desactualizado), 422 condicion_por_nombre_obsoleta; eventos con ambos campos; el reparto de un evento viejo resuelve el nombre con historial. Pruebas: 7 unitarias nuevas de tenant y 16 de integración (013, trigger, historial, atomicidad del renombre con el asiento); 13 unitarias y 12 de integración de document (046, ancla, homónimas, borrador, PATCH); 16 unitarias nuevas de workflow y 20 de integración (019: ambiguo falla cerrado, mapeo manual, reglas y condiciones, flow_events, idempotencia, ancla, gemelo de derivar_ubicacion, renombrar con trámite vivo, origen derivado); mutaciones aplicadas y revertidas (bandeja por nombre, count(*)=1→min(), RAISE→NOTICE, ORDER BY codigo LIMIT 1, guarda CODE_FIELDS). Impacto: aplicar tenant 013 → document 046 → workflow 019 (el runner/init_tenant ya lo ordena; la 046 y la 019 abortan si falta la función y hay nombres por resolver) y desplegar tenant, document y workflow. Un tenant con pasos vivos de nombre ambiguo no migra hasta registrar el mapeo manual. No se hizo: retirar el alias y los índices por nombre (incremento posterior, cuando Deprecation esté a cero un ciclo); notification, knowledge, mcp-server, gateway y frontend van en su propia tanda. Ronda de corrección (auditorías de seguridad y archivística): tenant 014 — resolver_codigo_dependencia hace que un mapeo manual SEA la respuesta (antes se sumaba a las homónimas y no desambiguaba), SET search_path FROM CURRENT en la función y los triggers, dependencia_codigo_retirado (solo inserción; 409 dependencia_codigo_retirado y trigger al reutilizar un código eliminado), siembra de dependencia_nombre_historico desde los dependencia.actualizada de audit_log; referencias() barre information_schema (transferencias, índice electrónico, plantillas, active_depe_id, historial); mapeos-nombre rechaza dependencia inactiva y el nombre vigente de otra activa (irreversible); PATCH admite motivo. document 047 y workflow 020: evidencia con mapeado enlazado a historico_id y antes/despues JSON completos. Reporte por dependencia: el grupo sin código lleva la etiqueta fija «(sin código)». Última ronda de corrección: document 047 ya no cambia resultado (reescribía evidencia anclada por la 046: el SHA-256 del ancla dejaba de reproducirse); solo enlaza historico_id y «mapeado» se deriva de historico_id IS NOT NULL (el CHECK vuelve al original). 047 y workflow 020 dejan ancla propia (document.dependencia_codigo.backfill_047, workflow.dependencia_codigo.backfill_020); la 020 solo completa columnas nuevas, ninguna de las que ancla la 019. tenant: mapeos-nombre solo admite nombres huérfanos o ambiguos (409 nombre_resuelto_a_otra_dependencia), el PATCH rechaza renombrar a un nombre ya mapeado a otro código (409 nombre_mapeado_a_otra_dependencia); referencias() usa pg_catalog, cuenta workflow_rules.conditions y has_children va dentro de la transacción del DELETE.

  • fix(archive)!: tanda B4 / re-auditoría archive — el paquete de entrega ya es la entrega completa y no sale si un componente no reproduce su huella de captura; el trigger de tenencia solo exime a la acción referencial; una ubicación con transferencia recibida no se borra. Por qué: la auditoría archivística halló que el paquete era un complemento del acta (sin índice firmado ni AIP), que el acta salía de acta_fuid_xml sin recotejo, que los componentes se leían como .text y su huella se calculaba sobre lo entregado (checksums autoconsistentes certifican la copia, no el original), que la exención «UPDATE que solo anula» de la 065 cubría un UPDATE manual y que el ON DELETE SET NULL de la 012 borraba la traza física de lo ya recibido; y el 409 listaba las rutas clasificado/.... Qué cambia: el paquete añade indice/indice-electronico.xades.xml (índice firmado de la versión entregada) y referencia el AIP en la nota (<aip bucket object_key sha256 retain_until retention_mode incluido_en_paquete="no">: no viaja por volumen); el inventario FUID es el acta. Sin AIP empaquetado con retención en el almacén, índice sin firmar o sin Object-Lock, o cualquier componente sin él: 409 componentes_sin_object_lock (las rutas clasificado/... se agregan en componentes_clasificados, sin enumerarlas). Antes de servir se recalcula sha256(acta_fuid_xml) contra acta_fuid_sha256, el sello del acta debe firmar esa misma acta (C14N-SHA256), y cada componente se lee como BYTES y se coteja con la huella que storage registró al subirlo; discrepancia: 409 componente_integridad_discrepante + asiento transferencia.paquete_entrega_incidente (un clasificado no se nombra en la respuesta); sin huellas de captura: 503. Migración tenant 067: la exención de la 065 aplica solo bajo pg_trigger_depth() > 1 (acción referencial); un UPDATE manual que anula el par da 23514. Migración tenant 068: trigger BEFORE DELETE en ubicacion que rechaza (23514 ubicacion_con_transferencia_recibida) borrar una ubicación referenciada por una transferencia recibida. Pruebas: 15 unitarias nuevas (409 por índice/AIP, agregado de clasificados, acta alterada, sello distinto, huella de captura ausente/distinta, bytes latin-1) y 2 de integración sobre Postgres real; 7 mutaciones aplicadas y revertidas, todas caen.
  • feat(auth,notification,infra)!: tanda B4 / P3 — las versiones previas bajo COMPLIANCE sobreviven a la restauración, cada objeto remapeado deja su evento PREMIS, un objetivo vacío ya no verifica en verde y cada entrega de webhook deja constancia. Por qué: (2) el espejo solo copiaba la versión actual y la restauración fallaba (o perdía, con RESTORE_ACEPTAR_PERDIDA_VERSIONES=1) un índice o AIP re-sellado sobre la misma clave; (3) registrar_restauracion cambiaba version_id sin evento de preservación (PREMIS); (4) verify_audit_chains salía 0 con «sin tenants» —una base vacía por un restore fallido o un filtro mal tecleado verificaba cero cadenas en verde—; (8) dispatch era mudo: ni a quién salió qué evento ni si llegó (ADR-018 lo declaraba pendiente). Qué cambia: infra — respaldar_versiones_previas copia el contenido de cada versión previa con COMPLIANCE vigente o retención legal a minio-versiones/<bucket>/<sha256(clave+vid)> y la anota en minio-versiones.tsv (fichero aparte: el formato de minio-retencion.tsv no cambia); viaja dentro del tar cifrado; el sello lleva versiones_previas_capturadas=SI|NO y backup.sh sale 5 si no quedó completa. restaurar_versiones_previas (antes del sync) las sube por LastModified ascendente, verifica el sha256, repone la retención sobre el VersionId devuelto y sube con put-object explícito la versión actual de esas claves; el mapa viejo→nuevo las incluye. RESTORE_ACEPTAR_PERDIDA_VERSIONES queda solo para respaldos anteriores; restore.sh rechaza un respaldo con versiones_previas_capturadas=NO sin esa aceptación. auth — registrar_restauracion inserta, en la misma transacción del remapeo, un evento PREMIS REPLICA (detalle.motivo='restauracion', version_vieja/version_nueva, artefacto, agente system:ops-restauracion) por cada fila actualizada; sin preservacion_evento todo se revierte. verify_audit_chains sale 2 con objetivo vacío (por filtro, o sin filtro y sin tenants) salvo --permitir-objetivo-vacio, que backup.sh/restore.sh no pasan. notification — migración 012 webhook_entregas (append-only: trigger + REVOKE, sin FK a la suscripción, solo sha256 del cuerpo, destino reducido a scheme://host:port); _deliver_one escribe la fila envio ANTES del POST (si no puede, no envía) y resultado después; GET /api/v1/webhooks/{id}/entregas. Ronda de auditoría: el export de expediente compara cada anexo con su sha256 registrado y aborta (409 fixity_fallida, asiento file.fixity_fallo, sin ZIP) antes del primer byte, y espera al hilo de escritura si lo cancelan; url_sha256 de webhook_entregas y del asiento pasa a ser la huella del origen (migración notification 013: mensaje del trigger como traza técnica). Pruebas: Pruebas nuevas con el s3api falso (previa capturada, orden ascendente, retención sobre el vid nuevo, sha distinto, exit 5), de integración (PREMIS y objetivo vacío) y de entregas (unitarias y Postgres real); mutaciones aplicadas y revertidas (saltar IsLatest=0, invertir el orden, retención sobre el vid viejo, no verificar el sha, quitar el INSERT PREMIS, return 0 con objetivo vacío, envio tras el POST, tragar el fallo de envio, quitar el trigger). Impacto: aplicar notification 012 (el runner) y desplegar auth y notification. No se probó: backup.sh/restore.sh extremo a extremo contra el almacén vivo (el restore es destructivo); el s3api real (get-object/put-object con S3_MONTAR) solo se ejerce con el cliente falso. Retención/purga de webhook_entregas: pendiente (ADR-018).
  • fix(storage,archive)!: tanda B4 / P2 — el export de expediente se arma en disco y se sirve por trozos, ya no entero en memoria (tope 512 MB). Por qué: GET /expedientes/{id}/export.zip armaba el ZIP completo en RAM en storage y volvía a leerlo entero en archive; un expediente grande agotaba la memoria y el tope de 512 MB lo hacía inexportable. Qué cambia: storage valida todo el inventario con una sola consulta, escribe el ZIP en un temporal (anexo a anexo, trozos de 1 MiB, sha256 incremental para checksums.txt) y solo entonces responde con Content-Length, borrando el temporal en finally y en tarea de fondo; todo fallo ocurre antes del primer byte (nunca un 200 truncado). Límites configurables: EXPORT_MAX_BYTES (4 GiB), EXPORT_MIN_FREE_BYTES (256 MiB), EXPORT_TMP_DIR; 413 export_demasiado_grande / 507 espacio_insuficiente. Archive consume en streaming (sin bufferizar), mapea 413/507/otros a 413/507/502 antes de asentar y servir, y detecta un cuerpo más corto que el Content-Length. Sin cambios en el no-read-up por radicado ni en el asiento archive.expediente_exportado. Impacto: reconstruir y desplegar storage y archive; sin migración.
  • feat(archive)!: tanda B4 / P1 — paquete de entrega de la transferencia secundaria, tenencia física garantizada en la base y dedup_key por intento (ADR-030 §5). Por qué: el sobre de revelación del fundamento «viaja en la transferencia secundaria» según ADR-030, pero no existía ningún paquete de transferencia (solo el acta FUID congelada y sus sellos), y el AIP se sella al cerrar el expediente, antes de que existan las constancias. La tenencia física (par origen/destino coherente con las unidades del expediente) solo se garantizaba en el servicio. Y el evento PREMIS de una fixity fallida usaba un sello de tiempo en su clave: dos reintentos del mismo fallo daban dos eventos. Qué cambia: POST /api/v1/transferencias/{id}/paquete-entrega → application/zip (admin nivel 3 + no-read-up con 404 neutro; 409 paquete_solo_secundaria_recibida; 409 componentes_sin_object_lock con la lista de rutas, sin descargar nada; 503 componente_no_disponible): acta/ (acta congelada tal cual, sello XAdES, firmas personales), clasificado/{constancias,revocaciones,sobres}/ (cadena completa, también las revocadas; Ley 1712 art. 18 lit. a), nota-entrega.xml (urn:orpycamcp:nota-entrega-transferencia:v1, determinista: referencia acta_fuid_sha256 sin reescribir el acta; sobre="ausente" en las v2/legadas) y checksums.txt. Los asientos transferencia.acta_fundamento_leido (via="paquete") y transferencia.paquete_entrega_generado (nota_sha256 y huellas) se escriben en una transacción antes del primer byte. Nunca por OAI/EAD/CMIS (guardián por fuente). Migración tenant 065: trigger BEFORE INSERT OR UPDATE OF expediente_id, ubicacion_origen_id, ubicacion_destino_id ON transferencias con las mismas reglas y códigos que el 422 del servicio (ERRCODE 23514; el servicio lo traduce a 422 con el mismo código); solo filas nuevas, el ON DELETE SET NULL de una ubicación no revalida. Migración tenant 066: transferencias.fixity_fallos_intentos; la clave PREMIS pasa a fixity-recepcion-fallo:{id}:{n} y el asiento *_failed lleva intento. Pruebas: 13 unitarias del paquete + 3 de intento + 4 de integración del trigger sobre Postgres real; mutaciones aplicadas y revertidas (asentar tras servir, quitar el 409, servir una primaria, quitar el clearance del WHERE, cada rama del trigger, volver al sello de tiempo). Impacto: aplicar archive 065 y 066 (el runner) y desplegar archive-service. En un entorno sin preservación (sin Object-Lock) el paquete responde 409 componentes_sin_object_lock, por diseño (fail-closed). No se hizo: sellar nota-entrega.xml con XAdES (tocaría signature-service; su huella queda anclada en audit_log); bloquear la desvinculación de unidades con transferencia abierta.
  • fix(document,storage,tenant,gateway,shared)!: tanda B2 — la IP de la constancia de autorización la firma el gateway, la política de tratamiento es de cada entidad y el acuse de un envío es de quien lo sube (ADR-031). Por qué: la constancia Ley 1581 de la ventanilla pública guardaba como IP lo que escribiera el cliente (X-Real-Ip/X-Forwarded-For), aceptaba cualquier cadena como versión de la política (una variable del frontend, única para todas las entidades) y radicaba aunque no hubiera aviso de privacidad; y el acuse de entrega de un envío servía el fichero que subió otra persona o un anexo ya radicado. Qué cambia: orpycamcp_common.identidad — firmar_ip_publica/ip_publica_verificada (par X-Real-Ip+X-Real-Ip-Ts+X-Public-Ip-Signature, HMAC con IDENTITY_SIGNING_SECRET ligado a ruta e instante, 120 s; sin secreto → nulo). api-gateway — inyecta ese par en las rutas públicas (IP por saltos de confianza; lo que el cliente mande se sobrescribe). document-service — la constancia pasa a v2 (version_constancia, politica_version, politica_huella, politica_url, instante, ip, ip_verificada; las v1 ip_declarada se conservan); GET /public/{tenant}/ventanilla/politica (lectura estricta: 503 politica_no_verificable); al radicar el servidor relee la política (409 politica_desactualizada / 409 politica_no_publicada) antes de subir anexos, reclamar la Idempotency-Key y consumir consecutivo; GET /ventanilla/estado añade politica_publicada; acuse de envío: 403 acuse_ajeno, 409 acuse_ya_referenciado, índice único parcial (migración tenant 045, aborta si ya hay duplicados). storage-service — POST /internal/files/procedencia. tenant-service — migración tenant 012 (tres parámetros politica_tratamiento_*, vacíos). scripts/verificar-ventanilla.sh exige la política; scripts/seed_demo.py publica una política de demostración. Docs: ADR-031, normativa.md («Supresión y retención»), plantilla de política, api.md, deployment.md (TRUSTED_PROXIES debe incluir nginx y el SSR). Contrato (frontend): radicar necesita politica_version = la de GET /ventanilla/politica; nuevos 409/503 de la ventanilla y 403/409 del acuse (ver api.md). Impacto: aplicar tenant 012 y document 045 (el runner), reconstruir y desplegar gateway, document, storage y tenant; una entidad sin política publicada deja de recibir radicación en línea hasta publicarla.
  • feat(frontend): ventanilla en línea con la política de la entidad y mensajes del acuse de envío (tanda B2). /radicacion toma la política de GET /public/{tenant}/ventanilla/politica (se retiran las variables POLITICA_TRATAMIENTO_*); la casilla muestra versión y enlace, bloquea el envío si la entidad no ha publicado política, recarga y pide autorizar de nuevo ante 409 politica_desactualizada, y una nota fija enlaza a #supresion. /envios traduce 403 acuse_ajeno y 409 acuse_ya_referenciado; prueba de componente del modal de acuse. Impacto: reconstruir el frontend (requiere el backend B2 desplegado).
  • fix(workflow,signature,notification,knowledge,shared)!: el no-read-up de la cola de eventos fallidos vive en el WHERE, no en un filtro en Python sobre 1.000 candidatas (AREA-10-08). Por qué: el listado traía como máximo 1.000 filas, resolvía su nivel en Python y paginaba en memoria; con más de 1.000 filas reservadas recientes, una pública antigua quedaba fuera de la ventana y el total mentía, y el mismo cálculo estaba copiado en cuatro servicios. Qué cambia: orpycamcp_common.deadletter es la fuente única (LEFT JOIN LATERAL contra el radicado VIVO, max(nivel_vivo, nivel_embebido), duda = nivel 3); lista, recuento, detalle, replay y descarte usan el mismo predicado y lo no visible responde el mismo 404 que un event_id inexistente. Total y página salen de una transacción REPEATABLE READ de solo lectura (un offset pasado ya no da total 0). Se borran resolve_deadletter_level[s], assert_can_read_deadletter, list_scoped_for_clearance, DEADLETTER_CANDIDATE_CAP y la cabecera X-Truncated. Sin radicados en el schema responde 503, no una lista vacía. Solo es más restrictivo que antes (un embebido 2.0/true, un sobre doblemente codificado o una lista cuentan como duda). Migraciones: índice ix_edl_keyset idéntico (workflow 018, signature 023, notification 011, knowledge 013). knowledge: además purga las resolved/discarded de más de 90 días (RETENTION_DEADLETTER_DAYS, compuerta diaria; antes no tenía retención) y la migración 014 añade knowledge_chunk.nivel_evento_at: un reclassified viejo reentregado desde la DLQ ya no baja un nivel posterior (sin occurred_at solo puede subir). Impacto: aplicar las migraciones tenant y desplegar los cuatro servicios; el gateway sigue reenviando X-Truncated pero ya nadie la emite.
  • fix(auth,signature)!: /internal/totp/verify exige X-Internal-Token y las claves de firma se mantienen solas. Por qué: el endpoint que verifica-y-consume el TOTP era el único interno de auth-service sin el gate servicio-a-servicio (cualquiera con un bearer podía quemar o bloquear intentos desde la red interna); y la purga de credenciales revocadas y el re-wrap de la KEK solo existían como herramienta de ops, sin programar. Qué cambia: /internal/totp/verify aplica require_internal_token (401 sin token, 403 erróneo, 503 sin configurar; mismo SIGNING_KEY_INTERNAL_TOKEN, sin secretos nuevos) y signature-service lo envía. Nuevo job app/jobs/signing_key_maintenance.py en el lifespan de auth-service (SIGNING_KEY_MAINTENANCE_ENABLED, SIGNING_KEY_MAINTENANCE_INTERVAL_SECONDS, 21600 por defecto): por tenant activo y bajo pg_try_advisory_lock, purga revocadas pasado SIGNING_KEY_PURGE_AFTER_DAYS y re-envuelve solo filas con key_version menor que la activa (el re-wrap ya no va «hacia atrás» si la activa retrocede); deja signing_key.maintenance_run con conteos cuando hubo actividad; sin auditoría no muta, sin llavero válido omite el re-wrap. Sin migraciones. Pruebas: 401/403/503 y cabecera en cable; integración sobre PG real con mutaciones (purgar antes del plazo, sin asiento, re-wrap hacia atrás ⇒ rojo). Impacto: reconstruir y desplegar auth-service y signature-service a la vez.
  • fix(frontend): la UI espeja el nivel 3 de las escrituras de dependencias, catálogos y PINAR. Los botones y formularios de escritura (nueva/editar/activar dependencia; nuevo/editar/borrar ítem de catálogo; nuevo plan, aprobar/ejecutar/cerrar, edición de contenido e instrumentos de PINAR) aparecen solo con USUA_PERM_ADMIN nivel ≥ 3 (permission_levels de /auth/me); con nivel menor la pantalla queda de solo lectura y el 403 se explica en español. Helper lib/utils/nivelAdmin.js; prueba admin/nivel-admin-escritura.test.js (nivel 2 sin acciones, nivel 3 con ellas).
  • fix(tenant,mcp)!: las escrituras de dependencias, catálogos y PINAR exigen USUA_PERM_ADMIN a nivel CRUD 3 (decisión del usuario). Por qué: bastaba el nivel 1 (lectura), de modo que un administrador de solo lectura creaba, editaba y borraba el organigrama, los vocabularios y el plan archivístico. Qué cambia: POST/PATCH/DELETE /dependencias, POST/PATCH/DELETE /catalogos/... y las 19 escrituras de /pinar (planes, aprobar/ejecutar/cerrar, aspectos, priorización, objetivos, proyectos, seguimiento, instrumentos) responden 403 con nivel 1 o 2; las lecturas siguen a nivel 1. La herramienta MCP registrar_avance_pinar declara min_crud=3. Sin migraciones. Pruebas: test_escrituras_nivel_3.py recorre por admisión las rutas no-GET de los tres routers (nivel 2 ⇒ 403, nivel 3 ⇒ pasa el gate). Impacto: reconstruir y desplegar tenant-service; los grupos de seed_demo.py con escritura ya tienen CRUD 3.
  • fix(archive)!: FIRMA-2, ronda 3 — el sobre huérfano se purga y el texto del fundamento se lee a petición. El sobre se sube ahora DESPUÉS de validar la firma personal; si la fila no llega a referenciarlo (trigger, carrera, fallo del asiento) se purga por /internal/purgar-huerfanos de storage, y un barrido de reconciliación recoge los ficheros acta-transferencia-sobre-… sin referencia. GET /acta y /acta/verificacion ya no devuelven fundamento ni sal (ni asientan lectura al visitar la pantalla); nuevo GET .../{excepcion_id}/fundamento?acto= (admin 3, asiento via=lectura).
  • feat(archive,storage)!: FIRMA-2, ronda 3 — sobre de revelación del fundamento. Por qué: el texto del fundamento y su sal vivían solo en la base (append-only, sin Object-Lock, fuera de la transferencia): perder la base perdía el fundamento entero (reserva Media de la auditoría archivística). Qué cambió: por cada constancia y revocación v3 se genera un XML {id, acto, texto, sal_hex, compromiso, algoritmo} (C14N exclusiva), sellado con el sello institucional y protegido WORM con la retención ancla de la cadena (storage 021: seis tipo acta_firma_*_sobre_<rol>, control y multi-artefacto; archive 064: artefacto sobre en las tablas _worm, reconciliación/renovación/reintento como los demás eslabones). Sin sello: 503 y nada escrito. Lectura solo mediada: GET .../firma-inobtenible/{excepcion_id}/sobre (admin nivel 3, asiento transferencia.acta_fundamento_leido con via=sobre). POST .../revocar ya no devuelve el fundamento ni la sal de la constancia original. ADR-030 documenta cómo viaja en la transferencia secundaria al AGN (componente clasificado Ley 1712 art. 18 lit. a).
  • fix(archive)!: FIRMA-2, ronda de auditoría — el fundamento de la constancia se huella con un compromiso con sal por registro (verificable por terceros), lo firmado es la C14N exclusiva, la cadena comparte ancla de retención, agotado tiene salida y incapacidad ya no viaja en claro. Por qué: los auditores señalaron que el HMAC con clave raíz de la plataforma no lo puede verificar el AGN sin entregarle la clave de toda la entidad (y renombrar el tenant lo rompía); que las huellas se calculaban sobre el texto serializado y no sobre una forma canónica, y la verificación no cotejaba los enlaces de la cadena; que el eslabón 1 caducaba antes que su revocación; que agotado era terminal; y que incapacidad (dato de salud) quedaba en claro en un artefacto WORM y en audit_log. Qué cambia: archive — migración tenant 063 (fundamento_sal BYTEA, fundamento_compromiso, fundamento_compromiso_algoritmo en constancia y revocación; causa en la revocación; CHECK v2/v3; constancia y revocación v3; las filas v2 de la 062, datos de prueba, se conservan como legadas y no se verifican por compromiso). SHA-256(sal_32B || texto) con secrets.token_bytes(32) por registro; sal solo en la base y solo para administración ≥ 3, nunca en el XML ni en audit_log. XML en C14N exclusiva (lxml): se firma, se guarda y se huella lo mismo. GET /acta/verificacion coteja los enlaces de la cadena (sustituyeA, revocacionSha256, constanciaSha256, huella guardada, artefacto firmado) y reporta el WORM de cada eslabón: enlace roto → valida=false con cadena_motivos. Retención WORM anclada al último acto de la cadena y alineación de los eslabones anteriores al proteger uno nuevo. Nuevos POST .../{excepcion_id}/worm/reintentar y .../revocacion/worm/reintentar (admin ≥ 3, asiento atómico). incapacidad → impedimento_sobreviniente en XML y asiento. La revocación exige causa (error_de_declaracion/parte_puede_firmar/otro). El asiento de declarar ya no lleva el número del radicado. Leer el fundamento en claro (GET /acta, /acta/verificacion, verificación del fundamento) deja el asiento transferencia.acta_fundamento_leido. Se retiran signature_client.hmac_fundamento/verificar_hmac_fundamento y el 503 huella_fundamento_no_disponible. ADR-030 (base legal Ley 1581 art. 10 lit. a, clasificación Ley 1712 art. 18 lit. a, exclusión de OAI-PMH/CMIS, salvedades). Contrato (frontend): la respuesta pierde fundamento_hmac, fundamento_hmac_key_version, fundamento_hmac_algoritmo y revocacion_fundamento_hmac; gana fundamento_compromiso, fundamento_compromiso_algoritmo, fundamento_sal (solo admin ≥ 3), revocacion_causa, revocacion_fundamento_compromiso[_algoritmo], revocacion_fundamento_sal, worm_retain_until, revocacion_worm_retain_until, enlace_valido, enlace_motivo; FundamentoVerificacion pierde key_version; /acta/verificacion gana cadena_motivos; el cuerpo de .../revocar exige causa. No se hizo: la revocación legada «regularización» (propuesta en el roadmap); el bloqueo de disposición mientras haya constancia vigente (propuesta). Impacto: aplicar la migración tenant 063 de archive (idempotente) y desplegar archive-service.
  • feat(frontend): FIRMA-2 en la pantalla de firma inobtenible. La constancia muestra ordinal y cadena, radicado y SHA-256 del anexo («soporte no visible con tu nivel» si el lector no lo ve), declarante, parte y revocante con su fuente, huella del fundamento y estado WORM de la revocación; declarar exige radicado y anexo, ofrece siempre el TOTP (obligatorio si el servidor lo exige) y permite re-declarar tras revocar; revocar acepta TOTP; botón «Verificar fundamento» (administración 3, proxy nuevo .../[excepcionId]/fundamento/verificacion); /admin/parametros no admite null (valor_nulo) y los festivos (crear, borrar, sembrar) exigen administración 3. Contrato final: compromiso con sal en lugar del HMAC, causa obligatoria al revocar, enlaces de cadena y cadena_motivos, y «Reintentar protección» WORM (proxies nuevos), «Descargar sobre» firmado (administración 3; proxy nuevo) y el sobre como cuarta copia de la verificación del fundamento. Revisión UX: «Mostrar fundamento» bajo demanda (administración 3; /acta ya no lo devuelve), la sal nunca se muestra, motivos de la cadena y estados WORM en español, foco al título tras declarar/revocar, el reintento recarga el acta, ids de excepción validados como UUID en los proxies.
  • fix(gateway,signature): FIRMA-2, ronda de auditoría — el gateway normaliza la ruta (decodifica hasta estabilizar, rechaza ./.., barras dobles, \\ y controles) y la reenvía normalizada; bloquea todo segmento internal o *-internal de cualquier servicio (sin distinguir mayúsculas); los prefijos de ruteo sin barra final exigen límite de segmento. signature retira POST /internal/hmac-fundamento[/verificar] (sin consumidor: archive usa compromiso con sal local; evita un oráculo de diccionario) y PROPOSITO_FUNDAMENTO.

  • feat(signature,tenant,gateway,archive): FIRMA-2, paquete B — HMAC por entidad del fundamento, operación masiva con responsable, calendario a nivel 3 y fuga de mocks cerrada. Qué cambia: signature — app.ops.invalidar_cadenas_sin_huella exige --operador (debe existir y estar activo en auth_users de cada tenant, si no aborta con salida ≠ 0 sin escribir), lo usa de actor y asienta firma.invalidacion_masiva_ejecutada al inicio y fin de cada tenant con conteos y dry_run. tenant — PUT /config/params/{key} con null da 422 valor_nulo; crear/borrar/sembrar festivos exigen nivel ≥ 3 (sin migración). gateway — /api/v1/signature/internal/* ya no se enruta. archive (solo pruebas) — un único patch envuelve el gather en dos pruebas de integración; una tercera dependía sin saberlo del mock fugado y ahora lo declara. Impacto: reconstruir y desplegar signature-service, tenant-service y api-gateway; sin migraciones.

  • feat(archive,storage)!: FIRMA-2, paquete A — la «firma inobtenible» se puede re-declarar tras revocarla (cadena), la revocación pasa a ser prueba firmada y protegida WORM, la constancia es autosuficiente y el fundamento se huella con HMAC por entidad. Por qué: ADR-030 cerraba la re-declaración porque el artefacto WORM era singleton por (expediente, tipo); el usuario lo reabrió. Los auditores señalaron además que la revocación era una fila sin firma, que el SHA-256 plano de un texto corto de fundamento se ataca por diccionario, que la constancia no bastaba por sí sola (referencia a un radicado sin número ni huella del anexo, partes sin nombre) y que get_by_expediente_and_tipo (ORDER BY created_at DESC LIMIT 1) habría vuelto «idempotente» a la segunda constancia —sin Object-Lock— y habría renovado solo la última. Qué cambia: archive — migración tenant 062: ordinal/sustituye_a (UNIQUE (transferencia_id, rol, ordinal), UNIQUE (sustituye_a), CHECK de coherencia), constancia v2 (número de radicado, SHA-256 del anexo, instantánea de identidad de declarante y parte, fundamento_hmac), revocación firmada (XML, huella, sello, firma personal, identidad, HMAC) y tabla ..._revocacion_worm; el trigger exige, para ordinal > 1, la revocación sellada y TODOS sus artefactos con Object-Lock (ck_firma_excepcion_revocacion_sin_proteger) y cierra la cadena si la parte firmó (ck_firma_excepcion_parte_ya_firmo). POST .../firma-inobtenible: referencia_anexo_id obligatorio, SHA-256 del anexo vía storage, identidad desde auth_users/auth_user_signing_key (solo columnas públicas), HMAC vía signature_client.hmac_fundamento (sin él, 503), firma personal siempre que haya certificado activo, 409 excepcion_vigente/revocacion_sin_proteger/parte_ya_firmo. POST .../revocar: exige PERM_FIRMA ≥ 2, XML urn:orpycamcp:acta-firma-excepcion-revocacion:v1 sellado (sin sello: 503 y ni fila ni asiento) y firma personal de quien revoca. Nuevo GET .../firma-inobtenible/{excepcion_id}/fundamento/verificacion (admin ≥ 3: recalcula el HMAC y lo coteja con columna, XML y asiento). /acta y /acta/verificacion recorren la cadena completa y recortan las referencias por el clearance del lector (lote contra document-service; null indistinguible ante error); fundamento_sha256 → fundamento_hmac. Reconciliación de la protección y la renovación CT de la revocación en app/jobs/indice_reconciliation.py (con file_id). storage — migración tenant 020: seis tipo acta_firma_revocacion[_personal]_<rol>, índice único (expediente_id, tipo, file_id) para los 12 tipos multi-artefacto (se retiran los seis singleton de la 019), get_by_expediente_and_tipo(..., file_id) obligatorio para ellos y RenovarRetencionRequest.file_id (422 si falta). No se hizo y por qué: (a) la pantalla (el frontend debe enviar referencia_anexo_id, totp_code al revocar y consumir los campos nuevos; no se tocó frontend/). (b) Una revocación LEGADA (anterior a la 062, sin sello) no admite re-declaración: queda definitiva, porque su baja no es prueba. (c) Migrar las constancias v1 a v2: son probatorias y no se reescriben; se declaran constancia_version = 1 y fundamento_hmac = null. Verificación: archive 1285 passed/284 skipped en unitario y, con RUN_INTEGRATION=1, 1569 passed y 0 failed (cadena a ordinal 3, trigger de cadena/WORM y parte que firma en la ventana contra Postgres real; además se cerró la fuga de mocks de test_transferencia_acta_worm_atomicity.py, mismo patrón que la de B); storage 572 passed/15 skipped (con integración); ./scripts/lint.sh de ambos en verde. 15 mutaciones caen todas (10 unitarias, 4 contra Postgres real, 1 en storage): HMAC→SHA-256, saltarse la firma personal con certificado, sello 503 (declarar y revocar), parte que firmó, revocante=parte, clearance, ordinal fijo, vigente→cualquiera (estado y SQL), trigger sin el chequeo WORM / sin parte_ya_firmo, firmar_personal con «cualquiera», lookup de storage sin file_id. Impacto: aplicar la migración de tenant 020 de storage y la 062 de archive con sus runners (idempotentes); reconstruir y desplegar storage-service y archive-service. El frontend debe adaptar el cuerpo del acto (referencia_anexo_id ya no es opcional).
  • fix(archive,storage)!: AREA-07-10, remediación de los dos auditores — la constancia de «firma inobtenible» era una anotación (sin firma, sin Object-Lock, soporte en texto libre) y pasa a ser prueba: sellada XAdES-T, firmada por el declarante según la política de la entidad, protegida WORM, con soporte documental real, revocable y sin conflicto de interés Por qué: ante el AGN la constancia no probaba nada (alto): no estaba firmada, el XML no tenía retención y referencia_documental era texto libre; quien envió, recibió o elaboró la transferencia podía declarar la excepción de otra parte; dos partes con excepción y ninguna firma se reportaban firmada_con_excepcion; una excepción declarada por error no tenía salida; el fundamento (salud, fallecimiento) salía completo a cualquier lector; y firma_personal_pendiente leía el entorno y no la política de la entidad. ADR-030. Qué cambia: archive — migración tenant 061 (columnas de la constancia: XML canónico, huella, firma_id, minio_key_firmado, firma personal, soporte referencia_radicado_id/referencia_anexo_id; tabla ..._excepcion_revocacion append-only; tabla ..._excepcion_worm por artefacto; triggers de conflicto de interés y de exclusión mutua que solo cuentan excepciones no revocadas, con CONSTRAINT estable). POST .../acta/firma-inobtenible ahora recibe referencia_radicado_id (+referencia_anexo_id, totp_code), resuelve el soporte contra document-service, exige PERM_FIRMA ≥ 2, sella con el sello institucional (sin sello: 503 y nada se declara) y firma con el declarante (obligatorio si firma_personal_require_xades de la entidad es todos, o clasificados con nivel ≥ 2). Nuevo POST .../acta/firma-inobtenible/revocar. 403 declarante_es_parte_de_la_transferencia (las tres partes) con asiento de denegación. completitud gana sellada_con_excepcion; fundamento solo a USUA_PERM_ADMIN ≥ 3; /acta/verificacion lee la política de la entidad y devuelve politica_xades, constancias_sin_firma y advertencias; /indice/verify propaga firma.advertencias. WORM y renovación CT de la constancia en las barridas existentes. storage — migración tenant 019 y seis tipo nuevos (acta_firma_excepcion[_personal]_<rol>). No se hizo y por qué: (a) la pantalla (declarar, revocar, estados nuevos; contrato en la entrega, no se tocó frontend/). (b) Re-declarar tras revocar: la constancia y su revocación son un par cerrado y el artefacto WORM es singleton por (expediente, tipo); si el rol sigue sin poder firmar es una decisión de dominio. (c) Firmar retroactivamente las constancias anteriores a la 061: quedan firmada=false y la verificación no las da por válidas. (d) Verificar en vivo el Object-Lock (RustFS con PRESERVATION_ENABLED=false en desarrollo: storage acepta el tipo y responde preservation_disabled) y la firma personal del declarante (el secreto TOTP de los perfiles de prueba no se conserva). (e) Dos pruebas de test_clientes_llevan_token_interno.py fallan SOLO con RUN_INTEGRATION=1 por una fuga de estado de test_indice_reconciliation_atomicity.py/test_indice_worm_atomicity.py (reproducida sin los archivos nuevos). Verificación: archive 1228 passed/275 skipped; con RUN_INTEGRATION=1, 1501 passed y 2 failed (la fuga de (e)); storage 526 passed/15 skipped (con integración); ./scripts/lint.sh de ambos en verde. 31 mutaciones (22 unitarias, 9 contra Postgres real) caen todas. En vivo con tokens de desarrollo: 403 de las tres partes, 422 de soporte inexistente/anexo ajeno, 201 con XAdES-T, sellada_con_excepcion, revocar y 409 de re-declarar. Impacto: aplicar la migración de tenant 019 de storage y luego la 061 de archive con sus runners (idempotentes); reconstruir y desplegar storage-service y archive-service. El frontend debe adaptar el cuerpo del acto (ya no existe referencia_documental). Frontend: /transferencias/[id] declara y revoca la «firma inobtenible» (selector de radicado y anexo, TOTP según la política de la entidad, confirmación de acto irreversible), distingue excepciones vigentes y revocadas, y etiqueta sellada_con_excepcion como «ninguna parte firmó; solo el sello de la entidad»; proxies POST /transferencias/api/{id}/acta/firma-inobtenible[/revocar] con allowlist y mensajes en español para todos sus 401/403/404/409/422/503.
  • feat(archive,storage)!: AREA-07-10 cerrada — una parte del acta que no puede firmar se declara con un acto administrativo (nivel 3, con fundamento) y el acta queda «firmada con excepción»; y las firmas personales del acta entran a la renovación de retención CT (decisiones del usuario 2026-10-04). Por qué: si quien debía firmar el acta moría, se retiraba o quedaba incapacitado, el acta quedaba para siempre sin su firma y nada lo decía: o se dejaba pendiente sin salida, o se firmaba por otro (falsificar). Y el XML de cada firma personal tenía Object-Lock con fecha fija, sin renovarse en las series de conservación total: el candado vencía en silencio. Qué cambia: POST /transferencias/{id}/acta/firma-inobtenible (USUA_PERM_ADMIN ≥ 3; rol, motivo de catálogo cerrado, fundamento ≥ 20 caracteres y referencia_documental obligatorios). Migración tenant 059: constancia inmutable (append-only por trigger y por privilegios), una por rol, CHECK declarada_por <> parte_id (quien declara no es la parte) y exclusión mutua firma↔excepción en el motor (trigger con advisory lock) en ambos sentidos. La parte la deriva el servidor del acta congelada; constancia + asiento archive.acta_firma_inobtenible_declarada en la misma tx (el asiento lleva la huella del fundamento, no su texto); un 403 deja asiento de denegación. /acta/verificacion suma completitud=firmada_con_excepcion y excepciones[]; firmada_completa exige ahora cero excepciones de cualquier rol; GET /acta trae excepciones_firma. firmar-personal rechaza con 409 antes de gastar el TOTP. Efecto sobre acta_firma_personal_require: la excepción cubre a su parte (con ambas cubiertas deja de estar pendiente); una parcial no apaga el gate. Renovación CT: storage acepta acta_firma_{elabora,entrega,recibe} en RenovarRetencionRequest (sin cambio de servicio: ya era por (expediente, tipo) y monotónica); archive, migración 060 (estado/backoff en transferencia_acta_firma_personal_worm), renovar_worm_firma_personal y un 4.º advisory lock en run_once_worm_renovacion; espejo con GREATEST; asiento acta_firma_personal_worm_retencion_renovada; pendientes-renovacion las lista con su tipo. No se hizo y por qué: (a) la pantalla de declarar la excepción y su proxy BFF (contrato en la respuesta al equipo de frontend; no se tocó frontend/). (b) Una tool MCP para el acto: es un acto administrativo irreversible con su propia escalera de confirmación y no se abrió aquí. (c) El XML/FUID congelado no se reescribe (es probatorio y su huella está anclada): la declaración vive en la constancia y en /acta. (d) La constancia no tiene Object-Lock propio (inmutable por motor y respaldada por el asiento hash-encadenado). (e) No se impide que el declarante sea OTRA parte del traspaso (p. ej. el remitente declara al receptor): la regla pedida es que no sea la parte que no firma; el rol de administración lo acota. Verificación: archive-service 1144 passed/267 skipped y 243 passed con RUN_INTEGRATION=1 (Postgres real: atomicidad constancia+asiento, CHECK, exclusión mutua en ambos sentidos, append-only, filtro CT y clearance en el WHERE, espejo monotónico); storage-service 463 passed/64 skipped; ./scripts/lint.sh verde en ambos. 15 mutaciones (guarda del declarante, CHECK del motor, asiento sin tx, firmada_completa con excepción, precheck de firma, trigger de exclusión, GREATEST, 4.ª fase, filtro CT, clearance, texto del fundamento en el asiento, tipo del sello en lugar del de la firma, columna de identidad equivocada, Literal de storage): todas caen. Impacto: aplicar las migraciones de tenant 059 y 060 con el runner de archive (idempotentes); reconstruir y desplegar archive-service y storage-service. El frontend debe añadir el proxy y la pantalla (contrato en la entrega).
  • fix(signature,tenant,document,archive,docs)!: Cuatro decisiones del usuario sobre la firma (2026-10-04): la política XAdES es de la entidad, el ESSCertID se exige desde un corte, se firma el contenido y no el identificador, y un ajeno ya no sondea el estado de la cadena. Por qué: (1) FIRMA_PERSONAL_REQUIRE_XADES era una variable de despliegue y valía igual para todas las entidades, cuando la decisión depende de si CADA una ya enroló los certificados de sus firmantes. (2) TSA_ESSCERTID_OBLIGATORIO obligaba a elegir entre rechazar todo el corpus anterior de sellos o no exigir nunca nada. (3) una cadena sobre radicado/expediente firmaba el identificador "{tipo}:{id}" (una firma sobre el nombre sigue válida después de que el documento cambie; AREA-04-09 Media-2). (4) firmar/rechazar/vobo/challenge contestaban 409 solicitud_no_pendiente antes de comprobar no_es_firmante: quien solo tenía el nivel de acceso distinguía una cadena abierta de una cerrada. Qué cambia: política XAdES por entidad — nuevo app/core/params_entidad.py (signature): modo_xades_de_la_entidad lee firma_personal_require_xades de sgd_param_admin antes de la transacción de firmar; el entorno es respaldo solo si la entidad no define la clave (sin fila / null / sin tabla) y un valor desconocido se aplica como todos con error en el log (nunca relaja). tenant-service: migración de tenant 010_params_firma.sql (siembra JSON null, ver la remediación de abajo) y PUT /config/params/firma_personal_require_xades valida off|clasificados|todos (422 valor_invalido, PARAMS_VOCABULARIO_CERRADO). El gate no cambia de contratos: con clasificados/todos, nivel ≥ 2 sin certificado 422 firma_xades_requerida y canal caído 503 canal_firma_personal_no_disponible (nunca nativa); con off se conserva la degradación y firma.fallback_nativa_canal_caido (el gate_modo de los asientos es el efectivo). La pantalla genérica /admin/parametros ya lo lista (no se construyó pantalla nueva). ESSCertID por corte — TSA_ESSCERTID_CORTE (ISO 8601; default 2026-10-04T00:00:00Z; global y no por entidad porque la TSA es infraestructura compartida y el corpus anterior es del despliegue) sustituye al booleano: genTime ≥ corte sin ESSCertID → tsa_sin_esscertid; anterior → vale y VerifyResponse.advertencias[] lleva tsa_sin_esscertid_anterior_al_corte (parámetro de salida advertencias de verify_xades). Contenido, no identificador — document-service GET /api/v1/internal/documentos/radicados/{ref}/huella (SHA-256 del PDF vigente: anexo es_principal más reciente o el PDF más reciente; la huella la calculó storage) y archive-service GET /api/v1/internal/expedientes/{ref}/indice/huella (router propio internal_firma.py + gate app/core/internal_auth.py; xml_sha256 de la última versión del índice), ambos con X-Internal-Token. signature: radicado/expediente pasan a CADENA_PAYLOAD_OBLIGATORIO/AUTORITATIVO (CADENA_SIN_HUELLA_DECLARADA queda vacío), RESOLUTOR_DE_HUELLA gana los dos resolutores, crear resuelve la huella en el servidor y _gate_documento_vigente la relee antes de cada firma y visto bueno (409 documento_modificado; productor caído 503; sin contenido 409 radicado_sin_documento/indice_no_disponible/anexo_sin_huella; inexistente 404 neutro; cadenas abiertas antes, huella NULL: 409 cadena_sin_huella). Clientes get_huella_radicado/get_huella_indice_expediente con el token del servicio AL QUE VAN; compose (dev y prod) y .env.example ganan ARCHIVE_INTERNAL_TOKEN (archive: INTERNAL_SERVICE_TOKEN) y el compose de prod completa DOCUMENT_INTERNAL_TOKEN de signature, que faltaba. Orden de guardas — pertenencia (403 no_es_firmante) antes que estado (409) en firmar, rechazar, vobo y challenge; firmar_lote lo hereda por ítem. Docs — api.md ES/EN (ediciones puntuales), ADR-016 (addendum), roadmap ES/EN (HSM/KMS: pendiente de proveedor; el enganche existe en auth-service/app/core/signing_keys.py::sign_digest). No se hizo y por qué: HSM/KMS (pendiente de proveedor). Pantalla propia de la política (la genérica ya la lista). Un CLAUDE.md de signature-service (no existe). Verificación: signature-service 651 passed/3 skipped con RUN_INTEGRATION=1 (Postgres real: lectura de sgd_param_admin, con tabla ausente y con valor corrupto); document-service 1002 passed con RUN_INTEGRATION=1 (incluye los tests nuevos del endpoint de huella: unit e integración con Postgres real); archive-service los 10 tests nuevos pasan (7 unit + 3 de integración); la suite completa no se terminó (disco de /var bajo el piso); tenant-service 186 passed con RUN_INTEGRATION=1; guardián nuevo shared/tests/test_param_firma_xades_se_siembra.py (lee/siembra/valida con el mismo nombre; 3 mutaciones, las 3 caen); ./scripts/lint.sh de los cuatro servicios limpio. Mutaciones: 22 ejecutadas, 22 caen —orden de guardas ×4, ESSCertID ×6, política por entidad ×5, vocabulario de tenant sin validar, gate que no compara la huella, cliente sin token y guardián de los tres sitios ×3—; M1f (el modo todos devolviendo «gate inactivo») SOBREVIVIÓ al primer juego de tests de la política por entidad (los de todos vivían solo en test_firma_xades_gate.py) y se cerró con dos tests nuevos de todos en nivel 1 (422 sin certificado, 503 con el canal caído) en test_politica_xades_por_entidad.py; M4c–M4o, ejecutadas el 2026-10-08 (el harness drive2.py no sobrevivió; se reconstruyeron las 13 desde su descripción sobre copias montadas :ro): token cruzado ×2, el 409 tratado como 503, crear con la huella del cliente, referencia sin escapar, ORDER BY del principal, filtro de PDF cualquiera, gate abierto de document y de archive, «sin índice» aceptado, gate que no compara, productor caído leído como «igual» (fail-open) y expediente no autoritativo: 13 de 13 caen (en total 35 de 35 con las 22 anteriores) Impacto: aplicar la migración de tenant 010 con el runner de tenant-service antes de signature (idempotente; sin ella signature respalda el entorno); reconstruir y desplegar tenant, document, archive y signature; exportar ARCHIVE_INTERNAL_TOKEN en producción. Rompe: cadenas de radicado/expediente abiertas antes (huella NULL) no se firman (409 cadena_sin_huella, abrir otra); un radicado sin PDF/anexo principal o un expediente sin índice ya no admite cadena (409); TSA_ESSCERTID_OBLIGATORIO deja de existir (usar TSA_ESSCERTID_CORTE); un entorno con FIRMA_PERSONAL_REQUIRE_XADES distinto de off debe fijar el parámetro en cada entidad (el valor lo fija la entidad; con la 010 corregida y la 011 manda el entorno hasta entonces). Contratos para el frontend: VerifyResponse.advertencias: string[], nuevos 409/503 al abrir/firmar, 403 no_es_firmante también sobre cadenas cerradas, PUT /config/params/firma_personal_require_xades con 422 valor_invalido. Remediación de la auditoría (2026-10-08):
  • Alto — la 010 anulaba el respaldo del entorno. Sembraba "off" y un valor presente gana sobre FIRMA_PERSONAL_REQUIRE_XADES: un despliegue con todos quedaba en off en silencio. La 010 siembra ahora null; la nueva migración de tenant 011_params_firma_null_no_off.sql pasa a null las filas que sigan en "off" sin fila en admin_audit para esa clave (la API escribe siempre una en la misma transacción, así que un "off" elegido por la entidad no se toca; el cambio deja su propia fila, actor NULL). Idempotente.
  • Alto — un administrador de solo lectura relajaba la política. PUT /config/params/{key} exige ahora USUA_PERM_ADMIN a nivel 3 (sale de SIN_NIVEL_DECLARADAS) y, si el cambio relaja firma_personal_require_xades (valor vigente null/desconocido = el más estricto), exige motivo (≥ 10 caracteres, 422 motivo_requerido); el asiento lleva motivo y relaja_politica. Cuerpo: {value, motivo?}.
  • La huella del radicado es solo el anexo es_principal (409 radicado_sin_documento sin él; antes «el PDF más reciente»: un soporte valía y subir otro PDF bloqueaba la cadena). El 503 de huella ya no reenvía el cuerpo ni el host del productor (document: huella_no_verificable; signature: verificacion_documento_no_disponible nombra solo al productor y el código HTTP).
  • Cadenas antiguas pendiente con huella NULL: python -m app.ops.invalidar_cadenas_sin_huella [--dry-run] [--tenant] las invalida (invalidada_motivo='cadena_sin_huella', un asiento firma.cadena_invalidada por cadena en la misma transacción, idempotente) para poder abrir la de reemplazo.
  • Asientos: gate_modo y huella_documento_sha256 en firma.turno_firmado; huella_documento_sha256 en firma.solicitud_creada. TSA_ESSCERTID_OBLIGATORIO presente al arrancar → error en el log que nombra TSA_ESSCERTID_CORTE; TSA_ESSCERTID_CORTE y FIRMA_PERSONAL_REQUIRE_XADES documentadas en infra/.env.example y infra/docker-compose.prod.yml. /verify: advertencias: list[str] es campo aditivo del contrato (archive-service lo propaga). Lecturas GET /signature/requests…: alcance (PERM_FIRMA + clearance) declarado como aceptado, no cambiado.
  • Verificación de la remediación: tenant 186 passed, signature 651 passed/3 skipped y document 1002 passed (todos con RUN_INTEGRATION=1), shared/tests 399 passed y 1 fallo previo ajeno (test_todo_archivo_de_codigo_lleva_su_licencia: los test_slug_unico_contrato.py de otros servicios), ./scripts/lint.sh verde en los tres. 29 mutaciones propias (nivel 3, relajar, antes desconocido, motivo, asiento, guard, siembra, 011 ×3, variable retirada ×2, cuerpo/host reenviado ×4, asientos ×3, ops ×7, principal/orden ×2, 503 de document): todas caen; una (ops contando cadenas con huella en el --dry-run) sobrevivió y se cerró con un test.
  • Impacto: aplicar la migración de tenant 011 con el runner de tenant-service; desplegar tenant, document y signature; ejecutar una vez python -m app.ops.invalidar_cadenas_sin_huella --dry-run y luego sin --dry-run. Contrato para el frontend: PUT /config/params/{key} acepta motivo y devuelve 403 por debajo de nivel 3 y 422 motivo_requerido; /admin/parametros debe pedir el motivo al relajar. Frontend: /admin/parametros espeja el nivel 3 de USUA_PERM_ADMIN para escribir parámetros, edita firma_personal_require_xades con selector (null = «heredado del entorno») y pide el motivo de ≥ 10 caracteres al relajarla; las verificaciones (/verify, /indice/verify, /acta/verificacion) muestran advertencias[], y los 409/503 de las cadenas de firma (radicado_sin_documento, indice_no_disponible, anexo_sin_huella, documento_modificado, cadena_sin_huella, verificacion_documento_no_disponible) se explican en español.
  • fix(shared,auth,tenant,document,archive,storage,workflow,notification,signature,knowledge)!: T15 — transversal sobre orpycamcp_common: el slug de tenant se validaba en 14 sitios con cinco cotas distintas (y el alta admitía -abc, que ningún servicio acepta), check_permission y resolve_caller_clearance vivían copiadas en nueve servicios, y 191 asientos de audit_log decían canal='api' aunque vinieran de un job (AREA-02-05, AREA-01-17 y AREA-03-07).
  • Remediación de auditoría T15: migración global 003_slug_forma.sql de tenant-service. Aborta, listando los slugs, si public.tenants tiene alguno fuera de ^[a-z0-9][a-z0-9-]{1,53}$ (el validador común es más estricto que las copias que sustituyó y ese fondo quedaría inaccesible), y después añade CHECK tenants_slug_forma_chk. Guardián de coincidencia SQL↔SLUG_PATTERN en test_slug_unico_contrato.py. Por qué: (1) cada database.py (y keys.py, auth_service.py, grant_root.py, document_repository.py, los repositorios de storage) llevaba su propia regex: {0,54}, {0,98}, {1,62} y el ^[a-z0-9-]+$ del alta; ningún guardián vigilaba la coincidencia y PreservacionWormRepository ni siquiera validaba. Una copia futura sin ancla o con _ habría permitido colisiones de schema o salirse del entrecomillado. (2) Las cuatro funciones del gate eran idénticas byte a byte (mismo hash de AST) en siete clearance.py y en tenant/authz.py: un gemelo que divergiera de la política fail-closed en una sola copia sería un hueco de autorización que ningún test del servicio sano ve. (3) audit.append(canal=None) caía a 'api': purgas, renovaciones WORM, reconciliación y consumidores quedaban asentados como llamadas a la API, y la traza legal mentía sobre el origen. Qué cambia: slug — orpycamcp_common/tenant.py (SLUG_RE = ^[a-z0-9][a-z0-9-]{1,53}$, slug_valido, tenant_schema que lanza ValueError); los nueve database.py, keys.py, auth_service.py, grant_root.py, registrar_restauracion.py, document_repository.py, los repositorios de storage y el schema TenantCreate lo importan (el alta ya no admite guion inicial: 422). Gate — orpycamcp_common/rbac.py con check_permission, resolve_caller_clearance, nivel_efectivo y mensaje_denegacion; cada servicio las re-exporta desde su core/clearance.py/authz.py y conserva solo require_permission. Canal — audit.append(..., canal: str) es OBLIGATORIO; audit.CANAL_PETICION declara «el acto es de una petición» (hereda el X-Canal del gateway; 'api' si no viene) y "system" jobs, consumidores y CLI; request_log.canal_de_fondo() fija 'system' en la tarea de cada bucle periódico (archive ×3, document ×2, storage) y spawn_supervised lo hace para todos los consumidores de eventos, de modo que una función mixta (petición y job) asienta bien en ambos. Los 191 llamantes sin canal se reescribieron; las rutas internas de purga (document, storage) y las de preservación disparadas por el reconciliador (canal_del_acto(actor)) asientan system. object_ref — GET /api/v1/audit?object_ref=<código> trae también los asientos históricos que nombran al expediente por UUID o por el UUID de su índice (y los del radicado por su número), resuelto en lectura con subconsultas (sin viaje extra); el histórico no se reescribe y el recorte no-read-up se aplica después. Docs — ADR-013 (la autocreación ya está decidida; la resolución está generalizada), ADR-008, multi-tenancy.md, api.md y los CLAUDE.md de servicio. No se hizo y por qué: (a) caché de permisos: sigue fuera a propósito (ADR-013: un permiso revocado debe surtir efecto en la petición siguiente; no hay perfil de carga que la pida). (b) require_permission en common: depende de get_tenant_conn de cada servicio; se queda local. (c) el histórico de object_ref: no se reescribe (cadena de hash); se resuelve en lectura. (d) append_denial conserva canal=CANAL_PETICION por omisión: una denegación del gate es, por definición, de una petición. Verificación: guardianes nuevos en shared/tests — test_un_solo_validador_de_slug (censo del árbol + anti-vacuidad + parametrizada; mutaciones: reintroducir una regex local y quitar el ancla caen), test_el_gate_se_define_una_vez (mutaciones: una definición de check_permission en workflow y return True ante usuario nulo caen) y test_el_asiento_declara_su_canal (censo AST de 190+ llamadas; mutación: quitar canal= de index_service.py cae; una tarea de fondo asienta system aunque declare CANAL_PETICION) — shared/tests 395 passed; en imágenes reconstruidas: document 752 passed/227 skipped, auth 316 passed/44 skipped (más 3 de integración del filtro object_ref contra Postgres real, con la mutación a = exacto que cae; 3 fallos preexistentes de test_verify_audit_chains_job por el rol orpycamcp_app NOLOGIN de la pila local), tenant 137/7, archive 1084/254, storage 447/64, workflow 355/102, notification 228/20; signature 503/15 y knowledge 262/56 con el código montado :ro; ./scripts/lint.sh verde (ruff + mypy) en los nueve servicios. En vivo contra la pila: GET /audit?object_ref=EXP-2026-0002 devuelve 51 asientos, incluidos los dos que nombran al expediente por UUID e índice; un alta de expediente por el gateway asienta api; una tarea de fondo asienta system; TenantCreate rechaza -abc, a_b y 55 caracteres y acepta bogota-limpia (tenant_bogota_limpia). Impacto: sin migraciones; reconstruidos y desplegados los nueve servicios (auth, tenant, document, archive, storage, workflow, notification, signature, knowledge) con el rol propietario de la pila local. Cambia el contrato interno de audit.append (canal obligatorio): cualquier llamante externo a este repositorio debe declararlo. Los asientos de jobs pasan a decir system; los históricos siguen diciendo api. Un slug de 1 carácter o con guion inicial ya no se admite en ningún servicio (el alta ya exigía ≥ 2).
  • fix(docs,archive,frontend,signature,workflow,storage,document,shared): T16 — documentación y memoria: el roadmap se contradecía sobre la disposición final y daba por abiertos dos hallazgos ya cerrados, multi-tenancy.md y ADR-002 recetaban Alembic, SQLAlchemy y un SET search_path sin validar, la guía de arranque mandaba a dar de alta una entidad por una ruta que el gateway retiró, ADR-006/018/021 y los comentarios de TSA decían «no hay barrera», «registrando entregas» o «stub» sobre lo que ya existe o nunca existió, el roadmap EN iba 18 pendientes por detrás del ES y el listado de préstamos por unidad devolvía todo el histórico Por qué: cada afirmación falsa se escribió cuando era cierta y el código siguió; un texto que dice «no implementado» sobre algo que ya existe reabre lo cerrado, y una receta insegura en un ADR la copia quien lo lea. (1) AREA-14-01: docs/es/roadmap.md decía a la vez «la disposición final no se ejecuta / no destruye nunca» y «RF-TRD-07 queda cerrado»; el código da la razón a lo segundo (POST /lotes/{id}/ejecutar, USUA_PERM_TRD nivel 4). (2) AREA-14-02 y AREA-02-01: el «hallazgo de dominio» de escrituras sin require_permission en metadatos/catálogos/config (cerrado en 32fc631) y el «limitador cuenta por IP» (cerrado por RateLimitPorUsuarioMiddleware, 9c2cc6a) seguían como abiertos. (3) AREA-14-06/-07: multi-tenancy.md ES/EN y ADR-002 citaban «migraciones Alembic», session.execute(text(f"SET search_path TO tenant_{slug}…")) (SQLAlchemy, sin validar ni entrecomillar: una inyección en search_path copiable), el bucket «de plantillas» (no existe), el stream por entidad (es uno por servicio) y un curl POST /api/v1/tenants por el gateway —ruta retirada a propósito— con un slug con guion bajo que tenant-service rechaza; getting-started ES/EN repetía el curl. (4) AREA-14-08/-09/-10/-11: ADR-021 daba a notification «pendiente» (cerrado el 2026-07-01), ADR-006 decía «no hay barrera dura» (is_embeddable/is_generatable son fail-closed), ADR-018 daba el SSRF por «evolutivo» (implementado, con TOCTOU cerrado) y prometía «registrando entregas» (solo hay logger.warning), y el cliente RFC 3161 remoto figuraba como stub en tsa.py, config.py, firma.py, ADR-016 y el roadmap. (5) AREA-14-03/-04 y OMI-07: cabecera y cifra de ADRs escritas a mano, 18 pendientes del roadmap EN cerrados en ES (recuperación de 2FA, Font Awesome, DataTable…), y docs/en/security-audit.md sin la segunda pasada ni el cambio de comportamiento de la ventanilla. (6) AREA-14-13/-14/-15/-16/-17/-19: TODO obsoletos en bandeja y radicar, comentarios de deuda ya cerrada en storage/workflow/notification/document, CLAUDE.md de workflow contradictorio, arranque.md con «MinIO Console» y sin whisper, e infra/DOCKER_REGISTRY.md describiendo un pipeline de GitHub Actions que no existe y un Kubernetes «recomendado» sin manifiestos. (7) AREA-07-19: GET /unidades/{id}/prestamos devolvía todo el histórico de una unidad («la cota la pone el objeto»: supuesta, no impuesta). (8) AREA-07-16: el EAD del OAI de expedientes nunca se validaba contra el XSD. Qué cambia: docs — roadmap ES/EN: el bloque de disposición final pasa a histórico con el estado vigente; los hallazgos 14-02/02-01 quedan cerrados con su evidencia y los residuales aceptados (tokens inválidos sin caché, limitador que falla abierto sin Redis); cabecera 2026-10-03 y sin número de ADRs a mano; los 18 pendientes EN cerrados con su motivo. multi-tenancy.md ES/EN reescritos sobre el patrón real (tenant_schema con _SLUG_RE fail-closed + SET search_path = "{schema}", public sobre asyncpg; alta con scripts/init-tenant.sh; stream compartido orpycamcp.{service}.events; buckets documents y preservation); ADR-002 gana una nota «parcialmente superado por ADR-003 y ADR-027» con el snippet seguro; ADR-006/016/018/021 corregidos sin tocar su decisión; deployment.md rebaja Kubernetes a «no soportado todavía» y avisa del estado en memoria al escalar; DOCKER_REGISTRY.md reescrito para GitLab; contributing.md ya no pide «modelos SQLAlchemy con migraciones Alembic»; security-audit.md EN con la segunda pasada y la ventanilla. código (solo comentarios, sin cambio de comportamiento) — tsa.py, config.py, firma.py, distribution.py (workflow), preservacion.py (storage), oai_service.py (document y archive), transferencia_repository.py, TODO de bandeja/+page.svelte y radicar/+page.svelte, y el comentario de .gitlab-ci.yml sobre cuándo se publican imágenes. signature — migración tenant 022 (COMMENT ON COLUMN firma.xades_level: B/T/LT/LTA con productor; la 008, ya aplicada, no se edita). archive — GET /unidades/{id}/prestamos pagina (page/size ≤ 100, X-Total-Count, mismo repositorio y WHERE para filas y total) y el BFF archivo-fisico/api/unidades/[id]/prestamos pide size=100 y conserva el total; tests/test_ead_xsd.py valida el EAD difundido (sin serie, 1, 2, 3 y 5 niveles) contra el XSD oficial de EAD 2002 vendorizado en tests/fixtures/ (ead.xsd + xlink.xsd, sin red). shared — shared/tests/test_la_documentacion_dice_lo_que_hay.py: 20 guardianes que atan cada frase al hecho del código que la haría falsa (/ejecutar, gates de escritura, RateLimitPorUsuarioMiddleware, is_embeddable, validate_webhook_target, httpx.post de la TSA, proxies de bandeja…), la paridad de pendientes ES/EN del roadmap, el censo de ADRs, las tandas de security-audit ES/EN y las recetas prohibidas (session.execute(text(, «Alembic», SET search_path sin comillas). Ya cerrados por tandas anteriores (verificados, sin cambio): AREA-14-12 —el COMMENT de transferencia_acta_firma_personal.rol ya lo reescribió la 028, y una migración nueva habría pisado ese texto— (se añadió una prueba de integración que lee col_description; la docstring del repositorio, sí, estaba caducada y se corrigió); el nombre de la memoria limitador-por-ip-comparte-cuota.md pasa a CERRADA. No se hizo y por qué: (a) AREA-14-10, tabla webhook_entregas (constancia durable de qué salió a un tercero): el ADR ya dice la verdad y la declara pendiente; implementarla exige reconstruir notification-service y es una tanda propia. (b) AREA-14-04, paridad del changelog EN: docs/es/changelog.md declara que la versión inglesa es una selección y no una traducción; exigir paridad contradice esa decisión (pregunta abierta). (c) AREA-11-05, cifra de cobertura del MCP generada: censo_mcp.py necesita los OpenAPI de los once servicios en ejecución; la separación «excluido a propósito / hueco real» es decisión del usuario. (d) AREA-07-16, discriminador de nivel del CCD: trd_series no tiene columna de nivel (ADR-015: raíz = serie, descendiente = subserie), así que «sección/subsección» no existe en el dato; añadirla es decisión de dominio. (e) AREA-07-17 (export de expediente en streaming) y AREA-09-17 (suite de carga): tanda propia, ver el resumen. Verificación: guardián documental 20 passed (27 mutaciones reintroduciendo cada frase falsa: todas caen); archive-service 1084 passed/254 skipped (+ integración contra Postgres real: test_los_prestamos_se_paginan_integration, test_comment_de_la_firma_personal_integration); signature-service 503 passed/15 skipped (+ integración test_comment_de_xades_level_integration, que cae sin la 022); workflow 355, storage 447, document 752 sin regresión; ruff+mypy verdes en los cinco. Mutaciones del código: quitar size del router de préstamos por unidad (cae), level = "series" fijo o "sub-series" en el EAD (caen el nivel y el XSD). No se reconstruyeron las imágenes (disco /var al 98 %, 563 MB libres): las pruebas corrieron sobre la imagen existente con las fuentes montadas :ro. Impacto: desplegar archive-service (paginación de préstamos por unidad) y el frontend (BFF); signature-service aplica 022 con su runner (solo COMMENT); el resto son comentarios y documentación, sin despliegue.
  • fix(infra,auth): T14, remediación de auditoría: una sola fila rota de audit_log abortaba para siempre todos los respaldos (dejando un .PARCIAL en claro cada noche), la retención no leída se tomaba por «sin retención», las versiones previas bajo COMPLIANCE se perdían con un aviso y rc 0, restaurar no dejaba traza inmutable, y el entorno de los servicios en producción llevaba la conexión del propietario (Alta + 3 Medias; la cuarta Media, knowledge sin gate, queda declarada). Por qué: (1) audit_log solo admite anexar (Ley 594/2000): una rotura histórica no se repara; verify_audit_chains salía 1 ante UNA rotura y backup.sh hacía exit 1 ANTES de cifrar, sellar y rotar. (2) get-object-retention ... || true convertía el error en vacío; el restore era fail-closed solo sobre lo que el respaldo capturó. (3) reponer_retencion devolvía 0 con versiones previas protegidas. (4) el mapa version_id quedaba en un TSV sin asiento y preservacion_worm_objeto.version_id apuntaba a versiones inexistentes. (5) MIGRATIONS_DATABASE_URL (propietario) seguía en el entorno de los 10 servicios en ejecución del compose de producción, y el guardián exigía esa postura débil. Qué cambia: verify_audit_chains acepta una línea base de roturas históricas aprobada por el responsable (--roturas-declaradas FICHERO, o líneas ROTURA<TAB>slug<TAB>id<TAB>nota mezcladas en el stdin del ancla): solo cuentan las roturas NUEVAS, las no enumerables (más del tope de verify_chain), un retroceso y un cambio del hash anclado; la declarada se informa en cada corrida. backup.sh ya no aborta: si la verificación falla no emite ancla pero completa (cifrado, sello con verificacion_auditoria=FALLIDA, purga) y sale con 3 al final; la línea base va por BACKUP_AUDIT_ROTURAS_DECLARADAS (una ruta que no existe = FALLIDA, fail-closed). Retención: _leer_json_s3 distingue «sin configuración» (NoSuchObjectLockConfiguration/ObjectLockConfigurationNotFoundError) de cualquier otro fallo o JSON ilegible; el fallo aborta ESE bucket, la cuenta de versiones listadas contra filas OBJETO debe cuadrar, y el respaldo se sella con retencion_capturada_completa=NO y sale con 4; restore.sh rechaza ese respaldo salvo RESTORE_ACEPTAR_RETENCION_INCOMPLETA=1. Restore: una versión previa bajo COMPLIANCE vigente hace fallar reponer_retencion salvo RESTORE_ACEPTAR_PERDIDA_VERSIONES=1 (anótese en el acta); nuevo app.ops.registrar_restauracion (auth-service) que, en UNA transacción, actualiza preservacion_worm_objeto.version_id desde el mapa y deja el asiento storage.restauracion (hash del mapa, recuentos, previas no restauradas) en audit_log; el mismo criterio de línea base aplica a la verificación de restore.sh (roturas-declaradas.tsv junto a los respaldos o BACKUP_AUDIT_ROTURAS_DECLARADAS). Producción: MIGRATIONS_DATABASE_URL sale del entorno de los 10 servicios en ejecución y queda solo en migrate-<svc>; el guardián se invirtió; deployment.md migra con migrate-<svc> y pasa la conexión del propietario solo al contenedor de un solo uso de cifrar_webhook_secrets. No se hizo y por qué: gatear knowledge /search, /antecedentes, /rag (Media, AREA-01-18): decisión de producto, sigue DECLARADA en test_toda_ruta_lleva_gate con su test anti-caducidad; hoy el único control es clearance + ACL en la consulta. Un evento PREMIS por objeto remapeado: el asiento de audit_log y la actualización de version_id cubren el mínimo pedido. Las versiones previas bajo COMPLIANCE siguen sin respaldarse (el espejo copia solo la versión actual); ahora la restauración lo dice con un fallo, no con un aviso. Verificación: shared/tests 333 passed; auth-service 362 passed con RUN_INTEGRATION=1 (Postgres real; 11 pruebas nuevas en test_verify_audit_chains_job.py y test_registrar_restauracion.py) ejecutadas sobre la imagen existente con app/, tests/ y shared/ montados :ro (disco /var al límite: no se reconstruyó); ./scripts/lint.sh auth-service verde. Mutaciones que caen: ignorar la línea base, no pasarla desde _main, tolerar las roturas omitidas, omitir el UPDATE o el asiento de registrar, volver a || true en la lectura de retención, quitar la cuenta de versiones, quitar el fallo por previas COMPLIANCE, volver a abortar en backup.sh, quitar la salida 3, quitar el acta del restore, devolver MIGRATIONS_DATABASE_URL al runtime. Impacto: reconstruir auth-service (módulos verify_audit_chains y registrar_restauracion); backup.sh puede salir 3 o 4 con un respaldo completo y sellado (la supervisión debe tratar esos códigos como alerta, no como pérdida de respaldo); un runtime de producción ya no puede correr migraciones con run <svc>: usar migrate-<svc>.
  • fix(infra,auth,ci)!: T14 — guardianes de CI e infraestructura: ninguna regla no negociable tenía guardián ejecutable, la matriz de CI era una lista a mano (sin signature en integración ni whisper en unitarias), producción construía en el servidor sin vuelta atrás ni migraciones al arrancar, el respaldo ANCLABA la cadena de auditoría sin verificarla (y el heredoc de restore.sh reventaba con TypeError), la restauración daba por buena una retención no repuesta, y la auditoría de dependencias no podía fallar (AREA-01-18, 13-01/02/03/05/08/09/11/13/14, 08-10/12, 03-02/05/06, 05-08). Por qué: (1) ningún test derivaba del árbol que toda ruta lleve gate (knowledge /search, /antecedentes y /rag estaban abiertos al RBAC y solo un censo manual lo supo); las reglas 1, 3 y 7 de CLAUDE.md solo las vigilaba un hook local que CI no corre. (2) .gitlab-ci.yml: whisper-service fuera de test-unit, signature-service (con tests/integration/) fuera de test-integration, la batería WORM contra S3 real (RUN_S3_WORM) en ningún job, los tests de puertas internas solo a un clic. (3) infra/docker-compose.prod.yml tenía build: y ningún image:: pull ignoraba los doce servicios y ORPYCA_TAG=<sha-anterior> up -d no volvía a ninguna versión; las migraciones solo corrían a mano. (4) backup.sh ancló count+hash de audit_log con un psql sin verificar nada (podía sellar una cadena ya rota o retrocedida), y el heredoc de restore.sh desempaquetaba verify_chain como tupla (ok, fila) cuando devuelve un ResultadoCadena: la verificación de la restauración reventaba. (5) put-object-retention ... || true + un contador informativo: objetos borrables con un mensaje verde; la retención legal no se guardaba ni se reponía y solo se recorría la versión actual. (6) dependency-audit: pip-audit || true (un fallo de red = «0 paquetes»), un JSON ausente se saltaba, y decidía claude -p con exit 0 si no escribía nada; sin npm audit, sin escáner de imágenes (docker scan está retirado). (7) las particiones de audit_log dependen de un cron externo cuyo fallo no se veía; el trigger de SENTENCIA de 004 no lo distinguía ningún test (el de fila de 002 ya bloquea con 1 fila) y en CI archive/document/signature/storage/knowledge/workflow probaban una copia del DDL que crea su propio conftest. Qué cambia: guardianes nuevos en shared/tests (corren en cada push; test-unit-shared instala ahora PyYAML, packaging y el extra kek): test_toda_ruta_lleva_gate (AST de services/*/app/routers: más de 400 rutas; cada una lleva require_permission/require_internal_token/require_public_interop, cuelga de /public/ o figura en EXCEPCIONES con su motivo —72 declaradas, entre ellas knowledge—; sin crecer ni caducar; censo > 100), test_reglas_de_dependencias (ni sqlalchemy/alembic/python-jose en requirements ni imports; suelos pyjwt>=2.10.0/python-multipart>=0.0.18/mcp>=1.12.0; todo bind mount del repo :ro), test_matriz_ci_cubre_servicios (matrices de test-unit y test-integration derivadas del árbol; FUERA_DE_INTEGRACION declara notification y tenant), test_ci_guardianes_de_infra (job WORM con rules:changes sobre los compose y con el digest exacto de producción; trivy antes de cada push; sin docker scan en la doc), test_prod_inmutable, test_migraciones_al_arrancar, test_manifiesto_prod_seguro (sin ports:, Redis requirepass, credenciales :? sin defecto —rotaciones vacías declaradas—, terceros con tag fijo), test_audit_umbral y test_retencion_respaldo_restauracion (con un s3api falso en bash). CI: whisper en test-unit, signature y workflow en test-integration, las migraciones globales REALES de auth-service antes de cada tier (el conftest de integración ahora FALLA con CI definido si no encuentra audit_log en vez de crear su copia), job storage-worm-real (RustFS por digest, RUN_S3_WORM=1; test_worm_object_lock_real_s3.py FALLA en CI sin RUN_S3_WORM), job automático test-security-smoke (gates internos del gateway, storage y whisper), trivy en build-images (CRITICAL con corrección, antes del push), dependency-audit fail-closed con umbral determinista (scripts/audit_umbral.py, también en MR y sobre requirements-ml.lock; el modelo solo redacta) y job npm-audit. Producción: image: ${ORPYCA_REGISTRY:?}/<svc>:${ORPYCA_TAG:?} en los 12 servicios propios (sin build:; la construcción local vive en infra/docker-compose.build.yml) y un migrate-<svc> de un solo uso por servicio con base de datos (service_completed_successfully, misma imagen inmutable, conexión del propietario). Respaldo/restauración: auth-service/app/ops/verify_audit_chains.py (verifica cada cadena, compara con el último ancla —conteo y hash del asiento anclado—, 0/1, solo lee) que backup.sh invoca ANTES de anclar y NO ancla si falla, y que restore.sh reutiliza en lugar del heredoc; infra/lib-retencion-s3.sh (todas las versiones, retención legal, repone y CUENTA: restore.sh sale ≠ 0 si queda una retención vigente o legal sin reponer, y escribe el mapa version_id viejo → nuevo para el acta de ADR-027). /health/ready de auth-service añade audit_particiones (degraded si falta la partición del mes siguiente; sigue 200). scripts/preflight-prod.sh (TLS y puertos desde fuera). seed_demo.py siembra un borrador con circuito de dos turnos cuyo texto cambia en la ventana de invalidación y e2e/authenticated/firma-gate-orden.spec.js lo ejerce. Docs: deployment.md (ES/EN: imágenes inmutables, migraciones al arrancar, verificación diaria, particiones, retención, checklist con guardianes), security-audit.md (ES/EN: ya no afirma «sin vulnerabilidades» sin ejecución registrada). No se hizo y por qué: (a) gatear knowledge /search, /antecedentes, /rag y aceptar con un permiso: es una decisión de producto (hoy el catálogo MCP las declara permission=None); quedan DECLARADAS en EXCEPCIONES con su motivo, no aprobadas. (b) migraciones al arrancar en el compose de desarrollo (no se tocó la pila local: sigue a mano) y el entrypoint dentro de los 12 Dockerfile (obligaría a reconstruir todo; el migrate-<svc> de un solo uso no). (c) build-images solo corre en tag o a un clic (decisión de costo del usuario; el tag es obligatorio y está documentado). (d) las versiones previas con retención no se pueden reponer (el espejo solo copia la versión actual): se reportan. (e) notification y tenant no entran a la matriz de integración (declarado). Verificación: shared/tests 316 passed; auth-service 350 passed con RUN_INTEGRATION=1 (307/43 skipped sin ella); workflow 457 con RUN_INTEGRATION=1 (la prueba de bandeja contra Postgres real: total y filas); archive 1074 passed/252 skipped y el tier de atomicidad del índice en verde; lint de los servicios tocados. Mutaciones que caen (restaurado y vuelto a correr): quitar el gate de anulacion aprobar; python-jose en un lock, pyjwt==2.9.0, quitar :ro; quitar image: de auth, ports: 5432, :-x en Redis, redis:latest, quitar la dependencia de migrate-auth; quitar whisper/signature de las matrices, la regla changes, el || true de pip-audit, --exit-code 0 de trivy; put-object-retention || true y quitar el exit 1 de restore; verificar que devuelve siempre 0 o que no mira res.ok; la sonda de particiones con ok fijo; el trigger de sentencia 004 (en una transacción revertida); sacar _reconcile_aip_once de su try; COALESCE(…, 1) y quitar el predicado de clearance de la bandeja; el gate de documento antes de la pertenencia en firmar(). Contra la pila viva: la cadena de demo y __system__ verifica íntegra (11 404 cadenas, exit 0, sin roturas); /health/ready con la partición al día; circuito sembrado: el ajeno recibe 403 no_es_firmante sin documento_modificado ni version, el firmante con turno 409 documento_modificado (versión 2). Impacto: producción exige ORPYCA_REGISTRY y ORPYCA_TAG en cada docker compose; reconstruir auth-service (módulo app.ops.verify_audit_chains y la sonda; backup.sh/restore.sh lo necesitan) — no se pudo reconstruir en la pila local por falta de disco (/var con ~460 MB); sin migraciones SQL nuevas. En CI: test-integration y test-unit suman servicios y puede exigir ajustes la primera corrida.
  • fix(document,frontend)!: T13 — cumplimiento documental: el radicado nacía sin tipo ni dependencia, el índice de clasificados admitía fundamentos NULL, el cambio de nivel se publicaba después del commit y se perdía con Redis caído, la ventanilla pública no dejaba constancia de la autorización de tratamiento ni tenía freno de automatización ni reintento seguro, el secreto del webhook postal era único y estaba en claro, y publicar de oficio difundía el primer anexo (AREA-09-02/04/05/07/08/10/16, 07-12 y 02-07 —de este último solo el honeypot y la Idempotency-Key—). Por qué: (1) DocumentCreate y el lote dejaban doc_class, dest_dept_code y origin_dept_code opcionales (Ac. AGN 060/2001 §5.2): un radicado podía nacer sin serie TRD y sin destino, fuera de la bandeja de todos. (2) La importación INT-05 admitía nivel ≥ 2 con clasificacion.fundamento nulo y la base no lo impedía: la promesa «nunca NULL» del índice del art. 20 no tenía respaldo. (3) La reclasificación publicaba su evento tras el commit y tragaba el fallo: con Redis caído justo ahí, el nivel quedaba subido y knowledge —que deriva el nivel de sus fragmentos SOLO del evento— seguía sirviendo el fragmento al nivel anterior (R6, declarado y diferido). (4) La ventanilla y la PQRS daban de alta al remitente sin constancia de consentimiento (Ley 1581), sin campo trampa y sin clave de idempotencia: un reintento de red gastaba dos consecutivos oficiales. (5) postal_operator_credential.webhook_secret era TEXT en claro, una fila por operador, ningún endpoint lo escribía y no había rotación; un envío pasaba a entregado a mano sin prueba. (6) get_public_anexo servía el primer anexo de cualquier radicado publicado, y publicar solo exigía nivel 1 (que es el DEFAULT de una PQRS). (7) documento.contenido_cambiado no se emitía. Qué cambia: datos mínimos — app/core/datos_minimos.py (E/I → tipo + destino; S → tipo + origen) importado por DocumentCreate y BatchDocumentCreate (el lote gana origin/dest_dept_code y resuelve el nombre en dependencias); los productores internos los derivan o rechazan antes de gastar número (exigir_o_422): la respuesta rápida hereda origen y tipo del antecedente, el radicado desde borrador deriva tipo (plantilla), destino (nombre) y origen (URD de quien radica) y POST /borradores/{id}/radicar admite un cuerpo opcional {tipo_documental?, origin_dept_code?, dest_dept_code?} que manda; la bandeja de correo lo exige antes de traer el mensaje. Índice — migración 041 (CHECK radicados_clasificado_con_fundamento NOT VALID + backfill «PENDIENTE DE MOTIVAR…» + VALIDATE; create inserta ya el fundamento porque el CHECK no es diferible), la importación rechaza todo el paquete (422) y el índice añade dependencia y registrado_por. Outbox — migración 042 (eventos_salientes) + services/outbox.py: alta, reclasificación y anulación encolan el evento en la MISMA transacción que el cambio y su asiento; camino rápido tras el commit y relé en el barrido (fase 4, SKIP LOCKED), mismo event_id al reenviar; BorradorService.editar emite documento.contenido_cambiado si cambia la huella del texto. Publicación — migración 040 (anexos.publicable, DEFAULT FALSE, backfill de lo ya publicado), PublicacionRequest.anexos_publicables, get_public_anexo exige a.publicable, y set_publicable excluye en el WHERE lo de canal ventanilla_publica y lo tipificado con un tipo admite_ventanilla_publica (404 neutro); clasificar desmarca los anexos. Ventanilla pública — acepta_tratamiento: Literal[True] + politica_version (constancia en metadata.consentimiento y en el asiento; ip_declarada), honeypot sitio_web, Idempotency-Key con public_submission_keys (migración 043, reclamada dentro de la tx del radicado y antes del consecutivo; otro contenido → 409), GET /ventanilla/estado y scripts/verificar-ventanilla.sh; el formulario SvelteKit lleva la casilla (con la política de la entidad: POLITICA_TRATAMIENTO_VERSION/URL), la trampa fuera de pantalla y una clave por contenido. Postal — migración 044: dos ranuras de secreto cifradas (AESGCM, AAD entidad+operador), verify_any, PUT /envios/operadores/{op}/credencial (USUA_PERM_ADMIN crud≥5, sin devolver ni asentar el secreto), acuse_file_id/acuse_sha256 obligatorios para entregado a mano (la pantalla de envíos sube el acuse y lo manda). Storage — REFERENCIAS_DE_ARCHIVO += postal_shipment.acuse_file_id (el guardián test_referencias_de_archivo_cubren_todo_file_id lo cazó: sin la entrada la purga de huérfanos no vería la referencia del acuse); el guardián test_eventos_de_radicado_declaran_nivel ahora también censa outbox.encolar (si no, el censo de emisores se vaciaba en silencio) y documento.contenido_cambiado declara nivel_seguridad. Docs — comentario R6 de knowledge-service/app/services/ingestion.py corregido (cerrado en origen). No se hizo y por qué: (a) el proveedor de CAPTCHA (AREA-02-07): decisión del usuario (reCAPTCHA contradice la soberanía; ALTCHA u otra prueba de trabajo autoalojada, verificada en document-service antes de abrir la transacción). (b) no vincular a un anónimo con la ficha de un tercero existente por número de documento (nota de AREA-09-08): cambia la convergencia del directorio que hoy fijan los tests; pregunta: ¿el canal anónimo debe radicar con metadata.remitente y sin ficha cuando el documento ya existe en el directorio? (c) nivel serie/expediente del índice y catálogo cerrado de fundamentos (AREA-09-04.3) y doble control para desclasificar (AREA-09-05): decisión de la entidad. (d) barrido de reconciliación nivel-del-fragmento en knowledge (AREA-09-05), consumidor de document.envio.devuelto en notification (AREA-09-10) y consumidor de documento.contenido_cambiado en signature (AREA-09-16): fuera de los servicios de la tanda; el outbox cierra la pérdida en origen y la garantía síncrona de firma ya existe. (e) tipos_documentales.publicable_permitido (AREA-07-12): columna de archive-service; se reutilizó admite_ventanilla_publica, que ya es la declaración de la entidad de «esto entra de fuera»; el histórico de publicaciones queda en audit_log. (f) IP verificada por saltos de confianza en las rutas públicas: el gateway no la inyecta; se guarda ip_declarada. (g) habilitada dentro de GET /ventanilla/tipos (AREA-09-07): habría roto la forma de la respuesta (lista) y una lista vacía no puede llevarla; es un endpoint nuevo GET /ventanilla/estado. (h) captura de tipo y dependencia en el formulario de borradores: sin ella, un borrador sin plantilla con tipo y sin URD del autor responde 422 al radicar (el cuerpo opcional lo resuelve por API). Verificación: document-service 741 passed/219 skipped y 219 passed con RUN_INTEGRATION=1 (Postgres real: 23 pruebas nuevas —el CHECK 041 y su backfill, el WHERE de publicación por tipo y por canal, anexos.publicable, el outbox con Redis caído, el rollback del UPDATE si falla el INSERT del evento, la clave de idempotencia bajo asyncio.gather de 4 conexiones, la derivación del borrador contra las tablas reales, el lote con dependencia inexistente—); frontend 2655 passed (235 ficheros; 8 pruebas nuevas: la casilla, el honeypot oculto, la clave por contenido, el proxy y el acuse); ruff+mypy verdes (storage-service: 447 passed/63 skipped y ruff verde; shared/tests 255 passed). 17 mutaciones que caen (todas restauradas y la imagen reconstruida): quitar la rama E / doc_class de S / el validador del lote / la comprobación del import / el CHECK 041 / AND a.publicable / la exclusión por tipo / por canal del WHERE / mover encolar fuera de la tx / sacar la reclamación de la clave de la tx / el honeypot / Literal[True] / habilitada fija / la caducidad de la ranura anterior / el acuse obligatorio / el enviar de contenido_cambiado. Cuatro defectos propios cazados al escribir las pruebas: el CHECK 041 no admite el INSERT en dos pasos de create (rompía toda radicación clasificada), una mutación mal dirigida (cayó sobre un comentario de la migración, no sobre el CHECK), radicar_en_ventanilla con parámetro sin valor tras uno con valor (no arrancaba) y un 500 si la dependencia heredada por la respuesta rápida estaba inactiva. Impacto: rompe a clientes que radican sin tipo/dependencia (POST /documents, POST /batch/documents, ventanilla y PQRS sin acepta_tratamiento/politica_version, entregado a mano sin acuse): avisar a las integraciones; un lote encolado antes del cambio sin códigos termina con esos ítems failed. Migraciones 040–044 por tenant (python migrations/runner.py de document-service; no dependen de otro servicio: tipos_documentales ya existía por archive-service), reconstruir document-service, frontend y storage-service (este último, una línea: REFERENCIAS_DE_ARCHIVO). Para rotar el secreto postal hace falta EMAIL_ENCRYPTION_KEY (sin ella 503). Publicar un radicado ya NO publica sus anexos: marcarlos con anexos_publicables. Los tests de integración de otros servicios que siembren radicados con nivel_seguridad ≥ 2 por SQL crudo deben llevar fundamento. Verificado en la pila viva (tenant demo, migraciones 040–044 aplicadas, document-service y frontend desplegados): POST /documents E sin destino / sin tipo / S sin origen → 422, completos → 201; lote sin códigos → 422 y con una dependencia inexistente → ítem failed (La dependencia 999 no existe o está inactiva); el índice trae dependencia y registrado_por y un UPDATE a nivel 3 sin fundamento viola el CHECK; PATCH …/security-level deja la fila del outbox enviada y el evento en el stream, y una fila puesta de nuevo en pendiente fue reenviada por el relé del barrido (mismo event_id); ventanilla (habilitada solo durante la prueba y devuelta a su estado): estado false→true y el script sale 1→0, sin autorización 422, honeypot 422, la misma Idempotency-Key dos veces (y tres peticiones simultáneas) → un solo radicado y un solo consecutivo, otro contenido con la misma clave 409, la constancia idéntica en metadata y en el asiento, y lo mismo a través del proxy del frontend; publicación: el radicado de ventanilla 404, un anexo ajeno 422 sin cambios, un anexo propio marca solo ese y retirar los desmarca; rotación postal con rol crud 5 (devuelto a 3 después): S1→S2 con gracia, callback firmado con S1 applied, con S2 y con un secreto ajeno 401, con la ranura anterior caducada 401, el secreto nunca en claro en la fila ni en el asiento; entregado sin acuse 422 y con acuse (subido a storage) 200 con huella en la fila y en el asiento; PATCH de borrador: solo el asunto no emite, el texto sí (documento.contenido_cambiado en el stream); radicar borrador sin plantilla 422 con campos:[doc_class] y con {tipo_documental} 201 (origen 901 derivado de la URD, destino 902 por nombre). La casilla y el honeypot salen en /radicacion. Incidente de disco durante el despliegue: la reconstrucción del frontend (se había purgado la caché de npm install) y los 500 MB de WAL de las corridas de integración llevaron /var a 0 y Postgres cayó con PANIC por disco lleno; se liberó espacio (WAL con pg_switch_wal+CHECKPOINT, max_wal_size bajado con ALTER SYSTEM a 64 MB y min_wal_size 32 MB —restablecerlos con ALTER SYSTEM RESET—, imagen python:3.12-slim sin uso y las imágenes latest sin contenedor de tenant, notification, workflow, signature y auth, todas reconstruibles) y se levantó orpycamcp-postgres (recuperación sin pérdida).
  • fix(document,knowledge): T13, remediación de auditoría: el relé del outbox reenviaba una reclasificación vieja con su nivel original, el acuse de entrega se asentaba sin cotejarlo con el almacén y el backfill de la 041 no dejaba asiento (AREA-09-05, 09-10 y 09-04). Por qué: (1) retransmitir reenviaba cualquier pendiente con más de 60 s sin mirar si después había salido otra reclasificación del mismo radicado; knowledge fija el nivel EXACTO, así que una A (3→1) tardía tras una B (1→3) bajaba el fragmento y lo servía a quien no debía (el comentario «un reenvío tardío es inocuo» era falso para un evento reordenado). (2) acuse_file_id/acuse_sha256 solo se validaban en su forma: un UUID inventado marcaba el envío «entregado» y quedaba citado como prueba. (3) El UPDATE de la 041 cambia el campo del índice de la Ley 1712 art. 20 sin traza (invariante 6). Qué cambia: el relé publica el nivel_nuevo VIGENTE leído de radicados.nivel_seguridad en la misma transacción (outbox._con_nivel_vivo; el contrato del evento no cambia). POST /envios/{id}/estado coteja el acuse con storage (/internal/files/hashes, solo conoce los ficheros del tenant) antes de la transacción: 422 acuse_invalido si el fichero no existe o la huella no coincide, 503 acuse_no_verificable si storage no responde, y se asienta la huella que devuelve storage. El runner de migraciones (asentar_backfill_fundamento) deja un asiento document.clasificacion_fundamento_backfill (actor system:migration-041, de: null, a: <texto>) por cada fila rellenada por la 041, idempotente y válido también donde la 041 ya corrió; el asiento va en el runner y no en SQL porque la cadena de hashes la calcula la aplicación. Corregido el comentario de knowledge-service/app/services/ingestion.py. No se hizo y por qué: (a) comparar occurred_at en knowledge como segunda red (exige columna y migración en otro servicio; el origen ya no publica niveles rancios). (b) exigir que el acuse lo haya subido quien marca el envío y rechazar un file_id ya usado como anexo: storage no expone ese dato por fichero y la referencia cruzada vive en otro servicio; queda como mejora. (c) las filas rellenadas por la 041 exigen que la entidad las motive: el texto «PENDIENTE DE MOTIVAR» aparece tal cual en el índice. Verificación: document-service 979 passed con RUN_INTEGRATION=1 (Postgres real; 4 unitarias y 4 de integración nuevas) y mutaciones que caen: quitar _con_nivel_vivo, saltar la verificación del acuse, quitar el NOT EXISTS o el filtro nivel >= 2 del backfill; shared/tests 255 passed; ruff y mypy limpios en document-service y knowledge-service.
  • fix(archive,storage)!: T12 — cumplimiento archivístico: el índice se firmaba sin validar su XSD, una transferencia se preparaba con el índice sin firmar y su acta no decía qué firma amparaba, el fondo acumulado y las firmas personales del acta no tenían Object-Lock, la réplica del AIP no se releía y la deduplicación de anexos revelaba los reservados (AREA-07-01/03/04/05/06/08/09/11/14/15, 06-04/07, 08-02/04/05/09/15; 07-10 solo el WORM técnico). Por qué: (1) index_builder construía el XML y ningún módulo lo validaba contra indice-v4.xsd (que se declaraba «no un gate de runtime»): un índice con un hijo fuera de orden se sellaba XAdES-T y se protegía bajo WORM, irreversible; la reconciliación, además, firma por su cuenta sin pasar por IndexService. (2) recibir exigía el índice firmado y create no: el defecto avanzaba hasta la última fase; el acta pinneaba solo el NÚMERO de versión del índice, no la firma; las actas anteriores a la migración 052 se sellaron con la cabecera vacía y nada lo declaraba. (3) El fixity de la recepción vivía solo en el payload de un asiento; el acta y las firmas personales conservaban UPDATE/DELETE para el rol de la app (solo las protegía un trigger) y transferencias.objeto era obligatorio solo en la entrada. (4) Los seis resolutores WORM devolvían None para fondo_acumulado (correcto como fail-closed, permanente como estado); las firmas personales del acta no entraban al almacén WORM. (5) La réplica del AIP se daba por buena porque put/copy no fallaron —ni retención ni bytes se releían— y su motivo de fallo era str(exc) (host, bucket, request-id) hacia el evento PREMIS y el audit_log; looks_like_pdf miraba solo el offset 0; el guard de arranque no miraba un veraPDF pedido sin binario. (6) La deduplicación por SHA-256 devolvía la fila de OTRO usuario —su id y su filename— con un 200 en vez de un 201: un oráculo de existencia sobre anexos reservados. (7) Los eventos de preservación del expediente omitían los de ficheros y AIP; un AIP agotado no tenía camino de vuelta; PREMIS declaraba el formato solo por MIME; el circuito WORM nunca se había recorrido por la aplicación contra un almacén real; retention_years/total_retention y las dos fases podían quedar en desacuerdo. Qué cambia: archive — app/services/index_xsd.py (lxml, esquemas por espacio de nombres, cargados una vez) validado en _current_signable_or_none y en _reconciliar_indice (motivo xsd_invalido, sin firmar), aviso por correo al agotarse el sellado (_avisar_indice_agotado, mismos permisos que el reintento y clearance del expediente); create exige índice firmado (409 indice_no_firmado), _validar_ubicaciones dentro de la tx con FOR SHARE y 422 ubicacion_sin_tenencia, FK en la carrera → 422 con asiento; migración 053 (indice_firma_id/indice_sha256 en el acta y en el XML que se hashea; recibir lee la fila de la versión que usó la fixity) y cabecera_completa derivado al leer; 054 (REVOKE UPDATE/DELETE sobre transferencia_acta y firmas personales, guardado por existencia de rol), 055 (CHECK de objeto, con el histórico exento porque un NOT VALID también se evalúa en cada UPDATE), 024 con DO-block que aborta con mensaje ante duplicados; 056 (detalle del fixity + backoff: evento PREMIS FIJACION post-commit con dedup_key, fase run_once_fixity_premis), 057 (tabla de bookkeeping WORM por firma personal, backfill, fase run_once_firmas_personales_worm), 058 (marca origen_completado_at y job completar_origen_legado: solo columnas NULL y solo expedientes abiertos); retention_math.base_worm (fecha extrema final + clamp, WORM_FONDO_ACUMULADO_MARGEN_DIAS=400) usado por los seis resolutores; POST /expedientes/{id}/aip/reintentar (estado y clearance en el WHERE, asiento en la misma tx); eventos de preservación por expediente + AIP + file_id de anexos vigentes visibles (503 si document-service cae); PATCH /trd/{id} acepta las dos fases y el juego legado se deriva al leer y se reescribe al editar; lxml en requirements.txt y .lock. storage — la deduplicación es solo del mismo autor (find_by_sha256(sha256, uploaded_by) + comparación en el servicio); réplica con relectura de retención y SHA-256, motivo saneado y secundario igual al primario → local; guard de arranque para veraPDF sin binario y secundario duplicado; looks_like_pdf con ventana de 1 KiB (compartida con el AIP); formatRegistry PRONOM declarado (pronom.py); migración 018 (tipos acta_firma_elabora|entrega|recibe con índice único parcial por tipo, instrumentos de control); POST /preservacion/eventos-internal (idempotente) y GET /eventos con object_ref repetido; tests/integration/test_worm_circuito_real.py (Postgres + RustFS reales). No se hizo y por qué: (a) AREA-07-10, el acto firma_inobtenible (rol admin con fundamento obligatorio que deje constancia sin fingir la firma): decisión de dominio. (b) AREA-06-07, la jerarquía de dos niveles/reclasificación masiva: decisión de usuario; solo se hizo el mínimo (juego legado derivado y PATCH de las fases). (c) AREA-08-05, preservationLevel y rights: el plan no tiene un valor de nivel de preservación y rights depende de AREA-08-01, fuera de la tanda. (d) Consumidor de notification para «índice agotado»: se envía el correo directo con el cliente de notification que ya usa la alerta de disposición, en vez de un evento a un stream que nadie consume. (e) La renovación de retención CT de las firmas personales (hoy solo la protección inicial). (f) Desviación de la propuesta de AREA-07-03/04: el código del 409 de create es indice_no_firmado (el mismo que ya usa recibir), no indice_sin_firmar. (g) Desviación de la D4.4 del ADR-026: se fija un clamp de ~400 días en vez de «no fijar retención» cuando el calendario del fondo ya venció; es un COMPLIANCE irreversible que el usuario debe confirmar. Verificación: storage 444 passed/63 skipped (50 con RUN_INTEGRATION=1 RUN_S3_WORM=1, Postgres y RustFS reales: la dedup por autor sobre la base, los tres acta_firma_* con su índice único parcial, el evento interno idempotente y el circuito proteger→releer→renovar→purga rechazada), archive 1039 passed/252 skipped (228 con RUN_INTEGRATION=1: gate del índice, par sin tenencia, acta que ata la firma, CHECK de objeto con el histórico operable, SET ROLE orpycamcp_app sin UPDATE/DELETE sobre el acta, reintento del AIP con clearance y estado en el WHERE, índice que no valida sin sellar, backfill sin pisar valores ni tocar cerrados, fixity PREMIS y WORM de firma personal), shared/tests 255 passed; lint ruff+mypy verde en ambos. Mutaciones que caen (copia mutada montada :ro, sin tocar el árbol): storage — comparación de autor en la dedup (1) y el uploaded_by = $2 (1), relectura de bytes (1) y de retención (1) de la réplica, str(exc) como motivo (1), bool(endpoint) (1), startswith en looks_like_pdf (2), condición del guard de veraPDF (1), puid=None (2), dedup_key (1) y ANY (1) del evento interno, tipo acta_firma_recibe fuera del schema (1) y, en el circuito real, saltar ensure_object_lock_bucket y verificar_retencion_aplicada (2 de integración); archive — quitar el gate del índice en create (1 integración + 4 unitarias), indice_firma_id: None (1), quitar AND estado = 'agotado' (2), CHECK de objeto sin eximir al histórico → sin CHECK (1), quitar el REVOKE (1: llega el RaiseError del trigger), quitar assertValid (varias), base closed_at/sin clamp en base_worm (2 grupos de 3+3), FOR SHARE (1), par sin tenencia (varias), captura de la FK (1), registro del evento PREMIS (1), bookkeeping de la firma personal (1), mapper del juego legado (1), aviso de agotado (2), object_refs solo del expediente (1), COALESCE del backfill (1), cabecera_completa fija (4). Pila viva (preservación activada de forma temporal con un override fuera del repo y restaurada después): nada de lo anterior se dio por bueno solo con pruebas — con tokens de jefe/archivista/operador/consulta del tenant demo: dedup por autor (201/201/200), 409→201 del gate, 422 ubicacion_sin_tenencia, acta con indice_firma_id dentro del XML y cabecera_completa=false en las tres actas anteriores, evento FIJACION fixity check en /preservacion/eventos, POST /aip/reintentar (200 operador, 404 neutro dos veces, 403 consulta), índice con XML roto → pendiente_firma/xsd_invalido y reintento manual tras restaurarlo → firmado, índice real firmado por signature-service tras el gate, PATCH de archivo_gestion_years (total 12 derivado) y 409 sobre una versión convalidada, y un fondo acumulado de 1985 (calendario vencido en 1990) cuyo índice quedó bajo COMPLIANCE hasta hoy + 400 días (2027-11-07, exacto), no 1990 ni closed_at + 5; el backfill completó 23 vínculos de expedientes abiertos y dejó los cerrados intactos; la cadena de auditoría del tenant verifica íntegra. Impacto: reconstruir storage-service y archive-service (este añade lxml; vigilar el disco). Migraciones, en este orden: storage tenant 018 y luego archive tenant 053–058 (python migrations/runner.py de cada uno). Censo previo de la pila local (tenant demo): 0 expedientes cerrados con el índice sin firmar y 0 transferencias activas, así que el gate de create no detiene nada en curso; antes de desplegar en una entidad real hay que repetirlo (closed con índice ≠ firmado, y preparada/enviada). La migración 024 (solo para tenants nuevos o sin el índice) aborta con un mensaje si hay transferencias activas duplicadas. El rol de la app (orpycamcp_app, cuando existe) pierde UPDATE/DELETE sobre el acta. Remediación de la auditoría de T12 (2026-10-03, archive): (1) un fondo acumulado ya no admite fecha_extrema_final futura (422, validar_forma_fondo_acumulado: era la única base de Object-Lock COMPLIANCE que fija el cliente, y una de 2120 bloqueaba el AIP ~100 años) y retention_math.base_worm devuelve None (fail-closed, se pospone) si la base es futura. (2) Una recepción cuya fixity discrepa registra ahora el evento PREMIS FIJACION con resultado="fallo" (post-rollback, best-effort, dedup_key fixity-recepcion-fallo:{transferencia}:{sello}, solo conteos, sin UUID de radicados reservados); y una fixity con documentos sin huella declara detalle.verificacion="parcial" ("completa" si no). (3) El asiento archive.vinculo_origen_completado incluye valores (solo lo escrito), fuente y resuelto_at, y completar_origen_vinculo toma FOR SHARE OF e sobre el expediente para serializarse con el cierre (la carrera concurrente real no se probó: solo la sintaxis contra Postgres y la presencia del bloqueo).
  • fix(frontend,storage,gateway,document,archive): T11 — guardas del BFF, cookies y espejo de RBAC (AREA-12-01/03/04/05/06/07). Por qué: (1) la guarda de sesión de los proxies BFF se probaba en una muestra a mano (21 imports) y nada impedía que el próximo +server.js naciera sin ella; los flags de la cookie del token (httpOnly/sameSite/secure) no los afirmaba ningún test (hooks.server.test.js mockeaba setSessionCookies); (2) /403 existía y nada redirigía a ella: forzar /admin/usuarios sin permiso dibujaba una pantalla de 403 por XHR; (3) la pestaña Preservación decía «sin AIP, o sin visibilidad» con PRESERVATION_ENABLED=false (un 404 neutro que hacía pasar por normal que nada se empaqueta); (4) una plantilla de metadatos con una propiedad array/object obligatoria hacía imposible crear el expediente/radicado desde la UI (el campo no existía) y el 422 no nombraba la propiedad; (5) layout.scss repetía a mano el color del velo y escapaba al guardián, que solo leía .svelte; (6) la cifra «36 endpoints, la UI consume 6» de plan-pruebas.md era falsa (30 endpoints, desfase cerrado en agosto) y envejecía en silencio. Qué cambia: frontend — todo-proxy-exige-sesion.test.js (censo con import.meta.glob de los 219 +server.js de (app), 290 verbos, invocados con locals={} y body/red/cookies que lanzan, esperando 401; anti-vacuidad >200); auth-cookies.test.js (flags en dev=true/false, PKCE 300 s, tope de 600 s); admin/+layout.server.js + lib/utils/adminGate.js (permiso REAL por área —TRD→USUA_PERM_TRD, terceros→PERM_RADI, colas→PERM_DLQ_ADMIN, interoperabilidad/preservación→USUA_PERM_EXPEDIENTE—, fail-closed USUA_PERM_ADMIN para áreas no declaradas, no expulsa con /auth/me degradado; un test cruza el mapa con las tarjetas de /admin y con las carpetas); SchemaField con array de escalares (lista repetible) y object de un nivel, valores como JSON de crudos tipado por coerceSchemaValue, campo oculto meta_<clave> y controles interiores sin name (FormField name={null}), ubicarErrorDeCampo y campoError para marcar el control desde field (expedientes y radicar); tokens --op-on-brand-* y layout.scss al --op-scrim, guardián extendido a .scss; pinarProxy.server.js gana lectura() y tres puertas (PATCH planes/[id], GET planes/[id]/mapa-ruta, GET proyectos/[id]/seguimiento) con pinar-paridad.test.js (backend↔BFF derivado de pinar.py); proxy expedientes/api/preservacion-estado y estado en la pestaña; e2e rbac-restringido.spec.js (usuario consulta, añadido a realm-dev.json) y axe-vistas-clave.spec.js (@axe-core/playwright, falla en serious/critical). storage — GET /api/v1/preservacion/estado → {enabled} (dato de instancia, sin consulta por expediente, gate USUA_PERM_EXPEDIENTE). api-gateway — subruta /preservacion/estado admitida. document/archive — MetadataValidationError.field (ruta de absolute_path, más la propiedad ausente en los required) y field en el sobre del 422 metadata_validation_failed. No se hizo y por qué: migrar las vistas con metadata inline (expedientes/[id], bandeja) a SchemaField, arrays de objetos y object anidados (alcance: requieren un editor de tablas); T-18 branding por tenant y presupuesto Lighthouse/bundle (decisión de alcance del producto); ejecutar los e2e nuevos en CI. Verificación: ver el resumen de la tanda; mutaciones: quitar el guard de pinarProxy.server.js (14 casos del censo), httpOnly/secure/sameSite en auth.server.js (4/2/4 casos), invertir el permiso del layout (8), borrar el proxy tablero (paridad), enabled invertido en la pestaña (2), array → null (11), field fuera del sobre (document y archive). Impacto: reconstruir y desplegar storage-service, api-gateway, document-service, archive-service y frontend (sin migraciones). Instalar @axe-core/playwright (npm ci) para correr los e2e.
  • fix(knowledge,mcp,gateway,shared,infra)!: lo que el asistente confirma se asentaba como canal api y no había forma de atarlo a su turno ni a su propuesta; el asistente no comprobaba que los radicados del texto existieran; todas sus llamadas compartían el cubo por IP de un solo contenedor; ollama_local era «soberano» por su nombre aunque OLLAMA_URL fuera pública; las sugerencias del RAG no tenían aceptación humana; y «opt-in por tenant» era una declaración falsa Por qué: (1) todo lo que el mcp-server hace en nombre de una persona entra al gateway por /api/v1/<dominio>, y derive_canal solo conocía mcp por la RUTA: un radicado creado desde la tarjeta quedaba api, igual que una llamada directa; y aun con el gateway diciendo mcp, audit.append tenía canal="api" por defecto y document-service no lo pasaba, así que el canal se perdía un salto después (AREA-11-04). (2) «no inventes números» era una línea del prompt: nada comprobaba el texto final (AREA-11-04). (3) mcp-server no reenviaba la IP del cliente: las llamadas del asistente de TODAS las entidades caían en el cubo por IP de su contenedor, el problema del NAT de 9c2cc6a un salto más adentro; y el costo de un turno no se medía (AREA-11-08). (4) is_generatable decidía por el NOMBRE ollama_local; con OLLAMA_URL pública, /rag enviaba chunks nivel 2-3 a ese host y solo quedaba un warning (AREA-11-12). (5) kb_suggestions.aceptada_por/aceptada_en existían y ningún endpoint los escribía (AREA-11-11, CC-02). (6) main.py, la guía y ADR-006 decían «opt-in por tenant»: no existe, la ingesta es global (OMI-04). Qué cambia: shared identidad.firmar_origen/verificar_origen (marca X-Origen-Canal: mcp.<ts>.<hmac> ligada al tenant y al sha256 del Authorization, vigencia 120 s, sin secreto no se emite ni se verifica) e ip_real_firmada (X-Real-Ip solo si la identidad que la acompaña verifica); request_log captura X-Canal (solo api|ui|mcp) e Idempotency-Key en ContextVars; audit.append/append_denial toman canal de la petición cuando no se pasa y, solo en canal mcp, añaden payload.correlacion = {request_id, idempotency_key}. api-gateway derive_canal(path, origen, tenant=, authorization=): mcp por ruta o por marca válida, api en cualquier otro caso (la marca no se reenvía); reenvía Idempotency-Key; enruta /api/v1/knowledge/suggestions/. mcp-server services/origen.py::cabeceras_de_origen es el único sitio que arma las cabeceras salientes (despacho, /auth/me, marco de alcance): Authorization, X-Tenant-Slug, la marca, X-Request-Id y X-Forwarded-For con la IP firmada por el gateway; ContextoEntranteMiddleware + RequestLogMiddleware; el jti de la propuesta viaja como Idempotency-Key (en un procedimiento, <jti>:<n> por paso); run_turn devuelve no_verificados (todo {YYYY}-{TENANT}-{E|S|I}-{NNNNNN} del texto ausente de los tool_result; lo que tecleó la persona no cuenta) y añade el aviso «No verificado», y registra iteraciones/llamadas_gateway/propuestas/no_verificados por turno. knowledge url_es_local + is_local_llm_provider/is_generatable(…, ollama_url) (host local = loopback, privado, contenedor o *.local; con un host público el RAG excluye nivel >= 2); migración 012_kb_suggestions_creada_por.sql; POST /api/v1/knowledge/suggestions/{id}/aceptar (solo quien la generó, 404 neutro, 409 si ya aceptada, asiento rag.aceptada atómico). Documentación: se retira la promesa «opt-in por tenant» (main.py, integraciones-externas ES/EN, ADR-006) y se corrige el embedding «stub» de la guía a local_st; el docstring de mcp-server/health.py decía que no instalaba la librería compartida (la instala). docker-compose*.yml: IDENTITY_SIGNING_SECRET en mcp-server (obligatorio en producción). No se hizo y por qué: (a) la propagación de la reclasificación de expedientes al índice (AREA-11-11) no aplica: ExpedienteUpdate no admite nivel_seguridad, ningún UPDATE expedientes lo toca y archive-service no publica eventos —no hay productor que consumir—; shared/tests/test_el_nivel_del_expediente_no_cambia_en_silencio.py deja la premisa falsable. (b) OMI-04 sin implementar el parámetro por entidad: fijar knowledge.habilitado con default false apagaría la ingesta de las entidades actuales; es decisión de producto. (c) El nombre de consumidor por réplica ya lo cerró T9. (d) Streaming del RAG, diferido. Verificación: knowledge 259 passed/55 skipped (314 con RUN_INTEGRATION=1, Postgres real: aceptación atómica y con propiedad en el WHERE, barrera con OLLAMA_URL pública sobre knowledge_chunk real), mcp-server 260 passed, api-gateway 126 passed, shared/tests 249 passed, document-service 682 passed/196 skipped (196 passed con RUN_INTEGRATION=1); lint ruff+mypy verde en knowledge, mcp-server y api-gateway. Mutaciones que caen: is_local_llm_provider por nombre (9 unitarias + 1 de integración), quitar creada_por del WHERE (2 de integración), asiento fuera de la transacción de aceptación (1), verificar_origen sin comprobar la firma (4), idempotency_key sin enviar (1), X-Forwarded-For sin reenviar (1), validación de radicados anulada (3), audit.append sin correlación (1) y un UPDATE expedientes SET nivel_seguridad en archive (1). Contra la pila viva: un radicado confirmado desde POST /mcp/proposals/execute queda con canal=mcp y correlacion.{request_id, idempotency_key=jti de la propuesta}; una llamada directa, y una con X-Origen-Canal falsa, quedan api; el cubo por IP de las llamadas internas del asistente es el de la IP del cliente (X-Forwarded-For: 203.0.113.50 en el borde) y no el del contenedor; POST /knowledge/suggestions/{id}/aceptar: ajena 404, propia 200, repetida 409, con su asiento. Impacto: aplicar la migración de knowledge 012 (python migrations/runner.py) y desplegar knowledge, api-gateway y mcp-server; el canal mcp solo aparece en los servicios reconstruidos con la librería compartida nueva (document-service ya está; archive, workflow, signature, notification y el resto lo recogerán en su próxima reconstrucción, sin migración). En producción IDENTITY_SIGNING_SECRET pasa a ser obligatorio también para mcp-server. Los asientos de canal mcp llevan ahora payload.correlacion. Pendiente: la verificación de radicados inventados no se pudo ejercitar contra el modelo real (la pila local no tiene ANTHROPIC_API_KEY); la cubren las pruebas con modelo simulado.
  • fix(shared,mcp,gateway,knowledge,infra)!: el mcp-server, el servicio más expuesto, poseía el secreto MAESTRO de identidad; y el asiento de aceptación del RAG no fijaba QUÉ se aceptó (cierre de hallazgos de auditoría de la tanda T10, AREA-11-04/08 y AREA-11-11). Por qué: (1) IDENTITY_SIGNING_SECRET firma X-User-Id/X-Tenant-Slug y todos los servicios confían en esa firma; al dárselo al mcp-server (que corre el loop LLM, habla con un proveedor externo y atiende clientes MCP) un compromiso suyo bastaba para suplantar a cualquier usuario de cualquier entidad llamando directo a un servicio interno. (2) kb_suggestions es mutable y está fuera de la cadena hash: el asiento rag.aceptada solo llevaba aceptada_en, así que el registro inmutable no podía probar después que el texto aceptado es el que hoy está en la fila. Qué cambia: secreto dedicado MCP_ORIGIN_SECRET, solo en api-gateway y mcp-server, y IDENTITY_SIGNING_SECRET retirado de mcp-server en ambos compose (obligatorio en producción; infra/.env.example). identidad.firmar_origen/verificar_origen lo usan; el gateway firma además X-Real-Ip en X-Real-Ip-Signature (firmar_ip_real, ligada a IP + persona + entidad) y ip_real_firmada verifica esa firma en vez de exigir la identidad completa. Con solo MCP_ORIGIN_SECRET se puede marcar el canal y verificar la IP, pero no firmar una identidad. rag.aceptada lleva ahora sha256_texto (de la respuesta sugerida), proveedor, modelo y fuentes (los source_ref citados, sin su contenido: Ley 1712), calculados de la fila bloqueada con get_for_update en la misma transacción. No se hizo y por qué: OMI-04 (opt-in knowledge.habilitado por entidad) sigue abierto: implementarlo cambia el comportamiento visible (con default false apaga la ingesta en las entidades actuales) y falta la decisión del usuario sobre ese default; mientras tanto la promesa está retirada de la documentación y la ingesta es global. Cuando se implemente: el consumidor lo consulta antes de embeber y /search, /antecedentes y /rag responden 503 neutro, con mutación que se salte la consulta. Verificación: knowledge 262 passed/56 skipped (318 con RUN_INTEGRATION=1, Postgres real), mcp-server 264, api-gateway 128, shared/tests 255; ruff+mypy verdes en knowledge, mcp-server y api-gateway. Mutaciones que caen: _secreto_origen leyendo el secreto maestro (1), ip_real_firmada sin comparar la firma (2), firma de IP sin ligar a persona/entidad (1), el gateway sin firmar_ip_real (1), mcp-server con el secreto maestro como secreto de origen (6), asiento sin _que_se_acepto (2) y huella de un texto fijo (2). Pila local: mcp-server arranca sin IDENTITY_SIGNING_SECRET y con MCP_ORIGIN_SECRET. No verificado contra la pila viva: el cubo por IP del asistente de extremo a extremo (requiere un token real de Keycloak); lo cubren las pruebas del gateway y del mcp-server. Impacto: definir MCP_ORIGIN_SECRET en el entorno de api-gateway y mcp-server antes de desplegar (en producción el compose aborta si falta; en dev hay valor por defecto) y reconstruir ambos. Sin él el canal cae a api y no se reenvía la IP (lado seguro). Los asientos rag.aceptada nuevos llevan los campos adicionales; los anteriores no se reescriben.
  • fix(workflow,signature,notification,knowledge,shared,gateway)!: la cola de eventos fallidos es una sola tabla para cuatro consumidores y su clave era solo event_id: el aparcamiento del segundo grupo se descartaba en silencio; knowledge no podía inspeccionar ni reprocesar la suya; el nombre de consumidor era fijo; el dead-letter huérfano iba sin causa; X-Truncated saltaba con exactamente 1000 filas; y el tamaño de la DLQ no se veía en ningún sitio Por qué: (1) evento_dead_letter (misma base y esquema para workflow, signature, notification y knowledge) tenía PRIMARY KEY (event_id) y ON CONFLICT (event_id) DO NOTHING: si un evento agotaba los reintentos en dos grupos, el segundo no dejaba fila ni asiento, su admin no lo veía y el mensaje ya estaba confirmado (AREA-10-07; lo declarado, «la deduplicación se pasa de largo», era falso: el defecto era el contrario). (2) knowledge solo tenía insert_if_new: un purged/annulled aparcado dejaba el fragmento buscable con su texto (AREA-10-10). (3) CONSUMER = "…-1" en los cuatro workers (AREA-10-06). (4) la rama huérfana de reclaim_stale aparcaba con last_error="" (AREA-10-09). (5) len(candidates) >= cap daba falso positivo (AREA-10-08). (6) el ReclaimStats se descartaba en los cuatro consumidores (AREA-13-12). Qué cambia: migraciones idénticas en los 4 servicios (workflow 017, signature 021, notification 010, knowledge 011, *_dead_letter_pk_por_grupo.sql): LOCK + comprobación en pg_constraint y PK (origin_group, event_id), idempotente y aplicable en cualquier orden; knowledge añade antes 010_dead_letter_estado_y_grupo.sql (origin_group NOT NULL, status, resolved_at, índices) y se corrige el comentario falso de 004 («instancia distinta»). Los 4 insert_if_new usan ON CONFLICT (origin_group, event_id). knowledge gana /api/v1/knowledge/admin/deadletter (listar, detalle, replay, discard; PERM_DLQ_ADMIN, escritura a min_crud=3, predicado tenant_slug + origin_group, no-read-up sobre el nivel vivo con 404 neutro, 422 unsupported_event_type; el replay corre apply_event en la MISMA transacción que la transición y el asiento) y el gateway lo enruta (/api/v1/knowledge/admin/). shared events.consumer_name(servicio) ({servicio}-{HOSTNAME}); record_failure guarda el último error por mensaje (HSET orpycamcp:{grupo}:lasterr, TTL 24 h, best-effort) y la rama huérfana lo recupera o escribe desconocido: sin intento en este barrido en vez de vacío; reclaim_stale(status_name=…) deja el último ReclaimStats y el acumulado en consumer_status(); health.consumer_report añade a /health/ready de los 4 servicios checks.dead_letter (degraded si la DLQ de Redis tiene entradas o el último barrido aparcó) y el objeto consumer (XLEN, contadores). Solo informa: no decide el 503. Los listados de workflow/signature/notification piden cap + 1 filas y marcan X-Truncated solo con más de cap. Verificación: workflow 355 passed/97 skipped (452 con RUN_INTEGRATION=1), signature 503/13 (514), notification 228/19 (247), knowledge 223/43 (266; antes 223/36: 7 de integración nuevos), api-gateway 112, shared/tests 226 (guardián nuevo test_dlq_compartida_y_consumidores.py: censo de CONSUMER literal, de todo ON CONFLICT sobre la tabla, de status_name en cada reclaim_stale); lint ruff+mypy verde en los 5. Cazó un defecto real: el fixture de integración de notification no aplicaba la restricción nueva y el ON CONFLICT fallaba con «no unique or exclusion constraint matching». Mutaciones que tumban pruebas (restaurado y reconstruido): ON CONFLICT (event_id) en un repositorio (censo + 2 de integración), PK solo por event_id en la migración (3), CONSUMER literal (censo), no escribir el HSET (1), no persistir el barrido (1), quitar status_name de un worker (1), sonda sin degradar (2 + 1 por servicio), >= en lugar de > (1), quitar origin_group del WHERE del listado de knowledge (1), quitar el gate de clearance del replay (1), quitar min_crud=3 (1), quitar void_radicado del despacho (1). En vivo (pila local, tenant demo): migraciones aplicadas en los 4 servicios (PK compuesta confirmada en tenant_demo; también en ambos órdenes sobre un esquema desechable); jefe lista la DLQ de los 4 servicios (200) y consulta recibe 403 en los 4; un mismo event_id aparcado por knowledge y por workflow aparece solo en el listado de su grupo y el replay de knowledge resuelve únicamente su fila (la de workflow sigue pending); replay de un created aparcado indexa el fragmento y el de un purged lo deja voided con texto vacío, con asiento evento.dead_letter.replayed; consulta no puede reprocesar; los consumidores ya se registran por réplica en Redis (workflow-service-<host>, etc.; los -1 antiguos quedan inactivos y sin pendientes) y /health/ready muestra dead_letter: ok y el último barrido. Filas y fragmento de prueba retirados. No hecho: llevar el clearance de la lista al WHERE y paginar por cursor (AREA-10-08, parte de fondo), y Prometheus/alertas (AREA-13-12, decisión de operación). Remediación de auditoría (T9): el replay de knowledge ya no aplica el sobre archivado a ciegas: antes de apply_event ancla el reclassified al nivel VIVO de radicados (y created a max(evento, vivo)); sin radicado vivo responde 409 stale_event y el evento sigue pendiente (un reclassified(1) aparcado ya no puede bajar un chunk que volvió a nivel 3 y exponerlo a clearance 1). Knowledge 233/48 (281 con RUN_INTEGRATION=1), lint verde; mutación (quitar el ancla) tumba 4 pruebas.
  • fix(signature,auth,frontend)!: el segundo factor y la credencial se verificaban contra el dueño del bearer mientras la firma se atribuía a X-User-Id; quien tenía TOTP bajaba al correo en actos de nivel ≥ 2; el cliente elegía la huella de radicado/expediente; un único secreto servía a todas las entidades y a dos usos; y ninguna firma nativa de /sign-xml verificó jamás (T8 de la auditoría del 2026-10-02: AREA-04-05, -02, -09, -11, -12, -06, -10, -13, -08 y AREA-05-02, -07). Por qué: (1) OTP→correo del bearer, TOTP→dispositivo del bearer, credencial→clave del bearer, y la firma a nombre de X-User-Id: el comentario lo llamaba «fail-safe» y era al revés —quien tiene el bearer recibe el código y completa él mismo la firma de otro—; el gateway lo impide desde fuera, no un servicio interno comprometido. (2) el actor con TOTP enrolado en un acto de nivel ≥ 2 podía firmar con el OTP del correo (bastaba controlar el buzón); la clave totp_requerido_por_nivel estaba «reservada» y nadie la leía. (3) en radicado/expediente la huella la ponía quien abría la cadena, y sin huella el firmante elegía los bytes al firmar mientras el desafío 2FA se había atado al digest del identificador. (4) un signature_secret único para el HMAC de la firma y el del OTP, igual para todas las entidades, con un mensaje que no nombraba entidad ni objeto. (5) con FIRMA_PERSONAL_REQUIRE_XADES=off un canal de credenciales caído degradaba a nativa sin dejar rastro. (6) la llamada HTTP a auth-service corría con el row-lock del turno. (7) /verify se documentaba «público» y cualquier usuario de la entidad distinguía 404 de 200 sobre actos reservados. (8) una revocación se evaluaba contra el estado actual, no contra el instante de la firma. (9) el sello de tiempo no se cruzaba con el certificado firmante ni con el ESSCertID. (10) acreditado era un literal false en verificación. (11) payload_sha256 no decía que no es el hash del documento. Y una avería hallada al probar en vivo: /sign-xml por el camino nativo persistía un now() distinto del instante que quedó dentro del HMAC, así que /verify recomputaba con otro instante y ninguna firma nativa de ese camino verificó jamás (en la base de demostración no había una sola; el tier real tampoco las veía porque no verificaba). Qué cambia: signature — get_signer_identity (reemplaza get_signer_email: manda X-Tenant-Slug a /me y devuelve user_id) y comparación con el actor antes de crear el desafío; 403 identidad_incoherente + asiento firma.identidad_incoherente (en emitir_challenge el asiento se escribe en su propia transacción porque la de emisión se revierte; en firmar/TOTP/credencial//sign-personal se devuelve-y-asienta). _veto_factor_correo: nivel ≥ 2 y TOTP activo → 409 totp_requerido (emisión y _gate_2fa, antes de tocar el desafío), fail-closed 503, y el estado de OTRO bearer no vale. crear ignora el payload_sha256 del cliente para radicado/expediente (NULL, se firma el identificador) y /sign rechaza payload_xml donde la cadena no fija huella (422 payload_xml_no_admitido). Claves HKDF por (propósito, entidad) en core/secret_derivation.py; mensaje de la firma = JSON [v1, tenant, tipo, objeto, sha, firmante, sello]; el HMAC del OTP ata challenge_id; migración 020_key_version_hkdf.sql (firma.key_version y firma_challenge.key_version, DEFAULT 0 = esquema anterior, CHECK 0/1; lo nuevo escribe 1 explícito). El estado de la credencial personal se resuelve antes del async with transaction(). Asiento firma.fallback_nativa_canal_caido (misma transacción que la firma) y aviso al arrancar con el gate en off. /verify y GET /firmas/{id} aplican el no-read-up en el WHERE (NOT EXISTS sobre radicados/expedientes; sin X-User-Id —archive-service— no aplica) y la nativa verifica con la key_version de la fila; acreditado se deriva de nivel_conformidad ∈ xades_{b,lt,lta}_acreditado y solo si la cripto valida (VerifyOutcome ya no lleva acreditado); revocación POSTERIOR al instante de la firma (sello TSA verificado o sello_tiempo) no invalida y devuelve revocacion_posterior=true; verify_timestamp_token(signer_cert=…) exige genTime dentro de la vigencia del firmante y comprueba el ESSCertID (signing-certificate-v2; la TSA local ahora lo emite; ausencia solo se rechaza con TSA_ESSCERTID_OBLIGATORIO=true). /sign-xml persiste el instante que quedó dentro del HMAC. auth — GET /internal/totp/status, user_id en el veredicto válido de /internal/totp/verify y en /internal/signing-key/status; se elimina totp_requerido_por_nivel. frontend — el asistente pasa a pedir la app si el reto por correo responde totp_requerido. docs — api.md ES/EN (incluida la advertencia de payload_sha256, con guardián en shared/tests/), ADR-016, auditoría de seguridad, roadmap. Verificación: signature-service 480 passed/10 skipped (422/4 antes) y 8 passed con RUN_INTEGRATION=1 (Postgres real: la migración 020, el DEFAULT 0/CHECK y el gate no-read-up contra radicados/expedientes reales; cazó un defecto de verdad: sin ninguna de las dos tablas el $2 sobraba); auth-service 287 passed/43 skipped; guardianes shared/tests 213 passed; lint ruff+mypy verde en ambos; frontend SignAssistant.test.js 13 passed. Mutaciones que tumban pruebas (restaurado y reconstruido): quitar la comparación de identidad (OTP, TOTP, credencial personal y /sign-personal, 4 a 4), veto en todos los niveles / estado sin consultar / fallo de status como fallback al correo, huella del cliente en crear, payload_xml admitido, clave raíz sin HKDF, mensaje sin entidad, OTP sin challenge_id, now() en vez del sello del HMAC, key_version fija en /verify, quitar el asiento de fallback, comparar la revocación con now(), revocado_at ausente como posterior, quitar el gate de /verify y de GET, quitar el cruce genTime/firmante y el cotejo ESSCertID, acreditado literal / sin valida AND, mover el status dentro del lock, y la rama del asistente. En vivo (pila local, tenant demo, tokens de operador/jefe/consulta): firma nativa de /sign-xml verifica (antes imposible) y una fila key_version=0 con el HMAC literal anterior también; consulta (clearance 1) recibe 404 firma_not_found idéntico al de un UUID inexistente sobre la firma de un radicado nivel 3 mientras operador la verifica; XAdES-T nuevo verifica con sello_tiempo_valido=true/acreditado=false y su token lleva ESSCertID (los 8 tokens del corpus anterior no: por eso el default tolerante); challenge/sign de nivel 2 con TOTP activo → 409 totp_requerido; cadena con payload_sha256 declarado queda en NULL y /sign con payload_xml da 422; el bearer de operador con X-User-Id de jefe (llamada interna firmada) → 403 identidad_incoherente sin desafío y con asiento. Impacto: desplegar auth-service ANTES que signature-service (sin user_id en sus respuestas signature falla cerrado: 403 identidad_incoherente); aplicar la migración 020 por tenant con el runner de signature-service (idempotente); reconstruir signature-service, auth-service y frontend. Contratos: /sign rechaza payload_xml donde la cadena no fija huella; /challenge y /sign pueden responder 409 totp_requerido en nivel ≥ 2 y 403 identidad_incoherente; /verify y GET /firmas/{id} dan 404 sobre objetos reservados al sin clearance y ya no son «públicos»; VerifyResponse gana revocacion_posterior; las firmas nativas antiguas siguen verificando (key_version=0). Los desafíos OTP en vuelo al migrar siguen valiendo (v0). Pendiente: activar FIRMA_PERSONAL_REQUIRE_XADES=clasificados (decisión de la entidad, exige enrolar certificados); HSM/KMS (externo); TSA_ESSCERTID_OBLIGATORIO=true cuando el corpus de dev se re-selle; /internal/totp/verify sigue sin X-Internal-Token; la prueba en vivo del camino de firma con OTP-correo no fue posible (el stack no trae mailhog: 503 canal_2fa_no_disponible al enviar).
  • fix(signature,auth,frontend): cierre de los hallazgos de los auditores sobre T8 (AREA-04-13): una revocación «posterior» se medía contra el reloj del propio servidor, y el motivo de revocación era texto libre Por qué: /verify conservaba valida=true si la clave se revocó después del sello_tiempo de la fila, que lo escribe el servidor y no es fecha cierta (RFC 3161; DUR 1074 cap. 47-48); y no distinguía un compromiso de clave (que pone en duda también lo firmado antes, ETSI EN 319 102-1) de una salida de personal. Qué cambia: auth — motivo_revocacion pasa a vocabulario cerrado (RFC 5280: key_compromise, superseded, cessation_of_operation, affiliation_changed, privilege_withdrawn, unspecified): 422 fuera de él en el self-revoke y el admin-revoke, migración tenant 019_motivo_revocacion_vocabulario.sql (los textos libres previos pasan a unspecified, el original queda en el asiento signing_key.revocada; CHECK idempotente) y GET /internal/signing-key/revocation-status devuelve también motivo_revocacion. signature — /verify toma como instante de la firma SOLO el genTime de una TSA verificada: con key_compromise → valida=false, motivo=clave_comprometida; sin TSA verificada → valida=false, motivo=instante_firma_no_cierto, revocacion_posterior=null (firma_criptograficamente_valida no cambia); con TSA y motivo distinto de compromiso se conserva valida=true+revocacion_posterior=true. frontend — el motivo es un <select> con el vocabulario (perfil y /admin/seguridad/claves); los proxies descartan lo que no pertenezca a él. Verificación: auth-service 292 passed/43 skipped, signature-service 499 passed/10 skipped (imágenes reconstruidas), lint ruff+mypy verde en ambos, migración 019 ejecutada dos veces sobre un Postgres desechable (idempotente; normaliza el texto libre; el CHECK rechaza un valor fuera). Mutaciones que tumban pruebas: quitar la rama key_compromise (1), volver a usar el sello_tiempo del servidor como instante (1), motivo de nuevo str libre en el esquema (1), no devolver motivo_revocacion en revocation-status (1). Impacto: desplegar auth-service (y aplicar la 019 por tenant con su runner) ANTES que signature-service y el frontend; los clientes que enviaban texto libre en motivo reciben 422. Las firmas personales sin TSA verificada que ya tenían una revocación pasan de «válida con revocación posterior» a valida=false. Declarado fuera: AREA-04-09 (cerrar la huella de radicado/expediente) exige que document-service y archive-service expongan por HTTP interno el SHA-256 del PDF radicado y del índice vigente, resolverlos al crear la cadena y re-leerlos al firmar: es un incremento de tres servicios; mientras tanto el asiento huella_de_contenido=false sigue declarando que se sella el identificador.
  • fix(tenant,infra,auth)!: tenant-service conectaba como propietario (superusuario) pese a estar enrutado por el gateway; el alta y la suspensión de entidades no dejaban asiento; renombrar una dependencia con trámite vivo orfanaba la bandeja; y el cambio de parámetros no asentaba el valor anterior (OMI-02, AREA-03-04, AREA-10-02, AREA-10-13, AREA-10-14; tanda T7) Por qué: (1) la excepción de la migración 008 («tenant-service sigue como propietario: no enrutado») era falsa —/dependencias, /catalogos, /config, /business-days y /pinar van a ese servicio— y con el rol propietario el solo-anexar de audit_log no lo frenaba. (2) POST/PATCH /tenants escribían en autocommit, sin asiento, y un CREATE SCHEMA fallido dejaba la fila huérfana. (3) PATCH /dependencias/{id} renombraba o desactivaba sin mirar flow_steps/workflow_rules, que correlacionan por NOMBRE. (4) PUT /config/params/{key} asentaba solo el valor nuevo. Qué cambia: tenant-service usa el rol de aplicación en dev y producción (DATABASE_URL con DB_APP_USER; MIGRATIONS_DATABASE_URL con el propietario solo para el runner). POST /tenants ya NO ejecuta DDL: registra la fila con provisioning_status='pending' y scripts/init_tenant.py (propietario) crea el esquema, aplica migraciones y la pasa a active. Alta y PATCH /tenants/{id} van en conn.transaction() con asiento tenant.created/tenant.updated (tenant_slug=__system__, canal=system, antes/después; fail-closed). PATCH /dependencias/{id} responde 409 dependencia_con_tramite_en_curso (con pasos_vivos y reglas) si cambia nombre o pone activo=false y hay pasos pending/in_progress o reglas por ese nombre. PUT /config/params/{key} asienta antes/despues/existia (lectura previa FOR UPDATE). Guardián shared/tests/test_servicio_enrutado_no_conecta_como_propietario.py (deriva los servicios de proxy.py). Comentario falso de 008 corregido. Verificación: tenant-service 129 passed/6 skipped y 6 passed con RUN_INTEGRATION=1 (Postgres real con un rol desechable itest_app_* sin privilegios de propietario: no puede UPDATE/DELETE sobre audit_log ni CREATE SCHEMA, y aun así hace el alta con asiento); mutaciones (quitar el asiento, sacarlo de la transacción, quitar el conteo, reintroducir CREATE SCHEMA, quitar el «antes») y ${DB_USER} en producción hacen caer sus pruebas. Impacto: reconstruir tenant-service; en producción exige DB_APP_USER/DB_APP_PASSWORD también para este servicio. El alta de entidades se hace SOLO con scripts/init_tenant.py. Los parámetros de numeración (formato_numero_radicado, consec_digits…) no los lee ningún servicio: cambiarlos hoy no tiene efecto.

Cierre de auditoría (T7): DELETE /dependencias/{id} aplica el mismo 409 dependencia_con_tramite_en_curso que el PATCH (fila bloqueada FOR UPDATE y conteo dentro de la misma transacción), y los asientos dependencia.actualizada/dependencia.eliminada guardan el nombre y estado anteriores (antes/despues). tenant-service 133 passed/7 skipped; mutaciones (quitar el gate del DELETE, quitar el antes) hacen caer sus pruebas. Declarado fuera: la correlación por nombre (en vez de código/id) sigue siendo deuda de diseño y requiere ADR. - fix(archive,document,mcp,frontend)!: una sola persona podía proponer, avalar y ejecutar la destrucción de un lote; el vínculo sellaba en el índice firmado lo que escribiera quien vinculaba; y se podía destruir sin el candado del acta (T6 de la auditoría del 2026-10-02: AREA-06-01, AREA-06-01-B, AREA-06-01-C, AREA-06-02, AREA-07-02, AREA-06-08 parcial, AREA-02-08, AREA-07-20) Por qué: (1) las siete fases del lote pedían USUA_PERM_TRD crud>=3 y ninguna capa comparaba actores: quien proponía avalaba y ejecutaba. (2) publicar/avalar/anular/marcar ejecutado hacían el UPDATE en autocommit y el asiento después (un fallo entre ambos dejaba el estado nuevo sin traza). (3) el GET público de un lote daba 500 con un slug inexistente y 404 con uno real: enumeraba qué entidades aloja la instalación. (4) el WORM del acta era de un solo intento (sin reintento programado ni consumidor del índice 050) y ejecutar no lo exigía. (5) link_radicado sellaba tracking_number, tipología, dependencia productora, formato, tamaño y huella tal como venían, sin comprobar siquiera que el radicado existiera. (6) performed_by (préstamos) y generado_por (índice) se escribían con X-Username, el login, que Keycloak permite renombrar. (7) la atomicidad del archivo físico no se probaba contra Postgres real. Qué cambia: archive — avalar y ejecutar piden crud>=4 (el resto de fases sigue en 3); separación de funciones: avalar lleva AND creado_por <> $5 en el WHERE de la transición y ejecutar comprueba avalado_por <> actor ANTES de destruir el primer expediente (409 separacion_de_funciones, con asiento archive.eliminacion_separacion_denegada); LoteEliminacionResponse.avalado_por; publicar/avalar/anular/marcar-ejecutado son UN acto con su asiento (conn.transaction()); GET /public/archive/eliminacion/{tenant}/lotes/{id} comprueba public.tenants (mismo 404 que un lote inexistente); WORM del acta: el fallo programa su reintento con backoff y marca worm_reconcile_agotado con asiento de sistema, nueva barrida run_once_actas_eliminacion_worm (mismo ciclo que el WORM del acta de transferencia), POST …/acta/firmar completa el candado de un acta ya firmada, y ejecutar exige worm_protegido_at (409 acta_sin_worm); vincular (POST …/radicados y …/batch) resuelve en el servidor número, tipología, dependencia productora, formato, tamaño y huella y coteja lo declarado: 404 radicado_no_encontrado, 422 metadato_no_coincide (nombra los campos, nunca el valor resuelto; asiento archive.radicado_vinculo_denegado), 503 document_service_no_disponible (fail-closed) — se persiste SIEMPRE lo resuelto; fisico.performed_by e indice.generado_por guardan el UUID de X-User-Id. document — POST /internal/documentos/metadata devuelve además tracking_number y doc_class. mcp — avalar_lote_eliminacion declara min_crud=4 (el guardián shared/tests/test_permisos_declarados.py lo cruza con el router). frontend — /admin/eliminacion ofrece avalar/ejecutar solo con nivel 4 y no al proponente/avalador; se corrigen los textos que decían que el borrado seguro «todavía no está construido». scripts/seed_demo.py — los grupos de gestión llevan USUA_PERM_TRD en 4. Sin migraciones. Verificación: archive-service 946 passed/237 skipped (unit) y 213 passed con RUN_INTEGRATION=1 (Postgres real; 193 antes); document-service 682/196 + integración de la metadata; mcp-server 243; shared/tests 199 (guardián nuevo test_el_actor_no_es_el_login.py); frontend 2292 (svelte-check 0 errores); lint verde en los tres servicios. Mutaciones que tumban pruebas: quitar creado_por <> $5 (2 unit + 1 integración), quitar la guarda de ejecutar (1), 4→3 en avalar/ejecutar (2), quitar _tenant_existe (1), quitar la guarda acta_sin_worm (1), excluir el acta del job (3), quitar el WORM del reintento (1), no programar el backoff (2), quitar el cotejo de tracking/dependencia/tipología (4), restaurar el fallback a X-Username (3 + el guardián), catálogo MCP 4→3 con el router en 4 (guardián), sacar el asiento de transaction() en las 4 transiciones (4) y en los 5 actos del archivo físico (6), quitar tracking_number/doc_class del mapeo de document-service (1). Contra la pila viva (tokens de operador, jefe, archivista, consulta): el proponente recibe 409 al avalar y jefe 200; el avalador recibe 409 al ejecutar y archivista destruye el lote (expediente y 2 radicados purgados, lápida, verify_chain íntegra); vincular con número/tipología/dependencia/huella ajenos da 422, con radicado inexistente 404 y con document-service caído 503; lo persistido sale del servidor sin que el cuerpo lo declare; préstamo y devolución guardan el UUID; el GET público da el mismo 404 para slug y lote inexistentes. Impacto: desplegar document-service ANTES que archive-service (sin tracking_number en la respuesta, vincular falla cerrado con 503). Quien tenía USUA_PERM_TRD en 3 deja de poder avalar y ejecutar: hay que dar nivel 4 al perfil del Comité en los tenants reales (y que sean al menos dos personas). Con PRESERVATION_ENABLED=false en storage (el valor de desarrollo) el acta nunca queda protegida y ejecutar responde 409 acta_sin_worm. Los clientes que declaraban tipología/formato/huella/dependencia al vincular reciben 422 si no coinciden con lo que guarda el sistema (el frontend y el MCP solo mandan radicado_id y tracking_number). Declarado fuera: AREA-01-13 (préstamo con prestado_a en UUID y gate de clearance: cambia el contrato y exige validación previa de archival-compliance-auditor) y la mitad de AREA-06-08 que propaga un retipificado posterior al índice (evento document.radicado.retyped + marca «requiere revisión TRD»); batch.py sigue aceptando tracking_numbers y dependencia del cliente. Seguimiento de la revisión de T6 (hallazgos de los auditores): (a) vincular respeta el no-read-up: la resolución se pide con el clearance del LLAMANTE; un radicado por encima de su nivel responde igual que uno inexistente (404 radicado_no_encontrado, sin desacuerdos), así que ya no se enumeran tipología, formato, productora ni huella de un radicado reservado con solo su UUID (los campos erróneos del 422 solo los ve quien puede leer el radicado); (b) un radicado sin doc_class no se vincula (422 radicado_sin_tipificar): el XSD del índice exige <tipologia> y el vínculo ya no admite declararla; hay que tipificarlo antes en document-service; (c) AREA-01-13 parcial: POST /unidades/{id}/prestamos aplica el no-read-up del PRESTADOR en el WHERE del INSERT … SELECT (403 clearance_insuficiente si la unidad contiene un expediente por encima de su clearance; sin préstamo, movimiento ni asiento). Sigue abierto: prestado_a_user_id UUID + min_crud=3 (cambia el contrato del frontend/MCP y pide validación de archival-compliance-auditor) y propagar un retipificado posterior (document.radicado.retyped). Mutaciones que tumban pruebas: pedir CLEARANCE_SIN_RESTRICCION (2), quitar el raise de sin-tipificar (1), quitar el NOT EXISTS del préstamo (1 integración). Impacto: quien vinculaba radicados reservados sin poder leerlos recibe ahora 404; un radicado sin tipificar ya no entra a un expediente.

  • fix(storage,document,notification): la purga de huérfanos podía borrar un objeto que otra operación acababa de recibir por deduplicación, un lote se leía y reprocesaba por cualquiera con PERM_RADI, y radicar un borrador no exigía tenerlo (cierre de los hallazgos de los dos auditores sobre la tanda de purga/lotes/tenencia/webhooks) Por qué: (1) upload deduplica por SHA-256 y devuelve el mismo file_id; purge_orphan solo veía referencias COMMITEADAS, así que borraba un objeto que otra importación/subida/correo acababa de recibir y aún no commiteaba. (2) GET /status devolvía el subject de todos los ítems —un radicado del lote reclasificado después a reservado se leía con PERM_RADI:1— y cualquier PERM_RADI veía/reprocesaba lotes ajenos. (3) radicar (irreversible) no exigía la tenencia que sí exige editar (Acuerdo AGN 060/2001). (4) el reproceso limpiaba el motivo del fallo sin dejarlo en el asiento, el radicado de un lote era indistinguible de uno a mano tras la retención de 90 días, y la reanudación automática atribuía el acto al usuario. (5) un secreto de webhook de más de 255 caracteres volvía en claro en el 422 de pydantic (input). Qué cambia: storage — migración 017 (files.dedup_hit_at); la rama de dedup de upload hace UPDATE ... RETURNING (toma el bloqueo de fila, se serializa con el FOR UPDATE de la purga; si la purga gana, la subida deja un objeto nuevo) y purge_orphan devuelve referenciado si la marca existe; referencias() falla cerrado si falta una tabla de la lista (tabla(ausente)); corregido el docstring que decía «Object-Lock lo rechaza» (el bucket es versionado: un remove_object sin versionId solo crea un delete marker; lo que protege es preservacion_worm_objeto); guardián shared/tests/test_referencias_de_archivo_cubren_todo_file_id.py que DERIVA de las migraciones de tenant de todos los servicios las columnas *file_id* UUID y las compara con REFERENCIAS_DE_ARCHIVO (con control positivo del parser y anti-vacuidad). document — GET /batch/documents/{id}/status y POST .../reprocesar: solo el creador del lote o PERM_RADI nivel 3, en el WHERE, 404 lote_no_encontrado neutro (en reprocesar, ANTES del lock para que no haya oráculo por 409); el subject lleva el no-read-up en el SQL (LEFT JOIN radicados + CASE); lote_reprocesado guarda error_message (≤500) e intentos por ítem; radicado_created de un lote lleva origen:"lote", job_id, item_index; la reanudación automática asienta con el actor de sistema, canal="system" y en_nombre_de; radicar un borrador exige la misma tenencia que editar (404 neutro; aprobar/devolver siguen sin ella); el UPDATE de PATCH lleva estado <> 'radicado' (409 si se radicó entre medias); el paso a agotado de un binario huérfano deja document.import_binario_agotado en la misma transacción. notification — manejador global de RequestValidationError que quita input/ctx del 422. Contrato para el frontend (lotes): el 404 de GET /status y de POST /reprocesar ahora es {"detail": {"error": "lote_no_encontrado", "detail": "...", "status": 404}} (antes detail era una cadena en el GET); el GET exige X-User-Id; un lote ajeno devuelve 404 aunque exista; items[].subject puede ser null (radicado por encima del nivel del llamante) y cada ítem trae nivel_seguridad (int | null, null si no hay radicado o no se alcanza); el resto del contrato no cambia. Verificación: storage 413 passed/57 skipped + 457 passed/13 skipped con RUN_INTEGRATION=1 (con Postgres real y dos conexiones para la carrera dedup↔purga); notification 224/16 + 240 passed; document 679/195 + 874 passed; lint (ruff+mypy) verde en los tres; guardián nuevo de shared/tests verde. Mutaciones que tumban pruebas: quitar la marca de dedup en upload (2), ignorarla en la purga (1), continue por tabla ausente (1), quitar el predicado de creador (1), quitar el gate del subject (1), quitar el chequeo previo al lock (1), no asentar error_message/origen/actor de sistema/agotado (1 cada una), radicar sin tenencia (2), quitar estado <> 'radicado' (1), quitar el manejador del 422 (1) y quitar una columna de REFERENCIAS_DE_ARCHIVO (el guardián). Impacto: aplicar la migración storage 017 por tenant y reconstruir storage-service, document-service y notification-service. Quien reprocesaba o miraba lotes ajenos con PERM_RADI recibe 404; quien radicaba borradores ajenos recibe 404.

  • fix(document,storage): editar un borrador basta con tener el radicado, una importación fallida ya purga sus binarios huérfanos, y los lotes de radicados se reanudan solos y se pueden reprocesar sin renumerar (decisiones del usuario del 2026-10-02: AREA-01-N2 complemento, AREA-09-14, AREA-09-15) Por qué: (1) cualquier PERM_RADI editaba cualquier borrador no reservado del tenant. (2) los binarios que una importación revertida había subido solo se asentaban y se quedaban en el almacén para siempre. (3) un lote de radicados corría en un asyncio.create_task: reiniciar el servicio lo dejaba pending/processing para siempre y un ítem fallido no se podía reintentar. Qué cambia: (1) PATCH /borradores/{id} exige TENER el radicado (tenencia de ADR-028 replicada en SQL dentro del WHERE del UPDATE: paso activo asignado al actor o sin asignar y de su dependencia; sin paso activo, quien poseía el último completado; sin ningún paso, quien radicó el padre; sin padre, el creador); quien no lo tiene recibe el mismo 404 neutro y la fila no se toca. aprobar y devolver NO la exigen (actos de revisión; aprobar suele ser de otra persona y está atado al circuito de firma). (2) storage-service POST /internal/purgar-huerfanos (X-Internal-Token): borra solo lo que creó esa importación, sin ninguna referencia en el tenant (anexos de cualquier radicado, correos, borradores, migraciones, objetos WORM) y si el almacén lo acepta, fila+objeto+asiento file.purgado_huerfano en una transacción. document-service encola los binarios en import_binario_huerfano (migración 038, atómica con el asiento …_sin_radicado), intenta purgar al momento y, si storage falla, un barrido de fondo reintenta (20 intentos → agotado); la purga confirmada asienta document.import_binarios_purgados. (3) migración 039 + barrido: reanuda los lotes interrumpidos bajo un advisory lock por lote (varias réplicas), cada ítem es idempotente (reclamo FOR UPDATE; el consecutivo y el radicado van en la misma transacción: sin huecos ni doble numeración); los ítems fallidos quedan failed con error_message, error_tipo e intentos; el lote termina completado_con_errores; GET .../status suma errores[], pending_items, reprocesable; nuevo POST /batch/documents/{job_id}/reprocesar (mismo permiso que crear, asiento document.lote_reprocesado); retención de lotes completados sin errores (90 días). status del lote pasa a VARCHAR(40). Verificación: document-service unit 676 passed/186 skipped + integración (Postgres real) 186 passed; storage-service ver abajo; lint (ruff+mypy) verde en ambos. Mutaciones que tumban pruebas: quitar el gate de tenencia, cada rama del predicado (último paso, completed, URD activa, creador), no pasar el actor; quitar el chequeo de referencias, de «preexistente», cada fuente de referencias, la transacción; tratar error como terminal, no encolar, no purgar tras revertir, no reintentar; quitar el reclamo del ítem, el lock, el recuento, el estado completado_con_errores, el filtro de reproceso, la retención, la reanudación y el permiso del endpoint. Impacto: aplicar las migraciones 038 y 039 por tenant y reconstruir document-service y storage-service. Quien editaba borradores ajenos con PERM_RADI recibe 404. El lote que terminaba failed por ítems fallidos ahora termina completado_con_errores (la UI debe tratarlo; el contrato está en docs/es/api.md). Declarado fuera: la carrera de dos importaciones concurrentes del MISMO binario donde una falla y purga antes de que la otra confirme (la comprobación de referencias y subido_desde la acotan, no la cierran); el status de lote del frontend aún no muestra errores ni el botón «Procesar los faltantes». Frontend: /admin/interoperabilidad (pestaña «Carga masiva de documentos») muestra «Terminó con errores» con el conteo de documentos sin radicar y una tabla de los fallidos (asunto, motivo en español, el texto técnico como detalle secundario, intentos); si el backend dice reprocesable y la persona tiene PERM_RADI, ofrece «Procesar los faltantes» con confirmación que dice cuántos se procesan y que lo ya radicado conserva su número, hace POST .../reprocesar por un proxy SSR con allowlist (D-05), vuelve a sondear hasta que termine y traduce los 404/409 (lote_no_encontrado, lote_en_proceso, nada_que_reprocesar) a mensajes en español. Pruebas: proxy, utilidades y pantalla; la mutación que quita el chequeo de PERM_RADI tumba la prueba.

  • fix(notification,auth,shared,infra)!: el secreto HMAC de los webhooks se guardaba en claro: quien leyera la base, un respaldo o una inyección podía forjar entregas que el receptor aceptaría; ahora va cifrado en reposo con una KEK versionada (deuda declarada del cierre T5 de la auditoría del 2026-10-02) Por qué: webhook_subscription.secret era TEXT en claro. No sirve un hash: el servicio necesita el secreto original para firmar cada entrega (HMAC-SHA256). Qué cambia: mismo esquema criptográfico que la custodia de la firma personal de auth-service, ahora en orpycamcp_common.kek (AES-256-GCM, AAD tenant:{slug}:webhook:{id}:secret, nonce nuevo por cifrado, llavero versionado v1 + WEBHOOK_SECRET_KEKS); auth-service delega en él sin cambiar un byte de lo ya guardado. Migración tenant 009 (secret_cifrado, secret_nonce, secret_kek_version, tres CHECK; chk_webhook_secret_sin_claro impide escribir un secreto nuevo en claro). Crear un webhook cifra (el id lo acuña la aplicación porque entra en el AAD; sin KEK utilizable responde 503 webhook_secret_kek_unavailable); la entrega descifra solo en memoria para firmar y falla cerrada: una fila aún en claro o un ciphertext que no descifra no se entrega ni se firma. Ningún GET/lista devuelve el secreto, ni lo llevan los logs ni los asientos. WEBHOOK_SECRET_KEK es obligatoria (el servicio no arranca sin ella; en producción, ${…:?}). Pasos de operación: python -m app.ops.cifrar_webhook_secrets (cifra los heredados, deja secret en NULL en el mismo UPDATE y escribe el asiento notification.webhook.secret_cifrado desde la aplicación; idempotente) y python -m app.ops.rewrap_webhook_secrets (rotación de KEK, --verify por versión). Verificación: notification-service 223 passed/16 skipped (+ 16 de integración con Postgres real); auth-service 280 passed/42 skipped + 46 de integración; shared/tests 191 passed (los 3 rojos son de otras tandas); lint verde en ambos. Mutaciones que tumban pruebas: quitar el AAD (5), cifrar con identidad (8), escribir el claro en el INSERT (5), devolver el secreto en el GET (2), entregar filas en claro (2), no anular secret al cifrar (1), AAD sin el id del webhook (2). Impacto: definir WEBHOOK_SECRET_KEK (openssl rand -base64 32) antes de reconstruir notification-service; aplicar migraciones y correr cifrar_webhook_secrets ANTES de up -d (ver docs/es/deployment.md). Respaldar la KEK con la misma disciplina que la de la firma personal: si se pierde, hay que volver a dar de alta cada webhook con secreto. Declarado fuera: la columna secret en claro sigue existiendo (siempre NULL); retirarla es una migración posterior, cuando ningún despliegue conserve filas sin cifrar.

  • fix(document): corregir dest_dept por PATCH dejaba el código de otra dependencia, y el radicado aparecía en ?ubicacion_dept_code= donde ya no estaba (ADR-029, auditoría) Por qué: el PATCH cambiaba el nombre y no el código; la ubicación generada mezclaba ambos. Ahora el código se resuelve por nombre en el mismo UPDATE (único activo, si no NULL) y el PATCH de dest_dept sobre un radicado closed responde 409 dest_dept_immutable.

  • fix(notification,workflow): cierre de la auditoría de la tanda: la ruta de dependencias de un radicado reservado salía por webhook, el asiento guardaba el token de la URL, y se podía designar un revisor sin habilitación. Qué cambia: dest_dept, dest_dept_code y origin_dept se recortan en niveles >= 2; el asiento de crear/borrar webhook guarda solo scheme://host:port + url_sha256 (nunca el path, donde Slack/Teams/Discord llevan el token); POST /workflows/{id}/vistos-buenos rechaza con 422 revisor_sin_habilitacion si algún revisor tiene clearance menor al nivel del radicado. Verificación: notification 202 passed/4 skipped, workflow 331 passed/81 skipped; lint verde; mutaciones (quitar campos, volver a emitir el path, desactivar la guarda) tumban 3, 3 y 1 pruebas.

  • fix(notification,workflow,frontend)!: un webhook sacaba la causal y el fundamento de una reserva, la hoja de ruta de un reservado ya purgado quedaba pública, y el visto bueno ignoraba el nivel del radicado (T5 de la auditoría del 2026-10-02: OMI-01, OMI-03, AREA-10-11, AREA-01-N1, AREA-01-06, AREA-10-03, AREA-01-07, AREA-02-06) Por qué: (1) el recorte de webhooks buscaba nivel_seguridad y el evento de reclasificación envía nivel_nuevo/nivel_anterior: salía ENTERO, con causal y fundamento jurídico; y los eventos de workflow (tracking y ruta de dependencias) no declaraban nivel. El test fijaba ese supuesto falso. (2) /webhooks crear/borrar no dejaba asiento, ni actor, y bastaba nivel de lectura. (3) validate_webhook_target validaba una resolución DNS y httpx hacía otra (rebinding). (4) GET /vistos-buenos no tenía gate de clearance y decidir aprobaba por turno aunque el revisor no pudiera leer el radicado. (5) sin FK flow_steps→radicados, la purga TRD dejaba huérfanos que el COALESCE(...,1) leía como PÚBLICOS. (6) la UI mostraba Devolver con PERM_RADI y el backend exige PERM_TRAMITAR. Qué cambia: nivel efectivo = máximo de nivel_seguridad/nivel_nuevo/nivel_anterior; causal, fundamento y plazo se quitan SIEMPRE; un evento document.*/workflow.* sin nivel sale mínimo (tipo, id, fechas; sin tracking, dependencias ni dedup_key); la ruta de dependencias de un reservado tampoco sale; los productores de workflow declaran nivel_seguridad. Crear/borrar webhook exige nivel 3 y deja notification.webhook.created/deleted con actor en la misma transacción (URL sin credenciales ni query, sin secret). La entrega conecta a la IP validada con Host y SNI originales y follow_redirects=False. list/active de vistos buenos hacen JOIN a radicados con nivel_seguridad <= clearance (404 neutro sin_cadena/sin_pendiente); crear la cadena de un radicado no visible da 404. COALESCE de huérfanos a 3 (fail-closed) en _clearance_condition y list_flow_events. La DLQ de workflow usa get_actor en detalle/replay/discard. La UI espeja PERM_TRAMITAR para Devolver. Pruebas de cruce entre entidades de la DLQ de notification (Postgres real). Verificación: notification-service 197 unit + 2 de integración (199 con RUN_INTEGRATION=1), workflow-service 329 unit / 405 con RUN_INTEGRATION=1, frontend bandeja 148, shared/tests (guardián de nivel) verde, lint limpio. Mutaciones (todas tumban pruebas, restauradas): quitar nivel_nuevo/nivel_anterior del nivel efectivo; pasar la URL original a client.post; min_crud 3→1 en webhooks; quitar audit.append; COALESCE de huérfanos a 1; quitar el JOIN de vistos buenos; quitar tenant_slug de la DLQ; volver a PERM_RADI en canDevolver. Impacto: reconstruir notification-service, workflow-service y frontend (sin migraciones). Quien administraba webhooks con USUA_PERM_ADMIN en nivel 1-2 recibe 403 al crear/borrar. Un revisor sin habilitación suficiente recibe 404 donde antes veía o aprobaba. Los eventos de workflow/document sin nivel llegan al webhook con el sobre mínimo. Declarado fuera, a propósito: cifrar el secret de webhooks en reposo; la prueba de aislamiento con dos esquemas de tenant-service y de workflow (el aislamiento allí es por search_path, solo se cubrió la DLQ compartida, donde era un predicado).

  • fix(document,mcp)!: un radicado fallido gastaba un número sin asiento, import podía dejar la radicación de todo un año caída con un número de otra entidad, y despachar o marcar «entregado» un envío no dejaba asiento ni pedía más que nivel de lectura (T4 de la auditoría del 2026-10-02: AREA-09-01, AREA-03-08 ítem 4, AREA-09-14, AREA-09-11, AREA-09-13, AREA-09-03, AREA-09-09, AREA-09-15 parcial)
  • fix(document): revisión de T4: un cliente que goteaba el cuerpo de POST /import retenía el cupo de todos los tenants, un número propio en 999999 dejaba el siguiente radicado nativo en 7 dígitos, y los binarios de un import revertido quedaban sin asiento (cuerpo con plazo de 300 s → 408; techo propio 999998 → 422; asiento document.import_binarios_sin_radicado; purga de huérfanos queda como deuda) Por qué: (1) DocumentService.create tomaba el consecutivo en autocommit ANTES de la transacción: un tercero_id inexistente daba 404 y revertía radicado y asiento pero no el número (hueco en la numeración oficial, Ac. AGN 060/2001, alcanzable con un token real). (2) import reconciliaba tracking_sequences con el número del paquete sin comprobar que fuera de esta entidad ni acotar la secuencia: 2026-OTRA-E-2147483647 dejaba last_seq en INT_MAX y la siguiente radicación nativa del año fallaba con «integer out of range». (3) La transición manual de un envío y el despacho no asentaban nada, exigían solo nivel de lectura, y el UPDATE no comparaba el estado previo: el último en escribir pisaba un estado terminal (carrera con el callback del operador). (4) Import/export armaban el ZIP entero en memoria y hasheaban en el event loop. (5) El lote de radicados puenteaba la plantilla de metadatos. (6) La guía postal del stub salía del radicado, así que dos envíos del mismo radicado y operador chocaban con 409. Qué cambia: el consecutivo se toma dentro de conn.transaction() (hace rollback con el radicado). import reconcilia el contador solo si el código de entidad del número es el del tenant, y rechaza con 422 secuencias > 999999 y años fuera de [1900, año actual]. Envíos: POST /documents/{id}/envios y POST /envios/{id}/estado pasan a PERM_RADI_SALIDA nivel 3, asientan document.envio_registrado / document.envio_estado (actor = X-User-Id) en la misma transacción, y el UPDATE lleva estado previo y clearance en el WHERE (0 filas → 409 transicion_invalida; el callback del operador aplica la misma guarda y, si pierde la carrera, queda como «no aplicada»). La guía deriva de un uuid4 del envío. import y export pasan a USUA_PERM_EXPEDIENTE nivel 3, corren validación, hashing y armado del ZIP en hilos (asyncio.to_thread), toman un cupo de proceso (app/core/operacion_pesada.py; ocupado → 429 operacion_ocupada con Retry-After), el import corta la lectura del cuerpo al superar 512 MiB (413 sin bufferizar todo) y el export tiene tope de 512 MiB de anexos (422 export_demasiado_grande). El lote valida la plantilla activa del doc_class (falla el ítem) y guarda metadata_template_id, y numera con el año de la zona de radicación. Catálogo MCP: despachar_envio y actualizar_estado_envio declaran nivel 3. Sin migración. Verificación: document-service unit 620 + integración (Postgres real, RUN_INTEGRATION=1) 150, 770 en total, mcp-server 236, shared/tests (guardián de nivel) verde, lint limpio. Mutaciones (cada una tumba pruebas, restauradas y reconstruidas): consecutivo fuera de la transacción, sin comparación de entidad, sin tope de secuencia, sin to_thread, sin cupo, sin tope de bytes, sin validate_metadata en el lote, año en UTC, guía del radicado, sin asiento de transición, sin estado previo en el WHERE, sin min_crud=3. Impacto: reconstruir document-service y mcp-server. Quien tenía PERM_RADI_SALIDA o USUA_PERM_EXPEDIENTE en nivel 1-2 recibe 403 al despachar, cambiar estado, importar o exportar; la UI de /envios y de interoperabilidad aún no espeja ese nivel (seguimiento de frontend). Declarado fuera, a propósito: borrar los binarios huérfanos de una importación revertida (storage deduplica por SHA-256: borrar sin saber quién más los referencia es peligroso; requiere endpoint interno y decisión); reanudar batch_jobs pendientes al arrancar y purgar los completados; job asíncrono/streaming para import-export; PERM_DESPACHO propio y planilla persistente; caché /auth/validate sin MAC del gateway y la corrección de §9/§10 del documento de auditoría (T2/T14).
  • fix(document,mcp)!: un PERM_RADI de nivel 1 leía y editaba los borradores de respuesta a radicados reservados, el asunto de un reservado salía al proveedor del asistente por dos listados, y la «firma» declarativa la firmaba una huella que elegía el cliente (T3 de la auditoría del 2026-10-02: AREA-11-X1, AREA-11-X2, AREA-01-N2, AREA-05-03-S) Por qué: (1) GET /terceros/{id}/radicados devolvía el subject de radicados RESERVADOS sin gate de clearance, y la barrera de residencia del asistente solo recorta lo que declara nivel_seguridad, que el esquema no llevaba: el asunto viajaba íntegro a Anthropic (ADR-006, «sin excepciones»). RespuestaItem tenía el mismo defecto. (2) borrador no tenía nivel: cualquiera listaba, leía y editaba los de todo el tenant, incluido el cuerpo ya combinado con datos del padre reservado; y editar no asentaba nada. (3) POST /documents/{id}/signatures registraba la huella del cuerpo, a nivel de lectura y sin asiento en audit_log. Qué cambia: radicados_de lleva AND r.nivel_seguridad <= $3 en el mismo WHERE, con el OR de las fichas fusionadas entre paréntesis (sin ellos la rama directa saltaba el gate), declara nivel_seguridad en el ítem y asienta la lectura (document.tercero_radicados_consultado). Migración 035_borrador_nivel_seguridad.sql: el borrador hereda el nivel del padre; get/list/update/set_estado filtran en el WHERE por el nivel efectivo (GREATEST(snapshot, nivel vigente del padre), de modo que reclasificar el padre cierra sus borradores), 404 neutro; crear sobre un padre fuera de alcance da 404; PATCH asienta borrador.editado atómico (BorradorService.editar cambia de firma: max_clearance y actor). Firma declarativa: PERM_FIRMA nivel 2, anexo_id obligatorio, huella resuelta por el servidor desde storage (/internal/files/hashes; el content_hash del cuerpo se ignora; 503 si storage no responde), asiento document.signature_created en la misma transacción (lógica en SignatureService). Guardián nuevo shared/tests/test_contenido_declara_su_nivel.py (censo por ast, lista de excepciones congelada que solo puede bajar). Descripción falsa de firmas_de_un_radicado corregida (decía que había proveedor y nivel de conformidad). De paso: _BO no traía cuerpo_sha256 ni version, que editar y radicar leen. Verificación: document-service unit 576 + integración (Postgres real) con RUN_INTEGRATION=1, mcp-server 236, shared/tests 182, lint limpio. Mutaciones (cada una tumba pruebas, restauradas y reconstruidas): quitar el gate de tercero, quitar los paréntesis del OR, quitar el gate de get / list / update / set_estado del borrador, quitar el GREATEST, quitar el asiento de editar, usar la huella del cuerpo, sacar el asiento de la transacción de firma, quitar el nivel 2, quitar nivel_seguridad de RespuestaItem o de RadicadoDeTercero (guardián), y subir _NIVEL_RESERVADA (asistente). Impacto: aplicar la migración 035 por tenant y reconstruir document-service y mcp-server. Un cliente que firmaba sin anexo_id recibe 422; uno con PERM_FIRMA en lectura, 403. Declarado fuera, a propósito: decisión del usuario sobre retirar el POST de firmas con 410; si editar un borrador exige ser su creador o tener turno en el circuito; SearchHit y RadicadoMetaItem siguen sin nivel_seguridad (en EXCEPCIONES del guardián); radicar no propaga el nivel del borrador al radicado nuevo. Remediación de auditoría (T3): (Alta) radicar creaba el radicado en nivel 1 aunque el borrador respondiera a un reservado: ahora hereda el nivel VIGENTE del padre (GREATEST con la instantánea), su fundamento/causal/plazo (si el padre no tiene fundamento, se motiva por herencia: nunca cae a PUBLICA) y el vínculo responde_a. (Media) migración 036_signatures_origen.sql: las firmas declarativas heredadas quedan origen='cliente_no_verificado' y verify las excluye; las nuevas llevan origen='servidor'. (Media) aprobar (persona y circuito completado, ruta interna) y devolver asientan borrador.aprobado / borrador.devuelto en la misma transacción que el cambio de estado. Verificación: document-service 744 (con RUN_INTEGRATION=1), lint limpio; mutaciones que tumban pruebas: quitar la herencia de nivel, quitar el filtro de origen, cambiar la acción del asiento. Impacto: aplicar 036 por tenant y reconstruir document-service.

  • fix(workflow): el cierre de un trámite vencido quedaba asentado como «no consta» el plazo (E05, ADR-028) Por qué: fuera_de_plazo del asiento workflow.finalizar solo miraba flow_steps.due_at, que casi nunca existe (todo el corpus lo tiene NULL). Recorriendo la pila, 2026-DEMO-E-000118 —vencido el 18/09— se cerró el 02/10 con fuera_de_plazo: null. Ahora manda el plazo del paso si existe y, si no, el vencimiento legal del radicado (radicados.due_date); null solo si no consta ninguno. Verificación: workflow-service 397 con RUN_INTEGRATION=1, lint limpio; prueba nueva con control positivo y negativo (vencido → true, a tiempo → false); la mutación que ignora el vencimiento del radicado la tumba.

  • feat(workflow,document,mcp): el flujo mueve el estado del radicado — repartir ya no deja el radicado en registered (E05, E01, ADR-028) Por qué: el estado del radicado y el avance de los pasos eran dos máquinas que no se hablaban (decisión de dominio abierta el 2026-10-01). Repartir, trasladar y tramitar avanzaban flow_steps; radicados.status solo cambiaba si alguien pulsaba a mano «Distribuir»/«En trámite» —un PATCH con status—, así que el panel, la bandeja y la búsqueda filtraban por estados que el flujo nunca escribía. En Orfeo repartir es un cambio de estado. Cómo: workflow-service escribe radicados.status en la misma transacción que el paso, después de mutarlo y antes del asiento (si audit.append falla no queda ni paso ni estado), con una sola función pura (estado_radicado.py::derivar_estado_radicado) cuyo gemelo literal es el CASE del backfill: paso activo pending → distributed, in_progress → in_progress. Asignar, trasladar, devolver, revertir, reasignar en cascada y el reparto automático al radicar lo aplican; completar sin marcar leído deja distributed (declarado). in_progress solo por marcar_leido del poseedor, y para eso se cerró un hueco que ya existía: execute_transaction no comprobaba tenencia, así que cualquiera con PERM_TRAMITAR podía marcar leído un trámite ajeno (transaction_types.requires_holder, migración 012; no poseedor → el mismo 404 no_active_step, sin oráculo). El único cierre es POST /workflows/{id}/finalizar (PERM_TRAMITAR, solo poseedor, comentario obligatorio, tipo_tx=finalizar): no es un transaction_type, porque el catálogo es configurable por el tenant y el acto irreversible no puede vivir ahí. closed y anulado son terminales: todo acto que mueva el paso responde 409 radicado_terminal y revierte; informar/agendar siguen permitidas. La cascada devuelve los pasos a pending (Orfeo LEIDO=0), excluye terminales (skipped_terminal), toma los cerrojos ordenados y asienta workflow.estado_radicado por radicado cambiado. Tras el commit se publica workflow.radicado.estado_cambiado (best-effort). document-service: el PATCH rechaza status con 422 status_managed_by_workflow (quitar el campo no bastaba: Pydantic habría ignorado el extra y el cliente creería que cambió). MCP: actualizar_radicado pierde status; nueva finalizar_tramite (irreversible) → 207 tools, 75 de escritura, 27 irreversibles. Backfill 013_radicado_estado_desde_flujo.sql con tabla de evidencia radicado_estado_recalculo, sin escribir audit_log desde SQL, idempotente. Verificación: workflow-service 308 pruebas unitarias (18 de integración omitidas) y 18 de integración contra Postgres real (RUN_INTEGRATION=1, tier nuevo tests/integration/: atomicidad con fallo forzado de audit.append, terminales sin cambiar un recuento, cascada, backfill dos veces —la 2.ª, 0 filas— y cada fila comparada con la función Python); mcp-server 228; shared/tests 171; lint.sh limpio en los tres servicios. Verificado por mutación (7, cada una tumba pruebas): quitar la sincronización de _create_step, sacarla de la transacción, quitar la guarda terminal, quitar la comprobación de poseedor, invertir el CASE del backfill, no excluir terminales en la cascada e ignorar la bandera de finalizar. Frontend: /bandeja retira los botones manuales «Distribuir»/«Iniciar trámite» (y su PATCH de status, que ahora da 422 status_managed_by_workflow). El detalle ofrece «Marcar en trámite» (marcar_leido) y «Finalizar trámite» —comentario obligatorio de 5-500 caracteres + casilla de confirmación, nuevo proxy POST /bandeja/api/{id}/finalizar— y «Devolver» solo a quien posee el paso activo (assigned_to = yo, o sin asignar y de mi dependencia, leído de GET /current), con PERM_TRAMITAR; el backend re-valida. Mensajes en español para 409 radicado_terminal, 404 no_active_step y 422 status_managed_by_workflow. Tras la auditoría: «Finalizar» exige además PERM_TRAMITAR nivel ≥3 (espejo de min_crud=3, leído de permission_levels; con nivel 2 no se ofrece) y pide un motivo de cierre obligatorio (selector: Respondido · No requiere respuesta (NRR) · Traslado por competencia · Otro) que el proxy valida (422 sin llamar al gateway) y reenvía junto al comentario ({comentario, motivo_cierre}, allowlist). Sin paso activo, también se ofrece a quien poseía el último paso completado de un radicado no terminal (se lee GET /history). El Tablero (WorkflowBoard/WorkflowCard) respeta la tenencia: «En trámite», Trasladar, Trámite, Completar, Revertir y el arrastre a «En trámite» solo para el poseedor; un colega ve la tarjeta pero no la acciona. Mensajes nuevos para 404 step_not_found y 404 radicado_not_found. Tras R15–R21: «Asignar a flujo» se ofrece solo sin paso activo y a quien poseía el último paso completado o, sin ningún paso, a quien radicó (registered_by del detalle comparado con el user_id interno, no el sub); con registered_by nulo (importado/ingesta) o historial no cargado no se ofrece (fail-closed; el backend da 404 no_active_step). «Devolver» y «Revertir» siguen la tenencia de _holder_condition (también el paso sin dueño de mi dependencia; ya no existe 403 not_current_holder) y «Revertir» solo con el paso pending. Mensaje en español para 404 no_flow (flujo inexistente o radicado por encima del nivel de acceso). Declarado fuera, a propósito: las salidas y los internos no tienen reparto fuera; la anulación no cancelaba el paso activo (corregido en la remediación, abajo); rollback solo desde pending; completar sin marcar leído deja distributed; finalizar/devolver sobre un cerrado dan el 404 neutro (un cerrado no tiene paso, y decir «está cerrado» a quien no posee nada sería un oráculo); la importación INT-05 conserva el status del paquete. Pendiente: el frontend (botón «Finalizar trámite», retirar «Distribuir»/«En trámite» manuales, traducir el 409). Impacto: aplicar migraciones (012, 013 por tenant) y reconstruir workflow-service, document-service y mcp-server. Cualquier cliente que enviara status al PATCH /documents/{id} recibe ahora 422. finalizar pasa a nivel 3 (ver remediación). Con la remediación, además: aplicar 014/015 por tenant y 034 de document-service, y reconstruir también document-service y mcp-server; un tenant de desarrollo que ya aplicó 013 antes de la remediación debe recrearse (013 no se había liberado y se editó en lugar de añadir una correctora). Remediación de la auditoría (2026-10-02, mismo ADR-028): tras la revisión de seguridad y de cumplimiento archivístico se corrigió, en el backend: (1) assign/transfer ya no son la puerta lateral al cierre —B se autoasignaba un radicado ajeno (incluso reservado) y «Finalizar» lo cerraba—: dentro de la transacción y tras el cerrojo, el clearance del actor va en el WHERE de la lectura del radicado (404 radicado_not_found, el mismo de un radicado inexistente) y, con paso activo, solo su poseedor puede repartirlo o trasladarlo (404 no_active_step); el reparto automático al radicar es un actor de sistema (sistema=True, solo auto_distribute); redistribuir el paso de otro queda en la cascada de administración. (2) El oráculo 409 radicado_terminal se cierra (el gate de tenencia va antes de sincronizar; anular pasa a requires_holder, migración 014, y toda transacción que mueve el paso exige tenencia). (3) finalizar exige PERM_TRAMITAR nivel 3 (era 1, el de lectura); sale de SIN_NIVEL_DECLARADAS y el catálogo MCP lo espeja. (4) complete_step usa la misma definición de poseedor (antes assigned_to IS NULL bastaba para cerrar el paso sin dueño de otra dependencia, y un paso cerrado se «re-cerraba»). (5) MCP finalizar_tramite se confirma tecleando el radicado (confirmar_con), no con CONFIRMO. (6) La cascada toma un único cerrojo advisory de alcance (compartido en cada acto, exclusivo en la cascada) más cerrojos de fila ordenados, en lugar de uno por radicado que agotaba max_locks_per_transaction. (7) La anulación cancela el paso activo en la misma transacción (document-service, flow_events.tipo_tx='anulacion'), la migración 015_sanear_pasos_zombi.sql sanea los existentes con tabla de evidencia de solo inserción (flow_step_saneamiento) e inbox/pending/vencidos excluyen radicados terminales. (8) La importación INT-05 rechaza paquetes con anulado (enum cerrado en RADICADO_SCHEMA), registra el status_origen en el asiento y la migración 034_radicado_status_check.sql (document-service) cierra el dominio de radicados.status con un CHECK (NOT VALID + validación si los datos lo permiten). (9) El backfill 013 deja evidencia de solo inserción (tracking_number, paso_activo, origen), ya no toca updated_at y el runner de migraciones escribe el asiento-ancla workflow.estado_radicado.backfill (conteo + SHA-256) en audit_log, una vez por tenant. (10-12) finalizar exige motivo_cierre (respondido|nrr|traslado_competencia|otro), su asiento añade step_id, flow_event_id, tiene_respuesta, fuera_de_plazo y en_expediente, y se permite sin paso activo a quien poseía el último paso completado. (13) _cambios se vacía al inicio de cada acto (la prueba xfail pasó a verde). Defecto previo hallado al probar contra Postgres real: AnulacionRepository.decide condicionaba a estado = 'pendiente', valor que el CHECK nunca admite, así que ninguna aprobación de anulación podía aplicarse (siempre 409 anulacion_ya_decidida); los mocks no lo veían. Corregido a 'solicitada'.

Re-auditoría de seguridad (2026-10-02, mismo ADR-028; R15–R21 y RN-8): (A) finalizar, complete_step, marcar_leido, rollback, devolver y toda transacción con tenencia miran el clearance del poseedor (nivel en el WHERE; un miembro de nivel 1 de la dependencia ya no cierra un reservado repartido a ella sin dueño); mismo 404 neutro. (B) Un radicado sin paso activo ya no lo toma cualquiera con PERM_TRAMITAR: con historial lo reparte quien poseía el último paso; sin ningún paso, quien lo radicó (registered_by); un cerrado da 404 no_active_step al reparto (antes 409). (C/H) Las transacciones sin tenencia (vobo, informar, nrr, agendar, solicitar_firma…) aplican clearance y devuelven 404 no_flow, igual que un radicado inexistente o sin pasos: ya no filtran tracking_number/to_dept de un reservado ni distinguen «existe» de «no existe». (D/E) rollback/devolver leen paso y tenencia dentro de la transacción tras el cerrojo y apply_* repiten la condición; ambos usan la misma definición de poseedor (el miembro de la dependencia ya puede revertir/devolver un paso sin dueño). (F) El MCP finalizar_tramite declara motivo_cierre (obligatorio, enum): toda llamada daba 422. (G) La carrera de dos decisiones de anulación da 409 anulacion_ya_decidida, no 500. RN-8 la guarda de auto_distribute se repite dentro del cerrojo. Declarado, no corregido: la anulación puede anular un closed; USUA_PERM_ADMIN en min_crud=1 para la cascada; TRUNCATE no cubierto por los triggers de evidencia. Verificación de la remediación: workflow-service 376 pruebas con RUN_INTEGRATION=1 (322 unitarias + 54 de integración, 0 xfail), document-service 695 con integración (569 unitarias), mcp-server 230, shared/tests 171, lint.sh limpio en los tres. Mutaciones (cada una tumba pruebas): quitar el gate de tenencia de assign, quitar el clearance del WHERE, finalizar de vuelta a nivel 1 (también lo tumba el guardián de shared), complete_step con la definición vieja, cascada con cerrojo por radicado, quitar la cancelación del paso en la anulación, quitar el enum del esquema de importación, quitar el CHECK, y volver al 'pendiente'. Contratos que cambian para el frontend: POST /workflows/{id}/finalizar exige motivo_cierre (respondido|nrr|traslado_competencia|otro) además de comentario, y PERM_TRAMITAR nivel 3 (403 con nivel menor); assign/transfer responden 404 no_active_step a quien no posee el paso activo y 404 radicado_not_found si el radicado no se alcanza; PATCH /{step_id}/complete responde 404 step_not_found sobre un paso ajeno o ya cerrado; POST /{id}/transactions con anular/marcar_leido (o cualquier tipo que mueva el paso) responde 404 no_active_step a un no-poseedor, también sobre radicados terminales (ya no 409); inbox/pending dejan de listar pasos de radicados terminales. Declarado fuera de esta tanda: el acto «reabrir» (MoReq: reapertura controlada con permiso de administración); Salidas/Internos sin reparto; completar un paso in_progress deja el radicado in_progress sin paso activo (se mantiene); un radicado con historial y sin paso activo lo puede repartir quien tenga PERM_TRAMITAR y clearance (residual de R1).

Deprecated

  • workflow,document,tenant: los campos de nombre de dependencia como llave (to_dept, from_dept, assign_to_dept, ?dept=, dest_dept en PATCH/borradores) quedan como alias deprecado un ciclo (cabecera Deprecation + Link); el campo canónico es *_dept_code (ADR-032).

Added

  • feat(document,workflow,mcp): la lista, la búsqueda y el detalle dicen dónde está el radicado ahora, no solo a quién iba dirigido (E05, E01, ADR-029) Por qué: 2026-DEMO-E-000117 aparecía con la dependencia de radicación («Oficina de Tecnología») tras haber sido repartido a Talento Humano y devuelto a Control Interno. dest_dept es una instantánea legal que no cambia; dónde está el radicado solo se reconstruía recorriendo flow_steps. En Orfeo es radi_depe_actu/radi_usua_actu: dos columnas del propio radicado que cada reparto reescribe. Qué cambia: document-service migración 037_radicado_ubicacion.sql (columnas flujo_dept_code/flujo_dept/flujo_usuario + dos columnas generadas ubicacion_dept_code/ubicacion_dept, calculadas por pares, con índice); workflow-service sincronizar_estado_radicado las escribe en el mismo UPDATE que status (también la cascada), con la función pura nueva derivar_ubicacion (paso activo; sin él, el que se cerró más recientemente, no el de mayor número; terminales congelan; código solo si el nombre casa con una dependencia activa) y migración 016_radicado_ubicacion_desde_flujo.sql (backfill, evidencia de solo inserción radicado_ubicacion_recalculo, ancla workflow.ubicacion_radicado.backfill en el runner, RAISE si document-service aún no migró). GET /documents y GET /search admiten ?ubicacion_dept_code= (misma condición en filas y COUNT); las respuestas llevan flujo_* y ubicacion_*; flujo_usuario solo en el detalle. Reportes: by_ubicacion (sin cerrados ni anulados) y la sección CSV dependencia pasa a dependencia_radicacion (+ ubicacion_actual). Export INT-01 con la ubicación como dato informativo que el import ignora. MCP: ubicacion_dept_code en listar_radicados y buscar_radicados; dest_dept[_code] descritos como «de radicación». Nada de esto sale en lo público (verificación por código, OAI-PMH, CMIS). Verificación: workflow-service 441 passed (RUN_INTEGRATION=1; 347 + 94 de integración omitidas sin él) incl. integración contra Postgres real que reproduce el caso real (radicar en Tecnología → Talento Humano → devolver a Control Interno ⇒ Control Interno, dest_dept intacto), rollback, rollback + completar, finalizar, anulación que congela, cascada, nombre duplicado → código NULL, atomicidad ante fallo de la auditoría y backfill fila a fila contra la función Python (2.ª corrida: 0 cambios; el trigger rechaza UPDATE/DELETE); document-service 816 passed (RUN_INTEGRATION=1; 658 + 158 omitidas sin él); mcp-server 243; lint limpio. Mutaciones (cada una tumba pruebas): quitar la escritura de flujo_*, ordenar el «último» por step_number, sacar el filtro del COUNT, añadir la ubicación a PublicVerifyResponse. Aplicado a la pila viva: 2026-DEMO-E-000117 queda en Control Interno (código 907). Frontend: /bandeja (lista y detalle), /busqueda (columna y filtro ubicacion_dept_code), /reportes (panel «Por ubicación actual (en trámite)» y renombre a «Por dependencia de radicación»), el panel principal («Dónde está el trámite», sobre by_ubicacion) y el selector de radicados del expediente dicen dónde está el radicado ahora y no a quién iba dirigido. El detalle separa «Ubicación actual» (con responsable nombrado, nunca el UUID; «Cerrado en X» para los terminales) de «Dependencia de radicación». Sin ubicacion_dept la celda dice «—»: no se cae a dest_dept, que sería inventar la ubicación. Los proxies de búsqueda admiten ubicacion_dept_code solo como entero positivo. Helper nuevo lib/utils/ubicacionRadicado.js; tipos en lib/types/api.js. 2237 pruebas del frontend (19 nuevas), svelte-check 0/0; mutadas por separado y en rojo con su prueba: la celda de la lista y la de búsqueda volviendo a dest_dept, el bloque «Cerrado en», el bloque «Dependencia de radicación», el filtro retirado de cada proxy y del load, el panel de reportes y la tarjeta del panel leyendo by_dependencia, el fallback a dest_dept en el helper y la columna del selector de expedientes. Revisión de UX (2026-10-02): sin pasos el detalle lo dice («Sin repartir — en X»; una Salida/Interno sin destino, «en la dependencia productora: X») y un terminal que nunca pasó por el flujo («Anulado/Cerrado sin pasar por el flujo — en X»), en vez de «aún en su dependencia de radicación»; «Ubicación actual» pasa a ser la primera fila del detalle, destacada, antes que la de radicación; la ubicación sube como línea «En X» bajo el asunto en la lista de /bandeja y de /busqueda a ≤768 px, donde la columna se oculta; el filtro de /busqueda pasa del código numérico a un selector del catálogo de dependencias («nombre — código»; filtra por código, así que un radicado de nombre ambiguo —sin código— no aparece en él); /bandeja/api/users (exige PERM_RADI) ya no se llama sin ese permiso ni se repite tras un fallo, y el responsable se dice «A cargo de un responsable de la dependencia» sin avisar de error; el panel principal dice «No disponible» si el resumen no trae by_ubicacion en vez de «Sin radicados en trámite». Pruebas del frontend con una mutación por punto importante en rojo. Impacto: aplicar 037 (document-service) y después 016 (workflow-service) por tenant; reconstruir document-service, workflow-service y mcp-server. El CSV de reportes ya no tiene la sección dependencia. Declarado fuera, a propósito: flow_steps.to_dept_code.
  • fix(auth): el asiento de renombrar o borrar un grupo ahora conserva el valor anterior (RBAC, ADR-010, MoReq/ISO 15489) Por qué: rbac.grupo_actualizado solo decía QUE campos cambiaron y rbac.grupo_eliminado iba con payload vacio; un grupo creado «Archivo», renombrado «Ventanilla» y borrado no se podia reconstruir desde audit_log. Como: update_group y delete_group leen la fila con FOR UPDATE dentro de la misma transaccion (RBACRepository.get_group_for_update) y asientan {campos, antes, despues} y {name, description} respectivamente. Sin migracion. Verificacion: auth-service 322 con RUN_INTEGRATION=1 (280 unitarias), lint limpio; mutacion (leer el «despues» como «antes» y vaciar el payload de la baja) tumba 3 pruebas. Declarado fuera: min_crud=3 en escrituras de webhooks y plantillas, asiento al crear/borrar un webhook y permiso propio de lectura de auditoria (preexistente, declarado en el censo SIN_NIVEL_DECLARADAS; proxima tanda).

  • docs(api,architecture,roadmap): el catálogo de herramientas creció cuatro veces y la referencia se quedó en la foto vieja (E18, ADR-019, ADR-020) Por qué: docs/es/api.md y su gemela inglesa declaraban «45 tools, 16 de escritura» y una cobertura de «367 operaciones, 41 cubiertas». El catálogo real son 206 tools, 74 de escritura, y la cobertura 203 de 342 operaciones expuestas (59 %). La cifra no se olvidó por falta de registro —el CHANGELOG anotó cada incremento: 106, 112, 146, 157, 170, 201—: era un número escrito a mano al lado de uno que se mueve, y así estuvo ocho meses. Con ella se quedaron atrás tres afirmaciones que habían pasado de incompletas a falsas: que el asistente es «solo lectura» y excluye radicar_documento (hoy ofrece las 206 y ninguna escritura se despacha: se firma una propuesta que confirma la persona), que la lista de actos fuera del catálogo son «anular, transferir, derogar TRD/TVD…» (todos ellos existen hoy como herramientas) y el «Diferido: habilitar escrituras con doble confirmación» del roadmap inglés, cerrado hace meses. Cómo: la tabla de las 206 se genera desde services/mcp-server/app/tools/catalog.py y va agrupada por épica con el permiso y el nivel CRUD que el propio catálogo declara —los mismos que shared/tests/test_permisos_declarados.py cruza contra el endpoint en los dos sentidos—, distinguiendo las 26 irreversibles y las 5 que exigen teclear un valor de la tarjeta. La lista de exclusiones se sustituye por los 15 actos que el servidor publica en mi_alcance, con los cuatro criterios que los separan. En la versión inglesa la columna de operación no se traduce y se dice por qué: es la etiqueta que el servidor devuelve en la tarjeta, no prosa, y una copia traducida sería una segunda redacción que nadie mantendría al día.

  • docs(architecture,getting-started): el duodécimo servicio no estaba en el mapa (ADR-020) Por qué: whisper-service tiene carpeta, Dockerfile, puerto 8012 y servicio en docker-compose.yml, y no aparecía en architecture.md, ni en api.md, ni en el CLAUDE.md, que seguía diciendo «11 microservicios». Solo lo nombraban el changelog, el roadmap y el ADR-020 — las páginas que narran, no las que describen el sistema. Alguien que leyera la arquitectura para desplegar no sabía que existe ni por qué arranca apagado. Cómo: alta en la tabla de puertos y en el diagrama de las dos versiones, sección propia con sus tres rasgos deliberados —perfil assistant apagado por defecto y sin stub de transcripción, sin ruta por el gateway ni puerto publicado, y el único servicio sin base de datos ni schema por tenant—, y el docker compose --profile assistant en los primeros pasos.

  • docs(api): dos endpoints sin ficha — POST /signature/internal/circuitos/{tipo}/{id}/invalidar, que deja sin efecto los circuitos vivos de un borrador cuyo texto cambió (best-effort e idempotente: la garantía dura es la comparación de huellas al radicar; esto es lo que evita que a alguien le quede en la bandeja un turno para firmar un documento que ya no existe), y GET /api/v1/catalogos, que dice qué catálogos hay para no tener que adivinarlos.

Verificación, sin Docker: cruce mecánico código↔documentación en los cuatro ejes — 397 rutas con las 27 restantes comprobadas a mano como notación agrupada (POST/GET /planes/{id}/aspectos), 206/206 tools presentes en ES y EN, 51/51 pantallas, 27/27 ADRs en nav e índice. Y una trampa propia, la misma de la tanda anterior: el primer extractor acusó 95 endpoints sin documentar; al mirarlos uno a uno, todos estaban — no leía las alternancias ni las rutas relativas. El instrumento se comprueba antes que el sujeto.

  • feat(document,frontend,mcp): el informe de vencimientos no podía ver lo vencido (E01, RF-RAD-04, RF-UI-03) Por qué: GET /documents ordenaba siempre por fecha de radicación, así que el radicado que llevaba más tiempo vencido —el único que una alerta viene a buscar— quedaba fuera de las primeras páginas por construcción: se radicó antes, luego salía después. Y no había filtro por plazo, ni forma de pedir dos estados a la vez, de modo que el tablero derivaba sus «Alertas» de los 20 radicados más recientes de cada estado. El módulo existía, se pintaba, y solo podía avisar de lo que acababa de entrar. Cómo: status pasa a ser repetible (status = ANY($n::text[])), aparecen due_from/due_to con los dos extremos inclusivos y order_by/order_dir sobre un catálogo cerrado de dos columnas —un ORDER BY no admite parámetro enlazado, así que un nombre de columna que llegue por la URL es inyección con otro nombre—. due_date ordena con NULLS LAST en ambos sentidos y todos los órdenes desempatan por id: sin clave única final el orden es parcial, y al paginar eso significa un radicado repetido en una página y otro que no aparece en ninguna. El asiento de búsqueda registra también los filtros nuevos, porque un informe de vencimientos es «quién pidió ver qué plazos» y con solo doc_type/status el registro afirmaría de menos. Decisión declarada: el endpoint no define «vencido». El proyecto usa hoy las dos convenciones opuestas —TRD <= CURRENT_DATE, préstamos <— y el rango inclusivo deja que cada consulta declare su corte en vez de imponer una a la otra mitad del sistema. Consumidores: el tablero pasa a hacer una llamada para pendientes —el total lo cuenta la base, no la suma de dos cabeceras— y una consulta propia para alertas, ordenada por plazo ascendente; y listar_radicados del MCP expone los cuatro parámetros, con la descripción escrita en días y no en la palabra «vencidos», para que el modelo no tenga que adivinar si el que vence hoy entra. Verificación: 9 pruebas contra Postgres real —las cláusulas SQL no las prueba un mock— y 7 de contrato HTTP sobre el SQL emitido. Por mutación caen el ANY, el extremo inclusivo, el NULLS LAST descendente y el desempate. Y una mutación que no cayó destapó un comentario mío falso: escribí que Postgres pone los NULL al final solo en descendente, y es al revés —NULLS LAST es el default de ASC—, así que en esa rama la cláusula es redundante. Corregido el comentario y reapuntado el test a la rama que sí mide algo.

  • feat(mcp): el despacho diario era el flujo con menos cobertura del asistente (E18, E23, E11, E17) Por qué: tras los dos incrementos anteriores, el censo señalaba «respuesta y despacho» con un 43 % — y es trámite de todos los días: proyectar un oficio, ver cómo queda y saber dónde está lo que se envió. Faltaba lo más consultado: no se podía preguntar por un envío ni rastrearlo con el operador, ni ver qué campos admite una plantilla, ni el inventario documental. Cómo: seis herramientas. Dos sobre el envío —una lee lo que el sistema guardó, la otra le pregunta al operador, y son preguntas distintas: cuando nadie ha registrado todavía la entrega, solo la segunda contesta—; los campos disponibles de una plantilla; la plantilla combinada con sus campos resueltos y la lista de los que no resolvieron, que es lo que hay que mirar antes de crear el borrador; devolver a edición un borrador aprobado; y el Formato Único de Inventario Documental. Dos decisiones de fricción, en sentidos opuestos: combinar una plantilla es POST por contrato pero no es escritura, así que no pide confirmación — gastarla en una vista previa la desgasta para donde hace falta, porque quien confirma diez veces al día confirma sin leer. Y devolver a edición sí se declara de alto impacto aunque no destruya nada: el texto queda, pero las aprobaciones ya recogidas se caen y hay que volver a pedirlas a las mismas personas. Un defecto propio, cazado antes de cerrar: la descripción del rastreo decía que sirve «para saber si ya llegó». El conector con el operador es hoy un stub que devuelve siempre «no hay estado», y el backend es honesto al respecto —contesta operador_conectado: false en vez de inventar «sin novedad»—, pero anunciarle al modelo esa capacidad trasladaba la promesa falsa un salto más allá: habría leído la ausencia de estado como que el envío sigue igual, que es justo lo que el backend se cuida de no decir. La descripción lo dice ahora, y un test lo fija. Impacto: el flujo pasa de 43 % a 71 %. Queda declarado lo que no se ofrece: el PDF de la previsualización —un binario no cabe en una conversación— y la edición de plantillas, que se hace viéndola y no dictándola.

  • feat(mcp,shared): el asistente seguía siendo la fábrica de duplicados que el directorio vino a cerrar (E18, E01, RF-RAD-05, RF-BUS-10) Por qué: el trabajo del directorio de terceros cerró cinco puertas por las que un actor entraba sin pasar por el catálogo, y dejó un guardián para que no volvieran a abrirse. Ese guardián censa frontend/src/routes/**: el catálogo de herramientas del MCP era una sexta puerta que nunca miró. La herramienta radicar_documento y el procedimiento de ventanilla ciudadana componían el radicado con sender_name en texto libre y sin actores, así que cada radicación del asistente creaba otro «José Pérez» suelto — con el guardián en verde. Un censo que no mira una puerta no informa de menos: afirma de más. Cómo: las dos declaran ahora actores, y en el procedimiento el argumento viaja en la MISMA llamada que crea el radicado, donde el servicio lo congela dentro de su transacción. El guardián pasa a censar también el catálogo y los procedimientos, buscando la herramienta por su ruta y no por su nombre —renombrarla no puede sacarla del censo— y con su control de vacuidad. Impacto: el asistente radica por la misma puerta que las pantallas. Lo cazó un guardián del propio MCP: declarar el argumento sin enchufarlo a ningún paso lo habría anunciado al modelo para caerse en silencio.

  • feat(mcp): cuatro preguntas que el funcionario hace y el asistente no podía contestar (E18, E01, E06) Por qué: el censo por flujos destapó, entre otras, un gemelo: el historial de acceso estaba expuesto para un expediente y no para un radicado suelto —que son la mayoría—, así que «¿quién ha visto esto?» tenía respuesta sobre uno y no sobre el otro. Y el directorio de terceros, recién construido, no ofrecía ni el informe de duplicados ni en qué radicados aparece una ficha: la pregunta «¿qué le hemos enviado a esta persona?» seguía sin poder hacerse. Cómo: cuatro lecturas —posibles duplicados del directorio, radicados de una ficha, historial de acceso de un radicado y firmas de un radicado—. Impacto: cierra el gemelo y pone el directorio al alcance del asistente que lo alimenta.

  • feat(mcp): el circuito de firma entero no existía para el asistente, y el censo que debía delatarlo era ciego a tres servicios (E18, E06, ADR-019) Por qué: cruzando el catálogo de tools contra los endpoints reales —391 operaciones, de las que 62 quedan fuera de alcance con su motivo escrito— apareció que el servicio de firma no tenía ni una herramienta. El asistente no podía contestar «¿qué tengo pendiente de firmar?», que es la pregunta más frecuente sobre un circuito que acaba de recibir cinco correcciones. Y la razón de que nadie lo hubiera notado es peor que el hueco: el censo se alimentaba del OpenAPI agregado del gateway, que compone la documentación de siete de los once servicios. No dejaba el total corto — publicaba porcentajes de cobertura sobre un universo que no contenía knowledge, signature ni el propio mcp-server. El flujo de preservación y firma anunciaba «55 %» sin haber contado ni una de las 21 operaciones de firma. Un número que nadie sospecha es peor que ninguno. Cómo: cinco herramientas de leer y preparar —los pendientes del propio turno, el estado de una cadena con sus turnos en orden, si un documento ya está en circuito, el comprobante de una firma, y abrir un circuito, que no firma nada ni compromete a nadie—. Firmar, aprobar, rechazar y verificar no se ofrecen, y se declaran en el marco de alcance en vez de omitirse: firmar consume un segundo factor de un solo uso y un chat no lo custodia; el visto bueno no consume ninguno y aun así queda fuera porque afirma «he leído y apruebo» sobre un documento que este canal no puede mostrar; y verificar exige el XML firmado completo, que por aquí habría que inventar. Y el censo deja de ser ciego: lee el esquema de cada servicio (scripts/censo-esquemas.sh), y el que no responda se dice en voz alta en lugar de saltarse en silencio. Impacto: la tabla de cobertura cambió y varios números empeoraron porque se volvieron ciertos — preservación y firma pasa de un 55 % calculado sin el circuito a un 39 % que sí lo cuenta.

  • feat(frontend): la otra mitad de la puerta: una cadena de firmas sobre un radicado (E06, RF-FIR-04, RF-RAD) Por qué: el incremento anterior le dio puerta a la cadena sobre un expediente y dejó declarado el otro tipo que el entorno de pruebas siembra — «Firma de la respuesta», sobre una Salida—. Sin ella, lo que la entidad produce y envía no se podía someter a firma desde la pantalla donde se tramita. Cómo: el mismo modal reutilizable, ahora desde el cajón de la bandeja, con dos ejes de admisión independientes. El del tipo: solo Salida e Interno, porque una cadena de firmas se abre sobre lo que la entidad produce — una Entrada la firmó quien la envió, y que el receptor la firme no significa nada. Y el del permiso: PERM_RADI para ver el radicado más PERM_FIRMA, que es lo que el router de cadenas exige; pedir menos ofrecería un botón destinado al 403 y pedir más lo escondería a quien sí puede. El predicado de tipo se escribe aparte del de la anulación aunque hoy den el mismo par de tipos: son dos reglas distintas —«solo se anula el registro propio» y «solo se firma lo propio»— y fundirlas ataría dos cosas que pueden dejar de coincidir. Impacto: los dos actos que el sembrado del entorno crea ya se pueden hacer desde la interfaz. Y dos defectos propios del modal, que solo aparecieron pulsando: reabrirlo sobre el mismo objeto no volvía a preguntar por la cadena —conservaba la composición anterior y ofrecía abrir una segunda sobre algo que ya tenía una, con el 409 esperando al final del formulario—, porque la relectura se disparaba al cambiar de objeto; y el componente se cerraba a sí mismo asignando su propia prop, compitiendo con los dos padres que ya la cierran. Ahora se relee en cada apertura y cerrar lo decide quien abrió. Guarda declarada, no barrera: la pantalla tampoco lo ofrece sobre un radicado anulado —un documento sin efecto no recoge firmas—, y esa comprobación vive solo en el cliente: el backend no la impone.

  • feat(frontend): una cadena de firmas sobre un expediente no se podía abrir desde ninguna pantalla (E06, ADR-016, RF-FIR-04) Por qué: el proxy /firmas/api/requests estaba escrito y probado, y ninguna vista lo llamaba. /borradores tiene su propio circuito —visto bueno y firma sobre un texto que aún se edita—, pero los otros dos tipos que el backend admite, expediente y radicado, no tenían por dónde abrirse una cadena: /firmas era una bandeja que firmaba lo que otro creaba, y para esos dos no había otro. No es una capacidad hipotética ni vestigial: el propio sembrado del entorno de pruebas crea las dos, y del expediente dice literalmente que «es donde una entidad la usa de verdad». Capacidad construida, probada, sembrada y sin puerta. Cómo: un modal reutilizable compone los turnos en orden —la posición en la lista es el turno— y los abre desde la ficha del expediente, con el mismo par de permisos que firmar su índice (USUA_PERM_EXPEDIENTE + PERM_FIRMA). Todos los turnos son de firma, que es lo que distingue este acto del circuito del borrador: aquí lo que se firma ya está fijado y no hay texto que revisar antes de producirlo. Si el expediente ya tiene una cadena viva, la pantalla la enseña —con el nombre de cada firmante, no su identificador interno— y no deja abrir una segunda; una persona repetida tampoco, porque el segundo turno no añade respaldo y hace aparentar más del que hay. El catálogo de personas se reutiliza del que ya existe (genérico por dentro, GET /auth/users/pickable) en vez de duplicar el proxy: dos proxies contra el mismo endpoint se desincronizan. Impacto: el acto que el entorno de pruebas siembra ya se puede hacer desde la interfaz. Comprobado de punta a punta en el sistema: abierta desde /expedientes/{id}, aparece en la bandeja del firmante como «Expediente · Turno 1 · FIRMA».

  • feat(frontend): la bandeja de firmas no ofrecía el segundo factor más fuerte, teniéndolo activo (E06 Inc.2, RF-FIR-13) Por qué: firmar un turno de una cadena sobre un documento clasificado exige segundo factor, y el backend admite dos en ese punto —el código de la app autenticadora o un OTP enviado por correo—, con el primero ganando siempre que llegue. El asistente de firma era el único sitio donde esa rama no existía: ante la petición de segundo factor emitía un desafío y un correo, sin mirar si quien firma tiene app autenticadora. Se vio recorriendo B6 con operador, que tiene TOTP activo y firmó igualmente por correo. El efecto no es cosmético: ata cada firma de cadena a que un correo salga del sistema, llegue y se lea antes de que el código venza —y el correo es la pieza que más veces se ha caído en este proyecto—, mientras el factor más fuerte, ya enrolado por el usuario en su perfil, no se usa nunca. La capacidad estaba construida en el servidor y sin ofrecer en la pantalla. Cómo: ante 2fa_requerido el asistente consulta una sola vez el estado del segundo factor del propio firmante y, si está activo, pide el código de la app sin emitir nada —ni desafío ni correo—. El correo no desaparece: queda como salida explícita («no tengo la app a mano»), porque perder el teléfono no puede dejar a nadie sin poder firmar. Esa salida no se ofrece en el camino de la credencial personal (totp_requerido), donde el backend no admite el correo y ofrecerlo sería prometer una vía cerrada. Si el estado no se puede averiguar se degrada al correo, que es el camino que existe siempre. Impacto: quien tiene app autenticadora firma con ella y sin depender del correo; quien no la tiene ve exactamente lo de antes.

Documentation

  • docs(roadmap,changelog): el hallazgo del limitador que salió de una prueba «inestable» (E18, ADR-020) Por qué: asistente-dock.spec.js fallaba en las tres corridas completas de la suite autenticada y pasaba aislado. La tentación era llamarlo inestable y seguir; el log del gateway dice otra cosa: GET /api/v1/assistant/alcance devuelve 429 con "tenant": null, "usuario": null y 1,6 ms de duración. Esos null son el dato: el limitador rechaza antes de autenticar, así que la cuota va por IP y no por persona. Impacto declarado, sin tocar nada: en una entidad pública lo normal es una oficina entera detrás de un NAT —cincuenta funcionarios con una sola IP saliente compartiendo cuota—, y el primero que trabaje deprisa deja a los demás con 429. No se «arregla» subiendo el límite ni metiendo esperas en el test: eso taparía el hallazgo. Queda como decisión pendiente —eje de la cuota: IP, usuario autenticado, o ambos con límites distintos—, porque contar después de autenticar gasta validación en peticiones anónimas y contar antes reparte el castigo entre inocentes: hay que elegir a sabiendas. Lo que enseña sobre el método: una prueba que pasa aislada y falla en la suite no es flaky por definición — aquí el patrón era 3 de 3, o sea determinista, y el motivo estaba en el log del servidor, no en el test.

  • docs(roadmap): la migración a DataTable se cierra decidiendo, no migrando (E22) Por qué: quedaba una casilla abierta —/archivo-fisico— que en realidad pedía una decisión, no trabajo: añadir soporte de árbol al componente o dejar la pantalla con su render propio. Un pendiente que es una decisión sin tomar pesa igual que uno que es trabajo sin hacer, y dirige atención a un frente que no existe. Cómo: se deja el render propio, con el motivo escrito. Y el motivo resultó más fuerte que el que la casilla nombraba: no es solo que DataTable no modele jerarquía, es que el árbol de ubicaciones carga los hijos bajo demanda al desplegar cada nodo, así que las filas ni siquiera están todas en memoria — DataTable parte de tener el conjunto para ordenarlo, filtrarlo y paginarlo. Darle un árbol perezoso no es añadirle una prop, es cambiarle la premisa, y esa premisa la pagarían las siete pantallas que hoy lo usan bien. Comprobado antes de cerrar, para que la decisión no tape deuda: las seis tablas de la vista ya cumplen los dos contratos que el componente garantizaba —envoltorio overflow-x: auto y región alcanzable con teclado (técnica WCAG SCR29)— y el objetivo táctil es una regla global bajo pointer: coarse, no una propiedad de DataTable. Impacto: de las tres casillas del bloque, dos son decisiones de no migrar con su motivo y la tercera se había descartado por premisa falsa. El bloque queda cerrado.

  • docs(roadmap): de siete casillas abiertas, ninguna era trabajo pendiente Por qué: al terminar la tanda quedaban siete - [ ], y el recuento sugería siete frentes. Verificadas una a una: una llevaba cerrada desde hacía tiempo sin tachar —las lecturas de TRD y PINAR, comprobada ahora línea a línea en los routers: el circuito exige USUA_PERM_TRD (archive-service/app/routers/trd.py:363) y PINAR entero lleva USUA_PERM_ADMIN por admisión en su APIRouter (tenant-service/app/routers/pinar.py:77)—; tres eran decisiones ya tomadas con la casilla mal puesta, y dos de ellas estaban incluso tachadas en su propio texto mientras la casilla decía lo contrario; dos son futuro planeado y no deuda; y la última espera una decisión de producto. Impacto: quedan tres casillas y solo una es trabajo. Lo que enseña: una lista cuya casilla contradice a su propio texto no informa, desinforma — da la sensación de siete frentes donde hay uno, y esa sensación dirige trabajo. Es el mismo defecto que el roadmap persigue en el producto —el dato correcto guardado donde nadie lo mira— aplicado al instrumento que sirve para encontrarlo. Es además la tercera vez en esta misma sesión que aparece un pendiente cerrado sin tachar.

  • docs(plan-pruebas): B2 recorrido — el circuito de versiones de la TRD funciona de punta a punta Por qué: los instrumentos que gobiernan retención y disposición final nunca se habían recorrido desde la pantalla, y son los que la ley exige aprobar y convalidar antes de que rijan. Cómo: circuito completo sobre una serie —borrador, aprobación por acta del comité, convalidación con acto administrativo, versión nueva y relevo—, comprobando cada paso contra la base y contra el registro de auditoría, más el gate invocado sin pasar por el botón. Impacto: primer bloque del plan que cierra sin defectos; queda documentado por qué aguanta, un límite declarado y un falso positivo propio anotado a propósito.

Changed

  • feat(storage,infra)!: MinIO cerró su proyecto libre; el almacén pasa a RustFS, y el motor lo eligieron las pruebas (E10, E12, ADR-027, Ac. AGN 001/2024 art. 4.3.2.4 P1) Por qué: MinIO archivó su proyecto de código abierto y retiró sus imágenes de todos los registros públicos. Verificado con control positivo —alpine, node y keycloak se descargaban con normalidad en el mismo momento, así que no era la red ni un límite de tasa—: quay.io responde denied y lista cero tags, Docker Hub da 404, el binario de dl.min.io da 410 Gone, y el rescate por el digest que el propio compose tenía anotado tampoco resuelve. Sin la imagen en caché local, la pila entera dejaba de arrancar. La lección, que vale más que el incidente: un pin protege de que te cambien la versión, no de que te la quiten — un registro público es un canal de distribución, no un archivo. Cómo se eligió el sustituto: no por folleto. Se escribió una batería de conformidad WORM contra un almacén S3 real (test_worm_object_lock_real_s3.py), con siete pruebas sobre lo que el código realmente usa —COMPLIANCE (76 usos), legal hold (122), Object Lock de bucket, versionado, copy_object heredando el candado, re-sellado de clave bloqueada— y cada una con control positivo y negativo. Medido: RustFS 1.0.0 → 7/7, MinIO (línea base) → 7/7, Garage → 0/7. El control negativo no es decorativo: sin él, una batería que aprueba a todos no mide nada. La prueba decisiva usa la credencial raíz, porque que el borrado falle ante root es lo que distingue COMPLIANCE de GOVERNANCE — y en GOVERNANCE quien levanta la retención es la misma credencial del servicio que escribe. Descartados con su motivo: SeaweedFS aceptaba PutObjectRetention en COMPLIANCE devolviendo 200 y dejaba borrar igual, y sus arreglos están fusionados pero no publicados; Garage no tiene Object Lock y no puede tenerlo (su diseño carece de consenso); Ceph RGW deja de ser un servicio de Compose para ser un clúster; compilar MinIO desde el fuente era el puente recomendado mientras hubiera datos que migrar, y se descartó al confirmarse que el fondo entero es de pruebas — su único argumento era evitar la migración y su problema, cero parches de seguridad nunca más. Daño colateral reparado, que nadie había notado: infra/backup.sh y infra/restore.sh invocaban la imagen minio/mc, también borrada — el respaldo y la restauración estaban rotos, incluido mc retention set, que es lo que repone la retención WORM al restaurar. No se veía porque el arranque falla antes. Migrados a aws-cli, con un helper único en vez de seis docker run repetidos. Y una mejora que vino con el cambio: mc retention set solo aceptaba duraciones en días, así que reponer una retención la redondeaba hacia arriba y movía la fecha de vencimiento en cada restauración; put-object-retention admite RetainUntilDate, así que el plazo vuelve a ser el que la entidad fijó una sola vez. Probado de punta a punta: respaldo, restauración, retención repuesta y borrado del objeto restaurado rechazado con AccessDenied citando la fecha. Lo que NO cambia, declarado: los nombres MINIO_*, el servicio minio del compose y los buckets orpycamcp-{slug}-documents. Son el contrato interno del cliente S3 —miniopy-async habla S3 estándar— y viven en 13 ficheros; renombrarlos multiplicaría el riesgo sin ganar nada. Léase MINIO_* como «el almacén de objetos». Volumen nuevo: la compatibilidad on-disk de RustFS con MinIO la marcan sus propios autores como preview, así que el fondo se resiembra en vez de reinterpretarse, y minio-data se conserva declarado y sin montar porque borrarlo es irreversible.

Fixed

  • fix(frontend,mcp): el panel decía «Vas al día» a quien tenía 101 pasos pendientes, las alertas callaban 127 plazos vencidos, y el asistente no se podía abrir (E22, E18, RF-RAD-04, RF-UI-06) Tres defectos que ninguna suite podía ver y que salieron recorriendo la aplicación con el navegador contra la pila levantada. 1. El panel contaba desde un estado que el flujo no escribe. Los módulos 01 «Radicados pendientes» y 03 «Alertas» filtraban radicados en distributed/in_progress. Repartir y tramitar por el flujo avanzan los pasos (flow_steps), no el radicado: su estado solo cambia si alguien pulsa a mano los botones de la vista Lista de la bandeja. Medido: los 208 radicados en registered, la bandeja con 101 pasos pendientes y el panel diciendo «No tienes radicados pendientes»; 127 radicados con el plazo de respuesta vencido y el panel diciendo «No hay radicados con fecha límite de respuesta cercana». Cómo: los pendientes salen de GET /workflows/inbox, la misma consulta que el tablero de /bandeja —que el panel y la bandeja cuenten desde la misma fuente es lo que impide que vuelvan a contradecirse—, y las alertas incluyen registered, porque un plazo de respuesta corre desde que se radica. El enlace «Ver toda la bandeja» apuntaba a ?status=distributed y abría la lista vacía. Los tests consagraban el defecto: su doble construía radicados en distributed, un estado al que el flujo no lleva nunca. Verificado por mutación: con el filtro anterior caen 8 pruebas. 2. El panel del asistente quedaba fuera de la pantalla. layout.scss traía desde el andamiaje una regla global .assistant-dock { transform: translateX(100%) } de un diseño en el que el panel se mostraba con .is-open; el componente actual se monta con {#if open} y no declara transform, así que heredaba el de la hoja global. El fondo se oscurecía y el panel se pintaba en x=1901 con un viewport de 1916. Y la prueba e2e lo aprobaba: toBeVisible() de Playwright solo exige caja no vacía y que el elemento no esté oculto — un panel desplazado fuera del viewport es «visible» para esa aserción. Ahora exige toBeInViewport({ ratio: 1 }), y un guardián (src/pruebas/el-panel-del-asistente-no-lo-estila-una-hoja-global.test.js, con control positivo) impide que una hoja global vuelva a estilar la clase raíz del panel. 3. El asistente prometía algo falso al no estar disponible. Sin modelo configurado, el panel decía «Esta función se activará próximamente», un resto de cuando el backend no existía. El backend juntaba además dos causas bajo un mismo texto: ahora 503 assistant_unavailable dice cuál —sin modelo configurado (esperar no lo arregla, hay que avisar a quien administra) o proveedor caído (basta reintentar)—, el proxy SvelteKit reenvía ese motivo en vez de sustituirlo por uno fijo, y el panel lo muestra. Verificado por mutación en el backend. Verificación: 33 pruebas del panel, 74 del asistente en el frontend y 9 del endpoint en mcp-server; ESLint, Prettier y lint.sh mcp-server limpios; y en el navegador real, el panel con 101 pendientes y las alertas encabezadas por los vencidos de hace 14 días, el asistente abriéndose dentro de la pantalla y el aviso con el motivo real. Queda abierto, y es una decisión de dominio, no un arreglo: el estado del radicado y el avance del flujo son dos máquinas que no se hablan. La bandeja ofrece Devolver solo a radicados en distributed/in_progress, y la búsqueda filtra por esos estados: los dos se quedan sin nada que mostrar mientras el flujo no mueva el radicado.

  • fix(storage): el sistema ordenaba la retención WORM y no volvía a mirarla nunca (E10, ADR-023, ADR-027) Por qué: set_object_retention aparecía en tres ficheros y get_object_retention en ninguno. Tras escribir la retención, el servicio insertaba retention_mode='COMPLIANCE' en preservacion_worm_objeto y un evento PREMIS FIJACION con resultado='ok' — es decir, atestiguaba una protección que jamás había comprobado, sobre la palabra del almacén. Con MinIO era tolerable por reputación; al cambiar de motor dejó de serlo, porque es exactamente donde fallan los candidatos: SeaweedFS aceptaba la orden en COMPLIANCE y permitía borrar igualmente. Con un almacén así, el acta de transferencia —con su huella y su firma XAdES— afirma una garantía inexistente: un documento criptográficamente impecable y falso en su contenido, peor que no tener WORM. Y con un efecto perverso: el gate de disposición bloquea los borrados legítimos apoyándose en esa fila, mientras que el borrado ilegítimo en el almacén no lo impide nadie — el control funciona al revés de su propósito. Cómo: verificar_retencion_aplicada relee la retención recién escrita y aborta si el modo no coincide, si la fecha es anterior a la pedida o si el almacén ni siquiera puede responder; se invoca fail-closed tras proteger el índice o el acta, tras sellar el AIP —antes de replicarlo, porque replicar un objeto cuya protección no se pudo confirmar sería propagar la duda a la segunda copia— y tras renovar la retención, donde un comentario afirmaba que la llamada «ya confirmó la extensión del lado del servidor», que es justo lo que un 200 no demuestra. Mismo patrón que la verificación de fixity previa: ante la duda, no se fija y no se asienta. Verificación: seis pruebas de la lógica del verificador (incluido el control positivo, sin el cual un verificador que siempre falla «aprobaría» todo) más las siete de conformidad contra el almacén real. El doble de pruebas pasa a recordar lo que se le pidió y devolverlo: representa a un almacén que cumple, en vez de afirmarlo gratis.

  • fix(frontend): un texto invisible de un píxel desplazaba la página entera, y el medidor acusaba a la tabla (E22, RF-UI, WCAG 2.1.1) Por qué: cinco pantallas —/admin/trd, /admin/tvd, /admin/usuarios, /archivo-fisico y /borradores— desplazaban el documento a lo ancho en móvil hacia una franja vacía. El culpable era un span.sr-only: texto para lector de pantalla, 1 px, position: absolute, dentro de las cabeceras de las tablas anchas. Un absolute lo recorta el overflow de un ancestro solo si ese ancestro es su bloque contenedor; sin ninguno posicionado en la cadena se resolvía contra el documento, así que el envoltorio recortaba la tabla a la perfección y la página se desplazaba igual. Cómo: position: relative en todo envoltorio que recorta en horizontal (20 ficheros), y guardián que exige esa condición. Medido en vivo antes y después: 827/612/708/580/461 → 390 exactos; las 51 pruebas de adaptabilidad pasan. Los tres síntomas apuntaban al inocente, y de ahí los cinco días: la franja salía vacía —porque el culpable es invisible y mide 1 px—, ocultar la tabla «curaba» el problema —porque el span vive dentro de ella, lo que la puso en el banquillo— y el propio test la acusaba en cada corrida. La acusación era del instrumento: getBoundingClientRect() ignora el recorte de los ancestros, así que una tabla de 917 px perfectamente contenida en un envoltorio de 294 px reporta right = 917. Comprobado pasando el medidor viejo por una página mínima sana —documento a 390 px, sin desplazamiento—: acusaba al mismo reparto exacto, table, thead, tr, th, th. Un culpable que aparece también cuando no hay delito no es una pista, y antes de perseguir lo que un instrumento señala hay que pasarlo por un caso sano. Lo que encontró al culpable en una sola corrida, tras días de teoría: bisección por el navegador —ocultar cada elemento y quedarse con el más profundo que devuelve scrollWidth al viewport— y un repro mínimo en file:// sin backend ni sesión, que además demostró por qué había que mirar fuera del main: app-layout__content declara overflow-y: auto y CSS no admite un eje auto con el otro visible (computa el segundo a auto), así que nada dentro de él puede inflar el documento. El guardián va blindado contra una trampa propia: quita los comentarios antes de mirar, porque el comentario que acompaña a la corrección contiene la cadena position: relative y un detector ingenuo se aprobaría a sí mismo. Con control positivo, control de vacuidad, y caída por mutación que nombra el fichero.

  • fix(frontend): la suite no falla en un Node viejo: se descompone, y el destrozo no se parece a su causa (E22) Por qué: ejecutar las pruebas del frontend desde un host con Node 18 produce nueve rojos repartidos en seis ficheros que no tienen nada que ver con el código que se esté tocando: cinco guardianes ni siquiera cargan (process.getBuiltinModule llegó en Node 22.3) y los cuatro casos de handleError revientan por el crypto global (estable desde Node 19). Y ya había costado un diagnóstico antes: aquella vez se corrigió subiendo Dockerfile.dev de node:18 a node:22, con la lección escrita en el propio Dockerfile — y volvió a pasar, porque aquella corrección arregló la imagen y no la puerta por la que se cuela la versión equivocada. Un aviso que se lee una vez no es una barrera. Cómo: vite.config.js comprueba la versión mayor y, si no llega a 22, para con un mensaje que dice qué pasa, por qué esos rojos no son del cambio en curso y cuál es la orden Docker para ejecutarla bien. Va en el config de Vite y no en engines de package.json porque engines sin engine-strict solo advierte al instalar, y lo que hay que parar es la ejecución — y este archivo lo cargan por igual vitest, el servidor de desarrollo y el build. Verificado en las dos direcciones: en el host (Node 18) para con el mensaje; en node:22-alpine no estorba y la suite entera pasa 2 131 de 2 131. Comprobado de paso que ni producción ni CI estaban afectados: las dos imágenes y el job test-frontend ya son node:22-alpine.

  • fix(frontend): quedaba una cuarta copia del inicio de sesión, y era la que funcionaba (E22) Por qué: el trabajo que unificó el login de la suite autenticada en _sesion.js dejó escrito en el propio helper que estaba «copiado en tres archivos». Eran cuatro. La de asistente-dock.spec.js se quedó fuera precisamente porque era la que funcionaba: las otras dos caían por un localizador ambiguo y por eso se miraron; esta no daba síntoma, así que nada la delató. Un recuento que se cree completo no informa de menos, afirma de más — y un guardián escrito entonces habría encontrado cero culpables con una copia viva delante. Cómo: la copia se retira y las specs pasan a heredar storageState; ninguna teclea ya credenciales. Lo que la copia tenía de propio no era el login: era la espera de hidratación —el botón del asistente está en el HTML servido desde el primer byte, pero su on:click no existe hasta que Svelte toma el control—, y esa se conserva en su spec, que es de donde es. bandeja-operador.spec.js deja también de iniciar sesión por test: sus dos casos solo leen. Guardián (src/pruebas/una-sola-puerta-de-sesion.test.js): ninguna spec puede tocar el formulario de Keycloak, con tres excepciones declaradas —el helper, el setup y la prueba del propio inicio de sesión—, más control positivo, control de vacuidad y una comprobación de que las excepciones existen de verdad: una lista de excepciones que nombra archivos inexistentes envejece a permiso en blanco. Verificado por mutación: reintroducir un page.fill("#password") en el dock lo pone en rojo con el nombre del culpable. Impacto: una sola puerta de sesión, comprobable. Corregidas de paso dos afirmaciones caducadas del README de la suite: decía que sin credenciales «los casos que necesitan identidad se saltan solos» —hoy el proyecto setup falla y se lleva la suite, que es deliberado: una suite que se salta sola informa igual que una en verde— y publicaba un «6 passed» de cuando había seis casos.

  • fix(frontend): la barra superior desbordaba en las 17 pantallas de móvil, y 51 pruebas iniciaban sesión 51 veces (E22, RF-UI) Por qué: la suite de adaptabilidad autenticada, al correrse por primera vez contra la pila levantada, dio 35 verdes y 17 rojos — y los 17 eran todos los casos de móvil. Portátil y escritorio pasaban enteros, lo que ya apuntaba a algo global y no a diecisiete defectos de pantalla. Lo era: topbar-actions ocupaba 385 px empezando en x=164 sobre un viewport de 390, o sea terminaba en 549. La causa es el min-width: auto que traen por defecto los hijos de un contenedor flex y que les impide encoger por debajo de su contenido; como la barra es común a todas las vistas, fallaban todas. Cómo: min-width: 0 en el contenedor de acciones y en sus hijos, y el selector de dependencia acotado (220 px, 104 en pantalla estrecha) sin esconderlo: es el único sitio del sistema donde se cambia de dependencia, y ocultarlo en móvil quitaría la capacidad en vez de ajustarla. Verificado en vivo: la barra pasa de terminar en 549 a terminar en 374. De 17 fallos a 5. Y el carril entero pasa a autenticarse una vez: un proyecto setup de Playwright inicia sesión y guarda storageState, del que dependen los demás — antes cada una de las 51 pruebas hacía su propio login contra Keycloak. La corrida baja a 1,2 minutos. login-flow.spec.js queda excluido del estado a propósito: es la prueba que ejercita el inicio de sesión, y heredar una sesión ya abierta la dejaría midiendo nada. Quedan 5 en rojo a propósito — ver el hallazgo abierto en el roadmap; el criterio no se relaja para ponerlas en verde.

  • fix(frontend): tres tamaños distintos para el título de una página, y ninguno era una decisión (E22, ADR-011) Por qué: la regla global de encabezados solo fijaba la familia, así que el tamaño lo ponía quien escribiera cada pantalla. De las 49 con <h1>: 27 en --op-font-xl (22 px, que es el token de H2), 16 en --op-font-lg (18 px, que no es ningún nivel de encabezado) y 5 sin declarar nada, heredando el 2em del navegador. Y el pendiente que lo describía estaba mal en las cifras y en la premisa: decía «35 no declaran tamaño» —son 5— y «hace falta decidir cuál es el tamaño de un título». No hacía falta: la escala v1.0 lo decía desde siempre, $op-font-2xl: 28px; // = H1 v1.0… y no lo usaba ni una sola pantalla. Un token que dice ser el H1 y que ningún h1 usa es un comentario falso con forma de valor. Cómo: token semántico propio --op-font-title-page, aplicado por la regla global sobre h1 —a secas, para alcanzar también a las cinco que no declaraban nada, que eran las que más se salían— y retiradas las 43 declaraciones por pantalla. El valor sale de la práctica (22 px) y no de la tabla: entre subir 43 vistas a un tamaño que nadie ha visto nunca y adoptar el que 27 ya tienen —mirado por quien las construyó, una a una— se eligió lo segundo, y se corrigió la anotación de la escala en vez de dejarla prometiendo algo que el producto contradice desde el primer día. $op-font-2xl conserva sus 28 px: cinco sitios legítimos lo usan para cifras destacadas y el hero, así que cambiarle el valor para arreglar los h1 habría sido una regresión en todos ellos. La landing queda como excepción declarada, con su clamp() de marca. Verificación: guardián con lista de excepciones por admisión y control de vacuidad —caen por mutación tanto quitar la regla global como que una pantalla vuelva a declarar lo suyo—, y la medida real en navegador: tres pruebas de Playwright comprueban que el h1 renderizado mide exactamente el token, que es justo lo que ningún guardián de CSS puede saber. Dos defectos propios, los dos de lo mismo: mi verificador y luego mi propio guardián leían el bloque SCSS con una ventana fija de 400 caracteres, y se colaban en la regla siguiente — acusaban al subtítulo del tamaño del título. Ahora el cuerpo de la regla se delimita contando llaves.

  • test(frontend): la adaptabilidad pasa a medirse con un navegador de verdad (E22) Por qué: el defecto de ancho y de tablas recortadas lo reportó una persona usando el producto con la suite entera en verde, y no fue mala suerte: Vitest corre sobre jsdom, que no calcula layout. Dos mil pruebas no sabían cuántos píxeles ocupa nada, así que ningún guardián de CSS podía ver el resultado — solo la regla. Cómo: el proyecto ya tenía un carril Playwright, así que el hueco se cierra donde corresponde. e2e/adaptabilidad.spec.js mide las cuatro páginas públicas en tres anchos —390 de móvil, 1366 del portátil donde se reportó y 1920 del escritorio donde más espacio se desperdiciaba— y corre en el smoke, sin backend. e2e/authenticated/adaptabilidad-app.spec.js hace lo mismo con 17 pantallas de trabajo, 51 pruebas, con la pila arriba y credenciales por entorno. El medidor vive una vez (e2e/_adaptabilidad.js) porque copiarlo sería garantizar que dentro de un mes midan cosas distintas — que es justo lo que ya pasó con el helper de inicio de sesión de ese mismo directorio. Y el control positivo cazó un defecto del propio medidor, del mismo tipo que venía persiguiendo: comparaba scrollWidth con el ancho del contenedor, buscando un desbordamiento que no ocurre. Una tabla no desborda: comprime, estrecha sus celdas hasta partir el contenido y scrollWidth acaba valiendo lo mismo que el contenedor — el síntoma se borra a sí mismo. Ahora se mide el ancho natural (width: max-content un instante, y se restaura), que es lo único que delata una tabla estrujada.

  • fix(frontend): revisión de adaptabilidad de las 51 pantallas (E22, WCAG 2.1.1) Por qué: lo pidió el usuario tras el arreglo del ancho — «que las pantallas se ajusten a los dispositivos que las usan» —, así que se recorrió el árbol entero por los ejes que rompen esa adaptación en vez de mirar pantalla por pantalla. Qué se encontró y se cerró: ocho tablas sin contenedor desplazable —tres de 17 columnas en /admin/eliminacion, dos en /admin/trd, dos en los paneles de PINAR y una en la consulta pública—, todas envueltas en .op-table-scroll con el trío de accesibilidad completo (tabindex="0" para alcanzarlas sin ratón, role="region" para que sean parada de navegación y aria-label para que el lector diga de qué tabla se trata). Tres rejillas de formulario (/admin/parametros, /admin/trd, /admin/tvd) que fijaban dos columnas y en un móvil de 360 px dejaban cada campo en ~150 px: pasan a repeat(auto-fit, minmax(220px, 1fr)), que pone dos si caben y una si no sin que nadie tenga que elegir un punto de corte. Y el maestro-detalle de /admin/catalogos, con la columna maestra clavada en 280 px: en un móvil quedaban menos de 80 px para el detalle, que es donde está el contenido; ahora cede con minmax y colapsa en un punto que sí está justificado —dos columnas dejan de tener sentido cuando el detalle no alcanza el ancho del maestro—, porque auto-fit no sirve para un maestro-detalle: pondría las dos columnas iguales, que es justo lo que no son. Lo que se midió y NO era defecto, para que nadie lo vuelva a perseguir: los grids auto 1fr / 140px 1fr son listas de etiqueta y valor y se adaptan solos; perfil y la landing ya eran mobile-first (min-width); los white-space: nowrap de los asuntos truncan con elipsis, que es lo correcto; y no hay un solo ancho fijo de 300 px o más en todo el árbol. Verificación: guardián que exige región desplazable para toda <table> —con control de vacuidad, porque «no encontré descubiertas» y «no miré nada» se ven igual—; cae por mutación al quitar un tabindex. Y el guardián encontró tres tablas que mi propio censo no vio por mirar solo routes/ y no lib/components/; de paso señaló dos falsos positivos suyos —un <table> mencionado dentro de un comentario y el envoltorio propio de DataTable, que ya cumplía el contrato entero desde antes de que existiera la utilidad global—.

  • fix(frontend): las pantallas dejaban el escritorio a medio usar y aun así recortaban sus tablas (E22, ADR-011) Por qué: cada pantalla decidía su propio ancho. El censo encontró 27 pantallas con nueve valores distintos —1200, 1100, 1000, 960, 900, 820, 760, 720 y 640— y 19 de ellas alineadas a la izquierda, así que el espacio sobrante no se repartía: quedaba todo a la derecha. Ninguno de los nueve era una decisión; eran nueve personas resolviendo lo mismo por separado. Las cuentas del caso peor: en un portátil de 1536 px con la barra lateral quedan 1272 px útiles, y /admin/parametros se acotaba a 900 — 372 px desperdiciados y la tabla con desplazamiento horizontal, con el hueco al lado. En un monitor de 1920 son 756. Cómo: dos tokens, porque son dos preguntas distintas y confundirlas es lo que produjo los nueve. --op-content-max para pantallas de datos, sin techo: la pantalla se ajusta al dispositivo que la usa —un portátil de 1366 o un monitor de 2560— y la tabla dispone de todo el ancho antes de tener que desplazarse; un número fijo aquí sería lo mismo que había, alguien decidiendo el ancho de una pantalla que no tiene delante, solo que una vez en vez de nueve. --op-reading-max (880 px) para formularios y texto seguido, donde el límite no lo pone el sitio disponible: un campo de una columna a 1.200 px es incómodo de rellenar y una línea de 200 caracteres se pierde al saltar de renglón; se ajusta igual hacia abajo, porque max-width nunca ensancha. 18 pantallas de datos y 6 de formulario pasan a token; las dos públicas y la landing quedan declaradas como excepción con su motivo. La otra mitad del defecto: /admin/eliminacion pinta tres tablas de 17 columnas sin contenedor desplazable, y una <table width:100%> con muchas columnas no desborda — comprime, hasta que el contenido se parte y lo que se lee es una tabla rota. Se envuelven con .op-table-scroll, utilidad nueva que recoge un patrón que ya existía con tres nombres locales distintos, incluida la técnica WCAG SCR29 (tabindex="0" + role="region" + aria-label) para que la región se pueda recorrer con el teclado y no solo arrastrando. Verificación: guardián sobre el contenedor raíz de cada página —no sobre los max-width interiores, que acotar una tarjeta es legítimo—, con lista de excepciones por admisión, control positivo del extractor y control de vacuidad: cuenta cuántas pantallas usan ya el token, porque «no encontré culpables» y «no miré nada» se ven exactamente igual.

  • fix(frontend): el objetivo táctil se aplicaba a mano, y 62 controles se habían quedado fuera (E22, WCAG 2.5.5) Por qué: el criterio existía —19 declaraciones de @media (pointer: coarse) repartidas por diez archivos— pero vivía componente a componente. Un censo de los <button> nativos del árbol encontró 62 controles sin ningún mínimo declarado: las pestañas de /admin, los cierres de modal y de cajón, la paginación de DataTable, los botones de acción de la bandeja. Repararlos uno a uno es exactamente cómo se llegó a 62 — el mismo error que ya se pagó con el estilo de code, corregido tres veces pantalla por pantalla antes de mirar la hoja global. Y es un defecto invisible donde se desarrolla: con ratón no se nota. Cómo: una regla en utilities.scss bajo pointer: coarse, con min-height/min-width y no height —es un suelo, no una talla única—, y con input[type="checkbox"]/[type="radio"] excluidos a propósito: ahí lo correcto es ampliar el área del <label>, porque forzar 44px pinta una casilla enorme en vez de una zona cómoda de pulsar. No sustituye a las declaraciones de los componentes: convive con ellas. Verificación: guardián con cinco comprobaciones; caen por mutación tanto quitar la regla como cambiar min-height por height. Y el guardián empezó midiendo su propio comentario —encontraba la mención de @media (pointer: coarse) en la prosa que hay encima de la regla—; ahora se ancla a la llave de apertura. Es la segunda vez que un comentario se aplica a sí mismo en esta suite.

  • fix(archive,frontend): GET /prestamos devolvía la tabla entera sin LIMIT (E17, RF-ARF-07) Por qué: sin page/size, sin X-Total-Count y sin cota. Los préstamos activos y los vencidos están acotados por la operación —son los que alguien tiene encima de la mesa—, pero devuelto es el histórico: crece con cada préstamo que se cierra y no deja de crecer. Y sin la cabecera, el único recuento posible era items.length, que es el tamaño de la ventana haciéndose pasar por el de la consulta. Cómo: paginación con el COUNT compartiendo el mismo WHERE que las filas, y desempate por id en el orden — fecha_prestamo es TIMESTAMPTZ DEFAULT now() y now() devuelve el instante de inicio de la transacción, así que todo lo insertado en un mismo lote comparte timestamp exacto: sin clave única final, paginar sobre ese conjunto repite una fila y pierde otra sin síntoma. El listado por unidad se deja sin paginar, y se dice por qué: ahí la cota la pone el objeto. En la pantalla, el contador sale del servidor y aparece un «cargar más» que trae la siguiente página en vez de traerlo todo de golpe. Se confirma lo ya verificado: esta consulta no une expedientes, así que no hay nivel de seguridad que filtrar — se sospechó una fuga de clearance y no lo es; faltaba paginación, no un gate. Verificación: cinco pruebas contra Postgres real y seis del contrato del proxy; caen por mutación el COUNT sin WHERE, el desempate y la cabecera que el proxy conserva. La mutación del desempate volvió a no caer midiendo por páginas —el mismo plan devuelve los empates en el mismo orden por casualidad—, así que se mide comparando la secuencia contra id DESC, igual que en /documents.

  • fix(frontend,auth): ocho pantallas pedían size=200 creyendo que era holgura — es el TOPE del backend (E04, ADR-026, D-10) Por qué: el comentario decía «para traer el árbol completo de una vez», y GET /trd//tvd declaran size: int = Query(..., le=200). A partir de la fila 201 la vista se quedaba corta sin que nada lo delatara, con el X-Total-Count que el servicio ya emite delante y sin que ninguno de los ocho lo mirara. El daño no es «faltan filas»: /admin/trd y /admin/tvd arman jerarquía por parent_id, así que una subserie cuya serie padre quedó fuera del corte no aparece en ninguna rama — el árbol no se ve incompleto, se ve distinto. Y en un selector de personas o de series, lo que no entró en la ventana no se lee como una lista corta: se lee como que esa persona o esa serie no existe. Cómo: traerListaCompleta pide páginas hasta cubrir el total y, si aun así no cabe (tope de 25 páginas), lo declara en vez de devolver una lista corta con cara de completa — un tope sin aviso solo mueve el mismo defecto más lejos. Para el ojo, un role="status" en /admin/trd, /admin/tvd, /admin/metadatos y /admin/flujos/reasignacion diciendo cuántas de cuántas se están mostrando. Se para por el tamaño del lote y no por el total, para que un servidor que no emita la cabecera no deje el bucle sin fin. La otra mitad, en el backend: GET /auth/users/pickable no pagina —recorta con un LIMIT y ya está—, así que no había nada que mirar; ahora emite X-Total-Count con los usuarios activos que hay, no con los que caben, y los tres selectores piden 500, que es su tope real. Impacto: el pendiente decía «/admin/trd y /admin/tvd»; el censo encontró ocho, incluido /archivo-fisico. Verificación: siete pruebas del helper —incluido el caso de 250 series, el fallo a mitad que conserva lo traído marcándolo incompleto, y el servidor sin cabecera—; y en auth-service, un control positivo de que el total se cuenta con el mismo filtro que la lista: un aviso que cansa es un aviso que se ignora.

  • fix(workflow): las reglas que enrutan la correspondencia se creaban, editaban y borraban sin dejar rastro (E05, ADR-010) Por qué: rules_service.py tenía cero referencias a audit, frente a 49 en su hermano workflow_service.py del mismo servicio. Y no es un CRUD cualquiera: una regla decide a qué dependencia se asigna cada radicado entrante, así que crearla es redirigir correspondencia oficial que todavía no ha llegado, y borrarla es hacer que deje de redirigirse. La autorización sí estaba —USUA_PERM_ADMIN por endpoint, cerrado en su día—, pero autorizar dice quién puede; el asiento dice quién lo hizo, y esa mitad faltaba entera. Cómo: los tres actos asientan dentro de la misma transacción que escriben —si la cadena inmutable no puede recibirlo, la regla no se crea—. workflow.regla_creada lleva el contenido de la regla, porque «se creó la regla tal» no responde la pregunta que se le hace a la traza: a dónde asignaba y con qué condiciones. workflow.regla_modificada lleva solo los campos que la petición tocó, cada uno con su antes y su despues, leyendo el estado previo con FOR UPDATE dentro de la transacción para que no se cuele una edición entre medias; volcar la regla entera enterraría el cambio —lo único que se viene a mirar— entre once campos iguales. Y workflow.regla_eliminada lleva el retrato completo, porque el DELETE es físico y el asiento pasa a ser el único sitio donde sobrevive lo que decía: sin él, «¿por qué dejaron de asignarse a Jurídica?» queda sin respuesta posible. object_ref es el UUID y no el nombre — renombrar una regla no puede partir su historial en dos objetos aparentemente distintos—. PATCH y DELETE pasan a exigir X-User-Id, que POST ya exigía. Verificación: caen por mutación los tres asientos, el detalle del antes/despues y el retrato del borrado; y hay dos controles positivos por el lado contrario —un 404 y un PATCH vacío no asientan nada, porque llenar la cadena de actos que no ocurrieron es la forma de volverla ilegible—. Defecto propio de camino: escribí a ojo los índices de los argumentos del asiento y estaban desplazados, así que los tests fallaban por la razón equivocada; ahora un test los ancla al SQL real de audit.append en vez de a lo que yo recordara de él.

  • fix(scripts): el gate de lint ejecutaba el texto de sus propios comentarios (ADR-019) Por qué: scripts/lint.sh pasa un guion largo al contenedor dentro de sh -c "...", y esa cadena va entre comillas dobles: ahí los acentos graves siguen siendo sustitución de comandos aunque estén detrás de un #, porque el host expande la cadena antes de que exista ningún shell que reconozca comentarios. Dos comentarios que citaban `set +e` y `sh -c` se ejecutaban en el host en cada invocación: el segundo imprimía sh: 0: -c requires an argument —un mensaje que parece del contenedor y no lo es— y el primero anulaba el set -euo pipefail del propio guion. El gate llegó a imprimir «✓ document-service OK» y salir con 127. Cómo: los dos acentos escapados, y un guardián en shared/tests/ que mira solo dentro de la cadena que viaja a sh -c —fuera de ella hay diez acentos legítimos en comentarios de bash, y un guardián ruidoso acaba desactivado— con su control positivo, porque una comprobación que solo sabe decir «no encontré nada» hay que verla encontrar algo al menos una vez. Impacto: el gate vuelve a ser estricto y su código de salida vuelve a significar algo. Es la variante ejecutable del comentario falso como vector: aquí el comentario no miente sobre el código — es código.

  • fix(frontend): el radicado guardaba a quién iba dirigido y ninguna pantalla lo leía (E01, RF-RAD-05) Por qué: el trabajo del directorio dejó cerrado el circuito de escritura —radicar, ventanilla ciudadana, correo y el asistente atan ya remitente y destinatarios al radicado, con su contacto congelado—, pero la lectura se quedó a medias: /envios era el único sitio que los consultaba, y solo para precargar el destinatario postal. El detalle del radicado seguía describiendo al actor desde sender_name, que lleva únicamente al principal. Así que una Salida con tres destinatarios se veía exactamente igual que una con uno, contradiciendo la regla del propio dominio; y un dato que nadie mira no se puede comprobar: el vínculo podía dejar de escribirse y nada lo delataba. Cómo: el cajón de detalle de /bandeja lee los actores del radicado y los enumera separados por rol —primero de quién viene, después a quién va—, con entidad, contacto y lugar. Se pide la copia congelada y no la ficha vigente del catálogo: «a quién iba dirigido este radicado» y «dónde vive hoy esa persona» son preguntas distintas, y responder la segunda a quien hace la primera es información errónea. Cada actor se pinta en columna y no en fila, porque la dirección es prosa y un contenedor horizontal la trocea. Sin actores —un Interno, o un radicado anterior al modelo— la sección sencillamente no aparece: es un caso legítimo, no un fallo. Impacto: el eje completo pasa a ser observable desde la interfaz. Verificación: la mutación que se queda solo con el principal —que es el defecto de sender_name— pone en rojo los tres tests que miden el caso de varios y deja verde el control de la lista vacía.

  • fix(gateway): el gateway se comía los parámetros repetidos de la URL, en silencio y en las once APIs (ADR-019) Por qué: el proxy reenviaba params=dict(request.query_params). La cadena de consulta es un multidict y dict() se queda con el último valor de cada clave, así que ?status=distributed&status=in_progress salía del gateway convertido en {'status': 'in_progress'} — comprobado ejecutándolo contra la Starlette real del contenedor, no leyéndolo. El modo de fallo es el peor que hay: 200, sin error y con menos filas; quien pidiera dos estados recibiría uno y creería que ese es el total. Y dejaba inalcanzable desde fuera cualquier parámetro repetible que un servicio declarase, por bien que lo declarase: el frontend y el mcp-server hablan siempre por aquí. Por qué ninguna suite lo cazaba, que es la parte que importa: los tests de cada servicio golpean su propia app, donde el multidict llega intacto. El defecto vivía en el salto, que no es de nadie — la misma forma que la cabecera que tres servicios leían y ninguno producía. Cómo: se reenvía como lista de pares, con el tipo anotado porque list es invariante y la firma de httpx admite valores de varios tipos. El test vive en el gateway y mira lo que se reenvía, no lo que contesta un servicio, con su control positivo: la clave sin repetir —el caso de casi todas las llamadas— tiene que seguir cruzando igual, y el orden de los valores se conserva, porque un servicio puede darle sentido y reordenar sería decidir por él. Impacto: desbloquea cualquier filtro multivalor de las once APIs. Encontrado al verificar tres pendientes del roadmap que resultaron estar mal escritos: ninguno lo mencionaba.

  • fix(frontend): el navegador descargaba 141 KB de fuentes de iconos que la aplicación no usa ni una vez (E22) Por qué: el layout importaba all.min.css de Font Awesome, que arrastra las familias solid, regular y brands con sus tres fuentes. Medido y no supuesto: 771 usos de fas/fa-solid en el árbol y cero de far/fab — así que fa-brands-400.woff2 (116 KB) y fa-regular-400.woff2 (25 KB) viajaban a cada visitante para nada. Hasta el pendiente que pedía esto traía la cifra vieja: decía «la app solo usa iconos solid y regular». Cómo: se importan el núcleo y solid. Y con ello un guardián, porque el modo de fallo es mudo: un fab fa-github escrito mañana no da error ni aviso — el glifo no existe en la fuente cargada y se pinta una caja vacía, igual que un var(--op-*) inexistente. El guardián ata las dos mitades del trato, «uso esta familia» y «la cargo», que era justo lo que no estaba atado. Impacto: 141 KB menos de fuentes y 17 KB menos de CSS en la primera carga de cada visitante, incluida la ventanilla ciudadana.

  • fix(frontend): la misma sombra escrita a mano en dos rutas públicas Por qué: la consulta ciudadana y la ventanilla pintaban sus tarjetas con 0 12px 32px rgba(12, 23, 16, 0.28), copiado en las dos. Así es como una sombra se convierte en dos sombras parecidas. Cómo: --op-shadow-sobre-oscuro, declarado como lo que es — no un escalón más de la escala de sombras, que está calculada sobre fondo claro con negro al 5 %: estas flotan sobre el fondo oscuro de las pantallas públicas y necesitan más contraste y el verde profundo de la marca.

  • fix(frontend): el velo de los diálogos se escribía a mano, con cuatro opacidades y ninguna decisión (E22) Por qué: cuatro componentes pintaban su velo con el mismo color —rgba(33, 36, 33, ·)— y opacidades 0.25, 0.35, 0.4 y 0.5. Dos de ellos, los dos cajones, diferían en 0.05: eso no es diseño, es deriva. Y cambiar el velo del sistema obligaba a encontrar cuatro literales repartidos por tres componentes y una ruta, sin nada que dijera dónde estaban. Cómo: el color vive una vez y hay dos niveles con significado, no cuatro por accidente — --op-scrim para lo que bloquea (el Modal y los cajones, que atrapan el foco) y --op-scrim-soft para el asistente, que se abre sobre la pantalla sin pretender que el resto desaparece. Un guardián prohíbe volver a escribir ese color a mano —no rgba() en general: un degradado de marca o una sombra son otra cosa— y comprueba que quien bloquea use el nivel fuerte. Impacto: el nivel del velo pasa a decir si lo de detrás se puede usar, que es lo que la pantalla le está prometiendo a quien mira.

  • fix(frontend): una fila de indicadores se anunciaba como lista y no tenía elementos de lista (E22) Por qué: los KPIs del panel de métricas iban en un role="list" cuyos hijos son Card, que renderiza un <div> sin rol. Un lector de pantalla anuncia una lista y después no encuentra sus elementos: no se puede navegar por ella, y es peor que no declarar nada. Cómo: role="group" con su etiqueta, que es lo que de verdad son — indicadores que van juntos y tienen un nombre. Comprobado de paso: los demás role="list" del proyecto están sobre <ul>/<ol> con sus <li>, donde el rol es el refuerzo conocido para que Safari no pierda la semántica al quitar las viñetas; y el que aparecía en Stepper estaba dentro de su docstring.

  • fix(frontend): cuatro defectos de accesibilidad que afectan a todas las pantallas (E22) Por qué: salieron del barrido de verificación del roadmap. (a) Button con href renderiza un <a> y le ponía role="button": el lector anunciaba «botón» y la persona pulsaba la barra espaciadora, que en un enlace no activa nada —la tecla de un enlace es Intro—. Prometer el rol obliga a implementar su teclado. Con él se caían dos atributos que no decían nada y un trozo de código muerto: esa rama solo existe cuando !isDisabled, y como isDisabled = disabled || loading, dentro de ella aria-disabled era siempre false, aria-busy siempre false y el spinner inalcanzable. (b) El atajo / movía el foco al buscador del topbar aunque hubiera un diálogo modal abierto —el asistente, el drawer de navegación, cualquier Modal—: dentro de un modal el foco no puede salir, y quien navega con teclado acababa en un sitio que su diálogo declara inexistente. (c) El botón de métricas del panel declaraba aria-controls="metrics-panel" sobre un nodo que solo existía desplegado: el lector anunciaba un control que gobierna algo que no puede encontrar. (d) EmptyState llevaba una tercera definición de .op-btn, copiada «por si Button no está importado» — tres definiciones del mismo botón divergen, y el escape que la justificaba era importar el componente. Cómo: el enlace se anuncia como enlace; la guarda del atajo pregunta por [aria-modal="true"] en el DOM y no por una bandera local, porque lo modal lo declara quien se abre y una lista mantenida a mano se queda corta con el próximo; el panel permanece en el árbol y se oculta con hidden, que lo saca del árbol de accesibilidad igual de bien; y EmptyState usa Button. Impacto: cuatro correcciones transversales. Dos tests afirmaban el defecto como si fuera el contrato —uno decía literalmente «Button con href renderiza <a role="button">, no role="link"»— y se reescribieron. Una mutación no cayó y enseñó el resto: quitar la guarda del atajo dejaba la suite en verde porque el foco se mueve tras un tick() y la aserción corría en el mismo latido; midiendo después, sí cae.

  • fix(document): un comentario aseguraba un control de permisos que ese router no tiene Por qué: el docstring del historial de acceso de un radicado decía «Gate: el mismo del router (PERM_RADI)». El router no declara dependencies, y seis lecturas —listar, consultar, anexos, descarga y este historial— no llevan permiso: la línea que decide qué se ve es el clearance, dentro de la consulta. No es un agujero, es una decisión deliberada que el catálogo del MCP ya declara así. Lo que sobraba era la frase: hacía creer protegido por permiso algo que no lo está, y quien extienda ese router apoyándose en ella daría por puesto un control inexistente — que es cómo este repositorio ha pagado ya cuatro veces el comentario falso. Cómo: el docstring dice lo que hay, y dice también lo que decía y no era cierto.

  • fix(signature,frontend): la guarda que decía «un documento anulado no se firma» comparaba con un estado que no existe (E01/RF-RAD, E06, RF-FIR-04) Por qué: al ir a implementar la barrera en el backend —declarada como pendiente en el incremento anterior— resultó que el estado real de un radicado anulado es anulado (UPDATE radicados SET status='anulado'), no cancelled, que pertenece al vocabulario de los pasos de flujo. Dos guardas de la bandeja lo comparaban con "cancelled": la de la cadena de firmas, recién escrita, y la de la anulación, que llevaba ahí desde antes ofreciendo «Anular» sobre un radicado ya anulado —botón que el backend rechaza con 409 ya_anulado—. Las dos eran inertes, y el test de la primera pasaba en verde porque usaba el mismo valor equivocado: un test que codifica el valor malo es peor que su ausencia. Además anulado no estaba en el mapa de etiquetas, así que el estado más consecuente de todos —el documento queda sin efecto y su número se conserva— se pintaba crudo en pantalla, en el mismo fichero cuyo comentario cuenta que eso ya pasó con «registered». Cómo: las tres guardas miran el valor real; anulado entra en el mapa de etiquetas y de chips; y un censo lee del fuente del servicio los estados que de verdad se escriben en radicados.status y exige que la UI los conozca todos —con su control de vacuidad, porque un censo que no lee nada aprueba por vacío—. Impacto: los dos botones dejan de ofrecerse sobre un documento sin efecto, y el estado se lee en español.

  • fix(signature): un documento sin efecto ya no recoge firmas, y la barrera está donde ocurre el acto (E01/RF-RAD, E06) Por qué: que la pantalla no ofrezca el acto es cortesía, no barrera: por el API se abría igual una cadena sobre un radicado anulado. Y un control solo en la creación tampoco bastaría, porque anular puede ocurrir entre la apertura de la cadena y cada turno: todas las cadenas ya abiertas quedarían firmables. Firmar un documento que la entidad declaró sin efecto produce un acto que la compromete sobre algo que ya no vale, y la firma sobrevive al motivo por el que se pidió. Cómo: se comprueba al abrir la cadena, al firmar un turno y al dar visto bueno —el gemelo, que va en el mismo cambio o dentro de un mes uno de los dos vuelve a abrirse—. El estado del radicado viaja con la fila de la solicitud (LEFT JOIN en la lectura que firmar hace siempre) en vez de pedirse aparte: es una consulta menos en el camino más caliente, y sobre todo deja el dato delante de quien decide, en lugar de depender de acordarse de consultarlo — que es como nacen los gates que no guardan. El control va después del no-read-up: «el radicado está anulado» le confirma la existencia del objeto a quien no lo alcanza. Y no denegar cuando no se puede establecer: un NULL significa que el objeto no es un radicado —un expediente o un borrador lo son legítimamente—, no que esté bien. Impacto: 409 radicado_anulado en los tres caminos. Verificado contra Postgres real, no solo con mocks: el JOIN trae el estado, y el identificador que no resuelve devuelve «no se sabe» y no «no está anulado».

  • fix(signature): se podían abrir cadenas de firma sobre un tipo de objeto que no se podía firmar nunca (E06, E23 Inc.4, RF-PRO-09) Por qué: documento figuraba entre los objeto_tipo admitidos de una cadena de firma sin productor: ningún servicio crea, guarda ni resuelve un objeto de ese tipo. Exigía huella de contenido y no había quién la releyera, así que la guarda de vigencia lo buscaba como si fuera un borrador, no lo encontraba y devolvía 409 documento_desaparecido — afirmando además que era «un borrador que ya no existe» sobre algo que jamás lo fue. Medido contra el sistema levantado: crear devolvía 201 y firmar 409, sin salida. Un camino declarado que no puede completarse, y una fábrica de cadenas muertas para cualquiera que lo usara. Peor de fondo: su payload_sha256 lo declaraba el llamante y no había contra qué contrastarlo, que es exactamente lo que este subsistema existe para impedir —una huella que el llamante elige no vincula a nadie—. Cómo: documento sale del allow-set; el criterio de admisión ya estaba escrito en ese mismo fichero para radicado y expediente («cuando cada uno tenga un artefacto canónico que firmar, entra aquí») y no lo cumplía. Vuelve el día que exista, con su productor. Y las dos mitades del trato —«exijo huella» y «sé resolverla»— pasan a vivir atadas: la guarda decide por un mapa de resolutores en vez de por una lista escrita aparte, y tres guardianes cruzan los conjuntos por admisión, de modo que un tipo nuevo obliga a declarar de qué lado está o la suite se cae. Impacto: no se pueden fabricar cadenas que nadie podrá firmar; el 422 lo dice en la puerta. Efecto secundario que conviene leer: retirado documento, el único tipo que exige huella (borrador) la tiene resuelta por su productor, así que a ningún llamante se le pide ya una huella — el validador que la reclamaba queda inerte, y esa es la forma correcta de que quede. Sin migración: no había ninguna cadena de ese tipo salvo la sonda de diagnóstico, retirada por la vía del producto.

  • fix(signature): el circuito de firma le contaba a un desconocido que el documento había cambiado, y por qué versión iba (E06 Inc.2, E23 Inc.4, RF-PRO-11, RF-SEG-08) Por qué: al firmar o dar visto bueno, la comprobación de que el texto sigue siendo el aprobado corría antes que las guardas de pertenencia y turno. Resultado: alguien con nivel de acceso suficiente pero ajeno por completo a la cadena recibía 409 documento_modificado con el número de versión vigente — le confirmaba que existe un circuito de firma sobre ese objeto, que el texto cambió y por dónde va, sin ser parte de nada. El mismo actor sobre la misma solicitud recibía 403 no_es_firmante en /challenge, que es lo correcto; y el criterio ya estaba escrito allí mismo: un colapso que solo existe en uno de dos caminos no es una defensa, es una incoherencia. Cómo: la comprobación pasa detrás de la pertenencia y del turno, y sigue delante de la transacción y del segundo factor — que es la razón de fondo por la que estaba donde estaba: ningún OTP ni TOTP puede quemarse en un acto que se va a rechazar igual. Se corrigió también el gemelo del visto bueno, que tenía el mismo orden. Impacto: quien no es parte de la cadena recibe una respuesta sobre su relación con ella y nada sobre el documento; quien sí lo es se sigue topando con el 409 antes de firmar, y sin gastar factor. Dos pruebas existentes fijaban el orden anterior y daban tres razones: dos eran equivocadas —presentaban como daño que un ajeno recibiera no_es_firmante— y la tercera, la del segundo factor, es la que este cambio conserva; se reescribieron para medir esa garantía en vez del orden que producía la fuga, en lugar de borrarlas.

  • fix(infra): la pila dejó de poder arrancar: la imagen de MinIO ya no se puede descargar de Docker Hub Por qué: minio/minio no publica ya ningún tag en Docker Hub —el repositorio quedó vacío—, y un docker compose up falla con «pull access denied … repository does not exist», que se lee como un problema de credenciales y no como lo que es. El tag fijo es deliberado: ahí vive el Object-Lock en modo COMPLIANCE (E10), donde una retención mal aplicada es irreversible durante décadas, así que la salida barata —soltar el pin a latest— era justo la que no se podía tomar. Cómo: los tres manifiestos descargan la imagen de quay.io, el registro del propio MinIO, con el mismo RELEASE y el mismo digest que el manifiesto de producción ya tenía anotado (verificado tras el pull: sha256:14cea49…8936e). No cambia la versión ni se relaja el pin, solo de dónde se descarga. Impacto: la pila vuelve a levantarse desde cero sin tocar la versión del almacén de preservación.

  • fix(archive,document,frontend): el acta de entrega no decía de qué oficina salían los documentos, y el manifiesto de exportación tampoco Por qué: dos huecos con la misma raíz — el dato existía y nadie lo traía. (a) El Anexo FUID del Acuerdo AGN 001/2024 abre con tres campos: unidad administrativa, oficina productora y objeto de la transferencia. Los tres existían como parámetros de la generación del inventario y nadie los enviaba nunca: al recibir, el acta se congelaba con la cabecera en blanco, se le calculaba la huella y se le ponía un sello XAdES-T válido. Un sello exacto sobre un acta que no identifica la entrega — el mismo patrón que el inventario vacío, corregido en el mismo circuito. (b) El manifiesto del ZIP de exportación llevaba las columnas formato y dependencia_productora vacías en todas sus filas, y el índice electrónico no emitía <dependenciaProductora> para ningún documento, porque las dos columnas se piden en el cuerpo de la vinculación y ninguna pantalla las manda. Cómo: (a) la oficina productora es la dependencia activa de quien prepara la transferencia —quien entrega los documentos, que es lo que el Anexo pide—, y la unidad administrativa es su dependencia padre; una oficina raíz es su propia unidad administrativa, que dice la verdad, en vez de dejarla vacía. Se captura como instantánea al preparar y no al recibir: entre las dos cosas pueden pasar semanas en las que el preparador cambie de dependencia o la dependencia se renombre, y un acta es autocontenida o no es un acta. El objeto lo escribe quien prepara y es obligatorio: es un dato humano, y componerlo del expediente y las ubicaciones produciría siempre la misma frase, que es dejarlo vacío con más letras. (b) la dependencia productora y el formato se resuelven en el servidor al vincular, preguntándoselos al servicio dueño del radicado, por el mismo motivo por el que la huella no se le pide al cliente: alimentan un índice que se firma, y quien vincula suele ser un archivista central y no la oficina que produjo el documento — el dato que aportaría sería el equivocado por construcción. Lo que el llamante declare explícitamente sigue mandando, para las importaciones con metadatos propios. Ambas resoluciones son de mejor esfuerzo: si el otro servicio no responde, el vínculo se hace igual y el hueco se declara. Impacto: el acta de entrega identifica la oficina que entrega y para qué; el manifiesto y el índice dicen de dónde viene cada documento y en qué formato. Límites declarados: las actas ya congeladas y los índices ya emitidos no se rehacen —son probatorios—; y los vínculos anteriores conservan sus columnas vacías, aunque el manifiesto de exportación cae a la metadata del origen para no dejar la columna en blanco teniendo el dato al lado.

  • fix(frontend): la pantalla de transferencias no leía su propia dirección Por qué: /transferencias?estado=enviada es el enlace que el panel principal y los avisos ya producían, y la pantalla lo ignoraba: pedía la lista completa y pintaba los desplegables en «todos». Quien llegaba por el enlace veía otra cosa de la que pidió, sin ninguna señal de que el filtro se hubiera perdido. Cómo: los filtros se resuelven en la carga del servidor —no al montar— para que la primera pintada ya venga filtrada, y los desplegables arrancan mostrando el filtro que de verdad se aplicó; con lista de admisión, para que un valor inventado en la dirección se descarte allí en vez de viajar al backend y volver como un error sobre un campo que nadie escribió. Impacto: el enlace lleva a donde dice.

  • fix(frontend): la imagen de desarrollo corría cuatro versiones mayores de Node por detrás de la que sirve la aplicación Por qué: Dockerfile.dev usaba node:18 y Dockerfile (producción) node:22. El desfase no era cosmético: la suite de pruebas del frontend ni siquiera carga en node:18, así que ejecutarla en la imagen de desarrollo producía siete fallos que no tenían nada que ver con el código — pasó, y costó un diagnóstico. Desarrollar sobre un runtime distinto del que sirve la aplicación convierte cada diferencia entre los dos en un defecto que solo aparece en producción. Impacto: las dos imágenes comparten versión mayor.

  • fix(archive,frontend): la traza archivaba el mismo expediente con tres nombres distintos, y el filtro perdía justo el asiento del sellado Por qué: la referencia con la que se busca un asiento de auditoría es el código del expediente —lo que el sistema imprime y la persona reconoce—. Para los asientos del índice electrónico se escribía de tres formas según el sitio: el código en cuatro llamadas, el identificador interno del expediente en seis y el identificador del propio índice —otra entidad distinta— en dos. El daño, medido contra el sistema: preguntar en la pantalla de auditoría «qué le ha pasado a EXP-2026-0015» devolvía siete asientos y dejaba fuera el del sellado de su índice electrónico, el más probatorio de todos, archivado bajo un identificador que nadie escribe en un filtro. Una respuesta incompleta que no se anuncia como incompleta, en el registro que la ley exige. Salieron además tres asientos del mismo defecto sobre el expediente en sí, y el peor es el de su destrucción bajo la Tabla de Retención: tras el borrado la fila ya no existe, así que ese identificador no lo puede resolver nadie nunca más — y el asiento de una destrucción es justo el que hay que poder encontrar. Cómo: la referencia se resuelve al código con un ayudante que cae al identificador solo si no existe —una traza con una referencia rara es mejor que ninguna traza—, y un guardián censa el árbol por forma: se prohíbe archivar un expediente por str(<identificador>). Verificado por mutación y contra el sistema levantado: el mismo filtro que perdía el asiento ahora lo devuelve. Impacto: la pregunta «qué le ha pasado a este expediente» se contesta entera. Límite declarado: los asientos ya escritos conservan la referencia que tienen; el registro es de solo-anexar (Ley 594/2000).

  • feat(archive,gateway,frontend): un control que repara en silencio se ve igual que un sistema que no falla Por qué: el sellado del índice se intenta al cerrar el expediente y, si falla, lo recoge un trabajo de reconciliación. Ese trabajo, al firmar, pone a cero el contador de intentos —a propósito, para no dejar basura en la cola de pendientes— y con eso borra el único rastro de que hubo que reparar. Durante dos días el sellado inmediato falló en todos los cierres y el trabajo lo arreglaba minutos después: la pantalla decía «sin pendientes» con toda la razón, la cadena de auditoría estaba íntegra, y el único residuo era que el sello quedaba a nombre del sistema en vez de a nombre de quien cerró. Nadie mira eso. Una cola vacía no distingue «nunca hubo cola» de «el compensador la vació». Cómo: la pantalla de preservación muestra cuántos índices acabó sellando el sistema y no el acto del cierre, con la lectura escrita —un número que crece indica un fallo del sellado inmediato, no un incidente puntual—. Se deriva de quién firmó y no de un contador nuevo, que se olvidaría de incrementarse igual que el otro se olvidó de no borrarse; se recorta por el nivel de acceso del llamante como la cola que acompaña; distingue «no se sabe» de «cero», porque afirmar cero sin saberlo sería afirmar que el compensador nunca actuó; y la cabecera queda declarada en la lista del gateway, sin la cual se caería en el trayecto sin que nada fallara. Impacto: el fallo permanente que se disfrazó de sistema sano ahora tiene un número.

  • fix(frontend): la tabla afirmaba un total que nadie le había dado Por qué: el historial de envíos muestra los últimos 50 avisos y el servicio no expone un total. La tabla recibía como total el número de filas de esa ventana y el resumen decía «1–50 de 50» habiendo 420 en la base — mientras el comentario del propio módulo afirmaba que la pantalla «nunca finge una paginación que el backend no ofrece». Un total falso es peor que ninguno: quien lo lee deja de buscar. Cómo: la tabla gana una señal explícita para decir que el total se desconoce, y entonces resume «N registros en esta consulta» en vez de dar un rango cerrado. No se dedujo de la opción que desactiva la navegación, porque otra pantalla la usa con un total que sí es real. Impacto: la ventana se anuncia como ventana.

  • docs(auditoría): la cadena de hash no prueba lo que su comentario decía que prueba Por qué: la restricción que protege el registro declaraba que una fila insertada con un hash bien formado pero falso «solo la detecta la reverificación de la cadena». Es falso, y comprobarlo fue el hallazgo de fondo del bloque: el hash es un SHA-256 sin secreto sobre los campos, así que quien puede insertar puede además calcular el hash correcto —la función vive en la librería que importan los once servicios— y dejar un asiento perfectamente encadenado. Demostrado contra el registro real dentro de una transacción revertida: 932 asientos íntegros, se añade uno forjado a nombre de un usuario real, y la reverificación sigue diciendo 933 verificados y cero roturas. Cómo: se escribe lo que la cadena sí garantiza —que la historia no se reescribe: modificar y borrar están cerrados por privilegios y la inserción retroactiva exigiría recalcular las filas siguientes— y lo que no —la autenticidad de un asiento nuevo, que ninguna cadena de hash sin clave puede dar—, con las dos vías para cerrarlo declaradas como cambio de diseño. Impacto: nadie va a dar por probada una garantía que no existe. Comprobado y correcto: la pantalla de auditoría no exageraba — dice «se recalculó y coincide extremo a extremo», que es exactamente lo que se verifica.

  • fix(archive,signature,frontend): el acta de entrega y el comprobante de firma decían «quién» con un identificador interno Por qué: séptima aparición del mismo defecto. La ficha del acta de una transferencia mostraba a sus tres responsables —elaborador, remitente y receptor— como 50b64803-568d-49f7-9299-cb140279d947, y el comprobante de firma titulaba «Firmante» y hacía lo mismo. Un acta de entrega es justamente el documento que dice quién entregó y quién recibió; un comprobante que no dice quién firmó no certifica nada. El guardián que vigila este eje no lo vio porque estaba atado al NOMBRE del campo —buscaba uno llamado actor— y aquí se llaman elaborado_por, entregado_por, recibido_por y firmante_id: la primera vez el censo miraba la capa equivocada, esta vez el nombre equivocado. Cómo: el identificador congelado no se toca —el acta es probatoria y una firma es un hecho fechado— y el nombre se resuelve al leer, con unión por la izquierda y por texto, porque el responsable puede no ser un usuario del catálogo (actas migradas, sellos del sistema); el identificador queda a mano en el título emergente, que es donde sigue haciendo falta para cruzar el documento con el registro de firmas. La lectura además degrada en vez de romperse si el catálogo de usuarios no está: lo crea otro servicio, y que la falta de una tabla ajena tumbe la lectura de un documento probatorio sería cambiar un dato ausente por un error; la comprobación mira la COLUMNA y no la tabla, porque la tabla puede existir con otra forma —fue exactamente lo que pasó— y mirar solo la tabla es comprobar la mitad y fallar igual. El guardián pasa a censar lo que se PINTA y no lo que se menciona: comparar un identificador con el del usuario en sesión o atarlo a un desplegable no es enseñarlo, y un censo que no los distingue obliga a declarar excepciones que no lo son. Impacto: las dos pantallas responden quién, y una octava no puede aparecer sin declararse.

  • fix(archive,frontend): el atajo para transferir un expediente no decía que cerraba la puerta del circuito formal Por qué: la ficha del expediente tiene un botón «Transferir expediente» que cambia la custodia sin acta de entrega, sin FUID y sin cotejo de integridad, y su confirmación solo decía que el estado pasaría a «Transferido». Lo que no decía es que es de un solo sentido: la transferencia formal exige el expediente cerrado, así que después de pulsarlo abrir una transferencia con acta para ese expediente es imposible para siempre. Y el error que lo rechazaba —«solo se transfiere un expediente cerrado»— se leía como «ciérrelo primero», cuando no hay nada que cerrar. Cómo: no se retira la capacidad —una transferencia puramente lógica es legítima y quitarla no es decisión de una pantalla—, pero la confirmación dice lo que hace, lo que no produce y lo que cierra, y ofrece la otra puerta; y el rechazo distingue las dos situaciones con dos mensajes distintos porque tienen dos remedios distintos. Impacto: quien pulsa el atajo sabe que lo es.

  • fix(archive,document,signature,shared): el sello del índice electrónico dejó de atribuirse a una persona, y un trabajo de reparación lo tapó Por qué: al firmar la identidad que viaja entre servicios se cerró que cualquiera dentro de la red pudiera decir ser quien quisiera. El middleware que la verifica se dispara por la sola presencia de la cabecera de usuario, venga de fuera o de otro servicio; y la clase que lo implementa declaraba por escrito que las llamadas entre servicios «no traen identidad de persona». Es falso: siete clientes internos propagan la identidad de quien originó el acto porque el destino la EXIGE para atribuírselo. El caso peor es el sellado del índice: se atribuye a quien cierra el expediente (RF-FIR-11/12/14), no al sistema. Desde ese endurecimiento, esa llamada devolvía 401 en todos los cierres. No se notó porque existe un trabajo de reconciliación —pensado para fallos pasajeros— que sella lo que quedó pendiente actuando como el sistema: el índice acababa firmado minutos más tarde, con lo cual la pantalla no enseñaba nada raro, pero el sello decía que lo había puesto un proceso automático y no la archivista. Un control compensatorio absorbiendo un fallo permanente lo vuelve invisible. Entretanto, el expediente recién cerrado no se podía transferir (Ac. AGN 001/2024 art. 4.3.2.4) hasta que el trabajo pasara. Cómo: quien propaga una identidad la firma, igual que hace la pasarela; la premisa falsa se corrige donde estaba escrita; y un guardián nuevo censa el árbol por FUNCIÓN —no por archivo, porque había tres emisiones en uno solo— exigiendo que toda cabecera de usuario que salga hacia otro servicio vaya firmada, con la pasarela dentro del censo. Verificado por mutación y con control positivo y negativo contra el servicio levantado. Impacto: el sello vuelve a ponerse en el mismo acto del cierre y a nombre de quien cierra. Límite declarado: los índices ya sellados por la reconciliación conservan al sistema como firmante; el registro es de solo-anexar y no se reescribe.

  • fix(archive,frontend): el acta de entrega de una transferencia se sellaba sin decir qué se entregaba Por qué: el inventario que sostiene el acta arrancaba la consulta en la tabla que ata un expediente a su caja física. Un expediente sin caja —el electrónico puro, que la propia creación de la transferencia admite a propósito como «el caso de uso central de un sistema documental moderno»— no producía ninguna fila. Al recibir, el acta se congelaba con la cabecera puesta y cero registros, se le calculaba la huella, se le ponía un sello XAdES-T válido y la pantalla la presentaba como «Acta de entrega — Formato Único de Inventario Documental». La garantía criptográfica era exacta sobre un documento que no dice nada (Ley 594/2000 arts. 15/26; Ac. AGN 001/2024 Anexo FUID). Las dos mitades estaban bien y el hueco vivía entre ellas. Las pruebas existentes del inventario no podían verlo: simulan la respuesta de la base, así que pasan en verde con cualquier unión. Cómo: el inventario lo manda el expediente y la unidad se une por la izquierda; las columnas físicas quedan en blanco cuando no hay caja, que es la verdad —inventarlas sería peor—; y cuatro pruebas contra Postgres real cubren el electrónico, el de papel, el filtro por ubicación y el inventario completo, verificadas por mutación. Impacto: el acta de una transferencia electrónica dice qué expediente, de qué serie, con qué fechas extremas y con qué frecuencia de consulta se entregó; el inventario general del archivo deja de ocultar todo lo que no esté en una caja. Límite declarado: la unidad administrativa, la oficina productora y el objeto de la cabecera siguen viajando vacíos —la transferencia no captura hoy la dependencia—, y las actas ya congeladas no se rehacen.

  • fix(frontend): recibir una transferencia era el acto irreversible que menos avisaba Por qué: la tarjeta de confirmación preguntaba «¿marcar esta transferencia como recibida?», que suena a cambiar una etiqueta. Recibir congela el expediente —deja de admitir documentos—, coteja su integridad contra el índice firmado y congela el acta de entrega, que ya no se regenera. Era la única de las tres confirmaciones del circuito que no nombraba su consecuencia. Cómo: la confirmación dice lo que ocurre y que no tiene vuelta atrás; el estado vacío deja de culpar a unos filtros que nadie aplicó; y la ficha del acta deja de llamar «dependencias» a lo que son ubicaciones físicas. Impacto: quien pulsa sabe qué está firmando.

  • fix(archive): sexta aparición de la identidad en crudo, y esta vez la corrección ya estaba escrita en sus dos gemelas Por qué: al cerrar un expediente, la hoja de ruta mostraba dos líneas seguidas para la misma persona: una con su identificador interno en texto libre —«firmante=50b64803-…»— y otra con su nombre resuelto. El asiento del cierre escribía el identificador en el campo de comentario libre mientras la columna tipada de actor recibía ese mismo valor, porque el router pasa la misma identidad a los dos parámetros. Los otros dos actos que escriben en esa tabla, transferir y recibir una transferencia, ya lo habían retirado, y el comentario de uno de ellos lo dejaba escrito con todas las letras: texto libre redundante con la columna tipada. Era el tercer sitio de tres, arreglado en dos. Cómo: se retira el comentario del acto de cierre y el guardián del historial pasa a censar el árbol buscando la identidad metida en texto libre junto a la escritura de un evento, saltando a propósito las menciones en comentarios —que no son el defecto, son su lápida—. Impacto: los cierres que se registren desde ahora dicen quién una sola vez y de forma resoluble. Límite declarado: los asientos ya escritos conservan el identificador en crudo; el registro de eventos es de solo-anexar por Ley 594/2000 y no se reescribe.

  • feat(frontend): el expediente avisa cuando la serie que lo clasifica ya no rige Por qué: la ficha mostraba el nombre de la serie documental sin decir de qué versión del instrumento venía ni si esa versión sigue vigente. Un expediente clasificado bajo una serie derogada se cierra igual —al cerrarlo el servicio de archivo re-resuelve la serie por su código a la versión vigente, comprobado—, pero quien pulsa «Cerrar» no tenía forma de saber que el instrumento bajo el que está trabajando fue relevado, ni que el expediente quedará clasificado bajo otra versión. Cómo: la ficha marca la versión junto al código y, cuando el estado de la serie no rige, muestra un distintivo con la explicación de que al cerrar se re-resolverá a la versión vigente. La decisión se deja en manos del archivista, informado, en vez de bloquear el acto. Impacto: el relevo de versiones deja de ser invisible en el punto donde importa.

  • fix(frontend): 29 archivos de prueba no se ejecutaban, y casi todos eran guardianes escritos tras un defecto real Por qué: la configuración de pruebas fuerza la condición «browser» al resolver módulos bajo Vitest, y lo hace por un buen motivo —sin ella el framework resuelve a su versión de servidor y el enganche de montaje de los componentes queda inerte, de modo que el modal y el cajón no registran su trampa de foco—. El coste no estaba escrito: los módulos integrados de Node para leer ficheros, rutas, URLs y procesos se resuelven al sustituto del navegador y no exportan nada. El resultado eran cuatro caras del mismo fallo y 29 archivos que morían al cargar, sin ejecutar una sola prueba: de las 2.031 que existen solo corrían 1.851. Como el rojo era de recolección y no de aserción, no señalaba a ningún defecto y se leía como ruido del entorno. Dentro estaban los guardianes de los tokens de diseño inexistentes, del atributo que oculta, de la prosa partida en columnas, de la identidad interna frente al identificador del proveedor de sesión, del gate por rol de firmas y del circuito de visto bueno — casi todos escritos después de un defecto que este repositorio ya vivió. Cómo: los 26 que solo leen ficheros declaran su entorno en el propio archivo; los 3 que además montan nodos del documento piden el módulo de lectura directamente al motor, sin pasar por la resolución del empaquetador, y conservan el navegador simulado. La configuración global no se toca, así que la condición «browser» sigue protegiendo lo que protegía. Impacto: 202 de 202 archivos y 2.031 de 2.031 pruebas en verde; 180 que nunca llegaban a correr vuelven a vigilar. Ningún fallo era de producto: no apareció una sola aserción rota en todo el proceso.

  • fix(frontend): el expediente no se abría por el identificador que el sistema imprime en pantalla Por qué: la ficha del expediente muestra su código —EXP-2026-0002— en las migas, en el título y en la carátula, pero la ruta exigía el identificador interno. Copiar de la pantalla y pegar en la barra de direcciones, o compartir ese enlace con alguien, terminaba en una tarjeta de error que decía «500» en el título y «HTTP 422» en el cuerpo: dos códigos distintos, ninguno cierto y ninguno accionable. El 422 lo devuelve el servicio de archivo al recibir un código donde espera un identificador, y la carga de la página lo colapsaba a 500 por no ser ni 404 ni 403. Lo delator es que los proxies hijos de esa misma pantalla ya aceptaban las dos formas con un ayudante cuyo comentario dice que el código «es la identidad con la que se trabaja»: la corrección estaba escrita al lado y la pantalla principal no la usaba. Cómo: la carga resuelve la referencia con ese mismo ayudante, con coincidencia exacta —abrir un expediente es un acto sobre uno concreto, no sobre el más parecido—, sigue aceptando el identificador interno sin gastar una búsqueda, y cuando no hay coincidencia responde 404 nombrando lo que la persona escribió. Impacto: el enlace que cualquiera copia de la pantalla funciona.

  • fix(frontend): la ubicación física del expediente decía dónde mirar y no qué sacar del estante Por qué: la pestaña de ubicación física mostraba «Signatura» y una columna «Unidad (ID)» con el identificador interno de la unidad de conservación. Quien baja al depósito no busca un identificador: busca la caja CAJA-000. La misma respuesta del backend ya trae el tipo y el código por su unión, y el comentario del esquema que los proyecta narra literalmente esta corrección hecha en la pantalla de archivo físico, donde sí se muestran como «Tipo unidad» y «Código unidad» — se arregló una pantalla y se dejó la otra, sobre los mismos datos. Cuarta aparición del dato cargado que ninguna pantalla lee. Cómo: la columna del identificador se sustituye por tipo y código, dejando la signatura como la coordenada del estante. Impacto: la pestaña responde qué unidad hay que sacar. Comprobado: el vínculo del corpus no trae rango de folios y por eso se ven en blanco; la captura de ese rango existe y funciona en la pantalla de archivo físico, así que es un dato que faltó al sembrar, no un hueco de captura.

  • fix(licencia): 56 archivos de código no decían bajo qué licencia se distribuyen, y la plantilla de servicio sembraba la omisión Por qué: el proyecto es software libre AGPL v3 derivado del Orfeo GPL y la regla es que cada archivo de código nace con su encabezado de atribución y licencia, porque un archivo viaja solo — se copia a otro repositorio, se adjunta a un correo — y fuera del árbol el fichero de licencia de la raíz no lo acompaña. El encabezado se había puesto a 437 archivos de una vez; al censar el árbol entero faltaba en 56 de 1.773. No se saltó por una decisión: se abrió porque nadie volvió a mirar. Los dieciséis peores eran los de la plantilla de la que se copia cada servicio nuevo, de modo que el defecto se reproducía solo en cada servicio que naciera de ella. Cómo: encabezado añadido con el prefijo de comentario que corresponde a cada tipo y después del shebang donde lo había; y un guardián que censa el árbol por extensión, exige la línea de atribución en la cabecera, declara sus excepciones con motivo escrito y tiene prueba anti-vacuidad, más una prueba propia para la plantilla, verificado por mutación en ambas. Impacto: los 1.772 archivos de código del repositorio declaran su origen y su licencia; el único exento es la configuración de la herramienta de estilo del frontend, declarada con su motivo.

  • fix(archive,frontend): tercera aparición del mismo defecto, y la primera que se me escapa por vigilar la capa equivocada Por qué: tras corregir el historial de acceso del radicado y el del expediente, la hoja de ruta del expediente seguía diciendo quién lo abrió con un identificador interno. Sale de otra tabla —los eventos del ciclo de vida, no el registro de auditoría—, así que el guardián recién escrito no la miraba: vigilaba los repositorios de la pista de acceso, y el defecto no vive en una tabla concreta sino en lo que lee la persona. Cómo: el nombre se resuelve al leer, con unión por la izquierda para que los eventos sin actor —los que escribe el propio sistema— no desaparezcan; y el guardián pasa a censar las pantallas que muestran un actor, exigiendo que tengan un nombre a mano, con el visor forense de auditoría declarado como excepción razonada, porque allí el identificador es la clave con la que se cruzan las entradas. Impacto: las tres pantallas responden quién, y una cuarta no puede aparecer sin declararse.

  • fix(archive,document,frontend): el índice electrónico se firmaba sin decir de qué clase era ningún documento Por qué: el elemento que declara la tipología documental de cada pieza es obligatorio en el esquema del perfil v4, pero su tipo admite cadena vacía — así que el índice se generaba, validaba contra el perfil y se sellaba con la tipología en blanco para todos sus documentos, mientras el dato existía y era correcto en cada radicado. El constructor de las entradas del índice armaba todos los demás campos —orden, título, fecha, huella, formato, tamaño, folios, nivel de seguridad, estado y dependencia productora— y omitía este, que se quedaba en su valor por defecto. Un esquema no puede cazar un elemento obligatorio que se cumple con vacío; solo puede cazarlo quien mire el dato. Cómo: la tipología viaja con el vínculo, igual que las otras cinco columnas que ya funcionan así, porque el servicio de archivo no lee la tabla de radicados y resolverla al generar metería una llamada de red dentro de un artefacto probatorio; el buscador pasa a devolver la clase documental para que quien vincula tenga el dato delante, y la pantalla de vinculación lo captura y lo transporta. Impacto: los índices que se generen desde ahora declaran la clase documental de cada pieza. Límite declarado: no reescribe los índices ya emitidos, y un radicado retipificado después de vincularse conserva la tipología del momento del vínculo.

  • fix(gateway,tenant,shared): el cierre de la auditoría dejó dos cabos: una cabecera de seguridad que el gateway se comía y un servicio que ya no arrancaba Por qué: los dos salieron de comprobar el sistema levantado, no de leer el código. La descarga por el protocolo de interoperabilidad pasó a marcar el contenido como no adivinable por el navegador, pero el gateway solo reenvía las cabeceras de respuesta que tiene declaradas, así que la cabecera desaparecía en el trayecto y el navegador volvía a deducir el tipo por el contenido: justo lo que la lista de tipos admitidos vino a impedir. Y al extender la comprobación de secretos a los tokens internos, el registro de instituciones pasó a exigir el suyo para poder arrancar, cuando su ausencia es la postura correcta —no hay hoy ningún consumidor legítimo por HTTP y la puerta está cerrada precisamente por no tener llave—; el servicio llevaba horas caído y el estado de salud lo mostraba como inalcanzable, que es lo mismo que se ve cuando falla la red. Cómo: la cabecera se declara con su motivo escrito; y se separa la comprobación en dos, la que exige presencia y la que solo rechaza el valor de ejemplo del repositorio. Un guardián nuevo cruza lo que cada arranque exige contra lo que el manifiesto de desarrollo entrega, verificado por mutación con el defecto real. Impacto: la descarga conserva su marca de seguridad de punta a punta y ningún servicio puede volver a quedarse sin arrancar por una comprobación que nadie puede satisfacer.

  • fix(document,frontend): las tres pantallas del directorio funcionaban y ninguna terminaba de responder su pregunta Por qué: se abrieron por primera vez en el navegador y las tres tenían el mismo defecto de fondo. La consolidación de fichas duplicadas aceptaba fusionar dos personas con número de documento distinto, cuando el índice único del catálogo ya garantiza que dos documentos distintos son dos personas — el sistema usaba ese dato para afirmar que dos fichas son la misma y nunca para negarlo, justo en el único acto que no tiene vuelta atrás. El selector de remitente retiraba el buscador al llenarse el cupo pero dejaba en pantalla los resultados de la búsqueda anterior con sus botones activos, de modo que se llegaba a dos remitentes en una Entrada y el fallo aparecía al pulsar «Radicar», contra una restricción de unicidad, con los anexos ya subidos. Y la sección «quién ha visto este radicado» contestaba con un identificador interno que nadie puede resolver. Cómo: guarda de documentos distintos con el mensaje nombrando los dos números, para que se vea cuál está mal capturado; el cupo lleno retira también los resultados listados; y el nombre de quien accedió se resuelve en el backend con un JOIN por texto que tolera los actores que no son usuarios. Impacto: la fusión deja de poder destruir una identidad legítima, el límite del remitente se descubre antes de trabajar y no después, y el historial de acceso responde con un quién.

Security

  • fix(shared,gateway,archive,document,signature)!: Bloque B10 (ítem 5) — la identidad firmada que el gateway inyecta (y que propagan los clientes internos) queda atada al método y a la ruta de la petición (ADR-036). Qué cambia: método y ruta canónica entran en la base del HMAC (v2) sin viajar como cabeceras; una identidad capturada no sirve en otra operación del mismo servicio (401 identidad_no_autentica). La ruta canónica es la de la petición sin query, decodificada hasta estabilizar. Guardián AST: en cada cliente interno la ruta firmada es la de la llamada HTTP. Impacto: corte atómico — el gateway y los nueve servicios con IdentidadFirmadaMiddleware se reconstruyen y despliegan a la vez; si conviven dos versiones, toda petición autenticada da 401.
  • fix(auth,gateway)!: administrar RBAC ya no se puede con el permiso de solo lectura, nadie entra en un grupo de clearance mayor que el suyo, y el token se valida con typ/azp (AREA-01-01 y AREA-01-N3 altas; AREA-02-04, AREA-03-03 y AREA-04-04 medias; AREA-02-02, AREA-02-12 y AREA-01-15 bajas; AREA-02-03 y AREA-13-07 pruebas) Por qué: USUA_PERM_ADMIN a nivel 1 (leer) bastaba para crear usuarios y grupos, meterse en grupos y concederse permisos —y es el mismo permiso que pide leer la auditoría—; además POST /groups/{g}/members/{self} subía el clearance propio sin pasar por el no-write-up que sí tiene PUT /groups/{g}/clearance (el clearance efectivo es MAX(role_clearance) sobre las membresías). El censo de shared/tests/test_escritura_exige_nivel.py era ciego a los gates de APIRouter(dependencies=[...]), por eso nadie lo veía. Cómo: (1) toda escritura de rbac.py, urd.py, PUT /groups/{g}/clearance y las bajas administrativas de 2FA y de clave de firma de OTRA persona exigen USUA_PERM_ADMIN min_crud=3; las lecturas siguen a nivel 1 (los auditores no pierden acceso). El censo aprende a leer gates de router (paréntesis balanceados, cualquier variable *_router): descubrió 16 escrituras más abiertas a nivel de lectura (cadena de firma, webhooks, DLQ ×3, alta de terceros) que quedan declaradas, no aprobadas, en SIN_NIVEL_DECLARADAS. (2) add_membership_guarded: el no-write-up va en el WHERE del INSERT (ROOT exento; grupo sin role_clearance cuenta como 1) y un rechazo es 403 no_write_up sin asiento. (3) PATCH/DELETE /groups/{id} (409 si tiene miembros o URD, comprobado en la misma sentencia que borra). (4) validate_token exige typ=Bearer, azp ∈ ALLOWED_AZP (por defecto orpycamcp-frontend,api-gateway; vacío = nada pasa) y exp/iat/iss/sub obligatorios. (5) /validate emite expires_in y el gateway acota con él el TTL de su caché; sin expires_in no cachea (el tope por exp era inerte: la respuesta nunca lo traía y el test usaba un dict que el productor no emite). (6) Las entradas de la caché de validación se sellan con HMAC-SHA256 ligado a su clave (AUTH_CACHE_MAC_SECRET, exclusivo del gateway; sin él la caché queda apagada) y el camino de caché vuelve a exigir valid is True. (7) /audit/verify devuelve solo ok y total_roturas a quien no tiene el clearance máximo (total − X-Total-Count era el número exacto de asientos clasificados ocultos). (8) totp.desenrolado, totp.desenrolado_con_recuperacion y totp.restablecido_por_admin se asientan dentro de la transacción de la baja y sin except (si el asiento falla, el factor no se retira). Verificación: auth-service 309 con RUN_INTEGRATION=1 (273 unitarias + 36 de integración contra Postgres real: grant_root, atomicidad TOTP, no-write-up de membresía, borrado de grupo), api-gateway 112, shared/tests 174, lint.sh limpio. Mutaciones (cada una tumba pruebas, restauradas y reconstruidas): quitar min_crud=3 del PUT de permisos (también lo tumba el censo), quitar la condición de nivel del INSERT, sustituir el asiento estricto por el best-effort, quitar el chequeo de typ, de azp, HS256 en algorithms, vaciar ALLOWED_AZP, devolver total a todos, quitar expires_in, sacar el asiento de grant_root de la transacción, quitar su idempotencia, aceptar cualquier MAC de la caché, MAC sin la clave, quitar la recomprobación de valid, ensanchar la allowlist del proxy con cabeceras de identidad y TTL = máximo. Una mutación del inventario resultó equivalente: invertir el orden de headers.update(extra) no tumba nada con la allowlist actual (las cabeceras de identidad del cliente no pasan), por eso se añadió una prueba que ensancha la allowlist y fija el orden. Impacto (cambio de comportamiento): cualquier perfil que hoy administre RBAC con USUA_PERM_ADMIN a nivel 1 o 2 recibirá 403 en las escrituras: súbalo a 3 antes de desplegar (los grupos del demo ya lo tienen a 3). Los tokens que no sean de orpycamcp-frontend o api-gateway (p. ej. un cliente de servicio nuevo) dan 401 hasta añadirlos a ALLOWED_AZP. Nueva variable obligatoria en producción: AUTH_CACHE_MAC_SECRET en el api-gateway (${…:?} en el compose de producción; sin ella el gateway sirve pero sin caché de validación). Reconstruir auth-service y api-gateway. No hecho: permiso de solo lectura de auditoría separado de USUA_PERM_ADMIN (exige migración con backfill y decisión de nombre); identificador opaco en el listado de auditoría (los huecos de id siguen siendo el residuo declarado RF-FIR-15); subir min_crud de cadena de firma, webhooks, DLQ y terceros; ACL de Redis por servicio y firma asimétrica de la identidad (AREA-02-02, recomendación estructural).

  • fix(signature,storage,archive,gateway)!: el sello institucional y el Object-Lock ya no se alcanzan con el JWT de un usuario (AREA-04-07 crítica, AREA-08-16 y AREA-08-07 altas, AREA-08-06 media) Por qué: POST /signature/sign-xml estampaba el sello de la entidad sobre un XML arbitrario a cualquier humano con PERM_FIRMA nivel 1 (saltándose los gates de archive: expediente cerrado, permiso, versión del índice), y el gateway enrutaba /preservacion/ entero: un usuario con USUA_PERM_EXPEDIENTE:3 ocupaba el singleton WORM irreversible (COMPLIANCE, hasta 100 años) de cualquier expediente con un file_id arbitrario y un plazo corto (la idempotencia por (expediente, tipo) lo congelaba y archive recibía un 200 «ya protegido»), o fijaba un legal_hold sin acto para levantarlo, bloqueando la disposición final para siempre. Cómo: (1) /sign-xml exige además X-Internal-Token cuando objeto_tipo es del allow-set XAdES (indice, acta_transferencia, acta_eliminacion) y objeto_tipo pasa a conjunto cerrado (422 fuera de él); archive-service lo envía en sign_xml/sign_xml_outcome junto a la identidad firmada (si no, el sellado al cerrar habría caído a pendiente_firma en silencio y la reconciliación lo repetiría sin fin). (2) storage sella /aip, /proteger-indice y /proteger-artefacto con X-Internal-Token (archive los llama con el token + identidad firmada, que conserva la atribución al humano que cierra). (3) El gateway enruta /api/v1/preservacion/ por lista de admisión (plan, eventos, migrar-formato, migraciones/); los actos de sistema devuelven 404 route_not_found. (4) Defensa en profundidad: segunda protección del mismo (expediente, tipo) con otro file_id → 409 worm_singleton_conflict, sin subir. (5) legal_hold deja de ser campo del body de toda ruta de protección (422) y se declara/levanta solo por el acto interno POST /preservacion/legal-hold-internal (motivo + acta_ref obligatorios; enable/disable_object_legal_hold sobre la version_id exacta; fila + asiento worm.legal_hold_fijado|levantado en una misma tx). Corregidos los comentarios falsos («NO existe un endpoint público») que ya no lo eran y que ahora lo son. Verificación: signature-service 409 pasan (4 omitidas), storage-service 394 pasan (43 omitidas) y 30 de integración contra Postgres real, archive-service 913 pasan (217 omitidas), api-gateway 100 pasan, guardianes shared/tests 171 pasan; lint y mypy limpios en los cuatro. Pruebas nuevas test_sign_xml_sello_no_es_publico.py, test_preservacion_selladas_y_legal_hold.py (storage), test_clientes_llevan_token_interno.py (archive: mira la PETICIÓN saliente) y 2 del gateway; mutaciones (quitar el token en la rama institucional, devolver objeto_tipo a texto libre, quitar la cabecera en los clientes de archive, restaurar require_permission, quitar la comparación de file_id, re-admitir legal_hold, asentar fuera de la tx, volver al prefijo completo del gateway) tumban su prueba. Remediación de la auditoría de la tanda: (a) /sign-xml ya no firma a nombre de objetos inexistentes ni ajenos: para los tipos de cadena (radicado|expediente|borrador) el objeto debe existir y ser visible para el firmante, con el clearance dentro de la consulta (FILTER nivel_seguridad <= $2; 404 objeto_no_encontrado neutro; borrador no se resuelve en este servicio y por tanto no se firma por esta ruta cruda) y el gate sube a PERM_FIRMA min_crud=2 (se retira la excepción congelada de test_escritura_exige_nivel.py: 86 → 85). Cambio de comportamiento declarado: un usuario con PERM_FIRMA en nivel 1 que cierra un expediente ya no sella el índice con su identidad; queda pendiente_firma y lo sella la reconciliación a nombre de SISTEMA. (b) El legal hold ya tiene acto de usuario: POST /api/v1/expedientes/{id}/legal-hold en archive-service (USUA_PERM_ADMIN crud≥3, motivo ≥10 y acta_ref obligatorios en ambos sentidos, expediente visible por clearance o 404 neutro, asiento propio archive.expediente_legal_hold_fijado|levantado) que orquesta legal-hold-internal con token + identidad firmada. (c) Storage: fijar_legal_hold toma el advisory lock (tenant, expediente) (fijar y levantar concurrentes ya no divergen de la fila); alcanza también la réplica del AIP (la ausencia de réplica se tolera, cualquier otro error aborta) y registra copias_alcanzadas en el asiento; y levantar asienta primero worm.legal_hold_levantamiento_solicitado (tx propia) y solo después toca el almacén, con worm.legal_hold_levantamiento_fallido si el almacén falla, de modo que un objeto liberado nunca queda sin asiento. Verificación: signature-service 422 pasan (4 omitidas), storage-service 408 pasan (43 omitidas) y 33 de integración contra Postgres real, archive-service 925 pasan (217 omitidas), mcp-server 232, shared/tests 171; lint y mypy limpios en signature, storage y archive. Mutaciones (cada una tumba pruebas): quitar min_crud=2, quitar la comprobación de visibilidad del objeto, quitar el FILTER de clearance, levantar sin asiento de solicitud previo, no alcanzar la réplica, no tolerar la réplica ausente, quitar el lock, quitar el asiento de fallo, quitar el asiento de archive, quitar la visibilidad del expediente, cambiar el permiso por USUA_PERM_EXPEDIENTE y quitar el token del cliente de archive. No hecho: la lista de admisión del gateway sigue comparando por prefijo (/plan, /eventos; informativa, sin subruta futura que la explote hoy); el permiso del acto de legal hold reutiliza USUA_PERM_ADMIN en vez de uno propio (sembrarlo exige migración en auth-service y decisión de producto); y el acto no se expone como herramienta MCP a propósito.

  • fix(gateway,11 servicios,shared): la identidad entre servicios viajaba sin firma, y el gateway ni siquiera podía firmarla Por qué: quién eres, de qué entidad y con qué roles viajaban en cabeceras de texto plano. Desde fuera no se podían forjar, porque el gateway no las reenvía si vienen del cliente, pero dentro de la red de contenedores cualquiera que alcanzara el puerto de un servicio podía hablarle diciendo ser quien quisiera y de la entidad que quisiera. Sin prueba de origen, una cabecera es una afirmación y no una identidad. Cómo: el gateway las firma y cada servicio las verifica en su punto más externo, solo cuando hay identidad de persona —las llamadas selladas con token interno tienen su propio gate—. Salió además que el gateway era el único de los doce servicios que no instalaba la librería compartida, así que su contexto de construcción pasa a ser la raíz. Impacto: cierra la suplantación desde dentro de la red. Requiere una variable nueva al desplegar, obligatoria en producción.

  • fix(signature,mcp,workflow,storage,document,archive,api-gateway,scripts): los últimos catorce hallazgos de la auditoría Por qué: un gate de firma que se esquivaba cambiando la etiqueta del tipo de objeto, que la elige quien abre la cadena; un procedimiento del asistente que se autorizaba por su permiso agregado y luego encadenaba herramientas que exigen otros más altos; la tarjeta de confirmación que perdía los campos que avisan de que el acto es irreversible, de modo que la persona lo descubría al ver la frase que tenía que teclear; dos peticiones simultáneas abriendo dos pasos activos para el mismo radicado; una exportación que acotaba por número de radicados cuando lo que llena la memoria son los bytes; la cosecha anónima de interoperabilidad compitiendo por el cerrojo que serializa la cadena de auditoría de la entidad; y el fichero de secretos de desarrollo legible por toda la máquina. Impacto: la auditoría queda cerrada — 81 hallazgos confirmados, 81 tratados.

  • fix(11 servicios,shared,frontend,infra): cierre de los pendientes de la auditoría — 30 hallazgos más Por qué: quedaban 45 confirmados de severidad media y baja. Los de más fondo: el sello de la auditoría no cubría la marca de tiempo, de modo que mover la fecha de un asiento no rompía la cadena y en un registro archivístico el cuándo es la mitad de la prueba; una expresión regular de una regla de distribución podía detener el reparto de toda la entidad; el registro de entidades tenía una columna de estado que ningún camino miraba, así que suspender una entidad no le retiraba el acceso; la guardia de rutas del gateway cometía dentro de sí misma el defecto que persigue; y cinco hallazgos compartían la forma de comprobar en una consulta y aplicar en otra, con el caso peor en la anulación, que escribía el asiento de una decisión que no llegó a aplicarse. Cómo: cada uno en su capa y verificado por mutación; los que no admitían prueba unitaria se comprobaron contra la pila levantada. Impacto: quedan 15 hallazgos de severidad baja, todos de endurecimiento. Requiere atención al desplegar: Redis pasa a exigir contraseña también en desarrollo, los servicios internos dejan de publicarse en todas las interfaces, y el catálogo de la API deja de servirse sin credenciales salvo que se declare lo contrario.

  • fix(notification,shared): el contenido documental salía del ámbito donde el sistema lo protege Por qué: los webhooks enviaban el sobre de evento completo a una dirección externa sin mirar la clasificación del objeto, así que el asunto, el nombre del remitente y hasta la causal de reserva con su fundamento jurídico viajaban a un tercero; dar de alta la suscripción solo exige el permiso de administración, que no es lo mismo que tener nivel de acceso sobre lo reservado. La contradicción estaba escrita dentro del propio servicio, que declara no exponer el payload del dead-letter por esa misma razón. Y los flujos de eventos conservaban indefinidamente el asunto y el nombre del ciudadano de cada radicación, fuera del esquema de la entidad y fuera de la Tabla de Retención Documental. Cómo: se recorta el contenido de lo clasificado antes de enviarlo, conservando el sobre para que la integración siga sabiendo qué pasó y cuándo; y los flujos de dominio caducan por antigüedad con la misma cadencia que los recortes que ya existían. Impacto: cierra la salida de contenido clasificado hacia un destino externo y acota a un mes lo que antes no caducaba nunca. Nota: un consumidor detenido más de la ventana pierde los eventos recortados.

  • fix(infra): el respaldo era el punto donde todas las garantías del sistema dejaban de aplicar Por qué: contenía en claro y legible por cualquier cuenta del servidor lo mismo que el control de acceso protege dentro —un radicado reservado se leía abriendo el fichero, sin credencial, sin nivel y sin dejar asiento—; restaurar destruía la protección de solo-lectura del índice firmado y de las actas, porque el espejo copia objetos y no su inmutabilidad; y restaurar un respaldo antiguo suprimía un periodo entero de la traza legal mientras el propio script imprimía «cadena íntegra», porque una cadena vieja es perfectamente coherente consigo misma. Cómo: permisos restrictivos y decisión explícita obligatoria sobre el cifrado, sin la cual el respaldo no se hace; captura de la retención que el espejo no copia y reposición al restaurar, con los buckets recreados con bloqueo ANTES de copiar porque después ya no lo admiten; y un ancla acumulativa del extremo de la cadena, guardada fuera del respaldo fechado, que al restaurar detecta si la traza retrocedió y aborta. Impacto: cierra la lectura de documentos clasificados fuera de la aplicación y convierte la supresión silenciosa de traza en un acto detectable. Requiere decisión al desplegar: elegir clave de cifrado o aceptar el texto claro.

  • fix(11 servicios,infra): todos los servicios hablaban con Postgres como superusuario, y con ese rol el guardián de la auditoría no guarda nada Por qué: las tres capas que protegen el registro de auditoría se apoyan en revocaciones de privilegios que el superusuario omite por definición, y así lo decía la propia migración que las creó. Un guardián de solo-anexar que descansa sobre permisos que nadie retira es una declaración, no un control. Cómo: se separa quien ESTRUCTURA de quien OPERA. El rol propietario aplica migraciones, provisiona entidades y crea particiones; el rol de aplicación lee y escribe filas y sobre la auditoría solo puede añadir. Los runners y los scripts operativos leen una conexión propia. tenant-service queda como excepción declarada porque crea esquemas al dar de alta una entidad. Impacto: con el rol de aplicación, borrar o modificar un asiento devuelve permiso denegado; antes solo lo impedía el disparador. Requiere acción al desplegar: activar el rol con un secreto propio y definir dos variables nuevas.

  • fix(document,infra): el año del número de radicado se tomaba en horario universal Por qué: Colombia es UTC-5, así que entre las 19:00 y las 23:59 del 31 de diciembre el servidor ya estaba en el año siguiente. Un radicado con fecha impresa 31/12/2026 recibía el número 2027, de modo que el número y su propio acuse se contradicen y el consecutivo del año que cierra queda cortado antes de tiempo, sobre un número que es inmutable por Ley 594. Cómo: el año y la fecha base de los plazos se deciden en la zona de radicación, por omisión la de Bogotá. Se declara del despliegue y no de la entidad, y se dice así en el código. Impacto: los radicados del cierre de año quedan en el año que les corresponde.

  • fix(infra): dos secretos obligatorios no estaban en ningún manifiesto de producción Por qué: sin la clave de custodia, el servicio de documentos arranca pero no puede descifrar las credenciales de los buzones institucionales, así que la ingesta de correo queda muerta sin decirlo. Y la firma de propuestas del asistente era obligatoria en producción sin aparecer en la plantilla de variables, de modo que quien preparaba el despliegue no tenía forma de saber que hacía falta. Cómo: ambas declaradas en el manifiesto y en la plantilla, con el motivo escrito. Impacto: un despliegue nuevo no puede quedarse sin ellas por descuido.

  • fix(whisper,mcp,knowledge): el servicio de transcripción no tenía gate, ni tope, ni usuario, ni pruebas Por qué: era el único servicio interno sin gate servicio-a-servicio. El razonamiento era que el gateway no lo enruta, pero eso es una tabla de ruteo y no una frontera: dentro de la red de contenedores cualquiera podía hacerle transcribir audio arbitrario, sin tope de tamaño y siendo el proceso que además carga un modelo en memoria, todo ello como root. Cómo: gate fail-closed igual que sus cinco gemelos, tope aplicado antes de leer los bytes, usuario sin privilegios, y las primeras pruebas del servicio junto con la forma de ejecutarlas. Se corrigió también la única de las seis copias del gate interno que comparaba el token con != en vez de en tiempo constante. Impacto: cierra un endpoint abierto a toda la red interna. Nota de despliegue: en una instalación existente hay que ceder el volumen del modelo al usuario del servicio una vez.

  • fix(shared,notification,workflow,knowledge,signature): el sobre de evento no probaba su origen, y el destino viajaba dentro Por qué: los cuatro consumidores toman la entidad destino del propio sobre y escriben en su schema, así que quien pudiera escribir en Redis actuaba como el productor legítimo sobre cualquier entidad alojada: publicar una reclasificación a la baja bastaba para bajar de nivel un radicado reservado ajeno. La expresión que valida el nombre del schema cerraba la inyección, no la suplantación del origen: son dos preguntas y solo estaba respondida la primera. Cómo: firma y verificación en el paso único por el que pasan todos los productores y todos los consumidores, de modo que no se pueda olvidar al añadir el quinto. Sin secreto configurado no se verifica, decisión explícita para no dejar sin procesar lo ya encolado; en producción la variable es obligatoria. Impacto: cierra la orden anónima sobre entidades ajenas.

  • fix(mcp,knowledge): el asistente enviaba material reservado al proveedor de generación externo Por qué: el proyecto decidió sin excepciones que lo clasificado no sale a un proveedor externo, y lo implementó en el servicio de conocimiento. El asistente también genera con proveedor externo y no replicaba la barrera, así que lo que la consulta aumentada retira del contexto salía igualmente por la herramienta de búsqueda, que ni siquiera exponía el nivel. Cómo: la búsqueda expone la clasificación del fragmento y el asistente recorta el contenido clasificado antes de que entre al contexto del modelo, conservando lo justo para poder decir cuál documento no puede mostrar. Impacto: cierra la salida de contenido documental clasificado hacia un tercero.

  • fix(document,signature): el estado de invalidación existía en el esquema y ningún código lo escribía Por qué: la migración que lo creó decía textualmente que hacía falta porque una cadena podía quedar «firmado sobre uno que cambió después». Nadie lo escribía, así que los turnos de un circuito superado seguían pidiéndole a alguien firmar un documento que ya no existe, para después esfumarse sin explicación. Cómo: editar el texto invalida los circuitos anteriores con un motivo escrito. La condición mira la huella y no el campo enviado, para no tirar un circuito vivo al guardar un cambio de asunto. Es best-effort: la garantía dura sigue siendo la comparación de huellas al radicar. Impacto: la bandeja deja de mentir.

  • fix(auth): el rol de base de datos nacía con una contraseña publicada en el repositorio Por qué: tiene permiso de inserción sobre la tabla global de auditoría, así que una credencial conocida permite fabricar asientos a nombre de cualquiera. La nota decía que debía cambiarse, pero ningún servicio usa ese rol, así que nadie tenía motivo operativo para hacerlo. Cómo: nace sin capacidad de conectar y el operador la habilita con un secreto suyo. Se corrigió además la afirmación de la migración según la cual los servicios conectan con ese rol: no lo hacen, y una regla escrita que nadie implementa se lee como un control existente. Impacto: retira la credencial publicada. Migrar los servicios al rol acotado sigue pendiente: es trabajo de despliegue.

  • fix(gateway,document,storage,signature,shared): auditoría de seguridad de segunda pasada — 64 hallazgos confirmados, los 6 de severidad alta corregidos Por qué: las 12 auditorías anteriores cubrían el control de acceso por endpoint y la integridad del registro de auditoría. Catorce ejes no se habían mirado nunca: perímetro, confianza entre servicios, superficie pública, subidas, criptografía, salidas de red, frontend, analizadores, mensajería interna, asistente, infraestructura, registros y lógica de negocio. Cada hallazgo pasó por revisores cuyo cometido era refutarlo; se registran los que sobrevivieron. Impacto: 6 altos y 5 medios corregidos, cada uno verificado por mutación.

  • fix(gateway): un anónimo desde una sola dirección dejaba a todas las entidades sin servicio Por qué: el limitador incrementaba el contador global antes de decidir, así que las peticiones que él mismo rechazaba seguían sumando al cubo compartido. Cómo: el cubo por dirección se resuelve primero y, si rechaza, se sale sin tocar el global. El chequeo de salud sale del contador compartido, para que la saturación provocada por un tercero no declare enfermo a un gateway sano. Impacto: el coste del ataque deja de recaer sobre terceros.

  • fix(gateway,document): la ventanilla ciudadana no tenía tope propio y leía los anexos antes de medirlos Por qué: el cubo estrecho vigilaba la ruta de PQRS anterior y no la ventanilla vigente, que además acepta archivos; y la lectura traía los ficheros enteros a memoria antes de comprobar su tamaño, de modo que el rechazo llegaba con el coste ya pagado. Cómo: lista por admisión que cubre las dos puertas; tamaño declarado primero y lectura acotada después; tope de cuerpo propio de 32 MiB para las rutas anónimas, frente a los 512 MiB dimensionados para la importación autenticada. Impacto: la puerta anónima deja de ser un vector de caída.

  • fix(storage): el renderizador del oficio resolvía cualquier dirección del marcado Por qué: sin resolutor propio, WeasyPrint alcanzaba servicios internos, el servicio de metadatos de la nube y ficheros locales, desde el proceso que custodia las credenciales del almacén. El cuerpo llegaba saneado, pero en otro proceso: el gateway enruta la escritura del cuerpo directo al servicio de documentos, y quien llama a la interfaz no pasa por el navegador. Cómo: resolución cortada por admisión; solo se admiten recursos ya incrustados en el documento. Impacto: cierra el acceso a la red interna y la lectura de ficheros locales desde el documento oficial.

  • fix(document,signature): el texto firmado por toda la cadena se podía cambiar después, y radicar el cambiado Por qué: radicar comprobaba el estado y que no hubiera circuito abierto, y ninguna de las dos cosas se entera de una edición posterior al cierre. Salía un documento oficial con número inmutable cuyo texto nadie aprobó, mientras los asientos seguían atestiguando lo contrario. Cómo: el circuito publica la huella del texto que firmó y radicar la compara con el vigente. Editar no se bloquea: rehacer un documento firmado es el trámite real, y el sistema ya tenía la salida prevista en devolver_a_borrador; lo que faltaba era impedir saltársela. Impacto: las firmas dejan de dar por bueno un texto que no firmaron.

  • fix(document): el vínculo entre anexo y radicado lo afirmaba quien radica Por qué: la descarga mediada comprueba que el archivo sea anexo de ese radicado, y esa condición la escribía el propio solicitante. Adosar el identificador de un anexo clasificado a un radicado propio devolvía sus bytes, auditado como lectura legítima. Cómo: no se adjunta un archivo que ya cuelga de un radicado fuera del alcance del solicitante, con mensaje neutro para no convertir el rechazo en un oráculo. La comprobación va antes de pedir el número, porque un rechazo no puede costar un consecutivo. Impacto: cierra una lectura por encima del nivel de seguridad.

  • fix(document): la ruta heredada de PQRS sorteaba las tres guardas de la ventanilla Por qué: construía el radicado a mano en vez de delegar, como su propio contrato declaraba. Admitía en entidades que no habían habilitado la radicación en línea, sin tipo documental que diera serie ni retención, y con destino nulo: fuera de la bandeja de toda dependencia, con el término de la Ley 1755 corriendo. Cómo: delega en la ventanilla única. Impacto: cambio visible en una superficie pública. Una entidad sin tipo admisible ni dependencia receptora recibirá «ventanilla no habilitada» donde antes admitía. Verificar la configuración antes de actualizar.

  • fix(document,notification,shared): las cuentas de correo aceptaban cualquier destino, y la conexión no tenía plazo Por qué: era la única salida configurable sin filtro de red interna —los webhooks ya lo tenían—, así que quien administra una entidad podía apuntar el buzón a la base de datos o al almacén y usar «probar conexión» como escáner de puertos. Y sin plazo máximo, un destino que acepta y calla detenía la radicación automática de todos los buzones de esa entidad, sin dejar traza. Cómo: el validador de destinos pasa a orpycamcp_common y lo usan los dos servicios, para que no haya dos listas que crezcan aparte; plazo máximo de 30 s en la conexión. Impacto: cierra el escaneo de la red interna y el bloqueo indefinido del sondeo.

Corregido

  • fix(frontend): un sondeo de 2 segundos mantenía viva la sesión de una pestaña olvidada (cierre por inactividad) El control estaba bien y donde debe: el cierre por inactividad vive en Keycloak (ssoSessionIdleTimeout, 30 min en la plantilla de producción; 8 h en desarrollo, declarado «SOLO DESARROLLO»). Un cierre decidido por el navegador sería un control falso — se salta desactivando JavaScript. Pero el navegador sí puede ANULARLO: ese contador se reinicia al usar el refresh token, y la renovación transparente de hooks.server.js se dispara con cualquier petición al servidor de SvelteKit, también con las que la aplicación se hace a sí misma. /admin/interoperabilidad sondea el estado de una carga masiva cada 2 segundos —seis veces más rápido que la bandeja de correo, que ya se había corregido por esto mismo en agosto— y no paraba nunca si el sondeo fallaba o si el trabajo se quedaba en un estado no terminal. Una pestaña olvidada sobre una mesa sostenía la sesión hasta el tope absoluto de 10 h, con la pantalla desbloqueada. Cómo apareció: el README de Keycloak dejó escrita la pregunta al corregir el caso del correo — «antes de añadir cualquier sondeo periódico nuevo: ¿esto renueva la sesión de alguien que no está delante?»— y nadie se la había hecho para esta pantalla. La revisión consistió en responderla para las cuatro que sondean. Qué cambia: la misma corrección que ya se decidió para el correo, no una nueva — vigilarInactividad sobre los dos sondeos de la pantalla (documentos y expedientes; proteger uno solo dejaba el agujero abierto por el otro). No se cierra sesión desde el cliente: se deja de fingir actividad. Los jobId se conservan, el trabajo sigue corriendo en el servidor, y al volver la interacción se reanuda con una pasada inmediata. Se anuncia en pantalla: un seguimiento congelado en silencio se lee como un trabajo atascado, que es otra cosa. Guardián: shared/tests/test_sondeo_no_finge_actividad.py deriva del árbol las pantallas que combinan setInterval con fetch al servidor y exige que vigilen la inactividad. Lista de excepciones vacía a propósito. Dos veces es un patrón, y un patrón necesita un guardián en vez de una nota. De paso: dos guardianes ya existentes cazaron el aviso nuevo antes de que llegara a pantalla — mezclaba texto suelto con <strong> dentro de un contenedor flex, que trocea la frase en columnas. Corregido envolviéndola en un solo hijo. Verificado: por mutación con el defecto real (quitar la vigilancia pone el guardián en rojo y nombra la pantalla); shared 126; frontend 2024; lint y svelte-check limpios.

  • fix(scripts): el reset del entorno fallaba en su primera ejecución real, por dos pasos que faltaban (encontrados corriéndolo) La espera de disponibilidad no comprobaba lo que la siembra necesita: esperaba al /health del gateway, que no depende de Keycloak, así que contestaba a los 10 s mientras Keycloak seguía importando el realm sobre una base recién creada. La siembra arrancaba y moría con un ConnectError que no nombra a nadie. Peor: la comprobación se hacía desde el host por el puerto publicado, una ruta distinta de la que usa la siembra. Una comprobación de disponibilidad que no recorre el mismo camino que aquello a lo que da paso no prueba nada. Ahora espera al realm de Keycloak, a auth-service y al gateway, desde dentro de la red y por los mismos nombres y puertos. Faltaba crear el tenant: sobre un volumen nuevo no existe tenant_demo, y seed_demo.py da por hecho que sí —su propio mensaje de error lo dice—. Sin init-tenant.sh la siembra moría con un 500 al pedir el primer token, después de haber creado el usuario en Keycloak: un estado a medias que se lee como «Keycloak está mal». El paso entra como 5/8 y el guion pasa de siete pasos a ocho. Verificado ejecutándolo entero: audit_log pasó de 46.639 filas a 371, y las ~44.400 de los 7.910 tenants de prueba —las que «nadie podía borrar»— desaparecieron, que es el argumento entero de recrear en vez de restaurar. La cadena de hash verifica 370 asientos con cero roturas, primera vez limpia en esta instalación. Y los cinco perfiles quedaron exactamente como se diseñaron, incluido operador sin ninguna fila de credencial y archivista con la suya revocada.

  • fix(scripts): un ca-dir equivocado fabricaba una raíz de confianza paralela en silencio (E17/F4, credenciales de firma personal) Por qué: gen-user-cert.sh genera la sub-CA de usuarios cuando no la encuentra —lo correcto en un clon limpio—, pero sin distinguir «aún no hay ninguna» de «me han dado la ruta equivocada». Con un argumento de más, crea una raíz nueva, firma con ella y sobrescribe el espejo público que auth-service monta. El sistema deja de confiar en la CA que firmó las credenciales ya activas, y la emisión termina en verde. Cómo apareció: de verdad, el 2026-09-05. gen-user-cert.sh <csr> docker run … —dos comandos pegados en una línea— tomó docker como ca-dir y run como directorio de salida. Resultado: una CA nueva en docker/, el certificado en run/, el espejo apuntando a la CA accidental y el certificado activo de otro usuario colgando de la anterior. Nada lo dijo; se vio al notar un directorio docker/ de más en git status. Qué cambia: si ya existe el espejo público, ya hay una raíz en juego y el script no la reemplaza por iniciativa propia: aborta diciendo que casi siempre significa que el ca-dir es incorrecto. Crear la primera sigue permitido (el camino del clon limpio), y para una raíz nueva deliberada hay escotilla explícita (ORPYCA_PERMITIR_NUEVA_CA=1). Reparado en el entorno: espejo restaurado a la CA canónica, CA accidental y certificado suelto eliminados. La credencial de jefe vuelve a encadenar con la raíz que la emitió; la de archivista quedó anclada a la accidental pero está revocada, que es su estado de diseño, así que no firma nunca.

  • fix(auth,shared,frontend): la verificación de integridad de audit_log se paraba en la primera rotura (ADR-008, Ley 594/2000 arts. 22-24) Por qué: verify_chain devolvía (ok, id_de_la_primera_rota) y retornaba ahí. Sobre un registro que por ley es append-only —una rotura no se puede reparar nunca— eso significa que una sola fila mala inutiliza la comprobación para el resto de la vida del sistema: la herramienta deja de servir exactamente en la situación para la que existe, después de un incidente. Y decía menos de lo que hay: la pantalla presentaba el registro entero como comprometido. Ante un juez lo que importa es poder afirmar «2052 asientos íntegros y estas 4 filas rotas, aquí están sus id», no «desde la 41004 no sé nada». El propio aviso decía «no continúe asumiendo que el resto es confiable sin investigar» y no ofrecía ninguna forma de investigar: el recuento hubo que hacerlo a mano contra la base. Qué cambia: verify_chain recorre la cadena entera y devuelve un ResultadoCadena (total, roturas con id y motivo, verificados, total_roturas). La decisión de fondo: tras una rotura se sigue encadenando desde el hash almacenado, no desde el recomputado — con el recomputado una sola fila mala pintaría de rotas todas las siguientes y el resultado volvería a ser «desde aquí no sé nada» con más pasos. Tope de 100 roturas enumeradas y todas contadas: un registro masivamente corrupto no puede parecer que tiene exactamente 100 problemas. Recorrer entero no empeora la memoria — la consulta ya traía todas las filas. Contrato: broken_id se conserva (= roturas[0].id), el resto de campos son aditivos. La pantalla pasa de un veredicto binario a decir cuántas roturas hay, cuáles, y cuántos asientos verifican. Verificado: por mutación con el defecto real (reponer el return en la primera rotura pone el test en rojo), 233 tests de auth, 194 de integración de archive contra Postgres real (nueve archivos consumían el contrato viejo), y en el sistema corriendo — la pantalla produce sola los mismos números que el recuento manual: 4 roturas, 2052 íntegros, los cuatro id con su motivo. Cómo apareció: ejecutando la verificación final que el propio plan de pruebas llama «la prueba más fuerte». La alarma saltó por 4 filas sintéticas que una suite insertó en la base de desarrollo —causa raíz ya cerrada por chk_audit_log_hash_forma, e imposibles de borrar porque el registro es append-only—, y al investigarlas se vio que la herramienta no podía distinguir eso de una corrupción total.

  • fix(archive,mcp): un usuario de solo lectura podía escribir sobre un expediente (RF-SEG-03, Ley 594/2000 art. 24) Por qué: require_permission(perm) tiene min_crud=1 por defecto, y docs/es/api.md declara que el nivel 1 significa leer. Cuatro endpoints de escritura de expedientes omitían el parámetro, así que quedaban abiertos a quien tenía USUA_PERM_EXPEDIENTE en modo lectura: crear un expediente, editar sus metadatos, vincular un radicado y vincularlos por lote. Lo que lo delataba estaba en el propio archivo: excluir un radicado exigía nivel 3 y vincularlo no exigía nada; cerrar un expediente exigía 3 y crearlo no. El gate estaba puesto en una sola dirección de cada par — tercera vez que aparece esa forma en este proyecto. Qué cambia: POST /expedientes → min_crud=3 (crear); PATCH /{id}, POST /{id}/radicados y .../batch → min_crud=2 (editar). El catálogo de tools del MCP se casa con los nuevos niveles — lo exigió el invariante cruzado que ya existía (test_permisos_declarados), que cazó las tres tools que se habrían ofrecido a quien recibiría un 403 al confirmar. Lo que NO era defecto, comprobado antes de reportarlo: la guarda de estado sí existe y es por campo — cambiar trd_serie_id sobre un expediente closed/transferred devuelve 409 expediente_not_open, porque altera la retención; corregir el nombre o la descripción se permite a propósito. Y la exportación ZIP a nivel 1 es la conducta declarada en la migración 018, no un hueco. Guardián: shared/tests/test_escritura_exige_nivel.py deriva el censo de los 11 servicios y congela las 86 escrituras que hoy siguen a nivel 1 como excepciones declaradas, no aprobadas — mismo criterio que 018_permisos_inertes_declarados.sql usó con los permisos inertes. Una escritura nueva a nivel 1 rompe el test; arreglar una obliga a quitarla de la lista, que es como se sabe que la deuda baja. Con test anti-vacuidad, que cazó de inmediato que el censo salía vacío al ejecutarlo desde otra raíz. Y la documentación afirmaba lo contrario: api.md decía que una escritura ofrecida a nivel 1 «el backend la niega con 403». No la niega. Corregido con el censo real, porque esa frase es justo por la que un espejo RBAC de interfaz se fía del backend. Cómo apareció: en el barrido negativo B13, con el usuario consulta —creado para ser el control negativo— editando de verdad los metadatos de un expediente transferido desde la consola del navegador, sin pasar por ningún botón.

  • fix(signature,frontend): tener credencial de firma personal impedía firmar cualquier cadena (E06 Inc.2 / E17-F4 Inc.1, RF-FIR-13) Por qué: otra vez dos reglas escritas aparte, cada una correcta. El backend decide —y lo dice en un comentario— que «si el actor tiene un cert activo, ESTE es el único camino de firma para este acto (bypasea _gate_2fa por completo) — nunca ambos», y ese camino exige totp_code. SignAssistant.svelte, el único componente que firma cadenas, manda siempre challenge_id + otp por correo y nunca totp_code. Entre las dos, enrolar una credencial de firma quitaba la capacidad de firmar cadenas. Por qué era peor que un error: la rama personal devolvía 409 2fa_requerido, el mismo código que significa lo contrario —«arranca el reto por correo»—. El asistente lo leía así, pedía otro OTP, llegaba el correo, el firmante lo pegaba, el backend volvía a pedir totp_code, y vuelta a empezar. Un bucle sin salida y sin un solo mensaje veraz, que gastaba un desafío y un correo por vuelta. En la base quedan cuatro desafíos de jefe con intentos = 0: el OTP nunca llegó siquiera a comprobarse. Qué cambia: la rama personal pasa a 409 totp_requerido —código propio, para que los dos significados dejen de compartir nombre— y el asistente, al recibirlo, no emite ningún reto: pide el código de la app autenticadora y firma con totp_code. Se respeta la regla del backend, no se la esquiva: quien tiene certificado firma con su certificado. De paso, el asistente deja de tratar totp_incorrecto, totp_bloqueado y totp_no_activo como errores genéricos. Verificado: por mutación con el defecto real (devolver need_2fa ante totp_requerido pone 2 tests en rojo), y en el sistema corriendo — el turno 2 de la cadena del expediente quedó tipo_firma = personal, provider = xades_personal, factor_tipo = totp, challenge_id = null, sin un solo correo nuevo. Es la primera firma personal PKI ejecutada desde la bandeja de firmas. Cómo apareció: firmando como jefe, el único usuario del demo con credencial activa. Ninguna suite lo veía porque cada regla, por separado, está probada.

  • docs(signature): FirmaResponse.payload_sha256 significa una tercera cosa más (E06, continuación del asiento mal nombrado) Por qué: la corrección anterior de este mismo campo se escribió sin haber ejecutado nunca la vía PKI. Con provider xades_personal los bytes firmados van embebidos en el envoltorio <acto xmlns="urn:orpyca:firma-personal" …> que XAdES necesita, y el valor publicado es el digest del envoltorio, no el de los bytes que contiene. Qué se vio: dos turnos de la misma cadena, sobre el mismo objeto y con huella_de_contenido: false en ambos, publicaron 3727b474… (nativa, digest de expediente:<id>) y c4321d48… (personal, digest del envoltorio). Comprobado reconstruyendo el envoltorio: coincide exactamente. Qué cambia: solo la documentación —el valor no se toca, igual que la vez anterior—. El campo queda declarado como comparable solo entre firmas del mismo provider.

  • fix(signature): el asiento de auditoría de una firma afirmaba llevar el hash del documento y llevaba otra cosa (E06, Ley 594/2000 — trazabilidad del acto) Por qué: firma.turno_firmado publicaba bajo la clave hash_documento el payload_sha256 que devuelve el firmante, que es el SHA-256 de los bytes que se firmaron. Para un borrador esos bytes son el texto hexadecimal de la huella del documento, no el documento: el valor era el hash del hash. El docstring del firmante lo declaraba igual de mal —«el SHA-256 del contenido»— aunque la función es genérica y correcta (su Args ya admite «XML, hash, etc.»); quien nombraba mal era el llamante. Qué rompía: nada criptográfico —la verificación recompone los mismos bytes y es coherente consigo misma—, pero sí la verificación desde fuera. Quien tenga el PDF y el certificado de firma compara el SHA-256 del documento con firma.payload_sha256, ve que no coinciden, y nada en el nombre se lo advierte. Y un auditor que lea dos asientos consecutivos del mismo acto encuentra firma.challenge.emitido con 49fff365… y firma.turno_firmado con cd51626b…: dos valores distintos bajo el mismo nombre, justo donde hay que poder demostrar qué se firmó. Qué cambia — y qué no: el valor no se toca. El HMAC de las firmas ya emitidas cubre esos bytes y reescribirlo las invalidaría. Se corrige lo que el sistema dice: el asiento pasa a payload_firmado_sha256, el docstring del firmante deja de prometer una correspondencia que no existe, y FirmaResponse.payload_sha256 documenta que no sirve para comprobar contra un PDF en la mano. Los asientos anteriores conservan la clave vieja: no se reescribe audit_log, que es append-only por ley. De paso: el registro del factor consumido por TOTP leía sol["payload_sha256"] directamente y salía vacío para radicado/expediente; ahora usa el mismo resolutor compartido que el desafío. Guardián: test_asiento_nombra_lo_que_lleva.py prohíbe que una clave llamada hash_documento se alimente de un resultado de firma, y exige que toda fuente que la alimente sea una de las conocidas — un guardián que solo prohíbe la forma ya vista deja de servir en cuanto el error cambia de forma. Verificado por mutación con el defecto real. Cómo apareció: comparando dos asientos consecutivos de audit_log después de firmar. No lo habría encontrado leyendo el código.

  • fix(signature): una cadena de firma sobre un radicado o un expediente era imposible de firmar (E06 Inc.2, RF-FIR-13) Por qué: dos reglas escritas aparte que nunca se encontraron. CADENA_PAYLOAD_OBLIGATORIO deja radicado y expediente fuera a propósito y con la reserva escrita —se firman como conjunto, no tienen un artefacto canónico propio—, así que el contrato permite crear la cadena sin payload_sha256. Y emitir_desafio exigía esa huella para todos los tipos, con un 422 incondicional. Entre las dos, esas cadenas se podían abrir y no se podían cerrar: la bandeja las listaba, ofrecía «Firmar», y el paso 1 del asistente moría con «firma_solicitud.payload_sha256 debe fijarse antes de emitir un desafío de doble factor» — una columna de la base en la cara de un funcionario que no tiene nada que hacer con esa frase. Qué es en realidad: el gemelo invertido del fail-closed inalcanzable de este mismo lote. Allí un gate no podía dispararse nunca; aquí uno se dispara siempre sobre dos tipos válidos. Los dos se leen como una defensa y ninguno lo es: un gate que nadie puede satisfacer no protege un acto, lo suprime. Qué cambia: huella_para_desafio(sol) — devuelve payload_sha256 donde existe, y donde no, el SHA-256 de "{objeto_tipo}:{objeto_id}", que son exactamente los bytes que firmar sella en ese caso. El desafío no promete más de lo que la firma cumple, ni menos. Es el mismo criterio que ya aplicaba _gate_documento_vigente, que para estos tipos retorna sin decir nada en vez de denegar: lo que no se puede comprobar no se afirma, pero tampoco se convierte en un veto. Una función, dos llamadas: la emisión y la verificación la comparten. Antes cada punto leía el campo por su cuenta; si al corregir solo uno hubieran divergido, todo desafío de estos tipos se habría rechazado como «de otro documento» — y el fallo solo aparecería al teclear el OTP, con el código ya gastado. El test que había afirmaba el 422: codificaba el defecto, igual que el 202 del envío de correo. Reescrito para asertar sobre el hash_documento persistido — comprobar solo el 201 pasaría también si la huella se guardara en blanco o distinta de la que usa la verificación. Cómo apareció: pulsando «Firmar» sobre una cadena de radicado en /firmas. Ninguna pantalla abre hoy cadenas de estos dos tipos —solo /borradores, que sí resuelve la huella—, así que el defecto vivía donde no llega ninguna suite: en un tipo que el contrato admite y la interfaz nunca produce.

  • fix(storage): radicar un borrador con documento no funcionaba: storage rechazaba el PDF que el propio sistema produce (E23, RF-PRO-09) Por qué: allowed_system_mime_types —la lista de lo que el sistema genera, distinta de la de anexos que sube una persona— valía application/xml,message/rfc822. El PDF/A del oficio de salida se combinaba, se renderizaba entero y storage lo rechazaba al guardarlo: 415 Formato no admitido: application/pdf. Tercera vez que esta lista se queda corta por el mismo motivo: nació para el XML del índice —que se firmaba con su sello de tiempo y se perdía al guardarlo—, se amplió para el .eml del correo conservado, y cuando la producción documental empezó a generar PDF nadie volvió a mirarla. La garantía que sí funcionó: el orden de radicar es combinar → renderizar → subir → número. El fallo ocurrió antes de pedir el consecutivo, así que no se gastó ningún número: el borrador se quedó en aprobado sin tracking_number. Una garantía escrita para otra cosa —un fallo de render no consume un radicado— cubrió también ésta. Qué cambia: application/pdf entra en la lista de artefactos. No abre superficie: ya estaba admitido como anexo, así que cualquiera podía subir un PDF por la puerta de usuario; aquí solo se reconoce que el sistema también los produce. Las dos listas siguen separadas —fundirlas dejaría subir XML y correos completos con sus adjuntos incrustados por la puerta de anexos—, y hay un test que lo fija. Y un guardián para que no haya cuarta vez: test_artefactos_del_sistema_admitidos.py declara qué formato produce cada servicio y quién lo genera. Una lista de admisión y un productor que crecen por separado solo dan señal cuando alguien ve un 415 en producción; ahora la señal es un test en rojo. Verificado en el sistema: 2026-DEMO-S-000066 radicado con su PDF/A anexado como documento_principal (2026-DEMO-S-000066.pdf), lo que prueba de paso la segunda pasada de combinación — el documento se re-renderiza con el número ya asignado.

  • fix(storage): radicar un borrador con documento no funcionaba: storage rechazaba el PDF que el propio sistema produce (E23, RF-PRO-09) Por qué: allowed_system_mime_types —la lista de lo que el sistema genera, distinta de la de anexos que sube una persona— valía application/xml,message/rfc822. El PDF/A del oficio de salida se combinaba, se renderizaba entero y storage lo rechazaba al guardarlo: 415 Formato no admitido: application/pdf. Tercera vez que esta lista se queda corta por el mismo motivo: nació para el XML del índice —que se firmaba con su sello de tiempo y se perdía al guardarlo—, se amplió para el .eml del correo conservado, y cuando la producción documental empezó a generar PDF nadie volvió a mirarla. La garantía que sí funcionó: el orden de radicar es combinar → renderizar → subir → número. El fallo ocurrió antes de pedir el consecutivo, así que no se gastó ningún número: el borrador se quedó en aprobado sin tracking_number. Una garantía escrita para otra cosa —un fallo de render no consume un radicado— cubrió también ésta. Qué cambia: application/pdf entra en la lista de artefactos. No abre superficie: ya estaba admitido como anexo, así que cualquiera podía subir un PDF por la puerta de usuario; aquí solo se reconoce que el sistema también los produce. Las dos listas siguen separadas —fundirlas dejaría subir XML y correos completos con sus adjuntos incrustados por la puerta de anexos—, y hay un test que lo fija. Y un guardián para que no haya cuarta vez: test_artefactos_del_sistema_admitidos.py declara qué formato produce cada servicio y quién lo genera. Una lista de admisión y un productor que crecen por separado solo dan señal cuando alguien ve un 415 en producción; ahora la señal es un test en rojo. Verificado en el sistema: 2026-DEMO-S-000066 radicado con su PDF/A anexado como documento_principal (2026-DEMO-S-000066.pdf), lo que prueba de paso la segunda pasada de combinación — el documento se re-renderiza con el número ya asignado.

  • fix(signature): firmar estaba roto para todo el que no tuviera credencial PKI personal (E06/E23, RF-FIR-12) Por qué: firma.tipo_firma es NOT NULL desde la migración 002, el INSERT del repositorio nombra la columna, y la rama nativa de la cadena no le pasaba valor — así que le pasaba NULL explícito. Un NULL explícito ignora el DEFAULT: el default solo actúa cuando la columna se omite. Resultado: NotNullViolationError y un 500 crudo en la cara del firmante, para el camino más común. La rama personal sí fijaba el valor, así que el defecto solo existía en el caso mayoritario. Por qué no lo vio nadie: todas las suites doblan la conexión con AsyncMock, y un mock acepta cualquier fila. En la base de demostración no había una sola firma nativa — ese camino no había llegado nunca a Postgres. Y el docstring del propio repositorio afirmaba lo contrario de lo que hacía: «tipo_firma se fija explícitamente en cada INSERT». Séptima aparición en este proyecto del comentario que declara lo que el código no cumple. Qué cambia: el valor se resuelve en el repositorio (or "nativa"), que es el punto único donde la ausencia se convertía en NULL. Y signature-service estrena tier de integración contra Postgres real, portado del de document-service (gate RUN_INTEGRATION=1, schemas tenant_itest_* desechables): es la clase de defecto que solo aparece cuando quien valida es la base y no un doble. El tier cazó de paso que sello_tiempo es igual de NOT NULL. Y un segundo defecto debajo: dos migraciones definían la misma columna y solo una llegó. La 008 quiso redefinir tipo_firma a TEXT DEFAULT 'nativa' y su IF NOT EXISTS la convirtió en no-op; lo único suyo que sí se aplicó fue el COMMENT —los comentarios no llevan guarda—, así que la columna quedó documentando el vocabulario nuevo (nativa | sello_sistema | personal) y con el DEFAULT del viejo (electronica-nativa), un valor que ya no lee ningún código. Migración 019 lo alinea. Sin CHECK: una base con filas históricas del vocabulario viejo convertiría la restricción en una avería de despliegue.

  • fix(notification,signature): el envío de correo fallaba y el servicio respondía «aceptado» (E06 Inc.2, RF-FIR-13) Por qué: el envío es síncrono —send_notification agota sus tres reintentos SMTP antes de volver, y guarda el resultado como failed con su last_error— y el router respondía 202 Accepted pasara lo que pasara. El servicio tenía el dato correcto y contestaba lo contrario. Lo que rompía está un servicio más allá: el único llamante de /send es el OTP de segundo factor de signature-service, que tiene escrito su fail-closed 503 canal_2fa_no_disponible. Con el 202 incondicional ese gate no podía dispararse nunca por un correo no entregado: el firmante veía «Enviamos un código, vence en 4:48» y esperaba un correo que nadie mandó. Un fail-closed inalcanzable es peor que no tenerlo, porque se lee como una garantía. Y para un OTP la entrega es la función: un código con cinco minutos de vida que se reintenta en segundo plano no sirve de nada. Qué cambia: 502 envio_fallido cuando el SMTP no entregó; send_otp_email ya lo traduce al 503 que estaba escrito. La notificación se persiste igual — el historial del intento fallido es justamente lo que permitió encontrarlo (status=failed, attempts=3 mientras la pantalla decía lo contrario). El test que había afirmaba 202 con el SMTP caído: fijaba el defecto en su sitio. Reescrito conservando lo que sí medía (que la fila se persiste), y verificado por mutación en los dos servicios. Cómo apareció: recorriendo el sistema con MailHog apagado. No lo habría encontrado ninguna suite.

  • fix(frontend): se resolvía una anulación sin poder ver el radicado (RF-BUS, cola de anulaciones) Por qué: el detalle mostraba la causal, el motivo alegado y las fechas — es decir, todo lo que dijo quien pidió la anulación, y nada del documento sobre el que se decide. Anular no se deshace y recae sobre un radicado que ya existe y ya produjo efectos, así que resolver viendo solo la versión del solicitante es la peor forma de decidir barata. Mismo corte que el visto bueno a ciegas de /firmas: una decisión formalmente impecable sobre un objeto invisible. Qué cambia: enlace «Ver el radicado {n.º}» en el detalle, apuntando al deep-link /bandeja?radicado={id} — que ya existía, estaba probado y solo lo usaba /busqueda. No hace falta pantalla nueva ni endpoint nuevo: hacía falta usarla desde donde se decide. La trampa que evita el test: en una fila de la cola, id es el de la solicitud de anulación, no el del radicado (abrirDesdeCola ya lo documentaba). El enlace cuelga de selectedRow, que siempre lleva el del radicado; con el otro, el deep-link abriría un drawer vacío sin que nada fallara a la vista. Impacto: 3 tests nuevos; sin cambios de backend.

  • fix(frontend): el revisor tenía que aprobar sin ver el documento (E23, RF-PRO-10) Por qué: el detalle de la cadena mostraba identificador, tipo y fecha de solicitud, y ni el texto ni ninguna forma de llegar a él. Se daba visto bueno a ciegas. Y es el defecto que vacía la garantía del circuito por dentro: la aprobación se ancla al hash del cuerpo —el asiento dice «aprobó ESTE texto»— y esa precisión criptográfica no significa nada si quien aprueba nunca lo vio. El sistema podía demostrar qué se aprobó, no que se hubiera leído. Qué cambia: acción «Ver documento» en el detalle, para objetos de tipo borrador. Reutiliza el proxy de previsualización que ya existe en vez de abrir un camino propio: no persiste nada —es su razón de ser— y ya está gateado por clearance en el servidor, así que el revisor no ve nada que no pudiera ver de todos modos. Límite declarado, no disimulado: el endpoint de previsualización exige PERM_RADI. Un revisor que solo tenga PERM_FIRMA recibe un 403 y lo ve dicho, con qué pedir y a quién — no un botón mudo. Que tener un turno en un circuito dé por sí solo derecho a leer lo que se te pide aprobar es lo correcto y hoy no está resuelto en el backend; queda anotado como tal. De paso, anotado sin perseguir: /firmas/api/documento/[documentId] y su /verify existen, están probados y no los llama ninguna pantalla — otra capacidad construida sin puerta, del mismo corte que el proxy del circuito.

  • fix(signature,frontend): un turno de visto bueno se podía FIRMAR — y la bandeja invitaba a ello (E23, RF-PRO-10) Por qué: el gate estaba puesto solo en una dirección, y en la inocua. dar_visto_bueno rechazaba los turnos de firma desde el primer día («resolverlo por aquí comprometería a la entidad sin firmar»). El simétrico no existía: firmar comprobaba que el turno fuera tuyo y nunca miraba turno["rol"]. Así que un revisor podía producir una firma electrónica que compromete a la entidad y genera el documento definitivo, por un acto que él entendía como «lo he leído y me parece bien» — exactamente lo que el docstring del módulo dice que el diseño existe para impedir. Y la pantalla lo facilitaba activamente: la casilla de firma en lote se pintaba en toda fila y el botón decía «Ver / Firmar» sobre un visto bueno. Bastaba un «seleccionar todas en esta página» para comprometer a la entidad en bloque sin proponérselo. El peor gesto posible: barato, plausible e irreversible. Qué cambia: 409 turno_es_de_visto_bueno en firmar, y el rechazo ocurre antes de consultar a auth-service el estado de la credencial — ningún factor 2FA ni consulta de certificado se consume en un acto que se va a rechazar. En la bandeja, la selección de lote solo alcanza los turnos de firma (firmables), la barra de seleccionar-todas desaparece si no hay ninguno, y cada fila nombra su acto real: «Ver / Dar visto bueno» o «Ver / Firmar», con el aria-label diciendo lo mismo que el botón. De paso: SignaturePendingItem no declaraba rol aunque el backend lo manda desde que existe PendienteItem — el contrato del cliente se había quedado atrás, y sin él la pantalla no podía distinguir los dos actos sin que svelte-check protestara. Un test propio que no servía: la primera versión de «no se registra ninguna firma» pasaba también con el gate quitado, porque el flujo fallaba más adelante por otra razón. Se sustituyó por uno que mide dónde para. Un test que sigue verde bajo su propia mutación se tira. Impacto: 9 tests nuevos (3 en signature, 6 en la pantalla), verificados por mutación; visto en la bandeja real con el turno de visto bueno de jefe.

  • fix(document,signature,frontend): el circuito de aprobación no ataba: se abría, se ignoraba y se radicaba igual (E23, RF-PRO-09) Por qué: BorradorService.aprobar no consultaba nada. Comprobaba el estado de origen y marcaba «aprobado». De modo que se podía abrir una cadena de tres turnos sobre un borrador, no resolver ninguno, pulsar «Aprobar» y consumir el número. El visto bueno de los funcionarios era decorativo — y de rebote el gate documento_modificado que protege cada turno no protegía nada, porque nadie estaba obligado a pasar por él. No lo veía ninguna suite porque no faltaba en ninguna pieza: las tres —el circuito, la aprobación y la radicación— hacían bien lo suyo, y entre ellas no había nada. Qué cambia: el gate es vinculante si existe, no obligatorio. Sin circuito abierto, aprobar directo funciona igual que siempre — hay entidades donde una salida no pasa por visto bueno, y exigirlo a todas convertiría en requisito un paso que nadie pidió. Con un circuito abierto, 409 circuito_pendiente. Resuelto el último turno, signature-service deja el borrador aprobado solo. La dirección de la consulta es la decisión de fondo: document pregunta a signature (GET /api/v1/signature/internal/circuitos/{tipo}/{id}, sellado con X-Internal-Token, respuesta content-free). La alternativa —una marca en la fila del borrador que signature mantuviera al día— es una copia, y una copia se queda rancia por caminos que no pasan por signature: borrar la cadena, restaurar la base, un consumidor de eventos retrasado. Rancia en el lado permisivo es una puerta abierta. Preguntar devuelve el estado de ahora. Y por eso el aviso automático puede ser best-effort: si la llamada que aprueba al cerrarse el circuito se pierde, el botón vuelve a estar disponible porque la consulta ve el circuito ya cerrado. Se pierde un clic, no la garantía. Al revés —fiarle la garantía al aviso— habría sido la copia rancia otra vez. Y el acto que de verdad había que atar era RADICAR, no aprobar: el gate se puso primero solo en aprobar, y eso fue un error de análisis. En la pantalla el circuito se abre sobre un borrador ya aprobado —ese es el orden del trámite: se aprueba el texto, se manda a visto bueno, firma quien responde, y entonces sale—, así que la vía real para saltárselo nunca fue aprobar dos veces: era radicar con los turnos sin resolver. Y radicar es peor, porque consume el número inmutable: aprobar de más se deshace devolviendo a edición, un consecutivo gastado no se devuelve nunca. En tenant_demo había dos borradores aprobados con su circuito abierto, a un clic de radicarse; ahora los dos responden 409. El gate va antes de combinar, renderizar y pedir el consecutivo — mismo orden por el que un fallo de render tampoco gasta un número. Fail-closed: si signature-service no responde, 503 y no se aprueba. Suponer «no hay circuito» reabriría el mismo agujero por la puerta de atrás, y justo cuando el sistema está degradado. En la pantalla: con turnos sin resolver ya no se ofrece «Aprobar borrador» — se dice cuántos faltan. El estado del circuito se leía solo con el borrador ya aprobado, así que el dato se cargaba y nadie lo miraba justo cuando hacía falta. Impacto: 19 tests nuevos (7 en document, 4 de cableado y 6 del endpoint interno en signature, 5 en la pantalla), cada corrección verificada por mutación; el guardián de tokens internos cubre ahora el cliente nuevo; sin migración.

  • fix(signature,document,frontend): el circuito de firmas era inalcanzable en la práctica, y no había vuelta atrás (E23 Inc.4, RF-PRO-10/11) Por qué: el circuito exigía que el borrador tuviera un PDF/A ya guardado, y el PDF solo se genera al radicar —previsualizar no persiste nada, es su razón de ser—. Así que solo podía abrirse DESPUÉS de radicar, cuando recoger aprobaciones ya no sirve para nada. Y una vez aprobado el borrador, el texto quedaba congelado: el ciclo era de una sola dirección y la única salida era radicar. Entre las dos cosas, el trámite que se pidió —varios dan visto bueno, uno firma, y si el documento cambia se recogen otra vez— no se podía ejecutar de principio a fin. Qué cambia: La cadena de un borrador ancla al TEXTO (cuerpo_sha256), no al PDF/A. Es lo que corresponde al trámite real: los turnos de visto bueno aprueban un texto —es lo que leen y lo que discuten— y el turno de firma es el que produce el documento definitivo con su número. La garantía se conserva entera y en el mismo sitio: si alguien edita el cuerpo, el disparador de la 030 recalcula el hash, el gate síncrono deja de casar y las aprobaciones caen. Sin ventana temporal, porque la comprobación es en el momento de resolver el turno. POST /borradores/{id}/devolver reabre un aprobado para corregirlo, con su acción en la pantalla. No toca el circuito a propósito: devolver y volver a aprobar sin tocar una letra deja las aprobaciones intactas, que es lo correcto. Lo que las tira es EDITAR — la invalidación se deriva del hecho, no del acto, porque si dependiera de que alguien pulse un botón, el caso en que se olvida sería exactamente el caso del fraude. Tres defectos más, del mismo corte: El rol se perdía al construir la respuesta. La consulta lo trae, el esquema lo declara, y _build_response no lo pasaba: TODOS los turnos salían con el valor por defecto —«firma»— hacia todas las pantallas. Es la peor dirección posible, y el propio esquema lo advertía: presentarlos igual «atribuiría a un revisor un compromiso que no adquirió». De rebote dejaba media función inalcanzable, porque /firmas decide con ese campo si ofrece «Dar visto bueno» en vez de «Firmar»: la acción de visto bueno no aparecía nunca, con el endpoint, el proxy y la pantalla escritos y probados. Los dobles de prueba omitían la columna, así que 29 tests tapaban el hueco desde el otro lado. La bandeja de firmas no decía qué te piden. PendienteItem no llevaba rol, así que no se distinguía «revise esto» de «comprométase con esto» hasta abrir el detalle. Ahora viaja desde la consulta. El panel del circuito adivinaba los nombres de campo. turnos/firmado_at, y la respuesta tratada como lista cuando es un objeto: el panel salía «turno 1 de 0» con el circuito perfectamente creado en base de datos. El contrato de un servicio se lee, no se supone — hay un test que ata los nombres al esquema Pydantic real. Verificado en el sistema en marcha, no solo en tests: circuito de OPERADOR (VoBo) → SECRETARIA (VoBo) → JEFE (firma) anclado al hash del texto; se reabre, se corrige el texto (versión 1→2, hash 49fff365… → a10472b7…) y el visto bueno responde 409 documento_modificado — «El texto cambió después de abrirse este circuito. Las aprobaciones anteriores no se arrastran.» Impacto: 9 tests nuevos, cada corrección verificada por mutación; sin migración nueva.

  • fix(signature,frontend): el circuito de firmas no se podía abrir desde ninguna pantalla (E23 Inc.4, RF-PRO-10) Por qué: el proxy /borradores/api/[id]/circuito existía, estaba probado, y su propio comentario decía que cerraba el hueco de que ninguna pantalla creara cadenas de firma. Ninguna pantalla lo llamaba. /firmas era una bandeja que firmaba lo que otro creaba, y no había otro. Séptima aparición en este proyecto del comentario que afirma lo que el código no hace — y esta vez sobre el requisito central que se pidió: varios dan visto bueno, uno firma. Qué trae: la acción «Abrir circuito de firmas» en /borradores con su modal de composición (turnos en orden, persona + rol), el estado del circuito abierto dentro del cajón, y /borradores/api/firmantes. Decisiones de fondo: Las personas salen de /auth/users/pickable (PERM_RADI), no del listado de administración. Componer un circuito es trabajo de quien redacta un oficio: exigir USUA_PERM_ADMIN para elegir a un compañero dejaría la función en manos de casi nadie de los que producen documentos. Las tres condiciones se dicen antes de pulsar, no como error después: falta el turno de firma, persona repetida, turno sin persona. El backend las rechaza igual, pero enterarse por un 400 obliga a rehacer el formulario entero para averiguar qué faltaba. Dos defectos que solo aparecieron al pulsar: El validador exigía al cliente una huella que el servicio tira. CADENA_PAYLOAD_OBLIGATORIO reclamaba payload_sha256 para borrador mientras CADENA_PAYLOAD_AUTORITATIVO hace que el servicio la ignore y la resuelva contra el productor. La única forma de satisfacer al validador era inventarse una huella — justo el gesto que la regla existe para impedir — y el proxy, escrito a propósito sin dónde ponerla, se llevaba un 422. Ahora se exige solo donde el servidor no puede resolverla (documento). La garantía no se afloja: se queda donde no se puede falsear. El token interno corregido había entrado en la función equivocada. El arreglo del token de la huella se aplicó a get_signing_key_revocation_status, que habla con auth-service: la huella seguía mandando el token de auth (403 en todas las consultas, y con ellas 503 en abrir circuito, dar visto bueno y firmar) y, de propina, la comprobación de revocación —que estaba sana— pasó a mandar el token de document a auth-service. Un arreglo que rompió lo que funcionaba mientras dejaba roto lo que iba a arreglar. El guardián existente no lo vio porque compara los VALORES de las variables del compose, y las dos casaban: la llamada usaba la que no era. Se le añade un segundo eje que ata cada base_url de destino con el token que viaja en esa misma función, verificado por mutación en los dos sentidos. Impacto: 7 tests de frontend + 2 guardianes nuevos en shared/; comentario de la migración 003 corregido (decía «OBLIGATORIO para borrador/documento», que ya no es el contrato).

  • fix(document,frontend): el aviso de campos sin dato vivía un solo instante (E23, RF-PRO-01) Por qué: la lista de campos que la plantilla no pudo resolver se calculaba al combinar, se enseñaba una vez en el cajón de creación y se perdía. Y nada podía volver a deducirla: el cuerpo se guarda YA combinado —donde no había dato hay una cadena vacía, no un *NOM_R*—, así que recombinarlo no encuentra ningún marcador que reportar. Al reabrir el borrador no había aviso, y X-Faltantes llegaba vacía en la previsualización. Dicho de otro modo: el aviso existía justo en el instante en que el hueco todavía no importaba, y desaparecía para siempre justo antes de importar. Quien abriera el borrador al día siguiente —o quien no fuera quien lo creó— vería un espacio en blanco en mitad del oficio sin nada que le dijera que ahí faltaba un dato. El docstring de previsualizar ya decía lo que tenía que pasar: «sin esa lista, la persona vería un hueco en el documento sin saber que es un hueco — y el oficio saldría así al ciudadano». Qué trae: columna borrador.faltantes (migración de tenant 032), el aviso al reabrir el borrador, y X-Faltantes que suma los guardados a los que sigan sin resolver. Decisiones de fondo: Se guarda como hecho histórico de la combinación, no como estado a mantener. Dice qué campos no tenían dato cuando la plantilla se combinó, y eso sigue siendo cierto por mucho que después se edite el texto. Por eso no se limpia al editar: borrarlo a la primera pulsación quitaría el aviso precisamente cuando alguien empieza a tocar el documento sin haber rellenado necesariamente los huecos. Avisa, no bloquea. Un hueco puede ser deliberado; quien decide es la entidad. La combinación corre SIEMPRE que haya plantilla, aunque el cuerpo venga puesto. Antes solo con cuerpo is None, y la pantalla manda el cuerpo ya combinado —lo pide para enseñarlo en el editor—, así que la rama no se ejecutaba nunca. Las dos salidas fáciles eran malas: aceptar la lista que mande el cliente (un dato que el cliente elige y que acaba impreso en un documento que da fe) o dejar de enseñar el texto resuelto antes de guardar. El servidor rehace la misma combinación y saca de ahí los faltantes; el cuerpo que se guarda sigue siendo el del cliente, que ha podido editarlo y con razón. Impacto: 6 tests nuevos —2 de integración contra Postgres real— y verificación por mutación de las tres piezas (columna, cálculo en la creación, suma en la previsualización).

  • fix(frontend,document,tenant,shared): cinco defectos que solo aparecieron pulsando botones (E23, revisión por navegador) Por qué: E23 tenía 534 tests en verde y sus cinco incrementos cerrados. Al recorrerlo desde el navegador, como lo hará un funcionario, el mecanismo central —la combinación de campos— resultó ser inalcanzable desde la única pantalla que escribe plantillas. Ninguno de los cinco defectos era visible desde una suite: los cinco viven en las costuras entre piezas que sus tests miran por separado. Qué se corrigió: 1. El editor se comía los marcadores. La regla de entrada de Markdown para cursiva convierte *texto* en <em>texto</em> al teclear el asterisco de cierre, y los marcadores se escriben exactamente así: quien escribía *NOM_R* guardaba <em>NOM_R</em>. Los asteriscos desaparecían antes de llegar a la base de datos, la plantilla se veía bien —en cursiva— y nada la delataba, porque para el backend allí nunca hubo un marcador y la lista de faltantes no podía avisar. Nueva extensión marcadoresDeCombinacion, con prioridad por encima de la cursiva y casando con la MISMA forma que reconoce el backend; *importante* sigue poniéndose en cursiva. Solo se activa en los dos editores cuyo texto alimenta la combinación. 2. La sesión caducada no renovaba: rompía todas las pantallas. La guarda miraba si la cookie EXISTE, nunca si el token sigue vivo, y el refresco solo corría con la cookie ausente. La cookie sobrevive siempre al token —su maxAge corre contra el reloj del navegador y el exp contra el de Keycloak—, así que con la sesión muerta la guarda la daba por buena, locals se poblaba con claims caducados y cada pantalla se dibujaba entera con un 401 dentro. El funcionario tenía un refresh_token vivo en el tarro y nadie lo usaba. Nuevo tokenVigente(token) con margen, aplicado en hooks.server.js y en la guarda del layout privado. 3. El vocabulario existía y la pantalla no lo pedía. GET /plantillas/campos publica los 40 marcadores desde el primer día; /admin/plantillas —la única pantalla donde se escriben plantillas— no lo consumía. El propio docstring del endpoint lo advertía: «sin esta lista, escribir una plantilla es adivinar». Ahora se cargan en el SSR y se listan agrupados junto al editor, con inserción al pulsar y marca de los que se resuelven al radicar. 4. El pie de firma llevaba la dependencia equivocada. Los tres marcadores del grupo Dependencia —documentados como «dependencia productora»— se resolvían desde dest_dept_code, la dependencia DESTINO. En una Salida eso es la unidad que recibe: el oficio se firmaba y se enviaba con el nombre de otra dependencia. Un hueco se ve al releer; un valor equivocado no. Ahora se resuelve de la dependencia activa —o principal— de quien produce, y si no se puede establecer se declara faltante en vez de inventarse. 5. funcionario era un parámetro que ningún router pasaba. Estaba en la firma del servicio desde el primer día, con su test, y ni previsualizar ni crear ni radicar lo rellenaban: *LOGIN* salía vacío en todos los caminos, y como el hueco se reporta en faltantes parecía un dato sin configurar. Se toma de X-Username, que inyecta el gateway desde el token ya validado. Y un sexto, del mismo corte: las siete claves entidad_* que alimentan el membrete de todo documento producido existían únicamente dentro de un diccionario de Python. Ninguna migración las creaba, así que /admin/parametros —que enseña las filas que hay— no las ofrecía: el membrete salía en blanco en todos los oficios y no había forma de rellenarlo. Migración de tenant 009_params_entidad (tenant-service) las siembra en vacío —nunca con datos de ejemplo: un NIT inventado en un documento oficial es peor que un hueco, porque el hueco se ve— y un guardián en shared/ ata las claves que el código LEE a las que la migración CREA, en las dos direcciones. Lo que queda declarado, no decidido: *FUNCIONARIO* y *CARGO* —el nombre y el cargo de quien firma— no tienen origen en ningún sitio del sistema: auth_users solo guarda username y email. Se siguen declarando faltantes en vez de rellenarse con el login, que no es un nombre y quedaría impreso como tal en el pie de un oficio. Dónde vive el nombre de registro de un funcionario es una decisión de producto pendiente. Dos guardianes propios en rojo, corregidos: cinco mensajes de error escritos sin tildes, y el censo del contrato de storage acusaba un falso positivo —confundía el cuerpo de una PETICIÓN con el de una respuesta por llamarse igual la variable—. Un guardián que acusa donde no hay defecto se acaba silenciando. De paso se extendió al contrato del render, que nadie cruzaba. Impacto: 12 tests nuevos (frontend y backend), cada corrección verificada por mutación; shared/ 113 en verde; migración 009 de tenant-service.

Añadido

  • feat(document,frontend): quién leyó y quién descargó un radicado, por fin preguntable (RF-BUS-10; Ley 594/2000 art. 22) Por qué: la traza existía desde siempre y era buena —document.radicado_consultado al abrir la ficha y document.anexo_descargado al bajar un fichero, las dos con actor, canal e instante— y no había forma de pedirla. Solo desde /admin/auditoria, que exige USUA_PERM_ADMIN y saber por qué acción filtrar; o, si el radicado estaba en un expediente, desde el historial de ESE expediente. Un radicado suelto —la mayoría— no tenía respuesta. Otra vez el dato guardado que nadie podía leer. Qué añade: GET /api/v1/documents/{id}/historial-acceso (paginado, X-Total-Count) y la sección «Acceso» del detalle en /bandeja, plegada y cargada bajo demanda — un radicado muy consultado acumula cientos de asientos y traerlos al abrir cada ficha pagaría esa consulta en todas las aperturas para una pregunta que casi nadie hace. No es un oráculo: no-read-up ANTES de tocar audit_log. Sin esa guarda, quien no puede ver un radicado reservado se enteraría igualmente de que existe y de cuánta gente lo ha mirado. Verificado contra el sistema: consulta (clearance 1) recibe sobre un radicado CLASIFICADO el mismo 404 document_not_found que sobre un UUID inexistente — indistinguibles, porque un 403 ya sería una confirmación. No filtra el payload, y no por omisión: los asientos llevan nivel_seguridad y el file_id del anexo, y ninguna de las dos cosas hace falta para responder «¿quién ha visto esto?». No se filtran al serializar: no se leen de la base, que es la única forma de que no se escapen el día que alguien añada un campo sin mirar quién lo pinta. Solo ACCESOS, por admisión: crear, anular o reclasificar son actos SOBRE el documento, no lecturas DE él; mezclarlos convertiría esto en un historial de todo y enterraría lo que alguien viene a buscar. Misma lista que ya usa archive-service para el expediente — dos listas que respondieran distinto a «¿esto es un acceso?» darían dos historiales del mismo hecho. La pantalla distingue con palabras: «consultó la ficha» no es «descargó un anexo», y en una reclamación por fuga de información la diferencia es toda. Y si el historial está truncado lo dice: uno incompleto presentado como completo es una afirmación falsa sobre quién ha visto un documento. De paso: un comentario de document_service.py decía que este endpoint no existía «pero se reservan [constantes] dedicadas para cuando exista». Ya existe — la previsión resultó buena y el comentario, actualizado. Verificado: 3 tests de integración contra Postgres real, con mutación sobre el no-read-up (quitarlo pone el test del oráculo en rojo); document-service 516 + 103 de integración; frontend 2024; lint limpio.

  • test(shared,scripts): el guardián del directorio, y un corpus que puede demostrarlo (E01, RF-RAD-05) El guardián: shared/tests/test_actores_pasan_por_el_catalogo.py deriva del árbol las pantallas que componen un radicado con sender_name y exige que manden también actores. No prohíbe sender_name —lo leen 43 archivos— sino que sea lo ÚNICO. Las excepciones se declaran con su motivo, y un test aparte impide que la lista se vuelva un cementerio. Se cazó a sí mismo: la primera versión comprobaba si la palabra «actores» aparecía en el archivo, y /radicar la menciona en sus comentarios y al importar ActorPicker — así que quitarle el envío real dejaba el guardián en verde. Lo destapó su propia verificación por mutación. Ahora exige actores como clave de objeto, con el mismo criterio que usa para detectar sender_name. Una sexta puerta que no estaba contada: el censo encontró /admin/interoperabilidad — la carga masiva de radicados importados de otro sistema. Queda como excepción declarada, no resuelta: lo importado entra con el remitente en texto libre y no alimenta el directorio. Obligar a buscar ficha por fila convertiría una importación de doscientos documentos en doscientas decisiones; cerrarla de verdad pide resolución en el backend al importar, como la que ya hace la ingesta de correo. El corpus puede enseñar la función: sembraba Ana Lucía Bermúdez 2 y … 3, que son nombres distintos y no los agrupa nadie — se comprobó, y /terceros/duplicados devolvía cero grupos sobre el corpus recién sembrado. Ahora siembra duplicados de los que ocurren de verdad: la misma persona con y sin tildes, en mayúsculas y en minúsculas. Solo el eje del nombre, y eso es una buena noticia: el del documento no se puede producir por la API porque el alta es idempotente por documento — la última pareja va con el mismo NIT a propósito, y demuestra que dos altas dan una ficha. Y los seeds vinculan actores: sembraban 40 terceros y cero vínculos, así que radicado_actor quedaba vacía y el eje entero —copia congelada, historial de una ficha, herencia tras una fusión— no se podía observar en el entorno de pruebas. El rol depende del tipo: en una Entrada la persona envía, en una Salida recibe. Dos defectos propios encontrados al probarlo contra el sistema: los duplicados deliberados no convergían —cada corrida del corpus añadía otra copia, cinco «José Pérez» tras tres ejecuciones— contra la promesa del propio corpus de dejar el mismo archivo; y el demo y el corpus usaban el mismo nombre por su cuenta, produciendo un duplicado accidental indistinguible de los sembrados a propósito. Un detector que enseña ruido del propio seed es un detector en el que nadie confía.

  • feat(document,frontend): las otras tres puertas por donde entraba un actor sin pasar por el directorio (E01, RF-RAD-05) Por qué: cerrado /radicar, quedaban tres flujos que seguían dejando texto libre y ninguna ficha. Cada uno necesita una clave de identidad distinta, y ahí está el criterio. Ventanilla ciudadana: con identificación crea o reutiliza la ficha por documento (crear_o_recuperar, idempotente), así que el mismo ciudadano escribiendo tres veces converge a UNA ficha. Sin identificación no crea ninguna: la petición anónima es legítima (Ley 1755/2015 art. 16) y una ficha por cada una sería una fábrica de duplicados, además de un directorio de «anónimos» que no sirve a nadie. Es la decisión que más importa de este incremento. Ingesta de correo: la dirección es la identidad del corresponsal en ese canal, igual que el documento lo es en la ventanilla. Se reutiliza la ficha que ya la tenga; si no hay, se crea con nombre y correo. Es la puerta de más volumen: sin esto, el mismo remitente escribiendo cada semana eran cincuenta textos distintos en sender_name. Envíos: precarga destinatario y dirección desde el actor congelado del radicado, editables — la dirección de ESE envío puede diferir legítimamente («remítanlo a mi oficina») y lo corregido se queda en el envío. Solo rellena lo vacío: pisar lo que alguien ya escribió le borraría el trabajo sin avisar. Era la tercera vez que se tecleaba la misma dirección. Un defecto propio, cazado por su test: la resolución por correo devolvía None tanto si no había ficha como si había varias, y caía a crear una tercera — empeorando el problema que venía a resolver. Ahora la lectura distingue las dos situaciones: con varias no elige ninguna (sería decidir en silencio a quién pertenece el documento) y deja que se resuelva en /admin/terceros. Mismo patrón que ya mordió antes en este proyecto: si de la lectura depende una escritura, no puede devolver «vacío» para dos cosas distintas. De paso, dos dobles frágiles: los tests de ventanilla simulaban fetchrow/fetchval con un valor fijo y una lista ordenada, así que se rompían en cuanto el código hacía una consulta más — aunque no tuviera que ver con lo que el test comprueba. Ahora responden según la consulta, no según el turno. Y una aserción que miraba await_args (la última llamada) pasa a mirar entre todas: lo que afirma es que se preguntó por la bandera del organigrama, no en qué orden. Verificado: 5 tests de integración nuevos contra Postgres real, con mutación sobre la rama anónima (crear ficha siempre la pone en rojo); document-service 511 + 100 de integración; frontend 2024; lint limpio.

  • feat(document,frontend): el directorio de terceros se cierra: buscar al radicar, y dónde administrarlo (E01, RF-RAD-05) Por qué: al radicar una Entrada no había forma de buscar en el directorio, como sí la había en el Orfeo legado. Mirándolo, el hueco era mayor: radicado_actor estaba vacía sobre 182 radicados mientras el catálogo tenía 40 terceros, y ninguna pantalla llamaba a /terceros/radicado/{id}/actores — ni para leer ni para escribir. El remitente de una Entrada no era una entidad del sistema sino dos columnas de texto libre, así que el directorio solo podía aprender de las Salidas; y de esas tampoco, porque el vínculo no se persistía. De 182 radicados, 104 eran Entradas. De ahí los duplicados. Qué cambia — radicar: POST /documents acepta actores y los congela dentro de la misma transacción que crea el radicado y su asiento; un tercero_id inexistente revierte la radicación entera, en vez de dejar un radicado sin remitente con el número ya gastado. DestinatariosPicker se generaliza a ActorPicker y sirve al remitente de una Entrada (tope 1) y a los destinatarios de una Salida (varios). Qué cambia — administrar: nueva pantalla /admin/terceros (/admin tenía 20 secciones y ninguna era el directorio). Lista, busca, muestra en qué radicados aparece cada ficha, y detecta duplicados por dos ejes que no valen lo mismo: mismo documento es certeza, mismo nombre normalizado es sospecha —puede haber dos homónimos—, así que muestra y nunca fusiona sola. La fusión es irreversible y se confirma con una frase tecleada. La base corrigió el diseño de la fusión: el plan repuntaba radicado_actor.tercero_id del absorbido al superviviente, y el trigger de la migración 026 lo rechazó — incluye tercero_id entre las columnas inmutables, con el mensaje «los datos de un actor son los USADOS en el envío y no se editan». Y tiene razón: a qué ficha se atendió a alguien también es parte de lo que ocurrió, porque ese día existían dos. Así que la fusión no reescribe nada: marca al absorbido y quien lee resuelve a través del puntero, de modo que el superviviente hereda el historial sin falsificar el pasado. Un mock habría dejado pasar el diseño equivocado. Verificado: 8 tests de integración contra Postgres real (3 de radicación con actores, 5 de fusión), cada uno por mutación con el defecto real; document-service 506 + 95 de integración; frontend 2024. Y contra el sistema corriendo: 2026-DEMO-E-000106 es la primera Entrada cuyo remitente es una entidad con su contacto congelado, 2026-DEMO-S-000053 conserva los tres destinatarios, y José Pérez Gómez / JOSE PEREZ GOMEZ se detectan como duplicado y se consolidan conservando el radicado de la absorbida. Migración: tenant/033_tercero_fusion.sql (validada en Postgres desechable: idempotente y con los dos CHECK mordiendo).

  • feat(scripts): un comando reconstruye el entorno de pruebas entero, con perfiles diseñados (soporte del plan de pruebas) Por qué: el recorrido funcional dependía de un entorno montado a mano y, sobre todo, de diferencias entre usuarios que nadie había decidido. El peor defecto de la ejecución de septiembre —tener credencial de firma impedía firmar cadenas— solo apareció porque jefe era, por accidente de trasteo previo, el único con certificado activo. Tras un down -v los cinco usuarios habrían quedado idénticos y esa rama habría vuelto a ser invisible. Qué añade: scripts/reset-entorno.sh encadena derribo con volúmenes → secretos y material criptográfico → pila → espera al gateway → seed_demo → seed_corpus → perfiles, e imprime una tarjeta con los secretos TOTP y el código vigente. scripts/perfiles_prueba.py reparte segundo factor y credencial de firma según un diseño explícito: operador nativa, jefe PKI activa, secretaria sin TOTP y clearance 2, archivista con la credencial revocada, consulta el control negativo. Cada fila existe porque ejercita un camino que ninguna otra toca. Recrear, no restaurar: audit_log es append-only por ley y ni el --purge lo toca, así que la única forma de dejarlo limpio es recrear la base — un snapshot congelaría la cadena tal cual, y en esta instalación estaba contaminada con 44.408 filas de 7.910 tenants de test que el tier de integración fue dejando y que nadie puede borrar. Además, sembrar por la API hace que cada documento nazca con su asiento y su índice; un snapshot puede contener estados que el sistema ya no sabe producir. Dónde corre cada cosa: los guiones van dentro de la red (scripts/Dockerfile, imagen propia y no la de un servicio, para no atar el entorno de pruebas a las dependencias de otro) porque sus valores por defecto ya son nombres internos — hablan con Postgres por asyncpg y con Keycloak por su API de administración. La emisión del certificado se queda en el host: gen-user-cert.sh declara que la clave de la sub-CA no entra en ningún contenedor, así que lo que cruza es un CSR, que es público. De paso, dos defectos del propio aprovisionamiento: _montar_grupo solo añadía miembros, así que mover a secretaria al grupo de nivel 2 la dejaba también en el de nivel 3 —y el clearance es el MAX—, con lo que el peldaño intermedio no llegaba a existir y nada lo decía; la pertenencia es ahora declarativa, acotada a los usuarios que el guion administra. Y pdf_minimo producía una línea de texto: un anexo así no ejercita paginación, ni descarga, ni límites de tamaño — ahora pdf_documento genera documentos de varias páginas y tamaño variable, porque un corpus donde todo pesa lo mismo esconde justo los problemas de los extremos. Limpieza: borrados los 3 esquemas tenant_itest_* huérfanos y el tenant itest- que quedaba registrado. Las 44.408 filas de audit_log no se pueden borrar y se quedan: es correcto, y es la razón por la que el reset recrea.

  • docs(pruebas,pantallas): la segunda ejecución del plan de pruebas queda escrita en el repo Por qué: un plan que solo puede ejecutar quien lo ejecutó se degrada en cuanto lo deja de ejecutar. docs/es/plan-pruebas.md ya recogía la ejecución de agosto; le faltaba la de septiembre. Qué trae: sección 7 con los cuatro defectos de E23 y cómo se destapó cada uno —dos de ellos no salen leyendo código: uno apareció consultando el estado real de tenant_demo (dos borradores aprobados con circuito abierto, a un clic de radicarse) y otro solo aparece pulsando—; cuatro casos ejecutables en el formato del documento; las dos trampas del propio método (dar por caído un servicio sano por un wget que seguía el redirect, y un guardián que no caía bajo su propio defecto); y lo que esta ejecución no cerró, declarado en vez de disimulado. La lección que queda escrita: en agosto el patrón era «el sistema tenía el dato bueno y la pantalla mostraba otro»; en septiembre es «cada pieza hacía bien lo suyo, y entre ellas no había nada». La pregunta que los caza no es «¿qué hace esta función?» sino «¿qué impide el acto indebido?». También: docs/es/pantallas.md refleja el gate del circuito en /borradores, el recorte de la firma en lote y «Ver documento» en /firmas, y el enlace al radicado en /anulaciones.

  • feat(document,frontend): paridad con el legado — plantillas por dependencia e importación de .docx/.odt (E23 Inc.5, RF-PRO-06/07) Por qué: una plantilla era global y punto, así que la lista que veía un funcionario de Jurídica incluía las de Contratación, Talento Humano y todas las demás. No es un problema de seguridad —una plantilla no contiene datos de nadie— sino de uso: con cuarenta en la lista, la que hace falta no se encuentra y el camino corto es no usar ninguna. Y una entidad que migra llega con sus plantillas en .docx con los *NOM_R* dentro: reescribirlas a mano es exactamente como una migración se queda a medias. Qué trae: plantilla.es_global + plantilla_dependencia (paridad con sgd_plan_plantillas.depe_codi + plan_global), recorte del listado por las URD activas del llamante, PUT /plantillas/{id}/alcance, la pantalla de asignación en /admin/plantillas, y POST /plantillas/importar para .docx/.odt. Decisiones de fondo: es_global por defecto TRUE. Las plantillas que ya existen las ve hoy todo el mundo; ponerlas en FALSE las haría desaparecer de todas las pantallas a la vez y el fallo se leería como «se borraron las plantillas». Restringir es un acto que la entidad decide plantilla por plantilla. Sin URD activa se ven SOLO las globales, no todas: el vacío no es un atajo a «sin restricción». Es la misma regla fail-closed que el clearance, donde lo dudoso cae a Pública — si una lista vacía significara «todas», bastaría con quitarle la URD a alguien para ampliarle lo que ve. ?todas=true exige USUA_PERM_ADMIN, comprobado en el handler y no subiendo el gate del endpoint: la lista normal la necesita cualquiera con PERM_RADI para redactar. Un recorte que el propio llamante puede apagar con un parámetro no es un recorte. La columna se llama es_global y no global —como el legado y como decía el plan— porque global es palabra reservada de Python: un schema Pydantic no puede declarar ese atributo, y salir del paso con un alias dejaría dos nombres para lo mismo en la frontera más leída del servicio. La importación promete lo que cumple. Conserva párrafos, negritas, listas y tablas del .docx; del .odt, solo texto por párrafos —y eso se declara SIEMPRE, no solo cuando se detecta, porque prometer negritas y luego perderlas es peor que avisar de entrada—. Encabezados y pies no se importan a propósito: los pone la plantilla de página del sistema, y traérselos duplicaría el membrete en cada oficio. No guarda nada: devuelve el cuerpo con los marcadores desconocidos antes de crear la plantilla, no después de tener que borrarla. Dos hallazgos: dependencias_del_usuario reventaba en un tenant a medio provisionar. Lee urd_usuaroldep, que crea auth-service y no este servicio; sin capturar UndefinedTableError el listado de plantillas —la pantalla más usada de la producción documental— respondía 500. Lo destapó el tier de integración, que provisiona solo las migraciones propias. Corregido devolviendo la lista vacía, que recorta a las globales, nunca None, que significaría «sin restricción». La pantalla de administración pedía el listado recortado. Sin ?todas=true, la pantalla cuyo trabajo es gestionar las plantillas de toda la entidad dejaba fuera precisamente las que hay que asignar. Es el patrón del dato cargado que nadie lee, en su variante «la pantalla pide lo que no le sirve». Seguridad de la importación: un .docx es un ZIP que sube un administrador. Se acota el tamaño comprimido y el descomprimido —unos pocos KB pueden expandirse a gigabytes y tumbar el proceso, y esa segunda defensa es la que importa; se decide con la tabla del ZIP, sin abrir un byte—. El texto del .odt se escapa: un < en el documento es un carácter, no una etiqueta. odfpy parsea con defusedxml, que cierra XXE. Las dos defensas verificadas por mutación. Impacto: migración de tenant 031 validada dos veces contra Postgres real; mammoth y odfpy (Python puro — no van detrás de una factory como el renderizador: una costura de degradación para algo que no puede faltar es ceremonia); lock regenerado; docs/es/api.md.

  • feat(frontend): la pantalla separa aprobar de firmar — y por fin alguien puede crear un circuito (E23 Inc.4, T-23, RF-PRO-10) Por qué: el backend ya tenía el circuito completo y no había forma de usarlo. POST /signature/requests existía en el gateway y solo lo invocaban los tests: /firmas era una bandeja que firmaba lo que otro creaba, y no había otro. Es el patrón de la capacidad construida y no ofrecida, otra vez. Qué trae: en /firmas, el acto que se ofrece depende del rol del turno propio —aprobar o firmar, nunca los dos—, y el rol se enseña en todos los turnos de la cadena, también en los ya resueltos: quien la lea dentro de diez años tiene que poder distinguir quién aprobó de quién firmó. En /borradores, componer el circuito (revisores + firmante, ordenados) y consultar su estado. Dos ausencias deliberadas, y en las dos la ausencia ES la garantía: el proxy del visto bueno no tiene dónde poner un segundo factor (D-6) y el del circuito no tiene dónde poner la huella —la resuelve el servidor, porque una que elige el cliente no vincula a nadie—. Cada una con su test sobre lo que se envía al gateway, no sobre lo que el código parece hacer. Accesibilidad: el rol se distingue por forma (borde y peso), no solo por color. Quien no separa los dos tonos tiene que poder separar igualmente una opinión de un compromiso de la entidad. Y el mismo error mío, cuarta vez en la épica: toMatch(/\{#if X\}[\s\S]*Boton/) sobre el fichero entero casaba la condición de un bloque con el botón de otro, así que sustituir la condición real por false no rompía nada; al acotarlo, el indexOf desde cero caía en el comentario de cabecera y devolvía un rango vacío, y un slice vacío hace pasar cualquier aserción. Las cuatro apariciones quedan anotadas con su regla: acotar la región, comprobar que no está vacía, y nunca [\s\S]* sin límite. Pendiente de E23: el evento documento.contenido_cambiado (limpieza de bandejas — la garantía ya la da el gate síncrono) y el Incremento 5 de paridad con el legado.

  • feat(signature,document): el circuito de visto bueno y firma — varios aprueban, uno firma, y nadie firma el nombre del objeto (E23 Inc.4, RF-PRO-09/10/11) Por qué: el sistema tenía dos mecanismos y ninguno servía para esto. La cadena de visto_bueno de workflow cuelga del radicado —que para una Salida ya consumió el número, o sea aprueba después de salir— y es un booleano: no guarda huella del texto aprobado. La cadena de firma personal sí tenía turnos, 2FA y XAdES, pero payload_sha256 era opcional y la propia migración lo documentaba: «si no se provee, la firma se produce sobre objeto_tipo:objeto_id». Una firma sobre el nombre del objeto sigue siendo válida después de que el texto cambie — el fraude exacto que un circuito de aprobación existe para impedir. Qué trae: firma_cadena.rol ∈ visto_bueno|firma para que los dos actos vivan en una sola cadena ordenada; POST /signature/requests/{id}/vobo; payload_sha256 obligatorio para borrador/documento; el estado invalidada; y borrador.version/cuerpo_sha256 con trigger. Cinco decisiones de fondo: El hash lo calcula la BASE. En la capa de servicio sería una convención — se respeta hasta que alguien añade otro camino de escritura. Con un trigger, ningún camino (presente, futuro, o un UPDATE a mano en una consola) puede guardar texto nuevo conservando la huella vieja. Y el trigger retira el documento producido al cambiar el cuerpo: dejarlo colgando sería peor que no tenerlo, porque alguien lo firmaría creyendo que es el documento del texto que está leyendo. Así la invalidación queda derivada de un hecho de la base, no de que alguien recuerde pulsar «revocar» — si dependiera de eso, el caso en que se olvida sería exactamente el caso de fraude. La huella la fija el servidor, no el cliente. Para un borrador, signature-service la resuelve contra document-service e ignora la del cuerpo. Es el gemelo exacto de la resolución autoritativa del nivel de seguridad, y por el mismo motivo: una huella que el llamante elige no vincula a nadie — bastaría con enviar la del documento anterior para que la cadena siguiera pareciendo válida tras editar el texto, el fraude entrando por la puerta principal. Mejora sobre el plan, que la hacía viajar desde quien abre el circuito. La defensa que manda es la síncrona. Al resolver cualquier turno se relee la huella vigente y se compara (409 documento_modificado). La invalidación por evento es at-least-once y puede retrasarse; si la garantía viviera solo en la cola, existiría una ventana en la que alguien firma sobre un texto ya modificado — y un test que dependiera de la cola pasaría en verde por casualidad de tiempos. No poder comprobar deniega (503): si un fallo de red se leyera como «no ha cambiado», bastaría con que document-service estuviera caído para poder firmar sobre un texto editado. Y el gate corre antes de mirar si el actor es firmante, con test propio para el orden: si corriera después, el 2FA se habría consumido para un acto que se va a rechazar igual. El visto bueno no lleva PKI ni 2FA (D-6, confirmada por el usuario). Lo obligatorio siempre es el anclaje a la huella, que es lo que convierte «aprobó» en «aprobó este texto». Exigir credencial de firma y segundo factor a cada revisor es viable en una entidad de tres y no en una de cuarenta. El firmante final sí firma con PKI: es quien compromete a la entidad. Y un turno de rol='firma' no se puede cerrar por /vobo — sería la vía para comprometer a la entidad sin firmar. Los dos mecanismos de visto bueno conviven con frontera escrita (D-7): el de workflow aprueba el trámite de una Entrada, este aprueba el documento de una Salida. Migrar hoy rompería trámites en curso sin ganar nada; dos mecanismos parecidos sin frontera escrita terminan usándose al azar. El mismo hallazgo, tres veces, y es sobre mi propio método: mis tests llamaban a la pieza directamente —el gate, _resolver_huella— y probaban su lógica sin probar que estuviera cableada. Desconectarla de firmar()/crear() los dejaba a todos en verde: el mismo defecto que un test que mide el DOM en vez de la variable emitida. Cada vez hizo falta un test que atraviese el punto de entrada real, y esos sí caen con la mutación. Detalles que valen: en la migración 018 el CHECK de estado se reemplaza y no se añade otro (dos CHECK sobre una columna se conjugan con AND, así que uno nuevo con invalidada no habría admitido nada), y lleva una guarda que aborta si alguien cambia el predicado del índice de unicidad — sin WHERE estado='pendiente', una solicitud invalidada bloquearía para siempre la cadena que la sustituye. El censo de valores admitidos se generalizó: el defecto original no es de nivel_conformidad, es de cualquier columna con dominio cerrado. Pendiente declarado: el evento documento.contenido_cambiado (limpieza de bandejas, no garantía) y la pantalla del circuito.

  • feat(document,gateway,frontend): el documento entra al archivo — radicar produce un PDF/A anexado, y el número se consume el último (E23 Inc.3, RF-PRO-03/05/08) Por qué: con Inc.1 la plantilla ya combinaba y con Inc.2 storage sabía generar PDF/A, pero radicar seguía escribiendo el cuerpo en una columna de texto. Este incremento une las dos mitades y toca lo único irreversible del sistema: el consecutivo. El orden ES la garantía: combinar → renderizar → subir → número → segunda pasada *RAD_S* → anexar. El render va antes de pedir el número para que un fallo no gaste un consecutivo. Un número de radicado es inmutable por ley y no se devuelve: un hueco en la secuencia es permanente y hay que explicárselo a un auditor. El test que lo sostiene no mira el código de estado —que sigue siendo 503 en ambos casos— sino que la secuencia ni se tocó; moviendo el render detrás de la creación, ese test cae y los demás no. Solo se renderiza dos veces si el cuerpo usa *RAD_S*/*F_RAD_S*, que no existen hasta consumir el número. Ese segundo render ocurre ya con el número gastado: riesgo residual declarado, no disimulado — la primera pasada probó que el motor funciona y que ese cuerpo se deja pintar, así que lo único que cambia es una cadena sustituida; si aun así fallara, se reintenta el anexado, no la radicación. Sin renderizador NO se radica (503). Degradar a «radicar sin documento» dejaría a la entidad creyendo que produjo un oficio que no existe, y el fallo aparecería cuando el ciudadano reclame. El contexto queda congelado en borrador.combinacion, y la huella del documento en borrador.documento_sha256 — que es lo que un visto bueno afirmará haber leído y lo que una firma comprometerá (Inc.4). Cambiar después la plantilla o la ficha de un tercero no reescribe lo ya producido: el dato usado pertenece al acto, no al sujeto. Mismo criterio que la dirección de un envío, y por eso la columna va sin clave foránea a ningún catálogo. Previsualizar (POST /borradores/{id}/previsualizar) devuelve el PDF sin persistir nada. *RAD_S* se queda a la vista a propósito: sustituirlo por un número de ejemplo enseñaría un documento que no es el que se va a producir, que es lo contrario de para qué sirve previsualizar. Dos hallazgos: El gateway habría tragado X-Faltantes en silencio. El cuerpo de la previsualización es el PDF, así que no hay otro sitio donde decir qué campos quedaron sin valor; sin la cabecera en FORWARDED_RESPONSE_HEADERS, la pantalla enseñaría un documento con huecos y nadie sabría que son huecos. Tercera vez que una cabecera propia necesita entrar en esa lista y tener test de contrato; quitarla deja el test en rojo. Un comentario mío afirmaba algo falso. Justifiqué el ::jsonb de set_documento diciendo que sin él la columna recibiría texto. Lo comprobé quitándolo contra Postgres real y el test siguió en verde: en un UPDATE ... SET columna=$n Postgres ya conoce el tipo de destino. El cast se queda por explícito y el comentario dice ahora la verdad — un comentario falso ha mantenido cuatro defectos vivos en este proyecto. Impacto: migración de tenant 029 (documento_file_id, documento_sha256, documento_formato, combinacion JSONB), validada dos veces contra Postgres real; TenantServiceClient.get_params —que devuelve {} ante fallo y NO lanza, al revés que sus hermanas: de un plazo mal calculado depende un vencimiento legal, de un membrete depende que el oficio lleve el nombre de la entidad, y tumbar una radicación por eso sería peor que la falta—; docs/es/api.md.

  • feat(storage,document): el documento de salida existe — render a PDF/A con conformidad verificada (E23 Inc.2, RF-PRO-03/04) Por qué: no había ninguna librería de generación en los once requirements.txt. BorradorService.radicar escribía el cuerpo en una columna de texto y no producía fichero alguno: el envío postal despachaba un radicado sin pieza documental y la firma personal PKI —construida, con TOTP y XAdES— no tenía sobre qué recaer en el acto más común de una oficina. Qué trae: app/services/render/ con la misma costura que el migrador de formato (types + factory + implementación), WeasyPrintRenderer HTML→PDF/A, y POST /api/v1/render/documento-internal sellado con X-Internal-Token — un endpoint inter-servicio se sella con token, no se gatea por permiso: el único llamante es document-service, que ya autorizó al funcionario. Tres decisiones de fondo: Sin renderizador no se inventa el documento. No hay stub, igual que en el migrador y por el mismo motivo: un validador puede declarar honestamente «no evaluado», que es un tercer estado real; un renderizador falso tendría que producir un documento, y el único que puede producir sin la herramienta es uno inventado. Sin motor, 503 explícito. Un oficio que la entidad cree haber producido y no existe es peor que un error visible, porque el fallo aparece cuando el ciudadano reclama. El documento nace en formato de conservación (D-2): PDF/A-2b directo, no PDF más una migración posterior. El barrido de preservación existe para el fondo heredado, no para lo que la entidad produce hoy. El membrete y el número los pone la plantilla de página, no el editor (RF-PRO-04; Ac. AGN 060/2001 art. 10; GTC 185). El editor es de texto, no de documento: dejar la identificación de la entidad a merced de que el funcionario se acuerde de escribirla es la mitad del problema que esta épica corrige. Sobre cómo está verificado, que es lo que sostiene la afirmación: los tests corren contra el renderizador real, no contra un mock —un mock del renderizador es una afirmación sobre él, y en este proyecto un mock que inventaba una clave dejó dos tests en verde sobre un campo inexistente—. Las librerías nativas de WeasyPrint van en la imagen base y no solo en production, al revés que veraPDF: con ellas solo en producción, el test que prueba la conformidad no correría nunca donde se corren los tests. Y el documento generado se valida con veraPDF real (PASS ... 2b), test que se salta declarándolo donde veraPDF no está. Quitar pdf_variant del render deja en rojo tanto el test estructural como el de veraPDF. Una trampa que me costó dos tests: buscar OutputIntent y FontFile en los bytes crudos del PDF da falso negativo — un PDF 1.7 mete los objetos en flujos comprimidos. Y el texto visible tampoco se encuentra literalmente, porque va codificado con la fuente subconjuntada: hay que extraerlo (pypdf, dependencia de test). Comprobar la forma del artefacto exige abrirlo, no mirarlo por encima. Impacto: RENDER_ENGINE/RENDER_PDFA_PROFILE en storage-service; weasyprint y pypdf en requirements + lock regenerado; docs/es/api.md. Aditivo: sin RENDER_ENGINE el servicio arranca y se comporta igual que antes.

  • feat(document,frontend): la plantilla deja de ser texto muerto — combinación de campos en la producción de respuestas (E23 Inc.1, RF-PRO-01/02) Por qué: BorradorService.crear hacía cuerpo = pl["cuerpo"] — copiaba la plantilla literal, marcadores incluidos. No había vocabulario, ni sustitución, ni contexto: el *NOM_R* del legado llegaba impreso al oficio del ciudadano. Es la primera pieza de la épica de producción documental, la que hace falta antes de que exista ningún fichero. Qué trae: vocabulario cerrado y declarativo de 40 marcadores (app/core/combinacion.py) con las dos sintaxis —*NOM_R* del legado y {{nom_r}} como alias, porque las entidades llegan con plantillas del Orfeo original y reescribirlas a mano es como una migración se queda a medias—; resolución del contexto contra las fuentes autoritativas del tenant (contexto_combinacion.py); GET /plantillas/campos; POST /plantillas/{id}/combinar; y validación al guardar la plantilla (422 marcador_desconocido con la lista: sin ella hay que buscar el marcador a ojo en un cuerpo de varias páginas, y el camino corto es borrar los buenos hasta que pase). Tres decisiones de fondo: La combinación ocurre en el servidor. Un marcador resuelto en el navegador es un dato que el cliente puede cambiar, y el documento que da fe no puede depender de eso. Por eso la pantalla ya no siembra el editor con la plantilla cruda: pide el cuerpo combinado. Un campo sin valor se vacía Y se declara. Las dos mitades importan: no dejar *DIR_P* impreso en lo que recibe el ciudadano, y no ocultar que el dato faltó. faltantes[] viaja en la respuesta y la pantalla lo enseña. Sustitución literal, nunca un motor de plantillas. Nada de Jinja2: la plantilla la edita un administrador desde el navegador, y un motor con condicionales y bucles ahí es ejecución de código gobernada por datos. Dos defectos que aparecieron al construirlo: La sustitución secuencial volvía a barrer lo ya sustituido. Un dato de contacto que contuviera un marcador —el nombre de un tercero, una dirección copiada de un formulario que rellena el ciudadano— se resolvía en la vuelta siguiente y traía al oficio un dato que nadie pidió. El valor combinado es dato, nunca plantilla: ahora es una sola pasada con las dos sintaxis en una alternancia, porque aplicarlas en dos pasadas deja que el valor insertado por la primera lo lea la segunda. La pantalla precargaba la plantilla en el navegador y la enviaba llena, así que la rama que combina en el backend no se habría ejecutado nunca: el patrón del dato cargado que nadie lee, aquí con el marcador llegando al ciudadano. Corregido con POST /plantillas/{id}/combinar y su test, verificado por mutación. No-read-up (RF-SEG-08): el radicado padre se lee con el clearance del llamante —su asunto acaba escrito dentro del cuerpo, así que sin gate combinar sería una vía de exfiltración—, y el test lo comprueba sobre el SQL enviado, no sobre el mock: con un mock, quien decide que no hay fila es el mock y borrar el WHERE dejaría el test en verde. Impacto: migración de tenant 028 (borrador.radicado_padre_id); docs/es/api.md; DS en documentos/specDrive/epics/E23-produccion-documental/. Sin efecto sobre lo ya radicado.

  • feat(mcp,document,frontend): correo institucional y notificaciones en el MCP — y el buzón que su propio funcionario no podía abrir (E18/E19, F10 + F13) Cierra el censo por flujos: F10 de 0 % a 57 %, F13 de 0 % a 70 %, 185 de 367 operaciones y un procedimiento nuevo, atender_correo_del_buzon (reservar → radicar), que impone el orden que el backend exige — sin reserva vigente, radicar se rechaza. El reparto de F10 es la decisión, no la cobertura. La bandeja es trabajo de ventanilla: un correo que entra por el buzón oficial es una comunicación oficial recibida (Ac. AGN 060/2001 art. 3), y decidir si se radica, quién lo trabaja y a qué dependencia se reparte es exactamente lo que hace una unidad de correspondencia. Se ofrece entera. Configurar los buzones, no: registrar la aplicación OAuth, dar de alta una cuenta o canjear una autorización es mover credenciales —el client_secret del proveedor, la contraseña del buzón, el código de autorización—, y ninguna puede viajar por un chat. Tampoco los buzones personales: su contraseña no se guarda a propósito, así que sincronizar_buzon y radicar_correo no declaran el parámetro password aunque la ruta lo admita; un buzón personal responde password_requerida, que es la respuesta correcta y no un fallo. Dos defectos que aparecieron al construirlo, los dos del patrón «capacidad construida y no ofrecida»: 1. El funcionario de ventanilla no podía llegar a su buzón. La bandeja se gatea con PERM_RADI a propósito, pero GET /email/cuentas —la única forma de saber qué buzones existen y con qué identificador— exigía USUA_PERM_ADMIN, y la única entrada en la interfaz era un botón dentro de /admin/correo. Aflojar ese gate no afloja nada: qué cuentas devuelve el listado no lo decide el permiso, lo decide el WHERE del repositorio, que reparte la personal a su dueño y la institucional a los asignados — y que ya recibía es_admin como PARÁMETRO, es decir, estaba escrito para servir a quien no lo es, con el gate de administrador dejando esa rama en código muerto. Corregido el gate, y añadidas la entrada de menú y la pantalla índice /correo, que distingue los tres finales —no se pudo preguntar, no tiene buzones, aquí están— que hasta ahora se veían igual: como una URL que nadie sabía escribir. 2. Se radicaba con la dependencia y el tipo documental ESCRITOS A MANO. La pantalla enviaba dest_dept —un nombre suelto— en vez de dest_dept_code, así que un radicado nacido de un correo quedaba sin vínculo con el organigrama: no aparecía al repartir por código, y «Jurídica» y «Juridica» eran dos áreas distintas. Y un tipo documental tecleado no casa con ninguna serie de la TRD, que es la que gobierna cuánto se conserva el documento. /radicar siempre ofreció selectores para los dos; esta pantalla, que radica exactamente el mismo tipo de documento, no. Es la misma lección que costó la corrección entera de la geografía DIVIPOLA: el dato tiene que elegirse. F13 se ofrece para observar y desatascar, no para configurar. Historial de notificaciones, una notificación concreta, los canales de salida configurados y la cola de eventos fallidos entera. Dar de alta o retirar un webhook, no: manda los eventos documentales de la entidad a una URL de fuera, y abrir un canal permanente hacia un tercero no se decide en una conversación. Los dos actos sobre la cola piden CONFIRMO en direcciones opuestas: reintentar tiene efecto externo y no transaccional —se manda correo— y puede duplicar avisos ya recibidos; descartar significa que lo que ese evento iba a producir no ocurrirá nunca. Se prefiere duplicar a perder en silencio, y se dice en la propia herramienta. Radicar un correo no pide teclear el identificador del mensaje: sería un UUID que la persona no puede contrastar con nada —el mismo motivo por el que no se ofrece sellar en WORM—; la tarjeta enseña el asunto y el remitente, que sí se contrastan. Liberar la reserva no pide nada: parar tiene que ser barato. 202 tests de mcp-server, 433 de document-service (+3), 1.937 del frontend (+3), 102 guardianes compartidos, lint verde en los once servicios, mkdocs --strict limpio. Verificado contra la pila: 201 herramientas visibles, las 16 de F10/F13 presentes, y GET /email/cuentas responde 200 a un usuario sin USUA_PERM_ADMIN (con la lista recortada por el WHERE, que es lo correcto) donde antes daba 403. Cuatro mutaciones, cuatro tests en rojo. Por qué: el censo por flujos existía para encontrar lo que falta contando, y lo que encontró en F10 no fue una herramienta que faltaba sino una pantalla cuyo público no tenía puerta — Impacto: 185 de 367 operaciones, 8 procedimientos, y siete actos declarados en el marco con su motivo y su vía.

  • feat(mcp): administración y seguridad en el MCP: 26 lecturas y cuatro exclusiones declaradas (E18, F12) El flujo que se resuelve casi entero diciendo lo que no se ofrece. 59 operaciones, 0 % → 44 %, y las 26 herramientas nuevas son todas de lectura: ni una escritura, y no por falta de tiempo. Se ofrece lo que se pregunta mientras se tramita: el vencimiento en días hábiles según el calendario de la entidad —contar días corridos da una fecha equivocada y los términos de ley se cuentan así—, los festivos que lo explican, las dependencias en lista, árbol y por código, los catálogos y sus valores admitidos, los parámetros, la identidad en lectura (usuarios, grupos, permisos con su nivel, miembros, clearance, permisos efectivos, unidades de reparto), el estado de las credenciales propias y la auditoría de un objeto con la verificación de la cadena de hash. No se ofrece conceder permisos, y ese es el punto. Todo el marco de alcance consiste en trabajar DENTRO de los límites de quien pregunta; un acto que los ensancha —conceder un permiso, subir un clearance, meter a alguien en un grupo— los deja de tener. Una frontera alcanzable desde dentro no es una frontera. Tampoco las credenciales personales: el secreto TOTP y los códigos de recuperación se revelan exactamente una vez, por diseño; entregarlos por este canal los dejaría escritos en el historial de una conversación. Se ofrece el estado —¿tengo el segundo factor activo?—, que es justo lo que hace falta saber antes de intentar una firma y no revela nada. Ni el calendario ni el vocabulario, por un motivo distinto y más callado: no son actos peligrosos, son la REGLA con la que se mide todo lo demás. Un festivo añadido hoy corre el vencimiento de plazos que ya están andando, sin avisar y sin aparecer en la traza de ningún expediente; y un catálogo al que se le añaden entradas al vuelo deja de ser cerrado, que era lo único que lo hacía servir para algo — «Bogotá», «BOGOTA» y «Bogotá D.C.» ya costaron una corrección entera. El guardián se escribió por PREFIJO DE RUTA, no por lista de nombres: una herramienta nueva sobre /auth/, /config/, /catalogos o /dependencias entra en él el día que se escribe, sin que nadie tenga que acordarse. Un guardián por lista se queda atrás justo cuando importa. Verificado por mutación: dos herramientas falsas —conceder un permiso, dar de alta el TOTP— lo ponen en rojo. 202 tests de mcp-server (+11), 102 guardianes compartidos, verificación por mutación de los dos invariantes nuevos. Por qué: un catálogo recortado en silencio hace que el límite se descubra pidiendo algo que nunca va a ocurrir — Impacto: 170 herramientas en total; F12 de 0 a 26, y cuatro actos declarados en el marco con su motivo y su vía alternativa.

Corregido

  • fix(shared): el guardián de tildes marcaba el verbo «se publica» como si le faltara una — Por qué: su propio criterio es admitir solo formas que están mal SIEMPRE, y «solo se publica un lote» es correcto; un guardián que se equivoca enseña a ignorarlo — Impacto: se acota al único contexto que desambigua a máquina (precedida de «se » es verbo), con control positivo para que el adjetivo «pública» sin tilde siga marcándose. La suite compartida vuelve a verde: llevaba en rojo desde el commit anterior.

Añadido

  • feat(mcp): los instrumentos archivísticos en el MCP — circuito TRD/TVD, tipos, metadatos y PINAR (E18, F9) El bloque más grande del censo (69 operaciones, 6 % → 55 %). La TRD fija cuánto se conserva cada cosa y qué se hace con ella al final: es el instrumento del que cuelga todo lo demás. 34 herramientas nuevas: el circuito de versiones completo de TRD y TVD —crear · nueva versión · aprobar · devolver · convalidar · derogar · registrar RUSD—, el historial, la disposición vencida y su decisión, los expedientes pendientes de revisión tras un cambio de tabla, tipos documentales, elementos y plantillas de metadatos, y la lectura del PINAR con el registro de avance. La fricción va en el NÚMERO DE VERSIÓN. Convalidar y derogar cambian qué tabla gobierna la clasificación de aquí en adelante, y el error irreparable no es convalidar —eso es lo que toca— sino convalidar la versión equivocada: no se corrige con otro acto, hay que abrir una versión nueva y recorrer el circuito entero, con la tabla equivocada rigiendo mientras tanto. Ambos piden teclear el número de versión. Y los pasos con vuelta atrás no piden nada. El circuito ofrece devolver para regresar de aprobada a borrador: poner ahí la fricción del acto irreversible sería gastarla donde hay remedio. Misma regla que en F7 —parar barato, consumar caro— aplicada a un circuito que trae su propia marcha atrás. PINAR se lee y se le anota avance; no se formula. Tablero, mapa de ruta, objetivos, proyectos, priorización y seguimiento, sí; armar la estructura, no — formular un PINAR es planeación con el Comité delante, no una secuencia de altas. Declarado en el marco con su vía. El guardián compartido cazó dos herramientas del incremento anterior apuntando a un endpoint SELLADO: consultar_aip y eventos_preservacion iban contra las rutas de storage, que exigen X-Internal-Token y solo alcanza el propio servicio — habrían dado 401 a todo el mundo. Las buenas son las mediadas de archive-service, donde el mediador resuelve el clearance del llamante contra el nivel vigente del expediente. Es el mismo guardián que hace dos incrementos estaba medio ciego a los gates de router; corregido, ya paga. Verificado contra la pila con los instrumentos del corpus: 157 herramientas visibles, 6 series TRD con las cuatro disposiciones, aprobar no pide teclear nada y convalidar y derogar piden el número de versión. 191 tests de mcp-server + 5 nuevos de fricción, 17 guardianes compartidos, lint verde, mkdocs --strict limpio. Por qué: sin los instrumentos el asistente puede tramitar documentos pero no puede responder con qué reglas se conservan, que es la pregunta que un archivista hace primero — Impacto: 146 herramientas en total; F9 de 4 a 38, con la fricción puesta en el dato cuyo error no se corrige.

  • feat(mcp): preservación en el MCP, y por qué sellar en WORM NO se ofrece (E18, F8) Cierra la pregunta que quedó abierta al construir la escalera de confirmación: en qué escalón cae sellar un artefacto. La respuesta no es un escalón — es que no se ofrece, y por un motivo distinto del de ejecutar en F7. En F7 el motivo era la garantía humana. Aquí es anterior y más simple: proteger-indice y proteger-artefacto exigen el file_id del artefacto firmado, un identificador que solo compone archive-service en el contexto del expediente — por eso la propia interfaz tampoco los ofrece. Un modelo tendría que inventárselo, y sellar el fichero equivocado bajo un candado de diez años no se deshace: la retención no se acorta. La tarjeta no protege de eso: le enseñaría a la persona un UUID que no tiene forma de contrastar, y una tarjeta que muestra un dato incontrastable no es un control, es un trámite. Mismo criterio para el empaquetado manual del AIP, que además es reconciliación sin decisión humana. No ofrecer el acto no puede significar dejar a ciegas: el sellado ocurre solo al cerrar, así que lo que se ofrece es enterarse — qué índices quedaron pendientes de firma, qué artefactos están por vencer su retención — y reintentar la firma del índice, que sí es un camino legítimo del operador. Ocho herramientas nuevas y dos distinciones que evitan gastar fricción donde no toca: migrar formato NO es irreversible (crea un derivado y el original queda intacto, Decreto 2609/2012 art. 32) y declarar el plan de preservación tampoco (versionado append-only: la corrección es otra versión); en cambio anotar en el registro PREMIS sí lo es, aunque no destruya — el registro no admite corrección, así que lo irreversible es la afirmación. Y el censo tenía el denominador inflado: /api/v1/storage/* salvo upload no lo enruta el gateway («SOLO /upload se expone»), y los gemelos …-internal son disparos de sistema sellados con X-Internal-Token. Contarlos como cobertura pendiente hacía que un servicio bien cerrado pareciera un hueco. Corregido en scripts/censo_mcp.py: F8 pasa de 24 operaciones a 11, y de 0 % a 73 % — los tres que faltan son los tres declarados como no ofrecidos. Verificado contra la pila: 123 herramientas visibles, el plan de preservación devuelve la política real (formatos_origen), registrar un evento pide CONFIRMO, migrar formato no pide nada, y el marco declara los cuatro actos que este canal no ofrece con su motivo y su vía. 191 tests de mcp-server (6 nuevos), lint verde en los 11 servicios, mkdocs --strict limpio. Por qué: la pregunta «¿en qué escalón cae sellar en WORM?» tenía una respuesta que no era un escalón, y responderla con fricción habría dado una falsa sensación de control sobre un identificador inventado — Impacto: 8 herramientas de preservación, dos actos más declarados en NO_OFRECIDAS, censo con el denominador corregido y 112 herramientas en total.

  • feat(mcp): la eliminación documental en el MCP, y la escalera de confirmación que exigía (E18, F7) F7 es el único procedimiento del sistema que termina destruyendo documentos de archivo: proponer → publicar → avalar → acta firmada → ejecutar. Antes de exponerlo había que responder una pregunta que el catálogo no tenía resuelta: qué se debe confirmar. El gate tenía un solo escalón —la frase fija CONFIRMO para todo lo irreversible— y no basta, no por severidad sino por objeto: una frase fija se teclea de memoria antes de leer nada, así que confirma «un acto», no «este acto». Ahora hay tres escalones y un límite: (1) aprobar sin teclear, para lo reversible; (2) CONFIRMO, para lo irreversible sin pérdida —radicar, publicar el inventario, enviar una transferencia—; (3) el identificador del objeto, tomado de la tarjeta, para lo que habilita una destrucción; y (—) no ofrecerlo. El tercer escalón es el salto: reproducir el identificador del lote no se puede anticipar, hay que haber mirado la tarjeta. Y lo que NO hace va dicho en el código y en la documentación: no convierte a un modelo en una persona; encarece el descuido, no lo impide. Por eso ejecutar no se ofrece. Es el único camino que borra documentos de archivo y su garantía humana no puede depender de que un cliente ajeno haya preguntado a alguien. Se ofrecen las cuatro fases previas y la ausencia se DECLARA en el marco de alcance con su motivo y su vía —dejar el lote avalado y con su acta firmada, y ejecutar desde la interfaz—: un catálogo recortado en silencio hace que el límite se descubra pidiendo algo que nunca va a ocurrir. Con el mismo criterio queda fuera la firma personal del acta de transferencia, que exige un segundo factor y no debe pedirse por un chat. La fricción va repartida al revés de como suele hacerse. Retirar un lote —el acto que PARA la destrucción— es el más barato de todo F7: se aprueba con un clic. Avalar, que la habilita, es el más caro. Si parar costara lo mismo que seguir, la fricción empujaría hacia adelante. Y la tarjeta resume en vez de volcar. Un lote lleva los identificadores de lo que va a destruir; cuarenta UUID en la tarjeta no informan, tapan. Las listas se muestran por su tamaño («12 elementos») y el detalle sigue viajando dentro de la propuesta firmada: lo que cambia es qué se le pone delante a quien decide. Qué exige cada acto lo fija el CATÁLOGO sobre los argumentos del token firmado, nunca el cliente: nadie puede rebajar su propio control declarando que su acto era de los sencillos. Y si el identificador no viaja, se cae a la frase fija y no a ninguna — degradar hacia más control. 10 herramientas de F7 (0 % → 91 %; falta solo ejecutar, por decisión). Verificado contra la pila: proponer no pide teclear nada, publicar pide CONFIRMO, avalar pide el UUID del lote, anular no pide nada, la tarjeta del lote dice «12 elementos» y el marco declara los dos actos no ofrecidos. 185 tests de mcp-server (12 nuevos), mutación en rojo sobre los dos mecanismos nuevos, 17 guardianes compartidos, lint verde, mkdocs --strict limpio. Por qué: exponer la eliminación con la misma ceremonia que consultar una bandeja habría sido el peor error posible de este catálogo, y una frase fija idéntica para radicar y para autorizar una destrucción enseña a confirmar sin leer — Impacto: confirmar_con en el catálogo, frase_requerida() en el gate, tarjetas que resumen listas, NO_OFRECIDAS en el marco de alcance, y 106 herramientas en total.

  • feat(mcp): F4, F5 y F6 pasan de casi nada a cubiertos — expediente, archivo físico y transferencias, con tres procedimientos más (E18) Segundo incremento sobre la clasificación por flujos, y en el orden que ella misma marcó: F5 y F6 eran los dos flujos que no se podían llevar desde el asistente —un archivista no puede trabajar si no puede ubicar una caja ni cerrar una transferencia—. 27 herramientas nuevas. F5 (7 % → 79 %): ubicaciones, unidades de conservación, colocación de un expediente con su rango de folios, movimientos e historial de préstamos. F6 (9 % → 82 %): el ciclo de custodia entero —crear, enviar, recibir, rechazar, acta congelada y verificación de firmas—. F4 (32 % → 61 %): excluir con causal, índice en XML, verificación de integridad, versiones, firma del índice, publicación de oficio, historial de acceso y grupos autorizados. Los tres actos que cambian la custodia —enviar, recibir, rechazar— se declaran irreversibles: cambian quién responde del acervo. Y preparar_transferencia no recibe, a propósito: recibir es del archivo receptor, y que una llamada hiciera las dos puntas convertiría una entrega entre dos responsables en un trámite de uno solo. Tres invariantes nuevos, y los tres encontraron algo. Que cada paso mapee solo argumentos que su tool declara —el error real fue escribir dest_dept, el nombre del endpoint, donde la tool declara dest_dept_code—; que aporte todos los obligatorios; y que ningún parámetro que se le pide a la persona quede sin usar. El tercero destapó un defecto mío: responder_radicado pedía el radicado de origen y no lo vinculaba. Su descripción prometía un vínculo que el circuito de borradores no hace, porque el sistema crea el antecedente al CREAR la Salida, no después. Renombrado a proyectar_y_radicar_respuesta, con la descripción diciendo la verdad y remitiendo a crear_respuesta_vinculada para el otro caso. Y el mismo defecto estaba en el corpus: seed_corpus.py leía GET /expedientes/{id}/radicados, una ruta que no existe (los radicados vienen en GET /expedientes/{id}), con opcional=True — el 404 se leía como «no tiene ninguno» y cada pasada rellenaba expedientes ya llenos. Tercera aparición del mismo patrón en dos días: una lectura opcional convierte «no pude leer» en «no hay». Corregido y la convergencia vuelta a medir contando antes y después. Verificado contra la pila con datos del corpus: 103 herramientas visibles para operador (96 tools + 7 procedimientos), las de archivo físico devuelven las 9 unidades y 6 ubicaciones sembradas, y estado_del_expediente compone cuatro consultas en una sobre un expediente real. 173 tests de mcp-server (3 nuevos, verificados por mutación), 17 guardianes compartidos, lint verde en los 11 servicios, mkdocs --strict limpio. Por qué: la clasificación por flujos no vale como documento si no se convierte en las herramientas que faltaban, y los dos flujos con menos cobertura eran justo los que dejaban a un archivista sin poder trabajar — Impacto: 96 herramientas (desde 69) y 7 procedimientos (desde 4); cobertura total 92 → 96 operaciones de 367, concentrada en los seis flujos que un funcionario recorre.

  • feat(mcp,shared): el API clasificado por FLUJOS DE TRABAJO, y 24 herramientas nuevas salidas de esa clasificación (E18) Pedido por el usuario: «realiza la clasificación por flujos de trabajo para crear las herramientas del MCP». El mapa de servicios dice quién sirve cada endpoint; lo que hay que decidir es qué necesita alguien que está haciendo un trabajo — y un trámite de ventanilla toca cuatro servicios. De ahí que la unidad de análisis sea el flujo y no el microservicio. Catorce flujos, censados mecánicamente cruzando el OpenAPI del gateway con el catálogo del MCP (scripts/censo_mcp.py, reproducible, con --detalle F4 para ver operación por operación). El documento vive en docs/es/mcp-flujos.md. Lo que queda FUERA está declarado con su motivo, porque un censo que contara las 367 como deuda mentiría: /internal/* va sellado servicio-a-servicio; /api/v1/tenants/* no está enrutado a propósito (el registro de entidades vive en public y cualquier usuario de una entidad podría escribir el de otra); /api/v1/public/* es la ventanilla ciudadana, sin sesión, y el MCP actúa siempre en nombre de un funcionario. Y el censo declara su propio límite: el agregador del gateway compone siete de los once servicios, así que 367 es un suelo. 24 herramientas nuevas, en los tres flujos que un funcionario recorre a diario: F1 ventanilla (terceros con cascada DIVIPOLA, actores congelados, reclasificación motivada, publicación de oficio y el circuito de anulación entero: solicitar · consultar · cola · aprobar · rechazar), F2 trámite (devolver con causal, cadena de vistos buenos, eventos, revertir asignación) y F3 despacho (bandeja de envíos, despachar, actualizar estado). La cobertura de esos tres pasa de 24 % a 61 %. Los permisos no se declararon a ojo: se extrajeron de los require_permission reales de cada router. Un permiso inventado hace la tool invisible para todo el mundo salvo ROOT, y eso ya pasó en este catálogo con un RADI_CREAR que no existía — la única escritura del MCP llevaba apagada desde su primer commit. El guardián que impide eso estaba medio ciego, y esa es la corrección de fondo. shared/tests/test_permisos_declarados.py solo leía el require_permission del DECORADOR, no el del propio APIRouter(..., dependencies=[...]). Tenía las dos caras: acusaba de «exigir de más» a las cinco tools de terceros —cuyo router sí declara PERM_RADI— y, lo grave, no habría cazado a una tool que declarara de MENOS frente a un gate de router, que es exactamente el fallo que este guardián existe para impedir. Además guardaba un solo prefijo por fichero: en anulacion.py, que tiene dos routers, los endpoints del primero colgaban de un prefijo ajeno y no casaban con nada — cuatro tools quedaron sin comprobar y solo se supo porque bajó el recuento total. Corregido: se resuelve router por router, y verificado por mutación (declarar de menos contra un gate de router ahora sale en rojo). Dos hallazgos legítimos del guardián ya corregido: consultar_borrador declaraba menos permiso del que exige su endpoint —se habría ofrecido a quien recibe un 403—, y decidir_visto_bueno declara PERM_VOBO sobre un endpoint sin gate, marcado como permiso resuelto en tiempo de ejecución: la autorización sale de ser el revisor de turno, y una escritura sin permiso declarado sería visible para toda la entidad. Verificado contra la pila con datos del corpus: 73 herramientas visibles para el usuario operador, listar_departamentos devuelve los 33 del DANE y buscar_terceros encuentra los sembrados. 170 tests de mcp-server, 17 guardianes compartidos, lint verde en los 11 servicios. Por qué: agrupar por servicio responde a quién sirve cada endpoint, no a qué necesita quien trabaja; y un catálogo cuyo guardián no ve la mitad de los gates puede mentir sobre los permisos sin que nada falle — Impacto: docs/es/mcp-flujos.md y scripts/censo_mcp.py nuevos, 24 herramientas más (69 en total + 4 procedimientos), y el cruce de permisos ahora lee también los gates de router.

  • feat(mcp,gateway,scripts): una base de pruebas de verdad, y el MCP empieza a ofrecer PROCEDIMIENTOS y no solo llamadas (E18, ADR-019) Pedido por el usuario: antes de tocar el MCP hacía falta una base poblada que permitiera probar el sistema entero. scripts/seed_corpus.py siembra ahora 197 radicados (150 anexos sobre 78 de ellos, 28 reservados y 22 clasificados), 16 expedientes con contenido desigual —24, 21, 20, 20, 12, 10, 7, 7, 5, 4, 3, 3, 2, 1, 1 y uno vacío, que es el borde que la pantalla suele olvidar—, 40 terceros con geografía DIVIPOLA repartida por el país, 41 envíos postales en varios estados, y archivo físico con un préstamo sin devolver. Todo por la API, nunca por SQL. Un INSERT directo se salta la numeración atómica, las reglas de negocio y la cadena de hashes de audit_log — en esta instalación las únicas filas no conformes de la auditoría son, precisamente, fixtures de SQL crudo. Y converge: dos ejecuciones dejan el mismo archivo, no uno más grande; un corpus que crece en cada pasada consume números de radicado, que son inmutables y no se devuelven. Sembrar destapó dos defectos que ninguna prueba había tocado. POST /api/v1/terceros respondía route_not_found: el prefijo no estaba en la tabla de ruteo del gateway, así que el alta de destinatarios y la cascada DIVIPOLA —construidas, probadas por unidad y documentadas— eran inalcanzables desde el frontend. Y la migración tenant/027 no estaba aplicada en el despliegue: la tabla tercero no existía. Una capacidad construida y no ofrecida, otra vez, y esta vez con dos causas apiladas. Tres defectos del propio guion, y el más caro fue mío: pedía size=200 donde el tope es 100, el 422 caía en una lectura declarada «opcional» y «no pude leer» se leía como «no existe» — cada ejecución volvía a crear lo ya creado. La corrección no es subir el tope: es que una comprobación de convergencia no puede ser opcional. Suponer vacío es exactamente lo que produce los duplicados, y sin dejar rastro. Los otros dos: el token de Keycloak caducaba a mitad de una siembra larga (ahora se renueva una vez por petición), y los expedientes reutilizados quedaban vacíos porque converger se había entendido como «no hacer nada si ya existen». El MCP: 367 operaciones en el gateway, 41 cubiertas. El censo se hizo cruzando el OpenAPI agregado contra el catálogo, no leyendo: 11 % de cobertura. Este incremento no cierra ese hueco —queda declarado y es el trabajo que sigue— sino que resuelve lo que lo hacía inútil: el MCP ofrecía llamadas sueltas y un funcionario ejecuta trámites. Radicar una petición son cuatro actos, no uno; con tools sueltas, quien elige el orden y enlaza los pasos es el modelo, y si algo falla a mitad nadie sabe en qué estado quedó el trámite. Los procedimientos declaran esa secuencia en el servidor, en la misma forma declarativa del catálogo (una entrada, no código por procedimiento): tramitar_peticion_ciudadana, responder_radicado, conformar_expediente y estado_del_tramite. Tres propiedades los hacen seguros: cada paso viaja con el token de la persona —un procedimiento es un orden, no una credencial: no puede hacer nada que su usuario no pueda—; una sola confirmación para el acto completo, porque pedir cuatro seguidas enseña a confirmar sin leer; y no se finge atomicidad. Eso último es el núcleo. No hay transacción que abarque llamadas HTTP a servicios distintos, y aunque la hubiera, radicar consume un número inmutable que no vuelve al mostrador. Por eso el resultado es un parte paso por paso y no un booleano. Verificado en vivo, y por accidente del mejor tipo: al probarlo escribí «Oficina Juridica» sin tilde, el radicado 2026-DEMO-E-000127 quedó creado y el reparto falló con 422; la respuesta dijo pasos_ejecutados: ['radicar'] y advirtió de no repetirlo entero. Un «falló» a secas habría llevado a radicar dos veces la misma petición, que es el daño más caro y menos reversible que puede causar este sistema. Un Protocol (Invocable) permite que tool y procedimiento compartan la misma validación de argumentos, la misma tarjeta de confirmación y el mismo gate de permisos: duplicarlas habría creado dos validaciones que divergen al primer cambio, ofreciendo o negando actos según por dónde entre la llamada. 170 tests de mcp-server (11 nuevos), 79 del gateway (4 rutas nuevas), 430 de document-service, 1.937 del frontend, lint verde en los 11 servicios. Mutadas por separado y en rojo con su test: la ruta de terceros retirada del gateway, el enlace roto entre pasos mandando None, y el parte que oculta qué quedó hecho. Por qué: sin una base poblada no se puede probar el sistema entero, y un MCP que solo ofrece llamadas sueltas obliga al modelo a inventarse el trámite — Impacto: scripts/seed_corpus.py nuevo, /api/v1/terceros enrutado en el gateway, cuatro procedimientos en el MCP con su ejecutor y su parte de lo ocurrido, expuestos por los dos transportes (REST y protocolo MCP).

  • fix(archive,storage,frontend): dos capacidades construidas a las que la pantalla no llegaba — la huella de cada documento del índice electrónico (E15 T-08) y la administración de grupos a ciegas Salen del repaso de pendientes que pidió el usuario. De los tres apuntados, el primero no era un defecto: comprobado contra la pila levantada, un token inválido, uno con firma falsa y la ausencia de cabecera dan los tres 401 correcto — el 503 del gateway solo se emite si auth-service responde 5xx o no responde, que es la semántica buena. Lo había dado por abierto leyendo la rama != 200 y una nota vieja, sin probarlo. 1) El índice electrónico no llevaba huella de ningún documento vinculado desde la interfaz. RT-15 art. 4.3.2.3 la exige, y el sistema SIEMPRE la ha tenido: storage-service calcula el SHA-256 al subir el anexo. Lo que no había era quien la trajera — el proxy de vincular manda radicado_id y tracking_number, y content_hash quedaba nulo. Resultado: «documentos con huella verificada: 0 / N» en expedientes cuyos radicados sí tenían ficheros con huella al lado. No se le pide al cliente, por el mismo motivo por el que el departamento se deriva del municipio: una huella aportada por quien vincula no prueba nada sobre lo que el archivo custodia. La resuelve el servidor —anexos del radicado en document-service, sus SHA-256 en storage— y el campo del contrato sigue existiendo para quien la conoce de primera mano (importación con fixity, E11 INT-05): lo declarado manda sobre lo resuelto. Qué significa «la huella de un documento» cuando trae varios anexos, declarado en un solo sitio (app/core/huella_documento.py): sin anexos no hay huella y se DICE (sin_huella_declarada, que no es una discrepancia de integridad); un anexo, su SHA-256 tal cual; varios, el SHA-256 de sus huellas ordenadas —un árbol de Merkle de un nivel—. Ordenarlas es lo que hace que la huella sea función del conjunto y no del orden en que la base devolvió las filas: sin eso, la misma evidencia daría dos valores y la verificación acusaría una alteración inexistente. Best-effort a propósito: si document-service o storage no responden, el vínculo se hace igual sin huella. Vincular un radicado a su expediente es un acto archivístico que no puede quedar preso de que otros dos servicios estén en pie, y un documento sin huella el índice sabe declararlo; abortar cambiaría un dato ausente por trabajo perdido. La resolución va fuera de la transacción: ese tramo es 100 % BD para no sostener el pg_advisory_xact_lock del tenant durante un viaje de red. 2) /admin/grupos administraba a ciegas, y el backend sí exponía las lecturas. El drawer daba de alta y de baja miembros, fijaba permisos y clearance sin mostrar nada de lo guardado: en su lugar enseñaba una lista «de esta sesión» con lo que él mismo había hecho —el recuerdo de los clics, no la membresía—. Los tres GET existían en auth-service desde el espejo de lectura de RBAC. Lo que mantuvo el hueco abierto fue un comentario que afirmaba que no existían, repetido en el proxy, en el +page.server.js y en el aviso de la propia pantalla. Cuarta vez en este proyecto que una frase falsa en un comentario es el vector: borrarla es parte del arreglo. Consecuencia concreta del hueco: como todos los selectores de permiso mostraban 0, guardar uno cualquiera podía rebajar en silencio otro que ya estaba concedido con nivel 3. Verificado en el navegador sobre tenant_demo: vinculado 2026-DEMO-E-000015 (un anexo) desde la pantalla, el vínculo quedó con 3af08544…, que es exactamente el sha256 del fichero en storage, y «Verificar integridad» pasó a decir 1 / 1. En grupos, «Demostración» muestra sus 4 miembros y todo en nivel 3; el control positivo con «Consulta» muestra 1 miembro, permisos en 0 salvo los suyos en 1, y clearance 1 — PÚBLICA. 900 tests de archive-service + 188 de integración contra PostgreSQL real (16 nuevos), 349 de storage-service (4 nuevos), 1.937 del frontend (6 nuevos), svelte-check 0/0, lint verde en los 11 servicios. Mutadas por separado y en rojo con su test: la resolución desconectada del vínculo individual, lo resuelto pisando lo declarado en el lote, y el lote preguntando por los que ya traían huella. Por qué: un índice que se autodeclara sin huellas teniendo los ficheros con SHA-256 al lado no cumple RT-15, y quien administra permisos no puede trabajar sin ver los que ya concedió — Impacto: POST /storage/internal/files/hashes nuevo, huella resuelta en servidor al vincular (individual y por lote), tres proxies de lectura en /admin/grupos y el drawer mostrando el estado real.

  • fix(storage,frontend): la política de migración vivía donde la entidad no la podía escribir (RF-PRE-04) El barrido periódico decía —en su docstring, en la config y en la documentación— que «qué formato está obsoleto lo declara la ENTIDAD, no este código». Y luego leía una variable de entorno. Una variable de entorno es del DESPLIEGUE: la misma para todas las entidades que comparten la instalación, intocable para el archivista, sin versión, sin autor y sin fecha. La regla estaba escrita y el código hacía lo contrario. La política pasa a preservacion_plan.formatos_origen, que es el otro extremo de formatos_destino —ya presente desde la primera versión del plan— y hereda las tres propiedades que faltaban: versionado append-only (cambiar la política es crear una versión nueva, la anterior sigue ahí), autoría y fecha. Cambiar qué se convierte del archivo de una entidad es un acto archivístico y tiene que poder mirarse hacia atrás. Se declara desde /admin/preservacion, donde el archivista ya administra el plan. La precedencia va en un solo sentido: el plan de la entidad manda sobre el valor del despliegue. La variable queda como valor por defecto de la instalación para las entidades que no hayan declarado nada — en un despliegue de una sola entidad, quien despliega es la entidad; en uno compartido, cada una lo sustituye con su plan. Al revés, una entidad no podría cambiar su propia política sin pedírselo a quien opera los contenedores. Y el corte por «sin política» ya no puede ser global. Antes, con la variable vacía, el job volvía antes de mirar la base: una entidad que hubiera declarado su política en su plan habría sido ignorada sin que nada lo dijera. Ahora se pregunta entidad por entidad, y las que no declaran nada se cuentan (sin_politica) en vez de desaparecer del resumen. Dos formas nuevas de creer que se convirtió, cerradas. Un MIME mal escrito —"msword", "application/ msword"— no casa con ningún archivo: el barrido no convertiría nada y la política se vería declarada en la pantalla. Se valida la forma tipo/subtipo y se rechaza con 422 en vez de guardarse, en el schema y también en el proxy SSR. Y la comparación pasa a ser insensible a mayúsculas (RFC 2045 §5.1): el MIME guardado es el que mandó quien subió el archivo, y un Application/MSWord quedaba fuera de una política que sí lo nombra, dando cero candidatos sin que nada pareciera roto. No cuesta índice — no hay ninguno sobre mime_type. La columna llega con DEFAULT '{}': un plan anterior a la migración no declara nada y nadie empieza a convertir por haber actualizado. Probado contra PostgreSQL real, que es donde se ve. 345 tests de storage-service + 30 de integración contra PostgreSQL real (4 nuevos), 19 del frontend en admin/preservacion (2 nuevos), svelte-check 0/0, lint verde. Mutadas por separado y cada una en rojo con su test: la precedencia invertida, el lower() quitado, la validación de MIME anulada, la columna sin viajar al INSERT y el campo sin viajar al PUT desde la pantalla. Por qué: una política que solo puede escribir quien opera los contenedores no es la política de la entidad, por mucho que el comentario diga lo contrario — Impacto: migración storage tenant/016, formatos_origen en GET/PUT /api/v1/preservacion/plan, campo nuevo en /admin/preservacion, y FORMAT_MIGRATION_MIME_TYPES degradada a valor por defecto de la instalación.

  • fix(document): el catálogo DIVIPOLA que cargué no era el del DANE (RF-RAD-05) Pregunta del usuario horas después de entregarlo: «revisa si cambió la DIVIPOLA del DANE». No solo había cambiado — lo que cargué nunca fue el catálogo oficial. La migración 003 lo tomó del volcado del Orfeo de ICETEX y lo verificó contra cuatro casos conocidos: 05001 Medellín, 11001 Bogotá, 27150 Carmen del Darién, 91001 Leticia. Los cuatro daban bien. El resto no se comprobó. Cuatro aciertos no dicen nada de 1.135 filas, y encima había una fuente oficial descargable que ni busqué. El cotejo mecánico contra el DANE (Datos Abiertos, recurso gdxc-w37w) encontró 38 diferencias: 15 nombres distintos, y uno no era una variante de escritura sino un municipio EQUIVOCADO: el volcado traía (muni_codi=21, dpto=05, 'PASTO') — Pasto colgado de Antioquia, con el código que pertenece a Alejandría. Pasto es 52001, en Nariño. Alejandría figuraba a su vez en 05076, que tampoco es su código. Verificado en el propio volcado: el dato de origen está corrupto, no fue un fallo de extracción — es la basura acumulada de una instalación real, irónicamente la misma clase de suciedad que este catálogo existe para impedir. 18 códigos que DIVIPOLA no reconoce: Sincelejo en el departamento 15 (Boyacá), Solano en 18765 en vez de 18756, corregimientos y veredas mezclados con municipios. 5 municipios ausentes, incluidos Tuchín (23815) y Nuevo Belén de Bajirá (27493), segregado de Riosucio e incorporado por el DANE en 2023. Los 33 departamentos sí coincidían — se habían escrito a mano contra la denominación oficial, que es exactamente lo que no se hizo con los municipios. Migración 004 nueva, no se reescribe la 003. El libro public.schema_migrations identifica por (servicio, nombre_archivo) y no guarda hash del contenido: editar la 003 dejaría a cualquier despliegue que ya la aplicó con el catálogo malo y sin que nada avisara. Es el fallo mudo que este proyecto ya conoce. Un código que el DANE no reconoce no se borra: se marca vigente = false. Borrarlo rompería una ficha que ya lo apuntara, y la política de no borrar un municipio suprimido la declaró la propia 003. Desaparece de la cascada y sigue resolviendo el nombre de un envío histórico. El guardián que faltaba CUENTA, que es lo que un caso suelto no hace: 1.122 entidades vigentes (1.103 municipios + 18 áreas no municipalizadas + 1 isla) y 33 departamentos, más la coherencia código↔departamento sobre el catálogo entero. Mutado dejando el catálogo sin corregir: los dos tests nuevos se ponen en rojo. Por qué: un vocabulario cerrado con el municipio equivocado es peor que el texto libre — el texto libre al menos no afirma ser oficial — Impacto: migración global/004, 1.122 entidades desde la fuente del DANE, y dos tests que cuentan en vez de mirar cuatro filas.

  • feat(document,storage,frontend): la geografía se elige y ya no se escribe (RF-RAD-05, cascada DIVIPOLA) y la migración de formato deja de necesitar que alguien la invoque uno a uno (RF-PRE-04, barrido periódico) Los dos pendientes que quedaron declarados al cerrar sus incrementos. No se parecen en nada salvo en la forma del defecto: una capacidad construida a la que no había forma de llegar. 1) La otra mitad del problema de duplicación. Las tres defensas de RF-RAD-05 atajaban el duplicado del SUJETO. En el DESTINO seguía abierto: mientras municipio y departamento fueron texto libre, «Bogotá», «BOGOTA», «Bogota D.C.» y «Santa Fe de Bogotá» eran cuatro municipios distintos para cualquier consulta, cualquier reporte y cualquier exportación. Una restricción de unicidad no lo cierra —las cuatro escrituras SON distintas y todas pasan—; lo único que lo cierra es que el municipio deje de ser algo que se escribe. El catálogo (33 departamentos, 1.135 municipios) sale del volcado del Orfeo de origen, que ya traía los códigos DIVIPOLA correctos y los nombres acentuados — verificado contra casos conocidos: 05001 Medellín, 11001 Bogotá, 27150 Carmen del Darién, 91001 Leticia. Vive en public porque es nacional: no lo edita la entidad y replicarlo por schema sería copiar un dato que nadie puede divergir. Fecha de corte 2017, y se declara: 94343 figura como «Guaviare» y hoy es Barrancominas, erigido en 2019. La carga es idempotente para poder actualizarlo con un volcado más reciente. La asimetría entre las dos tablas es la decisión de fondo, y es la misma lección de la copia congelada. En tercero los códigos llevan clave foránea al catálogo —la ficha viva debe apuntar a un municipio que existe—; en radicado_actor no la llevan, a propósito: esa fila dice a dónde se envió y tiene que sobrevivir a que el catálogo cambie, incluido que el municipio deje de existir. Atarla convertiría una reorganización territorial en una corrupción del registro histórico —o, con un CASCADE, se lo llevaría por delante—. Probado con un código que NO está en el catálogo: si hubiera clave foránea, esa inserción fallaría. Y el departamento se DERIVA del municipio, no se le cree al cliente. El código de municipio lleva dentro el de su departamento, así que aceptar el segundo por separado sería aceptar una contradicción que luego hay que detectar. Derivarlo la hace imposible, y un CHECK (left(codigo,2) = departamento_codigo) la remata en la base — es el error que una cascada mal programada produce en silencio: se cambia el departamento y queda el municipio del anterior. El nombre que se guarda pasa a ser el oficial del catálogo, no el tecleado, así que la búsqueda, los reportes, el índice electrónico y la exportación —que siguen leyendo las columnas de texto— dejan de ver cuatro escrituras del mismo municipio sin que ninguno tenga que cambiar. El código es opcional: una dirección en el extranjero no tiene DIVIPOLA y las fichas anteriores siguen valiendo — exigirlo habría cerrado la ventanilla por un dato de presentación. Por lo mismo el municipio entró en la deduplicación identidad+destino: «Calle 10 #5-20» existe en medio país, y sin él dos envíos legítimos a esa dirección en dos municipios se rechazaban como duplicado. Un test mío pasaba con la mutación puesta, y eso valía más que el arreglo. El caso «cambiar de departamento borra el municipio anterior» miraba el <select>: como vaciar la lista de opciones ya deja el desplegable en blanco, daba por buena una cascada que seguía mandando 05001 con el departamento 11. Reescrito contra el valor emitido, que es el que viaja al backend, y entonces sí se pone en rojo. Cuarta vez que un test verde no probaba nada. 2) Migrar existía y solo se disparaba a mano, archivo por archivo. Un archivo institucional tiene cientos de miles de ficheros. El barrido recorre los tenants activos y encola lo que falte, con tope por barrida —convertir es caro y sin él la primera se comería la máquina—. Qué está obsoleto lo declara la ENTIDAD, y por eso la lista viene vacía. Es un juicio archivístico que depende del parque documental de cada institución y de su PINAR; un job que lo decidiera solo estaría convirtiendo el archivo de alguien según una lista adivinada por el fabricante. Con la política sin declarar el barrido recorre los tenants y no toca un solo archivo. Mismo criterio que el barrido de disposición —cuenta y avisa, no elimina— con una diferencia que conviene tener presente: migrar no destruye; el original se conserva y el derivado convive con él. Lo peligroso no es convertir de más, es creer que se convirtió — por eso el barrido tampoco promueve nada que no haya pasado el validador. fallido y no_verificado NO son lo mismo, y confundirlos tiene consecuencias opuestas que no se ven. fallido habla del ARCHIVO —no se pudo convertir, o el resultado no era conforme— y reintentarlo cada noche cuesta un subproceso por archivo sin arreglar nada. no_verificado habla del DESPLIEGUE —se convirtió y no había validador con qué comprobarlo— así que el día que veraPDF esté instalado, la barrida siguiente lo resuelve sola sin que nadie tenga que acordarse. Tratarlos igual deja la máquina reconvirtiendo lo imposible, o abandona para siempre archivos que solo esperaban una herramienta. 430 tests de document-service + 8 de integración DIVIPOLA contra PostgreSQL real, 364 de storage-service + 6 de integración de la cola, 14 del frontend en terceros/, svelte-check 0/0, lint verde en los 11 servicios. Mutadas por separado y cada una en rojo con su test: el CHECK de pertenencia, la clave foránea ausente de la copia congelada, el trigger sobre el código, la limpieza del municipio al cambiar de departamento, la política adivinada y la distinción fallido/no_verificado. Por qué: un catálogo cerrado es lo único que impide que cuatro escrituras del mismo municipio sean cuatro municipios, y una conversión que hay que invocar archivo por archivo no protege un archivo de cientos de miles — Impacto: dos endpoints nuevos de catálogo, migraciones global/003 y tenant/027, cascada en /radicar, y un barrido apagado por defecto con la política en FORMAT_MIGRATION_MIME_TYPES.

  • feat(document,frontend): destinatarios con identidad, varios por radicado, y la dirección que no se puede falsificar (RF-RAD-05, núcleo) Tercera de las revisiones pedidas, y la que destapó el hueco mayor: no había lista de destinatarios que pudiera duplicarse, porque no había lista. El remitente y el destinatario vivían en dos columnas de texto libre de radicados —sender_name, sender_entity— reutilizadas para los dos papeles y distinguidas solo por doc_type. Una salida admitía un destinatario cuando el requisito exige varios, y sin identidad «Juan Pérez», «JUAN PEREZ» y «Juan Perez Gómez» eran tres personas distintas. Y el tablero volvía a mentir, tercera vez: RF-RAD-05 figuraba como [x] con el RT diciendo «Parcial». El código le daba la razón al RT. Corregido a [~] y la tabla resumen regenerada. LA CORRECCIÓN DEL USUARIO CAMBIÓ EL DISEÑO A MITAD, Y PARA MEJOR. Mi primer modelo ataba el radicado al tercero solo por identificador, con la dirección viviendo en la ficha. Eso significaba que actualizar la ficha haría que todos los envíos pasados dijeran que se enviaron a otra parte: información errónea sobre un acto hecho con un dato que era verdad entonces, sin importar que hoy sea otro. La regla quedó en: identidad por referencia, datos de contacto por copia congelada. tercero guarda quién es —cinco tipologías y documento de identidad— y se comparte entre radicados; radicado_actor guarda a dónde se envió, con nombre, entidad, correo, teléfono y dirección congelados e inmutables por trigger. Verificado contra PostgreSQL real: cambiar la ficha no altera lo que dice el radicado, y la copia no se deja editar ni a propósito — pero el orden y el canal sí, porque el radicado se compone (con su control positivo: un trigger que bloqueara todo pasaría el test y dejaría el radicado sin poder armarse). Y ESO ATAJA LA PLAGA DEL ORFEO ORIGINAL, donde el mismo sujeto llegaba a aparecer treinta veces con direcciones distintas y variaciones de tildes. La causa no era que faltara una restricción de unicidad: era atar la identidad a los datos de contacto, de modo que cada dirección nueva obligaba a crear un tercero nuevo. Con la dirección en el envío, cambiar de dirección ya no crea un tercero. Tres defensas contra el duplicado, y la más importante no es la restricción. Único por documento de identidad; búsqueda sin tildes ni mayúsculas —los duplicados no se crean por saltarse un índice, se crean por no encontrar lo que ya está—; y único por identidad + destino, no solo por identidad: el mismo señor en dos direcciones es un envío legítimo, en la misma dirección dos veces es un duplicado. Repetirlo es un no-op, no un error. EL CANAL ES UNA CONDICIÓN MATERIAL, NO UNA PREFERENCIA. Sin correo no hay envío electrónico y sin dirección no hay envío físico, así que un envío imposible no se puede ni registrar (dos CHECK). Dejarlo anotado haría que el radicado prometiera algo incumplible y el fallo aparecería al despachar, cuando ya no hay a quién preguntarle el dato. canal = NULL significa «todavía sin decidir» y se admite a propósito: obligar a elegirlo empujaría a poner uno cualquiera para poder seguir. La comprobación va sobre la copia congelada y no sobre la ficha, y el 422 nombra al destinatario y el dato que falta. Al radicar se puede corregir el contacto para ese envío, que es lo que pasa de verdad, y no toca el catálogo — la pantalla lo dice. El asiento anota cuántos actores se congelaron con un contacto distinto al de la ficha: la señal de que alguien cambió una dirección al radicar. Con su puerta el mismo día: buscador de destinatarios en el asistente, con el buscador primero y el alta solo cuando la búsqueda no dio con nadie; el canal ofreciendo únicamente lo que el contacto permite; y el aviso de que alguien sin correo ni dirección no puede recibir nada por ningún canal. 425 tests de document-service + 9 de integración contra PostgreSQL real, 1923 del frontend, svelte-check 0/0, lint verde. Migración 026 validada con sus nueve invariantes. Mutadas por separado: la corrección del momento, la idempotencia del alta, el trigger de inmutabilidad, la clave de deduplicación, la validación del canal, los canales ofrecidos y el aviso de «sin ningún canal».

    sender_name y sender_entity NO se tocan: cuarenta y tres archivos las leen. El modelo es aditivo y el primer destinatario las alimenta. Por qué: un destinatario sin identidad no se puede deduplicar, y una dirección que cambia bajo los pies del registro falsifica lo que se hizo — Impacto: cuatro endpoints nuevos, migración 026, y el selector de destinatarios en /radicar. Pendiente declarado: la cascada DIVIPOLA.

  • feat(document,frontend): destinatarios con identidad, varios por radicado, y la dirección que no se puede falsificar (RF-RAD-05, núcleo) Tercera de las revisiones pedidas, y la que destapó el hueco mayor: no había lista de destinatarios que pudiera duplicarse, porque no había lista. El remitente y el destinatario vivían en dos columnas de texto libre de radicados —sender_name, sender_entity— reutilizadas para los dos papeles y distinguidas solo por doc_type. Una salida admitía un destinatario cuando el requisito exige varios, y sin identidad «Juan Pérez», «JUAN PEREZ» y «Juan Perez Gómez» eran tres personas distintas. Y el tablero volvía a mentir, tercera vez: RF-RAD-05 figuraba como [x] con el RT diciendo «Parcial». El código le daba la razón al RT. Corregido a [~] y la tabla resumen regenerada. LA CORRECCIÓN DEL USUARIO CAMBIÓ EL DISEÑO A MITAD, Y PARA MEJOR. Mi primer modelo ataba el radicado al tercero solo por identificador, con la dirección viviendo en la ficha. Eso significaba que actualizar la ficha haría que todos los envíos pasados dijeran que se enviaron a otra parte: información errónea sobre un acto hecho con un dato que era verdad entonces, sin importar que hoy sea otro. La regla quedó en: identidad por referencia, datos de contacto por copia congelada. tercero guarda quién es —cinco tipologías y documento de identidad— y se comparte entre radicados; radicado_actor guarda a dónde se envió, con nombre, entidad, correo, teléfono y dirección congelados e inmutables por trigger. Verificado contra PostgreSQL real: cambiar la ficha no altera lo que dice el radicado, y la copia no se deja editar ni a propósito — pero el orden y el canal sí, porque el radicado se compone (con su control positivo: un trigger que bloqueara todo pasaría el test y dejaría el radicado sin poder armarse). Y ESO ATAJA LA PLAGA DEL ORFEO ORIGINAL, donde el mismo sujeto llegaba a aparecer treinta veces con direcciones distintas y variaciones de tildes. La causa no era que faltara una restricción de unicidad: era atar la identidad a los datos de contacto, de modo que cada dirección nueva obligaba a crear un tercero nuevo. Con la dirección en el envío, cambiar de dirección ya no crea un tercero. Tres defensas contra el duplicado, y la más importante no es la restricción. Único por documento de identidad; búsqueda sin tildes ni mayúsculas —los duplicados no se crean por saltarse un índice, se crean por no encontrar lo que ya está—; y único por identidad + destino, no solo por identidad: el mismo señor en dos direcciones es un envío legítimo, en la misma dirección dos veces es un duplicado. Repetirlo es un no-op, no un error. EL CANAL ES UNA CONDICIÓN MATERIAL, NO UNA PREFERENCIA. Sin correo no hay envío electrónico y sin dirección no hay envío físico, así que un envío imposible no se puede ni registrar (dos CHECK). Dejarlo anotado haría que el radicado prometiera algo incumplible y el fallo aparecería al despachar, cuando ya no hay a quién preguntarle el dato. canal = NULL significa «todavía sin decidir» y se admite a propósito: obligar a elegirlo empujaría a poner uno cualquiera para poder seguir. La comprobación va sobre la copia congelada y no sobre la ficha, y el 422 nombra al destinatario y el dato que falta. Al radicar se puede corregir el contacto para ese envío, que es lo que pasa de verdad, y no toca el catálogo — la pantalla lo dice. El asiento anota cuántos actores se congelaron con un contacto distinto al de la ficha: la señal de que alguien cambió una dirección al radicar. Con su puerta el mismo día: buscador de destinatarios en el asistente, con el buscador primero y el alta solo cuando la búsqueda no dio con nadie; el canal ofreciendo únicamente lo que el contacto permite; y el aviso de que alguien sin correo ni dirección no puede recibir nada por ningún canal. 425 tests de document-service + 9 de integración contra PostgreSQL real, 1923 del frontend, svelte-check 0/0, lint verde. Migración 026 validada con sus nueve invariantes. Mutadas por separado: la corrección del momento, la idempotencia del alta, el trigger de inmutabilidad, la clave de deduplicación, la validación del canal, los canales ofrecidos y el aviso de «sin ningún canal».

    sender_name y sender_entity NO se tocan: cuarenta y tres archivos las leen. El modelo es aditivo y el primer destinatario las alimenta. Por qué: un destinatario sin identidad no se puede deduplicar, y una dirección que cambia bajo los pies del registro falsifica lo que se hizo — Impacto: cuatro endpoints nuevos, migración 026, y el selector de destinatarios en /radicar. Pendiente declarado: la cascada DIVIPOLA.

  • fix(frontend,infra): un sondeo del navegador anulaba el cierre por inactividad (segunda revisión pedida) Keycloak cierra la sesión a los 30 minutos sin actividad en producción (ssoSessionIdleTimeout), y ese control estaba bien puesto: en el servidor, no en el navegador. Lo que no estaba previsto es que la propia aplicación lo desactivara. ssoSessionIdleTimeout se reinicia cada vez que se usa el refresh token, y la renovación transparente de hooks.server.js se dispara con cualquier petición al servidor de SvelteKit — también con las que la aplicación se hace a sí misma. La bandeja de correo sondea cada 12 s mientras la pestaña esté abierta. Resultado: una pestaña olvidada en una mesa mantenía la sesión viva hasta el tope absoluto de 10 horas, con la pantalla desbloqueada. Nadie decidió eso; salió de la suma de dos cosas razonables por separado. La corrección NO es cerrar la sesión desde el navegador. Eso sería un control falso —se salta desactivando JavaScript— y además duplicaría en el cliente una política que ya vive donde debe. Lo que se hace es dejar de fingir actividad: el sondeo se detiene tras cinco minutos sin interacción real ($lib/utils/inactividad.js), no hay renovación, y el temporizador del servidor hace su trabajo. Al volver a usar la pantalla, el sondeo se reanuda con una pasada inmediata — si no, la primera novedad tardaría hasta 12 s justo cuando alguien acaba de mirar. Ocultar la pestaña no cuenta como actividad; volver a ella sí. Irse a otra aplicación no debe renovar nada. La pausa se anuncia en pantalla. Una bandeja que deja de actualizarse en silencio se lee como una bandeja sin novedades, que es lo contrario de lo que pasa. Un guardián por forma impide que alguien quite la pausa o suba el umbral por encima del de Keycloak: si el umbral del cliente superara los 30 min, el sondeo seguiría renovando la sesión hasta después de que el servidor debería haberla cerrado. Y un test mío que no discriminaba: el de «ocultar la pestaña no es actividad» adelantaba el reloj el umbral entero DESPUÉS de ocultar, así que pasaba con y sin reinicio — medía otra cosa. Ahora adelanta casi hasta el umbral antes y solo un poco después. La advertencia queda escrita en infra/keycloak/README.md, que es donde mirará quien vaya a tocar los tiempos de sesión: antes de añadir cualquier sondeo periódico, ¿esto renueva la sesión de alguien que no está delante? 1914 tests del frontend, svelte-check 0/0, lint verde. Mutadas por separado: la visibilidad como actividad, el aviso único de inactivo y el umbral por encima del de Keycloak. Por qué: un cierre por inactividad que una pestaña abierta desactiva no es un cierre por inactividad — Impacto: el sondeo de la bandeja se pausa sin interacción real; ningún cambio en la política de sesión, que ya era correcta.

  • fix(archive,frontend): la traza decía quién descargó cada documento y la pantalla no la pedía (RF-BUS-10) Primera de las tres revisiones que pidió el usuario. La pregunta era simple —«¿quién accedió a este documento, quién lo descargó?»— y la respuesta estaba guardada desde el principio: document.radicado_consultado y document.anexo_descargado, escritas por document-service con actor, canal y nivel de seguridad, descartando el canal system y el actor nulo. Trazas correctas. Lo que fallaba era la puerta. El historial de acceso del expediente consultaba object_type ∈ {expediente, expediente_indice} filtrando por el código del expediente, mientras esas dos trazas se escriben con object_type='radicado' y object_ref=<número de radicado>. Resultado: la pregunta no tenía respuesta en la pantalla hecha para responderla, aunque el dato existiera. Enésima aparición del mismo patrón. Ahora los números de radicado del expediente entran en la consulta, con una lista de admisión y solo de lecturas: un radicado acumula además creación, anulación y reclasificación, y volcarlas convertiría el historial de acceso en un historial de todo. Una acción nueva no entra sola — hay que decidir si es un acceso. La fila dice ahora SOBRE QUÉ. «Descarga de un anexo» sin el número de radicado no responde nada; se muestra la referencia y no el payload, que lleva el nivel de clasificación del documento. No abre fuga: esos números ya se listan en la misma vista para quien supera el gate del expediente. Y el diccionario de etiquetas de esa pestaña no casaba con NINGUNA acción real: usaba read, download, verify… y las reales son archive.expediente_consultado, document.anexo_descargado, etc. Todas caían al humanize, y peor: la entrada «Descarga» hacía creer que la pestaña ya mostraba las descargas, que era justo lo que no mostraba. El mapa sale del .svelte a su propio módulo —dentro de una plantilla no se podía probar, y por eso se desalineó en silencio— con un test que exige que toda clave sea un nombre de acción del backend. Un comentario obsoleto que denunciaba un agujero YA CERRADO. La pantalla de auditoría advertía que AuditQueryRepository no acotaba por clearance y que un admin con nivel bajo vería acciones sobre documentos clasificados. Ya no es cierto: el repositorio aplica _clearance_clause (no-read-up, RF-SEG-08) resolviendo el nivel en vivo. Se deja escrito el cierre en vez de borrar el párrafo, porque el comentario obsoleto ya había sido vector aquí antes: un aviso de agujero abierto que está tapado manda al siguiente lector a auditar lo que ya está bien — o a «arreglarlo» otra vez. El recuento y la página comparten una sola condición: si vivieran separadas, el total podría contar entradas que la página no muestra y nadie se enteraría. 883 tests de archive-service, 1906 del frontend, svelte-check 0/0, lint verde. Mutadas por separado: el paso de los números de radicado y el vocabulario de etiquetas. Por qué: un registro de acceso que no se puede consultar sobre el documento concreto no sirve para rendir cuentas — Impacto: el historial de acceso del expediente incluye ahora las lecturas y descargas de sus documentos, con la referencia a la vista.

  • feat(storage,frontend): la migración de formato contra la obsolescencia — el sistema ya puede producir copias de preservación (RF-PRE-04) El último requisito de preservación en brecha. Había validación PDF/A (veraPDF real desde E10 C2b) y no había migración: se sabía decir que un documento no era PDF/A y no se podía hacer nada al respecto. Ahora POST /api/v1/preservacion/migrar-formato produce la copia de preservación, con Ghostscript instalado en la imagen de producción. EL ORIGINAL NUNCA SE TOCA. La migración produce un artefacto derivado que convive con él; el de origen no se marca, no se actualiza y no se reemplaza. Un sistema que sustituye el original ha perdido la evidencia el día que alguien discuta si la conversión alteró el contenido — y el test lo comprueba de verdad: el repositorio de archivos se sustituye por un doble y se afirma que la única llamada sobre el origen es la lectura, así que un futuro «marcar como sustituido» pondría el test en rojo. LA FIXITY SE COMPRUEBA ANTES DE CONVERTIR, no después. El SHA-256 del origen se recalcula sobre los bytes descargados y se compara con el almacenado: migrar un archivo ya corrupto propagaría la corrupción a la copia de preservación y le daría además el aspecto de ser la buena. Un desajuste responde 409 y deja constancia del intento, porque un archivo que falla la fixity es justo el que hay que ir a mirar. Y UNA CUARTA GARANTÍA QUE LA NORMA NO PIDE Y EL OFICIO SÍ: convertir a PDF/A y no comprobar que el resultado ES PDF/A produce una copia que nadie ha validado. El derivado pasa por veraPDF y hay tres desenlaces que no se confunden: migrado (conforme), fallido (no conforme — los bytes se descartan, no se guardan «por si acaso») y no_verificado (se convirtió pero no había validador: tampoco se promueve a copia de preservación; queda el registro del intento, no una copia con etiqueta de buena). NO HAY MIGRADOR «STUB», Y ESA ES LA DECISIÓN. La fábrica gemela del validador PDF/A degrada a un stub honesto porque «no evaluado» es un resultado real, distinto de conforme y de no-conforme. Aquí no existe el equivalente: un stub tendría que producir un archivo, y el único que puede producir sin la herramienta es uno inventado. Así que la ausencia se propaga como None hasta un 503 que dice qué falta instalar. Es la misma asimetría deliberada que la transcripción de voz — allí un stub tendría que inventarse lo que se dijo. El tipo de evento PREMIS MIGRACION llevaba en el vocabulario desde el principio y nadie lo escribía; ahora lo escribe esto, con hash_before y hash_after — un evento de migración sin los dos hashes no demuestra que el contenido sobrevivió al viaje. La cadena de procedencia (preservacion_migracion, migración 015) guarda además los dos file_id, la herramienta y el desenlace, e incluye los intentos fallidos: «se intentó y no se pudo» es lo que dice qué documentos siguen esperando una migración que no sale. Mismo modelo de amenaza que el validador, más dos precauciones propias de convertir: -dSAFER —sin él, Ghostscript admite operadores PostScript que leen y escriben archivos del contenedor, y un documento adversarial deja de ser un documento para ser un programa— y la salida en el directorio aislado, jamás sobre el archivo de origen. El perfil pasa por una lista cerrada de las variantes b: las a/u exigen texto etiquetado o Unicode garantizado y una conversión automática no puede prometerlo. Con su puerta el mismo día: acción «Migrar a PDF/A» por anexo en el workspace del expediente —solo sobre PDF, porque ofrecerla sobre un .docx sería ofrecer un botón que el convertidor rechaza— y el aviso de éxito dice que se creó una copia, no que el anexo «se convirtió», que es lo que se entendería si no se dijera. Y dos tests míos que pasaban por la razón equivocada, cazados por mutación: el de la lista cerrada de perfiles seguía verde porque con un binario que no convierte nada el fallo llegaba igual por otro camino (se afirma ahora sobre el motivo), y el del original intacto comprobaba un atributo inexistente en vez del repositorio. 335 tests de storage-service, 1903 del frontend, svelte-check 0/0, ruff + mypy verdes. Migración 015 validada en Postgres real, incluidos los dos CHECK de forma y la idempotencia de la migración vigente. Mutadas por separado: la fixity previa, el descarte del derivado no conforme, el no-verificado que no se promueve, el registro del intento fallido, -dSAFER, la lista de perfiles y el entorno scrubbeado. Por qué: saber que un documento no es PDF/A y no poder hacer nada al respecto no es preservar, es diagnosticar — Impacto: dos endpoints nuevos, migración 015, ghostscript en la imagen de producción y la acción en el workspace del expediente. RF-PRE-04 cerrado.

  • feat(storage,archive,frontend): el acta de eliminación entra en el WORM — cerrada la deuda que la fase 4 dejó declarada (E10 + RF-TRD-07) Al cerrar el acta de eliminación quedó con tres garantías locales —trigger append-only, SHA anclado en audit_log y firma XAdES— y sin Object-Lock. La razón se declaró entonces y era de MODELO, no de pereza: el almacén WORM asumía que todo artefacto protegido pertenece a un expediente, y un acta de eliminación cubre N expedientes que además van a dejar de existir. Clavarla bajo el id de uno de ellos no era incómodo: era incorrecto. La migración storage tenant/014 abre el modelo con un CHECK que impone la pareja. objeto_tipo (expediente | lote) dice de qué es expediente_id, y la restricción cierra las dos combinaciones imposibles: un acta_eliminacion pertenece siempre a un lote, y todo lo demás siempre a un expediente. Sin ella podría existir la fila que afirmara que un índice electrónico pertenece a un lote, y el gate de purga —que consulta por expediente— dejaría de verla sin que nada avisara. Verificado en Postgres real: las dos combinaciones prohibidas rebotan, la correcta entra y no se duplica. expediente_id NO se renombra, y es una decisión con coste que se declara. Era ya un id de propietario sin clave foránea (apunta a otro servicio), pero el nombre seguirá siendo impreciso para las filas de tipo lote. Renombrarlo tocaría 142 referencias en diez módulos, y la gran mayoría hablan de expedientes de verdad: el riesgo de ese diff supera al de un nombre histórico documentado. Lo que sí cambia es que el gate de purga y el listado por expediente exigen objeto_tipo = 'expediente' explícitamente — hoy los identificadores de ambos espacios no coinciden en la práctica, pero «en la práctica» no es un control. El acta entra en CONTROL_INSTRUMENT_TIPOS, como el acta de transferencia: sobrevive al contenido y no debe postergar su disposición. Y con más razón — su retención es permanente, así que tratarla como contenido la convertiría en el instrumento impidiendo el acto que él mismo documenta. Un guardián del repo tenía un punto ciego, y se estrenó con este cambio. test_preservacion_tipos_guard.py obliga a clasificar todo tipo nuevo del CHECK como contenido o instrumento de control… pero leía la migración 010 por su nombre. La 014 amplía el CHECK y el guard habría seguido leyendo la 010, dando por buena una taxonomía incompleta sin fallar. Ahora recorre todas las migraciones y se queda con la definición vigente: un guardián que apunta a un archivo fijo caduca el día que alguien escribe el siguiente. Conservación permanente = 100 años, y se dice por qué: es el máximo que admite el contrato, porque Object-Lock exige una fecha, no un infinito. No es una retención archivística —el acta no se elimina jamás— sino la forma de decirle al almacén que no deje borrarla; y es renovable por la misma vía que las series de Conservación Total. El cableado corre tras el sellado, best-effort y nunca revierte la firma (que ya es un hecho consumado), deriva el file_id de la clave del artefacto firmado y no de una entrada externa, y anota el motivo si falla. /admin/eliminacion muestra si el acta está protegida o solo firmada: durante una fase entera esa diferencia estuvo declarada como pendiente y no se veía en ninguna parte. Y un fallo de método propio, anotado: un test del cableado seguía verde al mutar su guarda porque cortaba antes otra condición —el doble no tenía minio_key_firmado—, así que probaba al llamante creyendo probar la guarda. Se separó en dos: uno que verifica al llamante y otro que ejerce la guarda directamente, con la fila construida para que sea esa condición la que decida. 880 tests de archive-service, 324 de storage, 1898 del frontend, svelte-check 0/0, ruff + mypy verdes. Migraciones storage/014 y archive/050 validadas en Postgres real, incluida la idempotencia sobre una tabla con filas previas (que se clasifican solas como expediente). Mutadas por separado: el objeto_tipo del acta, la anotación del fallo, la guarda del cableado, la taxonomía del guardián y la fila de la pantalla. Por qué: el documento que sobrevive a lo eliminado era el único artefacto del sistema sin protección contra borrado — Impacto: objeto_tipo en el almacén WORM (compatible: las filas existentes quedan como expediente), dos migraciones y el estado del candado a la vista. E04 queda sin cabos sueltos.

  • feat(frontend): el PINAR deja de ser un tablero que nunca se puede llenar (RF-ADM-08, cierre del desfase API↔UI) La pantalla consumía 6 de los 31 endpoints del módulo: se podía crear un plan y recorrer su ciclo de vida, pero no había forma de crear un aspecto crítico, un objetivo ni un proyecto desde el navegador. El tablero salía siempre vacío y la propia pantalla lo declaraba como «fase posterior de PINAR». Un tablero que no puede llenarse nunca no es un estado vacío: es un falso verde — y era el último caso conocido del patrón que esta revisión lleva cerrando. Tres pestañas nuevas en /admin/pinar/[id]: Aspectos y priorización, Objetivos y proyectos (con seguimiento) e Instrumentos articulados. LA PANTALLA ESPEJA LOS DOS CICLOS DE VIDA DEL SERVICIO, QUE NO SE SOLAPAN. La estructura solo se edita en borrador; el seguimiento solo con el plan aprobado o en_ejecucion. Nunca las dos cosas a la vez, así que ofrecer la que no toca haría descubrir el límite chocando con un 409. Y son dos condiciones, no una: el estado del plan y USUA_PERM_ADMIN —el router entero está gateado con él—; el gate de permiso faltaba en la primera versión de los paneles y lo destapó su propio test. La prioridad la calcula el backend y la pantalla solo la muestra. La matriz puntúa cada aspecto de 1 a 10 frente a los cinco ejes y la prioridad es la suma; calcularla en pantalla dejaría que dos vistas del mismo plan ordenaran distinto. La tabla se presenta ya ordenada por prioridad, que es la razón de ser de la matriz —leerla desordenada obligaría a comparar a ojo—, y «sin priorizar» no se pinta como prioridad cero: confundirlos mandaría el aspecto al final de la lista como si ya se hubiera decidido que no urge. El avance de un proyecto no se teclea: sale de sus cortes de seguimiento, que son append-only —se registra el avance, nunca se corrige; un histórico reescribible no sirve para rendir cuentas—. Por eso avance_pct no está en la allowlist de crear ni de editar un proyecto: admitirlo dejaría declarar un proyecto terminado sin un solo registro que lo respalde. estado sí viaja al editar, porque hay estados que no derivan del avance (cancelado). Una fábrica de proxies en vez de once archivos con su propio cuerpo. Los once proxies que hacían falta son la misma función salvo por la ruta, el método y la allowlist — que es la barrera anti mass-assignment, y once copias de una barrera se desalinean en silencio. Aquí la allowlist es un parámetro. headersFor se importa del helper que ya existía en vez de volver a escribirse: había dieciséis copias de esa función en las rutas y esta no añade la diecisiete. Detalles que la pantalla no se puede permitir: omitir un campo no es enviarlo en null (los PATCH del PINAR leen lo ausente como «no lo toques» y el null como «bórralo», así que un recorte que los confundiera borraría media ficha al editar un campo); el 409 del plan congelado se muestra tal cual —dice que hay que reformular con una versión nueva, y colapsarlo a «no se pudo guardar» mandaría a buscar un permiso a quien lo que tiene es un plan aprobado—; y un fallo de carga no se lee como «este plan está vacío», que es justo lo que esta pantalla llevaba diciendo desde que existe. Un guardián del repo cazó un descuido propio: sobreErrorPlano.test.js prohíbe leer error/detail del primer nivel del cuerpo —el backend anida su sobre dentro del detail de FastAPI, así que leerlo plano pinta «[object Object]»— y los tres sitios nuevos lo hacían. Corregidos a mensajeError. 1896 tests del frontend, svelte-check 0/0, lint verde. Mutadas por separado: el gate de permiso, el orden por prioridad, «sin priorizar» como cero, el gate del seguimiento y las dos allowlists (avance_pct y prioridad). Por qué: un tablero que no se puede llenar es peor que no tenerlo, porque parece que funciona — Impacto: /admin/pinar/[id] pasa de 2 a 5 pestañas y de 6 a 31 endpoints consumidos; sin cambios de backend.

  • feat(archive,document,storage,knowledge,frontend): el borrado seguro — la TRD por fin destruye lo que ordena destruir (RF-TRD-07, fase 5 de 5: épica cerrada) El sistema conservaba bien y no destruía nunca. El invariante 12 de la constitución se cumplía sin fisuras y eso era, a la vez, el incumplimiento: conservar todo para siempre no es aplicar la TRD, es no aplicarla (Ley 594/2000 art. 24; Ac. AGN 001/2024 art. 4.3.2.5). Este es el único camino del sistema que borra documentos de archivo, y llega al final de cinco actos: propuesta con cinco condiciones de admisión, publicidad previa con plazo para oponerse, aval del Comité con su acto administrativo, acta firmada de conservación permanente, y ahora la ejecución. NO ACEPTA NINGÚN CUERPO, y es la decisión de diseño más deliberada de la fase. En una destrucción no hay nada que el cliente pueda parametrizar: qué se destruye lo dice el inventario congelado al proponer, y bajo qué acto lo dice el aval. Un dry_run o un forzar serían justo los parámetros que un día alguien pasa mal, y aquí no hay vuelta atrás. En pantalla, el botón exige escribir el código del lote: un acto irreversible no debe estar a un clic de distancia. Cuatro precondiciones, y la tercera puede bloquear: lote avalado, acta existente, acta firmada, e inventario congelado como única fuente. Exigir la firma deja la ejecución bloqueada si signature-service está caído — y es lo correcto: el último acto irreversible es exactamente el momento de no seguir adelante con una constancia a medias. Hay reintento manual para desbloquearlo, y el 409 dice cuál fue el fallo del sellado. EL ORDEN ES LA MITAD DEL DISEÑO. Dentro de cada servicio, los bytes antes que las filas: al revés quedarían objetos huérfanos en MinIO que nadie sabe ya a qué radicado pertenecían — imposibles de encontrar y, por tanto, imposibles de eliminar, el peor desenlace para una obligación de destruir. Entre servicios, los documentos antes que el expediente: al revés, los vínculos a radicados caerían en cascada y ya no habría forma de saber qué había que destruir; quedarían vivos para siempre y sin nadie que supiera que debían morir. Y la lápida antes del borrado, en la misma transacción: al revés, un fallo entre ambos dejaría el expediente destruido y sin lápida, y el silencio es indistinguible de «nunca existió». TODO-O-NADA POR EXPEDIENTE. El que falle queda intacto y sin lápida, y viaja en fallidos con su código —quien reintenta necesita saber cuál es, y un UUID no se lo dice—; el lote no pasa a ejecutado mientras quede uno pendiente, porque un lote marcado ejecutado con expedientes vivos dentro mentiría sobre lo único que ese estado significa. El candado WORM aborta la purga entera del expediente, no la del archivo protegido: un expediente medio destruido, con un índice que ya no describe nada, es peor que no haber empezado. Y los recuentos son los reales, no los pedidos: es el número que la lápida afirma para siempre. purge_for_disposition LLEVABA SIN PUERTA DESDE QUE SE ESCRIBIÓ. Estaba en storage-service con su gate WORM y su asiento, y ningún router lo alcanzaba. Es el patrón que esta revisión lleva persiguiendo —una capacidad construida y no ofrecida— apareciendo por última vez sobre el acto más irreversible del sistema. Ahora tiene su endpoint, sellado con X-Internal-Token y no enrutado por el gateway: nadie de fuera puede pedir un borrado físico. LA MIGRACIÓN 049 SOLTÓ TRES CLAVES FORÁNEAS, Y UNA EVITÓ UNA PÉRDIDA SILENCIOSA. Al ir a borrar aparecieron tres que hacían lo contrario de lo que hace falta: eliminacion_lote_items y disposicion_decisiones bloqueaban el borrado, y transferencias iba en ON DELETE CASCADE — se habría llevado por delante las transferencias y, en cascada, transferencia_acta, un instrumento de control que el propio código de storage-service ya declara que debe sobrevivir al contenido (Ley 594/2000 art. 26). Nadie la habría visto ocurrir. Se sueltan las tres con un solo criterio: lo que debe sobrevivir conserva el expediente_id como referencia, sin clave foránea, exactamente como expediente_eventos desde el principio. Y una al revés: expediente_aip pasa a CASCADE, porque empaqueta el contenido y no describe nada sin él. LA LÁPIDA ES CONTENT-FREE POR CONSTRUCCIÓN: código, serie, fechas extremas, volumen y el acto que lo autorizó. La denominación no está —«Historia laboral de…» es contenido, y una lápida que lo conservara filtraría para siempre justo lo que se destruyó por orden de la TRD—. Append-only por trigger y sin clave foránea a expedientes: si la tuviera, el borrado se la llevaría y la única respuesta a «¿qué pasó con 2015-DEM-000123?» sería el silencio. POR QUÉ LA PURGA DEL VECTOR NO USA UN DELETE, aunque el documento ya no exista: bajo entrega at-least-once, un created reentregado DESPUÉS del DELETE reinsertaría el fragmento desde cero, sin marcador, y el texto del documento eliminado volvería a ser consultable. El DELETE borraría el propio centinela que hace la purga irreversible. Se reutiliza el centinela voided=true, que no conserva texto, ni embedding, ni metadatos. La anulación y la eliminación por TRD son actos jurídicamente distintos que para una copia derivada significan lo mismo: se distinguen por nombre para la traza, el efecto es uno solo. Lo que sobrevive: la lápida, el acta firmada, el inventario congelado, las decisiones de disposición, la hoja de ruta del expediente, las transferencias con su acta, y el audit_log con un asiento por radicado destruido con su número de radicado — sin él, el asiento diría que se destruyó un UUID. 875 tests de archive-service + 20 de integración, 414 de document-service, 320 de storage, 176 de knowledge, 1870 del frontend, svelte-check 0/0, ruff + mypy verdes en los 11 servicios. Migración 049 validada en Postgres real, incluido el efecto exacto del borrado sobre las cuatro tablas. Mutadas por separado: las cuatro precondiciones, el aborto sin lápida ante un fallo de purga, el lote que no se marca ejecutado con pendientes, el orden bytes/filas, el orden documentos/expediente, el enrutado de la purga del vector, las claves foráneas de la 049 (restaurarlas pone tres tests de integración en rojo) y, en pantalla, el gate del acta firmada y la confirmación por código. Por qué: una TRD que ordena eliminar y un sistema que nunca elimina son un incumplimiento, no una precaución — Impacto: dos endpoints nuevos en archive, uno interno en document y otro en storage, migración 049, y la sección de ejecución y lápidas en /admin/eliminacion. RF-TRD-07 queda cerrado y con él E04 completo.

  • feat(archive,signature,frontend): el acta de eliminación — lo que sobrevive a lo eliminado (RF-TRD-07, fase 4 de 5) Cuando la fase 5 borre los bytes, el expediente dejará de existir y el acta será —junto con la lápida y el audit_log— la única constancia de qué se destruyó, bajo qué acto y quién lo autorizó. Es de conservación permanente: no tiene calendario de retención ni disposición final, y no se elimina jamás. Va ANTES del borrado y no después, y no es un detalle de orden: el inventario que el acta describe sale de expedientes que van a dejar de existir. Un acta redactada después no tendría de dónde leerlo. Son dos tablas y no una (migración 048), por el mismo criterio que el acta de transferencia: eliminacion_acta guarda el contenido congelado y es append-only por trigger —ni se edita ni se borra—, y eliminacion_acta_firma guarda el estado mutable del sellado, que avanza y reintenta. Mezclarlas obligaría a que el contenido fuera mutable para que la firma pudiera avanzar, y el contenido es justo lo que no puede moverse. Consecuencia deliberada, verificada en Postgres real: con el acta escrita, la fila del lote ya no se puede borrar — el ON DELETE CASCADE intenta llevarse el acta y el trigger aborta la operación entera. Es lo que se quiere. El XML es determinista o el SHA no significa nada: serialización canónica (C14N) y el inventario ordenado por código, para que el orden del plan de consulta no cambie el documento firmado. Su acta_sha256 se ancla además en audit_log, que es indeleble: un acta alterada saltándose el trigger seguiría delatándose contra ese número. EL INVENTARIO DEL ACTA VA COMPLETO, SIN RECORTAR POR CLEARANCE. Un acta a la que le faltan los expedientes clasificados no da fe de lo que se destruyó. Lo que se acota es quién la lee, y el umbral se deriva del contenido —el nivel de seguridad más alto del inventario, nunca un campo declarado—. Quien no lo despeja recibe 403 con el motivo, no un acta recortada: es un documento firmado y monolítico, y romper su firma sería peor que negarla. El 403 (y no 404) es deliberado: quien pregunta ya veía el lote, así que no se le confirma nada nuevo — lo que necesita es entender por qué. El método de borrado no lo inventa el sistema. Es obligatorio, de al menos 10 caracteres y sin valor por defecto en ninguna de las tres capas (proxy, esquema y CHECK): el acta debe decir cómo se destruye (Ac. AGN 001/2024 art. 4.3.2.5), y una frase por defecto sería una afirmación que nadie hizo sobre un acto irreversible. acta_eliminacion entra en el allow-set XAdES de signature-service, y ese es el punto. Sin estar en XADES_ELIGIBLE_OBJETO_TIPOS, el acta habría caído al HMAC nativa sin error y sin diferencia visible: quedaría marcada «firmada» siendo estructuralmente más débil que el acta de transferencia — y es el documento que sobrevive cuando ya no queda nada con qué contrastarlo. acreditado viaja explícito y es siempre false (art. 7 de la Ley 527/1999, nunca art. 28), y la pantalla lo dice. UN DEFECTO QUE DESTAPÓ MYPY, NO UN TEST: minio_key_firmado es str | None y el código lo daba por presente. Una firma sin artefacto habría chocado con el CHECK de la 048 con un error opaco — o peor, habría dejado un acta «firmada» cuyo XML firmado no está en ninguna parte y no se podría verificar nunca más. Ahora se trata como fallo del sellado, con su motivo (sellado_sin_artefacto) y su test. El sellado corre post-commit y no bloquea: el acta ya quedó íntegra y su SHA anclado, así que un signature-service caído retrasa la firma pero no pierde el documento. Tres desenlaces, los del acta de transferencia, y el motivo del último fallo a la vista — un «pendiente de firma» mudo deja sin saber si esperar o llamar a alguien. WORM pendiente y declarado, con su razón: el almacén de Object-Lock está indexado por (expediente_id, tipo) y un acta de eliminación cubre N expedientes que además van a dejar de existir; clavarla ahí no es incómodo, es incorrecto. Hoy el acta se protege con sus tres garantías locales —trigger append-only, SHA en audit_log y firma XAdES—. 864 tests de archive-service, 15 de integración del lote, 1864 del frontend, svelte-check 0/0, ruff + mypy verdes. Mutadas por separado: el aval como precondición, el umbral derivado del contenido, el objeto_tipo que decide si hay XAdES real, el orden del inventario, el control del artefacto ausente, y en pantalla el aviso de firma no acreditada y la explicación del acta fuera de alcance. Por qué: destruir sin dejar un documento que diga qué se destruyó no es cumplir la TRD, es perder la traza — Impacto: tres endpoints nuevos y la sección de acta en /admin/eliminacion. Sigue sin eliminarse nada: falta el borrado seguro.

  • feat(archive,frontend): el aval del Comité, la retirada del lote y la pantalla que faltaba (RF-TRD-07, fase 3 de 5) Se podía proponer un lote y no había dónde verlo después: el procedimiento existía en el backend y se abandonaba a medias en la interfaz. Es exactamente el patrón que esta revisión lleva persiguiendo —una capacidad construida y no ofrecida—, y esta vez tocaba al procedimiento que termina destruyendo. Ahora /admin/eliminacion lista los lotes, abre su inventario y ofrece los tres actos, con las cinco fases siempre a la vista y la quinta declarada no construida. El aval no se registra con el plazo de publicidad todavía abierto, y esa es la regla de fondo de la fase: autorizar la destrucción antes de que venza el tiempo que la propia entidad dio para oponerse convierte la publicidad previa en un adorno. La guarda vive en el WHERE de la transición (AND publicado_hasta < CURRENT_DATE, con el reloj del servidor) y no en una comprobación previa en Python, que dejaría una ventana entre leer el plazo abierto y escribirlo cerrado — o al revés, en el cambio de día. El 409 dice CUÁL de los tres motivos fue. «No se pudo avalar» manda a buscar un permiso a quien lo que le faltan son cuatro días de plazo: es el mismo defecto que ya se pagó aquí con un 403 que acusaba de faltar un permiso que el usuario tenía. Así que hay una lectura de diagnóstico —que no decide nada, la guarda sigue siendo el WHERE— y tres respuestas distintas: publicidad_en_curso con la fecha, lote_no_avalable con el estado, y 404 si el identificador no existe. Las tres constancias del aval se exigen en las tres capas —schema, servicio y CHECK de la migración 047—: acta del Comité, fecha (no futura: se registra un acta que ya existe, no una reunión que aún no ha ocurrido) y acto administrativo. El sistema no decide qué se elimina; deja constancia de que un Comité decidió y con qué acto (Ac. AGN 001/2024 art. 4.3.2.5). Un lote se retira hasta el último momento, incluso ya avalado — y nunca después de ejecutarlo: lo destruido no se des-destruye, y marcar «anulado» un lote ejecutado afirmaría lo contrario de lo que pasó. La condición se escribe por admisión de los tres estados vivos y no negando ejecutado, porque un estado nuevo entra en el CHECK antes de que nadie se acuerde de esa línea, y con la lista negativa entraría además, callando. Y ahí apareció un caso que la fase 2 se dejaba fuera, en silencio. El chokepoint público era la lista ("publicado", "avalado", "ejecutado"), así que un lote publicado y luego retirado desaparecía de la vista pública — justo de quien leyó el inventario y podía estar esperando saber en qué quedó su oposición. Reescrito por la marca del acto (publicado_at IS NOT NULL), el caso no se puede olvidar: borrador es el único estado sin esa marca. Ahora el lote retirado sigue publicado, con su motivo, y la pantalla pública lo dice. Verificación por mutación en el carril de integración, que es donde estas guardas se pueden probar de verdad: quitar el plazo del WHERE, admitir ejecutado en la anulación y abrir el chokepoint a TRUE ponen en rojo tres tests distintos, cada uno el suyo, con su control positivo al lado. 848 tests unitarios de archive-service + 10 de integración, 1868 del frontend, svelte-check 0/0, lint verde. Por qué: un procedimiento que no se puede seguir es un procedimiento que se abandona a medias, y este termina destruyendo — Impacto: nueva pantalla /admin/eliminacion (con su tarjeta en el panel de administración) y dos endpoints nuevos. El proxy de publicar se mueve de /admin/trd a la pantalla del lote; en TRD solo se queda proponer, que es donde se propone. Sigue sin eliminarse nada: faltan el acta firmada y el borrado seguro.

  • feat(archive,frontend): la publicidad previa — el inventario de lo que se va a destruir, a la vista de cualquiera (RF-TRD-07, fase 2 de 5) Un lote publicado deja de ser un asunto interno: su inventario queda visible sin autenticar, con el plazo para oponerse, y enlazado desde el portal. Es la fase que da sentido a las otras — sin publicidad previa, el aval del Comité decide sobre algo que nadie fuera pudo mirar. El canal de oposición no se inventa: es la ventanilla única que la entidad ya tiene, y la pantalla lo dice y enlaza. Montar un formulario de oposición aparte crearía una segunda bandeja que nadie mira, y una oposición es exactamente lo que una ventanilla existe para recibir. El chokepoint es el ESTADO del lote, no un interruptor de despliegue. Deliberadamente no cuelga de public_interop_enabled —la cosecha OAI/CMIS—: publicar el registro de correspondencia es una decisión de la entidad; publicar el inventario de lo que va a eliminar es una obligación del procedimiento. Atarlas al mismo interruptor dejaría a una entidad sin poder eliminar por no haber activado una cosecha que no le interesa. Un chokepoint se reutiliza cuando la política es la misma, no porque el nombre encaje. Un lote en borrador o anulado responde 404, indistinguible de un identificador inventado; los avalado y ejecutado siguen siendo consultables, porque la constancia de qué se eliminó no puede desaparecer al eliminarlo. La descripción de un expediente clasificado no se publica, pero la fila sí, y el recorte va en el SELECT —no en quien pinta—: si el nombre saliera de la base y se filtrara después, cualquier consumidor futuro del repositorio lo reintroduciría sin enterarse. Omitir la fila entera dejaría de cumplir la publicidad previa justo sobre los expedientes que más importa que alguien pueda objetar; lo que se retiene es la denominación, que es donde vive el contenido («Historia laboral de…»). La fila viaja marcada y la pantalla lo dice —«denominación reservada por ley», con la norma— en vez de dejar un hueco mudo: quien lee tiene que poder distinguir «no tiene nombre» de «su nombre está reservado», porque de eso depende que sepa si tiene algo que objetar. Publicar congela el inventario: el trigger de la migración 047 deja de admitir que se quite una fila, porque lo publicado es sobre lo que un tercero decidió no oponerse. Y publicado_hasta es obligatorio y futuro, sin default: no se inventa cuántos días da una entidad para oponerse a que destruyan un documento; publicar con el plazo ya vencido no da a nadie ocasión de oponerse. Un test propio no discriminaba, y lo destapó la mutación. El de «un lote que ya no está en borrador no se republica» mockeaba fetchrow devolviendo None, y al mutar el WHERE ... AND estado = 'borrador' siguió en verde: el «0 filas» lo daba el mock, no la condición. Es la misma trampa que ya se pagó aquí con un test de un-solo-uso. Se corrige por los dos lados: el unitario ahora afirma sobre el SQL emitido, y la carrera de verdad se prueba en el carril de integración, contra PostgreSQL real, donde el conteo de filas lo produce Postgres — seis casos que ejercen los CHECK y el trigger con SQL crudo, bypaseando la capa de servicio a propósito. Y otro token de color inexistente: var(--op-text-muted) no existe (es --op-text-secondary). Cuarta aparición del mismo fallo mudo, cazado comprobando cada token uno a uno antes de dar la pantalla por hecha. 832 tests unitarios de archive-service + 6 de integración, 1837 del frontend, svelte-check 0/0, lint verde. Mutadas por separado: el nombre del clasificado, el estado como chokepoint, el congelado del inventario y la explicación del hueco en pantalla. Por qué: destruir un documento sin que nadie de fuera haya podido mirar el inventario no es un procedimiento, es un trámite interno — Impacto: nueva pantalla pública (public)/eliminacion, enlazada desde el portal. Sigue sin eliminarse nada: faltan el aval del Comité, el acta firmada y el borrado seguro.

  • feat(archive,api-gateway,frontend): el lote de eliminación — primera de las cinco fases del único requisito que faltaba (RF-TRD-07) Se elimina por lotes porque el acta y el aval del Comité son del lote (Ley 594/2000 art. 24; Ac. 001/2024 art. 4.3.2.5). Esta fase propone y congela el inventario; publicar, avalar, firmar el acta y ejecutar el borrado seguro son las cuatro siguientes, y la última no está construida. ejecutado viaja siempre en false y la nota de la respuesta lo deletrea, por el mismo motivo que en la decisión: un 201 sobre «lote de eliminación» invita a creer que algo se eliminó. Recurso propio, no un apéndice de /trd —mismo criterio que la cola de anulaciones—: un lote no es una lectura del instrumento, es un procedimiento con ciclo de vida borrador → publicado → avalado → ejecutado (o anulado), y cada tránsito tiene sus constancias exigidas por CHECK: publicar sin fecha límite no da plazo para oponerse, y avalar sin acta, fecha y acto administrativo es la misma afirmación no verificada que la convalidación de una TRD sin sus tres campos. El inventario se congela, y no es una copia por comodidad: es lo que se publica para que un tercero pueda oponerse, y lo que el acta describirá — el expediente va a dejar de existir. Un trigger lo blinda: editarlo nunca, y quitar una fila solo mientras el lote siga en borrador; en cuanto se publica queda congelado, porque lo publicado es sobre lo que alguien decidió no oponerse. Cinco condiciones de admisión, y ninguna se salta: vencimiento confirmado por usuario autorizado (sin decisión no hay propuesta, que es la única barrera contra la destrucción automática); disposición que termina en destrucción —E, S y M; Conservación Total jamás entra—; instrumento congelado que la gobierna (ADR-025 D5); candado WORM ya expirado; y no estar ya en otro lote vivo. El candado son TRES, y manda el más largo. Un expediente puede tener worm_retain_until en su índice firmado, en su acta de transferencia y en su AIP; mientras uno siga vigente, en modo COMPLIANCE no puede borrar nadie, ni el administrador. Quedarse con el primero que se encuentre daría un lote que revienta al ejecutarse, con el Comité ya movilizado — así que se toma el máximo de los tres y el rechazo dice hasta cuándo. El plan había verificado que la fecha WORM y el calendario de la TRD coinciden por construcción; esto es la comprobación defensiva de esa coincidencia, que es distinto de confiar en ella. Lo rechazado se informa, no se descarta. Quien propone 40 y recibe 31 tiene que ver cuáles nueve faltan y qué le pasa a cada uno; si no, la única salida es probar de uno en uno. Y un expediente fuera del clearance del proponente se informa como «no encontrado» sin distinguirlo de uno inexistente, para no convertir el endpoint en un oráculo. Al abrir un lote el inventario se recorta por clearance, pero total_items sale de la base y no de contar lo devuelto: recalcularlo dejaría que la resta entre lo que el lote dice tener y lo que se ve delatara cuántos clasificados contiene. El listado de lotes no se recorta — un lote no es un expediente: su código, su estado y su acta no dicen nada del archivo. Con su puerta el mismo día, que en este proyecto no es opcional: en la pestaña de disposición vencida solo llevan casilla las filas ya confirmadas y cuya disposición destruye —ofrecer una casilla sobre una fila que el backend va a rechazar es hacer perder el viaje, y Conservación Total no lleva ninguna—; el modal dice que esto es el primero de cinco pasos y que el borrado no existe; y el 409 con los motivos se reenvía tal cual en vez de colapsarse a «no se pudo crear el lote». Migración archive tenant/047 validada contra PostgreSQL real: idempotencia, los cuatro CHECK de forma y las dos reglas del trigger (editar nunca, quitar solo en borrador). 823 tests de archive-service, 1828 del frontend, svelte-check 0/0, lint verde. Las cinco condiciones verificadas por mutación una por una, más la de la casilla en pantalla. Por qué: conservar todo para siempre no es cumplir la TRD, es no aplicarla — Impacto: /api/v1/eliminacion/ entra en el gateway. Sigue sin eliminarse nada: faltan publicar el inventario, el aval del Comité, el acta firmada y el borrado seguro.

  • feat(archive): el barrido que corre solo, y la alerta que llega a quien no está mirando (RF-TRD-08 + R.4.8) Cuarto paso del incremento que no destruye nada, y el que cierra el disparo programado. Hasta ahora la cola de vencimientos había que ir a mirarla; ahora el sistema barre por su cuenta y avisa a quien puede decidir. Es un solo trabajo porque la alerta necesita que algo corra solo, y ese algo es el barrido. No hizo falta inventar infraestructura: archive-service ya tenía dos bucles de fondo con la forma exacta —run_once invocable desde un test, kill-switch e intervalo propios, y nunca mueren por una excepción—; este es un tercero con el mismo molde. Su intervalo es la cadencia del aviso, a propósito: así no hay un «último enviado» por tenant que mantener sincronizado. Semanal por defecto, porque un vencimiento no caduca y un correo diario sobre lo mismo acaba filtrado a la papelera. Lo que apaga el aviso es decidir, no haberlo leído. La recomendación del plan era falsa, y se corrigió antes de construir sobre ella. El plan decía «empezar in-app» dando por hecho que E16 tenía un canal de novedades. No lo tiene: el store del frontend son toasts efímeros de sesión y notification-service es solo correo. Un toast no es una alerta —desaparece en cuatro segundos y solo lo ve quien ya estaba mirando, que es justo a quien la alerta no va dirigida—. Así que el canal es el correo, por el endpoint sellado con X-Internal-Token, igual que el OTP de firma. Verificar la premisa y no solo la implementación ya evitó un plan entero mal ejecutado en este proyecto. El aviso se compone por DESTINATARIO, no una vez por tenant, y ahí estaba la trampa que el plan anotó por adelantado: el recuento se recorta por el clearance de quien lo recibe. Decirle «tienes 14 vencimientos» a quien solo alcanza 9 le está diciendo que existen 5 expedientes clasificados. Es la lección de la cola de anulaciones reapareciendo en un sitio nuevo —allí el punto delicado era el total de la lista, aquí es el número del correo— y a quien no le toca nada no se le escribe: que otro con más clearance sí tenga trabajo no es asunto suyo. Comprobado contra PostgreSQL real: un vencido clasificado se cuenta para clearance 3 y no para 1 ni 2. A quién se avisa se resuelve por consulta RBAC, con el mismo criterio que el gate del endpoint que decide (USUA_PERM_TRD con crud≥3), nunca por una lista de correos configurada: una lista se desincroniza de los permisos en silencio y acaba avisando a quien ya no decide y callando a quien sí. El correo es deliberadamente pobre: un número, un plazo y a dónde ir. Ni números de radicado, ni asuntos, ni series. Un correo sale de la institución y acaba en bandejas que el SGDEA no gobierna; lo que hay que mover es a la persona hasta la pantalla, no el archivo hasta su correo. Y dice que vencer no es eliminar, porque el aviso es lo primero que muchos leerán del asunto. Fallos aislados por tenant y por destinatario: que un correo rebote no deja sin avisar a los demás. Un guardián por forma verifica que el job no ejecuta ninguna disposición —cuenta y avisa—; su primera versión casaba con su propio docstring, que era justo el que decía que no elimina, así que inspecciona el código sin comentarios (2.ª vez que un censo se encuentra a sí mismo aquí). 808 tests de archive-service, lint verde, consulta y migraciones 045/046 aplicadas y verificadas sobre el tenant de demostración —la 045 marcó el expediente que se había colado—. Tres mutaciones verificadas. Por qué: una cola que hay que acordarse de mirar no cumple «alerta a usuarios autorizados» — Impacto: archive-service gana un tercer bucle de fondo y envía correo; exige NOTIFICATION_INTERNAL_TOKEN, y sin él se niega a avisar en vez de mandar un correo que será rechazado. Sigue sin eliminarse nada.

  • feat(archive,frontend): la confirmación de usuario autorizado — «ninguna destrucción es automática» (RF-TRD-06) Tercer paso del incremento que no destruye nada, y el que convierte la cola de vencimientos en algo más que información: ahora un usuario autorizado decide. Es la regla de negocio 4 del RT de TRD —«ninguna destrucción es automática sin confirmación de usuario autorizado»— y hasta hoy no existía en ningún sitio. Confirmar sigue sin eliminar nada, y la respuesta lo dice. ejecutada: false viaja explícito junto a una nota, porque un 201 sobre un endpoint llamado «decidir» invita a creer que algo ocurrió; y el modal de la pantalla lo repite antes de que la persona pulse. Lo que queda registrado —irreversiblemente— es que ese funcionario lo autorizó. El gate de ADR-025 D5 recibe por fin un llamante. assert_disposicion_final_autorizada llevaba desde su incremento escrito, probado y sin nadie que lo llamara —su propio docstring lo declaraba: «hoy sin caller»—. Ahora corta la confirmación de una eliminación cuyo instrumento congelado no la gobierna, que es el momento correcto: rechazarla al final del procedimiento, cuando ya se movilizó al Comité, sería descubrir el límite fallando. Sobre CT el gate no aplica — conservación total no termina en destrucción, y exigir un instrumento convalidado para confirmar que algo se conserva sería un requisito inventado. Sí aplica a E, S y M: selección elimina lo no seleccionado y microfilmación elimina el original. Lo que no se acepta del cliente es lo importante. La fecha del vencimiento y el código de disposición no viajan en el cuerpo —los resuelve el backend desde el snapshot congelado—, porque si viajaran, confirmar una eliminación sería cuestión de mandar la fecha adecuada. El proxy los descarta además por su cuenta, con fase y decision contra listas cerradas. El expediente se resuelve acotado por clearance y uno fuera de alcance responde 404, no 403: un 403 confirmaría que existe. Aplazar es una decisión, no quitar algo de la vista (R.4.9 del Modelo SGDEA): exige motivo de al menos 10 caracteres —lo que leerá quien retome el expediente— y una fecha futura de regreso, las dos cosas validadas en el proxy, en el esquema y en un CHECK de la migración. El vencimiento sale de la cola activa y vuelve solo. Un aplazamiento sin fecha de regreso es un olvido con mejor nombre. La decisión vive en disposicion_decisiones (migración archive tenant/046), append-only con el mismo trigger que la hoja de ruta del expediente: una confirmación no se edita ni se retira, se sustituye por una decisión posterior. Y guarda el vencimiento congelado sobre el que se decidió, porque una confirmación de 2026 no puede autorizar la eliminación que una TRD reformulada ordene en 2030. El asiento en la cadena inmutable va en la misma transacción: si no puede escribirse, la decisión no se toma. La cola aprende de las decisiones con un LEFT JOIN LATERAL que va literalmente en el COUNT y en la página — si solo estuviera en la página, el total contaría los aplazados que la página oculta y el aviso de la pantalla mentiría. Y el botón de la interfaz es espejo del gate real (crud≥3), fail-closed si la sesión no trae el nivel. Migración validada contra PostgreSQL real: los dos CHECK rechazan el aplazamiento sin motivo y con motivo corto, el UPDATE y el DELETE rebotan contra el trigger, y la reejecución es idempotente. 801 tests de archive-service, 1824 del frontend, svelte-check 0/0, lint verde. Cinco mutaciones verificadas por separado. Por qué: un calendario que vence sin que nadie decida es un archivo que se administra solo, y la norma exige exactamente lo contrario — Impacto: POST /api/v1/trd/disposicion-vencida/{id}/decidir y dos acciones nuevas en la cuarta pestaña de /admin/trd. Sigue sin eliminarse nada.

  • feat(archive,frontend): el sistema guardaba el calendario de retención y no lo decía en voz alta — la cola de vencimientos (RF-TRD-08) Segundo paso del incremento que no destruye nada. Al cerrar un expediente, el sistema congela su calendario —fin_archivo_gestion, fin_archivo_central, el código de disposición y el acto que convalidó la serie— y ahí se quedaba: un dato correcto que nadie consultaba nunca. GET /api/v1/trd/disposicion-vencida lo saca a la luz, y una cuarta pestaña en /admin/trd lo pone delante de quien decide. El endpoint sin puerta es el patrón que esta revisión lleva persiguiendo desde el principio, así que llega con la suya el mismo día. Vencer no es una orden de destruir, y la interfaz no puede dejar que se lea así. En Conservación Total el vencimiento del archivo central significa transferir al histórico y conservar para siempre; presentar esa fila junto a las de eliminación sin distinguirlas convertiría la cola en una «lista de lo que hay que borrar» que incluiría justo los expedientes que jamás se borran. Por eso cada fila trae la acción que corresponde, derivada del snapshot congelado y escrita por admisión: un código que no esté en el mapa no recibe una acción inventada, recibe la constancia de que el snapshot no la declara. gobierna_eliminacion no reimplementa el gate. El criterio de ADR-025 D5 —solo una serie convalidada, o derogada con acto, gobierna una eliminación efectiva— se extrajo a un predicado único que ahora comparten el assert que corta el acto y la cola que solo informa. Copiar la condición en la consulta habría sido reimplementar lo que se verifica, y la divergencia no fallaría al escribirla sino el día que alguien tocara un lado: la pantalla diría «se puede eliminar» sobre un expediente que el gate va a rechazar. Cuando el instrumento no lo gobierna, la fila lo advierte en vez de dejar que el funcionario descubra el límite fallando. El punto delicado es el recuento, no la página. El no-read-up va en la misma condición que las filas, COUNT incluido: recortar solo la página dejaría que el total delatara cuántos expedientes clasificados hay vencidos —la lección de la cola de anulaciones—. USUA_PERM_TRD es ortogonal al clearance, igual que en el informe gemelo de revisión pendiente. Y la consulta deja traza agregada (RF-BUS-10): enumerar lo que está a un paso de eliminarse debe dejar rastro de quién lo hizo. Dos barreras sobre la fase, que se interpola en el SQL: el Literal del router y un mapa cerrado en el repositorio. Un snapshot ilegible deja su fila sin afirmar nada sobre la eliminación en vez de tumbar el informe entero — el resto de la cola sigue siendo trabajo de alguien. Un token de color inexistente cazado antes de nacer: el aviso usaba var(--op-danger), que no existe (es --op-error). El navegador habría descartado la declaración entera en silencio, sin que svelte-check, ESLint ni el build dijeran nada — tercera aparición del mismo fallo mudo en este repositorio. 787 tests de archive-service, 1811 del frontend, svelte-check 0/0, lint verde. Cuatro mutaciones verificadas: el recuento, la distinción de CT, el predicado compartido y el aviso de la pantalla. Por qué: un calendario que solo vive en la base de datos no gobierna nada, porque nadie lo mira — Impacto: la entidad ve por primera vez qué venció, cuándo, bajo qué acto y qué corresponde hacer. No se ejecuta ningún acto: la eliminación efectiva (RF-TRD-07) sigue sin construirse.

  • fix(archive): un expediente cerrado sin serie TRD salía del ciclo de vida en silencio Primer incremento del hueco de producto más grande que quedaba: la disposición final no se ejecuta. El sistema tiene el calendario —al cerrar congela en expedientes.disposition el fin_archivo_gestion, el fin_archivo_central, el código de disposición y el acto que convalidó la serie— y tiene el gate que autoriza la eliminación (assert_disposicion_final_autorizada, ADR-025 D5, escrito y sin ningún llamante). Lo que no tiene es el brazo que ejecuta. Este incremento es deliberadamente el que no destruye nada. Y al mirarlo apareció un agujero anterior a todo eso. Cerrar un expediente sin serie TRD dejaba disposition = NULL y trd_revision_requerida = false: sin calendario de retención, sin fecha de disposición y sin aparecer en ninguna cola. No es un expediente conservado de más — es un expediente que sale del ciclo de vida archivístico en silencio, porque nada lo nombra y nadie volverá a mirarlo. Comprobado sobre el tenant de demostración: 1 expediente cerrado en ese estado, invisible para /trd/revision-pendiente. El motivo cierre_sin_snapshot_de_retencion existía desde la migración 037, que lo usó para marcar los cierres históricos; la tercera pestaña de /admin/trd sabía presentarlo y explicaba que no tiene resolución automática; y ningún camino de código lo escribió nunca después. Es decir: el backfill reparó el pasado y nada protegía el futuro — exactamente el hueco que tenían las tres defensas de audit_log, que cubrían modificar y borrar y ninguna cubría añadir. Otra capacidad construida y jamás alimentada. La corrección son dos mitades, y la primera sola habría sido inerte. El servicio ahora marca el cierre sin calendario; pero las marcas de revisión vivían dentro de if pin_trd:, que solo es cierto cuando el expediente tiene serie — justo el caso contrario al que hay que marcar. Un cierre sin serie no puede declarar que le falta el calendario si la declaración solo se escribe cuando el calendario existe. Ahora se escriben al cerrar; en la transferencia siguen sin tocarse, porque los defaults del método borrarían una marca pendiente que nadie ha resuelto —trasladar un expediente no responde la pregunta que la marca hace—. Las dos mitades verificadas por mutación por separado. Se marca, no se bloquea: es el criterio que este mismo método ya aplica a las series migrada (ADR-025 D6, «marca de revisión honesta, nunca bloqueante»). Bloquear el cierre dejaría atrapados los expedientes que ya están abiertos sin serie y convertiría un problema de clasificación en uno de disponibilidad. Migración archive tenant/045 para lo que se coló entre la 037 y hoy, idempotente y validada contra PostgreSQL real (recoge el expediente, la segunda pasada toca 0 filas): reparar el pasado y proteger el futuro son dos actos distintos, y hacer solo uno ya salió caro aquí una vez. 776 tests de archive-service, lint verde. Por qué: un expediente sin calendario de retención no es un expediente conservado, es uno que nadie volverá a mirar — Impacto: los cierres sin serie TRD aparecen desde ahora en la tercera pestaña de /admin/trd, y la migración recoge los anteriores.

  • fix(archive,tenant,frontend): el sistema escondía el PINAR y la TRD y los servía a la vez Cierra la decisión de producto que el barrido negativo (B13) dejó abierta y anotada en docs/es/plan-pruebas.md §4.bis en vez de resolverse por cuenta propia. La pregunta era: las lecturas de TRD y PINAR no exigían ningún permiso, mientras la barra lateral escondía Administración entera y /admin gateaba sus tarjetas. Gatearlas o declararlas abiertas —las dos salidas eran legítimas—, pero lo que había no era defendible. Lo primero que cambió fue el tamaño del problema. El recuento de §4.bis —«TRD 4, PINAR 11»— se hizo a mano. Un censo por FORMA sobre los routers de los dos servicios encontró 33 lecturas sin gate alcanzables por el gateway: las 4 de TRD, 4 de TVD que la pregunta no mencionaba, las 11 de PINAR y 14 de catálogos. Enésima vez que una lista escrita a mano se queda corta en silencio. Y con la lista completa la disyuntiva se disolvió, porque esas lecturas no son una sola cosa. El vocabulario de clasificación —listado y detalle de series TRD y agrupaciones TVD, retención, tipos documentales, dependencias, catálogos, parámetros— queda abierto a cualquier autenticado del tenant, y ahora está declarado en el código y con test: lo consumen /radicar y /expedientes para poder clasificar, y USUA_PERM_TRD lo tiene quien administra el instrumento, no quien lo usa — exigirlo dejaría sin radicar a todo operador ordinario, con el síntoma («no puedo radicar») lejos de su causa. Además el instrumento convalidado es de publicación obligatoria (Ac. AGN 004/2019, compilado por 001/2024; Ley 1712/2014 art. 9). Que esa mitad tenga test no es adorno: un endurecimiento posterior la rompería en silencio. El circuito sí se gatea con USUA_PERM_TRD: GET /trd/{code}/versiones y su gemelo de TVD devuelven el historial completo —los borradores, el acta_comite, la fecha_aprobacion_comite, la instancia_convalidante y el created_by de cada versión—, que es la deliberación del Comité Institucional de Gestión y Desempeño, no el instrumento publicado. Su gemelo GET /revision-pendiente ya lo exigía: era el hueco que quedaba de ese par. Las 11 lecturas del PINAR pasan a exigir USUA_PERM_ADMIN, con el gate en el propio APIRouter —por admisión, para que lo herede el endpoint que alguien añada mañana— y borrando la frase del docstring que declaraba lo contrario («los GET quedan solo autenticados, son consulta»): un comentario obsoleto es el vector, no el adorno. Se cierra en vez de abrirse porque un plan en borrador es material de trabajo del Comité, y porque publicar el PINAR aprobado (Decreto 1080/2015 art. 2.8.2.5.8) es un acto de la entidad hacia fuera —una pantalla pública, como la consulta ciudadana— y no un GET interno sin gate que se le parece por accidente. Fuera de /admin/pinar no había ningún consumidor: abrirlas no servía a nadie. Y en el frontend, la otra mitad. El load de /admin/pinar metía el 403 en el mismo saco que los fallos de red, así que la pantalla lo habría presentado como avería. Ahora viaja como sinPermiso y la pantalla dice qué permiso falta, con el mismo bloqueo que /admin/colas; y hace caso al backend aunque la sesión diga otra cosa —el nivel CRUD o una concesión revocada pueden discrepar—. Es la cuarta aparición del mismo defecto en esta revisión (/envios con su 403 como «no hay registros», la bandeja de correo con su «lista desactualizada»), y por eso lleva test. El guardián del PINAR recorre las rutas que el router declara, no una lista, con aserción anti-vacuidad para que no pase en verde si el censo devolviera cero. 773 tests de archive-service, 117 de tenant-service, 1804 del frontend; las cinco correcciones verificadas por mutación. Por qué: un sistema que esconde algo y lo sirve a la vez no ha decidido nada — Impacto: las 11 lecturas del PINAR y los dos historiales de versiones de TRD/TVD ahora responden 403 sin el permiso; el vocabulario de clasificación no cambia y /radicar sigue igual.

  • docs(pruebas): la bandeja de correo entra en el plan de pruebas, y dos pantallas faltaban en inglés docs/es/plan-pruebas.md declaraba la ingesta de correo fuera del barrido «porque el despliegue de pruebas no tiene buzón IMAP configurado». Dejó de ser cierto el 2026-08-28: se levantó GreenMail en la red del compose y se recorrieron los siete casos con actos reales. El bloque B14 queda escrito con el comando que levanta el servidor —el único motivo por el que el bloque no existía era ese, y ahora está en el documento—, y con el campo «lo que distingue el botón respondió de el acto ocurrió» en cada caso: el .eml conservado, el mensaje movido a Radicados en el servidor IMAP, el asiento email.mensaje.descartado sin asunto ni remitente, y el 404 neutro al administrador sobre un buzón personal. El §6 gana la lectura del bloque, porque su hallazgo no encaja en ninguno de los dos patrones anteriores: no era la pantalla mostrando otro dato ni un gate viviendo solo en el navegador, sino un gate puesto donde el código lo necesitaba y no donde la seguridad lo necesitaba. Y un cruce mecánico de docs/{es,en}/pantallas.md —39 rutas contra 37— destapó que las dos pantallas de correo nunca entraron en la versión inglesa. Es la segunda vez que la documentación en inglés se queda atrás sin que nadie lo note leyendo: se encuentra contando filas, no leyendo. Por qué: un plan de pruebas que declara fuera lo que ya se probó envía a nadie a probarlo otra vez — Impacto: la bandeja de correo es reproducible por cualquiera con el comando del documento; pantallas.md vuelve a decir lo mismo en los dos idiomas.

  • fix(frontend): una bandeja que no se alcanza anunciaba que su lista podía estar desactualizada Encontrado en el navegador, no en un test, validando el gate de alcance recién puesto: jefe (administrador) abrió por URL directa la bandeja de la cuenta personal de otra funcionaria. El backend la negó con el 404 neutro —correcto, y la pantalla lo explicaba bien— pero además mostraba en ámbar «no se está pudiendo comprobar si hay novedades, así que esta lista puede estar desactualizada, actualícela a mano antes de radicar». Sobre una bandeja que no existe, sin lista y sin nada que radicar. El sondeo seguía preguntando cada 12 s y su 404 se leía como un fallo transitorio: un error de autorización presentado como otra cosa, el mismo defecto por el que /envios presentaba un 403 como «no hay registros». Con la bandeja inaccesible ya no se sondea —preguntar cada 12 s por algo que se sabe inalcanzable—, el aviso de sondeo caído queda callado y «Consultar el buzón» no ofrece un acto imposible. Las tres consecuencias cuelgan de una sola condición, y hay test de que esa condición no desaparece; verificado por mutación y comprobado en vivo: sin llamadas de sondeo en la cuenta ajena, y con ellas en la propia. Por qué: un mensaje que atribuye a un fallo de red lo que es una negativa de acceso hace buscar la avería donde no está — Impacto: quien abra una bandeja que no le corresponde lee una sola explicación, y es la cierta.

  • fix(document,frontend): el gate de la bandeja de correo decía quién puede radicar, no qué buzón Hallazgo de seguridad, encontrado al ir a documentar los endpoints de bandeja y descubrir que no estaban documentados. PERM_RADI habilita trabajar correo; el buzón concreto lo decide otro recorte — el WHERE de email_cuenta_repository, escrito con cuidado y con su test, que oculta la cuenta personal de un funcionario incluso al administrador. Listar la bandeja, reservar, liberar y descartar iban directas al mensaje por su id, sin pasar por ahí: cualquiera con PERM_RADI de la entidad podía leer el asunto y el remitente del correo privado de otro funcionario y apartárselo. Solo sincronizar y radicar cruzaban el recorte, porque lo necesitaban para abrir la sesión IMAP — es decir, el gate llegaba donde hacía falta técnicamente, no donde hacía falta por seguridad. Es la segunda vez en este repositorio que un recorte correcto no alcanza a la puerta de al lado. Se cierra con un solo guardián en el servicio —la comprobación que se repite en cada endpoint es la que un día falta— que responde 404 cuenta_no_encontrada, no 403: distinguirlos convertiría el endpoint en un oráculo de qué buzones tiene la entidad y quién los trabaja. Y una segunda capa en el WHERE de la mutación: el cuenta_id ata el mensaje al buzón, porque si no, alcanzar uno bastaría para operar sobre el correo de cualquier otro con solo conocer su id. Las dos capas verificadas por mutación por separado. Descartar un correo ahora deja asiento en la cadena inmutable, en la misma transacción y no best-effort: si audit_log no puede recibirlo, el correo no se descarta. Decidir que una comunicación recibida no se registra es tan decisión como registrarla —de hecho es la que hace desaparecer el documento del archivo— y hasta ahora solo dejaba un UPDATE sobre una tabla corriente, reescribible después sin rastro. El asiento no lleva el asunto ni el remitente: un correo descartado nunca se radicó, así que no tiene nivel de clasificación con el que recortar su lectura, y en audit_log lo vería todo USUA_PERM_ADMIN. El motivo sí, porque es el texto de la decisión, no el del documento. Y aparece la enésima capacidad construida y no ofrecida: liberar() existía en el repositorio con su test desde el Increment 2 y no había forma de llamarlo. El efecto era que abrir un correo, ver que no era para uno y cerrar la pantalla lo dejaba bloqueado quince minutos para toda la ventanilla, sin más salida que esperar. Ahora hay endpoint, cerrar el formulario suelta la reserva, y la fila propia ofrece «Soltarlo». El carril de integración de document-service gana el bootstrap de public.audit_log portado de archive-service: la atomicidad del asiento no se puede comprobar con un doble, porque el rollback lo produce Postgres. Y docs/{es,en}/api.md documentan por fin los siete endpoints de bandeja, cruzados contra el OpenAPI del servicio arrancado — no leídos. 466 tests de document-service (14 de integración sobre la bandeja), lint verde. Por qué: un permiso que dice «puede radicar» no dice «puede este buzón», y el correo personal de un funcionario no es de la ventanilla — Impacto: quien no tenga asignado un buzón deja de alcanzar su bandeja; las cuentas personales solo las ve su dueño, también desde la bandeja.

  • refactor(document,frontend): retirada la ruta heredada de un solo buzón (IMAP_*) Cierra el Increment 3 de E19. Desaparecen las variables IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASSWORD, IMAP_USE_SSL, IMAP_MAILBOX e IMAP_TENANT_SLUG, los endpoints GET /api/v1/ingest/email/estado y POST /api/v1/ingest/email/poll, el cliente imaplib y el ingestor que los servían, y el bloque «Buzón heredado» de /admin/correo con su historial. No es limpieza cosmética: es que esa ruta no podía hacer bien el trabajo. Un buzón por despliegue no expresa lo que exige el Acuerdo AGN 060/2001 art. 13 —«asignarán responsabilidades de acuerdo con la cantidad de cuentas habilitadas»—; la contraseña vivía en claro en el entorno de un contenedor; y sobre todo radicaba de golpe todo lo no leído, es decir, también la publicidad y los boletines, porque no tenía dónde separar lo que se radica de lo que no. Lo sustituye lo construido en los tres incrementos: una fila por buzón con su credencial cifrada, su carpeta de filtrado, su carpeta «Radicados», su modo y su responsable. Y de paso desaparece una contradicción de raíz. La pantalla llegó a decir «La ingesta de correo no está configurada» con un buzón configurado y funcionando justo debajo: eran dos fuentes de verdad hablando a la vez. Aquello se corrigió condicionando el aviso; ahora la segunda fuente no existe, que es la corrección fuerte. El guardián que vigilaba la condición pasa a vigilar eso. El registro de qué se radicó desde correo no se pierde con el historial: cada radicado deja su asiento en la auditoría inmutable, y la bandeja de cada cuenta conserva qué mensaje produjo qué número — que además dice de qué buzón vino, cosa que el historial del poll no distinguía. 409 tests de document-service (los 15 de la ruta retirada se van con ella), 9 de /admin/correo, svelte-check 0/0, lint verde. Por qué: mantener dos rutas de ingesta obliga a decidir cuál manda, y la heredada radicaba la publicidad — Impacto: quien tuviera IMAP_* en su entorno debe configurar su buzón desde /admin/correo; las variables se ignoran y los dos endpoints devuelven 404.

  • feat(document,notification): la constancia al remitente — y el aviso que no llegaba a la dependencia Cierra el Increment 3 de E19 salvo la limpieza de la ruta heredada. Encuadre honesto, y es la mitad del trabajo. La spec original afirmaba que la Circular Externa AGN 001/2020 obliga a acusar recibo; consultado el texto, no lo dice. Y la Ley 1755/2015 art. 15 exige la constancia «si quien presenta la petición solicita constancia de haberla presentado» — a petición del interesado, no de oficio. Así que esto es opt-in por buzón: una buena práctica que le da al ciudadano el número con el que seguir su trámite, no una obligación inventada. Un buzón de PQRS querrá acusar; uno de notificaciones judiciales, donde el remitente es un juzgado, probablemente no. Dos riesgos que el diseño cierra, y no son teóricos. (1) Bucle: si el remitente es la propia cuenta —alguien reenvía desde el buzón, un reenvío automático mal configurado— la constancia volvería a la carpeta de trabajo, se radicaría y generaría otra, consumiendo números de la secuencia oficial en cada vuelta. Se corta comprobando que el destinatario no sea el propio buzón, con la caja normalizada. (2) Rebote a un tercero: el From se falsifica trivialmente, así que un correo suplantado haría que la entidad escribiera a quien nunca le escribió — riesgo acotado (solo se acusa lo que se radica, y el contenido es un número) pero real, y otra razón para que sea opt-in. La constancia dice lo justo y calla lo que no sabe: número, asunto registrado, fecha de recibo y dónde consultar. No promete plazos —los fija la ley según el trámite— ni repite el contenido de la comunicación, que podría acabar en manos de quien no la envió. El envío es best-effort: el número ya está asignado y es inmutable, deshacerlo por un fallo de correo sería mucho peor que un ciudadano sin acuse; el fallo se registra para que alguien pueda verlo, porque «no se envió» y «no se pudo enviar» son cosas distintas. Y de camino apareció un defecto que llevaba tiempo ahí. El aviso interno de radicación leía solo dest_dept_code, así que una radicación hecha por nombre de dependencia —la que produce la bandeja de correo, y la de cualquier cliente que no maneje códigos— fallaba por dos sitios a la vez: el correo decía «Dependencia destino: sin asignar» teniendo el nombre en el mismo payload, y se enviaba a la ventanilla genérica en lugar de a la dependencia competente, que nunca se enteraba de su documento. Es el mismo defecto que ya se había corregido en el stream de flujos; el de documentos se quedó fuera. Ahora buzon_destino es keyword-only y sin default —igual que su gemelo— y el consumidor resuelve el nombre contra la base cuando no hay código; el reenvío del dead-letter, por el mismo camino, o reintentar un aviso muerto reproduciría el fallo. build_document_notifications no tenía ningún test propio —solo se mencionaba en el docstring de otro— y por eso su defecto sobrevivió. Ahora tiene seis, verificados por mutación. Verificado en vivo con MailHog: la constancia llegó a pedro@ciudadano.co con su número y su fecha de recibo, y el aviso interno fue a dependencia-902 diciendo «Dependencia destino: Oficina Jurídica» — antes habría ido a la ventanilla genérica diciendo «sin asignar». 424 tests de document-service + 178 de notification-service + 53 de integración. Por qué: un ciudadano sin su número no puede seguir su trámite, y una dependencia que no recibe el aviso no sabe que tiene trabajo — Impacto: quien escribe recibe su constancia, y quien debe tramitar se entera.

  • feat(document): sondeo programado y modo automático — y el responsable que la norma exige Increment 3 de E19. Hasta aquí la ingesta era a demanda: alguien pulsaba «consultar el buzón». Eso sirve para una ventanilla atendida, pero no para una entidad que recibe correspondencia fuera de horario y quiere encontrarla ya en la bandeja. El modo automático destapó un problema que la norma ya tenía resuelto. Sin funcionario que pulse no hay autoría, y eso rompía por dos sitios a la vez: los anexos se suben a storage bajo la identidad de quien radica, y storage exige PERM_RADI de un usuario concreto —responde 403 sin él, comprobado—; y un radicado que nadie firma incumple el Acuerdo 060/2001 art. 2, que incluye el «funcionario responsable del trámite» entre los datos mínimos. La misma respuesta resuelve las dos: la cuenta automática declara bajo qué funcionario radica (responsable_user_id, migración 024, con CHECK en ambas direcciones). No es un tecnicismo para contentar a storage: es quien responde de lo que entre por ese buzón sin revisión previa, que es lo que pide el art. 13 al hablar de asignar responsabilidades. Tres cosas que el worker hace bien, cada una con su test. (1) No duplica trabajo entre réplicas: cada cuenta se toma con un advisory lock, y con la variante que no espera — con la bloqueante, una réplica atascada en un buzón lento dejaría a la otra parada ahí en vez de atender las demás. (2) No castiga a un buzón caído: retroceso exponencial acotado (1, 2, 4… minutos hasta una hora) que se reinicia al primer éxito; sin tope, un buzón mal configurado seguiría golpeando hasta que el proveedor bloquee la cuenta; sin reintento, se arreglaría la configuración y nadie volvería a probar. (3) Una cuenta caída no impide sondear las demás: si el buzón de PQRS no responde, el de notificaciones judiciales entra igual. El automático no le quita un correo a quien lo tiene abierto: modo automático significa «no hace falta que alguien lo abra», no «el proceso manda sobre quien lo abrió». Y las cuentas personales quedan fuera del sondeo, que no es un olvido: su credencial no se guarda —se pide al funcionario en cada sesión— así que un proceso de fondo no puede abrirlas, e incluirlas produciría un fallo por cuenta y por vuelta para siempre. Opt-in y con entidad declarada: arranca apagado, y aunque se active no arranca sin EMAIL_SONDEO_TENANT_SLUG —un sondeo que adivinara la entidad radicaría en el archivo equivocado—; la negativa se dice en el registro para que la ausencia no se confunda con un fallo silencioso. El bucle sobrevive a cualquier excepción: un worker que muere en silencio deja la ingesta parada y el síntoma sería «no llega correo», indistinguible de un buzón vacío. Verificado en vivo con el sondeo activo: un correo entró al buzón y se radicó solo como 2026-DEMO-E-000024, con su anexo, el original conservado, atribuido al responsable declarado y movido a «Radicados» — sin que nadie pulsara nada. Retroceso y exclusión entre réplicas verificados por mutación. 416 tests de document-service. Por qué: una ventanilla no deja de recibir a las seis de la tarde — Impacto: una entidad puede dejar que su buzón de correspondencia se radique solo, sabiendo quién responde por ello.

  • fix(document,frontend): el correo equivocado: un uid sin su UIDVALIDITY puede radicar el mensaje de otro Dos defectos que solo aparecieron usando el sistema desde el navegador, no en ningún test. (1) El grave. El adaptador traía y movía mensajes por uid sin comprobar el UIDVALIDITY con el que se registraron. Un uid solo identifica un mensaje mientras ese valor no cambie (RFC 3501 §2.3.1.1); cuando cambia —restauración del buzón, migración de servidor— los uid se reparten de nuevo desde 1, y la bandeja seguiría apuntando al «mensaje 7» trayendo el que ahora ocupa esa posición: otro correo, de otro remitente, radicado bajo el asunto del primero. El modelo guardaba el uidvalidity desde el principio y la migración lo documentaba; lo que faltaba era mirarlo. Se descubrió al reiniciarse el servidor de pruebas, que asignó uno nuevo — y el síntoma fue un error, pero con mensajes presentes habría sido silencioso y peor. Ahora traer y mover lo exigen, y el mensaje dice qué hacer: volver a consultar el buzón. (2) sub no es user_id. La pantalla comparaba la autoría que escribe el backend (reservado_por, la identidad interna del tenant) con el identificador de Keycloak. Nunca coinciden: un funcionario no habría reconocido sus propios correos y, como el botón se deshabilita sobre lo reservado por otro, no habría podido continuar con el que él mismo abrió. El propio auth.server.js ya lo advierte —«confundirlos es lo que dejó sin funcionar tres pantallas»— y esta iba a ser la cuarta. Guardián nuevo identidad-interna-vs-sub.test.js, por FORMA: censa comparaciones entre campos de autoría y sub. Cuando la identidad interna no se puede resolver, un correo reservado se muestra como reservado sin afirmar de quién es, y se deja intentar: el backend decide. Deshabilitar por no saber bloquearía la ventanilla; darlo por propio escondería una reserva ajena. Verificado desde la pantalla: 2026-DEMO-E-000023 radicado con su anexo, el mensaje original conservado, la dependencia asignada, atribuido a JEFE, y el correo movido a «Radicados» en el buzón. Las dos fechas conviven: recibo 01:19:14, radicación 01:20:13. Ambas correcciones verificadas por mutación. Por qué: radicar el contenido de otro correo bajo el asunto de este es indetectable después — Impacto: la bandeja no puede confundir mensajes tras una restauración del buzón, y cada quien reconoce lo suyo.

  • feat(document,frontend): la pantalla de la bandeja, y el aviso que mantiene sincronizados a los operadores Cierra el Increment 2 de E19. La reserva ya existía en la base; ahora se ve: un correo que otro funcionario está trabajando aparece marcado como ocupado y su botón de abrir queda deshabilitado. Sin eso, el segundo lo descubriría pulsando y llevándose un error que no esperaba — y eso es exactamente lo que hacía el Orfeo legacy, donde un correo ya radicado seguía en la lista con una «R» roja. El aviso a las pantallas abiertas. Un sondeo cada doce segundos pide solo el resumen —pendientes, ocupados y la marca de la última novedad— y la lista se recarga únicamente si la marca cambió. Traerse la lista entera en cada sondeo, con varios operadores mirando la misma ventanilla, convertiría la función en un estorbo. La marca se mueve ante todo lo que cambia lo que se ve, incluida una reserva ajena: sin contarla, el resto seguiría viendo «libre» algo que ya no lo está. Y no recarga por debajo de quien está trabajando. Si el operador tiene un correo abierto, la novedad se avisa y decide él: recargar mientras completa un formulario le borraría lo escrito, y enterarse un minuto más tarde es mejor que perder el trabajo. Cuando el sondeo falla se dice, en vez de dejar una pantalla que parece viva y lleva minutos congelada. Abrir la bandeja no sincroniza: traer del buzón es un acto que el operador dispara. Aunque no radique nada, descargar un buzón entero cada vez que alguien mira la lista sería un coste que nadie pidió — y hay un test que lo comprueba método a método. El tope de correos por sincronización se declara (hay_mas) en vez de recortar en silencio: sin decirlo, «trajo 50» sería indistinguible de «eso era todo». Las tres acciones sobre un correo van por una ruta con lista cerrada de acciones, resuelta con Object.hasOwn — un acceso directo devolvería miembros heredados de Object.prototype y el guard dejaría de ser la lista cerrada que aparenta. Y los 409 de la reserva llegan al cliente tal cual: no son averías, son la reserva funcionando, y la pantalla los presenta como aviso y no como alarma. La bandeja se enlaza desde la ficha de cada buzón en /admin/correo: sin ese enlace existiría y solo se alcanzaría escribiendo la URL, que es el hueco que esta revisión ha encontrado media docena de veces. Verificado en vivo: 3 correos sincronizados, el sondeo pasando de pendientes=4 ocupados=0 a ocupados=1 al reservar, el segundo funcionario rechazado con 409 correo_ocupado, y un boletín descartado con motivo bajando el contador a 3. 409 tests de document-service + 53 de integración. Por qué: una reserva que el backend aplica pero la pantalla no muestra se descubre fallando — Impacto: varias personas pueden trabajar la misma ventanilla viendo lo que hacen las demás.

  • feat(document,storage): radicar desde la bandeja: los adjuntos ya no se pierden y la fecha es la de recibo Segunda mitad del Increment 2 de E19. El ciclo completo funciona: sincronizar el buzón, reservar un correo, radicarlo con sus soportes y moverlo a la carpeta de radicados. Los adjuntos se guardan. La implementación anterior metía los NOMBRES en metadata y tiraba los bytes: se radicaba la petición y se perdía su soporte. El Acuerdo 060/2001 art. 2 incluye «Anexos» entre los datos mínimos del registro, así que no era una carencia sino un incumplimiento. Ahora suben a storage con su SHA-256 y quedan atribuidos al funcionario que radicó, no al sistema. Dos fechas, y no se confunden. La de radicación la pone el sistema y da fe del acto administrativo — ningún cliente puede fijarla, porque permitirlo abriría la puerta a falsear cuándo radicó la entidad. La de recibo sale del INTERNALDATE del buzón y viaja como dato de la comunicación. Verificado con un correo real: recibo a las 22:02, radicación a las 22:11. El mensaje original se conserva íntegro (Ley 527/1999 art. 12) y va por la puerta de artefactos del sistema, no por la de anexos. La distinción no es burocrática: admitir .eml en la lista de anexos dejaría subir correos completos —con sus adjuntos incrustados— por la puerta que valida formatos de anexo, y esa la alcanza cualquiera con PERM_RADI. Es el mismo razonamiento que storage ya aplicaba al XML, escrito en su propio comentario. Orden de las operaciones al radicar, por reversibilidad: primero los binarios (un fichero huérfano es molesto pero recuperable), luego el radicado (el número no se libera nunca) y por último mover el correo (se puede deshacer a mano). Y la reserva se vuelve a exigir al cerrar: entre la comprobación inicial y ese punto pasó una subida de ficheros y una radicación, y en ese rato pudo caducar. Un correo ilegible no tumba la sincronización: se anota y se sigue. Un MIME roto en el buzón dejaría a la ventanilla sin poder traer nada y bloquearía a todos los que vinieran detrás. Dos hallazgos del lector de correos: una imagen incrustada (inline, con Content-ID) no es un anexo — sin esa distinción, cada correo con firma corporativa metería su logotipo en el expediente como soporte documental; y un nombre de adjunto con separadores de ruta se sustituye entero en vez de maquillarse, porque .._.._etc_passwd ya no compone una ruta pero conserva la forma del original y es más difícil de reconocer como manipulado. El tipo cazó de paso el error de ArchivoSubido.id — el campo es file_id, exactamente el defecto que ese tipo se creó para impedir tras haberse colado dos veces. Verificado contra un buzón real: dos correos sincronizados, el segundo funcionario rechazado al reservar y al radicar, el primero radicado como 2026-DEMO-E-000022 con su anexo, fuera de la bandeja y movido a «Radicados» en el servidor. 409 tests de document-service + 51 de integración + 320 de storage. Por qué: radicar una comunicación sin sus soportes ni su fecha de recibo no cumple lo que la norma pide del registro — Impacto: un correo se convierte en un radicado completo, con anexos, original conservado y trazabilidad de quién lo hizo.

  • feat(document): la bandeja compartida: dos funcionarios ya no pueden radicar el mismo correo dos veces Increment 2 de E19, el que ataca el problema que planteó el usuario. Espejo local del buzón (email_mensaje) con reserva, que es lo que el Orfeo legacy no tenía: allí un correo radicado seguía en la lista con una «R» roja y nada impedía que otro lo abriera. Tres defensas, y las tres viven en la base de datos. (1) Un mismo correo no entra dos veces en la bandeja: UNIQUE (cuenta, message_id). (2) Reservar es un UPDATE condicionado —entre un SELECT que dijera «está libre» y un UPDATE que lo tomara cabe la petición de otra persona, y esa ventana es el defecto—. (3) Radicar exige la reserva vigente del propio actor, no solo que el correo siga pendiente: sin eso, dos personas que lo hubieran abierto podrían radicarlo las dos. Las tres verificadas por mutación, cada una cayendo en su test. La reserva caduca, y es deliberado: sin caducidad, quien cierra el navegador deja el correo bloqueado para siempre y la ventanilla necesita un administrador para desatascarlo — un bloqueo que solo se libera a mano acaba siendo un bloqueo que nadie usa. La identidad es el Message-ID, no el uid. Un uid solo es único mientras el UIDVALIDITY de la carpeta no cambie (RFC 3501 §2.3.1.1); si el servidor la recrea —una restauración, una migración de buzón— los uid se reutilizan desde cero y el mensaje nº 7 nuevo se confundiría con el nº 7 viejo, dándose por radicado sin estarlo. Probado con ese caso exacto. Y un hallazgo que cambió la fecha del radicado. Iba a usar la cabecera Date, hasta que el servidor de pruebas la devolvió vacía al primer intento. Date la pone el cliente del REMITENTE: depende de su reloj, puede faltar y sobre todo la controla él, así que podría fecharse en el pasado para ganar plazo en un derecho de petición. La fecha que cuenta es el INTERNALDATE, que pone el servidor de la entidad cuando el mensaje entra en el buzón — literalmente la «fecha y hora de recibo» de la que la Circular Externa AGN 001/2020 exige dejar constancia. Si el servidor no la trae, no se inventa: una fecha de recibo falsa es peor que ninguna, porque nadie volvería a revisarla. El adaptador gana listar_uids (que no descarga nada: la carpeta de una ventanilla puede acumular meses), traer (mensaje íntegro en su formato original, Ley 527/1999 art. 12, con BODY.PEEK para no tocar la marca \Seen, que es compartida) y mover (con respaldo COPY+EXPUNGE para los servidores sin MOVE, que los hay). Los tres probados contra un IMAP real: listó, trajo el mensaje con su adjunto, y lo movió creando la carpeta de destino. 402 tests unitarios + 51 de integración contra Postgres real. Por qué: dos números de la secuencia oficial —inmutable por Ley 594— consumidos por una sola comunicación no se pueden liberar — Impacto: la bandeja de la ventanilla ya se puede trabajar entre varias personas.

  • feat(frontend): pantalla de cuentas de correo — y un guardián con dos puntos ciegos que ocultaban una advertencia de seguridad Cierra el Increment 1 de E19 por el lado de la interfaz: /admin/correo pasa de mostrar la cuenta única heredada a gestionar los buzones de la entidad. Alta de cuenta con los tres tipos, probar conexión —que devuelve las carpetas del buzón para elegir la de filtrado de una lista en vez de escribirla a mano—, registro de la aplicación OAuth de la entidad, y el flujo de autorización completo con su ruta de retorno. La pantalla explica por qué, no solo qué: cuando el proveedor es Google o Microsoft se dice que no admiten contraseña y desde cuándo, porque un administrador que no lo sepa buscará el problema en la contraseña, que es donde no está. Y una cuenta OAuth2 sin autorizar se señala explícitamente: sin decirlo, el síntoma sería «no llega nada», indistinguible de un buzón vacío. De qué cuenta se trata al volver del proveedor se recuerda en sessionStorage, porque la dirección de retorno es fija —los proveedores no admiten comodines en la ruta—. No es un canal de confianza y no necesita serlo: el backend comprueba que el state pertenezca a esa cuenta y a ese usuario, así que manipularlo solo consigue que el canje falle. Tres defectos propios, y el tercero destapó uno real que llevaba oculto. (1) Mi test del load no discriminaba: las aserciones vivían dentro del mock, y el try/catch del propio código bajo prueba se traga el error que lanza un expect. Cambiar la URL de las cuentas por una inventada seguía en verde. Reescrito para recoger las llamadas y comprobarlas fuera. (2) El guardián prosa-en-flex comparaba solo el sufijo BEM de la clase, así que .correo__aviso —que sí es flex— marcaba como culpable a .form__aviso, que no lo es. Ahora resuelve el & de Sass contra su bloque y compara el nombre completo. (3) El punto ciego caro: el cuerpo de un contenedor se extraía con un cierre no codicioso, que casa con el PRIMER </div> —el de un hijo, no el suyo—. Con un contenedor flex envolviendo la pantalla, la primera coincidencia se comía todo hasta ese cierre y el índice saltaba por detrás de los contenedores interiores, que quedaban sin examinar. El censo daba verde sobre un defecto reproducido a propósito para probarlo. Con el censo arreglado apareció un defecto real y preexistente: la advertencia de los códigos de recuperación del segundo factor en /perfil —«Guárdelos ahora en un lugar seguro. No se volverán a mostrar»— estaba troceada en cinco columnas dentro de un flex en fila. Es del mismo lote que las cinco ya corregidas; esta se escapó por el punto ciego. Corregida con el patrón establecido. Y dos defectos más que solo aparecieron al mirar la pantalla en el navegador, no en ningún test: (a) el bloque superior afirmaba «La ingesta de correo no está configurada» mientras justo debajo había un buzón configurado, probado y funcionando — hablaba de la configuración heredada del despliegue, pero redactado en nombre de toda la ingesta; ahora solo aparece cuando no hay ninguna cuenta, que es el único caso en que es cierto, y el bloque heredado se rotula como tal. (b) El usuario_login viajaba en la respuesta y la ficha no lo leía —el patrón dominante de toda esta revisión—, justo el dato que hace falta lo primero al diagnosticar un «credenciales rechazadas». Verificado en el navegador de punta a punta: la cuenta se lista con su usuario distinguido de la dirección, probar conexión con el servidor caído registra y muestra el fallo, y con el servidor arriba conecta y lista las carpetas del buzón. Al elegir Google, el formulario explica desde cuándo no admite contraseña y avisa de que falta registrar la aplicación de la entidad. 1 786 tests de frontend, svelte-check 0/0. Por qué: un guardián con un punto ciego es peor que no tenerlo, porque aprueba en silencio — Impacto: la configuración de correo se opera desde la interfaz, y una advertencia crítica vuelve a leerse como una frase.

  • feat(document): el consentimiento OAuth2 de Google y Microsoft, con PKCE y state de un solo uso Cierra la conexión de E19: una cuenta OAuth2 ya se puede autorizar de verdad, no solo registrar. Los valores no son configurables ni inventados. Google: https://mail.google.com/, más access_type=offline y prompt=consent — sin esos dos no entrega token de refresco, así que la cuenta funcionaría una hora y se apagaría sola, con el diagnóstico apareciendo como «credenciales rechazadas» en un momento en que nadie tocó nada. Microsoft: https://outlook.office.com/IMAP.AccessAsUser.All más offline_access — no outlook.office365.com, que es el del flujo de credenciales de aplicación y produce un token que el servidor IMAP rechaza sin explicar por qué. Cada constante cita su fuente. PKCE S256 aunque el cliente sea confidencial (RFC 7636): el código de autorización viaja por el navegador del administrador, donde puede quedar en el historial o en el Referer; con el verificador guardado en el servidor, un código interceptado no sirve. El state se valida y se marca usado en el mismo UPDATE, con cuatro condiciones —un solo uso, no caducado, misma cuenta, mismo iniciador—. Entre un SELECT previo y el marcado cabría una segunda petición con el mismo state, que es justo el ataque de reproducción que el state existe para impedir. El 400 no dice cuál de las cuatro falló: distinguirlas le diría a quien prueba estados qué está acertando. La redirección del proveedor aterriza en la interfaz, que ya sabe quién es el usuario, y esta canjea contra el backend — en vez de un endpoint público que reciba la redirección, que abriría superficie sin autenticar en el gateway. El error del proveedor se recorta al código normalizado (RFC 6749 §5.2) y nunca a la descripción: el cuerpo de error puede reflejar los parámetros enviados, client_secret incluido, y acabaría en el registro del servidor y en la pantalla. Las cuatro condiciones del state verificadas por mutación una a una, cada una cayendo solo en su propio test. Las dos primeras mutaciones fueron inválidas y hubo que rehacerlas: borrar la condición desalineaba los parámetros, y sustituirla por ($2 IS NOT NULL) dejaba a asyncpg sin poder inferir el tipo — en ambos casos los tests fallaban por la razón equivocada, que no prueba nada. Con ($2::uuid IS NOT NULL) cada condición quedó aislada. De paso, el CHECK de vigencia demostró su valor rechazando el primer intento de envejecer una fila en el test: prohíbe que exista un state nacido caducado. 399 tests unitarios + 42 de integración contra Postgres real. Por qué: sin consentimiento no hay acceso, y una cuenta OAuth2 registrada sin autorizar no sirve para nada — Impacto: una entidad con Microsoft 365 o Google Workspace ya puede conectar su buzón de ventanilla.

  • feat(document): cuentas de correo: multi-cuenta, credenciales custodiadas y OAuth2 — y dónde viven las llaves de Google y Microsoft Increment 1 de la reconstrucción de E19. Sustituye la cuenta única de las variables IMAP_* —que no podía expresar lo que exige el Acuerdo AGN 060/2001 art. 13, «asignarán responsabilidades de acuerdo con la cantidad de cuentas habilitadas», y guardaba la contraseña en claro en el entorno— por un catálogo por entidad. La pregunta que resolvió el diseño: ¿las llaves de Google y Microsoft se piden al crear la entidad? No, y hay dos credenciales que se confunden: la de la aplicación (client_id/client_secret), que identifica a OrpycaMCP ante el proveedor y es de la entidad, y la autorización del buzón (refresh token), que es por cuenta. La aplicación la registra cada entidad en su propia organización, no el despliegue: Google exige verificación y una evaluación de seguridad CASA anual por un tercero acreditado a cualquier aplicación que pida el scope de IMAP, pero exime a las internas — «the app is only used by people in your Google Workspace or Cloud Identity organization». Con una aplicación central habría que pasar esa auditoría, y OrpycaMCP es software que cada institución despliega, no un servicio que pueda sostenerla. Y no va en el alta del tenant: al crear la entidad nadie sabe aún qué correo usará, quien la da de alta rara vez tiene los permisos de Azure o Google Cloud a mano, y muchas entidades usarán IMAP de hosting sin necesitarlo. Se registra al configurar la primera cuenta OAuth2. Tres tipos de cuenta, según lo acordado: institucional compartida (con funcionarios asignados), personal del funcionario y varias de recepción a la vez. La credencial de una cuenta personal no se guarda en absoluto —se pide al entrar y vive en la sesión, el patrón del lock.php de Orfeo, que en esto acertaba—: custodiarla convertiría al sistema en titular del correo entero de alguien. Las institucionales se cifran con AESGCM y AAD tenant|cuenta, mismo esquema que la clave de firma personal. La visibilidad va en el WHERE, no en una comprobación previa: una cuenta personal la ve solo su dueño —ni el administrador, porque administrar la ventanilla no da acceso al correo privado de un funcionario— y una institucional, sus asignados. El endpoint de conectar lleva el mismo recorte que el de leer: sin él, un identificador adivinado bastaría para abrir sesión IMAP con una credencial de una cuenta que ni se puede ver. Siete tests contra Postgres real, verificados por mutación en las dos direcciones. Tres defectos propios, cada uno cazado por un control distinto. (1) Mi _es_admin() leía X-User-Permissions, una cabecera que nadie produce — el defecto ya había aparecido en mcp-server, cuyos tests lo dejan escrito. Habría devuelto False siempre: ningún administrador vería una sola cuenta, y la pantalla saldría vacía sin ningún error. Resuelto contra la base con check_permission, más un guardián nuevo en shared/tests que censa por FORMA toda cabecera leída sin productor. (2) Ese guardián no discriminó en su primera versión: su patrón de «producida» casaba con la propia lectura que denunciaba (headers.get("X-…", "") lleva una coma detrás) — segunda vez que un censo de este repositorio se encuentra a sí mismo. (3) El login IMAP no siempre es la dirección de correo, algo que el servidor real destapó al primer intento y que el Orfeo legacy ya resolvía cortando por el «@»; ahora direccion y usuario_login son campos distintos. Verificado de extremo a extremo contra un servidor IMAP real: cuenta creada, credencial cifrada en la base (comprobado que no contiene la contraseña), conexión abierta y carpetas del buzón devueltas. 392 tests unitarios + 37 de integración. Por qué: sin OAuth2 y sin multi-cuenta la ingesta no existe para quien usa Microsoft 365 o Google Workspace — Impacto: una entidad puede registrar sus buzones de recepción, cada uno con su carpeta de filtrado y su responsable.

  • feat(document): la ingesta de correo se rehace — estaba declarada completa y no existía Revisión de los tres frentes que pidió el usuario: la norma, los Orfeo legacy y la implementación real. Primer hallazgo, y el que cambia todo: la spec de E19 estaba marcada impl_status: completo y el roadmap repetía el «✅». Verificado pieza por pieza: no existe la columna imap_uid, ni el endpoint from-email, ni las tablas email_ingestion_config/email_ingestados, ni el evento, ni el acuse. Lo que hay es un sondeo con imaplib que radica el asunto y el cuerpo de lo no leído de INBOX. Corregido el estado de la spec y sus citas normativas. Tres incumplimientos, no tres carencias. Los adjuntos no se guardan —solo sus nombres en metadata— y el Acuerdo 060/2001 art. 2 los incluye entre los datos mínimos del registro. El mensaje original no se conserva —se convierte a texto plano— y la Ley 527/1999 art. 12 exige conservarlo en el formato en que se recibió, con origen, destino, fecha y hora. Y la fecha del radicado es la del sondeo, no la de recibo, cuando la Circular Externa AGN 001/2020 exige «constancia de la fecha y hora de recibo» — de ella dependen los plazos del derecho de petición. El defecto de concurrencia era peor de lo que parecía. El cliente usaba search/store sin el prefijo UID: eso opera sobre números de secuencia, que se renumeran cuando alguien borra un mensaje de la carpeta. Con dos operadores en el mismo buzón, la marca caía sobre el correo equivocado. El campo se llamaba uid y no lo era. Y no conectaba con las plataformas reales. Exchange Online retiró la autenticación básica de IMAP a finales de 2022; Google Workspace dejó de aceptar usuario+contraseña —contraseñas de aplicación incluidas— el 2025-05-01. Sin OAuth2 la funcionalidad no existía para la mayoría de las entidades. Increment 1 (en curso) — la conexión. Migración 021_email_cuentas.sql: catálogo de cuentas por entidad (Ac. 060/2001 art. 13, «asignarán responsabilidades de acuerdo con la cantidad de cuentas habilitadas»), con carpeta parametrizable —el filtro humano previo que las entidades usan y que el Orfeo legacy no soportaba: abría INBOX siempre— y carpeta de destino tras radicar. Seis CHECK que hacen imposible una cuenta personal con credencial guardada, una institucional con dueño, media pareja de secreto/nonce o las dos carpetas iguales; validados uno a uno contra Postgres real, más idempotencia y segundo esquema de tenant. core/email_credenciales.py: AESGCM con AAD tenant|cuenta, mismo patrón que la clave de firma personal —antes la única credencial de correo vivía en claro en una variable de entorno—. clients/imap_adaptador.py: UID real, UIDVALIDITY, XOAUTH2 y listado de carpetas. Dos defectos propios cazados en el acto. Mi test de no-ambigüedad del AAD no discriminó bajo mutación: el par que elegí no colisiona ni concatenando a secas. Y la lectura de UIDVALIDITY estaba mal —buscaba la palabra dentro de un contenido que solo trae el número—, lo que destapó el servidor IMAP real al primer intento; un doble habría devuelto la forma que yo suponía, que es exactamente por qué esto se prueba contra un IMAP de verdad. Por qué: una capacidad declarada completa que no existe es peor que una pendiente, porque nadie la vuelve a mirar — Impacto: queda el diagnóstico, el rediseño acordado y el cimiento de la conexión; 392 tests de document-service, lint verde.

  • fix(frontend): trece tokens de diseño que no existían — treinta y dos declaraciones que el navegador descartaba en silencio Salió al construir /admin/correo: escribí la escala de espaciado de memoria (--op-space-md, --op-space-lg) cuando en este sistema es numérica (--op-space-1..8). Once declaraciones muertas en una pantalla recién hecha, y visible solo porque el icono se montaba sobre el título. Es el mismo modo de fallo que --op-font-mono, por la otra puerta. Una variable CSS no definida hace que el navegador descarte la declaración entera: sin error, sin aviso, sin que svelte-check, eslint, prettier ni el build lo noten — no miran dentro de var(). Solo se ve mirando la pantalla, y solo si uno sabe cómo tenía que verse. El censo destapó veintiuna más que no eran mías, en cinco archivos. La peor concentración, doce, en ProposalCard.svelte — la tarjeta con la que el asistente propone un acto al usuario, casi entera sin estilo. Cada huérfano se mapeó por el valor que pretendía, no por parecido de nombre: space-md quería 16 px (--op-space-4), radius-md quería el radio «md» de v1.0, que aquí se llama --op-radius a secas. Guardián src/styles/tokens-existen.test.js, hermano del de monoespaciado: aquel vigila el mal uso de un token real, este la referencia a uno inexistente. Exige que el token exista incluso cuando hay respaldo — un respaldo sobre un nombre inventado funciona de casualidad y esconde el error, que es justo como estaba var(--op-radius-pill, 9999px): se veía bien y no participaba del sistema de diseño. Con anti-vacuidad y verificado por mutación en las dos direcciones (reintroducir un huérfano, y estrechar el censo). Por qué: un sistema de diseño cuyos nombres no se comprueban se erosiona en silencio — Impacto: una pantalla con un token mal escrito ya no llega a producción sin que nadie lo note.

  • feat(document,frontend): la ingesta de correo tenía endpoint y no tenía puerta — ni forma de saber si estaba encendida sin encenderla Tercer y último hueco de producto de los que la revisión dejó declarados: E19 radicaba correos desde F2 y no había ninguna pantalla. Solo se podía disparar con curl, y su resultado solo se podía ver buscando los radicados a ciegas. El defecto de fondo no era la pantalla que faltaba, era que no se podía construir. El poll era la única puerta: para saber si la ingesta estaba configurada había que ejecutarla — y ejecutarla consume números de la secuencia oficial de radicados, que es inmutable por Ley 594, y marca los correos como leídos, con lo que el siguiente poll ya no los ve. Averiguar el estado costaba un acto irreversible. Una capacidad cuya única forma de consultarse es usarla no se puede ofrecer en una interfaz. Nuevo GET /api/v1/ingest/email/estado (USUA_PERM_ADMIN, sin efectos). Responde 200 con habilitada:false, no un error: «esta entidad no tiene ingesta de correo» es una respuesta correcta a la pregunta, no una avería. Tres motivos distinguidos, cada uno explicado por lo que el administrador puede hacer con él — que no siempre es arreglarlo: un buzón de otra entidad no es un error de configuración suyo. El punto delicado, y su test: el 403 del poll está redactado a propósito para no decir de quién es el buzón. Un estado que devolviera servidor y cuenta cuando el buzón es de otra entidad habría tirado ese cuidado por la ventana desde la puerta de al lado — la dirección de correspondencia y el servidor de correo de otra institución del despliegue no son asunto de quien pregunta. Con buzon_de_otra_entidad los tres campos van vacíos. Ambas puertas comparten diagnosticar_buzon —imap_configurado keyword-only y sin valor por defecto—, para que el estado no pueda anunciar disponible lo que el poll va a rechazar. El test que lo blinda recorre las cuatro combinaciones y exige que estado y poll coincidan siempre; es el que se pondría rojo si alguien vuelve a escribir la condición dos veces. /admin/correo: estado del buzón, el acto con confirmación explícita que deletrea que no es «actualizar» sino radicar y que no se deshace, los números creados enlazados a su radicado, y el historial leído del audit_log (action=ingest.email.poll) en vez de un segundo libro que pudiera discrepar del registro inmutable. Cargar la pantalla no puede radicar nada, y hay un test que lo comprueba método a método. Verificado por mutación: describir el buzón ajeno y desacoplar el estado del diagnóstico ponen rojos, respectivamente, el test de no-revelación y el de coherencia. Por qué: un endpoint sin puerta no es una funcionalidad entregada — Impacto: un administrador ve si su entidad tiene ingesta de correo, la dispara sabiendo lo que cuesta, y puede auditar cada ejecución.

  • fix(frontend): el estilo del identificador se reparaba pantalla por pantalla; ahora se hereda Tres defectos convergían en lo mismo —no había un estilo base para <code>— y cada pantalla nueva volvía a tropezar con ellos. (1) Bulma pintaba code en rojo #da1039 sobre gris. En esta aplicación casi todo <code> es un número de radicado o un código de verificación, y el rojo es el color del error: el estilo por defecto contradecía el significado del dato. Se reparó tres veces pantalla por pantalla —en la consulta ciudadana salía rojo; en la cola de anulaciones el fondo se estiraba a todo el ancho y se leía como un campo de formulario vacío justo encima del asunto— antes de que alguien mirara la hoja global. Corregido donde el proyecto ya ajusta Bulma: code, kbd y samp heredan color, sin fondo ni relleno. (2) --op-font-mono es un TAMAÑO (12px), no una familia. Nueve declaraciones hacían font-family: var(--op-font-mono, monospace), que el navegador descarta por inválida — y el respaldo monospace NO se aplica, porque solo actúa si la variable no está definida. Funcionaba de casualidad allí donde el elemento era <code>, que ya es monoespaciado por defecto del navegador; en cualquier otro elemento el texto salía con la fuente heredada. Nuevo token --op-font-family-mono con pila real, y aviso escrito junto al token de tamaño que causaba la confusión. (3) Tres formas distintas de pedir monoespaciado en 46 sitios: monospace a secas (35), una pila completa (2) y la variante inerte (9). Unificadas en el token, en 27 archivos. monospace a secas deja la elección al sistema operativo, así que el mismo número de radicado se veía distinto en cada equipo. Nueva utilidad .op-dato para el caso contrario: el identificador que sí debe destacarse —el acuse de una radicación ciudadana, el código de verificación que hay que copiar—. Tres pantallas lo escribían a mano con los mismos cinco valores y una cuarta se los inventó distintos. Lleva align-self explícito porque el caso habitual es ser hijo de un flex en columna, donde se estiraría y volvería a leerse como un campo vacío. Guardián src/styles/tokens-mono.test.js, que vigila la causa y no los síntomas: que exista una familia mono con pila de respaldo, que code no herede el rojo, que nadie use el token de tamaño como familia, y que no vuelva a haber tres formas de pedir lo mismo. Con anti-vacuidad. Verificado en el navegador con estilos calculados: en la cola, rgb(110,115,112) sin fondo y ancho ajustado al texto (antes rojo sobre gris, a todo el ancho); en la consulta ciudadana, .op-dato en verde de marca sobre fondo claro. 1 774 tests, svelte-check 0/0. Por qué: un defecto que se repara en cada pantalla nueva no está reparado — Impacto: la próxima pantalla que muestre un número de radicado nace bien.

  • feat(frontend): las dos pantallas ciudadanas: radicar y consultar sin cuenta La capacidad estaba entera en el backend y no tenía ninguna forma de llegar a ella — el hueco de producto que la revisión dejó declarado desde el principio. Ahora hay dos pantallas públicas, enlazadas desde la portada antes que «Iniciar sesión», porque quien llega sin cuenta viene a radicar o a consultar, no a entrar al sistema. La pregunta de fondo, respondida: hay dos ventanillas y las dos son legítimas. La ventanilla de mostrador ya existía (/radicar), donde un funcionario de correspondencia radica lo que llega en persona, por correo o por mensajería: esa sí es de usuario del sistema. La nueva es la misma ventanilla con la puerta a la calle, y tiene que ser pública y sin cuenta —como una PQRSD—, porque exigir registrarse para presentar un derecho de petición sería poner una barrera donde la ley no la pone (Ley 1755/2015). No se sustituyen: son dos puertas al mismo trámite. /radicacion — formulario de la ventanilla en línea: tipo de trámite del catálogo que la entidad declara, remitente que puede ser persona o entidad, datos de notificación y soportes. El acuse destaca el código de verificación y advierte que no se puede volver a mostrar: es lo único que permite consultar sin identificarse. Avisa antes de enviar cuando alguien se identifica sin dejar un medio de respuesta — descubrir un requisito fallando es la peor forma de conocerlo. /consulta — seguimiento por código. Mantiene neutro el «no encontrado»: el backend responde igual para un código inexistente y para uno de un radicado clasificado, y la pantalla no deshace esa indistinción. Dos decisiones declaradas: la pantalla no repite los límites de anexos como validación propia —los anuncia, pero decide el backend: dos listas que pueden divergir dejarían al ciudadano viendo la que se puede saltar—, y no promete plazos, que los fija la ley según el trámite. El inquilino sale de configuración de despliegue (PORTAL_TENANT_SLUG), no de la URL: a un ciudadano no se le explica qué es un inquilino ni se le deja cambiarlo escribiendo otro. Sin valor por defecto: si falta, las pantallas se declaran no habilitadas en vez de mandar peticiones a la entidad equivocada. Dos defectos propios, ambos cazados por controles del repositorio: (1) llamé a la variable PUBLIC_TENANT_SLUG y PUBLIC_ es un prefijo reservado por SvelteKit —$env/dynamic/private excluye esas variables—, así que existía en el contenedor y llegaba como undefined: las pantallas se declaraban «no habilitadas» con la configuración puesta, un fallo mudo; (2) el guardián del sobre de error me cazó leyendo cuerpo?.detail plano en las dos pantallas nuevas, el defecto que una vez dejó un aviso diciendo «[object Object]». Verificado en el navegador sin sesión: formulario → 2026-DEMO-E-000021 con su código → consulta que devuelve estado, tipo, fecha y dependencia responsable. 1 769 tests de frontend, svelte-check 0/0. Por qué: una capacidad a la que nadie puede llegar es una capacidad que no existe — Impacto: un ciudadano puede radicar ante la entidad y seguir su trámite sin crear ninguna cuenta.

  • feat(document,storage): anexos en la ventanilla pública — y el defecto que destapó: dos caminos leían de storage una clave que nunca existió Segundo incremento de la ventanilla única. Una tutela o un derecho de petición sin sus soportes es inservible, y el Acuerdo AGN 060/2001 exige registrar folios y anexos. Pero quien sube aquí no está autenticado, así que cada límite es una decisión de exposición, no una preferencia. Una sola puerta: el endpoint pasa a multipart con los datos en una parte JSON y los anexos en otras. Una ventanilla cuyo caso principal —la tutela con soportes— necesitara un endpoint distinto del caso simple serían dos puertas, que es lo que una ventanilla única no debe tener. Se comprueba el CONTENIDO, no el tipo declarado. El Content-Type de una subida lo escribe el cliente: sin mirar los bytes, la lista de formatos es una cortesía que se esquiva cambiando una cabecera. Se leen las firmas (PDF, JPEG, PNG, TIFF) y el MIME que se guarda es el deducido, no el declarado, para que ningún fichero quede registrado con un tipo que no es el suyo. Lista corta a propósito: los ofimáticos que sí admite un funcionario quedan fuera —son contenedores con macros y su superficie no la justifica el caso de uso—. No hay antivirus, y queda declarado. Límites: 5 archivos, 10 MB cada uno, 25 MB el conjunto; el número se acota antes de leer nada. Nueva puerta sellada POST /storage/internal/anexo-externo con X-Internal-Token y la lista de anexos (RF-DIG-04): no se reutiliza /upload porque exige PERM_RADI y el ciudadano no tiene identidad, ni /internal/artefacto, que aplica la lista de artefactos del sistema —admite XML— y dejaría a un desconocido subir XML como anexo, el atajo contra el que advierte el comentario de ese propio endpoint. El fichero se guarda con uploaded_by = NULL: no se inventa un actor, porque atribuirlo a un usuario del sistema falsearía la traza. Los anexos se resuelven ANTES de asignar número: un número de radicado es inmutable (Ley 594/2000), así que no puede quedar uno emitido al que le falten sus soportes. Si el almacén falla, 503 y no se radica nada. El defecto que apareció al probarlo contra el sistema real: el importador (E11 INT-05) y la ventanilla leían result["file_id"] de la respuesta de storage, y esa clave nunca existió —el contrato FileResponse expone id—. Importar un paquete con anexos binarios reventaba con KeyError. Los dos tests estaban en verde porque sus mocks devolvían la clave inventada: comprobaban que el código coincide con la creencia de quien lo escribió, no con el servicio del otro lado. Corregido en la raíz: el cliente devuelve ArchivoSubido y traduce el formato de cable en un solo sitio, así que ningún llamante vuelve a adivinar una clave. Guardián en shared/tests (que sí cruza servicios y corre en CI) que lee el esquema real de storage y falla si el cliente pide algo que el contrato no expone — verificado por mutación. Verificado contra el sistema en marcha: radicación externa sin autenticar con anexo → 2026-DEMO-E-000020 con su anexo marcado como principal, el fichero en el almacén con su SHA-256 y sin actor atribuido; un ejecutable con extensión .pdf y Content-Type application/pdf rechazado con 422. python-multipart>=0.0.18 añadido con su lockfile regenerado por el procedimiento de solo-lectura. Por qué: sin anexos la ventanilla no sirve para los trámites que más la necesitan — Impacto: se puede presentar una tutela con sus soportes sin identificarse, dentro de límites declarados.

  • feat(document,archive,frontend): la radicación pública era «de PQRS»; una ventanilla única recibe todo lo que llega de fuera Corrección de encuadre del usuario, y cambia el diseño de raíz. La superficie pública asumía que lo único que entra sin autenticar es una petición ciudadana y fijaba en un Literal de Python la taxonomía de la Ley 1755 (petición/queja/reclamo/sugerencia/denuncia). Pero una ventanilla única de radicación es el punto por el que entra toda la correspondencia externa: tutelas, oficios de otras entidades, facturas, invitaciones, hojas de vida. Decidir en código qué recibe cada entidad es decidir por ella. Tres decisiones, cada una con su motivo: (1) El vocabulario sale del catálogo de tipos documentales de la entidad, que es el que gobierna la TRD: lo que entra queda clasificado desde el primer momento y hereda retención y disposición final (Ley 594/2000 arts. 21-24), en vez de entrar sin serie a la espera de que alguien lo clasifique. Nueva columna tipos_documentales.admite_ventanilla_publica (archive-service, migración tenant 044), opt-in con default FALSE: un tipo interno —«Acta de comité»— no debe aparecer en un formulario público solo porque exista en el catálogo. El silencio no significa «todo». Un tipo no admitido se rechaza con 422 y su motivo, nunca se acepta reclasificándolo en silencio — el tipo determina la serie y con ella la retención. (2) Lo radicado se enruta a la dependencia de ventanilla. Antes entraba con dest_dept nulo: no estaba en la bandeja de nadie, y un derecho de petición con quince días hábiles de plazo que nadie ve es exactamente cómo se incumple un término legal. La dependencia se resuelve por dependencias.depe_rad_tp2 —la bandera «habilita radicación web» que el modelo traía heredada de Orfeo y que nadie usaba—, así que la entidad declara qué ventanilla recibe; si no ha declarado ninguna, la radicación se niega con 503 en vez de dejar el documento en el limbo con un número que hace creer que el trámite empezó. No se crea el paso de flujo desde aquí: la distribución la hace una persona de correspondencia, igual que con lo radicado en mostrador, y además sería una escritura sobre el dominio de workflow-service. (3) El remitente puede ser una ENTIDAD. Un oficio lo manda una entidad con su NIT; sender_entity existe en el modelo de radicado desde siempre y la ventanilla pública era la única puerta que lo ignoraba, obligando a escribir el nombre de la empresa en el campo de la persona natural. Se añaden además tipo de identificación y los datos para notificar la respuesta, con una regla declarada: quien se identifica debe dejar un medio de contacto —aceptarlo sin forma de contestarle garantiza el incumplimiento del término—, pero una radicación anónima sigue siendo legítima (una denuncia anónima no espera contestación personal y exigir un canal la impediría). La puerta legítima, no solo el cierre: admite_ventanilla_publica entra en el contrato de tipos-documentales (crear/editar/listar) y en la pantalla /admin/trd, con su casilla y su columna. Sin eso, la única forma de habilitar la ventanilla habría sido un UPDATE a mano — cerrar una puerta sin dejar la manija es media corrección, que este proyecto ya pagó una vez. Los proxies BFF construyen el payload campo a campo, así que el campo nuevo se habría descartado en silencio: tiene test en las dos rutas, incluida la comprobación de que un PATCH que no lo menciona no lo apaga al corregir un nombre. El endpoint /pqrs se conserva —es una superficie pública ya publicada— marcado como legado y sin ampliarse; lo que crece es la ventanilla. Verificado contra el sistema real: sin nada declarado la ventanilla responde 422 y lista vacía (fail-closed); tras declarar el tipo y la dependencia, una radicación externa sin autenticar produjo 2026-DEMO-E-000019 con doc_class y con destino, el aviso llegó a dependencia-901@ con estado sent, y la consulta pública por código devuelve su estado. 371 tests de document-service, 763 de archive-service, 1 762 de frontend, svelte-check 0/0 y lint limpio; el enrutamiento verificado por mutación. Por qué: una ventanilla que solo admite PQRS deja fuera la tutela, el oficio y la factura — es decir, casi todo lo que entra — Impacto: la entidad declara qué recibe y por dónde, y lo que entra queda clasificado y en la bandeja de alguien.

  • fix(document): con solo el código, un anónimo leía metadatos de un radicado RESERVADO — sin autenticarse y a través del gateway Encontrado al ir a construir la pantalla que falta para la verificación pública: antes de dar interfaz a un endpoint, mirar qué expone. GET /api/v1/public/{tenant}/verify/{code} devolvía número de radicado oficial, tipo, estado y fecha de cualquier radicado cuyo código coincidiera, sin ningún filtro de clasificación. La raíz es que verification_code lo tiene todo radicado: la migración 010 lo añadió con DEFAULT gen_random_uuid(), así que existe también para los internos y los clasificados, que nunca tuvieron un ciudadano detrás. El código no es adivinable (128 bits) y el acceso es por capacidad, no una puerta abierta — pero vive en una columna que viaja en respaldos, informes y exportaciones: allí donde se filtrara uno, seguía una lectura anónima de metadatos clasificados. Y el resto del sistema trata la existencia de un radicado reservado como protegida: el acceso directo devuelve 404 y no 403 precisamente para no confirmarla. Dos superficies públicas, una gateada y la otra no. Corrección de una cláusula: AND nivel_seguridad = 1. Un llamante anónimo tiene, por definición, el clearance más bajo — no es una política nueva, es la que ya rige en todo el sistema y faltaba en esta puerta. La respuesta es el mismo 404 neutro que un código inexistente. No se reutiliza _PUBLIC —el chokepoint de OAI/CMIS (publicable AND nivel_seguridad = 1 AND status <> 'anulado')— y las dos diferencias son deliberadas, cada una con su test: exigir publicable rompería la consulta para toda PQRS ciudadana, porque la petición de un ciudadano no es un documento publicado de oficio; y ocultar los anulados haría creer al ciudadano que su código es inválido cuando lo que pasó es que su radicado se anuló, que es justo lo que tiene derecho a saber. Verificado contra el sistema real, no solo con mocks: el radicado reclasificado a Reservada en la revisión devolvía 200 con sus metadatos y ahora devuelve 404 neutro; un radicado público sigue consultándose (control positivo, para no dar por bueno un arreglo que simplemente rompió la función). El test comprueba el SQL enviado y no el resultado del mock — con un mock, el «no encontrado» lo decide el mock y no el WHERE. Por qué: una superficie anónima sin filtro de clasificación convierte cualquier fuga del código en una lectura de material reservado — Impacto: la consulta pública recorta en la fuente, y la capacidad ciudadana sigue intacta.

  • feat(document,gateway,frontend): la cola de anulaciones no existía: el aprobador solo se enteraba de que tenía trabajo si alguien se lo decía Hueco de producto que la revisión dejó declarado. document-service exponía el estado de anulación por radicado (GET /documents/{id}/anulacion) y ningún endpoint que listara lo pendiente, así que /anulaciones obligaba a buscar radicado por radicado y lo explicaba en un banner permanente. Una cola de trabajo que obliga a adivinar su propio contenido no es una cola: una anulación pendiente bloquea el ciclo de vida de un documento oficial (Ley 594/2000 art. 28) y podía quedarse ahí indefinidamente sin que nadie la viera. Nuevo GET /api/v1/anulaciones — recurso propio, no una subruta de un documento: no es un atributo de un documento, es el trabajo pendiente de un rol, y el prefijo propio hace además determinista el ruteo del gateway sin depender de que una ruta literal se declare antes que /{document_id}. Gate PERM_PANU_CODI, el mismo que decidir: ofrecer una cola cuyos actos luego se rechazan es justo el defecto que el barrido negativo fue a buscar. Orden FIFO por solicitado_at — la solicitud más antigua es la más urgente, no la más reciente. Trae número, asunto y tipo del radicado, porque una cola que solo dijera «hay 7 pendientes» con sus UUID obligaría a abrir siete fichas. El punto delicado es el recuento. El no-read-up (RF-SEG-08) se aplica en la MISMA condición para las filas y para el total: filtrar solo las filas y contar sin filtrar diría «hay 7» mostrando 5, y esa diferencia es exactamente la existencia de dos solicitudes sobre radicados clasificados. La condición vive una sola vez (_VISIBLE_LISTA) y las dos consultas la comparten para que no puedan divergir. Verificado por mutación: quitar el filtro del COUNT pone el test en rojo. Frontend: la cola sustituye al banner, se carga en SSR y se refresca tras cada decisión —una fila ya resuelta que sigue en pantalla hasta recargar a mano es lo que vuelve inútil una cola—. El buscador no se retira: sigue siendo la vía directa cuando alguien llega con un número en la mano. Y tres estados que no se confunden entre sí: «no hay pendientes», «no se pudo consultar» (con el aviso de que no significa que no haya) y «sin permiso». Trampa propia del cambio, con su test y su mutación: una fila de la cola trae dos identificadores —id (la solicitud) y radicado_id—, y el detalle se consulta por el del radicado. Pasar la fila tal cual habría usado el equivocado sin que nada fallara a la vista: el proxy devolvería 404 y la pantalla diría «no tiene ninguna anulación registrada» sobre una solicitud que sí existe. Un guardián del repositorio cazó mi propio código: el proxy nuevo leía el sobre de error plano (cuerpo?.error) y el sobre de este proyecto va anidado — el defecto que una vez dejó un aviso diciendo «[object Object]». Corregido con codigoError(). 362 tests de document-service, 79 del gateway, 96 compartidos y 1 760 de frontend en verde; lint limpio. Verificado en el navegador que el 403 recorre la cadena entera (navegador → proxy BFF → gateway → servicio). Por qué: sin cola, el trabajo pendiente solo existía para quien ya sabía que existía — Impacto: /anulaciones deja de ser un buscador y pasa a ser una bandeja de trabajo.

  • fix(archive,shared): mensajes escritos en español y sin tildes — el guardián solo miraba que no estuvieran en inglés Pendiente de la revisión. Existía ya un censo compartido que verifica que ningún mensaje de error esté en inglés, y 26 mensajes lo pasaban limpiamente estando mal escritos: «No existe la version del codigo de serie», «Ya existe una version en tramite», «el acto del Comite». Todos en archive-service, en el circuito de convalidación de TRD y TVD — texto que lee un funcionario cuando algo le bloquea un instrumento archivístico. Corregidos los 26, con la parte mecánica hecha dentro de las cadenas de mensaje y protegiendo los marcadores {…}, porque un reemplazo a ciegas habría tocado {payload.version} y las columnas SQL. Lo que no se toca queda explícito: los nombres de campo (fecha_publicacion, justificacion_valoracion, acta_comite) y los literales de estado ('convalidada', "migrada") no llevan tilde. Las formas ambiguas —aplico/aplicó, cambio/cambió— se corrigieron leyéndolas, no por lista. Nuevo guardián junto a su gemelo (shared/tests/test_mensajes_en_espanol.py), con lista de admisión de errores: cada entrada es una forma que escrita así está mal siempre —-cion/-sion es de precisión inusual, porque toda palabra española con esa terminación lleva tilde sin excepción—. Excluye a propósito las ambiguas y declara lo que no demuestra: no prueba que el texto esté bien escrito, sino que no ha vuelto el subconjunto comprobable a máquina. Con control positivo (la frase real que tenía el servicio) y control negativo (la forma correcta y un nombre de campo no se marcan). Barrido equivalente sobre el texto visible del frontend: cinco candidatos, los cinco falsos positivos —invalida y publica son formas verbales correctas, y dos venían de código y comentarios—. El texto del frontend está bien escrito y no se añade guardián allí: uno que dispara con formas correctas entrena a ignorarlo, que es peor que no tenerlo. 763 tests de archive-service en verde, lint limpio, corrección verificada por mutación. Por qué: la lengua de trabajo del sistema es el español y las tildes son ortografía, no tipografía — Impacto: los mensajes del circuito de instrumentos se leen bien escritos, y el guardián cubre ya los dos ejes.

  • feat(infra,frontend): la pantalla de acceso — la única que no pasa por SvelteKit — salía en inglés y sin marca Pendiente que el usuario pidió el 2026-08-18 y que la revisión arrastraba. La página de inicio de sesión es la única del recorrido que no pasa por SvelteKit, así que se quedó fuera de todo el trabajo de diseño: salía con el tema por defecto de Keycloak —fondo poligonal gris, botón azul PatternFly, «Sign in to your account» y «Forgot Password?» en inglés— justo antes de la pantalla de Orpyca. Van seguidas, así que el contraste era máximo, y es la primera impresión que se lleva un funcionario. Nuevo tema infra/keycloak/themes/orpyca/login/: hereda de keycloak (parent=keycloak) y no copia ninguna plantilla .ftl — solo tokens y superficies, con los mismos valores del sistema de diseño del frontend. Es deliberado: una plantilla copiada se congela en la versión en que se copió y hay que re-auditarla en cada actualización, que es donde viven los arreglos de seguridad de las pantallas de identidad. Reparto isotipo/nombre según los dos límites del activo ya decididos (la marca denominativa está en negro degradado y desaparece sobre fondo oscuro), y foco visible propio, porque la accesibilidad no se hereda del color de marca. El idioma resultó ser dos mitades, y la evidente no bastaba. internationalizationEnabled + defaultLocale: es en el realm dejaron la pantalla igual de inglesa: defaultLocale es el respaldo para cuando el navegador no expresa preferencia, y siempre la expresa. La otra mitad la pone el frontend con ui_locales=es en la petición de autorización (OIDC Core §3.1.2.1) — la aplicación declara en qué lengua trabaja en vez de dejar que la negocie el navegador. Con test de contrato en e2e/public-pages.spec.js, porque es un parámetro que se pierde en un refactor sin que nada se rompa a la vista. Documentada además una trampa de operación: start-dev --import-realm importa solo si el realm NO existe, así que editar realm-dev.json no cambia nada sobre una instalación ya arrancada — hay que aplicarlo por la API de administración. El modo de fallo clásico de «cambié la configuración y el sistema siguió igual». El README de infra/keycloak/ trae el procedimiento. Verificado en pantalla y con la suite autenticada en verde tras el cambio (6/6): los tests localizan por los id del formulario, no por el nombre accesible, y por eso sobreviven a que la página cambie de idioma. Por qué: once servicios se tradujeron al español y la primera pantalla del sistema seguía en inglés — Impacto: acceso, olvido de contraseña y pantallas de error salen en español y con la marca.

  • test(e2e): reactivar la suite no bastaba: al ejecutarla por primera vez fallaron 4 de 6 Continuación tras B13. El commit anterior daba por entregado el tercer entregable del plan —reactivar frontend/e2e/authenticated/— habiendo retirado los frenos sin correr la suite ni una vez. Al ejecutarla contra la pila levantada, 4 de los 6 tests fallaron: eran esqueletos escritos contra una interfaz imaginada, no contra la real. Causa única de los cuatro: getByLabel(/contraseña|password/i) casa con dos elementos en el tema por defecto de Keycloak —el <input> y el botón de mostrar/ocultar, cuyo aria-label es «Show password»—, y Playwright falla en modo estricto. El inicio de sesión estaba copiado en tres archivos y solo una de las copias (asistente-dock.spec.js, la única que llevaba tiempo activa) usaba los localizadores buenos: los id del formulario de Keycloak, que son su contrato estable mientras el nombre accesible lo fija un tema de terceros que además sale en inglés. Extraído a _sesion.js, un solo sitio que sepa entrar. Los dos tests de bandeja daban por supuesta una tabla: /bandeja abre en Tablero, no en Lista, así que esperaban 60 s a una fila que no iba a existir. Y el botón de detalle lleva aria-label="Ver detalle de radicado {nº}", que sustituye al texto visible como nombre accesible — buena práctica de accesibilidad, y el motivo de que un localizador por el texto no encontrara nada. Reescritos para ejercer algo que valga: uno comprueba que el detalle abre en la misma vista (ADR-020 §2, cajón y no pestaña) y el otro que sobre una Entrada no se ofrece anular — la regla por TIPO documental, ortogonal al RBAC, que el backend defiende con 409 acto_no_aplica_al_tipo y no con 403. Ese segundo comprueba una ausencia, así que lleva aserción anti-vacuidad: primero exige que la zona de acciones exista («Responder» está siempre en una Entrada) para que el «no está» signifique «no se ofrece» y no «no había nada». Verificado por mutación: exigir la ausencia de un botón que sí está lo pone en rojo. El caso de credenciales inválidas también estaba mal: comprobaba toHaveURL(/\/login/), que daba falso verde porque Keycloak reintenta en su propia página y su ruta contiene «login-actions». Ahora comprueba lo que importa —que no se entra y que no queda sesión—. Estado verificado: 6 de 6 en verde (14 s) con credenciales; sin ellas, 5 saltan y corre la única que no necesita identidad. La suite de humo sigue en 6/6. Por qué: reactivar una suite sin correrla es media corrección, y es exactamente el defecto que esta revisión lleva quince bloques señalando — Impacto: los recorridos autenticados quedan ejecutables y ejecutados.

  • fix(frontend,8 servicios): el barrido negativo (B13): un 403 presentado como «no hay registros», y un error que acusaba de faltar un permiso que sí se tiene Último bloque de la revisión por navegador, ejecutado con el usuario consulta (permisos PERM_RADI + USUA_PERM_EXPEDIENTE, nivel CRUD 1, clearance 1). Cada comprobación es doble por diseño: un botón oculto no prueba nada, así que todo lo que la interfaz esconde se invocó además por el proxy BFF, sin pasar por ningún botón. Lo que aguanta, y es lo importante. El gate NO vive solo en el navegador: POST /expedientes/{id}/close y /transfer → 403; POST de plan PINAR y de serie TRD → 403 nombrando el permiso; /envios/api → 403. El eje de nivel CRUD es real en el backend (min_crud=3 en cerrar, transferir y todo el circuito de versiones de TRD), y la interfaz lo refleja: ni Cerrar ni Transferir ni el circuito de versiones se ofrecen. El no-read-up funciona con control positivo: buscar «certificacion» (nivel 1) da 4 resultados y «Tutela» (el radicado reclasificado a nivel 2 en B11) da 0; el acceso directo por número responde 404, no 403 — un 403 confirmaría que el documento existe. Defecto 1 — /envios mentía. El load capturaba el 403 del gateway en loadError, lo devolvía en data… y +page.svelte no lo mencionaba ni una vez. A quien no tiene PERM_RADI_SALIDA la pantalla le decía «No hay registros que coincidan con los criterios seleccionados»: afirmaba que la entidad no tiene envíos postales cuando lo que pasa es que no puede verlos. El mismo texto tapaba también un servidor caído. El idioma correcto ya estaba en /admin/colas, que nombra el permiso. Ahora el load conserva el estado además del texto —403 y 5xx son cosas distintas para quien mira— y la pantalla distingue las dos, con el matiz que importa: «Esto no significa que no haya envíos registrados». Guardián src/lib/error-de-carga-leido.test.js: todo campo de error que un +page.server.js devuelve tiene que aparecer citado en su +page.svelte; no juzga cómo se pinta, solo que no se tire. Defecto 2 — el 403 acusaba de faltar un permiso que el usuario tiene. Cerrar un expediente devolvía «Se requiere el permiso USUA_PERM_EXPEDIENTE» a un usuario que lo tiene, con nivel 1 en vez de 3. Faltar un permiso y tenerlo corto de nivel son dos problemas con dos soluciones distintas —conceder vs. subir el nivel de uno ya concedido—, y el mensaje mandaba al administrador a buscar una concesión que ya existía. El asiento de audit_log sí guardaba min_crud: el dato bueno estaba y el mensaje decía otra cosa. Corregido en los ocho require_permission del sistema con un ayudante compartido (nivel_efectivo + mensaje_denegacion); auth-service ya nombraba el nivel y ahora además distingue la causa, sin consulta extra porque ya tiene los niveles en memoria. El censo compartido (shared/tests/test_gate_distingue_nivel.py, que corre en CI y deriva la lista de services/*/ en vez de escribirla a mano) destapó un noveno sitio que no estaba en mi lista: el mensaje ciego vivía también en la capa de servicio de workflow, fuera de require_permission. Y correr las once suites destapó dos defectos de la propia corrección que un solo servicio no habría enseñado: la variable del llamante no se llama igual en los siete módulos (user_id en cuatro, actor en tres — NameError en tres servicios), y nivel_efectivo no validaba el tipo de lo que le devolvía la consulta, así que un valor no entero producía la frase «con nivel False» en la cara de un funcionario. bool es subclase de int en Python; ahora se exige un entero de verdad y cualquier otra cosa cae al mensaje conservador. Por qué: un error que no distingue la causa no dice qué hacer, que es lo único para lo que existe — Impacto: las dos pantallas dicen la verdad, y ambos patrones quedan bajo guardián. 1752 tests de frontend y las suites de los servicios tocados en verde; correcciones verificadas por mutación.

  • fix(frontend): cinco advertencias de actos irreversibles se leían en columnas, y el censo que las buscaba tenía un punto ciego Decimoquinto bloque de la revisión por navegador — B12, /admin/pinar y /perfil. El aviso que advierte de que aprobar un plan PINAR congela su contenido de forma permanente vivía en un <p> con display: flex. En un contenedor flex cada hijo directo se convierte en una columna: el icono, los tres trozos de texto y los dos <strong> se repartieron en seis columnas y la frase quedó ilegible. El cuadro de confirmación del propio acto irreversible estaba peor —cinco columnas de dos o tres palabras—, y es el último texto que alguien lee antes de congelar un instrumento archivístico. Ni svelte-check, ni eslint, ni prettier ven esto: el marcado es válido y el CSS también. Solo se ve mirando la pantalla. Cinco ocurrencias: las tres advertencias de los actos de ciclo de vida de PINAR (aprobar, ejecutar, cerrar), el aviso permanente del plan en borrador, y la de /perfil sobre quedarse sin poder revocar ni renovar la credencial de firma. El idioma correcto ya existía en el código (disp-danger__text, cert-drawer__legal-banner): envolver la prosa en un <span> para que el flex tenga dos hijos —icono y texto— en vez de seis. Estas pantallas lo olvidaron. Guardián: src/lib/prosa-en-flex.test.js censa los 90 componentes buscando la FORMA del defecto —un contenedor flex que aloja una frase en prosa con énfasis en línea—, con control positivo, control negativo y anti-vacuidad. Mide el texto que es hijo directo (profundidad 0), que es lo único que importa: la prosa dentro de un <span> es un solo hijo y se compone normal. El censo tuvo dos defectos propios, y el segundo valía más que el hallazgo. El primero: contaba «todo el texto sin etiquetas» y no distinguía el caso roto del arreglado. El segundo: leía las declaraciones de cada regla con [^{}]*, así que toda regla con un selector anidado le era invisible — y &__warning lo tiene. Con ese punto ciego el censo daba verde habiendo visto una de las cinco ocurrencias y ninguna de las tres de los actos irreversibles. Un censo con un punto ciego es peor que no tenerlo: aprueba en silencio. Verificado por mutación —quitar el <span> pone el censo en rojo; una primera mutación que solo renombraba la etiqueta no lo hacía y se descartó por no reproducir el defecto— y en pantalla, con el ciclo de PINAR ejecutado de verdad: crear → aprobar con acto administrativo, ambos con su asiento en audit_log. 1750 tests en verde, svelte-check 0/0. Por qué: una advertencia que no se puede leer no advierte de nada — Impacto: las cinco se leen de corrido, y el patrón no puede volver sin que un test lo diga. Corrección al plan de pruebas: PINAR estaba declarado como punto ciego («siempre saldrá vacío, no verificable de extremo a extremo»). Era falso: la pantalla crea planes, recorre sus cuatro estados y registra cada acto. Un punto ciego declarado y no comprobado es una pantalla que nadie mira.

  • fix(notification): el aviso que dice a una dependencia que tiene trabajo no se entregó nunca, ni una sola vez Decimocuarto bloque de la revisión por navegador — B12, /admin/notificaciones. El historial de envíos mostraba un patrón que no admitía otra lectura: los 25 workflow.step.created del tenant fallaron, el 100%, con 3 intentos cada uno, mientras los document.radicado.created salían. La diferencia estaba en el destinatario: Oficina Jurídica@orpycamcp.local frente a dependencia-902@orpycamcp.local. El aviso de asignación se dirigía a f"{to_dept}@orpycamcp.local", y to_dept es —por convención deliberada de todo el sistema, documentada en workflow_repository._own_scope_condition— el NOMBRE de la dependencia, texto libre de hasta 100 caracteres. Eso produce una parte local con espacios y tildes que ningún servidor SMTP sin SMTPUTF8 acepta; el propio registro lo decía: «An address containing non-ASCII characters was provided». No es un caso extremo, es la norma: casi todo nombre de dependencia de una entidad pública colombiana lleva espacio o tilde. La misma oficina recibía además dos direcciones distintas según el camino, y solo una era entregable. Corrección: app/services/buzones.py, el único sitio donde se construye una dirección. Resuelve nombre → dependencias.codigo del catálogo (mismo SELECT que ya valida el paso al crearlo) y emite dependencia-{codigo}@…, la dirección que el camino de radicación ya usaba — una dependencia tiene un buzón, no dos. Cae a la ventanilla única con aviso en el log cuando el nombre no resuelve: que un trámite oficial llegue al sitio equivocado es malo; que no llegue a ninguno es peor, y es lo que pasaba. El camino de radicación pasa por el mismo punto. buzon_destino es keyword-only y sin valor por defecto a propósito, y eso destapó un segundo punto de llamada: el reenvío desde la cola de errores (/admin/colas) reproducía el defecto, así que reintentar uno de los 25 avisos muertos volvía a mandarlo a Oficina Jurídica@… — la cola no se podía vaciar por esa vía. Un default habría dejado ese camino roto en silencio. Guardián: test_buzones.py censa el árbol buscando la FORMA del defecto —f-strings y concatenaciones cuyas partes literales contienen «@»— y exige que solo buzones.py componga direcciones. Persigue la construcción dinámica, no toda mención del dominio: una lista de sitios permitidos escrita a mano deja fuera lo que nadie recordó, que es como llegó aquí el defecto. Con control positivo y anti-vacuidad. Los tests que cubrían esto llevaban desde siempre en verde: sus fixtures usaban "juridica" y "001" —identificadores ASCII—, el único valor que no podía fallar. Ahora usan «Oficina Jurídica». Verificado de extremo a extremo con la pila levantada: radicar → distribuir → asignar produce workflow.step.created → dependencia-902@orpycamcp.local → sent, 1 intento, y el correo está en MailHog. 172 tests en verde, lint limpio, y la corrección verificada por mutación en sus dos puntos. Por qué: sin ese aviso, una dependencia solo se entera de que tiene un documento si entra a mirar — Impacto: la notificación de asignación funciona por primera vez.

  • docs(es),test(e2e): el plan de pruebas que no existía, y una suite apagada con tres frenos de los que solo uno significaba algo Entregables de la revisión por navegador. Hasta ahora, en todo el repositorio no había ningún plan de pruebas, checklist de QA ni guía de verificación manual: docs/es/pantallas.md era —y sigue siendo— un inventario funcional correcto, pero no dice qué está probado. La garantía del proyecto era de unidad y de contrato, no de uso. Nuevo docs/es/plan-pruebas.md: el recorrido completo B0–B13 con la forma de cada caso —seis campos, y el sexto es «fuera de pantalla», el que distingue el botón respondió de el acto ocurrió—, el orden de bloques justificado como dependencia de estado (alterarlo obliga a sembrar por SQL, que es lo que rompe la cadena de audit_log), el orden interno de los actos irreversibles con lo que cada uno inutiliza, y dos secciones que un plan honesto no puede omitir: dónde puede dar un falso verde (PINAR siempre vacío, 503 honestos que parecen averías, WORM/PDF-A inertes, avisos que se desvanecen) y lo que no cubre — de donde salen tres hallazgos de producto: PQRS ciudadana, verificación pública por código e ingesta IMAP existen en el backend y no hay forma de llegar a ellas. Reactivada frontend/e2e/authenticated/: estaba apagada con triple freno —testIgnore, test.describe.skip en cada archivo y credenciales ausentes—, y su propio README pedía «crea un segundo config sin esa exclusión» sin que nadie lo creara. Un test que exige editar su propia configuración para ejecutarse no se ejecuta nunca. Se retiran los dos frenos redundantes y queda el único que significa algo: sin E2E_USERNAME/E2E_PASSWORD se salta el caso que necesita identidad. Las credenciales siguen sin valor por defecto a propósito — un default funcional aquí acabaría commiteado y sería una cuenta conocida más. Por qué: un plan que solo puede ejecutar una persona se degrada en cuanto deja de ejecutarlo — Impacto: el recorrido queda reproducible por cualquiera, y pantallas.md deja de sugerir que inventariar es verificar.

  • fix(auth): el registro legal se defendía de que lo modificaran, no de que lo contaminaran Decimotercer bloque de la revisión por navegador — B12, la reverificación de la cadena de auditoría sobre todo lo que el plan había generado. El resultado de fondo es bueno: de los 972 asientos del tenant de demostración, 968 recomputan su hash correctamente y ninguno está genuinamente roto — las únicas cuatro filas no conformes son las fixtures de SQL crudo que se describen abajo. (Cifra corregida: una primera medición dio «946 de 951, 1 roto» usando una reimplementación del hash escrita a mano; la buena usa orpycamcp_common.audit.compute_hash, la misma función que escribe los asientos. No se reimplementa la cosa que se está verificando.) La garantía de integridad de Ley 594 queda probada con datos reales encadenados a lo largo de una revisión completa, no con mocks. Y la pantalla detecta la anomalía y se niega a declarar el registro fiable, que es exactamente lo que debe hacer. Lo que la reverificación destapó son 25 filas con hash = 'h1'…'h4', 'x1'…'x15' y prev_hash vacío, insertadas por SQL en el registro de los tenants demo y otra-entidad para probar la propia pantalla de auditoría. No es manipulación: es basura de prueba. Pero el daño no es reparable — el registro es append-only por ley, esas filas no se pueden borrar, y la cadena de demo queda rota desde ahí para siempre. La única rotura «real» que quedaba era la entrada legítima siguiente, que se encadenó sobre la última fixture: el mecanismo hizo lo correcto sobre datos incorrectos. Las tres capas de defensa existentes —REVOKE UPDATE,DELETE, trigger de fila, trigger de sentencia— cubren la amenaza clásica (modificar o borrar un asiento) y ninguna impedía añadir uno que no viniera de audit.append. Se añade la cuarta: chk_audit_log_hash_forma, que exige forma SHA-256 (64 hex) en hash y prev_hash. NOT VALID a propósito — las filas ya contaminadas no se pueden borrar, así que la restricción protege el futuro y deja en paz un pasado que es inmutable por diseño. Se declara lo que no cierra: no impide que alguien con acceso SQL inserte una fila con un hash bien formado pero falso; eso solo lo detecta la reverificación, que es el control de fondo. Lo que elimina es la contaminación accidental —datos de prueba y scripts improvisados—, que es la que ha ocurrido. La restricción cazó de inmediato a dos culpables en el propio repositorio: test_audit_partitions.py insertaba por SQL con hash = 'h'. Corregidos para usar un hash de la forma correcta; su propósito es el enrutado a la partición y su inmutabilidad, no el contenido del hash. Por qué: un registro que solo se protege contra la modificación deja abierta la puerta de la contaminación, y esa puerta ya se usó. Impacto: migración global auth-service/006_audit_log_hash_forma.sql. Validada contra PostgreSQL 15 real sobre la tabla PARTICIONADA: la restricción se propaga a las particiones y el control positivo confirma que un asiento legítimo sigue entrando. Cualquier script o test que inserte en audit_log sin pasar por orpycamcp_common.audit.append fallará a partir de ahora — y debe fallar.

  • fix(frontend): rechazar una transferencia nunca funcionó, y el listado no decía de qué expediente hablaba Duodécimo bloque de la revisión por navegador — B11-7, la decisión de recepción. La rama de rechazo estaba muerta. El backend exige motivo (TransferenciaRechazo, mínimo 3 caracteres, obligatorio) y el proxy mandaba observacion, opcional: todo rechazo devolvía 422 y ninguno llegó a ocurrir jamás. Cada mitad era correcta por su cuenta —el esquema, el proxy— y solo el par estaba roto, que es por lo que nadie lo vio. Y el nombre no era un detalle de nomenclatura: el backend hizo el motivo obligatorio a propósito —«antes se admitía un rechazo inmotivado, lo que dejaba el porqué sin registro reconstruible»— porque el rechazo es un estado TERMINAL y su motivo es el único insumo de subsanación para la oficina de origen; el frontend seguía ofreciendo exactamente lo que esa decisión venía a impedir, con el campo rotulado «opcional». Corregido en las tres capas: el proxy manda motivo y valida antes de llamar al gateway, la pantalla lo exige con marca de obligatorio y error en el propio campo, y la ayuda explica para qué sirve. El guardián no fija el literal —volvería a divergir el día que el backend renombre el campo—: lee los campos requeridos del esquema Pydantic y comprueba que el proxy los manda, con guarda anti-vacuidad por si la extracción se rompe. Y el listado seguía pintando tres UUID. La corrección anterior alcanzó el detalle y el acta, no la lista — que es la primera pantalla que se ve. Peor: lo hacía con t.expediente_id ?? t.expediente?.code, el identificador PRIMERO en la cadena de ??, así que el código no podía aparecer ni aunque llegara. La resolución vive ahora en un helper compartido que usan los dos caminos —el load, que va directo al gateway, y el proxy, por donde pasan la recarga tras crear y cada filtro—, porque resolverlo solo en el load dejaba sin etiqueta todo lo que no viniera en la carga inicial: el defecto reaparecía al filtrar. Una petición por expediente (nunca repetida), tope de 60, y un expediente que quien mira no puede leer se queda sin etiqueta — la etiqueta jamás revela lo que el control de acceso oculta. Por qué: dos mitades correctas pueden formar un par roto, y eso no lo caza ningún test que mire un solo lado. Impacto: sin migraciones. Reconstruir frontend. El proxy de rechazo acepta ahora motivo (antes observacion) y responde 400 si falta; el listado de transferencias incluye expediente_codigo/expediente_nombre por fila (aditivo).

  • fix(auth,archive,frontend): quien perdía el autenticador no podía firmar nunca más, y el error de firma acusaba al servicio equivocado Los dos hallazgos que la revisión por navegador dejó anotados al firmar el acta de transferencia (B11-6). El encierro. El TOTP es un factor de posesión y todas sus salidas exigían el propio factor: enroll y DELETE /me/totp piden el código actual o uno de recuperación, y los códigos de recuperación no sirven para firmar — /internal/totp/verify, que es la ruta por la que pasa sign-personal, solo clasifica un TOTP. Ningún administrador podía reponerle el segundo factor a otra persona: /me/totp/* es siempre sobre uno mismo. Quien perdía el teléfono y los códigos quedaba fuera de la firma personal para siempre, y la única salida era un UPDATE a mano en la base de datos — que para una entidad no es una salida, es la ausencia de una. Se añade POST /api/v1/auth/users/{user_id}/totp/revoke (USUA_PERM_ADMIN, motivo obligatorio): retira el factor de otra persona y anula sus códigos de recuperación en la misma transacción, dejándola lista para registrarlo ella misma desde «Mi perfil», con su acción en el drawer de /admin/usuarios. No devuelve ningún secreto ni enrola en nombre de nadie — quien pudiera enrolar por otro tendría de hecho su firma personal. No se puede usar sobre uno mismo (409): quien se restableciera el suyo podría retirárselo sin demostrar posesión del dispositivo y enrolar otro acto seguido, así que hacen falta dos personas. Y el asiento se atribuye al administrador, no a quien lo padece: _audit_event fijaba el actor al dueño de la credencial sin alternativa, lo cual es correcto en todo el auto-servicio pero aquí habría escrito que la persona se retiró el factor a sí misma —una atribución falsa en el único acto del sistema en que alguien reduce la protección de otro—. No se resolvió admitiendo códigos de recuperación al firmar: son un secreto de vida larga que se guarda en papel, y dejarlos firmar habría rebajado el segundo factor justo en el acto que da fe. Se repone el factor, no se degrada. El error que señalaba al inocente. signature_client.sign_personal() colapsaba en un mismo unavailable=True dos cosas muy distintas: que el canal no responda, y que responda con un 5xx. La pantalla decía «signature-service no está disponible» las dos veces que ocurrió de verdad, y las dos era falso — la causa estaba dos servicios más abajo (MinIO sin disco; y un CHECK que rechazaba el valor que el propio signature-service escribe). Ahora se separan: unavailable solo cuando nadie contestó (503, mismo código de error), y remote_failure cuando sí contestó y falló (502 firma_personal_fallo_remoto, con el detail remoto dentro del mensaje). Ambos siguen siendo fail-closed; lo que cambia es a quién manda a mirar. De paso, un test de auth-service que dependía del ambiente: test_activate_fails_closed_without_sub_ca_configured daba por vacía una ruta que settings lee del entorno, y se puso rojo solo porque la máquina montó su sub-CA de desarrollo. Ahora la fija él mismo. Por qué: un mecanismo de seguridad sin vía de recuperación acaba resolviéndose por fuera del sistema, y un mensaje de error que nombra al servicio equivocado cuesta más que no dar ninguno. Impacto: sin migraciones. Reconstruir auth-service, archive-service y frontend. POST /api/v1/auth/users/{id}/totp/revoke es nuevo; la firma personal del acta puede devolver ahora 502 además de 503 — quien discrimine por código debe contemplarlo.

  • fix(signature): la firma personal por-usuario nunca llegó a guardarse: el INSERT moría contra un CHECK que no admitía su propio valor Undécimo bloque de la revisión por navegador — B11-6, las tres firmas del acta de transferencia. PersonalSigningService fija nivel_conformidad='xades_personal_no_acreditado' desde que se implementó la firma personal (E17), y ninguna de las tres migraciones que han tocado firma_nivel_conformidad_check añadió ese valor: la 008 lo creó con los tres perfiles del sello institucional, la 013 le sumó LT/LTA, y la 015 solo lo repone si falta. El incremento añadió el productor y se olvidó del consumidor. El orden de las operaciones convierte el fallo en algo peor que un error: cuando el INSERT revienta, el payload ya se firmó, ya se selló con TSA y ya se subió a MinIO. Seis XML de acta firmada quedaron huérfanos en el bucket del tenant de demostración, sin ninguna fila que los reclame. Por qué no lo cazó ningún test: los nueve que usan ese literal —dos en signature-service, seis en archive-service— conducen el repositorio con la base simulada, y un mock acepta cualquier cadena. Una restricción de integridad no se prueba con mocks, igual que la atomicidad. Y por qué el mensaje mandaba al sitio equivocado: la pantalla decía «signature-service no está disponible para la firma personal», y era falso — signature-service estaba perfectamente. signature_client.sign_personal() colapsa cualquier 5xx en unavailable=True, así que la causa se perdía a dos servicios de distancia. El fail-closed es correcto; el diagnóstico que ofrece, no. La protección contra recaída no es la migración sino un censo: test_nivel_conformidad_admitido.py extrae con ast todo literal que el código pueda escribir en ese campo —hay cuatro sitios que lo fijan, no uno— y lo compara contra la lista del último DROP+ADD incondicional del CHECK, que es el que define el estado final (la 015 re-crea la lista antigua, pero guardada tras un IF NOT EXISTS, y sobre un tenant migrado no hace nada). Falla ruidoso si la extracción se rompe, para que no pase comparando el conjunto vacío contra todo. Por qué: un valor codificado en Python y una lista escrita en SQL pueden separarse sin que nada lo note, y aquí llevaban separados desde el primer día de la funcionalidad. Impacto: migración de tenant signature-service/016_firma_nivel_personal.sql — aplicarla antes de usar la firma personal. Sin cambios de API ni de esquema más allá del CHECK. Los XML huérfanos que haya dejado el defecto no se borran solos.

  • fix(frontend,archive): la firma personal del acta de transferencia era inalcanzable, y el módulo entero no dejaba mover una transferencia Décimo bloque de la revisión por navegador — B11-6, el traslado del expediente cerrado. Cinco defectos encadenados, cada uno suficiente por sí solo para dejar el módulo inservible. Una identidad confundida con otra. Cada persona tiene dos UUID: el sub de Keycloak y su auth_users.id dentro del tenant. El backend escribe SIEMPRE el segundo en las columnas de autoría (creada_por, enviada_por, decidida_por, solicitado_por, firmante_id); el frontend guardaba solo el primero y comparaba con él, así que toda pregunta de pertenencia daba siempre falso, y en silencio: el botón «Firmar como …» del acta no aparecía nunca —ni para quien había preparado, enviado y recibido el traslado—, ningún turno de una cadena de firma se reconocía como propio en /firmas, y el aviso de que uno no puede aprobar su propia anulación no llegaba a mostrarse. /auth/me ya resolvía el id interno («Replace keycloak sub with the internal id for coherence with X-User-Id») y el frontend lo descartaba al construir la sesión. Un guardián censa ahora las pantallas para que no vuelva a compararse un campo de autoría contra el sub. Un estado que no existe. La pantalla de transferencias comparaba contra "pendiente"; el ciclo real es preparada → enviada → recibida | rechazada, y así está en el servicio, en su docstring y en el CHECK de la tabla. Una transferencia nace preparada, así que ninguna acción aparecía jamás: ni Enviar, ni Rechazar, ni Recibir. El mismo estado inventado estaba en el filtro del listado y en la allowlist del proxy, que además rechazaba preparada —el filtro que sí sirve— con 422. Los estados se derivan ahora del backend y un test lo comprueba leyendo _VALID. Un radicado sin anexos contaba como acervo alterado. La verificación de fixity de la recepción resolvía tres situaciones con un solo booleano, y trataba «nunca hubo huella» igual que «la huella cambió». Un radicado sin adjuntos —un registro de metadatos, el caso normal en ventanilla— no tiene content_hash, así que ningún expediente de ese tipo se podía transferir, y el motivo que se devolvía era «la verificación de fixity del acervo falló»: una corrección que no había ocurrido. Ahora se distingue y se declara: los documentos sin huella salen del cómputo y se cuentan aparte, y el asiento de recepción registra documentos_verificados junto a documentos_sin_huella — una atestación que no dice de qué NO responde no es una atestación. Una huella que existe y cambia sigue bloqueando igual que antes. Y no había forma de corregir una ubicación. tipo_archivo es opcional al crear y decide qué transferencias admite la ubicación (Ley 594/2000 art. 23): una primaria va de «gestión» a «central». Cargar la topografía sin ese dato es lo natural cuando nada advierte, y dejaba el archivo físico sin poder transferir y sin arreglo posible, porque no existía ni endpoint ni pantalla de edición. Se añade PATCH /api/v1/ubicaciones/{id} (gate crud >= 3, asiento con el antes y el después de cada campo) y su modal; codigo y parent_id quedan fuera a propósito —el código está impreso en las signaturas de las unidades y el padre arrastraría toda la rama—. El formulario de alta advierte ahora la consecuencia de dejar el tipo vacío. Más el formulario que pedía tres UUID a mano (expediente y las dos ubicaciones), el mismo defecto ya corregido en /archivo-fisico; y el acta de entrega —un documento con valor probatorio— mostraba esos identificadores donde deben ir EXP-2026-0020 y DEP-001 → SED-001. Por qué: una capacidad construida que ninguna pantalla puede alcanzar equivale a no tenerla, y cuesta más porque nadie la busca. Impacto: sin migraciones. Reconstruir archive-service y frontend. POST /api/v1/ubicaciones/{id} es nuevo (PATCH); IndiceFixityResult gana documentos_sin_huella e IndiceVerifyDoc gana sin_huella_declarada (aditivos); el 409 de recepción solo se emite ya por huellas declaradas que discrepan. Verificado en el navegador de punta a punta: ubicación corregida a «gestión» desde la pantalla —el acto que era imposible—, transferencia primaria creada, enviada y recibida con su acta congelada (índice v4, sello XAdES-T), y los tres botones de firma personal visibles por primera vez. El sistema declara además el auto-traslado: «la misma persona envió y recibió — sin control dual». Lección de método: uno de mis tests no discriminaba el defecto de reactividad que creí haber encontrado — con los datos presentes desde el primer render, el fallo es invisible. En vez de dar la corrección por buena, se reescribió el comentario para no afirmar un fallo no observado y se añadió el caso que sí lo distingue (datos que llegan después). Afirmar en un comentario una corrección que no se ha probado es exactamente el defecto que este bloque encontró tres veces en el código ajeno.

  • fix(archive,document,frontend): un expediente con el índice firmado no se podía cerrar nunca, y quien pedía una anulación podía aprobársela él mismo Noveno bloque de la revisión por navegador — los actos irreversibles (B11), ejecutados de verdad sobre tenant_demo. Cerrar un expediente era imposible si su índice ya estaba firmado. Firmar el índice electrónico con el expediente todavía abierto es un camino que la propia interfaz ofrece. Quien lo usaba se quedaba encerrado: close_expediente transicionaba el expediente a closed, llamaba a regenerate para emitir el índice definitivo, chocaba con el guard de inmutabilidad del índice firmado y la transacción revertía entera. El expediente volvía a open y el único rastro era un asiento archive.expediente_close_failed cuyo motivo, expediente_closed, decía justo lo contrario de su estado real (status_actual: open). Cerrar es el acto central del ciclo de vida (Ley 594/2000) y sin él tampoco hay transferencia ni disposición final. Lo más caro: el código ya describía el problema — el comentario del llamante decía «el hueco que este parche cierra» sobre un parche que no lo cerraba, porque assert_open=False levanta el guard del estado del expediente y no el del índice, que son guards distintos. Tercera aparición en este proyecto de un comentario que afirma una corrección inexistente, y por eso la frase se borra además de arreglar el código. El guard sigue vivo para todo lo demás: fuera del cierre, regenerar sobre un índice firmado sigue siendo 409. Quien solicitaba una anulación podía aprobarla él mismo. La pantalla de solicitud promete «aprobación posterior por un supervisor»; nadie hacía cumplir esa promesa. En la revisión, el mismo usuario solicitó y aprobó seguido, y el radicado quedó anulado sin que ningún tercero lo mirara — un acto irreversible que retira de la circulación un documento oficial. La comprobación no existía en ninguna capa, y los tres tests de aprobación del servicio la daban por buena porque resolvían con el mismo usuario que figuraba como solicitante. Ahora el backend responde 409 aprobacion_del_propio_solicitante y la pantalla lo declara antes, nombrando la vía que sí queda abierta: rechazar la propia solicitud (que es desistir) se permite a propósito, porque bloquearlo dejaría solicitudes vivas que nadie puede retirar. Clasificar un radicado era imposible siguiendo la pantalla. El fundamento jurídico se ofrecía como «(opcional)» y el backend lo exige a partir de Reservada (Ley 1712/2014 art. 19/28) — 422 en cada intento. Y el aviso genérico no dejaba averiguar qué faltaba: los 422 de validación llegan en el formato de lista de FastAPI, que el traductor de errores del frontend no reconocía. Ahora se muestran los mensajes que este proyecto redactó (los que Pydantic marca con Value error,), y los automáticos de la biblioteca —«invalid uuid», en inglés y sobre un campo interno— siguen cayendo al texto de la pantalla. El índice electrónico mostraba tres columnas vacías y ocultaba el hueco trazable. La tabla leía fecha_archivado, y el contrato lo llama fecha_incorporacion (alineado con <fechaIncorporacion> del XML probatorio): la columna salía vacía en todas las filas y el «—» la hacía pasar por un dato no declarado. Es el mismo cruce de esquemas parecidos que ya se corrigió en la foliación, así que la corrección de fondo es el @typedef que faltaba: sin él, leer un campo inexistente no falla en ningún sitio. Además, el documento excluido se listaba igual que uno vigente —el hueco trazable dejaba de serlo— y la cabecera decía «1 doc(s)» sobre una tabla de dos filas, porque num_documentos cuenta solo vigentes. Y el aviso de un acto irreversible salía cortado. El diálogo que confirma la derogación de una TRD se renderiza dentro de un contenedor con white-space: nowrap —puesto para que no se partan los botones de una fila— y lo heredaba: el párrafo que explica la consecuencia se extendía 1361 px dentro de un cuadro de 400, con una barra horizontal que había que arrastrar para leerlo. Quien confirmaba sin arrastrarla decidía sin haber leído qué hacía. Se corrige en el propio Modal, que ahora no hereda el contexto tipográfico de donde se monte, porque el problema es de clase y no de sitio. Un resultado de búsqueda no llevaba a su radicado. «Ver en bandeja» apuntaba a /bandeja a secas: quien buscaba un documento aterrizaba en la bandeja de su dependencia, donde ese radicado casi nunca está, sin ningún aviso — mientras el aria-label prometía «ver radicado X». Ahora /bandeja?radicado=<id> abre su detalle, y el parámetro se lee en un bloque reactivo y no en onMount, porque con onMount el enlace solo funcionaba la primera vez (ir de un radicado a otro dejaba en pantalla el anterior — la primera versión de esta misma corrección tenía ese defecto, encontrado navegando). Por qué: los actos que no se deshacen son exactamente aquellos en los que la pantalla no puede equivocarse ni callar. Impacto: sin migraciones. Reconstruir archive-service, document-service y frontend. POST /expedientes/{id}/close funciona ahora en el caso que antes revertía; POST /documents/{id}/anulacion/aprobar puede responder 409 aprobacion_del_propio_solicitante; el 409 de regenerar un índice firmado pasa a llamarse indice_firmado_inmutable (antes expediente_closed, que describía otra cosa). Verificado en el navegador, acto por acto y con su asiento en audit_log: radicado reclasificado a Reservada con su acto motivado (y aparecido en el índice de clasificados de la Ley 1712, content-free), radicado publicado de oficio con fundamento, anulación de una Salida solicitada y resuelta, TRD v2 aprobada y convalidada —lo que derogó la v1 en el mismo acto— y expediente cerrado con su índice definitivo regenerado. Dos comprobaciones que confirmaron lo contrario de lo que yo esperaba: la anulación de una Entrada responde 409 acto_no_aplica_al_tipo con la vía alternativa escrita, tal como se diseñó; y la derogación de una TRD con expedientes abiertos se bloquea con un 409 que explica cómo proceder. Ninguna de las dos era un defecto — y el «no pasa nada al pulsar» que creí ver en la segunda era mío: el aviso aparecía y yo lo miraba cuatro segundos tarde.

  • fix(gateway,document,archive,frontend): la carga masiva nunca ha funcionado, y el gateway se comía el conteo de lo exportado Octavo bloque de la revisión por navegador — interoperabilidad (E11). El round-trip completo (exportar → importar) resultó correcto: paquete ZIP con los seis radicados, esquema JSON Schema, manifiesto, anexo y fixity verificada 9/9; reimportarlo omitió los seis por número duplicado sin sobreescribir nada, que es exactamente lo que promete. Lo que no funcionaba era todo lo demás. La carga masiva de documentos no creó jamás un radicado. El lote se quedaba en «Pendiente 0/2 procesados» para siempre. El router encolaba el trabajo y lanzaba asyncio.create_task(...) pasándole la conexión de la petición — la misma que seguía ocupada terminando el handler y que se devuelve al pool en cuanto se responde. Con Postgres real la tarea moría en el acto (InterfaceError: cannot perform operation: another operation is in progress) y, como nadie recoge la excepción de una tarea suelta, no se enteraba nadie. El gemelo de archive-service ya tenía la corrección y la advertencia escrita en su propio docstring; document-service no la aplicó. Con la conexión arreglada aparecieron dos defectos más, apilados detrás: pages es NOT NULL DEFAULT 0 y el lote insertaba el null que el formulario manda cuando el campo —«(opcional)»— se deja vacío, así que todo ítem sin páginas violaba la restricción; y el INSERT omitía tenant_code y registered_by, las dos columnas que dicen de quién es el radicado y quién lo produjo. Tres defectos en serie, ninguno detectable con los tests que había: con una conexión simulada nada de esto ocurre — un mock atiende dos operaciones a la vez sin protestar y no tiene restricciones que violar. El gateway descartaba el conteo de la exportación. /admin/interoperabilidad anunciaba «— radicado(s) exportado(s)» sobre un paquete de seis. X-Export-Count, X-Export-Excluded y X-Export-UUID no estaban en FORWARDED_RESPONSE_HEADERS: el ZIP viaja como cuerpo, así que esas cabeceras son lo único que dice cuánto se exportó y cuántos radicados recortó el clearance de quien exporta (Ley 1712/2014, arts. 18-19). Es la tercera vez que este proyecto pierde una cabecera propia por olvidarla en una lista, después de X-Total-Count y X-Truncated, así que la corrección no es añadir tres nombres: es un guardián que censa los once servicios y exige que toda cabecera X-… esté decidida — o cruza, o se declara por qué no. Y un aviso legal se leía partido en tres columnas. El paso que confirma una importación —una escritura oficial— tenía el contenedor en display: flex con el texto suelto y el nombre del fichero en <strong>: flex convierte cada nodo suelto en una columna, así que la frase se rompía con el nombre del paquete flotando en medio. El guardián que se escribió para cerrarlo destapó que no era un caso aislado sino trece, entre ellos los avisos de derogar una TRD, convalidarla, la disposición final, la publicación de oficio y revertir una asignación: precisamente los que preceden a actos que no se deshacen. Corregidos los trece. Y el panel de progreso remataba la pantalla: pintaba «Error #1 null» en cada ítem recién encolado —todo lo que no fuera success era «Error»— mientras el contador de al lado, leyendo el mismo objeto, decía «0 con error». Y la carga masiva de expedientes creaba expedientes vacíos. El formulario pide «Números de radicado a vincular»; el proxy los mandaba en tracking_numbers, que en el contrato de archive-service es una lista de etiquetas paralela a radicado_ids (E15 perfil v4, R1), no una forma de referirse a un radicado. Sin radicado_ids, no se vinculaba nada — y el panel informaba «Completado · 0 con error» sobre un expediente sin un solo documento dentro. El docstring del proxy afirmaba que «archive-service resuelve ambos»: era falso, y esa frase es parte del defecto, así que se borra además de corregir el código. Ahora el proxy resuelve los números a UUID (radicadoRef.js, hermano de expedienteRef.js) y manda las dos listas alineadas por índice; un número que no resuelve —porque no existe o porque excede el clearance de quien opera— detiene el lote y se nombra, en vez de descartarse en silencio. El contraste quedó en la base de datos: EXP-2026-0021, creado antes del arreglo, sin radicados; EXP-2026-0022, después, con el suyo. Por qué: una capacidad que informa de que está trabajando, y no está trabajando, es peor que una que falla — nadie va a buscar el fallo. Impacto: sin migraciones. Reconstruir api-gateway, document-service, archive-service y frontend. POST /api/v1/batch/documents sigue devolviendo 202 con el mismo contrato; lo que cambia es que ahora el trabajo ocurre, y que un lote que revienta queda failed con su motivo en vez de «pendiente» para siempre. Verificado en el navegador: exportación de 6 radicados con fixity comprobada fuera del sistema, round-trip de importación con los 6 omitidos correctamente, y la primera carga masiva que termina — 2026-DEMO-E-000017, «Completado 1/1 · 0 con error», con su tenant_code y su autor en la base. Lección de método: mi primer test de integración pasaba en verde sin cubrir el defecto principal — era yo quien llamaba al servicio con la conexión buena, así que comprobaba el arreglo, no el error. El defecto vivía en el router, y hasta que no hubo un test sobre el router (verificado por mutación) no había nada que impidiera reintroducirlo.

  • fix(frontend,document,archive): la búsqueda no encontraba nada — ni lo que existe, ni lo que se escribe sin tildes Séptimo bloque de la revisión por navegador. /busqueda es la pantalla más usada de un sistema de gestión documental, y no funcionaba. Buscar desde la propia pantalla devolvía siempre «Sin resultados». El formulario navega con goto() a la misma ruta con otros parámetros: SvelteKit reutiliza el componente y solo sustituye data. La pantalla hacía let results = data.results ?? [] —una asignación de una sola vez—, así que el valor quedaba congelado en el del primer render. El gateway respondía 200 con las tres coincidencias correctas y la pantalla decía que no había ninguna. Solo funcionaba recargando la página entera con la consulta ya puesta en la URL, que no es como nadie usa un buscador: quien entra por el menú y escribe nunca ve un resultado. Es la segunda aparición de este patrón —la primera fue el panel de /admin/interoperabilidad—; la bandeja lo tenía resuelto y su comentario explica cómo, resolviendo el dato con un fetch al proxy en vez de depender del load. Y buscar «certificacion» devolvía cero mientras «certificación» devolvía tres. El ADR-014 decidió usar unaccent —«normalización de acentos para FTS en español»— y la extensión se instala desde el primer día; nunca llegó a usarse: el search_vector y la consulta seguían con la configuración spanish, que distingue acentos. En un sistema que usan funcionarios colombianos escribiendo deprisa, escribir sin tildes no es un caso exótico, es el caso normal — y el fallo es mudo, porque «Sin resultados» es indistinguible de «no existe». archive-service además lo declaraba al revés («sin unaccent para no depender de extensiones»), una razón que no se sostiene sobre la misma base de datos donde document ya las instala, y cuyo único efecto era que el mismo texto diera resultados distintos según la pantalla. Ahora hay una configuración public.spanish_unaccent y las migraciones recrean la columna generada: el índice y la consulta se mueven juntos o no sirve de nada moverlos: un índice viejo con acentos no encuentra lo que la consulta nueva pide. Y las pestañas no ocultaban nada. En /busqueda, /reportes y /admin/preservacion los tres paneles se pintaban a la vez, apilados: el atributo hidden lo oculta la hoja del navegador con especificidad de elemento, y un display: flex de clase gana esa cascada. La navegación por pestañas era decorativa. Para quien usa un lector de pantalla era peor, porque aria-selected y tabindex sí decían la verdad mientras el contenido de las tres seguía en el árbol de accesibilidad. Se arregla una vez en el CSS global —el único !important del proyecto, y aquí es exactamente lo que hace falta—, no panel por panel: así la próxima pestaña nace funcionando. Por qué: una búsqueda que no encuentra lo que hay es peor que no tener búsqueda — quien la usa concluye que el documento no existe. Impacto: con migraciones — document-service global/002 + tenant/020, archive-service tenant/043. Recrean la columna search_vector y su índice GIN en radicados y expedientes, recalculando el índice de cada fila; en un archivo grande esa migración tarda. Reconstruir document-service, archive-service y frontend. Verificado en el navegador: buscar «certificacion laboral» sin tildes y sin recargar devuelve los tres radicados. Las migraciones se validaron contra Postgres real —cuatro tests de integración, idempotencia comprobada y provisión de un tenant nuevo desde cero—, porque una columna generada y una configuración de texto no se prueban con mocks. Lección de la verificación por mutación: uno de los tests de integración sigue en verde con la corrección a medias —si solo se arregla la consulta y no el índice—, porque el texto guardado ya venía sin acento. Queda anotado en el propio test: el que mide si las dos mitades se movieron es otro, y confundirlos habría dado por buena media migración.

  • fix(archive,frontend): el archivo físico no se podía usar desde el navegador — ocho campos pedían un UUID que ninguna pantalla muestra Sexto bloque de la revisión por navegador. /archivo-fisico gobierna dónde está el papel: la topografía del depósito, las cajas, los préstamos y el FUID —el inventario que la entidad entrega al Archivo General—. Estaba inoperable de principio a fin, y por seis defectos que se refuerzan entre sí. Ocho campos de una sola pantalla exigían escribir un UUID a mano: la ubicación superior, la ubicación de una unidad nueva, el filtro de unidades, el expediente a vincular, el expediente a localizar, la unidad a prestar y los dos filtros del FUID. Ninguna vista del sistema muestra esos identificadores ni deja copiarlos, así que construir la jerarquía de un depósito, prestar una caja o consultar el inventario exigía abrir la base de datos. Ahora la ubicación y la unidad se eligen de un desplegable —con su ruta completa, SED-001 / DEP-001 · Depósito Central 1, porque un código suelto no dice dónde está—, y el expediente se refiere por su número (EXP-2026-0020), que es lo que está impreso en la carátula. La resolución exige coincidencia exacta: la búsqueda es de texto completo y devuelve parecidos por definición, y vincular una carpeta a una caja es un acto sobre un expediente concreto. El árbol de ubicaciones no era un árbol. La pantalla pedía GET /ubicaciones?activo=true creyendo —lo decía su comentario— que sin parent_id el backend devuelve las raíces. No: sin parent_id no filtra por padre, que es lo natural en un listado. Cada depósito, estante y gaveta aparecía también en el primer nivel, y con un archivo real eso son miles de filas planas donde debería haber una sede. Se añade solo_raiz, que dice lo que hace; pedirlo junto con parent_id es una petición sin significado y responde 422 en vez de elegir una mitad en silencio. Tres pantallas leían campos que el contrato no tiene. Localizar un expediente mostraba «tipo de unidad» y «código de unidad» vacíos; el listado de préstamos no decía qué unidad falta del estante —que es lo único que se consulta en un préstamo—; y el FUID tenía cuatro de sus ocho columnas en blanco: código de expediente, serie, unidad y folios. En los tres casos el código intentaba dos o tres nombres encadenados (row.serie ?? row.trd_serie?.nombre ?? "—"), que es la firma de quien nunca vio la respuesta: el ?? "—" convierte el error en «dato ausente», mucho más difícil de detectar que un «undefined». El backend ya traía casi todo; se le añaden tipo/codigo a la localización y el código de la unidad al préstamo y al FUID. La tabla del FUID pasa de ocho columnas a diez: fechas extremas y frecuencia de consulta las exige el Acuerdo AGN 042/2002, el backend las calcula —la frecuencia la deriva de la pista de acceso real, con un LATERAL sobre audit_log— y la pantalla no las mostraba. El XML descargable, que es el instrumento con valor legal, siempre estuvo completo: el defecto era de la vista, pero la vista es donde el archivista revisa el inventario antes de exportarlo. Y una fecha se mostraba un día antes de la pactada. Un préstamo con devolución el 19 aparecía como «18/09/2026». new Date("2026-09-19") interpreta una fecha SIN hora como medianoche UTC, y formatearla en la zona del navegador la retrocede en Colombia (UTC−5). El error es sistemático y mudo: en el país al que va dirigido el sistema, siempre está mal, y no hay nada en pantalla que lo delate. Estaba copiado en diecisiete pantallas —entre ellas los plazos de retención, donde un día de menos no es estética sino un vencimiento equivocado—, así que la corrección no fue arreglar una: el criterio se muda a $lib/utils/fecha.js y un guardián impide que vuelva a escribirse suelto, con exención local y declarada para el único caso legítimo. Corregir una pantalla de diecisiete habría dejado nacer la decimoctava; ya pasó en esta misma revisión con el desplegable de series TRD. Por qué: un módulo que solo se puede operar con acceso a la base de datos no está construido, está esbozado. Impacto: sin migraciones. Reconstruir archive-service y frontend. GET /ubicaciones acepta solo_raiz; UnidadDeExpediente, PrestamoResponse y FuidRow ganan campos (aditivo). Verificado en el navegador de punta a punta: jerarquía sede → depósito → estante creada sin tocar la base de datos, caja creada y vinculada a un expediente por su número, préstamo registrado y devuelto, FUID consultado con sus diez columnas — y cada acto comprobado también en audit_log y en movimiento_fisico (RETIRADO → DEVUELTO). Dos lecciones propias, las dos cazadas por la verificación: un test mío del CTE no discriminaba —el propio CTE de la ruta contiene parent_id IS NULL, así que la aserción daba verde aunque solo_raiz no hiciera nada—, y otro repetía el error de leer un diccionario escrito por mí en vez del SQL enviado: quitar dos columnas del SELECT dejaba la suite entera en verde. Y Postgres real cazó lo que los mocks no pueden: el CTE recursivo abortaba por tipos (varchar(30) frente a varchar), un fallo que solo aparece ejecutando SQL de verdad.

  • fix(infra): Keycloak dejó de arrancar por un comentario dentro del JSON del realm — y con él, todo el sistema La pila se levantó limpia para continuar la revisión por navegador y el servidor de identidad no arrancó. Causa: realm-dev.json llevaba una clave _comentario_sesiones que explicaba —bien, y con razones— por qué la inactividad de sesión se sube a 8 h en desarrollo. Pero el JSON de un realm se deserializa contra RealmRepresentation con Jackson, y cualquier propiedad que no conozca aborta la importación con UnrecognizedPropertyException. Como --import-realm corre en el arranque, el contenedor no levanta; y como Keycloak es la puerta de entrada, no hay pantalla que muestre el error: el sistema entero queda inaccesible con un ERR_CONNECTION_REFUSED que no apunta a nada. Lo que hace este defecto peligroso es que es latente: se introdujo tres commits atrás y no rompió nada mientras el contenedor no se reiniciara. El daño aparece en el arranque siguiente, que es exactamente cuando nadie lo está mirando. realm-prod.json.example arrastraba el mismo comentario, veintitrés líneas, y ahí habría reventado el primer despliegue de una entidad. La prosa no se pierde: se muda a infra/keycloak/README.md, que es donde no rompe nada, con la explicación de las sesiones y la de la plantilla de producción íntegras. shared/tests/test_realm_keycloak_importable.py vigila que no vuelva a entrar — recorriendo el documento en profundidad, porque la misma clave escrita un nivel más abajo, dentro de un cliente, tumba el arranque igual. Por qué: un comentario en un formato que no admite comentarios no documenta, apaga el sistema. Impacto: sin migraciones. docker compose up -d keycloak. El realm ya importado en la base de datos no cambia.

  • fix(storage,signature,archive,document,frontend,scripts): el índice firmado no se podía guardar y un envío postal no se podía registrar — dos funciones centrales, bloqueadas de punta a punta Continuación de la revisión por navegador, ahora sobre firma electrónica y envíos postales. Los dos bloques estaban rotos hasta el final, y en los dos el sistema tenía ya el dato o el camino correcto a un paso de donde hacía falta. El índice electrónico firmado no se podía almacenar. signature-service lo subía por /api/v1/storage/upload, que es el endpoint de los anexos que sube una persona y aplica la lista blanca de RF-DIG-04 — donde no está application/xml. La firma XAdES se producía entera, con su sello de tiempo, y se perdía al guardarla: 415, el índice quedaba pendiente_firma para siempre y ningún expediente podía cerrarse con índice firmado. La salida NO fue ensanchar la lista de anexos —eso habría permitido a cualquiera subir XML para resolver un problema que no es de los anexos—, sino separar lo que son dos cosas distintas: un endpoint /internal/artefacto sellado con X-Internal-Token (el gateway no lo enruta) y su propia lista, allowed_system_mime_types. Hay un test gemelo que comprueba que la puerta de los anexos sigue rechazando XML: sin él, el atajo habría pasado en verde. Registrar un envío postal era imposible desde la interfaz. El formulario pedía el UUID del radicado y su ayuda decía «Disponible en el detalle del radicado en la Bandeja». Ese detalle no lo muestra ni deja copiarlo: había que sacar el identificador de la base de datos. La instrucción falsa es lo peor del defecto — hace perder media hora antes de sospechar del formulario. Ahora se admite el número de radicado, que es lo que la persona tiene delante, y el servidor lo resuelve; el UUID se sigue aceptando para integraciones. La resolución exige coincidencia exacta: correo físico no se manda por parecido. El envío mostraba el UUID donde va el número, en el detalle y —peor— en la planilla que se imprime y se entrega al operador postal, donde ese identificador no le sirve a nadie. Las CUATRO consultas del repositorio ya hacían JOIN radicados (lo exige el no-read-up, RF-SEG-08) y no se llevaban la columna. La causal de devolución se perdía en un texto libre. El webhook del operador postal SÍ alimentaba motivo_devolucion; la devolución que registra una persona —el camino habitual— la embebía en descripcion y dejaba el campo NULL, así que no había forma de reportar por qué se devuelven los envíos aunque la causal se eligiera de una lista. El proxy lo declaraba «roadmap»: el campo existía desde el webhook, lo que faltaba era pasarlo por el camino manual. El desafío de firma mentía a quien no está en la cadena. Pedir el código para firmar respondía «no es el turno de este actor» también a quien no es firmante, y a ese le afirma algo falso: no espera turno, nunca le va a llegar. El colapso no protegía nada, porque POST /sign sobre esa misma solicitud ya distingue los dos casos. Un colapso que solo existe en uno de los dos caminos no es una defensa, es una incoherencia. Los guiones de material criptográfico generaban lo que el servicio no puede leer. gen-dev-signing-cert.sh y gen-dev-tsa-cert.sh dejaban la clave en 600 con el uid del host, y los contenedores corren como uid 999: PermissionError → 500 → índice pendiente_firma, sin que nada explicara la causa. El par _dev/ —auto-firmado y de pruebas por definición— queda ahora legible y el guion lo dice; el de un tenant concreto sigue en 600, porque ahí lo correcto es ajustar la propiedad del fichero, no abrir la clave. La CA no se toca: no se monta en ningún contenedor. Y archive-service descartaba el motivo del fallo de firma. Con «HTTP 503» a secas, «falta el sello», «falta la TSA» y «storage rechaza el formato» son indistinguibles y tienen tres remedios distintos. Registrar el cuerpo del error hizo aparecer el diagnóstico en el primer intento: esta corrección de una línea se pagó tres veces en media hora. Por qué: son defectos que no se ven hasta que alguien intenta terminar el trámite. Impacto: sin migraciones. Reconstruir storage-service, signature-service, archive-service, document-service y frontend. Para firmar en desarrollo hacen falta ./scripts/gen-dev-signing-cert.sh y ./scripts/gen-dev-tsa-cert.sh. Verificado en el navegador de punta a punta: índice firmado en XAdES-T con firma válida y acreditado=false declarado, y envío postal recorrido entero (registrar → en tránsito → devolución con causal), todo comprobado también en base de datos y en audit_log. Una lección propia, cazada por la verificación por mutación: el primer test del número de radicado SOBREVIVIÓ. Construía la respuesta desde un diccionario escrito por mí, así que comprobaba que el esquema acepta el campo, no que la consulta lo traiga — se tiró y se reescribió inspeccionando el SQL que se envía. Queda anotado, sin tocar: no hay forma de crear una cadena de firma desde ninguna pantalla (el backend expone POST /signature/requests y el gateway lo enruta), y el índice se genera sin huella por documento (E15 T-08, ya declarado como pendiente en el código), lo que hace que todo índice firmado desde la interfaz se autodeclare inconsistente.

  • fix(frontend,archive,scripts): la revisión por navegador llega a expedientes — y el dato correcto estaba ahí, sin que la pantalla lo leyera Seis defectos encontrados recorriendo el sistema como lo recorre un funcionario. Cinco comparten la misma forma, que conviene nombrar: el sistema ya tenía el dato bueno y la pantalla mostraba otro. No son fallos de cálculo ni de permisos; son de lectura, y por eso ninguna prueba unitaria los veía. El botón de salir no hacía nada. La CSP declaraba form-action 'self' con el comentario «el logout y los formularios envían solo a este origen» — falso justamente del logout, que termina en el end_session_endpoint de Keycloak. Chrome aplica esa directiva a toda la cadena de redirecciones del envío y bloqueaba el salto («Sending form data to … violates … form-action 'self'», visible solo en la consola del navegador). Y el fallo era peor que inerte: el servidor SÍ había borrado ya las cookies, así que la sesión local quedaba cerrada, la de Keycloak abierta y la pantalla mostrando al usuario dentro hasta que algo la recargara. El origen se añade en runtime, no en svelte.config.js: la URL pública de Keycloak es $env/dynamic/public y un operador la cambia en el compose sin reconstruir; una CSP horneada en el build apuntaría al origen viejo y rompería el cierre de sesión otra vez, en producción y en silencio. La foliación se mostraba como «undefined». La pestaña leía los campos de IndiceItemResponse —el índice electrónico, que tiene su propia pestaña— sobre la respuesta de GET /expedientes/{id}/foliado, que es ExpedienteIndice (folio, radicado_id, tracking_number, added_at). Dos esquemas parecidos del mismo servicio, cruzados: ninguno de los seis campos leídos existía, así que la foliación —el orden que da fe de cómo está compuesto el expediente— salía vacía, con tres columnas de guiones que ese endpoint no puede llenar y una clave de {#each} que era undefined repetido en todas las filas. El proxy BFF ya documentaba el contrato bueno. El paquete de entrega afirmaba una firma que no existe. LEEME.txt decía «Indice electronico FIRMADO incluido: si» leyendo una variable que solo significa «el XML se pudo incluir». El propio código sabe que puede no estarlo —su comentario dice «un expediente sin índice firmado (aún abierto) se exporta igual»—, y aun así el paquete lo afirmaba. Quien recibe una copia de consulta lee esa línea como garantía de autenticidad: es un defecto probatorio, no de redacción. Ahora son dos hechos separados y cada uno dice la verdad. Al añadir la consulta del estado, un test que ya existía enseñó que except HTTPException no bastaba: cualquier otro fallo tumbaba el export entero por un dato accesorio del LEEME. La serie TRD se mostraba como UUID en la ficha del expediente, mientras el servidor ya resolvía su nombre en una faceta dedicada cuyo comentario dice para qué: «no solo un UUID crudo». Ese dato solo llegaba al drawer de disposición. La misma serie aparecía dos veces en el desplegable de metadatos: con el circuito de convalidación abierto, DEM-100 existe a la vez como v1 convalidada y v2 en borrador, y etiquetadas solo con «código — nombre» son dos filas idénticas entre las que se elige a ciegas. Ya estaba corregido en el asistente de radicación, con un comentario que describe el problema palabra por palabra: arreglado en un sitio de dos. Por eso el criterio se mueve a $lib/utils/trd.js y lo consumen las dos pantallas, para que no nazca una tercera copia. El buscador de vinculación mostraba in_progress donde la bandeja dice «En trámite», con la función de traducción ya escrita y probada. Y el selector de usuarios llamaba «revisor» a quien no lo era. UserPicker nació para la cadena de vistos buenos y se reutiliza en cuatro sitios que no son una cadena —el responsable de un traslado, el de una asignación, el usuario origen y el destino de una reasignación—. En ellos el botón anunciaba «Agregar a X a la cadena», la columna se titulaba «Cadena de revisión», se pintaban flechas de reordenar permanentemente inertes, y al elegir al único responsable posible el aviso «Máximo 1 revisores» borraba la ayuda que el padre había escrito. Ahora el vocabulario se declara y el default es el que nunca miente: un uso futuro que lo olvide dirá «usuario», cierto en los cinco sitios. Un defecto de entorno con causa de código: la bandeja ofrecía cuatro radicados inexistentes —cinco acciones por tarjeta que solo podían devolver 404—. El --purge de seed_demo.py borraba los pasos de flujo con un subquery sobre la tabla que borra después: si el radicado ya no está, el paso sobrevive, y repetir la purga no lo alcanza jamás. Un borrado que depende de la existencia de lo que borra no es idempotente. Por qué: son los defectos que solo aparecen pulsando botones, y este proyecto no tenía ninguna cobertura de navegador sobre las 36 pantallas. Impacto: sin migraciones. Reconstruir frontend y archive-service. Verificado por mutación en los seis, incluida la del arreglo perezoso (escribir siempre «no está firmado»), que motiva el test gemelo. Los actos se ejercieron de verdad contra tenant_demo: traslado, respuesta vinculada, devolución con causal, vinculación, exclusión con hueco trazable, verificación de integridad y export — cada uno comprobado también fuera de pantalla, en flow_steps y en audit_log. Queda anotado, sin tocar: el índice electrónico se genera sin huella por documento (0/2 en la verificación), y RT-15 la exige. El sistema lo reporta honestamente como no válido; corregirlo es materia de conformidad, no de esta pasada.

  • fix(11 servicios): el sistema explicaba sus errores en inglés a funcionarios que trabajan en español, y con el UUID en la cara Los códigos de error (not_found, cadena_existente) son identificadores de máquina y siguen en inglés, que es donde deben estar. El texto que acompaña a cada uno no: es lo que lee una persona en la pantalla cuando algo no le deja seguir, y estaba en inglés en 223 mensajes de los once servicios —«Expediente not found», «It is not this actor's turn in the signature chain»—. Un mensaje que no se entiende no es un mensaje: la persona ve que algo falló y no sabe qué hacer a continuación, que es justo lo que el mensaje existía para decir. Se traducen todos. Y se quitan los UUID crudos. «Expediente 3f2a…-b1c9 not found» le enseña a quien lo lee un identificador interno que no puede usar para nada: no lo puede buscar, no lo puede citar en un correo y no le dice qué expediente era. Donde el dato sí es del dominio —el número de radicado, el código del catálogo, el slug de la entidad— se conserva, porque eso sí lo puede consultar y repetir. Tres clases de mensaje, y solo una se veía a simple vista. El barrido mecánico encuentra los "detail": "…" literales (193). Otros veinte estaban construidos por un ayudante (_not_found("…"), _conflict("…"), _forbidden("…")) o partidos en varias líneas por concatenación implícita, y no los habría visto ningún grep del texto completo: hubo que buscar la forma —qué funciones fabrican el sobre— y no la cadena. Dos de ellos, además, no eran mensajes en absoluto: preservacion.py y el despachador MCP ponían en el detail el UUID o el nombre a secas, sin frase alguna. Los diez últimos los encontró el guardián, no yo, y por un fallo instructivo del propio barrido: mi detector de idioma contaba palabras españolas frente a inglesas, y «Caller has no active…» le sumaba un punto al español por ese no, que es palabra de las dos lenguas. Diez mensajes quedaban así por debajo del umbral —«User '…' already exists», «Requires permission…», «Too many requests, please slow down»—. shared/tests/test_mensajes_en_espanol.py no cuenta: recorre el AST de los once servicios, extrae todo "detail" (literal, f-string o concatenación) y todo argumento de los ayudantes, y falla si aparece una sola palabra que en español no existe. Trae su propia autocomprobación —más de 300 fuentes y más de 400 mensajes hallados—, porque un guardián que recorre cero archivos pasa siempre. only estuvo en la lista de marcas y se quitó a conciencia: «append-only» y «read-only» son términos de oficio dentro de frases en español, y una marca que se dispara con ellos entrena a ignorar el guardián. Un fallo propio, cazado por ese mismo guardián. Al verificar por mutación reintroduje un mensaje en inglés y deshice el experimento con git checkout <archivo> — que revirtió el archivo entero al último commit y se llevó por delante las otras cuatro traducciones que ya tenía. El árbol quedaba verde salvo por el guardián, que las nombró una a una. Revertir por archivo es demasiado grueso cuando el archivo tiene trabajo sin commitear. La plantilla de servicio nuevo (docs/templates/service-template/) se traduce también, o el próximo servicio nace con el defecto. Por qué: la lengua de trabajo del sistema es el español y sus destinatarios son entidades colombianas. Impacto: no cambia ningún código de error ni ningún contrato — solo el texto. Los cuatro tests que citaban el mensaje en inglés se ajustan; el resto compara códigos, que es como deben escribirse. Verificado por mutación en las dos direcciones: reintroducir un mensaje en inglés pone en rojo el guardián, y cegar su extractor (que devuelva la lista vacía) pone en rojo la autocomprobación — sin ella, el guardián habría pasado en verde sin mirar nada.

  • fix(frontend,workflow,tenant): el frontend leía el sobre de error por fuera — un aviso decía «[object Object]» y toda rama que distinguía un código estaba muerta El defecto se destapó pidiendo un visto bueno fuera de turno: el backend explicaba exactamente qué pasaba y la pantalla mostraba [object Object]. La causa no está en esa pantalla. El backend levanta sus errores con HTTPException(detail={"error", "detail", "status"}) en ~500 sitios de los once servicios, así que FastAPI anida el sobre del proyecto dentro de su propio detail; los proxies BFF lo reenvían intacto, y el frontend lo leía al primer nivel en 231 sitios. Ahí detail no es un texto sino un objeto —de ahí el aviso ilegible— y error es undefined, de modo que toda rama que discriminaba un código concreto no se ejecutaba nunca. Ese es el daño de fondo: no es un mensaje feo, es que las respuestas específicas que alguien se molestó en escribir estaban muertas, y sin ruido: la pantalla no rompe, solo deja de decir la verdad. apiError.js (mensajeError/codigoError) queda como único lector del sobre y se barren los 231 sitios, más 12 que colapsaban el código real a "upstream_error". mensajeError nunca devuelve un objeto: si el sobre viene raro, devuelve el texto de reserva. El guardián se escribe por admisión, no por lista de archivos. sobreErrorPlano.test.js recorre las fuentes de src/ y prohíbe el patrón; la excepción se marca en el sitio mismo (sobre-ajeno, en las cinco líneas anteriores) en vez de en una lista que se pudre en cuanto alguien renombra un archivo. Hoy la usa una sola cosa, y por un motivo real: el sobre OAuth2 de Keycloak (RFC 6749 §5.2) es {error, error_description} plano por especificación, y leerlo con codigoError daría otra cosa. El test se autocomprueba (más de 150 fuentes recorridas) para que no pase en verde recorriendo cero archivos. Por qué: un mensaje de error que no llega es indistinguible de un sistema que no explica nada. Impacto: ninguna migración ni despliegue coordinado; solo frontend. Verificado por mutación: devolver el primer nivel en mensajeError y quitar la marca de excepción del sobre OAuth2 caen cada uno en su test.

Y tres defectos que salieron del mismo recorrido:

La decisión del visto bueno no dejaba traza. Conformar la cadena de revisores se auditaba (vobo.cadena_creada); aprobar o rechazar, no. O sea que audit_log sabía a quién se le pidió el visto bueno y no quién lo dio, cuándo ni por qué, que es justo el acto de gestión —el que decide si el trámite sigue o se detiene— y justo lo que exige la trazabilidad del trámite (Ley 594/2000, art. 22). vobo.decidido se asienta ahora dentro de la misma transacción que la mutación: si la traza falla, la decisión no queda. El comentario del revisor viaja en el payload como ya hace workflow.devolver, porque object_ref es el UUID del radicado y el recorte por clearance de /admin/auditoria resuelve el nivel por id.

El catálogo de causales existía y no lo leía ninguna pantalla operativa. Estaba sembrado desde la migración 006 y se administraba en /admin/catalogos, pero devolver y anular pedían el código a mano, con ejemplos de ayuda —«INCOMPLETO», «DUPLICADO»— que ni siquiera existían en el catálogo. El obstáculo era que el acto al que sirve cada causal estaba escrito dos veces y declarado ninguna: en el prefijo del código (DEV-, ANU-, REA-) y en el texto del nombre. Un dato que se infiere de una convención de nombres no se puede consultar, y su fallo es silencioso: un modal de devolución ofreciendo «Anulación: radicado duplicado» no rompe nada, solo deja mal el trámite. La migración tenant 008 añade la columna ambito con backfill desde el prefijo y un CHECK que la acota; la API la expone y acepta ?ambito=; /admin/catalogos la edita. Los tres campos de causal pasan a selector del catálogo real, y /envios abandona su lista fija (ámbito devolucion_postal: el vocabulario postal —dirección incorrecta, rehusado, no reclamado— no es el documental, y por eso gana ámbito propio en vez de fundirse con el otro). Pedir ambito sobre un catálogo que no lo distingue responde 422 ambito_no_aplica en vez de ignorarlo. Y si el catálogo no carga, el modal lo dice y deja escribir el código: degradar en silencio a un campo vacío sería fingir que no hay causales.

«Dependencia destino» era texto libre en Devolver mientras Trasladar y Distribuir, en la misma pantalla y con el mismo catálogo ya cargado, ofrecían un selector. Verificado por mutación en cada uno: quitar la traza del visto bueno (y comprobar que sin ella la decisión no se persiste), estrechar y ensanchar el filtro de ámbito —los dos sentidos, porque ofrecer de menos y de más son el mismo defecto—, e inspeccionar el SQL que se envía en vez del fuente, que ya nos engañó una vez.

  • feat(whisper-service,mcp,frontend): dictado soberano de verdad — el audio se transcribe en la propia entidad Cierra la deuda que dejó el bloque 11 del plan MCP funcional. Allí se descubrió que la Web Speech API de Chrome y Edge manda el audio a un servicio de Google, contra lo que decidió ADR-020, así que VoiceInput solo se ofrecía donde el navegador reconoce en el dispositivo — y en la práctica eso deja sin dictado a casi todo el mundo. Este es el camino que ADR-020 especificó y que faltaba construir. whisper-service: FastAPI + faster-whisper en CPU. No lleva openai-whisper, que arrastra torch (varios GB) para el mismo trabajo; esto es lo que permite a una entidad sin GPU transcribir su propia voz en vez de mandarla fuera. Arranca desactivado (perfil assistant de Compose, como decidió ADR-020 para los servicios de inferencia) y no lo enruta el gateway: es interno, solo le habla el mcp-server — mismo criterio que storage-service con D-02, porque superficie de red que nadie necesita es superficie que alguien acabará usando. El modelo se baja al primer arranque a un volumen, no se empaqueta en la imagen. POST /api/v1/assistant/transcribe en mcp-server, con un proveedor conmutable — y sin stub, a diferencia de los embeddings de E21. Esa asimetría es la decisión importante: un vectorizador por hashing es recuperación léxica honesta, más pobre pero real; un «stub» de transcripción tendría que inventarse lo que la persona dijo, y ese texto acaba en el compositor del asistente o en el cuerpo de un documento oficial. Así que el default es disabled y su comportamiento es negarse, con un 503 que dice exactamente qué falta configurar. Un sistema que contesta «no puedo transcribir» es utilizable; uno que devuelve una frase inventada como si la hubiera oído, no — y el daño aparece cuando alguien la firma. La fábrica es fail-closed: una errata en STT_PROVIDER deja el sistema sin transcribir, nunca activando por descarte un camino que nadie eligió. Hay un test que prueba justamente el nombre stub, porque es el que alguien intentará por analogía. Frontend: VoiceInput gana el segundo camino. Prefiere el reconocimiento del dispositivo cuando existe (instantáneo, sin salir del equipo) y si no pregunta al backend si esta instalación transcribe, antes de ofrecer el botón — no ofrecer lo que va a fallar, el mismo criterio del marco de alcance. La etiqueta dice dónde se transcribe: quien dicta tiene derecho a saber por dónde pasa su voz, y los dos caminos no son equivalentes en eso. Un audio que no se entendió no rellena el compositor: se avisa, y no se inventa nada. El micrófono se suelta siempre al desmontar, o el indicador de grabación del navegador se queda encendido tras cerrar el panel. Rendimiento medido, no estimado (small, int8, 4 CPU): ~9 s para ~5 s de audio, cerca del doble del tiempo real. Sirve para dictar una frase al compositor; no para transcribir una reunión. Está en el ADR para que nadie lo descubra dictando. Lo que NO está verificado, y se dice: la calidad sobre voz humana. La prueba de extremo a extremo usó voz sintética (espeak-ng), notoriamente difícil para cualquier ASR — transcribió bien la parte final y falló en los nombres propios. Eso demuestra que la tubería funciona, no que la transcripción sea buena, y en dictado de números de radicado y nombres propios es justo donde importa. Antes de ofrecerlo como función terminada a una entidad hay que probarlo con personas. Por qué: era el único camino de dictado que cumple la decisión de soberanía de ADR-020 en todos los navegadores. Impacto: docker compose --profile assistant up -d whisper-service y STT_PROVIDER=whisper. Sin eso, todo sigue igual que antes y el endpoint responde 503 explicando cómo activarlo. Nuevo servicio en services/whisper-service/ (12.º del sistema). Verificado por mutación: hacer que el proveedor deshabilitado devuelva texto en vez de negarse, que la fábrica caiga en whisper por descarte, que el frontend ofrezca el botón sin preguntar, y que un texto vacío rellene el compositor caen cada uno en su test — el último sobrevivió a la primera vuelta y obligó a escribir el test que faltaba.

  • feat(scripts,infra): el demo queda funcional de punta a punta, con firma múltiple real Continuación del guion de demostración: ahora recorre también la firma, y para eso hacían falta cuatro piezas. Tres usuarios más en el realm de desarrollo (jefe, secretaria, archivista). Una cadena de firma con el mismo usuario repetido enseñaría una pantalla que no corresponde a nada, así que los firmantes son distintos y reales: operador → jefe → secretaria → archivista, en orden. scripts/dev-secrets.sh genera los secretos de desarrollo AL AZAR en .env (gitignorado). signature-service no arrancaba porque su guardián rechaza el change-me-in-production del compose, y eso está bien. La tentación era copiar lo que hace MCP_PROPOSAL_SECRET, que trae un default conocido (orpycamcp_dev_proposal_secret) y por eso sí arranca — pero ese patrón es el débil de los dos: con change-me-in-production, un despliegue que olvide definirlo no arranca, falla cerrado y en voz alta; con un default conocido arranca tan feliz con un secreto que está en un repositorio público, que es fallar abierto y en silencio. En vez de debilitar el guardián, el valor va donde van los valores locales y se genera aleatorio. El guion crea los usuarios en Keycloak si faltan. Keycloak NO reimporta un realm que ya existe, así que quien levantó el sistema antes de este cambio no los tendría y el demo fallaría con un 401 sin explicar por qué. Dos trampas de Keycloak documentadas en el código porque el mensaje de error no las menciona: sin firstName/lastName y requiredActions: [] la cuenta queda «not fully set up» (400 al entrar), y desde Keycloak 24 los atributos que el user profile no declara se descartan en silencio — tenant_slug es uno, y su pérdida aparece luego como tenant_mismatch, un 403 que no menciona ningún atributo. Los usuarios se aprovisionan entrando, no insertando su fila: auth_users la escribe el auth-service al validar el primer token de cada persona, y crearla por detrás es como se consigue un id que no casa con el de Keycloak y un fallo que aparece semanas después. Tres correcciones más de convergencia, todas del mismo tipo —aceptar un conflicto no es lo mismo que resolverlo—: la vinculación radicado↔expediente devolvía 409 radicado_already_linked en la segunda ejecución y abortaba el recorrido; el id de una cadena de firma viene en solicitud_id y no en id, así que el guion daba por fallida una cadena que había creado perfectamente; y el aviso de la firma colapsaba «ya está sembrada» con «el servicio no responde» en un solo mensaje, que son situaciones opuestas —una es «ya está hecho» y la otra «te falta configurar algo»—. El purgado borra además las cadenas de firma, pero no las firmas: si alguien llegó a firmar, esa firma es un hecho criptográfico sobre un documento, no un dato de prueba. Por qué: «ver cómo se ve una firma de uno o varios» era parte explícita de lo pedido, y una cadena simulada no lo enseña. Impacto: ./scripts/dev-secrets.sh && docker compose up -d && python scripts/seed_demo.py. Dos ejecuciones seguidas dejan exactamente un juego de datos. Quien ya tenga el volumen de Keycloak no necesita borrarlo: el guion completa lo que falte.

  • feat(scripts): un demo navegable del sistema completo — y dos defectos que encontró al construirse scripts/seed_demo.py deja el tenant demo con un recorrido entero: dependencias, serie TRD con su tipo documental, una Entrada con anexo en PDF, su expediente foliado, la asignación por el flujo, y una respuesta que va de la plantilla al borrador, del borrador a la aprobación y de ahí al número de radicado. Al final imprime qué pantalla abrir para ver cada cosa. Se construye llamando a la API, no insertando en la base, y esa es la decisión de fondo. Un seed por SQL directo dejaría radicados sin su asiento en audit_log —que es una cadena de hashes y se verifica entera—, sin sus eventos y sin entrar en el índice electrónico: un estado que el propio sistema no habría podido producir. Lo único que va por SQL es el aprovisionamiento de permisos, que es administración de plataforma — la misma frontera que respeta init_tenant.py. Los PDF se generan en código (606 bytes, PDF 1.4 válido): un binario de ejemplo versionado es algo que nadie vuelve a mirar y que engorda el clon para siempre. Converge, no acumula. Dos ejecuciones dejan exactamente un juego de datos. Costó dos correcciones: la convergencia por 409 solo vale donde la base impone unicidad —el expediente y la plantilla no la tienen, y con razón—, así que para esos se busca antes de crear; y la búsqueda de radicados probaba items/results/documents, tres nombres plausibles y ninguno el bueno (hits), de modo que cada ejecución creaba otro radicado y consumía otro número inmutable en silencio. --purge deshace lo que la ley permite, y dice el resto. No se borran audit_log (cadena inmutable, ADR-010) ni flow_events: la base lo impide con un trigger —«append-only, Ley 594/2000»— y hace bien. Que un guion de demostración pudiera borrar la hoja de ruta de un radicado sería un defecto grave, no una comodidad. Dos defectos reales que destapó, y que ninguna prueba unitaria iba a encontrar porque van contra el sistema en marcha: (1) POST /api/v1/trd con un código duplicado devolvía 500 por un UniqueViolationError sin capturar, cuando su gemelo tipos_documentales.py sí lo traduce a 409 — lo destapó ejecutar el guion dos veces seguidas, que es lo primero que hace cualquiera; (2) tenant_demo estaba a medio migrar (35 migraciones de archive-service cuando la que añade trd_series.estado es la 037), así que crear una serie por la API reventaba — no un fallo de código sino un tenant provisionado hace tiempo y nunca re-migrado, situación que se dará en cualquier instalación real. Y dos degradaciones honestas: si mcp-server no responde, o si signature-service no arrancó (su guardián de secretos lo impide sin SIGNATURE_SECRET, que es correcto), el demo lo dice y sigue. La primera versión de su lector se tragaba cualquier respuesta que no fuera 200 y no imprimía ni el dato ni el motivo. Por qué: quien acaba de levantar el sistema necesita verlo funcionando, no leer una lista de funcionalidades. Impacto: python scripts/seed_demo.py con el stack levantado, --purge para deshacerlo. Nunca corre solo.

  • fix(document,frontend): una Entrada se anulaba con solo tener el permiso, y eso es un acto que sobre una Entrada no existe Corrección de dominio aportada por el usuario, y abre un eje que este trabajo no estaba auditando. Toda la revisión de permisos de estos días responde a «¿está autorizada esta persona?». Hay una pregunta anterior: «¿es este acto posible sobre esta clase de documento?». Son ejes distintos y no se sustituyen. Un radicado de Entrada no se anula. Si el documento entró a la entidad, entró: es un hecho ocurrido, no una decisión revisable. La anulación existe para lo que la entidad produce —una Salida o un Interno emitidos por error—, donde sí cabe dejar sin efecto lo emitido; sobre una Entrada no hay nada que dejar sin efecto, y borrar ese registro sería negar que llegó. El servicio, en cambio, solo comprobaba que el radicado existiera y no estuviera ya anulado: con PERM_SOL_ANULAR se anulaba una Entrada igual. El Orfeo original sí distinguía — su flujo procesa un tipo de radicado a la vez (sgd_trad_codigo, include/tx/Anulacion.php). La regla vive en app/core/reglas_tipo.py, escrita por ADMISIÓN: se declara qué tipos admiten cada acto, no cuáles se excluyen. Un tipo documental nuevo llega sin actos hasta que alguien decida cuáles; con una lista de exclusiones el olvido queda del lado permisivo y no avisa. Y ni ROOT lo salta: el bypass de ROOT sirve para autorizar a quien no estaba autorizado, no para hacer posible lo que no lo es, así que la comprobación vive en el servicio de dominio y no en un Depends. Hay un test que lo fija con una sesión ROOT. 409, no 403, y la diferencia importa para quien lo recibe: no es que le falte autorización —dársela no cambiaría nada—, es que la petición choca con la naturaleza del objeto. Un 403 mandaría a la persona a pedir un permiso que no le va a servir. El mensaje dice por qué y ofrece la vía que sí existe. Las dos mitades. La bandeja ofrecía «Solicitar anulación» mirando solo el permiso (canAnular = permissions.includes("PERM_SOL_ANULAR")), así que pintaba el botón sobre una Entrada. Ahora exige además que el radicado lo admita. La barrera de verdad sigue siendo el backend; la pantalla deja de prometer de más — el mismo reparto que la frase de las propuestas irreversibles. El estado y el tipo se leen en una sola consulta (radicado_estado_y_tipo), no en dos: son dos datos de la misma fila y el no-read-up (nivel_seguridad <= $2) tiene que aplicarse igual a ambos; con dos lecturas separadas sería una repetición fácil de olvidar en una de ellas. Por qué: un permiso no puede habilitar un acto que sobre ese documento no existe. Es una regla archivística, no una de acceso. Impacto: POST /documents/{id}/anulacion responde 409 acto_no_aplica_al_tipo sobre una Entrada, con o sin permiso, con o sin ROOT. La bandeja no ofrece el botón. Reconstruir document-service. Verificado por mutación en las dos capas: quitar la comprobación del servicio, corromper la lista de admisión para que acepte E, y hacer que la guarda del frontend devuelva siempre true caen cada una en su test. Queda abierto: esta es la primera regla de su clase que se declara. Si hay más particularidades por tipo —qué admite un Interno, qué no admite una Salida— reglas_tipo.py es ahora el sitio donde ponerlas, y conviene enumerarlas de una vez en vez de irlas descubriendo de una en una.

  • fix(document,auth): el catálogo de permisos prometía nueve controles y ejercía uno La pregunta era qué hacer con PERM_RADI_ENTRADA y PERM_RADI_INTERNO. El barrido inverso del guardián de permisos —qué se siembra que nadie exija, el reverso del que cazó los seis fantasma— destapó que no eran dos: de los 26 permisos sembrados, nueve no los exige ningún gate ni ninguna fila. Verificados uno a uno, no por grep:

  • USUA_PERM_ROOT es legítimo: no se comprueba con require_permission sino por is_root, que es el bypass.
  • SGD_PERM_ESTADISTICA era un hueco REAL y se cierra aquí. La interfaz lo honra —el menú «Reportes» y la sección de métricas del panel solo se muestran a quien lo tiene— y el backend no lo exigía: cualquier autenticado de la entidad podía pedir GET /reports/radicados y su gemelo CSV directamente. Una entidad que lo concediera a unos pocos creía estar acotando quién ve sus estadísticas de gestión documental, y no lo estaba. Tercera aparición hoy del mismo patrón, tras la aprobación de borradores y el gate ausente de /storage/upload: el cliente es la parte hostil, así que un control que solo él aplica no es un control. El test comprueba que la denegación ocurre antes de consultar la base, porque el resumen agrega sobre toda la tabla de radicados del tenant.
  • Los siete restantes no tienen ni una referencia en todo el repositorio. Nombres heredados de Orfeo que nunca se cablearon. Sobre la pregunta original: los permisos por tipo no se pueden aplicar hoy, y el motivo es concreto. El trío está incompleto porque PERM_RADI_SALIDA ya está tomado: gatea los seis endpoints de envío postal, no el radicar Salidas. Aplicar solo ENTRADA e INTERNO daría un medio control que parece entero —se podría restringir «no entradas» pero nunca «no salidas»—; y aplicar también el tercero, o deja fuera a todo el que hoy radica salidas, o exige un backfill que ampliaría el acceso al envío postal a todo el que sabe radicar, que es una regresión de seguridad. Cablear el trío exige antes rediseñar ese nombre, y eso es una decisión de producto, no un arreglo. Por qué se corrigen las descripciones y no se borran las filas: auth_group_permissions.permission_id es ON DELETE CASCADE, así que un DELETE arrastraría en silencio las concesiones que una entidad ya haya hecho — y silencioso es justo lo que estamos quitando de en medio. Además varios corresponden a funciones que sí existen pero gateadas con un permiso más grueso (nrr por PERM_TRAMITAR, la descarga del expediente por USUA_PERM_EXPEDIENTE, la anulación por PERM_PANU_CODI): cablear cada uno a su permiso fino es una decisión por función, y borrarlos cerraría esa puerta sin que nadie la hubiera decidido. Lo que no puede quedarse es un catálogo que le dice a un administrador que reparte un control que el sistema no ejerce — y el texto es lo que esa persona lee justo antes de conceder. Y un guardián nuevo que impide que la lista caduque, porque una lista a mano solo es segura si algo deriva la verdad y la compara. Falla en los dos sentidos: si alguien cablea uno de los inertes, exige sacarlo de la lista y corregir su descripción —que pasaría a ser falsa justo al empezar a restringir—; y si aparece un permiso sembrado nuevo que nadie exige, exige declararlo. Verificado con las dos mutaciones. El catálogo MCP se sincronizó solo… porque el guardián compartido lo puso en rojo: al gatear los reportes, reporte_radicacion pasó a declarar «sin permiso» sobre un endpoint que sí lo pide. Es exactamente para lo que existe. Por qué: un permiso otorgable que no restringe nada es una promesa falsa a una institución pública sobre quién puede ver y hacer qué. Impacto: los reportes exigen SGD_PERM_ESTADISTICA (la interfaz ya lo asumía, así que quien usa Reportes lo tiene). Migraciones auth-service/018 (solo descripciones; reescribe también las de la 017 para que las siete se reconozcan de un vistazo, y corrige la de PERM_RADI_SALIDA, cuyo nombre engaña sobre lo que gatea). Reconstruir document-service y mcp-server.

  • ci: el pipeline gastaba 20 minutos por commit y casi nada de eso hacía falta todavía when: manual y NO rules:changes, aunque filtrar por rutas ahorraría lo mismo y es la opción obvia: un filtro mal escrito deja de correr un gate sin que nadie se entere, que es el modo de fallo del skip de arriba. Un job manual se queda a la vista en la interfaz, sin ejecutar y diciéndolo. Si el ahorro va a costar cobertura, que al menos se vea. Una guarda nueva en pages: aborta si no hay public/index.html. needs: [build-site] debería impedir que corra sin sitio construido, pero ese comportamiento depende de cómo trate GitLab un needs sobre un job manual con allow_failure, y publicar un directorio vacío encima de la documentación en línea es destructivo. No puede descansar en una semántica sutil: ahora el peor caso es un job en rojo, no un sitio en blanco. Por qué: el pipeline no es lo que guarda el código —eso lo hace git push—, y repetir en un runner compartido la suite que ya corre entera en local antes de cada commit costaba la cuota del mes. Impacto: de ~20 minutos por push a uno o dos. La suite completa sigue a un clic, sobre máquina limpia — que es lo que caza una imagen local sin reconstruir, error que se cometió dos veces hoy. Publicar la documentación pasa a ser una decisión explícita.

  • fix(document,storage): reclasificar con permiso de lectura, subir archivos sin ningún permiso, y radicar un borrador que nadie aprobó Cierre de la deuda que el plan MCP funcional fue declarando sin arreglar. Los seis hallazgos se verificaron uno a uno contra el código antes de tocarlos, y dos se disolvieron al mirarlos — se dicen igual, porque un hallazgo falso que se queda en un informe hace perder el tiempo tanto como uno real que no se reporta. PATCH /documents/{id}/security-level exigía nivel de LECTURA. PERM_RECLASIFICAR con el min_crud por defecto (1), de modo que quien lo tuviera solo para consultar podía subir o bajar la reserva de un radicado oficial. Sus dos gemelos ya exigían 3 y su propio comentario explicaba por qué: publicacion usa el MISMO permiso con nivel de escritura porque «publicar y clasificar son las dos caras de la misma competencia sobre el régimen de acceso», y disposition se había corregido antes por lo mismo. Este era el tercer hermano y el único que se quedó en 1. No deja fuera a nadie: quien puede publicar ya necesitaba nivel 3 sobre ese permiso. POST /storage/upload no tenía ningún gate. Es el ÚNICO endpoint de storage-service que el gateway expone al exterior —los que sirven bytes, URL o metadata están sellados con X-Internal-Token (D-02)—, así que cualquier autenticado de la entidad subía archivos al almacén. Pasa a exigir PERM_RADI con nivel 1: el permiso no se inventa, se toma del único consumidor real (el flujo de radicación sube el anexo antes de radicar), y el nivel iguala al de POST /documents porque pedir aquí más que en el acto que este archivo acompaña sería exigir de más — el defecto simétrico, que además falla en silencio. Nadie que hoy trabaje se queda fuera, que es la comprobación obligada antes de encender un control apagado. radicar_borrador no exigía la aprobación, y la pantalla sí. El ciclo documentado es crear → editar → aprobar → radicar, y /borradores lo respeta (canRadicar = estado === "aprobado"), pero el servicio solo comprobaba que no estuviera ya radicado: un POST directo saltaba la aprobación y consumía el número igualmente. Un control que vive solo en el navegador no es un control, porque el navegador es la parte hostil (D-05). Ahora es 409 borrador_sin_aprobar, y no rompe nada porque la única interfaz que llama a esa ruta nunca la ofrece sin aprobar. Se corrigen de paso las descripciones de aprobar_borrador y radicar_borrador en el catálogo MCP, que decían lo contrario. PERM_RADI_ENTRADA y PERM_RADI_INTERNO siguen sin limitar nada, y ahora lo dicen. Ningún gate los consulta (0 apariciones): una entidad puede concederlos creyendo que restringe a un funcionario a radicar solo entradas, y no restringe. Las dos salidas de fondo son decisiones de producto y ninguna es inocua — aplicarlos es funcionalidad nueva y choca con que PERM_RADI_SALIDA hoy signifique «gestionar envíos» (6 gates) y no «radicar salidas»; retirarlos es un borrado que arrastraría las concesiones ya hechas. Mientras se decide, la migración 017 corrige lo que sí es inaceptable: la descripción, que es lo que un administrador lee en la pantalla de grupos antes de conceder. Cambio no destructivo, ninguna concesión se toca. Dos hallazgos míos que resultaron falsos. (1) /vistos-buenos/decidir «sin gate»: sí autoriza, y mejor que con un permiso — exige ser miembro de la cadena de revisores de ESE radicado y que sea tu turno. Autorización por-instancia, más fuerte que un permiso genérico; añadirle PERM_VOBO habría podido bloquear a revisores designados. Vi que faltaba require_permission y di por hecho que no había control. (2) PATCH /expedientes/{id} y vincular «a nivel de lectura»: no es un olvido sino una convención coherente — min_crud=3 marca los actos irreversibles (cerrar, transferir, desvincular) y 1 la escritura ordinaria, igual en document-service. Que la convención use «leer» como suelo de escritura es discutible, pero es una decisión de política de todo el proyecto, no un defecto de estos endpoints. Por qué: eran gates de seguridad que el plan declaró y no tocó porque eran de otras épicas; cerrado el plan, ya no había nada que los tapara. Impacto: quien tenga PERM_RECLASIFICAR solo con nivel de consulta deja de poder reclasificar (era el defecto); subir archivos exige PERM_RADI; radicar un borrador exige aprobarlo antes. Migración auth-service/017 (solo descripciones). Reconstruir document-service y storage-service. Verificado por mutación: devolver security-level a nivel de lectura y quitar el gate de /upload caen cada uno en su test; el de upload comprueba además que la denegación ocurre antes de tocar MinIO, porque un gate que rechazara después habría dejado el fichero subido y solo se habría notado en la factura.

  • fix(auth,mcp): seis permisos de trámite que el código exigía y que nadie podía tener — y el guardián que debía cazarlos llevaba desde siempre saltándose Hallazgo de la verificación extremo a extremo del plan MCP funcional, con el stack levantado y un tenant real: tenant_demo.auth_permissions traía 20 filas y el código exigía seis que no estaban — PERM_TRAMITAR, PERM_VOBO, PERM_ANULAR, PERM_CERRAR_EXP, PERM_INFORMAR, PERM_AGENDAR. Un permiso que no está en esa tabla no se puede conceder a ningún grupo, así que resolve_effective_permissions nunca lo devuelve y el gate niega a todo el mundo salvo a ROOT, que hace bypass. Efecto real: los doce tipos de transacción sembrados y las cinco escrituras de flujo (assign, transfer, complete, devolver, rollback) eran ROOT-only. Falla cerrado —el lado correcto— y aun así es capacidad que existe en el código y que ninguna entidad podía repartir entre sus funcionarios. Es exactamente el defecto RADI_CREAR que abrió este plan, a mayor escala. Aquel se cazó porque alguien fue a usar la tool; estos, porque se levantó el stack. Y el guardián escrito para impedirlo estaba apagado. test_todo_permiso_del_catalogo_existe_en_el_sistema vivía en la imagen de mcp-server, que no monta el repositorio: no encontraba las migraciones de auth-service y hacía pytest.skip en cada corrida. En el resumen, «11 passed, 1 skipped» se lee igual de bien que «12 passed». Un skip que oculta un defecto real es otro mecanismo que reporta éxito sin verificar nada — y este llevaba el nombre del defecto que dejaba pasar. Se mueve a shared/tests/test_permisos_existen.py, el único carril de CI que ve el repositorio entero (allí el skip deja de ser posible) y se amplía a las tres fuentes de exigencia: el catálogo MCP, los require_permission de cualquier router de cualquier servicio, y la columna requires_permission de las filas sembradas. Los seis que faltaban no los declara el catálogo: los declara el gate de un router y una fila de transaction_types. Mirar una sola fuente es cómo sobrevivieron a un control que existía para cazarlos. Las tres se derivan del código; no hay ninguna lista a mano. Los nombres no se inventan: se toman literalmente de lo que el código y las migraciones de workflow-service ya exigen. Renombrar el gate habría sido la corrección de menos riesgo aparente y la peor — dejaría las filas de transaction_types de las entidades ya provisionadas apuntando a un permiso que vuelve a no existir. Por qué: exponer el trámite por MCP amplifica un gate que nadie podía satisfacer; el plan exigía cerrar antes lo que amplificaba. Impacto: migración auth-service/016_permissions_tramite.sql (aditiva, ON CONFLICT DO NOTHING); hay que correr el runner de auth-service en cada tenant existente o los flujos siguen siendo ROOT-only. La suite de mcp-server pasa de «142 passed, 1 skipped» a 143 passed, 0 skipped. Verificado por mutación: retirar la migración pone en rojo los dos tests nuevos, y retirar solo PERM_VOBO —que no lo declara ningún gate, solo una fila sembrada— pone en rojo exactamente el que mira esa fuente.

  • test(e2e): el criterio de éxito del plan, comprobado contra el stack levantado El plan decía que su criterio de éxito no era la suma de bloques sino el recorrido completo con el sistema en marcha. Verificado con docker compose y un operador real de tenant_demo: el gateway enruta /api/v1/mcp/ (200, frente a route_not_found en los controles /api/v1/tenants/ y una ruta inventada); el marco de alcance devuelve 15 operaciones ejecutables, 29 consultables y 1 denegada con su motivo, derivadas de los permisos reales; invocar una escritura devuelve una propuesta firmada y la base no cambia (0 radicados); confirmarla sin la frase da confirmacion_requerida y la base sigue sin cambiar; con la frase da 201 y 2026-DEMO-E-000001; reintentar el mismo token da propuesta_consumida; y el radicado queda con el registered_by del usuario real y su asiento document.radicado_created en audit_log con el actor correcto — el mismo resultado que a mano. Control negativo: una tool cuyo permiso el usuario no tiene no aparece en el catálogo (44 de 45) e invocarla igualmente da 403 sin tocar la base. Pendiente declarado: el turno del asistente con el modelo real exige ANTHROPIC_API_KEY y no se ha ejercido; lo verificado es todo el camino determinista, que es donde viven los gates.

  • feat(frontend): dictado por voz — y el botón no aparece si dictar significaría mandar la voz a un tercero Último bloque del plan MCP funcional. VoiceInput.svelte en el compositor del asistente: reconocimiento en es-CO, resultados parciales pintados sobre una base para que la frase no se repita palabra a palabra, y errores que dicen lo que son —permiso, micrófono, idioma— en vez de un «no se pudo dictar» que deja a la persona sin saber qué hacer. La premisa del plan era falsa y por eso el diseño cambia. El plan justificaba usar el reconocimiento del navegador con que «el audio no sale del equipo», y decía que así cumplía «mejor» el objetivo de soberanía que el POST /assistant/transcribe con Whisper. En Chrome y Edge —los navegadores con los que se trabaja— la Web Speech API clásica envía el audio a un servicio de reconocimiento de Google. ADR-020 había decidido lo contrario y por escrito: «STT en servidor con Whisper; el audio no sale a terceros», «soberanía del dato: LLM y STT locales». Implementarlo tal cual habría mandado la voz de un funcionario público a un tercero en un sistema que gestiona radicados reservados, con una justificación escrita que afirmaba justo lo contrario. Así que el botón no se ofrece porque la API exista: se ofrece solo cuando el navegador puede reconocer en el dispositivo y lo confirma él mismo (SpeechRecognition.availableOnDevice para ese idioma, más processLocally). Un modelo downloadable no vale: hasta que esté, el navegador puede recurrir al servicio remoto sin decir nada. Y la comprobación es fail-closed por construcción — si la API de disponibilidad no existe, o revienta, el resultado es que no hay botón. El peor caso posible es quedarse sin dictado; nunca dictar hacia fuera sin que nadie lo sepa. Es la degradación honesta del plan (11.2) aplicada a la soberanía, no solo al soporte. El dictado nunca envía ni confirma (11.3): rellena el compositor y ahí se acaba. Una propuesta de escritura se aprueba con la mano, en su tarjeta — una acción con consecuencias sobre un registro oficial no puede dispararse por lo que el reconocedor creyó oír. Hay un test que comprueba que el componente no emite ningún otro evento. ADR-020 queda anotada con lo implementado y con su límite: el transcribe con Whisper sigue sin construir y es el único camino soberano para todos los navegadores. Se declara como pendiente, no como algo ya resuelto por la vía del navegador. Por qué: era el último punto del plan, y su justificación escrita no se sostenía. Impacto: dictado disponible donde el navegador reconoce localmente; invisible donde no. 1488 tests vitest, svelte-check 0/0. Verificado por mutación: aceptar la API por el mero hecho de existir, dar por buena una disponibilidad downloadable y dejar de pedir processLocally caen cada uno en su test — el primero en cuatro.

  • feat(mcp,frontend): el asistente prometía por escrito una confirmación que no existía — ahora la tarjeta está construida El panel del asistente declaraba que «toda acción de escritura pedirá confirmación explícita» sobre una interfaz que no estaba hecha: no había render de propuestas, ni diálogo, ni botones. Y el backend lo resolvía por el otro lado, prohibiendo toda escritura (v1 solo-lectura). Las dos mitades eran coherentes entre sí y ninguna cumplía lo prometido: la frase describía un mecanismo, y el mecanismo estaba sin construir. Backend: el asistente deja de excluir las tools de escritura del conjunto que ve el modelo. Una tool_use sobre una de ellas no llega al dispatcher: se convierte en una propuesta firmada (write_gate.preparar) que viaja en AssistantMessageResponse.propuestas — campo aditivo, sin despliegue coordinado. Recortar la lista nunca fue lo que hacía seguro el sistema (el gate vive en _execute_tool); lo que hacía era dejar al modelo sin manera de decir «puedo preparártelo». El anclaje del historial, que es lo que impide la mentira más probable: el tool_result que vuelve al modelo dice ejecutado: false, status: "propuesta_emitida" y «NO SE HA EJECUTADO NADA… no encadenes acciones que dependan de su resultado». Sin él, el modelo lee su propia tool_use como si hubiera surtido efecto y redacta «he radicado el documento» — un hecho falso sobre un registro oficial, que es la regla de no-invención aplicada a sus propios actos. El token no viaja al modelo: no lo necesita, y meterlo en su contexto lo pondría al alcance de cualquier cosa que consiga que lo repita. La frase de los actos irreversibles, exigida por el backend a partir del TOKEN. Un clic no basta cuando el acto no se deshace, y quién dice que este acto es irreversible no es el cliente: es la propuesta firmada, de la que sale la tool. Así no hay manera de confirmarlo declarando que no lo era. La interfaz deshabilita el botón por cortesía —para no mandar a la persona contra un error previsible—, pero la barrera está en write_gate: una frase validada solo en el navegador es teatro, porque el navegador es la parte hostil (D-05). La elicitación por stdio pide la frase en su propio esquema, para que los actos irreversibles no sean justamente los únicos que ese camino no puede aprobar. Frontend: ProposalCard.svelte en el dock, proxy /asistente/api/confirmar, y estado por propuesta en el store. Una confirmación por acto, también dentro de una cadena — un botón que aprobara «todo lo pendiente» sería el booleano global que este diseño retiró, con otra forma. La tarjeta la redacta el catálogo: operacion y etiquetas salen de la declaración de la tool, y los valores se pintan como texto y nunca como HTML — los compuso el modelo a partir de una conversación que puede incluir el contenido de un radicado de Entrada, es decir, texto de un tercero, y es el último sitio del sistema donde convendría relajar el escapado. Tres defectos propios, cazados por sus tests. (1) Al pasar de «no ejecutar escrituras» a «firmarlas», coerce_args empezó a correr en ese camino y su ProposalError se escapaba: un argumento que el modelo puede corregir solo tumbaba el turno entero con un 500. (2) La allowlist del proxy de confirmación, hecha para impedir que se cuelen argumentos, se llevaba por delante la frase — y con ella la única pieza que sí tenía que pasar: ningún acto irreversible se habría podido confirmar desde la web. (3) El test del nivel CRUD sobrevivió a su mutación porque comprobaba la primera barrera (el conjunto ofrecido) dos veces y la segunda (authorize) ninguna; se sustituye por uno que rompe la capa de delante a propósito, que es la única forma de ejercer una defensa en profundidad. Por qué: una promesa de confirmación sin interfaz detrás es peor que no prometer nada — la persona confía en un control que no existe. Impacto: el asistente puede preparar radicaciones, expedientes, vinculaciones, asignaciones y borradores; ninguno se ejecuta sin que alguien lo apruebe en su tarjeta. Reconstruir mcp-server. Frontend: 1472 tests, svelte-check 0/0. Verificado por mutación: despachar la escritura en vez de proponerla, decir en el tool_result que sí se ejecutó, dejar escapar el ProposalError, saltarse la segunda barrera de autorización y reenviar el cuerpo íntegro en el proxy caen cada uno en su propio test.

  • fix(mcp,gateway): la cabecera de permisos que nadie producía, el ajuste de entorno que nadie consultaba, y el marco de alcance que solo veía el asistente web Este bloque cierra la Parte 0 del plan (marco de alcance) por la superficie MCP y el bloque 9 (exposición a clientes externos). Los dos comparten causa: la autorización se leía de sitios que el backend no consulta. Tres puntos leían X-User-Permissions, una cabecera que ningún componente produce — el gateway inyecta X-User-Id, X-Username, X-Tenant-Slug, X-User-Roles, X-Canal y X-Real-Ip, y la descarta si la manda un cliente, que está bien porque sería forjable. Llegaba siempre vacía: GET /api/v1/mcp/tools —el endpoint que existe precisamente para decirle a alguien qué puede hacer— le contestaba a todo el mundo con las tools sin permiso, y el transporte streamable ofrecía lo mismo. X-User-Roles sí llega, de modo que un ROOT lo veía todo y el defecto quedaba invisible justo para quien podía detectarlo. Y su gemelo por stdio era peor: MCP_USER_PERMISSIONS/MCP_USER_ROLES sí se podían poner, así que quien los configuraba creía estar concediendo algo. Lo único que lograba era ver ofrecidas tools que el backend le negaría al usarlas — anunciar lo inutilizable, servido por la propia configuración. Los dos ajustes se retiran. La identidad sale de GET /auth/me en los tres transportes, con el permission_levels incluido: build_tools no pasaba levels, así que tener PERM_RADI para leer mostraba tipificar_radicado, que exige nivel de creación. El marco de alcance llega a MCP: tool mi_alcance y resource orfeo://alcance, los dos derivados de build_scope — dos puertas al mismo dato, no dos versiones. Se ofrece siempre, aunque el usuario no alcance ninguna escritura, y ahí está la mitad que faltaba: el conjunto de tools dice qué se puede hacer y calla el resto, y callar es exactamente cómo un límite acaba descubriéndose fallando. El marco dice qué no se puede, por qué, y por dónde sí. El gateway enruta /api/v1/mcp/, que antes quedaba fuera a propósito. Eso dejaba a medias la decisión de «un solo catálogo y un solo gate para el asistente integrado y para clientes MCP externos»: los externos no tenían por dónde entrar salvo hablando con el servicio a espaldas del gateway, sin validación JWT ni X-Tenant-Slug. Se puede exponer porque el gate de escritura ya no depende del cliente: invocar una escritura devuelve una propuesta firmada y no ejecuta nada. Con X-Confirm-Write —un booleano que cualquiera fabricaba— enrutar esto habría sido abrir la escritura al exterior. El transporte streamable se monta también en /api/v1/mcp/stream para caber por la tabla de ruteo sin abrirle al proxy una forma de ruta fuera de /api/v1/.... Un guardián nuevo en shared/tests/: ninguna tool puede apuntar a /api/v1/mcp ni /api/v1/assistant. Hasta ahora era imposible por accidente —el gateway no los enrutaba, así que habría sido un 404—, y al exponerlos esa protección de hecho desaparece. Una tool así abriría recursión (invoke despachando invoke, con el token del usuario en cada salto) y, peor, saltaría el gate: llamar a /proposals/execute como si fuera una lectura del catálogo ejecutaría una propuesta sin que nadie confirmase nada. Un hueco propio, cazado por mutación y corregido. Mi control negativo —«mandar la cabecera forjada no concede nada»— sobrevivió a volver a leerla: pasaba, pero por otra razón, porque el permiso colado llega sin su nivel y visible_tools compara también el nivel. Defensa en profundidad real y a la vez un test que afirmaba una cosa comprobando otra; los dos defectos se habrían compuesto en cuanto alguien tocara levels. Se sustituye por un control estructural sobre el AST —la cabecera y los ajustes no pueden aparecer como parámetro, nombre, atributo ni literal en ningún módulo de app/— y se inspecciona el árbol y no el texto a propósito: los ficheros están llenos de comentarios que la nombran para explicar por qué se retiró, y un grep habría encontrado esas explicaciones y dado el hallazgo por bueno. Por qué: un límite legítimo que solo se descubre fallando hace perder el tiempo a dos personas y parece una avería. Impacto: /api/v1/mcp/ pasa a estar enrutado por el gateway (hereda su JWT). MCP_USER_PERMISSIONS y MCP_USER_ROLES dejan de existir: quien los tuviera puestos no pierde nada, porque no concedían nada. build_tools es async y recibe el cliente del gateway. Reconstruir mcp-server y api-gateway. Corregidas afirmaciones falsas en docs/{es,en}/api.md e integraciones-externas.md. Verificado por mutación: quitar levels, retirar mi_alcance, volver a leer la cabecera, restaurar el ajuste de entorno y quitar el montaje de /api/v1/mcp/stream caen cada uno en su propio test.

  • feat(mcp): instrumentos y archivo físico — el catálogo cierra en 45 tools, todas de consulta 5 tools nuevas (bloque 8, solo lectura): consultar_serie y consultar_retencion (qué tiempos y qué disposición gobiernan lo que se clasifique en una serie de la TRD), ubicacion_expediente (dónde está físicamente, con su signatura), listar_prestamos y reporte_radicacion. Con esto el catálogo llega a 45 tools, 16 de escritura, y las 45 cruzan contra el gate de su endpoint: ninguna queda en la holgura del control de cobertura. Dos descripciones que acotan lo que la tool NO es, porque el modelo tiende a usar lo que tiene a mano: consultar_retencion es un CÁLCULO sobre una serie y una fecha de cierre, no la disposición registrada del expediente concreto; y reporte_radicacion devuelve conteos por tipo, estado, mes y dependencia —ni número, ni asunto, ni remitente—, así que se dice que no es un listado, para que un agregado no se presente como si fuera un inventario. ubicacion_expediente dice dónde está el expediente y no qué contiene: el backend no le aplica no-read-up a propósito (es gestión de custodia, ortogonal al clearance de lectura), y la descripción refleja ese límite en vez de sugerir que devuelve contenido. Primer GET con un obligatorio fuera de la ruta. consultar_retencion exige closed_at como query. El reparto por location="auto" ya lo resolvía, pero ese caso no estaba ejercido por ningún test: si la fecha acabara en el cuerpo, el backend devolvería 422 por un argumento que el usuario sí había dado. Ahora hay un test que comprueba las tres cosas —la serie en la ruta, la fecha en la query y el GET sin cuerpo—; verificado por mutación moviendo el parámetro al cuerpo y renombrándolo. Por qué: son las consultas que sostienen los actos de los bloques anteriores — tipificar sin poder mirar la retención de la serie es firmar a ciegas. Impacto: el catálogo pasa de 40 a 45 tools; ninguna escritura nueva. Reconstruir la imagen de mcp-server.

  • feat(mcp): el ciclo de la respuesta — antecedentes, borrador y radicación — y un 422 que dejaba de descubrirse fallando 10 tools nuevas (bloque 7 del plan). Recuperación: buscar_antecedentes (radicados anteriores parecidos, cada uno con su número para verificarlo en la fuente) y redactar_con_antecedentes (texto apoyado en el archivo, con citas, que no escribe nada). Respuesta: listar_respuestas y crear_respuesta_vinculada. Borradores: listar_plantillas, listar_borradores, crear_borrador, editar_borrador, aprobar_borrador y radicar_borrador. El catálogo queda en 40 tools, 16 de escritura. El borrador es la pieza que faltaba para poder proponer sin comprometer: no tiene número, no entra en el índice y se edita o se abandona sin consecuencia; solo radicar_borrador consume el número, que es inmutable. Por eso los dos actos declaran destructive distinto aunque toquen el mismo objeto. Un límite que dejaba de descubrirse fallando. buscar_antecedentes admite el asunto o el radicado de referencia, nunca ambos ni ninguno: lo impone AntecedentesRequest con un model_validator y JSON Schema no lo expresa con las piezas del catálogo. Anunciados los dos como simplemente opcionales, el modelo manda ambos —que es lo natural cuando tiene los dos datos a mano— y recibe un 422 después de haber hecho esperar a la persona. Se declara con exactly_one_of en el catálogo y coerce_args lo rechaza antes de que salga la petición. Es el mismo principio que el parametro_no_declarado, en pequeño: el límite se dice, no se tropieza. Con él va su invariante, porque una declaración también se corrompe en silencio: un grupo que nombra un parámetro inexistente rechaza toda llamada a la tool con un mensaje sobre un argumento que el modelo no puede enviar, y un parámetro obligatorio dentro del grupo no admite ninguna llamada válida. Ninguna de las dos la caza el test de comportamiento, que solo prueba los grupos tal y como están hoy. Un aviso que va en la propia descripción de la tool: el ciclo documentado del borrador es crear → editar → aprobar → radicar, pero POST /borradores/{id}/radicar no comprueba la aprobación — solo que no esté ya radicado. La aprobación es hoy una marca de procedimiento, no un control. Se expone aprobar_borrador porque el procedimiento existe, y se dice lo que la marca no hace, para que el asistente no la presente como una barrera inexistente. No se corrige aquí (ADR-019: es de la épica de document-service). Por qué: «prepárame la respuesta con antecedentes» era parte explícita del criterio de éxito de extremo a extremo, y sin borradores solo quedaba radicar a la primera. Impacto: el catálogo pasa de 30 a 40 tools; ninguna quedó sin cruzar contra el gate de su endpoint. Reconstruir la imagen de mcp-server. Verificado por mutación: quitar el enforcement de exactly_one_of, quitar su declaración de la tool, contar las claves crudas en vez de los valores efectivos y corromper un nombre del grupo caen cada uno en su propio test; y declarar listar_plantillas «sin permiso» cae en el guardián compartido, que es la prueba de que las tools nuevas están dentro del cruce y no fuera de él.

  • fix(workflow,mcp): devolver y revertir un radicado no exigían ningún permiso, y el bloque de flujos era el que faltaba para cerrar un trámite POST /workflows/{id}/devolver y POST /workflows/{id}/rollback no tenían ningún require_permission, mientras sus tres hermanas del mismo router —assign, transfer y complete— exigen PERM_TRAMITAR sobre el mismo radicado. Mismo fichero, mismo objeto, solo algunas gateadas: es omisión, no diseño, y ellas son el control positivo. Cualquier autenticado de la entidad podía sacar un radicado de la dependencia que lo tramita y mandarlo de vuelta al origen, o deshacer la última asignación hecha por otra persona. Y un tercer hueco, este sí documentado como decisión. En execute_transaction, un tipo con requires_permission = NULL era «abierto»: se ejecutaba sin permiso alguno. Había un test que lo afirmaba expresamente, así que se cambia declarándolo, no borrándolo. El motivo: transaction_types es un catálogo del tenant, de modo que una fila con la columna vacía —sembrada por una migración futura, o por alguien con acceso a la base— quedaba ejecutable por cualquiera. Dejar un campo en blanco no puede ser la manera más fácil de abrir una puerta. Ahora cae al suelo PERM_TRAMITAR. No cambia nada observable hoy: los 12 tipos sembrados declaran el suyo. Y el suelo va en el servicio, no como gate de ruta, porque el permiso aquí es POR TIPO: exigir PERM_TRAMITAR a todas rompería a quien tiene PERM_VOBO para dar vistos buenos y nada más. 8 tools nuevas (bloque 6, el que permite cerrar un trámite de principio a fin, donde antes no había ni una sola de distribución): pendientes_equipo, tipos_transaccion, hoja_ruta_radicado, paso_actual, asignar_radicado, trasladar_radicado, completar_paso y ejecutar_transaccion. El catálogo queda en 30 tools, 11 de escritura. completar_paso declara su parámetro como lo que es —step_id, el id del PASO, no el del radicado— en vez de heredar el común: esa ruta cuelga de otra entidad que el resto del router y era el error más probable de todo el bloque. Una excepción que hubo que declarar, no saltarse. ejecutar_transaccion resuelve su permiso en tiempo de ejecución, así que no hay decorador que comparar y el guardián test_permisos_declarados.py la marcaba como «exige un permiso que su endpoint no pide». Se resuelve con un campo explícito del catálogo (permiso_en_runtime) más un test propio que comprueba que esa marca no esté puesta sobre una ruta que sí tiene gate — porque así es como una excepción legítima acaba convertida, con el tiempo, en la manera de callar hallazgos. Por qué: exponer una operación por MCP la hace más alcanzable, así que los gates flojos que amplificaría se cierran antes, no después. Impacto: devolver y rollback exigen PERM_TRAMITAR (quien no lo tenga recibirá 403 donde antes pasaba). Reconstruir las imágenes de workflow-service y mcp-server. Dos afirmaciones falsas corregidas en docs/{es,en}/api.md: la autorización de devolver y el «tipo abierto». Verificado por mutación: quitar cada gate, devolver el NULL a «abierto» y usar la marca de permiso-en-runtime donde sobra caen cada uno en su propio test.

  • feat(mcp): tres tools se ofrecían a quien iba a recibir un 403 — y ahora hay quien lo vigile Al ampliar el catálogo (bloques 4 y 5 del plan: de 9 tools a 22) salió el reflejo exacto del defecto de RADI_CREAR. consultar_expediente, indice_expediente y consultar_fuid declaraban «sin permiso» sobre endpoints gateados con USUA_PERM_EXPEDIENTE. Allí se escondía algo utilizable; aquí se anunciaba algo inutilizable, que es peor por lo que este proyecto se comprometió a hacer: el marco de alcance existe precisamente para que nadie descubra un límite fallando, y un catálogo que promete lo que el backend niega lo convierte en una lista de buenas intenciones. El invariante que ya existía comprueba que el permiso exista en las migraciones de RBAC — habría cazado RADI_CREAR y no caza esto: un None es un nombre perfectamente existente. Lo que faltaba es comparar contra el gate del endpoint concreto, y para eso hacen falta los dos árboles a la vez. Ahora lo hace shared/tests/test_permisos_declarados.py, con un test por sentido —declarar de menos y declarar de más son dos fallos distintos con dos víctimas distintas— y un tercero que mide la cobertura del cruce, sin el cual los otros dos serían decorativos: si el parseo dejara de casar rutas, todo se saltaría y quedarían verdes para siempre. Su gemelo, test_rutas_mcp_alcanzables.py, cierra el hueco que ya se coló una vez: la carga masiva se construyó entera contra endpoints que existían y el gateway no tenía /api/v1/batch/ en su tabla de ruteo, así que devolvía 404 route_not_found. Una tool cuya ruta no esté enrutada se ofrece, se propone, se confirma… y falla. Los dos viven en shared/tests/ porque es el único carril de CI que ve el repositorio entero: el contenedor de mcp-server no tiene el código del gateway y el del gateway no tiene el catálogo. Ninguno de los dos mantiene una lista propia — ambos lados se derivan del código. 13 tools nuevas. Radicación y clasificación: consultar_por_numero (el número es como lo nombra la gente; el id no sale en el sello), listar_anexos, consultar_disposicion, actualizar_radicado, tipificar_radicado, listar_tipos_documentales. Expedientes: crear_expediente, buscar_expedientes, vincular_radicado, actualizar_expediente, cerrar_expediente, consultar_foliado, hoja_ruta_expediente. Los parámetros se verificaron contra los routers reales, no contra la tabla del plan — y ese cruce es el que destapó tanto el hueco de la tipificación como estos tres permisos. Dos exclusiones deliberadas, dichas en voz alta: actualizar_expediente declara exactamente los tres campos de ExpedienteUpdate y ni uno más, porque ese schema lleva extra="forbid" y status está retirado allí a propósito (era una ruta alterna al cierre sin auditoría ni disposición); y la vinculación masiva (hasta 500 radicados) se queda fuera mientras su tarjeta de confirmación no pueda leerse — una tarjeta que dice «500 radicados» no permite confirmar con conocimiento de causa, que es justo para lo que existe. Dos tests existentes se pusieron en rojo, y por la razón correcta: pasaban sobre la premisa equivocada (el usuario de pruebas solo tenía PERM_RADI y el catálogo no exigía más). Corregidos concediendo el permiso real, no relajando la comprobación. Por qué: ofrecer una capacidad que el backend va a negar hace perder el tiempo a la persona y convierte un límite legítimo en una avería aparente — el defecto que este plan viene a corregir, cometido dentro del propio plan. Impacto: el catálogo pasa de 9 a 22 tools (6 de escritura, todas por propuesta firmada). cerrar_expediente es el único irreversible que entra, con crud >= 3, porque «archivar» era parte explícita de lo pedido. Reconstruir la imagen de mcp-server. Verificado por mutación: devolver una tool a «sin permiso» y declarar un permiso inexistente caen cada uno en su test; apuntar una tool a una ruta no enrutada cae en el suyo.

  • feat(document,mcp): tipificar un radicado no se podía deshacer ni rehacer: el tipo documental solo existía al radicar Salió al verificar el bloque 4 del plan MCP contra los routers reales, en vez de dar por buena la tabla del plan. doc_class —el tipo documental— solo se fijaba al crear el radicado y en la importación masiva; PATCH /api/v1/documents/{id} aceptaba subject, dest_dept, observations, status y pages, y el tipo no estaba. No había ningún endpoint que lo hiciera, ni por esta vía ni por otra. Un documento mal tipificado en ventanilla —o radicado sin tipificar, que el schema permite— se quedaba así para siempre, y con él la serie TRD que gobierna su retención y su disposición final (Ley 594/2000 art. 24). No era una restricción deliberada: era un hueco. Y explica por qué el frontend solo sugiere tipo al radicar (TrdSuggestChips): después no había a dónde mandar la corrección. Retipificar revalida los metadatos contra la plantilla del tipo nuevo. Es el corazón del arreglo, no un extra: sin eso, retipificar habría sido un UPDATE doc_class que deja los campos variables del tipo anterior colgando, y el radicado habría pasado a declarar una conformidad con una plantilla que ya no era la suya. Se usa la misma comprobación que hace create, a propósito — un radicado retipificado tiene que ser indistinguible de uno radicado así desde el principio. Los metadatos se reemplazan enteros, no se fusionan (una fusión deja residuo del tipo anterior, que es exactamente lo que hace que un expediente deje de cuadrar), y la versión de plantilla usada se reescribe siempre, incluido el NULL cuando el tipo nuevo no tiene plantilla activa; sin ese NULL explícito el radicado seguiría apuntando a la plantilla del tipo anterior. Enviar solo metadata también revalida: si no, la validación obligatoria de la radicación sería puenteable con un PATCH posterior. El gate del acto nuevo, sin tocar el de los que ya existían. El PATCH está gateado con PERM_RADI a nivel 1 —de lectura— y así se queda: subirlo entero habría roto a todo el que hoy corrige un asunto o un número de folios, y alinear ese umbral es una decisión propia de la épica, no un efecto colateral de este cambio. Pero doc_class decide cuánto se conserva el documento y si acaba destruido, así que se comprueba aparte con crud >= 3. El acto nuevo entra con su nivel correcto; los antiguos conservan el suyo. Un defecto propio, cazado por mis propios tests: añadí el modo de fallo (validación de metadatos) sin su respuesta — MetadataValidationError no estaba en el except del PATCH, solo en el del POST, así que la validación salía como 500. Una validación sin su mapeo de respuesta no es una validación, es una caída. Y un test que no discriminaba, cazado por mutación: comprobaba que metadata_template_id apareciera en el SQL del UPDATE, y lo encontraba en el RETURNING — daba verde con la escritura puesta y quitada. Corregido a metadata_template_id = $N y a comprobar el valor enviado. Enésima aparición del mismo patrón: la cadena buscada aparece en otro sitio del mismo texto. Por qué: el tipo documental no es un campo descriptivo, es lo que ata el radicado a su serie TRD; dejarlo inmutable por omisión convierte un error de ventanilla en una retención equivocada para toda la vida del documento. Impacto: PATCH /api/v1/documents/{id} acepta doc_class y metadata; 422 metadata_validation_failed cuando no encajan; 403 al tipificar sin nivel de creación. Nuevas tools MCP tipificar_radicado (escritura, min_crud=3, con su vía alternativa declarada) y listar_tipos_documentales, que es de donde sale el nombre que la primera espera. Reconstruir las imágenes de document-service y mcp-server. Verificado por mutación, cada control en rojo por su propio test: quitar el gate de nivel, quitar la revalidación, no reescribir la versión de plantilla, y sacar doc_class de los campos auditables.

  • feat(mcp): confirmar «algo» no era confirmar «esto» — la escritura pasa a ser una propuesta firmada El gate de escritura de la capa MCP era X-Confirm-Write: true: un booleano que cualquier cliente podía poner y que no ataba la confirmación a ningún acto concreto. Valía igual para consultar que para radicar. El binding MCP directamente lo daba por puesto (confirm_write=True fijo, «porque el human-in-the-loop lo aporta el cliente MCP» — una garantía convertida en suposición sobre software ajeno), y el asistente prohibía toda escritura. Tres comportamientos distintos según por dónde entraras es garantía de que alguno está mal, y el que estaba mal era el que menos se miraba. Ahora hay uno solo para los tres transportes: invocar una escritura devuelve una propuesta y no ejecuta nada. La propuesta es un token opaco firmado con HMAC-SHA256 que lleva dentro el acto completo —tool y argumentos ya validados—, y al confirmar los argumentos se toman del token, nunca del cuerpo de la petición: «lo que se firmó» es «lo que se ejecuta» por construcción, no por disciplina. Que una escritura devuelva una propuesta en vez de un 412 es deliberado: un cliente antiguo u hostil que fabrique cualquier cabecera obtiene una descripción del acto, no un efecto. Firmado y no guardado en memoria porque el único almacén del servicio es un diccionario por proceso, declarado no válido para varias réplicas: con memoria, proponer en una réplica y confirmar en otra rompería el botón que la persona sí pulsó. La reautorización al confirmar no es redundante. Entre proponer y confirmar median los segundos que tarda alguien en leer la tarjeta, y en ese hueco le pueden haber retirado el permiso: la propuesta acredita qué se aprobó, no que quien la presenta siga pudiendo hacerlo. Confundir esas dos cosas es lo que convierte un token en un salvoconducto. Y la tarjeta la redacta el catálogo, no el modelo: un texto generado podría describir mal el acto que se está firmando, y eso sería ingeniería social sobre la propia persona que confirma. Para clientes MCP externos, que no tienen ni tarjeta ni endpoint propio: la tool genérica confirmar_accion(token) —ofrecida solo a quien alcanza alguna escritura, porque sugerírsela a quien no puede escribir insinúa un poder que no tiene—, annotations estándar (readOnlyHint, destructiveHint) y elicitación en banda donde el transporte es bidireccional (stdio). La aprobación por elicitación no ejecuta de otra manera: pasa por el mismo token firmado, así que se reautoriza y se consume igual — si ejecutara por su cuenta sería la auto-confirmación retirada, con otro nombre. Dos defectos encontrados al implementarlo, ambos del tipo «mecanismo que informa de éxito sin verificar nada»: el endpoint de ejecución nunca llegó a insertarse —una verificación por grep de una cadena que también aparecía en un comentario dio por bueno lo que no existía; ahora se verifica por el símbolo definido—, y las propuestas emitidas por el transporte MCP nacían muertas: se firmaban con una clave del diccionario de sesión que nunca existió, así que el sub iba vacío y la verificación las rechazaba después como ajenas. Salía una propuesta de aspecto impecable e inconfirmable; el fallo solo aparecía al pulsar el botón. destructive se declara explícitamente en toda tool de escritura, con default None a propósito: con default False, olvidarlo habría equivalido a afirmar «este acto se deshace» sin que nadie lo comprobara, y un cliente externo habría bajado su fricción confiando en esa afirmación fantasma. Un invariante lo exige. Por qué: un booleano en una cabecera no ata la confirmación a un acto, y una garantía que depende de que el cliente pregunte no es una garantía. Impacto: X-Confirm-Write deja de leerse (quien la envíe recibe una propuesta igualmente). Nuevos POST /api/v1/mcp/proposals/execute y POST /api/v1/assistant/confirmar —este último bajo /assistant/ porque es el único prefijo del mcp-server que el gateway enruta—. MCP_PROPOSAL_SECRET es obligatoria: el servicio no arranca sin ella (una firma con clave vacía la reproduce cualquiera y dejaría el gate decorativo). Esto obliga a construir mcp-server con el contexto en la raíz del repo para instalar shared/. Reconstruir su imagen. Límites declarados, no disimulados: el consumo por jti vive en el proceso —cubre el doble clic y el reintento, no el multi-réplica—, y Idempotency-Key se emite como gancho, no como garantía; en HTTP streamable (stateless) no hay canal de vuelta para elicitar y queda el camino de dos tools.

  • fix(mcp): Un identificador manipulado podía hacer que el asistente consultara otra cosa con las credenciales del usuario Cuando el asistente consulta un documento por su identificador, ese identificador se insertaba en la dirección interna sin sanear. Un valor manipulado con ../ no producía un error: hacía que el sistema consultara un endpoint distinto del previsto, con las credenciales de quien preguntaba. Es el mismo problema de direcciones torcidas que se corrigió en la puerta de entrada, reaparecido un paso más adentro. Corregido, y con doble barrera: los identificadores se validan antes, así que la mayoría ni llega a la segunda. Además, la capa de traducción solo sabía hacer dos tipos de petición, y la mayoría de las operaciones del sistema usan un tercero. Sin eso, buena parte de las tareas no se podían expresar siquiera. Por qué: una capa que traduce peticiones y no sanea lo que inserta deja de ser una fachada y pasa a ser una puerta. Impacto: interno; ninguna funcionalidad cambia de comportamiento.

  • feat(mcp): El asistente ahora dice qué puede hacer cada persona antes de empezar Hasta ahora los permisos funcionaban como un filtro invisible: el asistente escondía lo que el funcionario no podía hacer, de modo que el límite se descubría fallando. Alguien pedía «envía este documento» y, o aparecía un error incomprensible, o se quedaba esperando una acción que con sus credenciales nunca iba a ocurrir. Ahora, al abrir el asistente, la persona ve qué puede consultar, qué puede ejecutar y qué no — y, cuando algo no está a su alcance, por dónde sí puede llegar: «no puede enviar el documento, pero puede trasladarlo a correspondencia para que lo envíen». Esa segunda parte es la útil; negar sin más deja a la gente igual de atascada. Se corrigieron además dos cosas que estaban rotas por dentro. La capacidad de radicar desde el asistente llevaba apagada desde el principio, porque estaba amarrada a un permiso que no existe en el sistema; nadie lo notó porque las propias pruebas se inventaban ese permiso. Y el asistente anunciaba límites de búsqueda distintos de los reales del sistema, con una prueba que daba por buena esa diferencia. El marco de alcance también declara lo que el sistema no distingue, para no prometer precisión que no tiene: quien puede radicar puede radicar cualquier tipo de documento, y lo que cada quien ve no depende de su dependencia sino de su nivel de acceso a la información. Por qué: enterarse de un límite fallando hace perder el tiempo a dos personas, y una negativa sin alternativa no resuelve nada. Impacto: el asistente sigue siendo de solo consulta; lo que cambia es que ahora dice con claridad qué está a su alcance. Hay que reconstruir las imágenes al desplegar.

  • fix(tooling): La comprobación automática de código podía fallar sin decir por qué Apareció al ejecutarla mientras otra instancia estaba en marcha: cuatro servicios en rojo —dos de ellos sin ningún cambio— y ningún mensaje que explicara nada. No fallaba el código: fallaba la instalación de las herramientas dentro del contenedor, y esa instalación tenía los mensajes de error suprimidos, de modo que un problema de red o de saturación se presentaba como «este servicio no pasa» sin más. La misma lección ya estaba escrita dos líneas más abajo en el propio archivo, para otra instalación. Estaba aplicada solo a la mitad. Por qué: una comprobación que da fallos inexplicables enseña a no hacerle caso, y entonces deja de proteger. Impacto: solo herramientas de desarrollo.

  • feat(archive,frontend): La decisión de publicar ya alcanza también a los expedientes Faltaba la mitad. Cuando se corrigió que publicar fuera una decisión y no un efecto de no haber clasificado, el cambio se aplicó a los documentos individuales y no a los expedientes: el canal de consulta pública seguía difundiendo el código, el nombre, la descripción, las fechas y el número de documentos de todo expediente no clasificado. Lo detectaron por separado las dos revisiones —de seguridad y de conformidad normativa— hechas sobre el trabajo anterior. Ahora los expedientes tienen su propio acto de publicación, con el mismo permiso, el mismo fundamento obligatorio y el mismo registro que los documentos, y con su pantalla construida en el mismo cambio: dejar la capacidad sin pantalla ya nos costó una corrección hace dos entregas. Dos detalles que no son evidentes y se decidieron a propósito. Un expediente cerrado o transferido sigue siendo publicable: al contrario que un documento anulado, no está retirado de la circulación, sino en un estado normal de su ciclo de vida — copiar la regla del documento habría dejado de difundir, sin avisar, la mayor parte del archivo histórico. Y sobre un expediente reservado la pantalla no ofrece publicarlo, pero sí retirar una publicación anterior, que es justo cuando más urge poder hacerlo. Lo que se difunde de un expediente es siempre agregado: nunca se enumeran los números de los documentos que contiene, porque un expediente público puede incluir documentos reservados. Por qué: una decisión que vale para un tipo de documento y no para otro no es una decisión, es una excepción. Impacto: los expedientes existentes quedan sin publicar. Si su institución usa el canal de consulta pública de expedientes, debe publicar expresamente lo que quiera difundir. Hay que reconstruir las imágenes al desplegar.

  • fix(document,shared,ci): La decisión de publicar no alcanzaba a los archivos adjuntos, y reclasificar un documento la deshacía sin que nadie lo decidiera Auditoría cruzada de seguridad y de conformidad archivística sobre lo construido. Los tres problemas tienen la misma forma: la corrección anterior era correcta, pero no llegaba a todas partes. Los adjuntos se seguían entregando. El cambio anterior hizo que un documento solo se difundiera si la entidad lo decidía expresamente. Eso quedó bien para la ficha del documento —número, asunto, dependencia—, pero el canal que entrega el archivo adjunto completo conservaba la regla antigua escrita aparte, y nadie la actualizó. Con los canales de consulta pública activos, cualquiera en internet podía descargar el adjunto de cualquier documento no clasificado, aunque la entidad no lo hubiera publicado nunca. Y como los números de radicado son consecutivos, se podían recorrer uno a uno. Corregido de forma que la regla ya no se pueda copiar mal: ahora hay una sola, y las dos consultas la construyen desde el mismo sitio. Reservar un documento no retiraba su publicación. Si un documento publicado se clasificaba después como reservado, dejaba de difundirse —correcto—, pero la marca de «publicado» se quedaba puesta por dentro. Meses más tarde, al levantar la reserva por cualquier otro motivo, el documento volvía solo a los canales públicos, sin que nadie hubiera decidido publicarlo de nuevo. Ahora reservar retira la publicación, y volver a publicar exige el acto motivado de siempre. Y no se podía retirar la publicación de un documento ya reservado, que es justo cuando más urge. También corregido. Además se cerraron dos huecos de fontanería: una de las herramientas internas podía dar por aplicada una actualización de base de datos que nunca corrió (el mismo fallo que se corrigió antes, reaparecido un paso más allá), y las pruebas de la librería compartida —la que guarda el registro de auditoría inalterable— no se estaban ejecutando en el sistema de integración continua, porque la lista de proyectos a probar enumera los once servicios y la librería no es uno de ellos. Por qué: una decisión de publicar que no alcanza a los archivos no es una decisión, y un acto que otro deshace sin explicación no es un acto. Impacto: si su institución ya tenía documentos publicados y luego reservados, conviene revisarlos antes de activar los canales públicos. Hay que reconstruir la imagen al desplegar.

  • feat(frontend): Los expedientes que quedaron pendientes de revisar ya se pueden ver Cuando se implantó el control de instrumentos de retención, el sistema marcó los expedientes ya cerrados sobre los que no podía afirmar bajo qué tabla de retención se habían cerrado. La marca era correcta —es más honesto señalarlo que inventar el dato—, pero no había ninguna pantalla que la mostrara: quedaban marcados indefinidamente sin que nadie llegara a saberlo. Ahora hay una pestaña en la administración de la TRD que los lista y, sobre todo, distingue los casos por lo que se puede hacer con ellos: unos se resuelven solos en cuanto se convalide la serie correspondiente —y se dice dónde hacerlo—, y otros no tienen hoy ninguna forma automática de resolverse y necesitan una decisión del área de archivo. La pantalla lo dice tal cual, en vez de ofrecer un botón que no arreglaría nada. Por qué: un aviso que nadie puede ver no avisa de nada. Impacto: solo interfaz. Reconstruir la imagen al desplegar.

  • docs(en): La documentación en inglés deja de ir por detrás: ahora dice qué es la versión 0.01 El registro de cambios en inglés iba 135 entradas por detrás del español, todas correspondientes a una misma versión que todavía no se ha publicado. Al medirlo quedó claro que el problema no era de traducción sino de propósito: quien llega al proyecto desde fuera y lee en inglés no necesita ciento treinta y cinco anotaciones internas de «se corrigió tal hallazgo», necesita saber qué hace el sistema y qué no hace. La versión inglesa empieza ahora por un resumen de la línea base organizado por áreas —radicación y expedientes, retención, índice electrónico y firma, preservación, seguridad, flujos, interoperabilidad, inteligencia artificial, interfaz y operación— y declara los límites donde corresponde: la firma no está acreditada, el repositorio de preservación tampoco, y el conector con el operador postal es todavía un simulacro. Cada fichero dice en su cabecera qué es, para que nadie vuelva a medir un desfase contra una equivalencia que ya no se persigue. Por qué: mantener más de trescientas anotaciones internas duplicadas en dos idiomas era la causa del desfase, no su consecuencia. Impacto: solo documentación.

  • fix(document,frontend): Se podía dejar de publicar un documento, pero no publicarlo En el cambio anterior se corrigió algo importante: hasta entonces, un radicado se difundía por los canales de consulta pública solo por no estar clasificado como reservado — es decir, no haber decidido nada equivalía a haber decidido publicar. La ley pide lo contrario: publicar es un acto de la entidad. Se corrigió, y todos los radicados pasaron a nacer sin publicar. Lo que faltó fue la otra mitad: no se construyó la pantalla para publicarlos. La capacidad existía en el servidor, pero ninguna pantalla la usaba, así que una institución que actualizara se quedaba con todo su archivo sin publicar y sin ninguna forma de publicar nada desde la aplicación. Se cerró una puerta indebida y se dejó el edificio sin la puerta buena. Al construir la pantalla apareció un segundo hueco: el sistema permitía marcar un documento como publicado, pero no consultar si lo estaba. Ni la pantalla podía mostrarlo ni la entidad podía responder «¿qué hemos publicado?», que es justo lo que hay que poder acreditar. Ahora hay un panel en la bandeja que muestra el estado real y ofrece siempre la acción contraria a la vigente —publicar si no lo está, retirar si lo está—, exige explicar el motivo en ambos casos, y avisa de lo que pasa al publicar: el documento queda consultable y descargable desde internet por cualquiera. Sobre un documento reservado o anulado no se ofrece el formulario: se explica el motivo, en vez de dejar que alguien redacte una justificación para recibir después un rechazo incomprensible. Por qué: una decisión que la ley obliga a tomar, y a poder acreditar después, no puede depender de que alguien entre a la base de datos. Impacto: los documentos siguen naciendo sin publicar; ahora hay cómo publicarlos y cómo saber qué se publicó. La institución debe reconstruir las imágenes al desplegar.

  • fix(shared,gateway,document,archive,auth,storage,infra): Publicar información dejaba de ser una decisión, y una incidencia no se podía seguir de principio a fin Lo que se publica de oficio ahora se decide, no se deduce. El sistema ofrece dos canales por los que otras entidades y el público pueden cosechar los documentos de la institución. Hasta ahora, entraba en esos canales todo radicado que no estuviera marcado como reservado — es decir, no haber decidido nada equivalía a haber decidido publicar. Un documento recién radicado, o uno desclasificado con otro propósito, salía solo. La Ley 1712 de 2014 pide justo lo contrario en su artículo 12: un esquema de publicación, un acto por el que la entidad decide qué difunde. Ahora cada radicado tiene una marca de publicación que empieza apagada y solo se enciende con un acto explícito, motivado y registrado, que además queda anotado con quién lo hizo y cuándo. La reserva sigue mandando: marcar como publicable un documento reservado no es posible. Una incidencia no se podía seguir entre servicios. El sistema son once servicios que se llaman entre sí. Cuando algo fallaba, no había forma de saber qué le había pasado a una petición concreta: cada servicio anotaba lo suyo sin nada que los cosiera. Existía un identificador de petición… que se generaba al terminar, cuando ya no servía para etiquetar nada. Ahora se asigna al entrar, viaja por todos los servicios y vuelve al navegador, y cada servicio deja una línea con la entidad, el usuario, la operación y cuánto tardó. Quien atiende una incidencia puede reconstruir el recorrido completo con un solo dato — y el texto de las búsquedas y los números de radicado no quedan en esos registros. Contraseñas de ejemplo que funcionaban de verdad. El código de este proyecto es público. Dos claves —la que protege las firmas electrónicas y la del almacén de archivos— traían un valor de ejemplo que funcionaba: una institución que se olvidara de cambiarlas no recibía ningún aviso, y quedaba operando con una clave que cualquiera puede leer en internet. Ahora el sistema se niega a arrancar sin ellas. La configuración de ejemplo del sistema de identidad, que incluía dos usuarios con contraseña publicada —uno de ellos administrador—, pasa a llamarse claramente «de desarrollo», y producción parte de una plantilla que no se puede usar sin rellenar. Cualquier página de internet podía llamar al sistema con la sesión del visitante. La puerta de entrada tenía una configuración de permisos entre sitios web abierta de par en par y escrita en el código, sin forma de cambiarla. Ahora está cerrada por defecto —el sistema no la necesita para funcionar— y quien la abra tiene que enumerar los sitios autorizados. La guía de instalación mandaba copiar una plantilla que no existía. La del servidor web que va delante del sistema. Sin ella, tres cosas fallaban en silencio: el inicio de sesión no completaba, la dirección de red que se sella en el registro de auditoría era la del propio servidor en lugar de la del ciudadano, y las descargas grandes se volvían a acumular en memoria. Ya está incluida y comprobada. La contabilidad de actualizaciones de la base de datos estaba llevada por duplicado. Dos herramientas anotaban lo aplicado en sitios distintos y sin verse, con el agravante de que identificaban cada actualización solo por el nombre del archivo — y hay cuatro nombres repetidos entre servicios. La consecuencia: la actualización del segundo servicio se daba por hecha sin haberse aplicado. Ahora hay un libro único que identifica cada actualización por servicio, y lo ya aplicado se importa sin repetir nada. El registro de auditoría no estaba dividido por meses. La estructura estaba prevista desde el principio, pero la tarea que crea las divisiones nunca se escribió: todo se acumulaba en un mismo bloque. Además de la lentitud, esto se agrava con el tiempo, porque dividir es cada vez más difícil cuanto más histórico hay. Ya existe la tarea, que crea las divisiones de los próximos meses y las protege contra borrado, igual que el resto del registro. Tres controles de acceso que faltaban. (1) La ingesta automática de correo la podía disparar cualquier usuario con sesión, y como el buzón es uno solo para el servidor, un usuario de una institución podía llevarse a la suya correos dirigidos a otra —y quedaban marcados como leídos, así que no volvían a aparecer—. Ahora el buzón declara a qué institución pertenece y hace falta permiso de administración. (2) Marcar un documento para eliminación exigía el mismo permiso que consultarlo. Ahora exige permiso de escritura, obliga a motivar la decisión y queda registrada. (3) El inventario documental (FUID) salía sin cabecera: sin decir de qué entidad ni de qué oficina era, ni para qué se levantó. Ya la lleva, con el nombre de la entidad resuelto por el propio sistema, y con la columna de frecuencia de consulta calculada a partir de los accesos reales en lugar de estimada de memoria. Por qué: publicar información pública es una decisión de la entidad, no un efecto secundario de no haber clasificado; y una clave de ejemplo que funciona es una clave conocida. Impacto: los radicados existentes quedan como no publicables hasta que alguien ejecute el acto de publicación; si la institución usa los canales de cosecha, debe publicar expresamente lo que quiera difundir. Además hay que definir tres variables obligatorias antes de desplegar (la clave de firmas, las dos del almacén de archivos) y, si se usa la ingesta de correo, indicar a qué institución pertenece el buzón.

  • fix(shared,gateway,*): El reparto automático de la correspondencia podía detenerse en silencio, y el sistema seguía diciendo que todo iba bien Cuatro procesos internos se encargan de reaccionar a lo que ocurre en el sistema: repartir automáticamente la correspondencia que entra, enviar los correos, sellar las firmas y alimentar el buscador. Si el servicio de mensajería interna no estaba disponible en el momento exacto del arranque —o se reiniciaba después—, esos procesos morían y no volvían. El sistema seguía funcionando en apariencia: se podía radicar, consultar y firmar. Pero los documentos nuevos dejaban de repartirse a las dependencias, los correos no salían y el buscador se quedaba con la información de antes. Nada lo avisaba. Ahora vuelven a levantarse solos, con esperas crecientes para no saturar, y cada caída queda registrada. La comprobación de salud daba un «todo bien» automático. Tres servicios respondían que estaban sanos sin comprobar nada: lo decían igual con la base de datos caída. Y el caso contrario también estaba mal: la puerta de entrada del sistema se declaraba enferma cuando fallaba cualquier servicio detrás, con lo que el orquestador reiniciaba precisamente lo único capaz de seguir atendiendo. Ahora hay dos comprobaciones distintas: una responde «el proceso está vivo» —la que decide reinicios— y otra, nueva, informa de verdad del estado de la base de datos, la mensajería interna, el correo y los procesos internos, para que el balanceador deje de enviar tráfico a un servidor que no puede atenderlo, sin reiniciarlo. Además, esas comprobaciones publicaban nombres y puertos internos en el texto del error, y cualquiera podía consultarlas sin autenticarse. Ahora informan del estado con una palabra; el detalle queda solo en el registro del servidor. Los fallos de inicio de sesión sin causa aparente. El sistema pedía a la base de datos más conexiones simultáneas de las que ésta concede, y comparte servidor con el sistema de autenticación. El resultado no se veía donde estaba el problema: bajo carga documental, lo que fallaba era entrar al sistema. La cuenta está ahora hecha, escrita y ajustada, y una consulta atascada ya no retiene su conexión indefinidamente. Por qué: un proceso que muere en silencio es correspondencia oficial que deja de repartirse sin que nadie se entere, y una comprobación que siempre dice «bien» desactiva la vigilancia que aparenta dar. Impacto: la institución debe reconstruir las imágenes y desplegar el manifiesto actualizado de producción, que ajusta la configuración de la base de datos. Quien vigile el sistema con la dirección /health y espere ver ahí el estado de las dependencias debe pasar a /health/ready.

  • fix(frontend): Pasar el ratón por el icono de salir cerraba la sesión El icono de cerrar sesión era un enlace, y la interfaz está configurada para ir adelantando el trabajo de una página cuando el puntero pasa por encima. Como el cierre de sesión ocurría en esa carga previa, rozar el icono —sin llegar a hacer clic— cerraba la sesión de verdad. Peor: al estar el efecto en una simple visita a la dirección, también lo disparaba un enlace o una imagen puestos en un correo. Ahora hace falta pulsar el botón, y quien escriba la dirección a mano encuentra una pantalla de confirmación. Los mensajes de error mostraban la red interna de la institución. Cuando algo fallaba, el texto que aparecía en pantalla incluía nombres de servidores, direcciones y puertos internos. Ahora se muestra un mensaje claro y un código de referencia que el usuario puede dictar a soporte; el detalle técnico queda solo en el registro del servidor. Y se añade una pantalla de error propia: antes, un enlace roto llevaba a una página en blanco con texto técnico, sin marca ni forma de volver. La interfaz podía quedarse esperando para siempre. Si el sistema central tardaba en responder, las pantallas no daban error ni se recuperaban: se quedaban cargando indefinidamente, incluida la de inicio de sesión. Ahora hay un plazo máximo de espera. Las descargas grandes —exportación de un expediente, paquete de preservación, FUID— quedan expresamente excluidas del plazo: lo que se corta es un servidor que no contesta, no uno que está enviando un archivo grande. El botón Atrás mostraba la bandeja del usuario anterior. El navegador guardaba las páginas y las volvía a mostrar sin pedirlas de nuevo. En un puesto de ventanilla compartido, el siguiente funcionario podía ver así radicados y asuntos del anterior —incluidos documentos clasificados que él no tiene autorización para consultar—, y cerrar sesión no lo evitaba. Ahora ninguna pantalla con información de trabajo se guarda en el navegador. Se añade además una política de seguridad de contenido: una segunda barrera del navegador que impide ejecutar código introducido en un documento, en una plantilla o en una respuesta del asistente, incluso si la limpieza de texto que ya existía fallara. Por último, cuando el sistema no logra cargar los permisos del usuario, la aplicación lo dice. Antes el menú simplemente aparecía vacío, indistinguible de que le hubieran retirado los permisos: el funcionario llamaba al administrador, que veía sus permisos intactos y no encontraba nada que corregir. Por qué: una acción con consecuencias no puede ocurrir por pasar el ratón, y una pantalla que oculta que está funcionando a medias hace perder el tiempo a dos personas. Impacto: el botón de cerrar sesión se comporta igual al pulsarlo. La institución debe reconstruir la imagen de la interfaz al desplegar.

  • chore(deps,docker): Dependencias al día y los servicios dejan de ejecutarse con privilegios de administrador Se actualizaron las bibliotecas de los once servicios, incluidas las de criptografía, las del servidor web y la que firma el índice electrónico. Por eso el cambio no se dio por bueno leyendo la lista: se ejecutaron las más de 2.500 pruebas y las de base de datos real antes de aceptarlo. La interfaz pasa a Node 22 —la versión anterior dejó de recibir soporte en abril de 2025 y era la que se desplegaba— y a la última versión de su marco de trabajo, que incluye el arreglo del límite de tamaño de archivos corregido en el bloque anterior. Diez de los once servicios se ejecutaban como administrador dentro de su contenedor. Todos procesan información que no controlan —documentos, correos, formularios—, así que un fallo en cualquier biblioteca daba el control completo del contenedor en vez de solo el del proceso. Ahora corren con un usuario sin privilegios. Y la imagen del almacén de documentos deja de seguir la etiqueta «última versión»: ahí vive la protección de retención irreversible, y que cambie sola en una actualización rutinaria no es aceptable. Por qué: ejecutar como administrador anula el aislamiento del contenedor precisamente donde se procesa información no confiable, y una versión sin soporte no recibe correcciones de seguridad. Impacto: ningún cambio visible en el uso. Las imágenes deben reconstruirse al desplegar.

  • fix(gateway,auth,frontend): La dirección de red que quedaba registrada la elegía el propio visitante El sistema confiaba en una cabecera que cualquiera puede escribir para saber desde qué dirección llegaba cada petición. Dos consecuencias: el límite de peticiones se esquivaba cambiándola en cada intento, y —más grave— la dirección que quedaba grabada en el registro de auditoría, que es inmutable y tiene valor probatorio, era la que el visitante decidía poner. Un dato de la cadena de custodia que se puede falsificar es peor que uno vacío, porque el vacío no engaña a nadie. Ahora esa dirección se calcula en el servidor siguiendo la cadena de intermediarios declarados como de confianza, y se transmite internamente por una vía que el visitante no puede alterar. Si la institución no ha declarado sus intermediarios, la cabecera se ignora por completo — se prefiere un dato inútil a uno falso. Se corrige además un problema que afectaba al uso diario: todas las peticiones de todos los usuarios se contaban como si vinieran de la misma dirección, porque la interfaz no transmitía la del navegador. Con varias consultas por pantalla, eso producía bloqueos por «demasiadas peticiones» en una mañana normal de ventanilla. Y el alta ciudadana de PQRS estrena un límite propio y más estricto: crea radicados sin autenticación y consume números que no se reutilizan. Por último, el registro de auditoría deja de anotar una entrada por cada petición para decir que el token era válido. Eso sepultaba los hechos que de verdad importan —accesos, firmas, reclasificaciones— bajo un ruido enorme, y volvía el registro ilegible justo para quien viene a investigar. Los intentos con token inválido sí se siguen registrando. Por qué: un registro con valor probatorio en el que casi todas las filas son ruido no sirve para probar nada, y una dirección que elige el visitante tampoco. Impacto: la institución debe declarar sus intermediarios de red al desplegar (TRUSTED_PROXIES); sin ello el límite por dirección no distingue usuarios.

  • fix(ci): Las pruebas automáticas nunca se habían ejecutado en el sistema de integración continua El pipeline que debía verificar cada cambio moría en su primera línea, por una diferencia de configuración entre la forma en que lo invocaba y la que usa el proyecto. Como la herramienta se detiene en el primer fallo, la ejecución de las pruebas nunca llegaba a ocurrir — y el pipeline llevaba tiempo dando la impresión contraria. Ahora el sistema de integración continua usa exactamente la misma comprobación que ejecuta un desarrollador en su máquina, en vez de una copia que podía divergir; ejecuta las pruebas de los once servicios en paralelo; y estrena dos carriles que no existían: pruebas contra una base de datos real —256 pruebas que estaban escritas pero jamás se habían ejecutado— y pruebas del frontend, que no tenía ninguna. Se añade también la publicación de imágenes etiquetadas por versión, que es lo que hace posible volver atrás tras una actualización fallida. Al ejecutarlo de verdad aparecieron tres problemas que la lectura no revelaba, incluidos dos en el propio arreglo. Se corrigieron antes de darlo por bueno. Por qué: un pipeline que no ejecuta las pruebas no es una verificación, es una decoración — y da una confianza que no corresponde. Impacto: solo integración continua. Ningún cambio en el comportamiento del sistema.

  • feat(infra): El respaldo y la restauración existen de verdad, y se han probado de punta a punta El procedimiento de respaldo vivía únicamente como un ejemplo dentro de la guía, y ese ejemplo no funcionaba: se conectaba con un usuario de pruebas, copiaba los archivos a una carpeta que no existe, y —lo más grave— si el volcado fallaba generaba igualmente un fichero vacío y borraba los respaldos anteriores. Ejecutado de madrugada por una tarea programada, eso significa acumular meses de copias vacías con apariencia de normalidad. El procedimiento de recuperación, por su parte, apagaba la base de datos y acto seguido intentaba restaurar sobre ella. Ahora hay dos programas reales. El de respaldo copia la base de datos y los documentos, comprueba que la copia se puede leer antes de darla por buena, y solo entonces borra las antiguas; si algo se interrumpe, la copia queda marcada como incompleta y el programa de restauración se niega a usarla. El de restauración pide confirmación escrita, y al terminar verifica la cadena de integridad del registro de auditoría: que la base arranque no demuestra que la traza documental esté intacta, y es precisamente eso lo que una entidad tiene que poder acreditar. Si algo se manipuló, lo dice y señala el asiento. El ciclo completo —copiar, destruir, restaurar, verificar— se ha ejecutado sobre un entorno real, y la verificación se puso a prueba alterando deliberadamente un asiento: la detecta. Se añade además el procedimiento de actualización y vuelta atrás, que no existía, incluyendo la advertencia de que revertir el programa no revierte los cambios de estructura de la base: para eso hace falta el respaldo. Por qué: un respaldo cuya restauración nunca se ha probado no es un respaldo, es un archivo. Y ninguna institución debería poner documentos oficiales en un sistema sin esa garantía. Impacto: solo despliegue y operación.

  • fix(gateway,storage,frontend): Ya se pueden adjuntar documentos escaneados: antes fallaba todo lo que pasara de 512 KB El servidor de la interfaz traía un límite de tamaño de 512 KB por petición que nunca se había configurado, y como los adjuntos pasan por él, cualquier PDF escaneado real era rechazado con un escueto «Error al subir el archivo». La importación masiva de expedientes era directamente inservible. Subir el límite a secas habría sido peor: el sistema copiaba cada archivo entero en memoria dos veces —una en la puerta de entrada y otra en el servicio de almacenamiento— y no existía ningún tope de tamaño, así que un archivo grande no habría dado un error sino que habría tumbado el servicio. Por eso el cambio hace las tres cosas a la vez: los archivos ahora se transmiten por trozos sin acumularse en memoria, hay un tope explícito que devuelve un mensaje claro cuando se supera, y el límite de la interfaz sube a 512 MB. Por qué: un sistema de gestión documental en el que no se puede adjuntar un documento escaneado no cumple su función. Impacto: los adjuntos y la importación masiva funcionan. Un archivo por encima del tope recibe ahora un error explícito con el tamaño máximo admitido, en vez de un fallo genérico.

  • fix(document): Modificar un radicado ya deja constancia, y una anulación no se deshace editando un campo Cambiar el asunto, el destinatario, el estado o el número de folios de un radicado no quedaba registrado en ninguna parte — llamativo, porque consultarlo sí quedaba. Un documento oficial con número inmutable que se puede editar sin dejar constancia no cumple lo que la traza documental exige. Además, un radicado anulado —decisión que pasa por solicitud y aprobación de un supervisor— podía volver al estado activo con una simple edición, sin causal, sin aprobador y sin registro. Ahora el sistema lo rechaza y explica por qué: revertir una anulación, si alguna vez procede, es un acto propio con su justificación, no el efecto lateral de editar un campo. Cada modificación registra ahora qué campo cambió, de qué valor y a cuál, junto con quién la hizo. Y si por cualquier motivo esa constancia no pudiera guardarse, la modificación no se aplica: se prefiere que un cambio falle a que ocurra sin dejar rastro. Por qué: la trazabilidad de un documento de archivo no admite huecos, y una anulación que se deshace por una vía lateral no es una anulación. Impacto: intentar modificar un radicado anulado devuelve ahora un error explícito.

  • fix(document,archive): El registro de correspondencia dejaba de ser público por defecto El sistema ofrece dos canales de cosecha (OAI-PMH y CMIS) pensados para que otras instituciones y portales de datos abiertos consulten lo que la entidad ha decidido publicar. Estaban activos desde el primer arranque, y lo que dejaban ver dependía de que cada documento estuviera clasificado como reservado: como el valor por defecto de un radicado es «público», la práctica era «se publica todo salvo que alguien se acuerde». Por ahí salían el número, el asunto y la dependencia de cada radicado, y podía descargarse el contenido de los anexos, sin quedar registro de quién lo hizo. Ahora estos canales vienen apagados y la entidad los enciende cuando lo decide, que es lo que corresponde: publicar el registro de correspondencia forma parte del esquema de publicación de la entidad (Ley 1712, art. 12), no de la instalación del software. Mientras están apagados, responden como si no existieran. Y cuando se encienden, cada consulta y cada descarga quedan registradas en la traza de auditoría, con qué se pidió y cuánta información se entregó. Por qué: qué información publica una entidad es una decisión suya, y necesita poder responder después a quién consultó su archivo. Impacto: si su institución ya usaba estos canales, hay que activarlos explícitamente tras actualizar.

  • fix(gateway,tenant,storage): Una dirección web torcida podía llevar a un sitio distinto del que el sistema creía autorizar El portal de entrada decidía a qué servicio mandar cada petición mirando la dirección tal como llegaba, pero la libreria que la reenviaba la simplificaba después —igual que .. en una ruta de carpetas sube un nivel—. Resultado: una dirección construida a mano podía autorizarse como una cosa y ejecutarse como otra. Por esa vía se alcanzaba el registro de instituciones, que estaba deliberadamente fuera del alcance público, y se podía sustituir o dar de baja el archivo adjunto de un radicado sin que la traza registrara quién. Ahora el portal rechaza esas direcciones en la puerta, antes de comprobar la identidad y antes de decidir el destino. Y no se ha confiado solo en eso: el registro de instituciones y las operaciones que modifican archivos adjuntos exigen además su propia credencial de servicio, de modo que si una de las dos protecciones fallara, la otra sigue en pie. Por qué: si la puerta de entrada autoriza una dirección y ejecuta otra, ninguna de sus decisiones vale. Y no aparecer en una lista de rutas no es una protección: es una ausencia. Impacto: ningún cambio visible en el uso normal del sistema.

  • fix(infra): El manifiesto de despliegue en producción nunca se había ejecutado El fichero que describe cómo levantar OrpycaMCP en una institución real estaba escrito pero nunca se había probado, y fallaba en cadena: siete de los once servicios no llegaban a construirse, la preparación inicial de la base de datos apuntaba a una ruta que no existe (y Docker, en vez de avisar, monta una carpeta vacía en silencio), el gestor de identidades moría al arrancar por una configuración de certificados incompleta, y ninguno de los secretos que los servicios usan para hablar entre sí estaba definido — lo que no daba un error visible, sino un sistema aparentemente en marcha en el que no se descarga un solo anexo y nadie con segundo factor puede entrar. Además, cualquier reinicio del servidor apagaba la institución de forma permanente. Todo eso está corregido y comprobado sobre contenedores reales, no solo leído: los doce servicios se construyen, la base de datos ejecuta su preparación inicial y el gestor de identidades arranca. El fichero de ejemplo de variables documenta ahora cada secreto, qué dos servicios lo comparten, y cuáles hay que respaldar con la misma disciplina que la base de datos — perder la clave del segundo factor obliga a que toda la plantilla lo vuelva a configurar. Por qué: sin un manifiesto que arranque no hay despliegue posible, y ninguna otra comprobación de seguridad puede verificarse de verdad. Impacto: solo despliegue. Queda pendiente el ensayo completo con los dieciséis servicios a la vez, que necesita un realm de identidades de producción.

  • fix(frontend): La pantalla de perfil ya no ofrece emitir la credencial de firma a quien no puede, y muestra los códigos de recuperación Dos correcciones en la misma pantalla. La primera: emitir la credencial de firma requiere un permiso que no todos los usuarios tienen, pero la pantalla ofrecía el botón a todos y el rechazo llegaba al pulsarlo. Ahora se dice antes, con el motivo y a quién acudir. La segunda: los códigos de recuperación del segundo factor se muestran al activarlo —advirtiendo que no volverán a mostrarse—, se avisa cuando quedan dos o menos, y se pueden generar de nuevo. Al pedir el código de confirmación, la pantalla reconoce sola si es de la aplicación autenticadora o de recuperación, sin que haya que indicarlo. Por qué: una interfaz que ofrece lo que el sistema va a rechazar convierte un permiso faltante en un error incomprensible; y unos códigos que existen pero no se muestran no existen para quien los necesita. Impacto: solo interfaz.

  • feat(auth): Perder el teléfono ya no deja la cuenta bloqueada: el segundo factor tiene códigos de recuperación El segundo factor (TOTP) vive en el teléfono, y hasta ahora todas las formas de dejar de depender de él —desactivarlo, cambiarlo a un teléfono nuevo, o retirar la credencial personal de firma— pedían un código de ese mismo teléfono. Quien lo perdía se quedaba fuera, y la única salida era que alguien tocase la base de datos a mano. Al activar el segundo factor se entregan ahora 10 códigos de un solo uso. Cualquiera de ellos sirve para desactivar el segundo factor o para pasarlo a un teléfono nuevo. También se puede pedir un juego nuevo cuando quedan pocos, lo que anula el anterior. Los códigos se muestran una sola vez: en el sistema solo queda una huella irreversible de cada uno, así que no hay forma de volver a consultarlos —solo de generar otros—. La pantalla puede avisar de cuántos quedan sin usar, pero nunca mostrarlos. Se entregan en el mismo momento en que el segundo factor queda activo, para que no exista un intervalo en el que esté protegiendo la cuenta sin haber dado la vía de recuperación. Un código de recuperación no sirve para firmar. Firmar sigue exigiendo el teléfono: un código impreso o guardado en un gestor de contraseñas no demuestra que quien firma tenga el dispositivo, y esa demostración es justamente lo que da valor a la firma. Recuperar el acceso y firmar son actos distintos y se piden pruebas distintas. Por qué: era una brecha de producto reconocida — sin ella, perder el teléfono impedía incluso retirar la propia credencial de firma. Impacto: nada de lo existente cambia; los campos nuevos son opcionales. Requiere aplicar la migración 015 en cada entidad.

  • feat(archive,frontend): Ya se puede cargar un fondo acumulado en masa, y la pantalla de expedientes lo distingue del archivo corriente El incremento anterior permitía cerrar expedientes de fondo acumulado —documentación antigua, típica de entidades liquidadas— pero no había forma práctica de cargarlos: la carga masiva no declaraba el origen del expediente, así que todos entraban como archivo corriente y el sistema los rechazaba uno a uno al ver que su instrumento era una tabla de valoración. Migrar un fondo acumulado son miles de expedientes; la única alternativa era crearlos de uno en uno. Ahora la carga masiva acepta el origen y las fechas extremas por expediente, y la pantalla de creación tiene un selector de origen que cambia el catálogo que ofrece: un expediente corriente se clasifica bajo la TRD y uno de fondo acumulado bajo la TVD, nunca al revés. Si no hay tablas de valoración registradas, la pantalla lo dice y enlaza a su administración en lugar de ofrecer un catálogo que el sistema va a rechazar. El aviso de la fecha extrema final advierte que el calendario de retención puede resultar ya vencido — que en un fondo acumulado es lo normal, no un error. Por qué: sin esto, la gestión de fondo acumulado existía pero era inalcanzable para su caso de uso real. Impacto: los campos nuevos son opcionales, así que nada de lo que ya funcionaba cambia. Queda deliberadamente fuera el cálculo de la retención en almacenamiento inmutable para fondo acumulado: hoy el sistema se abstiene de fijarla en vez de fijarla mal, porque esa fecha no se puede corregir después.

  • docs(en,es): La documentación en inglés ya cubre lo mismo que la española No era una diferencia de matices: la referencia de API en inglés omitía 95 de los 249 endpoints y, de forma sistemática, todas las notas de control de acceso —qué permiso exige cada ruta, qué se recorta por nivel de seguridad, por qué un 404 a veces significa "no puedes verlo"—. Es justo lo que alguien de fuera necesita para juzgar si el sistema sirve, así que una página que parecía terminada estaba engañando por omisión. Se completaron los bloques ausentes (segundo factor y credencial de firma, administración de usuarios y grupos, PINAR, preservación digital, firma electrónica, envíos postales, interoperabilidad OAI-PMH/CMIS, importación y exportación, asistente y búsqueda semántica) y las otras cuatro páginas pendientes: primeros pasos, dominio documental, despliegue y normativa. En despliegue faltaban dos apartados cuya ausencia deja una instalación que rechaza todas las sesiones o que nunca completa el inicio de sesión. La cobertura se comprobó contando, no leyendo: los 249 endpoints de ambos idiomas casan exactamente, y las 40 pantallas del sistema figuran en las tablas de los dos idiomas. Por qué: la documentación en inglés es la única puerta por la que una institución o un contribuidor no hispanohablante evalúa el proyecto. Impacto: solo documentación. De paso se corrigieron dos afirmaciones que habían quedado desactualizadas y decían que hay cosas sin construir que sí están construidas (el asistente conversacional y la firma XAdES del índice electrónico); lo que sigue pendiente en firma es la acreditación ante la ONAC, que es otra cosa.

  • feat(frontend): La aplicación ya lleva el logotipo institucional de Orpyca Hasta ahora no lo llevaba. Lo que hacía de logotipo era una imagen de relleno —un orbe genérico recortado de una pieza publicitaria— y el panel lateral, que es la superficie de marca que se tiene delante toda la jornada, ni siquiera usaba esa: mostraba un icono de archivador de una librería de iconos. Entran los originales: el isotipo (el anillo verde con la serpiente) y el logotipo completo con el nombre y la línea "orfeo pyme calidad", además de los iconos de pestaña del navegador y de pantalla de inicio en iOS. El reparto entre uno y otro no es estético, lo imponen dos límites del propio activo. El nombre "Orpyca" está dibujado en negro degradado, así que sobre fondo oscuro desaparece: en el panel de acceso el nombre va escrito con la tipografía del sistema de diseño, junto al isotipo, sobre un verde de marca profundo donde el anillo recupera la silueta con la que fue diseñado. Y la línea "orfeo pyme calidad" es diminuta: a la altura de una barra de navegación no llegaría a 6 píxeles, así que el logotipo completo solo aparece donde hay sitio —la tarjeta de acceso— y en las franjas estrechas se usa el isotipo con el nombre en texto. Escribir el nombre en vez de incrustarlo en la imagen tiene además una ventaja práctica: se ve nítido en cualquier pantalla y sigue leyéndose cuando se amplía el navegador. La marca aparece ahora en el panel lateral, en la barra superior en móvil (donde el panel lateral está plegado y la aplicación no mostraba ninguna señal de marca), en la pantalla y el panel de acceso, en la barra y el pie de la página pública, en la pantalla de acceso denegado y en la de verificación de sesión. Al compartir un enlace del sistema en mensajería o redes, la vista previa muestra el logotipo. Por qué: un sistema que una institución instala bajo su nombre no puede presentarse con un icono de librería y una imagen de relleno. Impacto: solo interfaz; ningún contrato de API cambia. Se verificó que la línea de firma sobre el fondo oscuro cumple el contraste mínimo de accesibilidad (pasó de 4,31:1 a 10,6:1).

  • feat(archive): Los expedientes de fondo acumulado ya pueden gestionarse, con su calendario calculado desde la fecha correcta (ADR-026, segundo incremento) El incremento anterior permitía valorar un fondo acumulado —documentación antigua, típica de entidades liquidadas— pero no gestionarlo: ningún expediente podía clasificarse bajo esa tabla de valoración. Esto lo habilita, y la parte delicada no es el permiso sino desde qué fecha se cuenta la retención. El sistema calculaba los plazos desde la fecha de cierre del expediente. Para documentación corriente es correcto: un expediente se cierra cuando deja de producirse. Para un fondo acumulado sería gravísimo: un expediente cuyos documentos son de 1985, registrado hoy, vería su plazo de archivo central terminar en 2046 — y esa fecha es la que se graba como protección de borrado irreversible en el almacenamiento. Documentación cuya conservación se agotó hace décadas quedaría imposible de eliminar durante veinte años más, sin marcha atrás posible, porque ese tipo de protección no admite acortarse. Ahora la fecha base es siempre explícita y debe declararse en cada cálculo: para un fondo acumulado es la fecha del documento más reciente que contiene, nunca la de registro. Quién fue esa fecha y por qué se eligió queda congelado junto al expediente, para que dentro de veinte años sea comprobable y no haya que reconstruirlo. Y el caso de un plazo ya vencido —lo normal en un fondo acumulado— se muestra tal cual, sin empujarlo hacia adelante: el expediente queda listo para su disposición final desde el día en que se registra. Se cerró además una vía que nadie había pedido revisar: al permitir cerrar estos expedientes, tres procesos de preservación habrían calculado esa misma protección de borrado desde la fecha equivocada. Quedan bloqueados para fondo acumulado hasta que el siguiente incremento los habilite correctamente. Por qué: sin esta pieza, una entidad con fondo acumulado podía registrar su instrumento de valoración y aun así no poder archivar nada bajo él. Impacto: 721 pruebas unitarias y 168 de integración contra base de datos real, incluida una obligatoria con fechas de los años 80 cuyo resultado solo puede ser correcto si se usó la fecha debida — con la equivocada el plazo caería siempre en el futuro y la prueba fallaría. Queda para más adelante el cableado real de la protección de borrado y la publicación previa del inventario de eliminación, que sigue pendiente de que se determine el artículo y el plazo legal exactos.

  • fix(document): auditoría de la creación masiva de radicados + reactivación de la suite de pruebas de carga masiva La creación de radicados en lote (POST /api/v1/batch/documents) no dejaba ningún rastro en la auditoría inmutable: se podían crear cientos de documentos oficiales, con número de radicado irrepetible, sin que quedara constancia de quién lo hizo ni cuándo. Se detectó porque la misma operación en lote sobre expedientes, en otro servicio, sí lo hacía desde el principio — dos funciones gemelas, solo una dejaba rastro. Se corrige dejando un asiento de auditoría por cada radicado creado (no uno por lote completo, para no perder la trazabilidad individual), atribuido siempre a la persona que encargó el lote, y de forma que un lote parcialmente exitoso (por ejemplo 480 de 500) deje constancia exacta de lo que sí entró y lo que no, nunca de la intención original. Además, la batería de pruebas automáticas de esta función llevaba deshabilitada por completo desde su creación (hacía referencia a una pieza de pruebas que nunca llegó a existir). Se reescribió por completo, cubriendo también el permiso de acceso que se había añadido recientemente sin ninguna prueba que lo verificara. Por qué: crear documentos oficiales en masa sin dejar rastro de autoría es justo lo que la auditoría existe para impedir; una batería de pruebas apagada no protege nada, y menos un permiso de acceso recién añadido. Impacto: sin cambios de esquema (la tabla de auditoría ya existía). Suite de pruebas del servicio de documentos: 284 aprobadas, 20 pendientes (antes 273 aprobadas, 25 pendientes — las 20 restantes son pruebas de integración contra base de datos real, no relacionadas con este cambio).

  • feat(frontend): /admin/interoperabilidad — exportar/importar radicados, carga masiva y rastreo postal (Fase 8, última del cierre del desfase API↔UI) Cinco funciones del backend llevaban tiempo listas sin ninguna pantalla que las usara: exportar un paquete ZIP de radicados, importarlo de vuelta preservando el número original, crear documentos o expedientes en lote, y consultar el rastreo de un envío postal contra el operador. La nueva pantalla cubre las cuatro primeras en pestañas, y la última se añadió como botón "Actualizar rastreo" en la bandeja de envíos existente. La parte más delicada es la importación: es todo-o-nada (si un solo archivo del paquete no coincide con su huella digital declarada, no se importa nada) y cuando eso pasa, la pantalla muestra exactamente qué archivo falló y por qué, nunca un mensaje genérico de "no se pudo importar". La carga masiva, al ser una escritura en bloque sobre documentos oficiales, exige confirmar con el número exacto de elementos antes de ejecutar, y mientras se procesa consulta el progreso cada dos segundos distinguiendo siempre "no se pudo consultar el estado" de "todavía no hay resultados" (nunca se muestra un progreso de cero inventado). El rastreo postal, al no tener todavía una integración real con el operador, lo dice explícitamente en vez de aparentar una consulta en vivo. Al construirla se descubrió que la carga masiva, aunque completa en la pantalla, no podía funcionar: la puerta de enlace central del sistema no tenía registrada la ruta que usan esos dos endpoints. Eso quedó corregido (ver más abajo, en Corregido), pero al corregirlo la dirección de consulta de progreso cambió de forma y la pantalla se quedó con la antigua. Sumado a un defecto de refresco del panel de progreso, la carga masiva no informaba de su resultado en ninguna circunstancia. Lo detectó la revisión de experiencia de uso del 2 de agosto de 2026 y quedó corregido en la entrada de esa revisión, más abajo. Exportar, importar y rastreo postal sí funcionaron de punta a punta desde el principio. Por qué: cierre de la última fase pendiente del plan de poner en la interfaz todo lo que el backend ya expone. Impacto: 1 pantalla nueva con 4 secciones, 1 botón nuevo en envíos postales, 27 pruebas automáticas nuevas; exportar/importar/rastreo quedan operables de punta a punta, la carga masiva queda a la espera de un ajuste de ruteo fuera de esta entrega.

  • feat(archive): TVD — Tabla de Valoración Documental de fondo acumulado (ADR-026, Increment 1: registro y convalidación, migración 039) Una entidad con fondo acumulado (documentación ya producida, sin criterio archivístico, típica de dependencias suprimidas o entidades liquidadas) no tenía dónde registrar su instrumento de valoración: sin él, no podía cerrar ni disponer nada de ese fondo. La TVD entra como el mismo instrumento que la TRD, con el mismo circuito de convalidación (Comité → Consejo → RUSD → publicación) — un discriminador sobre la misma tabla, no una tabla nueva, para no bifurcar el punto único del que dependen siete escrituras irreversibles de retención WORM. Cuatro capas lo blindan: un código de serie solo puede pertenecer a un instrumento; las listas se leen siempre a través de una vista que ya filtra por tipo; el contenido propio de cada instrumento (fondo, fechas extremas, justificación de la valoración para la TVD) es obligatorio o prohibido según el tipo, verificado por la base de datos; y, lo más importante en este incremento, ningún expediente puede todavía clasificarse bajo una TVD — la base de datos lo impide directamente, así que el riesgo de retención irreversible es cero mientras esta pieza no exista. Rutas /api/v1/tvd espejo de las de TRD (crear, editar, versionar, aprobar, devolver, convalidar, registrar RUSD, derogar), mismo permiso que TRD. También se añadió la consulta de historial completo de versiones (GET .../{code}/versiones) tanto para TRD como para TVD — estaba prometida desde el incremento anterior y nunca se había construido. Por qué: una entidad con fondo acumulado no tenía ninguna forma de valorarlo dentro del sistema — hallazgo de la auditoría de conformidad archivística sobre el modelo de retención. Impacto: 1 migración de esquema, rutas nuevas bajo /api/v1/tvd + historial de versiones nuevo para TRD, sin romper el contrato existente; 20 tests unitarios + 13 de integración contra Postgres real nuevos, suite completa sin roturas (721 unitarios + 164 de integración, antes 701+151).

  • feat(frontend): pantalla /admin/tvd + circuito de convalidación compartido con /admin/trd (ADR-026, frontend) Pantalla nueva /admin/tvd: mismo patrón visual que /admin/trd (árbol de agrupaciones, alta, edición) pero con las columnas que le corresponden a una TVD -- fondo/productora, fechas extremas, estado del instrumento -- y sin la columna de archivo de gestión, que en una TVD no existe. La justificación de la valoración es el campo principal del formulario de creación, no uno secundario: es el objeto del instrumento. El circuito completo (aprobar, convalidar, registrar RUSD, derogar, devolver) se construyó como un componente compartido y se conectó a las dos pantallas -- TRD y TVD -- para que ambas lo resuelvan de la misma manera; los actos irreversibles (convalidar, derogar) avisan dentro del cuadro de confirmación, antes de pulsar el botón, nunca después de que la acción falle. Ningún botón de transición se ofrece si el estado actual no lo permite. Se comprobó, porque el ADR lo pedía expresamente, que el chip de sugerencia de clasificación de radicación nunca pudiera proponer una TVD: no lo hace, porque nunca consulta las series/agrupaciones -- resuelve el tipo documental sugerido a partir de radicados precedentes, un dato distinto. Por qué: sin pantalla, ninguna entidad con fondo acumulado puede completar el circuito de valoración que el backend ya expone. Impacto: 2 rutas nuevas, 1 componente compartido, proxies nuevos (incluido el historial de versiones que le faltaba a TRD en el frontend); 68 tests nuevos, suite completa 1295/1295 (antes 1227), sin errores de tipos.

  • fix(archive): remediación del dictamen de conformidad+seguridad sobre el circuito de convalidación de la TRD (ADR-025, migración 038) Cinco hallazgos de conformidad sobre el Increment 2 (recién liberado): aprobada era un callejón sin salida que empujaba a declarar una convalidación falsa — se añade devolver (con observaciones, motivo obligatorio); el criterio de qué puede autorizar una eliminación efectiva estaba invertido (bloqueaba eliminaciones legítimas de expedientes cuyo instrumento fue convalidado y luego derogado) — corregido para leer el snapshot congelado del expediente, no la fila viva; derogar sin sucesora dejaba el código de serie sin ningún camino de vuelta — ahora se bloquea mientras haya expedientes abiertos bajo el código, y crear una versión nueva ya no da 404 permanente; la aprobación del Comité no dejaba ni acta ni fecha — ahora es obligatoria y se verifica antes de convalidar; el RUSD y la publicación solo eran capturables en el momento de convalidar, cuando la norma da hasta 30 días hábiles después — nuevo endpoint para registrarlos. De regalo, cinco hallazgos de seguridad sobre el mismo código: el informe de expedientes pendientes de revisión no filtraba por nivel de clasificación (cualquiera con permiso sobre la TRD veía expedientes reservados); el re-apunte de tipos documentales podía romperse con un 500 en un flujo perfectamente legítimo; una carrera entre dos convalidaciones concurrentes podía dejar referencias huérfanas sin avisar; los registros de auditoría no dejaban ver el estado anterior de una derogación; y el fundamento jurídico de una versión ya convalidada solo lo protegía la aplicación, nunca la base de datos. Por qué: una auditoría de conformidad archivística + una de seguridad, ambas sobre el mismo incremento recién entregado. Impacto: 3 endpoints nuevos (devolver, registrar-rusd, más el body ahora obligatorio de aprobar), 1 migración de esquema, 22 tests nuevos; suite completa sin roturas (701 unitarios + 151 de integración contra Postgres real, antes 679+145).

  • feat(frontend): MVP de PINAR — Plan Institucional de Archivos en /admin/pinar (Fase 7, Acuerdo AGN 003/2015) PINAR tenía 30 endpoints en el backend y ninguna pantalla. Este MVP cubre el listado de planes (filtro por estado, crear una versión nueva siempre en borrador) y el workspace de un plan con un indicador visual del ciclo de vida (borrador → aprobado → en ejecución → cerrado, informativo — las transiciones solo ocurren por botón + confirmación) y una pestaña de tablero de avance por objetivo/por eje. Aprobar un plan congela su contenido de forma permanente (queda registrado en la auditoría inmutable): el aviso aparece antes de confirmar, no después de un error. El resto de PINAR (aspectos críticos, priorización, objetivos, proyectos, seguimiento, instrumentos, mapa de ruta) queda para una fase posterior. Por qué: cerrar la Fase 7 del desfase entre lo que el backend ya exponía y lo que la aplicación permitía usar. Impacto: 2 rutas nuevas + 6 proxies SSR; 41 tests nuevos; suite completa 1227/1227, sin errores de tipos.

  • feat(archive): circuito de convalidación de la TRD — aprobar/convalidar/derogar + ratificación de series migradas (ADR-025 Increment 2) Sobre el versionado append-only de la TRD (Increment 1, ya en main), se añade el circuito completo del Acuerdo AGN 004/2019: aprobar una versión en borrador (aprobación del Comité), convalidarla (con acto administrativo, fecha e instancia convalidante — deroga la versión previa y re-apunta los tipos documentales/plantillas que quedarían huérfanos), derogar una versión vigente, y ratificar una serie preexistente marcada migrada sin necesidad de crear una versión nueva. Nuevo informe paginado de expedientes pendientes de revisión. La regla de que la eliminación efectiva de documentos exigirá una serie estrictamente convalidada queda codificada desde ya, aunque el sistema todavía no tiene ningún proceso que ejecute esa eliminación. Por qué: cerrar la fuga retroactiva (Increment 1) dejó todas las series existentes "sin poder afirmar si fueron convalidadas" — hacía falta la vía real para que una entidad declare el acto administrativo que ya tenía en papel. Impacto: 4 endpoints nuevos bajo /api/v1/trd en archive-service, mismo control de acceso que el resto de la TRD; sin migración de base de datos; 36 tests nuevos (unitarios + contra Postgres real), sin roturas en la suite existente.

  • feat(frontend): búsqueda semántica y de antecedentes en /busqueda (Fase 6, E21) Dos pestañas nuevas junto a la búsqueda exacta existente — Semántica (similitud vectorial sobre el contenido indexado) y Antecedentes (recuperación anclada y citada, por texto o por un radicado pivote) — resueltas por ?tab= como deep-link, igual patrón que el conmutador de vista de /bandeja. Tres proxies SSR nuevos (D-05): búsqueda semántica, antecedentes, y resolución de un radicado por su número (verificación del pivote antes de buscar antecedentes "por radicado"). Los resultados se presentan siempre como documentos parecidos (similitud, con una banda cualitativa explicada), nunca como coincidencias exactas; un fallo de red se distingue de "sin resultados"; y el recorte por permisos de acceso es invisible por diseño — la pantalla nunca revela cuántos resultados quedaron ocultos. Por qué: el servicio de conocimiento ya exponía búsqueda semántica y antecedentes sin ningún consumidor de exploración directa en el frontend. Impacto: 3 proxies SSR nuevos con 18 tests; pestañas con roles ARIA de tablist accesibles por teclado; suite completa 1186/1186 (antes 1168), svelte-check 0 errores.

  • feat(frontend): ayuda generativa (RAG) integrada en redacción y clasificación de radicación/borradores (F6, E21) El RAG se integra donde ya se redacta y clasifica, no como módulo aparte. Botón "Sugerir con antecedentes" en /radicar (paso 1, cuerpo de Salida/Interno) y en el drawer de /borradores, que llama al RAG generativo con citas (E21 Inc.3) vía proxies SSR (D-05). Chips de tipo documental sugerido junto al selector de clasificación, alimentados por /antecedentes (E21 Inc.2, Patrón A) más una resolución honesta de doc_class por precedente (ese endpoint no lo trae en su contrato). Sección "Respuestas" nueva en el drawer de detalle de /bandeja (Salidas que responden a un radicado). Tres reglas no negociables: el texto generado se inserta SIEMPRE marcado como generado y con sus citas (sobrevive el saneo del editor porque usa solo etiquetas permitidas); si no hay citas no se ofrece insertar, nunca una afirmación sin fuente; el chip de tipo documental sugerido nunca se autoaplica; y un fallo del servicio de conocimiento queda contenido en el componente sin bloquear radicar/guardar un borrador. Por qué: cierre de la Fase 6 del desfase API↔UI — el backend ya exponía RAG/antecedentes sin consumidor en el frontend. Impacto: 4 proxies SSR nuevos, 2 componentes nuevos (lib/components/features/rag/), lib/utils/ragInsert.js nuevo; 51 tests nuevos; suite completa 1186/1186 (antes 1135), svelte-check 0 errores.

  • fix(notification): endurece el destino de los webhooks salientes (hallazgo Media, auditoría de seguridad — no bloqueante) WebhookCreate.url validaba la forma, no el destino: un administrador podía suscribir un webhook a postgres, keycloak o cualquier IP privada/loopback/link-local, dirigiendo 3 reintentos × evento contra la red interna. El auditor confirmó severidad Media (POST ciego, sin cabeceras controlables, sin seguimiento de redirects). Nuevo app/core/webhook_security.py::validate_webhook_target, usado al crear la suscripción (rápido, sin DNS, produce 422 con el motivo) y en cada intento de entrega (con resolución DNS real, cierra el caso de DNS rebinding y cubre suscripciones preexistentes). Kill-switch de desarrollo webhook_allow_private_targets (False por defecto — estricto). Suscripciones preexistentes que ya no cumplen la política no se borran: simplemente dejan de entregarse. Por qué: auditoría de seguridad de notification-service. Impacto: POST /api/v1/webhooks gana un 422 nuevo; 18 tests pytest nuevos (155 passed en el servicio); sin migración.

  • fix(auth): cierre de hallazgo Alto — auto-escalada a USUA_PERM_ROOT; ROOT ahora se aprovisiona fuera de banda (ADR-024) Un administrador de entidad (USUA_PERM_ADMIN) podía auto-concederse USUA_PERM_ROOT (bypass total de RBAC) por dos vías — crear un usuario con is_root: true, o conceder USUA_PERM_ROOT al grupo del que él mismo es miembro — y GET /auth/permissions ofrecía USUA_PERM_ROOT sin filtrar en el desplegable del panel admin. Un tercer eje: podía subir el clearance de un grupo por encima de su propio clearance efectivo. Las tres vías responden ahora 403 a un llamante no-ROOT; el catálogo de permisos omite USUA_PERM_ROOT para no-ROOT. Como el primer ROOT de un tenant no tenía ninguna vía de creación fuera de la API, se añadió app/ops/grant_root.py (script de operación, no HTTP): concede ROOT a un usuario existente de un tenant explícito, idempotente y auditado. Por qué: auditoría de seguridad dedicada a auth-service — el administrador de entidad era, de facto, indistinguible de ROOT. Impacto: POST /auth/users, PUT /auth/groups/{gid}/permissions/{pid} y PUT /auth/groups/{gid}/clearance ganan un 403 nuevo para no-ROOT; GET /auth/permissions cambia de forma para no-ROOT; nuevo script app/ops/grant_root.py --tenant <slug> --user <username>; 10 tests nuevos (198 passed, 15 skipped); sin migración.

  • fix(tenant,gateway): app/core/authz.py + Canal A en tenant-service; /api/v1/tenants/ sacado del gateway (cierra 2 hallazgos Críticos de auditoría de seguridad) El servicio no tenía ningún gate de autorización: 13 escrituras (catálogos, dependencias, festivos, parámetros) quedaban abiertas a cualquier autenticado del tenant — un radicador podía borrar los festivos del año, adelantando el cálculo de todos los plazos legales de respuesta. Un comentario en config.py afirmaba (falsamente) que "el api-gateway media el acceso"; se borró junto con sus gemelos. Cerrado con app/core/authz.py (require_permission, mismo patrón de document-service/archive-service) — USUA_PERM_ADMIN en las 13 escrituras + las 18 del PINAR; los GET quedan solo autenticados. Además, /api/v1/tenants/ (registro de tenants, sin scoping de tenant) estaba enrutado por el gateway sin ningún gate — la única fuga entre entidades del sistema: cualquier usuario de la entidad A podía leer y escribir el registro de la entidad B. Se sacó ese prefijo de la tabla de ruteo del gateway (no hay consumidor legítimo externo). Por último, las escrituras que antes mutaban sin traza ahora dejan asiento en public.audit_log dentro de la misma transacción (fail-closed, patrón RBACService). Por qué: una auditoría de seguridad dedicada encontró que tenant-service, pese a gestionar plazos legales y el organigrama, no tenía ningún control de acceso, y que el único endpoint sin scoping de tenant del sistema estaba expuesto al exterior. Impacto: /api/v1/tenants/ ya no es alcanzable desde el gateway (cambia el contrato); 40 tests pytest nuevos/actualizados en tenant-service + 2 en api-gateway; sin migración.

  • fix(document): USUA_PERM_TRD + auditoría en el catálogo de metadatos (E03, cierra hallazgo Alto de auditoría) Crear una plantilla de metadatos de documento y las tres escrituras del catálogo de elementos reutilizables (crear/editar/borrar) no exigían ningún permiso — cualquier autenticado del tenant podía anular la validación de metadatos obligatorios de un tipo documental completo, ya que los radicados se validan contra la plantilla activa. Su gemelo en archive-service sí exigía USUA_PERM_TRD; se cierra con el mismo permiso. El borrado exige además min_crud=3. Las cuatro escrituras ahora también auditan en public.audit_log, dentro de la misma transacción que la mutación (fail-closed). Por qué: era una omisión, no una decisión de diseño — ya advertida como hallazgo pendiente en el changelog de /admin/metadatos. Impacto: las lecturas (GET) quedan intactas; 10 tests pytest nuevos; sin migración; cambia el contrato (endpoints que antes no exigían permiso ahora sí).

  • feat(auth): Auditoría inmutable de las escrituras RBAC/URD/clearance (E08, cierra hueco de trazabilidad) Crear usuario, crear grupo, alta/baja de miembro, asignar permiso a grupo, crear/eliminar URD y subir/bajar el clearance de un grupo ahora dejan asiento en public.audit_log, DENTRO de la misma transacción que la mutación — no best-effort: si la auditoría falla, la escritura se revierte; si la librería de auditoría no está instalada, la escritura se rehúsa (500) en vez de saltar el asiento en silencio. tenant_slug es obligatorio y fail-closed. El asiento de clearance incluye el nivel anterior y el nuevo. Por qué: son las vías por las que se escala un privilegio; el panel /admin las convirtió en un clic, así que el hueco pasó de teórico a explotable por error humano. Impacto: sin migración ni cambio de contrato HTTP; 20 tests pytest nuevos.

  • feat(frontend): Administración de TRD/CCD, metadatos, catálogos y parámetros (E03/E04/E14, Fase 5 cierre de desfase API↔UI, segunda tanda) Cuatro pantallas nuevas del panel /admin. /admin/trd: series TRD/CCD en árbol indentado por parent_id (código inmutable, gate real USUA_PERM_TRD) y tipos documentales con CRUD completo. /admin/metadatos: plantillas de expediente y de documento (solo alta — el backend no tiene PATCH/DELETE, nueva versión = nuevo registro), con el json_schema editado como JSON crudo validado antes de enviar; y elementos de metadato reutilizables con CRUD completo. /admin/catalogos: maestro-detalle (catálogos a la izquierda, items a la derecha), que degrada a dos niveles navegables en móvil. /admin/parametros: parámetros del tenant, calendario de festivos (precarga Ley 51/1983 sin llamada externa) y calculadora de días hábiles que muestra el desglose de qué días se descontaron y por qué (el backend solo devuelve la fecha resultante). Hallazgo de dominio reportado (no corregido en el front): a diferencia de TRD/tipos documentales/plantillas de expediente (gate real USUA_PERM_TRD), las plantillas/elementos de metadato de documento y los catálogos/parámetros de tenant-service no exigen ningún permiso en el backend hoy; las cuatro pantallas gatean sus botones de escritura del lado del cliente como medida conservadora. Por qué: cerraba cuatro áreas de administración listadas como "Próximamente" desde la Fase 0 — sin ellas, la clasificación documental, los esquemas de metadatos, los catálogos de referencia y los parámetros de plazo solo eran operables vía API directa. Impacto: 13 proxies SSR nuevos; sin cambios de backend ni de esquema; 55 tests nuevos de proxy; svelte-check 0 errores/0 warnings; 1125 tests vitest verdes.

  • feat(auth): Lectura de membresías, permisos y clearance de grupo (E08, cierra el gap RBAC read) Tres GET nuevos, espejo exacto de la escritura ciega equivalente: GET /api/v1/auth/groups/{group_id}/members (paginado), GET /api/v1/auth/groups/{group_id}/permissions y GET /api/v1/auth/groups/{group_id}/clearance (max_level: null si no tiene fila asignada). Mismo gate USUA_PERM_ADMIN que sus escrituras hermanas. group_id inexistente o de otro tenant → 404 indistinguible, nunca 403. Sin migración. Por qué: el panel /admin/grupos podía dar de alta un miembro/permiso/clearance pero no mostrar el estado actual del grupo — un admin no puede auditar ni corregir lo que no puede ver. Impacto: 3 endpoints nuevos; 14 tests pytest nuevos (200, 404 grupo inexistente/de otro tenant, 403 sin permiso); sin cambios de esquema.

  • feat(frontend): Administración de grupos y dependencias (E08/E14, Fase 5 cierre de desfase API↔UI) Nueva pantalla /admin/grupos: listado con CatalogCrudPanel (crear grupo; no hay edición ni borrado — el backend solo tiene alta+lectura de grupos) y un drawer de detalle para miembros (buscador + alta/baja), permisos por grupo (crud 0-5) y clearance (reutiliza nivelSeguridad.js). Gap de backend verificado y advertido en la propia UI: no existe ningún endpoint de lectura de membresías, permisos ni clearance ya asignados a un grupo — solo altas/bajas ciegas. Nueva pantalla /admin/dependencias: vista árbol del organigrama (GET /dependencias/tree) con un componente nuevo DependenciaTreeNode.svelte (no existía ningún componente de árbol reutilizable en el proyecto), crear/editar/activar/desactivar. Renombrar o desactivar dispara un modal de confirmación de riesgo explícito (nunca un tooltip): las dependencias se correlacionan por nombre, no por id/código, en las reglas de enrutamiento y en los pasos de flujo en vuelo. Borrado duro queda fuera del alcance pedido. Por qué: cerraba dos áreas de administración listadas como "Próximamente" desde la Fase 0, con impacto directo en RBAC/clearance y en la integridad del enrutamiento. Impacto: 12 proxies SSR nuevos (D-05), 1 componente nuevo, sin cambios de backend ni de esquema; 57 tests nuevos de proxy; svelte-check 0 errores.

  • feat(frontend): Índice de información clasificada y reservada (E08, Ley 1712 art. 20) + Disposición final del radicado/expediente (RF-MET-08/E04, Fase 4 cierre de desfase API↔UI) Nueva pestaña "Índice de clasificados" en /reportes (gate real PERM_RECLASIFICAR): registro content-free por diseño con banner legal, sin ninguna columna de asunto/contenido, y descarga CSV completa diferenciada de la exportación local de solo lo visible. Nuevo Drawer "Disposición final" accesible desde /bandeja (edición, con confirmación explícita para la acción de eliminación) y desde /expedientes/[id] (solo lectura: muestra la regla ya aplicada al cierre, o una vista previa de la regla de la serie TRD si el expediente sigue abierto — nunca una fecha inventada). Se verificó contra el backend que un radicado individual no tiene vínculo directo a una serie TRD; el Drawer lo declara explícitamente en vez de simular un vínculo inexistente. Por qué: cerraba dos obligaciones sin UI operable — el instrumento de transparencia del art. 20 y la decisión (irreversible en el caso de eliminación) sobre disposición final. Impacto: 2 proxies SSR nuevos, 1 faceta SSR no crítica nueva, 2 componentes nuevos; sin cambios de backend ni de esquema; 16 tests nuevos de proxy.

  • feat(frontend): Detalle de transferencia con acta-certificado y firma personal por rol + administración de preservación digital (E12/E17/E10, Fase 4 cierre de desfase API↔UI) Nueva subruta /transferencias/[id] con el acta de entrega presentada en apariencia de certificado (qué se transfiere, entre qué dependencias, cuándo, y el estado de firma de sus TRES roles — elaborador, remitente, receptor). El rol de CADA persona se deriva client-side (espejo del backend) para mostrarle solo lo que a ella le corresponde firmar, nunca un botón "Firmar" genérico — el backend siempre re-valida. Incluye verificación on-demand del sello institucional XAdES y de las firmas personales, y reintento manual del sellado. Nueva pantalla /admin/preservacion (plan de preservación versionado, índices pendientes de sellado XAdES con reintento manual, y visibilidad de artefactos WORM pendientes de renovación) — el empaquetado manual del AIP y la protección WORM manual de un artefacto se omitieron deliberadamente por falta de un endpoint público utilizable sin inventar datos. Por qué: cerraba un desfase entre lo que el backend ya exponía (E12/E17 firma personal por rol, E10 visibilidad operacional) y lo que la UI permitía operar. Impacto: solo proxies SSR nuevos (D-05); sin cambios de backend ni de esquema; 26 tests nuevos de proxy.

  • fix(workflow): workflow_rules.assign_to_dept validado al configurar la regla + traza en audit_log de la degradación silenciosa POST /workflow/rules y PATCH /workflow/rules/{id} no comprobaban assign_to_dept (texto libre, sin FK) contra el catálogo dependencias — una regla mal tecleada se aceptaba en silencio y solo se descubría en ejecución, radicado a radicado, vía un logger.warning que nadie lee. WorkflowRulesService._validate_assign_to_dept reutiliza WorkflowRepository.dependencia_activa (el mismo helper que ya cierra assign/transfer) para rechazar con 422 dependencia_desconocida ANTES de escribir la fila; el PATCH parcial solo valida si el cliente tocó el campo. Además, auto_distribute (para reglas ya existentes cuya dependencia se dio de baja después) deja un asiento best-effort en public.audit_log (workflow.regla_dependencia_invalida) en vez de degradar solo por log. Impacto: aditivo, sin migración; no cambia el comportamiento de degradación de auto_distribute (el radicado sigue sin autodistribuirse, sin reintentos de DLQ contra un dato permanentemente inválido).

  • fix(document): Auditoría del ciclo de anulación de radicados (ADR-008/010) AnulacionService no dejaba ningún asiento en public.audit_log: solicitar, aprobar y rechazar la anulación de un radicado (el acto más consecuente de un SGDEA — retira de la circulación un documento oficial con número inmutable) era invisible para la traza legal forense. Ahora los tres actos (document.anulacion_solicitada, document.anulacion_aprobada, document.anulacion_rechazada) auditan dentro de la misma transacción que la mutación — si audit.append falla, la solicitud/decisión entera revierte — con object_ref resuelto server-side (tracking_number), nunca del body. Por qué: anular un radicado incumplía Ley 594/2000 y el ADR-010 sin dejar rastro atribuible; una anulación aprobada sin traza es peor que una anulación fallida. Impacto: aditivo, sin migración de esquema (reutiliza public.audit_log); no toca autorización ni clearance existentes.

  • fix(workflow): Auditoría forense de devolución, transacción de trámite y reasignación en cascada (ADR-008/010) devolver, execute_transaction y reassign_cascade mutaban el trámite sin dejar asiento en public.audit_log (Canal A) — deuda declarada en el propio servicio. Ahora los tres auditan (workflow.devolver, workflow.transaccion, workflow.reasignacion_cascada) dentro de la misma transacción que la mutación, con object_ref resuelto server-side. El asiento del barrido en cascada registra el conteo REAL de pasos movidos, y no fabrica un número de radicado que no existe. Por qué: eran las tres últimas mutaciones del servicio sin traza legal atribuible (Ley 594/2000); una reasignación masiva sin constancia de su alcance es forensemente inútil. Impacto: aditivo, sin migración ni cambios de autorización, clearance o no-oráculo.

  • feat(workflow): Motivo obligatorio al revertir una asignación (RF-FLU-08) Nuevo campo motivo obligatorio (5-500 caracteres) en POST /workflows/{radicado_id}/rollback; antes el frontend lo pedía pero el backend lo descartaba sin dejar rastro. Por qué: revertir una asignación es un acto sobre el ciclo de vida del radicado y debe quedar trazado legalmente, no solo operativamente. Impacto: el motivo queda registrado en el historial operativo (flow_events) y en la traza legal (audit_log); sin migración de esquema.

  • feat(frontend): Sistema de Diseño OrpycaMCP v1.0 (E22, ADR-020) _tokens.scss/custom-bulma.scss pasan de la paleta provisional a la definitiva v1.0, conservando todos los nombres --op-*/$op-*. Por qué: el contraste WCAG de cada par texto/fondo se calculó por primera vez en vez de estimarse, y obligó a separar el primario de uso no-textual (--op-primary, 4.28:1) del de texto (--op-primary-dark, 6.56:1). Impacto: nueva escala tipográfica, radios, sombras, tokens de movimiento y tipografías self-hosted; cero rotura en las 19 pantallas existentes.

  • fix(frontend): Font Awesome nunca se cargaba y el favicon daba 404 (E22) El paquete estaba instalado y las clases fas fa-* se usaban en toda la app, pero su CSS nunca se importaba: los iconos eran cajas vacías. +layout.svelte ahora lo importa y static/ recibe los activos reales de marca.

  • feat(frontend): Landing pública en / (E22) Nueva ruta (public)/+page.svelte con 7 secciones (hero, franja normativa Ley 594/Acuerdo AGN 060, comparativa Orfeo→ORPYCA, footer AGPL v3); redirige a /dashboard si hay sesión.

  • feat(frontend): Login, callback y /403 con la marca real (E22) /login sustituye el placeholder textual por el logotipo y estrena composición split mobile-first; /403 pasa a un mensaje no culpabilizador que orienta a contactar al administrador.

  • feat(frontend): Búsqueda global en el topbar y favoritos configurables en el sidebar (E22) El topbar gana un formulario de búsqueda hacia /busqueda?q=…; el sidebar gana favoritos fijables persistidos en localStorage, namespaced por tenant+usuario. Impacto: el store de favoritos nunca filtra por su cuenta — exige la lista ya filtrada por permisos, así un favorito cuyo permiso se pierde deja de mostrarse solo.

  • feat(frontend): Dashboard rediseñado a los cuatro módulos personales (E22) /dashboard mostraba las métricas agregadas del tenant (información de jefatura); ahora prioriza los cuatro módulos personales y relega las métricas a una sección colapsable. Impacto: las métricas quedan gateadas por SGD_PERM_ESTADISTICA tanto en la UI como en el servidor — la UI vuelve a ser espejo del RBAC.

  • feat(frontend): DataTable gana las ocho capacidades obligatorias del sistema de diseño (E22) Filtros por columna, ordenamiento, exportación CSV (con neutralización de inyección de fórmulas), columnas configurables, vistas guardadas, selección múltiple, virtualización y edición rápida en celda — todas opt-in por prop, con el comportamiento actual como default.

  • docs(es): Actualización integral de arquitectura, pantallas y funcionalidades architecture.md reescrito con diagramas mermaid; nueva pantallas.md con el inventario de vistas; ADR-021/022/023 incorporados al sitio; puertos de ejemplo corregidos en toda la documentación. docs/en/ sincronizado en un segundo pase.

  • feat(archive): Descripción archivística EAD 2002 / ISAD(G) multinivel de expedientes en OAI-PMH (E11, RF-INT-06) Nuevo endpoint público GET /api/v1/public/archive/oai/{tenant}: cada expediente público se disemina como fragmento EAD 2002 multinivel, caminando el CCD (fonds→series→file). Impacto: chokepoint _PUBLIC en expedientes y frontera anti-fuga en level="file" — nunca enumera radicados hijos reservados de un expediente público.

  • feat(document): Descripción archivística EAD 2002 / ISAD(G) en OAI-PMH (E11, RF-INT-06) Segundo metadataPrefix=ead en GET /api/v1/public/oai/{tenant} con los 6 elementos obligatorios de ISAD(G)/NTC 4095. Reutiliza el chokepoint _PUBLIC ya existente — no crea oráculo de reservados. Pendiente: descripción multinivel (resuelta luego por archive-service).

  • feat(document): Importación interoperable de radicados (E11, RF-INT-05) POST /api/v1/import recibe el paquete ZIP de exportación (INT-01) con validación todo-o-nada: verifica el SHA-256 de cada binario antes de ingerir. Impacto: preserva el número de radicado original (colisión → omite, no sobreescribe, Ac. 060/2001); checksum no coincidente → 422 sin estado parcial. Cierra la interoperabilidad de radicados junto a INT-01.

  • feat(document): Exportación interoperable de radicados (E11, RF-INT-01) POST /api/v1/export produce un paquete ZIP con manifiesto, JSON Schema publicado, un JSON validado por radicado, binarios y checksums.txt (fixity, Ley 594 art. 19). Impacto: gate no-read-up por-radicado; el número de radicado se preserva como identidad portable, cimiento de la importación (INT-05).

  • feat(document): Perfil CMIS 1.1 mínimo de lectura (E11, RF-INT-02) GET /api/v1/public/cmis/{tenant} (repositoryInfo + Browser Binding JSON); getContentStream sirve los bytes del anexo solo si el radicado es público, mismo chokepoint que OAI. Solo-lectura, sin carpetas anidadas ni CMIS-SQL.

  • feat(document): OAI-PMH — cosecha de metadatos (E11, RF-INT-02; SGDEA R.12.1) GET /api/v1/public/oai/{tenant}: los 6 verbos OAI-PMH 2.0 + Dublin Core, cosecha selectiva y resumptionToken. Por qué: crítico de seguridad (Ley 1712 arts. 18-19) — solo expone lo público (nivel_seguridad=1), filtrado en la fuente para que la omisión sea indistinguible.

  • feat(tenant): PINAR — Plan Institucional de Archivos (RF-ADM-08, E14; Acuerdo AGN 003/2015) tenant-service gana /api/v1/pinar/*: gestiona el PINAR como instrumento de planeación articulado por referencia con CCD/TRD, FUID/IUD y el plan de preservación. Impacto: 9 tablas nuevas, priorización determinista por impacto, un único plan en ejecución por tenant, aprobación con acto administrativo auditado en audit_log inmutable.

  • fix(document) + feat(document,archive): Cierre de deudas declaradas (E08/E10/E02) No-write-up al radicar (un usuario PUBLICA no puede originar una CLASIFICADA); asientos de reconciliación WORM atribuidos al principal de sistema; el manifiesto de export de expediente gana columnas tipo/asunto. Impacto: quedan deudas declaradas en roadmap (conector postal E11, catálogo cerrado de fundamentos E08, PDF combinado E02, LLM/embeddings reales E21).

  • feat(archive) + feat(storage): Exportación de expedientes a ZIP — GET /api/v1/expedientes/{id}/export.zip (E02/export; Ley 594/2000 art. 19) Descarga el expediente completo como paquete portable de consulta (distinto del AIP de preservación): índice XML, manifiesto.csv, bytes de anexos, LEEME.txt y checksums.txt. Impacto: remediada una fuga read-up por-radicado — el gate era solo a nivel de expediente y un radicado clasificado dentro de un expediente de menor nivel entregaba sus bytes; ahora se filtra por fila.

  • feat(document): Índice de información clasificada y reservada — GET /api/v1/reports/indice-reservado(.csv) (E08; Ley 1712/2014 art. 20) Registro que la Ley 1712 obliga a publicar; materializa la clasificación vigente en radicados (content-free: nunca expone el asunto). Por qué: es ortogonal al clearance — registro completo por mandato legal, igual que la disposición o la custodia. Impacto: clasificar a nivel≥2 exige fundamento obligatorio (422 si falta), así el índice nunca queda con fundamentos nulos.

  • feat(document): Operador postal como consumidor de webhooks — callback entrante POST /api/v1/public/postal/callback/{tenant_slug} (E20 F5; RF-POR-08; ADR-018) El operador notifica el estado de entrega vía callback idempotente con identidad propia (HMAC-SHA256 per-tenant/operador), bajo /public/ sin tocar el gateway. Impacto: aislamiento multi-tenant estricto (el secreto vive en el schema del tenant), fail-closed ante firma inválida, idempotencia por UNIQUE(operador, event_id).

  • feat(knowledge): RAG generativo con citas — POST /knowledge/rag (E21 Inc.3; ADR-006) Genera una respuesta anclada a los radicados recuperados, con proveedor de LLM pluggable (ollama_local/ollama_cloud/anthropic/disabled). Por qué: resuelve la decisión de soberanía pendiente del Inc.1 — la barrera dura de residencia (is_generatable) aplica también a la generación, sin override, así material reservado/clasificado nunca llega a un proveedor externo. Impacto: sin contexto tras excluir por residencia, degrada honestamente al patrón de solo-recuperación (503 nunca respuesta simulada).

  • feat(knowledge): Recuperación anclada con citas — POST /knowledge/antecedentes (E21 Inc.2) Expone la búsqueda semántica como respuesta anclada y verificable — radicados relacionados con snippet y score, siempre con advisory de que es un índice derivado a confirmar contra la fuente. Reutiliza el pre-filtro ACL de search, sin duplicarlo.

  • feat(knowledge): Proveedor de embeddings real, local/soberano (E21 Inc.1; ADR-006) Sustituye el stub determinista por EmbeddingProvider pluggable, con local_st (sentence-transformers, corre en el contenedor) como default de despliegue. Impacto: migración que NULLea los embeddings existentes (dimensión incompatible, 64→384) y requiere backfill (python -m app.ops.reembed); con proveedor local, el material Reservado/Clasificado sí se embebe (nunca sale del contenedor).

  • feat(storage): Validación PDF/A en la ingesta/captura de anexos (E07, E10 C2b follow-up; RF-PRE-02) La validación PDF/A solo corría al empaquetar el AIP; ahora corre también al subir el anexo, en modo advisory (default, no bloquea la radicación) o strict (opt-in, 422 ante no-conformante). Por qué: Ac. 060/2001 obliga a radicar toda comunicación — no se puede bloquear la captura por formato.

  • feat(storage) + feat(archive): AIP índice-only para expedientes 100% físicos (E10 AIP wiring follow-up; OAIS ISO 14721; RFC 8493) Un expediente sin anexos digitales quedaba sin paquete OAIS portable pese a tener su índice firmado bajo WORM; ahora se empaqueta un AIP índice-only con el índice firmado como payload del Bag. Impacto: el PREMIS nunca fabrica objetos-documento inexistentes; declara explícitamente que es un expediente con soportes físicos referenciados.

  • feat(archive) + feat(storage): Perfil PDF/A configurable por serie documental / TRD (E10 C2b follow-up; RF-PRE-02) El perfil de validación del AIP era global (2b); ahora es configurable por serie vía trd_series.pdfa_profile (whitelist). Impacto: un perfil ausente cae a 2b (retrocompat); uno inválido se degrada a not_evaluated, nunca conforme.

  • feat(archive) + feat(storage): Renovación WORM del AIP para series de Conservación Total (E10; cierra deuda Alta del cableado del AIP) La renovación de retención cubría índice+acta pero no el AIP (que no tenía espejo cuando se implementó); ahora run_once_worm_renovacion renueva los 3 artefactos. Cierra la brecha de preservación donde el paquete OAIS completo del CT podía perder su lock.

  • feat(archive) + feat(document) + feat(storage): Cableado del AIP OAIS al cierre del expediente (E10 debt; OAIS ISO 14721; PREMIS v3; Ac. AGN 001/2024 art. 4.3) POST /preservacion/aip empaquetaba el AIP real pero nadie lo disparaba; ahora archive lo dispara al cerrar el expediente (reconciliación best-effort). Impacto: nuevo endpoint interno de document-service (sin gate de clearance — acto de sistema/inventario total, inalcanzable por el gateway); advisory lock cierra la carrera de doble-empaquetado WORM irreversible.

  • feat(storage): Validación PDF/A veraPDF real vía subprocess endurecido (E10 Increment C2b; RF-PRE-02) Sustituye el stub honesto por el validador real; el núcleo del cambio es el modelo de seguridad del subprocess (no-shell, ENV scrubbeado, killpg+reap garantizado, JSON-only, whitelist de flavour). Impacto: sigue siendo advisory — un PDF no-conformante no bloquea el AIP; degrada honestamente a stub sin el binario instalado.

  • feat(storage) + feat(archive): Renovación de retención WORM para series de Conservación Total (E10 debt; Ley 594/2000 + Ac. AGN 001/2024 art. 4.3.2.6) Los objetos COMPLIANCE de series CT llevaban retain_until finito y volvían a ser borrables al vencer; nuevo endpoint interno extiende la retención solo si la nueva fecha es estrictamente mayor (idempotente). Impacto: se retiró la ruta pública equivalente (dejaba a cualquier crud≥3 fijar irreversiblemente un lock no-CT) — solo queda la interna.

Corregido

  • fix(frontend,auth,scripts,infra): once defectos que la revisión funcional por navegador destapó antes de llegar a la segunda pantalla Primer tramo de la revisión completa del sistema desde el navegador (bloques B0 «arranque y sesión» y B1 «administración de identidad»). El patrón que los une: ninguno rompía nada de forma visible — cada uno se presentaba como una pantalla vacía, un botón sin efecto o un dato que parecía correcto. Secretos que nunca llegaban a su destino. TOTP_ENCRYPTION_KEY no estaba declarada en docker-compose.yml para auth-service, mientras varios comentarios del propio archivo la nombraban como si estuviera configurada: daba igual lo que se pusiera en .env, la variable jamás entraba al contenedor y /me/totp/enroll devolvía un 500 opaco. Como la credencial de firma personal exige un TOTP activo, con ella caía toda esa cadena. Y scripts/dev-secrets.sh generaba SIGNING_KEY_ENCRYPTION_KEY con openssl rand -hex 32: 64 caracteres que, leídos como base64, decodifican a 48 bytes cuando AES-256 pide 32 — presente, con buena pinta e inservible. El script pasa a distinguir dos familias (hmac / aes256) y, sobre todo, a validar las que ya están: su idempotencia estaba perpetuando la clave rota en toda máquina que la tuviera. La dependencia activa no caía en la principal. auth_users.active_depe_id solo se escribe al conmutar explícitamente, así que quien tenía una URD marcada es_principal —el campo que existe justo para eso— aparecía sin dependencia activa: conmutador en blanco en la barra superior de todas las pantallas, con una sola opción en la que la persona ya parecía estar. Se resuelve en LECTURA y no escribiendo al crear la URD: vale para los datos existentes sin migración y no depende del orden de las escrituras. Sin principal se devuelve NULL a propósito — elegir «la primera» sería fabricar una decisión organizacional que nadie tomó. Dos pantallas afirmaban lo que no habían leído. El detalle de /admin/usuarios lanza dos peticiones en paralelo y compartían una variable de error con un else if (!detailError): si fallaba la primera, el fallo de la segunda se descartaba, la lista quedaba vacía y el panel declaraba «Sin dependencias asignadas» sobre datos que nunca llegaron. Y en /admin/parametros, la precarga del calendario sembraba 36 festivos correctamente mientras la tabla seguía diciendo «Sin festivos registrados» — la lista era let holidays = data.holidays ?? [], una copia única y no reactiva. Lección de método: la primera corrección de esto añadió invalidateAll(), el test comprobaba que se llamara, y pasaba en verde con la pantalla igual de rota. Comprobar la llamada no es comprobar el resultado; el test que vale afirma que la tabla deja de decir que no hay festivos. La documentación interna de dos componentes se mostraba al usuario. Ambos dibujaban su máquina de estados en texto —borrador --aprobar--> aprobada— dentro de un comentario HTML. Ese primer --> cierra el comentario, y las notas de diseño, la lista de props y los nombres de eventos pasaban a ser contenido de la página: en /admin/trd se veían en la columna «Acciones», empujando los botones fuera de la vista. Lo relevante es qué no lo detectó: prettier, ESLint, svelte-check y los 1504 tests unitarios pasaban en verde, y prettier llegó a reflotar el texto ya filtrado como si fuera prosa. Queda un guardián que cuenta aperturas y cierres, deliberadamente tonto porque un --> de más no se puede detectar leyendo el comentario ya parseado — para entonces terminó donde no debía y el resto es texto legítimo. El contenedor del frontend estaba permanentemente unhealthy. La sonda usaba localhost:3000, que dentro del contenedor resuelve primero a ::1, y adapter-node escucha solo en IPv4. En desarrollo no se nota porque nadie depende de su salud; con un readiness probe de Kubernetes o un depends_on: condition: service_healthy, el frontend nunca recibiría tráfico. El demo no era un demo. El grupo llamado «Demostración (todos los permisos)» concedía 8 de 26: /envios, /admin/colas, /anulaciones, la publicación de oficio y el índice de clasificados aparecían vacíos, indistinguible de funciones sin construir. Ahora concede 25 —USUA_PERM_ROOT queda fuera por escrito, porque salta comprobaciones en vez de habilitar funciones y un demo desde ROOT enseña el sistema sin sus controles— con clearance 3, que es un eje aparte con default fail-closed a 1 y sin el cual la clasificación no se puede ni ver. Además no existía ninguna asignación usuario–dependencia: cero filas en urd_usuaroldep para los cinco usuarios, cuyo síntoma visible era la tarjeta vacía y el invisible —peor— una bandeja que decía «vas al día» con radicados asignados, porque se filtra por la dependencia de quien mira. Se añade un usuario consulta pobre (2 permisos, CRUD 1, clearance 1), montado con el MISMO código que el rico a propósito: si se provisionara por otra vía, un fallo suyo dejaría el barrido negativo pasando en verde sin haber probado control alguno. Por qué: la garantía del proyecto era de unidad y de contrato, no de uso — un defecto que solo aparece al pulsar un botón real era invisible. Impacto: ./scripts/dev-secrets.sh regenera la clave AES mal formada y añade la de TOTP; hay que recrear auth-service para que las tome. Guardianes nuevos en shared/tests/test_permisos_existen.py (el demo se pone rojo cuando alguien añada un permiso y no lo conceda) y en frontend/src/lib/components/comentariosHtmlCerrados.test.js. El realm de desarrollo sube su sesión inactiva a 8 h — bloque marcado para no copiarse a producción, con el access token intacto porque la renovación transparente ya lo repone y alargarlo solo ampliaría la ventana de un token robado. Verificado por mutación, once veces: revertir cada arreglo pone su test en rojo, incluidos los dos sentidos del guardián de permisos y el control negativo de que el aviso de «sin dependencias» no se cuele en la barra superior.

  • fix(tooling,*): La comprobación automática de código pasa a estar en verde en los once servicios — y resultó que no estaba mirando tres de ellos Continuación de la corrección de la puerta de enlace (más abajo), donde quedó anotado que la comprobación seguía en rojo. Al ponerse a arreglarla apareció algo más incómodo que los errores: la propia herramienta no revisaba tres de los once servicios. No fallaba en ellos; simplemente no los abría, y entre los tres escondían más de cien avisos. La lista de servicios ya no se escribe a mano: se deduce del proyecto, de modo que un servicio nuevo entra en la revisión sin que nadie tenga que acordarse. Y algo más serio: la biblioteca de auditoría —la que deja constancia inmutable de cada acto, la que sostiene la trazabilidad legal— no se estaba comprobando en ningún servicio. No estaba instalada en el entorno de revisión, así que la herramienta la daba por buena sin mirarla. Al instalarla y declararla correctamente, veintinueve avisos desaparecieron y no apareció ni un problema nuevo: las llamadas ya eran correctas, solo que nadie lo había verificado nunca. Se dejó además preparado para que, si esa instalación fallara algún día, la revisión se detenga en vez de continuar en falso. Entre los problemas de verdad que la herramienta destapó: la firma electrónica daba por supuesto un tipo concreto de clave criptográfica y habría fallado con un mensaje incomprensible ante cualquier otro; la comprobación de que un certificado corresponde a su solicitud no funcionaba con dos tipos de clave modernos; un evento sin número de radicado podía llegar a modificar datos sin saber a qué documento se refería; y crear una versión de tabla de valoración incompleta daba un error interno en vez de decir qué faltaba. Ninguno estaba dando problemas hoy, pero todos eran fallos esperando ocurrir. También apareció una prueba que no probaba nada: extraía un dato, explicaba en tres líneas de comentario qué había que comprobar sobre él, y no lo comprobaba. Escrita la comprobación que faltaba. Por qué: una comprobación que siempre está en rojo deja de leerse, y esta además daba por buenos tres servicios y la biblioteca de la que depende la trazabilidad legal. Impacto: los once servicios en verde y 2 516 pruebas sin una sola rotura.

  • fix(api-gateway): La puerta de enlace central pasaba a comprobarse sin verificación de tipos, y con ella todo el tráfico del sistema Toda petición que entra al sistema atraviesa un mismo punto. Ese punto armaba la llamada al servicio de destino de una forma que el verificador automático de tipos no podía inspeccionar, así que emitía once avisos y dejaba sin comprobar el único tramo por el que pasa el cien por cien del tráfico. Reescrita la llamada de forma explícita. En el camino quedó fijado por escrito —y con pruebas— un detalle que antes era implícito: cuando no se indica un tiempo máximo de espera, hay que decir «usa el del cliente», que no es lo mismo que «sin límite». Confundirlos dejaría cada petición esperando indefinidamente, y el aviso de «el servicio no respondió a tiempo» nunca llegaría a mostrarse. Por qué: el archivo afectado es por donde pasa cada petición del sistema, y era el único tramo del recorrido que no se comprobaba de verdad. Impacto: sin cambios de comportamiento. Dos pruebas nuevas que fijan el tiempo de espera en los dos casos —el general y el del asistente conversacional, que necesita uno mucho mayor—, comprobadas al revés: sin el arreglo, fallan. Conviene no leer de más este arreglo: al quedar limpio este punto se hizo visible que los otros siete servicios acumulan sus propios avisos, anteriores e independientes, así que la comprobación de tipos del proyecto todavía no puede usarse como control de calidad.

  • fix(tooling): Ningún contenedor vuelve a dejar archivos que su propietario no pueda editar Varias herramientas del proyecto (el revisor de estilo, la regeneración de dependencias, las pruebas documentadas para quien contribuye) se ejecutaban dentro de contenedores que tenían acceso de escritura al código. Un contenedor trabaja con la identidad del administrador del sistema, así que todo lo que escribía quedaba en el disco a nombre de esa identidad, y quien había clonado el proyecto ya no podía modificarlo. Se habían acumulado unas nueve mil rutas en esa situación, entre ellas catorce archivos de código de la interfaz que llevaban meses con un aviso del revisor sin poder corregirse, precisamente porque el corrector automático no podía escribirlos. El fallo era invisible: los comandos funcionaban, el control de versiones registraba los archivos con toda normalidad, y el problema solo aparecía la siguiente vez que alguien intentaba editarlos. Se cerraron los tres orígenes: ahora las herramientas reciben el código en modo solo lectura y escriben sus archivos temporales dentro del contenedor; cuando un comando debe producir un archivo de verdad, lo entrega por la salida estándar y es el equipo de quien lo ejecuta el que lo escribe, con su propia identidad. La regla y su comprobación quedan escritas en la guía interna del proyecto. Por qué: corregir los archivos afectados sin corregir las herramientas que los generaban habría reproducido el problema en la siguiente ejecución. Impacto: sin cambios en el código de los servicios. Se comprobó con un control positivo: el revisor de estilo entrega exactamente los mismos resultados con el acceso de solo lectura que con el anterior, de modo que el cambio no altera lo que se analiza.

  • fix(frontend): El formulario de alta de las pantallas de administración se reabría con lo escrito antes; en «Usuarios», con la casilla ROOT aún marcada Siete pantallas de administración comparten el mismo esqueleto: una tabla, un botón «Nuevo…» y una ventana con el formulario de alta. Al unificar ese esqueleto, se perdió el paso que vaciaba el formulario al abrirlo. Consecuencia: si alguien pulsaba «Nuevo…», escribía algo y cancelaba, al volver a abrir la ventana reaparecía todo lo anterior, incluido el mensaje de error de la vez pasada. En la pantalla de Usuarios esto dejaba de ser una molestia: la casilla «Otorgar ROOT» —que concede control total sobre el sistema— también conservaba la marca. Alguien podía marcarla, arrepentirse, cancelar y, al dar de alta a la siguiente persona, concederle ese control sin darse cuenta de que la casilla seguía activada. Es precisamente la pantalla con más riesgo de conceder privilegios de más. Lo detectó el revisor automático de código, que señalaba siete funciones de limpieza que ya nadie invocaba. Es la segunda vez que ese revisor, una vez puesto en marcha, saca a la luz un fallo real que las demás comprobaciones no veían. Una de las siete pantallas ni siquiera aparecía señalada, así que también se revisó a mano: el aviso automático marca el síntoma, no el problema. Por qué: lo que parecía código sobrante era en realidad la limpieza del formulario; en Usuarios el residuo tiene consecuencias de seguridad. Impacto: corregido en el componente compartido y en las siete pantallas, con cinco pruebas nuevas que reproducen el caso de la casilla ROOT y que se comprobaron al revés — sin el arreglo, fallan.

  • fix(api-gateway): La puerta de enlace descartaba el total de resultados, así que toda pantalla paginada creía tener una sola página Cuando un listado devuelve resultados, el servidor informa aparte de cuántos hay en total y, en las bandejas de eventos fallidos, de si la respuesta se quedó corta y hay más elementos antiguos que no se ven. La puerta de enlace central eliminaba ambos avisos antes de entregar la respuesta al navegador. Consecuencia: el total colapsaba al número de elementos de la página actual, de modo que ninguna pantalla paginada del sistema ofrecía la página siguiente; y el aviso de "hay más de lo que ves" de las bandejas de eventos fallidos nunca llegaba a mostrarse, con lo que la pantalla afirmaba en silencio que se veía todo. Ninguno de los dos fallos rompía nada visiblemente: la interfaz simplemente daba una información incorrecta con toda naturalidad. Es el mismo modo de fallo que ya se había pagado antes en el sentido contrario, con la firma del operador postal que la puerta de enlace tampoco reenviaba. Por qué: apareció al documentar dos pantallas de administración cuyas garantías de paginación y de aviso de truncamiento resultaron inalcanzables en producción; afecta a todas las pantallas paginadas, no solo a esas dos. Impacto: corregido junto con una prueba que fija el contrato y que se comprobó al revés — sin el arreglo, la prueba falla.

  • fix(archive): Una prueba de integración quedó rota por la migración anterior La comprobación que verifica el tratamiento de datos heredados aplicaba todas las migraciones menos una para reconstruir el escenario histórico, y la migración añadida en la entrega anterior depende precisamente de esa. Su comprobación de seguridad —correcta en un despliegue real, donde las migraciones se aplican en orden— hacía fallar ese escenario artificial. Corregido reordenando la prueba, sin tocar la comprobación de seguridad. Por qué: la migración se integró sin ejecutar el carril de pruebas contra base de datos real; el fallo salió al correrlo completo. Impacto: 168 pruebas de integración en verde. Ningún cambio en el comportamiento del sistema.

  • style(frontend) + fix(frontend,repo): El verificador automático de calidad volvió a ejecutarse — y al hacerlo destapó tres defectos reales La comprobación automática de calidad del código encadena dos herramientas, y la primera fallaba en 676 archivos por diferencias de formato, con lo que la segunda —la que busca errores de verdad— llevaba tiempo sin llegar a ejecutarse en la integración continua. Se formatearon todos y se corrigieron los doce errores que la segunda reportó al volver a funcionar. Lo importante no es el formateo, sino lo que dejó a la vista: Uno: diez textos de pantallas de administración estaban truncados a la vista del usuario. Llevaban comillas dobles dentro de un texto ya delimitado por comillas dobles, así que el sistema cortaba la frase por la mitad y donde debía leerse «Crea el primero con "Nuevo usuario"» solo aparecía «Crea el primero con ». Ninguna otra comprobación lo detectaba. Dos: una carpeta de archivos generados automáticamente —737 archivos, 16 MB— se había guardado en el repositorio por error, porque la regla que debía excluirla no cubría el nombre con el que quedó. Es la segunda vez que una regla de exclusión demasiado literal deja entrar lo que debía dejar fuera. Tres: una opción de configuración de un componente compartido no hacía nada desde que se escribió, y dos pantallas la usaban creyendo que sí. Las dos excepciones que se dejaron marcadas están justificadas por escrito, no silenciadas: el punto donde se inserta contenido con formato ya pasó antes por la limpieza de seguridad, y el patrón que busca caracteres invisibles los busca precisamente porque son los que se usan para disfrazar enlaces maliciosos. Por qué: una comprobación que nunca llega a ejecutarse da luz verde sin haber mirado; y lo que parecía deuda de estilo era lo único que ocultaba un texto cortado en diez pantallas. Impacto: verificación de tipos sin ningún aviso (antes tenía uno), 1370 pruebas en verde. Queda un error sin corregir: catorce archivos del proyecto pertenecen al usuario administrador del sistema por un montaje de Docker mal permisado y no se pueden editar sin elevar privilegios, cosa que no se hizo. Se resuelve devolviendo la propiedad de esos archivos al usuario.

  • fix(archive): La base de datos ya puede garantizar que todo expediente apunta a una serie documental que existe (migración 040) Cuando se introdujo el vínculo entre expediente y serie de la TRD, se dejó a propósito sin verificar el histórico: exigirlo de golpe habría impedido poner en marcha entidades con datos heredados. Quedaba anotado que la verificación completa vendría después, y es lo que hace esta migración. Hasta ahora el sistema impedía crear vínculos rotos nuevos, pero no podía afirmar que no hubiera ninguno antiguo. La decisión importante es qué hacer con los vínculos rotos que aparezcan: no se arreglan solos. Un expediente que apunta a una serie inexistente conserva, en ese dato, la posición que tuvo dentro de la tabla de retención que decidió cuánto debía conservarse; vaciar el campo borraría esa evidencia sin dejar constancia, y borrar el expediente sería destruir un documento público. Ambas son decisiones de archivo que corresponden a una persona con el caso delante, no a un script. Así que la migración se detiene y los enumera uno a uno, sin tocar nada, y puede volver a ejecutarse cuantas veces haga falta hasta que estén resueltos. Por qué: una garantía que la base de datos anuncia pero no comprueba no es una garantía. Impacto: probada contra una base de datos real en los tres escenarios (con un vínculo roto, sin ninguno, y repetida). 721 pruebas del servicio de archivo en verde. Lo que no se hizo, y no por descuido: existe una regla escrita que autoriza o deniega la eliminación definitiva de un expediente según el estado del instrumento que la gobierna, y hoy nada la invoca. La razón es que el sistema todavía no elimina nada de forma definitiva: esa capacidad —con su calendario de vencimientos, aprobación del comité de archivo, publicidad previa del inventario y acta de eliminación— es una funcionalidad completa aún por construir. La regla se escribió por adelantado para que quien la construya no tenga que reinventar el criterio. Conectarla ahora obligaría a improvisar una eliminación irreversible sin diseño, que es justo lo contrario de lo que exige la ley de archivos.

  • fix(auth,frontend): La interfaz ofrecía acciones que el servidor iba a rechazar, porque no sabía en qué nivel estaba cada permiso (tercer lote de la revisión de experiencia de uso) Un permiso en este sistema no es un sí o un no: tiene nivel —consultar, editar, crear o borrar—. El servidor lo sabe y lo exige; la interfaz no lo recibía, porque la consulta que le dice quién es el usuario resolvía el nivel y luego lo tiraba, enviando solo la lista de nombres. Resultado: a alguien con permiso de solo consulta se le ofrecían botones de Cerrar, Transferir, Excluir o Firmar acta, y al pulsarlos recibía un "no tienes permiso". Las dos revisiones de experiencia de uso, hechas por separado y sin verse, llegaron a esta misma conclusión y ambas se negaron a arreglarlo pantalla por pantalla: el problema no estaba en las pantallas sino en lo que el servidor les cuenta. Se arregló en las dos mitades. El servidor ahora envía también el nivel de cada permiso, sin quitar nada de lo que ya enviaba, para que nada de lo existente se rompa ni haga falta actualizar todo a la vez. Y la interfaz aprende a preguntar por nivel, con una regla deliberadamente estricta: si el nivel no llega —una sesión abierta antes de este cambio, por ejemplo— la respuesta es no, nunca "no lo sé, lo permito". Al aplicarlo se revisaron todos los botones que escriben, uno por uno, contra lo que su servicio exige de verdad. Eso corrigió la premisa del propio hallazgo: la mayoría de las operaciones solo piden el nivel más bajo, así que solo cuatro pantallas necesitaban la restricción alta. Exigir de más habría sido el mismo error al revés, escondiendo funciones a quien sí puede usarlas, y además en silencio. Se anotó, sin tocarlo, que en la bandeja ocurre justo eso: la interfaz pide un permiso que el servidor no exige, porque allí quien puede actuar se decide por tener el trámite asignado, no por permiso. Por qué: la interfaz debe ser espejo fiel de lo que el servidor autoriza; ofrecer lo que va a ser rechazado es un fallo de funcionamiento, no de apariencia. Impacto: sin cambios en la base de datos. 199 pruebas del servicio de autenticación y 1370 del frontend, todas en verde, incluida una que reproduce exactamente el caso reportado.

  • fix(frontend): Accesibilidad: contraste insuficiente, tablas inalcanzables sin ratón, errores que no se decían en su campo y sugerencias de IA que no se anunciaban (segundo lote de la revisión de experiencia de uso) Cuatro problemas que dejaban fuera a parte de los usuarios. Uno: texto blanco sobre el verde de marca no alcanzaba el contraste mínimo exigido, y no en un efecto pasajero sino en estados permanentes —el número del paso actual en los asistentes de doble factor, credencial de firma y firma; el turno activo de la cadena de firma; los filtros activos de borradores y envíos—. En varios sitios estaba al revés: el tono legible se usaba solo al pasar el ratón. Dos: las tablas anchas cortaban por la derecha justo las columnas de Estado y Acciones, y las que sí se desplazaban no podían desplazarse con el teclado, así que quien no usa ratón no llegaba a ellas. Se corrigió en los dos componentes compartidos de tabla, con lo que el arreglo alcanza a la mayoría de pantallas. Tres: en el perfil, los siete mensajes de error de validación aparecían solo como aviso flotante y nunca junto al campo que fallaba — precisamente en la pantalla que gobierna la credencial de firma electrónica; ahora se anuncian en el campo, y los códigos de un solo uso abren el teclado numérico y se autocompletan. Cuatro: las sugerencias de la IA aparecían sin avisar a quien usa lector de pantalla; ahora se anuncian, sin tocar el contenido, que ya cumplía la exigencia normativa de citar fuentes y advertir que fue generado por IA. Dos precisiones honestas: los contenedores de tabla que no se modificaron envuelven un componente cuyo desplazamiento ya es alcanzable, y añadirles otro punto de parada del teclado habría creado un destino vacío; y el hallazgo original mencionaba cuatro tablas con una celda de más, que un barrido de las 28 tablas del proyecto no pudo reproducir — se deja dicho en vez de forzar la cifra. Por qué: una columna de acciones que no se ve en el móvil, o un error que un lector de pantalla no puede localizar, no son detalles de acabado: impiden completar la tarea. Impacto: 1348 pruebas en verde. Queda deliberadamente fuera el código de recuperación del doble factor: es una carencia del servidor y no debe disimularse en la interfaz.

  • fix(frontend): La carga masiva no informaba de su resultado, tres tarjetas de administración llevaban a "sin permiso" y dos pantallas no estaban enlazadas (revisión de experiencia de uso, primer lote) Las ~20 pantallas construidas para poner en la interfaz todo lo que el backend ya exponía se implementaron sin pasar por la revisión de experiencia de uso y accesibilidad, que es el último paso del proceso del proyecto. La revisión se hizo después y encontró tres problemas de funcionamiento, no de apariencia; este es su primer lote de corrección. Uno: la carga masiva de documentos y expedientes nunca informaba de su resultado, por dos fallos que se sumaban — el panel de progreso no se refrescaba, y la pantalla consultaba el progreso en una dirección que había dejado de existir cuando se corrigió el ruteo. Un administrador podía cargar mil radicados y no saber si se crearon o si falló todo. Dos: tres tarjetas del panel de administración anunciaban un permiso distinto del que realmente exige el servidor, así que llevaban a una pantalla de "no tienes permiso"; el caso de Interoperabilidad era doble, porque además ocultaba la pantalla entera a un radicador que sí podía usar parte de ella. Se revisaron las dieciséis tarjetas, no solo las tres detectadas, porque este mismo patrón ya había reaparecido cuatro veces. Tres: dos pantallas terminadas —preservación digital y claves de firma— no figuraban en el índice de administración, que se presenta como el mapa completo, y solo se alcanzaban escribiendo la dirección a mano. Se corrigió también la causa de fondo del primer fallo: varios comentarios del código afirmaban cosas que habían dejado de ser ciertas (que la puerta de enlace no enrutaba la carga masiva, que los servicios de metadatos no pedían permiso). Es la tercera vez en esta versión que un comentario desactualizado provoca un defecto real, así que se borraron en lugar de dejarlos junto al arreglo, y se añadieron pruebas que fijan la dirección exacta que cada pantalla pide al servidor, para que un cambio de ruta no vuelva a romperla en silencio. Por qué: una pantalla dada por terminada y documentada como completa tenía su función principal inoperante; construir la interfaz descubre las capacidades, pero solo la revisión descubre la interfaz. Impacto: la carga masiva informa de su resultado; el panel de administración deja de ofrecer lo que negará y deja de esconder lo que autoriza; dos pantallas dejan de ser invisibles. 1339 pruebas automáticas del frontend en verde.

  • fix(repo): Cuatro archivos de la pantalla de interoperabilidad nunca llegaron a guardarse en el repositorio Una regla de exclusión pensada para una carpeta concreta de la raíz del proyecto estaba escrita sin anclar, de modo que se aplicaba a cualquier carpeta con ese nombre en cualquier parte del árbol. Coincidió con la carpeta que contiene los archivos de carga masiva de documentos, así que quedaron fuera del registro histórico pese a estar documentados como entregados: en la copia oficial del proyecto, la pantalla llamaba a direcciones que allí no existían. Corregido anclando la regla a la raíz. Por qué: apareció al reejecutar el trabajo sobre una copia limpia; la revisión anterior no podía verlo, porque lee los archivos de trabajo y allí sí estaban. Impacto: ninguno en el comportamiento de la aplicación. La lección es que este tipo de exclusión falla en silencio: el archivo simplemente no aparece como pendiente de guardar.

Seguridad

  • fix(signature): Al pedir firmas sobre un documento, era quien hacía la petición quien decidía qué nivel de seguridad se le aplicaba Cuando se abre una cadena de firmas sobre un documento, el sistema anota su nivel de seguridad para saber a quién puede mostrarla después. Ese nivel debía leerse del documento; en la práctica se leía solo si el tipo de objeto venía escrito exactamente como «radicado». Escribirlo con mayúscula inicial, o declararlo como «borrador» apuntando al mismo documento, hacía que el sistema aceptara el nivel que indicara la propia petición. Y aun escribiéndolo bien, si el identificador no correspondía a ningún documento real, también se volvía al valor declarado. En los tres casos, el control que impide que alguien sin habilitación vea el trámite se comprobaba contra un nivel inventado. Al corregirlo apareció una cuarta vía por el mismo mecanismo, en la firma personal, donde ese campo no se puede acotar porque por ahí pasan también las actas institucionales. Eso obligó a la corrección de fondo, que es la que vale la pena retener: el sistema ya no pregunta de qué tipo dice ser el objeto para decidir si comprueba su nivel. Busca el identificador entre los documentos y expedientes registrados y, si lo encuentra, manda el nivel real, se haya declarado lo que se haya declarado. De camino se cerró el mismo agujero en los expedientes, que también tienen nivel propio y también lo estaba declarando el cliente. Y se conservó a propósito el nivel declarado para borradores y documentos sin registrar —ahí no hay nada que consultar y el autor es la única fuente—, con una prueba dedicada: apretar de más habría sido el mismo error al revés. Por qué: el nivel de seguridad de un documento no puede depender de cómo alguien escriba una palabra en su petición. Impacto: sin cambios de esquema. Las pruebas del servicio de firma pasan de 295 a 300; las dos que comprueban que el tipo declarado ya no influye están verificadas volviendo a introducir el fallo a propósito.

  • fix(auth): El registro de auditoría dejaba ver documentos clasificados a administradores sin habilitación para conocerlos Todo el sistema impide que alguien sin la habilitación de seguridad adecuada vea un documento clasificado: la búsqueda lo oculta, el expediente lo excluye, la descarga lo niega. La pantalla de auditoría era la excepción: bastaba con ser administrador para leer allí el número de radicado, quién actuó, qué hizo y cuándo, de todo lo que el resto del sistema ocultaba. La confusión de fondo es fácil de cometer y conviene nombrarla: administrar la entidad no es lo mismo que estar autorizado a conocer sus asuntos reservados. Son dos cosas distintas y el sistema las tenía mezcladas en un solo permiso. Ahora el listado se recorta por la habilitación de quien consulta, y de tres maneras deliberadas. Se recorta también el total, no solo la página: si el sistema dijera «hay 412 asientos» y mostrara 9, el número sería por sí mismo una filtración de cuánto hay clasificado. Lo que no se puede clasificar queda fuera: si un asiento apunta a un documento que ya no existe, el sistema no lo da por público — no puede comprobar su nivel, así que no lo enseña. Y quien sí tiene la habilitación máxima ve el registro íntegro: recortarlo ahí no protegería nada y destruiría el valor probatorio que justifica que exista. El filtro está en el servidor, no en el navegador. La pantalla se había negado en su día a esconder los datos en el cliente, con el argumento correcto: si el dato llegó al navegador, ya salió del sistema, y taparlo visualmente solo produce una falsa sensación de seguridad. La verificación de que la cadena de auditoría no ha sido manipulada no se recorta, a propósito: solo demuestra algo si se recalcula entera, y su respuesta no dice nada de ningún documento — únicamente si la cadena está intacta. Una decisión de fondo merece explicarse porque fue el error del primer intento: el recorte enumera lo que sí se puede ver, no lo que hay que esconder. Al principio se hizo al revés —una lista de cinco cosas a ocultar— y parecía equivalente. No lo es: el registro de auditoría lo escriben once componentes distintos con unos cuarenta tipos de asiento, y varios que no son un documento llevan su número y su asunto dentro. Un fallo de entrega de correo, por ejemplo, guarda el mensaje completo con el número de radicado incluido; una importación masiva guarda la lista literal de los números importados; un anexo guarda su nombre de fichero, que en la práctica suele ser el asunto. Con la lista al revés, cada tipo nuevo —y cada componente nuevo— nacería visible y nadie se enteraría. Ahora nace oculto, y si algo legítimo falta, se ve en pantalla y se corrige. Se prefiere el error que se nota. Por qué: era un hallazgo abierto y ya declarado por escrito como limitación conocida de la pantalla; cerrarlo era lo que faltaba. Impacto: sin cambios de esquema ni de formato de respuesta. Las pruebas del servicio pasan de 201 a 218, con dos nuevas contra base de datos real que comprueban el recorte subiendo y bajando la habilitación sobre los mismos datos. Los dos errores del camino los encontraron controles, no lecturas: la comprobación automática descubrió que en una entidad sin tablas de documentos el recorte se omitía por completo —dejaba pasar todo justo cuando debía ser más estricto—, y una revisión de seguridad independiente descubrió que la lista estaba invertida. Ambos corregidos y comprobados volviendo a introducir el fallo a propósito para verificar que la prueba lo detecta. Queda anotado: el número de orden de cada asiento va en la respuesta y es correlativo, así que los saltos permiten deducir cuántos asientos se ocultaron y cuándo, aunque no de qué tratan. No se cierra porque ese mismo número es el que identifica dónde se rompería la cadena si alguien la manipulara. Y se detectó, de paso, un problema en otro servicio, sin relación con esta pantalla: al crear una cadena de firma, el tipo de objeto es texto libre, así que escribiéndolo con una mayúscula distinta el sistema deja de consultar el nivel de seguridad real y acepta el que declare quien hace la petición. Se reporta, no se corrige aquí.

  • fix(workflow): Cierre de hallazgos bloqueantes en workflow-service (RF-SEG-03/08; E05) PATCH /{step_id}/complete era la única mutación sin identidad del llamante — cualquier tenedor de PERM_TRAMITAR podía cerrar el paso activo de otra dependencia, y la traza acusaba a assigned_to en vez de a quien ejecutó el cierre. Impacto: autorización y mutación ancladas en el mismo UPDATE (sin ventana TOCTOU); /evaluate(/batch) gana el gate USUA_PERM_ADMIN que le faltaba. Deuda declarada: devolver/execute_transaction/reassign_cascade siguen sin asiento en audit_log.

  • fix(notification): Sellado de /send + autorización de webhooks y del historial (RF-SEG-03; D-02; E11) POST /notifications/send es servicio-a-servicio (signature-service despacha ahí los OTP de 2FA) pero el gateway lo exponía sin gate — un JWT válido de cualquier tenant enviaba correo arbitrario con la identidad SMTP institucional. Impacto: sellado con X-Internal-Token; webhooks.py y /history ganan USUA_PERM_ADMIN (eran canal de exfiltración de eventos del tenant, respectivamente).

  • fix(workflow): Enforcement RBAC de las transacciones de trámite y las rutas de distribución (RF-SEG-03; E05) POST /{id}/transactions ignoraba transaction_types.requires_permission — la migración lo daba por "delegado al gateway", pero el gateway solo autentica, nunca autoriza. Impacto: cualquier usuario autenticado podía ejecutar anular/cerrar_exp/vobo sin el permiso; ahora el permiso por-tipo se aplica en la capa de servicio, fail-closed.

  • fix(document): Cierre del no-read-up en firmas, anulación, respuesta vinculada y envíos (RF-SEG-08; Ley 594/2000 art. 28) El mismo patrón de fuga se repetía en 9 endpoints. crear_respuesta copiaba el asunto de un antecedente reservado sin verificar clearance; las rutas de firma no tenían ningún gate y verify era un oráculo de contenido. Impacto: en las escrituras el filtro va dentro del INSERT (WHERE EXISTS), no como consulta previa, para que comprobación y escritura sean atómicas.

  • fix(document): No-read-up en disposición y PATCH de radicados (RF-SEG-08; RF-MET-08; Ley 594/2000 arts. 24 y 28) GET/PATCH /documents/{id}/disposition y PATCH /documents/{id} se gateaban solo por permiso, no por clearance — permitía fijar la disposición final de un radicado clasificado sin poder leerlo. Impacto: max_clearance pasa a ser obligatorio en las firmas (sin default "sin restricción", el modo de fallo que originó la fuga).

  • fix(archive): Atribución del audit_log de la renovación WORM al principal de sistema (E10; RF-SEG-08) Los 6 asientos de la renovación de retención usaban actor=None — inconsistente con los asientos de empaquetado/protección que ya usan SYSTEM_RECONCILER_ID. Corregido para los 6, acotado solo al eje de renovación.

  • fix(archive) + fix(storage): No-read-up en la consulta de eventos de preservación por expediente (E10 Increment C; RF-SEG-08) GET /api/v1/preservacion/eventos?object_ref={id} devolvía eventos PREMIS gateado solo por permiso, no por clearance. Impacto: archive media con no-read-up y 404 neutro; storage sella el endpoint interno con X-Internal-Token.

  • fix(archive) + fix(storage): No-read-up en la descarga del AIP de preservación (E10 Increment C; RF-SEG-08; Ley 594/2000 art. 22) GET /api/v1/preservacion/aip/{id} devolvía el inventario completo (nombres/SHA/tamaños) gateado solo por permiso. Impacto: archive media con no-read-up y 404 neutro en los tres caminos de denegación; storage sella get_aip.

  • fix(workflow): CRUD de reglas de enrutamiento automático sin autorización alguna (RF-SEG-03; E05) rules.py no tenía require_permission en ninguna ruta — cualquier usuario del tenant podía crear/editar/borrar las reglas que deciden a qué dependencia se asigna la correspondencia entrante. Impacto: USUA_PERM_ADMIN en los cinco endpoints CRUD; /evaluate(/batch) queda de solo-lectura sin gate (el motor real las invoca en proceso, no vía HTTP).

  • fix(document) + fix(storage) + fix(gateway): No-read-up en la descarga de anexos (RF-SEG-08; Ley 1712/2014; D-02) La descarga emitía una presigned URL sin chequeo de clearance — y una presigned salta la auth si se reenvía. Impacto: document-service ahora media toda descarga con streaming de bytes (nunca presigned al cliente); storage sellado, solo alcanzable vía X-Internal-Token.

  • fix(workflow): No-read-up en la bandeja + tenencia en rollback + cierre de oráculo en devolver (RF-SEG-08) Tres hallazgos: (1) los listados de bandeja no filtraban por clearance; (2) rollback mutaba sin verificar tenencia del paso; (3) devolver distinguía 403 de 404 según el caso, actuando como oráculo. Impacto: subconsulta correlacionada de clearance compartida por COUNT y SELECT; rollback exige assigned_to == actor; ambos casos de devolver devuelven el mismo 404.

  • fix(document): No-read-up en las Salidas vinculadas — GET /documents/{id}/respuestas (RF-SEG-08; Ley 1712/2014) listar_respuestas devolvía las Salidas vinculadas a un antecedente sin filtro de clearance ni resolución del llamante, violando el invariante del servicio. Hueco hallado incidentalmente al revisar la traza de lecturas.

  • fix(archive): Ciclo de vida de transferencias (E12) — carrera TOCTOU letal cerrada + los cuatro actos de custodia con asiento e identidad unívoca (RF-SEG-08; Ac. AGN 001/2024 Tít. 4.4) El defecto más grave (H-A): update_estado mutaba sin guarda de estado — dos llamadas concurrentes (recibir+rechazar) podían comprometer ambas, dejando un expediente rechazada y a la vez congelado transferred, un estado jurídicamente imposible en un registro indeleble. Impacto: guarda de estado esperado en el UPDATE serializa la transición; se añade re-verificación de fixity al recibir (H-G, bloqueante de conformidad) y motivo obligatorio al rechazar. Cambios de API: rechazar exige motivo; recibir gana 409 fixity_mismatch. Con esto el ciclo de transferencias queda CONFORME con el Ac. AGN 001/2024 Tít. 4.4.

Corregido

  • fix(api-gateway,document,archive): Carga masiva inalcanzable — falta el ruteo en la puerta de enlace y el contrato de estado era ambiguo entre servicios Cierra el pendiente que dejó la pantalla de interoperabilidad: la puerta de enlace central no tenía registrada la ruta /api/v1/batch/, así que la carga masiva de documentos y de expedientes devolvía siempre "ruta no encontrada". Al investigarlo apareció un problema más de fondo: los dos servicios que atienden la carga masiva usan la misma ruta base, y la consulta de estado del trabajo (.../batch/{id}/status) tenía la forma exacta en ambos — no hay manera de que la puerta de enlace adivine, solo por el identificador del trabajo, a cuál de los dos preguntar. La solución fue anidar la consulta de estado bajo el tipo de trabajo (documentos o expedientes), igual que ya se hacía al crearlo, para que la decisión sea inequívoca. De paso, la carga masiva de documentos quedó exigiendo el mismo permiso que crear un documento uno por uno — antes no exigía ninguno, a diferencia de la de expedientes. Por qué: sin esta ruta la pantalla de carga masiva, ya construida, no podía funcionar; el contrato compartido entre los dos servicios había que desambiguarlo en el origen, no parcharlo por fuera. Impacto: las URLs de consulta de estado de la carga masiva cambian de forma —y la pantalla que las consume se quedó con la forma antigua, corregido después en la entrada de la revisión de experiencia de uso—; la carga masiva de documentos gana control de permisos. Lo que esta entrada dejó pendiente —la carga masiva de documentos sin registro en la bitácora de auditoría— quedó cerrado por la primera entrada de esta versión.

  • fix(frontend): Barrido de accesibilidad tras la auditoría del rediseño v1.0 — 27 hallazgos Alto/Crítico (E22, WCAG 2.1 AA) Tres familias: contraste AA (componentes migran a --op-primary-dark, 6.56:1), objetivos táctiles ≥44px, y semántica/foco (recuperación de foco visible, confirmación explícita antes de firmar en lote). La mayoría eran defectos preexistentes, solo destapados al calcular los ratios de contraste por primera vez.

  • fix(frontend): Bucle de redirección entre Keycloak y /login (E22) Cuando Keycloak devolvía /login?error=…, el load reiniciaba PKCE sin mirar el parámetro y volvía a mandar al usuario a Keycloak — bucle infinito. Ahora lee ?error= antes de reiniciar el flujo y muestra un mensaje mapeado desde una tabla cerrada en español (nunca el string crudo de Keycloak).

  • fix(frontend): Comentarios // en <style lang="scss"> rompían la compilación SSR cruda (E22) El test de seguridad SSR compila sin preprocesar SCSS y el parser CSS no entiende //; Modal.svelte y Drawer.svelte quedaban fuera de esa garantía. Cambiados a /* */.

  • fix(migrations): Guards de idempotencia sin filtrar por schema rompían la provisión del 2º tenant (multi-tenant) Los guards IF NOT EXISTS sobre nombres de constraint/columna comprobaban sin current_schema(); como esos nombres se repiten entre tenants, el guard omitía el objeto tras el primer tenant. Impacto: severidad crítica en las migraciones de DLQ (workflow/notification/signature) — abortaban la provisión de un segundo tenant. Todos los guards acotados a current_schema() + backfill schema-scoped para tenants ya provisionados.

  • fix(knowledge): POST /api/v1/knowledge/search daba 500 contra Postgres real (E21) El pool de asyncpg no registraba codec jsonb, así que metadata/acl se leían como texto crudo y la validación Pydantic reventaba. Invisible en unit tests (mockean la conexión); descubierto al ejercitar el RAG real. Fix: codec jsonb en el init del pool, con encode como texto para no doble-encodear las escrituras existentes.

  • fix(scripts): init_tenant.py provisionaba tenants incompletos y abortaba a media provisión (E14/E21) Dos defectos: (a) TENANT_MIGRATIONS omitía knowledge-service y tenant-service sin dar error — el tenant quedaba "bien" y el servicio fallaba en runtime (PINAR inservible); (b) _migrations.filename es único sobre el nombre desnudo, y varios servicios numeran desde 001 — colisión → UniqueViolationError a media provisión. Impacto: la identidad de migración pasa a servicio/archivo, con backfill para tenants ya provisionados con claves desnudas.

  • fix(knowledge): 2 tests de contención de inyección de prompt estaban en rojo desde su propio commit (E21 Inc.3) El escape html.escape funcionaba, pero los tests buscaban la cadena verbatim y sus cargas de prueba llevaban apóstrofos que el escape transforma. git log -S confirma que código y test entraron en el mismo commit sin correr nunca en verde (oculto porque la imagen no compilaba).

  • fix(knowledge): El requirements.lock contradecía su propia declaración y hacía irreconstruible el servicio requirements.txt declaraba "CPU-only suficiente" pero el lock resolvió la variante CUDA de torch (~5 GB de wheels nvidia-*): knowledge-service era el único servicio que no compilaba (agotaba disco). Impacto: la dependencia pesada se separa a requirements-ml.txt con índice CPU; Dockerfile gana ARG WITH_ML (default 1, sin cambio para un despliegue local; WITH_ML=0 da una imagen de 212 MB).

  • fix(storage): El gate de disposición deja de postergar la eliminación del contenido por la retención del acta de transferencia (E10; Ley 594/2000 arts. 24-26) El gate bloqueaba la purga de todo el expediente mientras cualquier objeto WORM tuviera retención futura (all-or-nothing); como el acta ancla en firmado_at (posterior al cierre), la eliminación mandada por la TRD quedaba postergada indebidamente. Impacto: nueva taxonomía CONTENIDO (bloquea) vs INSTRUMENTO DE CONTROL (el acta, sobrevive sin bloquear); legal_hold sigue siendo bloqueo incondicional.

  • fix(frontend): radicar usa el SchemaField compartido — cierra la degradación de campos enum/boolean a texto libre (E22/E03) El mapeo inline de metadatos solo cubría number/date; un campo enum o boolean degradaba a texto libre, permitiendo un valor inválido que el backend rechazaba con 422. Reemplazado por el componente SchemaField compartido con el Form Builder de expediente (sin tocarlo).

  • fix(frontend): Sincroniza la descarga de anexos del workspace de expediente con el nuevo contrato mediado por document-service (D-05, D-02, RF-SEG-08) El backend cerró la descarga directa a storage-service (URL presignada sin clearance) y la reemplazó por un endpoint mediado con streaming y no-read-up. El proxy BFF se reestructura con allowlist de UUID e inyección de X-User-Id fail-closed. Impacto: nota abierta — ExpedienteRadicadoResponse aún no incluye anexos por radicado; el panel queda sin datos hasta que el backend lo exponga.

  • fix(frontend): El diálogo de confirmación de radicación nunca era visible en producción — faltaba la prop open del Modal radicar montaba el Modal sin la prop open, que gatea todo su markup — el diálogo nunca renderizaba y no había forma de disparar el submit desde la UI. Defecto crítico preexistente, hallado al migrar radicar a SchemaField.

Cambiado

  • chore(compose): knowledge-service configurable para máquinas sin capacidad de modelo local (E21) El compose de desarrollo puede sobreescribir por entorno a EMBEDDING_PROVIDER=stub + AI_PROVIDER=ollama_cloud. Impacto: los defaults de config.py no se tocan — siguen siendo los soberanos local_st+ollama_local; stub no es un vector falso, es recuperación léxica real (hashing vectorizer), sin migración.

Añadido

  • feat(archive) + feat(storage): Cableado WORM/Object-Lock del acta de transferencia firmada (E10 Inc.2; Ac. AGN 001/2024 Anexo FUID; Ley 594/2000 arts. 15/16/26) El índice ya quedaba protegido (Inc.1); el acta de entrega firmada (sello XAdES) seguía borrable pese al sello — misma brecha de custodia sobre el instrumento que evidencia el traslado del FUID. Impacto: retención anclada en firmado_at del acta (no en el cierre del expediente) porque el acta puede firmarse años después en transferencias secundarias; generalización de storage proteger-indice→proteger-artefacto por tipo.

  • feat(archive) + feat(storage): Cableado WORM/Object-Lock del índice electrónico firmado al cierre (E10 Inc.1; Ac. AGN 001/2024 art. 4.3.2.6) La capacidad WORM de storage existía pero nadie la disparaba — el índice firmado quedaba borrable pese al sello XAdES. Ahora archive lo protege inmutable tras firmarlo (disparo síncrono best-effort + reconciliación). Impacto: retención derivada de la TRD, fail-closed sin TRD; advisory lock de sesión serializa la carrera entre el disparo síncrono y la reconciliación. Alcance honesto: Object-Lock de MinIO en dev, no repositorio certificado.

  • feat(auth): Purga del material privado de las credenciales de firma revocadas (E17/F4; Ley 527/1999 art. 7) Una privada de firma revocada no tiene uso operativo; retenerla cifrada es un pasivo de seguridad. Tras una ventana configurable (default 30 días) un job de ops anula el material privado, conservando todo lo público. Impacto: desbloquea retiradas de KEK que las credenciales revocadas mantenían ancladas; es minimización de datos, no cambia el tier legal (sigue art. 7, no acreditado).

  • feat(auth): Rotación de la KEK de custodia de las claves de firma personales — llavero versionado + re-wrap de ops (E17/F4; Ley 527/1999 art. 7) No había forma de rotar la KEK maestra sin re-enrolar a todos los usuarios; ahora es un llavero versionado, con un job de ops que re-cifra (nunca vía HTTP) con round-trip verify antes de persistir. Impacto: higiene de custodia — no cambia el tier legal (sin sole-control, art. 7). Deuda: HSM/KMS externo como paso hacia art. 28.

  • feat(archive): Firma electrónica personal PKI del acta de entrega — elaborador, cierra el trío de responsables del FUID (E17/F4; Ac. AGN 042/2002 art. 7) El elaborador ("Elaborado por") firma también el acta, como firmante aditivo — no parte del traspaso (esa función la cumplen receptor/remitente de los incrementos anteriores). Impacto: firmada_completa permanece intacta (5 valores); la firma del elaborador se reporta en un campo booleano ortogonal, sin inflar el veredicto de completitud.

  • feat(archive): Firma electrónica personal PKI del acta de entrega — remitente + firmada_completa bilateral (E17/F4; Ac. AGN 001/2024 Anexo FUID) Solo el receptor podía firmar; ahora quien envió firma también, en cualquier orden, completando "Entregado por"/"Recibido por" del FUID. Impacto: el rol se deriva de la identidad congelada, nunca del cliente (403 firmante_no_es_parte para un tercero); el auto-traslado (misma persona en ambos roles) se permite y se señala.

  • feat(archive) + feat(signature): Firma electrónica personal PKI del acta de entrega — receptor (E17/F4; Ac. AGN 001/2024 Anexo FUID; Ley 527/1999 art. 7) El acta ya se sellaba institucionalmente, pero ninguna persona la firmaba con su certificado; ahora quien recibe firma como paso explícito posterior a recibir(), con TOTP. Impacto: se exige que el firmante sea el receptor real (403 antes de quemar el TOTP) — cierra un bloqueante hallado en auditoría donde un impostor podía bloquear al receptor legítimo vía la restricción de unicidad.

  • feat(signature): Gate firma_personal_require_xades por nivel de seguridad — Inc.3 del epic (E17/F4; Ley 527/1999 art. 7) Cierra un fail-open silencioso: un certificado ausente o auth-service caído degradaban a HMAC nativo incluso para actos RESERVADOS/CLASIFICADOS. Nuevo flag de tres modos (off/clasificados/todos) que corre antes de consumir cualquier factor 2FA. Impacto: rollout — fijar clasificados en producción solo tras completar el enrolamiento offline de firmantes con clearance≥2.

  • feat(auth) + feat(signature): Revocación en línea de la firma personal PKI + anclaje de la sub-CA de usuarios en /verify — Inc.2 del epic (E17/F4; Ley 527/1999 art. 7) Revocar hace que /verify reporte valida=false al instante, incluidas las firmas emitidas antes de la revocación; se valida además que la sub-CA de usuarios esté anclada a la CA institucional. Impacto: limitación conocida — la revocación es por estado actual, sin sellar la fecha de firma (grace-period ETSI requeriría TSA acreditada).

  • feat(signature) + feat(auth): Firma electrónica personal PKI por-usuario (XAdES-B/T), custodia servidor — Inc.1 del epic (E06/E17 F4; Ley 527/1999 art. 7) La firma personal usaba un HMAC opaco con un secreto único de servidor; ahora cada firmante con clave activa firma con su propio certificado (sub-CA local de dev). Impacto: split firma-remota — la privada nunca sale de auth-service; es la interfaz exacta de un HSM, así que conmutar a firma cualificada no toca signature-service. Sigue siendo art. 7, no acreditado, sin sole-control.

  • feat(archive) + feat(signature): Firma XAdES del acta de entrega de transferencias (H-H, E06/F4; Ac. AGN 001/2024 Anexo FUID) El acta ya tenía tres garantías (trigger de inmutabilidad, fixity, anclaje en la cadena de hash) pero ninguna firma XAdES. Ahora recibe un sello XAdES-B/T/LT/LTA + OCSP institucional enveloped, best-effort con reconciliación. Impacto: el sello atribuye a los tres responsables por identidad autenticada, pero no son las firmas personales de cada uno (esa es la deuda que cierran los incrementos posteriores de E17).

  • feat(archive) + feat(signature): Reconciliación del sellado del índice electrónico al cierre (E15/E06 Inc.8; Ac. AGN 001/2024 art. 4.3.2.4) El sellado del índice al cerrar es best-effort post-commit; un fallo dejaba el índice pendiente_firma permanentemente. Se añade intento síncrono acotado + job de reconciliación con backoff + endpoint de visibilidad de pendientes. Impacto: cierra el modo de fallo permanente, pero no garantiza firma síncrona al instante del cierre — con el sello ausente el índice sigue quedando pendiente hasta intervención humana.

  • feat(signature): OCSP stapled en la firma XAdES-LT del índice (E06 Inc.7, ADR-016 addendum; RFC 6960; ETSI EN 319 132) La revocación se verificaba solo por CRL; ahora, opt-in, se añade una respuesta OCSP incrustada firmada por un responder delegado emitido offline por la CA del tenant (la clave de la CA nunca entra al servicio). Impacto: enriquece la evidencia, no sube la acreditación — sigue acreditado=false.

  • feat(archive) + feat(signature): [duplicado] Reconciliación del sellado del índice electrónico al cierre (E15/E06 Inc.8) Entrada repetida en el changelog original, idéntica a la anterior.

  • feat(signature): [duplicado] OCSP stapled en la firma XAdES-LT del índice (E06 Inc.7) Entrada repetida en el changelog original, idéntica a la anterior.

  • feat(document) + fix(frontend): El panel de Anexos del expediente ya muestra datos — endpoint de listado gateado por clearance (RF-SEG-08) El panel estaba vacío porque asumía un campo anexos en la respuesta de archive que nunca existió (los anexos viven en document-service). Nuevo endpoint dedicado con no-read-up; frontend con carga perezosa al expandir.

  • feat(mcp) + feat(gateway): El asistente E18 gana la tool buscar_conocimiento — búsqueda semántica (E21) as-the-user con ACL (ADR-019/ADR-006) El asistente puede hacer recuperación semántica sobre el knowledge-service auto-poblado; el clearance lo impone el knowledge-service desde el usuario real, así cada usuario ve solo fragmentos a su clearance. Impacto: el modelo no puede colar un acl propio — el input se filtra al esquema declarado antes de despachar.

  • feat(knowledge) + feat(document): Purga-en-anulación del índice semántico (E21; RF-SEG-08; Ley 1712/2014) Un radicado anulado seguía consultable en la búsqueda semántica. document-service emite un evento al anular; knowledge-service marca el chunk como purgado permanentemente y search lo excluye a cualquier clearance.

  • feat(knowledge) + feat(document): Ingesta event-driven del índice semántico (E21, MVP) (ADR-006/010/021; RF-SEG-08) El knowledge-service se auto-puebla consumiendo eventos de document-service, con ACL fail-closed (nivel ausente → CLASIFICADA) y un guard que bloquea material reservado hacia proveedores de embeddings externos. Impacto: invariante de monotonía bajo entrega at-least-once en cualquier orden — dos rondas adversariales de auditoría hallaron y cerraron dos fugas reales de reordenamiento.

  • feat(workflow): Traza de auditoría de las lecturas de la bandeja de trámite y hoja de ruta (RF-BUS-10; Ley 1712/2014) Última pieza del barrido RF-BUS-10 (tras archive y document). Traza agregada de la bandeja + traza por-radicado de la hoja de ruta y del trámite puntual. Cierra RF-BUS-10 para los tres servicios.

  • feat(document): Traza de auditoría de las lecturas de radicados (RF-BUS-10; Ley 1712/2014) document-service aplicaba no-read-up en las lecturas pero no auditaba ninguna — consultar un radicado reservado no dejaba rastro (hallazgo al revisar el asistente E18). Impacto: lectura individual y búsqueda agregada distinguidas; minimización de PII — nunca se registra el texto crudo de la búsqueda.

Impacto: v1 es solo lectura por doble defensa (la tool de escritura se excluye del prompt y del despacho); se remedió un IDOR intra-tenant en la llave del historial de conversación.

  • feat(frontend): Form Builder en la creación de expediente — campos dinámicos desde el json_schema de la plantilla de metadatos (E22/E03/ADR-007) La creación de expediente enviaba metadata: {} vacío; las plantillas por serie existían en el backend pero el front no ofrecía sus campos. Nuevo mapeo JSON Schema → control compartido cliente/servidor.

  • feat(frontend): Slice del asistente conversacional (E22/ADR-020) — UI de chat lista contra un contrato definido, backend E18 pendiente en ese momento El asistente es full-stack pero su backend era scaffold; se construye el frontend completo, honesto sobre la falta de funcionalidad end-to-end (nunca respuesta simulada). Sanea el output del asistente server-side antes de cruzar al cliente.

  • feat(archive): Origen/destino obligatorio + cotejado contra la ubicación real, condicionado a la tenencia física (E12/E17; Ley 594/2000 arts. 15/26, 23) Origen/destino eran opcionales; un expediente con tenencia física localizada ahora los exige y coteja el origen contra la ubicación actual. Un expediente puramente electrónico queda exento (rama B).

  • feat(archive): Integridad de la creación de transferencias — unicidad de transferencia activa (H-L) + coherencia origen/destino por tipo (H-M) (E12; Ley 594/2000 art. 23) Nada impedía N transferencias solapadas sobre el mismo expediente, ni validaba que origen/destino fueran coherentes con el tipo de traslado (gestión→central→histórico). Impacto: índice único parcial serializa la concurrencia (409, nunca 500); origen/destino siguen opcionales por decisión — exigirlos acoplaría E12 a un E17 aún parcial.

  • feat(archive): Inmutabilidad forzada por el motor del acta de entrega + tamper-evidence anclada en la hash-chain (E12; Ley 594/2000 art. 16) El acta congelada era solo inmutable procedimentalmente (sin ruta de UPDATE en el código), pero seguía mutable por SQL directo sin oposición del motor. Impacto: triggers append-only rechazan UPDATE/DELETE/TRUNCATE; el SHA del acta se ancla además en el asiento hash-encadenado, así un bypass del trigger se vuelve detectable.

  • feat(archive): Congelar el FUID como acta de entrega inmutable al recibir la transferencia (E12 H-I; Ley 594/2000 arts. 15/26) El FUID se generaba al vuelo y perdía valor de acta en cuanto el expediente se tocara. Ahora, al recibir, se congela un acta probatoria con el XML canónico y los tres responsables con sus fechas. Impacto: la inmutabilidad es procedimental + verificable por fixity en este incremento, no forzada por trigger de motor (eso llega en un incremento posterior).

  • feat(frontend): Vista de listado de expedientes (E22 T-12) — reemplaza el único listado stub del frontend Era la última vista de dominio sin implementar. Listado SSR puro sobre GET /api/v1/expedientes/search (ya filtra por clearance no-read-up); todo el estado vive en la URL, el access_token nunca llega al cliente. Impacto: nivel_seguridad deliberadamente no se muestra — toda fila visible ya está a-nivel-o-por-debajo del clearance, así que un badge no informa y para clearance alto sería un inventario de qué exfiltrar.

  • feat(archive): Traza agregada de consulta de expedientes (RF-BUS-10) — una entrada de auditoría por búsqueda/listado, no por fila El path de listado/búsqueda emitía cero traza, contra RF-BUS-10 y sobre-declarando conformidad. Ahora una entrada por consulta exitosa, con q minimizado (solo longitud/presencia, nunca el texto). Impacto: cubre solo listado/búsqueda de expedientes — la traza RF-BUS-10 global seguía pendiente en este punto.

  • test(frontend) + fix(frontend): Cobertura de tests de UI + correcciones a11y/SSR en componentes overlay (E22) El frontend funcional tenía 1 test unitario y 0 E2E. Infra montada (Vitest + Testing Library + jsdom + Playwright) destapó y corrigió bugs reales: guard SSR faltante, apilamiento incorrecto de overlays, race de restauración de foco.

  • feat(storage): Validación PDF/A en la ingesta del AIP — contrato + stub honesto (E10 Preservación, Increment C2a, ADR-023 addendum) El AIP no validaba el formato de los documentos; OAIS/PREMIS esperan validación en ingesta. Nuevo validador con stub honesto (not_evaluated por defecto, nunca finge un veredicto) — el validador real llega en C2b.

  • feat(storage): Réplica best-effort del AIP a un segundo bucket WORM (E10 Preservación, Increment C1b, ADR-023 addendum) OAIS exige múltiples copias. Al empaquetar el AIP, opt-in, se crea una réplica inmutable en un segundo bucket (local o cross-site vía endpoint secundario). Impacto: un fallo de réplica jamás revierte el AIP primario — es no-fatal por diseño.

  • fix(storage): PREMIS v3 conformidad plena — el AIP valida contra el XSD oficial de la LoC, no contra un perfil propio (E10 Preservación, Increment C1a, ADR-023 addendum) El AIP validaba contra un XSD local propio, que no prueba conformidad. Se vendoriza el premis.xsd oficial de la Library of Congress y el <object> emite xsi:type="premis:file" correcto.

  • feat(storage): AIP real (BagIt RFC 8493) + PREMIS v3 subido al bucket WORM (E10 Preservación, Increment B, ADR-023 addendum) El AIP era un stub textual en BD; ahora es un paquete OAIS real (Bag conforme RFC 8493 + manifiesto PREMIS v3), subido al bucket WORM con retención de la TRD. Impacto: remedió dos hallazgos Alta antes de merge — colisión de nombres que podía invalidar el Bag bajo WORM, y el endpoint GET /aip sin RBAC.

  • feat(storage): WORM / Object-Lock real (MinIO) sobre el índice electrónico firmado + gate de disposición (E10 Preservación, Increment A, ADR-023) El índice firmado solo tenía inmutabilidad lógica; un SGDEA exige inalterabilidad de infraestructura durante la retención. Nuevo bucket WORM por tenant con Object-Lock; el gate de disposición bloquea la purga bajo retención futura o legal hold. Impacto: primer RBAC en storage-service; modo COMPLIANCE por defecto con guard de arranque fail-closed.

  • feat(auth,signature): 2FA por TOTP (RFC 6238) en la firma personal — secreto en auth-service (AESGCM), elevando el OTP-correo (E06 Inc.6, ADR-016 addendum) El segundo factor era OTP-correo (posesión de canal); TOTP añade posesión de dispositivo, sin infra externa. El secreto vive cifrado en auth-service; la regla "no desactivable para nivel≥2" se preserva.

  • feat(signature): XAdES-LT/LTA del índice con CA local de desarrollo — material de validación a largo plazo, no acreditado (E06 Inc.5, ADR-016 addendum) La firma llegaba a XAdES-T pero sin material de validación no sobrevive a la expiración del certificado. Escala a XAdES-LTA cuando el sello del tenant trae material de CA (la clave de la CA nunca se monta en ningún contenedor). Impacto: degrada observablemente a XAdES-T sin material CA; acreditado siempre false.

  • feat(archive): Índice electrónico v4 "conforme completo" — dependencia productora + fecha de declaración + pista de auditoría + política de acceso (E15, ADR-022 addendum) v3 era "conforme extendido" con 4 residuales sin fuente en archive; v4 los cierra a nivel de capacidad sin sobre-declarar: dependencia productora por documento, fecha de declaración, historial de acceso instrumentado y política de acceso portable en la cabecera. Impacto: content_hash v4 cubre el árbol completo canonicalizado (v3 solo hasheaba <documentos>); los índices v1-v3 firmados quedan inmutables.

  • feat(archive): Índice electrónico v3 "conforme extendido" — orden documental estable + exclusión trazable + ACL + contexto (E15, ADR-022 addendum) El <orden> era recalculado y se renumeraba al excluir, contra el principio de orden original. Nuevo orden_documental estable e inmutable; la exclusión pasa de DELETE físico a soft-delete trazable (<documento excluido="true">). Impacto: v3 no se declara "completo" — el disclaimer lista los 4 residuales que cierra v4.

  • feat(signature): XAdES-T — sello de tiempo RFC 3161 del índice (E06 Inc.4, ADR-016 addendum) El XAdES-B usaba SigningTime del reloj (no oponible); se inserta un xades:SignatureTimeStamp (token RFC 3161) firmado por una TSA local in-process, post-procesado como propiedad no firmada (no invalida el XAdES-B). Impacto: /verify valida el sello con pinning del fingerprint TSA; acreditado=false (TSA local sin ONAC) — fecha cierta verificable, no acreditada.

  • feat(archive): Metadatos RT-15 del índice electrónico — perfil v2 (E15/E06 Inc.3, ADR-022) El Acuerdo AGN 001/2024 art. 4.3.2.3 (RT-15) exige por documento formato, tamaño y foliación, que el índice omitía. Nuevo perfil v2 que los emite (omitiendo el elemento si el dato es nulo, sin romper compatibilidad con v1 firmado). Impacto: v2 se etiqueta "ampliado (RT-15 parcial)", no "conforme completo" — aún faltan orden documental/ACL/contexto (resueltos por v3/v4).

Seguridad

  • fix(workflow): GET /workflows/inbox//pending acotados por dependencia (RF-SEG-08, E05) Los dos listados de bandeja no tenían ninguna dependencia de permiso — cualquier autenticado del tenant obtenía la carga de trabajo activa de toda la entidad. Por qué: ese listado sin acotar era además la primitiva de enumeración que hacía explotable el step_id ajeno cerrado en un fix anterior. Impacto: el alcance por defecto pasa a ser lo propio (asignado al actor o a su dependencia); ampliar el alcance ahora exige PERM_TRAMITAR/USUA_PERM_ADMIN, rechazado en vez de ignorado en silencio.

  • test(archive): Tier de integración contra Postgres real — la atomicidad no es verificable con mocks El carril unit mockea la conexión, así que conn.transaction() es un context manager de mentira: su ausencia no es observable. Los últimos incrementos de atomicidad estaban respaldados por tests que habrían pasado igual sin transacción real. Impacto: nuevo carril de integración (piloto en archive-service) con conexión asyncpg real, schema desechable y commits reales. 19/19 mutantes muertos al validar por mutación cada garantía.

  • fix(archive): Cerrada la ruta alterna PATCH /expedientes/{id} que transicionaba el ciclo de vida sin dejar rastro (Ley 594/2000 arts. 22/24) ExpedienteUpdate.status ejecutaba las mismas transiciones que close/transfer pero con un UPDATE pelado: sin asiento, sin evento, sin disposición TRD materializada, sin transacción. Por qué: el historial muestra que esta ruta alterna ya se había detectado dos veces y cada vez se tapó solo una dimensión del problema. Impacto: status se retira de ExpedienteUpdate con extra="forbid" (load-bearing — sin forbid un {"status":...} se ignoraría en silencio, peor que el bug original).

  • fix(archive): Atomicidad transición↔asiento en cierre y transferencia + TOCTOU de disposición + ruta gemela de transferencia sin auditoría close/transfer no abrían ninguna transacción (mutación y asiento en autocommits sueltos); transition_expediente no tenía guarda de estado esperado, así que dos close concurrentes podían ambos commitear. Impacto: el tramo de BD se envuelve en una única transacción con el asiento como último write; el sellado XAdES queda fuera (post-commit, no revertible por ROLLBACK).

  • fix(archive): Atomicidad vínculo/exclusión↔asiento de auditoría en link/unlink de radicados (incremento gemelo, RF-SEG-08 residual) Mismo residual de la creación, ahora en los tres métodos de composición del expediente: mutación + evento + asiento en autocommit suelto. Envueltos en transacción; el batch usa una única tx para el lote (todo-o-nada).

  • fix(archive): Atomicidad expediente↔asiento de auditoría + carrera de código duplicado en la creación (integridad, RF-SEG-08 residual) El INSERT del expediente y su asiento no eran atómicos; y next_expediente_code podía dejar que dos creadores concurrentes leyeran el mismo consecutivo. Impacto: colapsado a un único INSERT…ON CONFLICT…RETURNING; con la transacción, el row-lock serializa concurrentes sin duplicados ni huecos.

  • fix(archive): No-read-up al enumerar los expedientes de una unidad física + auditoría de la creación por batch (barrido RF-SEG-08) GET /unidades/{id}/expedientes enumeraba contenido de una caja sin filtro de clearance; la creación por batch no dejaba asiento de auditoría. Ambos corregidos; la gemela GET /expedientes/{id}/unidades (custodia ortogonal) queda deliberadamente sin filtrar.

  • fix(archive): No-read-up en el FUID — el inventario documental deja de filtrar expedientes clasificados (E12/V2, RF-SEG-08) El FUID (asunto/serie/signatura/folios) es descripción de contenido y se servía sin filtro de clearance por 3 puertas. Impacto: single → 404 total; multi → filtro por fila con max_clearance obligatorio. Cierra el último diferido de RF-SEG-08 en expedientes.

  • fix(archive): Respuesta acotada de link/unlink/rebuild — sin oráculo de pertenencia/volumen/ciclo de vida de expedientes clasificados (M2, RF-SEG-08) Estos endpoints filtraban a un usuario de bajo clearance la pertenencia, el volumen y el estado de un expediente clasificado, aunque su mutación es legítima sin clearance de lectura. Impacto: la mutación SIEMPRE ocurre y SIEMPRE audita; solo se redacta la respuesta cuando el nivel supera el clearance del llamante. Cierra la terna M1/M2/M3 de RF-SEG-08.

  • fix(archive): No-write-up al crear expedientes — no se puede originar un expediente por encima del propio clearance (M3, RF-SEG-08) Un usuario con permiso pero clearance bajo podía crear una CLASIFICADA y perderla de vista de inmediato (el no-read-up ya la oculta). Impacto: 403 explícito, no clamp silencioso (rebajar el nivel sería una desclasificación de facto sin traza).

  • fix(archive): Disposición de expedientes clasificados sin 404 espurio + respuesta acotada que no filtra contenido (M1, RF-SEG-08) close/transfer terminaban leyendo el expediente sin user_id → clearance PUBLICA → 404 espurio pese a que la mutación ya se había comprometido, incluso para un archivista con clearance real. Por qué: la disposición es ortogonal al clearance de lectura (Ley 594 arts. 22-24) — un archivista mueve series al AGN sin leer cada expediente reservado. Impacto: respuesta estrecha sin re-leer, sin exponer metadata/description/radicados.

  • fix(archive): No-read-up en el listado y el PATCH de expedientes (RF-SEG-08) — cierra la enumeración de expedientes reservada/clasificada GET /api/v1/expedientes no filtraba por nivel_seguridad — cualquier portador del permiso enumeraba expedientes clasificados y su total. El mismo leak existía en PATCH /{id}. Impacto: se elimina el default peligroso max_clearance=3; los call sites diferidos pasan explícitamente la constante nombrada CLEARANCE_SIN_RESTRICCION, con el riesgo visible en el código, no escondido en una firma.

  • fix(shared,auth,archive,document,workflow,signature,notification): Auditoría de accesos denegados — el gate RBAC deja traza en audit_log al denegar (cierra el último residual RBAC) Las denegaciones de permiso no dejaban rastro — invisible una escalada de privilegios. Nuevo helper compartido append_denial, best-effort, invocado antes del 403 en los 7 servicios.

  • fix(archive): Mínimo privilegio en el gate RBAC — umbral min_crud por operación; disposición exige crud≥3 (RF-SEG-03) require_permission concedía con cualquier crud>0 sin distinguir lectura/escritura/disposición — un usuario con nivel Leer podía cerrar/transferir expedientes. Impacto: elevado a min_crud=3 en las 9 operaciones de disposición de archive; se cerró además la ruta alterna del PATCH que las eludía.

  • fix(archive): Cierre del residual RBAC en la config archivística + trazabilidad Las 6 mutaciones de configuración archivística (TRD, tipos documentales, plantillas) no aplicaban require_permission — cualquier usuario del tenant podía alterar los instrumentos que gobiernan retención/disposición. Ahora exigen USUA_PERM_TRD y auditan.

  • fix(archive): Cierre del gap RBAC en la gestión de expedientes Los 12 endpoints de expedientes.py no aplicaban require_permission — cualquier usuario del tenant podía crear/cerrar/transferir/leer expedientes sin el permiso. Impacto: destapó y cerró dos bypass adicionales en batch.py y un bug de aislamiento cross-tenant (tarea detached reutilizaba la conexión request-scoped).

  • fix(notification,signature): Cierre de la fuga del OTP — notification-service no persiste el cuerpo de correos sensibles (seguimiento de E06 Inc.2, RF-FIR-13) El OTP viajaba en el cuerpo del correo y notification-service persistía el body completo, dejando el OTP en claro en BD/backups durante 5 minutos. Impacto: nuevo flag sensible — el correo se envía con el body real pero se persiste redactado. La única copia persistente del OTP es su HMAC en firma_challenge.

Añadido

  • feat(signature): 2FA por OTP-correo en la firma personal (E06 Inc.2, RF-FIR-13, ADR-016 addendum) signature-service exige un segundo factor en el acto de firma, ligado a un challenge_id de un solo uso vinculado al hash del documento (Ley 527/1999 art. 7). Impacto: solo el HMAC del OTP se persiste, nunca el OTP en claro; BREAKING: POST /batch pasa a requerir un OTP por documento. No desactivable para nivel≥2.

  • feat(signature,archive): Firma XAdES-B real del índice electrónico + sello institucional (E06 Inc.1, ADR-016 addendum) signature-service estrena un proveedor de firma enchufable; el índice se sella con el certificado institucional del tenant, atribuido a la persona que cierra vía X-User-Id+auditoría. Impacto: si el sellado falla, el cierre no se bloquea — el índice queda pendiente_firma y bloquea la transferencia hasta reintentarse. Sin CA acreditada ONAC, todo queda no_acreditado.

  • feat(signature,workflow,notification): Pulido de 3 seguimientos Baja de la gestión del dead-letter (ADR-021) Cap señalizado en las candidatas de clearance (con header X-Truncated), resolución de nivel en lote (elimina el N+1) e identidad unificada en el listado de workflow.

  • feat(signature): Cierre de los 3 seguimientos no-bloqueantes de la Fase 5 (ADR-021) en signature-service Filtro de clearance fila-a-fila en el listado admin del DLQ, auditoría de vista del detalle, y origin_group obligatorio con índice compuesto.

  • feat(workflow): Cierre de los 3 seguimientos no-bloqueantes de la Fase 5 (ADR-021) en workflow-service Mismo diseño que signature/notification: filtro de clearance en el listado, auditoría de vista, origin_group obligatorio.

  • feat(notification): Cierre de los 3 seguimientos no-bloqueantes de la Fase 5 (ADR-021) en notification-service Mismo diseño que signature/workflow, aplicado sobre el listado admin de notification.

  • feat(notification): Replay/gestión del dead-letter + purga selectiva (Fase 5 Inc.2 + Fase 4 MVP-3 de notification, ADR-021 — cierra el roadmap de eventos) notification estrena la superficie admin completa (listar/detalle/replay/discard) gateada por PERM_DLQ_ADMIN, con replay best-effort (claim-then-send) y purga solo de estados terminales. Impacto: auditoría como dependencia dura — si el módulo de auditoría no está, replay/discard fallan con 503 en vez de commitear sin traza. Cierra el roadmap ADR-021 en los tres servicios.

  • feat(signature,workflow): Purga selectiva de evento_dead_letter (Fase 4 MVP-3, ADR-021) Job que purga solo estados terminales con gracia de 90 días configurable; pending nunca se purga.

  • feat(signature,workflow,auth): Gestión/replay del dead-letter — endpoints admin (Fase 5 Inc.1, ADR-021) Superficie admin por servicio gateada por el nuevo permiso PERM_DLQ_ADMIN; replay atómico con SELECT FOR UPDATE + reproceso en la misma transacción + auditoría (evento.dead_letter.replayed).

  • feat(events): Retención del buffer DLQ Redis vía XTRIM (Fase 4 MVP-2, ADR-021) Los streams dead-letter de Redis crecían sin tope; nuevo helper compartido recorta por edad (30 días configurable) — la copia autoritativa vive en Postgres, Redis es solo buffer.

  • feat(notification): Retención del cache de deduplicación (Fase 4 MVP-1, ADR-021) Job que purga los marcadores de deduplicación vencidos (7 días configurable), iterando tenants activos de forma aislada por schema.

  • feat(notification): Dead-letter durable + idempotencia de eventos (Fase 3, ADR-021 — completa el roadmap de eventos) notification-service consume dos streams sin ser idempotente; nueva tabla de eventos procesados con patrón insert-then-send evita doble correo/webhook. Impacto: trade-off documentado — una caída entre el claim y el envío puede perder ese correo, aceptado porque E16/E11 son efectos de cortesía, no el registro legal.

  • feat(workflow): Recuperación de pendientes + dead-letter durable de eventos (Fase 2, ADR-021) workflow-service (que auto-distribuye radicado.created, el camino más sensible) estrena el mismo dead-letter durable+atómico que signature, cerrando un modo de fallo donde el xack se emitía incondicionalmente aunque la distribución fallara.

  • feat(events,signature): Dead-letter durable y atómico — cierre de R-4 (ADR-021) La traza del dead-letter era best-effort tras el xack y solo vivía en Redis (volátil); ahora se persiste atómicamente en Postgres junto con el asiento de auditoría, con el xack gateado por el éxito.

  • feat(events,signature): Recuperación de pendientes + dead-letter de eventos (Fase 1, ADR-021) Nuevo helper compartido que recupera mensajes vencidos del PEL de Redis Streams y, tras el máximo de reintentos, deriva el evento a un dead-letter por grupo consumidor con traza inmutable en audit_log.

  • feat(frontend): UI de reclasificación del nivel de seguridad en la bandeja Drawer de detalle del radicado con chip de nivel y botón "Reclasificar" gateado por PERM_RECLASIFICAR; modal con motivo obligatorio y campos de reserva condicionales.

  • feat(document): nivel_seguridad expuesto en el read path de radicados La columna existía y se usaba en filtros de clearance, pero no se devolvía en ninguna respuesta; ahora se incluye en todas las lecturas.

  • feat(document,signature,auth): Reclasificación del nivel de seguridad de un radicado (E08) + re-sync del snapshot de firma Nuevo PATCH /api/v1/documents/{id}/security-level con no-read-up, no-write-up y motivo obligatorio; signature-service re-sincroniza su snapshot de nivel al recibir el evento.

  • feat(frontend): T-12d-firma — bandeja del firmante (UI de la cadena E06) Cierra el ciclo E06 de extremo a extremo: bandeja cross-documento, firma individual y en lote, con espejo RBAC de PERM_FIRMA.

  • feat(signature): E06 — cadena de firma (bandeja del firmante) Nuevo modelo de cadena de firma con turnos ordenados; el turno se respeta con SELECT FOR UPDATE (cierra TOCTOU de doble firma); archive verifica la firma del índice contra este servicio.

Corregido

  • fix(signature,workflow): Scoping por origin_group de la tabla compartida evento_dead_letter Las filas se distinguen por origin_group pero ninguna query lo filtraba — la purga de un servicio borraba dead-letters del otro. Las 5 queries pasan a filtrar explícitamente.

  • fix(workflow,notification): Los consumidores de eventos ya no tratan la expiración normal de xreadgroup(block=...) como error redis-py lanza TimeoutError al expirar el bloqueo sin entradas nuevas; caía en el except general y generaba backoff/ruido innecesario. Ahora se captura en rama propia con continue silencioso.

  • fix(signature): Consumidor de re-sync ya no loguea la expiración normal de xreadgroup(block=...) como error Mismo patrón que el fix anterior, descubierto primero en signature-service durante un smoke test E2E (cientos de líneas de log/min sin errores reales).

  • fix(frontend): D-16 — SSR 500 en todas las rutas (app) por onDestroy que toca document AppLayout, AssistantDock, Drawer y Modal limpiaban su listener de teclado con onDestroy, que también corre en SSR donde document no existe — ReferenceError → HTTP 500 en /dashboard, /firmas y toda ruta del layout privado. Impacto: la limpieza pasa al retorno de onMount (solo cliente); verificado en navegador real junto con la bandeja de firmas y el no-read-up.

  • fix(auth): D-07 — espejo RBAC de la UI roto GET /auth/me no devolvía permissions — el frontend hacía me.permissions ?? [], siempre vacío, así que toda la UI de control por rol quedaba desactivada. UserInfoResponse ahora incluye los permisos efectivos resueltos desde la BD del tenant.

Seguridad

  • fix(archive): [duplicado] Ciclo de vida de transferencias (E12) — carrera TOCTOU letal cerrada Entrada repetida en el changelog original, idéntica a la de la sección Seguridad anterior.

  • fix(archive): [duplicado] No-read-up en la consulta de eventos de preservación por expediente Entrada repetida en el changelog original.

  • fix(archive): [duplicado] No-read-up en la descarga del AIP de preservación Entrada repetida en el changelog original.

  • fix(workflow): [duplicado] CRUD de reglas de enrutamiento automático sin autorización Entrada repetida en el changelog original.

  • fix(document) + fix(storage) + fix(gateway): [duplicado] No-read-up en la descarga de anexos Entrada repetida en el changelog original.

  • fix(workflow): [duplicado] No-read-up en la bandeja + tenencia en rollback Entrada repetida en el changelog original.

  • fix(document): [duplicado] No-read-up en las Salidas vinculadas Entrada repetida en el changelog original.

  • fix(document): [duplicado] Cierre del no-read-up en firmas, anulación, respuesta vinculada y envíos Entrada repetida en el changelog original.

  • fix(document): [duplicado] No-read-up en disposición y PATCH de radicados Entrada repetida en el changelog original.

  • fix(archive): [duplicado] Atribución del audit_log de la renovación WORM Entrada repetida en el changelog original.

  • fix(notification): [duplicado] Sellado de /send + autorización de webhooks Entrada repetida en el changelog original.

  • fix(workflow): [duplicado] Enforcement RBAC de las transacciones de trámite Entrada repetida en el changelog original.

  • fix(workflow): [duplicado] Cierre de hallazgos bloqueantes en workflow-service Entrada repetida en el changelog original.

Cambiado

  • docs(rebrand): Nombre visible del proyecto OrfeoMCP → OrpycaMCP Por qué: el proyecto es la evolución de Orfeo bajo el concepto Orpyca ("Orfeo Pyme Calidad") con conexión a IA vía MCP. Impacto: convención por superficie — marca OrpycaMCP, repo/imágenes orpyca-mcp, imports Python orpycamcp_common, datos/infra orpycamcp (sin la sigla, por restricciones de Postgres/MinIO).

  • refactor(infra): Renombrado del plano de datos orfeomcp → orpycamcp Completa el rebrand en datos/infra: BD, credenciales, realm Keycloak, buckets, streams Redis, paquete Python. Impacto: requiere recrear volúmenes (postgres/minio/keycloak) — los datos de desarrollo previos no son compatibles.

  • chore(ci): CI unificada en GitLab — eliminado .github/workflows/; README/badges/clone apuntan a GitLab.

Agregado

  • feat(infra): Frontend en su propio contenedor Split de URLs público/interno, variables para el redirect_uri del PKCE tras el proxy, y pinning de issuer para el desfase navegador↔red Docker.

  • fix(workflow): GET /workflows/{id}/events acotado por clearance (E05 §10 / RF-SEG-08) — la hoja de ruta del radicado solo es visible para quien pueda leerlo.

  • feat(archive): Hoja de ruta del expediente (E02 §9) — tabla append-only de eventos y GET /expedientes/{id}/eventos acotado por clearance.

  • feat(audit): Cobertura de public.audit_log (E08 §9) — workflow y archive emiten a la auditoría forense transversal vía la librería compartida.

  • chore(compose): Puertos de desarrollo movidos a rango alto (19xxx/15432/16379) para no chocar con otras pilas de desarrollo. Comunicación interna sin cambios.

  • fix(compose): signature-service y knowledge-service esperan postgres healthy — antes salían con ConnectionRefused en el arranque.

  • fix(security): Blindaje total de lectura — GET de detalle y búsqueda semántica (RF-SEG-08 / RF-BUS-04) Cierra las superficies de lectura que aún no aplicaban clearance: acceso directo por id/tracking, y la búsqueda semántica de knowledge-service, que confiaba en el acl enviado por el cliente en vez de resolverlo en el servidor. Impacto: control de acceso de lectura cerrado en TODAS las superficies (búsqueda, listado, detalle, recuperación semántica).

  • fix(document): Control de acceso por clasificación en la búsqueda (RF-SEG-08 / RF-BUS-04) La búsqueda solo aislaba por tenant; dentro de un tenant cualquier usuario veía todo radicado sin importar clearance. Impacto: nueva columna nivel_seguridad con DEFAULT 1 (no rompe lo existente); filtro no-read-up fail-closed a PUBLICA.

  • fix(archive): Misma acotación por clearance en listado de radicados y búsqueda de expedientes (RF-SEG-08 / RF-BUS-04) Completa el control de acceso de lectura en las superficies que aún exponían todo dentro del tenant.

  • docs(adr): ADR-020 — sistema de diseño Orpyca + asistente conversacional del frontend (E22) Fija las decisiones transversales de diseño/UX e IA antes de codificar el frontend: tokens --op-*, UI por rol espejo del RBAC, asistente que traduce lenguaje natural a tools MCP sin ejecutar acciones por sí mismo.

  • chore(quality): Saneamiento previo al primer commit — ruff check limpio (36 hallazgos corregidos), .env.example completados, notas de trabajo movidas fuera del árbol versionado.

  • chore(license): Encabezado de atribución/licencia en todo el código fuente — añadido a 437 archivos con la nota AGPL v3.

  • feat(mcp-server): OAuth por sesión — el MCP obtiene/refresca el token Keycloak (E18, ADR-019) El mcp-server gestiona el ciclo de vida del token vía ROPC/refresh de auth-service, con caché por expiración. Completa la capa MCP (tools + resources + prompts + stdio + HTTP + OAuth).

  • feat(mcp-server): Resources y prompts MCP (E18, ADR-019) — completa las tres capacidades MCP (tools + resources + prompts) resueltas contra el gateway vía el catálogo.

  • feat(mcp-server): Transporte MCP HTTP streamable para clientes remotos (E18, ADR-019) — endpoint POST /mcp con StreamableHTTPSessionManager; el contexto de sesión llega por cabeceras.

  • feat(mcp-server): Servidor MCP funcional por stdio (E18, ADR-019) — binding del SDK MCP oficial sobre el catálogo/dispatcher existentes, con handshake verificado.

  • docs(ops): Notas de operación de integraciones externas (ES+EN) — documenta el punto de swap y contrato estable para cada capacidad enchufable (PKI, WORM, PDF/A, MCP, RAG, operador postal).

  • docs(i18n): Documentación publicable completa en español e inglés — traducidas al inglés las 14 páginas del nav; mkdocs build --strict limpio en ambos idiomas.

  • feat(document): Dashboard estadístico ampliado + export CSV (E09, exigible AGN) — nuevo desglose por dependencia y exportación CSV del resumen de radicados.

  • feat(document): Radicación ciudadana de PQRS (público, Ley 1755/2015) — endpoint público sin autenticación que crea un radicado de Entrada con seguimiento por código de verificación.

  • feat(document): Plantillas + borradores de Salida (paridad legado) — flujo crear/editar/aprobar/radicar antes de asignar número oficial.

  • docs(api): Documentar la administración del tenant (E14) — festivos/días no hábiles y cálculo de días hábiles, que existían en código pero no en api.md.

  • feat(workflow): Vistos buenos secuenciales (paridad legado) — cadena ordenada de revisores; un rechazo detiene la cadena.

  • feat(document): Respuesta rápida — Salida vinculada a su antecedente de Entrada (paridad legado) — cubre el 30-50% de la producción diaria de Salidas.

  • feat(document): Anulación de radicado en dos pasos (paridad legado) — la ley prohíbe borrar radicados; se anulan con aprobación supervisora, el número se conserva.

  • feat(workflow): Devolución al remitente (paridad legado) — re-enrutar un radicado mal asignado, la acción más frecuente tras recibir.

  • feat(tenant): Catálogos de operador — causales, formas de envío, soportes, mensajes rápidos (paridad legado, E14).

  • feat(archive): Inclusión masiva de radicados a un expediente (paridad legado) — hasta 500 por lote, idempotente, regenera el índice una sola vez.

  • feat(storage): Empaquetado AIP (BagIt + manifiesto PREMIS) (E10 RF-PRE-05/07, ISO 14721 OAIS) — primera versión del empaquetado OAIS, con WORM real y veraPDF pendientes de incrementos posteriores.

  • feat(archive): Firma del índice electrónico al cierre del expediente (E15-F3, RF-EXP-06) — best-effort: si signature-service no responde, el cierre no se bloquea.

  • feat(signature-service): Nuevo servicio de firma electrónica (E06, ADR-016) — proveedor pluggable (nativo por defecto), firma y verificación de payloads.

  • fix(gateway): Enrutar el motor de reglas /api/v1/workflow/rules/ (E05) — el prefijo singular dejaba el motor de reglas inalcanzable vía gateway (404).

  • docs(domain): Actualizar domain.md con las entidades F3-F6 (índice electrónico, firma, archivo físico, FUID, transferencia, preservación, envío postal, webhook).

  • docs(adr): ADR-014 — búsqueda con PostgreSQL FTS (tsvector + pg_trgm + JSONB), no Elasticsearch — coherente con el aislamiento por schema, sin infra externa.

  • feat(document): Ingesta de correo IMAP → radicado de Entrada (E19/F2) — crea un radicado de Entrada por correo no leído, con remitente y anexos en metadata.

  • feat(document): Consulta pública por código de verificación (E13/F2) — un ciudadano verifica trazabilidad sin login (Ley 1712/2014), solo campos no sensibles.

  • docs(adr): ADR-017 — archivo físico: Ubicación (dirección recursiva) ≠ Unidad de conservación (contenedor móvil), patrón ArchivesSpace.

  • feat(document): Envíos postales de radicados (E20/F5, vía E11) — proveedor pluggable, máquina de estados registrado→en_transito→entregado/devuelto/fallido.

  • docs(adr): ADR-019 — capa MCP como fachada fina cliente del gateway — catálogo de tools declarativo, sin lógica de negocio ni BD propia.

  • feat(knowledge-service): Nuevo servicio de capa de conocimiento (E21/F6, ADR-006) — pgvector por tenant, proveedor de embeddings pluggable (stub local por defecto, sin LLM), capa advisoria/derivada.

  • feat(mcp-server): Nuevo servicio de capa MCP (E18/F6, ADR-019) — catálogo declarativo de tools mapeadas 1:1 a endpoints REST, filtradas por permisos del usuario.

  • feat(storage): Plan de preservación digital + eventos PREMIS (E10/F5, RF-PRE-01/07, AGN 001/2024 art. 4.3.2.6) — registro inmutable de intervenciones sobre el acervo.

  • docs(adr): ADR-018 — interoperabilidad saliente por webhooks HTTP firmados (HMAC-SHA256) — suscritos por tenant, sin exponer el bus interno.

  • feat(notification): Webhooks salientes firmados (E11/F5, ADR-018) — entrega best-effort con reintentos que nunca bloquea el procesamiento del evento origen.

  • feat(archive): Transferencias documentales primarias/secundarias (E12/F5, AGN 001/2024 art. 4.4.x) — ciclo preparada→enviada→recibida/rechazada; el expediente se congela al recibir.

  • feat(archive): FUID — Formato Único de Inventario Documental (E17 RF-ARF-10, AGN 042/2002) — inventario canónico consumido por las transferencias.

  • feat(archive): Préstamos e historial de movimientos físicos (E17 RF-ARF-07/08) — control de salida/devolución de unidades, traza append-only.

  • feat(archive): Modelo de archivo físico nuclear (E17/F5, ADR-017) — ubicación recursiva + unidad de conservación con signatura topográfica.

  • feat(archive): Verificación de integridad del índice (E15 T-14) — contrasta la huella registrada con el hash actual de cada documento.

  • feat(archive): Valor huella por documento en el índice (E15 T-08) — el hash SHA-256 viaja con el vínculo, lo aporta quien conoce el contenido.

  • feat(archive): Actualización automática del índice + inmutabilidad al vincular/excluir (E15 RF-EXP-04) — un expediente no open rechaza cambios de vínculo.

  • feat(archive): Índice electrónico del expediente (E15, Acuerdo AGN 001/2024) — pieza que da validez legal al expediente electrónico; XML con huella canónica y versionado append-only.

  • docs(adr): ADR-016 — firma electrónica nativa (SHA-256 + identidad + sello de tiempo), PKI/PDF diferida.

  • feat(document): Firma electrónica de radicados/anexos (E06/F3, ADR-016) — deja constancia verificable de quién firmó qué y cuándo.

  • feat(archive): Ciclo de vida del expediente — cierre/transferencia/índice (E02/F3, ADR-015) — aplica la disposición TRD al cerrar.

  • docs(adr): ADR-015 — modelo TRD/CCD (jerarquía serie/subserie, retención en dos fases, disposición AGN CT/E/S/M).

  • feat(archive): Modelo TRD/CCD y cálculo de retención (E04/F3, ADR-015) — subseries, retención en dos fases, cálculo de disposición final.

  • feat(archive): Búsqueda full-text de expedientes (E09/F2, ADR-014) — FTS con ranking, filtros por estado/fecha/metadata.

  • feat(document): Reportes/estadísticas de radicados (E09/F2) — conteos por tipo/estado/mes.

  • feat(document): Búsqueda avanzada (E09/F2, ADR-014) — texto opcional, filtros combinables, ranking cuando hay texto.

  • fix(gateway): Enrutamiento de todos los prefijos de dominio (consolidación F1) — varios prefijos de dominio quedaban inalcanzables (404) vía gateway.

  • feat(auth): auth-service — Keycloak ROPC, JWT validation, audit log, endpoints /token /refresh /logout /validate /me.

  • feat(tenant): tenant-service — CRUD de instituciones + provisioning de schema PostgreSQL por tenant.
  • feat(gateway): api-gateway — proxy centralizado, validación JWT, rate limiting Redis, inyección de headers de identidad.
  • feat(document): document-service — radicación E/S/I, numeración atómica, gestión de referencias a anexos.
  • feat(storage): storage-service — upload a MinIO, SHA-256, deduplicación, URLs pre-firmadas, buckets por tenant.
  • feat(workflow): workflow-service — flujos de distribución entre dependencias, historial, eventos Redis Streams.
  • feat(archive): archive-service — expedientes con ciclo de vida, vinculación de radicados, TRD con retención documental.
  • feat(notification): notification-service — consumer Redis Streams, SMTP, historial en Redis.
  • refactor(all): Retirar SQLAlchemy/Alembic — asyncpg directo y migraciones SQL en archivos numerados.

Fase 1 — Cimientos (en progreso, 2026-06-16)

  • feat(shared): Librería compartida orpycamcp_common (ADR-010) — auditoría (cadena de hash) y sobre de eventos idénticos en todos los servicios.

  • feat(auth): public.audit_log canónico append-only + migración de auditoría (ADR-008/010) — tabla particionada por mes con trigger de inmutabilidad y cadena de hash.

  • chore(infra): Contexto de build en la raíz + .dockerignore — permite COPY shared en los Dockerfile; patrón a replicar en servicios que usen la librería compartida.

  • feat(infra): Runner de migraciones por tenant (ADR-012) — separa migrations/global/ (→ public) de migrations/tenant/ (→ cada schema), idempotente.

  • feat(auth): RBAC de 4 tablas + URD + clasificación de seguridad (E08) — Keycloak autentica, la BD autoriza (RF-SEG-03/04/05); seed de 15 permisos.

  • feat(auth): Resolución de permisos + endpoints de administración RBAC (E08) — RBACService.resolve_effective_permissions (MAX de crud por permiso, ROOT primero); pendiente el gate require_permission (espera el JWT enriquecido).

  • feat(tenant): Configuración por tenant — calendario de días hábiles, parámetros y catálogos (E14) — festivos colombianos (Ley 51/1983) y cálculo de días hábiles para el plazo de respuesta.

  • feat(auth): Clasificación de seguridad y verificación de acceso (E08, RF-SEG-08, Ley 1712/2014) — catálogo de niveles y resolución del clearance del llamante.

  • feat(auth): URD multi-dependencia + cambio de contexto (E08, RF-SEG-04, ADR-013) — un usuario con rol en varias dependencias fija su dependencia activa sin reemitir token.

  • feat(auth): Consulta y verificación de auditoría (E08, ADR-008) — GET /audit y /audit/verify recalculan la cadena de hash; repositorio de solo lectura.

  • feat(auth): Gate require_permission en endpoints RBAC + decisión R4 (E08, ADR-013) — los endpoints de administración RBAC ganan el gate que les faltaba; Keycloak autentica, la BD autoriza.

  • docs(adr): ADR-013 — autorización resuelta en la BD por request — se descarta firma propia y token-exchange; permisos siempre frescos, revocación inmediata.

  • feat(tenant): CRUD de catálogos de referencia (E14) — tipos de identificación/remitente/anexo y medios de recepción parametrizables por institución.

  • fix(tenant): Endpoints de tenants devolvían 404 con barra final — redirect_slashes=False sin rutas registradas con barra; alineado a la convención /api/v1/{resource}.

  • feat(notification): Alertas de vencimiento de trámite (E16↔E05, RF-FLU-07) — el barrido de vencimientos de workflow ahora dispara un correo de aviso.

  • feat(workflow): Motor de transacciones de trámite (E05, RF-FLU-01) — catálogo de 12 transacciones (informar, NRR, VoBo, anular, cerrar exp...) con evento append-only por acto.

  • feat(workflow): Vencimientos y semáforos del trámite (E05, RF-FLU-07) — semáforo calculado en lectura (verde/amarillo/rojo/vencido); barrido periódico que alerta y marca sin duplicar.

  • feat(workflow): Bandejas tipadas de trámite (E05, RF-FLU-03) — listado de pendientes por tipo (Entrada/Salida/Internos), priorizado por antigüedad.

  • feat(workflow): Rollback de asignación + reasignación en cascada (E05, RF-FLU-08) — deshace la última asignación errónea; reasigna masivamente sin radicados huérfanos.

  • feat(workflow): Distribución automática por enrutador (E05, RF-FLU-04) — al radicar, workflow-service evalúa las reglas activas y autoasigna, idempotente.

  • feat(workflow): Historial de trámite append-only flow_events (E05, RF-FLU-02) — trigger que rechaza UPDATE/DELETE; cada transacción escribe su evento en la misma tx que el paso.

  • feat(workflow): Motor de reglas con operadores, AND/OR y evaluación batch (E05) — operadores comparativos sobre 8 campos, combinables; evaluación de hasta 500 radicados en una sola lectura de reglas.

  • refactor(storage,workflow): Migraciones reorganizadas a tenant/ (ADR-012) — ambos servicios compartían tablas entre instituciones (aislamiento roto).

  • feat(notification): Reintentos SMTP con backoff (E16) — hasta 3 intentos con backoff lineal; estado persistido.

  • feat(notification): Historial de notificaciones persistido en BD por tenant (E16, ADR-002/012) — el historial vivía solo en Redis (volátil, TTL 30 días); ahora durable y aislado por institución.

  • feat(document): Filtro de consulta por metadato (GIN) + disposición documental (E03, RF-MET-02/08) — consulta por campo de metadato vía contención JSONB; columna de disposición gestionable.

  • feat(archive): Catálogo de tipos documentales (3er nivel TRD) (E03, RF-MET-07) — CRUD con plantilla de metadatos asociada.

  • feat(document): Catálogo de elementos de metadato (E03, RF-MET-03) — definición reutilizable de campos con tipo, ocurrencias y mapeo Dublin Core.

  • feat(document): Clasificación y foliación de anexos (E07, RF-DIG-01/RF-DIG-10) — tipo documental, marca de principal y número de folios por anexo.

  • feat(storage): Versionado inmutable de anexos (E07, RF-DIG-01) — "reemplazar" crea una nueva versión conservando las anteriores, no sobrescribe.

  • feat(storage): Integridad en descarga + validación de formatos (E07, RF-DIG-02/04) — recalcula el SHA-256 al descargar y valida el MIME contra una allowlist en la subida.

  • feat(archive): Metadatos flexibles del expediente (E03, ADR-007) — JSON Schema por serie, igual que el radicado.

  • refactor(archive): Migraciones reorganizadas a tenant/ (ADR-012) — el seed institucional de FondeCund deja de auto-aplicarse a todos los tenants.

  • feat(document): Metadatos flexibles por tipo documental (E03, ADR-007) — JSON Schema versionado por tipo; radicar sin cumplir el schema falla con 422.

  • refactor(workflow): Migración al sobre canónico de eventos (E05, ADR-010) — workflow-service adopta el mismo sobre que document-service; elimina el formato plano legado.

  • feat(notification): Consumo del stream canónico de document (E16, ADR-010) — al radicar, la dependencia destino recibe notificación; despacho por nombre de stream.

  • feat(document): Auditoría inmutable + eventos de dominio en la radicación (ADR-008/010, E01) — cada radicación escribe en audit_log atómicamente con el INSERT y publica el evento (best-effort).

  • feat(document): Vínculo de radicados con el organigrama de dependencias (E01) — valida origin_dept/dest_dept contra el catálogo, con snapshot legal del nombre.

  • feat(document): Fecha de vencimiento en días hábiles (RF-RAD-04, E01) — calcula el plazo consumiendo el calendario de tenant-service vía HTTP.

  • fix(document): Aislamiento por-tenant de radicación (ADR-012, E01) — la numeración de radicados (tracking_sequences) quedaba compartida entre instituciones antes de este fix.

  • refactor(document): Routers de radicación/búsqueda/batch usan get_tenant_conn (ADR-002/012, E01) — search/batch no fijaban search_path y solo funcionaban con las tablas en public.

  • feat(tenant): CRUD jerárquico de dependencias (organigrama, E14) — árbol jerárquico, consumido por la radicación para numerar por dependencia.

Decisiones de arquitectura — ADR (2026-06-15)

  • docs(adr): ADR-006 — proveedor de IA configurable multi-proveedor — soberanía local por defecto, recomendada.
  • docs(adr): ADR-007 — metadatos JSONB validados por plantilla — se descarta EAV.
  • docs(adr): ADR-008 — auditoría inmutable public.audit_log — append-only particionado por mes con cadena de hash por tenant.
  • docs(adr): ADR-009 — historial de flujos por eventos — flow_events append-only como fuente de verdad, flow_steps reconstruible.
  • docs(adr): ADR-010 — librería compartida orpycamcp_common — auditoría y sobre de eventos idénticos en todos los servicios.
  • docs(adr): ADR-011 — frontend SvelteKit en el monorepo — stack alineado con sgdINTI (SSR + Vite + Bulma).
  • scaffold(frontend): Estructura inicial de frontend/ — herramientas y árbol fijados, dependencias no instaladas (Docker-only).

Fase 6 — Características Avanzadas (2026-06-05)

  • feat(phase6): TRD seed data migration — scripts/migrate_trd_from_fondecund.py con 150+ series de FondeCund.
  • feat(batch): Batch Documents — POST /api/v1/batch/documents (1-1000 radicados, 202 Accepted, job tracking).
  • feat(batch): Batch Expedientes — POST /api/v1/batch/expedientes (1-100 expedientes con radicados).
  • feat(search): Full-Text Search — GET /api/v1/search?q=... con PostgreSQL pg_trgm + tsvector.
  • feat(workflow): Workflow Rules Engine — CRUD de reglas + evaluación automática + audit log.
  • docs(adr): ADR-005 documenta decisión de Batch API (async jobs vs sync).

Fase 5 — Comunidad y Publicación (2026-06-04)

  • infra(deploy): docker-compose.prod.yml + infra/.env.example + docs/es/deployment.md.
  • docs(contributing): CONTRIBUTING.md con workflow, testing y requisitos de documentación.
  • docs(readme): README.md en inglés con quick start, arquitectura, features.
  • docs(en): docs/en/getting-started.md + docs/en/api.md — documentación bilingüe.
  • infra(ci): GitHub Actions workflows — docker-build.yml + ci.yml (lint, test, E2E).
  • feat(openapi): agregación de specs en api-gateway (GET /api/v1/openapi.json).
  • infra(registry): infra/DOCKER_REGISTRY.md — guía GHCR + tagging strategy.

Fase 4 — Calidad e Integración (2026-06-04)

  • infra(lint): pyproject.toml centralizado — ruff + mypy en todos los 8 servicios.
  • infra(health): /health orquestado verifica Redis + 7 servicios en paralelo.
  • test(e2e): docker-compose.e2e.yml con tests de flujo completo.
  • docs(adr): ADR-003 documenta decisión de asyncpg vs ORM.

[0.1.0] — 2026-06-03

Agregado

  • Estructura base del proyecto con 8 microservicios FastAPI
  • docker-compose.yml con infraestructura completa de desarrollo
  • Realm Keycloak con roles RBAC y usuarios de prueba
  • Schema isolation PostgreSQL por tenant
  • Pipeline GitLab CI/CD con documentación automática