Saltar a contenido

Roadmap

Actualizado al 2026-10-03.

OrpycaMCP ha seguido dos planificaciones complementarias:

  1. Roadmap fundacional (Fases 1–6, abajo «Andamiaje»): levantó los 8 microservicios con su CRUD básico, infraestructura y CI/CD. Completo.
  2. 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_actu de Orfeo): el workflow-service escribe flujo_dept/flujo_dept_code/flujo_usuario en el mismo UPDATE que el estado, ubicacion_* son columnas generadas, y GET /documents y /search filtran por ?ubicacion_dept_code=. Migraciones 037 (document) y 016 (workflow, backfill con evidencia), reportes «Ubicación actual», export INT-01 informativo y MCP: ✅. Frontend ✅ (/bandeja, /busqueda con 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.status en la misma transacción que el paso: repartir, trasladar, devolver, revertir y reasignar dejan distributed; marcar_leido del poseedor, in_progress; y solo «Finalizar trámite» (POST /workflows/{id}/finalizar) cierra (closed). closed y anulado son terminales (409 radicado_terminal) y el PATCH del radicado rechaza status (422). Backend, MCP (finalizar_tramite), migraciones (backfill 013) 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); rollback solo desde pending; completar un paso sin marcarlo leído deja distributed; completar un paso in_progress deja in_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/transfer exigen clearance y tenencia, finalizar exige nivel 3 y motivo_cierre, la anulación cancela el paso (y la migración 015 sanea los zombis), la importación rechaza anulado (+ CHECK de la migración 034), 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.disposition su fin_archivo_gestion, su fin_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.md los 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 deja cierre_sin_snapshot_de_retencion, que la tercera pestaña de /admin/trd ya 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 el WHERE de 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 en audit_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_TRD nivel 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 un DELETE con 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_unavailable sin ANTHROPIC_API_KEY o ante fallo del SDK — nunca una respuesta simulada, RF-FIR-15). Conectado a E21 ✅: tool buscar_conocimiento (POST /api/v1/knowledge/search ví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/me y consume la propuesta antes de despachar. Sustituye a X-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/execute y POST /assistant/confirmar; para clientes MCP externos, tool confirmar_accion(token), annotations estándar y elicitación en banda por stdio. Límites declarados: el consumo por jti es 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 tool mi_alcance y el resource orfeo://alcance declaran 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 de X-User-Permissions —cabecera que nadie producía— y de MCP_USER_PERMISSIONS/MCP_USER_ROLES —ajustes que no concedían nada—: sale de /auth/me en 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 en ProposalCard; el tool_result que vuelve al modelo dice ejecutado: false para 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): VoiceInput rellena 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/transcribe con Whisper ✅ — camino soberano para TODOS los navegadores: whisper-service (faster-whisper en CPU, perfil assistant, interno) transcribe dentro de la instalación. Sin proveedor configurado el endpoint se NIEGA (503): no hay stub de 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 desde shared/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) — embebe subject/sender del 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; created nunca baja un nivel). Purga-en-anulación ✅ (document.radicado.annulled → chunk voided permanente, excluido de la búsqueda a cualquier clearance; created tardío nunca re-indexa un anulado). Proveedor de embeddings REAL ✅ Inc.1 (E21, capa de conocimiento real): EmbeddingProvider pluggable (local_st sentence-transformers multilingüe, local/soberano, default de despliegue; stub determinista, default forzado en tests; ollama mínimo viable, diferido) reemplaza el stub como proveedor único — EMBEDDING_DIM (384) fuente única de la dimensión de knowledge_chunk.embedding, guard fail-closed al arrancar, migración 007 (NULLea dim 64 incompatible + vector(384) + índice HNSW) con job de backfill python -m app.ops.reembed. is_embeddable generalizado a LOCAL_EMBEDDING_PROVIDERS: con local_st el 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_ref XOR) reusa search (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): LLMProvider pluggable (ollama_local default soberano, ollama_cloud/openai_compatible externos mínimo-viables, anthropic con Citations API nativa, disabled→Patrón A) tras POST /knowledge/rag. Barrera dura de soberanía para GENERACIÓN (decisión tomada 2026-07-20, reconciliación en ADR-006): is_generatable excluye del contexto los chunks nivel_seguridad >= RESERVADA con proveedor externo, fail-closed, sin override — a diferencia del modelo suave original del ADR. Tabla kb_suggestions (migración 008, proveedor/modelo en columnas separadas, CC-03) + asiento en audit_log. Degradación honesta 503 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 el premis.xsd OFICIAL 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ía MINIO_SECONDARY_*—, disparada por num_copias≥2, no-fatal) + validación PDF/A — contrato + stub honesto ✅ Increment C2a (interfaz PdfaValidator pluggable, stub por defecto que reporta no_evaluado sin fingir, evento VALIDACION_PDFA + PREMIS validation, advisory, migración 008 del 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ón run_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), columnas worm_* ortogonales al estado (migración archive 029), marca+asiento atómicos; storage gana idempotencia (índice único parcial migración 009 + corte-circuito + advisory lock de sesión por (tenant, expediente) que impide duplicar objetos WORM irreversibles bajo carrera) y el endpoint interno proteger-indice-internal para 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-indice se generaliza a proteger-artefacto con tipo ∈ {indice, acta_transferencia} (migración storage 010 amplía el CHECK + índice único parcial; _worm_lock_key incluye tipo; /proteger-indice[-internal] quedan como alias duros), archive gana proteger_worm_acta (disparo síncrono best-effort + 4ª fase run_once_actas_worm) con columnas worm_* en transferencia_acta_firma (migración archive 030), retención TOTAL de la TRD con fecha_inicio = firmado_at del 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 de tipo. 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_hold sigue incondicional, fail-closed ante tipo no 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 su retain_until original — nuevo par POST /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 usa set_object_retention (verificado en miniopy-async 1.23.5 real, no put_object) — migración storage 011 (renovado_at/renovaciones). Archive gana la 5ª fase de reconciliación (run_once_worm_renovacion, piggyback en el ciclo del índice): espejo local worm_retain_until en expediente_indice/transferencia_acta_firma (migraciones 031/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 rodante nueva = 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: solo disposition ∈ {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 visibilidad GET /expedientes/indices/pendientes-renovacion (espejo de pendientes-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): SubprocessVeraPdfValidator ejecuta 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 ve PATH+JAVA_TOOL_OPTIONS, nunca DATABASE_URL/MINIO_SECRET_KEY/tokens), start_new_session+killpg+reap garantizado en finally (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 stub not_evaluated sin binario (RF-FIR-15). JRE+veraPDF solo en la imagen de deploy (Dockerfile multi-stage base/production no-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): el POST /preservacion/aip existí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 los file_id de 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 + índice firmado + 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). Tabla expediente_aip (mig archive 033, ciclo independiente), 6ª fase run_once_aip (advisory-lock propio), mig storage 012 (unique por contenido). Advisory lock de sesión en empaquetar_aip (patrón proteger_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; omitido fí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ª fase run_once_worm_renovacion cubre ahora los 3 artefactos (índice, acta, AIP) — espejo worm_retain_until+worm_renovacion*_enexpediente_aip(mig archive034), AipWiringService.renovar_worm_aip, filtro CT fail-closed en SQL, ventana rodante, 3er advisory lock worm_renovacion_aip; storage renovar-retencion-internalya genérico admitetipo='aip'; el endpoint de visibilidad pendientes-renovacion cubre 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 _\_agotado de 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 /tenants ya no ejecuta DDL; la provisión es de init_tenant.py), alta/cambio de estado de entidades con asiento atómico, 409 al renombrar/desactivar una dependencia con trámite vivo y antes/despues en el cambio de parámetros. Pendiente: verificar con el rol real orpycamcp_app (la pila local corre con el propietario por decisión del usuario) y el ADR para guardar codigo en lugar de nombre.
  • 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ón 020, key_version; el esquema 0 se retiró en B9, migración 024: lo anterior ya no verifica y /verify dice esquema_hmac_retirado), el estado de la credencial se resuelve fuera del row-lock, /verify aplica no-read-up y deja de llamarse «público», acreditado sale 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-xml verificaba (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ón 010 de 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 con advertencias[] en /verify; una cadena sobre radicado/expediente firma el contenido (PDF vigente / índice vigente, resueltos por endpoint interno en document/archive) y detecta si cambió (409 documento_modificado); y firmar/rechazar/vobo/challenge comprueban la pertenencia antes del estado (un ajeno no sondea el estado). Pendiente: HSM/KMS — pendiente de proveedor; no se implementa. El enganche existe en services/auth-service/app/core/signing_keys.py::sign_digest (único punto que un proveedor PKCS#11/KMS reemplazaría) y en services/signature-service/app/providers/base.py (proveedor de sello institucional); activar el gate clasificados en 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-xml exige X-Internal-Token para el sello institucional y objeto_tipo es 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-indice y /proteger-artefacto con token interno, el gateway enruta /preservacion/ por lista de admisión, la segunda protección con otro file_id da 409, y legal_hold sale del body y se gobierna por legal-hold-internal (motivo + asiento atómico). Pendiente: endpoint de usuario en archive que orqueste el legal hold (permiso por decidir) y subir min_crud de PERM_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}/radicados y /respuestas declaran y respetan nivel_seguridad; el borrador hereda el nivel del padre (migración 035) y se gatea en el WHERE; PATCH de borrador asienta; la firma declarativa exige nivel 2, resuelve la huella en el servidor y asienta atómicamente. Guardián test_contenido_declara_su_nivel. Pendiente de decisión: retirar el POST de firmas con 410; titularidad al editar un borrador; nivel_seguridad en SearchHit/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.codigo inmutable 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 tenant 013 / document 046 / workflow 019 con relleno que falla cerrado y evidencia anclada. Pendiente: retirar el alias por nombre (campos *_dept, ?dept=) y los índices por nombre cuando la cabecera Deprecation esté 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 la 046/019 no 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 (ExpedienteUpdate es extra="forbid": haría falta PATCH con revalidación, asiento de auditoría y decisión sobre expedientes cerrados) y un detalles_schema por tipo de trámite; hasta entonces EditarMetadatosModal y TransaccionModal no migran a SchemaField.
  • Metadatos: deuda declarada (2026-10, B6, ronda de corrección): (1) un tipo documental sin plantilla activa guarda metadata sin 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-of require_any_permission (fuente única en orpycamcp_common.rbac) para users/pickable, email/cuentas, plantillas y plantillas/campos (PERM_RADI o USUA_PERM_ADMIN); PUT /config/params/{key} con claves cerradas (422 config_param_desconocido). Pendiente (frontend): /admin/parametros aú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 el WHERE por destinatario; (2) signature consume documento.contenido_cambiado e invalida solo los circuitos sobre un texto que ya no es el vigente; (3) knowledge gatea con el nivel vigente del radicado (GREATEST en el WHERE) 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 del ArchiveTimeStamp de XAdES-LTA con herencia WORM y PREMIS RENOVACION, 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 tras remote_sign (se asienta y se avisa); un consumidor de firma.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 asientos firma.lta_* y el log; firma_sello_archivo.estado/ultimo_aviso sin endpoint); /verify no evalúa el TimeStampValidationData; radicados/auth_users sin calificar con public en el search_path (SEG #12). Correcciones de auditoría (2026-10-17): 404 route_not_found en vez de 500 para rutas que no estabilizan; ocurrido_at firmado en el callback postal (aviso del operador ≠ fecha del hecho), 413 tambié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 /antecedentes gateado en el WHERE; 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/validate informa tenant_estado y 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 seis get_tenant_conn lo vuelven a comprobar antes del search_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 responden 503 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 en get_db_conn; la identidad firmada lleva X-Identity-Ts y 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_canal usa el tenant del token y no la cabecera del cliente. Pendiente: el frontend debe mostrar «Entidad suspendida» ante 403 tenant_inactivo en 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/ready solo desde el propio edge, TRUSTED_PROXIES=172.20.128.0/17 sin el gateway de la red, terceros por tag y digest, .env.example completo; restore.sh falla cerrado ante cualquier error de pg_restore ajeno a las PK heredadas y comprueba el Object-Lock de cada bucket protegido antes de tocar datos; backup.sh exige el bucket de cada tenant no dado de baja (activo o suspendido) en el espejo; restore.sh exige RESTORE_OPERADOR, hace el pre-vuelo del Object-Lock antes de parar nada, solo admite pg_restore con 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 si TRUSTED_PROXIES cubre la puerta de enlace de la red; POST /challenge aplica las mismas precondiciones que /sign (credencial personal, huella vigente, visto bueno, anulado) y /auth/me con una entidad sin schema es 200 con permisos vacíos. Pendiente: nginx -t real (también confirma los listen [::]) y levantar edge en un servidor; (SEG-M3 cerrado en B9, ADR-035); actualizar Keycloak 24.0.5 (fuera de soporte); (el 500 de get_tenant_conn con un tenant sin schema y el /auth/me de un tenant suspended quedaron 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): Transforms exc-c14n explícitos en las referencias que signxml deja sin ellos, SignedInfo firmado después del parcheo (sello y firma personal), autoverificación fail-closed con XAdESVerifier() por defecto, sin atajo al verificar, y entrada del ArchiveTimeStamp segú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_vencido antes de consumir el TOTP); /verify de un LTA cuyo certificado caducó después de firmar lo juzga a la fecha del SignatureTimeStamp, con aviso; los sellos de tiempo se verifican con la canonicalización que declaran. Validado en vivo: SignatureIntact y todos los DataIntact, INDETERMINATE/NO_CERTIFICATE_CHAIN_FOUND por la CA de desarrollo. Pendiente: confianza (cadena a una lista de confianza / ONAC), que el humo contra DSS entre al CI, renovar el ArchiveTimeStamp (EN 319 132-1 §5.5.3), releer nivel_seguridad bajo 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; import no mueve el contador con números de otra entidad ni lo desborda; envíos con nivel 3, asiento con actor y UPDATE con 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_DESPACHO propio, 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): avalar y ejecutar del lote de eliminación piden USUA_PERM_TRD nivel 4 y quien propone no avala ni quien avala ejecuta (409 separacion_de_funciones, en el WHERE de la transición y antes del primer borrado); publicar/avalar/anular/ejecutar atómicos con su asiento; GET público del lote sin oráculo de entidades; WORM del acta con reintento programado + barrida propia y exigido antes de destruir (409 acta_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 422 radicado_sin_tipificar; el préstamo físico aplica el clearance del prestador en el INSERT (403). Pendiente: AREA-01-13 restante (prestado_a_user_id UUID + min_crud=3: cambia el contrato y pide validación de archival-compliance-auditor), propagar al índice un retipificado posterior (evento document.radicado.retyped), y batch.py que 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) en evento_dead_letter (el aparcamiento del segundo grupo ya no se pierde) con migración idempotente en los 4 servicios y ON CONFLICT por grupo; knowledge gana la superficie admin de su DLQ (/api/v1/knowledge/admin/deadletter, replay de purged/annulled atómico); events.consumer_name() por réplica; dead-letter huérfano con last_error; X-Truncated sin falso positivo; /health/ready con dead_letter y último barrido. Pendiente: métricas Prometheus/alertas (decisión de operación) y clearance en el WHERE + paginación por cursor del listado.
  • E21/E16/E05/E06 — gate de la DLQ en el WHERE (AREA-10-08) ✅ (2026-10): fuente única orpycamcp_common.deadletter, paginación y recuento exactos sin tope de 1.000, 404 indistinguible, X-Truncated retirada; knowledge gana retención de la DLQ terminal y segunda red por occurred_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 tenant 012), la sirve GET /ventanilla/politica con lectura estricta (503 politica_no_verificable) y se relee al radicar (409 politica_desactualizada/politica_no_publicada, antes de gastar consecutivo); el acuse de un envío debe haberlo subido quien lo registra (403 acuse_ajeno) y no ser ya otra cosa (409 acuse_ya_referenciado, índice único 045). Texto de supresión frente a retención en normativa.md y 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 (marca X-Origen-Canal firmada, ligada a tenant y token, verificada por el gateway; audit.append hereda el canal de la petición) y lleva payload.correlacion (request_id + jti de la propuesta); POST /assistant/message devuelve no_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_local solo es soberano si OLLAMA_URL es 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ón 012); 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 canal mcp al reconstruirse y el streaming del RAG. La reclasificación de expedientes no existe, así que no hay nada que propagar al índice (guardián en shared/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_steps a nivel 3; get_actor en la DLQ de workflow; Devolver espeja PERM_TRAMITAR; cruce entre entidades de la DLQ de notification probado. Cerrado 2026-10-02: el secret de webhooks va cifrado en reposo (AES-256-GCM con KEK versionada propia del servicio, AAD tenant+webhook, re-wrap por app.ops.rewrap_webhook_secrets, esquema común en orpycamcp_common.kek; la entrega falla cerrada ante una fila en claro). Pendiente: aislamiento con dos esquemas en tenant/workflow; productor de document.* 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_ADMIN nivel 3; el alta en un grupo aplica no-write-up de clearance en el INSERT; PATCH/DELETE /groups/{id}; typ/azp/claims obligatorios en el JWT; caché de validación del gateway con expires_in real y sellada con MAC; /audit/verify sin total ni 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, subir min_crud de 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-formato con 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 queda no_verificado y 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 responde 503. El tipo PREMIS MIGRACION deja 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 en xades:OCSPValues y cubierta por el ArchiveTimeStamp, certStatus derivado de la CRL, verify fail-closed con pinning del responder; no acreditado, acreditado=false, opt-in SIGNER_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}, con ca.crt, crl.der y 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=False es un literal en keys.py::load_seal —un sello real de una CA acreditada se reportaría igual como xades_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 si tsa_url y tsa_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 de SignedInfo con la clave real consumiendo el TOTP atómicamente, la privada nunca sale = seam a HSM; enroll con PERM_FIRMA + emisión offline con ca.key nunca montada; degrada a HMAC nativo sin cert; migración 011; acreditado=false SIEMPRE). 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 con USUA_PERM_ADMIN, asiento signing_key.revocada ató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.key nunca montada) + veredicto matizado (firma_criptograficamente_valida/revocacion_status, fail-closed por defecto); cierra las dos Baja del Inc.1. Inc.3 ✅ (E17/F4): gate firma_personal_require_xades de tres modos (off|clasificados|todos, default off retrocompat) — cierra el fail-open del Inc.2 (Baja #3) haciéndolo opt-in por nivel de seguridad: sin cert → 422 firma_xades_requerida, auth caído → 503 fail-closed, denegación con asiento atómico firma.xades_requerida_denegada SIN consumir factor 2FA; env legacy bool coaccionado, valor inválido aborta el arranque. Sin cambio de tier legal (art. 7, no acreditada). Rollout: habilitar clasificados/todos SOLO 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 a recibir() (POST /transferencias/{id}/acta/firmar-personal), reutilizando el primitivo extraído (personal_signing.sign_personal_xml) vía el endpoint nuevo POST /signature/sign-personal (autenticado como usuario, ASSERT firmante_id==user); firmas independientes sobre el acta_fuid_xml congelado en la tabla transferencia_acta_firma_personal (migración 027, UNIQUE(transferencia_id, rol), sin reconciliación desatendida — requieren TOTP interactivo); autorización atada al receptor real (decidida_por==actor, 403 firmante_no_es_receptor sin quemar TOTP) + no-read-up; verificación agregada GET /acta/verificacion (completitud ∈ {sellada, firmada_receptor}); gate acta_firma_personal_require (default off) que nunca bloquea recibir(). 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 (rol entrega, "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, el rol param solo selecciona, nunca concede → 403 firmante_no_es_parte a un tercero); firmas independientes en cualquier orden; auto-traslado (misma persona ambos lados) permitido con rol obligatorio y señalado con flag auto_traslado; completitud a 5 valores (firmada_remitente/firmada_completa nuevos); 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ía entrega). Inc.3 de la sub-épica ✅ (E17/F4; Ac. AGN 042/2002): firma personal del ELABORADOR (rol elabora, "Elaborado por" = creada_por), completando el trío de responsables del FUID — firmante ADITIVO (no parte del traspaso), así que completitud NO cambia (retrocompat con Inc.2) y su firma se reporta en un campo booleano ortogonal elaborador_firmado; tercera derivación de rol por identidad (misma garantía que Inc.2); migración 028 amplía el CHECK a elabora; valida agregado 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 columna key_version (sin usar desde la mig 011) por fin se usa (enroll cifra con la activa, load_and_sign_digest descifra 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ón 013 (í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) anula private_key_cifrado/nonce de las revocadas conservando todo lo público (verificación/revocation-status siguen idénticos); migración 014 (NULLABLE + private_key_purgado_at + CHECK invariante); una purgada deja de anclar su KEK (desbloquea retiradas). revoke() sella revocado_at para 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 y firmada_completa queda 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 .xsd v2/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 fija search_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). Idempotencia UNIQUE (operador, event_id); transición + asiento audit_log atómicos; evento Redis para E16 tras commit; notificación tardía/retroceso → recorded_no_change (honesto, RF-FIR-15). motivo_devolucion en devuelto; consulta por guía GET /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 tenant 017. Deuda declarada (roadmap): conector concreto E11 a cada operador (hoy stub consultar_estado); acuse de entrega (acuse_file_id con 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 ante devuelto.
  • 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 tablas pinar_* (migración tenant 007): 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_pct proyectado del último registro), y articulación con instrumentos. Máquina de estados borrador→aprobado→en_ejecucion→cerrado con 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 la audit_log INMUTABLE (E08, hash-chain) vía orpycamcp_common — igual que crear/ejecutar/cerrar. Contenido CONGELADO al aprobar (_EDITABLE = {borrador}, correspondencia acto↔contenido); las mutaciones estructurales (solo en borrador) auditan en admin_audit. Invariantes (índice único, trigger append-only, CHECK impacto 1–10 / avance 0–100) + el wiring cross-schema a audit_log y su inmutabilidad validados contra Postgres real (que además destapó un AmbiguousParameterError en set_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ón aprobado→en_ejecucion (POST /planes/{id}/ejecutar) añadida para hacer verificable el invariante. Para instalar orpycamcp_common el 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_series gana estado (borrador/aprobada/convalidada/migrada/derogada) + los campos del acto administrativo (aprobación del Comité — acta_comite/fecha_aprobacion_comite, ahora escritos por aprobar —, convalidación del Consejo, RUSD, publicación); trigger trg_trd_series_append_only rechaza UPDATE/DELETE sustantivo fuera de borrador (excepción explícita: pdfa_profile) y, desde la 038, blinda a nivel de motor una fila convalidada: el acto es inmutable, estado solo puede ir a derogada, rusd_radicado/fecha_publicacion admiten fill-once; PATCH /trd/{id} deja de recalcular retroactivamente — solo aplica sobre borrador; POST /trd/{code}/versiones clona la vigente como fila nueva (o la última por número si el código quedó sin vigente, 038). expedientes.trd_serie_id se congela al CERRAR (trigger trg_expedientes_trd_pin_immutable) — cierra la fuga retroactiva que motivó el hallazgo. Backfill honesto: toda serie preexistente queda estado='migrada', con marca trd_revision_requerida en 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 de aprobada), 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; o migrada→convalidada, ratificación D6), registrar-rusd (038, RUSD/publicación posteriores a la convalidación), derogar (convalidada/migrada→derogada, bloqueado si quedan expedientes open bajo 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, o derogada con 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 en documentos/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 a documents.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 — discriminador tipo_instrumento ∈ {TRD, TVD} sobre trd_series, no tabla propia (D1: bifurcar expedientes.trd_serie_id, con siete derivaciones WORM COMPLIANCE irreversibles, es el diseño descartado). Cuatro capas de blindaje del discriminador: (1) espacio de code único y compartido con TRD (índices de la 037 intactos — 409 instrumento_code_en_uso indicando el tipo del ocupante); (2) dos vistas v_trd_series/v_tvd_agrupaciones con WITH CHECK OPTION — list_trd_series pasa de SELECT * FROM trd_series WHERE 1=1 a FROM 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 exige fondo_nombre/fechas extremas/justificacion_valoracion + archivo_gestion_years=0, TRD exige esas cuatro en NULL); (4) trigger trg_expedientes_pin_tipo en forma ESTRICTA — rechaza CUALQUIER expediente amarrado a una TVD, riesgo WORM cero garantizado por el motor. Router /api/v1/tvd espejo 1:1 de /api/v1/trd (mismo circuito completo: crear/versiones/aprobar/devolver/convalidar/registrar-rusd/derogar), tipo_instrumento fijado por el router, permiso reutilizado USUA_PERM_TRD, asientos de auditoría distintos (archive.tvd_*). Gemelo GET /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_final con CHECK de forma en ambas direcciones (un fondo_acumulado exige fecha extrema final; un corriente no puede cargarlas); trigger de la Capa 4 relajado de "ningún expediente puede amarrarse a una TVD" a la regla bidireccional origen↔tipo_instrumento — ni corriente→TVD ni fondo_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 antiguo closed_at se renombró a base_date para 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 en close_expediente, exhaustiva sobre origen. El snapshot declara base_date/base_date_origen siempre, 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 calculado retain_until desde closed_at — el daño de D4 por otra puerta; quedan fail-closed devolviendo None. Calendario ya vencido = caso normal, sin max(fin, hoy) ni redondeo. Filtro ?tipo= en revision-pendiente. 721 unit + 168 integración; el test que el ADR exige usa fecha_extrema_final=1985-06-30 y asevera fin_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 + el INSERT de batch_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 un retain_until irreversible se separan en cuanto una cambie); y UI: /expedientes gana 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/tvd si 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 un fail(). 726 tests de archive (+5) y 1380 de frontend (+5), ambos lados verificados por mutación. Increment 3 — resto, pendiente: clamp de retain_until ya vencido y cableado WORM real del fondo acumulado (hoy los seis resolutores devuelven None fail-closed para origen='fondo_acumulado': no protegen, solo posponen con backoff — estado correcto hasta que la fecha base sea fecha_extrema_final con 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 selectiva from/until/set (set = doc_type), resumptionToken paginado, 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: completeListSize cuenta solo público, ListSets no enumera un set solo-reservado, GetRecord/ListMetadataFormats sobre un id reservado → idDoesNotExist. Validado contra Postgres real (público vs reservado/clasificado/anulado + until de día inclusivo). Identificador oai:{tenant}:{tracking_number} (preserva el radicado como identidad). 17 tests (213 verdes en document-service). Auditado seguridad (apto, sin Críticos/Altos: chokepoint _PUBLIC valida 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, until de 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ética cmis:folder, paginación maxItems/skipCount. getContentStream sirve los bytes del anexo (via storage_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 _PUBLIC que 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) con repositoryUrl/rootFolderUrl del 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 del Content-Disposition/Content-Type reutilizando el patrón de storage-service + 1 Baja adquisición de conexión a prueba de excepciones) y conformidad CMIS 1.1 (remediados 1 Media repositoryUrl/rootFolderUrl, 1 Baja notSupported→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/export en document-service (autenticado, gate USUA_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}.json validado contra ese esquema ("en su totalidad": metadata sistema+contextual E03 + disposición TRD + ACL nivel/clasificación + anexos + historial de audit_log), los binarios de anexos, y checksums.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 exporta action/fecha/actor/canal sin 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.export en audit_log con quién/alcance/UUID content-free — Alta, ambos auditores); antecedente E↔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 entrada ausente trazable; 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/import en document-service (autenticado, gate USUA_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 → 422 sin 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; audita radicado.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; reconciliar tracking_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=ead en el MISMO endpoint OAI-PMH (GET /api/v1/public/oai/{tenant}, junto a oai_dc). Cada radicado público se disemina como fragmento EAD 2002 autocontenido (namespace urn: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 el OaiRepository y su chokepoint _PUBLIC → el formato es ORTOGONAL al recorte de seguridad (elegir ead no sortea el filtro ni crea oráculo de reservados; un id reservado/anulado → idDoesNotExist indistinguible en oai_dc y en ead); el resumptionToken preserva el metadataPrefix. 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úblico GET /api/v1/public/archive/oai/{tenant} en archive-service que disemina cada expediente público como fragmento EAD 2002 MULTINIVEL — caminando la cadena trd_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 nivel file (<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" en expedientes (reservado → idDoesNotExist indistinguible); frontera anti-fuga item-level: para en level="file" — nunca enumera los tracking_number de 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-level ya reclasifica el nivel_seguridad con permiso PERM_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 por PERM_RECLASIFICAR en el drawer de la bandeja (con nivel_seguridad expuesto 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 en radicados (mig 018, poblada/limpiada por reclassify); mapeo nivel 2→art. 19 (reservada) / nivel 3→art. 18 (clasificada); CONTENT-FREE (nunca el asunto), ortogonal al clearance (registro completo por mandato legal), gate PERM_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.create resuelve el clearance del radicador y devuelve 403 clasificacion_forbidden si nivel_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 /expedientes y del PATCH /{id} (filtraban con el default "sin restricción"); ambos aplican ahora no-read-up con el COUNT/X-Total-Count filtrado. (M1) ✅ close/transfer de 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 sin user_id— se corrigió devolviendo un ExpedienteDisposicionResult acotado que no expone contenido. (M3) ✅ write-up en POST /expedientes: no-write-up con 403 explí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 de link/batch/unlink/rebuild filtraba 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_CLASIFICAR dedicado 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 gemela GET /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 asiento archive.expediente_created en audit_log. Atomicidad mutación↔asiento ✅: create_expediente, la creación por batch (por ítem) y link/unlink/link_batch (incremento gemelo) envuelven ahora sus escrituras en conn.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 en next_expediente_code (colapsado a …RETURNING). close/transfer/recibir ✅ (cerrado): ya no quedan fuera — el tramo DB se envuelve en conn.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_expediente gana expected_status → WHERE ... AND status = $N: dos close concurrentes ya no commitean ambos duplicando asientos en un audit_log indeleble); la ruta gemela TransferenciaService.recibir, que congelaba el expediente a transferred sin tx, sin evento y sin asiento; el actor ausente en el asiento de transferencia (el router ni declaraba X-User-Id pese a que el gate RBAC ya lo exige); y la ruta alterna PATCH /expedientes/{id}, que ejecutaba las mismas transiciones con un UPDATE pelado — status retirado del schema (con extra="forbid": sin él se ignoraría en silencio, fail-open) y del repositorio (era el segundo escritor no auditado de expedientes.status). El PATCH de metadatos audita ahora la reclasificación TRD (archive.expediente_reclasificado, valores antes/después), y los intentos denegados dejan expediente_close_failed/transfer_failed. Test de integración contra Postgres real ✅ (cerrado): nuevo carril tests/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 para link_batch sobre un expediente de alta concurrencia (advisory lock por-expediente); PERM_CLASIFICAR dedicado para nivel≥2. Ciclo de vida de transferencias (E12) ✅ (cerrado, incremento propio): create/enviar/recibir/rechazar envuelven ahora mutación→evento→asiento en conn.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_estado gana la guarda de estado esperado (WHERE id=$N AND estado=$expected, keyword-only obligatorio) → la carrera letal rechazar-vs-recibir (transferencia 'rechazada' + expediente ya congelado 'transferred') queda cerrada, validada por mutación; identidad unificada en app/core/actor.py (X-User-Id UUID como fuente única, sin fallback a username, fail-closed) para transferencias y para close/transfer de expedientes; la hoja de ruta puebla la columna tipada actor (antes NULL). Migración tenant 020 (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 helper IndexService.fixity pura-BD SHA-256 compartido con verify(), con CLEARANCE_SIN_RESTRICCION para no imponer clearance de lectura a un acto de custodia; mismatch → 409 fixity_mismatch que revierte sin congelar, con solo un conteo en la respuesta y la enumeración de radicados en el audit_log; Ley 594 art. 16, ISO 16363), H-K (rechazo con motivo obligatorio en columna propia sin pisar la observación de preparación) y H-J (rechazada_at). Migración tenant 021. 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 — tabla transferencia_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ía GET /transferencias/{id}/acta con no-read-up (generación ortogonal al clearance, lectura con no-read-up). Migración 022. 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ón 023 — transferencia_acta es ahora inmutable forzada por el motor (triggers BEFORE UPDATE OR DELETE FOR EACH ROW + BEFORE TRUNCATE FOR EACH STATEMENT, decisión (B): UPDATE+DELETE+TRUNCATE), no solo procedimental; además el acta_fuid_sha256 se ancla en la hash-chain de audit_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 a audit_log + tamper-evidence del SHA anclado; NO paridad total (falta la capa de privilegios REVOKE, innecesaria sin DDL en el rol de la app). H-L/H-M ✅ (cerrado): integridad de la creación de transferencias — migración 024 (índice único parcial WHERE estado IN ('preparada','enviada') → una sola transferencia activa por expediente, con re-preparación tras rechazo; concurrencia serializada por el índice → 409 transferencia_activa_existente) y validación de coherencia origen/destino por tipo en create() (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ía expediente_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 en create()). 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_id NULL), sin enforcement de BD. Caveat de despliegue (H-L): el índice único 024 falla 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 mutable transferencia_acta_firma (migración 026) nacida pendiente_firma en la tx de recibir(), 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 /acta gana bloque firma (+?verify=true), POST /acta/firmar reintento 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 (pin indice_firma_id completo — indice_version ya 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", rol elabora, Inc.3, firmante ADITIVO ortogonal a firmada_completa, campo elaborador_firmado, migración 028) (POST /transferencias/{id}/acta/firmar-personal, rol derivado de la identidad, completitud a 5 valores con firmada_completa bilateral, flag auto_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 y object_ref por UUID en los asientos del sellado. Reconciliación del sellado del índice ✅ (cerrado el modo de fallo permanente, E15/E06 Inc.8): el residual "expediente closed con í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 distingue seal_absent → degrada sin reintentar, de transient → reintento acotado); (3) job de reconciliación (task del lifespan + python -m) con backoff exponencial (migración 025: 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) endpoint GET /expedientes/indices/pendientes-firma (no-read-up, incluye agotado) para visibilidad operacional. Atribución de sistema: ruta interna POST /signature/internal/sign-indice (solo X-Internal-Token, el gateway no lo reenvía; cableada a objeto_tipo="indice"; SYSTEM_RECONCILER_ID) que estructuralmente no puede firmar la cadena personal. Asiento archive.indice_firmado con origen ∈ 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 queda pendiente_firma indefinidamente (reconcile_agotado, exige intervención humana) — nunca se finge firmado. Endurecimiento diferido: alerta proactiva sobre los agotado (í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 = 1 y 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 columna radicados.publicable con DEFAULT FALSE (migración document 019) y acto explícito, motivado y auditado (POST /documents/{id}/publicacion, PERM_RECLASIFICAR + crud≥3, asientos document.publicacion_activada/_retirada). Se suma al recorte de clasificación en el chokepoint único oai_repository._PUBLIC (publicable AND nivel_seguridad = 1 AND status <> 'anulado'), no lo sustituye; el UPDATE exige además nivel_seguridad = 1 en su propio WHERE, 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; y publicable no 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 el getContentStream de CMIS, llevaba una copia del WHERE escrita a mano sin publicable, 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 archive 042, POST /expedientes/{id}/publicacion, drawer en /expedientes/{id}). El recorte de archive no copia la condición de estado de radicados: closed/transferred no 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/audit era el último punto del sistema donde el clearance no se aplicaba — cualquier tenedor de USUA_PERM_ADMIN leía en /admin/auditoria la existencia, el número de radicado, el actor, la acción y el payload de todo lo actuado sobre CLASIFICADA. La confusión que lo causó merece quedar escrita: USUA_PERM_ADMIN es competencia administrativa, no habilitación de seguridad. Cerrado en el WHERE de AuditQueryRepository con resolución en vivo del nivel contra radicados/expedientes (nunca denormalizado: un valor copiado quedaría obsoleto justo en la reclasificación que SUBE el nivel), COUNT/X-Total-Count recortado 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/verify queda 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 y evento/import/file/transferencia seguían publicando número de radicado y asunto de CLASIFICADA en su payload (el dead-letter serializa el sobre íntegro con business.tracking_number; el import, la lista literal de números; el anexo, su nombre de fichero) — lo destapó tenant-security-auditor censando los ~40 productores de object_type de 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 de id (secuencia monótona expuesta) permiten inferir cuántas entradas se ocultaron y cuándo, sin contenido — no se cierra porque id casa con broken_id de /audit/verify; y el hallazgo derivado en signature-service, ya cerrado (ver E06 abajo).
  • E06 (objeto_tipo no puede apagar el control de nivel) ✅: el nivel de seguridad de una cadena de firma se resolvía con if 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 el or fail-open que volvía al nivel declarado cuando el objeto no resolvía. Cuarta puerta encontrada al leer y no reportada: /sign-personal gateaba 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 en radicados/expedientes y devuelve el nivel real. Complementos: allow-set CADENA_OBJETO_TIPOS (422 fuera del conjunto; el único cliente de /requests es la UI), fuente autoritativa añadida para expediente (tenía el mismo agujero por otra puerta), y fail-closed (404 neutro) cuando el tipo exige documento registrado y no resuelve. Se conserva el nivel declarado para borrador/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_busqueda para list/search/respuestas + document.radicado_consultado) y trámite en workflow-service (workflow.bandeja_consultada + workflow.hoja_ruta_consultada/tramite_consultado para hoja de ruta / current / events). Los tres servicios usan object_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 literal False) 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 en AssistantDock (antes placeholder), con D-05 (token server-only), allowlist, y saneo server-side del output (contenido no confiable → marked+sanitizeHtml, ignora el html del gateway); backend ausente → 503 assistant_unavailable (estado honesto, sin respuestas simuladas). Backend E18 ✅ implementado (ver F6/E18): gateway proxy /api/v1/assistant/ → mcp-server con loop LLM Claude real ejecutando las tools de solo lectura as-the-user. Contrato /api/v1/assistant/message estable, preparado para streaming; APTO svelte-ux-reviewer. Diferidos del slice: VoiceInput, subida de anexos, streaming SSE, persistencia de historial; y ~~reconciliar X-Username vs preferred_username~~ ✅ (ya reconciliado: preferred_username → claims['username'] → ValidateResponse.username → X-Username firmado, 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 el json_schema de la plantilla de metadatos activa de la serie y se renderizan campos dinámicos (schemaField.js mapea JSON Schema → control + coerción; SchemaField.svelte), poblando el metadata:{} que iba vacío; actions.create lo reconstruye campo a campo (allowlist por claves del schema); vitest 314; APTO svelte-ux-reviewer. radicar migrado a SchemaField ✅ — cierra un hueco real: su campo dinámico inline solo cubría number/date/text, así que un enum/boolean del tipo documental degradaba a texto libre (→422); ahora usa la fuente única (enum→select, boolean→Sí/No) y envía el metadata tipado; +5 tests (radicar no tenía del campo dinámico). De paso se corrigió un defecto crítico pre-existente: el Modal de confirmación de radicación nunca era visible (faltaba la prop open), así que la radicación no se podía confirmar desde la UI. ~~Diferidos~~ ✅ cerrados en parte (AREA-12-05, 2026-10): array de escalares (lista repetible), object de un nivel y mapeo por campo del 422 (el sobre lleva field, ruta con puntos); sigue pendiente migrar las vistas con metadata inline (expedientes/[id], bandeja) a SchemaField, 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.scss repintado 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-primary no-textual 4.28:1 / --op-primary-dark texto 6.56:1); escala tipográfica, radios, sombras y movimiento v1.0 + regla global prefers-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 /dashboard si 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ón IdP↔/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 por SGD_PERM_ESTADISTICA (UI espejo del RBAC) y NAV_ITEMS extraído a lib/utils/navItems.js como fuente única; DataTable con las 8 capacidades obligatorias (filtros, orden aria-sort, export CSV anti-inyección de fórmulas, columnas configurables, vistas guardadas, selección múltiple, virtualización por content-visibility, edición en celda), todas opt-in, con /busqueda y los 4 paneles de /reportes migrados. Auditado por svelte-ux-reviewer: 27 hallazgos Alto/Crítico remediados (contraste AA en Button y 8 pantallas, objetivos táctiles ≥44px en 8 componentes, foco visible en UserPicker, 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-check 0 errores/0 warnings, build limpio. Otros pendientes del front: E2E autenticado contra docker-compose (escrito, hoy skip). /admin/grupos y /admin/dependencias ✅ (E08/E14, Fase 5 cierre de desfase API↔UI): grupos reutiliza CatalogCrudPanel (encaja sin extensión: al no invocar nunca editOpen/deleteOpen, los modales de editar/borrar simplemente no se activan — el backend no tiene PATCH/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 de rbac.py/clearance.py, no una omisión de UI). Dependencias añade DependenciaTreeNode.svelte (árbol nuevo con <details>/<summary> nativos — no existía un componente de árbol reutilizable en el proyecto) sobre GET /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 de workflow_rules.assign_to_dept/flow_steps. 57 tests nuevos de proxy (401, allowlist, UUID + encodeURIComponent, edición parcial); svelte-check 0 errores. Diferido: borrado duro de dependencias (el backend lo tiene, bloqueado si hay hijos, fuera del alcance pedido). /admin/trd, /admin/metadatos, /admin/catalogos y /admin/parametros ✅ (E03/E04/E14, Fase 5 cierre de desfase API↔UI, segunda tanda): TRD/CCD reutiliza CatalogCrudPanel en dos pestañas — series en árbol indentado (no lista plana, la jerarquía parent_id es el dato) con código inmutable, y tipos documentales con CRUD completo y filtro por serie; gatea sus botones con USUA_PERM_TRD (el permiso REAL que exige trd.py/tipos_documentales.py — más específico que USUA_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 tiene PATCH/DELETE, nueva versión = nuevo registro) con el json_schema editado 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) porque BusinessDaysResult del 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 en 32fc631): los routers metadata.py/metadata_elements.py de document-service y catalogos.py/config.py de tenant-service no declaraban ningún require_permission, así que cualquier autenticado del tenant podía escribir. Hoy todas las escrituras están gateadas —USUA_PERM_TRD en metadatos y elementos de metadato (el borrado de un elemento, nivel 3), USUA_PERM_ADMIN en catálogos y parámetros— y solo los GET quedan abiertos al autenticado del tenant, por diseño; los fijan shared/tests/test_escritura_exige_nivel.py y test_permisos_declarados.py. Las cuatro pantallas gatean además sus botones con USUA_PERM_ADMIN del 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-check 0 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 /borradores llama a POST /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 de doc_class por precedente vía GET /documents/by-tracking/{tracking} (ese endpoint no trae doc_class en 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 de sanitizeHtml.js para 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-check 0 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 de knowledge-service es 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-check 0 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 en tenant-service sin ninguna pantalla. El MVP cubre listado (filtro por estado, alta de una versión nueva siempre en borrador) y workspace con Stepper del ciclo de vida (borrador→aprobado→en_ejecucion→cerrado, puramente informativo — las transiciones son botones + Modal de confirmación, nunca clic en el Stepper) y pestaña Tablero (avance por objetivo/por eje). 6 proxies SSR (ejes, planes GET+POST, planes/{id}/{aprobar,ejecutar,cerrar,tablero}). Honestidad de interfaz: aprobar congela el contenido del plan de forma permanente (asiento en audit_log inmutable) — se advierte ANTES de confirmar, con el botón de confirmación deshabilitado sin acto_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-check 0 errores. /admin/tvd ✅ (ADR-026 Increment 1, fondo acumulado): espejo estructural de /admin/trd con columnas propias (fondo/productora, fechas extremas, estado del instrumento) y sin columna de archivo de gestión (no existe para TVD); justificacion_valoracion como 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 compartido InstrumentLifecycleActions — 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 del Modal de confirmación para convalidar/derogar y ningún botón ofrecido si el estado actual no lo permite. Edición append-only fuera de borrador (solo pdfa_profile viaja). Se verificó que TrdSuggestChips.svelte no pudiera sugerir nunca una TVD: no consulta series/agrupaciones, resuelve doc_class de radicados precedentes — no había nada que corregir. 68 tests nuevos; 1295 tests vitest verdes (108 archivos), svelte-check 0 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, Stepper subir→validación→confirmar→resultado; el 422 de fixity muestra el archivo/checksum EXACTO que falló, nunca un mensaje genérico; antecedentes_no_resueltos se 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 declara operador_conectado=false en vez de aparentar una consulta en vivo. HALLAZGO DE ALCANCE (no corregido, fuera de services/ 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 devuelven 404 route_not_found hasta que se añada esa entrada (cambio de fastapi-developer/orfeo-architect). Exportar/Importar/rastreo sí quedan operables de punta a punta. 27 tests nuevos (allowlist, encodeURIComponent+UUID en [jobId], streaming/saneo de Content-Disposition, 503 explícito del sondeo ante fallo de conexión); 1322 tests vitest verdes (110 archivos), svelte-check 0 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.txt de alcance + checksums.txt (SHA-256) + bytes de los anexos; no-read-up POR-RADICADO (document-service filtra nivel <= clearance y omite los sobre-clearance incluida su existencia — cerró una fuga read-up que el gate solo-expediente no cubría), gate USUA_PERM_EXPEDIENTE, anti zip-slip, auditoría agregada. asunto/tipo en el manifiesto ✅ (cerrado en el barrido de deudas: endpoint interno POST /internal/documentos/metadata clearance-aware con el mismo no-read-up por-radicado → columnas tipo/asunto en manifiesto.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.md con el estado verificado, y la suite frontend/e2e/authenticated/ reactivada (estaba apagada con triple freno).
  • [x] Trámites ciudadanos con pantalla (hueco de producto, cerrado 2026-08): (public)/radicacion y (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, gate PERM_PANU_CODI (el mismo que decidir), orden FIFO y no-read-up aplicado por igual a las filas y al recuento. /anulaciones deja 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 /radicar y /expedientes: USUA_PERM_TRD lo 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}/versiones exige USUA_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_ADMIN en el APIRouter, 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.js de (app) (antes, 21 proxies a mano), test de los flags de cookie (httpOnly/sameSite/secure), admin/+layout.server.js que redirige a /403 según el permiso REAL de cada área, estado de la preservación visible en la pestaña del expediente (GET /preservacion/estado), SchemaField con array/object y field en el 422, tokens CSS (--op-on-brand-*, layout.scss al 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; create de 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 declara cabecera_completa; el fixity de la recepción es un evento PREMIS (con reconciliación); REVOKE de UPDATE/DELETE sobre el acta y CHECK de objeto (054/055); WORM para el fondo acumulado (ADR-026 Inc.3, con clamp) y para cada firma personal del acta (tipos acta_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 acto firma_inobtenible (07-10, migración 059) y la renovación CT de las firmas personales del acta (060). Remediación de auditoría (2026-10-09, ADR-030, migraciones archive 061 y storage 019): 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 archive 062 y storage 020): la excepción se puede re-declarar tras revocarla (cadena lineal ordinal/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 archive 063): el HMAC se sustituyó por un compromiso con sal por registro SHA-256(sal || texto) verificable por terceros (constancia y revocación v3), lo firmado es la C14N exclusiva y acta/verificacion coteja los enlaces de la cadena, todos los eslabones comparten ancla de retención WORM, agotado tiene salida (.../worm/reintentar, admin 3), incapacidad viaja neutra y leer el fundamento deja asiento. Propuestas abiertas (no implementadas): (a) regularización de la revocación legada anterior a la 062 (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 archive 064 y storage 021): 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 archive 065 y 066): 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 y dedup_key PREMIS por número de intento. Pendiente: sellado XAdES de nota-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), preservationLevel y rights en 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 + CHECK 041 + 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 para documento.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 de documento.contenido_cambiado en signature-service~~, ~~alerta en notification para document.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 y npm 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 usa restore.sh: su heredoc reventaba), la restauración falla si queda una retención o una retención legal sin reponer y emite el mapa version_id; /health/ready de 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 (sello retencion_capturada_completa=NO, salida 4); las previas bajo COMPLIANCE fallan la restauración, que además deja el asiento storage.restauracion; MIGRATIONS_DATABASE_URL sale del runtime de producción. Declarado: knowledge sin 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 PREMIS REPLICA; verify_audit_chains sale 2 con objetivo vacío; webhook_entregas (notification 012) + 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_denegacion definidos una vez en orpycamcp_common.rbac (ADR-013 corregido: la autocreación ya está decidida), y audit.append sin valor por omisión de canal (191 llamantes reescritos; CANAL_PETICION hereda el del gateway, system en jobs, CLI y consumidores, fijado por canal_de_fondo() en cada tarea de fondo); el filtro object_ref de GET /audit resuelve 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.md y ADR-002 dejan de recetar Alembic/SQLAlchemy y un SET search_path sin validar; el alta de entidades se documenta por scripts/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.md rebaja Kubernetes a «no soportado» y DOCKER_REGISTRY.md pasa a GitLab; el roadmap EN iguala los pendientes del ES y security-audit.md EN gana la segunda pasada. Código: préstamos por unidad paginados (+ BFF), migración signature 022 (COMMENT de xades_level), EAD del OAI validado contra el XSD oficial (21 registros reales de demo, 0 inválidos), scripts/carga/busqueda_sla.py (p95 de /search contra RF-BUS-09: 3 s simple / 10 s compleja; p95 ≤ 0,2 s sobre 222 radicados) y shared/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ón 028 reescribió el COMMENT). Abierto: tabla webhook_entregas (ADR-018), export de expediente en streaming (AREA-07-17), discriminador de nivel del CCD (decisión de dominio; trd_series no 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 icono fa-archive de 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-primary el 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ás theme-color y Open Graph.
  • [x] La carga masiva no reporta resultado en ninguna circunstancia (dos defectos independientes que se suman, ambos en /admin/interoperabilidad). (1) createJobPoller mutaba 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 → 404 permanente. Corregido (lote 1): el sondeo pasó a variables let de componente reasignadas, las URL a la forma anidada real (/batch/documents/{jobId}/status y /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 con FORWARDED_REQUEST_HEADERS.
  • [x] Tres tarjetas de /admin llevan a un 403 — el mismo patrón ya corregido en Colas, que resultó no ser un caso aislado. Corregido (lote 1) cruzando las 16 tarjetas contra el require_permission real de su router, no solo las tres observadas: TRD / CCD y Metadatos pasan a USUA_PERM_TRD, e Interoperabilidad —cuyo gate es heterogéneo por pestaña— se abre con USUA_PERM_EXPEDIENTE || PERM_RADI y 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 en metadatos/ que afirmaba que los routers de document-service no 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/claves faltaba en el índice, que se presenta como el mapa completo de administración. Corregido (lote 1): ambas añadidas a ADMIN_AREAS con 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 el documentos/ de la raíz— no llevaba barra inicial, así que también ocultaba frontend/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 en main la 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 tres Stepper (2FA, credencial PKI, firma), el turno activo de la cadena de firma y los chips de filtro de /borradores y /envios —que estaban literalmente invertidos, con el oscuro en el hover— pasan a --op-primary-dark (6.56:1), siguiendo lo que Button.svelte ya resolvía bien. Corregidas además cuatro instancias del otro par que falla, --op-text-secondary sobre --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-visible en DataTable y CatalogCrudPanel —que lo propagan a 8+ y 7 pantallas respectivamente— y en ~20 envoltorios de tabla cruda; bandeja-table, la única con overflow: hidden sin ningún mecanismo de scroll, gana un envoltorio propio con overflow-x: auto. Los overflow: hidden restantes envuelven un DataTable cuyo scroll vive en el wrapper interno, así que no se les añadió tab-stop: sería mudo y redundante. Cada tabindex nuevo dispara a11y-no-noninteractive-tabindex, regla que no reconoce la técnica WCAG SCR29; silenciada con svelte-ignore justificado, 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 el error de FormField (aria-invalid/aria-describedby), y los cinco de código OTP ganan inputmode="numeric" + autocomplete="one-time-code" — hubo que extender FormField con esas dos props, que no existían.
  • [x] CatalogCrudPanel aplicaba display: flex sobre 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.js sigue siendo el único constructor del fragmento y ya cumple el requisito normativo.
  • [x] /perfil no gateaba PERM_FIRMA en el enrolamiento de la credencial de firma (cerrado 2026-08-07). El gate real de POST /auth/me/signing-key/enroll es require_permission("PERM_FIRMA"), pero la pantalla ofrecía el botón a cualquier usuario y el 403 llegaba al pulsarlo. Corregido con can("PERM_FIRMA", 1) — el helper con nivel CRUD y ?? 0 fail-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ón 015: activate emite 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 en enroll y DELETE /me/totp, más POST /me/totp/recovery-codes para 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/verify no 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 el WHERE), sustituido por uno que fija el contrato del SQL. UI ✅ el mismo día: /perfil los 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. Proxy perfil/api/totp/recovery-codes con allowlist y no-store reafirmado (SvelteKit construye una respuesta nueva; la cabecera del upstream no viaja sola), verificado por mutación.

  • [x] Causa raíz de contrato — /auth/me descartaba el nivel CRUD. El router resolvía {permiso: nivel_crud} y devolvía list(perms.keys()), así que el store recibía una lista plana y can("USUA_PERM_EXPEDIENTE") no distinguía lectura de escritura, mientras los backends gatean con min_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/me añade permission_levels{} de forma aditiva —permissions[] se conserva, así que no hace falta despliegue coordinado— y el store gana can(permission, minCrud) con minCrud opcional y fail-closed explícito: si el nivel no viene (sesión anterior al contrato, backend antiguo), el nivel efectivo es 0, nunca "no lo sé ⇒ lo permito". Al aplicarlo se barrieron todos los gates de escritura contra el require_permission real de su router, y el resultado corrige la premisa del hallazgo: la gran mayoría de las mutaciones usan min_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 /bandeja el gate de la UI es más estricto que el backend (devolver y visto bueno autorizan por tenencia del paso, sin require_permission): queda anotado, no tocado, porque es un cambio de comportamiento distinto. Dos defectos colaterales encontrados y cerrados: radicar/+page.svelte evaluaba can() dentro de un $: sin ninguna dependencia reactiva —can() usa get(), 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 que context/switch no altera permisos ni niveles, solo active_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 (/bandeja fuera de DataTable, 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 —/bandeja fuera de DataTable, 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 de fas/fa-solid y cero de far/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). El static/orpyca-logo.png que 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 a DataTable o 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. DataTable parte 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 con overflow-x: auto y 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 bajo pointer: coarse y no una propiedad de DataTable. Con esto la migración a DataTable queda 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 con enableFilters/enableSort el 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 /documents no filtraba ni ordenaba por due_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 con due_from/due_to (extremos inclusivos) y order_by/order_dir sobre 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). status es 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 dos X-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_steps de workflow-service, y el alcance por defecto de /workflows/inbox ya ES «asignado a mí o a mi dependencia»); traerlo a /documents repartiría el gate de clearance y el COUNT entre 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-scrim para lo que bloquea y --op-scrim-soft para el asistente—, con guardián. Queda abierto: los rgba(...) 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-mono en _tokens.scss y 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ían font-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-mono existe: es el tamaño), y Bulma pintaba code en rojo #da1039 sobre gris — el color del error sobre el número de radicado. Guardián styles/tokens-mono.test.js.
  • [x] <a role="button"> (cerrado 2026-09-18): el enlace se anuncia como enlace. Con el rol se fueron aria-disabled y aria-busy —siempre false en esa rama, que solo existe cuando !isDisabled— y el spinner de loading, 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 de DataTable, las acciones de la bandeja—. Repararlos uno a uno es cómo se llegó a 62. Cerrado con una regla en la hoja global bajo pointer: coarse (el escritorio no cambia ni un píxel), con min-* y no height para que un control mayor no encoja, y con casillas y radios excluidos a propósito —ahí lo que se agranda es el <label>—. Guardián styles/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/parametros se acotaba a 900 y desperdiciaba 372 px con la tabla recortada al lado. Cerrado con dos tokens —--op-content-max sin techo, para que la pantalla de datos se ajuste al dispositivo que la usa, y --op-reading-max 880 px para formularios y texto, donde el límite es de legibilidad y no de sitio—, más .op-table-scroll para 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-page aplicado por la regla global sobre h1, 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-2xl conserva sus 28 px porque cinco sitios legítimos lo usan para otra cosa. Medido en navegador real, no solo en el CSS.

  • [x] EmptyState reimplementa .op-btn (cerrado 2026-09-18): usa Button. La copia se justificaba «por si Button no 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 con hidden, así que el identificador que el botón declara controlar existe siempre. El role="list" con hijos que no son listitem se cerró también: la fila de KPIs pasa a role="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 al tick() en el que se mueve el foco.
  • [x] AssistantDock promete lo que no cumple (cerrado, verificado 2026-09-17): el dock monta ProposalCard y 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. Llamar clearPersistedFavorites() 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 en localStorage de 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: /envios era el único consumidor, y solo para precargar el destinatario postal. El detalle de /bandeja describía al actor desde sender_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 su antes/despues, leídos con FOR UPDATE; el borrado —físico— lleva el retrato completo, que pasa a ser el único sitio donde sobrevive lo que la regla decía. PATCH y DELETE exigen ahora X-User-Id, que POST ya exigía.

  • [x] size=200 truncaba en silencio (cerrado 2026-09-19). Eran ocho pantallas, no dos: se sumaron /archivo-fisico y las tres de personas. 200 es el le del backend, no una holgura. Cerrado con traerListaCompleta —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 ahora X-Total-Count con los activos que hay: sin esa cabecera no había nada que mirar.
  • [x] GET /prestamos devolvía la lista entera sin paginar (cerrado 2026-09-19). page/size + X-Total-Count, con el COUNT compartiendo WHERE y desempate por id (fecha_prestamo empata 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 une expedientes.

Deuda de tooling y documentación

  • [x] npm run lint no llegaba a ejecutar ESLint (cerrado 2026-08-05): el script es prettier --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 en 32fc631. Ni el patrón .svelte-kit/ del .gitignore ni el hook guard-bash casaban 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 icon en CatalogCrudPanel: declarada, nunca renderizada (el EmptyState usa emptyIcon), y dos pantallas la pasaban creyendo que hacía algo. Eliminada de las tres. Los {@html} y el regex de caracteres de control de sanitizeHtml.js se marcaron con eslint-disable justificado (son deliberados: el saneo server-side es la barrera y el {@html} su consumidor; los caracteres de control son justamente lo que un java\x00script: usa para esquivar la detección). svelte/no-unused-svelte-ignore se apagó en .eslintrc.cjs documentando la razón: es una divergencia real entre ESLint y el compilador — svelte-check sí necesita esos svelte-ignore (al quitarlos aparecen 13 avisos), y ESLint no evalúa esa regla de a11y.
  • [x] 14 archivos de frontend/src/ pertenecen a root (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 sin sudo, lo que dejaba un error de ESLint sin corregir (no-useless-escape en expedientes/[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.sh monta :ro con las cachés en /tmp del contenedor, el comando de lockfiles de CLAUDE.md escribe por stdout y redirige en el host, y CONTRIBUTING.md documenta 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.sh en 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-server y knowledge-service nunca se linteaban, con 108 errores invisibles entre los tres; ahora la lista se deriva de services/*/; (2) orpycamcp_common, la librería de auditoría inmutable, se verificaba como Any en los once servicios (se instala desde shared/, no desde requirements.txt, así que el contenedor de lint no la tenía) — el síntoma eran 35 type: ignore marcados como sobrantes, y borrarlos habría tapado el agujero; cerrado instalando shared/ y añadiendo el marcador PEP 561 py.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 de cryptography en TSA/OCSP/CRL, public_numbers() inexistente en Ed25519/Ed448 al comparar certificado y CSR, tracking_number nulo llegando al repositorio en rutas de ACL y anulación, y un test de storage-service que 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] de changelog.md, que además no tenía la sección Security en 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.md estaba incompleto (cerrado 2026-08-07): nueve pantallas existían en frontend/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/reglas y /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.svelte de src/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-fisico y /borradores— desplazaban el documento a lo ancho en móvil hacia una franja vacía. El culpable era un span.sr-only: texto para lector de pantalla, 1 px de ancho, position: absolute, dentro de las cabeceras de las tablas anchas. Un absolute lo recorta el overflow de un ancestro solo si ese ancestro es su bloque contenedor; sin ninguno posicionado en la cadena se resolvía contra el documento, así que el envoltorio recortaba la tabla a la perfección y la página se desplazaba igual. 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, porque getBoundingClientRect() 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: relative en 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.js exige que todo bloque con overflow-x: auto sea 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 cadena position: relative y 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-actions ocupaba 385 px empezando en x=164 sobre un viewport de 390) por el min-width: auto que traen por defecto los hijos de un contenedor flex. Corregido en AppLayout.svelte y DependencySwitcher.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/alcance responde 429 cuando la suite autenticada corre completa, y el log del gateway trae el dato que importa: "tenant": null, "usuario": null con 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 de JWTValidationMiddleware en main.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.py y test_limitador_no_gasta_el_cubo_ajeno.py; la mutación que las tumba es quitar el add_middleware o 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.sh y restore.sh estaban rotos y no se había notado: usaban la imagen minio/mc, también borrada. Migrados a aws-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 en ok. 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.md iba 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.md no tenía tres pantallas —/correo, /admin/terceros y 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.md no tenía la sección de cadena de firmas sobre un expediente. Traducida.
  • [x] mcp-flujos.md y plan-pruebas.md existen solo en español y, con fallback_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.md decí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 de docs/en/api.md.