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.mdno 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 elArchiveTimeStampde XAdES-LTA se renueva. Qué cambia: 1 (document + notification) — los eventosdocument.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 elWHEREpor 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 consumedocumento.contenido_cambiadoe invalida las solicitudes cuyopayload_sha256no 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 asientofirma.circuito_invalidadoen la misma tx, solo si invalidó alguna; el endpoint interno síncrono exige{cuerpo_sha256_vigente}(422sin él); el replay de la DLQ de signature despacha porevent_type(antes aplicaba siempre la reclasificación). 3 (knowledge) —searchyget_pivot_chunkgatean conGREATEST(nivel del fragmento, nivel vigente del radicado)en elWHERE; barrido horario monótonoreconciliacion_nivel(solo sube; tombstonea lo de proveedor externo ≥ RESERVADA) con asientoknowledge.nivel_reconciliado(canalsystem); el docstring falso deingestion.pyse corrige. 4 (document) — un callback postal con firma válida de una entidad suspendida responde202 deferredy queda en cola (migración tenant048), que el barrido aplica al reactivar (fase 5) con la fecha de recepción; cola llena (POSTAL_DIFERIDOS_MAX=10000) →503conRetry-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 trasremote_signresponde503contotp_posiblemente_consumidoy asientafirma.autoverificacion_fallida_tras_totp(también en la firma por cadena). 7 (signature + storage + archive) — renovación delArchiveTimeStampde XAdES-LTA (TimeStampValidationData+ ATS nuevo sobre todo lo previo, EN 319 132-1 §5.5.2): cadenafirma_sello_archivo(signature, migración026), storage migración022(firma_lta_renovada,renueva_a) con herencia WORM y evento PREMISRENOVACION;/verifyvalida cada ATS (reglaarchive_timestamp_cadena_rota) y acepta el original o cualquier XML vigente de la cadena;GET /firmas/{id}exponeminio_key_vigenteyarchive_timestamp_ordinal; eventofirma.lta_renovada(best-effort, sin consumidor hoy); job diario (LTA_RENOVACION_MARGEN_DIAS=90,LTA_RENOVACION_INTERVALO_S=86400,0lo 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_jobacotado con--slug(1,35 s). Pruebas: nuevastest_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 document048, signature026y storage022(la048antes del barrido nuevo). Desplegar auth y signature juntos (cert_fingerprintobligatorio ensign-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 dejarrecorded_no_changea 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 defirma.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 responde404 route_not_foundy no500(residuos declarados en ADR-036: sin query ni cuerpo, equivalencia%2F); el cuerpo del callback postal admiteocurrido_at(ISO 8601 con zona, dentro del HMAC), que queda enocurrido_operador_at:tses la recepción del aviso del operador, no la fecha del hecho;413 cuerpo_demasiado_grandea 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 (descartahuella_no_coincide) y trasPOSTAL_DIFERIDO_MAX_INTENTOS=10descarta con asientodocument.envio_callback_diferido_descartadosin bloquear la cola; el aviso de devolución llega también al tenedor actual (buzón de la ubicación +flujo_usuario) y, si hayresponde_a, al buzón y al poseedor de la entrada, con dedupe por dirección y gate en elWHEREpor destinatario; el pivote de/antecedentesgatea en elWHERE(max_clearance). L (signature, storage; ADR-034 §5) — no se renueva sobre una TSA ya caducada (eslabóncaducado, asientofirma.lta_cadena_caducada);/verifyjuzga el último ATS a la fecha actual (lta_vigente; si ya no prueba, baja a XAdES-LT con la advertenciaarchive_timestamp_caducadoy sin vigencia diferida); elTimeStampValidationDatalleva 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 sinRevocationValues; 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 dejafirma.lta_sello_vigente; el job recorre entidadesactiveysuspended; solo un404 borrador_not_foundcuenta como borrador desaparecido; el vencimiento del eslabón 0 sale de la huella de la TSA; un token sin certificados dejafirma.lta_sin_eslabonsin tumbar la firma (migración signature026editada:caducado,ultimo_aviso, vista). storage renueva la retención en cascada a losfirma_lta_renovada(renueva_a) con PREMISRENOVACIONretencion_en_cascada, yrenovar-artefacto-internalexige que el anterior sea de firma y el nuevo XML (422 artefacto_no_es_de_firma) y hereda el mayorretain_untilde la cadena. L-H3 (storage, archive; ADR-035 §8) — una entidad suspendida sigue conservando:get_db_connde storage admite, conX-Internal-Tokeny solo para la lista explícitaRUTAS_DE_CONSERVACION(app/core/conservacion.py), las operaciones de conservación, con asientoentidad.conservacion_admitida;inactivey 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/verifydevuelvefirma.lta_vigente. R2 — el asientodocument.envio_estado_operador(y_no_aplicada) registraocurrido_operador_atyrecibido_at; unocurrido_atposterior 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 nuevaTSA_CA_PEM_PATHS, PEM separados por comas), y una cadena caducada dejafirma.lta_cadena_caducadauna sola vez aunque tenga varios eslabones;GET /expedientes/{id}/indice/verifyverifica el XML vigente (renovado) y, si signature no lo resuelve, cae al original con la advertenciaartefacto_vigente_no_resuelto; la excepción de conservación de storage compara la ruta exacta (fullmatchsobrescope["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_userssin calificar conpublicen elsearch_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/verifyquitaba 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 plegaba403 tenant_inactivoa503 auth_service_errory 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 (enrequire_permission) dejaba abiertas las lecturas que no piden permiso:GET /workflows/inboxrespondía 200 con la entidad suspendida. Qué cambia: N1 —orpycamcp_common.tenant.atencion_publica(COMPLETA | SOLO_LECTURA | NINGUNA): una entidadsuspendedcon schema aprovisionado sirveGET /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}/ventanillay las tres consultas previasventanilla/tipos|politica|estado) responden503 canal_no_disponible+Retry-Afterantes de fijar su schema. Inexistente, dada de baja (inactive) y sin aprovisionar siguen con el404 not_foundneutro (la existencia de una entidad operativa o suspendida no es secreta). El gateway reenvía ahoraRetry-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 =404neutro; con la entidad operativa se conserva el401 firma_invalida. N3 —X-Identity-Tsentra enCAMPOS_FIRMADOS(lo estampaidentidad.firmar) yidentidad.verificarrechaza una identidad de más deIDENTITY_MAX_AGE_SECONDS(120 s), de más deIDENTITY_MAX_SKEW_SECONDS(30 s) en el futuro o sin instante entero; yget_db_connde archive, workflow y storage llama averedicto_entidadantes de ceder la conexión (solo si la petición trae unX-Tenant-Slugválido: las rutas públicas y el slug mal formado conservan su camino). N4 —POST /auth/tokeny/auth/refreshno 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), yresolve_identitydel mcp-server propaga esos códigos desde/auth/meen lugar de503 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/tests540 passed (identidad con instante,atencion_publica, guardián por forma deget_db_connde 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_publicainvertida 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_connsin 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 sinRetry-After(1). En vivo condemosuspendida: login 403;verify200; eliminación pública 200; PQRS yventanilla/estado503 conRetry-After(a través del gateway); callback postal con firma inválida o ilegible 404; el mcp-server directo 403tenant_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);demorestaurada aactive/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 montanIdentidadFirmadaMiddleware(auth, document, archive, workflow, storage, knowledge, notification, signature, tenant); uno rezagado responde401 identidad_no_autenticaa todo lo que le llegue del gateway. El OAI/CMIS siguen apagados en desarrollo porpublic_interop_enabled(el mismo 404 neutro). Hallazgo aparte:docker-compose.ymlno defineIDENTITY_SIGNING_SECRETpara storage-service, que por eso no verifica la identidad (ni con N3). El frontend debe mostrar «Entidad suspendida» ante el403 tenant_inactivodel login y del refresh, y tratar503 canal_no_disponiblede 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: elWHEREdeavalar_lote_eliminacionreconstruye de los asientostenant.updateddepublic.audit_loglos intervalos en que la entidad no fueactive(la ventanilla, único canal de oposición, daba503) y exige que los días operativos entrepublicado_aty ayer alcancen el plazo original; si no,409 plazo_oposicion_interrumpidocondias_suspendidosyplazo_efectivo_hasta, y el acta declaraplazoOposicionEfectivoHasta/diasPlazoInterrumpidosolo si hubo interrupción (sin endpoint nuevo: el plazo efectivo se deriva). D (document)GET /public/{t}/ventanilla/politicapasa a lectura (get_public_tenant_conn) y se sirve con la entidad suspendida; se lee directo desgd_param_adminporque tenant-service veta a la suspendida (comprobado en vivo: por él daba503 politica_no_verificable). E (signature) el fixture autouse_nivel_vigente_neutroya no alcanza atests/integration. F el test de la 025 filtrapg_constraintporconrelid = '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_updatedevuelve(nivel, copia_bloqueada)y elnivel_anteriordel asientofirma.solicitud_reclasificadasale de la copia bloqueada. Deuda declarada (B, Media): el callback postal con la entidad suspendida responde503conRetry-Afterfijo; 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 delWHEREcaen 2); document-service 1173 passed; signature-service 754 passed (7 skipped); auth-service 381 passed (66 skipped; +4 de revocación, mutación: sinlogoutcaen 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 tenantsuspended; era parcialmente falso (document-service sí lo hacía, sin ningún test) pero cierto para los otros diez: losget_tenant_connde auth, knowledge, notification, signature y tenant solo validaban la forma del slug, archive/workflow/storage fijan elsearch_pathen más de veinte puntos, un schema inexistente acababa enUndefinedTableError(500) y cada ruta pública (/public/*, CMIS, OAI, eliminación de archive) usaba su propio predicado de existencia, o ninguno (public_eliminacionignorabastatus). Aparte, el gateway ataba la marcaX-Origen-Canala la cabeceraX-Tenant-Slugdel cliente y no al tenant del token (la marca solo afecta a la etiquetacanalde un asiento, pero esa etiqueta es probatoria). Qué cambia: B9-1 —orpycamcp_common.tenant.estado_entidad(OPERATIVA | INACTIVA | NO_PROVISIONADA, una consulta conto_regnamespace) yveredicto_entidad(fail-closed: el registro caído es503)./auth/validatedevuelvetenant_estado(operativa | inactiva | no_provisionada | desconocido) y no aprovisionaauth_usersen 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_disponiblesi el campo falta o esdesconocido;desconocidono se cachea; la suspensión surte efecto como mucho tras el TTL de 15 s de la caché de validación); los seisget_tenant_conn(auth, document, knowledge, notification, signature, tenant) llaman averedicto_entidadantes delSET search_path(en document sustituye a_tenant_activo). Rutas públicas: document usaget_public_tenant_conn/public_tenant_conn(slug mal formado, entidad inexistente, suspendida o sin aprovisionar = el MISMO404 not_found(sustituido por el Bloque N, arriba: la suspendida pasa a solo lectura pública) con el mismo cuerpo; ya no hay400 invalid_tenantni lista vacía/habilitada=falsepara el inexistente), CMIS y OAI (document y archive) y la eliminación pública de archive sustituyento_regclass/SELECT 1 FROM tenantsporestado_entidad. Consumidores de eventos y tareas en segundo plano no se bloquean (cerrar la convergencia dereclassifiedabriría una fuga; ver ADR-035). B9-4 —derive_canalrecibepayload["tenant_slug"]y norequest.headers["x-tenant-slug"]; el mcp-server no cambia (ya firmaba con el tenant del token); corregido el docstring deorigen.py(se firma conMCP_ORIGIN_SECRET). Pruebas: api-gateway 183 passed; auth-service 432 passed (RUN_INTEGRATION=1; 5 deestado_entidadsobre Postgres real y 2 devalidate); document-service 1166 passed (RUN_INTEGRATION=1; incluye el403 tenant_inactivodeget_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/tests506 passed (guardián por forma de los seisget_tenant_connyestado_entidad/veredicto_entidad; el único rojo del arnés es un symlink deCLAUDE.mdfuera del montaje). Lint (ruff + mypy) limpio en los ocho servicios tocados. Mutaciones, todas caen: canal por cabecera del cliente (2 tests), campo ausente = operativa,desconocidocacheado, gate solo sin caché (2), sinto_regnamespace,status IN ('active','suspended'), aprovisionar antes del estado, quitarveredicto_entidadde cada uno de los seisget_tenant_conn(más el guardián por forma), dependencia pública sin estado, callback postal sin estado, yto_regclassen lugar deestado_entidaden 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 conget_tenant_conn: knowledge, notification, signature, tenant; archive por su OAI y su eliminación pública). El frontend debe tratar un403 tenant_inactivoen/auth/mey en el login como «Entidad suspendida», y las pantallas de la ventanilla pública un404también para la entidad inexistente. Riesgo conocido: un token de superadmin con eltenant_slugde 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 design-xml. Qué cambia:(app)/+layout.server.jsclasifica 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 devuelveentidadNoOperativa; el layout pintaEntidadNoOperativa.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: el404 not_foundde/radicacion,/eliminaciony los proxies de radicación y consulta se muestra como «Esta entidad no tiene ventanilla disponible» sin distinguir inexistente, suspendida o sin aprovisionar. Se borrafirmas/api/sign-xml(sin consumidor) y su suite;solicitud_not_foundno 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), yCertificadoDrawermuestra «Firma emitida con un esquema retirado: el sistema ya no puede verificarla. No indica que el documento haya sido alterado» paraesquema_hmac_retirado(firma_criptograficamente_validapuede sernull). Corrección tras la revisión UX: con la entidad suspendida los GET de/public/{t}/ventanilla/{tipos|politica}y el POST dan503 canal_no_disponible;/radicacionmuestra «Esta entidad tiene suspendida temporalmente la recepción de comunicaciones. Las consultas y verificaciones siguen disponibles» con enlace a/consulta;admin/+layout.server.jsya no manda a/403cuando la entidad no está operativa,(app)/+error.sveltepinta loserror(403)de páginas hijas dentro del layout, yEntidadNoOperativausarole="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-xmldeja de firmar radicados y el esquema HMACkey_version=0se retira Por qué: (a)firmary/challengedecidían el gate XAdES, el 2FA obligatorio de nivel ≥ 2, el veto del correo y los asientos con la copiafirma_solicitud.nivel_seguridadleída SIN bloqueo; esa copia se sincroniza por evento (con retraso, DLQ, o nunca si la cadena se declaróborradorsobre 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-xmlaceptabaradicado|expediente|borradory, con soloPERM_FIRMAcrud ≥ 2, daba una filafirmaVERIFICABLE («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=0seguía vivo como «lo anterior sigue verificando». Qué cambia (signature): B9-2CadenaRepository.nivel_vigente_for_update(FOR UPDATEde la solicitud +max(copia, nivel autoritativo vivo), sin mirarobjeto_tipo) yCadenaFirmaService._sol_con_nivel_vigente, llamado dentro de la transacción defirmary de/challenge: reevalúa el clearance (404neutro sin consumir nada), el gate XAdES (en/challengepasa 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.firmarahora toma la fila de la solicitud ANTES que la del turno (el mismo orden que/challenge: antes se cruzaban).resolver_nivel_por_objetousa un savepoint: dentro de una transacción, unUndefinedTableError(tenant sinexpedientes) la dejaba abortada. B9-3SignXmlRequest.objeto_tipoadmite soloXADES_ELIGIBLE_OBJETO_TIPOS(422para el resto, también los tipos de cadena) y/sign-xmlexigeX-Internal-TokenSIEMPRE; se borra la rama de clearance yCadenaRepository.objeto_visible. B9-5KEY_VERSION_LEGACYpasa aKEY_VERSION_SIN_HMAC(relleno de las filas no nativas), se borran_mensaje_v0y las ramas v0 designeryotp,/verifyrespondevalida=false,motivo=esquema_hmac_retirado(nohmac_no_coincide) sobre una fila nativa conkey_version≠1, y la migración024quita elDEFAULT 0defirma.key_versionyfirma_challenge.key_versiony reescribe sus COMMENT. Unificación: el404de no-read-up de la cadena essolicitud_not_founden/signy/challenge(antes/challengedecíasolicitud_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,/pendingcon fila yX-Total-Countbajo la misma condición) se gatean conGREATEST(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 asientofirma.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 eventoreclassifiedconserva su semántica. S4firma.turno_firmadollevanivel_seguridad(vigente) ynivel_copia, yfirma.creadade/sign-personalllevanivel_seguridad. S5/S10rechazarydar_visto_buenoreleen el nivel bajo bloqueo y toman solicitud antes que turno (el mismo orden de los cuatro caminos). S6/verifycon esquema HMAC retirado devuelvefirma_criptograficamente_valida=null(bool | None), nofalse. S7FirmaRepository.insertexigekey_version=1explícito a la nativa y la migración tenant025borra la fila sintética v0 y añadeCHECK (provider <> 'nativa' OR key_version = 1). S8 docstring deproviders/native.py. S11 las 7 copias del cuerpo 404 neutro decadena_service.pypasan 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; nuevastests/test_correcciones_b9_signature.py(15) ytests/integration/test_correcciones_b9_integration.py(8), cada una con su mutación demostrada (13 mutaciones, todas caen). Fixture autouse nuevolectura_vigente_real(neutraliza S1 en las suites de mocks). Impacto: aplicar la migración025por 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↔/signcon nivel vivo mayor, y los casos «v0 ya no verifica» designer,otp, cadena y/verify. Mutaciones demostradas: sin reemplazar el nivel (cae), sinFOR UPDATE(cae la concurrencia), solo copia (caen 2),minen vez demax(caen 3), sin savepoint (caen 2), validador con tipos de cadena (caen 7), rama v0 ensigner(caen 2), enotp(caen 2),/verifysin corte (cae), sinDROP DEFAULT(caen 3), código distinto en/challenge(cae). Impacto: aplicar la migración024por tenant (python migrations/runner.py); las firmasnativasy desafíos anteriores a la020(key_version=0) dejan de verificar (aceptado: entorno de pruebas, sin datos viejos); quien llame a/sign-xmlcon un tipo de cadena recibe422. Carril frontend pendiente: borrar el proxyfrontend/src/routes/(app)/firmas/api/sign-xml/y sudescribeenfirmas-fase3-server.test.js(no tiene consumidor). -
fix(signature,auth,shared,infra)!: tanda B7 —/challengeexige lo mismo que/sign,/auth/mede 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 /challengeemití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 (/signresponde409 documento_modificado); (b)GET /auth/mecon elX-Tenant-Slugde una entidad sin schema daba500 UndefinedTableError; (c)restore.shtomaba por inocuo unpg_restorecaído sin líneas de error ybackup.shdaba por bueno un espejo vacío; y (d) el compose de producción no publicaba ningún puerto mientras la plantilla de nginx apuntaba a127.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_turnoes el código único de pertenencia, estado, turno, rol de firma y radicado anulado parafirmary/challenge;/challengepasa a leer ANTES de la transacción (comofirmar; dentro solo la relectura conFOR UPDATE) y añade409 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_requeridosin correo,403 identidad_incoherentecon asientovia=signing_key_status,422 firma_xades_requerida/503 canal_firma_personal_no_disponiblebajo el gate). Sin turno activo,/challengeresponde ahora409 solicitud_no_pendiente(como/sign) y no403 no_es_su_turno. Se retira del contrato422 payload_no_fijado, que ya no existía. - F3 — auth:
get_optional_tenant_conncompruebato_regclass(format('%I.auth_users', schema))antes delSET search_path; una entidad sin schema (pendingo inexistente) es200conpermissions=[],permission_levels={}yuser_id=sub. El 400 por slug malformado se conserva. - P1/P2 — infra: servicio
edge(nginx:1.30-alpine@sha256:…) como ÚNICO conports:(80:80,443:443), plantillainfra/nginx/orpyca.conf.template(sustituye ainfra/nginx.conf.example) conserverTLS para frontend, API y Keycloak (solo/realms/y/resources/;/admin/y/realms/master/denegados; búferes de cabecera ampliados),proxy_passa variables conresolver 127.0.0.11y alias de red para los tres dominios. La red declaraip_range 172.20.128.0/17ygateway 172.20.0.1;TRUSTED_PROXIESpor defecto pasa de172.20.0.0/16a172.20.128.0/17para que el gateway de la red no sea de confianza y la IP sellada enaudit_logno pueda ser la que forjó el cliente. - P3/P4/P5:
postgres,redis,keycloak,edgeyamazon/aws-cli(antes:latestenbackup.sh/restore.sh) por tag y digest, también endocker-compose.yml;infra/.env.exampletraeORPYCA_REGISTRYyORPYCA_TAG(activas) yPDFA_PROFILE,SIGNER_LT_REQUIRE,SIGNER_OCSP_REQUIRE,WEBHOOK_SECRET_KEKS,WEBHOOK_SECRET_KEK_ACTIVE_VERSION(comentadas);whisper-servicequeda fuera del manifiesto de producción en v1 (intencional, declarado). - R2/R5/R5b — respaldo:
restore.shsolo admite unpg_restorefallido si hay al menos un error y todos son la PK heredada de las particiones deaudit_log; tras recrear cada bucket protegido comprueba conget-object-lock-configurationque el bloqueo quedóEnabledy aborta antes de tocar datos si no.backup.shya no usa|| trueal listar buckets y exigeorpycamcp-<slug>-documentsde cada tenantactiveen el espejo. - Corregido antes de la tanda, por el orquestador: R1
restore.shllamabarestaurar_versiones_previasantes de cargarlib-retencion-s3.shy de definirs3api(«command not found»: toda restauración moría tras restaurar Postgres); R3s3 sync ... || truepasa a fail-closed; R4limpiar_descifradocon[ ] && rmdevolvía 1 en la trampa EXIT bajoset -e(una restauración completa salía con rc=1); R5 de red:backup.sh/restore.shtomaban la PRIMERA red del compose (en produccióndefault, sin el almacén ⇒ espejo vacío) y ahora la del serviciominio; P0docker-compose.prod.yml: el anclax-migracionno declaraba red y los nuevemigrate-*caían endefault, sinpostgres(ninguna migración terminaba y ningún servicio arrancaba); M1mailhog:latest(abandonado) sustituido poraxllent/mailpit:v1.31.4@sha256:b68349e3…con puertos en127.0.0.1ySMTP_HOST=mailpit(red_interna.pyy la configuración de notification actualizados). - XMLDSig conforme (ADR-034) — signature: la verificación en vivo contra EU DSS dio
TOTAL_FAILED / HASH_FAILUREen TODO lo que firmamos:signxmldeja sinds:Transformslas referencias aSignedPropertiesyKeyInfopero 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 enverify_xades(default_reference_c14n_method=EXCLUSIVE) que hacía que solo OrpycaMCP verificara lo firmado. Ahora_declarar_transformsañade exc-c14n a cada referencia sinTransforms(los digests no cambian),_firmar_signed_infofirma elSignedInfoya parcheado —un solo camino para el sello y la firma personal; el digest que reciberemote_signsale delSignedInfofinal— y_autoverificarlo comprueba conXAdESVerifier()por defecto antes del sello de tiempo (503 firma_no_conforme_xmldsigsi no pasa; nada se persiste).verify_xadesverifica 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_bytesdeja 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 suMESSAGE_IMPRINTcomo no íntegro; de ocho composiciones probadas, solo la de la norma lo valida. Docstrings falsos corregidos (local.pyllamaba «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 slugitest-…para/auth/mesin schema);shared/tests479 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ástest_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 entest_xmldsig_conformidad.py—firma nueva verificada con signxml por defecto y conCANONICAL_XML_1_0, verificador independiente lxml+cryptography, digest enviado aremote_sign==SignedInfofinal, entrada delArchiveTimeStampreconstruida aparte, control negativo con un artefacto real anterior al arreglo— y 4 detest_dss_smoke.py, que solo corren conDSS_URL). Mutaciones que cada una tumba su prueba: parcheo no-op, digest antes de parchear, reintroducir el atajo enverify_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:SignatureIntacty todos losDataIntact,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/masterdaban 200 con la denegación anterior; ahora 404, y una ruta con;también); SEG-B1/health/readysolo desde127.0.0.1(se quita172.20.0.1; sondeo condocker exec); SEG-M2pg_restoresolo es inocuo con código 1 yerrors ignored on restore: N== PK heredadas (> 0); ARC-M3 el pre-vuelo del Object-Lock corre ANTES de parar servicios y depg_restore(en el código estaba DESPUÉS: «no se ha tocado ningún dato» era falso); ARC-M4RESTORE_OPERADORobligatorio (fail-closed antes de confirmar) y el actastorage.restauracionnombra al operador (actor=operador:<nombre>) y declara la ventana de traza perdida por tenant (extremo deaudit_loganotado antes depg_restorefrente al restaurado; sin la captura no hay acta); ARC-B3backup.shexige también los tenantssuspended(status <> 'inactive'); ARC-B4 api-gateway no arranca conAPP_ENV=productionsiTRUSTED_PROXIEScubre la puerta de enlace de la red (GATEWAY_RED_DOCKER_GATEWAYo 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_vencidoantes deremote_sign, sin consumir el TOTP) y_autoverificarya no acotaverification_time; ARC-M1/verifyde un XAdES-LTA conArchiveTimeStampválido y certificado caducado tras firmar juzga la vigencia a la fecha delSignatureTimeStamp(con advertencia; sin LTA válido siguecertificado_fuera_de_vigencia); SEG-B2/ARC-B1XmldsigNoConformeErroren las rutas personales es503 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 entest_vigencia_del_certificado_y_sellos.py, una reescrita), auth-service 414 passed (6 nuevas de integración deregistrar_restauracioncon Postgres real), api-gateway 170 passed (11 nuevas),shared/tests489 passed (guardianes de lista de admisión de Keycloak, sonda, IPv6, rc/N depg_restore, pre-vuelo, operador, extremo previo ysuspended). Lint (ruff + mypy) limpio en los tres. Una mutación por arreglo, todas caen. EU DSS:tests/test_dss_smoke.py4 passed contra DSS en127.0.0.1:18765, tras desplegar signature-service. Deuda declarada de la ronda: SEG-M3 (un tenantsuspendedno se hace cumplir en ningún servicio: funcionalidad nueva en el gateway), SEG-B4 (releernivel_seguridadbajo bloqueo al firmar), renovación delArchiveTimeStamp, autoverificación de la firma personal antes de consumir el TOTP, ynginx -treal (también confirma loslisten [::], ya escritos; la imagen no se descargó por el disco). Impacto: uninfra/.envheredado conTRUSTED_PROXIES=172.20.0.0/16reabre el defecto (cámbielo a172.20.128.0/17o bórrelo); hay que emitir un certificado por dominio antes del primerupy las renovaciones pasan a webroot; la consola de Keycloak deja de ser pública (túnel SSH).infra/nginx.conf.exampledesaparece. Desplegar signature-service y auth-service. No verificado aquí:nginx -treal niedgelevantado (imagen no descargada por el estado del disco); correrexec edge nginx -ten el primer despliegue. Deuda declarada: Keycloak 24.0.5 fuera de soporte;get_tenant_connsigue dando 500 con un tenant sin schema en los demás endpoints de auth, y un tenantsuspendedcon 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.createexige, paradoc_type=Scon actor humano, queorigin_dept_codesea una dependencia con URD activa del actor (422 origen_fuera_de_sus_dependencias); cubrePOST /documents, respuesta rápida, borrador (comprobación duplicada retirada) y cada ítem del lote (falla antes del consecutivo). ElPATCHsolo valida el tipo sidoc_classcambia. INT-05 muevefrom_borrador/canal/consentimientodel ZIP ametadata.procedencia_origen. Seeds:scripts/seed_corpus.pyradica Salidas con origen arbitrario (nombres deDEPENDENCIAS_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 validabadoc_classcontratipos_documentales(por API/MCP nacía un radicado sin serie TRD, es decir sin retención), quecanal(alimenta el gate de publicación de la Ley 1581) yconsentimiento(constancia con valor probatorio) podían aportarse enmetadata, quefrom_borradorno iba en el asiento y elPATCHde metadata lo podía borrar, que una Salida desde borrador aceptaba cualquierorigin_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.pyes el ÚNICO sitio que consulta el catálogo (la tabla vive en el mismo schema del tenant: sin cliente HTTP, comourd_usuaroldep):create(antes denext_sequence), elPATCHque retipifica, cada ítem del lote y la ventanilla (que sigue exigiendo ademásadmite_ventanilla_publica) →422 tipo_documental_desconocido;CLAVES_RESERVADAS = {from_borrador, canal, consentimiento}se rechaza enDocumentCreate,DocumentUpdate, la respuesta rápida y el borrador, y el sistema las pone porprocedencia(la ventanilla ya no las manda enmetadata; ya no pasan por la plantilla del tipo); elPATCHde metadata las preserva;document.radicado_createdllevafrom_borrador; Salida desde borrador:origin_dept_codelimitado 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 porget_caller(make_require_any_permission(identity=)): 401 sin identidad y asientousuario_no_aprovisionado, comorequire_permission; el guardiántest_escritura_exige_nivel.pyreconocerequire_any_permission((una escritura any-of también exigemin_crud>=2), con control positivo. tenant — las 10 claves inertes de la 002 salen dePARAMS_ADMITIDOS(PUT422) y pasan aPARAMS_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 mandecanal/consentimiento/from_borradorenmetadatarecibe 422. Pendiente (roadmap): los tipos sin plantilla activa guardanmetadatasin 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/camposyGET /plantillasexigían soloPERM_RADImientras/admin/flujos,/admin/grupos,/admin/correoy/admin/plantillasabren conUSUA_PERM_ADMIN; y el upsert dePUT /config/params/{key}guardaba cualquier clave en una tabla que todo el tenant puede leer. Qué cambia:orpycamcp_common.rbacganacheck_any_permission(una sola consulta= ANY, fail-closed) ymake_require_any_permission(asiento de denegación igual querequire_permission, conperm_name="A|B"); auth y document la instancian comorequire_any_permission. Las cuatro lecturas pasan a any-of {PERM_RADI,USUA_PERM_ADMIN} sin ganar exposición (losWHEREexistentes deciden las filas;?todas=truesigue exigiendoUSUA_PERM_ADMINen el cuerpo).PUT /config/params/{key}admite solo las 21 claves sembradas por las migraciones de tenant (422 config_param_desconocidopara 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.jsleerequire_any_permission(cadaGatellevaanyOf),contratoAdmin.jslo evalúa por brazos, y se retiran las excepciones obsoletas (pickable, email/cuentas, plantillas/campos);flujos GET /workflows/pendingse reetiqueta como gate de alcance en el cuerpo y el motivo deindice/firmarse 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=truesigue 403 con solo RADI),check_any_permissioncontra Postgres real, allowlist 422/200 y guardianes deshared/(require_any_permissioncuenta como gate; no se redefine fuera de la librería). Impacto: desplegar auth, document y tenant (solo esos tres usan lo nuevo deorpycamcp_common; el resto lo recoge al reconstruirse). La pantalla/admin/parametrosaún ofrece «nueva clave» libre: suPUTahora 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}/radicarno capturabaMetadataValidationError: ahora es422 metadata_validation_failedconfield, antes de pedir el consecutivo (no se gasta número y el asiento sigue atómico con el radicado). El cuerpo admitemetadata(BorradorRadicar); la procedenciafrom_borradorya 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 conSchemaField; 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 deREQUERIDOS_POR_TIPOcon guardián de paridad contradatos_minimos.py. NuevoSchemaValue(solo lectura) en/expedientes/[id](antes[object Object]y clave cruda) y en el detalle de/bandeja(cargabametadatay 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.EditarMetadatosModalyTransaccionModalno 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,codigoes 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 adependenciaspor 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 porDEPE_CODI); dos homónimas se confundían (borrador_serviceradicaba haciaORDER BY codigo LIMIT 1en silencio);codigoera editable y lo citan 8+ tablas de 5 servicios sin FK;nombreadmitía 500 caracteres y las instantáneas sonVARCHAR(100). Qué cambia: tenant — migración013: triggerBEFORE UPDATE OF codigo(inmutable),CHECK nombre ≤ 100,dependencia_nombre_historico(solo inserción) y la función únicaresolver_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 conserva409 dependencia_con_tramite_en_curso, ahora por código; eliminar da409 dependencia_con_historiasi cualquier tabla la cita; nuevoPOST /dependencias/{id}/mapeos-nombre(admin nivel 3, asentado). document — migración046:borrador.dest_dept_codey relleno deradicados.dest/origin_dept_codeyborrador(solo con un candidato; evidencia de solo inserción anclada enaudit_logpor 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 eventoradicado.createdañadeorigin_dept_code. workflow — migración019:*_codeen pasos, eventos y reglas, relleno con función única (ambiguo/huérfano en paso vivo, regla o condicióneq/ne/in⇒ la transacción aborta y la migración no queda en el libro; historia ⇒NULLcon evidencia), condicionesdest_dept/origin_deptreescritas a*_code, ubicación (flujo_dept_code) desde el código del paso,CHECKde 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 (_ANCLASadmite 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 dederivar_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, guardaCODE_FIELDS). Impacto: aplicar tenant013→ document046→ workflow019(el runner/init_tenantya lo ordena; la046y la019abortan 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, cuandoDeprecationesté 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): tenant014—resolver_codigo_dependenciahace que un mapeo manual SEA la respuesta (antes se sumaba a las homónimas y no desambiguaba),SET search_path FROM CURRENTen la función y los triggers,dependencia_codigo_retirado(solo inserción;409 dependencia_codigo_retiradoy trigger al reutilizar un código eliminado), siembra dedependencia_nombre_historicodesde losdependencia.actualizadadeaudit_log;referencias()barreinformation_schema(transferencias, índice electrónico, plantillas,active_depe_id, historial);mapeos-nombrerechaza dependencia inactiva y el nombre vigente de otra activa (irreversible);PATCHadmitemotivo. document047y workflow020: evidencia conmapeadoenlazado ahistorico_idyantes/despuesJSON completos. Reporte por dependencia: el grupo sin código lleva la etiqueta fija «(sin código)». Última ronda de corrección: document047ya no cambiaresultado(reescribía evidencia anclada por la046: el SHA-256 del ancla dejaba de reproducirse); solo enlazahistorico_idy «mapeado» se deriva dehistorico_id IS NOT NULL(elCHECKvuelve al original).047y workflow020dejan ancla propia (document.dependencia_codigo.backfill_047,workflow.dependencia_codigo.backfill_020); la020solo completa columnas nuevas, ninguna de las que ancla la019. tenant:mapeos-nombresolo admite nombres huérfanos o ambiguos (409 nombre_resuelto_a_otra_dependencia), elPATCHrechaza renombrar a un nombre ya mapeado a otro código (409 nombre_mapeado_a_otra_dependencia);referencias()usapg_catalog, cuentaworkflow_rules.conditionsyhas_childrenva dentro de la transacción delDELETE. 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 deacta_fuid_xmlsin recotejo, que los componentes se leían como.texty su huella se calculaba sobre lo entregado (checksums autoconsistentes certifican la copia, no el original), que la exención «UPDATE que solo anula» de la065cubría un UPDATE manual y que elON DELETE SET NULLde la012borraba la traza física de lo ya recibido; y el409listaba las rutasclasificado/.... Qué cambia: el paquete añadeindice/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 rutasclasificado/...se agregan encomponentes_clasificados, sin enumerarlas). Antes de servir se recalculasha256(acta_fuid_xml)contraacta_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+ asientotransferencia.paquete_entrega_incidente(un clasificado no se nombra en la respuesta); sin huellas de captura:503. Migración tenant067: la exención de la065aplica solo bajopg_trigger_depth() > 1(acción referencial); un UPDATE manual que anula el par da23514. Migración tenant068: triggerBEFORE DELETEenubicacionque rechaza (23514 ubicacion_con_transferencia_recibida) borrar una ubicación referenciada por una transferenciarecibida. 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, conRESTORE_ACEPTAR_PERDIDA_VERSIONES=1) un índice o AIP re-sellado sobre la misma clave; (3)registrar_restauracioncambiabaversion_idsin evento de preservación (PREMIS); (4)verify_audit_chainssalía 0 con «sin tenants» —una base vacía por un restore fallido o un filtro mal tecleado verificaba cero cadenas en verde—; (8)dispatchera mudo: ni a quién salió qué evento ni si llegó (ADR-018 lo declaraba pendiente). Qué cambia: infra —respaldar_versiones_previascopia el contenido de cada versión previa con COMPLIANCE vigente o retención legal aminio-versiones/<bucket>/<sha256(clave+vid)>y la anota enminio-versiones.tsv(fichero aparte: el formato deminio-retencion.tsvno cambia); viaja dentro del tar cifrado; el sello llevaversiones_previas_capturadas=SI|NOybackup.shsale 5 si no quedó completa.restaurar_versiones_previas(antes delsync) las sube porLastModifiedascendente, verifica el sha256, repone la retención sobre elVersionIddevuelto y sube conput-objectexplícito la versión actual de esas claves; el mapa viejo→nuevo las incluye.RESTORE_ACEPTAR_PERDIDA_VERSIONESqueda solo para respaldos anteriores;restore.shrechaza un respaldo conversiones_previas_capturadas=NOsin esa aceptación. auth —registrar_restauracioninserta, en la misma transacción del remapeo, un evento PREMISREPLICA(detalle.motivo='restauracion',version_vieja/version_nueva,artefacto, agentesystem:ops-restauracion) por cada fila actualizada; sinpreservacion_eventotodo se revierte.verify_audit_chainssale 2 con objetivo vacío (por filtro, o sin filtro y sin tenants) salvo--permitir-objetivo-vacio, quebackup.sh/restore.shno pasan. notification — migración012webhook_entregas(append-only: trigger +REVOKE, sin FK a la suscripción, solo sha256 del cuerpo, destino reducido ascheme://host:port);_deliver_oneescribe la filaenvioANTES del POST (si no puede, no envía) yresultadodespués;GET /api/v1/webhooks/{id}/entregas. Ronda de auditoría: el export de expediente compara cada anexo con susha256registrado y aborta (409fixity_fallida, asientofile.fixity_fallo, sin ZIP) antes del primer byte, y espera al hilo de escritura si lo cancelan;url_sha256dewebhook_entregasy del asiento pasa a ser la huella del origen (migración notification013: mensaje del trigger como traza técnica). Pruebas: Pruebas nuevas con els3apifalso (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 (saltarIsLatest=0, invertir el orden, retención sobre el vid viejo, no verificar el sha, quitar el INSERT PREMIS,return 0con objetivo vacío,enviotras el POST, tragar el fallo deenvio, quitar el trigger). Impacto: aplicar notification012(el runner) y desplegar auth y notification. No se probó:backup.sh/restore.shextremo a extremo contra el almacén vivo (el restore es destructivo); els3apireal (get-object/put-objectconS3_MONTAR) solo se ejerce con el cliente falso. Retención/purga dewebhook_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.ziparmaba 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 parachecksums.txt) y solo entonces responde conContent-Length, borrando el temporal enfinallyy 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 elContent-Length. Sin cambios en el no-read-up por radicado ni en el asientoarchive.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 ydedup_keypor 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 con404neutro;409 paquete_solo_secundaria_recibida;409 componentes_sin_object_lockcon 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: referenciaacta_fuid_sha256sin reescribir el acta;sobre="ausente"en las v2/legadas) ychecksums.txt. Los asientostransferencia.acta_fundamento_leido(via="paquete") ytransferencia.paquete_entrega_generado(nota_sha256y huellas) se escriben en una transacción antes del primer byte. Nunca por OAI/EAD/CMIS (guardián por fuente). Migración tenant065: triggerBEFORE INSERT OR UPDATE OF expediente_id, ubicacion_origen_id, ubicacion_destino_id ON transferenciascon las mismas reglas y códigos que el422del servicio (ERRCODE 23514; el servicio lo traduce a422con el mismo código); solo filas nuevas, elON DELETE SET NULLde una ubicación no revalida. Migración tenant066:transferencias.fixity_fallos_intentos; la clave PREMIS pasa afixity-recepcion-fallo:{id}:{n}y el asiento*_failedllevaintento. 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 el409, servir una primaria, quitar el clearance delWHERE, cada rama del trigger, volver al sello de tiempo). Impacto: aplicar archive065y066(el runner) y desplegar archive-service. En un entorno sin preservación (sin Object-Lock) el paquete responde409 componentes_sin_object_lock, por diseño (fail-closed). No se hizo: sellarnota-entrega.xmlcon XAdES (tocaría signature-service; su huella queda anclada enaudit_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(parX-Real-Ip+X-Real-Ip-Ts+X-Public-Ip-Signature, HMAC conIDENTITY_SIGNING_SECRETligado 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 v1ip_declaradase 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 laIdempotency-Keyy consumir consecutivo;GET /ventanilla/estadoañadepolitica_publicada; acuse de envío:403 acuse_ajeno,409 acuse_ya_referenciado, índice único parcial (migración tenant045, aborta si ya hay duplicados). storage-service —POST /internal/files/procedencia. tenant-service — migración tenant012(tres parámetrospolitica_tratamiento_*, vacíos).scripts/verificar-ventanilla.shexige la política;scripts/seed_demo.pypublica 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_PROXIESdebe incluir nginx y el SSR). Contrato (frontend): radicar necesitapolitica_version= la deGET /ventanilla/politica; nuevos 409/503 de la ventanilla y 403/409 del acuse (verapi.md). Impacto: aplicar tenant012y document045(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)./radicaciontoma la política deGET /public/{tenant}/ventanilla/politica(se retiran las variablesPOLITICA_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 409politica_desactualizada, y una nota fija enlaza a#supresion./enviostraduce 403acuse_ajenoy 409acuse_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 elWHERE, 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.deadletteres la fuente única (LEFT JOIN LATERALcontra 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 mismo404que unevent_idinexistente. Total y página salen de una transacciónREPEATABLE READde solo lectura (unoffsetpasado ya no da total 0). Se borranresolve_deadletter_level[s],assert_can_read_deadletter,list_scoped_for_clearance,DEADLETTER_CANDIDATE_CAPy la cabeceraX-Truncated. Sinradicadosen el schema responde503, no una lista vacía. Solo es más restrictivo que antes (un embebido2.0/true, un sobre doblemente codificado o una lista cuentan como duda). Migraciones: índiceix_edl_keysetidéntico (workflow 018,signature 023,notification 011,knowledge 013). knowledge: además purga lasresolved/discardedde más de 90 días (RETENTION_DEADLETTER_DAYS, compuerta diaria; antes no tenía retención) y la migración014añadeknowledge_chunk.nivel_evento_at: unreclassifiedviejo reentregado desde la DLQ ya no baja un nivel posterior (sinoccurred_atsolo puede subir). Impacto: aplicar las migraciones tenant y desplegar los cuatro servicios; el gateway sigue reenviandoX-Truncatedpero ya nadie la emite.fix(auth,signature)!:/internal/totp/verifyexigeX-Internal-Tokeny 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/verifyaplicarequire_internal_token(401 sin token, 403 erróneo, 503 sin configurar; mismoSIGNING_KEY_INTERNAL_TOKEN, sin secretos nuevos) y signature-service lo envía. Nuevo jobapp/jobs/signing_key_maintenance.pyen el lifespan de auth-service (SIGNING_KEY_MAINTENANCE_ENABLED,SIGNING_KEY_MAINTENANCE_INTERVAL_SECONDS, 21600 por defecto): por tenant activo y bajopg_try_advisory_lock, purga revocadas pasadoSIGNING_KEY_PURGE_AFTER_DAYSy re-envuelve solo filas conkey_versionmenor que la activa (el re-wrap ya no va «hacia atrás» si la activa retrocede); dejasigning_key.maintenance_runcon 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 conUSUA_PERM_ADMINnivel ≥ 3 (permission_levelsde/auth/me); con nivel menor la pantalla queda de solo lectura y el403se explica en español. Helperlib/utils/nivelAdmin.js; pruebaadmin/nivel-admin-escritura.test.js(nivel 2 sin acciones, nivel 3 con ellas).fix(tenant,mcp)!: las escrituras de dependencias, catálogos y PINAR exigenUSUA_PERM_ADMINa 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) responden403con nivel 1 o 2; las lecturas siguen a nivel 1. La herramienta MCPregistrar_avance_pinardeclaramin_crud=3. Sin migraciones. Pruebas:test_escrituras_nivel_3.pyrecorre 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 deseed_demo.pycon 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-huerfanosde storage, y un barrido de reconciliación recoge los ficherosacta-transferencia-sobre-…sin referencia.GET /actay/acta/verificacionya no devuelvenfundamentoni sal (ni asientan lectura al visitar la pantalla); nuevoGET .../{excepcion_id}/fundamento?acto=(admin 3, asientovia=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 (storage021: seistipoacta_firma_*_sobre_<rol>, control y multi-artefacto; archive064: artefactosobreen las tablas_worm, reconciliación/renovación/reintento como los demás eslabones). Sin sello:503y nada escrito. Lectura solo mediada:GET .../firma-inobtenible/{excepcion_id}/sobre(admin nivel 3, asientotransferencia.acta_fundamento_leidoconvia=sobre).POST .../revocarya 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,agotadotiene salida yincapacidadya 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; queagotadoera terminal; y queincapacidad(dato de salud) quedaba en claro en un artefacto WORM y enaudit_log. Qué cambia: archive — migración tenant063(fundamento_salBYTEA,fundamento_compromiso,fundamento_compromiso_algoritmoen constancia y revocación;causaen 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)consecrets.token_bytes(32)por registro; sal solo en la base y solo para administración ≥ 3, nunca en el XML ni enaudit_log. XML en C14N exclusiva (lxml): se firma, se guarda y se huella lo mismo.GET /acta/verificacioncoteja los enlaces de la cadena (sustituyeA,revocacionSha256,constanciaSha256, huella guardada, artefacto firmado) y reporta el WORM de cada eslabón: enlace roto →valida=falseconcadena_motivos. Retención WORM anclada al último acto de la cadena y alineación de los eslabones anteriores al proteger uno nuevo. NuevosPOST .../{excepcion_id}/worm/reintentary.../revocacion/worm/reintentar(admin ≥ 3, asiento atómico).incapacidad→impedimento_sobrevinienteen XML y asiento. La revocación exigecausa(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 asientotransferencia.acta_fundamento_leido. Se retiransignature_client.hmac_fundamento/verificar_hmac_fundamentoy el 503huella_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 pierdefundamento_hmac,fundamento_hmac_key_version,fundamento_hmac_algoritmoyrevocacion_fundamento_hmac; ganafundamento_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;FundamentoVerificacionpierdekey_version;/acta/verificacionganacadena_motivos; el cuerpo de.../revocarexigecausa. 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 tenant063de 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/parametrosno admitenull(valor_nulo) y los festivos (crear, borrar, sembrar) exigen administración 3. Contrato final: compromiso con sal en lugar del HMAC,causaobligatoria al revocar, enlaces de cadena ycadena_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;/actaya 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 segmentointernalo*-internalde cualquier servicio (sin distinguir mayúsculas); los prefijos de ruteo sin barra final exigen límite de segmento. signature retiraPOST /internal/hmac-fundamento[/verificar](sin consumidor: archive usa compromiso con sal local; evita un oráculo de diccionario) yPROPOSITO_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_huellaexige--operador(debe existir y estar activo enauth_usersde cada tenant, si no aborta con salida ≠ 0 sin escribir), lo usa de actor y asientafirma.invalidacion_masiva_ejecutadaal inicio y fin de cada tenant con conteos ydry_run. tenant —PUT /config/params/{key}connullda422 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 únicopatchenvuelve elgatheren 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 queget_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 tenant062: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, paraordinal > 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_idobligatorio, SHA-256 del anexo vía storage, identidad desdeauth_users/auth_user_signing_key(solo columnas públicas), HMAC víasignature_client.hmac_fundamento(sin él, 503), firma personal siempre que haya certificado activo, 409excepcion_vigente/revocacion_sin_proteger/parte_ya_firmo.POST .../revocar: exigePERM_FIRMA≥ 2, XMLurn:orpycamcp:acta-firma-excepcion-revocacion:v1sellado (sin sello: 503 y ni fila ni asiento) y firma personal de quien revoca. NuevoGET .../firma-inobtenible/{excepcion_id}/fundamento/verificacion(admin ≥ 3: recalcula el HMAC y lo coteja con columna, XML y asiento)./actay/acta/verificacionrecorren la cadena completa y recortan las referencias por el clearance del lector (lote contra document-service;nullindistinguible ante error);fundamento_sha256→fundamento_hmac. Reconciliación de la protección y la renovación CT de la revocación enapp/jobs/indice_reconciliation.py(confile_id). storage — migración tenant020: seistipoacta_firma_revocacion[_personal]_<rol>, índice único(expediente_id, tipo, file_id)para los 12 tipos multi-artefacto (se retiran los seis singleton de la019),get_by_expediente_and_tipo(..., file_id)obligatorio para ellos yRenovarRetencionRequest.file_id(422 si falta). No se hizo y por qué: (a) la pantalla (el frontend debe enviarreferencia_anexo_id,totp_codeal revocar y consumir los campos nuevos; no se tocófrontend/). (b) Una revocación LEGADA (anterior a la062, 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 declaranconstancia_version = 1yfundamento_hmac = null. Verificación: archive 1285 passed/284 skipped en unitario y, conRUN_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 detest_transferencia_acta_worm_atomicity.py, mismo patrón que la de B); storage 572 passed/15 skipped (con integración);./scripts/lint.shde 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 / sinparte_ya_firmo,firmar_personalcon «cualquiera», lookup de storage sinfile_id. Impacto: aplicar la migración de tenant020de storage y la062de archive con sus runners (idempotentes); reconstruir y desplegar storage-service y archive-service. El frontend debe adaptar el cuerpo del acto (referencia_anexo_idya 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 yreferencia_documentalera 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 reportabanfirmada_con_excepcion; una excepción declarada por error no tenía salida; elfundamento(salud, fallecimiento) salía completo a cualquier lector; yfirma_personal_pendienteleía el entorno y no la política de la entidad. ADR-030. Qué cambia: archive — migración tenant061(columnas de la constancia: XML canónico, huella,firma_id,minio_key_firmado, firma personal, soportereferencia_radicado_id/referencia_anexo_id; tabla..._excepcion_revocacionappend-only; tabla..._excepcion_wormpor artefacto; triggers de conflicto de interés y de exclusión mutua que solo cuentan excepciones no revocadas, conCONSTRAINTestable).POST .../acta/firma-inobtenibleahora recibereferencia_radicado_id(+referencia_anexo_id,totp_code), resuelve el soporte contra document-service, exigePERM_FIRMA≥ 2, sella con el sello institucional (sin sello: 503 y nada se declara) y firma con el declarante (obligatorio sifirma_personal_require_xadesde la entidad estodos, oclasificadoscon nivel ≥ 2). NuevoPOST .../acta/firma-inobtenible/revocar. 403declarante_es_parte_de_la_transferencia(las tres partes) con asiento de denegación.completitudganasellada_con_excepcion;fundamentosolo aUSUA_PERM_ADMIN≥ 3;/acta/verificacionlee la política de la entidad y devuelvepolitica_xades,constancias_sin_firmayadvertencias;/indice/verifypropagafirma.advertencias. WORM y renovación CT de la constancia en las barridas existentes. storage — migración tenant019y seistiponuevos (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 la061: quedanfirmada=falsey la verificación no las da por válidas. (d) Verificar en vivo el Object-Lock (RustFS conPRESERVATION_ENABLED=falseen desarrollo: storage acepta eltipoy respondepreservation_disabled) y la firma personal del declarante (el secreto TOTP de los perfiles de prueba no se conserva). (e) Dos pruebas detest_clientes_llevan_token_interno.pyfallan SOLO conRUN_INTEGRATION=1por una fuga de estado detest_indice_reconciliation_atomicity.py/test_indice_worm_atomicity.py(reproducida sin los archivos nuevos). Verificación: archive 1228 passed/275 skipped; conRUN_INTEGRATION=1, 1501 passed y 2 failed (la fuga de (e)); storage 526 passed/15 skipped (con integración);./scripts/lint.shde 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 tenant019de storage y luego la061de archive con sus runners (idempotentes); reconstruir y desplegar storage-service y archive-service. El frontend debe adaptar el cuerpo del acto (ya no existereferencia_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 etiquetasellada_con_excepcioncomo «ninguna parte firmó; solo el sello de la entidad»; proxiesPOST /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,motivode catálogo cerrado,fundamento≥ 20 caracteres yreferencia_documentalobligatorios). Migración tenant059: 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 + asientoarchive.acta_firma_inobtenible_declaradaen la misma tx (el asiento lleva la huella del fundamento, no su texto); un 403 deja asiento de denegación./acta/verificacionsumacompletitud=firmada_con_excepcionyexcepciones[];firmada_completaexige ahora cero excepciones de cualquier rol;GET /actatraeexcepciones_firma.firmar-personalrechaza con 409 antes de gastar el TOTP. Efecto sobreacta_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 aceptaacta_firma_{elabora,entrega,recibe}enRenovarRetencionRequest(sin cambio de servicio: ya era por(expediente, tipo)y monotónica); archive, migración060(estado/backoff entransferencia_acta_firma_personal_worm),renovar_worm_firma_personaly un 4.º advisory lock enrun_once_worm_renovacion; espejo conGREATEST; asientoacta_firma_personal_worm_retencion_renovada;pendientes-renovacionlas lista con sutipo. 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 conRUN_INTEGRATION=1(Postgres real: atomicidad constancia+asiento, CHECK, exclusión mutua en ambos sentidos, append-only, filtro CT y clearance en elWHERE, espejo monotónico); storage-service 463 passed/64 skipped;./scripts/lint.shverde en ambos. 15 mutaciones (guarda del declarante, CHECK del motor, asiento sin tx,firmada_completacon 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,Literalde storage): todas caen. Impacto: aplicar las migraciones de tenant059y060con 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_XADESera 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_OBLIGATORIOobligaba a elegir entre rechazar todo el corpus anterior de sellos o no exigir nunca nada. (3) una cadena sobreradicado/expedientefirmaba 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/challengecontestaban409 solicitud_no_pendienteantes de comprobarno_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 — nuevoapp/core/params_entidad.py(signature):modo_xades_de_la_entidadleefirma_personal_require_xadesdesgd_param_adminantes de la transacción defirmar; el entorno es respaldo solo si la entidad no define la clave (sin fila /null/ sin tabla) y un valor desconocido se aplica comotodoscon error en el log (nunca relaja). tenant-service: migración de tenant010_params_firma.sql(siembra JSONnull, ver la remediación de abajo) yPUT /config/params/firma_personal_require_xadesvalidaoff|clasificados|todos(422 valor_invalido,PARAMS_VOCABULARIO_CERRADO). El gate no cambia de contratos: conclasificados/todos, nivel ≥ 2 sin certificado422 firma_xades_requeriday canal caído503 canal_firma_personal_no_disponible(nunca nativa); conoffse conserva la degradación yfirma.fallback_nativa_canal_caido(elgate_modode los asientos es el efectivo). La pantalla genérica/admin/parametrosya lo lista (no se construyó pantalla nueva). ESSCertID por corte —TSA_ESSCERTID_CORTE(ISO 8601; default2026-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 yVerifyResponse.advertencias[]llevatsa_sin_esscertid_anterior_al_corte(parámetro de salidaadvertenciasdeverify_xades). Contenido, no identificador — document-serviceGET /api/v1/internal/documentos/radicados/{ref}/huella(SHA-256 del PDF vigente: anexoes_principalmás reciente o el PDF más reciente; la huella la calculó storage) y archive-serviceGET /api/v1/internal/expedientes/{ref}/indice/huella(router propiointernal_firma.py+ gateapp/core/internal_auth.py;xml_sha256de la última versión del índice), ambos conX-Internal-Token. signature:radicado/expedientepasan aCADENA_PAYLOAD_OBLIGATORIO/AUTORITATIVO(CADENA_SIN_HUELLA_DECLARADAqueda vacío),RESOLUTOR_DE_HUELLAgana los dos resolutores,crearresuelve la huella en el servidor y_gate_documento_vigentela relee antes de cada firma y visto bueno (409 documento_modificado; productor caído503; sin contenido409 radicado_sin_documento/indice_no_disponible/anexo_sin_huella; inexistente404neutro; cadenas abiertas antes, huella NULL:409 cadena_sin_huella). Clientesget_huella_radicado/get_huella_indice_expedientecon el token del servicio AL QUE VAN; compose (dev y prod) y.env.examplegananARCHIVE_INTERNAL_TOKEN(archive:INTERNAL_SERVICE_TOKEN) y el compose de prod completaDOCUMENT_INTERNAL_TOKENde signature, que faltaba. Orden de guardas — pertenencia (403 no_es_firmante) antes que estado (409) enfirmar,rechazar,voboychallenge;firmar_lotelo hereda por ítem. Docs —api.mdES/EN (ediciones puntuales), ADR-016 (addendum), roadmap ES/EN (HSM/KMS: pendiente de proveedor; el enganche existe enauth-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 conRUN_INTEGRATION=1(Postgres real: lectura desgd_param_admin, con tabla ausente y con valor corrupto); document-service 1002 passed conRUN_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/varbajo el piso); tenant-service 186 passed conRUN_INTEGRATION=1; guardián nuevoshared/tests/test_param_firma_xades_se_siembra.py(lee/siembra/valida con el mismo nombre; 3 mutaciones, las 3 caen);./scripts/lint.shde 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 modotodosdevolviendo «gate inactivo») SOBREVIVIÓ al primer juego de tests de la política por entidad (los detodosvivían solo entest_firma_xades_gate.py) y se cerró con dos tests nuevos detodosen nivel 1 (422sin certificado,503con el canal caído) entest_politica_xades_por_entidad.py; M4c–M4o, ejecutadas el 2026-10-08 (el harnessdrive2.pyno sobrevivió; se reconstruyeron las 13 desde su descripción sobre copias montadas:ro): token cruzado ×2, el 409 tratado como 503,crearcon la huella del cliente, referencia sin escapar,ORDER BYdel 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) yexpedienteno autoritativo: 13 de 13 caen (en total 35 de 35 con las 22 anteriores) Impacto: aplicar la migración de tenant010con el runner de tenant-service antes de signature (idempotente; sin ella signature respalda el entorno); reconstruir y desplegar tenant, document, archive y signature; exportarARCHIVE_INTERNAL_TOKENen producción. Rompe: cadenas deradicado/expedienteabiertas 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_OBLIGATORIOdeja de existir (usarTSA_ESSCERTID_CORTE); un entorno conFIRMA_PERSONAL_REQUIRE_XADESdistinto deoffdebe fijar el parámetro en cada entidad (el valor lo fija la entidad; con la010corregida y la011manda el entorno hasta entonces). Contratos para el frontend:VerifyResponse.advertencias: string[], nuevos409/503al abrir/firmar,403 no_es_firmantetambién sobre cadenas cerradas,PUT /config/params/firma_personal_require_xadescon422 valor_invalido. Remediación de la auditoría (2026-10-08):- Alto — la
010anulaba el respaldo del entorno. Sembraba"off"y un valor presente gana sobreFIRMA_PERSONAL_REQUIRE_XADES: un despliegue contodosquedaba enoffen silencio. La010siembra ahoranull; la nueva migración de tenant011_params_firma_null_no_off.sqlpasa anulllas filas que sigan en"off"sin fila enadmin_auditpara 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 ahoraUSUA_PERM_ADMINa nivel 3 (sale deSIN_NIVEL_DECLARADAS) y, si el cambio relajafirma_personal_require_xades(valor vigentenull/desconocido = el más estricto), exigemotivo(≥ 10 caracteres,422 motivo_requerido); el asiento llevamotivoyrelaja_politica. Cuerpo:{value, motivo?}. - La huella del radicado es solo el anexo
es_principal(409 radicado_sin_documentosin é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_disponiblenombra solo al productor y el código HTTP). - Cadenas antiguas
pendientecon huella NULL:python -m app.ops.invalidar_cadenas_sin_huella [--dry-run] [--tenant]las invalida (invalidada_motivo='cadena_sin_huella', un asientofirma.cadena_invalidadapor cadena en la misma transacción, idempotente) para poder abrir la de reemplazo. - Asientos:
gate_modoyhuella_documento_sha256enfirma.turno_firmado;huella_documento_sha256enfirma.solicitud_creada.TSA_ESSCERTID_OBLIGATORIOpresente al arrancar → error en el log que nombraTSA_ESSCERTID_CORTE;TSA_ESSCERTID_CORTEyFIRMA_PERSONAL_REQUIRE_XADESdocumentadas eninfra/.env.exampleyinfra/docker-compose.prod.yml./verify:advertencias: list[str]es campo aditivo del contrato (archive-service lo propaga). LecturasGET /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/tests399 passed y 1 fallo previo ajeno (test_todo_archivo_de_codigo_lleva_su_licencia: lostest_slug_unico_contrato.pyde otros servicios),./scripts/lint.shverde 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 (opscontando cadenas con huella en el--dry-run) sobrevivió y se cerró con un test. - Impacto: aplicar la migración de tenant
011con el runner de tenant-service; desplegar tenant, document y signature; ejecutar una vezpython -m app.ops.invalidar_cadenas_sin_huella --dry-runy luego sin--dry-run. Contrato para el frontend:PUT /config/params/{key}aceptamotivoy devuelve403por debajo de nivel 3 y422 motivo_requerido;/admin/parametrosdebe pedir el motivo al relajar. Frontend:/admin/parametrosespeja el nivel 3 deUSUA_PERM_ADMINpara escribir parámetros, editafirma_personal_require_xadescon selector (null= «heredado del entorno») y pide el motivo de ≥ 10 caracteres al relajarla; las verificaciones (/verify,/indice/verify,/acta/verificacion) muestranadvertencias[], 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 sobreorpycamcp_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_permissionyresolve_caller_clearancevivían copiadas en nueve servicios, y 191 asientos deaudit_logdecíancanal='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.sqlde tenant-service. Aborta, listando los slugs, sipublic.tenantstiene 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ñadeCHECK tenants_slug_forma_chk. Guardián de coincidencia SQL↔SLUG_PATTERNentest_slug_unico_contrato.py. Por qué: (1) cadadatabase.py(ykeys.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 yPreservacionWormRepositoryni 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 sieteclearance.pyy entenant/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_schemaque lanzaValueError); los nuevedatabase.py,keys.py,auth_service.py,grant_root.py,registrar_restauracion.py,document_repository.py, los repositorios de storage y el schemaTenantCreatelo importan (el alta ya no admite guion inicial: 422). Gate —orpycamcp_common/rbac.pyconcheck_permission,resolve_caller_clearance,nivel_efectivoymensaje_denegacion; cada servicio las re-exporta desde sucore/clearance.py/authz.pyy conserva solorequire_permission. Canal —audit.append(..., canal: str)es OBLIGATORIO;audit.CANAL_PETICIONdeclara «el acto es de una petición» (hereda elX-Canaldel 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) yspawn_supervisedlo 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)) asientansystem.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.mdy 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_permissionencommon: depende deget_tenant_connde cada servicio; se queda local. (c) el histórico deobject_ref: no se reescribe (cadena de hash); se resuelve en lectura. (d)append_denialconservacanal=CANAL_PETICIONpor omisión: una denegación del gate es, por definición, de una petición. Verificación: guardianes nuevos enshared/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 decheck_permissionen workflow yreturn Trueante usuario nulo caen) ytest_el_asiento_declara_su_canal(censo AST de 190+ llamadas; mutación: quitarcanal=deindex_service.pycae; una tarea de fondo asientasystemaunque declareCANAL_PETICION) —shared/tests395 passed; en imágenes reconstruidas: document 752 passed/227 skipped, auth 316 passed/44 skipped (más 3 de integración del filtroobject_refcontra Postgres real, con la mutación a=exacto que cae; 3 fallos preexistentes detest_verify_audit_chains_jobpor el rolorpycamcp_appNOLOGIN 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.shverde (ruff + mypy) en los nueve servicios. En vivo contra la pila:GET /audit?object_ref=EXP-2026-0002devuelve 51 asientos, incluidos los dos que nombran al expediente por UUID e índice; un alta de expediente por el gateway asientaapi; una tarea de fondo asientasystem;TenantCreaterechaza-abc,a_by 55 caracteres y aceptabogota-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 deaudit.append(canalobligatorio): cualquier llamante externo a este repositorio debe declararlo. Los asientos de jobs pasan a decirsystem; los históricos siguen diciendoapi. 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.mdy ADR-002 recetaban Alembic, SQLAlchemy y unSET search_pathsin 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.mddecí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_TRDnivel 4). (2) AREA-14-02 y AREA-02-01: el «hallazgo de dominio» de escrituras sinrequire_permissionen metadatos/catálogos/config (cerrado en32fc631) y el «limitador cuenta por IP» (cerrado porRateLimitPorUsuarioMiddleware,9c2cc6a) seguían como abiertos. (3) AREA-14-06/-07:multi-tenancy.mdES/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 ensearch_pathcopiable), el bucket «de plantillas» (no existe), el stream por entidad (es uno por servicio) y uncurl POST /api/v1/tenantspor el gateway —ruta retirada a propósito— con un slug con guion bajo quetenant-servicerechaza;getting-startedES/EN repetía elcurl. (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_generatableson fail-closed), ADR-018 daba el SSRF por «evolutivo» (implementado, con TOCTOU cerrado) y prometía «registrando entregas» (solo haylogger.warning), y el cliente RFC 3161 remoto figuraba como stub entsa.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…), ydocs/en/security-audit.mdsin la segunda pasada ni el cambio de comportamiento de la ventanilla. (6) AREA-14-13/-14/-15/-16/-17/-19: TODO obsoletos enbandejayradicar, comentarios de deuda ya cerrada en storage/workflow/notification/document,CLAUDE.mdde workflow contradictorio,arranque.mdcon «MinIO Console» y sin whisper, einfra/DOCKER_REGISTRY.mddescribiendo un pipeline de GitHub Actions que no existe y un Kubernetes «recomendado» sin manifiestos. (7) AREA-07-19:GET /unidades/{id}/prestamosdevolví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.mdES/EN reescritos sobre el patrón real (tenant_schemacon_SLUG_REfail-closed +SET search_path = "{schema}", publicsobre asyncpg; alta conscripts/init-tenant.sh; stream compartidoorpycamcp.{service}.events; bucketsdocumentsypreservation); 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.mdrebaja Kubernetes a «no soportado todavía» y avisa del estado en memoria al escalar;DOCKER_REGISTRY.mdreescrito para GitLab;contributing.mdya no pide «modelos SQLAlchemy con migraciones Alembic»;security-audit.mdEN 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 debandeja/+page.svelteyradicar/+page.svelte, y el comentario de.gitlab-ci.ymlsobre cuándo se publican imágenes. signature — migración tenant022(COMMENT ON COLUMN firma.xades_level: B/T/LT/LTA con productor; la008, ya aplicada, no se edita). archive —GET /unidades/{id}/prestamospagina (page/size≤ 100,X-Total-Count, mismo repositorio yWHEREpara filas y total) y el BFFarchivo-fisico/api/unidades/[id]/prestamospidesize=100y conserva el total;tests/test_ead_xsd.pyvalida el EAD difundido (sin serie, 1, 2, 3 y 5 niveles) contra el XSD oficial de EAD 2002 vendorizado entests/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.postde la TSA, proxies de bandeja…), la paridad de pendientes ES/EN del roadmap, el censo de ADRs, las tandas desecurity-auditES/EN y las recetas prohibidas (session.execute(text(, «Alembic»,SET search_pathsin comillas). Ya cerrados por tandas anteriores (verificados, sin cambio): AREA-14-12 —elCOMMENTdetransferencia_acta_firma_personal.rolya lo reescribió la028, y una migración nueva habría pisado ese texto— (se añadió una prueba de integración que leecol_description; la docstring del repositorio, sí, estaba caducada y se corrigió); el nombre de la memorialimitador-por-ip-comparte-cuota.mdpasa a CERRADA. No se hizo y por qué: (a) AREA-14-10, tablawebhook_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.mddeclara 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.pynecesita 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_seriesno 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óntest_comment_de_xades_level_integration, que cae sin la022); workflow 355, storage 447, document 752 sin regresión; ruff+mypy verdes en los cinco. Mutaciones del código: quitarsizedel 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/varal 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 aplica022con su runner (soloCOMMENT); el resto son comentarios y documentación, sin despliegue.fix(infra,auth): T14, remediación de auditoría: una sola fila rota deaudit_logabortaba para siempre todos los respaldos (dejando un.PARCIALen 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,knowledgesin gate, queda declarada). Por qué: (1)audit_logsolo admite anexar (Ley 594/2000): una rotura histórica no se repara;verify_audit_chainssalía 1 ante UNA rotura ybackup.shhacíaexit 1ANTES de cifrar, sellar y rotar. (2)get-object-retention ... || trueconvertía el error en vacío; el restore era fail-closed solo sobre lo que el respaldo capturó. (3)reponer_retenciondevolvía 0 con versiones previas protegidas. (4) el mapaversion_idquedaba en un TSV sin asiento ypreservacion_worm_objeto.version_idapuntaba 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_chainsacepta una línea base de roturas históricas aprobada por el responsable (--roturas-declaradas FICHERO, o líneasROTURA<TAB>slug<TAB>id<TAB>notamezcladas en el stdin del ancla): solo cuentan las roturas NUEVAS, las no enumerables (más del tope deverify_chain), un retroceso y un cambio del hash anclado; la declarada se informa en cada corrida.backup.shya no aborta: si la verificación falla no emite ancla pero completa (cifrado, sello converificacion_auditoria=FALLIDA, purga) y sale con 3 al final; la línea base va porBACKUP_AUDIT_ROTURAS_DECLARADAS(una ruta que no existe = FALLIDA, fail-closed). Retención:_leer_json_s3distingue «sin configuración» (NoSuchObjectLockConfiguration/ObjectLockConfigurationNotFoundError) de cualquier otro fallo o JSON ilegible; el fallo aborta ESE bucket, la cuenta de versiones listadas contra filasOBJETOdebe cuadrar, y el respaldo se sella conretencion_capturada_completa=NOy sale con 4;restore.shrechaza ese respaldo salvoRESTORE_ACEPTAR_RETENCION_INCOMPLETA=1. Restore: una versión previa bajo COMPLIANCE vigente hace fallarreponer_retencionsalvoRESTORE_ACEPTAR_PERDIDA_VERSIONES=1(anótese en el acta); nuevoapp.ops.registrar_restauracion(auth-service) que, en UNA transacción, actualizapreservacion_worm_objeto.version_iddesde el mapa y deja el asientostorage.restauracion(hash del mapa, recuentos, previas no restauradas) enaudit_log; el mismo criterio de línea base aplica a la verificación derestore.sh(roturas-declaradas.tsvjunto a los respaldos oBACKUP_AUDIT_ROTURAS_DECLARADAS). Producción:MIGRATIONS_DATABASE_URLsale del entorno de los 10 servicios en ejecución y queda solo enmigrate-<svc>; el guardián se invirtió;deployment.mdmigra conmigrate-<svc>y pasa la conexión del propietario solo al contenedor de un solo uso decifrar_webhook_secrets. No se hizo y por qué: gatearknowledge /search,/antecedentes,/rag(Media, AREA-01-18): decisión de producto, sigue DECLARADA entest_toda_ruta_lleva_gatecon su test anti-caducidad; hoy el único control es clearance + ACL en la consulta. Un evento PREMIS por objeto remapeado: el asiento deaudit_logy la actualización deversion_idcubren 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/tests333 passed; auth-service 362 passed conRUN_INTEGRATION=1(Postgres real; 11 pruebas nuevas entest_verify_audit_chains_job.pyytest_registrar_restauracion.py) ejecutadas sobre la imagen existente conapp/,tests/yshared/montados:ro(disco /var al límite: no se reconstruyó);./scripts/lint.sh auth-serviceverde. Mutaciones que caen: ignorar la línea base, no pasarla desde_main, tolerar las roturas omitidas, omitir elUPDATEo el asiento deregistrar, volver a|| trueen la lectura de retención, quitar la cuenta de versiones, quitar el fallo por previas COMPLIANCE, volver a abortar enbackup.sh, quitar la salida 3, quitar el acta del restore, devolverMIGRATIONS_DATABASE_URLal runtime. Impacto: reconstruir auth-service (módulosverify_audit_chainsyregistrar_restauracion);backup.shpuede 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 conrun <svc>: usarmigrate-<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 derestore.shreventaba conTypeError), 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,/antecedentesy/ragestaban 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-servicefuera detest-unit,signature-service(contests/integration/) fuera detest-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.ymlteníabuild:y ningúnimage::pullignoraba los doce servicios yORPYCA_TAG=<sha-anterior> up -dno volvía a ninguna versión; las migraciones solo corrían a mano. (4)backup.shancló count+hash deaudit_logcon unpsqlsin verificar nada (podía sellar una cadena ya rota o retrocedida), y el heredoc derestore.shdesempaquetabaverify_chaincomo tupla(ok, fila)cuando devuelve unResultadoCadena: 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íaclaude -pconexit 0si no escribía nada; sinnpm audit, sin escáner de imágenes (docker scanestá retirado). (7) las particiones deaudit_logdependen 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 enshared/tests(corren en cada push;test-unit-sharedinstala ahora PyYAML, packaging y el extrakek):test_toda_ruta_lleva_gate(AST deservices/*/app/routers: más de 400 rutas; cada una llevarequire_permission/require_internal_token/require_public_interop, cuelga de/public/o figura enEXCEPCIONEScon 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; suelospyjwt>=2.10.0/python-multipart>=0.0.18/mcp>=1.12.0; todo bind mount del repo:ro),test_matriz_ci_cubre_servicios(matrices detest-unitytest-integrationderivadas del árbol;FUERA_DE_INTEGRACIONdeclara notification y tenant),test_ci_guardianes_de_infra(job WORM conrules:changessobre los compose y con el digest exacto de producción; trivy antes de cada push; sindocker scanen la doc),test_prod_inmutable,test_migraciones_al_arrancar,test_manifiesto_prod_seguro(sinports:, Redisrequirepass, credenciales:?sin defecto —rotaciones vacías declaradas—, terceros con tag fijo),test_audit_umbralytest_retencion_respaldo_restauracion(con uns3apifalso en bash). CI: whisper entest-unit, signature y workflow entest-integration, las migraciones globales REALES de auth-service antes de cada tier (el conftest de integración ahora FALLA conCIdefinido si no encuentraaudit_logen vez de crear su copia), jobstorage-worm-real(RustFS por digest,RUN_S3_WORM=1;test_worm_object_lock_real_s3.pyFALLA en CI sinRUN_S3_WORM), job automáticotest-security-smoke(gates internos del gateway, storage y whisper), trivy enbuild-images(CRITICAL con corrección, antes del push),dependency-auditfail-closed con umbral determinista (scripts/audit_umbral.py, también en MR y sobrerequirements-ml.lock; el modelo solo redacta) y jobnpm-audit. Producción:image: ${ORPYCA_REGISTRY:?}/<svc>:${ORPYCA_TAG:?}en los 12 servicios propios (sinbuild:; la construcción local vive eninfra/docker-compose.build.yml) y unmigrate-<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) quebackup.shinvoca ANTES de anclar y NO ancla si falla, y querestore.shreutiliza en lugar del heredoc;infra/lib-retencion-s3.sh(todas las versiones, retención legal, repone y CUENTA:restore.shsale ≠ 0 si queda una retención vigente o legal sin reponer, y escribe el mapaversion_idviejo → nuevo para el acta de ADR-027)./health/readyde auth-service añadeaudit_particiones(degradedsi falta la partición del mes siguiente; sigue 200).scripts/preflight-prod.sh(TLS y puertos desde fuera).seed_demo.pysiembra un borrador con circuito de dos turnos cuyo texto cambia en la ventana de invalidación ye2e/authenticated/firma-gate-orden.spec.jslo 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) gatearknowledge /search,/antecedentes,/ragyaceptarcon un permiso: es una decisión de producto (hoy el catálogo MCP las declarapermission=None); quedan DECLARADAS enEXCEPCIONEScon 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; elmigrate-<svc>de un solo uso no). (c)build-imagessolo 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/tests316 passed; auth-service 350 passed conRUN_INTEGRATION=1(307/43 skipped sin ella); workflow 457 conRUN_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 deanulacion aprobar;python-joseen un lock,pyjwt==2.9.0, quitar:ro; quitarimage:de auth,ports: 5432,:-xen Redis,redis:latest, quitar la dependencia demigrate-auth; quitar whisper/signature de las matrices, la reglachanges, el|| truede pip-audit,--exit-code 0de trivy;put-object-retention || truey quitar elexit 1de restore;verificarque devuelve siempre 0 o que no mirares.ok; la sonda de particiones conokfijo; el trigger de sentencia 004 (en una transacción revertida); sacar_reconcile_aip_oncede sutry;COALESCE(…, 1)y quitar el predicado de clearance de la bandeja; el gate de documento antes de la pertenencia enfirmar(). Contra la pila viva: la cadena dedemoy__system__verifica íntegra (11 404 cadenas, exit 0, sin roturas);/health/readycon la partición al día; circuito sembrado: el ajeno recibe403 no_es_firmantesindocumento_modificadoniversion, el firmante con turno409 documento_modificado(versión 2). Impacto: producción exigeORPYCA_REGISTRYyORPYCA_TAGen cadadocker compose; reconstruir auth-service (móduloapp.ops.verify_audit_chainsy la sonda;backup.sh/restore.shlo necesitan) — no se pudo reconstruir en la pila local por falta de disco (/varcon ~460 MB); sin migraciones SQL nuevas. En CI:test-integrationytest-unitsuman 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 laIdempotency-Key—). Por qué: (1)DocumentCreatey el lote dejabandoc_class,dest_dept_codeyorigin_dept_codeopcionales (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íanivel ≥ 2conclasificacion.fundamentonulo 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_secretera TEXT en claro, una fila por operador, ningún endpoint lo escribía y no había rotación; un envío pasaba aentregadoa mano sin prueba. (6)get_public_anexoservía el primer anexo de cualquier radicado publicado, y publicar solo exigíanivel 1(que es el DEFAULT de una PQRS). (7)documento.contenido_cambiadono se emitía. Qué cambia: datos mínimos —app/core/datos_minimos.py(E/I → tipo + destino; S → tipo + origen) importado porDocumentCreateyBatchDocumentCreate(el lote ganaorigin/dest_dept_codey resuelve el nombre endependencias); 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) yPOST /borradores/{id}/radicaradmite 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ón041(CHECK radicados_clasificado_con_fundamentoNOT VALID+ backfill «PENDIENTE DE MOTIVAR…» +VALIDATE;createinserta ya el fundamento porque el CHECK no es diferible), la importación rechaza todo el paquete (422) y el índice añadedependenciayregistrado_por. Outbox — migración042(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), mismoevent_idal reenviar;BorradorService.editaremitedocumento.contenido_cambiadosi cambia la huella del texto. Publicación — migración040(anexos.publicable, DEFAULT FALSE, backfill de lo ya publicado),PublicacionRequest.anexos_publicables,get_public_anexoexigea.publicable, yset_publicableexcluye en elWHERElo de canalventanilla_publicay lo tipificado con un tipoadmite_ventanilla_publica(404 neutro); clasificar desmarca los anexos. Ventanilla pública —acepta_tratamiento: Literal[True]+politica_version(constancia enmetadata.consentimientoy en el asiento;ip_declarada), honeypotsitio_web,Idempotency-Keyconpublic_submission_keys(migración043, reclamada dentro de la tx del radicado y antes del consecutivo; otro contenido → 409),GET /ventanilla/estadoyscripts/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ón044: dos ranuras de secreto cifradas (AESGCM, AAD entidad+operador),verify_any,PUT /envios/operadores/{op}/credencial(USUA_PERM_ADMINcrud≥5, sin devolver ni asentar el secreto),acuse_file_id/acuse_sha256obligatorios paraentregadoa mano (la pantalla de envíos sube el acuse y lo manda). Storage —REFERENCIAS_DE_ARCHIVO+=postal_shipment.acuse_file_id(el guardiántest_referencias_de_archivo_cubren_todo_file_idlo cazó: sin la entrada la purga de huérfanos no vería la referencia del acuse); el guardiántest_eventos_de_radicado_declaran_nivelahora también censaoutbox.encolar(si no, el censo de emisores se vaciaba en silencio) ydocumento.contenido_cambiadodeclaranivel_seguridad. Docs — comentario R6 deknowledge-service/app/services/ingestion.pycorregido (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 conmetadata.remitentey 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 dedocument.envio.devueltoen notification (AREA-09-10) y consumidor dedocumento.contenido_cambiadoen 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 enaudit_log. (f) IP verificada por saltos de confianza en las rutas públicas: el gateway no la inyecta; se guardaip_declarada. (g)habilitadadentro deGET /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 nuevoGET /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 conRUN_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 bajoasyncio.gatherde 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/tests255 passed). 17 mutaciones que caen (todas restauradas y la imagen reconstruida): quitar la rama E /doc_classde 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 / moverencolarfuera de la tx / sacar la reclamación de la clave de la tx / el honeypot /Literal[True]/habilitadafija / la caducidad de la ranura anterior / el acuse obligatorio / elenviardecontenido_cambiado. Cuatro defectos propios cazados al escribir las pruebas: el CHECK 041 no admite el INSERT en dos pasos decreate(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_ventanillacon 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 sinacepta_tratamiento/politica_version,entregadoa mano sin acuse): avisar a las integraciones; un lote encolado antes del cambio sin códigos termina con esos ítemsfailed. Migraciones040–044por tenant (python migrations/runner.pyde document-service; no dependen de otro servicio:tipos_documentalesya 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 faltaEMAIL_ENCRYPTION_KEY(sin ella503). Publicar un radicado ya NO publica sus anexos: marcarlos conanexos_publicables. Los tests de integración de otros servicios que siembrenradicadosconnivel_seguridad ≥ 2por SQL crudo deben llevar fundamento. Verificado en la pila viva (tenantdemo, migraciones040–044aplicadas, document-service y frontend desplegados):POST /documentsE sin destino / sin tipo / S sin origen → 422, completos → 201; lote sin códigos → 422 y con una dependencia inexistente → ítemfailed(La dependencia 999 no existe o está inactiva); el índice traedependenciayregistrado_pory unUPDATEa nivel 3 sin fundamento viola el CHECK;PATCH …/security-leveldeja 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 (mismoevent_id); ventanilla (habilitada solo durante la prueba y devuelta a su estado):estadofalse→truey el script sale 1→0, sin autorización 422, honeypot 422, la mismaIdempotency-Keydos veces (y tres peticiones simultáneas) → un solo radicado y un solo consecutivo, otro contenido con la misma clave 409, la constancia idéntica enmetadatay 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 S1applied, 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;entregadosin acuse 422 y con acuse (subido a storage) 200 con huella en la fila y en el asiento;PATCHde borrador: solo el asunto no emite, el texto sí (documento.contenido_cambiadoen el stream);radicarborrador sin plantilla 422 concampos:[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é denpm install) y los 500 MB de WAL de las corridas de integración llevaron/vara 0 y Postgres cayó conPANICpor disco lleno; se liberó espacio (WAL conpg_switch_wal+CHECKPOINT,max_wal_sizebajado conALTER SYSTEMa 64 MB ymin_wal_size32 MB —restablecerlos conALTER SYSTEM RESET—, imagenpython:3.12-slimsin uso y las imágeneslatestsin 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)retransmitirreenviaba 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_sha256solo se validaban en su forma: un UUID inventado marcaba el envío «entregado» y quedaba citado como prueba. (3) ElUPDATEde la 041 cambia el campo del índice de la Ley 1712 art. 20 sin traza (invariante 6). Qué cambia: el relé publica elnivel_nuevoVIGENTE leído deradicados.nivel_seguridaden la misma transacción (outbox._con_nivel_vivo; el contrato del evento no cambia).POST /envios/{id}/estadocoteja el acuse con storage (/internal/files/hashes, solo conoce los ficheros del tenant) antes de la transacción:422 acuse_invalidosi el fichero no existe o la huella no coincide,503 acuse_no_verificablesi storage no responde, y se asienta la huella que devuelve storage. El runner de migraciones (asentar_backfill_fundamento) deja un asientodocument.clasificacion_fundamento_backfill(actorsystem: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 deknowledge-service/app/services/ingestion.py. No se hizo y por qué: (a) compararoccurred_aten 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 unfile_idya 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 conRUN_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 elNOT EXISTSo el filtronivel >= 2del 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_builderconstruía el XML y ningún módulo lo validaba contraindice-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 porIndexService. (2)recibirexigía el índice firmado ycreateno: 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 conservabanUPDATE/DELETEpara el rol de la app (solo las protegía un trigger) ytransferencias.objetoera obligatorio solo en la entrada. (4) Los seis resolutores WORM devolvíanNoneparafondo_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 porqueput/copyno fallaron —ni retención ni bytes se releían— y su motivo de fallo erastr(exc)(host, bucket, request-id) hacia el evento PREMIS y elaudit_log;looks_like_pdfmiraba 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 —suidy sufilename— 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 AIPagotadono 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_retentiony 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_noney en_reconciliar_indice(motivoxsd_invalido, sin firmar), aviso por correo al agotarse el sellado (_avisar_indice_agotado, mismos permisos que el reintento y clearance del expediente);createexige índice firmado (409indice_no_firmado),_validar_ubicacionesdentro de la tx conFOR SHAREy 422ubicacion_sin_tenencia, FK en la carrera → 422 con asiento; migración053(indice_firma_id/indice_sha256en el acta y en el XML que se hashea;recibirlee la fila de la versión que usó la fixity) ycabecera_completaderivado al leer;054(REVOKE UPDATE/DELETE sobretransferencia_actay firmas personales, guardado por existencia de rol),055(CHECK deobjeto, con el histórico exento porque unNOT VALIDtambién se evalúa en cada UPDATE),024con DO-block que aborta con mensaje ante duplicados;056(detalle del fixity + backoff: evento PREMISFIJACIONpost-commit condedup_key, faserun_once_fixity_premis),057(tabla de bookkeeping WORM por firma personal, backfill, faserun_once_firmas_personales_worm),058(marcaorigen_completado_aty jobcompletar_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 elWHERE, asiento en la misma tx); eventos de preservación por expediente + AIP +file_idde 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;lxmlenrequirements.txty.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_pdfcon ventana de 1 KiB (compartida con el AIP);formatRegistryPRONOM declarado (pronom.py); migración018(tiposacta_firma_elabora|entrega|recibecon índice único parcial por tipo, instrumentos de control);POST /preservacion/eventos-internal(idempotente) yGET /eventosconobject_refrepetido;tests/integration/test_worm_circuito_real.py(Postgres + RustFS reales). No se hizo y por qué: (a) AREA-07-10, el actofirma_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 yPATCHde las fases). (c) AREA-08-05,preservationLevelyrights: el plan no tiene un valor de nivel de preservación yrightsdepende 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 decreateesindice_no_firmado(el mismo que ya usarecibir), noindice_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 conRUN_INTEGRATION=1 RUN_S3_WORM=1, Postgres y RustFS reales: la dedup por autor sobre la base, los tresacta_firma_*con su índice único parcial, el evento interno idempotente y el circuito proteger→releer→renovar→purga rechazada), archive 1039 passed/252 skipped (228 conRUN_INTEGRATION=1: gate del índice, par sin tenencia, acta que ata la firma, CHECK deobjetocon el histórico operable,SET ROLE orpycamcp_appsin UPDATE/DELETE sobre el acta, reintento del AIP con clearance y estado en elWHERE, índice que no valida sin sellar, backfill sin pisar valores ni tocar cerrados, fixity PREMIS y WORM de firma personal),shared/tests255 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 eluploaded_by = $2(1), relectura de bytes (1) y de retención (1) de la réplica,str(exc)como motivo (1),bool(endpoint)(1),startswithenlooks_like_pdf(2), condición del guard de veraPDF (1),puid=None(2),dedup_key(1) yANY(1) del evento interno, tipoacta_firma_recibefuera del schema (1) y, en el circuito real, saltarensure_object_lock_bucketyverificar_retencion_aplicada(2 de integración); archive — quitar el gate del índice encreate(1 integración + 4 unitarias),indice_firma_id: None(1), quitarAND estado = 'agotado'(2), CHECK deobjetosin eximir al histórico → sin CHECK (1), quitar el REVOKE (1: llega elRaiseErrordel trigger), quitarassertValid(varias), baseclosed_at/sin clamp enbase_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_refssolo del expediente (1),COALESCEdel backfill (1),cabecera_completafija (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 dejefe/archivista/operador/consultadel tenantdemo: dedup por autor (201/201/200),409→201del gate,422 ubicacion_sin_tenencia, acta conindice_firma_iddentro del XML ycabecera_completa=falseen las tres actas anteriores, eventoFIJACIONfixity checken/preservacion/eventos,POST /aip/reintentar(200operador, 404 neutro dos veces, 403consulta), índice con XML roto →pendiente_firma/xsd_invalidoy reintento manual tras restaurarlo →firmado, índice real firmado por signature-service tras el gate, PATCH dearchivo_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 hastahoy + 400 días(2027-11-07, exacto), no 1990 niclosed_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ñadelxml; vigilar el disco). Migraciones, en este orden: storage tenant018y luego archive tenant053–058(python migrations/runner.pyde cada uno). Censo previo de la pila local (tenantdemo): 0 expedientes cerrados con el índice sin firmar y 0 transferencias activas, así que el gate decreateno detiene nada en curso; antes de desplegar en una entidad real hay que repetirlo (closedcon índice ≠firmado, ypreparada/enviada). La migración024(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 admitefecha_extrema_finalfutura (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) yretention_math.base_wormdevuelveNone(fail-closed, se pospone) si la base es futura. (2) Una recepción cuya fixity discrepa registra ahora el evento PREMISFIJACIONconresultado="fallo"(post-rollback, best-effort,dedup_keyfixity-recepcion-fallo:{transferencia}:{sello}, solo conteos, sin UUID de radicados reservados); y una fixity con documentos sin huella declaradetalle.verificacion="parcial"("completa"si no). (3) El asientoarchive.vinculo_origen_completadoincluyevalores(solo lo escrito),fuenteyresuelto_at, ycompletar_origen_vinculotomaFOR SHARE OF esobre 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.jsnaciera sin ella; los flags de la cookie del token (httpOnly/sameSite/secure) no los afirmaba ningún test (hooks.server.test.jsmockeabasetSessionCookies); (2)/403existía y nada redirigía a ella: forzar/admin/usuariossin permiso dibujaba una pantalla de 403 por XHR; (3) la pestaña Preservación decía «sin AIP, o sin visibilidad» conPRESERVATION_ENABLED=false(un 404 neutro que hacía pasar por normal que nada se empaqueta); (4) una plantilla de metadatos con una propiedadarray/objectobligatoria hacía imposible crear el expediente/radicado desde la UI (el campo no existía) y el 422 no nombraba la propiedad; (5)layout.scssrepetí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» deplan-pruebas.mdera falsa (30 endpoints, desfase cerrado en agosto) y envejecía en silencio. Qué cambia: frontend —todo-proxy-exige-sesion.test.js(censo conimport.meta.globde los 219+server.jsde(app), 290 verbos, invocados conlocals={}y body/red/cookies que lanzan, esperando 401; anti-vacuidad >200);auth-cookies.test.js(flags endev=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-closedUSUA_PERM_ADMINpara áreas no declaradas, no expulsa con/auth/medegradado; un test cruza el mapa con las tarjetas de/adminy con las carpetas);SchemaFieldconarrayde escalares (lista repetible) yobjectde un nivel, valores como JSON de crudos tipado porcoerceSchemaValue, campo ocultometa_<clave>y controles interiores sinname(FormField name={null}),ubicarErrorDeCampoycampoErrorpara marcar el control desdefield(expedientes y radicar); tokens--op-on-brand-*ylayout.scssal--op-scrim, guardián extendido a.scss;pinarProxy.server.jsganalectura()y tres puertas (PATCH planes/[id],GET planes/[id]/mapa-ruta,GET proyectos/[id]/seguimiento) conpinar-paridad.test.js(backend↔BFF derivado depinar.py); proxyexpedientes/api/preservacion-estadoy estado en la pestaña; e2erbac-restringido.spec.js(usuarioconsulta, añadido arealm-dev.json) yaxe-vistas-clave.spec.js(@axe-core/playwright, falla enserious/critical). storage —GET /api/v1/preservacion/estado→{enabled}(dato de instancia, sin consulta por expediente, gateUSUA_PERM_EXPEDIENTE). api-gateway — subruta/preservacion/estadoadmitida. document/archive —MetadataValidationError.field(ruta deabsolute_path, más la propiedad ausente en losrequired) yfielden el sobre del 422metadata_validation_failed. No se hizo y por qué: migrar las vistas con metadata inline (expedientes/[id], bandeja) aSchemaField, 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 depinarProxy.server.js(14 casos del censo),httpOnly/secure/sameSiteenauth.server.js(4/2/4 casos), invertir el permiso del layout (8), borrar el proxytablero(paridad),enabledinvertido en la pestaña (2),array → null(11),fieldfuera 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 canalapiy 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_localera «soberano» por su nombre aunqueOLLAMA_URLfuera 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>, yderive_canalsolo conocíamcppor la RUTA: un radicado creado desde la tarjeta quedabaapi, igual que una llamada directa; y aun con el gateway diciendomcp,audit.appendteníacanal="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 de9c2cc6aun salto más adentro; y el costo de un turno no se medía (AREA-11-08). (4)is_generatabledecidía por el NOMBREollama_local; conOLLAMA_URLpública,/ragenviaba chunks nivel 2-3 a ese host y solo quedaba un warning (AREA-11-12). (5)kb_suggestions.aceptada_por/aceptada_enexistí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: sharedidentidad.firmar_origen/verificar_origen(marcaX-Origen-Canal: mcp.<ts>.<hmac>ligada al tenant y al sha256 delAuthorization, vigencia 120 s, sin secreto no se emite ni se verifica) eip_real_firmada(X-Real-Ipsolo si la identidad que la acompaña verifica);request_logcapturaX-Canal(soloapi|ui|mcp) eIdempotency-Keyen ContextVars;audit.append/append_denialtomancanalde la petición cuando no se pasa y, solo en canalmcp, añadenpayload.correlacion = {request_id, idempotency_key}. api-gatewayderive_canal(path, origen, tenant=, authorization=):mcppor ruta o por marca válida,apien cualquier otro caso (la marca no se reenvía); reenvíaIdempotency-Key; enruta/api/v1/knowledge/suggestions/. mcp-serverservices/origen.py::cabeceras_de_origenes el único sitio que arma las cabeceras salientes (despacho,/auth/me, marco de alcance):Authorization,X-Tenant-Slug, la marca,X-Request-IdyX-Forwarded-Forcon la IP firmada por el gateway;ContextoEntranteMiddleware+RequestLogMiddleware; eljtide la propuesta viaja comoIdempotency-Key(en un procedimiento,<jti>:<n>por paso);run_turndevuelveno_verificados(todo{YYYY}-{TENANT}-{E|S|I}-{NNNNNN}del texto ausente de lostool_result; lo que tecleó la persona no cuenta) y añade el aviso «No verificado», y registraiteraciones/llamadas_gateway/propuestas/no_verificadospor turno. knowledgeurl_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ón012_kb_suggestions_creada_por.sql;POST /api/v1/knowledge/suggestions/{id}/aceptar(solo quien la generó,404neutro,409si ya aceptada, asientorag.aceptadaatómico). Documentación: se retira la promesa «opt-in por tenant» (main.py,integraciones-externasES/EN, ADR-006) y se corrige el embedding «stub» de la guía alocal_st; el docstring demcp-server/health.pydecía que no instalaba la librería compartida (la instala).docker-compose*.yml:IDENTITY_SIGNING_SECRETen 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:ExpedienteUpdateno admitenivel_seguridad, ningúnUPDATE expedienteslo toca y archive-service no publica eventos —no hay productor que consumir—;shared/tests/test_el_nivel_del_expediente_no_cambia_en_silencio.pydeja la premisa falsable. (b) OMI-04 sin implementar el parámetro por entidad: fijarknowledge.habilitadocon defaultfalseapagarí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 conRUN_INTEGRATION=1, Postgres real: aceptación atómica y con propiedad en elWHERE, barrera conOLLAMA_URLpública sobreknowledge_chunkreal), mcp-server 260 passed, api-gateway 126 passed,shared/tests249 passed, document-service 682 passed/196 skipped (196 passed conRUN_INTEGRATION=1); lint ruff+mypy verde en knowledge, mcp-server y api-gateway. Mutaciones que caen:is_local_llm_providerpor nombre (9 unitarias + 1 de integración), quitarcreada_pordelWHERE(2 de integración), asiento fuera de la transacción de aceptación (1),verificar_origensin comprobar la firma (4),idempotency_keysin enviar (1),X-Forwarded-Forsin reenviar (1), validación de radicados anulada (3),audit.appendsin correlación (1) y unUPDATE expedientes SET nivel_seguridaden archive (1). Contra la pila viva: un radicado confirmado desdePOST /mcp/proposals/executequeda concanal=mcpycorrelacion.{request_id, idempotency_key=jti de la propuesta}; una llamada directa, y una conX-Origen-Canalfalsa, quedanapi; el cubo por IP de las llamadas internas del asistente es el de la IP del cliente (X-Forwarded-For: 203.0.113.50en el borde) y no el del contenedor;POST /knowledge/suggestions/{id}/aceptar: ajena404, propia200, repetida409, con su asiento. Impacto: aplicar la migración de knowledge012(python migrations/runner.py) y desplegar knowledge, api-gateway y mcp-server; el canalmcpsolo 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ónIDENTITY_SIGNING_SECRETpasa a ser obligatorio también para mcp-server. Los asientos de canalmcpllevan ahorapayload.correlacion. Pendiente: la verificación de radicados inventados no se pudo ejercitar contra el modelo real (la pila local no tieneANTHROPIC_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_SECRETfirmaX-User-Id/X-Tenant-Slugy 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_suggestionses mutable y está fuera de la cadena hash: el asientorag.aceptadasolo llevabaaceptada_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 dedicadoMCP_ORIGIN_SECRET, solo en api-gateway y mcp-server, yIDENTITY_SIGNING_SECRETretirado de mcp-server en ambos compose (obligatorio en producción;infra/.env.example).identidad.firmar_origen/verificar_origenlo usan; el gateway firma ademásX-Real-IpenX-Real-Ip-Signature(firmar_ip_real, ligada a IP + persona + entidad) yip_real_firmadaverifica esa firma en vez de exigir la identidad completa. Con soloMCP_ORIGIN_SECRETse puede marcar el canal y verificar la IP, pero no firmar una identidad.rag.aceptadalleva ahorasha256_texto(de la respuesta sugerida),proveedor,modeloyfuentes(lossource_refcitados, sin su contenido: Ley 1712), calculados de la fila bloqueada conget_for_updateen la misma transacción. No se hizo y por qué: OMI-04 (opt-inknowledge.habilitadopor entidad) sigue abierto: implementarlo cambia el comportamiento visible (con defaultfalseapaga 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,/antecedentesy/ragresponden503neutro, con mutación que se salte la consulta. Verificación: knowledge 262 passed/56 skipped (318 conRUN_INTEGRATION=1, Postgres real), mcp-server 264, api-gateway 128,shared/tests255; ruff+mypy verdes en knowledge, mcp-server y api-gateway. Mutaciones que caen:_secreto_origenleyendo el secreto maestro (1),ip_real_firmadasin comparar la firma (2), firma de IP sin ligar a persona/entidad (1), el gateway sinfirmar_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 sinIDENTITY_SIGNING_SECRETy conMCP_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: definirMCP_ORIGIN_SECRETen 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 aapiy no se reenvía la IP (lado seguro). Los asientosrag.aceptadanuevos 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 soloevent_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-Truncatedsaltaba 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íaPRIMARY KEY (event_id)yON 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íainsert_if_new: unpurged/annulledaparcado 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 dereclaim_staleaparcaba conlast_error=""(AREA-10-09). (5)len(candidates) >= capdaba falso positivo (AREA-10-08). (6) elReclaimStatsse 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 enpg_constrainty PK(origin_group, event_id), idempotente y aplicable en cualquier orden; knowledge añade antes010_dead_letter_estado_y_grupo.sql(origin_group NOT NULL,status,resolved_at, índices) y se corrige el comentario falso de004(«instancia distinta»). Los 4insert_if_newusanON CONFLICT (origin_group, event_id). knowledge gana/api/v1/knowledge/admin/deadletter(listar, detalle,replay,discard;PERM_DLQ_ADMIN, escritura amin_crud=3, predicadotenant_slug+origin_group, no-read-up sobre el nivel vivo con404neutro,422 unsupported_event_type; el replay correapply_eventen la MISMA transacción que la transición y el asiento) y el gateway lo enruta (/api/v1/knowledge/admin/). sharedevents.consumer_name(servicio)({servicio}-{HOSTNAME});record_failureguarda el último error por mensaje (HSET orpycamcp:{grupo}:lasterr, TTL 24 h, best-effort) y la rama huérfana lo recupera o escribedesconocido: sin intento en este barridoen vez de vacío;reclaim_stale(status_name=…)deja el últimoReclaimStatsy el acumulado enconsumer_status();health.consumer_reportañade a/health/readyde los 4 servicioschecks.dead_letter(degradedsi la DLQ de Redis tiene entradas o el último barrido aparcó) y el objetoconsumer(XLEN, contadores). Solo informa: no decide el503. Los listados de workflow/signature/notification pidencap + 1filas y marcanX-Truncatedsolo con más decap. Verificación: workflow 355 passed/97 skipped (452 conRUN_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/tests226 (guardián nuevotest_dlq_compartida_y_consumidores.py: censo deCONSUMERliteral, de todoON CONFLICTsobre la tabla, destatus_nameen cadareclaim_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 elON CONFLICTfallaba 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 porevent_iden la migración (3),CONSUMERliteral (censo), no escribir elHSET(1), no persistir el barrido (1), quitarstatus_namede un worker (1), sonda sin degradar (2 + 1 por servicio),>=en lugar de>(1), quitarorigin_groupdelWHEREdel listado de knowledge (1), quitar el gate de clearance del replay (1), quitarmin_crud=3(1), quitarvoid_radicadodel despacho (1). En vivo (pila local, tenant demo): migraciones aplicadas en los 4 servicios (PK compuesta confirmada entenant_demo; también en ambos órdenes sobre un esquema desechable);jefelista la DLQ de los 4 servicios (200) yconsultarecibe 403 en los 4; un mismoevent_idaparcado 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 siguepending); replay de uncreatedaparcado indexa el fragmento y el de unpurgedlo dejavoidedcon texto vacío, con asientoevento.dead_letter.replayed;consultano puede reprocesar; los consumidores ya se registran por réplica en Redis (workflow-service-<host>, etc.; los-1antiguos quedan inactivos y sin pendientes) y/health/readymuestradead_letter: oky el último barrido. Filas y fragmento de prueba retirados. No hecho: llevar el clearance de la lista alWHEREy 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 deapply_eventancla elreclassifiedal nivel VIVO deradicados(ycreatedamax(evento, vivo)); sin radicado vivo responde409 stale_eventy el evento sigue pendiente (unreclassified(1)aparcado ya no puede bajar un chunk que volvió a nivel 3 y exponerlo a clearance 1). Knowledge 233/48 (281 conRUN_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 aX-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-xmlverificó 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 deX-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 clavetotp_requerido_por_nivelestaba «reservada» y nadie la leía. (3) enradicado/expedientela 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) unsignature_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) conFIRMA_PERSONAL_REQUIRE_XADES=offun 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)/verifyse 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)acreditadoera un literalfalseen verificación. (11)payload_sha256no decía que no es el hash del documento. Y una avería hallada al probar en vivo:/sign-xmlpor el camino nativo persistía unnow()distinto del instante que quedó dentro del HMAC, así que/verifyrecomputaba 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(reemplazaget_signer_email: mandaX-Tenant-Sluga/mey devuelveuser_id) y comparación con el actor antes de crear el desafío;403 identidad_incoherente+ asientofirma.identidad_incoherente(enemitir_challengeel asiento se escribe en su propia transacción porque la de emisión se revierte; en firmar/TOTP/credencial//sign-personalse 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-closed503, y el estado de OTRO bearer no vale.crearignora elpayload_sha256del cliente pararadicado/expediente(NULL, se firma el identificador) y/signrechazapayload_xmldonde la cadena no fija huella (422 payload_xml_no_admitido). Claves HKDF por (propósito, entidad) encore/secret_derivation.py; mensaje de la firma = JSON[v1, tenant, tipo, objeto, sha, firmante, sello]; el HMAC del OTP atachallenge_id; migración020_key_version_hkdf.sql(firma.key_versionyfirma_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 delasync with transaction(). Asientofirma.fallback_nativa_canal_caido(misma transacción que la firma) y aviso al arrancar con el gate enoff./verifyyGET /firmas/{id}aplican el no-read-up en elWHERE(NOT EXISTSsobre radicados/expedientes; sinX-User-Id—archive-service— no aplica) y la nativa verifica con lakey_versionde la fila;acreditadose deriva denivel_conformidad∈xades_{b,lt,lta}_acreditadoy solo si la cripto valida (VerifyOutcomeya no llevaacreditado); revocación POSTERIOR al instante de la firma (sello TSA verificado osello_tiempo) no invalida y devuelverevocacion_posterior=true;verify_timestamp_token(signer_cert=…)exigegenTimedentro de la vigencia del firmante y comprueba el ESSCertID (signing-certificate-v2; la TSA local ahora lo emite; ausencia solo se rechaza conTSA_ESSCERTID_OBLIGATORIO=true)./sign-xmlpersiste el instante que quedó dentro del HMAC. auth —GET /internal/totp/status,user_iden el veredicto válido de/internal/totp/verifyy en/internal/signing-key/status; se eliminatotp_requerido_por_nivel. frontend — el asistente pasa a pedir la app si el reto por correo respondetotp_requerido. docs —api.mdES/EN (incluida la advertencia depayload_sha256, con guardián enshared/tests/), ADR-016, auditoría de seguridad, roadmap. Verificación: signature-service 480 passed/10 skipped (422/4 antes) y 8 passed conRUN_INTEGRATION=1(Postgres real: la migración020, el DEFAULT 0/CHECK y el gate no-read-up contraradicados/expedientesreales; cazó un defecto de verdad: sin ninguna de las dos tablas el$2sobraba); auth-service 287 passed/43 skipped; guardianesshared/tests213 passed; lint ruff+mypy verde en ambos; frontendSignAssistant.test.js13 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 encrear,payload_xmladmitido, clave raíz sin HKDF, mensaje sin entidad, OTP sinchallenge_id,now()en vez del sello del HMAC,key_versionfija en/verify, quitar el asiento de fallback, comparar la revocación connow(),revocado_atausente como posterior, quitar el gate de/verifyy deGET, quitar el crucegenTime/firmante y el cotejo ESSCertID,acreditadoliteral / sinvalida AND, mover el status dentro del lock, y la rama del asistente. En vivo (pila local, tenant demo, tokens deoperador/jefe/consulta): firma nativa de/sign-xmlverifica (antes imposible) y una filakey_version=0con el HMAC literal anterior también;consulta(clearance 1) recibe404 firma_not_foundidéntico al de un UUID inexistente sobre la firma de un radicado nivel 3 mientrasoperadorla verifica; XAdES-T nuevo verifica consello_tiempo_valido=true/acreditado=falsey su token lleva ESSCertID (los 8 tokens del corpus anterior no: por eso el default tolerante);challenge/signde nivel 2 con TOTP activo →409 totp_requerido; cadena conpayload_sha256declarado queda enNULLy/signconpayload_xmlda422; el bearer deoperadorconX-User-Iddejefe(llamada interna firmada) →403 identidad_incoherentesin desafío y con asiento. Impacto: desplegar auth-service ANTES que signature-service (sinuser_iden sus respuestas signature falla cerrado:403 identidad_incoherente); aplicar la migración020por tenant con el runner de signature-service (idempotente); reconstruir signature-service, auth-service y frontend. Contratos:/signrechazapayload_xmldonde la cadena no fija huella;/challengey/signpueden responder409 totp_requeridoen nivel ≥ 2 y403 identidad_incoherente;/verifyyGET /firmas/{id}dan404sobre objetos reservados al sin clearance y ya no son «públicos»;VerifyResponseganarevocacion_posterior; las firmas nativas antiguas siguen verificando (key_version=0). Los desafíos OTP en vuelo al migrar siguen valiendo (v0). Pendiente: activarFIRMA_PERSONAL_REQUIRE_XADES=clasificados(decisión de la entidad, exige enrolar certificados); HSM/KMS (externo);TSA_ESSCERTID_OBLIGATORIO=truecuando el corpus de dev se re-selle;/internal/totp/verifysigue sinX-Internal-Token; la prueba en vivo del camino de firma con OTP-correo no fue posible (el stack no traemailhog:503 canal_2fa_no_disponibleal 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é:/verifyconservabavalida=truesi la clave se revocó después delsello_tiempode 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_revocacionpasa a vocabulario cerrado (RFC 5280:key_compromise,superseded,cessation_of_operation,affiliation_changed,privilege_withdrawn,unspecified):422fuera de él en el self-revoke y el admin-revoke, migración tenant019_motivo_revocacion_vocabulario.sql(los textos libres previos pasan aunspecified, el original queda en el asientosigning_key.revocada; CHECK idempotente) yGET /internal/signing-key/revocation-statusdevuelve tambiénmotivo_revocacion. signature —/verifytoma como instante de la firma SOLO elgenTimede una TSA verificada: conkey_compromise→valida=false,motivo=clave_comprometida; sin TSA verificada →valida=false,motivo=instante_firma_no_cierto,revocacion_posterior=null(firma_criptograficamente_validano cambia); con TSA y motivo distinto de compromiso se conservavalida=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ón019ejecutada dos veces sobre un Postgres desechable (idempotente; normaliza el texto libre; el CHECK rechaza un valor fuera). Mutaciones que tumban pruebas: quitar la ramakey_compromise(1), volver a usar elsello_tiempodel servidor como instante (1), motivo de nuevostrlibre en el esquema (1), no devolvermotivo_revocacionenrevocation-status(1). Impacto: desplegar auth-service (y aplicar la019por tenant con su runner) ANTES que signature-service y el frontend; los clientes que enviaban texto libre enmotivoreciben422. Las firmas personales sin TSA verificada que ya tenían una revocación pasan de «válida con revocación posterior» avalida=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 asientohuella_de_contenido=falsesigue 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ón008(«tenant-service sigue como propietario: no enrutado») era falsa —/dependencias,/catalogos,/config,/business-daysy/pinarvan a ese servicio— y con el rol propietario el solo-anexar deaudit_logno lo frenaba. (2)POST/PATCH /tenantsescribían en autocommit, sin asiento, y unCREATE SCHEMAfallido dejaba la fila huérfana. (3)PATCH /dependencias/{id}renombraba o desactivaba sin mirarflow_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_URLconDB_APP_USER;MIGRATIONS_DATABASE_URLcon el propietario solo para el runner).POST /tenantsya NO ejecuta DDL: registra la fila conprovisioning_status='pending'yscripts/init_tenant.py(propietario) crea el esquema, aplica migraciones y la pasa aactive. Alta yPATCH /tenants/{id}van enconn.transaction()con asientotenant.created/tenant.updated(tenant_slug=__system__,canal=system, antes/después; fail-closed).PATCH /dependencias/{id}responde409 dependencia_con_tramite_en_curso(conpasos_vivosyreglas) si cambianombreo poneactivo=falsey hay pasospending/in_progresso reglas por ese nombre.PUT /config/params/{key}asientaantes/despues/existia(lectura previaFOR UPDATE). Guardiánshared/tests/test_servicio_enrutado_no_conecta_como_propietario.py(deriva los servicios deproxy.py). Comentario falso de008corregido. Verificación: tenant-service 129 passed/6 skipped y 6 passed conRUN_INTEGRATION=1(Postgres real con un rol desechableitest_app_*sin privilegios de propietario: no puedeUPDATE/DELETEsobreaudit_logniCREATE SCHEMA, y aun así hace el alta con asiento); mutaciones (quitar el asiento, sacarlo de la transacción, quitar el conteo, reintroducirCREATE SCHEMA, quitar el «antes») y${DB_USER}en producción hacen caer sus pruebas. Impacto: reconstruir tenant-service; en producción exigeDB_APP_USER/DB_APP_PASSWORDtambién para este servicio. El alta de entidades se hace SOLO conscripts/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 conPERM_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)uploaddeduplica por SHA-256 y devuelve el mismofile_id;purge_orphansolo 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 /statusdevolvía elsubjectde todos los ítems —un radicado del lote reclasificado después a reservado se leía conPERM_RADI:1— y cualquierPERM_RADIveí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ón017(files.dedup_hit_at); la rama de dedup deuploadhaceUPDATE ... RETURNING(toma el bloqueo de fila, se serializa con elFOR UPDATEde la purga; si la purga gana, la subida deja un objeto nuevo) ypurge_orphandevuelvereferenciadosi 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: unremove_objectsinversionIdsolo crea un delete marker; lo que protege espreservacion_worm_objeto); guardiánshared/tests/test_referencias_de_archivo_cubren_todo_file_id.pyque DERIVA de las migraciones de tenant de todos los servicios las columnas*file_id*UUID y las compara conREFERENCIAS_DE_ARCHIVO(con control positivo del parser y anti-vacuidad). document —GET /batch/documents/{id}/statusyPOST .../reprocesar: solo el creador del lote oPERM_RADInivel 3, en elWHERE,404 lote_no_encontradoneutro (en reprocesar, ANTES del lock para que no haya oráculo por409); elsubjectlleva el no-read-up en el SQL (LEFT JOIN radicados+CASE);lote_reprocesadoguardaerror_message(≤500) eintentospor ítem;radicado_createdde un lote llevaorigen:"lote",job_id,item_index; la reanudación automática asienta con el actor de sistema,canal="system"yen_nombre_de;radicarun borrador exige la misma tenencia que editar (404 neutro;aprobar/devolversiguen sin ella); elUPDATEdePATCHllevaestado <> 'radicado'(409 si se radicó entre medias); el paso aagotadode un binario huérfano dejadocument.import_binario_agotadoen la misma transacción. notification — manejador global deRequestValidationErrorque quitainput/ctxdel 422. Contrato para el frontend (lotes): el404deGET /statusy dePOST /reprocesarahora es{"detail": {"error": "lote_no_encontrado", "detail": "...", "status": 404}}(antesdetailera una cadena en el GET); el GET exigeX-User-Id; un lote ajeno devuelve 404 aunque exista;items[].subjectpuede sernull(radicado por encima del nivel del llamante) y cada ítem traenivel_seguridad(int | null,nullsi no hay radicado o no se alcanza); el resto del contrato no cambia. Verificación: storage 413 passed/57 skipped + 457 passed/13 skipped conRUN_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 deshared/testsverde. Mutaciones que tumban pruebas: quitar la marca de dedup enupload(2), ignorarla en la purga (1),continuepor tabla ausente (1), quitar el predicado de creador (1), quitar el gate delsubject(1), quitar el chequeo previo al lock (1), no asentarerror_message/origen/actor de sistema/agotado(1 cada una),radicarsin tenencia (2), quitarestado <> 'radicado'(1), quitar el manejador del 422 (1) y quitar una columna deREFERENCIAS_DE_ARCHIVO(el guardián). Impacto: aplicar la migración storage017por tenant y reconstruir storage-service, document-service y notification-service. Quien reprocesaba o miraba lotes ajenos conPERM_RADIrecibe404; quien radicaba borradores ajenos recibe404. -
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) cualquierPERM_RADIeditaba 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 unasyncio.create_task: reiniciar el servicio lo dejabapending/processingpara 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 delWHEREdelUPDATE: 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 mismo404neutro y la fila no se toca.aprobarydevolverNO la exigen (actos de revisión; aprobar suele ser de otra persona y está atado al circuito de firma). (2) storage-servicePOST /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+asientofile.purgado_huerfanoen una transacción. document-service encola los binarios enimport_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 asientadocument.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 (reclamoFOR UPDATE; el consecutivo y el radicado van en la misma transacción: sin huecos ni doble numeración); los ítems fallidos quedanfailedconerror_message,error_tipoeintentos; el lote terminacompletado_con_errores;GET .../statussumaerrores[],pending_items,reprocesable; nuevoPOST /batch/documents/{job_id}/reprocesar(mismo permiso que crear, asientodocument.lote_reprocesado); retención de lotes completados sin errores (90 días).statusdel lote pasa aVARCHAR(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; tratarerrorcomo terminal, no encolar, no purgar tras revertir, no reintentar; quitar el reclamo del ítem, el lock, el recuento, el estadocompletado_con_errores, el filtro de reproceso, la retención, la reanudación y el permiso del endpoint. Impacto: aplicar las migraciones038y039por tenant y reconstruir document-service y storage-service. Quien editaba borradores ajenos conPERM_RADIrecibe404. El lote que terminabafailedpor ítems fallidos ahora terminacompletado_con_errores(la UI debe tratarlo; el contrato está endocs/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 ysubido_desdela acotan, no la cierran); elstatusde 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 dicereprocesabley la persona tienePERM_RADI, ofrece «Procesar los faltantes» con confirmación que dice cuántos se procesan y que lo ya radicado conserva su número, hacePOST .../reprocesarpor un proxy SSR con allowlist (D-05), vuelve a sondear hasta que termine y traduce los404/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 dePERM_RADItumba 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.secreteraTEXTen 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 enorpycamcp_common.kek(AES-256-GCM, AADtenant:{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 tenant009(secret_cifrado,secret_nonce,secret_kek_version, tres CHECK;chk_webhook_secret_sin_claroimpide escribir un secreto nuevo en claro). Crear un webhook cifra (elidlo acuña la aplicación porque entra en el AAD; sin KEK utilizable responde503 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_KEKes obligatoria (el servicio no arranca sin ella; en producción,${…:?}). Pasos de operación:python -m app.ops.cifrar_webhook_secrets(cifra los heredados, dejasecreten NULL en el mismo UPDATE y escribe el asientonotification.webhook.secret_cifradodesde la aplicación; idempotente) ypython -m app.ops.rewrap_webhook_secrets(rotación de KEK,--verifypor 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/tests191 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 anularsecretal cifrar (1), AAD sin el id del webhook (2). Impacto: definirWEBHOOK_SECRET_KEK(openssl rand -base64 32) antes de reconstruir notification-service; aplicar migraciones y corrercifrar_webhook_secretsANTES deup -d(verdocs/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 columnasecreten claro sigue existiendo (siempre NULL); retirarla es una migración posterior, cuando ningún despliegue conserve filas sin cifrar. -
fix(document): corregirdest_deptpor 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 noNULL) y el PATCH dedest_deptsobre un radicadoclosedresponde 409dest_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_codeyorigin_deptse recortan en niveles >= 2; el asiento de crear/borrar webhook guarda soloscheme://host:port+url_sha256(nunca el path, donde Slack/Teams/Discord llevan el token);POST /workflows/{id}/vistos-buenosrechaza con 422revisor_sin_habilitacionsi 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 buscabanivel_seguridady el evento de reclasificación envíanivel_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)/webhookscrear/borrar no dejaba asiento, ni actor, y bastaba nivel de lectura. (3)validate_webhook_targetvalidaba una resolución DNS yhttpxhacía otra (rebinding). (4)GET /vistos-buenosno tenía gate de clearance ydecidiraprobaba por turno aunque el revisor no pudiera leer el radicado. (5) sin FKflow_steps→radicados, la purga TRD dejaba huérfanos que elCOALESCE(...,1)leía como PÚBLICOS. (6) la UI mostraba Devolver conPERM_RADIy el backend exigePERM_TRAMITAR. Qué cambia: nivel efectivo = máximo denivel_seguridad/nivel_nuevo/nivel_anterior; causal, fundamento y plazo se quitan SIEMPRE; un eventodocument.*/workflow.*sin nivel sale mínimo (tipo, id, fechas; sin tracking, dependencias nidedup_key); la ruta de dependencias de un reservado tampoco sale; los productores de workflow declarannivel_seguridad. Crear/borrar webhook exige nivel 3 y dejanotification.webhook.created/deletedcon actor en la misma transacción (URL sin credenciales ni query, sin secret). La entrega conecta a la IP validada conHosty SNI originales yfollow_redirects=False.list/activede vistos buenos hacen JOIN aradicadosconnivel_seguridad <= clearance(404 neutrosin_cadena/sin_pendiente); crear la cadena de un radicado no visible da 404.COALESCEde huérfanos a 3 (fail-closed) en_clearance_conditionylist_flow_events. La DLQ de workflow usaget_actoren detalle/replay/discard. La UI espejaPERM_TRAMITARpara Devolver. Pruebas de cruce entre entidades de la DLQ de notification (Postgres real). Verificación: notification-service 197 unit + 2 de integración (199 conRUN_INTEGRATION=1), workflow-service 329 unit / 405 conRUN_INTEGRATION=1, frontendbandeja148,shared/tests(guardián de nivel) verde, lint limpio. Mutaciones (todas tumban pruebas, restauradas): quitarnivel_nuevo/nivel_anteriordel nivel efectivo; pasar la URL original aclient.post;min_crud3→1 en webhooks; quitaraudit.append; COALESCE de huérfanos a 1; quitar el JOIN de vistos buenos; quitartenant_slugde la DLQ; volver aPERM_RADIencanDevolver. Impacto: reconstruir notification-service, workflow-service y frontend (sin migraciones). Quien administraba webhooks conUSUA_PERM_ADMINen nivel 1-2 recibe403al 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 elsecretde webhooks en reposo; la prueba de aislamiento con dos esquemas de tenant-service y de workflow (el aislamiento allí es porsearch_path, solo se cubrió la DLQ compartida, donde era un predicado). fix(document,mcp)!: un radicado fallido gastaba un número sin asiento,importpodí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 dePOST /importretení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; asientodocument.import_binarios_sin_radicado; purga de huérfanos queda como deuda) Por qué: (1)DocumentService.createtomaba el consecutivo en autocommit ANTES de la transacción: untercero_idinexistente 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)importreconciliabatracking_sequencescon el número del paquete sin comprobar que fuera de esta entidad ni acotar la secuencia:2026-OTRA-E-2147483647dejabalast_seqen 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 elUPDATEno 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 deconn.transaction()(hace rollback con el radicado).importreconcilia el contador solo si el código de entidad del número es el del tenant, y rechaza con422secuencias > 999999 y años fuera de [1900, año actual]. Envíos:POST /documents/{id}/enviosyPOST /envios/{id}/estadopasan aPERM_RADI_SALIDAnivel 3, asientandocument.envio_registrado/document.envio_estado(actor =X-User-Id) en la misma transacción, y elUPDATElleva estado previo y clearance en elWHERE(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.importyexportpasan aUSUA_PERM_EXPEDIENTEnivel 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_ocupadaconRetry-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 deldoc_class(falla el ítem) y guardametadata_template_id, y numera con el año de la zona de radicación. Catálogo MCP:despachar_envioyactualizar_estado_enviodeclaran 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, sinto_thread, sin cupo, sin tope de bytes, sinvalidate_metadataen el lote, año en UTC, guía del radicado, sin asiento de transición, sin estado previo en elWHERE, sinmin_crud=3. Impacto: reconstruir document-service y mcp-server. Quien teníaPERM_RADI_SALIDAoUSUA_PERM_EXPEDIENTEen nivel 1-2 recibe403al despachar, cambiar estado, importar o exportar; la UI de/enviosy 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); reanudarbatch_jobspendientes al arrancar y purgar los completados; job asíncrono/streaming para import-export;PERM_DESPACHOpropio y planilla persistente; caché/auth/validatesin MAC del gateway y la corrección de §9/§10 del documento de auditoría (T2/T14).-
fix(document,mcp)!: unPERM_RADIde 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}/radicadosdevolvía elsubjectde radicados RESERVADOS sin gate de clearance, y la barrera de residencia del asistente solo recorta lo que declaranivel_seguridad, que el esquema no llevaba: el asunto viajaba íntegro a Anthropic (ADR-006, «sin excepciones»).RespuestaItemtenía el mismo defecto. (2)borradorno tenía nivel: cualquiera listaba, leía y editaba los de todo el tenant, incluido el cuerpo ya combinado con datos del padre reservado; yeditarno asentaba nada. (3)POST /documents/{id}/signaturesregistraba la huella del cuerpo, a nivel de lectura y sin asiento enaudit_log. Qué cambia:radicados_dellevaAND r.nivel_seguridad <= $3en el mismoWHERE, con elORde las fichas fusionadas entre paréntesis (sin ellos la rama directa saltaba el gate), declaranivel_seguridaden el ítem y asienta la lectura (document.tercero_radicados_consultado). Migración035_borrador_nivel_seguridad.sql: el borrador hereda el nivel del padre; get/list/update/set_estado filtran en elWHEREpor 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;PATCHasientaborrador.editadoatómico (BorradorService.editarcambia de firma:max_clearanceyactor). Firma declarativa:PERM_FIRMAnivel 2,anexo_idobligatorio, huella resuelta por el servidor desde storage (/internal/files/hashes; elcontent_hashdel cuerpo se ignora; 503 si storage no responde), asientodocument.signature_createden la misma transacción (lógica enSignatureService). Guardián nuevoshared/tests/test_contenido_declara_su_nivel.py(censo porast, lista de excepciones congelada que solo puede bajar). Descripción falsa defirmas_de_un_radicadocorregida (decía que había proveedor y nivel de conformidad). De paso:_BOno traíacuerpo_sha256niversion, queeditaryradicarleen. Verificación: document-service unit 576 + integración (Postgres real) conRUN_INTEGRATION=1, mcp-server 236,shared/tests182, lint limpio. Mutaciones (cada una tumba pruebas, restauradas y reconstruidas): quitar el gate de tercero, quitar los paréntesis delOR, quitar el gate de get / list / update / set_estado del borrador, quitar elGREATEST, quitar el asiento deeditar, usar la huella del cuerpo, sacar el asiento de la transacción de firma, quitar el nivel 2, quitarnivel_seguridaddeRespuestaItemo deRadicadoDeTercero(guardián), y subir_NIVEL_RESERVADA(asistente). Impacto: aplicar la migración035por tenant y reconstruir document-service y mcp-server. Un cliente que firmaba sinanexo_idrecibe422; uno conPERM_FIRMAen lectura,403. Declarado fuera, a propósito: decisión del usuario sobre retirar el POST de firmas con410; si editar un borrador exige ser su creador o tener turno en el circuito;SearchHityRadicadoMetaItemsiguen sinnivel_seguridad(enEXCEPCIONESdel guardián);radicarno propaga el nivel del borrador al radicado nuevo. Remediación de auditoría (T3): (Alta)radicarcreaba el radicado en nivel 1 aunque el borrador respondiera a un reservado: ahora hereda el nivel VIGENTE del padre (GREATESTcon la instantánea), su fundamento/causal/plazo (si el padre no tiene fundamento, se motiva por herencia: nunca cae a PUBLICA) y el vínculoresponde_a. (Media) migración036_signatures_origen.sql: las firmas declarativas heredadas quedanorigen='cliente_no_verificado'yverifylas excluye; las nuevas llevanorigen='servidor'. (Media)aprobar(persona y circuito completado, ruta interna) ydevolverasientanborrador.aprobado/borrador.devueltoen la misma transacción que el cambio de estado. Verificación: document-service 744 (conRUN_INTEGRATION=1), lint limpio; mutaciones que tumban pruebas: quitar la herencia de nivel, quitar el filtro deorigen, cambiar la acción del asiento. Impacto: aplicar036por 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_plazodel asientoworkflow.finalizarsolo mirabaflow_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 confuera_de_plazo: null. Ahora manda el plazo del paso si existe y, si no, el vencimiento legal del radicado (radicados.due_date);nullsolo si no consta ninguno. Verificación: workflow-service 397 conRUN_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 enregistered(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 avanzabanflow_steps;radicados.statussolo cambiaba si alguien pulsaba a mano «Distribuir»/«En trámite» —unPATCHconstatus—, 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 escriberadicados.statusen la misma transacción que el paso, después de mutarlo y antes del asiento (siaudit.appendfalla no queda ni paso ni estado), con una sola función pura (estado_radicado.py::derivar_estado_radicado) cuyo gemelo literal es elCASEdel backfill: paso activopending→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 dejadistributed(declarado).in_progresssolo pormarcar_leidodel poseedor, y para eso se cerró un hueco que ya existía:execute_transactionno comprobaba tenencia, así que cualquiera conPERM_TRAMITARpodía marcar leído un trámite ajeno (transaction_types.requires_holder, migración 012; no poseedor → el mismo404 no_active_step, sin oráculo). El único cierre esPOST /workflows/{id}/finalizar(PERM_TRAMITAR, solo poseedor,comentarioobligatorio,tipo_tx=finalizar): no es untransaction_type, porque el catálogo es configurable por el tenant y el acto irreversible no puede vivir ahí.closedyanuladoson terminales: todo acto que mueva el paso responde409 radicado_terminaly revierte;informar/agendarsiguen permitidas. La cascada devuelve los pasos apending(OrfeoLEIDO=0), excluye terminales (skipped_terminal), toma los cerrojos ordenados y asientaworkflow.estado_radicadopor radicado cambiado. Tras el commit se publicaworkflow.radicado.estado_cambiado(best-effort). document-service: elPATCHrechazastatuscon422 status_managed_by_workflow(quitar el campo no bastaba: Pydantic habría ignorado el extra y el cliente creería que cambió). MCP:actualizar_radicadopierdestatus; nuevafinalizar_tramite(irreversible) → 207 tools, 75 de escritura, 27 irreversibles. Backfill013_radicado_estado_desde_flujo.sqlcon tabla de evidenciaradicado_estado_recalculo, sin escribiraudit_logdesde 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 nuevotests/integration/: atomicidad con fallo forzado deaudit.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/tests171;lint.shlimpio 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 elCASEdel backfill, no excluir terminales en la cascada e ignorar la bandera definalizar. Frontend:/bandejaretira los botones manuales «Distribuir»/«Iniciar trámite» (y suPATCHdestatus, que ahora da422 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 proxyPOST /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 deGET /current), conPERM_TRAMITAR; el backend re-valida. Mensajes en español para409 radicado_terminal,404 no_active_stepy422 status_managed_by_workflow. Tras la auditoría: «Finalizar» exige ademásPERM_TRAMITARnivel ≥3 (espejo demin_crud=3, leído depermission_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 (422sin 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 leeGET /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 para404 step_not_foundy404 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_bydel detalle comparado con eluser_idinterno, no elsub); conregistered_bynulo (importado/ingesta) o historial no cargado no se ofrece (fail-closed; el backend da404 no_active_step). «Devolver» y «Revertir» siguen la tenencia de_holder_condition(también el paso sin dueño de mi dependencia; ya no existe403 not_current_holder) y «Revertir» solo con el pasopending. Mensaje en español para404 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);rollbacksolo desdepending; completar sin marcar leído dejadistributed;finalizar/devolversobre 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 elstatusdel paquete. Pendiente: el frontend (botón «Finalizar trámite», retirar «Distribuir»/«En trámite» manuales, traducir el 409). Impacto: aplicar migraciones (012,013por tenant) y reconstruir workflow-service, document-service y mcp-server. Cualquier cliente que enviarastatusalPATCH /documents/{id}recibe ahora422.finalizarpasa a nivel 3 (ver remediación). Con la remediación, además: aplicar014/015por tenant y034de 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/transferya 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 elWHEREde 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, soloauto_distribute); redistribuir el paso de otro queda en la cascada de administración. (2) El oráculo409 radicado_terminalse cierra (el gate de tenencia va antes de sincronizar;anularpasa arequires_holder, migración 014, y toda transacción que mueve el paso exige tenencia). (3)finalizarexigePERM_TRAMITARnivel 3 (era 1, el de lectura); sale deSIN_NIVEL_DECLARADASy el catálogo MCP lo espeja. (4)complete_stepusa la misma definición de poseedor (antesassigned_to IS NULLbastaba para cerrar el paso sin dueño de otra dependencia, y un paso cerrado se «re-cerraba»). (5) MCPfinalizar_tramitese confirma tecleando el radicado (confirmar_con), no conCONFIRMO. (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 agotabamax_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ón015_sanear_pasos_zombi.sqlsanea los existentes con tabla de evidencia de solo inserción (flow_step_saneamiento) einbox/pending/vencidos excluyen radicados terminales. (8) La importación INT-05 rechaza paquetes conanulado(enum cerrado enRADICADO_SCHEMA), registra elstatus_origenen el asiento y la migración034_radicado_status_check.sql(document-service) cierra el dominio deradicados.statuscon unCHECK(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 tocaupdated_aty el runner de migraciones escribe el asiento-anclaworkflow.estado_radicado.backfill(conteo + SHA-256) enaudit_log, una vez por tenant. (10-12)finalizarexigemotivo_cierre(respondido|nrr|traslado_competencia|otro), su asiento añadestep_id,flow_event_id,tiene_respuesta,fuera_de_plazoyen_expediente, y se permite sin paso activo a quien poseía el último paso completado. (13)_cambiosse vacía al inicio de cada acto (la pruebaxfailpasó a verde). Defecto previo hallado al probar contra Postgres real:AnulacionRepository.decidecondicionaba aestado = 'pendiente', valor que elCHECKnunca admite, así que ninguna aprobación de anulación podía aplicarse (siempre409 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_depten PATCH/borradores) quedan como alias deprecado un ciclo (cabeceraDeprecation+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-000117aparecía con la dependencia de radicación («Oficina de Tecnología») tras haber sido repartido a Talento Humano y devuelto a Control Interno.dest_deptes una instantánea legal que no cambia; dónde está el radicado solo se reconstruía recorriendoflow_steps. En Orfeo esradi_depe_actu/radi_usua_actu: dos columnas del propio radicado que cada reparto reescribe. Qué cambia: document-service migración037_radicado_ubicacion.sql(columnasflujo_dept_code/flujo_dept/flujo_usuario+ dos columnas generadasubicacion_dept_code/ubicacion_dept, calculadas por pares, con índice); workflow-servicesincronizar_estado_radicadolas escribe en el mismoUPDATEquestatus(también la cascada), con la función pura nuevaderivar_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ón016_radicado_ubicacion_desde_flujo.sql(backfill, evidencia de solo inserciónradicado_ubicacion_recalculo, anclaworkflow.ubicacion_radicado.backfillen el runner,RAISEsi document-service aún no migró).GET /documentsyGET /searchadmiten?ubicacion_dept_code=(misma condición en filas yCOUNT); las respuestas llevanflujo_*yubicacion_*;flujo_usuariosolo en el detalle. Reportes:by_ubicacion(sin cerrados ni anulados) y la sección CSVdependenciapasa adependencia_radicacion(+ubicacion_actual). Export INT-01 con la ubicación como dato informativo que el import ignora. MCP:ubicacion_dept_codeenlistar_radicadosybuscar_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_deptintacto), rollback, rollback + completar, finalizar, anulación que congela, cascada, nombre duplicado → códigoNULL, atomicidad ante fallo de la auditoría y backfill fila a fila contra la función Python (2.ª corrida: 0 cambios; el trigger rechazaUPDATE/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 deflujo_*, ordenar el «último» porstep_number, sacar el filtro delCOUNT, añadir la ubicación aPublicVerifyResponse. Aplicado a la pila viva:2026-DEMO-E-000117queda en Control Interno (código 907). Frontend:/bandeja(lista y detalle),/busqueda(columna y filtroubicacion_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», sobreby_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». Sinubicacion_deptla celda dice «—»: no se cae adest_dept, que sería inventar la ubicación. Los proxies de búsqueda admitenubicacion_dept_codesolo como entero positivo. Helper nuevolib/utils/ubicacionRadicado.js; tipos enlib/types/api.js. 2237 pruebas del frontend (19 nuevas),svelte-check0/0; mutadas por separado y en rojo con su prueba: la celda de la lista y la de búsqueda volviendo adest_dept, el bloque «Cerrado en», el bloque «Dependencia de radicación», el filtro retirado de cada proxy y delload, el panel de reportes y la tarjeta del panel leyendoby_dependencia, el fallback adest_depten 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/bandejay de/busquedaa ≤768 px, donde la columna se oculta; el filtro de/busquedapasa 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(exigePERM_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 traeby_ubicacionen vez de «Sin radicados en trámite». Pruebas del frontend con una mutación por punto importante en rojo. Impacto: aplicar037(document-service) y después016(workflow-service) por tenant; reconstruir document-service, workflow-service y mcp-server. El CSV de reportes ya no tiene la seccióndependencia. 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_actualizadosolo decía QUE campos cambiaron yrbac.grupo_eliminadoiba con payload vacio; un grupo creado «Archivo», renombrado «Ventanilla» y borrado no se podia reconstruir desdeaudit_log. Como:update_groupydelete_groupleen la fila conFOR UPDATEdentro de la misma transaccion (RBACRepository.get_group_for_update) y asientan{campos, antes, despues}y{name, description}respectivamente. Sin migracion. Verificacion: auth-service 322 conRUN_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=3en escrituras de webhooks y plantillas, asiento al crear/borrar un webhook y permiso propio de lectura de auditoria (preexistente, declarado en el censoSIN_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.mdy 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 —elCHANGELOGanotó 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 excluyeradicar_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 desdeservices/mcp-server/app/tools/catalog.pyy va agrupada por épica con el permiso y el nivel CRUD que el propio catálogo declara —los mismos queshared/tests/test_permisos_declarados.pycruza 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 enmi_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-servicetiene carpeta, Dockerfile, puerto 8012 y servicio endocker-compose.yml, y no aparecía enarchitecture.md, ni enapi.md, ni en elCLAUDE.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 —perfilassistantapagado por defecto y sinstubde transcripción, sin ruta por el gateway ni puerto publicado, y el único servicio sin base de datos ni schema por tenant—, y eldocker compose --profile assistanten 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), yGET /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 /documentsordenaba 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:statuspasa a ser repetible (status = ANY($n::text[])), aparecendue_from/due_tocon los dos extremos inclusivos yorder_by/order_dirsobre un catálogo cerrado de dos columnas —unORDER BYno admite parámetro enlazado, así que un nombre de columna que llegue por la URL es inyección con otro nombre—.due_dateordena conNULLS LASTen ambos sentidos y todos los órdenes desempatan porid: 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 solodoc_type/statusel 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; ylistar_radicadosdel 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 elANY, el extremo inclusivo, elNULLS LASTdescendente y el desempate. Y una mutación que no cayó destapó un comentario mío falso: escribí que Postgres pone losNULLal final solo en descendente, y es al revés —NULLS LASTes el default deASC—, 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 esPOSTpor 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 —contestaoperador_conectado: falseen 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 censafrontend/src/routes/**: el catálogo de herramientas del MCP era una sexta puerta que nunca miró. La herramientaradicar_documentoy el procedimiento de ventanilla ciudadana componían el radicado consender_nameen texto libre y sinactores, 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 ahoraactores, 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_RADIpara ver el radicado másPERM_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 el409esperando 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/requestsestaba escrito y probado, y ninguna vista lo llamaba./borradorestiene su propio circuito —visto bueno y firma sobre un texto que aún se edita—, pero los otros dos tipos que el backend admite,expedienteyradicado, no tenían por dónde abrirse una cadena:/firmasera 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 conoperador, 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: ante2fa_requeridoel 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.jsfallaba 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/alcancedevuelve 429 con"tenant": null, "usuario": nully 1,6 ms de duración. Esosnullson 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 aDataTablese 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 queDataTableno 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 —DataTableparte 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 —envoltoriooverflow-x: autoy región alcanzable con teclado (técnica WCAG SCR29)— y el objetivo táctil es una regla global bajopointer: coarse, no una propiedad deDataTable. 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 exigeUSUA_PERM_TRD(archive-service/app/routers/trd.py:363) y PINAR entero llevaUSUA_PERM_ADMINpor admisión en suAPIRouter(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,nodeykeycloakse descargaban con normalidad en el mismo momento, así que no era la red ni un límite de tasa—: quay.io respondedeniedy lista cero tags, Docker Hub da 404, el binario dedl.min.ioda 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_objectheredando 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 aceptabaPutObjectRetentionen 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.shyinfra/restore.shinvocaban la imagenminio/mc, también borrada — el respaldo y la restauración estaban rotos, incluidomc retention set, que es lo que repone la retención WORM al restaurar. No se veía porque el arranque falla antes. Migrados aaws-cli, con un helper único en vez de seisdocker runrepetidos. Y una mejora que vino con el cambio:mc retention setsolo 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-retentionadmiteRetainUntilDate, 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 conAccessDeniedcitando la fecha. Lo que NO cambia, declarado: los nombresMINIO_*, el serviciominiodel compose y los bucketsorpycamcp-{slug}-documents. Son el contrato interno del cliente S3 —miniopy-asynchabla S3 estándar— y viven en 13 ficheros; renombrarlos multiplicaría el riesgo sin ganar nada. LéaseMINIO_*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, yminio-datase 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 endistributed/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 enregistered, 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 deGET /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 incluyenregistered, porque un plazo de respuesta corre desde que se radica. El enlace «Ver toda la bandeja» apuntaba a?status=distributedy abría la lista vacía. Los tests consagraban el defecto: su doble construía radicados endistributed, 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.scsstraí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 declaratransform, así que heredaba el de la hoja global. El fondo se oscurecía y el panel se pintaba enx=1901con 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 exigetoBeInViewport({ 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: ahora503 assistant_unavailabledice 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 ylint.sh mcp-serverlimpios; 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 ofreceDevolversolo a radicados endistributed/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_retentionaparecía en tres ficheros yget_object_retentionen ninguno. Tras escribir la retención, el servicio insertabaretention_mode='COMPLIANCE'enpreservacion_worm_objetoy un evento PREMISFIJACIONconresultado='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_aplicadarelee 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-fisicoy/borradores— desplazaban el documento a lo ancho en móvil hacia una franja vacía. El culpable era unspan.sr-only: texto para lector de pantalla, 1 px,position: absolute, dentro de las cabeceras de las tablas anchas. Unabsolutelo recorta eloverflowde 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: relativeen 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 reportaright = 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 devuelvescrollWidthal viewport— y un repro mínimo enfile://sin backend ni sesión, que además demostró por qué había que mirar fuera delmain:app-layout__contentdeclaraoverflow-y: autoy CSS no admite un ejeautocon el otrovisible(computa el segundo aauto), 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 cadenaposition: relativey 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.getBuiltinModulellegó en Node 22.3) y los cuatro casos dehandleErrorrevientan por elcryptoglobal (estable desde Node 19). Y ya había costado un diagnóstico antes: aquella vez se corrigió subiendoDockerfile.devde 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.jscomprueba 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 enenginesdepackage.jsonporqueenginessinengine-strictsolo advierte al instalar, y lo que hay que parar es la ejecución — y este archivo lo cargan por igualvitest, el servidor de desarrollo y el build. Verificado en las dos direcciones: en el host (Node 18) para con el mensaje; ennode:22-alpineno 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 jobtest-frontendya sonnode: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.jsdejó escrito en el propio helper que estaba «copiado en tres archivos». Eran cuatro. La deasistente-dock.spec.jsse 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 heredarstorageState; 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 suon:clickno existe hasta que Svelte toma el control—, y esa se conserva en su spec, que es de donde es.bandeja-operador.spec.jsdeja 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 unpage.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 proyectosetupfalla 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-actionsocupaba 385 px empezando en x=164 sobre un viewport de 390, o sea terminaba en 549. La causa es elmin-width: autoque 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: 0en 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 proyectosetupde Playwright inicia sesión y guardastorageState, 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.jsqueda 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 sobreh1—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-2xlconserva 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 suclamp()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 elh1renderizado 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.jsmide 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.jshace 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: comparabascrollWidthcon el ancho del contenedor, buscando un desbordamiento que no ocurre. Una tabla no desborda: comprime, estrecha sus celdas hasta partir el contenido yscrollWidthacaba valiendo lo mismo que el contenedor — el síntoma se borra a sí mismo. Ahora se mide el ancho natural (width: max-contentun 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-scrollcon el trío de accesibilidad completo (tabindex="0"para alcanzarlas sin ratón,role="region"para que sean parada de navegación yaria-labelpara 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 arepeat(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 conminmaxy colapsa en un punto que sí está justificado —dos columnas dejan de tener sentido cuando el detalle no alcanza el ancho del maestro—, porqueauto-fitno 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 gridsauto 1fr/140px 1frson listas de etiqueta y valor y se adaptan solos;perfily la landing ya eran mobile-first (min-width); loswhite-space: nowrapde 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 untabindex. Y el guardián encontró tres tablas que mi propio censo no vio por mirar soloroutes/y nolib/components/; de paso señaló dos falsos positivos suyos —un<table>mencionado dentro de un comentario y el envoltorio propio deDataTable, 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/parametrosse 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-maxpara 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, porquemax-widthnunca 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/eliminacionpinta 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 losmax-widthinteriores, 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 deDataTable, 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 decode, 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 enutilities.scssbajopointer: coarse, conmin-height/min-widthy noheight—es un suelo, no una talla única—, y coninput[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 cambiarmin-heightporheight. 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 /prestamosdevolvía la tabla entera sinLIMIT(E17, RF-ARF-07) Por qué: sinpage/size, sinX-Total-County 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—, perodevueltoes el histórico: crece con cada préstamo que se cierra y no deja de crecer. Y sin la cabecera, el único recuento posible eraitems.length, que es el tamaño de la ventana haciéndose pasar por el de la consulta. Cómo: paginación con elCOUNTcompartiendo el mismoWHEREque las filas, y desempate poriden el orden —fecha_prestamoesTIMESTAMPTZ DEFAULT now()ynow()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 uneexpedientes, 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 elCOUNTsinWHERE, 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 contraid DESC, igual que en/documents. -
fix(frontend,auth): ocho pantallas pedíansize=200creyendo 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», yGET /trd//tvddeclaransize: int = Query(..., le=200). A partir de la fila 201 la vista se quedaba corta sin que nada lo delatara, con elX-Total-Countque el servicio ya emite delante y sin que ninguno de los ocho lo mirara. El daño no es «faltan filas»:/admin/trdy/admin/tvdarman jerarquía porparent_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:traerListaCompletapide 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, unrole="status"en/admin/trd,/admin/tvd,/admin/metadatosy/admin/flujos/reasignaciondiciendo 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/pickableno pagina —recorta con unLIMITy ya está—, así que no había nada que mirar; ahora emiteX-Total-Countcon 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/trdy/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 enauth-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.pytenía cero referencias aaudit, frente a 49 en su hermanoworkflow_service.pydel 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_ADMINpor 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_creadalleva 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_modificadalleva solo los campos que la petición tocó, cada uno con suantesy sudespues, leyendo el estado previo conFOR UPDATEdentro 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. Yworkflow.regla_eliminadalleva el retrato completo, porque elDELETEes 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_refes el UUID y no el nombre — renombrar una regla no puede partir su historial en dos objetos aparentemente distintos—.PATCHyDELETEpasan a exigirX-User-Id, quePOSTya exigía. Verificación: caen por mutación los tres asientos, el detalle delantes/despuesy el retrato del borrado; y hay dos controles positivos por el lado contrario —un404y unPATCHvací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 deaudit.appenden 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.shpasa un guion largo al contenedor dentro desh -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íash: 0: -c requires an argument—un mensaje que parece del contenedor y no lo es— y el primero anulaba elset -euo pipefaildel propio guion. El gate llegó a imprimir «✓ document-service OK» y salir con 127. Cómo: los dos acentos escapados, y un guardián enshared/tests/que mira solo dentro de la cadena que viaja ash -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:/enviosera el único sitio que los consultaba, y solo para precargar el destinatario postal. El detalle del radicado seguía describiendo al actor desdesender_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/bandejalee 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 desender_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 reenviabaparams=dict(request.query_params). La cadena de consulta es un multidict ydict()se queda con el último valor de cada clave, así que?status=distributed&status=in_progresssalí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 porquelistes 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 importabaall.min.cssde Font Awesome, que arrastra las familiassolid,regularybrandscon sus tres fuentes. Medido y no supuesto: 771 usos defas/fa-soliden el árbol y cero defar/fab— así quefa-brands-400.woff2(116 KB) yfa-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 ysolid. Y con ello un guardián, porque el modo de fallo es mudo: unfab fa-githubescrito mañana no da error ni aviso — el glifo no existe en la fuente cargada y se pinta una caja vacía, igual que unvar(--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 con0 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-scrimpara lo que bloquea (elModaly los cajones, que atrapan el foco) y--op-scrim-softpara 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 —norgba()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 unrole="list"cuyos hijos sonCard, 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ásrole="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 enStepperestaba 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)Buttonconhrefrenderiza un<a>y le poníarole="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 comoisDisabled = disabled || loading, dentro de ellaaria-disabledera siemprefalse,aria-busysiemprefalsey 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, cualquierModal—: 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 declarabaaria-controls="metrics-panel"sobre un nodo que solo existía desplegado: el lector anunciaba un control que gobierna algo que no puede encontrar. (d)EmptyStatellevaba una tercera definición de.op-btn, copiada «por siButtonno 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 conhidden, que lo saca del árbol de accesibilidad igual de bien; yEmptyStateusaButton. Impacto: cuatro correcciones transversales. Dos tests afirmaban el defecto como si fuera el contrato —uno decía literalmente «Buttoncon href renderiza<a role="button">, norole="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 untick()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 declaradependencies, 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 esanulado(UPDATE radicados SET status='anulado'), nocancelled, 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 con409 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ásanuladono 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;anuladoentra en el mapa de etiquetas y de chips; y un censo lee del fuente del servicio los estados que de verdad se escriben enradicados.statusy 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 JOINen 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: unNULLsignifica que el objeto no es un radicado —un expediente o un borrador lo son legítimamente—, no que esté bien. Impacto:409 radicado_anuladoen los tres caminos. Verificado contra Postgres real, no solo con mocks: elJOINtrae 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é:documentofiguraba entre losobjeto_tipoadmitidos 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ía409 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: supayload_sha256lo 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:documentosale del allow-set; el criterio de admisión ya estaba escrito en ese mismo fichero pararadicadoyexpediente(«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; el422lo dice en la puerta. Efecto secundario que conviene leer: retiradodocumento, 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ía409 documento_modificadocon 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ía403 no_es_firmanteen/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 el409antes 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 recibierano_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/miniono publica ya ningún tag en Docker Hub —el repositorio quedó vacío—, y undocker compose upfalla 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 alatest— 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 elpull: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 columnasformatoydependencia_productoravací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=enviadaes 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.devusaba node:18 yDockerfile(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 porstr(<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— como50b64803-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 llamadoactor— y aquí se llamanelaborado_por,entregado_por,recibido_poryfirmante_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 conIdentidadFirmadaMiddlewarese reconstruyen y despliegan a la vez; si conviven dos versiones, toda petición autenticada da401.-
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 contyp/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_ADMINa 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ásPOST /groups/{g}/members/{self}subía el clearance propio sin pasar por el no-write-up que sí tienePUT /groups/{g}/clearance(el clearance efectivo esMAX(role_clearance)sobre las membresías). El censo deshared/tests/test_escritura_exige_nivel.pyera ciego a los gates deAPIRouter(dependencies=[...]), por eso nadie lo veía. Cómo: (1) toda escritura derbac.py,urd.py,PUT /groups/{g}/clearancey las bajas administrativas de 2FA y de clave de firma de OTRA persona exigenUSUA_PERM_ADMINmin_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, enSIN_NIVEL_DECLARADAS. (2)add_membership_guarded: el no-write-up va en elWHEREdelINSERT(ROOT exento; grupo sinrole_clearancecuenta como 1) y un rechazo es403 no_write_upsin asiento. (3)PATCH/DELETE /groups/{id}(409si tiene miembros o URD, comprobado en la misma sentencia que borra). (4)validate_tokenexigetyp=Bearer,azp∈ALLOWED_AZP(por defectoorpycamcp-frontend,api-gateway; vacío = nada pasa) yexp/iat/iss/subobligatorios. (5)/validateemiteexpires_iny el gateway acota con él el TTL de su caché; sinexpires_inno cachea (el tope porexpera 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 exigirvalid is True. (7)/audit/verifydevuelve solookytotal_roturasa quien no tiene el clearance máximo (total−X-Total-Countera el número exacto de asientos clasificados ocultos). (8)totp.desenrolado,totp.desenrolado_con_recuperacionytotp.restablecido_por_adminse asientan dentro de la transacción de la baja y sinexcept(si el asiento falla, el factor no se retira). Verificación: auth-service 309 conRUN_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/tests174,lint.shlimpio. Mutaciones (cada una tumba pruebas, restauradas y reconstruidas): quitarmin_crud=3delPUTde permisos (también lo tumba el censo), quitar la condición de nivel delINSERT, sustituir el asiento estricto por el best-effort, quitar el chequeo detyp, deazp,HS256enalgorithms, vaciarALLOWED_AZP, devolvertotala todos, quitarexpires_in, sacar el asiento degrant_rootde la transacción, quitar su idempotencia, aceptar cualquier MAC de la caché, MAC sin la clave, quitar la recomprobación devalid, ensanchar la allowlist del proxy con cabeceras de identidad y TTL = máximo. Una mutación del inventario resultó equivalente: invertir el orden deheaders.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 conUSUA_PERM_ADMINa nivel 1 o 2 recibirá403en las escrituras: súbalo a 3 antes de desplegar (los grupos del demo ya lo tienen a 3). Los tokens que no sean deorpycamcp-frontendoapi-gateway(p. ej. un cliente de servicio nuevo) dan 401 hasta añadirlos aALLOWED_AZP. Nueva variable obligatoria en producción:AUTH_CACHE_MAC_SECRETen 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 deUSUA_PERM_ADMIN(exige migración con backfill y decisión de nombre); identificador opaco en el listado de auditoría (los huecos deidsiguen siendo el residuo declarado RF-FIR-15); subirmin_crudde 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-xmlestampaba el sello de la entidad sobre un XML arbitrario a cualquier humano conPERM_FIRMAnivel 1 (saltándose los gates de archive: expediente cerrado, permiso, versión del índice), y el gateway enrutaba/preservacion/entero: un usuario conUSUA_PERM_EXPEDIENTE:3ocupaba el singleton WORM irreversible (COMPLIANCE, hasta 100 años) de cualquier expediente con unfile_idarbitrario y un plazo corto (la idempotencia por(expediente, tipo)lo congelaba y archive recibía un 200 «ya protegido»), o fijaba unlegal_holdsin acto para levantarlo, bloqueando la disposición final para siempre. Cómo: (1)/sign-xmlexige ademásX-Internal-Tokencuandoobjeto_tipoes del allow-set XAdES (indice,acta_transferencia,acta_eliminacion) yobjeto_tipopasa a conjunto cerrado (422fuera de él); archive-service lo envía ensign_xml/sign_xml_outcomejunto a la identidad firmada (si no, el sellado al cerrar habría caído apendiente_firmaen silencio y la reconciliación lo repetiría sin fin). (2) storage sella/aip,/proteger-indicey/proteger-artefactoconX-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 devuelven404 route_not_found. (4) Defensa en profundidad: segunda protección del mismo(expediente, tipo)con otrofile_id→409 worm_singleton_conflict, sin subir. (5)legal_holddeja de ser campo del body de toda ruta de protección (422) y se declara/levanta solo por el acto internoPOST /preservacion/legal-hold-internal(motivo+acta_refobligatorios;enable/disable_object_legal_holdsobre laversion_idexacta; fila + asientoworm.legal_hold_fijado|levantadoen 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, guardianesshared/tests171 pasan; lint y mypy limpios en los cuatro. Pruebas nuevastest_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, devolverobjeto_tipoa texto libre, quitar la cabecera en los clientes de archive, restaurarrequire_permission, quitar la comparación defile_id, re-admitirlegal_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-xmlya 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_encontradoneutro;borradorno se resuelve en este servicio y por tanto no se firma por esta ruta cruda) y el gate sube aPERM_FIRMAmin_crud=2(se retira la excepción congelada detest_escritura_exige_nivel.py: 86 → 85). Cambio de comportamiento declarado: un usuario conPERM_FIRMAen nivel 1 que cierra un expediente ya no sella el índice con su identidad; quedapendiente_firmay lo sella la reconciliación a nombre de SISTEMA. (b) Ellegal holdya tiene acto de usuario:POST /api/v1/expedientes/{id}/legal-holden archive-service (USUA_PERM_ADMINcrud≥3,motivo≥10 yacta_refobligatorios en ambos sentidos, expediente visible por clearance o404neutro, asiento propioarchive.expediente_legal_hold_fijado|levantado) que orquestalegal-hold-internalcon token + identidad firmada. (c) Storage:fijar_legal_holdtoma 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 registracopias_alcanzadasen el asiento; y levantar asienta primeroworm.legal_hold_levantamiento_solicitado(tx propia) y solo después toca el almacén, conworm.legal_hold_levantamiento_fallidosi 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/tests171; lint y mypy limpios en signature, storage y archive. Mutaciones (cada una tumba pruebas): quitarmin_crud=2, quitar la comprobación de visibilidad del objeto, quitar elFILTERde 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 porUSUA_PERM_EXPEDIENTEy 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 delegal holdreutilizaUSUA_PERM_ADMINen 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-servicequeda 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 endevolver_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 aorpycamcp_commony 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 dehooks.server.jsse dispara con cualquier petición al servidor de SvelteKit, también con las que la aplicación se hace a sí misma./admin/interoperabilidadsondea 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ó: elREADMEde 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 —vigilarInactividadsobre 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. LosjobIdse 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.pyderiva del árbol las pantallas que combinansetIntervalconfetchal 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/healthdel 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 unConnectErrorque 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 existetenant_demo, yseed_demo.pyda por hecho que sí —su propio mensaje de error lo dice—. Sininit-tenant.shla siembra moría con un500al 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_logpasó 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, incluidooperadorsin ninguna fila de credencial yarchivistacon la suya revocada. -
fix(scripts): unca-direquivocado fabricaba una raíz de confianza paralela en silencio (E17/F4, credenciales de firma personal) Por qué:gen-user-cert.shgenera 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ódockercomo ca-dir yruncomo directorio de salida. Resultado: una CA nueva endocker/, el certificado enrun/, 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 directoriodocker/de más engit 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 elca-dires 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 dejefevuelve a encadenar con la raíz que la emitió; la dearchivistaquedó 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 deaudit_logse paraba en la primera rotura (ADR-008, Ley 594/2000 arts. 22-24) Por qué:verify_chaindevolví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_chainrecorre la cadena entera y devuelve unResultadoCadena(total,roturascon 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_idse 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 elreturnen 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 porchk_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)tienemin_crud=1por defecto, ydocs/es/api.mddeclara que el nivel 1 significa leer. Cuatro endpoints de escritura de expedientes omitían el parámetro, así que quedaban abiertos a quien teníaUSUA_PERM_EXPEDIENTEen 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}/radicadosy.../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 un403al confirmar. Lo que NO era defecto, comprobado antes de reportarlo: la guarda de estado sí existe y es por campo — cambiartrd_serie_idsobre un expedienteclosed/transferreddevuelve409 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ón018, no un hueco. Guardián:shared/tests/test_escritura_exige_nivel.pyderiva el censo de los 11 servicios y congela las 86 escrituras que hoy siguen a nivel 1 como excepciones declaradas, no aprobadas — mismo criterio que018_permisos_inertes_declarados.sqlusó 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.mddecía que una escritura ofrecida a nivel 1 «el backend la niega con403». 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 usuarioconsulta—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 certactivo, ESTE es el único camino de firma para este acto (bypasea_gate_2fapor completo) — nunca ambos», y ese camino exigetotp_code.SignAssistant.svelte, el único componente que firma cadenas, manda siemprechallenge_id+otppor correo y nuncatotp_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ía409 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 pedirtotp_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 dejefeconintentos = 0: el OTP nunca llegó siquiera a comprobarse. Qué cambia: la rama personal pasa a409 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 contotp_code. Se respeta la regla del backend, no se la esquiva: quien tiene certificado firma con su certificado. De paso, el asistente deja de tratartotp_incorrecto,totp_bloqueadoytotp_no_activocomo errores genéricos. Verificado: por mutación con el defecto real (devolverneed_2faantetotp_requeridopone 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 comojefe, el único usuario del demo con credencial activa. Ninguna suite lo veía porque cada regla, por separado, está probada. -
docs(signature):FirmaResponse.payload_sha256significa 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. Conproviderxades_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 conhuella_de_contenido: falseen ambos, publicaron3727b474…(nativa, digest deexpediente:<id>) yc4321d48…(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 mismoprovider. -
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_firmadopublicaba bajo la clavehash_documentoelpayload_sha256que devuelve el firmante, que es el SHA-256 de los bytes que se firmaron. Para unborradoresos 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 (suArgsya 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 confirma.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 encuentrafirma.challenge.emitidocon49fff365…yfirma.turno_firmadoconcd51626b…: 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 apayload_firmado_sha256, el docstring del firmante deja de prometer una correspondencia que no existe, yFirmaResponse.payload_sha256documenta que no sirve para comprobar contra un PDF en la mano. Los asientos anteriores conservan la clave vieja: no se reescribeaudit_log, que es append-only por ley. De paso: el registro del factor consumido por TOTP leíasol["payload_sha256"]directamente y salía vacío pararadicado/expediente; ahora usa el mismo resolutor compartido que el desafío. Guardián:test_asiento_nombra_lo_que_lleva.pyprohíbe que una clave llamadahash_documentose 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 deaudit_logdespué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_OBLIGATORIOdejaradicadoyexpedientefuera 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 sinpayload_sha256. Yemitir_desafioexigía esa huella para todos los tipos, con un422incondicional. 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)— devuelvepayload_sha256donde existe, y donde no, el SHA-256 de"{objeto_tipo}:{objeto_id}", que son exactamente los bytes quefirmarsella 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 el422: codificaba el defecto, igual que el202del envío de correo. Reescrito para asertar sobre elhash_documentopersistido — comprobar solo el201pasarí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íaapplication/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.emldel correo conservado, y cuando la producción documental empezó a generar PDF nadie volvió a mirarla. La garantía que sí funcionó: el orden deradicares 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ó enaprobadosintracking_number. Una garantía escrita para otra cosa —un fallo de render no consume un radicado— cubrió también ésta. Qué cambia:application/pdfentra 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.pydeclara 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-000066radicado con su PDF/A anexado comodocumento_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íaapplication/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.emldel correo conservado, y cuando la producción documental empezó a generar PDF nadie volvió a mirarla. La garantía que sí funcionó: el orden deradicares 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ó enaprobadosintracking_number. Una garantía escrita para otra cosa —un fallo de render no consume un radicado— cubrió también ésta. Qué cambia:application/pdfentra 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.pydeclara 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-000066radicado con su PDF/A anexado comodocumento_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_firmaesNOT NULLdesde 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 pasabaNULLexplícito. Un NULL explícito ignora el DEFAULT: el default solo actúa cuando la columna se omite. Resultado:NotNullViolationErrory un500crudo 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 conAsyncMock, y un mock acepta cualquier fila. En la base de demostración no había una sola firmanativa— ese camino no había llegado nunca a Postgres. Y el docstring del propio repositorio afirmaba lo contrario de lo que hacía: «tipo_firmase 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. Ysignature-serviceestrena tier de integración contra Postgres real, portado del dedocument-service(gateRUN_INTEGRATION=1, schemastenant_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 quesello_tiempoes igual deNOT NULL. Y un segundo defecto debajo: dos migraciones definían la misma columna y solo una llegó. La008quiso redefinirtipo_firmaaTEXT DEFAULT 'nativa'y suIF NOT EXISTSla convirtió en no-op; lo único suyo que sí se aplicó fue elCOMMENT—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ón019lo alinea. SinCHECK: 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_notificationagota sus tres reintentos SMTP antes de volver, y guarda el resultado comofailedcon sulast_error— y el router respondía202 Acceptedpasara 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/sendes el OTP de segundo factor de signature-service, que tiene escrito su fail-closed503 canal_2fa_no_disponible. Con el202incondicional 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_fallidocuando el SMTP no entregó;send_otp_emailya lo traduce al503que estaba escrito. La notificación se persiste igual — el historial del intento fallido es justamente lo que permitió encontrarlo (status=failed, attempts=3mientras la pantalla decía lo contrario). El test que había afirmaba202con 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,ides el de la solicitud de anulación, no el del radicado (abrirDesdeColaya lo documentaba). El enlace cuelga deselectedRow, 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 tipoborrador. 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 exigePERM_RADI. Un revisor que solo tengaPERM_FIRMArecibe un403y 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/verifyexisten, 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_buenorechazaba 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:firmarcomprobaba que el turno fuera tuyo y nunca mirabaturno["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_buenoenfirmar, 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 elaria-labeldiciendo lo mismo que el botón. De paso:SignaturePendingItemno declarabarolaunque el backend lo manda desde que existePendienteItem— el contrato del cliente se había quedado atrás, y sin él la pantalla no podía distinguir los dos actos sin quesvelte-checkprotestara. 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 dejefe. -
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.aprobarno 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 gatedocumento_modificadoque 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 conX-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 enaprobar, 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. Entenant_demohabía dos borradores aprobados con su circuito abierto, a un clic de radicarse; ahora los dos responden409. 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,503y 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 la030recalcula 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}/devolverreabre 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: Elrolse perdía al construir la respuesta. La consulta lo trae, el esquema lo declara, y_build_responseno 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/firmasdecide 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.PendienteItemno llevabarol, 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, hash49fff365…→a10472b7…) y el visto bueno responde409 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]/circuitoexistí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./firmasera 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/borradorescon 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: exigirUSUA_PERM_ADMINpara 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_OBLIGATORIOreclamabapayload_sha256paraborradormientrasCADENA_PAYLOAD_AUTORITATIVOhace 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 un422. 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ó aget_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 cadabase_urlde 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 enshared/; comentario de la migración003corregido (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, yX-Faltantesllegaba 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 deprevisualizarya 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: columnaborrador.faltantes(migración de tenant032), el aviso al reabrir el borrador, yX-Faltantesque 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 concuerpo 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ónmarcadoresDeCombinacion, 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 —sumaxAgecorre contra el reloj del navegador y elexpcontra el de Keycloak—, así que con la sesión muerta la guarda la daba por buena,localsse 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. NuevotokenVigente(token)con margen, aplicado enhooks.server.jsy en la guarda del layout privado. 3. El vocabulario existía y la pantalla no lo pedía.GET /plantillas/campospublica 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 desdedest_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.funcionarioera 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 deX-Username, que inyecta el gateway desde el token ya validado. Y un sexto, del mismo corte: las siete clavesentidad_*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 tenant009_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 enshared/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_userssolo guardausernameyemail. 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ón009de 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_consultadoal abrir la ficha ydocument.anexo_descargadoal bajar un fichero, las dos con actor, canal e instante— y no había forma de pedirla. Solo desde/admin/auditoria, que exigeUSUA_PERM_ADMINy 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 tocaraudit_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 mismo404 document_not_foundque 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 llevannivel_seguridady elfile_iddel 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 dedocument_service.pydecí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.pyderiva del árbol las pantallas que componen un radicado consender_namey exige que manden tambiénactores. No prohíbesender_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/radicarla menciona en sus comentarios y al importarActorPicker— así que quitarle el envío real dejaba el guardián en verde. Lo destapó su propia verificación por mutación. Ahora exigeactorescomo clave de objeto, con el mismo criterio que usa para detectarsender_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: sembrabaAna Lucía Bermúdez 2y… 3, que son nombres distintos y no los agrupa nadie — se comprobó, y/terceros/duplicadosdevolví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í queradicado_actorquedaba 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 ensender_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íaNonetanto 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 simulabanfetchrow/fetchvalcon 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 mirabaawait_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_actorestaba 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 /documentsaceptaactoresy los congela dentro de la misma transacción que crea el radicado y su asiento; untercero_idinexistente revierte la radicación entera, en vez de dejar un radicado sin remitente con el número ya gastado.DestinatariosPickerse generaliza aActorPickery sirve al remitente de una Entrada (tope 1) y a los destinatarios de una Salida (varios). Qué cambia — administrar: nueva pantalla/admin/terceros(/admintení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 repuntabaradicado_actor.tercero_iddel absorbido al superviviente, y el trigger de la migración 026 lo rechazó — incluyetercero_identre 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-000106es la primera Entrada cuyo remitente es una entidad con su contacto congelado,2026-DEMO-S-000053conserva los tres destinatarios, yJosé Pérez Gómez/JOSE PEREZ GOMEZse 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ó porquejefeera, por accidente de trasteo previo, el único con certificado activo. Tras undown -vlos cinco usuarios habrían quedado idénticos y esa rama habría vuelto a ser invisible. Qué añade:scripts/reset-entorno.shencadena 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.pyreparte segundo factor y credencial de firma según un diseño explícito:operadornativa,jefePKI activa,secretariasin TOTP y clearance 2,archivistacon la credencial revocada,consultael control negativo. Cada fila existe porque ejercita un camino que ninguna otra toca. Recrear, no restaurar:audit_loges append-only por ley y ni el--purgelo 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.shdeclara 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_gruposolo añadía miembros, así que mover asecretariaal grupo de nivel 2 la dejaba también en el de nivel 3 —y el clearance es elMAX—, 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. Ypdf_minimoproducía una línea de texto: un anexo así no ejercita paginación, ni descarga, ni límites de tamaño — ahorapdf_documentogenera 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 esquemastenant_itest_*huérfanos y el tenantitest-que quedaba registrado. Las 44.408 filas deaudit_logno 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.mdya 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 detenant_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 unwgetque 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.mdrefleja 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.docxcon 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 consgd_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, yPOST /plantillas/importarpara.docx/.odt. Decisiones de fondo:es_globalpor 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=trueexigeUSUA_PERM_ADMIN, comprobado en el handler y no subiendo el gate del endpoint: la lista normal la necesita cualquiera conPERM_RADIpara redactar. Un recorte que el propio llamante puede apagar con un parámetro no es un recorte. La columna se llamaes_globaly noglobal—como el legado y como decía el plan— porqueglobales 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_usuarioreventaba en un tenant a medio provisionar. Leeurd_usuaroldep, que crea auth-service y no este servicio; sin capturarUndefinedTableErrorel 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, nuncaNone, 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.docxes 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.odtse escapa: un<en el documento es un carácter, no una etiqueta.odfpyparsea condefusedxml, que cierra XXE. Las dos defensas verificadas por mutación. Impacto: migración de tenant031validada dos veces contra Postgres real;mammothyodfpy(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/requestsexistía en el gateway y solo lo invocaban los tests:/firmasera 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 porfalseno rompía nada; al acotarlo, elindexOfdesde cero caía en el comentario de cabecera y devolvía un rango vacío, y unslicevací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 eventodocumento.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 devisto_buenode 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, peropayload_sha256era opcional y la propia migración lo documentaba: «si no se provee, la firma se produce sobreobjeto_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|firmapara que los dos actos vivan en una sola cadena ordenada;POST /signature/requests/{id}/vobo;payload_sha256obligatorio paraborrador/documento; el estadoinvalidada; yborrador.version/cuerpo_sha256con 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 unUPDATEa 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 unborrador, 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 derol='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 defirmar()/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 coninvalidadano habría admitido nada), y lleva una guarda que aborta si alguien cambia el predicado del índice de unicidad — sinWHERE 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 denivel_conformidad, es de cualquier columna con dominio cerrado. Pendiente declarado: el eventodocumento.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, peroradicarseguí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 enborrador.combinacion, y la huella del documento enborrador.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 tragadoX-Faltantesen 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 enFORWARDED_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::jsonbdeset_documentodiciendo que sin él la columna recibiría texto. Lo comprobé quitándolo contra Postgres real y el test siguió en verde: en unUPDATE ... SET columna=$nPostgres 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 tenant029(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 oncerequirements.txt.BorradorService.radicarescribí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),WeasyPrintRendererHTML→PDF/A, yPOST /api/v1/render/documento-internalsellado conX-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,503explí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 enproduction, 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á. Quitarpdf_variantdel render deja en rojo tanto el test estructural como el de veraPDF. Una trampa que me costó dos tests: buscarOutputIntentyFontFileen 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_PROFILEenstorage-service;weasyprintypypdfenrequirements+ lock regenerado;docs/es/api.md. Aditivo: sinRENDER_ENGINEel 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.crearhacíacuerpo = 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_desconocidocon 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 conPOST /plantillas/{id}/combinary 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 elWHEREdejaría el test en verde. Impacto: migración de tenant028(borrador.radicado_padre_id);docs/es/api.md; DS endocumentos/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 —elclient_secretdel 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í quesincronizar_buzonyradicar_correono declaran el parámetropasswordaunque la ruta lo admita; un buzón personal respondepassword_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 conPERM_RADIa propósito, peroGET /email/cuentas—la única forma de saber qué buzones existen y con qué identificador— exigíaUSUA_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 elWHEREdel repositorio, que reparte la personal a su dueño y la institucional a los asignados — y que ya recibíaes_admincomo 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 enviabadest_dept—un nombre suelto— en vez dedest_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./radicarsiempre 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 pidenCONFIRMOen 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 --strictlimpio. Verificado contra la pila: 201 herramientas visibles, las 16 de F10/F13 presentes, yGET /email/cuentasresponde 200 a un usuario sinUSUA_PERM_ADMIN(con la lista recortada por elWHERE, 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/,/catalogoso/dependenciasentra 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 ofrecedevolverpara 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_aipyeventos_preservacioniban contra las rutas de storage, que exigenX-Internal-Tokeny solo alcanza el propio servicio — habrían dado401a 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 --strictlimpio. 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 deejecutaren F7. En F7 el motivo era la garantía humana. Aquí es anterior y más simple:proteger-indiceyproteger-artefactoexigen elfile_iddel 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/*salvouploadno lo enruta el gateway («SOLO /upload se expone»), y los gemelos…-internalson disparos de sistema sellados conX-Internal-Token. Contarlos como cobertura pendiente hacía que un servicio bien cerrado pareciera un hueco. Corregido enscripts/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 pideCONFIRMO, 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 --strictlimpio. 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 enNO_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 fijaCONFIRMOpara 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 esoejecutarno 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 soloejecutar, por decisión). Verificado contra la pila: proponer no pide teclear nada, publicar pideCONFIRMO, 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 --strictlimpio. 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_conen el catálogo,frase_requerida()en el gate, tarjetas que resumen listas,NO_OFRECIDASen 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. Ypreparar_transferenciano 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 escribirdest_dept, el nombre del endpoint, donde la tool declaradest_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_radicadopedí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 aproyectar_y_radicar_respuesta, con la descripción diciendo la verdad y remitiendo acrear_respuesta_vinculadapara el otro caso. Y el mismo defecto estaba en el corpus:seed_corpus.pyleíaGET /expedientes/{id}/radicados, una ruta que no existe (los radicados vienen enGET /expedientes/{id}), conopcional=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 paraoperador(96 tools + 7 procedimientos), las de archivo físico devuelven las 9 unidades y 6 ubicaciones sembradas, yestado_del_expedientecompone 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 --strictlimpio. 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 F4para ver operación por operación). El documento vive endocs/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 enpublicy 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 losrequire_permissionreales 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 unRADI_CREARque 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.pysolo leía elrequire_permissiondel DECORADOR, no el del propioAPIRouter(..., dependencies=[...]). Tenía las dos caras: acusaba de «exigir de más» a las cinco tools de terceros —cuyo router sí declaraPERM_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: enanulacion.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_borradordeclaraba menos permiso del que exige su endpoint —se habría ofrecido a quien recibe un403—, ydecidir_visto_buenodeclaraPERM_VOBOsobre 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 usuariooperador,listar_departamentosdevuelve los 33 del DANE ybuscar_tercerosencuentra 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.mdyscripts/censo_mcp.pynuevos, 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.pysiembra 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. UnINSERTdirecto se salta la numeración atómica, las reglas de negocio y la cadena de hashes deaudit_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/tercerosrespondíaroute_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óntenant/027no estaba aplicada en el despliegue: la tablatercerono 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íasize=200donde 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_expedienteyestado_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 radicado2026-DEMO-E-000127quedó creado y el reparto falló con422; la respuesta dijopasos_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. UnProtocol(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 mandandoNone, 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.pynuevo,/api/v1/tercerosenrutado 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 tres401correcto — el503del 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!= 200y 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 mandaradicado_idytracking_number, ycontent_hashquedaba 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 elpg_advisory_xact_lockdel tenant durante un viaje de red. 2)/admin/gruposadministraba 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 tresGETexistí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.jsy 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 sobretenant_demo: vinculado2026-DEMO-E-000015(un anexo) desde la pantalla, el vínculo quedó con3af08544…, que es exactamente elsha256del 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-check0/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/hashesnuevo, huella resuelta en servidor al vincular (individual y por lote), tres proxies de lectura en/admin/gruposy 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 apreservacion_plan.formatos_origen, que es el otro extremo deformatos_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 formatipo/subtipoy se rechaza con422en 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 unApplication/MSWordquedaba fuera de una política que sí lo nombra, dando cero candidatos sin que nada pareciera roto. No cuesta índice — no hay ninguno sobremime_type. La columna llega conDEFAULT '{}': 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 enadmin/preservacion(2 nuevos),svelte-check0/0, lint verde. Mutadas por separado y cada una en rojo con su test: la precedencia invertida, ellower()quitado, la validación de MIME anulada, la columna sin viajar alINSERTy el campo sin viajar alPUTdesde 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ónstorage tenant/016,formatos_origenenGET/PUT /api/v1/preservacion/plan, campo nuevo en/admin/preservacion, yFORMAT_MIGRATION_MIME_TYPESdegradada 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ón003lo 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, recursogdxc-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ón004nueva, no se reescribe la003. El libropublic.schema_migrationsidentifica por(servicio, nombre_archivo)y no guarda hash del contenido: editar la003dejarí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 marcavigente = false. Borrarlo rompería una ficha que ya lo apuntara, y la política de no borrar un municipio suprimido la declaró la propia003. 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ónglobal/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: mientrasmunicipioydepartamentofueron 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 enpublicporque 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. Entercerolos códigos llevan clave foránea al catálogo —la ficha viva debe apuntar a un municipio que existe—; enradicado_actorno 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 unCASCADE, 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 unCHECK (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 mandando05001con el departamento11. 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.fallidoyno_verificadoNO son lo mismo, y confundirlos tiene consecuencias opuestas que no se ven.fallidohabla 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_verificadohabla 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 enterceros/,svelte-check0/0, lint verde en los 11 servicios. Mutadas por separado y cada una en rojo con su test: elCHECKde 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ónfallido/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, migracionesglobal/003ytenant/027, cascada en/radicar, y un barrido apagado por defecto con la política enFORMAT_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 deradicados—sender_name,sender_entity— reutilizadas para los dos papeles y distinguidas solo pordoc_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-05figuraba 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.terceroguarda quién es —cinco tipologías y documento de identidad— y se comparte entre radicados;radicado_actorguarda 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 elordeny elcanalsí, 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 (dosCHECK). 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 = NULLsignifica «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 el422nombra 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-check0/0, lint verde. Migración026validada 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_nameysender_entityNO 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ón026, 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 deradicados—sender_name,sender_entity— reutilizadas para los dos papeles y distinguidas solo pordoc_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-05figuraba 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.terceroguarda quién es —cinco tipologías y documento de identidad— y se comparte entre radicados;radicado_actorguarda 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 elordeny elcanalsí, 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 (dosCHECK). 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 = NULLsignifica «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 el422nombra 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-check0/0, lint verde. Migración026validada 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_nameysender_entityNO 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ón026, 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.ssoSessionIdleTimeoutse reinicia cada vez que se usa el refresh token, y la renovación transparente dehooks.server.jsse 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 eninfra/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-check0/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_consultadoydocument.anexo_descargado, escritas por document-service con actor, canal y nivel de seguridad, descartando el canalsystemy el actor nulo. Trazas correctas. Lo que fallaba era la puerta. El historial de acceso del expediente consultabaobject_type ∈ {expediente, expediente_indice}filtrando por el código del expediente, mientras esas dos trazas se escriben conobject_type='radicado'yobject_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 elpayload, 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: usabaread,download,verify… y las reales sonarchive.expediente_consultado,document.anexo_descargado, etc. Todas caían alhumanize, 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.sveltea 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 queAuditQueryRepositoryno 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-check0/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. AhoraPOST /api/v1/preservacion/migrar-formatoproduce 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 responde409y 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») yno_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 comoNonehasta un503que 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 PREMISMIGRACIONllevaba en el vocabulario desde el principio y nadie lo escribía; ahora lo escribe esto, conhash_beforeyhash_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ón015) guarda además los dosfile_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 variantesb: lasa/uexigen 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.docxserí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-check0/0, ruff + mypy verdes. Migración015validada en Postgres real, incluidos los dosCHECKde 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ón015,ghostscripten 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 enaudit_logy 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ónstorage tenant/014abre el modelo con unCHECKque impone la pareja.objeto_tipo(expediente|lote) dice de qué esexpediente_id, y la restricción cierra las dos combinaciones imposibles: unacta_eliminacionpertenece 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_idNO 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 tipolote. 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 exigenobjeto_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 enCONTROL_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.pyobliga a clasificar todotiponuevo delCHECKcomo contenido o instrumento de control… pero leía la migración 010 por su nombre. La 014 amplía elCHECKy 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 elfile_idde la clave del artefacto firmado y no de una entrada externa, y anota el motivo si falla./admin/eliminacionmuestra 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íaminio_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-check0/0, ruff + mypy verdes. Migracionesstorage/014yarchive/050validadas en Postgres real, incluida la idempotencia sobre una tabla con filas previas (que se clasifican solas comoexpediente). Mutadas por separado: elobjeto_tipodel 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_tipoen el almacén WORM (compatible: las filas existentes quedan comoexpediente), 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 enborrador; el seguimiento solo con el planaprobadooen_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 yUSUA_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 esoavance_pctno 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.estadosí 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.headersForse 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 ennull(losPATCHdel PINAR leen lo ausente como «no lo toques» y elnullcomo «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.jsprohíbe leererror/detaildel primer nivel del cuerpo —el backend anida su sobre dentro deldetailde FastAPI, así que leerlo plano pinta «[object Object]»— y los tres sitios nuevos lo hacían. Corregidos amensajeError. 1896 tests del frontend,svelte-check0/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_pctyprioridad). 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. Undry_runo unforzarserí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 enfallidoscon su código —quien reintenta necesita saber cuál es, y un UUID no se lo dice—; el lote no pasa aejecutadomientras 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_dispositionLLEVABA 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 conX-Internal-Tokeny 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_itemsydisposicion_decisionesbloqueaban el borrado, ytransferenciasiba enON 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 elexpediente_idcomo referencia, sin clave foránea, exactamente comoexpediente_eventosdesde el principio. Y una al revés:expediente_aippasa aCASCADE, 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 aexpedientes: 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 UNDELETE, aunque el documento ya no exista: bajo entrega at-least-once, uncreatedreentregado DESPUÉS delDELETEreinsertaría el fragmento desde cero, sin marcador, y el texto del documento eliminado volvería a ser consultable. ElDELETEborraría el propio centinela que hace la purga irreversible. Se reutiliza el centinelavoided=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 elaudit_logcon 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-check0/0, ruff + mypy verdes en los 11 servicios. Migración049validada 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ón049, 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 elaudit_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ón048), por el mismo criterio que el acta de transferencia:eliminacion_actaguarda el contenido congelado y es append-only por trigger —ni se edita ni se borra—, yeliminacion_acta_firmaguarda 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 — elON DELETE CASCADEintenta 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. Suacta_sha256se ancla además enaudit_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 yCHECK): 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_eliminacionentra en el allow-set XAdES de signature-service, y ese es el punto. Sin estar enXADES_ELIGIBLE_OBJETO_TIPOS, el acta habría caído al HMACnativasin 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.acreditadoviaja explícito y es siemprefalse(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_firmadoesstr | Noney el código lo daba por presente. Una firma sin artefacto habría chocado con elCHECKde 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 enaudit_logy firma XAdES—. 864 tests de archive-service, 15 de integración del lote, 1864 del frontend,svelte-check0/0, ruff + mypy verdes. Mutadas por separado: el aval como precondición, el umbral derivado del contenido, elobjeto_tipoque 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/eliminacionlista 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 elWHEREde 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 elWHERE— y tres respuestas distintas:publicidad_en_cursocon la fecha,lote_no_avalablecon el estado, y 404 si el identificador no existe. Las tres constancias del aval se exigen en las tres capas —schema, servicio yCHECKde 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 negandoejecutado, porque un estado nuevo entra en elCHECKantes 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:borradores 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 delWHERE, admitirejecutadoen la anulación y abrir el chokepoint aTRUEponen 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-check0/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/trda 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 depublic_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 enborradoroanuladoresponde 404, indistinguible de un identificador inventado; losavaladoyejecutadosiguen 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 elSELECT—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. Ypublicado_hastaes 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» mockeabafetchrowdevolviendoNone, y al mutar elWHERE ... 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 losCHECKy 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-check0/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.ejecutadoviaja siempre enfalsey la nota de la respuesta lo deletrea, por el mismo motivo que en la decisión: un201sobre «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 vidaborrador → publicado → avalado → ejecutado(oanulado), y cada tránsito tiene sus constancias exigidas porCHECK: 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,SyM; 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 tenerworm_retain_untilen 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, perototal_itemssale 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 el409con los motivos se reenvía tal cual en vez de colapsarse a «no se pudo crear el lote». Migraciónarchive tenant/047validada contra PostgreSQL real: idempotencia, los cuatroCHECKde forma y las dos reglas del trigger (editar nunca, quitar solo en borrador). 823 tests de archive-service, 1828 del frontend,svelte-check0/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-serviceya tenía dos bucles de fondo con la forma exacta —run_onceinvocable 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 ynotification-servicees 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 conX-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_TRDconcrud≥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 migraciones045/046aplicadas y verificadas sobre el tenant de demostración —la045marcó 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-servicegana un tercer bucle de fondo y envía correo; exigeNOTIFICATION_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: falseviaja explícito junto a una nota, porque un201sobre 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_autorizadallevaba 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. SobreCTel 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 aE,SyM: 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, confaseydecisioncontra 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 unCHECKde 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 endisposicion_decisiones(migraciónarchive 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 unLEFT JOIN LATERALque va literalmente en elCOUNTy 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 dosCHECKrechazan el aplazamiento sin motivo y con motivo corto, elUPDATEy elDELETErebotan contra el trigger, y la reejecución es idempotente. 801 tests de archive-service, 1824 del frontend,svelte-check0/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}/decidiry 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-vencidalo saca a la luz, y una cuarta pestaña en/admin/trdlo 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_eliminacionno reimplementa el gate. El criterio de ADR-025 D5 —solo una serieconvalidada, oderogadacon acto, gobierna una eliminación efectiva— se extrajo a un predicado único que ahora comparten elassertque 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,COUNTincluido: 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_TRDes 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 lafase, que se interpola en el SQL: elLiteraldel 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 usabavar(--op-danger), que no existe (es--op-error). El navegador habría descartado la declaración entera en silencio, sin quesvelte-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-check0/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 enexpedientes.dispositionelfin_archivo_gestion, elfin_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 dejabadisposition = NULLytrd_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 motivocierre_sin_snapshot_de_retencionexistía desde la migración 037, que lo usó para marcar los cierres históricos; la tercera pestaña de/admin/trdsabí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 deaudit_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 deif 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 seriesmigrada(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ónarchive tenant/045para 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 endocs/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/admingateaba 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/radicary/expedientespara poder clasificar, yUSUA_PERM_TRDlo 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 conUSUA_PERM_TRD:GET /trd/{code}/versionesy su gemelo de TVD devuelven el historial completo —los borradores, elacta_comite, lafecha_aprobacion_comite, lainstancia_convalidantey elcreated_byde cada versión—, que es la deliberación del Comité Institucional de Gestión y Desempeño, no el instrumento publicado. Su gemeloGET /revision-pendienteya lo exigía: era el hueco que quedaba de ese par. Las 11 lecturas del PINAR pasan a exigirUSUA_PERM_ADMIN, con el gate en el propioAPIRouter—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 enborradores 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/pinarno había ningún consumidor: abrirlas no servía a nadie. Y en el frontend, la otra mitad. Elloadde/admin/pinarmetía el403en el mismo saco que los fallos de red, así que la pantalla lo habría presentado como avería. Ahora viaja comosinPermisoy 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 (/envioscon 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/radicarsigue igual. -
docs(pruebas): la bandeja de correo entra en el plan de pruebas, y dos pantallas faltaban en inglésdocs/es/plan-pruebas.mddeclaraba 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.emlconservado, el mensaje movido aRadicadosen el servidor IMAP, el asientoemail.mensaje.descartadosin asunto ni remitente, y el404neutro 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 dedocs/{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.mdvuelve 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 el404neutro —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 su404se leía como un fallo transitorio: un error de autorización presentado como otra cosa, el mismo defecto por el que/enviospresentaba un403como «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_RADIhabilita trabajar correo; el buzón concreto lo decide otro recorte — elWHEREdeemail_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 conPERM_RADIde la entidad podía leer el asunto y el remitente del correo privado de otro funcionario y apartárselo. Solosincronizaryradicarcruzaban 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 responde404 cuenta_no_encontrada, no403: 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 elWHEREde la mutación: elcuenta_idata 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: siaudit_logno 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 unUPDATEsobre 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 enaudit_loglo vería todoUSUA_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 depublic.audit_logportado de archive-service: la atomicidad del asiento no se puede comprobar con un doble, porque el rollback lo produce Postgres. Ydocs/{es,en}/api.mddocumentan 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 variablesIMAP_HOST,IMAP_PORT,IMAP_USER,IMAP_PASSWORD,IMAP_USE_SSL,IMAP_MAILBOXeIMAP_TENANT_SLUG, los endpointsGET /api/v1/ingest/email/estadoyPOST /api/v1/ingest/email/poll, el clienteimapliby el ingestor que los servían, y el bloque «Buzón heredado» de/admin/correocon 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-check0/0, lint verde. Por qué: mantener dos rutas de ingesta obliga a decidir cuál manda, y la heredada radicaba la publicidad — Impacto: quien tuvieraIMAP_*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: elFromse 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 solodest_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. Ahorabuzon_destinoes 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_notificationsno 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ó apedro@ciudadano.cocon su número y su fecha de recibo, y el aviso interno fue adependencia-902diciendo «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 exigePERM_RADIde 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, conCHECKen 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 sinEMAIL_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 como2026-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: unuidsin suUIDVALIDITYpuede 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 poruidsin comprobar elUIDVALIDITYcon el que se registraron. Unuidsolo 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— losuidse 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 eluidvaliditydesde 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. Ahoratraerymoverlo exigen, y el mensaje dice qué hacer: volver a consultar el buzón. (2)subno esuser_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 propioauth.server.jsya lo advierte —«confundirlos es lo que dejó sin funcionar tres pantallas»— y esta iba a ser la cuarta. Guardián nuevoidentidad-interna-vs-sub.test.js, por FORMA: censa comparaciones entre campos de autoría ysub. 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-000023radicado 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 conObject.hasOwn— un acceso directo devolvería miembros heredados deObject.prototypey el guard dejaría de ser la lista cerrada que aparenta. Y los409de 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 dependientes=4 ocupados=0aocupados=1al reservar, el segundo funcionario rechazado con409 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 enmetadatay 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 delINTERNALDATEdel 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.emlen 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 conPERM_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, conContent-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_passwdya 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 deArchivoSubido.id— el campo esfile_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 como2026-DEMO-E-000022con 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 unUPDATEcondicionado —entre unSELECTque dijera «está libre» y unUPDATEque 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 elMessage-ID, no eluid. Un uid solo es único mientras elUIDVALIDITYde 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 cabeceraDate, hasta que el servidor de pruebas la devolvió vacía al primer intento.Datela 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 elINTERNALDATE, 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 ganalistar_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, conBODY.PEEKpara no tocar la marca\Seen, que es compartida) ymover(con respaldoCOPY+EXPUNGEpara los servidores sinMOVE, 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/correopasa 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 ensessionStorage, 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 elstatepertenezca 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 delloadno discriminaba: las aserciones vivían dentro del mock, y eltry/catchdel propio código bajo prueba se traga el error que lanza unexpect. Cambiar la URL de las cuentas por una inventada seguía en verde. Reescrito para recoger las llamadas y comprobarlas fuera. (2) El guardiánprosa-en-flexcomparaba 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) Elusuario_loginviajaba 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-check0/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 ystatede 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ásaccess_type=offlineyprompt=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.Allmásoffline_access— nooutlook.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 elReferer; con el verificador guardado en el servidor, un código interceptado no sirve. Elstatese valida y se marca usado en el mismoUPDATE, con cuatro condiciones —un solo uso, no caducado, misma cuenta, mismo iniciador—. Entre unSELECTprevio y el marcado cabría una segunda petición con el mismostate, que es justo el ataque de reproducción que elstateexiste para impedir. El400no 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_secretincluido, y acabaría en el registro del servidor y en la pantalla. Las cuatro condiciones delstateverificadas 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, elCHECKde vigencia demostró su valor rechazando el primer intento de envejecer una fila en el test: prohíbe que exista unstatenacido 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 variablesIMAP_*—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 dellock.phpde Orfeo, que en esto acertaba—: custodiarla convertiría al sistema en titular del correo entero de alguien. Las institucionales se cifran con AESGCM y AADtenant|cuenta, mismo esquema que la clave de firma personal. La visibilidad va en elWHERE, 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íaX-User-Permissions, una cabecera que nadie produce — el defecto ya había aparecido enmcp-server, cuyos tests lo dejan escrito. Habría devueltoFalsesiempre: ningún administrador vería una sola cuenta, y la pantalla saldría vacía sin ningún error. Resuelto contra la base concheck_permission, más un guardián nuevo enshared/testsque 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 «@»; ahoradireccionyusuario_loginson 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 marcadaimpl_status: completoy el roadmap repetía el «✅». Verificado pieza por pieza: no existe la columnaimap_uid, ni el endpointfrom-email, ni las tablasemail_ingestion_config/email_ingestados, ni el evento, ni el acuse. Lo que hay es un sondeo conimaplibque 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 enmetadata— 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 usabasearch/storesin el prefijoUID: 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 llamabauidy 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ón021_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 AADtenant|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 deUIDVALIDITYestaba 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 quesvelte-check,eslint,prettierni elbuildlo noten — no miran dentro devar(). 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, enProposalCard.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-mdquería 16 px (--op-space-4),radius-mdquería el radio «md» de v1.0, que aquí se llama--op-radiusa secas. Guardiánsrc/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 estabavar(--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 concurl, 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. NuevoGET /api/v1/ingest/email/estado(USUA_PERM_ADMIN, sin efectos). Responde 200 conhabilitada: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: el403del 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. Conbuzon_de_otra_entidadlos tres campos van vacíos. Ambas puertas compartendiagnosticar_buzon—imap_configuradokeyword-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 delaudit_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 pintabacodeen rojo#da1039sobre 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,kbdysampheredan color, sin fondo ni relleno. (2)--op-font-monoes un TAMAÑO (12px), no una familia. Nueve declaraciones hacíanfont-family: var(--op-font-mono, monospace), que el navegador descarta por inválida — y el respaldomonospaceNO 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-monocon 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:monospacea secas (35), una pila completa (2) y la variante inerte (9). Unificadas en el token, en 27 archivos.monospacea 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-datopara 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. Llevaalign-selfexplí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ánsrc/styles/tokens-mono.test.js, que vigila la causa y no los síntomas: que exista una familia mono con pila de respaldo, quecodeno 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-datoen verde de marca sobre fondo claro. 1 774 tests,svelte-check0/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 variablePUBLIC_TENANT_SLUGyPUBLIC_es un prefijo reservado por SvelteKit —$env/dynamic/privateexcluye esas variables—, así que existía en el contenedor y llegaba comoundefined: las pantallas se declaraban «no habilitadas» con la configuración puesta, un fallo mudo; (2) el guardián del sobre de error me cazó leyendocuerpo?.detailplano 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-000021con su código → consulta que devuelve estado, tipo, fecha y dependencia responsable. 1 769 tests de frontend,svelte-check0/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. ElContent-Typede 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 selladaPOST /storage/internal/anexo-externoconX-Internal-Tokeny la lista de anexos (RF-DIG-04): no se reutiliza/uploadporque exigePERM_RADIy 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 conuploaded_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íanresult["file_id"]de la respuesta de storage, y esa clave nunca existió —el contratoFileResponseexponeid—. Importar un paquete con anexos binarios reventaba conKeyError. 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 devuelveArchivoSubidoy traduce el formato de cable en un solo sitio, así que ningún llamante vuelve a adivinar una clave. Guardián enshared/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-000020con su anexo marcado como principal, el fichero en el almacén con su SHA-256 y sin actor atribuido; un ejecutable con extensión.pdfyContent-Typeapplication/pdfrechazado con 422.python-multipart>=0.0.18añ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 unLiteralde 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 columnatipos_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 condest_deptnulo: 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 pordependencias.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_entityexiste 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_publicaentra en el contrato detipos-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 unUPDATEa 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/pqrsse 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 produjo2026-DEMO-E-000019condoc_classy con destino, el aviso llegó adependencia-901@con estadosent, 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-check0/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 queverification_codelo tiene todo radicado: la migración 010 lo añadió conDEFAULT 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: exigirpublicableromperí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 elWHERE. 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-serviceexponía el estado de anulación por radicado (GET /documents/{id}/anulacion) y ningún endpoint que listara lo pendiente, así que/anulacionesobligaba 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. NuevoGET /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}. GatePERM_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 porsolicitado_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 delCOUNTpone 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) yradicado_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 concodigoError(). 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:/anulacionesdeja 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/-siones 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 —invalidaypublicason 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 temainfra/keycloak/themes/orpyca/login/: hereda dekeycloak(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: esen el realm dejaron la pantalla igual de inglesa:defaultLocalees el respaldo para cuando el navegador no expresa preferencia, y siempre la expresa. La otra mitad la pone el frontend conui_locales=esen 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 ene2e/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-realmimporta solo si el realm NO existe, así que editarrealm-dev.jsonno 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 deinfra/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 —reactivarfrontend/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, cuyoaria-labeles «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:/bandejaabre en Tablero, no en Lista, así que esperaban 60 s a una fila que no iba a existir. Y el botón de detalle llevaaria-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 409acto_no_aplica_al_tipoy 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: comprobabatoHaveURL(/\/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 usuarioconsulta(permisosPERM_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}/closey/transfer→ 403;POSTde 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=3en 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 —/enviosmentía. Elloadcapturaba el 403 del gateway enloadError, lo devolvía endata… y+page.svelteno lo mencionaba ni una vez. A quien no tienePERM_RADI_SALIDAla 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 elloadconserva 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ánsrc/lib/error-de-carga-leido.test.js: todo campo de error que un+page.server.jsdevuelve 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 deaudit_logsí guardabamin_crud: el dato bueno estaba y el mensaje decía otra cosa. Corregido en los ochorequire_permissiondel sistema con un ayudante compartido (nivel_efectivo+mensaje_denegacion);auth-serviceya 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 deservices/*/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 derequire_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_iden cuatro,actoren tres —NameErroren tres servicios), ynivel_efectivono 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.booles subclase deinten 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/pinary/perfil. El aviso que advierte de que aprobar un plan PINAR congela su contenido de forma permanente vivía en un<p>condisplay: 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. Nisvelte-check, nieslint, niprettierven 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/perfilsobre 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.jscensa 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&__warninglo 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 enaudit_log. 1750 tests en verde,svelte-check0/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 25workflow.step.createddel tenant fallaron, el 100%, con 3 intentos cada uno, mientras losdocument.radicado.createdsalían. La diferencia estaba en el destinatario:Oficina Jurídica@orpycamcp.localfrente adependencia-902@orpycamcp.local. El aviso de asignación se dirigía af"{to_dept}@orpycamcp.local", yto_deptes —por convención deliberada de todo el sistema, documentada enworkflow_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.codigodel catálogo (mismoSELECTque ya valida el paso al crearlo) y emitedependencia-{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_destinoes 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 aOficina 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.pycensa el árbol buscando la FORMA del defecto —f-strings y concatenaciones cuyas partes literales contienen «@»— y exige que solobuzones.pycomponga 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 produceworkflow.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.mdera —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. Nuevodocs/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 deaudit_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. Reactivadafrontend/e2e/authenticated/: estaba apagada con triple freno —testIgnore,test.describe.skipen 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: sinE2E_USERNAME/E2E_PASSWORDse 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, ypantallas.mddeja 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 usaorpycamcp_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 conhash='h1'…'h4','x1'…'x15'yprev_hashvacío, insertadas por SQL en el registro de los tenantsdemoyotra-entidadpara 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 dedemoqueda 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 deaudit.append. Se añade la cuarta:chk_audit_log_hash_forma, que exige forma SHA-256 (64 hex) enhashyprev_hash.NOT VALIDa 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.pyinsertaba por SQL conhash = '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 globalauth-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 enaudit_logsin pasar pororpycamcp_common.audit.appendfallará 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 exigemotivo(TransferenciaRechazo, mínimo 3 caracteres, obligatorio) y el proxy mandabaobservacion, 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 mandamotivoy 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 cont.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 —elload, que va directo al gateway, y el proxy, por donde pasan la recarga tras crear y cada filtro—, porque resolverlo solo en elloaddejaba 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. Reconstruirfrontend. El proxy de rechazo acepta ahoramotivo(antesobservacion) y responde 400 si falta; el listado de transferencias incluyeexpediente_codigo/expediente_nombrepor 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:enrollyDELETE /me/totppiden 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 pasasign-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 unUPDATEa mano en la base de datos — que para una entidad no es una salida, es la ausencia de una. Se añadePOST /api/v1/auth/users/{user_id}/totp/revoke(USUA_PERM_ADMIN,motivoobligatorio): 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_eventfijaba 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 mismounavailable=Truedos 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 unCHECKque rechazaba el valor que el propio signature-service escribe). Ahora se separan:unavailablesolo cuando nadie contestó (503, mismo código de error), yremote_failurecuando sí contestó y falló (502firma_personal_fallo_remoto, con eldetailremoto dentro del mensaje). Ambos siguen siendo fail-closed; lo que cambia es a quién manda a mirar. De paso, un test deauth-serviceque dependía del ambiente:test_activate_fails_closed_without_sub_ca_configureddaba por vacía una ruta quesettingslee 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. Reconstruirauth-service,archive-serviceyfrontend.POST /api/v1/auth/users/{id}/totp/revokees 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 unCHECKque no admitía su propio valor Undécimo bloque de la revisión por navegador — B11-6, las tres firmas del acta de transferencia.PersonalSigningServicefijanivel_conformidad='xades_personal_no_acreditado'desde que se implementó la firma personal (E17), y ninguna de las tres migraciones que han tocadofirma_nivel_conformidad_checkañadió ese valor: la008lo creó con los tres perfiles del sello institucional, la013le sumó LT/LTA, y la015solo 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 ensignature-service, seis enarchive-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-serviceestaba perfectamente.signature_client.sign_personal()colapsa cualquier 5xx enunavailable=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.pyextrae conasttodo 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 últimoDROP+ADDincondicional delCHECK, que es el que define el estado final (la015re-crea la lista antigua, pero guardada tras unIF 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 tenantsignature-service/016_firma_nivel_personal.sql— aplicarla antes de usar la firma personal. Sin cambios de API ni de esquema más allá delCHECK. 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: elsubde Keycloak y suauth_users.iddentro 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/meya 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 elsub. Un estado que no existe. La pantalla de transferencias comparaba contra"pendiente"; el ciclo real espreparada → enviada → recibida | rechazada, y así está en el servicio, en su docstring y en elCHECKde la tabla. Una transferencia nacepreparada, 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 rechazabapreparada—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 tienecontent_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 registradocumentos_verificadosjunto adocumentos_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_archivoes 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ñadePATCH /api/v1/ubicaciones/{id}(gatecrud >= 3, asiento con el antes y el después de cada campo) y su modal;codigoyparent_idquedan 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 irEXP-2026-0020yDEP-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. Reconstruirarchive-serviceyfrontend.POST /api/v1/ubicaciones/{id}es nuevo (PATCH);IndiceFixityResultganadocumentos_sin_huellaeIndiceVerifyDocganasin_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 sobretenant_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_expedientetransicionaba el expediente aclosed, llamaba aregeneratepara emitir el índice definitivo, chocaba con el guard de inmutabilidad del índice firmado y la transacción revertía entera. El expediente volvía aopeny el único rastro era un asientoarchive.expediente_close_failedcuyo 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, porqueassert_open=Falselevanta 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óanuladosin 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 409aprobacion_del_propio_solicitantey 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 conValue 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íafecha_archivado, y el contrato lo llamafecha_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@typedefque 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, porquenum_documentoscuenta 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 conwhite-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 propioModal, 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/bandejaa secas: quien buscaba un documento aterrizaba en la bandeja de su dependencia, donde ese radicado casi nunca está, sin ningún aviso — mientras elaria-labelprometía «ver radicado X». Ahora/bandeja?radicado=<id>abre su detalle, y el parámetro se lee en un bloque reactivo y no enonMount, porque cononMountel 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. Reconstruirarchive-service,document-serviceyfrontend.POST /expedientes/{id}/closefunciona ahora en el caso que antes revertía;POST /documents/{id}/anulacion/aprobarpuede responder 409aprobacion_del_propio_solicitante; el 409 de regenerar un índice firmado pasa a llamarseindice_firmado_inmutable(antesexpediente_closed, que describía otra cosa). Verificado en el navegador, acto por acto y con su asiento enaudit_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 409acto_no_aplica_al_tipocon 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 lanzabaasyncio.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 dearchive-serviceya tenía la corrección y la advertencia escrita en su propio docstring;document-serviceno la aplicó. Con la conexión arreglada aparecieron dos defectos más, apilados detrás:pagesesNOT NULL DEFAULT 0y el lote insertaba elnullque 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íatenant_codeyregistered_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/interoperabilidadanunciaba «— radicado(s) exportado(s)» sobre un paquete de seis.X-Export-Count,X-Export-ExcludedyX-Export-UUIDno estaban enFORWARDED_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 deX-Total-CountyX-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 cabeceraX-…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 endisplay: flexcon 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 fuerasuccessera «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 entracking_numbers, que en el contrato de archive-service es una lista de etiquetas paralela aradicado_ids(E15 perfil v4, R1), no una forma de referirse a un radicado. Sinradicado_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 deexpedienteRef.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. Reconstruirapi-gateway,document-service,archive-serviceyfrontend.POST /api/v1/batch/documentssigue devolviendo 202 con el mismo contrato; lo que cambia es que ahora el trabajo ocurre, y que un lote que revienta quedafailedcon 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 sutenant_codey 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./busquedaes 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 congoto()a la misma ruta con otros parámetros: SvelteKit reutiliza el componente y solo sustituyedata. La pantalla hacíalet 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 unfetchal proxy en vez de depender delload. Y buscar «certificacion» devolvía cero mientras «certificación» devolvía tres. El ADR-014 decidió usarunaccent—«normalización de acentos para FTS en español»— y la extensión se instala desde el primer día; nunca llegó a usarse: elsearch_vectory la consulta seguían con la configuraciónspanish, 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-serviceademá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ónpublic.spanish_unaccenty 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,/reportesy/admin/preservacionlos tres paneles se pintaban a la vez, apilados: el atributohiddenlo oculta la hoja del navegador con especificidad de elemento, y undisplay: flexde clase gana esa cascada. La navegación por pestañas era decorativa. Para quien usa un lector de pantalla era peor, porquearia-selectedytabindexsí 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!importantdel 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-serviceglobal/002 + tenant/020,archive-servicetenant/043. Recrean la columnasearch_vectory su índice GIN enradicadosyexpedientes, recalculando el índice de cada fila; en un archivo grande esa migración tarda. Reconstruirdocument-service,archive-serviceyfrontend. 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-fisicogobierna 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íaGET /ubicaciones?activo=truecreyendo —lo decía su comentario— que sinparent_idel backend devuelve las raíces. No: sinparent_idno 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ñadesolo_raiz, que dice lo que hace; pedirlo junto conparent_ides 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ñadentipo/codigoa 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 sobreaudit_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.jsy 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. Reconstruirarchive-serviceyfrontend.GET /ubicacionesaceptasolo_raiz;UnidadDeExpediente,PrestamoResponseyFuidRowganan 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 enaudit_logy enmovimiento_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 contieneparent_id IS NULL, así que la aserción daba verde aunquesolo_raizno 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 avarchar), 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.jsonllevaba una clave_comentario_sesionesque 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 contraRealmRepresentationcon Jackson, y cualquier propiedad que no conozca aborta la importación conUnrecognizedPropertyException. Como--import-realmcorre 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 unERR_CONNECTION_REFUSEDque 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.examplearrastraba 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 ainfra/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.pyvigila 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-servicelo 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 quedabapendiente_firmapara 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/artefactosellado conX-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íanJOIN 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Í alimentabamotivo_devolucion; la devolución que registra una persona —el camino habitual— la embebía endescripciony 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, porquePOST /signsobre 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.shygen-dev-tsa-cert.shdejaban la clave en 600 con el uid del host, y los contenedores corren como uid 999:PermissionError→ 500 → índicependiente_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. Yarchive-servicedescartaba 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. Reconstruirstorage-service,signature-service,archive-service,document-serviceyfrontend. Para firmar en desarrollo hacen falta./scripts/gen-dev-signing-cert.shy./scripts/gen-dev-tsa-cert.sh. Verificado en el navegador de punta a punta: índice firmado en XAdES-T con firma válida yacreditado=falsedeclarado, y envío postal recorrido entero (registrar → en tránsito → devolución con causal), todo comprobado también en base de datos y enaudit_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 exponePOST /signature/requestsy 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 declarabaform-action 'self'con el comentario «el logout y los formularios envían solo a este origen» — falso justamente del logout, que termina en elend_session_endpointde 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 ensvelte.config.js: la URL pública de Keycloak es$env/dynamic/publicy 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 deIndiceItemResponse—el índice electrónico, que tiene su propia pestaña— sobre la respuesta deGET /expedientes/{id}/foliado, que esExpedienteIndice(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 eraundefinedrepetido en todas las filas. El proxy BFF ya documentaba el contrato bueno. El paquete de entrega afirmaba una firma que no existe.LEEME.txtdecí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ñó queexcept HTTPExceptionno 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.jsy lo consumen las dos pantallas, para que no nazca una tercera copia. El buscador de vinculación mostrabain_progressdonde 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.UserPickernació 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--purgedeseed_demo.pyborraba 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. Reconstruirfrontendyarchive-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 contratenant_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, enflow_stepsy enaudit_log. Queda anotado, sin tocar: el índice electrónico se genera sin huella por documento (0/2en 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.pyy el despachador MCP ponían en eldetailel 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 eseno, 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.pyno 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.onlyestuvo 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 congit 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 conHTTPException(detail={"error", "detail", "status"})en ~500 sitios de los once servicios, así que FastAPI anida el sobre del proyecto dentro de su propiodetail; los proxies BFF lo reenvían intacto, y el frontend lo leía al primer nivel en 231 sitios. Ahídetailno es un texto sino un objeto —de ahí el aviso ilegible— yerroresundefined, 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".mensajeErrornunca 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.jsrecorre las fuentes desrc/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 concodigoErrordarí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 enmensajeErrory 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í queVoiceInputsolo 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-whisperen CPU. No llevaopenai-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 (perfilassistantde 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/transcribeen mcp-server, con un proveedor conmutable — y sinstub, 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 esdisabledy 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 enSTT_PROVIDERdeja el sistema sin transcribir, nunca activando por descarte un camino que nadie eligió. Hay un test que prueba justamente el nombrestub, porque es el que alguien intentará por analogía. Frontend:VoiceInputgana 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-serviceySTT_PROVIDER=whisper. Sin eso, todo sigue igual que antes y el endpoint responde 503 explicando cómo activarlo. Nuevo servicio enservices/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.shgenera los secretos de desarrollo AL AZAR en.env(gitignorado).signature-serviceno arrancaba porque su guardián rechaza elchange-me-in-productiondel compose, y eso está bien. La tentación era copiar lo que haceMCP_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: conchange-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: sinfirstName/lastNameyrequiredActions: []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_sluges uno, y su pérdida aparece luego comotenant_mismatch, un 403 que no menciona ningún atributo. Los usuarios se aprovisionan entrando, no insertando su fila:auth_usersla 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 409radicado_already_linkeden la segunda ejecución y abortaba el recorrido; el id de una cadena de firma viene ensolicitud_idy no enid, 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 construirsescripts/seed_demo.pydeja el tenantdemocon 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 enaudit_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 respetainit_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 probabaitems/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.--purgedeshace lo que la ley permite, y dice el resto. No se borranaudit_log(cadena inmutable, ADR-010) niflow_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/trdcon un código duplicado devolvía 500 por unUniqueViolationErrorsin capturar, cuando su gemelotipos_documentales.pysí lo traduce a 409 — lo destapó ejecutar el guion dos veces seguidas, que es lo primero que hace cualquiera; (2)tenant_demoestaba a medio migrar (35 migraciones de archive-service cuando la que añadetrd_series.estadoes 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: simcp-serverno responde, o sisignature-serviceno arrancó (su guardián de secretos lo impide sinSIGNATURE_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.pycon el stack levantado,--purgepara 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: conPERM_SOL_ANULARse 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 enapp/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 unDepends. 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}/anulacionresponde409 acto_no_aplica_al_tiposobre 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 acepteE, y hacer que la guarda del frontend devuelva siempretruecaen 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.pyes 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 conPERM_RADI_ENTRADAyPERM_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_ROOTes legítimo: no se comprueba conrequire_permissionsino poris_root, que es el bypass.SGD_PERM_ESTADISTICAera 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 pedirGET /reports/radicadosy 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_SALIDAya está tomado: gatea los seis endpoints de envío postal, no el radicar Salidas. Aplicar soloENTRADAeINTERNOdarí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_idesON DELETE CASCADE, así que unDELETEarrastrarí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 (nrrporPERM_TRAMITAR, la descarga del expediente porUSUA_PERM_EXPEDIENTE, la anulación porPERM_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_radicacionpasó 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 exigenSGD_PERM_ESTADISTICA(la interfaz ya lo asumía, así que quien usa Reportes lo tiene). Migracionesauth-service/018(solo descripciones; reescribe también las de la017para que las siete se reconozcan de un vistazo, y corrige la dePERM_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íawhen: manualy NOrules: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 delskipde 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 enpages: aborta si no haypublic/index.html.needs: [build-site]debería impedir que corra sin sitio construido, pero ese comportamiento depende de cómo trate GitLab unneedssobre un job manual conallow_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 hacegit 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-levelexigía nivel de LECTURA.PERM_RECLASIFICARcon elmin_crudpor 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é:publicacionusa 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», ydispositionse 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/uploadno 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 conX-Internal-Token(D-02)—, así que cualquier autenticado de la entidad subía archivos al almacén. Pasa a exigirPERM_RADIcon 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 dePOST /documentsporque 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_borradorno exigía la aprobación, y la pantalla sí. El ciclo documentado es crear → editar → aprobar → radicar, y/borradoreslo respeta (canRadicar = estado === "aprobado"), pero el servicio solo comprobaba que no estuviera ya radicado: unPOSTdirecto 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 409borrador_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 deaprobar_borradoryradicar_borradoren el catálogo MCP, que decían lo contrario.PERM_RADI_ENTRADAyPERM_RADI_INTERNOsiguen 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 quePERM_RADI_SALIDAhoy 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ón017corrige 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ñadirlePERM_VOBOhabría podido bloquear a revisores designados. Vi que faltabarequire_permissiony di por hecho que no había control. (2)PATCH /expedientes/{id}yvincular«a nivel de lectura»: no es un olvido sino una convención coherente —min_crud=3marca 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 tengaPERM_RECLASIFICARsolo con nivel de consulta deja de poder reclasificar (era el defecto); subir archivos exigePERM_RADI; radicar un borrador exige aprobarlo antes. Migraciónauth-service/017(solo descripciones). Reconstruir document-service y storage-service. Verificado por mutación: devolversecurity-levela nivel de lectura y quitar el gate de/uploadcaen 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_permissionstraí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í queresolve_effective_permissionsnunca 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 defectoRADI_CREARque 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_sistemavivía en la imagen de mcp-server, que no monta el repositorio: no encontraba las migraciones de auth-service y hacíapytest.skipen cada corrida. En el resumen, «11 passed, 1 skipped» se lee igual de bien que «12 passed». Unskipque 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 ashared/tests/test_permisos_existen.py, el único carril de CI que ve el repositorio entero (allí elskipdeja de ser posible) y se amplía a las tres fuentes de exigencia: el catálogo MCP, losrequire_permissionde cualquier router de cualquier servicio, y la columnarequires_permissionde las filas sembradas. Los seis que faltaban no los declara el catálogo: los declara el gate de un router y una fila detransaction_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 detransaction_typesde 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ónauth-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 soloPERM_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 condocker composey un operador real detenant_demo: el gateway enruta/api/v1/mcp/(200, frente aroute_not_founden 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 daconfirmacion_requeriday la base sigue sin cambiar; con la frase da 201 y2026-DEMO-E-000001; reintentar el mismo token dapropuesta_consumida; y el radicado queda con elregistered_bydel usuario real y su asientodocument.radicado_createdenaudit_logcon 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 exigeANTHROPIC_API_KEYy 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.svelteen el compositor del asistente: reconocimiento enes-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 elPOST /assistant/transcribecon 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.availableOnDevicepara ese idioma, másprocessLocally). Un modelodownloadableno 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: eltranscribecon 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-check0/0. Verificado por mutación: aceptar la API por el mero hecho de existir, dar por buena una disponibilidaddownloadabley dejar de pedirprocessLocallycaen 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. Unatool_usesobre una de ellas no llega al dispatcher: se convierte en una propuesta firmada (write_gate.preparar) que viaja enAssistantMessageResponse.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: eltool_resultque vuelve al modelo diceejecutado: false,status: "propuesta_emitida"y «NO SE HA EJECUTADO NADA… no encadenes acciones que dependan de su resultado». Sin él, el modelo lee su propiatool_usecomo 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á enwrite_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.svelteen 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:operaciony 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_argsempezó a correr en ese camino y suProposalErrorse 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-check0/0. Verificado por mutación: despachar la escritura en vez de proponerla, decir en eltool_resultque sí se ejecutó, dejar escapar elProposalError, 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íanX-User-Permissions, una cabecera que ningún componente produce — el gateway inyectaX-User-Id,X-Username,X-Tenant-Slug,X-User-Roles,X-CanalyX-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-Rolessí 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_ROLESsí 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 deGET /auth/meen los tres transportes, con elpermission_levelsincluido:build_toolsno pasabalevels, así que tenerPERM_RADIpara leer mostrabatipificar_radicado, que exige nivel de creación. El marco de alcance llega a MCP: toolmi_alcancey resourceorfeo://alcance, los dos derivados debuild_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 niX-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. ConX-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/streampara caber por la tabla de ruteo sin abrirle al proxy una forma de ruta fuera de/api/v1/.... Un guardián nuevo enshared/tests/: ninguna tool puede apuntar a/api/v1/mcpni/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/executecomo 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 yvisible_toolscompara 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 tocaralevels. 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 deapp/— 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 ungrephabrí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_PERMISSIONSyMCP_USER_ROLESdejan de existir: quien los tuviera puestos no pierde nada, porque no concedían nada.build_toolsesasyncy recibe el cliente del gateway. Reconstruir mcp-server y api-gateway. Corregidas afirmaciones falsas endocs/{es,en}/api.mdeintegraciones-externas.md. Verificado por mutación: quitarlevels, retirarmi_alcance, volver a leer la cabecera, restaurar el ajuste de entorno y quitar el montaje de/api/v1/mcp/streamcaen 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_serieyconsultar_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_prestamosyreporte_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_retenciones un CÁLCULO sobre una serie y una fecha de cierre, no la disposición registrada del expediente concreto; yreporte_radicaciondevuelve 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_expedientedice 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_retencionexigeclosed_atcomo query. El reparto porlocation="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) yredactar_con_antecedentes(texto apoyado en el archivo, con citas, que no escribe nada). Respuesta:listar_respuestasycrear_respuesta_vinculada. Borradores:listar_plantillas,listar_borradores,crear_borrador,editar_borrador,aprobar_borradoryradicar_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; soloradicar_borradorconsume el número, que es inmutable. Por eso los dos actos declarandestructivedistinto aunque toquen el mismo objeto. Un límite que dejaba de descubrirse fallando.buscar_antecedentesadmite el asunto o el radicado de referencia, nunca ambos ni ninguno: lo imponeAntecedentesRequestcon unmodel_validatory 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 conexactly_one_ofen el catálogo ycoerce_argslo rechaza antes de que salga la petición. Es el mismo principio que elparametro_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, peroPOST /borradores/{id}/radicarno comprueba la aprobación — solo que no esté ya radicado. La aprobación es hoy una marca de procedimiento, no un control. Se exponeaprobar_borradorporque 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 deexactly_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 declararlistar_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ámitePOST /workflows/{id}/devolveryPOST /workflows/{id}/rollbackno tenían ningúnrequire_permission, mientras sus tres hermanas del mismo router —assign,transferycomplete— exigenPERM_TRAMITARsobre 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. Enexecute_transaction, un tipo conrequires_permission = NULLera «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_typeses 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 sueloPERM_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: exigirPERM_TRAMITARa todas rompería a quien tienePERM_VOBOpara 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_pasoyejecutar_transaccion. El catálogo queda en 30 tools, 11 de escritura.completar_pasodeclara 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_transaccionresuelve su permiso en tiempo de ejecución, así que no hay decorador que comparar y el guardiántest_permisos_declarados.pyla 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:devolveryrollbackexigenPERM_TRAMITAR(quien no lo tenga recibirá 403 donde antes pasaba). Reconstruir las imágenes de workflow-service y mcp-server. Dos afirmaciones falsas corregidas endocs/{es,en}/api.md: la autorización dedevolvery el «tipo abierto». Verificado por mutación: quitar cada gate, devolver elNULLa «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 deRADI_CREAR.consultar_expediente,indice_expedienteyconsultar_fuiddeclaraban «sin permiso» sobre endpoints gateados conUSUA_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 cazadoRADI_CREARy no caza esto: unNonees 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 haceshared/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ía404 route_not_found. Una tool cuya ruta no esté enrutada se ofrece, se propone, se confirma… y falla. Los dos viven enshared/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_expedientedeclara exactamente los tres campos deExpedienteUpdatey ni uno más, porque ese schema llevaextra="forbid"ystatusestá 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íaPERM_RADIy 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_expedientees el único irreversible que entra, concrud >= 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}aceptabasubject,dest_dept,observations,statusypages, 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 unUPDATE doc_classque 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 hacecreate, 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 elNULLcuando el tipo nuevo no tiene plantilla activa; sin eseNULLexplícito el radicado seguiría apuntando a la plantilla del tipo anterior. Enviar solometadatatambié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 conPERM_RADIa 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. Perodoc_classdecide cuánto se conserva el documento y si acaba destruido, así que se comprueba aparte concrud >= 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 —MetadataValidationErrorno estaba en elexceptdel 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 quemetadata_template_idapareciera en el SQL delUPDATE, y lo encontraba en elRETURNING— daba verde con la escritura puesta y quitada. Corregido ametadata_template_id = $Ny 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}aceptadoc_classymetadata;422 metadata_validation_failedcuando no encajan;403al tipificar sin nivel de creación. Nuevas tools MCPtipificar_radicado(escritura,min_crud=3, con su vía alternativa declarada) ylistar_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 sacardoc_classde 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 eraX-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=Truefijo, «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éricaconfirmar_accion(token)—ofrecida solo a quien alcanza alguna escritura, porque sugerírsela a quien no puede escribir insinúa un poder que no tiene—,annotationsestá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 porgrepde 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 elsubiba 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.destructivese declara explícitamente en toda tool de escritura, con defaultNonea propósito: con defaultFalse, 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-Writedeja de leerse (quien la envíe recibe una propuesta igualmente). NuevosPOST /api/v1/mcp/proposals/executeyPOST /api/v1/assistant/confirmar—este último bajo/assistant/porque es el único prefijo del mcp-server que el gateway enruta—.MCP_PROPOSAL_SECRETes 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 construirmcp-servercon el contexto en la raíz del repo para instalarshared/. Reconstruir su imagen. Límites declarados, no disimulados: el consumo porjtivive en el proceso —cubre el doble clic y el reintento, no el multi-réplica—, yIdempotency-Keyse 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/healthy 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ón015en 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é un404a 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/tvdespejo 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):aprobadaera un callejón sin salida que empujaba a declarar una convalidación falsa — se añadedevolver(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;derogarsin 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 deaprobar), 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 enmain), se añade el circuito completo del Acuerdo AGN 004/2019: aprobar una versión enborrador(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 marcadamigradasin 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/trden 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-check0 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 dedoc_classpor 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.jsnuevo; 51 tests nuevos; suite completa 1186/1186 (antes 1135),svelte-check0 errores. -
fix(notification): endurece el destino de los webhooks salientes (hallazgo Media, auditoría de seguridad — no bloqueante)WebhookCreate.urlvalidaba la forma, no el destino: un administrador podía suscribir un webhook apostgres,keycloako 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). Nuevoapp/core/webhook_security.py::validate_webhook_target, usado al crear la suscripción (rápido, sin DNS, produce422con 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 desarrollowebhook_allow_private_targets(Falsepor 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/webhooksgana un422nuevo; 18 tests pytest nuevos (155 passed en el servicio); sin migración. -
fix(auth): cierre de hallazgo Alto — auto-escalada aUSUA_PERM_ROOT; ROOT ahora se aprovisiona fuera de banda (ADR-024) Un administrador de entidad (USUA_PERM_ADMIN) podía auto-concederseUSUA_PERM_ROOT(bypass total de RBAC) por dos vías — crear un usuario conis_root: true, o concederUSUA_PERM_ROOTal grupo del que él mismo es miembro — yGET /auth/permissionsofrecíaUSUA_PERM_ROOTsin 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 ahora403a un llamante no-ROOT; el catálogo de permisos omiteUSUA_PERM_ROOTpara 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}yPUT /auth/groups/{gid}/clearanceganan un403nuevo para no-ROOT;GET /auth/permissionscambia de forma para no-ROOT; nuevo scriptapp/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 enconfig.pyafirmaba (falsamente) que "el api-gateway media el acceso"; se borró junto con sus gemelos. Cerrado conapp/core/authz.py(require_permission, mismo patrón de document-service/archive-service) —USUA_PERM_ADMINen las 13 escrituras + las 18 del PINAR; losGETquedan 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 enpublic.audit_logdentro de la misma transacción (fail-closed, patrónRBACService). 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íaUSUA_PERM_TRD; se cierra con el mismo permiso. El borrado exige ademásmin_crud=3. Las cuatro escrituras ahora también auditan enpublic.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 enpublic.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_sluges 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/adminlas 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 porparent_id(código inmutable, gate realUSUA_PERM_TRD) y tipos documentales con CRUD completo./admin/metadatos: plantillas de expediente y de documento (solo alta — el backend no tienePATCH/DELETE, nueva versión = nuevo registro), con eljson_schemaeditado 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 realUSUA_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-check0 errores/0 warnings; 1125 tests vitest verdes. -
feat(auth): Lectura de membresías, permisos y clearance de grupo (E08, cierra el gap RBAC read) TresGETnuevos, espejo exacto de la escritura ciega equivalente:GET /api/v1/auth/groups/{group_id}/members(paginado),GET /api/v1/auth/groups/{group_id}/permissionsyGET /api/v1/auth/groups/{group_id}/clearance(max_level: nullsi no tiene fila asignada). Mismo gateUSUA_PERM_ADMINque sus escrituras hermanas.group_idinexistente o de otro tenant →404indistinguible, nunca403. Sin migración. Por qué: el panel/admin/grupospodí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 conCatalogCrudPanel(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 (reutilizanivelSeguridad.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 nuevoDependenciaTreeNode.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-check0 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 realPERM_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_deptvalidado al configurar la regla + traza enaudit_logde la degradación silenciosaPOST /workflow/rulesyPATCH /workflow/rules/{id}no comprobabanassign_to_dept(texto libre, sin FK) contra el catálogodependencias— una regla mal tecleada se aceptaba en silencio y solo se descubría en ejecución, radicado a radicado, vía unlogger.warningque nadie lee.WorkflowRulesService._validate_assign_to_deptreutilizaWorkflowRepository.dependencia_activa(el mismo helper que ya cierraassign/transfer) para rechazar con 422dependencia_desconocidaANTES 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 enpublic.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 deauto_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)AnulacionServiceno dejaba ningún asiento enpublic.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 — siaudit.appendfalla, la solicitud/decisión entera revierte — conobject_refresuelto 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 (reutilizapublic.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_transactionyreassign_cascademutaban el trámite sin dejar asiento enpublic.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, conobject_refresuelto 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 campomotivoobligatorio (5-500 caracteres) enPOST /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.scsspasan 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 clasesfas fa-*se usaban en toda la app, pero su CSS nunca se importaba: los iconos eran cajas vacías.+layout.svelteahora lo importa ystatic/recibe los activos reales de marca. -
feat(frontend): Landing pública en/(E22) Nueva ruta(public)/+page.sveltecon 7 secciones (hero, franja normativa Ley 594/Acuerdo AGN 060, comparativa Orfeo→ORPYCA, footer AGPL v3); redirige a/dashboardsi hay sesión. -
feat(frontend): Login, callback y/403con la marca real (E22)/loginsustituye el placeholder textual por el logotipo y estrena composición split mobile-first;/403pasa 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 enlocalStorage, 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)/dashboardmostraba 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 porSGD_PERM_ESTADISTICAtanto en la UI como en el servidor — la UI vuelve a ser espejo del RBAC. -
feat(frontend):DataTablegana 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 funcionalidadesarchitecture.mdreescrito con diagramas mermaid; nuevapantallas.mdcon 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úblicoGET /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_PUBLICenexpedientesy frontera anti-fuga enlevel="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) SegundometadataPrefix=eadenGET /api/v1/public/oai/{tenant}con los 6 elementos obligatorios de ISAD(G)/NTC 4095. Reutiliza el chokepoint_PUBLICya 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/importrecibe 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/exportproduce un paquete ZIP con manifiesto, JSON Schema publicado, un JSON validado por radicado, binarios ychecksums.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);getContentStreamsirve 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 yresumptionToken. 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-servicegana/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 enaudit_loginmutable. -
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 columnastipo/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.txtychecksums.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 enradicados(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 exigefundamentoobligatorio (422 si falta), así el índice nunca queda con fundamentos nulos. -
feat(document): Operador postal como consumidor de webhooks — callback entrantePOST /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 porUNIQUE(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 desearch, sin duplicarlo. -
feat(knowledge): Proveedor de embeddings real, local/soberano (E21 Inc.1; ADR-006) Sustituye el stub determinista porEmbeddingProviderpluggable, conlocal_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 modoadvisory(default, no bloquea la radicación) ostrict(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íatrd_series.pdfa_profile(whitelist). Impacto: un perfil ausente cae a 2b (retrocompat); uno inválido se degrada anot_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ó); ahorarun_once_worm_renovacionrenueva 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/aipempaquetaba 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 llevabanretain_untilfinito 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 cualquiercrud≥3fijar 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_KEYno estaba declarada endocker-compose.ymlpara 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/enrolldevolvía un 500 opaco. Como la credencial de firma personal exige un TOTP activo, con ella caía toda esa cadena. Yscripts/dev-secrets.shgenerabaSIGNING_KEY_ENCRYPTION_KEYconopenssl 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_idsolo se escribe al conmutar explícitamente, así que quien tenía una URD marcadaes_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/usuarioslanza dos peticiones en paralelo y compartían una variable de error con unelse 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 eralet 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/trdse veían en la columna «Acciones», empujando los botones fuera de la vista. Lo relevante es qué no lo detectó: prettier, ESLint,svelte-checky 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 permanentementeunhealthy. La sonda usabalocalhost: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 undepends_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_ROOTqueda 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 enurd_usuaroldeppara 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 usuarioconsultapobre (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.shregenera la clave AES mal formada y añade la de TOTP; hay que recrearauth-servicepara que las tome. Guardianes nuevos enshared/tests/test_permisos_existen.py(el demo se pone rojo cuando alguien añada un permiso y no lo conceda) y enfrontend/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}/completeera la única mutación sin identidad del llamante — cualquier tenedor dePERM_TRAMITARpodía cerrar el paso activo de otra dependencia, y la traza acusaba aassigned_toen vez de a quien ejecutó el cierre. Impacto: autorización y mutación ancladas en el mismoUPDATE(sin ventana TOCTOU);/evaluate(/batch)gana el gateUSUA_PERM_ADMINque le faltaba. Deuda declarada:devolver/execute_transaction/reassign_cascadesiguen sin asiento enaudit_log. -
fix(notification): Sellado de/send+ autorización de webhooks y del historial (RF-SEG-03; D-02; E11)POST /notifications/sendes 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 conX-Internal-Token;webhooks.pyy/historygananUSUA_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}/transactionsignorabatransaction_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 ejecutaranular/cerrar_exp/vobosin 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_respuestacopiaba el asunto de un antecedente reservado sin verificar clearance; las rutas de firma no tenían ningún gate yverifyera un oráculo de contenido. Impacto: en las escrituras el filtro va dentro delINSERT(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}/dispositionyPATCH /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_clearancepasa a ser obligatorio en las firmas (sin default "sin restricción", el modo de fallo que originó la fuga). -
fix(archive): Atribución delaudit_logde la renovación WORM al principal de sistema (E10; RF-SEG-08) Los 6 asientos de la renovación de retención usabanactor=None— inconsistente con los asientos de empaquetado/protección que ya usanSYSTEM_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 conX-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 sellaget_aip. -
fix(workflow): CRUD de reglas de enrutamiento automático sin autorización alguna (RF-SEG-03; E05)rules.pyno teníarequire_permissionen 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_ADMINen 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íaX-Internal-Token. -
fix(workflow): No-read-up en la bandeja + tenencia enrollback+ cierre de oráculo endevolver(RF-SEG-08) Tres hallazgos: (1) los listados de bandeja no filtraban por clearance; (2)rollbackmutaba sin verificar tenencia del paso; (3)devolverdistinguía 403 de 404 según el caso, actuando como oráculo. Impacto: subconsulta correlacionada de clearance compartida por COUNT y SELECT;rollbackexigeassigned_to == actor; ambos casos dedevolverdevuelven el mismo 404. -
fix(document): No-read-up en las Salidas vinculadas —GET /documents/{id}/respuestas(RF-SEG-08; Ley 1712/2014)listar_respuestasdevolví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_estadomutaba sin guarda de estado — dos llamadas concurrentes (recibir+rechazar) podían comprometer ambas, dejando un expedienterechazaday a la vez congeladotransferred, un estado jurídicamente imposible en un registro indeleble. Impacto: guarda de estado esperado en elUPDATEserializa la transición; se añade re-verificación de fixity al recibir (H-G, bloqueante de conformidad) ymotivoobligatorio al rechazar. Cambios de API:rechazarexigemotivo;recibirgana409 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=…, elloadreiniciaba 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.svelteyDrawer.sveltequedaban 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 guardsIF NOT EXISTSsobre nombres de constraint/columna comprobaban sincurrent_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 acurrent_schema()+ backfill schema-scoped para tenants ya provisionados. -
fix(knowledge):POST /api/v1/knowledge/searchdaba 500 contra Postgres real (E21) El pool de asyncpg no registraba codecjsonb, así quemetadata/aclse 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: codecjsonben elinitdel pool, con encode como texto para no doble-encodear las escrituras existentes. -
fix(scripts):init_tenant.pyprovisionaba tenants incompletos y abortaba a media provisión (E14/E21) Dos defectos: (a)TENANT_MIGRATIONSomitíaknowledge-serviceytenant-servicesin dar error — el tenant quedaba "bien" y el servicio fallaba en runtime (PINAR inservible); (b)_migrations.filenamees único sobre el nombre desnudo, y varios servicios numeran desde 001 — colisión →UniqueViolationErrora media provisión. Impacto: la identidad de migración pasa aservicio/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 escapehtml.escapefuncionaba, pero los tests buscaban la cadena verbatim y sus cargas de prueba llevaban apóstrofos que el escape transforma.git log -Sconfirma que código y test entraron en el mismo commit sin correr nunca en verde (oculto porque la imagen no compilaba). -
fix(knowledge): Elrequirements.lockcontradecía su propia declaración y hacía irreconstruible el serviciorequirements.txtdeclaraba "CPU-only suficiente" pero el lock resolvió la variante CUDA de torch (~5 GB de wheelsnvidia-*): knowledge-service era el único servicio que no compilaba (agotaba disco). Impacto: la dependencia pesada se separa arequirements-ml.txtcon índice CPU;DockerfileganaARG WITH_ML(default 1, sin cambio para un despliegue local;WITH_ML=0da 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 enfirmado_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_holdsigue siendo bloqueo incondicional. -
fix(frontend):radicarusa elSchemaFieldcompartido — cierra la degradación de camposenum/booleana texto libre (E22/E03) El mapeo inline de metadatos solo cubría number/date; un campoenumobooleandegradaba a texto libre, permitiendo un valor inválido que el backend rechazaba con 422. Reemplazado por el componenteSchemaFieldcompartido 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 deX-User-Idfail-closed. Impacto: nota abierta —ExpedienteRadicadoResponseaún no incluyeanexospor 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 propopendelModalradicarmontaba elModalsin la propopen, 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 migrarradicaraSchemaField.
Cambiado¶
chore(compose): knowledge-service configurable para máquinas sin capacidad de modelo local (E21) El compose de desarrollo puede sobreescribir por entorno aEMBEDDING_PROVIDER=stub+AI_PROVIDER=ollama_cloud. Impacto: los defaults deconfig.pyno se tocan — siguen siendo los soberanoslocal_st+ollama_local;stubno 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 enfirmado_atdel acta (no en el cierre del expediente) porque el acta puede firmarse años después en transferencias secundarias; generalización de storageproteger-indice→proteger-artefactopor 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_completapermanece 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_completabilateral (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 (403firmante_no_es_partepara 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 arecibir(), 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): Gatefirma_personal_require_xadespor 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 — fijarclasificadosen 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/verifyreportevalida=falseal 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 índicependiente_firmapermanentemente. 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 — sigueacreditado=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 campoanexosen 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 toolbuscar_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 unaclpropio — 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 ysearchlo 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 eljson_schemade la plantilla de metadatos (E22/E03/ADR-007) La creación de expediente enviabametadata: {}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 sobreGET /api/v1/expedientes/search(ya filtra por clearance no-read-up); todo el estado vive en la URL, elaccess_tokennunca llega al cliente. Impacto:nivel_seguridaddeliberadamente 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, conqminimizado (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_evaluatedpor 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 elpremis.xsdoficial de la Library of Congress y el<object>emitexsi: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 endpointGET /aipsin 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;acreditadosiempre 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_hashv4 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. Nuevoorden_documentalestable 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 usabaSigningTimedel reloj (no oponible); se inserta unxades:SignatureTimeStamp(token RFC 3161) firmado por una TSA local in-process, post-procesado como propiedad no firmada (no invalida el XAdES-B). Impacto:/verifyvalida 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//pendingacotados 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 elstep_idajeno 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 exigePERM_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í queconn.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 alternaPATCH /expedientes/{id}que transicionaba el ciclo de vida sin dejar rastro (Ley 594/2000 arts. 22/24)ExpedienteUpdate.statusejecutaba las mismas transiciones queclose/transferpero 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:statusse retira deExpedienteUpdateconextra="forbid"(load-bearing — sinforbidun{"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íaclose/transferno abrían ninguna transacción (mutación y asiento en autocommits sueltos);transition_expedienteno tenía guarda de estado esperado, así que doscloseconcurrentes 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; ynext_expediente_codepodía dejar que dos creadores concurrentes leyeran el mismo consecutivo. Impacto: colapsado a un únicoINSERT…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}/expedientesenumeraba contenido de una caja sin filtro de clearance; la creación por batch no dejaba asiento de auditoría. Ambos corregidos; la gemelaGET /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 conmax_clearanceobligatorio. 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:403explí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/transferterminaban leyendo el expediente sinuser_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 exponermetadata/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/clasificadaGET /api/v1/expedientesno filtraba pornivel_seguridad— cualquier portador del permiso enumeraba expedientes clasificados y su total. El mismo leak existía enPATCH /{id}. Impacto: se elimina el default peligrosomax_clearance=3; los call sites diferidos pasan explícitamente la constante nombradaCLEARANCE_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 enaudit_logal denegar (cierra el último residual RBAC) Las denegaciones de permiso no dejaban rastro — invisible una escalada de privilegios. Nuevo helper compartidoappend_denial, best-effort, invocado antes del 403 en los 7 servicios. -
fix(archive): Mínimo privilegio en el gate RBAC — umbralmin_crudpor operación; disposición exigecrud≥3(RF-SEG-03)require_permissionconcedía con cualquiercrud>0sin distinguir lectura/escritura/disposición — un usuario con nivel Leer podía cerrar/transferir expedientes. Impacto: elevado amin_crud=3en 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 aplicabanrequire_permission— cualquier usuario del tenant podía alterar los instrumentos que gobiernan retención/disposición. Ahora exigenUSUA_PERM_TRDy auditan. -
fix(archive): Cierre del gap RBAC en la gestión de expedientes Los 12 endpoints deexpedientes.pyno aplicabanrequire_permission— cualquier usuario del tenant podía crear/cerrar/transferir/leer expedientes sin el permiso. Impacto: destapó y cerró dos bypass adicionales enbatch.pyy 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 elbodycompleto, dejando el OTP en claro en BD/backups durante 5 minutos. Impacto: nuevo flagsensible— el correo se envía con el body real pero se persiste redactado. La única copia persistente del OTP es su HMAC enfirma_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 unchallenge_idde 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 /batchpasa 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íaX-User-Id+auditoría. Impacto: si el sellado falla, el cierre no se bloquea — el índice quedapendiente_firmay bloquea la transferencia hasta reintentarse. Sin CA acreditada ONAC, todo quedano_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 headerX-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, yorigin_groupobligatorio 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_groupobligatorio. -
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 porPERM_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/discardfallan con 503 en vez de commitear sin traza. Cierra el roadmap ADR-021 en los tres servicios. -
feat(signature,workflow): Purga selectiva deevento_dead_letter(Fase 4 MVP-3, ADR-021) Job que purga solo estados terminales con gracia de 90 días configurable;pendingnunca 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 permisoPERM_DLQ_ADMIN; replay atómico conSELECT FOR UPDATE+ reproceso en la misma transacción + auditoría (evento.dead_letter.replayed). -
feat(events): Retención del buffer DLQ Redis víaXTRIM(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-distribuyeradicado.created, el camino más sensible) estrena el mismo dead-letter durable+atómico que signature, cerrando un modo de fallo donde elxackse 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 elxacky solo vivía en Redis (volátil); ahora se persiste atómicamente en Postgres junto con el asiento de auditoría, con elxackgateado 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 enaudit_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 porPERM_RECLASIFICAR; modal con motivo obligatorio y campos de reserva condicionales. -
feat(document):nivel_seguridadexpuesto 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 NuevoPATCH /api/v1/documents/{id}/security-levelcon no-read-up, no-write-up ymotivoobligatorio; 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 dePERM_FIRMA. -
feat(signature): E06 — cadena de firma (bandeja del firmante) Nuevo modelo de cadena de firma con turnos ordenados; el turno se respeta conSELECT FOR UPDATE(cierra TOCTOU de doble firma); archive verifica la firma del índice contra este servicio.
Corregido¶
-
fix(signature,workflow): Scoping pororigin_groupde la tabla compartidaevento_dead_letterLas filas se distinguen pororigin_grouppero 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 dexreadgroup(block=...)como error redis-py lanzaTimeoutErroral expirar el bloqueo sin entradas nuevas; caía en elexceptgeneral y generaba backoff/ruido innecesario. Ahora se captura en rama propia concontinuesilencioso. -
fix(signature): Consumidor de re-sync ya no loguea la expiración normal dexreadgroup(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)poronDestroyque tocadocumentAppLayout,AssistantDock,DraweryModallimpiaban su listener de teclado cononDestroy, que también corre en SSR dondedocumentno existe —ReferenceError→ HTTP 500 en/dashboard,/firmasy toda ruta del layout privado. Impacto: la limpieza pasa al retorno deonMount(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 rotoGET /auth/meno devolvíapermissions— el frontend hacíame.permissions ?? [], siempre vacío, así que toda la UI de control por rol quedaba desactivada.UserInfoResponseahora 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 enrollbackEntrada 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 delaudit_logde 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 proyectoOrfeoMCP→OrpycaMCPPor 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 — marcaOrpycaMCP, repo/imágenesorpyca-mcp, imports Pythonorpycamcp_common, datos/infraorpycamcp(sin la sigla, por restricciones de Postgres/MinIO). -
refactor(infra): Renombrado del plano de datosorfeomcp→orpycamcpCompleta 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 elredirect_uridel PKCE tras el proxy, y pinning de issuer para el desfase navegador↔red Docker. -
fix(workflow):GET /workflows/{id}/eventsacotado 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 yGET /expedientes/{id}/eventosacotado por clearance. -
feat(audit): Cobertura depublic.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 conConnectionRefuseden 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 elaclenviado 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 columnanivel_seguridadcon 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 checklimpio (36 hallazgos corregidos),.env.examplecompletados, 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) — endpointPOST /mcpconStreamableHTTPSessionManager; 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 --strictlimpio 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 enapi.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): Actualizardomain.mdcon 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 noopenrechaza 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 compartidaorpycamcp_common(ADR-010) — auditoría (cadena de hash) y sobre de eventos idénticos en todos los servicios. -
feat(auth):public.audit_logcanó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— permiteCOPY shareden los Dockerfile; patrón a replicar en servicios que usen la librería compartida. -
feat(infra): Runner de migraciones por tenant (ADR-012) — separamigrations/global/(→public) demigrations/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 gaterequire_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 /audity/audit/verifyrecalculan la cadena de hash; repositorio de solo lectura. -
feat(auth): Gaterequire_permissionen 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=Falsesin 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-onlyflow_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 atenant/(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 atenant/(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 enaudit_logatómicamente con el INSERT y publica el evento (best-effort). -
feat(document): Vínculo de radicados con el organigrama de dependencias (E01) — validaorigin_dept/dest_deptcontra 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 usanget_tenant_conn(ADR-002/012, E01) —search/batchno fijabansearch_pathy solo funcionaban con las tablas enpublic. -
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 — metadatosJSONBvalidados por plantilla — se descarta EAV.docs(adr): ADR-008 — auditoría inmutablepublic.audit_log— append-only particionado por mes con cadena de hash por tenant.docs(adr): ADR-009 — historial de flujos por eventos —flow_eventsappend-only como fuente de verdad,flow_stepsreconstruible.docs(adr): ADR-010 — librería compartidaorpycamcp_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 defrontend/— 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.pycon 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.mdcon workflow, testing y requisitos de documentación.docs(readme):README.mden 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.tomlcentralizado — ruff + mypy en todos los 8 servicios.infra(health):/healthorquestado verifica Redis + 7 servicios en paralelo.test(e2e):docker-compose.e2e.ymlcon 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