Saltar a contenido

ADR-058: Gate de soporte y formato para Salidas e Internas

Estado: Aceptado Fecha: 2026-10-29 Autores: Giampiero (mantenedor principal); épica «gate de formato», salda la deuda declarada en ADR-057 §5 Relacionados: ADR-057 (soporte y formato por tipo), ADR-025 (TRD versionada), ADR-053 (MCP), ADR-023 (preservación)


Contexto

ADR-057 hizo que cada tipo documental declare el soporte y los formatos en que se produce, pero dejó la radicación sin bloqueo: solo un derivado de lectura (conformidad_tipo). Esa decisión era correcta para las Entradas, que vienen de fuera y que la ventanilla no puede devolver por su extensión. No lo es para lo que la entidad produce (Salidas e Internas): el Ac. AGN 001/2024 art. 4.1.4 obliga a planear la producción en la TRD (y su parágrafo obliga a incorporar, desde la planeación y la producción, mecanismos que garanticen la fiabilidad, autenticidad, integridad, veracidad e inalterabilidad; verificar lo producido contra lo planeado es uno de esos mecanismos), y una TRD que dice «Oficio en PDF/A» mientras el sistema radica un .docx sin que nadie lo note es una planeación decorativa.

El evaluador heredado tenía cinco defectos que habrían convertido el gate en una fuente de falsos veredictos:

  1. pdfa_status = 'pendiente' (la validación asíncrona aún no termina) caía en «fuera de formato».
  2. Un código de formato fuera del catálogo se descartaba en silencio, de modo que un tipo «solo X» se leía como «no declarado» y pasaba.
  3. Un anexo sin MIME se saltaba (continue) y se colaba.
  4. El MIME evaluado era el que copiaba anexos.mime_type de AnexoInput.mime_type, enviado por el cliente.
  5. El derivado fechaba con registered_at.date() (UTC) y la radicación decide el día en la zona de radicación: entre las 19:00 y las 24:00 de Bogotá el derivado y el gate habrían mirado TRD de días distintos.

Decisión

1. Qué actos se gatean

Acto ¿Gate?
Radicar Salida/Interna (POST /documents, respuesta rápida, borrador E23) Sí, antes de la transacción del número
Lote Salida/Interna (POST /batch/documents) Sí, por ítem (sin anexos juzga solo el soporte)
Retipificar (PATCH /documents/{id} con doc_class distinto) Sí, con los anexos ya guardados y la TRD a la fecha del radicado
corregir_conservacion No bloquea, asienta el veredicto (corrige un hecho; bloquearlo obligaría a mantener un medio_conservacion falso)
Firmar / enviar No: el contenido ya es inmutable (no existe un acto que añada o cambie anexos tras radicar; un guardián lo mantiene)
Importación INT-05, Entradas, correo, ventanilla No: traslado de un registro histórico, o viene de fuera

El gate corre después de los gates existentes (tipo, metadatos, alcance de anexos) y antes de next_sequence: un rechazo no cuesta número, que no se devuelve (numeración única e inmutable: Ac. AGN 001/2024 art. 4.2.3). En E23 hay además una pre-verificación antes del render (se espera pdfa) y la definitiva en create con el formato que declaró el renderizador (borrador.documento_formato).

2. Un solo evaluador

app/core/conformidad_tipo.py (puro): evaluar_conformidad devuelve un Veredicto rico; el derivado de lectura y el gate pasan por ConformidadService (único módulo que lo importa; guardián G1). El MIME lo resuelve el servidor desde files.mime_type (storage): AnexoInput.mime_type se retira del contrato (Pydantic ignora el campo si un cliente viejo lo envía; sin rama de compatibilidad).

Storage olfatea el contenido al subir (app/core/contenido.py): %PDF-, PNG, JPEG, GIF, TIFF, ZIP (y dentro de él OOXML por [Content_Types].xml + word//xl//ppt/, ODF por la entrada mimetype) y OLE (Office 97-2003). Si el contenido es de otro formato que la lista admite, se guarda el detectado (no se rechaza: un adjunto de correo mal rotulado no se pierde y lo que queda registrado es lo que el fichero ES); si el declarado se puede comprobar y el contenido no lo cumple ni es otro formato admitido, se rechaza (415). Deuda declarada: text/plain, text/csv, message/rfc822 y application/xml no tienen firma que comprobar y se conservan como los declaró el cliente; con la lista abierta (ALLOWED_MIME_TYPES=*) storage no olfatea nada y registra el MIME declarado (la entidad que la abre renuncia a esta garantía); los OLE (doc/xls/ppt) se distinguen de lo demás pero no entre sí.

Lo que declara el renderizador se normaliza en un solo sitio (formato_producido_normalizado: minúsculas y solo alfanuméricos): storage emite PDF/A-2B (perfil.upper()) y la comparación inicial con startswith("pdfa") no casaba, de modo que todo E23 de una Salida/Interna se rechazaba tras renderizar. Las pruebas no lo vieron porque los unitarios usaban un valor inventado (pdfa-2b) y la integración de E23 solo persistía documento_formato sin pasar por el gate; ahora una prueba de integración corre create y el anexado con el literal real.

3. Casos límite

  • Más de un candidato (el code vive en varias series y el radicado guarda el código, no el tipo): se evalúa cada uno y bloquea solo si ninguno admite (veredicto firme para cualquier serie); si alguno admite, pasa con ambiguo_admitido.
  • No declarado (tipo suelto, migrada sin captura, instantánea previa a la 082): ese candidato admite (no_declarado); la omisión la cierra el 409 tipo_documental_sin_soporte al aprobar. Sin TRD vigente a la fecha: pasa (sin_trd_vigente).
  • Original en papel (medio_conservacion = fisico): se juzga solo el soporte; los anexos son reproducciones.
  • PDF/A (el tipo admite pdfa y no pdf): conformant conforma; non_conformant → 422; pendiente → 409 con Retry-After: 5; sin veredicto (NULL/not_evaluated) → 409 fail-closed. Con el validador apagado (por defecto) un tipo «solo PDF/A» no admite PDFs subidos a mano: el remedio es activar PRESERVATION_PDFA_VALIDATION_ENABLED con veraPDF o que la TRD declare también pdf. Si el tipo admite pdf, cualquier application/pdf conforma.
  • PDF/A producido por el sistema (E23): excepción explícita y acotada a ADR-057: un anexo producido cuyo formato empieza por pdfa cuenta como pdfa (y, siendo PDF, como pdf). La procedencia la identifica el servidor (borrador.documento_file_id), nunca tipo_anexo (lo manda el cliente).
  • Declaración ilegible (código fuera del catálogo, files inaccesible, error al leer la TRD): 503 fail-closed; pasar sería afirmar conformidad sin haberla leído. Un anexo sin MIME: 409 mime_desconocido. Un file_id ausente de files: el mismo 422 neutro anexo_no_disponible (sin oráculo).
  • El plan de preservación no se consulta: alimenta los avisos del Comité (ADR-057 §2.5) y cambia con el tiempo; el gate juzga contra la TRD convalidada.
  • Una sola versión por serie, y el catálogo no anula la TRD (ronda de auditoría 2; el desempate por versión fue reemplazado por ADR-059): si dos versiones de la misma serie cubren la fecha (acto retroactivo de la N+1 y N derogada con valid_to = hoy) rige la de vigencia mayor (fecha_convalidacion, luego version); un tipo suelto (sin serie) solo cuenta cuando no hay ningún candidato de TRD para el code, de modo que crear un suelto homónimo ya no esquiva el plan convalidado; si el único candidato es un suelto, el 422 dice «el catálogo de tipos», no «la TRD vigente». Al convalidar la N+1 sobre una migrada, archive congela la instantánea de la migrada antes de re-apuntar sus tipos, y el gate la sigue leyendo (antes quedaba en sin_trd_vigente y retipificar pasaba sin juicio).
  • Soporte del original en la respuesta rápida y en el lote: ambos aceptan medio_conservacion (defecto electronico); sin él un tipo planeado solo en papel no tenía salida desde la bandeja ni por lote.
  • Fecha: un solo helper, fecha_de_radicacion, en la zona de radicación (defecto 5).

4. Contrato

  • 422 formato_no_planeado_en_trd — definitivo: el cliente cambia el payload (anexo, soporte o tipo).
  • 409 formato_no_verificable — motivo ∈ {pdfa_pendiente, pdfa_sin_veredicto, mime_desconocido}; Retry-After: 5 solo con pdfa_pendiente (reintento idéntico).
  • 503 conformidad_no_verificable — motivo ∈ {codigo_fuera_de_catalogo, files_inaccesible, lectura_trd, anexo_ausente_de_files}.

Los tres traen conformidad {motivo, doc_class, fecha_trd, candidatos, medio_conservacion, soportes_admitidos, formatos_admitidos, anexos_rechazados[{file_id, mime_type, motivo}]}. El rechazo no deja asiento (no hubo acto ni mutación): lo probatorio es lo que pasa. document.radicado_created, document.radicado_actualizado (retipificar), el asiento del ítem del lote y document.conservacion_corregida llevan conformidad_formato {veredicto, fecha_trd, candidatos, formatos_admitidos, soportes_admitidos, candidatos_detalle[{trd_serie_id, version, origen (convalidada|migrada|suelto), resultado}], anexos[{file_id, mime, formato (código del catálogo), pdfa_status, pdfa_profile, origen}]} con veredicto ∈ {conforme, solo_soporte, ambiguo_admitido, no_declarado, sin_trd_vigente} (y en la corrección también no_conforme/indeterminado/ilegible). solo_soporte = pasó el soporte pero no se juzgó ningún formato (original en papel, o electrónica sin anexos, lote incluido): asentar conforme afirmaría un formato que nadie vio. En E23 el asiento de creación juzga el formato del primer render y el anexado deja document.documento_producido_adjuntado con el file_id real y el veredicto del formato FINAL (con diferidos el definitivo es el segundo render); no bloquea, el número ya se consumió. Sin oráculo: el 422 solo muestra el mime_type de un fichero que subió quien radica, que ya es anexo del radicado o cuando no hay actor humano; el de un fichero ajeno sin radicado va en null. La lectura del gate corre en un savepoint: un error de Postgres no deja abortada la transacción del acto (corregir_conservacion daba 500). El lote falla el ítem con error = <código>: <detalle>. Retry-After ya cruza el gateway; no hay endpoint nuevo ni «pre-validar» (el 422 lleva todo).

5. Frontend y MCP

  • /radicar, /borradores, /bandeja (respuesta) pintan un aviso persistente (RechazoFormato.svelte) con lo que la TRD planea y los anexos señalados (por file_id); con pdfa_pendiente ofrece «Reintentar» tras Retry-After. Los proxies BFF reenvían Retry-After. La UI no decide: muestra, de forma informativa, los formatos que declara el tipo elegido.
  • MCP: sin tools nuevas (censo en 0 huecos). Las descripciones de radicar_documento, tipificar_radicado, crear_respuesta_vinculada, radicar_borrador y del procedimiento despachar_respuesta explican los tres códigos y qué hacer (no se reintenta a ciegas).

Consecuencias

  • Sin migración y sin backfill: lo ya radicado no se reevalúa; solo las radicaciones nuevas, los lotes y las retipificaciones.
  • Con la configuración por defecto (validador PDF/A apagado), una TRD que declare solo pdfa rechazará con 409 los PDFs subidos por una persona. Es deliberado: el gate existe para que un PDF no pase por PDF/A sin que nadie lo haya verificado. Las semillas de la demo y del corpus declaran pdfa y pdf.
  • AnexoInput.mime_type desaparece; conformidad_tipo cambia dos semánticas: original en papel → anexos_fuera_de_formato = null y pendiente → null (no «fuera»).
  • Guardianes: G1 (un solo módulo importa el evaluador, nadie más traduce MIME a formato), G2 (todo punto que pide consecutivo llama antes a exigir), G3 (los anexos solo se insertan en tres sitios).

Deuda declarada

Lote 6 (2026-10-30) salda del olfateo: un OLE declarado sin firma OLE se rechaza (solo un .xls de texto pasa a text/csv si la lista lo admite) y un .docx/.xlsx/.odt rotulado application/zip se registra siempre como lo que es (415 si la entidad no lo admite); el nombre guardado lo fija el servidor (nombre_seguro). Siguen abiertos el filename/file_size de AnexoInput en document-service y el olfateo de text/*.

  • Olfateo (storage, app/core/contenido.py): el PDF se reconoce después de las firmas estructurales (ZIP/OLE/imágenes) y solo al inicio (BOM/espacios) o con basura binaria delante; un ZIP/HTML que lleva %PDF- en el primer KiB ya no es PDF. Un .docx rotulado application/zip se registra como docx si la lista lo admite. Un OLE declarado (xls/doc/ppt) sin firma OLE no se rechaza (CSV que Windows/Outlook rotulan xls, RTF guardado como .doc; un CSV se registra text/csv si la lista lo admite): se conserva lo declarado, deuda igual que text/*.
  • ~~Cambiar la vigencia de las versiones en archive (valid_to de la N = acto de la N+1, rechazar fecha_convalidacion futura)~~: saldado por ADR-059. El motor garantiza una sola versión por código y fecha, y el desempate por lectura descrito en «Una sola versión por serie» se retiró.
  • Olfateo del MIME de text/plain, text/csv, message/rfc822 y application/xml (sin firma que comprobar): el gate hereda su fidelidad de files.mime_type.
  • Un Radicado S/I electrónico sin ningún anexo no es cuestión de formato y el gate no lo juzga.
  • AnexoInput.filename/file_size siguen viniendo del cliente.
  • Que el radicado guarde el id del tipo (no solo el code) eliminaría la ambigüedad: cambio de modelo aparte.