Roadmap¶
Actualizado al 2026-07-29.
OrpycaMCP ha seguido dos planificaciones complementarias:
- Roadmap fundacional (Fases 1–6, abajo «Andamiaje»): levantó los 8 microservicios con su CRUD básico, infraestructura y CI/CD. Completo.
- Plan specDrive (Fases F1–F6, 21 épicas, 138 RF): eleva el andamiaje a conformidad SGDEA (Ley 594/2000, Acuerdos AGN 001/2024, 042/2002, 003/2015; ISO 14721/16363). Es el plan vigente y su estado se detalla primero.
Plan specDrive — Conformidad SGDEA (vigente)¶
Estado global: núcleo de las 21 épicas implementado, probado en Docker (~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_unavailablesinANTHROPIC_API_KEYo ante fallo del SDK — nunca una respuesta simulada, RF-FIR-15). Conectado a E21 ✅: toolbuscar_conocimiento(POST/api/v1/knowledge/searchvía gateway, solo lectura) — recuperación semántica as-the-user con ACL server-side; los resultados se marcan como pistas de un índice derivado a confirmar contra la fuente autoritativa (ADR-006). 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-driven ✅ MVP (ADR-006/010/021): el índice pgvector se auto-puebla consumiendo
orpycamcp.document.events(consumer group propio + DLQ) — embebesubject/senderdel radicado con ACL fail-closed replicada del evento, guard contra proveedor externo para material reservado, y consistencia de nivel bajo entrega at-least-once en cualquier orden (tombstone/centinela +GREATEST;creatednunca baja un nivel). Purga-en-anulación ✅ (document.radicado.annulled→ chunkvoidedpermanente, excluido de la búsqueda a cualquier clearance;createdtardío nunca re-indexa un anulado). Proveedor de embeddings REAL ✅ Inc.1 (E21, capa de conocimiento real):EmbeddingProviderpluggable (local_stsentence-transformers multilingüe, local/soberano, default de despliegue;stubdeterminista, default forzado en tests;ollamamínimo viable, diferido) reemplaza el stub como proveedor único —EMBEDDING_DIM(384) fuente única de la dimensión deknowledge_chunk.embedding, guard fail-closed al arrancar, migración007(NULLea dim 64 incompatible +vector(384)+ índice HNSW) con job de backfillpython -m app.ops.reembed.is_embeddablegeneralizado aLOCAL_EMBEDDING_PROVIDERS: conlocal_stel material clasificado SÍ se embebe (local, no sale) — a diferencia de un proveedor externo. Residuales declarados: R1 (contenido ya enviado a un proveedor externo antes de este incremento), R6 (eventos reclassified/annulled best-effort → lag en capa derivada). Recuperación anclada con citas ✅ Inc.2 (Patrón A, sin LLM):POST /knowledge/antecedentes(query|radicado_refXOR) reusasearch(mismo pre-filtro ACL), colapso 404 anti-oráculo del pivote (no distingue "no existe" de "excede clearance"), advisory fijo CC-01/CC-02. RAG generativo con citas ✅ Inc.3 (Patrón B):LLMProviderpluggable (ollama_localdefault soberano,ollama_cloud/openai_compatibleexternos mínimo-viables,anthropiccon Citations API nativa,disabled→Patrón A) trasPOST /knowledge/rag. Barrera dura de soberanía para GENERACIÓN (decisión tomada 2026-07-20, reconciliación en ADR-006):is_generatableexcluye del contexto los chunksnivel_seguridad >= RESERVADAcon proveedor externo, fail-closed, sin override — a diferencia del modelo suave original del ADR. Tablakb_suggestions(migración008,proveedor/modeloen columnas separadas, CC-03) + asiento enaudit_log. Degradación honesta503 rag_unavailable(RF-FIR-15). 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 MinIO ✅ Increment A (ADR-023: bucket de preservación por tenant, índice firmado bajo
Retention(COMPLIANCE)derivada de la TRD, gate de disposición, legal hold) + AIP real + PREMIS v3 ✅ Increment B (ADR-023 addendum: BagIt RFC 8493 con bytes reales + fixity recalculada, subido al bucket WORM y cubierto por el gate de disposición) + PREMIS v3 conformidad plena ✅ Increment C1a (valida contra elpremis.xsdOFICIAL de la LoC vendorizado,xsi:type="premis:file") + REPLICA best-effort del AIP ✅ Increment C1b (una copia WORM inmutable a target configurable —default segundo bucket del mismo MinIO, honestamente "réplica local, no cross-site"; cross-site víaMINIO_SECONDARY_*—, disparada pornum_copias≥2, no-fatal) + validación PDF/A — contrato + stub honesto ✅ Increment C2a (interfazPdfaValidatorpluggable, stub por defecto que reportano_evaluadosin fingir, eventoVALIDACION_PDFA+ PREMISvalidation, advisory, migración008del tercer estado) + CABLEADO del WORM del índice al cierre ✅ Inc.1 wiring (E10; Ac. AGN 001/2024 art. 4.3.2.6): la capacidad WORM existía pero nadie la disparaba → el índice firmado era borrable pese al sello. Ahora archive lo protege tras firmarlo al cierre (disparo síncrono best-effort + 3ª fase de reconciliaciónrun_once_worm, nunca bloquea el cierre), retención TOTAL derivada de la TRD (gestión+central,fecha_inicio=cierre) con fail-closed sin TRD (nunca un default sobre COMPLIANCE irreversible), columnasworm_*ortogonales alestado(migración archive029), marca+asiento atómicos; storage gana idempotencia (índice único parcial migración009+ corte-circuito + advisory lock de sesión por(tenant, expediente)que impide duplicar objetos WORM irreversibles bajo carrera) y el endpoint internoproteger-indice-internalpara la reconciliación. Object-Lock de MinIO en dev, NO repositorio acreditado. + CABLEADO del WORM del acta de transferencia firmada ✅ Inc.2 wiring (E10; Ac. AGN 001/2024 Anexo FUID): fast-follow espejo del Inc.1 sobre el instrumento que evidencia el traslado del FUID —proteger-indicese generaliza aproteger-artefactocontipo ∈ {indice, acta_transferencia}(migración storage010amplía elCHECK+ índice único parcial;_worm_lock_keyincluyetipo;/proteger-indice[-internal]quedan como alias duros), archive ganaproteger_worm_acta(disparo síncrono best-effort + 4ª faserun_once_actas_worm) con columnasworm_*entransferencia_acta_firma(migración archive030), retención TOTAL de la TRD confecha_inicio = firmado_atdel acta (no el cierre — un acta se firma después en transferencias secundarias) y fail-closed sin TRD; el gate de disposición cubre el acta sin filtro detipo. APTO+CONFORME. + REMEDIO del gate de disposición ✅ (E10; Ley 594/2000 arts. 24-26): la observación Media del Inc.2 (acta e índice no expiran juntos → la eliminación mandada se postergaba hasta expirar el acta) queda cerrada con la taxonomía CONTENIDO (indice/aip, bloquean) vs INSTRUMENTO DE CONTROL (acta_transferencia, sobrevive al contenido y no lo bloquea);legal_holdsigue incondicional, fail-closed antetipono clasificado (test-guardián en CI), sin migración. APTO+CONFORME. + RENOVACIÓN de retención WORM para series de Conservación Total ✅ (E10 debt; Ley 594/2000 + Ac. AGN 001/2024 Art. 4.3.2.6): cierra el hueco de que un objeto COMPLIANCE de una serie CT (conservación permanente) volvía a ser borrable al vencer suretain_untiloriginal — nuevo parPOST /preservacion/renovar-retencion[-internal](storage, D-02, 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 usaset_object_retention(verificado en miniopy-async 1.23.5 real, noput_object) — migración storage011(renovado_at/renovaciones). Archive gana la 5ª fase de reconciliación (run_once_worm_renovacion, piggyback en el ciclo del índice): espejo localworm_retain_untilenexpediente_indice/transferencia_acta_firma(migraciones031/032, poblado al proteger), filtro CT fail-closed en la consulta SQL (app/core/disposition.py::CT_DISPOSITION_CODES, fuente única con_agn_disposition) — ventana rodantenueva = now() + total_TRD(worm_renovacion_ventana_dias, nunca fecha lejana/9999), backoff exponencial propio +agotado(alarma de mayor severidad: riesgo de vencimiento del lock sobre un registro perpetuo). Fail-closed por diseño: solodisposition ∈ {CT, conserve}se renueva — E/S/M expiran y se disponen legítimamente, nunca se tocan. AIP queda fuera de alcance (sin espejo en archive, deuda futura). Remediación de auditoría: se retiró la ruta pública/renovar-retencion(superficie irreversible sin llamador → solo-internal), asiento de renovación atómico, y nuevo endpoint de visibilidadGET /expedientes/indices/pendientes-renovacion(espejo dependientes-firma, no-read-up en SQL, verificado no oráculo) que aflora los CT con renovación agotada o en riesgo. APTO+CONFORME (delta re-auditado sin hallazgos). + validación PDF/A veraPDF REAL ✅ Increment C2b (E10; RF-PRE-02):SubprocessVeraPdfValidatorejecuta veraPDF sobre PDF no confiable con modelo de seguridad endurecido —create_subprocess_exec(no-shell) con args de lista fija + whitelist de flavour, ENV scrubbeado (el hijo solo vePATH+JAVA_TOOL_OPTIONS, nuncaDATABASE_URL/MINIO_SECRET_KEY/tokens),start_new_session+killpg+reap garantizado enfinally(incluida cancelación), semáforo de concurrencia +-Xmx/-XX:MaxMetaspaceSize, tempdir 0700 por-invocación, cap de salida dual anti-DoS, JSON-only (sin XXE), degradación honesta a stubnot_evaluatedsin binario (RF-FIR-15). JRE+veraPDF solo en la imagen de deploy (Dockerfile multi-stagebase/productionno-root; la de dev sigue slim) + job de CI con un fake veraPDF que ejerce el plumbing real. Probado contra veraPDF 1.30.2 real. APTO PARA MERGE (1 Media de reap-en-cancelación remediada) + CONFORME. + CABLEADO del AIP al cierre ✅ (E10 debt; OAIS ISO 14721; Ac. AGN 001/2024 art. 4.3): elPOST /preservacion/aipexistía pero nadie lo disparaba → archive lo dispara ahora al cerrar el expediente (reconciliación-only, best-effort, caro). Como el AIP debe ser el paquete de preservación COMPLETO, la resolución de losfile_idde los anexos usa un endpoint interno nuevo en document-service (POST /internal/documentos/anexos,require_internal_token, sin gate de clearance — acto de sistema/inventario total, inalcanzable por el gateway; el no-read-up se aplica en la frontera de LECTURA del AIP, no en el empaquetado) — primer endpoint interno D-02 de document-service. Precondición:closed+ índicefirmado+worm_protegido; retención TRD TOTAL fecha_inicio=closed_at fail-closed; expediente 100% físico (0 anexos) →estado='omitido'(el índice firmado ya WORM es su artefacto); anexo mutado → fixity 409 → aborta+backoff (sin AIP corrupto). Tablaexpediente_aip(mig archive033, ciclo independiente), 6ª faserun_once_aip(advisory-lock propio), mig storage012(unique por contenido). Advisory lock de sesión enempaquetar_aip(patrónproteger_indice) cierra la carrera de doble-empaquetado WORM irreversible para ambas rutas (verificado con Postgres concurrente real). APTO PARA MERGE (endpoint interno fail-closed, inalcanzable, sin fuga cross-tenant; 1 Media de doble-upload remediada;hmac.compare_digest) + CONFORME (completitud del inventario ≠ control de acceso;omitidofísico correcto; fixity aborta corruptos). + RENOVACIÓN WORM del AIP para series CT ✅ (E10; cierra la deuda Alta que la auditoría de conformidad de B levantó): la 5ª faserun_once_worm_renovacioncubre ahora los 3 artefactos (índice, acta, AIP) — espejoworm_retain_until+worm_renovacion_*enexpediente_aip(mig archive034),AipWiringService.renovar_worm_aip, filtro CT fail-closed en SQL, ventana rodante, 3er advisory lockworm_renovacion_aip; storagerenovar-retencion-internalya genérico admitetipo='aip'; el endpoint de visibilidadpendientes-renovacioncubre los 3 tipos (no-read-up). El paquete OAIS completo del CT ya no pierde su inmutabilidad al vencer. APTO+CONFORME (espejo exacto del patrón índice/acta; la renovación queda coherente para los 3 artefactos, sin brecha de preservación). Deuda menor restante: AIP índice-only uniforme para expedientes 100% físicos si se exige a futuro.actordel asiento de renovación unificado aSYSTEM_RECONCILER_ID✅ (antesNone, heredado de índice/acta) — cerrado en el barrido de deudas junto a los otros*_agotadode reconciliación. - E15/E06: firma XAdES-B del índice al cierre ✅ (Inc.1, ADR-016 addendum); 2FA por OTP-correo en la firma personal ✅ (Inc.2, RF-FIR-13); metadatos RT-15 del índice — perfil v2 ✅ (Inc.3, ADR-022); XAdES-T — sello de tiempo RFC 3161 con TSA local ✅ (Inc.4, ADR-016 addendum: fecha cierta verificable, no acreditada); XAdES-LT/LTA — material de validación a largo plazo con CA local de dev ✅ (Inc.5, ADR-016 addendum: cadena + revocación CRL embebidas +
ArchiveTimeStamp,revocation_provenance="dev", no acreditado, degradación observable a T sin material CA); OCSP stapled (RFC 6960) junto al CRL ✅ (Inc.7, ADR-016 addendum: respuesta OCSP de un responder delegado per-tenant emitida por la CA local, incrustada enxades:OCSPValuesy cubierta por elArchiveTimeStamp,certStatusderivado de la CRL, verify fail-closed con pinning del responder; no acreditado,acreditado=false, opt-inSIGNER_OCSP_ENABLED). Pendiente: acreditación ONAC (CA + TSA + responder OCSP acreditados vía integración externa — todono_acreditadohoy), perfil ArchiveTimeStamp plenamente EN 319 132 (hoy simplificado), OCSP en línea de un responder independiente (hoy stapled auto-producido a fecha-de-firma). 2FA de mayor aseguramiento — TOTP RFC 6238 ✅ (Inc.6, ADR-016 addendum: secreto en auth-service cifrado AESGCM, verify por bearer, anti-replay atómico + lockout; WebAuthn sigue pendiente). Firma PERSONAL PKI por-usuario XAdES-B/T — Inc.1 del epic ✅ (E17/F4: eleva la firma personal de la cadena de HMAC opaco a XAdES por-usuario con cert de una sub-CA local de dev, custodia servidor; clave privada solo en auth-service cifrada AESGCM con KEK separada; split firma-remota — signature-service arma el XAdES con clave efímera + cert público real, auth firma el digest deSignedInfocon la clave real consumiendo el TOTP atómicamente, la privada nunca sale = seam a HSM; enroll conPERM_FIRMA+ emisión offline conca.keynunca montada; degrada a HMAC nativo sin cert; migración011;acreditado=falseSIEMPRE). Alcance honesto (RF-FIR-15): firma electrónica art. 7 (mismo tier legal que el HMAC+2FA — verificabilidad/formato ≠ acreditación); custodia servidor ⇒ no sole-control (no-repudio no oponible contra el operador). Inc.2 ✅ (E17/F4): revocación en línea (estado en BD consultado al verificar, sin caché — self con step-up TOTP / admin conUSUA_PERM_ADMIN, asientosigning_key.revocadaatómico; cubre las firmas de Inc.1 vía backfill) + anclaje de la sub-CA de usuarios en/verify(cadena leaf→sub-CA + pin, rama separada del sello LT;ca.keynunca montada) + veredicto matizado (firma_criptograficamente_valida/revocacion_status, fail-closed por defecto); cierra las dos Baja del Inc.1. Inc.3 ✅ (E17/F4): gatefirma_personal_require_xadesde tres modos (off|clasificados|todos, defaultoffretrocompat) — cierra el fail-open del Inc.2 (Baja #3) haciéndolo opt-in por nivel de seguridad: sin cert → 422firma_xades_requerida, auth caído → 503 fail-closed, denegación con asiento atómicofirma.xades_requerida_denegadaSIN consumir factor 2FA; env legacy bool coaccionado, valor inválido aborta el arranque. Sin cambio de tier legal (art. 7, no acreditada). Rollout: habilitarclasificados/todosSOLO tras completar el enrolamiento offline de los firmantes con clearance>=2 (la emisión del cert personal NO es autoservicio, a diferencia del 2FA), o la firma clasificada de quien no tenga cert quedará bloqueada. Firma personal PKI del acta de entrega — Inc.1 de la sub-épica ✅ (E17/F4; Ac. AGN 001/2024 Anexo FUID): quien RECIBE firma personalmente el acta con su cert PKI, como paso explícito posterior arecibir()(POST /transferencias/{id}/acta/firmar-personal), reutilizando el primitivo extraído (personal_signing.sign_personal_xml) vía el endpoint nuevoPOST /signature/sign-personal(autenticado como usuario, ASSERTfirmante_id==user); firmas independientes sobre elacta_fuid_xmlcongelado en la tablatransferencia_acta_firma_personal(migración027,UNIQUE(transferencia_id, rol), sin reconciliación desatendida — requieren TOTP interactivo); autorización atada al receptor real (decidida_por==actor, 403firmante_no_es_receptorsin quemar TOTP) + no-read-up; verificación agregadaGET /acta/verificacion(completitud ∈ {sellada, firmada_receptor}); gateacta_firma_personal_require(defaultoff) que nunca bloquearecibir().acreditado=false(art. 7, custodia servidor, no sole-control). Inc.2 de la sub-épica ✅ (E17/F4; Ac. AGN 042/2002): firma personal del REMITENTE (rolentrega, "Entregado por") sobre el mismo endpoint, completando el par de responsables del FUID junto al receptor; rol DERIVADO de la identidad (enviada_por/decidida_por, elrolparam solo selecciona, nunca concede → 403firmante_no_es_partea un tercero); firmas independientes en cualquier orden; auto-traslado (misma persona ambos lados) permitido conrolobligatorio y señalado con flagauto_traslado;completituda 5 valores (firmada_remitente/firmada_completanuevos);firmada_completa=ambas firmas personales presentes (atribución bilateral reforzada, NO conformidad jurídica plena — art. 7 no acreditado, sin sole-control). Sin migración (la 027 ya admitíaentrega). Inc.3 de la sub-épica ✅ (E17/F4; Ac. AGN 042/2002): firma personal del ELABORADOR (rolelabora, "Elaborado por" =creada_por), completando el trío de responsables del FUID — firmante ADITIVO (no parte del traspaso), así quecompletitudNO cambia (retrocompat con Inc.2) y su firma se reporta en un campo booleano ortogonalelaborador_firmado; tercera derivación de rol por identidad (misma garantía que Inc.2); migración028amplía elCHECKaelabora;validaagregado fail-closed incluye la firma del elaborador. Las tres firmas personales + sello = atribución reforzada de los tres responsables, sigue NO acreditada (art. 7, no art. 28). Rotación de la KEK de custodia ✅ (E17/F4): la KEK que cifra en reposo las privadas de firma pasa de única a llavero versionado (signing_key_encryption_key=v1 congelada, v≥2 por JSON +signing_key_active_version); la columnakey_version(sin usar desde la mig011) por fin se usa (enrollcifra con la activa,load_and_sign_digestdescifra por la versión de la fila, fail-closed); re-wrap como job de ops (python -m app.ops.rewrap_signing_keys, nunca endpoint HTTP) por fila en tx con nonce nuevo + round-trip verify + asiento solo-metadatos; AAD invariante; invariante de retirada por conteo--verify==0 cross-tenant; migración013(índice). Higiene de custodia, NO cambia el tier legal (sin sole-control, art. 7). Purga del material privado de credenciales REVOCADAS ✅ (E17/F4; Ley 1581/2012 minimización): tras una ventana (signing_key_purge_after_days, default 30d) un job de ops (python -m app.ops.purge_revoked_signing_keys) anulaprivate_key_cifrado/noncede las revocadas conservando todo lo público (verificación/revocation-status siguen idénticos); migración014(NULLABLE +private_key_purgado_at+ CHECK invariante); una purgada deja de anclar su KEK (desbloquea retiradas).revoke()sellarevocado_atpara que el re-wrap no reinicie la ventana de las superseded. NO merma el valor probatorio (la verificación no usa la privada) ni cambia el tier legal (minimización, art. 7). Diferido: validación a largo plazo con grace-period (distinguir "revocado-después-de-firmar" de "revocado-al-firmar" — requiere TSA acreditada para fecha cierta oponible; hoy la revocación es por estado ACTUAL, conservador), rotación automática programada de la KEK, formalizar la ventana de purga como política de retención (seguridad/PGD), vía administrativa si a una parte se le revoca el clearance yfirmada_completaqueda inalcanzable, resolver el estado personal antes del row-lock del turno (seguimiento heredado), y HSM/KMS externo → firma cualificada acreditada (art. 28). RT-15 "conforme completo" (perfil v4, ADR-022): ✅ — cerrados dependencia productora, fecha de declaración, política de acceso por roles y pista de auditoría atestada (aplicabilidad prospectiva; instancias legacy pueden omitir productora). Pendiente menor: backfill de la dependencia productora de radicados legacy (requeriría un cliente a document-service) y validación XSD estricta con lxml por versión (los.xsdv2/v3/v4 están publicados pero no se ejecutan en runtime). - E20: integración con operador postal como consumidor de webhooks ✅ F5 (RF-POR-08; ADR-018; cierra D-14 #3): callback ENTRANTE idempotente
POST /api/v1/public/postal/callback/{tenant_slug}con identidad propia del operador (HMAC-SHA256 per-tenant/per-operador, simétrico al webhook saliente de ADR-018), separado del permiso humano de despacho. Bajo/api/v1/public/(el gateway no exige JWT ahí, sin tocar el gateway); tenant en el path fijasearch_path, secreto en el schema del tenant → sin falsificación cruzada. Fail-closed (sin credencial y firma mala → mismo 401 no-oráculo; estado no mapeable → 422; guía desconocida → 404). IdempotenciaUNIQUE (operador, event_id); transición + asientoaudit_logatómicos; evento Redis para E16 tras commit; notificación tardía/retroceso →recorded_no_change(honesto, RF-FIR-15).motivo_devolucionendevuelto; consulta por guíaGET /envios/{id}/tracking(pull, stub). Cierra el read-up del write path (D-14 #3): el estado manual y tracking resuelven el clearance del llamante. Migración tenant017. Deuda declarada (roadmap): conector concreto E11 a cada operador (hoy stubconsultar_estado); acuse de entrega (acuse_file_idcon SHA-256 + incorporación al expediente, spec invariante 12) sigue en el flujo humano F3; provisión/rotación del secreto HMAC por operador vía E14; alerta al productor antedevuelto. - E14 — PINAR (Plan Institucional de Archivos) ✅ (2026-07, RF-ADM-08; Acuerdo AGN 003/2015): OrpycaMCP gestiona el PINAR como instrumento de planeación (lo modela, prioriza, versiona y le da seguimiento) y lo articula por referencia con CCD/TRD (E04), FUID/IUD (E12/E17) y plan de preservación (E10) — sin duplicar el dato del dueño. En
tenant-service, prefijo/api/v1/pinar. Metodología del AGN en 6 pasos → 9 tablaspinar_*(migración tenant007): diagnóstico→aspectos críticos con riesgo, 5 ejes articuladores (seed), priorización determinista (matriz aspecto×eje, prioridad = Σ impactos, ordena el mapa de ruta), visión, objetivos, proyectos (meta/indicador/responsable/recurso/tiempos/avance), seguimiento append-only (trigger BD;avance_pctproyectado del último registro), y articulación con instrumentos. Máquina de estadosborrador→aprobado→en_ejecucion→cerradocon un único plan en ejecución por tenant (índice único parcial +UniqueViolationError→409), versionado al reformular, aprobación con acto administrativo (422 si falta) e imputable (400 si falta el actor) auditada en laaudit_logINMUTABLE (E08, hash-chain) víaorpycamcp_common— igual que crear/ejecutar/cerrar. Contenido CONGELADO al aprobar (_EDITABLE = {borrador}, correspondencia acto↔contenido); las mutaciones estructurales (solo en borrador) auditan enadmin_audit. Invariantes (índice único, trigger append-only, CHECK impacto 1–10 / avance 0–100) + el wiring cross-schema aaudit_logy su inmutabilidad validados contra Postgres real (que además destapó unAmbiguousParameterErrorenset_estado, corregido). 16 tests nuevos. Auditado por seguridad (2 Media) y conformidad (2 Media, Ac. 003/2015) — sin bloqueantes, todos remediados. Decisión que completa la spec: transiciónaprobado→en_ejecucion(POST /planes/{id}/ejecutar) añadida para hacer verificable el invariante. Para instalarorpycamcp_commonel build de tenant-service pasó a contexto raíz (Dockerfile + compose, patrón ADR-010). UI del PINAR — MVP ✅ (Fase 7 cierre de desfase API↔UI, ver E22 abajo): listado + workspace con ciclo de vida y tablero. Pendiente de E14: Form Builder SurveyJS (RF-ADM-06); resto de la superficie de UI del PINAR (aspectos críticos, priorización, objetivos, proyectos, seguimiento, instrumentos, mapa de ruta). - ADR-025 — La TRD como instrumento convalidado (E04, versionado append-only) ✅ Increment 1+2 + remediación (migración 038) (2026-07/08, Ac. AGN 001/2024 que compila el 004/2019; cierra hallazgo Crítico de
archival-compliance-auditor):UNIQUE(code)→UNIQUE(code, version);trd_seriesganaestado(borrador/aprobada/convalidada/migrada/derogada) + los campos del acto administrativo (aprobación del Comité —acta_comite/fecha_aprobacion_comite, ahora escritos poraprobar—, convalidación del Consejo, RUSD, publicación); triggertrg_trd_series_append_onlyrechazaUPDATE/DELETEsustantivo fuera deborrador(excepción explícita:pdfa_profile) y, desde la 038, blinda a nivel de motor una filaconvalidada: el acto es inmutable,estadosolo puede ir aderogada,rusd_radicado/fecha_publicacionadmiten fill-once;PATCH /trd/{id}deja de recalcular retroactivamente — solo aplica sobreborrador;POST /trd/{code}/versionesclona la vigente como fila nueva (o la última por número si el código quedó sin vigente, 038).expedientes.trd_serie_idse congela al CERRAR (triggertrg_expedientes_trd_pin_immutable) — cierra la fuga retroactiva que motivó el hallazgo. Backfill honesto: toda serie preexistente quedaestado='migrada', con marcatrd_revision_requeridaen los expedientes cerrados bajo ella. Circuito de convalidación (máquina de estados aislada en el service layer):aprobar(borrador→aprobada, exige acta del Comité),devolver(aprobada→borrador, motivo obligatorio, 038 — cierra el callejón sin salida deaprobada),convalidar(aprobada→convalidada, exige acta previa del Comité, deroga en la misma tx la vigente previa con re-apunte colisión-segura de FK huérfanas — 038; omigrada→convalidada, ratificación D6),registrar-rusd(038, RUSD/publicación posteriores a la convalidación),derogar(convalidada/migrada→derogada, bloqueado si quedan expedientesopenbajo el código, 038 — decisión: no exige sucesora ya convalidada, para no impedir retirar series sin uso).GET /trd/revision-pendiente— no-read-up por clearance + traza de consulta añadidas en la 038 (regresión de seguridad cerrada: el informe no filtraba nivel de seguridad). Gate D5 para la eliminación efectiva — criterio corregido en la 038: autoriza sobre el snapshot congelado (serie_estado/acto_administrativo), no la fila viva —convalidada, oderogadacon acto (el criterio original era el contrario del que exige el ADR y habría bloqueado eliminaciones legítimas); sigue sin caller porque el job de disposición no existe (deuda declarada, referenciada endocumentos/specDrive/trazabilidad.md). 701 tests unitarios + 151 de integración (Postgres real), sin roturas (antes 679/145 y 145 respectivamente). Pendiente (Increment 3, roadmap declarado): UI de administración TRD versionada, versión+acto en el índice electrónico (perfil v5), re-baselining explícito por expediente, propagación adocuments.disposition. - ADR-026 — TVD de fondo acumulado (E04) ✅ Increment 1 (registro y convalidación, migración 039) (2026-08, Ac. AGN 001/2024 que compila el 004/2019; cierra el hallazgo de
archival-compliance-auditor: "todo el modelo es exclusivamente TRD, un fondo acumulado no tiene dónde poner su TVD"): la TVD es el mismo instrumento que la TRD — discriminadortipo_instrumento ∈ {TRD, TVD}sobretrd_series, no tabla propia (D1: bifurcarexpedientes.trd_serie_id, con siete derivaciones WORM COMPLIANCE irreversibles, es el diseño descartado). Cuatro capas de blindaje del discriminador: (1) espacio decodeúnico y compartido con TRD (índices de la 037 intactos —409 instrumento_code_en_usoindicando el tipo del ocupante); (2) dos vistasv_trd_series/v_tvd_agrupacionesconWITH CHECK OPTION—list_trd_seriespasa deSELECT * FROM trd_series WHERE 1=1aFROM v_trd_series, cerrando la consulta de la clase "filtro olvidado"; (3) CHECK por tipo en ambas direcciones (trd_series_tvd_forma_check/trd_series_trd_forma_check: TVD exigefondo_nombre/fechas extremas/justificacion_valoracion+archivo_gestion_years=0, TRD exige esas cuatro en NULL); (4) triggertrg_expedientes_pin_tipoen forma ESTRICTA — rechaza CUALQUIER expediente amarrado a una TVD, riesgo WORM cero garantizado por el motor. Router/api/v1/tvdespejo 1:1 de/api/v1/trd(mismo circuito completo: crear/versiones/aprobar/devolver/convalidar/registrar-rusd/derogar),tipo_instrumentofijado por el router, permiso reutilizadoUSUA_PERM_TRD, asientos de auditoría distintos (archive.tvd_*). GemeloGET /api/v1/{trd,tvd}/{code}/versiones(declarado por ADR-025 y nunca implementado) añadido para ambos instrumentos. Ruta registrada en el gateway. 721 tests unitarios (+20) + 164 de integración (Postgres real, +13: dos schemas de tenant, CHECK/vistas/trigger por mutación), sin roturas (antes 701/151 y 151). Pendiente (Increment 2, roadmap declarado):expedientes.origen, trigger relajado aorigen↔tipo_instrumento,compute_retentioncon fecha base explícita (fecha_extrema_final, nuncaclosed_at— D4, el punto de mayor riesgo del diseño), snapshot contipo_instrumento/base_date_origen. Increment 3: clamp deretain_untilya vencido, gate de publicación previa del inventario de eliminación (artículo/plazo exacto por confirmar conarchival-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 selectivafrom/until/set(set = doc_type),resumptionTokenpaginado, códigos de error OAI (badVerb/badArgument/cannotDisseminateFormat/idDoesNotExist/noRecordsMatch/badResumptionToken). Requisito CRÍTICO de seguridad (Ley 1712/2014 arts. 18-19, RF-SEG-08): la cosecha SOLO expone lo PÚBLICO (nivel_seguridad=1, no anulado). El recorte vive en la FUENTE (OaiRepository, filtro único_PUBLIC) → el servicio y el gateway nunca ven un reservado, así la omisión es INDISTINGUIBLE por construcción:completeListSizecuenta solo público,ListSetsno enumera un set solo-reservado,GetRecord/ListMetadataFormatssobre un id reservado →idDoesNotExist. Validado contra Postgres real (público vs reservado/clasificado/anulado +untilde día inclusivo). Identificadoroai:{tenant}:{tracking_number}(preserva el radicado como identidad). 17 tests (213 verdes en document-service). Auditado seguridad (apto, sin Críticos/Altos: chokepoint_PUBLICvalida la indistinguibilidad; remediados 2 Media —tenant inexistente→400 no 500, offset negativo→badResumptionToken— + 1 Baja saneo de control chars XML) y conformidad OAI-PMH 2.0 (remediados 2 Media:<request>sin atributos en badArgument,untilde día inclusivo). Perfil CMIS 1.1 mínimo de lectura ✅ (2026-07, otra mitad de INT-02):GET /api/v1/public/cmis/{tenant}(repositoryInfo) +/root?cmisselector=object|children|content(Browser Binding JSON) — radicados públicos →cmis:document, raíz sintéticacmis:folder, paginación maxItems/skipCount.getContentStreamsirve los bytes del anexo (viastorage_client, streaming) solo si el radicado es público (get_public_anexo, JOIN público-only; consistente con Ley 1712: la info pública es descargable). Mismo chokepoint_PUBLICque OAI → getObject/getContent de un id reservado = 404 indistinguible, getChildren/numItems solo público; validado contra Postgres real. Perfil de solo-lectura (capabilities query/write/versioning en none/false) conrepositoryUrl/rootFolderUrldel Browser Binding; sin navegación de carpetas anidadas ni CMIS-SQL (Roadmap). 11 tests (224 verdes en document-service). Auditado seguridad (apto sin Críticos/Altos: fuga de contenido reservado, indistinguibilidad y aislamiento correctos por herencia del chokepoint + doble scoping en storage; remediados 1 Media saneo RFC 6266 delContent-Disposition/Content-Typereutilizando el patrón de storage-service + 1 Baja adquisición de conexión a prueba de excepciones) y conformidad CMIS 1.1 (remediados 1 MediarepositoryUrl/rootFolderUrl, 1 BajanotSupported→405). INT-02 completo (OAI-PMH + CMIS). Pendiente de E11: export/import con verificación de fixity (INT-05), Dublin Core/ISAD(G)/EAD completos, conectores SUIT/MIPG/SECOP, API keys/OAuth2 client-credentials, SOAP/GraphQL legado (opcional). - E11 — Exportación interoperable de radicados (INT-01) ✅ (2026-07, RF-INT-01):
POST /api/v1/exporten document-service (autenticado, gateUSUA_PERM_EXPEDIENTE+ no-read-up por-radicado) produce un paquete interoperable ZIP:manifiesto.json(UUID de exportación + comentario + marcas inicio/fin + lista de entidades + excluidos content-free por sobre-clearance, Ac. 001/2024 4.3.2.3),esquema/radicado.schema.json(JSON Schema draft 2020-12 publicado),radicados/{numero}.jsonvalidado contra ese esquema ("en su totalidad": metadata sistema+contextual E03 + disposición TRD + ACL nivel/clasificación + anexos + historial deaudit_log), los binarios de anexos, ychecksums.txt(SHA-256 por miembro, fixity verificable — Ley 594 art. 19). El número de radicado se preserva como identidad inmutable (Ac. 060/2001), clave para INT-05. El historial exportaaction/fecha/actor/canalsin el payload (que puede tener el fundamento de clasificación). No-read-up validado contra Postgres real. Empaquetado físico ZIP provisional (BagIt/OAIS+WORM = E10, roadmap); síncrono (job asíncrono = follow-up). 8 tests + smoke pg real (232 verdes en document-service). Auditado seguridad (APTO sin Críticos/Altos: no-read-up del paquete, aislamiento de historial por tenant, scoping de binarios, garantía de esquema, fail-closed de RBAC/clearance correctos) y conformidad (CONFORME 6/8). Remediados: auditoría del EGRESO (radicado.exportenaudit_logcon quién/alcance/UUID content-free — Alta, ambos auditores);antecedenteE↔S (responde_a→número portable, totalidad referencial RN-3); tope anti-DoS (422 si >5000, el ZIP síncrono no bloquea el worker); anexo purgado como entradaausentetrazable; arcname del tracking saneado; asunto NULL coaccionado. Es el cimiento de INT-05 (import con fixity), que se construye encima. Pendiente: INT-05 import; export de expedientes/usuarios/clases; job async; empaquetado BagIt (E10). - E11 — Importación interoperable de radicados (INT-05) ✅ (2026-07, RF-INT-05; cierra el hallazgo Crítico de la spec):
POST /api/v1/importen document-service (autenticado, gateUSUA_PERM_EXPEDIENTE+ no-write-up por clearance), recibe el paquete ZIP de INT-01 como cuerpo crudo. Validación TODO-o-NADA antes de tocar la BD (spec §5 inv. 1): cada radicado valida contra el JSON Schema de CONFIANZA del servicio (no el que trae el paquete, que podría venir laxo) y el SHA-256 de cada binario se verifica contra el declarado; un checksum no coincidente →422sin estado parcial (Ley 594 art. 19, Ac. 001/2024). Reingiere preservando el número de radicado original como identidad inmutable (Ac. 060/2001) — una colisión se OMITE (no sobreescribe); reconstruye la relación E↔S (responde_a) por número; sube los binarios a storage (storage_client.upload_bytes); ingest atómico en transacción; auditaradicado.import(content-free). Guardas zip-slip/zip-bomb (regex de nombres de miembro, tope de entradas + tamaño descomprimido total/por-fichero) + tope de subida 512 MiB. Round-trip real export→import validado contra Postgres real (identidad/metadata/relación preservadas + idempotencia). 10 tests + smoke (242 verdes en document-service). Auditado seguridad (APTA sin Críticos/Altos: aislamiento de tenant, no-write-up fail-closed, fixity mandatoria sin TOCTOU, zip-slip/bomb cerrados) y conformidad (sin incumplimiento; cierra el hallazgo Crítico de E11). Remediados: auditoría dura (no best-effort, dentro de la tx, con la lista de radicados importados para procedencia); antecedentes no resueltos declarados (antecedentes_no_resueltos); savepoint por-ítem (absorbe la carrera de unicidad del tracking sin degradar a 500); subida de binarios FUERA de la tx (no sostener la tx de BD a través de HTTP); anexo malformado→422; topes conservadores (256 MiB). Interoperabilidad de radicados (INT-01+INT-05) completa. Pendiente: import/export de expedientes/usuarios/clases; job asíncrono; adaptador para SGD externos; reconciliartracking_sequences(namespace de numeración) y limpieza de huérfanos en storage ante rollback (deudas declaradas). - E11 — Descripción archivística EAD 2002 / ISAD(G) en OAI-PMH (INT-06, parte) ✅ (2026-07, RF-INT-06): segundo
metadataPrefix=eaden el MISMO endpoint OAI-PMH (GET /api/v1/public/oai/{tenant}, junto aoai_dc). Cada radicado público se disemina como fragmento EAD 2002 autocontenido (namespaceurn:isbn:1-931666-22-9) con<archdesc level="item">(unidad documental simple) y los 6 elementos obligatorios de ISAD(G)/NTC 4095 para intercambio — 3.1.1<unitid countrycode="CO" repositorycode>(código de referencia país+repositorio+número), 3.1.2<unittitle>(asunto), 3.1.3<unitdate normal>(fecha), 3.1.4@level="item", 3.1.5<physdesc>, 3.2.1<origination>— más 3.4.1<accessrestrict>(público, Ley 1712/2014), 3.7.2<descrules>(ISAD(G) 2ª ed. / NTC 4095),<langmaterial>,<repository>. Incremento solo-lectura, sin migración ni SQL nuevo: reutiliza elOaiRepositoryy su chokepoint_PUBLIC→ el formato es ORTOGONAL al recorte de seguridad (elegireadno sortea el filtro ni crea oráculo de reservados; un id reservado/anulado →idDoesNotExistindistinguible enoai_dcy enead); elresumptionTokenpreserva elmetadataPrefix. Auditado seguridad (APTO sin Críticos/Altos: EAD no expone ningún campo nuevo del radicado —opera sobre las mismas 6 columnas de_RECORD_COLS—, chokepoint ortogonal por construcción, round-trip del prefijo doblemente validado pre-encode/post-decode; remediada 1 Baja de consistencia de saneo C0) y conformidad (CONFORME: 6 obligatorios cubiertos + EAD estructuralmente válido; remediadas #3 Media código de referencia país/repositorio para intercambio, #5 Baja<accessrestrict>+<descrules>). 6 tests EAD (245 verdes en document-service). Descripción MULTINIVEL de expedientes (INT-06, cierre de la deuda) ✅ (2026-07, RF-INT-06): nuevo endpoint públicoGET /api/v1/public/archive/oai/{tenant}en archive-service que disemina cada expediente público como fragmento EAD 2002 MULTINIVEL — caminando la cadenatrd_series.parent_id(el CCD, serie↔subserie) construye<archdesc level="fonds">→<dsc>→<c level="series">→(<c level="subseries">…)→<c level="file">(el expediente), cumpliendo ISAD(G) 2.2 (general→específico) y 2.4 (vínculo al nivel superior), con los 6 obligatorios en el nivelfile(<origination>=institución productora) + scopecontent/accessrestrict/descrules. Complementa el item-level de radicados (document = unidad simple, archive = unidad compuesta). Gateway rutea/api/v1/public/archive/antes del catch-all público. Seguridad (Ley 1712, RF-SEG-08): chokepoint_PUBLIC = "nivel_seguridad = 1"enexpedientes(reservado →idDoesNotExistindistinguible); frontera anti-fuga item-level: para enlevel="file"— nunca enumera lostracking_numberde radicados hijos (un expediente público puede contener radicados reservados y archive no tiene su nivel; solo un conteo agregado en<physdesc>; test-guardián por grep del XML). Auditado seguridad (APTO sin Críticos/Altos; remediada guarda de profundidad 50 en la CTE recursiva del CCD contra ciclos —endpoint anónimo—, validada en pg real) y conformidad (CONFORME al alcance; remediado<origination>=institución, no la serie). 22 tests + suite completa (627 verdes en archive-service). Pendiente (deuda de roadmap, NO incumplimiento): discriminador de tipo-de-nivel para CCD de 3+ niveles; Dublin Core calificado; validación contra XSD EAD oficial en CI. - E17 (menores): rótulo PDF/QR, capacidad/ocupación, custodia externa.
- E08 (reclasificación): el endpoint
PATCH /documents/{id}/security-levelya reclasifica elnivel_seguridadcon permisoPERM_RECLASIFICAR+ no-read-up/no-write-up, auditoría inmutable (motivo + fundamento jurídico) y re-sync del snapshot de firma vía evento — cierra el residual de staleness de RF-SEG-08. La UI ya está hecha: chip de nivel + modal de reclasificación gateado porPERM_RECLASIFICARen el drawer de la bandeja (connivel_seguridadexpuesto en el read path de radicados). Índice de información clasificada y reservada ✅ (Ley 1712 art. 20 + Decreto 1081/2015):GET /api/v1/reports/indice-reservado(.csv)genera el registro de radicados clasificados/reservados. Se materializa la metadata de clasificación vigente enradicados(mig018, poblada/limpiada porreclassify); mapeo nivel 2→art. 19 (reservada) / nivel 3→art. 18 (clasificada); CONTENT-FREE (nunca el asunto), ortogonal al clearance (registro completo por mandato legal), gatePERM_RECLASIFICAR, CSV con anti-inyección de fórmulas, auditoría agregada. Seguridad APTO; conformidad CONFORME (3 bloqueantes re-auditados y cerrados: fundamento OBLIGATORIO al clasificar —radicar o reclasificar, 422 si falta, Ley 1712 art. 19/28— → el índice nunca queda con fundamentos NULL; mapeo nivel↔artículo por terminología de la Ley;tipo_documental→tipo_radicado). No-write-up al radicar clasificado ✅ (espejo M3 archive, cerrado en el barrido de deudas):DocumentService.createresuelve el clearance del radicador y devuelve403 clasificacion_forbiddensinivel_seguridad > clearance(solo nivel≥2) — un PUBLICA ya no puede originar una CLASIFICADA. Deuda fast-follow restante: índice a nivel de serie + expedientes/series (archive); campos del registro de activos (dependencia/funcionario, Dec. 1081 art. 2.1.1.5); catálogo cerrado de fundamentos; backfill de clasificados previos. Aún diferido: outbox transaccional del evento de reclasificación, doble control de desclasificación. - E08 (no-read-up en expedientes): cerrado el bypass de clearance del listado
GET /expedientesy delPATCH /{id}(filtraban con el default "sin restricción"); ambos aplican ahora no-read-up con elCOUNT/X-Total-Countfiltrado. (M1) ✅close/transferde un CLASIFICADA: se decidió (Opción B, compliance) que la disposición es ortogonal al clearance de lectura (un archivista de custodia dispone sin leer el contenido); el bug real —404 espurio post-mutación por re-lectura sinuser_id— se corrigió devolviendo unExpedienteDisposicionResultacotado que no expone contenido. (M3) ✅ write-up enPOST /expedientes: no-write-up con403explícito (no clamp) — no se puede originar un expediente por encima del propio clearance; el audit de creación motiva el nivel (Ley 1712 art. 20). (M2) ✅ la respuesta delink/batch/unlink/rebuildfiltraba membresía/conteo/hash/ciclo-de-vida a bajo clearance: la mutación es legítima (composición documental sin clearance de lectura, como M1), así que se acota la salida — ack uniforme e indistinguible byte a byte en todo estado, la mutación siempre ocurre y siempre audita. La terna RF-SEG-08 (M1/M2/M3) de expedientes queda cerrada. E12 (transferencias) también: la transferencia como acto de custodia es ortogonal al clearance de lectura (create/enviar/recibir/rechazar, como M1 — decisión sin código), pero el FUID (asunto/serie/signatura = contenido) aplica ahora no-read-up en sus 3 puertas (/transferencias/{id}/fuid→ 404 total;/fisico/fuid+.xml→ filtro por fila). Diferido:PERM_CLASIFICARdedicado para nivel≥2 (hoy basta el clearance). Seguimiento cerrado: (a) ✅GET /unidades/{id}/expedientes(enumera expedientes de una unidad) ahora filtra por fila (no-read-up); su gemelaGET /expedientes/{id}/unidades(dónde está un expediente ya conocido por UUID) se deja ortogonal (custodia, como M1, decisión documentada). (b) ✅ la creación por batch ahora deja asientoarchive.expediente_createdenaudit_log. Atomicidad mutación↔asiento ✅:create_expediente, la creación por batch (por ítem) ylink/unlink/link_batch(incremento gemelo) envuelven ahora sus escrituras enconn.transaction()con el asiento como último write → un fallo a mitad ya no deja un expediente/vínculo/exclusión sin su asiento de auditoría; de paso se cerró una carrera de código duplicado ennext_expediente_code(colapsado a…RETURNING).close/transfer/recibir✅ (cerrado): ya no quedan fuera — el tramo DB se envuelve enconn.transaction()(transición → evento → índice → asiento) y el sellado XAdES (HTTP) queda post-commit, best-effort. Se cerraron además: el TOCTOU de la disposición (transition_expedienteganaexpected_status→WHERE ... AND status = $N: doscloseconcurrentes ya no commitean ambos duplicando asientos en unaudit_logindeleble); la ruta gemelaTransferenciaService.recibir, que congelaba el expediente atransferredsin tx, sin evento y sin asiento; el actor ausente en el asiento de transferencia (el router ni declarabaX-User-Idpese a que el gate RBAC ya lo exige); y la ruta alternaPATCH /expedientes/{id}, que ejecutaba las mismas transiciones con unUPDATEpelado —statusretirado del schema (conextra="forbid": sin él se ignoraría en silencio, fail-open) y del repositorio (era el segundo escritor no auditado deexpedientes.status). El PATCH de metadatos audita ahora la reclasificación TRD (archive.expediente_reclasificado, valores antes/después), y los intentos denegados dejanexpediente_close_failed/transfer_failed. Test de integración contra Postgres real ✅ (cerrado): nuevo carriltests/integration/(23 tests, schema de tenant desechable + commits reales) que ejercita el rollback físico,verify_chain, la concurrencia real y el append-only — con verificación por mutación (quitarle la tx al SUT pone los tests en rojo; los tests con mocks no podían detectarlo). Residuales Baja abiertos: la ubicación física (signatura/folios) de un CLASIFICADA vía/expedientes/{id}/unidades(custodia, a barrer con compliance junto a los call sites ortogonales); un tope de tamaño de lote paralink_batchsobre un expediente de alta concurrencia (advisory lock por-expediente);PERM_CLASIFICARdedicado para nivel≥2. Ciclo de vida de transferencias (E12) ✅ (cerrado, incremento propio):create/enviar/recibir/rechazarenvuelven ahora mutación→evento→asiento enconn.transaction()con asiento de éxito y de denegación (los cuatro actos de custodia dejan traza; el rechazo con asiento propio y motivo);transferencias.update_estadogana la guarda de estado esperado (WHERE id=$N AND estado=$expected, keyword-only obligatorio) → la carrera letalrechazar-vs-recibir(transferencia 'rechazada' + expediente ya congelado 'transferred') queda cerrada, validada por mutación; identidad unificada enapp/core/actor.py(X-User-IdUUID como fuente única, sin fallback a username, fail-closed) para transferencias y paraclose/transferde expedientes; la hoja de ruta puebla la columna tipadaactor(antes NULL). Migración tenant020(enviada_por). Conformidad Tít. 4.4 ✅ (CONFORME): en un segundo incremento se cerró el par conjuntamente bloqueante que faltaba — H-G (recibir()ahora re-verifica la fixity del acervo antes de congelar la custodia, vía helperIndexService.fixitypura-BD SHA-256 compartido converify(), conCLEARANCE_SIN_RESTRICCIONpara no imponer clearance de lectura a un acto de custodia; mismatch →409 fixity_mismatchque revierte sin congelar, con solo un conteo en la respuesta y la enumeración de radicados en elaudit_log; Ley 594 art. 16, ISO 16363), H-K (rechazo conmotivoobligatorio en columna propia sin pisar la observación de preparación) y H-J (rechazada_at). Migración tenant021. 293 unit + 35 integración verdes; H-A/H-G por mutación. APTO PARA MERGE por seguridad (×2, sin Crítico/Alto) y conformidad (×2 convergentes): Ac. AGN 001/2024 Tít. 4.4 CONFORME para el ciclo de transferencias. H-I ✅ (cerrado): el FUID se congela como acta de entrega inmutable al recibir — tablatransferencia_acta(1:1), XML canónico +acta_fuid_sha256+ los tres responsables "Elaborado/Entregado/Recibido por" con fechas +indice_version, todo dentro de la tx atómica de recepción (inline en BD, no MinIO, para no romper la atomicidad de H-G); lectura víaGET /transferencias/{id}/actacon no-read-up (generación ortogonal al clearance, lectura con no-read-up). Migración022. 299 unit + 41 integración; APTO por ambos auditores. Alcance honesto (RF-FIR-15): se congela el FUID vigente como acta; NO conformidad con todas las columnas del Anexo AGN (falta entidad productora, unidad administrativa, cargo/firma — deuda de E17); inmutabilidad procedimental + verificable por fixity, no por trigger de motor. Trigger append-only del acta ✅ (cerrado): migración023—transferencia_actaes ahora inmutable forzada por el motor (triggersBEFORE UPDATE OR DELETE FOR EACH ROW+BEFORE TRUNCATE FOR EACH STATEMENT, decisión (B): UPDATE+DELETE+TRUNCATE), no solo procedimental; además elacta_fuid_sha256se ancla en la hash-chain deaudit_log(archive.transferencia_recibida) → la manipulación tras un bypass del trigger (solo superusuario) es detectable. 299 unit + 47 integración; ambas garantías por mutación; APTO por ambos auditores. Alcance honesto (RF-FIR-15): capa de trigger equivalente aaudit_log+ tamper-evidence del SHA anclado; NO paridad total (falta la capa de privilegiosREVOKE, innecesaria sin DDL en el rol de la app). H-L/H-M ✅ (cerrado): integridad de la creación de transferencias — migración024(índice único parcialWHERE estado IN ('preparada','enviada')→ una sola transferencia activa por expediente, con re-preparación tras rechazo; concurrencia serializada por el índice → 409transferencia_activa_existente) y validación de coherencia origen/destino por tipo encreate()(primaria: gestión→central; secundaria: central→histórico; el salto que evita el archivo central se rechaza;422; Ley 594 art. 23). 310 unit + 52 integración; ambas por mutación; APTO por ambos auditores. Origen/destino obligatorio + cotejo ✅ (cerrado para lo físico): el residual RF-FIR-15 de origen/destino se cerró con obligatoriedad condicionada a la tenencia física localizada — si el expediente tiene ubicación física (víaexpediente_unidad, M2M), origen/destino son obligatorios (422 ubicaciones_requeridas_fisico) y el origen debe cotejar contra las ubicaciones actuales (422 ubicacion_origen_no_actual, respetando la multiplicidad); si es puramente electrónico, la transferencia es lógica (rama H-M previa). Sin migración (obligatoriedad encreate()). 320 unit + 59 integración; cotejo por mutación; APTO por ambos auditores; conformidad SATISFECHO (inventario FUID de partida/llegada + cotejo del origen real; caso electrónico conforme por diseño). Residuales declarados (deuda E17): rama B con par aportado no cotejado, tenencia no localizada (ubicacion_idNULL), sin enforcement de BD. Caveat de despliegue (H-L): el índice único024falla sobre un tenant con datos que ya violen el invariante (saneo previo). Firma XAdES del acta ✅ (H-H cerrado para su alcance, E06/F4): el acta de entrega recibe ahora un sello XAdES-B/T/LT/LTA + OCSP institucional enveloped sobre su XML canónico (cubre los bytes cuyo SHA se ancla en la hash-chain) → integridad + no-repudio institucional + fecha cierta verificable vía TSA (no acreditada). Sellado best-effort (aditivo): fila hermana mutabletransferencia_acta_firma(migración026) nacidapendiente_firmaen la tx derecibir(), intento síncrono acotado post-commit (atribuido al receptor) + reconciliación por el job generalizado a dos tipos de objeto (SYSTEM_RECONCILER_ID, cadencia menor);GET /actagana bloquefirma(+?verify=true),POST /acta/firmarreintento manual. signature 249 + archive 386 unit + 70 integración; APTO (seguridad, 1 Baja de nombre MinIO remediada para acta e índice) + CONFORME (H-H cerrado). Alcance honesto (RF-FIR-15): el sello institucional atribuye los tres responsables por identidad autenticada anclada, pero NO son las firmas personales de cada uno con cargo (PKI por-usuario, deuda E17); no acreditado ONAC; sin WORM del artefacto (E10). Endurecimientos pendientes (NO bloquean Tít. 4.4): H-H residual (pinindice_firma_idcompleto —indice_versionya se captura), columnas completas del Anexo FUID — entidad productora, unidad administrativa; el trío de responsables del FUID ya firma personalmente ✅ — receptor ("Recibido por", Inc.1), remitente ("Entregado por", Inc.2) y elaborador ("Elaborado por", rolelabora, Inc.3, firmante ADITIVO ortogonal afirmada_completa, campoelaborador_firmado, migración028) (POST /transferencias/{id}/acta/firmar-personal, rol derivado de la identidad,completituda 5 valores confirmada_completabilateral, flagauto_traslado,acreditado=false) (E17), enforcement en BD de la obligatoriedad física + cotejo en rama B (E17), evento PREMIS fixity-check / objeto PREMIS del acta (E10/F5),canal="api"hardcodeado yobject_refpor UUID en los asientos del sellado. Reconciliación del sellado del índice ✅ (cerrado el modo de fallo permanente, E15/E06 Inc.8): el residual "expedienteclosedcon índice sin firmar" se aborda con cuatro controles (alcance dictado por conformidad) — (1) elimina el modo de fallo permanente; (2) intento síncrono acotado de sellado en el propio cierre cuando el sello institucional está montado (firmar_indice_close, 2 intentos × ~1s → firmado-al-cierre en el caso común; el cliente distingueseal_absent→ degrada sin reintentar, detransient→ reintento acotado); (3) job de reconciliación (task del lifespan +python -m) con backoff exponencial (migración025:reconcile_intentos/proximo_at/ultimo_error/agotado), advisory lock por tenant e idempotente (asiento gateado por filas afectadas → sin doble-sello ni asiento fantasma bajo carrera); (4) endpointGET /expedientes/indices/pendientes-firma(no-read-up, incluyeagotado) para visibilidad operacional. Atribución de sistema: ruta internaPOST /signature/internal/sign-indice(soloX-Internal-Token, el gateway no lo reenvía; cableada aobjeto_tipo="indice";SYSTEM_RECONCILER_ID) que estructuralmente no puede firmar la cadena personal. Asientoarchive.indice_firmadoconorigen∈close/manual/reconciliation,object_ref=code. archive 359 unit + 65 integración (Postgres real) + signature 240; APTO (seguridad, 1 Media de asiento fantasma remediada) + CONFORME (conformidad). Alcance normativo honesto (RF-FIR-15): conformidad plena con el art. 4.3.2.4 SOLO en el caso común (sello montado + intento síncrono exitoso o convergencia acotada); NO garantiza firma síncrona al instante del cierre, y con el sello institucional AUSENTE el índice quedapendiente_firmaindefinidamente (reconcile_agotado, exige intervención humana) — nunca se fingefirmado. Endurecimiento diferido: alerta proactiva sobre losagotado(índice y acta). - E09 (RF-BUS-10 — traza de consulta): cubierto en los tres servicios con lecturas de radicado. Traza agregada por consulta (usuario/criterios/total, sin el texto crudo de
q, separada de la pista por-registro) + traza por-registro de la lectura individual: expedientes en archive-service (expediente_busqueda+expediente_consultado), radicados en document-service (document.radicado_busquedapara list/search/respuestas +document.radicado_consultado) y trámite en workflow-service (workflow.bandeja_consultada+workflow.hoja_ruta_consultada/tramite_consultadopara hoja de ruta / current / events). Los tres servicios usanobject_ref = tracking_number/code(clave de negocio) para correlación cross-servicio. Extensión futura: la traza a servicios sin lecturas de radicado, si en el futuro las tuvieran.
Andamiaje fundacional (Fases 1–6) ✅ Completo¶
Estas fases construyeron la base sobre la que el specDrive eleva la conformidad.
Fase 1 — Framework y Arquitectura¶
- [x] Estructura, CLAUDE.md, agentes, memoria persistente, docker-compose base (PostgreSQL, MinIO, Keycloak, Redis, MailHog), realm Keycloak, init-db.sql, CI/CD, MkDocs
- [x] ADR-001 microservicios · ADR-002 multi-tenancy
- [x] auth-service, tenant-service, api-gateway
Fase 2 — Dominio Core¶
- [x] document-service (radicación E/S/I, numeración atómica, anexos)
- [x] storage-service (MinIO, SHA-256, pre-signed URLs, bucket por tenant)
- [x] archive-service (TRD, expedientes
open→closed→transferred)
Fase 3 — Workflows y Notificaciones¶
- [x] workflow-service (asignación/transferencia, historial, eventos Redis)
- [x] archive-service (vínculo radicado↔expediente)
- [x] notification-service (SMTP, consumer de eventos, historial)
Fase 4 — Calidad e Integración¶
- [x] init-tenant, E2E, ADR-003 asyncpg, lint/type-check en CI, health check orquestado
Fase 5 — Comunidad y Publicación¶
- [x] Guías de despliegue/contribución, README EN, docs bilingüe, AGPL v3, OpenAPI aggregation, GitHub Actions, GHCR, API reference EN
Fase 6 — Características Avanzadas (andamiaje)¶
- [x] TRD seed FondeCund, Batch Documents/Expedientes, Full-Text Search, Workflow Rules Engine
Más allá del specDrive 📋 Planeado¶
- [ ] App móvil nativa (iOS/Android)
- [x] ~~Firma de documentos~~ — firma electrónica nativa + cadena de firma / bandeja del firmante hechas (E06: turno ordenado,
GET /pending, sign/reject/batch); XAdES-B del índice electrónico + sello institucional hecho (Inc.1, ADR-016 addendum); 2FA por OTP-correo en la firma personal hecho (Inc.2, RF-FIR-13); XAdES-T (TSA local) hecho (Inc.4) y XAdES-LT/LTA (CA local de dev + CRL + ArchiveTimeStamp, no acreditado) hecho (Inc.5, ADR-016 addendum); 2FA TOTP RFC 6238 en la firma personal hecho (Inc.6, ADR-016 addendum: secreto en auth-service, AESGCM, verify por bearer); OCSP stapled (RFC 6960) junto al CRL en XAdES-LT — responder delegado per-tenant, cubierto por el ArchiveTimeStamp, no acreditado hecho (Inc.7, ADR-016 addendum); firma PERSONAL PKI por-usuario XAdES-B/T (custodia servidor, sub-CA local de dev, split firma-remota = seam a HSM) hecha (Inc.1 del epic E17/F4;acreditado=false, art. 7, no sole-control); 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 enAssistantDock(antes placeholder), con D-05 (token server-only), allowlist, y saneo server-side del output (contenido no confiable →marked+sanitizeHtml, ignora elhtmldel gateway); backend ausente →503 assistant_unavailable(estado honesto, sin respuestas simuladas). Backend E18 ✅ implementado (ver F6/E18): gateway proxy/api/v1/assistant/→mcp-servercon loop LLM Claude real ejecutando las tools de solo lectura as-the-user. Contrato/api/v1/assistant/messageestable, preparado para streaming; APTOsvelte-ux-reviewer. Diferidos del slice: VoiceInput, subida de anexos, streaming SSE, persistencia de historial; y reconciliarX-Usernamevspreferred_usernameen 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 eljson_schemade la plantilla de metadatos activa de la serie y se renderizan campos dinámicos (schemaField.jsmapea JSON Schema → control + coerción;SchemaField.svelte), poblando elmetadata:{}que iba vacío;actions.createlo reconstruye campo a campo (allowlist por claves del schema); vitest 314; APTOsvelte-ux-reviewer.radicarmigrado aSchemaField✅ — cierra un hueco real: su campo dinámico inline solo cubría number/date/text, así que unenum/booleandel tipo documental degradaba a texto libre (→422); ahora usa la fuente única (enum→select,boolean→Sí/No) y envía elmetadatatipado; +5 tests (radicar no tenía del campo dinámico). De paso se corrigió un defecto crítico pre-existente: elModalde confirmación de radicación nunca era visible (faltaba la propopen), así que la radicación no se podía confirmar desde la UI. Diferidos: camposarray/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 aSchemaField. Adopción del Sistema de Diseño OrpycaMCP v1.0 ✅ (2026-07, ADR-020 actualizada):_tokens.scssrepintado a la paleta definitiva conservando todos los nombres--op-*(cero rotura en 19 pantallas) + contraste WCAG calculado, no estimado → el primario queda partido por rol (--op-primaryno-textual 4.28:1 /--op-primary-darktexto 6.56:1); escala tipográfica, radios, sombras y movimiento v1.0 + regla globalprefers-reduced-motion; Space Grotesk + Public Sans self-hosted (@fontsource, sin CDN); Font Awesome cableado por primera vez (su CSS nunca se importaba: todos los iconos eran cajas vacías) y favicon 404 corregido; landing pública en/(7 secciones, 302 a/dashboardsi hay sesión) — antes la raíz del sitio no existía; login/callback/403 con la marca real (composición split, mensaje de error de Keycloak desde tabla cerrada → cierra el bucle de redirecciónIdP↔/login); búsqueda global en el topbar (atajo/, deep-link?q=) y favoritos configurables en el sidebar (store namespaced por tenant+usuario, sin filtro propio: consume la lista ya filtrada por RBAC); dashboard rediseñado a los 4 módulos personales con las métricas del tenant relegadas a sección colapsable gateada porSGD_PERM_ESTADISTICA(UI espejo del RBAC) yNAV_ITEMSextraído alib/utils/navItems.jscomo fuente única;DataTablecon las 8 capacidades obligatorias (filtros, ordenaria-sort, export CSV anti-inyección de fórmulas, columnas configurables, vistas guardadas, selección múltiple, virtualización porcontent-visibility, edición en celda), todas opt-in, con/busqueday los 4 paneles de/reportesmigrados. Auditado porsvelte-ux-reviewer: 27 hallazgos Alto/Crítico remediados (contraste AA enButtony 8 pantallas, objetivos táctiles ≥44px en 8 componentes, foco visible enUserPicker, columna desalineada en móvil en/busqueda, y confirmación explícita antes de firmar en/firmas, que no la tenía). 481 tests vitest verdes,svelte-check0 errores/0 warnings,buildlimpio. Otros pendientes del front: E2E autenticado contra docker-compose (escrito, hoy skip)./admin/gruposy/admin/dependencias✅ (E08/E14, Fase 5 cierre de desfase API↔UI): grupos reutilizaCatalogCrudPanel(encaja sin extensión: al no invocar nuncaeditOpen/deleteOpen, los modales de editar/borrar simplemente no se activan — el backend no tienePATCH/DELETE /groups/{id}) con un drawer de miembros/permisos/clearance que advierte explícitamente que el backend no expone lectura de esas tres cosas ya asignadas a un grupo (solo altas/bajas ciegas — gap real derbac.py/clearance.py, no una omisión de UI). Dependencias añadeDependenciaTreeNode.svelte(árbol nuevo con<details>/<summary>nativos — no existía un componente de árbol reutilizable en el proyecto) sobreGET /dependencias/tree, con un modal de confirmación de riesgo explícito (no un tooltip) al renombrar o desactivar, por la deuda de correlación por-nombre deworkflow_rules.assign_to_dept/flow_steps. 57 tests nuevos de proxy (401, allowlist, UUID +encodeURIComponent, edición parcial);svelte-check0 errores. Diferido: borrado duro de dependencias (el backend lo tiene, bloqueado si hay hijos, fuera del alcance pedido)./admin/trd,/admin/metadatos,/admin/catalogosy/admin/parametros✅ (E03/E04/E14, Fase 5 cierre de desfase API↔UI, segunda tanda): TRD/CCD reutilizaCatalogCrudPanelen dos pestañas — series en árbol indentado (no lista plana, la jerarquíaparent_ides el dato) con código inmutable, y tipos documentales con CRUD completo y filtro por serie; gatea sus botones conUSUA_PERM_TRD(el permiso REAL que exigetrd.py/tipos_documentales.py— más específico queUSUA_PERM_ADMIN, que solo controla la visibilidad del panel/admin). Metadatos reparte con TRD (TRD = clasificación, metadatos = campos de esos esquemas): plantillas de expediente/documento son de solo alta (el backend no tienePATCH/DELETE, nueva versión = nuevo registro) con eljson_schemaeditado como JSON crudo en textarea (parseo y validación de forma de objeto ANTES de enviar — SchemaField.svelte/schemaField.js rellenan un formulario a partir de un schema ya existente, no lo autoran, por eso no aplican aquí) y elementos de metadato reutilizables con CRUD completo. Catálogos usa disposición maestro-detalle (lista de catálogos a la izquierda, items a la derecha) que degrada a dos niveles navegables con botón «Volver» en móvil, nunca a columnas apretadas. Parámetros suma calendario de festivos (precarga Ley 51/1983 sin llamada externa, algoritmo de Pascua/Emiliani ya existente en el backend) y una calculadora de días hábiles que reconstruye el desglose de qué días se descontaron (fin de semana/festivo con su descripción) porqueBusinessDaysResultdel backend solo devuelve la fecha de vencimiento, no el desglose — se deriva en el cliente del mismo catálogo de festivos ya cargado, no se inventa. HALLAZGO DE DOMINIO reportado (no corregido en el front): a diferencia detrd.py/tipos_documentales.py/metadata.pyde archive-service (gate realUSUA_PERM_TRD), los routersmetadata.py/metadata_elements.pyde document-service ycatalogos.py/config.pyde tenant-service no declaran ningúnrequire_permission— cualquier autenticado del tenant puede escribir;config.pylo documenta explícitamente en su propio docstring como pendiente. Estas cuatro pantallas gatean sus botones de escritura conUSUA_PERM_ADMINdel 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-check0 errores/0 warnings; 1125 tests vitest verdes (91 archivos), build limpio. RAG integrado en redacción y clasificación ✅ (F6, E21, cierre del desfase API↔UI): el RAG no es un módulo aparte — botón "Sugerir con antecedentes" (RagSuggestButton.svelte) en/radicar(cuerpo de Salida/Interno) y/borradoresllama aPOST /knowledge/rag(Inc.3) vía proxies SSR nuevos; chips de tipo documental sugerido (TrdSuggestChips.svelte) junto al selector de clasificación en/radicar, alimentados por/knowledge/antecedentes(Inc.2 Patrón A) con resolución honesta dedoc_classpor precedente víaGET /documents/by-tracking/{tracking}(ese endpoint no traedoc_classen su contrato real — no se inventa el campo). Sección "Respuestas" nueva en el drawer de detalle de/bandeja(GET /documents/{id}/respuestas). Tres reglas duras: el texto generado se inserta SIEMPRE marcado como generado y con sus citas (lib/utils/ragInsert.js, usando solo etiquetas de la allowlist desanitizeHtml.jspara que la marca sobreviva el saneo server-side); sin citas no se ofrece insertar (afirmación sin fuente en un documento oficial es peor que no tener sugerencia); el chip de clasificación nunca se autoaplica; y un fallo del servicio de conocimiento queda contenido en el componente, sin bloquear radicar/guardar un borrador. La barrera de residencia del backend (is_generatable) se refleja con un aviso genérico honesto, sin inventar cifras que el backend no da. 51 tests nuevos (proxies + las tres reglas duras); 1186 tests vitest verdes (101 archivos),svelte-check0 errores. Búsqueda semántica y de antecedentes en/busqueda✅ (F6, E21, cierre del desfase API↔UI): dos pestañas nuevas junto a la Exacta existente — Semántica (POST /knowledge/search) y Antecedentes (POST /knowledge/antecedentes, texto o radicado pivote) — resueltas por?tab=(deep-link, mismo patrón que?vista=de/bandeja); tres proxies SSR nuevos (busqueda/api/semantica,busqueda/api/antecedentes,busqueda/api/by-tracking/[trackingNumber]contra document-service para verificar el radicado pivote antes de buscar). Honestidad de interfaz no negociable: los resultados se presentan como documentos parecidos (similitud vectorial con banda cualitativa explicada, nunca coincidencia exacta), un fallo de red nunca se disfraza de "sin resultados", y el recorte por ACL deknowledge-servicees invisible por diseño (no se anuncia ningún conteo de resultados ocultos — evita convertir la búsqueda en oráculo de existencia). Tablist con roles ARIA correctos y navegación por flechas. 18 tests nuevos de proxy (401, allowlist,encodeURIComponent); 1186 tests vitest verdes (101 archivos),svelte-check0 errores. MVP de PINAR en/admin/pinar✅ (Fase 7, cierre del desfase API↔UI, E14/RF-ADM-08, Ac. AGN 003/2015): PINAR tenía 30 endpoints entenant-servicesin ninguna pantalla. El MVP cubre listado (filtro por estado, alta de una versión nueva siempre enborrador) y workspace conStepperdel ciclo de vida (borrador→aprobado→en_ejecucion→cerrado, puramente informativo — las transiciones son botones +Modalde confirmación, nunca clic en elStepper) y pestaña Tablero (avance por objetivo/por eje). 6 proxies SSR (ejes,planesGET+POST,planes/{id}/{aprobar,ejecutar,cerrar,tablero}). Honestidad de interfaz: aprobar congela el contenido del plan de forma permanente (asiento enaudit_loginmutable) — se advierte ANTES de confirmar, con el botón de confirmación deshabilitado sinacto_administrativo; el tablero distingue "no se pudo cargar" (banner) de "vacío legítimo" (plan sin proyectos aún,EmptyState), nunca pinta ceros por un fallo de red. Fuera de alcance (sin UI, fase posterior de PINAR): aspectos críticos, priorización, objetivos, proyectos, seguimiento, instrumentos, mapa de ruta — dependen de objetivos/proyectos, que no tienen pantalla propia todavía. 41 tests nuevos; 1227 tests vitest verdes (105 archivos),svelte-check0 errores./admin/tvd✅ (ADR-026 Increment 1, fondo acumulado): espejo estructural de/admin/trdcon columnas propias (fondo/productora, fechas extremas, estado del instrumento) y sin columna de archivo de gestión (no existe para TVD);justificacion_valoracioncomo campo principal obligatorio del formulario de creación (es el objeto del instrumento, no una nota). Circuito de convalidación (aprobar/devolver/convalidar/registrar-rusd/derogar) construido como componente compartidoInstrumentLifecycleActions— no existía en/admin/trd(ADR-025 lo dejó pendiente) y se construyó una única vez, conectado a ambas pantallas, con advertencia de irreversibilidad dentro delModalde confirmación para convalidar/derogar y ningún botón ofrecido si el estado actual no lo permite. Edición append-only fuera deborrador(solopdfa_profileviaja). Se verificó queTrdSuggestChips.svelteno pudiera sugerir nunca una TVD: no consulta series/agrupaciones, resuelvedoc_classde radicados precedentes — no había nada que corregir. 68 tests nuevos; 1295 tests vitest verdes (108 archivos),svelte-check0 errores/admin/interoperabilidad✅ frontend, carga masiva pendiente de ruteo en el gateway (Fase 8, última del cierre del desfase API↔UI, E11/E20): cuatro secciones — Exportar (POST /api/v1/export→ ZIP en cliente), Importar (POST /api/v1/import,Steppersubir→validación→confirmar→resultado; el422de fixity muestra el archivo/checksum EXACTO que falló, nunca un mensaje genérico;antecedentes_no_resueltosse lista por número de radicado, no solo se cuenta), y carga masiva de documentos/expedientes (POST /api/v1/batch/documents|expedientes, confirmación explícita con el número exacto de ítems, sondeo de progreso cada 2s contra el proxy propio — D-05 — que distingue "no se pudo consultar" de "aún sin resultados", nunca sintetiza contadores en cero). Se sumó "Actualizar rastreo" en/envios(GET /api/v1/envios/{id}/tracking, F5) que declaraoperador_conectado=falseen vez de aparentar una consulta en vivo. HALLAZGO DE ALCANCE (no corregido, fuera deservices/en esta fase): el gateway (api-gateway/app/routers/proxy.py::_route_to_upstream) no tiene registrado/api/v1/batch/en su tabla de ruteo — ni hacia document-service ni hacia archive-service — así que ambas secciones de carga masiva devuelven404 route_not_foundhasta que se añada esa entrada (cambio defastapi-developer/orfeo-architect). Exportar/Importar/rastreo sí quedan operables de punta a punta. 27 tests nuevos (allowlist,encodeURIComponent+UUID en[jobId], streaming/saneo deContent-Disposition, 503 explícito del sondeo ante fallo de conexión); 1322 tests vitest verdes (110 archivos),svelte-check0 errores - [x] ~~API de webhooks~~ — webhooks salientes firmados hechos (E11)
- [x] ~~Exportación de expedientes a PDF/ZIP~~ — ZIP hecho (E02/export):
GET /api/v1/expedientes/{id}/export.zip(archive orquesta, storage ensambla vía endpoint interno D-02) con índice electrónico XML (best-effort) +manifiesto.csv(incluye los excluidos con causal) +LEEME.txtde alcance +checksums.txt(SHA-256) + bytes de los anexos; no-read-up POR-RADICADO (document-service filtranivel <= clearancey omite los sobre-clearance incluida su existencia — cerró una fuga read-up que el gate solo-expediente no cubría), gateUSUA_PERM_EXPEDIENTE, anti zip-slip, auditoría agregada. asunto/tipo en el manifiesto ✅ (cerrado en el barrido de deudas: endpoint internoPOST /internal/documentos/metadataclearance-aware con el mismo no-read-up por-radicado → columnastipo/asuntoenmanifiesto.csv, best-effort). APTO+CONFORME. Pendiente: PDF combinado (motor de render), streaming para expedientes grandes,get_by_id(soft-delete de anexos) - [x] ~~Reportes y estadísticas~~ — reportes de radicados hechos (E09); panel/indicadores avanzados pendientes
Pendientes del frontend (E22) tras la adopción del Sistema de Diseño v1.0¶
Todos están registrados con su archivo y su motivo. Salvo el primer bloque, ninguno bloquea la línea base.
Revisión 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 →404permanente. 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ó conFORWARDED_REQUEST_HEADERS. - [ ] Tres tarjetas de
/adminllevan a un403— el mismo patrón ya corregido enColas, que resultó no ser un caso aislado.TRD / CCDse anuncia conUSUA_PERM_ADMINcuando el gate real esUSUA_PERM_TRD;Metadatosofrece "Nueva plantilla de expediente" conUSUA_PERM_ADMINcuando exigeUSUA_PERM_TRD; eInteroperabilidadgatea toda la pantalla conUSUA_PERM_EXPEDIENTEpese a que la carga masiva de documentos exigePERM_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/clavesfalta 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 deStepper(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/borradoresy/envios. En varios bloques está invertido:--op-primary-dark(6.56:1) en el hover y el claro en reposo.Button.svelte:148ya lo resolvió bien; las páginas se desviaron del propio sistema de diseño. - [ ] Tablas anchas inalcanzables: seis contenedores usan
overflow: hiddencon celdasnowrap, 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 llevantabindex="0", de modo que ningún usuario de solo teclado puede desplazarlos (WCAG 2.1.1) — incluidosDataTable.svelteyCatalogCrudPanel.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 queFormFieldya soportaerrorconaria-invalid/aria-describedbyy 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 declaraninputmode="numeric"niautocomplete="one-time-code". - [ ] Cuatro tablas emiten una celda más que encabezados tienen (la columna de acciones sin
<th>), yCatalogCrudPanelaplicadisplay: flexsobre 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í sí cumple el requisito normativo:
ragInsert.jses 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/medescarta el nivel CRUD.auth-service/app/routers/auth.py:226resuelve{permiso: nivel_crud}y devuelvelist(perms.keys()), así que el store recibe una lista plana ycan("USUA_PERM_EXPEDIENTE")no distingue lectura de escritura — mientras los backends sí gatean conmin_crud=3. Consecuencia: se ofrecen Cerrar / Transferir / Excluir / Firmar acta a quien tiene el permiso en modo lectura, y recibe un403. 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/mey añadir uncan(perm, nivel)al store. Es decisión de arquitectura y toca backend.
Rendimiento y activos
- [ ] Subset de Font Awesome: hoy
+layout.svelteimporta el CSS completo de Font Awesome. La app solo usa iconossolidyregular; 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.pnges el emblema circular (100×97 px), no un logotipo completo con wordmark. Mientras no exista un wordmark, la landing y/loginmuestran el emblema a tamaño grande y el nombre de la marca no aparece por encima del pliegue. Ademásapple-touch-icon.pngmide 180×176 (no 180×180 como declaraapp.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;DataTableno 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 emitefilterChange/sortChangepara que una pantalla los cablee a un refetch del gateway; ninguna lo hace todavía. Por eso/busquedano 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
/dashboardno tiene endpoint propio. Se deriva del campo realdue_date(RF-RAD-04) de los radicados abiertos ya cargados para "pendientes" — es un proxy declarado explícitamente en el docstring dedashboard/+page.server.js, no una simulación. Cuando exista un endpoint transversal (que agregue también/signature/pendingy/transferencias, no solodue_datede 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=distributedystatus=in_progress) porque el endpoint no acepta varios estados a la vez. Constatus[]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)) vsDrawer.svelte(.35) vsAppLayout(.4) vsAssistantDock(.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-monoes un tamaño (12px), no una familia. Hay ~16 sitios confont-family: monospacea pelo, justo en el número de radicado, que es el dato más identificatorio del sistema. Añadir--op-font-family-monoa_tokens.scssy migrar los 16 sitios (uno de ellos, en/dashboard, quedó con un stack literal como parche). - [ ]
<a role="button">:Button.svelteconhrefrenderiza un enlace anunciado como botón — no responde a la barra espaciadora. Afecta a los CTA de la landing, a/403y a los enlaces de acción deEmptyState. - [ ] Tamaño del H1 inconsistente entre pantallas (
--op-font-lgen 5 vistas,--op-font-xlen 8; ninguna usa el H1 de v1.0,--op-font-2xl), y objetivos táctiles <44px aún en los botones de paginación deDataTable(28px), sus casillas de selección, elsummaryde "Columnas"/"Vistas guardadas" (36px) y los controles deAssistantDock(~30px). - [ ]
EmptyStatereimplementa.op-btncopiando los estilos deButton.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-controlsapuntando a nodos que solo existen cuando el panel está abierto (DataTable,/dashboard);role="list"con hijos sinlistitem; nombres accesibles sobre<span>/<div>genéricos (StatusChip,AppLayout); casillas deDataTablenombradas por posición ("Seleccionar fila 3") en vez de por registro; y el anuncioaria-livede 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 elAssistantDock(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. - [ ]
AssistantDockpromete 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/clearPersistedFavoritesexisten 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 lintno llega a ejecutar ESLint: el script esprettier --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 deHEAD. Correrprettier --writesobre 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 dedocs/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.