ADR-023 — WORM / Object-Lock de preservación (MinIO) y hoja de ruta AIP/PREMIS¶
Estado: Aceptado (Increment A). Ratificado por orfeo-architect; auditado APTO por tenant-security-auditor y archival-compliance-auditor.
Fecha: 2026-07-06.
Contexto normativo: Ley 594/2000 (Ley General de Archivos), Acuerdo AGN 060/2001, ISO 14721 (OAIS), RFC 8493 (BagIt), PREMIS v3.
Contexto¶
E10 tenía plan de preservación + eventos PREMIS-lite funcionales, pero el empaquetado AIP era un stub textual en BD, el PREMIS un placeholder (namespace inventado, sin agent, sin XSD) y no existía inmutabilidad de infraestructura: el índice electrónico firmado (E15/E06) — el artefacto de mayor valor legal — se subía a MinIO por el flujo normal, con solo la inmutabilidad lógica de la aplicación. Un SGDEA conforme exige inalterabilidad garantizada de los documentos de archivo durante su período de retención.
miniopy-async (1.23.5, ya en uso) soporta Object-Lock completo, pero con una restricción dura: el object lock solo puede habilitarse al crear el bucket. Los buckets orpycamcp-{slug}-documents existentes no pueden convertirse retroactivamente.
Decisión¶
Se divide el trabajo en tres incrementos; este ADR cubre el Increment A y declara B y C como hoja de ruta (no deuda oculta).
- Increment A — Fundación WORM + índice firmado protegido (implementado): bucket de preservación WORM por tenant, protección del índice firmado bajo retención derivada de la TRD, y gate de disposición. Entrega la garantía de inmutabilidad sobre el artefacto crítico por sí solo.
- Increment B — AIP real + PREMIS v3 (siguiente):
build_bagitreal (bytes de MinIO, fixity recalculada, Bag RFC 8493 serializado zip), PREMIS v3 conforme (namespace oficial, object/event/agent, XSD con lxml), Bag subido al bucket WORM. - Increment C — diferido (roadmap): validación PDF/A con veraPDF (dependencia Java externa) + REPLICA (segunda copia geográfica).
Increment A — contrato¶
- Bucket WORM por tenant
orpycamcp-{slug}-preservation, conslugderivado exclusivamente del claim JWT validado, nunca del body (envenenar el WORM de otro tenant es irreversible). Provisioning idempotente: no existe →make_bucket(object_lock=True); existe con lock → OK; existe sin lock → error ruidoso (no se continúa con garantía falsa; el lock no se puede reactivar). - Retención por-objeto (
Retention(mode, retain_until)en cadaput_object), no retención default de bucket — cada expediente tiene una retención TRD distinta. La retención la aporta el caller (archive/workflow, dueños de la TRD); storage-service no invierte la dependencia llamando a archive.retain_untilse valida (presente, futuro, dentro de un pisoPRESERVATION_MIN_RETENTION_DAYSy un techo sensato); la derivación desderetencion_anios + fecha_iniciousa aritmética de años calendario (notimedeltaingenuo, para no desfasar por bisiestos). - Modo COMPLIANCE por defecto (inmutable de verdad, ni el root borra antes de expirar), configurable (
PRESERVATION_RETENTION_MODE) para test-safety; guard de arranque fail-closed: cualquierapp_envque no sea un entorno de no-producción reconocido se trata como producción y rechaza el arranque si el modo no es COMPLIANCE (unapp_envmal escrito no degrada la garantía en silencio). - Endpoint
POST /api/v1/preservacion/proteger-indice(gateadoUSUA_PERM_EXPEDIENTE,min_crud=3— umbral de disposición): verifica fixity (recalcula el SHA-256 sobre los bytes y lo contrasta con el hash registrado en la subida original — el llamador no declara ningún hash; detecta corrupción antes de fijar el objeto; mismatch → eventoFIJACIONfallo +audit_logfile.fixity_fallo+ no sube), sube al bucket WORM conRetention/legal_hold, registra enpreservacion_worm_objeto, y emite eventos PREMIS (FIJACION,WORM) +audit_log. - Gate de disposición:
purge_for_dispositionconsultapreservacion_worm_objetoy bloquea el borrado de cualquier objeto del expediente mientras haya una fila conretain_untilfuturo olegal_hold=true(WormLockActiveError+audit_log). - Legal hold para conservación total / expedientes en litigio (retención indefinida, independiente de
retain_until).
Como storage-service no tenía RBAC, se añadió app/core/clearance.py (twin de archive-service) y tenant_schema() en database.py; el gate usa el permiso ya sembrado USUA_PERM_EXPEDIENTE (sin sembrar permisos nuevos). Migración tenant 006_preservacion_worm.sql (tabla + índice), validada idempotente en Postgres 15.
Tensión WORM ↔ disposición TRD¶
El período WORM deriva de la retención TRD y la disposición final solo ocurre tras expirar. No es un conflicto: es exactamente lo que exige Ley 594/2000 + Acuerdo AGN 060/2001 — COMPLIANCE hace cumplir la ley. Como retención WORM y disparo de disposición derivan de la misma TRD, en el caso normal expiran juntos; el bloqueo del gate es la red de seguridad contra eliminación prematura (bug de derivación o disposición manual anticipada), no el camino feliz. legal_hold cubre conservación total y litigio.
Estrategia de test¶
Los tests de storage-service mockean el cliente MinIO por completo; ningún test escribe objetos COMPLIANCE reales (un objeto COMPLIANCE es genuinamente imborrable y rompería la repetibilidad de la suite). Se asertan las llamadas (make_bucket(object_lock=True), put_object con el Retention correcto, derivación de retain_until, nombre de bucket desde el claim, gate de purge, idempotencia del provisioning). El modo configurable existe precisamente para esta test-safety y para el guard de prod.
Consecuencias¶
Positivas: inmutabilidad de infraestructura real sobre el índice firmado; la fixity amarra preservación con firma; el gate impide eliminación prematura (conformidad legal); aislamiento WORM por tenant; primer RBAC en storage-service. Negativas / límites: COMPLIANCE es irreversible — un retain_until mal derivado deja objetos imborrables por años (mitigado: COMPLIANCE solo en prod, cotas de retención, derivación desde la TRD como única autoridad); versioning implícito cambia la semántica de delete (delete markers); AIP real + PREMIS v3 (Increment B) y veraPDF/REPLICA (Increment C) siguen pendientes. Frontera de confianza: la correspondencia expediente_id ↔ file_id la garantiza archive-service antes de llamar; storage valida que el file_id exista en el tenant pero no modela expedientes.
Addendum — Increment B (2026-07-06): AIP real (BagIt RFC 8493) + PREMIS v3¶
Estado: Aceptado. Auditado APTO por tenant-security-auditor (tras remediar 2 Alta + 5 Media) y archival-compliance-auditor.
Decisión¶
Convertir el AIP de un stub textual en BD en un paquete OAIS real: BagIt RFC 8493 serializado (ZIP) con los bytes reales de los documentos y fixity recalculada, PREMIS v3 (namespace oficial) validado estructuralmente, subido al bucket WORM del Increment A y cubierto por el gate de disposición.
- Mapeo documento→objeto:
AipDocInputganafile_id(localizador real enfiles);valor_huellase elimina (la fixity se recalcula, no se confía en la provista). - Bag real (
aip_builder.py): por cada documento se descargan los bytes de MinIO porobject_key, se recalcula el SHA-256 y se contrasta confiles.sha256— mismatch → aborta todo el empaquetado (409,FIJACIONfallo,audit_log), no empaqueta contenido corrupto. Se armadata/{nombre}+bagit.txt+bag-info.txt(Payload-Oxum real) +manifest-sha256.txt+premis.xml+indice-firmado.xml(opcional) +tagmanifest-sha256.txt(que cubremanifest-sha256.txt, RFC 8493 §2.2.1), serializado ZIP.aip_sha256pasa a ser el SHA-256 del ZIP completo. - PREMIS v3: namespace oficial
http://www.loc.gov/premis/v3, entidadesobject/event/agent, validado contra un perfil XSD local (no el schema completo de la Library of Congress — vendorizar el oficial + emitirxsi:type="premis:file"queda para Increment C; la conformidad se declara como validación estructural contra perfil v3, no "conforme pleno", RF-FIR-15). Se añadiólxmla storage-service (lockfile regenerado). - WORM + gate: el ZIP se sube al bucket de preservación con
Retention(derivada de la TRD, mismo contrato queproteger-indice) +legal_hold, y se registra enpreservacion_worm_objetocontipo='aip'→ el gate de disposición del Increment A también cubre el AIP. El registro (insert_aip+worm_repo.insert+ eventosINGESTA/FIJACION/WORMok +audit_log) va en una únicaconn.transaction()(atomicidad; la subida WORM, irreversible y externa a la BD, ocurre antes). Migración tenant007(aditiva/nullable, validada idempotente en PG15). - Endpoint
POST /api/v1/preservacion/aipgateadoUSUA_PERM_EXPEDIENTEmin_crud=3; todas las lecturas/escrituras del router de preservación quedan gateadas de forma consistente (GET /aip,GET /plan,GET /plan/versions,list_eventosconmin_crud=1;PUT /plan,POST /eventosconmin_crud=3) — endurecimiento de una fuga de metadatos (GET exponía nombres/hashes/ubicación WORM de documentos clasificados) y de escrituras ungated a la traza PREMIS.
Endurecimientos de la auditoría (remediados)¶
Alta: (1) colisión de nombres de payload — dos documentos con igual nombre se sobrescribían en el ZIP mientras el manifest contaba ambos → Bag inválido y pérdida silenciosa de contenido, irreparable bajo WORM COMPLIANCE; cerrado con saneo de nombre (rechaza /, \, .., control chars) + unicidad (422) + Payload-Oxum/num_files derivados de las entradas reales; (2) GET /aip sin RBAC → fuga del inventario de documentos; gateado. Media: tagmanifest que no cubría el manifest; sobre-declaración de conformidad PREMIS; AipResponse que rompía la retrocompat con filas stub (campos WORM → Optional); rastro de eventos incompleto; no-atomicidad del registro.
Límites (Increment C, diferido en el momento de B)¶
Validación PDF/A con veraPDF (dependencia Java externa) + evento VALIDACION_PDFA; REPLICA (segunda copia); XSD PREMIS oficial completo + xsi:type (→ cerrado en C1a). 76 tests verdes (MinIO 100% mockeado).
Addendum — Increment C1a (2026-07-06): PREMIS v3 conformidad plena¶
Estado: Aceptado. Auditado APTO por archival-compliance-auditor.
Cierra el gap de honestidad de B: el AIP validaba su premis.xml contra un perfil XSD local propio (validar contra un schema que escribimos nosotros no prueba conformidad). C1a lo hace conforme pleno: valida contra el premis.xsd OFICIAL de la Library of Congress (vendorizado en services/storage-service/schemas/premis-v3-loc.xsd, 52.845 B, targetNamespace=http://www.loc.gov/premis/v3, autocontenido — la referencia a xlink fue eliminada en PREMIS v3).
- El elemento
<object>emite ahoraxsi:type="premis:file"(mecanismo oficial de tipado por sustitución:objectComplexTypeesabstractyfile/representation/bitstreamlo extienden) y se elimina el elemento<objectCategory>(era de PREMIS v2, no existe en v3). Elnsmapdel root gana los prefijospremisyxsi._SCHEMA_PATHreapunta al XSD oficial; el perfil localpremis-v3.xsdse elimina (git rm). - Criterio binario de "conforme pleno" (ratificado): el
premis.xmlemitido (object file+eventingestion/fixity check +agent) pasaassertValidcontra el schema normativo. La ausencia derights/environment/relationship/significantProperties/preservationLeveles conforme (opcionales en el oficial;object filesolo exigeobjectIdentifier+objectCharacteristics) — no un gap. Notable: cero contenido semántico nuevo obligatorio — la estructura de B ya era conforme salvo el tipado delobject. - Sin migración, sin cambios de endpoint/RBAC/acceso a datos — puro cambio de emisión XML + XSD vendorizado. 79 tests verdes (incluye un test que valida contra el XSD oficial y uno negativo que confirma que
assertValidrechaza estructura inválida). Deuda de roadmap (no incumplimiento):formatNamelleva el MIME sinformatRegistry/PRONOM; vocabularios controlados y entidades opcionales (rights, preservationLevel) quedan como refinamiento futuro. REPLICA (C1b) y veraPDF/PDF-A (C2) siguen pendientes.
Addendum — Increment C1b (2026-07-06): REPLICA del AIP¶
Estado: Aceptado. Auditado APTO por tenant-security-auditor y archival-compliance-auditor.
Segunda copia inmutable del AIP para durabilidad de preservación (OAIS multiple copies, preservacion_plan.num_copias).
- Disparo por
num_copias(copias totales = 1 primaria + N réplicas):num_copias<=1(o sin plan) → cero réplicas (respeta el plan de copia única);>=2→ una réplica en C1b. Sinum_copias>2con solo target local, el déficit se registra honestamente en el evento (copias_solicitadasvscopias_hechas); el fan-out multi-réplica es roadmap. - Target configurable (
app/services/aip_replica_service.py::replicate_aip): default local — segundo bucket del mismo MinIOorpycamcp-{slug}-preservation-replica(nombre solo del claim JWT), provisionado conobject_lock=Trueycopy_objectserver-side con la mismaRetention/legal_holdque el primario (réplica tan inmutable como el original); siMINIO_SECONDARY_ENDPOINTestá configurado → cross-site a un segundo MinIO (segundo cliente +put_object). Feature-flagPRESERVATION_REPLICA_ENABLED(defaultfalse). - Best-effort NON-fatal (crítico): la réplica se intenta después de subir el AIP primario a WORM y antes de la transacción crítica;
replicate_aipnunca propaga excepciones — un fallo de I/O de MinIO se captura y se refleja comoReplicaResult(ok=False), de modo que un fallo de durabilidad opcional jamás revierte el AIP primario ya sellado. El resultado se registra comopreservacion_eventotipoREPLICA(resultado=ok|fallo) dentro de la tx — único registro: no se registra enpreservacion_worm_objeto(evitar migrar elCHECKy no contaminar el gate de disposición; la inmutabilidad la da el object-lock del bucket réplica) ni se retrofitea elpremis.xmlsellado (la réplica del objeto-AIP es un evento de repositorio, no de contenido). - Honestidad (RF-FIR-15): la réplica local (mismo MinIO) se etiqueta explícitamente como "réplica local (segundo bucket), no cross-site" en la respuesta (
replicado/replica_target/replica_copias_hechas) y eldetaildel evento; la cross-site real queda como capacidad de prod activable por config. Sin migración. 87 tests verdes (incluye el fallo de réplica best-effort no-fatal con el AIP primario intacto, y bucket réplica del claim vs body). - Baja aceptadas (roadmap/edge, no bloqueantes): sin re-verificación de fixity post-réplica en el camino cross-site;
str(exc)crudo de MinIO en eldetail/respuesta (acotar); sin validación de queMINIO_SECONDARY_ENDPOINTdifiera del primario (réplica ilusoria si coinciden); límite de 5 GiB decopy_objectserver-side (usarcompose_objectpara AIP grandes).
Addendum — Increment C2a (2026-07-06): validación PDF/A — contrato + honestidad (sin veraPDF real)¶
Estado: Aceptado. Auditado APTO por archival-compliance-auditor y tenant-security-auditor.
Cablea el paso de validación PDF/A del AIP (OAIS, validación en ingesta) de forma honesta, dejando la interfaz lista para que C2b enchufe veraPDF real. No existe un validador PDF/A puro-Python maduro (veraPDF es Java) y no se bloatea la imagen slim de dev con una JVM para una feature off-by-default — el validador real y su imagen de deploy son C2b.
- Interfaz pluggable (
app/services/pdfa/):PdfaValidator(Protocol,async validate(pdf_path, *, profile) -> PdfaResult),StubPdfaValidator(default, honesto: siempreoutcome="not_evaluated",validator_agent="stub/validator_unavailable"— no finge), factory que siPDFA_VALIDATOR=verapdfpero el binario no está degrada a stub con warning (no crashea), yrun_pdfa_validation(materializa a tempfile, nunca lanza). - Migración
008— habilita la honestidad:preservacion_evento.resultadoeraCHECK IN ('ok','fallo'); el honestono_evaluadono cabía (forzarlo aokimplica conformidad, afallono-conformidad — ambos mienten). La migración amplía la columna aVARCHAR(16)y el CHECK a('ok','fallo','no_evaluado')(idempotente, validada en Postgres 15). Sin columnas nuevas (detalleJSONB +agentecubren perfil/versión/outcome). - Encaje (
empaquetar_aip): detecta PDFs por magic bytes%PDF(no pormime_type, que lo declara el cliente), corre la validación fuera deconn.transaction()(un subprocess JVM futuro no puede correr con la tx abierta ni hacer rollback del AIP), y emite unpreservacion_eventoVALIDACION_PDFApor documento (resultadook|fallo|no_evaluado) + un evento PREMISvalidation(eventOutcomepassed|failed|"not evaluated") + un software Agent por validador — todo sigue validando contra el XSD oficial (premis-v3-loc.xsd, C1a) sin tocarlo. - Advisory por defecto (ratificado): un PDF válido no-PDF/A no bloquea el AIP (el documento ya está radicado con número inmutable; el remedio archivístico es migrar el formato, no rechazar). No hay modo estricto en el empaquetado.
PDFA_PROFILE(default2b) es política declarada, no implícita. Honestidad (RF-FIR-15):"not evaluated" ≠ "passed"; el wording deja claro que la validación no se realizó (validador no disponible). 95 tests verdes; veraPDF real (subprocess endurecido + imagen de deploy + CI) = C2b, roadmap. Baja aceptadas:%PDFexigido en offset 0;pdfa_profileno cotejado conpreservacion_plan.formatos_destino;object_ref=file_iden el evento vsdocument_iden el PREMIS (cross-ref).