Roadmap¶
Actualizado al 2026-10-03.
OrpycaMCP ha seguido dos planificaciones complementarias:
- Roadmap fundacional (Fases 1–6, abajo «Andamiaje»): levantó los 8 microservicios con su CRUD básico, infraestructura y CI/CD. Completo.
- Plan specDrive (Fases F1–F6, 21 épicas, 138 RF): eleva el andamiaje a conformidad SGDEA (Ley 594/2000, Acuerdos AGN 001/2024, 042/2002, 003/2015; ISO 14721/16363). Es el plan vigente y su estado se detalla primero.
Plan specDrive — Conformidad SGDEA (vigente)¶
Estado global: núcleo de las 21 épicas implementado, probado en Docker y con migraciones validadas contra PostgreSQL/pgvector real. Los ADRs vigentes están en el índice de decisiones de arquitectura (el número no se repite aquí a mano: envejece). El frontend tenía 1804 tests Vitest en 181 archivos al 2026-08-28; la cifra vigente la da npx vitest run.
Y, desde agosto de 2026, probado también desde el navegador. La garantía del proyecto era hasta entonces de unidad y de contrato, no de uso: las 36 pantallas de la aplicación tenían cero cobertura de extremo a extremo. La revisión funcional completa (bloques B0–B14) las recorrió ejecutando actos reales sobre un tenant de demostración —incluidos los irreversibles: anular, cerrar expediente, transferir, derogar una versión de TRD— y encontró defectos en todos los bloques que escribían. Todos corregidos, cada uno con su test verificado por mutación. La prueba de cierre fue reverificar la cadena de hashes de audit_log sobre lo que la revisión generó: la integridad de Ley 594 queda probada con datos, no con mocks.
| Fase | Épicas | Estado |
|---|---|---|
| F1 — Fundamentos | librería común (ADR-010), migraciones por tenant (ADR-012), E14 administración, E08 seguridad (RBAC + auditoría + URD + clasificación), E01 radicación, E03 metadatos, E07 anexos, E16 notificaciones, E05 flujos | ✅ |
| F2 — Consulta | E09 búsqueda avanzada + reportes + búsqueda de expedientes, E13 ventanilla única pública (radicación desde la calle + consulta ciudadana por código, con pantalla en (public)/ desde 2026-08-23), E19 ingesta de correo multi-cuenta con bandeja compartida (rediseñada entera y cerrada el 2026-08-28; la ruta heredada de un solo buzón IMAP_*, que radicaba también la publicidad, quedó retirada) |
✅ |
| F3 — Ciclo de vida | E04 TRD/CCD (jerarquía + retención en dos fases + disposición AGN), E02 expedientes (cierre/transferencia/foliado), E06 firma electrónica (hash+identidad + XAdES-B del índice, Inc.1) | ✅ |
| F4 — Conformidad | E15 índice electrónico (XML versionado append-only + huella por documento + verificación) + firma XAdES-B/T/LT/LTA del índice al cierre con sello institucional + sello de tiempo + material de validación a largo plazo (CA local de dev + CRL + OCSP stapled RFC 6960, no acreditado) (E06 Inc.1/4/5/7, ADR-016) + RT-15 perfil v4 "conforme completo" (orden documental estable + exclusión trazable + dependencia productora + fecha de declaración + política de acceso + pista de auditoría atestada, aplicabilidad prospectiva, ADR-022) | ✅ núcleo |
| F5 — Preservación / físico | E17 archivo físico (ubicaciones, unidades, signatura, préstamos, historial, FUID), E12 transferencias primarias/secundarias, E11 interoperabilidad (webhooks firmados), E10 preservación (plan + eventos PREMIS + WORM/Object-Lock del índice firmado, ADR-023) | ✅ núcleo |
| F6 — Inteligencia | E18 mcp-server (catálogo de tools sobre el gateway), E21 knowledge-service (pgvector + recuperación semántica con pre-filtrado ACL) | ✅ núcleo |
Pendientes del specDrive (dependen de integraciones/infra externas)¶
Ubicación actual del radicado (2026-10-02, ADR-029): backend ✅, frontend ✅. «Debería mostrar siempre en dónde estaba ubicado el radicado» (paridad con
radi_depe_actude Orfeo): el workflow-service escribeflujo_dept/flujo_dept_code/flujo_usuarioen el mismoUPDATEque el estado,ubicacion_*son columnas generadas, yGET /documentsy/searchfiltran por?ubicacion_dept_code=. Migraciones037(document) y016(workflow, backfill con evidencia), reportes «Ubicación actual», export INT-01 informativo y MCP: ✅. Frontend ✅ (/bandeja,/busquedacon filtro,/reportes, panel principal y selector de radicados). Pendiente:flow_steps.to_dept_code(cierra de raíz el caso de nombre de dependencia no único).Decisión de dominio resuelta (2026-10-02, ADR-028): el flujo mueve el estado del radicado. Antes el estado del radicado y el avance de los pasos eran dos máquinas que no se hablaban (decisión abierta el 2026-10-01). Ahora el workflow-service escribe
radicados.statusen la misma transacción que el paso: repartir, trasladar, devolver, revertir y reasignar dejandistributed;marcar_leidodel poseedor,in_progress; y solo «Finalizar trámite» (POST /workflows/{id}/finalizar) cierra (closed).closedyanuladoson terminales (409 radicado_terminal) y elPATCHdel radicado rechazastatus(422). Backend, MCP (finalizar_tramite), migraciones (backfill013) y pruebas contra Postgres real: ✅. Pendiente: el frontend (botón «Finalizar trámite», retirar «Distribuir»/«En trámite» manuales de la vista Lista, traducir el 409). Declarado fuera, a propósito: las salidas y los internos no tienen reparto fuera (su estado solo se mueve cuando un paso los toca);rollbacksolo desdepending; completar un paso sin marcarlo leído dejadistributed; completar un pasoin_progressdejain_progress; el acto «reabrir» (MoReq: reapertura controlada con permiso de administración) queda para su propio incremento. Remediación de la auditoría (2026-10-02) ✅ en backend:assign/transferexigen clearance y tenencia,finalizarexige nivel 3 ymotivo_cierre, la anulación cancela el paso (y la migración015sanea los zombis), la importación rechazaanulado(+CHECKde la migración034), el backfill deja evidencia inmutable con asiento-ancla. Ver el ADR (R1-R14).Histórico — el hueco de producto que se anotó el 2026-08-28 y que ya está cerrado (RF-TRD-07, ver abajo). Entonces, la disposición final no se ejecutaba: el sistema tenía el calendario —al cerrar un expediente congela en
expedientes.dispositionsufin_archivo_gestion, sufin_archivo_central, el código de disposición y el acto que convalidó la serie— y tiene el gate que autoriza (assert_disposicion_final_autorizada, ADR-025 D5). Lo que no tenía era el brazo: ningún job evalúa vencimientos (RF-TRD-08), no hay programa de eliminación con alerta de confirmación vencida (RF-TRD-06), ni borrado seguro con acta de eliminación por lote (RF-TRD-07), ni la validación de que ningún expediente se active sin retención asociada (RF-TRD-09). El RT fuente marca los cuatro como Brecha;trazabilidad.mdlos daba por cerrados y se ha corregido. RF-TRD-09 ya está cerrado (2026-08-28): un cierre sin serie TRD dejaba el expediente sin calendario y sin marca, fuera de toda cola; ahora dejacierre_sin_snapshot_de_retencion, que la tercera pestaña de/admin/trdya sabía presentar y nunca recibía. Y RF-TRD-08 en su parte que no destruye: la cola de vencimientos (GET /trd/disposicion-vencida+ cuarta pestaña de/admin/trd) saca a la luz el calendario congelado, distinguiendo que en Conservación Total vencer significa conservar para siempre. Y RF-TRD-06 en su parte de confirmación: un usuario autorizado decide sobre cada vencimiento —confirmar (que no ejecuta nada) o aplazar con motivo y fecha de regreso—, en una tabla append-only y con asiento inmutable en la misma transacción; ahí el gate de ADR-025 D5 recibe su primer llamante. Y el disparo programado con su alerta: un tercer bucle de fondo barre los vencimientos y avisa por correo a quien puede decidir, con el recuento recortado por el clearance de cada destinatario —decirle «tienes 14 pendientes» a quien alcanza 9 delataría 5 clasificados—. RF-TRD-06, 08 y 09 quedan cerrados, y RF-TRD-07 tiene tres de sus cinco fases: el lote de eliminación, que propone y congela el inventario con cinco condiciones de admisión —confirmado, disposición que destruye, instrumento que la gobierna, candado WORM expirado y no estar en otro lote— e informa de cada rechazo; la publicidad previa, que pone ese inventario a la vista de cualquiera sin sesión, con el plazo para oponerse y por la ventanilla única que la entidad ya tiene; y el aval del Comité, con sus tres constancias exigidas en las tres capas y que no se registra mientras el plazo para oponerse siga abierto —la guarda va en elWHEREde la propia transición, con el reloj del servidor—, más la retirada del lote, posible incluso ya avalado y nunca después de ejecutarlo. Todo ello desde/admin/eliminacion, la pantalla del procedimiento. Y el acta de eliminación: contenido congelado append-only por trigger, XML canónico con su SHA-256 anclado enaudit_log, firma XAdES best-effort con reintento, e inventario completo sin recortar por clearance —lo que se acota es quién la lee, con el umbral derivado del nivel más alto del propio inventario—. Es de conservación permanente: sobrevive a lo eliminado. Y el borrado seguro, que cierra la épica: la cadena archive → document → storage → knowledge, todo-o-nada por expediente, con la lápida content-free escrita antes del borrado y en su misma transacción. RF-TRD-07 queda cerrado y con él E04 completo: el sistema ya destruye lo que la TRD ordena destruir, con procedimiento, y solo eso.Estado vigente. El invariante 12 de la constitución se cumple porque la única ruta que borra un documento de archivo es
POST /lotes/{id}/ejecutar(archive-service,eliminacion.py,USUA_PERM_TRDnivel 4), y solo se llega a ella tras el procedimiento completo: propuesta, publicidad previa del inventario, aval del Comité, acta y borrado seguro con constancia (Ley 594/2000 art. 24; Acuerdo 001/2024 art. 4.3.2.5). Hasta el 2026-08-28 el sistema solo conservaba —y no aplicar la retención también es incumplir—; hoy destruye cuando la TRD lo ordena y con su procedimiento. No es unDELETEcon un cron delante.
- E18: binding del SDK MCP oficial (stdio / HTTP-streamable,
initialize/capabilities) ✅. Asistente conversacional — backend end-to-end ✅ (ADR-019/ADR-020):POST /api/v1/assistant/message(enrutado por el gateway → mcp-server) orquesta un loop LLM Claude que ejecuta las tools del catálogo como llamadas al gateway re-propagando el token del usuario (as-the-user; cada tool revalida RBAC/clearance). Frontera anti-inyección (el contenido de las tools es dato, no instrucción), no-oráculo RF-SEG-08 en la respuesta, historial efímero aislado por tenant, y degradación honesta (503 assistant_unavailablesinANTHROPIC_API_KEYo ante fallo del SDK — nunca una respuesta simulada, RF-FIR-15). Conectado a E21 ✅: toolbuscar_conocimiento(POST/api/v1/knowledge/searchvía gateway, solo lectura) — recuperación semántica as-the-user con ACL server-side; los resultados se marcan como pistas de un índice derivado a confirmar contra la fuente autoritativa (ADR-006). Gate de escritura unificado ✅ (bloque 3 del plan MCP funcional): invocar una tool de escritura por cualquiera de los tres transportes devuelve una propuesta firmada (HMAC-SHA256) y no ejecuta nada; confirmar toma los argumentos del token, reautoriza contra/auth/mey consume la propuesta antes de despachar. Sustituye aX-Confirm-Write: true, un booleano que cualquier cliente podía poner y que no ataba la confirmación a ningún acto (el binding MCP directamente lo daba por puesto).POST /mcp/proposals/executeyPOST /assistant/confirmar; para clientes MCP externos, toolconfirmar_accion(token),annotationsestándar y elicitación en banda por stdio. Límites declarados: el consumo porjties por proceso (cubre el doble clic y el reintento, no el multi-réplica) y en HTTP streamable no hay canal de vuelta para elicitar. Marco de alcance ✅ (Parte 0 del plan): la toolmi_alcancey el resourceorfeo://alcancedeclaran qué puede ejecutar la persona, qué puede consultar y qué no puede con su motivo y su vía alternativa — se ofrece siempre, porque un catálogo recortado calla el límite y callar es cómo se acaba descubriendo fallando. Exposición a clientes MCP externos ✅ (bloque 9): el gateway enruta/api/v1/mcp/(transporte streamable también en/api/v1/mcp/stream), y la identidad deja de leerse deX-User-Permissions—cabecera que nadie producía— y deMCP_USER_PERMISSIONS/MCP_USER_ROLES—ajustes que no concedían nada—: sale de/auth/meen los tres transportes, con su nivel CRUD. Tarjeta de confirmación ✅ (bloque 10): el asistente ya ofrece las tools de escritura y las convierte en propuestas firmadas que la persona aprueba enProposalCard; eltool_resultque vuelve al modelo diceejecutado: falsepara que no afirme haber hecho lo que no hizo, y los actos irreversibles exigen una frase tecleada que el backend revalida a partir del token firmado. Cierra la promesa que el panel hacía por escrito sobre una interfaz que no existía. Dictado por voz ✅ acotado (bloque 11):VoiceInputrellena el compositor —nunca envía ni confirma— y solo se ofrece si el navegador reconoce en el dispositivo; donde la Web Speech API manda el audio a un servicio externo (Chrome/Edge clásicos) el botón no aparece, porque ADR-020 decidió que el audio no sale a terceros. Diferidos: streaming SSE; historial persistente (hoy en memoria por proceso, no multi-réplica → migrar a Redis);POST /assistant/transcribecon Whisper ✅ — camino soberano para TODOS los navegadores:whisper-service(faster-whisperen CPU, perfilassistant, interno) transcribe dentro de la instalación. Sin proveedor configurado el endpoint se NIEGA (503): no haystubde transcripción, porque tendría que inventarse lo que se dijo. Medido: ~9 s por ~5 s de audio en 4 CPU. Sin verificar: la calidad sobre voz humana (la prueba usó voz sintética). Catálogo por funcionalidad ✅ (bloques 4-8): de 9 tools a 206, 74 de escritura (al 2026-09-25; eran 45 cuando se escribió este renglón) — radicación y clasificación, expedientes y vinculación, flujos de trabajo (asignar, trasladar, completar paso, ejecutar transacción), respuestas y RAG (antecedentes, redacción con citas, borradores y su radicación), instrumentos y archivo físico, y después los trece flujos restantes hasta cubrir 203 de las 342 operaciones expuestas por el gateway (59 %). El catálogo íntegro, con permiso y nivel CRUD por tool, está en la referencia de API; el desglose por flujo, en API por flujos. Dos invariantes que cruzan servicios lo sostienen desdeshared/tests/: toda ruta del catálogo está enrutada por el gateway, y lo que el catálogo declara es lo que el endpoint exige — en los dos sentidos. RAG anclado con citas y embeddings reales del knowledge-service (E21). - E21: ingesta event-driven ✅ MVP (ADR-006/010/021): el índice pgvector se auto-puebla consumiendo
orpycamcp.document.events(consumer group propio + DLQ) — embebesubject/senderdel radicado con ACL fail-closed replicada del evento, guard contra proveedor externo para material reservado, y consistencia de nivel bajo entrega at-least-once en cualquier orden (tombstone/centinela +GREATEST;creatednunca baja un nivel). Purga-en-anulación ✅ (document.radicado.annulled→ chunkvoidedpermanente, excluido de la búsqueda a cualquier clearance;createdtardío nunca re-indexa un anulado). Proveedor de embeddings REAL ✅ Inc.1 (E21, capa de conocimiento real):EmbeddingProviderpluggable (local_stsentence-transformers multilingüe, local/soberano, default de despliegue;stubdeterminista, default forzado en tests;ollamamínimo viable, diferido) reemplaza el stub como proveedor único —EMBEDDING_DIM(384) fuente única de la dimensión deknowledge_chunk.embedding, guard fail-closed al arrancar, migración007(NULLea dim 64 incompatible +vector(384)+ índice HNSW) con job de backfillpython -m app.ops.reembed.is_embeddablegeneralizado aLOCAL_EMBEDDING_PROVIDERS: conlocal_stel material clasificado SÍ se embebe (local, no sale) — a diferencia de un proveedor externo. Residuales declarados: R1 (contenido ya enviado a un proveedor externo antes de este incremento), R6 (eventos reclassified/annulled best-effort → lag en capa derivada). Recuperación anclada con citas ✅ Inc.2 (Patrón A, sin LLM):POST /knowledge/antecedentes(query|radicado_refXOR) reusasearch(mismo pre-filtro ACL), colapso 404 anti-oráculo del pivote (no distingue "no existe" de "excede clearance"), advisory fijo CC-01/CC-02. RAG generativo con citas ✅ Inc.3 (Patrón B):LLMProviderpluggable (ollama_localdefault soberano,ollama_cloud/openai_compatibleexternos mínimo-viables,anthropiccon Citations API nativa,disabled→Patrón A) trasPOST /knowledge/rag. Barrera dura de soberanía para GENERACIÓN (decisión tomada 2026-07-20, reconciliación en ADR-006):is_generatableexcluye del contexto los chunksnivel_seguridad >= RESERVADAcon proveedor externo, fail-closed, sin override — a diferencia del modelo suave original del ADR. Tablakb_suggestions(migración008,proveedor/modeloen columnas separadas, CC-03) + asiento enaudit_log. Degradación honesta503 rag_unavailable(RF-FIR-15). ~~Endpoint de aceptación humana de sugerencias (CC-02)~~ ✅ T10 (POST /knowledge/suggestions/{id}/aceptar). Pendiente: eventos de anexos (los de expediente no aplican: su nivel no cambia tras crearse), streaming de la respuesta generada. - E10: Object-Lock/WORM real en MinIO ✅ Increment A (ADR-023: bucket de preservación por tenant, índice firmado bajo
Retention(COMPLIANCE)derivada de la TRD, gate de disposición, legal hold) + AIP real + PREMIS v3 ✅ Increment B (ADR-023 addendum: BagIt RFC 8493 con bytes reales + fixity recalculada, subido al bucket WORM y cubierto por el gate de disposición) + PREMIS v3 conformidad plena ✅ Increment C1a (valida contra elpremis.xsdOFICIAL de la LoC vendorizado,xsi:type="premis:file") + REPLICA best-effort del AIP ✅ Increment C1b (una copia WORM inmutable a target configurable —default segundo bucket del mismo MinIO, honestamente "réplica local, no cross-site"; cross-site víaMINIO_SECONDARY_*—, disparada pornum_copias≥2, no-fatal) + validación PDF/A — contrato + stub honesto ✅ Increment C2a (interfazPdfaValidatorpluggable, stub por defecto que reportano_evaluadosin fingir, eventoVALIDACION_PDFA+ PREMISvalidation, advisory, migración008del tercer estado) + CABLEADO del WORM del índice al cierre ✅ Inc.1 wiring (E10; Ac. AGN 001/2024 art. 4.3.2.6): la capacidad WORM existía pero nadie la disparaba → el índice firmado era borrable pese al sello. Ahora archive lo protege tras firmarlo al cierre (disparo síncrono best-effort + 3ª fase de reconciliaciónrun_once_worm, nunca bloquea el cierre), retención TOTAL derivada de la TRD (gestión+central,fecha_inicio=cierre) con fail-closed sin TRD (nunca un default sobre COMPLIANCE irreversible), columnasworm_*ortogonales alestado(migración archive029), marca+asiento atómicos; storage gana idempotencia (índice único parcial migración009+ corte-circuito + advisory lock de sesión por(tenant, expediente)que impide duplicar objetos WORM irreversibles bajo carrera) y el endpoint internoproteger-indice-internalpara la reconciliación. Object-Lock de MinIO en dev, NO repositorio acreditado. + CABLEADO del WORM del acta de transferencia firmada ✅ Inc.2 wiring (E10; Ac. AGN 001/2024 Anexo FUID): fast-follow espejo del Inc.1 sobre el instrumento que evidencia el traslado del FUID —proteger-indicese generaliza aproteger-artefactocontipo ∈ {indice, acta_transferencia}(migración storage010amplía elCHECK+ índice único parcial;_worm_lock_keyincluyetipo;/proteger-indice[-internal]quedan como alias duros), archive ganaproteger_worm_acta(disparo síncrono best-effort + 4ª faserun_once_actas_worm) con columnasworm_*entransferencia_acta_firma(migración archive030), retención TOTAL de la TRD confecha_inicio = firmado_atdel acta (no el cierre — un acta se firma después en transferencias secundarias) y fail-closed sin TRD; el gate de disposición cubre el acta sin filtro detipo. APTO+CONFORME. + REMEDIO del gate de disposición ✅ (E10; Ley 594/2000 arts. 24-26): la observación Media del Inc.2 (acta e índice no expiran juntos → la eliminación mandada se postergaba hasta expirar el acta) queda cerrada con la taxonomía CONTENIDO (indice/aip, bloquean) vs INSTRUMENTO DE CONTROL (acta_transferencia, sobrevive al contenido y no lo bloquea);legal_holdsigue incondicional, fail-closed antetipono clasificado (test-guardián en CI), sin migración. APTO+CONFORME. + 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): cierra el hueco de que un objeto COMPLIANCE de una serie CT (conservación permanente) volvía a ser borrable al vencer suretain_untiloriginal — nuevo parPOST /preservacion/renovar-retencion[-internal](storage, D-02, mincrud=3/X-Internal-Token) identifica la fila por(expediente_id, tipo), exige extensión estrictamente mayor (si no, no-op idempotente 200 sin tocar MinIO — COMPLIANCE rechaza acortar de todos modos, defensa en profundidad) y usaset_object_retention(verificado en miniopy-async 1.23.5 real, noput_object) — migración storage011(renovado_at/renovaciones). Archive gana la 5ª fase de reconciliación (run_once_worm_renovacion, piggyback en el ciclo del índice): espejo localworm_retain_untilenexpediente_indice/transferencia_acta_firma(migraciones031/032, poblado al proteger), filtro CT fail-closed en la consulta SQL (app/core/disposition.py::CT_DISPOSITION_CODES, fuente única con_agn_disposition) — ventana rodantenueva = now() + total_TRD(worm_renovacion_ventana_dias, nunca fecha lejana/9999), backoff exponencial propio +agotado(alarma de mayor severidad: riesgo de vencimiento del lock sobre un registro perpetuo). Fail-closed por diseño: solodisposition ∈ {CT, conserve}se renueva — E/S/M expiran y se disponen legítimamente, nunca se tocan. AIP queda fuera de alcance (sin espejo en archive, deuda futura). Remediación de auditoría: se retiró la ruta pública/renovar-retencion(superficie irreversible sin llamador → solo-internal), asiento de renovación atómico, y nuevo endpoint de visibilidadGET /expedientes/indices/pendientes-renovacion(espejo dependientes-firma, no-read-up en SQL, verificado no oráculo) que aflora los CT con renovación agotada o en riesgo. APTO+CONFORME (delta re-auditado sin hallazgos). + validación PDF/A veraPDF REAL ✅ Increment C2b (E10; RF-PRE-02):SubprocessVeraPdfValidatorejecuta veraPDF sobre PDF no confiable con modelo de seguridad endurecido —create_subprocess_exec(no-shell) con args de lista fija + whitelist de flavour, ENV scrubbeado (el hijo solo vePATH+JAVA_TOOL_OPTIONS, nuncaDATABASE_URL/MINIO_SECRET_KEY/tokens),start_new_session+killpg+reap garantizado enfinally(incluida cancelación), semáforo de concurrencia +-Xmx/-XX:MaxMetaspaceSize, tempdir 0700 por-invocación, cap de salida dual anti-DoS, JSON-only (sin XXE), degradación honesta a stubnot_evaluatedsin binario (RF-FIR-15). JRE+veraPDF solo en la imagen de deploy (Dockerfile multi-stagebase/productionno-root; la de dev sigue slim) + job de CI con un fake veraPDF que ejerce el plumbing real. Probado contra veraPDF 1.30.2 real. APTO PARA MERGE (1 Media de reap-en-cancelación remediada) + CONFORME. + CABLEADO del AIP al cierre ✅ (E10 debt; OAIS ISO 14721; Ac. AGN 001/2024 art. 4.3): elPOST /preservacion/aipexistía pero nadie lo disparaba → archive lo dispara ahora al cerrar el expediente (reconciliación-only, best-effort, caro). Como el AIP debe ser el paquete de preservación COMPLETO, la resolución de losfile_idde los anexos usa un endpoint interno nuevo en document-service (POST /internal/documentos/anexos,require_internal_token, sin gate de clearance — acto de sistema/inventario total, inalcanzable por el gateway; el no-read-up se aplica en la frontera de LECTURA del AIP, no en el empaquetado) — primer endpoint interno D-02 de document-service. Precondición:closed+ índicefirmado+worm_protegido; retención TRD TOTAL fecha_inicio=closed_at fail-closed; expediente 100% físico (0 anexos) →estado='omitido'(el índice firmado ya WORM es su artefacto); anexo mutado → fixity 409 → aborta+backoff (sin AIP corrupto). Tablaexpediente_aip(mig archive033, ciclo independiente), 6ª faserun_once_aip(advisory-lock propio), mig storage012(unique por contenido). Advisory lock de sesión enempaquetar_aip(patrónproteger_indice) cierra la carrera de doble-empaquetado WORM irreversible para ambas rutas (verificado con Postgres concurrente real). APTO PARA MERGE (endpoint interno fail-closed, inalcanzable, sin fuga cross-tenant; 1 Media de doble-upload remediada;hmac.compare_digest) + CONFORME (completitud del inventario ≠ control de acceso;omitidofísico correcto; fixity aborta corruptos). + RENOVACIÓN WORM del AIP para series CT ✅ (E10; cierra la deuda Alta que la auditoría de conformidad de B levantó): la 5ª faserun_once_worm_renovacioncubre ahora los 3 artefactos (índice, acta, AIP) — espejoworm_retain_until+worm_renovacion*_enexpediente_aip(mig archive034),AipWiringService.renovar_worm_aip, filtro CT fail-closed en SQL, ventana rodante, 3er advisory lockworm_renovacion_aip; storagerenovar-retencion-internalya genérico admitetipo='aip'; el endpoint de visibilidadpendientes-renovacioncubre los 3 tipos (no-read-up). El paquete OAIS completo del CT ya no pierde su inmutabilidad al vencer. APTO+CONFORME (espejo exacto del patrón índice/acta; la renovación queda coherente para los 3 artefactos, sin brecha de preservación). Deuda menor restante: AIP índice-only uniforme para expedientes 100% físicos si se exige a futuro.actordel asiento de renovación unificado aSYSTEM_RECONCILER_ID✅* (antesNone, heredado de índice/acta) — cerrado en el barrido de deudas junto a los otros_\_agotadode reconciliación. - tenant-service sin superusuario y con auditoría ✅ (2026-10, T7, OMI-02/AREA-03-04/AREA-10-02/-13/-14): opera con el rol de aplicación (
POST /tenantsya no ejecuta DDL; la provisión es deinit_tenant.py), alta/cambio de estado de entidades con asiento atómico,409al renombrar/desactivar una dependencia con trámite vivo yantes/despuesen el cambio de parámetros. Pendiente: verificar con el rol realorpycamcp_app(la pila local corre con el propietario por decisión del usuario) y el ADR para guardarcodigoen lugar denombre. - E06 — segundo factor coherente con el actor, claves por entidad y verificación honesta ✅ (2026-10, auditoría 2026-10-02, T8, AREA-04-05/-02/-09/-11/-12/-06/-10/-13/-08, AREA-05-02/-07): el OTP, el TOTP y la credencial personal se comprueban contra el mismo usuario al que se atribuye la firma (
403 identidad_incoherente+ asiento), el correo no baja el factor de quien ya tiene TOTP en nivel ≥ 2 (409 totp_requerido), la huella de radicado/expediente no la elige el cliente, el HMAC nativo y el del OTP usan claves HKDF por entidad y propósito (migración020,key_version; el esquema0se retiró en B9, migración024: lo anterior ya no verifica y/verifydiceesquema_hmac_retirado), el estado de la credencial se resuelve fuera del row-lock,/verifyaplica no-read-up y deja de llamarse «público»,acreditadosale de lo persistido, la revocación posterior a la firma no la invalida y el sello de tiempo se cruza con el certificado firmante y el ESSCertID; y se arregló que ninguna firma nativa de/sign-xmlverificaba (instante distinto dentro del HMAC). Cerrado después (2026-10-04, decisiones del usuario): la política XAdES pasó a parámetro de la entidad (firma_personal_require_xades, migración010de tenant-service, editable en/admin/parametros; el env queda de respaldo); el ESSCertID se exige desde una fecha de corte (TSA_ESSCERTID_CORTE, default 2026-10-04) y los sellos anteriores valen conadvertencias[]en/verify; una cadena sobreradicado/expedientefirma el contenido (PDF vigente / índice vigente, resueltos por endpoint interno en document/archive) y detecta si cambió (409 documento_modificado); yfirmar/rechazar/vobo/challengecomprueban la pertenencia antes del estado (un ajeno no sondea el estado). Pendiente: HSM/KMS — pendiente de proveedor; no se implementa. El enganche existe enservices/auth-service/app/core/signing_keys.py::sign_digest(único punto que un proveedor PKCS#11/KMS reemplazaría) y enservices/signature-service/app/providers/base.py(proveedor de sello institucional); activar el gateclasificadosen cada entidad es decisión suya y exige enrolar certificados antes. - E10/E06 — sello y WORM solo de sistema ✅ (2026-10, auditoría 2026-10-02, T1):
/signature/sign-xmlexigeX-Internal-Tokenpara el sello institucional yobjeto_tipoes un conjunto cerrado (desde B9-3 solo el sello: la firma humana de radicado/expediente/borrador va únicamente por la cadena); storage sella/aip,/proteger-indicey/proteger-artefactocon token interno, el gateway enruta/preservacion/por lista de admisión, la segunda protección con otrofile_idda409, ylegal_holdsale del body y se gobierna porlegal-hold-internal(motivo + asiento atómico). Pendiente: endpoint de usuario en archive que orqueste el legal hold (permiso por decidir) y subirmin_cruddePERM_FIRMA. - E01/E06/E18 — no-read-up en terceros, borradores y asistente; firma declarativa sin huella del cliente ✅ (2026-10, auditoría 2026-10-02, T3):
/terceros/{id}/radicadosy/respuestasdeclaran y respetannivel_seguridad; el borrador hereda el nivel del padre (migración 035) y se gatea en elWHERE;PATCHde borrador asienta; la firma declarativa exige nivel 2, resuelve la huella en el servidor y asienta atómicamente. Guardiántest_contenido_declara_su_nivel. Pendiente de decisión: retirar el POST de firmas con410; titularidad al editar un borrador;nivel_seguridadenSearchHit/RadicadoMetaItem. - E02 — export de expediente por flujo ✅ (2026-10, B4-5): ZIP en temporal en disco servido por trozos (storage) y reenviado sin bufferizar (archive); tope
EXPORT_MAX_BYTES+ reserva de disco; sin 200 truncados. - Dependencia por código ✅ (2026-10-10, B5, ADR-032):
dependencias.codigoinmutable y llave de correlación del flujo, las reglas, la bandeja, los eventos y los borradores; el nombre pasa a instantánea editable (renombrar con trámite vivo ya no rompe la bandeja); un nombre ambiguo nunca se resuelve «a la de menor código»; migraciones tenant013/ document046/ workflow019con relleno que falla cerrado y evidencia anclada. Pendiente: retirar el alias por nombre (campos*_dept,?dept=) y los índices por nombre cuando la cabeceraDeprecationesté a cero durante un ciclo (≈ un ciclo después del despliegue); notification, knowledge, mcp-server, gateway y frontend consumen ya el código en su tanda. Pendiente (reparación): un mapeo manual registrado DESPUÉS de la046/019no se refleja en su evidencia anclada (las migraciones ya corrieron); falta un job de reparación que lo enlace con ancla propia. - Metadatos en la interfaz ✅ / pendiente (2026-10, B6): radicar un borrador captura tipo, origen y metadatos del tipo; los metadatos guardados se leen con su plantilla (
SchemaValue) en la bandeja y en el expediente. Pendiente (requiere backend): editar los metadatos de un expediente (ExpedienteUpdateesextra="forbid": haría falta PATCH con revalidación, asiento de auditoría y decisión sobre expedientes cerrados) y undetalles_schemapor tipo de trámite; hasta entoncesEditarMetadatosModalyTransaccionModalno migran aSchemaField. - Metadatos: deuda declarada (2026-10, B6, ronda de corrección): (1) un tipo documental sin plantilla activa guarda
metadatasin validar (las plantillas son opt-in por tipo): «metadatos mínimos por tipo» no está garantizado hasta que cada serie declare la suya; (2) las etiquetas del frontend (SchemaValue) usan la plantilla activa y no la versión con la que se guardó el radicado (metadata_template_id), así que tras versionar una plantilla un radicado viejo puede etiquetarse con campos que no tenía. Ambos pendientes de decisión; la parte de backend ya valida catálogo de tipos, claves reservadas y origen de la Salida. - Guardián de
/admin✅ (2026-10, B6): gate any-ofrequire_any_permission(fuente única enorpycamcp_common.rbac) parausers/pickable,email/cuentas,plantillasyplantillas/campos(PERM_RADIoUSUA_PERM_ADMIN);PUT /config/params/{key}con claves cerradas (422 config_param_desconocido). Pendiente (frontend):/admin/parametrosaún ofrece «nueva clave» libre. - Bloque B10 ✅ (2026-10-17, ADR-034 §5, ADR-035 §7, ADR-036): (1) los eventos
document.envio.*por outbox —también la devolución manual— y aviso por correo de la devolución a la dependencia productora y a quien radicó, con el clearance en elWHEREpor destinatario; (2) signature consumedocumento.contenido_cambiadoe invalida solo los circuitos sobre un texto que ya no es el vigente; (3) knowledge gatea con el nivel vigente del radicado (GREATESTen elWHERE) y reconcilia cada hora el nivel del fragmento; (4) callback postal de una entidad suspendida →202 deferred, se aplica al reactivar; (5) identidad firmada atada a método y ruta; (6) la firma personal no gasta el TOTP con una firma que no verifica (ensayo previo, huella del certificado, verificación en la tx del TOTP); (7) renovación delArchiveTimeStampde XAdES-LTA con herencia WORM y PREMISRENOVACION, y el AIP embebe el XML vigente; (8) la prueba del job de cadenas acotada con--slug. Abierto: reempaquetar los AIP ya empaquetados al renovar el sello; el residuo del TOTP gastado en la autoverificación final trasremote_sign(se asienta y se avisa); un consumidor defirma.lta_renovada; la ventana de 120 s de la identidad firmada no es un nonce (una captura vale para la misma operación dentro de ella); el sello de tiempo de la TSA solo se valida contra CRL (OCSP de la TSA no soportado); el empaquetado del AIP de una entidad suspendida espera a su reactivación (document-service veta los anexos; conservar la retención sí corre); una consulta de los sellos de archivo por vencer, caducados o sin revocación de la TSA (hoy solo asientosfirma.lta_*y el log;firma_sello_archivo.estado/ultimo_avisosin endpoint);/verifyno evalúa elTimeStampValidationData;radicados/auth_userssin calificar conpublicen elsearch_path(SEG #12). Correcciones de auditoría (2026-10-17):404 route_not_founden vez de500para rutas que no estabilizan;ocurrido_atfirmado en el callback postal (aviso del operador ≠ fecha del hecho),413también con la entidad activa y cola de diferidos con tope bajo lock, cotejo de huella y descarte tras 10 intentos; aviso de devolución también al tenedor actual; pivote de/antecedentesgateado en elWHERE; LTA juzgada a la fecha actual (lta_vigente), sin renovar sobre TSA caducada y con la CRL de la TSA anterior; retención en cascada a las renovaciones; excepción de conservación para la entidad suspendida (ADR-035 §8). - Estado de la entidad en el borde y canal MCP atado al token ✅ (2026-10-16, B9, ADR-035):
/auth/validateinformatenant_estadoy el api-gateway veta (403 tenant_inactivo,503 tenant_no_provisionado/tenant_estado_no_disponible, fail-closed; la suspensión surte efecto en ≤ 15 s, el TTL de la caché de validación); los seisget_tenant_connlo vuelven a comprobar antes delsearch_path(un schema inexistente es 503, no 500); las rutas públicas (/public/*, CMIS, OAI, eliminación de archive, callback postal) responden el mismo 404 a una entidad inexistente, dada de baja o sin aprovisionar, y una entidad suspendida conserva las lecturas públicas (verificación, OAI, CMIS, publicidad de la eliminación) mientras sus escrituras responden503 canal_no_disponible(cierre de la auditoría archivística: el plazo de oposición del inventario corría con el inventario invisible); archive, workflow y storage consultan el estado enget_db_conn; la identidad firmada llevaX-Identity-Tsy caduca a los 120 s; el login y el refresh no entregan token a una entidad no operativa y el mcp-server propaga el código de la entidad;derive_canalusa el tenant del token y no la cabecera del cliente. Pendiente: el frontend debe mostrar «Entidad suspendida» ante403 tenant_inactivoen el login. - Entrada de producción, restauración fail-closed y
/challenge=/sign✅ (2026-10-11, B7, ADR-033): nginx en contenedor (edge) como único servicio con puertos, Keycloak publicado por lista de admisión (solo/realms/orpycamcp/y/resources/; lo demás 404),/health/readysolo desde el propioedge,TRUSTED_PROXIES=172.20.128.0/17sin el gateway de la red, terceros por tag y digest,.env.examplecompleto;restore.shfalla cerrado ante cualquier error depg_restoreajeno a las PK heredadas y comprueba el Object-Lock de cada bucket protegido antes de tocar datos;backup.shexige el bucket de cada tenant no dado de baja (activo o suspendido) en el espejo;restore.shexigeRESTORE_OPERADOR, hace el pre-vuelo del Object-Lock antes de parar nada, solo admitepg_restorecon código 1 y N ignorados == PK heredadas, y su acta nombra al operador y la ventana de traza perdida; el gateway no arranca en producción siTRUSTED_PROXIEScubre la puerta de enlace de la red;POST /challengeaplica las mismas precondiciones que/sign(credencial personal, huella vigente, visto bueno, anulado) y/auth/mecon una entidad sin schema es 200 con permisos vacíos. Pendiente:nginx -treal (también confirma loslisten [::]) y levantaredgeen un servidor; (SEG-M3 cerrado en B9, ADR-035); actualizar Keycloak 24.0.5 (fuera de soporte); (el 500 deget_tenant_conncon un tenant sin schema y el/auth/mede un tenantsuspendedquedaron cerrados en B9, ADR-035); whisper (dictado) fuera del manifiesto de producción en v1. - Firma XAdES conforme a XMLDSig y verificable por EU DSS ✅ (2026-10-11, B7, ADR-034):
Transformsexc-c14n explícitos en las referencias quesignxmldeja sin ellos,SignedInfofirmado después del parcheo (sello y firma personal), autoverificación fail-closed conXAdESVerifier()por defecto, sin atajo al verificar, y entrada delArchiveTimeStampsegún EN 319 132-1 §5.5.2 (XAdES-LTA vuelve a ser honesto); no se firma con un certificado caducado (sello:503 sello_institucional_vencido; personal:422 certificado_personal_vencidoantes de consumir el TOTP);/verifyde un LTA cuyo certificado caducó después de firmar lo juzga a la fecha delSignatureTimeStamp, con aviso; los sellos de tiempo se verifican con la canonicalización que declaran. Validado en vivo:SignatureIntacty todos losDataIntact,INDETERMINATE/NO_CERTIFICATE_CHAIN_FOUNDpor la CA de desarrollo. Pendiente: confianza (cadena a una lista de confianza / ONAC), que el humo contra DSS entre al CI, renovar elArchiveTimeStamp(EN 319 132-1 §5.5.3), releernivel_seguridadbajo bloqueo al firmar (SEG-B4) y que la autoverificación de la firma personal ocurra antes de consumir el TOTP (hoy el factor ya está consumido cuando falla). - E01/E11/E20 — integridad de numeración, envíos e import ✅ (2026-10, auditoría 2026-10-02, T4): consecutivo dentro de la transacción del radicado;
importno mueve el contador con números de otra entidad ni lo desborda; envíos con nivel 3, asiento con actor yUPDATEcon estado previo; import/export a nivel 3, en hilos y de uno en uno; el lote respeta plantilla de metadatos y año local; guía postal por envío. Cerrado el 2026-10-02: binarios huérfanos de un import revertido (purga segura en storage + cola y barrido) y reanudación/reproceso/retención de lotes (completado_con_errores,POST /batch/documents/{id}/reprocesar); además, editar un borrador exige tener el radicado. Pendiente: job asíncrono de import/export,PERM_DESPACHOpropio, UI de errores del lote («Procesar los faltantes»). - E04/E02/E15/E12 — separación de funciones en la eliminación y datos probatorios del servidor ✅ (2026-10, auditoría 2026-10-02, T6):
avalaryejecutardel lote de eliminación pidenUSUA_PERM_TRDnivel 4 y quien propone no avala ni quien avala ejecuta (409separacion_de_funciones, en elWHEREde la transición y antes del primer borrado); publicar/avalar/anular/ejecutar atómicos con su asiento;GETpúblico del lote sin oráculo de entidades; WORM del acta con reintento programado + barrida propia y exigido antes de destruir (409acta_sin_worm); el vínculo radicado→expediente resuelve en el servidor número, tipología, dependencia productora, formato, tamaño y huella y rechaza lo declarado que no coincida (404/422/503 fail-closed); actor de préstamos e índice = UUID, no login (guardián nuevo); pruebas de atomicidad del archivo físico contra Postgres real. Revisión de T6: vincular con no-read-up (404 neutro) y 422radicado_sin_tipificar; el préstamo físico aplica el clearance del prestador en elINSERT(403). Pendiente: AREA-01-13 restante (prestado_a_user_idUUID +min_crud=3: cambia el contrato y pide validación de archival-compliance-auditor), propagar al índice un retipificado posterior (eventodocument.radicado.retyped), ybatch.pyque aún acepta número y dependencia del cliente. - E21/E16/E05/E06 — DLQ compartida y consumidores de Redis Streams ✅ (2026-10, auditoría 2026-10-02, T9): PK
(origin_group, event_id)enevento_dead_letter(el aparcamiento del segundo grupo ya no se pierde) con migración idempotente en los 4 servicios yON CONFLICTpor grupo; knowledge gana la superficie admin de su DLQ (/api/v1/knowledge/admin/deadletter, replay depurged/annulledatómico);events.consumer_name()por réplica; dead-letter huérfano conlast_error;X-Truncatedsin falso positivo;/health/readycondead_lettery último barrido. Pendiente: métricas Prometheus/alertas (decisión de operación) y clearance en elWHERE+ paginación por cursor del listado. - E21/E16/E05/E06 — gate de la DLQ en el
WHERE(AREA-10-08) ✅ (2026-10): fuente únicaorpycamcp_common.deadletter, paginación y recuento exactos sin tope de 1.000,404indistinguible,X-Truncatedretirada; knowledge gana retención de la DLQ terminal y segunda red poroccurred_at. Pendiente (decisión del usuario): radicado purgado o sin identificadores sigue siendo nivel 3. - E13/E20 — constancia de autorización de la ventanilla y acuse de envío (tanda B2) ✅ (2026-10, ADR-031): la IP de la constancia (Ley 1581) la firma el gateway con un par de cabeceras propio y document-service nunca lee
X-Forwarded-For(constancia v2,ip_verificada); la política de tratamiento es de cada entidad (3 parámetros, migración tenant012), la sirveGET /ventanilla/politicacon lectura estricta (503politica_no_verificable) y se relee al radicar (409politica_desactualizada/politica_no_publicada, antes de gastar consecutivo); el acuse de un envío debe haberlo subido quien lo registra (403acuse_ajeno) y no ser ya otra cosa (409acuse_ya_referenciado, índice único045). Texto de supresión frente a retención ennormativa.mdy plantilla de política. Pendiente: que cada entidad real redacte y publique la suya. - E18/E21 — canal del asistente, residencia y aceptación de sugerencias ✅ (2026-10, auditoría 2026-10-02, T10): lo que el asistente/MCP confirma ya se asienta con
canal=mcp(marcaX-Origen-Canalfirmada, ligada a tenant y token, verificada por el gateway;audit.appendhereda el canal de la petición) y llevapayload.correlacion(request_id+jtide la propuesta);POST /assistant/messagedevuelveno_verificados(radicados del texto que ningún resultado devolvió); mcp-server reenvía la IP real firmada (las llamadas del asistente ya no comparten el cubo por IP de su contenedor) y mide el costo de cada turno;ollama_localsolo es soberano siOLLAMA_URLes local (con host público el RAG excluye nivel >= 2);POST /knowledge/suggestions/{id}/aceptar(CC-02, solo quien la generó, atómico con su asiento, migración012); retirada la promesa falsa «opt-in por tenant». Pendiente: parámetro de habilitación por entidad (decisión de producto: su default apagaría la ingesta actual), que el resto de servicios recojan el canalmcpal reconstruirse y el streaming del RAG. La reclasificación de expedientes no existe, así que no hay nada que propagar al índice (guardián enshared/tests). - E11/E16/E05 — eventos y webhooks que salen de la entidad; bandeja y visto bueno ✅ (2026-10, auditoría 2026-10-02, T5): recorte de webhooks por nivel efectivo (reclasificación incluida) y sobre mínimo para eventos de radicado sin nivel; causal/fundamento nunca salen; CRUD de webhooks a nivel 3 con asiento y actor; entrega fijada a la IP validada (TOCTOU DNS); no-read-up en la cadena de vistos buenos y huérfanos de
flow_stepsa nivel 3;get_actoren la DLQ de workflow; Devolver espejaPERM_TRAMITAR; cruce entre entidades de la DLQ de notification probado. Cerrado 2026-10-02: elsecretde webhooks va cifrado en reposo (AES-256-GCM con KEK versionada propia del servicio, AAD tenant+webhook, re-wrap porapp.ops.rewrap_webhook_secrets, esquema común enorpycamcp_common.kek; la entrega falla cerrada ante una fila en claro). Pendiente: aislamiento con dos esquemas en tenant/workflow; productor dedocument.*sin nivel en eventos distintos de radicación/reclasificación. - E08/E14/E06 — RBAC sin escalada y token endurecido ✅ (2026-10, auditoría 2026-10-02, T2): escrituras de RBAC/URD/clearance y bajas administrativas de 2FA y de clave de firma exigen
USUA_PERM_ADMINnivel 3; el alta en un grupo aplica no-write-up de clearance en elINSERT;PATCH/DELETE /groups/{id};typ/azp/claims obligatorios en el JWT; caché de validación del gateway conexpires_inreal y sellada con MAC;/audit/verifysintotalni ids para quien no tiene el clearance máximo; asientos de 2FA atómicos con la baja; el censo de niveles lee gates de router. Pendiente: permiso de lectura de auditoría propio (migración + backfill), id opaco en el listado de auditoría, subirmin_crudde cadena de firma/webhooks/DLQ/terceros, ACL de Redis por servicio y firma asimétrica de la identidad. - RF-PRE-04 — migración de formato contra la obsolescencia ✅ (2026-08-29):
POST /preservacion/migrar-formatocon Ghostscript en la imagen de producción. El original nunca se toca (se produce un derivado), la fixity se verifica antes y después, y el derivado pasa por veraPDF — no conforme se descarta, sin validador quedano_verificadoy tampoco se promueve a copia de preservación. No hay migrador «stub»: un stub tendría que inventarse el archivo, así que sin convertidor el endpoint responde503. El tipo PREMISMIGRACIONdeja de estar sin escritor. Pendiente declarado: el job periódico que decida qué migrar — exige una política de obsolescencia por formato que todavía no está declarada, y no se inventa. - E15/E06: firma XAdES-B del índice al cierre ✅ (Inc.1, ADR-016 addendum); 2FA por OTP-correo en la firma personal ✅ (Inc.2, RF-FIR-13); metadatos RT-15 del índice — perfil v2 ✅ (Inc.3, ADR-022); XAdES-T — sello de tiempo RFC 3161 con TSA local ✅ (Inc.4, ADR-016 addendum: fecha cierta verificable, no acreditada); XAdES-LT/LTA — material de validación a largo plazo con CA local de dev ✅ (Inc.5, ADR-016 addendum: cadena + revocación CRL embebidas +
ArchiveTimeStamp,revocation_provenance="dev", no acreditado, degradación observable a T sin material CA); OCSP stapled (RFC 6960) junto al CRL ✅ (Inc.7, ADR-016 addendum: respuesta OCSP de un responder delegado per-tenant emitida por la CA local, incrustada enxades:OCSPValuesy cubierta por elArchiveTimeStamp,certStatusderivado de la CRL, verify fail-closed con pinning del responder; no acreditado,acreditado=false, opt-inSIGNER_OCSP_ENABLED). La acreditación ONAC NO es deuda de este proyecto (decisión 2026-08-31): depende de que cada entidad contrate un operador de firma acreditado y aporte su material, así que es propia de cada instalación, no del producto. La costura existe y es por-entidad: el sello institucional se monta en<signer_cert_dir>/<slug>/seal.{crt,key}, conca.crt,crl.dery el par del responder OCSP opcionales en el mismo directorio. Lo que sí queda como trabajo nuestro son dos costuras sin las cuales ese material no sirve de nada: (a)acreditado=Falsees un literal enkeys.py::load_seal—un sello real de una CA acreditada se reportaría igual comoxades_b_no_acreditado, es decir, el sistema sub-declararía la conformidad que la entidad pagó—, y (b) el cliente de TSA remota RFC 3161 (corregido 2026-10-03: ya está implementado —app/core/tsa.py,tests/test_tsa_remota.py—; el sello es acreditado sitsa_urlytsa_acreditada), cuando el sello de tiempo acreditado viene precisamente del operador. Pendiente además: perfil ArchiveTimeStamp plenamente EN 319 132 (hoy simplificado), OCSP en línea de un responder independiente (hoy stapled auto-producido a fecha-de-firma). 2FA de mayor aseguramiento — TOTP RFC 6238 ✅ (Inc.6, ADR-016 addendum: secreto en auth-service cifrado AESGCM, verify por bearer, anti-replay atómico + lockout; WebAuthn sigue pendiente). Firma PERSONAL PKI por-usuario XAdES-B/T — Inc.1 del epic ✅ (E17/F4: eleva la firma personal de la cadena de HMAC opaco a XAdES por-usuario con cert de una sub-CA local de dev, custodia servidor; clave privada solo en auth-service cifrada AESGCM con KEK separada; split firma-remota — signature-service arma el XAdES con clave efímera + cert público real, auth firma el digest deSignedInfocon la clave real consumiendo el TOTP atómicamente, la privada nunca sale = seam a HSM; enroll conPERM_FIRMA+ emisión offline conca.keynunca montada; degrada a HMAC nativo sin cert; migración011;acreditado=falseSIEMPRE). Alcance honesto (RF-FIR-15): firma electrónica art. 7 (mismo tier legal que el HMAC+2FA — verificabilidad/formato ≠ acreditación); custodia servidor ⇒ no sole-control (no-repudio no oponible contra el operador). Inc.2 ✅ (E17/F4): revocación en línea (estado en BD consultado al verificar, sin caché — self con step-up TOTP / admin conUSUA_PERM_ADMIN, asientosigning_key.revocadaatómico; cubre las firmas de Inc.1 vía backfill) + anclaje de la sub-CA de usuarios en/verify(cadena leaf→sub-CA + pin, rama separada del sello LT;ca.keynunca montada) + veredicto matizado (firma_criptograficamente_valida/revocacion_status, fail-closed por defecto); cierra las dos Baja del Inc.1. Inc.3 ✅ (E17/F4): gatefirma_personal_require_xadesde tres modos (off|clasificados|todos, defaultoffretrocompat) — cierra el fail-open del Inc.2 (Baja #3) haciéndolo opt-in por nivel de seguridad: sin cert → 422firma_xades_requerida, auth caído → 503 fail-closed, denegación con asiento atómicofirma.xades_requerida_denegadaSIN consumir factor 2FA; env legacy bool coaccionado, valor inválido aborta el arranque. Sin cambio de tier legal (art. 7, no acreditada). Rollout: habilitarclasificados/todosSOLO tras completar el enrolamiento offline de los firmantes con clearance>=2 (la emisión del cert personal NO es autoservicio, a diferencia del 2FA), o la firma clasificada de quien no tenga cert quedará bloqueada. Firma personal PKI del acta de entrega — Inc.1 de la sub-épica ✅ (E17/F4; Ac. AGN 001/2024 Anexo FUID): quien RECIBE firma personalmente el acta con su cert PKI, como paso explícito posterior arecibir()(POST /transferencias/{id}/acta/firmar-personal), reutilizando el primitivo extraído (personal_signing.sign_personal_xml) vía el endpoint nuevoPOST /signature/sign-personal(autenticado como usuario, ASSERTfirmante_id==user); firmas independientes sobre elacta_fuid_xmlcongelado en la tablatransferencia_acta_firma_personal(migración027,UNIQUE(transferencia_id, rol), sin reconciliación desatendida — requieren TOTP interactivo); autorización atada al receptor real (decidida_por==actor, 403firmante_no_es_receptorsin quemar TOTP) + no-read-up; verificación agregadaGET /acta/verificacion(completitud ∈ {sellada, firmada_receptor}); gateacta_firma_personal_require(defaultoff) que nunca bloquearecibir().acreditado=false(art. 7, custodia servidor, no sole-control). Inc.2 de la sub-épica ✅ (E17/F4; Ac. AGN 042/2002): firma personal del REMITENTE (rolentrega, "Entregado por") sobre el mismo endpoint, completando el par de responsables del FUID junto al receptor; rol DERIVADO de la identidad (enviada_por/decidida_por, elrolparam solo selecciona, nunca concede → 403firmante_no_es_partea un tercero); firmas independientes en cualquier orden; auto-traslado (misma persona ambos lados) permitido conrolobligatorio y señalado con flagauto_traslado;completituda 5 valores (firmada_remitente/firmada_completanuevos);firmada_completa=ambas firmas personales presentes (atribución bilateral reforzada, NO conformidad jurídica plena — art. 7 no acreditado, sin sole-control). Sin migración (la 027 ya admitíaentrega). Inc.3 de la sub-épica ✅ (E17/F4; Ac. AGN 042/2002): firma personal del ELABORADOR (rolelabora, "Elaborado por" =creada_por), completando el trío de responsables del FUID — firmante ADITIVO (no parte del traspaso), así quecompletitudNO cambia (retrocompat con Inc.2) y su firma se reporta en un campo booleano ortogonalelaborador_firmado; tercera derivación de rol por identidad (misma garantía que Inc.2); migración028amplía elCHECKaelabora;validaagregado fail-closed incluye la firma del elaborador. Las tres firmas personales + sello = atribución reforzada de los tres responsables, sigue NO acreditada (art. 7, no art. 28). Rotación de la KEK de custodia ✅ (E17/F4): la KEK que cifra en reposo las privadas de firma pasa de única a llavero versionado (signing_key_encryption_key=v1 congelada, v≥2 por JSON +signing_key_active_version); la columnakey_version(sin usar desde la mig011) por fin se usa (enrollcifra con la activa,load_and_sign_digestdescifra por la versión de la fila, fail-closed); re-wrap como job de ops (python -m app.ops.rewrap_signing_keys, nunca endpoint HTTP) por fila en tx con nonce nuevo + round-trip verify + asiento solo-metadatos; AAD invariante; invariante de retirada por conteo--verify==0 cross-tenant; migración013(índice). Higiene de custodia, NO cambia el tier legal (sin sole-control, art. 7). Purga del material privado de credenciales REVOCADAS ✅ (E17/F4; Ley 1581/2012 minimización): tras una ventana (signing_key_purge_after_days, default 30d) un job de ops (python -m app.ops.purge_revoked_signing_keys) anulaprivate_key_cifrado/noncede las revocadas conservando todo lo público (verificación/revocation-status siguen idénticos); migración014(NULLABLE +private_key_purgado_at+ CHECK invariante); una purgada deja de anclar su KEK (desbloquea retiradas).revoke()sellarevocado_atpara que el re-wrap no reinicie la ventana de las superseded. NO merma el valor probatorio (la verificación no usa la privada) ni cambia el tier legal (minimización, art. 7). Diferido: validación a largo plazo con grace-period (distinguir "revocado-después-de-firmar" de "revocado-al-firmar" — requiere TSA acreditada para fecha cierta oponible; hoy la revocación es por estado ACTUAL, conservador), rotación automática programada de la KEK, formalizar la ventana de purga como política de retención (seguridad/PGD), vía administrativa si a una parte se le revoca el clearance yfirmada_completaqueda inalcanzable, resolver el estado personal antes del row-lock del turno (seguimiento heredado), y HSM/KMS externo → firma cualificada acreditada (art. 28). RT-15 "conforme completo" (perfil v4, ADR-022): ✅ — cerrados dependencia productora, fecha de declaración, política de acceso por roles y pista de auditoría atestada (aplicabilidad prospectiva; instancias legacy pueden omitir productora). Pendiente menor: backfill de la dependencia productora de radicados legacy (requeriría un cliente a document-service) y validación XSD estricta con lxml por versión (los.xsdv2/v3/v4 están publicados pero no se ejecutan en runtime). - E20: integración con operador postal como consumidor de webhooks ✅ F5 (RF-POR-08; ADR-018; cierra D-14 #3): callback ENTRANTE idempotente
POST /api/v1/public/postal/callback/{tenant_slug}con identidad propia del operador (HMAC-SHA256 per-tenant/per-operador, simétrico al webhook saliente de ADR-018), separado del permiso humano de despacho. Bajo/api/v1/public/(el gateway no exige JWT ahí, sin tocar el gateway); tenant en el path fijasearch_path, secreto en el schema del tenant → sin falsificación cruzada. Fail-closed (sin credencial y firma mala → mismo 401 no-oráculo; estado no mapeable → 422; guía desconocida → 404). IdempotenciaUNIQUE (operador, event_id); transición + asientoaudit_logatómicos; evento Redis para E16 tras commit; notificación tardía/retroceso →recorded_no_change(honesto, RF-FIR-15).motivo_devolucionendevuelto; consulta por guíaGET /envios/{id}/tracking(pull, stub). Cierra el read-up del write path (D-14 #3): el estado manual y tracking resuelven el clearance del llamante. Migración tenant017. Deuda declarada (roadmap): conector concreto E11 a cada operador (hoy stubconsultar_estado); acuse de entrega (acuse_file_idcon SHA-256 + incorporación al expediente, spec invariante 12) sigue en el flujo humano F3; provisión/rotación del secreto HMAC por operador vía E14; alerta al productor antedevuelto. - E14 — PINAR (Plan Institucional de Archivos) ✅ (2026-07, RF-ADM-08; Acuerdo AGN 003/2015): OrpycaMCP gestiona el PINAR como instrumento de planeación (lo modela, prioriza, versiona y le da seguimiento) y lo articula por referencia con CCD/TRD (E04), FUID/IUD (E12/E17) y plan de preservación (E10) — sin duplicar el dato del dueño. En
tenant-service, prefijo/api/v1/pinar. Metodología del AGN en 6 pasos → 9 tablaspinar_*(migración tenant007): diagnóstico→aspectos críticos con riesgo, 5 ejes articuladores (seed), priorización determinista (matriz aspecto×eje, prioridad = Σ impactos, ordena el mapa de ruta), visión, objetivos, proyectos (meta/indicador/responsable/recurso/tiempos/avance), seguimiento append-only (trigger BD;avance_pctproyectado del último registro), y articulación con instrumentos. Máquina de estadosborrador→aprobado→en_ejecucion→cerradocon un único plan en ejecución por tenant (índice único parcial +UniqueViolationError→409), versionado al reformular, aprobación con acto administrativo (422 si falta) e imputable (400 si falta el actor) auditada en laaudit_logINMUTABLE (E08, hash-chain) víaorpycamcp_common— igual que crear/ejecutar/cerrar. Contenido CONGELADO al aprobar (_EDITABLE = {borrador}, correspondencia acto↔contenido); las mutaciones estructurales (solo en borrador) auditan enadmin_audit. Invariantes (índice único, trigger append-only, CHECK impacto 1–10 / avance 0–100) + el wiring cross-schema aaudit_logy su inmutabilidad validados contra Postgres real (que además destapó unAmbiguousParameterErrorenset_estado, corregido). 16 tests nuevos. Auditado por seguridad (2 Media) y conformidad (2 Media, Ac. 003/2015) — sin bloqueantes, todos remediados. Decisión que completa la spec: transiciónaprobado→en_ejecucion(POST /planes/{id}/ejecutar) añadida para hacer verificable el invariante. Para instalarorpycamcp_commonel build de tenant-service pasó a contexto raíz (Dockerfile + compose, patrón ADR-010). UI del PINAR — MVP ✅ (Fase 7 cierre de desfase API↔UI, ver E22 abajo): listado + workspace con ciclo de vida y tablero. Pendiente de E14: Form Builder SurveyJS (RF-ADM-06); resto de la superficie de UI del PINAR (aspectos críticos, priorización, objetivos, proyectos, seguimiento, instrumentos, mapa de ruta). La interfaz cerró su desfase el 2026-08-29: consumía 6 de los 31 endpoints del módulo —no había forma de crear un aspecto crítico, un objetivo ni un proyecto desde el navegador, así que el tablero salía siempre vacío y la propia pantalla lo declaraba «fase posterior de PINAR»—. Un tablero que no puede llenarse nunca no es un estado vacío: es un falso verde. Tres pestañas añadidas (aspectos con su matriz de priorización, objetivos y proyectos con seguimiento append-only, e instrumentos articulados), espejando los dos ciclos de vida que no se solapan del servicio. Sin cambios de backend. - ADR-025 — La TRD como instrumento convalidado (E04, versionado append-only) ✅ Increment 1+2 + remediación (migración 038) (2026-07/08, Ac. AGN 001/2024 que compila el 004/2019; cierra hallazgo Crítico de
archival-compliance-auditor):UNIQUE(code)→UNIQUE(code, version);trd_seriesganaestado(borrador/aprobada/convalidada/migrada/derogada) + los campos del acto administrativo (aprobación del Comité —acta_comite/fecha_aprobacion_comite, ahora escritos poraprobar—, convalidación del Consejo, RUSD, publicación); triggertrg_trd_series_append_onlyrechazaUPDATE/DELETEsustantivo fuera deborrador(excepción explícita:pdfa_profile) y, desde la 038, blinda a nivel de motor una filaconvalidada: el acto es inmutable,estadosolo puede ir aderogada,rusd_radicado/fecha_publicacionadmiten fill-once;PATCH /trd/{id}deja de recalcular retroactivamente — solo aplica sobreborrador;POST /trd/{code}/versionesclona la vigente como fila nueva (o la última por número si el código quedó sin vigente, 038).expedientes.trd_serie_idse congela al CERRAR (triggertrg_expedientes_trd_pin_immutable) — cierra la fuga retroactiva que motivó el hallazgo. Backfill honesto: toda serie preexistente quedaestado='migrada', con marcatrd_revision_requeridaen los expedientes cerrados bajo ella. Circuito de convalidación (máquina de estados aislada en el service layer):aprobar(borrador→aprobada, exige acta del Comité),devolver(aprobada→borrador, motivo obligatorio, 038 — cierra el callejón sin salida deaprobada),convalidar(aprobada→convalidada, exige acta previa del Comité, deroga en la misma tx la vigente previa con re-apunte colisión-segura de FK huérfanas — 038; omigrada→convalidada, ratificación D6),registrar-rusd(038, RUSD/publicación posteriores a la convalidación),derogar(convalidada/migrada→derogada, bloqueado si quedan expedientesopenbajo el código, 038 — decisión: no exige sucesora ya convalidada, para no impedir retirar series sin uso).GET /trd/revision-pendiente— no-read-up por clearance + traza de consulta añadidas en la 038 (regresión de seguridad cerrada: el informe no filtraba nivel de seguridad). Gate D5 para la eliminación efectiva — criterio corregido en la 038: autoriza sobre el snapshot congelado (serie_estado/acto_administrativo), no la fila viva —convalidada, oderogadacon acto (el criterio original era el contrario del que exige el ADR y habría bloqueado eliminaciones legítimas); sigue sin caller porque el job de disposición no existe (deuda declarada, referenciada endocumentos/specDrive/trazabilidad.md). 701 tests unitarios + 151 de integración (Postgres real), sin roturas (antes 679/145 y 145 respectivamente). Pendiente (Increment 3, roadmap declarado): UI de administración TRD versionada, versión+acto en el índice electrónico (perfil v5), re-baselining explícito por expediente, propagación adocuments.disposition. - ADR-026 — TVD de fondo acumulado (E04) ✅ Increment 1 (registro y convalidación, migración 039) (2026-08, Ac. AGN 001/2024 que compila el 004/2019; cierra el hallazgo de
archival-compliance-auditor: "todo el modelo es exclusivamente TRD, un fondo acumulado no tiene dónde poner su TVD"): la TVD es el mismo instrumento que la TRD — discriminadortipo_instrumento ∈ {TRD, TVD}sobretrd_series, no tabla propia (D1: bifurcarexpedientes.trd_serie_id, con siete derivaciones WORM COMPLIANCE irreversibles, es el diseño descartado). Cuatro capas de blindaje del discriminador: (1) espacio decodeúnico y compartido con TRD (índices de la 037 intactos —409 instrumento_code_en_usoindicando el tipo del ocupante); (2) dos vistasv_trd_series/v_tvd_agrupacionesconWITH CHECK OPTION—list_trd_seriespasa deSELECT * FROM trd_series WHERE 1=1aFROM v_trd_series, cerrando la consulta de la clase "filtro olvidado"; (3) CHECK por tipo en ambas direcciones (trd_series_tvd_forma_check/trd_series_trd_forma_check: TVD exigefondo_nombre/fechas extremas/justificacion_valoracion+archivo_gestion_years=0, TRD exige esas cuatro en NULL); (4) triggertrg_expedientes_pin_tipoen forma ESTRICTA — rechaza CUALQUIER expediente amarrado a una TVD, riesgo WORM cero garantizado por el motor. Router/api/v1/tvdespejo 1:1 de/api/v1/trd(mismo circuito completo: crear/versiones/aprobar/devolver/convalidar/registrar-rusd/derogar),tipo_instrumentofijado por el router, permiso reutilizadoUSUA_PERM_TRD, asientos de auditoría distintos (archive.tvd_*). GemeloGET /api/v1/{trd,tvd}/{code}/versiones(declarado por ADR-025 y nunca implementado) añadido para ambos instrumentos. Ruta registrada en el gateway. 721 tests unitarios (+20) + 164 de integración (Postgres real, +13: dos schemas de tenant, CHECK/vistas/trigger por mutación), sin roturas (antes 701/151 y 151). ✅ Increment 2 (amarre y calendario del fondo acumulado, migración 041):expedientes.origen+fecha_extrema_inicial/fecha_extrema_finalcon CHECK de forma en ambas direcciones (unfondo_acumuladoexige fecha extrema final; uncorrienteno puede cargarlas); trigger de la Capa 4 relajado de "ningún expediente puede amarrarse a una TVD" a la regla bidireccionalorigen↔tipo_instrumento— nicorriente→TVDnifondo_acumulado→TRD, igual de dura, probada por mutación en ambos sentidos con SQL crudo. D4 cerrado:compute_retention(serie_id, base_date, *, base_date_origen)con el parámetro keyword-only y sin default — es sintácticamente imposible invocarlo sin declarar la procedencia, así que no hay ruta de "se me olvidó"; el antiguoclosed_atse renombró abase_datepara no presuponer semántica de cierre; la fase de gestión se fuerza a 0 dentro del método sin confiar en el CHECK de la 039; y la bifurcación real vive enclose_expediente, exhaustiva sobreorigen. El snapshot declarabase_date/base_date_origensiempre, para ambos instrumentos, para que nadie confunda "es la fecha de cierre" con "se olvidó escribirlo". Hueco adicional cerrado, no previsto por el ADR: al permitir cerrar un expediente de fondo acumulado, los seis resolutores WORM (índice, acta, AIP) habrían calculadoretain_untildesdeclosed_at— el daño de D4 por otra puerta; quedan fail-closed devolviendoNone. Calendario ya vencido = caso normal, sinmax(fin, hoy)ni redondeo. Filtro?tipo=enrevision-pendiente. 721 unit + 168 integración; el test que el ADR exige usafecha_extrema_final=1985-06-30y aseverafin_archivo_central == 1990-06-30 < hoy, resultado aritméticamente imposible con la base equivocada. Increment 3 — parte no-WORM ✅ (2026-08-07):origen+ fechas extremas en el import masivo (BatchExpedienteCreate+ elINSERTdebatch_service, cerrando el modo de fallo que el ADR solo documentaba — sin esto, migrar un fondo acumulado, que ES una carga masiva, era imposible en la práctica) con la regla de forma D4 extraída a una función compartida por las rutas unitaria y masiva (dos copias de la regla que fija la fecha base de unretain_untilirreversible se separan en cuanto una cambie); y UI:/expedientesgana el selector de origen que cambia de catálogo (TRD ↔ TVD, cargados por separado — mezclarlos y filtrar en cliente reintroduciría en el front la consulta de la clase «filtro olvidado» que el ADR sacó de la base), limpia el instrumento al cambiar de origen, avisa y enlaza a/admin/tvdsi no hay agrupaciones en vez de degradar al catálogo de TRD, y declara que un calendario ya vencido es lo normal en fondo acumulado. Cerrados de paso 5 puntos de repoblación del formulario que perdían origen y fechas tras unfail(). 726 tests de archive (+5) y 1380 de frontend (+5), ambos lados verificados por mutación. Increment 3 — resto, pendiente: clamp deretain_untilya vencido y cableado WORM real del fondo acumulado (hoy los seis resolutores devuelvenNonefail-closed paraorigen='fondo_acumulado': no protegen, solo posponen con backoff — estado correcto hasta que la fecha base seafecha_extrema_finalcon su clamp y esté probada contra Postgres real; el ADR lo llama «el riesgo número uno» porque un error se materializa en objetos COMPLIANCE imborrables durante décadas), y el gate de publicación previa del inventario de eliminación, bloqueado a la espera de que se fije artículo y plazo exactos. Frontend del Increment 1 ✅ (ver E22 abajo): pantalla/admin/tvd+ circuito de convalidación compartido con/admin/trd. - E11 — OAI-PMH (cosecha de metadatos) ✅ (2026-07, RF-INT-02, mitad; SGDEA R.12.1):
GET /api/v1/public/oai/{tenant}en document-service (público por-path bajo/api/v1/public/, el gateway no exige JWT ahí — sin tocarlo, como el webhook postal). OAI-PMH 2.0 completo: los 6 verbos (Identify,ListMetadataFormats,ListIdentifiers,ListRecords,GetRecord,ListSets), metadatos Dublin Core (oai_dc), cosecha selectivafrom/until/set(set = doc_type),resumptionTokenpaginado, códigos de error OAI (badVerb/badArgument/cannotDisseminateFormat/idDoesNotExist/noRecordsMatch/badResumptionToken). Requisito CRÍTICO de seguridad (Ley 1712/2014 arts. 18-19, RF-SEG-08): la cosecha SOLO expone lo PÚBLICO (nivel_seguridad=1, no anulado). El recorte vive en la FUENTE (OaiRepository, filtro único_PUBLIC) → el servicio y el gateway nunca ven un reservado, así la omisión es INDISTINGUIBLE por construcción:completeListSizecuenta solo público,ListSetsno enumera un set solo-reservado,GetRecord/ListMetadataFormatssobre un id reservado →idDoesNotExist. Validado contra Postgres real (público vs reservado/clasificado/anulado +untilde día inclusivo). Identificadoroai:{tenant}:{tracking_number}(preserva el radicado como identidad). 17 tests (213 verdes en document-service). Auditado seguridad (apto, sin Críticos/Altos: chokepoint_PUBLICvalida la indistinguibilidad; remediados 2 Media —tenant inexistente→400 no 500, offset negativo→badResumptionToken— + 1 Baja saneo de control chars XML) y conformidad OAI-PMH 2.0 (remediados 2 Media:<request>sin atributos en badArgument,untilde día inclusivo). Perfil CMIS 1.1 mínimo de lectura ✅ (2026-07, otra mitad de INT-02):GET /api/v1/public/cmis/{tenant}(repositoryInfo) +/root?cmisselector=object|children|content(Browser Binding JSON) — radicados públicos →cmis:document, raíz sintéticacmis:folder, paginación maxItems/skipCount.getContentStreamsirve los bytes del anexo (viastorage_client, streaming) solo si el radicado es público (get_public_anexo, JOIN público-only; consistente con Ley 1712: la info pública es descargable). Mismo chokepoint_PUBLICque OAI → getObject/getContent de un id reservado = 404 indistinguible, getChildren/numItems solo público; validado contra Postgres real. Perfil de solo-lectura (capabilities query/write/versioning en none/false) conrepositoryUrl/rootFolderUrldel Browser Binding; sin navegación de carpetas anidadas ni CMIS-SQL (Roadmap). 11 tests (224 verdes en document-service). Auditado seguridad (apto sin Críticos/Altos: fuga de contenido reservado, indistinguibilidad y aislamiento correctos por herencia del chokepoint + doble scoping en storage; remediados 1 Media saneo RFC 6266 delContent-Disposition/Content-Typereutilizando el patrón de storage-service + 1 Baja adquisición de conexión a prueba de excepciones) y conformidad CMIS 1.1 (remediados 1 MediarepositoryUrl/rootFolderUrl, 1 BajanotSupported→405). INT-02 completo (OAI-PMH + CMIS). Pendiente de E11: export/import con verificación de fixity (INT-05), Dublin Core/ISAD(G)/EAD completos, conectores SUIT/MIPG/SECOP, API keys/OAuth2 client-credentials, SOAP/GraphQL legado (opcional). - E11 — Exportación interoperable de radicados (INT-01) ✅ (2026-07, RF-INT-01):
POST /api/v1/exporten document-service (autenticado, gateUSUA_PERM_EXPEDIENTE+ no-read-up por-radicado) produce un paquete interoperable ZIP:manifiesto.json(UUID de exportación + comentario + marcas inicio/fin + lista de entidades + excluidos content-free por sobre-clearance, Ac. 001/2024 4.3.2.3),esquema/radicado.schema.json(JSON Schema draft 2020-12 publicado),radicados/{numero}.jsonvalidado contra ese esquema ("en su totalidad": metadata sistema+contextual E03 + disposición TRD + ACL nivel/clasificación + anexos + historial deaudit_log), los binarios de anexos, ychecksums.txt(SHA-256 por miembro, fixity verificable — Ley 594 art. 19). El número de radicado se preserva como identidad inmutable (Ac. 060/2001), clave para INT-05. El historial exportaaction/fecha/actor/canalsin el payload (que puede tener el fundamento de clasificación). No-read-up validado contra Postgres real. Empaquetado físico ZIP provisional (BagIt/OAIS+WORM = E10, roadmap); síncrono (job asíncrono = follow-up). 8 tests + smoke pg real (232 verdes en document-service). Auditado seguridad (APTO sin Críticos/Altos: no-read-up del paquete, aislamiento de historial por tenant, scoping de binarios, garantía de esquema, fail-closed de RBAC/clearance correctos) y conformidad (CONFORME 6/8). Remediados: auditoría del EGRESO (radicado.exportenaudit_logcon quién/alcance/UUID content-free — Alta, ambos auditores);antecedenteE↔S (responde_a→número portable, totalidad referencial RN-3); tope anti-DoS (422 si >5000, el ZIP síncrono no bloquea el worker); anexo purgado como entradaausentetrazable; arcname del tracking saneado; asunto NULL coaccionado. Es el cimiento de INT-05 (import con fixity), que se construye encima. Pendiente: INT-05 import; export de expedientes/usuarios/clases; job async; empaquetado BagIt (E10). - E11 — Importación interoperable de radicados (INT-05) ✅ (2026-07, RF-INT-05; cierra el hallazgo Crítico de la spec):
POST /api/v1/importen document-service (autenticado, gateUSUA_PERM_EXPEDIENTE+ no-write-up por clearance), recibe el paquete ZIP de INT-01 como cuerpo crudo. Validación TODO-o-NADA antes de tocar la BD (spec §5 inv. 1): cada radicado valida contra el JSON Schema de CONFIANZA del servicio (no el que trae el paquete, que podría venir laxo) y el SHA-256 de cada binario se verifica contra el declarado; un checksum no coincidente →422sin estado parcial (Ley 594 art. 19, Ac. 001/2024). Reingiere preservando el número de radicado original como identidad inmutable (Ac. 060/2001) — una colisión se OMITE (no sobreescribe); reconstruye la relación E↔S (responde_a) por número; sube los binarios a storage (storage_client.upload_bytes); ingest atómico en transacción; auditaradicado.import(content-free). Guardas zip-slip/zip-bomb (regex de nombres de miembro, tope de entradas + tamaño descomprimido total/por-fichero) + tope de subida 512 MiB. Round-trip real export→import validado contra Postgres real (identidad/metadata/relación preservadas + idempotencia). 10 tests + smoke (242 verdes en document-service). Auditado seguridad (APTA sin Críticos/Altos: aislamiento de tenant, no-write-up fail-closed, fixity mandatoria sin TOCTOU, zip-slip/bomb cerrados) y conformidad (sin incumplimiento; cierra el hallazgo Crítico de E11). Remediados: auditoría dura (no best-effort, dentro de la tx, con la lista de radicados importados para procedencia); antecedentes no resueltos declarados (antecedentes_no_resueltos); savepoint por-ítem (absorbe la carrera de unicidad del tracking sin degradar a 500); subida de binarios FUERA de la tx (no sostener la tx de BD a través de HTTP); anexo malformado→422; topes conservadores (256 MiB). Interoperabilidad de radicados (INT-01+INT-05) completa. Pendiente: import/export de expedientes/usuarios/clases; job asíncrono; adaptador para SGD externos; reconciliartracking_sequences(namespace de numeración) y limpieza de huérfanos en storage ante rollback (deudas declaradas). - E11 — Descripción archivística EAD 2002 / ISAD(G) en OAI-PMH (INT-06, parte) ✅ (2026-07, RF-INT-06): segundo
metadataPrefix=eaden el MISMO endpoint OAI-PMH (GET /api/v1/public/oai/{tenant}, junto aoai_dc). Cada radicado público se disemina como fragmento EAD 2002 autocontenido (namespaceurn:isbn:1-931666-22-9) con<archdesc level="item">(unidad documental simple) y los 6 elementos obligatorios de ISAD(G)/NTC 4095 para intercambio — 3.1.1<unitid countrycode="CO" repositorycode>(código de referencia país+repositorio+número), 3.1.2<unittitle>(asunto), 3.1.3<unitdate normal>(fecha), 3.1.4@level="item", 3.1.5<physdesc>, 3.2.1<origination>— más 3.4.1<accessrestrict>(público, Ley 1712/2014), 3.7.2<descrules>(ISAD(G) 2ª ed. / NTC 4095),<langmaterial>,<repository>. Incremento solo-lectura, sin migración ni SQL nuevo: reutiliza elOaiRepositoryy su chokepoint_PUBLIC→ el formato es ORTOGONAL al recorte de seguridad (elegireadno sortea el filtro ni crea oráculo de reservados; un id reservado/anulado →idDoesNotExistindistinguible enoai_dcy enead); elresumptionTokenpreserva elmetadataPrefix. Auditado seguridad (APTO sin Críticos/Altos: EAD no expone ningún campo nuevo del radicado —opera sobre las mismas 6 columnas de_RECORD_COLS—, chokepoint ortogonal por construcción, round-trip del prefijo doblemente validado pre-encode/post-decode; remediada 1 Baja de consistencia de saneo C0) y conformidad (CONFORME: 6 obligatorios cubiertos + EAD estructuralmente válido; remediadas #3 Media código de referencia país/repositorio para intercambio, #5 Baja<accessrestrict>+<descrules>). 6 tests EAD (245 verdes en document-service). Descripción MULTINIVEL de expedientes (INT-06, cierre de la deuda) ✅ (2026-07, RF-INT-06): nuevo endpoint públicoGET /api/v1/public/archive/oai/{tenant}en archive-service que disemina cada expediente público como fragmento EAD 2002 MULTINIVEL — caminando la cadenatrd_series.parent_id(el CCD, serie↔subserie) construye<archdesc level="fonds">→<dsc>→<c level="series">→(<c level="subseries">…)→<c level="file">(el expediente), cumpliendo ISAD(G) 2.2 (general→específico) y 2.4 (vínculo al nivel superior), con los 6 obligatorios en el nivelfile(<origination>=institución productora) + scopecontent/accessrestrict/descrules. Complementa el item-level de radicados (document = unidad simple, archive = unidad compuesta). Gateway rutea/api/v1/public/archive/antes del catch-all público. Seguridad (Ley 1712, RF-SEG-08): chokepoint_PUBLIC = "nivel_seguridad = 1"enexpedientes(reservado →idDoesNotExistindistinguible); frontera anti-fuga item-level: para enlevel="file"— nunca enumera lostracking_numberde radicados hijos (un expediente público puede contener radicados reservados y archive no tiene su nivel; solo un conteo agregado en<physdesc>; test-guardián por grep del XML). Auditado seguridad (APTO sin Críticos/Altos; remediada guarda de profundidad 50 en la CTE recursiva del CCD contra ciclos —endpoint anónimo—, validada en pg real) y conformidad (CONFORME al alcance; remediado<origination>=institución, no la serie). 22 tests + suite completa (627 verdes en archive-service). Pendiente (deuda de roadmap, NO incumplimiento): discriminador de tipo-de-nivel para CCD de 3+ niveles; Dublin Core calificado; validación contra XSD EAD oficial en CI. - E17 (menores): rótulo PDF/QR, capacidad/ocupación, custodia externa.
- E08 (reclasificación): el endpoint
PATCH /documents/{id}/security-levelya reclasifica elnivel_seguridadcon permisoPERM_RECLASIFICAR+ no-read-up/no-write-up, auditoría inmutable (motivo + fundamento jurídico) y re-sync del snapshot de firma vía evento — cierra el residual de staleness de RF-SEG-08. La UI ya está hecha: chip de nivel + modal de reclasificación gateado porPERM_RECLASIFICARen el drawer de la bandeja (connivel_seguridadexpuesto en el read path de radicados). Índice de información clasificada y reservada ✅ (Ley 1712 art. 20 + Decreto 1081/2015):GET /api/v1/reports/indice-reservado(.csv)genera el registro de radicados clasificados/reservados. Se materializa la metadata de clasificación vigente enradicados(mig018, poblada/limpiada porreclassify); mapeo nivel 2→art. 19 (reservada) / nivel 3→art. 18 (clasificada); CONTENT-FREE (nunca el asunto), ortogonal al clearance (registro completo por mandato legal), gatePERM_RECLASIFICAR, CSV con anti-inyección de fórmulas, auditoría agregada. Seguridad APTO; conformidad CONFORME (3 bloqueantes re-auditados y cerrados: fundamento OBLIGATORIO al clasificar —radicar o reclasificar, 422 si falta, Ley 1712 art. 19/28— → el índice nunca queda con fundamentos NULL; mapeo nivel↔artículo por terminología de la Ley;tipo_documental→tipo_radicado). No-write-up al radicar clasificado ✅ (espejo M3 archive, cerrado en el barrido de deudas):DocumentService.createresuelve el clearance del radicador y devuelve403 clasificacion_forbiddensinivel_seguridad > clearance(solo nivel≥2) — un PUBLICA ya no puede originar una CLASIFICADA. Deuda fast-follow restante: índice a nivel de serie + expedientes/series (archive); campos del registro de activos (dependencia/funcionario, Dec. 1081 art. 2.1.1.5); catálogo cerrado de fundamentos; backfill de clasificados previos. Aún diferido: outbox transaccional del evento de reclasificación, doble control de desclasificación. - E08 (no-read-up en expedientes): cerrado el bypass de clearance del listado
GET /expedientesy delPATCH /{id}(filtraban con el default "sin restricción"); ambos aplican ahora no-read-up con elCOUNT/X-Total-Countfiltrado. (M1) ✅close/transferde un CLASIFICADA: se decidió (Opción B, compliance) que la disposición es ortogonal al clearance de lectura (un archivista de custodia dispone sin leer el contenido); el bug real —404 espurio post-mutación por re-lectura sinuser_id— se corrigió devolviendo unExpedienteDisposicionResultacotado que no expone contenido. (M3) ✅ write-up enPOST /expedientes: no-write-up con403explícito (no clamp) — no se puede originar un expediente por encima del propio clearance; el audit de creación motiva el nivel (Ley 1712 art. 20). (M2) ✅ la respuesta delink/batch/unlink/rebuildfiltraba membresía/conteo/hash/ciclo-de-vida a bajo clearance: la mutación es legítima (composición documental sin clearance de lectura, como M1), así que se acota la salida — ack uniforme e indistinguible byte a byte en todo estado, la mutación siempre ocurre y siempre audita. La terna RF-SEG-08 (M1/M2/M3) de expedientes queda cerrada. E12 (transferencias) también: la transferencia como acto de custodia es ortogonal al clearance de lectura (create/enviar/recibir/rechazar, como M1 — decisión sin código), pero el FUID (asunto/serie/signatura = contenido) aplica ahora no-read-up en sus 3 puertas (/transferencias/{id}/fuid→ 404 total;/fisico/fuid+.xml→ filtro por fila). Diferido:PERM_CLASIFICARdedicado para nivel≥2 (hoy basta el clearance). Seguimiento cerrado: (a) ✅GET /unidades/{id}/expedientes(enumera expedientes de una unidad) ahora filtra por fila (no-read-up); su gemelaGET /expedientes/{id}/unidades(dónde está un expediente ya conocido por UUID) se deja ortogonal (custodia, como M1, decisión documentada). (b) ✅ la creación por batch ahora deja asientoarchive.expediente_createdenaudit_log. Atomicidad mutación↔asiento ✅:create_expediente, la creación por batch (por ítem) ylink/unlink/link_batch(incremento gemelo) envuelven ahora sus escrituras enconn.transaction()con el asiento como último write → un fallo a mitad ya no deja un expediente/vínculo/exclusión sin su asiento de auditoría; de paso se cerró una carrera de código duplicado ennext_expediente_code(colapsado a…RETURNING).close/transfer/recibir✅ (cerrado): ya no quedan fuera — el tramo DB se envuelve enconn.transaction()(transición → evento → índice → asiento) y el sellado XAdES (HTTP) queda post-commit, best-effort. Se cerraron además: el TOCTOU de la disposición (transition_expedienteganaexpected_status→WHERE ... AND status = $N: doscloseconcurrentes ya no commitean ambos duplicando asientos en unaudit_logindeleble); la ruta gemelaTransferenciaService.recibir, que congelaba el expediente atransferredsin tx, sin evento y sin asiento; el actor ausente en el asiento de transferencia (el router ni declarabaX-User-Idpese a que el gate RBAC ya lo exige); y la ruta alternaPATCH /expedientes/{id}, que ejecutaba las mismas transiciones con unUPDATEpelado —statusretirado del schema (conextra="forbid": sin él se ignoraría en silencio, fail-open) y del repositorio (era el segundo escritor no auditado deexpedientes.status). El PATCH de metadatos audita ahora la reclasificación TRD (archive.expediente_reclasificado, valores antes/después), y los intentos denegados dejanexpediente_close_failed/transfer_failed. Test de integración contra Postgres real ✅ (cerrado): nuevo carriltests/integration/(23 tests, schema de tenant desechable + commits reales) que ejercita el rollback físico,verify_chain, la concurrencia real y el append-only — con verificación por mutación (quitarle la tx al SUT pone los tests en rojo; los tests con mocks no podían detectarlo). Residuales Baja abiertos: la ubicación física (signatura/folios) de un CLASIFICADA vía/expedientes/{id}/unidades(custodia, a barrer con compliance junto a los call sites ortogonales); un tope de tamaño de lote paralink_batchsobre un expediente de alta concurrencia (advisory lock por-expediente);PERM_CLASIFICARdedicado para nivel≥2. Ciclo de vida de transferencias (E12) ✅ (cerrado, incremento propio):create/enviar/recibir/rechazarenvuelven ahora mutación→evento→asiento enconn.transaction()con asiento de éxito y de denegación (los cuatro actos de custodia dejan traza; el rechazo con asiento propio y motivo);transferencias.update_estadogana la guarda de estado esperado (WHERE id=$N AND estado=$expected, keyword-only obligatorio) → la carrera letalrechazar-vs-recibir(transferencia 'rechazada' + expediente ya congelado 'transferred') queda cerrada, validada por mutación; identidad unificada enapp/core/actor.py(X-User-IdUUID como fuente única, sin fallback a username, fail-closed) para transferencias y paraclose/transferde expedientes; la hoja de ruta puebla la columna tipadaactor(antes NULL). Migración tenant020(enviada_por). Conformidad Tít. 4.4 ✅ (CONFORME): en un segundo incremento se cerró el par conjuntamente bloqueante que faltaba — H-G (recibir()ahora re-verifica la fixity del acervo antes de congelar la custodia, vía helperIndexService.fixitypura-BD SHA-256 compartido converify(), conCLEARANCE_SIN_RESTRICCIONpara no imponer clearance de lectura a un acto de custodia; mismatch →409 fixity_mismatchque revierte sin congelar, con solo un conteo en la respuesta y la enumeración de radicados en elaudit_log; Ley 594 art. 16, ISO 16363), H-K (rechazo conmotivoobligatorio en columna propia sin pisar la observación de preparación) y H-J (rechazada_at). Migración tenant021. 293 unit + 35 integración verdes; H-A/H-G por mutación. APTO PARA MERGE por seguridad (×2, sin Crítico/Alto) y conformidad (×2 convergentes): Ac. AGN 001/2024 Tít. 4.4 CONFORME para el ciclo de transferencias. H-I ✅ (cerrado): el FUID se congela como acta de entrega inmutable al recibir — tablatransferencia_acta(1:1), XML canónico +acta_fuid_sha256+ los tres responsables "Elaborado/Entregado/Recibido por" con fechas +indice_version, todo dentro de la tx atómica de recepción (inline en BD, no MinIO, para no romper la atomicidad de H-G); lectura víaGET /transferencias/{id}/actacon no-read-up (generación ortogonal al clearance, lectura con no-read-up). Migración022. 299 unit + 41 integración; APTO por ambos auditores. Alcance honesto (RF-FIR-15): se congela el FUID vigente como acta; NO conformidad con todas las columnas del Anexo AGN (falta entidad productora, unidad administrativa, cargo/firma — deuda de E17); inmutabilidad procedimental + verificable por fixity, no por trigger de motor. Trigger append-only del acta ✅ (cerrado): migración023—transferencia_actaes ahora inmutable forzada por el motor (triggersBEFORE UPDATE OR DELETE FOR EACH ROW+BEFORE TRUNCATE FOR EACH STATEMENT, decisión (B): UPDATE+DELETE+TRUNCATE), no solo procedimental; además elacta_fuid_sha256se ancla en la hash-chain deaudit_log(archive.transferencia_recibida) → la manipulación tras un bypass del trigger (solo superusuario) es detectable. 299 unit + 47 integración; ambas garantías por mutación; APTO por ambos auditores. Alcance honesto (RF-FIR-15): capa de trigger equivalente aaudit_log+ tamper-evidence del SHA anclado; NO paridad total (falta la capa de privilegiosREVOKE, innecesaria sin DDL en el rol de la app). H-L/H-M ✅ (cerrado): integridad de la creación de transferencias — migración024(índice único parcialWHERE estado IN ('preparada','enviada')→ una sola transferencia activa por expediente, con re-preparación tras rechazo; concurrencia serializada por el índice → 409transferencia_activa_existente) y validación de coherencia origen/destino por tipo encreate()(primaria: gestión→central; secundaria: central→histórico; el salto que evita el archivo central se rechaza;422; Ley 594 art. 23). 310 unit + 52 integración; ambas por mutación; APTO por ambos auditores. Origen/destino obligatorio + cotejo ✅ (cerrado para lo físico): el residual RF-FIR-15 de origen/destino se cerró con obligatoriedad condicionada a la tenencia física localizada — si el expediente tiene ubicación física (víaexpediente_unidad, M2M), origen/destino son obligatorios (422 ubicaciones_requeridas_fisico) y el origen debe cotejar contra las ubicaciones actuales (422 ubicacion_origen_no_actual, respetando la multiplicidad); si es puramente electrónico, la transferencia es lógica (rama H-M previa). Sin migración (obligatoriedad encreate()). 320 unit + 59 integración; cotejo por mutación; APTO por ambos auditores; conformidad SATISFECHO (inventario FUID de partida/llegada + cotejo del origen real; caso electrónico conforme por diseño). Residuales declarados (deuda E17): rama B con par aportado no cotejado, tenencia no localizada (ubicacion_idNULL), sin enforcement de BD. Caveat de despliegue (H-L): el índice único024falla sobre un tenant con datos que ya violen el invariante (saneo previo). Firma XAdES del acta ✅ (H-H cerrado para su alcance, E06/F4): el acta de entrega recibe ahora un sello XAdES-B/T/LT/LTA + OCSP institucional enveloped sobre su XML canónico (cubre los bytes cuyo SHA se ancla en la hash-chain) → integridad + no-repudio institucional + fecha cierta verificable vía TSA (no acreditada). Sellado best-effort (aditivo): fila hermana mutabletransferencia_acta_firma(migración026) nacidapendiente_firmaen la tx derecibir(), intento síncrono acotado post-commit (atribuido al receptor) + reconciliación por el job generalizado a dos tipos de objeto (SYSTEM_RECONCILER_ID, cadencia menor);GET /actagana bloquefirma(+?verify=true),POST /acta/firmarreintento manual. signature 249 + archive 386 unit + 70 integración; APTO (seguridad, 1 Baja de nombre MinIO remediada para acta e índice) + CONFORME (H-H cerrado). Alcance honesto (RF-FIR-15): el sello institucional atribuye los tres responsables por identidad autenticada anclada, pero NO son las firmas personales de cada uno con cargo (PKI por-usuario, deuda E17); no acreditado ONAC; sin WORM del artefacto (E10). Endurecimientos pendientes (NO bloquean Tít. 4.4): H-H residual (pinindice_firma_idcompleto —indice_versionya se captura), columnas completas del Anexo FUID — entidad productora, unidad administrativa; el trío de responsables del FUID ya firma personalmente ✅ — receptor ("Recibido por", Inc.1), remitente ("Entregado por", Inc.2) y elaborador ("Elaborado por", rolelabora, Inc.3, firmante ADITIVO ortogonal afirmada_completa, campoelaborador_firmado, migración028) (POST /transferencias/{id}/acta/firmar-personal, rol derivado de la identidad,completituda 5 valores confirmada_completabilateral, flagauto_traslado,acreditado=false) (E17), enforcement en BD de la obligatoriedad física + cotejo en rama B (E17), evento PREMIS fixity-check / objeto PREMIS del acta (E10/F5),canal="api"hardcodeado yobject_refpor UUID en los asientos del sellado. Reconciliación del sellado del índice ✅ (cerrado el modo de fallo permanente, E15/E06 Inc.8): el residual "expedienteclosedcon índice sin firmar" se aborda con cuatro controles (alcance dictado por conformidad) — (1) elimina el modo de fallo permanente; (2) intento síncrono acotado de sellado en el propio cierre cuando el sello institucional está montado (firmar_indice_close, 2 intentos × ~1s → firmado-al-cierre en el caso común; el cliente distingueseal_absent→ degrada sin reintentar, detransient→ reintento acotado); (3) job de reconciliación (task del lifespan +python -m) con backoff exponencial (migración025:reconcile_intentos/proximo_at/ultimo_error/agotado), advisory lock por tenant e idempotente (asiento gateado por filas afectadas → sin doble-sello ni asiento fantasma bajo carrera); (4) endpointGET /expedientes/indices/pendientes-firma(no-read-up, incluyeagotado) para visibilidad operacional. Atribución de sistema: ruta internaPOST /signature/internal/sign-indice(soloX-Internal-Token, el gateway no lo reenvía; cableada aobjeto_tipo="indice";SYSTEM_RECONCILER_ID) que estructuralmente no puede firmar la cadena personal. Asientoarchive.indice_firmadoconorigen∈close/manual/reconciliation,object_ref=code. archive 359 unit + 65 integración (Postgres real) + signature 240; APTO (seguridad, 1 Media de asiento fantasma remediada) + CONFORME (conformidad). Alcance normativo honesto (RF-FIR-15): conformidad plena con el art. 4.3.2.4 SOLO en el caso común (sello montado + intento síncrono exitoso o convergencia acotada); NO garantiza firma síncrona al instante del cierre, y con el sello institucional AUSENTE el índice quedapendiente_firmaindefinidamente (reconcile_agotado, exige intervención humana) — nunca se fingefirmado. Endurecimiento diferido: alerta proactiva sobre losagotado(índice y acta). - E08 — Esquema de publicación (Ley 1712/2014 art. 12) ✅ (2026-08): hasta ahora «público» se deducía de la ausencia de reserva — un radicado con
nivel_seguridad = 1y no anulado era cosechable por OAI-PMH y servible por CMIS, de modo que no haber decidido nada equivalía a haber decidido publicar. Nueva columnaradicados.publicableconDEFAULT FALSE(migración document019) y acto explícito, motivado y auditado (POST /documents/{id}/publicacion,PERM_RECLASIFICAR+crud≥3, asientosdocument.publicacion_activada/_retirada). Se suma al recorte de clasificación en el chokepoint únicooai_repository._PUBLIC(publicable AND nivel_seguridad = 1 AND status <> 'anulado'), no lo sustituye; elUPDATEexige ademásnivel_seguridad = 1en su propioWHERE, para no guardar la afirmación «esto es público» sobre un documento reservado que la próxima desclasificación publicaría sin que nadie lo hubiera decidido. Ortogonal al kill-switch de canal del bloque 2 (ENABLE_PUBLIC_INTEROP), que sigue existiendo: uno apaga el canal entero, este decide documento a documento. Compatibilidad: los radicados existentes quedan no publicables. Cerrado el 2026-08-13 (cruce API↔pantallas): el acto no tenía pantalla y su resultado no era legible. El endpoint existía y el gateway lo enrutaba, pero ninguna vista lo llamaba — la institución quedaba sin poder publicar salvo por SQL; ypublicableno viajaba en la respuesta del radicado ni en el listado, así que tampoco se podía acreditar qué se había publicado. Añadidos los dos campos al contrato (aditivo) y el drawer «Publicación» en/bandeja. Corregido tras auditoría cruzada (2026-08-13): el acto gobernaba los metadatos pero no los bytes —get_public_anexo, que alimenta elgetContentStreamde CMIS, llevaba una copia delWHEREescrita a mano sinpublicable, y su docstring afirmaba anclar en_PUBLIC—; y una reclasificación lo deshacía: clasificar dejaba la marca latente y la siguiente desclasificación republicaba sin acto. Cerrado con_public(alias)como chokepoint construido, retiro de la publicación al clasificar, y gate de nivel condicionado al sentido (retirar nunca se bloquea). Cerrado el 2026-08-14: el acto llega también a los expedientes (migración archive042,POST /expedientes/{id}/publicacion, drawer en/expedientes/{id}). El recorte de archive no copia la condición de estado de radicados:closed/transferredno son retirada de la circulación y copiarla habría despublicado en silencio todo el archivo cerrado. No hay gemelo del defecto de reclasificación: el nivel de un expediente se fija al crear y ninguna ruta lo muta. Pendiente: el esquema de publicación del art. 12 como instrumento (documento adoptado por acto administrativo) es de la institución que despliega; aquí está el mecanismo, no su adopción. - E08 (no-read-up en la consulta de auditoría) ✅:
GET /api/v1/auditera el último punto del sistema donde el clearance no se aplicaba — cualquier tenedor deUSUA_PERM_ADMINleía en/admin/auditoriala existencia, el número de radicado, el actor, la acción y elpayloadde todo lo actuado sobre CLASIFICADA. La confusión que lo causó merece quedar escrita:USUA_PERM_ADMINes competencia administrativa, no habilitación de seguridad. Cerrado en elWHEREdeAuditQueryRepositorycon resolución en vivo del nivel contraradicados/expedientes(nunca denormalizado: un valor copiado quedaría obsoleto justo en la reclasificación que SUBE el nivel),COUNT/X-Total-Countrecortado con el mismo filtro (si no, el total es un oráculo), lista de ADMISIÓN (se listan los tipos que no pueden referirse a un documento, más los clasificables que resuelvan a un nivel alcanzable; todo lo demás fuera) y sin recorte alguno con clearance máximo (recortar ahí destruye el valor probatorio sin proteger nada)./audit/verifyqueda deliberadamente fuera del recorte: la cadena solo es probatoria si se recalcula entera y su respuesta es content-free. Dos tests de integración contra Postgres real (el recorte vive en el SQL; un mock no puede probarlo) con verificación por mutación del clearance. Dos fallos del propio incremento, corregidos antes de entregar: (a) sin tablas fuente la cláusula se omitía entera —fail-open—, lo cazó el test de integración; (b) la frontera estaba escrita como lista de denegación de cinco tipos yevento/import/file/transferenciaseguían publicando número de radicado y asunto de CLASIFICADA en supayload(el dead-letter serializa el sobre íntegro conbusiness.tracking_number; el import, la lista literal de números; el anexo, su nombre de fichero) — lo destapótenant-security-auditorcensando los ~40 productores deobject_typede los once servicios; inversión verificada por mutación. LECCIÓN: una frontera de seguridad se escribe por ADMISIÓN o el olvido es silencioso — el comentario que afirmaba "olvidar una rama restringe de más" solo era cierto para los cinco tipos enumerados. Residuales declarados: los huecos deid(secuencia monótona expuesta) permiten inferir cuántas entradas se ocultaron y cuándo, sin contenido — no se cierra porqueidcasa conbroken_idde/audit/verify; y el hallazgo derivado en signature-service, ya cerrado (ver E06 abajo). - E06 (
objeto_tipono puede apagar el control de nivel) ✅: el nivel de seguridad de una cadena de firma se resolvía conif payload.objeto_tipo == "radicado"sobre un campo de texto libre del body — una llave de autorización que el llamante escribe no es un control. Tres puertas: variante de mayúsculas ("Radicado"), tipo distinto sobre el identificador de un radicado CLASIFICADA ("borrador"), y elorfail-open que volvía al nivel declarado cuando el objeto no resolvía. Cuarta puerta encontrada al leer y no reportada:/sign-personalgateaba igual, y ahí el campo NO puede acotarse (pasan las actas institucionales) — lo que forzó la corrección de fondo:resolver_nivel_por_objeto(objeto_id)no recibe el tipo, busca el identificador enradicados/expedientesy devuelve el nivel real. Complementos: allow-setCADENA_OBJETO_TIPOS(422fuera del conjunto; el único cliente de/requestses la UI), fuente autoritativa añadida paraexpediente(tenía el mismo agujero por otra puerta), y fail-closed (404neutro) cuando el tipo exige documento registrado y no resuelve. Se conserva el nivel declarado paraborrador/documento, con test propio: apretar ahí sería el defecto simétrico. 5 tests nuevos, 2 verificados por mutación. - E09 (RF-BUS-10 — traza de consulta): cubierto en los tres servicios con lecturas de radicado. Traza agregada por consulta (usuario/criterios/total, sin el texto crudo de
q, separada de la pista por-registro) + traza por-registro de la lectura individual: expedientes en archive-service (expediente_busqueda+expediente_consultado), radicados en document-service (document.radicado_busquedapara list/search/respuestas +document.radicado_consultado) y trámite en workflow-service (workflow.bandeja_consultada+workflow.hoja_ruta_consultada/tramite_consultadopara hoja de ruta / current / events). Los tres servicios usanobject_ref = tracking_number/code(clave de negocio) para correlación cross-servicio. Extensión futura: la traza a servicios sin lecturas de radicado, si en el futuro las tuvieran.
Andamiaje fundacional (Fases 1–6) ✅ Completo¶
Estas fases construyeron la base sobre la que el specDrive eleva la conformidad.
Fase 1 — Framework y Arquitectura¶
- [x] Estructura, CLAUDE.md, agentes, memoria persistente, docker-compose base (PostgreSQL, MinIO, Keycloak, Redis, MailHog), realm Keycloak, init-db.sql, CI/CD, MkDocs
- [x] ADR-001 microservicios · ADR-002 multi-tenancy
- [x] auth-service, tenant-service, api-gateway
Fase 2 — Dominio Core¶
- [x] document-service (radicación E/S/I, numeración atómica, anexos)
- [x] storage-service (MinIO, SHA-256, pre-signed URLs, bucket por tenant)
- [x] archive-service (TRD, expedientes
open→closed→transferred)
Fase 3 — Workflows y Notificaciones¶
- [x] workflow-service (asignación/transferencia, historial, eventos Redis)
- [x] archive-service (vínculo radicado↔expediente)
- [x] notification-service (SMTP, consumer de eventos, historial)
Fase 4 — Calidad e Integración¶
- [x] init-tenant, E2E, ADR-003 asyncpg, lint/type-check en CI, health check orquestado
Fase 5 — Comunidad y Publicación¶
- [x] Guías de despliegue/contribución, README EN, docs bilingüe, AGPL v3, OpenAPI aggregation, GitHub Actions, GHCR, API reference EN
Fase 6 — Características Avanzadas (andamiaje)¶
- [x] TRD seed FondeCund, Batch Documents/Expedientes, Full-Text Search, Workflow Rules Engine
Más allá del specDrive 📋 Planeado¶
- [ ] App móvil nativa (iOS/Android)
- [x] ~~Firma de documentos~~ — firma electrónica nativa + cadena de firma / bandeja del firmante hechas (E06: turno ordenado,
GET /pending, sign/reject/batch); XAdES-B del índice electrónico + sello institucional hecho (Inc.1, ADR-016 addendum); 2FA por OTP-correo en la firma personal hecho (Inc.2, RF-FIR-13); XAdES-T (TSA local) hecho (Inc.4) y XAdES-LT/LTA (CA local de dev + CRL + ArchiveTimeStamp, no acreditado) hecho (Inc.5, ADR-016 addendum); 2FA TOTP RFC 6238 en la firma personal hecho (Inc.6, ADR-016 addendum: secreto en auth-service, AESGCM, verify por bearer); OCSP stapled (RFC 6960) junto al CRL en XAdES-LT — responder delegado per-tenant, cubierto por el ArchiveTimeStamp, no acreditado hecho (Inc.7, ADR-016 addendum); firma PERSONAL PKI por-usuario XAdES-B/T (custodia servidor, sub-CA local de dev, split firma-remota = seam a HSM) hecha (Inc.1 del epic E17/F4;acreditado=false, art. 7, no sole-control); perfil LTA plenamente EN 319 132, OCSP en línea de responder independiente, rotación/revocación de certs de usuario y 2FA WebAuthn pendientes (signature-service). La acreditación ONAC sale de la lista de pendientes del proyecto: es de cada instalación —material del operador de firma que contrate la entidad— y la costura por-entidad ya existe; quedan de nuestro lado solo declarar acreditado un sello real (hoy literalFalse) y contratar la TSA remota acreditada (el cliente RFC 3161 remoto ya está implementado; falta el prestador, que es externo). El sole-control art. 28 vía HSM sigue siendo nuestro y es independiente de la acreditación: con custodia en servidor no hay control exclusivo del firmante aunque el certificado esté acreditado - [ ] Integración SSO corporativo (Entra ID, Okta, LDAP)
- [x] Frontend (E22,
frontend/, ADR-011/020) — prácticamente completo (~14k LOC): login OAuth2 PKCE server-side (cookies httpOnly, refresh transparente, guard de rutas), sistema de diseño Orpyca--op-*+ Bulma, 14 vistas de dominio con proxies BFF (bandeja, radicar, expedientes —listado T-12 ✅—, firmas, envíos, búsqueda, reportes, archivo físico, transferencias, admin…), nav espejo del RBAC, componentes UI reutilizables. Cobertura de tests ✅ (Vitest 249 + Playwright E2E smoke 6; infra @testing-library/svelte + jsdom + Playwright). Asistente conversacional — slice de frontend ✅ (ADR-020): store + service + proxy BFF(app)/asistente/api/message+ UI de chat enAssistantDock(antes placeholder), con D-05 (token server-only), allowlist, y saneo server-side del output (contenido no confiable →marked+sanitizeHtml, ignora elhtmldel gateway); backend ausente →503 assistant_unavailable(estado honesto, sin respuestas simuladas). Backend E18 ✅ implementado (ver F6/E18): gateway proxy/api/v1/assistant/→mcp-servercon loop LLM Claude real ejecutando las tools de solo lectura as-the-user. Contrato/api/v1/assistant/messageestable, preparado para streaming; APTOsvelte-ux-reviewer. Diferidos del slice: VoiceInput, subida de anexos, streaming SSE, persistencia de historial; y ~~reconciliarX-Usernamevspreferred_username~~ ✅ (ya reconciliado:preferred_username→claims['username']→ValidateResponse.username→X-Usernamefirmado,auth-service/app/core/security.py:88,auth_service.py:219,api-gateway/app/middleware/auth.py:232,shared/orpycamcp_common/identidad.py:34; en archive-service el login ya no se usa como actor —préstamos e índice guardan el UUID—, 2026-10, T6). Form Builder en la creación de expediente ✅ (E22/E03): al elegir la serie TRD (ahora<select>, antes texto libre de UUID), un proxy BFF trae eljson_schemade la plantilla de metadatos activa de la serie y se renderizan campos dinámicos (schemaField.jsmapea JSON Schema → control + coerción;SchemaField.svelte), poblando elmetadata:{}que iba vacío;actions.createlo reconstruye campo a campo (allowlist por claves del schema); vitest 314; APTOsvelte-ux-reviewer.radicarmigrado aSchemaField✅ — cierra un hueco real: su campo dinámico inline solo cubría number/date/text, así que unenum/booleandel tipo documental degradaba a texto libre (→422); ahora usa la fuente única (enum→select,boolean→Sí/No) y envía elmetadatatipado; +5 tests (radicar no tenía del campo dinámico). De paso se corrigió un defecto crítico pre-existente: elModalde confirmación de radicación nunca era visible (faltaba la propopen), así que la radicación no se podía confirmar desde la UI. ~~Diferidos~~ ✅ cerrados en parte (AREA-12-05, 2026-10):arrayde escalares (lista repetible),objectde un nivel y mapeo por campo del 422 (el sobre llevafield, ruta con puntos); sigue pendiente migrar las vistas con metadata inline (expedientes/[id], bandeja) aSchemaField, y los array de objetos y los object anidados. Adopción del Sistema de Diseño OrpycaMCP v1.0 ✅ (2026-07, ADR-020 actualizada):_tokens.scssrepintado a la paleta definitiva conservando todos los nombres--op-*(cero rotura en 19 pantallas) + contraste WCAG calculado, no estimado → el primario queda partido por rol (--op-primaryno-textual 4.28:1 /--op-primary-darktexto 6.56:1); escala tipográfica, radios, sombras y movimiento v1.0 + regla globalprefers-reduced-motion; Space Grotesk + Public Sans self-hosted (@fontsource, sin CDN); Font Awesome cableado por primera vez (su CSS nunca se importaba: todos los iconos eran cajas vacías) y favicon 404 corregido; landing pública en/(7 secciones, 302 a/dashboardsi hay sesión) — antes la raíz del sitio no existía; login/callback/403 con la marca real (composición split, mensaje de error de Keycloak desde tabla cerrada → cierra el bucle de redirecciónIdP↔/login); búsqueda global en el topbar (atajo/, deep-link?q=) y favoritos configurables en el sidebar (store namespaced por tenant+usuario, sin filtro propio: consume la lista ya filtrada por RBAC); dashboard rediseñado a los 4 módulos personales con las métricas del tenant relegadas a sección colapsable gateada porSGD_PERM_ESTADISTICA(UI espejo del RBAC) yNAV_ITEMSextraído alib/utils/navItems.jscomo fuente única;DataTablecon las 8 capacidades obligatorias (filtros, ordenaria-sort, export CSV anti-inyección de fórmulas, columnas configurables, vistas guardadas, selección múltiple, virtualización porcontent-visibility, edición en celda), todas opt-in, con/busqueday los 4 paneles de/reportesmigrados. Auditado porsvelte-ux-reviewer: 27 hallazgos Alto/Crítico remediados (contraste AA enButtony 8 pantallas, objetivos táctiles ≥44px en 8 componentes, foco visible enUserPicker, columna desalineada en móvil en/busqueda, y confirmación explícita antes de firmar en/firmas, que no la tenía). 481 tests vitest verdes,svelte-check0 errores/0 warnings,buildlimpio. Otros pendientes del front: E2E autenticado contra docker-compose (escrito, hoy skip)./admin/gruposy/admin/dependencias✅ (E08/E14, Fase 5 cierre de desfase API↔UI): grupos reutilizaCatalogCrudPanel(encaja sin extensión: al no invocar nuncaeditOpen/deleteOpen, los modales de editar/borrar simplemente no se activan — el backend no tienePATCH/DELETE /groups/{id}) con un drawer de miembros/permisos/clearance que advierte explícitamente que el backend no expone lectura de esas tres cosas ya asignadas a un grupo (solo altas/bajas ciegas — gap real derbac.py/clearance.py, no una omisión de UI). Dependencias añadeDependenciaTreeNode.svelte(árbol nuevo con<details>/<summary>nativos — no existía un componente de árbol reutilizable en el proyecto) sobreGET /dependencias/tree, con un modal de confirmación de riesgo explícito (no un tooltip) al renombrar o desactivar, por la deuda de correlación por-nombre deworkflow_rules.assign_to_dept/flow_steps. 57 tests nuevos de proxy (401, allowlist, UUID +encodeURIComponent, edición parcial);svelte-check0 errores. Diferido: borrado duro de dependencias (el backend lo tiene, bloqueado si hay hijos, fuera del alcance pedido)./admin/trd,/admin/metadatos,/admin/catalogosy/admin/parametros✅ (E03/E04/E14, Fase 5 cierre de desfase API↔UI, segunda tanda): TRD/CCD reutilizaCatalogCrudPanelen dos pestañas — series en árbol indentado (no lista plana, la jerarquíaparent_ides el dato) con código inmutable, y tipos documentales con CRUD completo y filtro por serie; gatea sus botones conUSUA_PERM_TRD(el permiso REAL que exigetrd.py/tipos_documentales.py— más específico queUSUA_PERM_ADMIN, que solo controla la visibilidad del panel/admin). Metadatos reparte con TRD (TRD = clasificación, metadatos = campos de esos esquemas): plantillas de expediente/documento son de solo alta (el backend no tienePATCH/DELETE, nueva versión = nuevo registro) con eljson_schemaeditado como JSON crudo en textarea (parseo y validación de forma de objeto ANTES de enviar — SchemaField.svelte/schemaField.js rellenan un formulario a partir de un schema ya existente, no lo autoran, por eso no aplican aquí) y elementos de metadato reutilizables con CRUD completo. Catálogos usa disposición maestro-detalle (lista de catálogos a la izquierda, items a la derecha) que degrada a dos niveles navegables con botón «Volver» en móvil, nunca a columnas apretadas. Parámetros suma calendario de festivos (precarga Ley 51/1983 sin llamada externa, algoritmo de Pascua/Emiliani ya existente en el backend) y una calculadora de días hábiles que reconstruye el desglose de qué días se descontaron (fin de semana/festivo con su descripción) porqueBusinessDaysResultdel backend solo devuelve la fecha de vencimiento, no el desglose — se deriva en el cliente del mismo catálogo de festivos ya cargado, no se inventa. HALLAZGO DE DOMINIO reportado en su momento (CERRADO en32fc631): los routersmetadata.py/metadata_elements.pyde document-service ycatalogos.py/config.pyde tenant-service no declaraban ningúnrequire_permission, así que cualquier autenticado del tenant podía escribir. Hoy todas las escrituras están gateadas —USUA_PERM_TRDen metadatos y elementos de metadato (el borrado de un elemento, nivel 3),USUA_PERM_ADMINen catálogos y parámetros— y solo losGETquedan abiertos al autenticado del tenant, por diseño; los fijanshared/tests/test_escritura_exige_nivel.pyytest_permisos_declarados.py. Las cuatro pantallas gatean además sus botones conUSUA_PERM_ADMINdel lado del cliente (la UI espeja el RBAC; el backend re-valida siempre). 55 tests nuevos de proxy (401, allowlist anti mass-assignment, validación de JSON Schema/fecha/rango, UUID +encodeURIComponent, whitelist de nombre de catálogo);svelte-check0 errores/0 warnings; 1125 tests vitest verdes (91 archivos), build limpio. RAG integrado en redacción y clasificación ✅ (F6, E21, cierre del desfase API↔UI): el RAG no es un módulo aparte — botón "Sugerir con antecedentes" (RagSuggestButton.svelte) en/radicar(cuerpo de Salida/Interno) y/borradoresllama aPOST /knowledge/rag(Inc.3) vía proxies SSR nuevos; chips de tipo documental sugerido (TrdSuggestChips.svelte) junto al selector de clasificación en/radicar, alimentados por/knowledge/antecedentes(Inc.2 Patrón A) con resolución honesta dedoc_classpor precedente víaGET /documents/by-tracking/{tracking}(ese endpoint no traedoc_classen su contrato real — no se inventa el campo). Sección "Respuestas" nueva en el drawer de detalle de/bandeja(GET /documents/{id}/respuestas). Tres reglas duras: el texto generado se inserta SIEMPRE marcado como generado y con sus citas (lib/utils/ragInsert.js, usando solo etiquetas de la allowlist desanitizeHtml.jspara que la marca sobreviva el saneo server-side); sin citas no se ofrece insertar (afirmación sin fuente en un documento oficial es peor que no tener sugerencia); el chip de clasificación nunca se autoaplica; y un fallo del servicio de conocimiento queda contenido en el componente, sin bloquear radicar/guardar un borrador. La barrera de residencia del backend (is_generatable) se refleja con un aviso genérico honesto, sin inventar cifras que el backend no da. 51 tests nuevos (proxies + las tres reglas duras); 1186 tests vitest verdes (101 archivos),svelte-check0 errores. Búsqueda semántica y de antecedentes en/busqueda✅ (F6, E21, cierre del desfase API↔UI): dos pestañas nuevas junto a la Exacta existente — Semántica (POST /knowledge/search) y Antecedentes (POST /knowledge/antecedentes, texto o radicado pivote) — resueltas por?tab=(deep-link, mismo patrón que?vista=de/bandeja); tres proxies SSR nuevos (busqueda/api/semantica,busqueda/api/antecedentes,busqueda/api/by-tracking/[trackingNumber]contra document-service para verificar el radicado pivote antes de buscar). Honestidad de interfaz no negociable: los resultados se presentan como documentos parecidos (similitud vectorial con banda cualitativa explicada, nunca coincidencia exacta), un fallo de red nunca se disfraza de "sin resultados", y el recorte por ACL deknowledge-servicees invisible por diseño (no se anuncia ningún conteo de resultados ocultos — evita convertir la búsqueda en oráculo de existencia). Tablist con roles ARIA correctos y navegación por flechas. 18 tests nuevos de proxy (401, allowlist,encodeURIComponent); 1186 tests vitest verdes (101 archivos),svelte-check0 errores. MVP de PINAR en/admin/pinar✅ (Fase 7, cierre del desfase API↔UI, E14/RF-ADM-08, Ac. AGN 003/2015): PINAR tenía 30 endpoints entenant-servicesin ninguna pantalla. El MVP cubre listado (filtro por estado, alta de una versión nueva siempre enborrador) y workspace conStepperdel ciclo de vida (borrador→aprobado→en_ejecucion→cerrado, puramente informativo — las transiciones son botones +Modalde confirmación, nunca clic en elStepper) y pestaña Tablero (avance por objetivo/por eje). 6 proxies SSR (ejes,planesGET+POST,planes/{id}/{aprobar,ejecutar,cerrar,tablero}). Honestidad de interfaz: aprobar congela el contenido del plan de forma permanente (asiento enaudit_loginmutable) — se advierte ANTES de confirmar, con el botón de confirmación deshabilitado sinacto_administrativo; el tablero distingue "no se pudo cargar" (banner) de "vacío legítimo" (plan sin proyectos aún,EmptyState), nunca pinta ceros por un fallo de red. Fuera de alcance (sin UI, fase posterior de PINAR): aspectos críticos, priorización, objetivos, proyectos, seguimiento, instrumentos, mapa de ruta — dependen de objetivos/proyectos, que no tienen pantalla propia todavía. 41 tests nuevos; 1227 tests vitest verdes (105 archivos),svelte-check0 errores./admin/tvd✅ (ADR-026 Increment 1, fondo acumulado): espejo estructural de/admin/trdcon columnas propias (fondo/productora, fechas extremas, estado del instrumento) y sin columna de archivo de gestión (no existe para TVD);justificacion_valoracioncomo campo principal obligatorio del formulario de creación (es el objeto del instrumento, no una nota). Circuito de convalidación (aprobar/devolver/convalidar/registrar-rusd/derogar) construido como componente compartidoInstrumentLifecycleActions— no existía en/admin/trd(ADR-025 lo dejó pendiente) y se construyó una única vez, conectado a ambas pantallas, con advertencia de irreversibilidad dentro delModalde confirmación para convalidar/derogar y ningún botón ofrecido si el estado actual no lo permite. Edición append-only fuera deborrador(solopdfa_profileviaja). Se verificó queTrdSuggestChips.svelteno pudiera sugerir nunca una TVD: no consulta series/agrupaciones, resuelvedoc_classde radicados precedentes — no había nada que corregir. 68 tests nuevos; 1295 tests vitest verdes (108 archivos),svelte-check0 errores/admin/interoperabilidad✅ frontend, carga masiva pendiente de ruteo en el gateway (Fase 8, última del cierre del desfase API↔UI, E11/E20): cuatro secciones — Exportar (POST /api/v1/export→ ZIP en cliente), Importar (POST /api/v1/import,Steppersubir→validación→confirmar→resultado; el422de fixity muestra el archivo/checksum EXACTO que falló, nunca un mensaje genérico;antecedentes_no_resueltosse lista por número de radicado, no solo se cuenta), y carga masiva de documentos/expedientes (POST /api/v1/batch/documents|expedientes, confirmación explícita con el número exacto de ítems, sondeo de progreso cada 2s contra el proxy propio — D-05 — que distingue "no se pudo consultar" de "aún sin resultados", nunca sintetiza contadores en cero). Se sumó "Actualizar rastreo" en/envios(GET /api/v1/envios/{id}/tracking, F5) que declaraoperador_conectado=falseen vez de aparentar una consulta en vivo. HALLAZGO DE ALCANCE (no corregido, fuera deservices/en esta fase): el gateway (api-gateway/app/routers/proxy.py::_route_to_upstream) no tiene registrado/api/v1/batch/en su tabla de ruteo — ni hacia document-service ni hacia archive-service — así que ambas secciones de carga masiva devuelven404 route_not_foundhasta que se añada esa entrada (cambio defastapi-developer/orfeo-architect). Exportar/Importar/rastreo sí quedan operables de punta a punta. 27 tests nuevos (allowlist,encodeURIComponent+UUID en[jobId], streaming/saneo deContent-Disposition, 503 explícito del sondeo ante fallo de conexión); 1322 tests vitest verdes (110 archivos),svelte-check0 errores - [x] ~~API de webhooks~~ — webhooks salientes firmados hechos (E11)
- [x] ~~Exportación de expedientes a PDF/ZIP~~ — ZIP hecho (E02/export):
GET /api/v1/expedientes/{id}/export.zip(archive orquesta, storage ensambla vía endpoint interno D-02) con índice electrónico XML (best-effort) +manifiesto.csv(incluye los excluidos con causal) +LEEME.txtde alcance +checksums.txt(SHA-256) + bytes de los anexos; no-read-up POR-RADICADO (document-service filtranivel <= clearancey omite los sobre-clearance incluida su existencia — cerró una fuga read-up que el gate solo-expediente no cubría), gateUSUA_PERM_EXPEDIENTE, anti zip-slip, auditoría agregada. asunto/tipo en el manifiesto ✅ (cerrado en el barrido de deudas: endpoint internoPOST /internal/documentos/metadataclearance-aware con el mismo no-read-up por-radicado → columnastipo/asuntoenmanifiesto.csv, best-effort). APTO+CONFORME. Pendiente: PDF combinado (motor de render), streaming para expedientes grandes,get_by_id(soft-delete de anexos) - [x] ~~Reportes y estadísticas~~ — reportes de radicados hechos (E09); panel/indicadores avanzados pendientes
Pendientes del frontend (E22) tras la adopción del Sistema de Diseño v1.0¶
Todos están registrados con su archivo y su motivo. Salvo el primer bloque, ninguno bloquea la línea base.
Revisión funcional completa desde el navegador (agosto 2026) ✅ — bloques B0 a B13
Hasta esta revisión la garantía del proyecto era de unidad y de contrato, no de uso: las 36
pantallas de (app)/ tenían cero cobertura de navegador. Se recorrió el sistema entero bloque por
bloque contra tenant_demo, ejecutando los actos reales incluidos los irreversibles. El
procedimiento, los casos y los puntos ciegos declarados quedan en
docs/es/plan-pruebas.md; cada defecto y su corrección, en el
changelog.
- [x] B0–B11 — sesión, identidad, instrumentos, radicación, trámite, expedientes, firma, envíos, archivo físico, consulta, interoperabilidad y los siete actos irreversibles.
- [x] B12 Observabilidad — reverificación de la cadena de
audit_log(968 de 972 asientos recomputan su hash, cero roturas genuinas), notificaciones, colas, preservación y PINAR. - [x] B13 Barrido negativo — confirmado que los controles de acceso no viven solo en el navegador: todo lo que la interfaz esconde se invocó por el proxy BFF y el backend respondió 403.
- [x] Entregables — plan de pruebas,
pantallas.mdcon el estado verificado, y la suitefrontend/e2e/authenticated/reactivada (estaba apagada con triple freno). - [x] Trámites ciudadanos con pantalla (hueco de producto, cerrado 2026-08):
(public)/radicaciony(public)/consulta. La ventanilla en línea y el seguimiento por código existían en el backend sin ninguna forma de llegar a ellos. Sin cuenta, enlazados desde la portada antes que «Iniciar sesión». No sustituyen a/radicar(ventanilla de mostrador, de usuario del sistema): son dos puertas al mismo trámite. - [x] Ingesta IMAP (cerrado 2026-08-23):/admin/correo. El obstáculo no era dibujar la pantalla, era que no se podía construir: el poll era la única puerta, y ejecutarlo consume números de la secuencia oficial y marca los correos como leídos, así que averiguar el estado costaba un acto irreversible. Rediseñado y cerrado 2026-08-26: la ruta de un solo buzón por despliegue está retirada —radicaba de golpe todo lo no leído, publicidad incluida— y en su lugar la entidad configura sus buzones (institucional compartido, personal del funcionario, varios a la vez) con OAuth2 para Google y Microsoft, carpeta de filtrado y carpeta «Radicados». Se radica en la bandeja de cada buzón, correo por correo y con reserva para que dos funcionarios no radiquen el mismo dos veces; opcionalmente en automático, con funcionario responsable declarado, y con constancia al remitente si el buzón la activa. - [x] Cola de anulaciones pendientes (hueco de backend, cerrado 2026-08):
GET /api/v1/anulaciones— recurso propio, gatePERM_PANU_CODI(el mismo que decidir), orden FIFO y no-read-up aplicado por igual a las filas y al recuento./anulacionesdeja de ser un buscador y pasa a ser una bandeja de trabajo. -
[x] Lecturas de TRD y PINAR sin permiso (cerrado; verificado en código 2026-09-19, la casilla llevaba tiempo sin tachar). La disyuntiva «gatear o declarar abierto» estaba mal planteada: las lecturas no son una sola cosa. El vocabulario —series, agrupaciones, tipos documentales, dependencias, catálogos— queda abierto a autenticados y declarado, porque lo consumen
/radicary/expedientes:USUA_PERM_TRDlo tiene quien administra el instrumento, no quien lo usa, y gatearlo dejaría sin radicar a todo operador con el síntoma lejos de la causa. El circuito sí se gateó (GET /trd/{code}/versionesexigeUSUA_PERM_TRD,archive-service/app/routers/trd.py:363): devuelve borradores y la deliberación del Comité, no el instrumento publicado. Y PINAR se cerró entero (USUA_PERM_ADMINen elAPIRouter,tenant-service/app/routers/pinar.py:77, gate por admisión que cubre sus 11 lecturas): publicar el plan aprobado es un acto hacia fuera, una pantalla pública, no un GET interno sin gate que se le parece por accidente. El recuento del pendiente también estaba corto —decía 15 y eran 33, incluidas 4 de TVD que ni mencionaba—. -
[x] T11 — guardas del BFF, cookies y espejo de RBAC (2026-10, AREA-12-01/03/04/05/06/07): guardián derivado del árbol que exige sesión en los 290 verbos de los 219
+server.jsde(app)(antes, 21 proxies a mano), test de los flags de cookie (httpOnly/sameSite/secure),admin/+layout.server.jsque redirige a/403según el permiso REAL de cada área, estado de la preservación visible en la pestaña del expediente (GET /preservacion/estado),SchemaFieldcon array/object yfielden el 422, tokens CSS (--op-on-brand-*,layout.scssal velo) y paridad PINAR backend↔BFF (30 endpoints con puerta). Abierto: ejecutar los e2e nuevos (RBAC restringido, axe) en CI y T-18 branding por tenant/presupuesto de bundle (decisión de alcance). -
[x] T12 — cumplimiento archivístico en archive y storage (2026-10, 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): el índice se valida contra su XSD antes de sellar (
pendiente_firma/xsd_invalido) y al agotarse el sellado se avisa por correo;createde una transferencia exige el índice firmado, lee la tenencia dentro de la transacción y rechaza un par sin tenencia; el acta ata la firma y la huella del índice (migración 053) y declaracabecera_completa; el fixity de la recepción es un evento PREMIS (con reconciliación);REVOKEde UPDATE/DELETE sobre el acta yCHECKdeobjeto(054/055); WORM para el fondo acumulado (ADR-026 Inc.3, con clamp) y para cada firma personal del acta (tiposacta_firma_*, 057); reintento manual del AIP agotado; eventos del expediente con ficheros y AIP; storage: la deduplicación ya no cruza autores, la réplica del AIP se relee, el guard de arranque avisa de veraPDF sin binario, PREMIS declara el PUID PRONOM y hay una prueba del circuito WORM contra Postgres + RustFS reales. Cerrado (2026-10-04): el actofirma_inobtenible(07-10, migración059) y la renovación CT de las firmas personales del acta (060). Remediación de auditoría (2026-10-09, ADR-030, migraciones archive061y storage019): la constancia pasó a ser prueba —XML canónico sellado XAdES-T y firmado por el declarante según la política de la entidad, Object-Lock con renovación CT, soporte documental resuelto por el servidor—, revocable, sin conflicto de interés y con etiqueta honesta (sellada_con_excepcion). FIRMA-2 (2026-10-09, addendum de ADR-030, migraciones archive062y storage020): la excepción se puede re-declarar tras revocarla (cadena linealordinal/sustituye_a, solo con la revocación sellada y protegida WORM), la revocación es prueba (XML sellado, firma personal si hay certificado, WORM con reconciliación y renovación CT), la constancia v2 es autosuficiente (número del radicado, SHA-256 del anexo, identidad de declarante y parte) y el fundamento se huella con HMAC por entidad; las referencias se recortan por el clearance del lector. Ronda de auditoría FIRMA-2 (2026-10-09, migración archive063): el HMAC se sustituyó por un compromiso con sal por registroSHA-256(sal || texto)verificable por terceros (constancia y revocación v3), lo firmado es la C14N exclusiva yacta/verificacioncoteja los enlaces de la cadena, todos los eslabones comparten ancla de retención WORM,agotadotiene salida (.../worm/reintentar, admin 3),incapacidadviaja neutra y leer el fundamento deja asiento. Propuestas abiertas (no implementadas): (a) regularización de la revocación legada anterior a la062(hoy definitiva: su baja no es prueba); (b) bloqueo de disposición del anexo de soporte mientras exista una constancia vigente que lo cite (hoy puede eliminarse por su TRD; la constancia conserva su id y su SHA-256). Ronda 3 (migraciones archive064y storage021): el texto del fundamento y su sal se conservan en un sobre XML sellado y WORM (solo admin 3, lectura con asiento); (c) deuda E12/E17, no implementada: empaquetar el sobre como componente clasificado (Ley 1712 art. 18 lit. a) en la transferencia secundaria al AGN y anotarlo en el FUID/acta de entrega —el ADR-030 lo describe, ningún código lo hace todavía—; (d) documentar en la política de tratamiento (Ley 1581) por qué no procede la supresión mientras corre la retención. B4 / P1 (2026-10-09, migraciones archive065y066): paquete de entrega de la transferencia secundaria (POST /transferencias/{id}/paquete-entrega: acta + cadena clasificada +nota-entrega.xml, solo con todo bajo Object-Lock, asientos antes del primer byte), trigger de tenencia física en la base ydedup_keyPREMIS por número de intento. Pendiente: sellado XAdES denota-entrega.xml(tocaría signature-service) y bloquear la desvinculación de unidades con transferencia abierta. Abierto (decisión de usuario): reglas de la jerarquía TRD de dos niveles/reclasificación masiva (06-07),preservationLevelyrightsen PREMIS (08-05/08-01), si el WORM del fondo acumulado debe fijarse con un margen (decisión tomada: clamp) o no fijarse (D4.4 original), y el consumidor de notification para el evento de índice agotado (hoy es correo directo). -
[x] T13 — cumplimiento documental en document-service y frontend (2026-10-03, AREA-09-02/04/05/07/08/10/16, 07-12, 02-07 — solo el honeypot y la
Idempotency-Key): los datos mínimos del radicado (tipo y dependencia, Ac. 060 §5.2) se exigen en las dos puertas de radicación y los productores internos los derivan o rechazan antes de gastar número; el índice de clasificados nunca tiene un fundamento NULL (importación rechaza +CHECK041 + backfill) y trae dependencia y registrador; outbox transaccional (eventos_salientes, 042) para alta, reclasificación y anulación —el 1→3 ya no se pierde con Redis caído— y paradocumento.contenido_cambiado; constancia de la autorización de tratamiento (Ley 1581) en ventanilla y PQRS; secreto HMAC del operador postal con dos ranuras cifradas y rotación sin corte, y acuse obligatorio para entregar a mano (044); publicación por anexo y sin PQRS ni lo de ventanilla (040);GET /ventanilla/estado+scripts/verificar-ventanilla.sh. Abierto (decisión de usuario): el proveedor de CAPTCHA (reCAPTCHA contradice la soberanía; ALTCHA u otra prueba de trabajo autoalojada), el texto de la política de tratamiento (lo aporta la entidad:POLITICA_TRATAMIENTO_VERSION/URL), el catálogo cerrado de fundamentos y el nivel serie/expediente del índice, el doble control para desclasificar, y si un anónimo puede vincularse a la ficha de un tercero existente por número de documento. Pendiente técnico: ~~consumidor dedocumento.contenido_cambiadoen signature-service~~, ~~alerta en notification paradocument.envio.devuelto~~ y ~~barrido de reconciliación nivel-del-fragmento en knowledge~~ (cerrados en B10), IP verificada por saltos de confianza en las rutas públicas (el gateway no la inyecta) y la captura de tipo/dependencia en el formulario de borradores. -
[x] T14 — guardianes de CI e infraestructura (2026-10-03, AREA-01-18, 13-01/02/03/05/08/09/11/13/14, 08-10/12, 03-02/05/06, 05-08): guardián de que toda ruta lleva gate (con las excepciones declaradas, entre ellas knowledge), de las reglas 1, 3 y 7 (sin ORM ni
python-jose, suelos de versión, repo:ro), de que la matriz de CI sale del árbol (whisper en unitarias, signature y workflow en integración), de las imágenes inmutables, las migraciones antes de servir (migrate-<svc>) y el manifiesto de producción seguro; job WORM real contra RustFS por digest, humo de seguridad automático, trivy antes de publicar, auditoría de dependencias fail-closed con umbral determinista ynpm audit. El respaldo verifica la cadena de auditoría antes de anclarla y no ancla si falla (app.ops.verify_audit_chains, que también usarestore.sh: su heredoc reventaba), la restauración falla si queda una retención o una retención legal sin reponer y emite el mapaversion_id;/health/readyde auth avisa de la partición ausente; el trigger de sentencia de 004 y la bandeja contra Postgres real tienen prueba. Remediación de auditoría (2026-10-03): una rotura histórica declarada (línea base aprobada) ya no impide respaldar —no ancla, completa y sale 3—; la retención no leída aborta ese bucket (selloretencion_capturada_completa=NO, salida 4); las previas bajo COMPLIANCE fallan la restauración, que además deja el asientostorage.restauracion;MIGRATIONS_DATABASE_URLsale del runtime de producción. Declarado:knowledgesin gate (decisión de producto). B4/P3 (2026-10-10): las previas bajo COMPLIANCE se respaldan (minio-versiones.tsv, salida 5) y se restauran con su retención; cada objeto remapeado deja evento PREMISREPLICA;verify_audit_chainssale 2 con objetivo vacío;webhook_entregas(notification012) +GET /webhooks/{id}/entregas. Pendiente: retención/purga de esa tabla y probar backup/restore extremo a extremo en un entorno desechable. Abierto (decisión de usuario): gatear con permiso las rutas de knowledge; reconstruir auth-service en la pila local (disco). - [x] T15 — transversal sobre
orpycamcp_common(2026-10-04, AREA-02-05, 01-17, 03-07): un solo validador de slug (orpycamcp_common.tenant; el alta ya no admite-abc),check_permission/resolve_caller_clearance/nivel_efectivo/mensaje_denegaciondefinidos una vez enorpycamcp_common.rbac(ADR-013 corregido: la autocreación ya está decidida), yaudit.appendsin valor por omisión decanal(191 llamantes reescritos;CANAL_PETICIONhereda el del gateway,systemen jobs, CLI y consumidores, fijado porcanal_de_fondo()en cada tarea de fondo); el filtroobject_refdeGET /auditresuelve en lectura los asientos viejos por UUID de expediente/índice. Guardianes:test_un_solo_validador_de_slug,test_el_gate_se_define_una_vez,test_el_asiento_declara_su_canal. Abierto: caché de permisos (sin perfil de carga que la pida). - [x] T16 — documentación y memoria (2026-10-03, AREA-02-01, 14-01/02/03/04/06/07/08/09/10/11/12/13/14/15/16/17/19,
OMI-07, 07-16/19, 09-17): el roadmap deja de contradecirse sobre la disposición final, cierra con evidencia los
hallazgos de gates de escritura y de limitador por IP, y ya no escribe a mano el número de ADRs;
multi-tenancy.mdy ADR-002 dejan de recetar Alembic/SQLAlchemy y unSET search_pathsin validar; el alta de entidades se documenta porscripts/init-tenant.sh(el gateway no expone/tenants); ADR-006/016/018/021 y los comentarios de TSA, workflow, storage y OAI dicen lo que hay;deployment.mdrebaja Kubernetes a «no soportado» yDOCKER_REGISTRY.mdpasa a GitLab; el roadmap EN iguala los pendientes del ES ysecurity-audit.mdEN gana la segunda pasada. Código: préstamos por unidad paginados (+ BFF), migración signature022(COMMENT dexades_level), EAD del OAI validado contra el XSD oficial (21 registros reales de demo, 0 inválidos),scripts/carga/busqueda_sla.py(p95 de/searchcontra RF-BUS-09: 3 s simple / 10 s compleja; p95 ≤ 0,2 s sobre 222 radicados) yshared/tests/test_la_documentacion_dice_lo_que_hay.py(20 guardianes de «la frase solo vale mientras el hecho exista»). Ya cerrado antes: AREA-14-12 (la migración028reescribió el COMMENT). Abierto: tablawebhook_entregas(ADR-018), export de expediente en streaming (AREA-07-17), discriminador de nivel del CCD (decisión de dominio;trd_seriesno tiene columna de nivel), paridad del changelog EN (decisión: hoy es una selección declarada), cifra de cobertura del MCP generada, y reconstruir archive-service y el frontend en la pila local (disco al 98 %) para que la paginación de préstamos por unidad llegue a ella. Revisión de experiencia de uso del cierre del desfase API↔UI (2026-08-02)
Las ~20 pantallas construidas en el cierre del desfase API↔UI (Fases 0–8) se implementaron sin pasar por el revisor de UX/accesibilidad, que es el último eslabón de la cadena de agentes del proyecto. La revisión se hizo después, en dos pasadas paralelas (panel /admin y pantallas operativas). Resultado: base sólida —sin {@html} sin sanear, sin llamadas al gateway desde el cliente (regla D-05 intacta), Modal/Drawer con trampa y devolución de foco correctas, confirmación proporcional al daño en los actos irreversibles— con estos pendientes:
- [x] La aplicación se construyó entera sin el logotipo institucional (destapado por el usuario al entregar los originales de marca, 2026-08-07). Lo que servía de logotipo (
static/orpyca-logo.png, 100×97) era un orbe genérico recortado de una pieza publicitaria, no la marca; y el panel lateral —la superficie de marca que el usuario tiene delante toda la jornada— ni siquiera usaba esa imagen: mostraba el iconofa-archivede Font Awesome. Corregido: entran el isotipo (static/brand/orpyca-mark.png) y el logotipo completo en sus dos composiciones, con favicons e icono de iOS regenerados desde el activo real. El reparto entre isotipo y logotipo completo lo imponen dos límites del activo, no el gusto: la marca denominativa está dibujada en negro degradado y desaparece sobre fondo oscuro (en el panel de acceso el nombre va como texto, sobre--op-brand-900, token nuevo — sobre--op-primaryel anillo verde oscuro se empastaba con el fondo), y la línea "orfeo pyme calidad" baja de 6px a la altura de una barra de navegación, así que el logotipo completo solo entra donde hay ancho. Cableado en panel lateral, topbar en móvil (donde el panel es un cajón cerrado y no había ninguna señal de marca), acceso, landing, 403 y/login/callback, mástheme-colory Open Graph. - [x] La carga masiva no reporta resultado en ninguna circunstancia (dos defectos independientes que se suman, ambos en
/admin/interoperabilidad). (1)createJobPollermutaba un objeto plano que nunca se reasigna: Svelte 4 no invalida y el panel quedaba congelado en "Consultando estado…". (2) Los dos proxies de sondeo pedían${API_V1}/batch/{jobId}/status, la forma anterior al fix de ruteo del gateway, que no casa con ningún prefijo →404permanente. Corregido (lote 1): el sondeo pasó a variablesletde componente reasignadas, las URL a la forma anidada real (/batch/documents/{jobId}/statusy/batch/expedientes/{jobId}/status), se borraron los cuatro comentarios obsoletos que afirmaban que el gateway no enrutaba/api/v1/batch/—el vector que produjo el defecto— y se añadieron tests de contrato de la URL, la defensa que faltaba y que ya se había pagado antes conFORWARDED_REQUEST_HEADERS. - [x] Tres tarjetas de
/adminllevan a un403— el mismo patrón ya corregido enColas, que resultó no ser un caso aislado. Corregido (lote 1) cruzando las 16 tarjetas contra elrequire_permissionreal de su router, no solo las tres observadas:TRD / CCDyMetadatospasan aUSUA_PERM_TRD, eInteroperabilidad—cuyo gate es heterogéneo por pestaña— se abre conUSUA_PERM_EXPEDIENTE || PERM_RADIy filtra cada pestaña con su permiso propio, de modo que ya no se oculta a un radicador que sí puede usarla. Las otras trece coincidían. Se corrigió además un comentario falso enmetadatos/que afirmaba que los routers dedocument-serviceno gateaban por permiso. - [x] Dos pantallas construidas y no enlazadas:
/admin/preservacion(784 líneas) no aparecía ni en el índice de tarjetas ni en el menú lateral —solo se alcanzaba escribiendo la URL—, y/admin/seguridad/clavesfaltaba en el índice, que se presenta como el mapa completo de administración. Corregido (lote 1): ambas añadidas aADMIN_AREAScon su permiso verificado contra el router. - [x] Los proxies de carga masiva de documentos nunca llegaron a versionarse (hallazgo de higiene de repositorio, destapado durante el lote 1 y no por la revisión UX). El patrón
documentos/del.gitignore—escrito para eldocumentos/de la raíz— no llevaba barra inicial, así que también ocultabafrontend/src/routes/(app)/admin/interoperabilidad/api/lotes/documentos/: la carpeta quedó fuera del commit de la Fase 8 pese a estar documentada como entregada, y enmainla pantalla llamaba a rutas inexistentes. Corregido anclando el patrón a/documentos/. - [x] Contraste AA: blanco sobre
--op-primary(4.28:1) en estados permanentes, no solo en:hover. Corregido (lote 2): los tresStepper(2FA, credencial PKI, firma), el turno activo de la cadena de firma y los chips de filtro de/borradoresy/envios—que estaban literalmente invertidos, con el oscuro en el hover— pasan a--op-primary-dark(6.56:1), siguiendo lo queButton.svelteya resolvía bien. Corregidas además cuatro instancias del otro par que falla,--op-text-secondarysobre--op-primary-light/--op-success-bg/--op-error-bg(~4.0:1). - [x] Tablas anchas inalcanzables (WCAG 1.4.10 Reflow y 2.1.1 Teclado). Corregido (lote 2):
tabindex="0"+role="region"+aria-label+:focus-visibleenDataTableyCatalogCrudPanel—que lo propagan a 8+ y 7 pantallas respectivamente— y en ~20 envoltorios de tabla cruda;bandeja-table, la única conoverflow: hiddensin ningún mecanismo de scroll, gana un envoltorio propio conoverflow-x: auto. Losoverflow: hiddenrestantes envuelven unDataTablecuyo scroll vive en el wrapper interno, así que no se les añadió tab-stop: sería mudo y redundante. Cadatabindexnuevo disparaa11y-no-noninteractive-tabindex, regla que no reconoce la técnica WCAG SCR29; silenciada consvelte-ignorejustificado, no dejada suelta. - [x]
/perfil: siete errores de validación de campo salen solo como notificación flotante. Corregido (lote 2): los siete se asocian al campo vía elerrordeFormField(aria-invalid/aria-describedby), y los cinco de código OTP gananinputmode="numeric"+autocomplete="one-time-code"— hubo que extenderFormFieldcon esas dos props, que no existían. - [x]
CatalogCrudPanelaplicabadisplay: flexsobre un<td>, sacando la celda del modelo de tabla en el árbol de accesibilidad. Corregido (lote 2) moviendo el flex a un<div>dentro del<td>; se propaga a las siete pantallas del panel. El hallazgo original hablaba además de "cuatro tablas con una celda más que encabezados": un barrido de las 28 tablas crudas del proyecto, comparando<th>contra<td>e incluyendo las columnas condicionadas por permiso, no pudo reproducirlo — probablemente ya se cerró al adoptar el patrón compartido. Se deja constancia en vez de forzar el conteo. - [x] Las sugerencias de IA aparecían sin anunciarse a lector de pantalla. Corregido (lote 2) con
aria-live="polite"en los chips de serie TRD y en el panel de antecedentes. El contenido no se tocó:ragInsert.jssigue siendo el único constructor del fragmento y ya cumple el requisito normativo. - [x]
/perfilno gateabaPERM_FIRMAen el enrolamiento de la credencial de firma (cerrado 2026-08-07). El gate real dePOST /auth/me/signing-key/enrollesrequire_permission("PERM_FIRMA"), pero la pantalla ofrecía el botón a cualquier usuario y el403llegaba al pulsarlo. Corregido concan("PERM_FIRMA", 1)— el helper con nivel CRUD y?? 0fail-closed, y con el mínimo real del backend: exigir de más habría sido el mismo defecto al revés (negar en silencio a quien sí puede), la lección que ya dejó el barrido de gates de/auth/me. El bloque explica el motivo y a quién acudir en vez de dejar que se descubra al final del flujo; el backend sigue revalidando. -
[x] Códigos de recuperación de 2FA (cerrado 2026-08-07; antes: 0 ocurrencias en
auth-service). El TOTP es un factor de POSESIÓN y todas sus salidas —revocarlo, reenrolarlo y revocar la clave personal de firma— exigían un código TOTP vigente: para dejar de depender del teléfono perdido había que usar el teléfono perdido, y la única salida era tocar la base de datos a mano. Cerrado con la migración015:activateemite 10 códigos de un solo uso (hasheados con scrypt + sal por código —a diferencia del secreto TOTP no hay que recuperarlos en claro, así que una copia de la tabla no permite autenticarse—), aceptados como step-up enenrollyDELETE /me/totp, másPOST /me/totp/recovery-codespara regenerar el lote. Emitidos en la misma transacción que la activación (no existe un instante con TOTP activo y sin vía de recuperación) y borrados en la misma transacción al revocar/reenrolar. Frontera deliberada: NO sirven para firmar —/internal/totp/verifyno los acepta; un código en papel no es posesión de un dispositivo y admitirlo debilitaría la atribución de RF-FIR-13. 218 tests (+17); migración validada contra Postgres real (idempotencia, 2.º schema de tenant, un-solo-uso y CASCADE por SQL crudo). Hallazgo propio cazado por mutación: el primer test de un-solo-uso no discriminaba (con conexión mockeada el «0 filas» lo da el mock, no elWHERE), sustituido por uno que fija el contrato del SQL. UI ✅ el mismo día:/perfillos muestra al activar (con la advertencia de que no se repiten), avisa con ≤2 restantes y permite regenerar; el campo de step-up distingue el factor por forma (6 dígitos = TOTP, lo demás = recuperación) en vez de pedir una casilla — quien perdió el teléfono no debería tener que explicarle a la pantalla qué está pegando. Proxyperfil/api/totp/recovery-codescon allowlist yno-storereafirmado (SvelteKit construye una respuesta nueva; la cabecera del upstream no viaja sola), verificado por mutación. -
[x] Causa raíz de contrato —
/auth/medescartaba el nivel CRUD. El router resolvía{permiso: nivel_crud}y devolvíalist(perms.keys()), así que el store recibía una lista plana ycan("USUA_PERM_EXPEDIENTE")no distinguía lectura de escritura, mientras los backends gatean conmin_crud=3. Las dos revisiones llegaron aquí por caminos independientes y ambas se negaron a parchearlo pantalla por pantalla. Corregido (lote 3), en dos mitades:/auth/meañadepermission_levels{}de forma aditiva —permissions[]se conserva, así que no hace falta despliegue coordinado— y el store ganacan(permission, minCrud)conminCrudopcional y fail-closed explícito: si el nivel no viene (sesión anterior al contrato, backend antiguo), el nivel efectivo es0, nunca "no lo sé ⇒ lo permito". Al aplicarlo se barrieron todos los gates de escritura contra elrequire_permissionreal de su router, y el resultado corrige la premisa del hallazgo: la gran mayoría de las mutaciones usanmin_crud=1, así que solo cuatro pantallas necesitaban nivel 3 (TVD y TRD en su circuito de convalidación, borrado de tipo documental y de elemento de metadato, y la nueva versión del plan de preservación). Poner nivel 3 donde el backend no lo exige habría sido el mismo defecto en dirección contraria. Se descubrió de paso que en/bandejael gate de la UI es más estricto que el backend (devolver y visto bueno autorizan por tenencia del paso, sinrequire_permission): queda anotado, no tocado, porque es un cambio de comportamiento distinto. Dos defectos colaterales encontrados y cerrados:radicar/+page.svelteevaluabacan()dentro de un$:sin ninguna dependencia reactiva —can()usaget(), que no es reactivo, así que esa línea nunca se reevaluaría tras un cambio de sesión en el mismo montaje— y un em dash pegado a un parámetro JSDoc opcional ([minCrud] —) rompía el parser de TypeScript. Verificado además quecontext/switchno altera permisos ni niveles, soloactive_depe_id.
Barrido de verificación — 2026-09-17. Los 18 pendientes abiertos se comprobaron uno a uno contra el código, no por memoria. Resultado: dos estaban cerrados sin tachar (el activo de marca y la promesa de confirmación del
AssistantDock), uno traía las cifras caducadas (los tamaños de H1: la dispersión no se corrigió, creció), dos son futuro y no deuda (app móvil, SSO corporativo) y dos son decisiones tomadas y declaradas con su motivo (/bandejafuera deDataTable, y las lecturas de TRD/PINAR). Los once restantes siguen vivos y se confirmaron con su evidencia.Se anota el barrido, y no solo su resultado, porque el coste de no hacerlo ya se pagó: en la misma jornada un pendiente cerrado sin tachar estuvo a punto de dirigir trabajo, igual que pasó con el censo del MCP y con el guardián de actores. Un pendiente sin fecha de comprobación no es un pendiente: es un recuerdo.
Segundo barrido (2026-09-19). Tras cerrar la tanda de trabajo quedaban siete casillas sin marcar, y ninguna era trabajo pendiente: una estaba cerrada desde hacía tiempo sin tachar (las lecturas de TRD/PINAR, comprobada ahora línea a línea en los dos routers), tres eran decisiones ya tomadas con la casilla mal puesta —
/bandejafuera deDataTable, los filtros server-side y los favoritos, las dos últimas incluso tachadas en su propio texto mientras la casilla decía lo contrario—, dos son futuro planeado y no deuda, y la última está esperando una decisión de producto.Lo que enseña no es el recuento sino la forma: una lista cuya casilla contradice a su propio texto no informa, desinforma. Da la sensación de siete frentes abiertos donde hay uno, y esa sensación dirige trabajo. Es el mismo defecto que esta lista corrige en el producto —el dato correcto guardado donde nadie lo mira— aplicado al instrumento que sirve para encontrarlo.
Rendimiento y activos
- [x] Subset de Font Awesome (cerrado 2026-09-18): se importan el núcleo y
solid. El recuento del pendiente también estaba viejo —decía «solid y regular»—: hay 771 usos defas/fa-solidy cero defar/fab, así que sobraban 141 KB de fuentes por visitante. Con guardián, porque un icono de familia no cargada no da error: pinta una caja vacía. - [x] Activo de marca (cerrado, verificado 2026-09-17):
static/brand/tiene ya el lockup con wordmark en PNG y SVG (orpyca-lockup,orpyca-lockup-vertical) y el emblema suelto (orpyca-mark). Elstatic/orpyca-logo.pngque este pendiente nombraba ya no existe: el pendiente se resolvió y nadie lo tachó.
Migración a DataTable
- [x]
/bandeja— decisión tomada, no se migra: es el flujo núcleo del sistema (tramitar, devolver, anular, responder, vistos buenos). Se dejó fuera de alcance deliberadamente: migrarla exige revalidar todo ese flujo, no solo el render de la tabla. - [x]
/archivo-fisico— decisión tomada (2026-09-19), no se migra: conserva su render propio. La casilla pedía decidir entre añadir soporte de árbol aDataTableo dejar la pantalla como está. Se deja como está, y el motivo es más fuerte que «no modela jerarquía»: el listado de ubicaciones carga los hijos bajo demanda al desplegar cada nodo (toggleUbicacion,ubicacionChildrenLoading), así que las filas ni siquiera están todas en memoria.DataTableparte de tener el conjunto y ordenarlo, filtrarlo y paginarlo; darle un árbol perezoso no es añadirle una prop, es cambiarle la premisa — y la pagaría cada una de las siete pantallas que hoy lo usan bien. Las otras cinco tablas de la vista sí son planas, pero migrarlas sueltas dejaría la misma pantalla hablando dos lenguajes visuales, que es peor que no migrar ninguna. Y no queda deuda transversal escondida detrás de la decisión: las seis tablas ya cumplen los dos contratos que el componente garantizaba — envoltorio conoverflow-x: autoy región enfocable con teclado (af-table-wrap, técnica WCAG SCR29,role="region"+tabindex=0), y el objetivo táctil, que es una regla global bajopointer: coarsey no una propiedad deDataTable. Con esto la migración aDataTablequeda cerrada: de sus tres casillas, dos son decisiones de no migrar con su motivo escrito y la tercera se descartó por premisa falsa. - [x] ~~Filtros/orden server-side en
DataTable~~ — descartado: — la premisa es FALSA, verificado 2026-09-18. En 6 de las 7 tablas conenableFilters/enableSortel conjunto llega íntegro (pageSize = rows.length): no hay página parcial que filtrar. Y las pantallas que sí paginan de verdad —bandeja, búsqueda, expedientes, auditoría— nunca activan esas props: ya filtran server-side en sus loaders. Conectar los eventos a un refetch sería regresión en reportes, donde los datos son agregados completos y el índice de reservados es content-free e íntegro por Ley 1712. El eje «orden» es además inejecutable hoy: ningún router acepta parámetro de orden. Lo que queda es backend sin demanda actual, no deuda de este componente.
Backend que falta para cerrar la UI
-
[x] Alertas y vencimientos (cerrado 2026-09-19, en la parte que era deuda real). No se construyó el agregador cross-service, y ese fue el hallazgo: el gateway es un proxy por prefijo sin lógica de negocio, tres de las cuatro fuentes ya existen y están enrutadas, y la cuarta —«transferencia vencida»— no puede existir porque el modelo no guarda fecha límite. Lo que sí dolía y el pendiente no nombraba:
GET /documentsno filtraba ni ordenaba pordue_date, así que el tablero derivaba sus alertas de los 20 radicados más recientes por estado y el vencido antiguo era invisible por construcción. Cerrado condue_from/due_to(extremos inclusivos) yorder_by/order_dirsobre catálogo cerrado, y con el tablero pidiendo su propia consulta ordenada por plazo. La pregunta previa se resolvió no respondiéndola: el endpoint no define «vencido» — el rango inclusivo deja que cada consulta declare su corte, y las dos convenciones del proyecto (TRD<=, préstamos<) siguen conviviendo sin que una imponga a la otra. Queda abierto: unificar esa convención es una decisión de producto, no de este endpoint. -
[x] Múltiples estados en
GET /documents(cerrado 2026-09-19).statuses repetible (= ANY($n::text[])), desbloqueado por la corrección del gateway. El tablero pasa de dos llamadas a una, y su total lo cuenta la base en vez de sumar dosX-Total-Count—correcto solo mientras los estados sean disjuntos—. Lo que este pendiente pedía y NO se hizo, con su motivo: «asignado a mí» ya está resuelto en otro servicio (flow_stepsde workflow-service, y el alcance por defecto de/workflows/inboxya ES «asignado a mí o a mi dependencia»); traerlo a/documentsrepartiría el gate de clearance y elCOUNTentre dos fuentes.
Accesibilidad y consistencia (backlog Medio/Bajo de la auditoría)
- [x] Colores crudos
rgba(...)en los velos (cerrado 2026-09-18): el color del velo vive una vez ($op-scrim-rgb) y hay dos niveles con significado —--op-scrimpara lo que bloquea y--op-scrim-softpara el asistente—, con guardián. Queda abierto: losrgba(...)de las superficies de marca de la landing y del login, que son degradados y sombras, no velos. - [x] Token de FAMILIA monoespaciada (cerrado 2026-08-22):
--op-font-family-monoen_tokens.scssy 46 declaraciones migradas en 27 archivos — eran más de los ~16 estimados, y en tres formas distintas. De paso aparecieron dos defectos que el apunte no preveía: nueve declaraciones hacíanfont-family: var(--op-font-mono, monospace), inválidas y descartadas por el navegador (el respaldo no se aplica si la variable existe, y--op-font-monoexiste: es el tamaño), y Bulma pintabacodeen rojo#da1039sobre gris — el color del error sobre el número de radicado. Guardiánstyles/tokens-mono.test.js. - [x]
<a role="button">(cerrado 2026-09-18): el enlace se anuncia como enlace. Con el rol se fueronaria-disabledyaria-busy—siemprefalseen esa rama, que solo existe cuando!isDisabled— y el spinner deloading, inalcanzable por lo mismo. Dos tests afirmaban el defecto como contrato y se reescribieron. - [x] Objetivos táctiles <44px (cerrado 2026-09-19). El criterio existía y se aplicaba componente a componente: 19
@media (pointer: coarse)en diez archivos, y un censo de los<button>nativos encontró 62 controles sin ningún mínimo declarado —pestañas de/admin, cierres de modal y cajón, la paginación deDataTable, las acciones de la bandeja—. Repararlos uno a uno es cómo se llegó a 62. Cerrado con una regla en la hoja global bajopointer: coarse(el escritorio no cambia ni un píxel), conmin-*y noheightpara que un control mayor no encoja, y con casillas y radios excluidos a propósito —ahí lo que se agranda es el<label>—. Guardiánstyles/el-dedo-alcanza-los-controles.test.js, que declara además lo que NO puede medir: jsdom no calcula layout, así que vigila el CSS, no el resultado renderizado. - [x] El escritorio se usaba a medias (cerrado 2026-09-19, reportado por el usuario). 27 pantallas con nueve anchos distintos y 19 alineadas a la izquierda: el espacio sobrante quedaba todo a la derecha. En un portátil de 1536 px con la barra lateral (1272 px útiles),
/admin/parametrosse acotaba a 900 y desperdiciaba 372 px con la tabla recortada al lado. Cerrado con dos tokens —--op-content-maxsin techo, para que la pantalla de datos se ajuste al dispositivo que la usa, y--op-reading-max880 px para formularios y texto, donde el límite es de legibilidad y no de sitio—, más.op-table-scrollpara las tres tablas de 17 columnas de/admin/eliminacion, que no desbordaban: comprimían. Guardián sobre el contenedor raíz, con excepciones declaradas. -
[x] Tamaño del H1 (cerrado 2026-09-19). Las cifras del pendiente eran falsas y la premisa también: no eran «35 sin declarar» sino 5, y no faltaba decidir el tamaño — la escala lo decía (
$op-font-2xl: 28px; // = H1 v1.0) y no lo usaba ni una pantalla. Cerrado con el token semántico--op-font-title-pageaplicado por la regla global sobreh1, retirando las 43 declaraciones por pantalla. Decisión tomada (opción b): el valor sale de la práctica (22 px, lo que ya tenían 27 de 44) y no de la tabla, y se corrigió la anotación de la escala;$op-font-2xlconserva sus 28 px porque cinco sitios legítimos lo usan para otra cosa. Medido en navegador real, no solo en el CSS. -
[x]
EmptyStatereimplementa.op-btn(cerrado 2026-09-18): usaButton. La copia se justificaba «por siButtonno está importado», y el escape era justamente importarlo. - [x] Detalles ARIA —
aria-controls(cerrado 2026-09-18): el panel de métricas permanece en el árbol y se oculta conhidden, así que el identificador que el botón declara controlar existe siempre. Elrole="list"con hijos que no sonlistitemse cerró también: la fila de KPIs pasa arole="group". - [x] Atajo
/y trampas de foco (cerrado 2026-09-18): la guarda pregunta por[aria-modal="true"]en el DOM —no por una bandera local: lo modal lo declara quien se abre— así que con el asistente o el drawer abiertos el foco ya no sale del diálogo. La mutación que la quita solo cae si el test espera altick()en el que se mueve el foco. - [x]
AssistantDockpromete lo que no cumple (cerrado, verificado 2026-09-17): el dock montaProposalCardy cablea la confirmación con frase tecleada (AssistantDock.svelte:377-382), y el placeholder que hacía la promesa ya no está. Importa que estuviera cerrado: su condición de inocuidad era «el backend v1 es solo-lectura», y el catálogo del MCP tiene hoy herramientas de escritura. - [x] ~~Limpiar favoritos al cerrar sesión~~ — descartado: REPLANTEADO 2026-09-18: el arreglo que este pendiente proponía sería una regresión. Los favoritos se guardan por
orpyca:favorites:<tenant>:<usuario>, así que la lista de otro NO se le muestra a nadie; y el logout es un<form method=POST>que redirige a Keycloak —navegación completa—, de modo que el estado en memoria muere solo. LlamarclearPersistedFavorites()al salir borraría los favoritos PROPIOS en cada cierre de sesión, que es lo contrario de por qué se persisten. Lo único real es el residuo enlocalStoragede una máquina compartida, y eso pide un acto explícito —«olvidar mis favoritos en este equipo»—, no un borrado automático. Queda como decisión de producto, no como defecto.
Encontrado al verificar lo anterior (2026-09-18) — no estaba en ningún pendiente
-
[x] El radicado guardaba a quién iba dirigido y ninguna pantalla lo leía (cerrado 2026-09-18). El circuito de escritura del directorio quedó completo —radicar, ventanilla ciudadana, ingesta de correo y el asistente atan ya remitente y destinatarios con su contacto congelado—, pero la lectura se quedó a medias:
/enviosera el único consumidor, y solo para precargar el destinatario postal. El detalle de/bandejadescribía al actor desdesender_name, que lleva únicamente al principal, así que una Salida con tres destinatarios se veía idéntica a una con uno. Cerrado con la sección de actores en el cajón de detalle, separada por rol y leyendo la copia congelada —no la ficha vigente del catálogo, que responde otra pregunta—. La mutación que se queda solo con el principal pone en rojo los tres tests del caso de varios. -
[x] Las reglas de flujo se crean, editan y borran sin asiento de auditoría (cerrado 2026-09-19). Los tres actos asientan dentro de la transacción que escriben (
workflow.regla_creada/_modificada/_eliminada,object_ref= UUID de la regla). La edición registra solo los campos tocados con suantes/despues, leídos conFOR UPDATE; el borrado —físico— lleva el retrato completo, que pasa a ser el único sitio donde sobrevive lo que la regla decía.PATCHyDELETEexigen ahoraX-User-Id, quePOSTya exigía. - [x]
size=200truncaba en silencio (cerrado 2026-09-19). Eran ocho pantallas, no dos: se sumaron/archivo-fisicoy las tres de personas. 200 es elledel backend, no una holgura. Cerrado contraerListaCompleta—pagina hasta cubrir el total y declara la carga parcial si no cabe— más el aviso visible en las cuatro pantallas donde la lista alimenta un árbol o un selector.GET /auth/users/pickable, que no pagina, emite ahoraX-Total-Countcon los activos que hay: sin esa cabecera no había nada que mirar. - [x]
GET /prestamosdevolvía la lista entera sin paginar (cerrado 2026-09-19).page/size+X-Total-Count, con elCOUNTcompartiendoWHEREy desempate porid(fecha_prestamoempata para todo lo insertado en una misma transacción). El listado por unidad se deja sin paginar, declarado. En pantalla, contador del servidor y «cargar más». Confirmado de nuevo: no es una fuga de clearance — esa consulta no uneexpedientes.
Deuda de tooling y documentación
- [x]
npm run lintno llegaba a ejecutar ESLint (cerrado 2026-08-05): el script esprettier --check . && eslint ., y 676 archivos sin formatear cortaban la cadena en el&&, así que ESLint llevaba tiempo sin correr en CI. Formateados todos, y con ESLint ya ejecutándose se cerraron sus 12 errores. Al hacerlo aparecieron tres defectos que el formateo destapó, no causó: - Diez atributos Svelte con comillas dobles sin escapar (
emptyDescription="Crea el primero con "Nuevo usuario"."y nueve más). El parser corta el atributo en la segunda comilla, así que el texto visible quedaba truncado en "Crea el primero con " — defecto de interfaz real en diez pantallas de/admin, invisible hasta que Prettier no pudo parsear el archivo. Corregidos con las comillas angulares «» que el proyecto ya usa. .svelte-kit.root-stale/estaba versionado: 737 archivos generados, 16 MB, commiteados en32fc631. Ni el patrón.svelte-kit/del.gitignoreni el hookguard-bashcasaban con el nombre renombrado. Sacado del índice (sin borrar del disco) y los patrones pasados a.svelte-kit*/. Es la segunda vez en esta línea base que un patrón sin anclar/comodín deja entrar lo que debía excluir.- Prop muerta
iconenCatalogCrudPanel: declarada, nunca renderizada (elEmptyStateusaemptyIcon), y dos pantallas la pasaban creyendo que hacía algo. Eliminada de las tres. Los{@html}y el regex de caracteres de control desanitizeHtml.jsse marcaron coneslint-disablejustificado (son deliberados: el saneo server-side es la barrera y el{@html}su consumidor; los caracteres de control son justamente lo que unjava\x00script:usa para esquivar la detección).svelte/no-unused-svelte-ignorese apagó en.eslintrc.cjsdocumentando la razón: es una divergencia real entre ESLint y el compilador —svelte-checksí necesita esossvelte-ignore(al quitarlos aparecen 13 avisos), y ESLint no evalúa esa regla de a11y. - [x] 14 archivos de
frontend/src/pertenecen aroot(cerrado 2026-08-06): residuo de un build en Docker que escribió como root en el volumen montado — no se podían editar desde el host sinsudo, lo que dejaba un error de ESLint sin corregir (no-useless-escapeenexpedientes/[id]/api/export/+server.js). Reapropiados por el usuario (chown, no se escalaron privilegios desde aquí) y cerrados los tres emisores para que no vuelva a ocurrir:scripts/lint.shmonta:rocon las cachés en/tmpdel contenedor, el comando de lockfiles deCLAUDE.mdescribe porstdouty redirige en el host, yCONTRIBUTING.mddocumenta la regla de no montar el repo en escritura. Comprobación:find . -not -user "$(id -un)" | grep -v '^\./\.git/'sale vacío. - [x] El gate
./scripts/lint.shen verde en los ONCE servicios (cerrado 2026-08-07): estaba en rojo en 10 de 11 (107 errores de ruff + 177 de mypy). Los dos hallazgos de fondo son del propio gate: (1) su lista de servicios estaba escrita a mano y cubría 8 de 11 —signature-service,mcp-serveryknowledge-servicenunca se linteaban, con 108 errores invisibles entre los tres; ahora la lista se deriva deservices/*/; (2)orpycamcp_common, la librería de auditoría inmutable, se verificaba comoAnyen los once servicios (se instala desdeshared/, no desderequirements.txt, así que el contenedor de lint no la tenía) — el síntoma eran 35type: ignoremarcados como sobrantes, y borrarlos habría tapado el agujero; cerrado instalandoshared/y añadiendo el marcador PEP 561py.typed, con lo que 29 de los 35 avisos desaparecen sin un solo error nuevo. Defectos reales destapados: relleno RSA asumido sobre el union de claves decryptographyen TSA/OCSP/CRL,public_numbers()inexistente en Ed25519/Ed448 al comparar certificado y CSR,tracking_numbernulo llegando al repositorio en rutas de ACL y anulación, y un test destorage-serviceque extraía un dato, comentaba qué había que comprobar y no comprobaba nada. 2 516 tests sin roturas. - [x]
docs/en/CERRADO (2026-08-07; parcialmente saldado el 2026-08-05). Puestos a paridad:roadmap.md(99→233 líneas — declaraba el corte del 2026-06-22 y daba por pendientes E18, E21, E10, E15, E20, además de describir el frontend como "SvelteKit scaffold"; todas esas afirmaciones eran activamente falsas, no incompletas),pantallas.md(47→128, con todas las filas de/admin/*y la sección de navegación global) y la sección[Unreleased]dechangelog.md, que además no tenía la secciónSecurityen absoluto. Decisión explícita: no se traduce el histórico completo del changelog (1004 líneas) — es registro histórico y el coste es desproporcionado.
Saldadas las cinco páginas restantes (2026-08-07), cerrando el pendiente: api.md (814→1083; faltaban 95 endpoints, no matices de redacción — TOTP y clave de firma personal, gestión RBAC de usuarios, grupos y permisos, PINAR entero, preservación digital completa —WORM/AIP/renovación CT—, el bloque de firma electrónica, envíos postales, los dos OAI-PMH, CMIS, export/import, RAG y el asistente), getting-started.md (173→235, faltaba toda la sección de interfaz web), domain.md (274→316, faltaban la TVD, el circuito de convalidación de la TRD y los perfiles v3/v4 del índice), deployment.md (483→524, faltaban el contrato de iss y la configuración del frontend SSR —dos secciones cuya ausencia produce un despliegue que rechaza todos los tokens o no completa el login—) y normativa.md (92→105, faltaba el marco de firma Ley 527/DUR 1074).
Verificado por cruce mecánico, no por lectura: los 249 endpoints de api.md extraídos por expresión regular casan en ambas direcciones (ni uno de más ni de menos). El cruce es reproducible:
ep(){ grep -oE '`?(GET|POST|PATCH|PUT|DELETE) (/api/v1|/oai|/cmis)[^ `|]*' "$1" | sed 's/^`//' | sort -u; }
diff <(ep docs/es/api.md) <(ep docs/en/api.md)
Dos afirmaciones caducadas corregidas de paso (no eran huecos, eran texto activamente falso — 4.ª aparición del patrón): api.md en español titulaba una sección "contrato definido, backend PENDIENTE" y declaraba el mcp-server como scaffold cuando E18 está implementado y documentado en el mismo archivo 300 líneas más arriba; y domain.md en inglés decía que la firma XAdES del índice "depende del signature-service, pendiente" cuando XAdES-B/T/LT/LTA está construido (lo que falta es la acreditación ONAC, que es otra cosa).
Corrección de alcance (2026-08-13): aquel «CERRADO» valía para las páginas de referencia —api.md sigue casando en ambas direcciones con el cruce de arriba— pero no para docs/en/changelog.md, que iba 135 entradas por detrás. La afirmación se leía como si cubriera todo docs/en/. No se cierra traduciendo: las 135 estaban todas en [Unreleased], o sea, eran la historia de una única línea base sin publicar, y mantener 300+ anotaciones de proceso duplicadas en dos idiomas era la causa del desfase, no su síntoma. La versión inglesa encabeza ahora con un resumen de qué es v0.01 por áreas, con sus límites declarados, y ambos ficheros dicen en cabecera qué son. La paridad entrada-a-entrada del changelog deja de ser un objetivo — no queda pendiente que perseguir.
- [x]
docs/es/pantallas.mdestaba incompleto (cerrado 2026-08-07): nueve pantallas existían enfrontend/src/routes/(app)/y no figuraban en ninguno de los dos idiomas —/anulaciones,/perfil,/admin(índice de tarjetas),/admin/auditoria,/admin/colas,/admin/notificaciones,/admin/usuarios,/admin/flujos/reglasy/admin/flujos/reasignacion. El inventario de pantallas es la página que un evaluador usa para saber qué hace el sistema, así que un hueco ahí pesa más que en otras. Las nueve entraron en ES y EN. Verificado por cruce mecánico, no por lectura: los 40+page.sveltedesrc/routes/contra las filas de ambas tablas — cobertura completa e idéntica entre idiomas; las diferencias que arroja el cruce son de notación —las rutas públicas se documentan con el prefijo del grupo ((public)/…) y algunas comparten fila con·—, no ausencias. El cruce es reproducible y conviene repetirlo al añadir pantallas:
cd frontend && find src/routes -name '+page.svelte' \
| sed 's#src/routes##; s#/+page.svelte##; s#/(app)##; s#/(public)##' | sort -u > /tmp/rutas.txt
grep -o '^| `[^`]*`' ../docs/es/pantallas.md | tr -d '|` ' | sort -u | comm -23 /tmp/rutas.txt -
CERRADO (2026-09-24) — el sr-only que desplazaba la página entera
- [x] Las 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 de ancho,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. Por qué costó tanto: los tres síntomas apuntaban al sitio equivocado. La franja salía vacía (el elemento es invisible y mide 1 px); ocultar la tabla «curaba» el problema —porque el span vive dentro de ella—, lo que puso a la tabla en el banquillo; y la lista de culpables que imprimía el propio test era ruido, porquegetBoundingClientRect()ignora el recorte de los ancestros y acusaba a la tabla en cada corrida. Comprobado sobre una página mínima SANA: el medidor viejo acusaba al mismo reparto exacto. Un culpable que aparece también cuando no hay delito no es una pista. Cura:position: relativeen todo envoltorio que recorta en horizontal (20 ficheros). Medido en vivo antes y después: 827/612/708/580/461 → 390 exactos. Guardián:una-tabla-ancha-se-desplaza.test.jsexige que todo bloque conoverflow-x: autosea su propio bloque contenedor — con control positivo, control de vacuidad y quitando los comentarios antes de mirar, porque el comentario de la corrección contiene la cadenaposition: relativey un detector ingenuo se aprobaría a sí mismo. Cae por mutación. Lo que cerró antes esta misma suite: la barra superior desbordaba en las 17 pantallas de móvil (topbar-actionsocupaba 385 px empezando en x=164 sobre un viewport de 390) por elmin-width: autoque traen por defecto los hijos de un contenedor flex. Corregido enAppLayout.svelteyDependencySwitcher.svelte: de 17 fallos a 5, y de 5 a cero.
Limitador por IP antes de autenticar — CERRADO (hallazgo 2026-09-24; cierre en 9c2cc6a)
- [x] ~~
GET /api/v1/assistant/alcanceresponde 429 cuando la suite autenticada corre completa, y el log del gateway trae el dato que importa:"tenant": null, "usuario": nullcon 1,6 ms de duración. El limitador rechaza antes de autenticar, así que la cuota va por IP y no por persona. Salió de una prueba que parecía inestable —fallaba en las tres corridas completas y pasaba aislada—: una prueba que pasa aislada y falla en la suite no es flaky por definición; aquí el patrón era 3 de 3, determinista, y el motivo estaba en el log del servidor. La interfaz degrada con honestidad («No se pudo consultar qué acciones tiene disponibles»), así que el síntoma es suave y el motivo queda invisible. Por qué importa: 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. Decisión pendiente, no ajuste: el eje de la cuota (IP, usuario autenticado, o ambos con límites distintos). Contar después de autenticar gasta validación en peticiones anónimas; contar antes reparte el castigo entre inocentes. No se cierra subiendo el límite ni metiendo esperas en el test — eso taparía el hallazgo.~~ Cerrado: se tomó la decisión de «ambos ejes». La IP se cuenta antes de autenticar con un tope holgado (rate_limit_per_ip=1200/min,rate_limit_login_per_ip=20) y, después de autenticar,RateLimitPorUsuarioMiddleware(services/api-gateway/app/middleware/rate_limit.py:154, registrado antes deJWTValidationMiddlewareenmain.py:94, o sea más interno) cuenta por persona+entidad (120/min), por entidad (3000/min) y global. Pruebas:test_la_cuota_es_por_persona.pyytest_limitador_no_gasta_el_cubo_ajeno.py; la mutación que las tumba es quitar eladd_middlewareo registrarlo tras el de JWT (sin identidad no cuenta). Residuales aceptados y declarados: los tokens inválidos no se cachean, así que una IP puede forzar hasta 1200 validaciones/min contra auth-service; y el limitador falla abierto sin Redis (rate_limit.py:95-97).
Sustitución del almacén de objetos — MinIO → RustFS (2026-09-25)
- [x] MinIO archivó su proyecto libre y retiró sus imágenes de todos los registros. La pila
entera dejaba de arrancar y la instalación no se podía reconstruir desde cero. Sustituido por
RustFS 1.0.0 (Apache 2.0). Decisión y alternativas descartadas en
ADR-027.
El motor lo eligieron las pruebas: batería de conformidad WORM contra un S3 real
(
services/storage-service/tests/integration/test_worm_object_lock_real_s3.py) — RustFS 7/7, MinIO 7/7 (línea base), Garage 0/7 (control negativo). - [x]
backup.shyrestore.shestaban rotos y no se había notado: usaban la imagenminio/mc, también borrada. Migrados aaws-cli. De paso, la retención se repone ahora con el instante exacto y no con días redondeados hacia arriba, que movían la fecha de vencimiento en cada restauración. -
[x] El read-back de la retención, que faltaba desde antes de esta decisión: el sistema ordenaba la retención y no volvía a mirarla nunca, pero asentaba
retention_mode='COMPLIANCE'y un evento PREMIS enok. Ahora se relee y se aborta fail-closed si no coincide. -
[ ] Pendiente de validar con la pila levantada (se aplazó a propósito: la máquina no podía sostenerla). Cuando haya recursos:
docker compose run --rm --no-deps storage-service pytest -q;./scripts/lint.sh storage-service; levantar la pila, resembrar y correr el carril autenticado del frontend. La batería WORM ya se ejecutó contra los tres motores en contenedores sueltos, pero el servicio completo aún no se ha ejercitado contra RustFS. - [ ] Los nombres
MINIO_*sobreviven al motor que los nombraba (13 ficheros de código, el servicio del compose y los buckets). Se conservan a propósito para no multiplicar el riesgo de esta migración; queda como deuda declarada, no como olvido.
Traducción inglesa puesta a paridad (2026-09-25)
- [x]
docs/en/api.mdiba 21 endpoints por detrás del español. Traducidos e insertados en su sección: directorio de terceros (duplicados, radicados de una ficha, fusión), plantillas y combinación documental de E23, circuito de borradores (previsualizar, radicar, devolver), cuentas de correo y OAuth de E19, visto bueno de firma, historial de acceso del radicado, contenido interno del fichero y las rutas internas de render y huella. Cruce verificado: 338 = 338, casan en ambas direcciones. - [x]
docs/en/pantallas.mdno tenía tres pantallas —/correo,/admin/tercerosy la página de error de la aplicación— ni la sección de trámites ciudadanos. Esto último no era formato: se había perdido el párrafo que explica por qué esas pantallas no piden cuenta (Ley 1755/2015), que no aparecía ni una vez en la versión inglesa. Repuesto. Cruce: 52 entradas idénticas. - [x]
docs/en/getting-started.mdno tenía la sección de cadena de firmas sobre un expediente. Traducida. - [x]
mcp-flujos.mdyplan-pruebas.mdexisten solo en español y, confallback_to_default: true, MkDocs las servía en español dentro del sitio inglés sin avisar. Ahora lo declaran en su cabecera, igual que hace el changelog con su alcance. - [x] Afirmación caducada corregida: el cruce de
pantallas.mddecía «las tres únicas diferencias»; hoy son más y todas de notación. Una cifra exacta que deja de serlo desinforma más que una descripción del criterio. Declarado, no oculto: la prosa inglesa es más condensada en un puñado de endpoints —una viñeta donde el español da una sección con más fundamento—. No falta ningún endpoint, parámetro, código de estado ni gate; falta parte del porqué. La nota de alcance está en la cabecera dedocs/en/api.md.