Saltar a contenido

Roadmap

Actualizado al 2026-07-29.

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 (~300 tests verdes en 11 microservicios) y con migraciones validadas contra PostgreSQL/pgvector real. 23 ADRs registrados (ver Decisiones de arquitectura).

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 consulta pública por código de verificación, E19 ingesta de correo IMAP
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)

  • 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 (claude-opus-4-8) que ejecuta las tools de solo lectura 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). Diferidos: streaming SSE; historial persistente (hoy en memoria por proceso, no multi-réplica → migrar a Redis); habilitar tools de escritura con doble confirmación; RAG anclado con citas y embeddings reales del knowledge-service (E21).
  • E21: ingesta event-drivenMVP (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 REALInc.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 citasInc.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 citasInc.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). Pendiente: eventos de expedientes/anexos, endpoint de aceptación humana de sugerencias (CC-02), streaming de la respuesta generada.
  • E10: Object-Lock/WORM real en MinIOIncrement 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 v3Increment 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 plenaIncrement C1a (valida contra el premis.xsd OFICIAL de la LoC vendorizado, xsi:type="premis:file") + REPLICA best-effort del AIPIncrement 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 honestoIncrement 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 cierreInc.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 firmadaInc.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, min_crud=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 REALIncrement 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_* en expediente_aip (mig archive 034), AipWiringService.renovar_worm_aip, filtro CT fail-closed en SQL, ventana rodante, 3er advisory lock worm_renovacion_aip; storage renovar-retencion-internal ya genérico admite tipo='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. actor del asiento de renovación unificado a SYSTEM_RECONCILER_ID (antes None, heredado de índice/acta) — cerrado en el barrido de deudas junto a los otros *_agotado de reconciliación.
  • 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). Pendiente: acreditación ONAC (CA + TSA + responder OCSP acreditados vía integración externa — todo no_acreditado hoy), 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 webhooksF5 (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).
  • 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/migradaderogada, 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-pendienteno-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 efectivacriterio 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 OPTIONlist_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). Pendiente (Increment 2, roadmap declarado): expedientes.origen, trigger relajado a origen↔tipo_instrumento, compute_retention con fecha base explícita (fecha_extrema_final, nunca closed_at — D4, el punto de mayor riesgo del diseño), snapshot con tipo_instrumento/base_date_origen. Increment 3: clamp de retain_until ya vencido, gate de publicación previa del inventario de eliminación (artículo/plazo exacto por confirmar con archival-compliance-auditor). 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_documentaltipo_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_statusWHERE ... 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 023transferencia_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 origenclose/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).
  • 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); acreditación ONAC (CA/TSA/responder OCSP + firma personal cualificada art. 28 vía HSM), 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)
  • [ ] 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 en el contrato del gateway. 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: campos array/object/anidados, mapeo por-campo robusto del 422 (el backend da el mensaje de jsonschema sin la propiedad ofensora), migrar otras vistas con metadata inline a SchemaField. 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 (no corregido en el front): a diferencia de trd.py/tipos_documentales.py/metadata.py de archive-service (gate real USUA_PERM_TRD), los routers metadata.py/metadata_elements.py de document-service y catalogos.py/config.py de tenant-service no declaran ningún require_permission — cualquier autenticado del tenant puede escribir; config.py lo documenta explícitamente en su propio docstring como pendiente. Estas cuatro pantallas gatean sus botones de escritura con USUA_PERM_ADMIN del lado del cliente como medida conservadora (nunca ofrecen MENOS de lo que el backend exige — aquí ofrecen más restricción, lo cual nunca es inseguro), sin fingir una autorización que el backend no aplica. 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 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:

  • [ ] La carga masiva no reporta resultado en ninguna circunstancia (dos defectos independientes que se suman, ambos en /admin/interoperabilidad). (1) createJobPoller (+page.svelte:239) muta un objeto plano que nunca se reasigna: Svelte 4 no invalida y el panel queda congelado en "Consultando estado…". (2) Los dos proxies de sondeo piden ${API_V1}/batch/{jobId}/status, la forma anterior al fix de ruteo del gateway, que no casa con ningún prefijo → 404 permanente. El administrador no sabe si se crearon 1000 radicados o si falló todo. Merece además un test de contrato de la URL: es el mismo modo de fallo que ya apareció con FORWARDED_REQUEST_HEADERS.
  • [ ] Tres tarjetas de /admin llevan a un 403 — el mismo patrón ya corregido en Colas, que resultó no ser un caso aislado. TRD / CCD se anuncia con USUA_PERM_ADMIN cuando el gate real es USUA_PERM_TRD; Metadatos ofrece "Nueva plantilla de expediente" con USUA_PERM_ADMIN cuando exige USUA_PERM_TRD; e Interoperabilidad gatea toda la pantalla con USUA_PERM_EXPEDIENTE pese a que la carga masiva de documentos exige PERM_RADI — doble error, porque además oculta la pantalla entera a un radicador que sí podría usarla.
  • [ ] Dos pantallas construidas y no enlazadas: /admin/preservacion (784 líneas) no aparece ni en el índice de tarjetas ni en el menú lateral —solo se alcanza escribiendo la URL—, y /admin/seguridad/claves falta en el índice, que se presenta como el mapa completo de administración.
  • [ ] Contraste AA: blanco sobre --op-primary (4.28:1) en estados permanentes, no solo en :hover — el dígito del paso actual de Stepper (los tres: 2FA, credencial PKI y firma), el número de turno activo de la cadena de firma, y los chips de filtro activos de /borradores y /envios. En varios bloques está invertido: --op-primary-dark (6.56:1) en el hover y el claro en reposo. Button.svelte:148 ya lo resolvió bien; las páginas se desviaron del propio sistema de diseño.
  • [ ] Tablas anchas inalcanzables: seis contenedores usan overflow: hidden con celdas nowrap, así que en móvil las columnas de la derecha (Estado y Acciones) se cortan sin barra de desplazamiento (WCAG 1.4.10 Reflow). Y los que sí desplazan no llevan tabindex="0", de modo que ningún usuario de solo teclado puede desplazarlos (WCAG 2.1.1) — incluidos DataTable.svelte y CatalogCrudPanel.svelte, que lo propagan a todas sus pantallas.
  • [ ] /perfil: siete errores de validación de campo salen solo como notificación flotante, nunca asociados al campo, pese a que FormField ya soporta error con aria-invalid/aria-describedby y el resto del proyecto lo usa. Quien usa lector de pantalla no sabe qué campo falló, en la pantalla que gobierna la credencial de firma electrónica. Los campos de código OTP tampoco declaran inputmode="numeric" ni autocomplete="one-time-code".
  • [ ] Cuatro tablas emiten una celda más que encabezados tienen (la columna de acciones sin <th>), y CatalogCrudPanel aplica display: flex sobre un <td>, lo que saca la celda del modelo de tabla en el árbol de accesibilidad. Afecta a las ocho pantallas que usan el panel: conviene cerrarlo en el componente compartido antes de que se copie a una novena.
  • [ ] Las sugerencias de IA aparecen sin anunciarse a lector de pantalla (chips de serie TRD tras un debounce, panel de antecedentes al abrirse). El contenido en sí cumple el requisito normativo: ragInsert.js es el único constructor del fragmento y siempre antepone el aviso de generación por IA más las fuentes, insertar sin citas está bloqueado, y la serie TRD nunca se autoaplica.
  • [ ] No existe código de recuperación de 2FA en el backend (verificado: 0 ocurrencias en auth-service). Quien pierda el teléfono queda sin poder revocar ni renovar su credencial de firma, y la pantalla no dice a quién acudir. Es brecha de producto, no de interfaz: no debe maquillarse en el frontend.

  • [ ] Causa raíz de contrato — /auth/me descarta el nivel CRUD. auth-service/app/routers/auth.py:226 resuelve {permiso: nivel_crud} y devuelve list(perms.keys()), así que el store recibe una lista plana y can("USUA_PERM_EXPEDIENTE") no distingue lectura de escritura — mientras los backends sí gatean con min_crud=3. Consecuencia: se ofrecen Cerrar / Transferir / Excluir / Firmar acta a quien tiene el permiso en modo lectura, y recibe un 403. Las dos revisiones llegaron a este punto por caminos independientes y ambas se negaron a parchearlo pantalla por pantalla: el arreglo es devolver el nivel en /auth/me y añadir un can(perm, nivel) al store. Es decisión de arquitectura y toca backend.

Rendimiento y activos

  • [ ] Subset de Font Awesome: hoy +layout.svelte importa el CSS completo de Font Awesome. La app solo usa iconos solid y regular; sustituirlo por un subset (o por importaciones por-icono) para aligerar la descarga inicial, que es la primera impresión de la landing pública.
  • [ ] Activo de marca: static/orpyca-logo.png es el emblema circular (100×97 px), no un logotipo completo con wordmark. Mientras no exista un wordmark, la landing y /login muestran el emblema a tamaño grande y el nombre de la marca no aparece por encima del pliegue. Además apple-touch-icon.png mide 180×176 (no 180×180 como declara app.html) y es RGBA: iOS lo compone sobre negro.

Migración a DataTable

  • [ ] /bandeja: 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.
  • [ ] /archivo-fisico: su listado es una estructura de árbol (ubicaciones recursivas), no una tabla plana; DataTable no modela jerarquía hoy. Requiere decidir antes si se añade soporte de árbol al componente o si la pantalla conserva su render propio.
  • [ ] Filtros/orden server-side en DataTable: hoy filtrar y ordenar son un refinamiento de la página ya servida. El componente emite filterChange/sortChange para que una pantalla los cablee a un refetch del gateway; ninguna lo hace todavía. Por eso /busqueda no los activa: tiene su propio formulario de filtros sincronizado con la URL y el backend, y un segundo filtro local sería una fuente de verdad duplicada.

Backend que falta para cerrar la UI

  • [ ] Endpoint de alertas/vencimientos: el módulo 03 del /dashboard no tiene endpoint propio. Se deriva del campo real due_date (RF-RAD-04) de los radicados abiertos ya cargados para "pendientes" — es un proxy declarado explícitamente en el docstring de dashboard/+page.server.js, no una simulación. Cuando exista un endpoint transversal (que agregue también /signature/pending y /transferencias, no solo due_date de documentos), sustituir ese cálculo: está aislado para facilitar el reemplazo.
  • [ ] Filtro "asignado a mí" / múltiples estados en GET /documents: el módulo 01 hace dos llamadas (status=distributed y status=in_progress) porque el endpoint no acepta varios estados a la vez. Con status[] o un filtro de asignación real se simplifica a una sola llamada más precisa.

Accesibilidad y consistencia (backlog Medio/Bajo de la auditoría)

  • [ ] Colores crudos rgba(...) fuera de tokens en velos y superficies de marca: Modal.svelte (rgba(33,36,33,.5)) vs Drawer.svelte (.35) vs AppLayout (.4) vs AssistantDock (.25) — cuatro opacidades para el mismo scrim, y el color base ni siquiera coincide con --op-text. Más los blancos translúcidos del hero/CTA/footer de la landing. No son hex, pero son invisibles a un rebranding por tenant.
  • [ ] Falta un token de FAMILIA monoespaciada: --op-font-mono es un tamaño (12px), no una familia. Hay ~16 sitios con font-family: monospace a pelo, justo en el número de radicado, que es el dato más identificatorio del sistema. Añadir --op-font-family-mono a _tokens.scss y migrar los 16 sitios (uno de ellos, en /dashboard, quedó con un stack literal como parche).
  • [ ] <a role="button">: Button.svelte con href renderiza un enlace anunciado como botón — no responde a la barra espaciadora. Afecta a los CTA de la landing, a /403 y a los enlaces de acción de EmptyState.
  • [ ] Tamaño del H1 inconsistente entre pantallas (--op-font-lg en 5 vistas, --op-font-xl en 8; ninguna usa el H1 de v1.0, --op-font-2xl), y objetivos táctiles <44px aún en los botones de paginación de DataTable (28px), sus casillas de selección, el summary de "Columnas"/"Vistas guardadas" (36px) y los controles de AssistantDock (~30px).
  • [ ] EmptyState reimplementa .op-btn copiando los estilos de Button.svelte "por si Button no está importado": es una tercera definición del botón primario que se desincronizará. Se usa en 9 de 10 pantallas.
  • [ ] Detalles ARIA: aria-controls apuntando a nodos que solo existen cuando el panel está abierto (DataTable, /dashboard); role="list" con hijos sin listitem; nombres accesibles sobre <span>/<div> genéricos (StatusChip, AppLayout); casillas de DataTable nombradas por posición ("Seleccionar fila 3") en vez de por registro; y el anuncio aria-live de edición en celda que afirma "actualizada" antes de que el consumidor confirme contra el gateway (miente justo si el gateway devuelve 403).
  • [ ] Atajo / y trampas de foco: con el AssistantDock (aria-modal) o el drawer móvil abiertos, pulsar / mueve el foco al buscador del topbar, fuera de la trampa de foco del diálogo.
  • [ ] AssistantDock promete lo que no cumple: su placeholder afirma que "toda acción de escritura pedirá confirmación explícita", pero no existe tal mecanismo en el componente. Hoy es inocuo (el backend v1 es solo-lectura), pero la promesa debe implementarse antes de habilitar tools de escritura, no después.
  • [ ] Limpiar favoritos al cerrar sesión: resetFavorites/clearPersistedFavorites existen y están testeadas pero ningún código de producción las llama; en un equipo compartido la lista del usuario anterior sobrevive al logout.

Deuda de tooling y documentación

  • [ ] npm run lint no llega a ejecutar ESLint: el script es prettier --check . && eslint ., y hay 17 archivos con diferencias de formato preexistentes que cortan la cadena en el &&. ESLint por separado reporta 3 errores + 1 warning, todos preexistentes e idénticos a los de HEAD. Correr prettier --write sobre esos 17 archivos en un commit de formato aparte (o separar los dos scripts) para que CI vuelva a validar ESLint.
  • [ ] docs/en/ sin sincronizar: la documentación en inglés es la traducción de docs/es/ y no se actualizó con esta iteración (landing, búsqueda global, dashboard, sistema de diseño v1.0, ADR-020). Es un trabajo aparte y pendiente.