Skip to content

Roadmap

Updated as of 2026-10-03.

OrpycaMCP has followed two complementary plans:

  1. Foundational roadmap (Phases 1–6, below "Scaffolding"): stood up the 8 microservices with their basic CRUD, infrastructure and CI/CD. Complete.
  2. specDrive plan (Phases F1–F6, 21 epics, 138 RF): raises the scaffolding to SGDEA conformance (Ley 594/2000 — Colombia's General Archives Law; Acuerdos AGN 001/2024, 042/2002, 003/2015 — General Archive of the Nation agreements; ISO 14721/16363). It is the current plan and its status is detailed first.

Glossary: SGDEA (Sistema de Gestión de Documentos Electrónicos de Archivo — electronic records management system); RF (requisito funcional — functional requirement).


specDrive plan — SGDEA conformance (current)

Global status: the core of all 21 epics is implemented, tested in Docker and with migrations validated against real PostgreSQL/pgvector. The current ADRs are listed in the architecture decisions index (the count is not repeated here by hand: it goes stale). The frontend had 1804 Vitest tests across 181 files as of 2026-08-28; npx vitest run gives the current figure.

And, since August 2026, tested from the browser as well. Until then the project's guarantee was one of unit and contract, not of use: the 36 application screens had zero end-to-end coverage. The full functional review (blocks B0–B14) walked them performing real acts against a demo tenant — including the irreversible ones: annul, close an expediente, transfer, derogate a TRD version — and found defects in every block that wrote. All fixed, each with a test verified by mutation. The closing proof was re-verifying the audit_log hash chain over everything the review generated: the Ley 594 integrity guarantee is proven with data, not with mocks.

Phase Epics Status
F1 — Foundations common library (ADR-010), per-tenant migrations (ADR-012), E14 administration, E08 security (RBAC + audit + URD + classification), E01 radicación, E03 metadata, E07 attachments, E16 notifications, E05 flows ✅
F2 — Query E09 advanced search + reports + expediente search, E13 public query by verification code, E19 IMAP email ingestion ✅
F3 — Lifecycle E04 TRD/CCD (hierarchy + two-phase retention + AGN disposition), E02 expedientes (closure/transfer/foliation), E06 electronic signature (hash+identity + XAdES-B of the index, Inc. 1) ✅
F4 — Conformance E15 electronic index (append-only versioned XML + per-document fingerprint + verification) + XAdES-B/T/LT/LTA signing of the index on closure, with an institutional seal + timestamp + long-term validation material (local dev CA + CRL + stapled OCSP per RFC 6960, not accredited) (E06 Inc. 1/4/5/7, ADR-016) + RT-15 "fully conformant" v4 profile (stable documentary order + traceable exclusion + producing dependencia + declaration date + access policy + attested audit trail, prospective applicability, ADR-022) ✅ core
F5 — Preservation / physical E17 physical archive (locations, units, shelf-mark, loans, history, FUID), E12 primary/secondary transfers, E11 interoperability (signed webhooks), E10 preservation (plan + PREMIS events + WORM/Object-Lock of the signed index, ADR-023) ✅ core
F6 — Intelligence E18 mcp-server (tool catalog over the gateway) + conversational assistant, E21 knowledge-service (pgvector + semantic retrieval with ACL pre-filtering + real RAG) ✅ core

specDrive status by epic (and what is still pending)

Current location of the radicado (2026-10-02, ADR-029): backend ✅, frontend ✅. "It should always show where the radicado was located" (parity with Orfeo's radi_depe_actu): workflow-service writes flujo_dept/flujo_dept_code/flujo_usuario in the same UPDATE as the status, ubicacion_* are generated columns, and GET /documents and /search filter by ?ubicacion_dept_code=. Migrations 037 (document) and 016 (workflow, backfill with evidence), the "Current location" reports, informational INT-01 export and MCP: ✅. Frontend ✅ (/bandeja, /busqueda with filter, /reportes, main panel and radicado picker). Pending: flow_steps.to_dept_code (closes the non-unique dependencia name case at the root).

Domain decision resolved (2026-10-02, ADR-028): the workflow moves the radicado's status. Radicado status and step progress used to be two machines that did not talk to each other (open decision of 2026-10-01). Now workflow-service writes radicados.status in the same transaction as the step: distributing, transferring, returning, rolling back and reassigning leave distributed; the holder's marcar_leido leaves in_progress; and only "Finish processing" (POST /workflows/{id}/finalizar) closes (closed). closed and anulado are terminal (409 radicado_terminal) and the radicado PATCH rejects status (422). Backend, MCP (finalizar_tramite), migrations (backfill 013) and real-Postgres tests: ✅. Pending: the frontend ("Finish processing" button, removing the manual "Distribute"/"In progress" from the List view, translating the 409). Declared out of scope, on purpose: outgoing and internal radicados have no outward distribution (their status only moves when a step touches them); rollback only from pending; completing a step without marking it as read leaves distributed; completing an in_progress step leaves in_progress; the "reopen" act (MoReq: controlled reopening with admin permission) is left for its own increment. Audit remediation (2026-10-02) ✅ on the backend: assign/transfer require clearance and holding the step, finalizar requires level 3 and motivo_cierre, annulment cancels the step (and migration 015 cleans up zombies), import rejects anulado (+ migration 034 CHECK), the backfill leaves immutable evidence with an anchor entry. See the ADR (R1-R14).

Every item below is delivered unless explicitly marked as pending. Scope statements are deliberately honest (RF-FIR-15): where something is verifiable but not accredited, it says so.

E18 — MCP layer and conversational assistant

  • Official MCP SDK binding ✅ (stdio / HTTP-streamable, initialize/capabilities), plus resources, prompts and per-session OAuth.
  • Conversational assistant — end-to-end backend ✅ (ADR-019/ADR-020): POST /api/v1/assistant/message (routed by the gateway → mcp-server) drives a Claude LLM loop that runs the catalogue's tools as gateway calls re-propagating the user's token (as-the-user; every tool revalidates RBAC/clearance). Prompt-injection boundary (tool content is data, never instructions), RF-SEG-08 non-oracle responses, ephemeral history isolated per tenant, and honest degradation (503 assistant_unavailable without ANTHROPIC_API_KEY or on SDK failure — never a simulated answer, RF-FIR-15).
  • Connected to E21 ✅: the buscar_conocimiento tool (POST /api/v1/knowledge/search via the gateway, read-only) performs semantic retrieval as-the-user with server-side ACL; results are labelled as hints from a derived index, to be confirmed against the authoritative source (ADR-006).
  • Catalogue by capability ✅: from 9 tools to 206, 74 of them writes (as of 2026-09-25), covering 203 of the 342 operations the gateway exposes — 59 %; the per-workflow breakdown is in API by workflow (Spanish only). Writes are never dispatched: they return a signed proposal (HMAC-SHA256) that the person confirms, 26 of them declared irreversible and 5 requiring a value typed from the card itself. 15 acts stay out by decision, each published with its reason and its alternative route rather than silently missing.
  • Deferred: SSE streaming; persistent history (today in-process memory, not multi-replica → move to Redis).

E21 — Knowledge layer (knowledge-service)

  • Event-driven ingestion ✅ MVP (ADR-006/010/021): the pgvector index populates itself by consuming orpycamcp.document.events (its own consumer group + DLQ) — it embeds the radicado's subject/sender with a fail-closed ACL replicated from the event, guards against sending reserved material to an external provider, and keeps level consistency under at-least-once delivery in any order (tombstone/sentinel + GREATEST; a created event never lowers a level). Purge-on-annulment ✅ (document.radicado.annulled → chunk permanently voided, excluded from search at any clearance; a late created never re-indexes an annulled radicado).
  • Real embedding provider ✅ Inc. 1: a pluggable EmbeddingProvider (local_st multilingual sentence-transformers, local/sovereign, the deployment default; deterministic stub, forced default in tests; minimal ollama, deferred) replaces the stub as the only provider — EMBEDDING_DIM (384) is the single source of knowledge_chunk.embedding's dimension, with a fail-closed startup guard, migration 007 (nulls the incompatible dim-64 vectors + vector(384) + HNSW index) and a backfill job (python -m app.ops.reembed). is_embeddable generalized to LOCAL_EMBEDDING_PROVIDERS: with local_st, classified material is embedded (locally, it never leaves) — unlike with an external provider. Declared residuals: R1 (content already sent to an external provider before this increment), R6 (reclassified/annulled events are best-effort → lag in the derived layer).
  • Grounded retrieval with citations ✅ Inc. 2 (Pattern A, no LLM): POST /knowledge/antecedentes (query XOR radicado_ref) reuses search (same ACL pre-filter), with an anti-oracle 404 collapse on the pivot (it does not distinguish "does not exist" from "exceeds clearance").
  • Generative RAG with citations ✅ Inc. 3 (Pattern B): a pluggable LLMProvider (ollama_local sovereign default, minimal-viable external ollama_cloud/openai_compatible, anthropic with the native Citations API, disabled → Pattern A) behind POST /knowledge/rag. Hard sovereignty barrier for GENERATION (decision taken 2026-07-20, reconciled in ADR-006): is_generatable excludes chunks with nivel_seguridad >= RESERVADA from the context when the provider is external — fail-closed, with no override, unlike the ADR's original soft model. kb_suggestions table (migration 008) + an audit_log entry. Honest degradation with 503 rag_unavailable (RF-FIR-15).
  • ~~Endpoint for human acceptance of suggestions (CC-02)~~ ✅ T10 (POST /knowledge/suggestions/{id}/aceptar). Pending: attachment events (expediente ones do not apply: its level cannot change after creation), streaming of the generated answer.

E10 — Digital preservation (WORM, AIP, PDF/A)

  • RF-PRE-04 — format migration against obsolescence ✅ (2026-08-29): POST /preservacion/migrar-formato with Ghostscript in the production image. The original is never touched (a derivative is produced), fixity is verified before and after, and the derivative goes through veraPDF — non-conformant is discarded, and with no validator it stays no_verificado and is not promoted either. There is no "stub migrator": a stub would have to invent the file, so with no converter the endpoint answers 503. The PREMIS MIGRACION event type stops being one with no writer. Declared pending: the periodic job deciding what to migrate — it needs a per-format obsolescence policy that has not been declared, and it is not invented.

  • Real Object-Lock/WORM on MinIO ✅ Increment A (ADR-023: per-tenant preservation bucket, signed index under Retention(COMPLIANCE) derived from the TRD, disposition gate, legal hold).

  • Real AIP + PREMIS v3 ✅ Increment B (BagIt RFC 8493 with real bytes + recomputed fixity, uploaded to the WORM bucket and covered by the disposition gate), full PREMIS v3 conformance ✅ Increment C1a (validated against the LoC's official vendored premis.xsd), and a best-effort AIP replica ✅ Increment C1b (one immutable WORM copy to a configurable target — by default a second bucket on the same MinIO, honestly labelled "local replica, not cross-site"; cross-site via MINIO_SECONDARY_* — triggered by num_copias≥2, non-fatal).
  • tenant-service without superuser and with audit ✅ (2026-10, T7): runs as the application role (POST /tenants no longer runs DDL; provisioning is init_tenant.py), atomic audit for institution create/status change, 409 when renaming/deactivating a department with live cases, antes/despues on parameter changes. Pending: verify with the real orpycamcp_app role (local stack runs as owner by user decision) and the ADR to store codigo instead of nombre.
  • E06 — second factor consistent with the actor, per-entity keys and honest verification ✅ (2026-10, audit 2026-10-02, T8): the OTP, TOTP and personal credential are checked against the same user the signature is attributed to (403 identidad_incoherente + audit entry), email does not lower the factor of someone who already has a TOTP on level ≥ 2 (409 totp_requerido), the fingerprint of radicado/expediente chains is not chosen by the client, the native HMAC and the OTP HMAC use per-entity, per-purpose HKDF keys (migration 020, key_version; scheme 0 was retired in B9, migration 024: earlier rows no longer verify and /verify says esquema_hmac_retirado), the credential state is resolved outside the row lock, /verify applies no-read-up and is no longer called "public", acreditado comes from what was persisted, a revocation after the signature does not invalidate it and the timestamp is cross-checked with the signing certificate and the ESSCertID; and a defect was fixed where no native /sign-xml signature ever verified. Closed afterwards (2026-10-04, user decisions): the XAdES policy became an entity parameter (firma_personal_require_xades, tenant-service migration 010, editable in /admin/parametros; the env is only a fallback); ESSCertID is required from a cut-off date (TSA_ESSCERTID_CORTE, default 2026-10-04) and earlier stamps stay valid with advertencias[] in /verify; a chain over radicado/expediente signs the content (current PDF / current index, resolved by an internal endpoint in document/archive) and detects when it changed (409 documento_modificado); and firmar/rechazar/vobo/challenge check membership before state (an outsider cannot probe the state). Pending: HSM/KMS — pending a provider; not implemented. The hook exists in services/auth-service/app/core/signing_keys.py::sign_digest (the single point a PKCS#11/KMS provider would replace) and in services/signature-service/app/providers/base.py (institutional-seal provider); enabling the clasificados gate in each entity is its own decision and requires enrolling certificates first.
  • Seal and WORM are system-only ✅ (2026-10, audit of 2026-10-02, T1): /signature/sign-xml requires X-Internal-Token for the institutional seal and objeto_tipo is a closed set (since B9-3 the seal only: human signing of radicado/expediente/draft goes through the chain exclusively); storage seals /aip, /proteger-indice and /proteger-artefacto behind the internal token, the gateway routes /preservacion/ through an allow-list, a second protection with another file_id answers 409, and legal_hold leaves the body and is governed by legal-hold-internal (motive + atomic audit entry). Pending: an archive user endpoint orchestrating the legal hold (permission TBD) and raising PERM_FIRMA's min_crud.
  • No-read-up on third parties, drafts and the assistant; declarative signature without a client hash ✅ (2026-10, audit of 2026-10-02, T3): /terceros/{id}/radicados and /respuestas declare and honour nivel_seguridad; drafts inherit the parent level (migration 035) and are gated in the WHERE; draft PATCH is audited; the declarative signature requires level 2, resolves the hash server-side and audits atomically. Guardian test_contenido_declara_su_nivel. Pending a decision: retire the signature POST with 410; ownership when editing a draft; nivel_seguridad on SearchHit/RadicadoMetaItem.
  • Metadata: declared debt (2026-10, B6, correction round): (1) a document type without an active template stores metadata unvalidated (templates are opt-in per type): "minimum metadata per type" is not guaranteed until each series declares one; (2) the frontend labels (SchemaValue) use the active template rather than the version the record was saved with (metadata_template_id), so after a template is versioned an old record may be labelled with fields it never had. Both pending a decision; the backend already validates the type catalogue, reserved keys and the Salida origin.
  • Block B10 ✅ (2026-10-17, ADR-034 §5, ADR-035 §7, ADR-036): (1) document.envio.* events through the outbox —the manual return too— and an email notice of the return to the producing department and to whoever registered it, with clearance in the WHERE per recipient; (2) signature consumes documento.contenido_cambiado and voids only the circuits over a text that is no longer current; (3) knowledge gates with the radicado's current level (GREATEST in the WHERE) and reconciles the fragment level hourly; (4) a suspended entity's postal callback → 202 deferred, applied on reactivation; (5) signed identity bound to method and path; (6) the personal signature no longer spends the TOTP on a signature that does not verify (prior rehearsal, certificate fingerprint, verification inside the TOTP transaction); (7) XAdES-LTA ArchiveTimeStamp renewal with WORM inheritance and PREMIS RENOVACION, and the AIP embeds the current XML; (8) the audit-chain job test narrowed with --slug. Open: repackaging AIPs already packaged when the seal is renewed; the residue of the TOTP spent in the final self-verification after remote_sign (recorded and reported); a consumer for firma.lta_renovada; the signed identity's 120 s window is not a nonce (a capture works for the same operation within it); the TSA's timestamp is only checked against a CRL (TSA OCSP not supported); AIP packaging for a suspended entity waits for its reactivation (document-service vetoes the attachments; preserving the retention does run); a query of archive seals about to expire, expired or without TSA revocation (today only firma.lta_* entries and the log; firma_sello_archivo.estado/ultimo_aviso have no endpoint); /verify does not evaluate the TimeStampValidationData; radicados/auth_users unqualified with public in the search_path (SEG #12). Audit corrections (2026-10-17): 404 route_not_found instead of 500 for paths that do not stabilise; signed ocurrido_at in the postal callback (operator's notice ≠ date of the event), 413 also with an active entity and a deferred queue capped under a lock, hash check and discard after 10 attempts; return notice also to the current holder; /antecedentes pivot gated in the WHERE; LTA judged at the current date (lta_vigente), no renewal over an expired TSA and with the previous TSA's CRL; retention cascading to renewals; preservation exception for the suspended entity (ADR-035 §8).
  • Entity state at the edge and MCP channel bound to the token ✅ (2026-10-16, B9, ADR-035): /auth/validate reports tenant_estado and the api-gateway vetoes (403 tenant_inactivo, 503 tenant_no_provisionado/tenant_estado_no_disponible, fail-closed; a suspension takes effect within ≤ 15 s, the validation-cache TTL); the six get_tenant_conn re-check it before the search_path (a missing schema is 503, not 500); public routes (/public/*, CMIS, OAI, archive disposal, postal callback) answer the same 404 for a non-existent, deactivated or unprovisioned entity, and a suspended entity keeps its public reads (verification, OAI, CMIS, disposal publicity) while its writes answer 503 canal_no_disponible (closure of the archival audit: the objection period of the inventory ran with the inventory invisible); archive, workflow and storage check the state in get_db_conn; the signed identity carries X-Identity-Ts and expires after 120 s; login and refresh issue no token to a non-operational entity and the mcp-server propagates the entity code; derive_canal uses the token's tenant, not the client header. Pending: the frontend must show «Entidad suspendida» on 403 tenant_inactivo at login.
  • Production entry, fail-closed restore and /challenge = /sign ✅ (2026-10-11, B7, ADR-033): containerised nginx (edge) as the only service with ports, Keycloak published through an allow-list (only /realms/orpycamcp/ and /resources/; everything else 404), /health/ready only from edge itself, TRUSTED_PROXIES=172.20.128.0/17 without the network's gateway, third-party images by tag and digest, a complete .env.example; restore.sh fails closed on any pg_restore error other than the inherited PKs and checks each protected bucket's Object-Lock before touching data; backup.sh requires the bucket of every tenant not decommissioned (active or suspended) in the mirror; restore.sh requires RESTORE_OPERADOR, runs the Object-Lock pre-flight before stopping anything, only tolerates pg_restore with exit code 1 and N ignored == inherited PKs, and its record names the operator and the lost-trail window; the gateway refuses to start in production if TRUSTED_PROXIES covers the network's gateway; POST /challenge applies the same preconditions as /sign (personal credential, current fingerprint, visto bueno, annulled) and /auth/me for an entity without a schema is 200 with empty permissions. Pending: a real nginx -t (it also confirms the listen [::]) and bringing edge up on a server; **(SEG-M3 closed in B9, ADR-035); upgrading Keycloak 24.0.5 (out of support); (the get_tenant_conn 500 for a tenant without a schema and /auth/me of a suspended tenant were closed in B9, ADR-035); whisper (dictation) outside the production manifest in v1.
  • XAdES signature conformant to XMLDSig and verifiable by EU DSS ✅ (2026-10-11, B7, ADR-034): explicit exc-c14n Transforms on the references signxml leaves without them, SignedInfo signed after the patch (seal and personal signature), fail-closed self-verification with a default XAdESVerifier(), no shortcut when verifying, and ArchiveTimeStamp input per EN 319 132-1 §5.5.2 (XAdES-LTA is honest again); no signing with an expired certificate (seal: 503 sello_institucional_vencido; personal: 422 certificado_personal_vencido before the TOTP is consumed); /verify of an LTA whose certificate expired after signing judges it at the SignatureTimeStamp date, with a warning; time stamps are verified with the canonicalization they declare. Validated live: SignatureIntact and every DataIntact, INDETERMINATE/NO_CERTIFICATE_CHAIN_FOUND because of the development CA. Pending: trust (chain to a trusted list / ONAC), bringing the DSS smoke into CI, renewing the ArchiveTimeStamp (EN 319 132-1 §5.5.3), re-reading nivel_seguridad under lock when signing (SEG-B4) and self-verifying the personal signature before the TOTP is consumed (today the factor is already consumed when it fails).
  • Numbering integrity, shipments and import ✅ (2026-10, audit of 2026-10-02, T4): sequence taken inside the radicado transaction; import neither moves the counter with another entity's numbers nor overflows it; shipments at level 3 with an audited actor and a previous-state UPDATE; import/export at level 3, threaded and one at a time; the batch honours the metadata template and local year; per-shipment postal waybill. Closed on 2026-10-02: orphan-blob cleanup after a rolled-back import (safe purge in storage + queue and sweep) and batch resume/reprocess/retention (completado_con_errores, POST /batch/documents/{id}/reprocesar); editing a draft now requires holding the radicado. Pending: async import/export job, dedicated dispatch permission, batch-errors UI («Process the missing ones»).
  • Separation of duties in elimination and server-resolved evidentiary data ✅ (2026-10, audit of 2026-10-02, T6): avalar and ejecutar on an elimination batch require USUA_PERM_TRD level 4, and whoever proposes does not endorse nor does whoever endorses execute (409 separacion_de_funciones, in the transition's WHERE and before the first deletion); publish/endorse/withdraw/execute atomic with their audit entry; public batch GET with no institution-enumeration oracle; the record's WORM with scheduled retry + its own sweep and required before destroying (409 acta_sin_worm); radicado→expediente linking resolves number, typology, producing dependency, format, size and hash server-side and rejects mismatching declarations (404/422/503 fail-closed); loan and index actor = UUID, not login (new guardian); physical-archive atomicity tests against real Postgres. Open: AREA-01-13 (loan to a prestado_a_user_id with a clearance gate in the INSERT: changes the contract and needs archival-compliance-auditor validation), propagating a later re-typing to the index (document.radicado.retyped event), and batch.py still accepting client-supplied number and dependency.
  • Shared DLQ and Redis Streams consumers ✅ (2026-10, audit of 2026-10-02, T9): evento_dead_letter primary key (origin_group, event_id) (the second group's parking is no longer lost) with an idempotent migration in the 4 services and per-group ON CONFLICT; knowledge gains the admin surface for its DLQ (/api/v1/knowledge/admin/deadletter, atomic replay of purged/annulled); per-replica events.consumer_name(); orphaned dead-letters keep last_error; X-Truncated without false positives; /health/ready with dead_letter and last sweep. Pending: Prometheus metrics/alerts (operations decision) and clearance in the WHERE + keyset pagination for the listing.
  • DLQ clearance gate inside the WHERE (AREA-10-08) ✅ (2026-10): single source orpycamcp_common.deadletter, exact pagination and count with no 1,000 cap, indistinguishable 404, X-Truncated retired; knowledge gains terminal-DLQ retention and an occurred_at second net. Open (user decision): a purged or identifier-less radicado stays level 3.
  • E13/E20 — window authorization record and shipment receipt (batch B2) ✅ (2026-10, ADR-031): the authorization record's IP (Law 1581) is signed by the gateway with its own header pair and document-service never reads X-Forwarded-For (record v2, ip_verificada); the processing policy belongs to each entity (3 parameters, tenant migration 012), is served by GET /ventanilla/politica with a strict read (503 politica_no_verificable) and re-read when filing (409 politica_desactualizada/politica_no_publicada, before spending a sequence number); a shipment's receipt must have been uploaded by whoever records it (403 acuse_ajeno) and not already be something else (409 acuse_ya_referenciado, unique index 045). Erasure-versus-retention text in normativa.md and a policy template. Pending: each real entity drafts and publishes its own.
  • E18/E21 — assistant channel, residency and suggestion acceptance ✅ (2026-10, audit of 2026-10-02, T10): what the assistant/MCP confirms is now recorded with canal=mcp (signed X-Origen-Canal mark bound to tenant and token, verified by the gateway; audit.append inherits the request's channel) and carries payload.correlacion (request_id + the proposal jti); POST /assistant/message returns no_verificados (radicados in the text that no result returned); the mcp-server forwards the signed real IP (assistant calls no longer share their container's per-IP bucket) and measures each turn's cost; ollama_local is sovereign only if OLLAMA_URL is local (with a public host RAG excludes level >= 2); POST /knowledge/suggestions/{id}/aceptar (CC-02, only whoever generated it, atomic with its entry, migration 012); the false "per-tenant opt-in" promise withdrawn. Pending: per-entity enablement switch (product decision: its default would stop today's ingestion), the remaining services picking up channel mcp when rebuilt, and RAG streaming. Expediente reclassification does not exist, so there is nothing to propagate to the index (guardian in shared/tests).
  • Events and webhooks leaving the entity; inbox and review chain ✅ (2026-10, audit of 2026-10-02, T5): webhook trimming by effective level (reclassification included) and a minimal envelope for radicado events without a level; cause/legal basis never leave; webhook CRUD at level 3 with audit entry and actor; delivery pinned to the validated IP (DNS TOCTOU); no-read-up on the review chain and orphaned flow_steps at level 3; get_actor on the workflow DLQ; Devolver mirrors PERM_TRAMITAR; cross-entity isolation of the notification DLQ tested. Closed 2026-10-02: the webhook secret is encrypted at rest (AES-256-GCM with the service's own versioned KEK, tenant+webhook AAD, re-wrap through app.ops.rewrap_webhook_secrets, scheme shared in orpycamcp_common.kek; delivery fails closed on a clear row). Pending: two-schema isolation tests in tenant/workflow.
  • RBAC without escalation and a hardened token ✅ (2026-10, audit of 2026-10-02, T2): RBAC/URD/clearance writes and administrative removal of 2FA and signing key require USUA_PERM_ADMIN level 3; joining a group applies clearance no-write-up in the INSERT; PATCH/DELETE /groups/{id}; typ/azp/mandatory claims in the JWT; gateway validation cache with real expires_in and sealed with a MAC; /audit/verify without total or ids for callers below maximum clearance; 2FA audit entries atomic with the removal; the level census reads router gates. Pending: own audit-read permission (migration + backfill), opaque id in the audit listing, raising min_crud on signature chain/webhooks/DLQ/third parties, per-service Redis ACLs and asymmetric identity signing.
  • WORM wiring of the index on closure ✅ Inc. 1 (Ac. AGN 001/2024 art. 4.3.2.6): the WORM capability existed but nothing triggered it → the signed index was deletable despite its seal. archive now protects it after signing at closure (best-effort synchronous trigger + a third reconciliation phase, run_once_worm, which never blocks closure), with a TOTAL retention derived from the TRD (gestión+central, fecha_inicio=closure) and fail-closed when there is no TRD (never a default over an irreversible COMPLIANCE lock); worm_* columns orthogonal to estado (archive migration 029), atomic mark+entry; storage gains idempotency (partial unique index, migration 009, + short-circuit + a session advisory lock per (tenant, expediente) preventing duplicate irreversible WORM objects under a race) and the internal proteger-indice-internal endpoint for reconciliation. MinIO Object-Lock in dev — NOT an accredited repository.
  • WORM wiring of the signed transfer acta ✅ Inc. 2 (Anexo FUID): a mirror of Inc. 1 over the instrument that evidences the FUID handover — proteger-indice is generalized to proteger-artefacto with tipo ∈ {indice, acta_transferencia} (storage migration 010; /proteger-indice[-internal] remain as hard aliases), archive gains proteger_worm_acta (best-effort synchronous trigger + a fourth phase, run_once_actas_worm) with worm_* columns in transferencia_acta_firma (archive migration 030), TRD TOTAL retention with fecha_inicio = firmado_at of the acta (not closure — an acta is signed later in secondary transfers) and fail-closed without a TRD.
  • Disposition-gate remedy ✅ (Ley 594/2000 arts. 24–26): the Medium finding from Inc. 2 (acta and index do not expire together → mandated deletion was postponed until the acta expired) is closed with a CONTENT (indice/aip, blocking) vs CONTROL INSTRUMENT (acta_transferencia, outlives the content and does not block it) taxonomy; legal_hold remains unconditional, fail-closed on an unclassified tipo (guard test in CI).
  • WORM retention renewal for Conservación Total series ✅ (Ley 594/2000 + Ac. AGN 001/2024 art. 4.3.2.6): closes the hole where a COMPLIANCE object of a CT (permanent conservation) series became deletable again once its original retain_until elapsed — a new POST /preservacion/renovar-retencion-internal pair (storage, D-02, min_crud=3/X-Internal-Token) identifies the row by (expediente_id, tipo), requires a strictly greater extension (otherwise an idempotent 200 no-op that never touches MinIO) and uses set_object_retention (verified against real miniopy-async 1.23.5) — storage migration 011. archive gains a fifth reconciliation phase (run_once_worm_renovacion): a local worm_retain_until mirror (migrations 031/032), a fail-closed CT filter in SQL (app/core/disposition.py::CT_DISPOSITION_CODES), a rolling window (nueva = now() + total_TRD, never a far-future/9999 date), exponential backoff and an agotado state (higher-severity alarm: risk of the lock expiring over a perpetual record). Fail-closed by design: only disposition ∈ {CT, conserve} is renewed — E/S/M expire and are legitimately disposed of. Audit remediation: the public /renovar-retencion route was withdrawn (irreversible surface with no caller → internal only), the renewal entry is atomic, and a new visibility endpoint GET /expedientes/indices/pendientes-renovacion (no-read-up in SQL, verified non-oracle) surfaces CT artefacts whose renewal is exhausted or at risk.
  • Real veraPDF PDF/A validation ✅ Increment C2b (RF-PRE-02): SubprocessVeraPdfValidator runs veraPDF over untrusted PDFs under a hardened security model — create_subprocess_exec (no shell) with a fixed argument list and a flavour whitelist, scrubbed ENV (the child only sees PATH+JAVA_TOOL_OPTIONS, never DATABASE_URL/MINIO_SECRET_KEY/tokens), start_new_session+killpg+reap guaranteed in finally (including cancellation), a concurrency semaphore + -Xmx/-XX:MaxMetaspaceSize, a 0700 per-invocation tempdir, a dual output cap against DoS, JSON-only (no XXE) and honest degradation to the not_evaluated stub when the binary is absent (RF-FIR-15). The JRE + veraPDF ship only in the deploy image (multi-stage base/production Dockerfile, non-root; the dev image stays slim) plus a CI job with a fake veraPDF exercising the real plumbing. Tested against real veraPDF 1.30.2. Increment C2a's honest stub (no_evaluado, never faked) remains the fallback contract.
  • AIP wiring on closure ✅ (OAIS ISO 14721; Ac. AGN 001/2024 art. 4.3): POST /preservacion/aip existed but nothing triggered it → archive now triggers it when the expediente closes (reconciliation-only, best-effort, expensive). Because the AIP must be the complete preservation package, resolving the attachments' file_ids uses a new internal endpoint in document-service (POST /internal/documentos/anexos, require_internal_token, with no clearance gate — a system/full-inventory act, unreachable through the gateway; no-read-up is enforced at the AIP's read boundary, not at packaging time). Preconditions: closed + firmado index + worm_protegido; TRD TOTAL retention, fail-closed; a 100% physical expediente (0 attachments) → estado='omitido' (its already-WORM signed index is its artefact); a mutated attachment → fixity 409 → abort + backoff (never a corrupt AIP). expediente_aip table (archive migration 033), a sixth phase run_once_aip, storage migration 012.
  • WORM renewal of the AIP for CT series ✅ (closes the High debt raised by the conformance audit of Increment B): the fifth phase now covers all three artefacts (index, acta, AIP) — worm_retain_until+worm_renovacion_* mirror in expediente_aip (archive migration 034), AipWiringService.renovar_worm_aip, fail-closed CT filter in SQL, rolling window, a third advisory lock; renovar-retencion-internal already accepted tipo='aip'; pendientes-renovacion covers all three types. A CT's complete OAIS package no longer loses its immutability when the lock expires. Remaining minor debt: a uniform index-only AIP for 100% physical expedientes, if ever required.
  • Pending: an accredited WORM repository (today MinIO Object-Lock in dev), and PDF/A format migration (PRE-04).

E15 / E06 / E17 — Electronic index and signature

  • Index signed on closure ✅ XAdES-B (Inc. 1) → XAdES-T with an RFC 3161 timestamp from a local TSA (Inc. 4: verifiable but not accredited trusted time) → XAdES-LT/LTA with long-term validation material from a local dev CA (Inc. 5: embedded chain + CRL revocation + ArchiveTimeStamp, revocation_provenance="dev", not accredited, observable degradation to T without CA material) → stapled OCSP (RFC 6960) alongside the CRL (Inc. 7: an OCSP response from a per-tenant delegated responder issued by the local CA, embedded in xades:OCSPValues and covered by the ArchiveTimeStamp, certStatus derived from the CRL, fail-closed verification with responder pinning; acreditado=false, opt-in via SIGNER_OCSP_ENABLED).
  • Two-factor authentication for personal signing ✅ — email OTP (Inc. 2, RF-FIR-13) and TOTP RFC 6238 (Inc. 6: secret held in auth-service encrypted with AES-GCM, verification by bearer, atomic anti-replay + lockout). WebAuthn still pending.
  • Per-user PKI personal signature, XAdES-B/T ✅ (epic Inc. 1, E17/F4): raises personal signing from an opaque HMAC chain to per-user XAdES with a certificate from a local dev sub-CA, under server custody; the private key lives only in auth-service encrypted with AES-GCM under a separate KEK; split remote signing — signature-service assembles the XAdES with an ephemeral key + the real public certificate, and auth signs the SignedInfo digest with the real key while atomically consuming the TOTP, so the private key never leaves = a seam to an HSM; enrollment requires PERM_FIRMA and offline issuance with a ca.key that is never mounted; degrades to native HMAC without a certificate; acreditado=false ALWAYS. Honest scope (RF-FIR-15): this is an art. 7 electronic signature (the same legal tier as HMAC+2FA — verifiability/format ≠ accreditation); server custody ⇒ no sole control (non-repudiation is not opposable against the operator).
  • Inc. 2 ✅: online revocation (state read from the DB at verification time, no cache — self-service with TOTP step-up, or an admin with USUA_PERM_ADMIN, with an atomic signing_key.revocada entry; covers Inc. 1 signatures via backfill) + anchoring the user sub-CA in /verify (leaf→sub-CA chain + pin, a branch separate from the LT seal) + a nuanced verdict (firma_criptograficamente_valida/revocacion_status, fail-closed by default).
  • Inc. 3 ✅: the firma_personal_require_xades gate with three modes (off|clasificados|todos, default off for backward compatibility) — closes Inc. 2's fail-open by making it opt-in per security level: no certificate → 422 firma_xades_requerida, auth down → 503 fail-closed, denial with an atomic firma.xades_requerida_denegada entry that does NOT consume a 2FA factor. Rollout: enable clasificados/todos ONLY after completing offline enrollment for signers with clearance ≥2 (personal certificate issuance is not self-service, unlike 2FA), or classified signing will be blocked for anyone without a certificate.
  • Personal PKI signing of the acta de entrega ✅ (E17/F4; Ac. AGN 001/2024 Anexo FUID, Ac. AGN 042/2002) — the FUID's three responsible parties now sign personally, as explicit steps after recibir(), over the frozen acta_fuid_xml (POST /transferencias/{id}/acta/firmar-personal, via the new POST /signature/sign-personal): Inc. 1 the receiver ("Recibido por"), Inc. 2 the sender ("Entregado por"), Inc. 3 the drafter ("Elaborado por"). The role is derived from identity (enviada_por/decidida_por/creada_por; the rol parameter only selects, it never grants → 403 firmante_no_es_parte), signatures are independent and order-free, self-transfer (same person on both sides) is allowed with an explicit auto_traslado flag, completitud has five values with firmada_completa = both handover signatures present, and the drafter is an additive signer reported in an orthogonal elaborador_firmado flag. Migrations 027/028; the acta_firma_personal_require gate (default off) never blocks recibir(). Reinforced bilateral attribution — not full legal conformance (art. 7, not accredited, no sole control).
  • Custody KEK rotation ✅ (E17/F4): the KEK that encrypts signing private keys at rest moves from a single value to a versioned keyring; the key_version column is finally used (enrollment encrypts with the active version, signing decrypts by the row's version, fail-closed); re-wrap as an ops job (python -m app.ops.rewrap_signing_keys, never an HTTP endpoint) row by row in a transaction with a fresh nonce + round-trip verification + a metadata-only entry; invariant AAD; retirement invariant verified by a cross-tenant --verify count of 0; migration 013. Custody hygiene — it does not change the legal tier.
  • Purge of private material for REVOKED credentials ✅ (Ley 1581/2012 minimization): after a window (signing_key_purge_after_days, default 30d) an ops job (python -m app.ops.purge_revoked_signing_keys) nulls private_key_cifrado/nonce on revoked rows while keeping everything public (verification and revocation status stay identical); migration 014; a purged row no longer anchors its KEK (unblocking retirement). It does not weaken evidentiary value (verification never uses the private key).
  • RT-15 "fully conformant" (v4 profile, ADR-022) ✅ — producing dependencia, declaration date, role-based access policy and attested audit trail are closed (prospective applicability; legacy instances may omit the producer).
  • Pending: ONAC accreditation (accredited CA + TSA + OCSP responder via external integration — everything is no_acreditado today), a fully EN 319 132 ArchiveTimeStamp profile (simplified today), online OCSP from an independent responder (today self-produced and stapled as of signing time), long-term validation with a grace period (distinguishing "revoked after signing" from "revoked at signing" — requires an accredited TSA), scheduled automatic KEK rotation, WebAuthn, an HSM/KMS for a qualified accredited signature (art. 28), a backfill of the producing dependencia for legacy radicados, and strict XSD validation with lxml per profile version (the v2/v3/v4 .xsd files are published but not executed at runtime).

E20 — Postal operator

  • Integration with the postal operator as a webhook consumer ✅ F5 (RF-POR-08; ADR-018): an idempotent INBOUND callback POST /api/v1/public/postal/callback/{tenant_slug} with the operator's own identity (per-tenant/per-operator HMAC-SHA256, symmetric to ADR-018's outbound webhook), separate from the human dispatch permission. It lives under /api/v1/public/ (the gateway does not require a JWT there, so the gateway was not touched); the tenant in the path fixes search_path and the secret lives in the tenant schema → no cross-tenant forgery. Fail-closed (missing credential and bad signature → the same non-oracle 401; unmappable state → 422; unknown tracking number → 404). Idempotency via UNIQUE (operador, event_id); atomic transition + audit_log entry; Redis event for E16 after commit; a late or backwards notification → recorded_no_change (honest, RF-FIR-15). motivo_devolucion on devuelto; per-shipment lookup GET /envios/{id}/tracking (pull, stub). Tenant migration 017.
  • Declared debt: a concrete E11 connector per operator (consultar_estado is a stub today), the delivery acknowledgement (acuse_file_id with SHA-256 + incorporation into the expediente) still in the F3 human flow, provisioning/rotation of the per-operator HMAC secret via E14, and an alert to the producer on devuelto.

E14 — PINAR (Institutional Archives Plan)

  • ✅ (2026-07, RF-ADM-08; Acuerdo AGN 003/2015): OrpycaMCP manages the PINAR as a planning instrument (modelling, prioritizing, versioning and tracking it) and articulates it by reference with CCD/TRD (E04), FUID/IUD (E12/E17) and the preservation plan (E10) — without duplicating the owner's data. In tenant-service, prefix /api/v1/pinar. The AGN's 6-step methodology maps to 9 pinar_* tables (tenant migration 007): diagnosis → critical aspects with risk, 5 articulating axes (seeded), deterministic prioritization (aspect×axis matrix, priority = Σ impacts, which orders the road map), vision, objectives, projects (target/indicator/owner/resource/timeline/progress), append-only follow-up (DB trigger; avance_pct projected from the latest record), and articulation with instruments.
  • State machine borrador→aprobado→en_ejecucion→cerrado with a single plan in execution per tenant (partial unique index → 409), versioning on reformulation, approval requiring an administrative act (422 if missing) that is attributable (400 without an actor) and audited in the IMMUTABLE audit_log (E08 hash chain) via orpycamcp_common — as are create/execute/close. Content is FROZEN on approval (_EDITABLE = {borrador}); structural mutations (only in borrador) are audited in admin_audit. Invariants and the cross-schema audit_log wiring were validated against real PostgreSQL. Audited for security (2 Medium) and conformance (2 Medium, Ac. 003/2015) — no blockers, all remediated. To install orpycamcp_common, tenant-service's build moved to the root context (ADR-010 pattern).
  • PINAR UI ✅ complete (2026-08-29). The MVP (Phase 7) covered listing and the lifecycle workspace, but consumed 6 of the module's 31 endpoints: there was no way to create a critical aspect, an objective or a project from the browser, so the dashboard was always empty and the screen called it "a later PINAR phase". A dashboard that can never be filled is not an empty state: it is a false green. Three tabs added — aspects with their prioritisation matrix, objectives and projects with append-only follow-up, and articulated instruments — mirroring the service's two non-overlapping lifecycles (structure only in borrador, follow-up only when aprobado/en_ejecucion). No backend changes: it was all interface over endpoints that already existed and were tested.

E04 — The TRD as a convalidated instrument (ADR-025) and the TVD (ADR-026)

  • ADR-025 — the TRD as a convalidated instrument (append-only versioning) ✅ Increments 1+2 + remediation (migration 038) (Ac. AGN 001/2024, which compiles 004/2019; closes a Critical finding from archival-compliance-auditor): UNIQUE(code) → UNIQUE(code, version); trd_series gains estado (borrador/aprobada/convalidada/migrada/derogada) plus the administrative-act fields (Comité approval, Consejo convalidation, RUSD, publication); the trg_trd_series_append_only trigger rejects substantive UPDATE/DELETE outside borrador (explicit exception: pdfa_profile) and, since 038, hardens a convalidada row at the engine level: the act is immutable, estado may only move to derogada, and rusd_radicado/fecha_publicacion are fill-once. PATCH /trd/{id} no longer recomputes retroactively — it applies only to borrador; POST /trd/{code}/versiones clones the current row as a new one. expedientes.trd_serie_id is frozen at CLOSURE (trg_expedientes_trd_pin_immutable) — closing the retroactive leak that motivated the finding. Honest backfill: every pre-existing series becomes estado='migrada', and expedientes closed under it are flagged trd_revision_requerida.
  • Convalidation circuit: aprobar (borrador→aprobada, requires the Comité minutes), devolver (aprobada→borrador, mandatory reason — closes the dead end at aprobada), convalidar (aprobada→convalidada, repealing the previous current version in the same transaction with collision-safe re-pointing of orphaned FKs; or migrada→convalidada as ratification), registrar-rusd, and derogar (convalidada/migrada→derogada, blocked while open expedientes remain under the code).
  • GET /trd/revision-pendiente — no-read-up by clearance + query trace added in 038 (a security regression: the report did not filter by security level). The D5 gate for effective deletion authorizes over the frozen snapshot (serie_estado/acto_administrativo), not the live row; it still has no caller, because the disposition job does not exist (declared debt).
  • 701 unit tests + 151 integration tests (real PostgreSQL). Pending (Increment 3): versioned TRD administration UI, version + act in the electronic index (v5 profile), explicit per-expediente re-baselining, propagation to documents.disposition.
  • ADR-026 — TVD for an accumulated fonds ✅ Increment 1 (registration and convalidation, migration 039) (2026-08; closes the archival-compliance-auditor finding "the whole model is TRD-only, an accumulated fonds has nowhere to put its TVD"): the TVD is the same instrument as the TRD — a tipo_instrumento ∈ {TRD, TVD} discriminator over trd_series, not a table of its own (D1: forking expedientes.trd_serie_id, with seven irreversible WORM COMPLIANCE derivations hanging off it, is the design that was rejected). Four layers protect the discriminator: (1) a single shared code space with TRD (409 instrumento_code_en_uso, naming the occupant's type); (2) two views v_trd_series/v_tvd_agrupaciones with WITH CHECK OPTION — list_trd_series now reads FROM v_trd_series, closing the "forgotten filter" class of bug; (3) per-type CHECKs in both directions (a TVD requires fondo_nombre/extreme dates/justificacion_valoracion + archivo_gestion_years=0; a TRD requires those four to be NULL); (4) the trg_expedientes_pin_tipo trigger in STRICT form — it rejects ANY expediente bound to a TVD, so the irreversible-WORM risk is zero, guaranteed by the engine. The /api/v1/tvd router is a 1:1 mirror of /api/v1/trd (same full circuit), with tipo_instrumento fixed by the router, the reused USUA_PERM_TRD permission and distinct audit entries (archive.tvd_*). The GET /api/v1/{trd,tvd}/{code}/versiones twin (promised by ADR-025 and never built) was added for both instruments. 721 unit + 164 integration tests.
  • Pending (Increment 2): expedientes.origen, the trigger relaxed to origen↔tipo_instrumento, compute_retention with an explicit base date (fecha_extrema_final, never closed_at — D4, the design's highest-risk point), and a snapshot carrying tipo_instrumento/base_date_origen. Increment 3: clamping an already-expired retain_until, and a gate requiring prior publication of the deletion inventory.
  • Increment 1 frontend ✅: the /admin/tvd screen + a convalidation circuit shared with /admin/trd.

  • RF-TRD-06/07/08/09 — final disposition: closed 2026-08-28/29. The system preserved well and never destroyed, which was simultaneously the non-compliance: keeping everything forever is not applying the retention schedule (Law 594/2000 art. 24; AGN Agreement 001/2024 art. 4.3.2.5). Closed in this order:

  • RF-TRD-09 — closing an expediente with no TRD series left it out of the lifecycle in silence; now it is flagged (migration 045 sweeps the ones that slipped through).
  • RF-TRD-08 — the due-disposition queue and the scheduled sweep with its per-recipient alert, the count narrowed by their clearance. It counts and warns; it executes nothing.
  • RF-TRD-06 — the authorised user's confirmation and the deferral with a reason and a return date (append-only table, migration 046). The ADR-025 D5 gate finally gets a caller.
  • RF-TRD-07 — in five phases: batch with a frozen inventory (047) → prior publicity in the open → the Committee's endorsement → the signed record, permanent preservation (048) → secure deletion with a content-free headstone (049), chained archive → document → storage → knowledge, all-or-nothing per expediente. The record's WORM lock closed the last debt (storage/014 + archive/050).
  • E04 is complete: the system now destroys what the retention schedule orders destroyed, with due process, and only that.

E11 — Interoperability

  • OAI-PMH (metadata harvesting) ✅ (RF-INT-02, half; SGDEA R.12.1): GET /api/v1/public/oai/{tenant} in document-service (public by path under /api/v1/public/, where the gateway requires no JWT — untouched, like the postal webhook). Full OAI-PMH 2.0: all six verbs, Dublin Core metadata (oai_dc), selective harvesting from/until/set (set = doc_type), paginated resumptionToken, and the OAI error codes. CRITICAL security requirement (Ley 1712/2014 arts. 18–19, RF-SEG-08): harvesting exposes ONLY what is public (nivel_seguridad=1, not annulled). The trimming lives at the source (OaiRepository, a single _PUBLIC filter) → neither the service nor the gateway ever sees a reserved record, so the omission is INDISTINGUISHABLE by construction: completeListSize counts public records only, ListSets does not enumerate a reserved-only set, and GetRecord over a reserved id returns idDoesNotExist. Identifier oai:{tenant}:{tracking_number} (the radicado number is preserved as identity). Audited for security (no Critical/High) and OAI-PMH 2.0 conformance.
  • Minimal read-only CMIS 1.1 profile ✅ (the other half of INT-02): GET /api/v1/public/cmis/{tenant} (repositoryInfo) + /root?cmisselector=object|children|content (Browser Binding JSON) — public radicados → cmis:document, a synthetic root cmis:folder, maxItems/skipCount pagination. getContentStream serves the attachment's bytes (streaming via storage_client) only if the radicado is public (consistent with Ley 1712: public information is downloadable). Same _PUBLIC chokepoint as OAI → getObject/getContent of a reserved id is an indistinguishable 404. Read-only profile (query/write/versioning capabilities none/false); no nested folder navigation and no CMIS-SQL (roadmap).
  • Interoperable export of radicados (INT-01) ✅ (RF-INT-01): POST /api/v1/export in document-service (authenticated, USUA_PERM_EXPEDIENTE gate + per-radicado no-read-up) produces an interoperable ZIP package: manifiesto.json (export UUID + comment + start/end marks + entity list + content-free exclusions for over-clearance items, Ac. 001/2024 4.3.2.3), esquema/radicado.schema.json (a published JSON Schema draft 2020-12), radicados/{numero}.json validated against that schema (system + contextual E03 metadata + TRD disposition + ACL level/classification + attachments + audit_log history), the attachment binaries, and checksums.txt (SHA-256 per member, verifiable fixity — Ley 594 art. 19). The radicado number is preserved as an immutable identity (Ac. 060/2001), which is what makes INT-05 possible. History is exported as action/fecha/actor/canal without the payload (which may carry the classification grounds). Remediated: egress auditing (radicado.export in audit_log, content-free), the E↔S antecedente as a portable number, an anti-DoS cap (422 above 5000 items), a purged attachment recorded as a traceable ausente entry.
  • Interoperable import of radicados (INT-05) ✅ (RF-INT-05; closes the spec's Critical finding): POST /api/v1/import (authenticated, USUA_PERM_EXPEDIENTE gate + no-write-up by clearance) takes the INT-01 ZIP as a raw body. ALL-OR-NOTHING validation before touching the DB: every radicado validates against the service's own trusted JSON Schema (not the one shipped inside the package, which could be lax) and each binary's SHA-256 is verified; a mismatch → 422 with no partial state (Ley 594 art. 19). It re-ingests preserving the original radicado number (a collision is SKIPPED, never overwritten), rebuilds the E↔S relation by number, uploads the binaries to storage, ingests atomically in a transaction, and audits radicado.import (content-free). Zip-slip / zip-bomb guards + a 512 MiB upload cap. A real export→import round trip was validated against real PostgreSQL. Remediated: hard auditing (inside the transaction, with the list of imported radicados for provenance), unresolved antecedents declared (antecedentes_no_resueltos), a per-item savepoint, and binary uploads outside the transaction.
  • EAD 2002 / ISAD(G) archival description in OAI-PMH (INT-06) ✅ (RF-INT-06), in two parts:
  • Item level: a second metadataPrefix=ead on the SAME OAI-PMH endpoint. Each public radicado is disseminated as a self-contained EAD 2002 fragment with <archdesc level="item"> and the six mandatory ISAD(G)/NTC 4095 interchange elements, plus <accessrestrict> (public, Ley 1712/2014), <descrules>, <langmaterial> and <repository>. Read-only increment, no migration and no new SQL: it reuses OaiRepository and its _PUBLIC chokepoint → the format is ORTHOGONAL to the security trimming (choosing ead does not bypass the filter or create an oracle); the resumptionToken preserves the metadataPrefix.
  • Multi-level: a new public endpoint GET /api/v1/public/archive/oai/{tenant} in archive-service disseminates each public expediente as a MULTI-LEVEL EAD 2002 fragment — walking the trd_series.parent_id chain (the CCD) it builds <archdesc level="fonds">→<dsc>→<c level="series">→(<c level="subseries">…)→<c level="file">, satisfying ISAD(G) 2.2 (general→specific) and 2.4 (link to the higher level). The gateway routes /api/v1/public/archive/ before the public catch-all. Anti-leak boundary: it stops at level="file" — it never enumerates child radicados' tracking_numbers (a public expediente may contain reserved radicados, and archive does not know their level; only an aggregate count in <physdesc>, with a grep guard test).
  • Pending for E11: level-type discriminator for CCDs with 3+ levels, qualified Dublin Core, validation against the official EAD XSD in CI, export/import of expedientes/users/classes, an async job, SUIT/MIPG/SECOP connectors, API keys / OAuth2 client-credentials, legacy SOAP/GraphQL (optional).

E08 — Security and classification

  • Reclassification: PATCH /documents/{id}/security-level reclassifies nivel_seguridad with the PERM_RECLASIFICAR permission + no-read-up/no-write-up, immutable auditing (reason + legal grounds) and a re-sync of the signature snapshot via an event — closing the RF-SEG-08 staleness residual. The UI is done as well: a level chip + a reclassification modal gated by PERM_RECLASIFICAR in the inbox drawer.
  • Index of classified and reserved information ✅ (Ley 1712 art. 20 + Decreto 1081/2015): GET /api/v1/reports/indice-reservado(.csv) generates the register of classified/reserved radicados. The current classification metadata is materialized in radicados (migration 018); level 2 → art. 19 (reserved) / level 3 → art. 18 (classified); it is CONTENT-FREE (never the subject) and orthogonal to clearance (a complete register is legally mandated), gated by PERM_RECLASIFICAR, with CSV formula-injection protection and aggregate auditing. Three conformance blockers were re-audited and closed — chief among them that the legal grounds are MANDATORY when classifying (whether registering or reclassifying, 422 if absent) so the index is never left with NULL grounds.
  • No-write-up when registering a classified radicado ✅: DocumentService.create resolves the registrar's clearance and returns 403 clasificacion_forbidden if nivel_seguridad > clearance — a PUBLICA user can no longer originate a CLASIFICADA radicado. Remaining fast-follow debt: a series-level index (archive), the asset-register fields (Dec. 1081 art. 2.1.1.5), a closed catalog of legal grounds, and a backfill of previously classified records. Still deferred: a transactional outbox for the reclassification event, and dual control for declassification.
  • No-read-up on expedientes ✅: the clearance bypass in the GET /expedientes listing and in PATCH /{id} is closed (they filtered with the "no restriction" default); both now apply no-read-up with a filtered COUNT/X-Total-Count. The RF-SEG-08 triad is closed: (M1) disposition (close/transfer) is orthogonal to read clearance (a custody archivist disposes without reading the content); the real bug — a spurious post-mutation 404 — was fixed with a narrowed ExpedienteDisposicionResult that exposes no content. (M2) link/batch/unlink/rebuild responses leaked membership/count/hash/life-cycle at low clearance: the mutation is legitimate, so the output is narrowed — a uniform, byte-for-byte indistinguishable ack in every state, with the mutation always happening and always audited. (M3) POST /expedientes applies no-write-up with an explicit 403 (not a clamp).
  • Atomicity of mutation ↔ audit entry ✅ across expediente creation (single and batch), link/unlink/link_batch, and close/transfer/recibir (the DB leg is wrapped in conn.transaction() with the entry as the last write; the XAdES sealing HTTP call is post-commit, best-effort). Also closed: the disposition TOCTOU (transition_expediente gained expected_status), the twin TransferenciaService.recibir route (which froze the expediente with no transaction, event or entry), the missing actor in the transfer entry, and the alternate PATCH /expedientes/{id} route that ran the same transitions with a bare UPDATE — status was removed from the schema (with extra="forbid") and from the repository, as it was the second unaudited writer of expedientes.status.
  • Integration testing against real PostgreSQL ✅: a tests/integration/ lane (disposable tenant schema + real commits) exercising physical rollback, verify_chain, real concurrency and append-only behavior — with verification by mutation (removing the transaction from the SUT turns the tests red; mock-based tests could not detect it).
  • E12 transfer life cycle ✅: create/enviar/recibir/rechazar wrap mutation → event → entry in conn.transaction() with entries for both success and denial; transferencias.update_estado gained an expected-state guard, closing the lethal rechazar-vs-recibir race; identity is unified in app/core/actor.py (X-User-Id UUID as the single source, fail-closed). Ac. AGN 001/2024 Tít. 4.4 CONFORMANT for the transfer cycle after closing H-G (recibir() re-verifies the holdings' fixity before freezing custody; a mismatch → 409 fixity_mismatch that reverts without freezing), H-K (mandatory rejection reason) and H-J (rechazada_at). H-I ✅: the FUID is frozen as an immutable acta de entrega on receipt (transferencia_acta, 1:1, canonical XML + acta_fuid_sha256 + the three responsible parties, inside the atomic reception transaction), with an engine-enforced append-only trigger (migration 023) and the acta_fuid_sha256 anchored in the audit_log hash chain → tampering after a trigger bypass (superuser only) is detectable. H-L/H-M ✅: a partial unique index (one active transfer per expediente) and origin/destination coherence by transfer type (primary: gestión→central; secondary: central→histórico), plus mandatory origin/destination cross-checked against current locations whenever the expediente is physically located. H-H ✅ for its scope: the acta receives an enveloped institutional XAdES-B/T/LT/LTA + OCSP seal over its canonical XML → integrity + institutional non-repudiation + verifiable trusted time via a TSA (not accredited), best-effort and additive, with manual retry and reconciliation.
  • Index sealing reconciliation ✅ (E15/E06 Inc. 8): the "closed expediente with an unsigned index" residual is addressed with four controls — it removes the permanent failure mode; a bounded synchronous sealing attempt during closure itself when the institutional seal is mounted (2 attempts ≈1s → signed-at-closure in the common case; the client distinguishes seal_absent, which degrades without retrying, from transient); a reconciliation job (lifespan task + python -m) with exponential backoff (migration 025), a per-tenant advisory lock and idempotency; and GET /expedientes/indices/pendientes-firma (no-read-up, includes agotado) for operational visibility. System attribution: an internal route POST /signature/internal/sign-indice (only X-Internal-Token, never forwarded by the gateway; SYSTEM_RECONCILER_ID) that structurally cannot sign the personal chain. Honest normative scope (RF-FIR-15): full conformance with art. 4.3.2.4 ONLY in the common case; it does not guarantee synchronous signing at the instant of closure, and with the institutional seal ABSENT the index stays pendiente_firma indefinitely (reconcile_agotado, requiring human intervention) — firmado is never faked. Deferred: proactive alerting on agotado items.

E09 — Query trace (RF-BUS-10)

  • ✅ Covered in all three services that read radicados. An aggregate trace per query (user/criteria/total, without the raw q text, kept separate from the per-record trail) plus a per-record trace of the individual read: expedientes in archive-service (expediente_busqueda + expediente_consultado), radicados in document-service (document.radicado_busqueda for list/search/replies + document.radicado_consultado) and processing in workflow-service (workflow.bandeja_consultada + workflow.hoja_ruta_consultada/tramite_consultado). All three use object_ref = tracking_number/code (the business key) for cross-service correlation.

E17 — Physical archive (minor pending items)

  • PDF/QR label, capacity/occupancy, external custody. Also: engine-level enforcement of mandatory physical origin/destination and of the branch-B cross-check.

Foundational scaffolding (Phases 1–6) ✅ Complete

These phases built the base on which specDrive raises conformance.

Phase 1 — Framework and Architecture

  • [x] Structure, CLAUDE.md, agents, persistent memory, base docker-compose (PostgreSQL, MinIO, Keycloak, Redis, MailHog), Keycloak realm, init-db.sql, CI/CD, MkDocs
  • [x] ADR-001 microservices · ADR-002 multi-tenancy
  • [x] auth-service, tenant-service, api-gateway

Phase 2 — Core Domain

  • [x] document-service (radicación E/S/I, atomic numbering, attachments)
  • [x] storage-service (MinIO, SHA-256, pre-signed URLs, per-tenant bucket)
  • [x] archive-service (TRD, expedientes open→closed→transferred)

Phase 3 — Workflows and Notifications

  • [x] workflow-service (assignment/transfer, history, Redis events)
  • [x] archive-service (radicado↔expediente link)
  • [x] notification-service (SMTP, event consumer, history)

Phase 4 — Quality and Integration

  • [x] init-tenant, E2E, ADR-003 asyncpg, lint/type-check in CI, orchestrated health check

Phase 5 — Community and Publication

  • [x] Deployment/contribution guides, EN README, bilingual docs, AGPL v3, OpenAPI aggregation, GitHub Actions, GHCR, EN API reference

Phase 6 — Advanced Features (scaffolding)

  • [x] FondeCund TRD seed, Batch Documents/Expedientes, Full-Text Search, Workflow Rules Engine

Beyond specDrive 📋 Planned

  • [ ] Native mobile app (iOS/Android)
  • [x] ~~Document signing~~ — native electronic signature + signature chain / signer's inbox done (E06: ordered turns, GET /pending, sign/reject/batch); XAdES-B of the electronic index + institutional seal done (Inc. 1); email-OTP 2FA (Inc. 2) and TOTP RFC 6238 2FA (Inc. 6) done; XAdES-T (local TSA) done (Inc. 4) and XAdES-LT/LTA (local dev CA + CRL + ArchiveTimeStamp, not accredited) done (Inc. 5); stapled OCSP (RFC 6960) in XAdES-LT done (Inc. 7); per-user PKI personal signature XAdES-B/T (server custody, local dev sub-CA, split remote signing = a seam to an HSM) done (E17/F4 Inc. 1; acreditado=false, art. 7, no sole control). Still pending: ONAC accreditation (CA/TSA/OCSP responder + qualified art. 28 personal signing via an HSM), a fully EN 319 132 LTA profile, online OCSP from an independent responder, and WebAuthn
  • [ ] Corporate SSO integration (Entra ID, Okta, LDAP)
  • [x] Frontend (E22, frontend/, ADR-011/020) — essentially complete (~14k LOC), see the section below
  • [x] ~~Webhooks API~~ — signed outbound webhooks done (E11), plus the inbound postal callback (E20)
  • [x] ~~Export expedientes to PDF/ZIP~~ — ZIP done (E02/export): GET /api/v1/expedientes/{id}/export.zip (archive orchestrates, storage assembles via an internal D-02 endpoint) with the electronic index XML (best-effort) + manifiesto.csv (including exclusions with their cause) + a LEEME.txt scope note + checksums.txt (SHA-256) + the attachment bytes; PER-RADICADO no-read-up (document-service filters nivel <= clearance and omits over-clearance radicados including their existence — closing a read-up leak that the expediente-level gate did not cover), USUA_PERM_EXPEDIENTE gate, zip-slip protection, aggregate auditing. Pending: a combined PDF (render engine), streaming for large expedientes
  • [x] ~~Reports and statistics~~ — radicado reports done (E09); advanced dashboard/indicators pending
  • [x] ~~Dependencia by code~~ (B5, ADR-032, 2026-10-10) — dependencias.codigo is immutable and the correlation key of the workflow, rules, inbox, events and drafts; the name becomes an editable snapshot (renaming with live cases no longer breaks the inbox); an ambiguous name is never resolved "to the lowest code"; tenant 013 / document 046 / workflow 019 migrations with fail-closed backfill and anchored evidence. Pending: remove the name alias (*_dept fields, ?dept=) and the name indexes once the Deprecation header is at zero for one cycle. Pending (repair): a manual mapping registered AFTER 046/019 is not reflected in their anchored evidence (the migrations already ran); a repair job that links it with its own anchor is missing.

Frontend (E22)

The frontend is not a scaffold: it is a practically complete SvelteKit application (~14k LOC) covering the operational and administrative surface of the system. See System screens for the screen-by-screen map.

  • Session and shell: server-side OAuth2 PKCE login (httpOnly cookies, transparent refresh, route guard), a public landing page at /, branded login/callback/403 (which closed the IdP↔/login redirect loop), global search in the top bar (shortcut /, deep link ?q=) and configurable favorites in the sidebar (store namespaced per tenant+user, consuming the already RBAC-filtered list). Navigation mirrors the real RBAC; NAV_ITEMS in lib/utils/navItems.js is the single source shared by SidebarNav and the /dashboard.
  • Domain views with BFF proxies (rule D-05: the token is server-only, the browser never calls the gateway): inbox, registration, drafts, search, expedientes (listing and detail), signatures, shipments, reports, physical archive, transfers (including the acta-as-certificate detail with personal signing by role), annulments, profile, and the /admin panel with its 18 areas — TRD, TVD, metadata, catalogs, parameters, groups, dependencias, users, queues, notifications, audit, PINAR, preservation, signing keys, interoperability, templates and flow rules.
  • Conversational assistant ✅ (ADR-020): store + service + BFF proxy + chat UI in AssistantDock, with server-side sanitization of the output (untrusted content → marked+sanitizeHtml, ignoring the gateway's html); with the backend absent it degrades to 503 assistant_unavailable — an honest state, never a simulated answer.
  • Form Builder ✅ (E22/E03): choosing the TRD series (now a <select>, previously free-text UUID) fetches the active metadata template's json_schema through a BFF proxy and renders dynamic fields (schemaField.js maps JSON Schema → control + coercion; SchemaField.svelte), populating the metadata:{} that used to travel empty; radicar was migrated to the same single source, closing a real gap (its inline dynamic field only covered number/date/text, so an enum/boolean degraded to free text → 422). ~~Deferred~~ ✅ partly closed (AREA-12-05, 2026-10): array of scalars (repeatable list), one-level object and per-field mapping of the 422 (the envelope carries field, a dotted path); still open: migrating views with inline metadata (expedientes/[id], inbox) to SchemaField, arrays of objects and nested objects.
  • RAG integrated into drafting and classification ✅ (F6, E21): a "Suggest from precedents" button in /radicar and /borradores calling POST /knowledge/rag, and suggested tipo documental chips fed by /knowledge/antecedentes. Three hard rules: generated text is ALWAYS inserted marked as generated and with its citations (lib/utils/ragInsert.js, using only allowlisted tags so the marking survives server-side sanitization); with no citations, insertion is not offered (an unsourced assertion in an official document is worse than no suggestion); and the classification chip is never auto-applied.
  • Semantic and precedent search ✅ in /busqueda (two new tabs beside the exact one, resolved by ?tab=): results are always presented as similar documents (vector similarity with an explained qualitative band, never an exact match), a network failure is never disguised as "no results", and knowledge-service's ACL trimming is invisible by design (no hidden-result count is announced — which would turn search into an existence oracle).
  • Orpyca Design System v1.0 ✅ (ADR-020): _tokens.scss repainted to the definitive palette keeping every --op-* name (zero breakage across 19 screens) with calculated, not estimated, WCAG contrast → the primary is split by role (--op-primary non-textual 4.28:1 / --op-primary-dark text 6.56:1); v1.0 type scale, radii, shadows and motion + a global prefers-reduced-motion rule; self-hosted Space Grotesk + Public Sans (@fontsource, no CDN); Font Awesome wired up for the first time (its CSS was never imported — every icon was an empty box) and the 404 favicon fixed; the /dashboard redesigned around four personal modules with tenant metrics relegated to a collapsible section gated by SGD_PERM_ESTADISTICA; DataTable with the eight mandatory capabilities (filters, aria-sort ordering, CSV export with formula-injection protection, configurable columns, saved views, multiple selection, content-visibility virtualization, in-cell editing), all opt-in.
  • Test coverage: ~1300 passing Vitest tests across 117 spec files + a Playwright E2E smoke suite; svelte-check reports 0 errors / 0 warnings and the build is clean. Pending: an authenticated E2E run against docker-compose (written, skipped today).

  • [x] T11 — BFF guards, cookies and RBAC mirror (2026-10, AREA-12-01/03/04/05/06/07): a tree-derived guardian requiring a session on all 290 verbs of the 219 +server.js under (app) (was 21 hand-picked proxies), a cookie-flags test (httpOnly/sameSite/secure), admin/+layout.server.js redirecting to /403 by each area's REAL permission, preservation state shown in the expediente tab (GET /preservacion/estado), SchemaField with array/object and field in the 422, CSS tokens (--op-on-brand-*, layout.scss on the scrim) and PINAR backend↔BFF parity (30 endpoints with a door). Open: running the new e2e (restricted RBAC, axe) in CI, and T-18 per-tenant branding / bundle budget (scope decision).

  • [x] T12 — archival compliance in archive and storage (2026-10, AREA-07-01/03/04/05/06/08/09/11/14/15, 06-04/07, 08-02/04/05/09/15 — 07-10 only the technical WORM): the index is validated against its XSD before sealing (pendiente_firma/xsd_invalido) and exhausted sealing sends an email; creating a transfer requires the signed index, reads custody inside the transaction and rejects a pair with no custody; the handover record ties the index signature and hash (migration 053) and declares cabecera_completa; the receipt's fixity check is a PREMIS event (with reconciliation); REVOKE of UPDATE/DELETE on the record and a CHECK on objeto (054/055); WORM for accumulated holdings (ADR-026 Inc.3, with clamp) and for each personal signature of the record (acta_firma_* types, 057); manual retry of an exhausted AIP; expediente events now include files and AIP; storage: deduplication no longer crosses authors, the AIP replica is re-read, the startup guard warns about veraPDF with no binary, PREMIS declares the PRONOM PUID, and there is a WORM-circuit test against real Postgres + RustFS. Closed (2026-10-04): the firma_inobtenible act (07-10, migration 059) and CT renewal of the record's personal signatures (060). Audit remediation (2026-10-09, ADR-030, archive 061 / storage 019): the record became evidence — canonical XML sealed XAdES-T and signed by the declarant per the entity policy, Object-Lock with CT renewal, server-resolved supporting document — revocable, free of conflict of interest and honestly labelled (sellada_con_excepcion). Open (user decision): two-level TRD hierarchy rules / bulk reclassification (06-07), preservationLevel and rights in PREMIS (08-05/08-01), whether accumulated-holdings WORM should be set with a margin (chosen: clamp) or not set at all (original D4.4), and a notification consumer for the exhausted-index event (today it is a direct email).

  • [x] T13 — document compliance in document-service and frontend (2026-10-03, AREA-09-02/04/05/07/08/10/16, 07-12, 02-07 — honeypot and Idempotency-Key only): the radicado's minimum data (type and department, AGN 060 §5.2) is required at both intake doors and internal producers derive it or reject before spending a number; the classified-information index never carries a NULL legal basis (import rejects + CHECK 041 + backfill) and shows department and registrar; transactional outbox (eventos_salientes, 042) for creation, reclassification and annulment — a 1→3 is no longer lost with Redis down — and for documento.contenido_cambiado; proof of the personal-data authorisation (Law 1581) on the single window and PQRS; the postal operator's HMAC secret with two encrypted slots and zero-downtime rotation, and a mandatory receipt for manual delivery (044); per-attachment publication that excludes PQRS and public-window intake (040); GET /ventanilla/estado + scripts/verificar-ventanilla.sh. Open (user decision): the CAPTCHA provider (reCAPTCHA contradicts sovereignty; ALTCHA or another self-hosted proof of work), the data-processing policy text (supplied by the entity: POLITICA_TRATAMIENTO_VERSION/URL), the closed catalogue of legal bases and the series/file level of the index, double control for declassification, and whether an anonymous submitter may bind to an existing third party's record by document number. Technical follow-up: ~~a consumer of documento.contenido_cambiado in signature-service~~, ~~a notification alert for document.envio.devuelto~~ and ~~a fragment-level reconciliation sweep in knowledge~~ (closed in B10), an IP verified by trusted hops on public routes (the gateway does not inject it) and type/department capture in the drafts form.

  • [x] T14 — CI and infrastructure guards (2026-10-03, AREA-01-18, 13-01/02/03/05/08/09/11/13/14, 08-10/12, 03-02/05/06, 05-08): a guard that every route carries a gate (with declared exceptions, knowledge among them), for rules 1, 3 and 7 (no ORM or python-jose, version floors, repo :ro), that the CI matrix comes from the tree (whisper in unit, signature and workflow in integration), for immutable images, migrations before serving (migrate-<svc>) and a safe production manifest; a real WORM job against RustFS pinned by digest, an automatic security smoke, Trivy before publishing, a fail-closed dependency audit with a deterministic threshold and npm audit. The backup verifies the audit chain before anchoring it and does not anchor if it fails (app.ops.verify_audit_chains, also used by restore.sh: its heredoc blew up), the restore fails if a retention or legal hold is left unrestored and emits the version_id map; auth's /health/ready flags a missing partition; the 004 statement trigger and the inbox against real Postgres have tests. Open (user decision): gate knowledge's routes with a permission; rebuild auth-service on the local stack (disk).

  • [x] T15 — cross-cutting work on orpycamcp_common (2026-10-04, AREA-02-05, 01-17, 03-07): a single slug validator (orpycamcp_common.tenant; registration no longer accepts -abc), check_permission/ resolve_caller_clearance/nivel_efectivo/mensaje_denegacion defined once in orpycamcp_common.rbac (ADR-013 corrected: auto-provisioning is already decided), and audit.append with no default canal (191 callers rewritten; CANAL_PETICION inherits the gateway's, system for jobs, CLIs and consumers, pinned by canal_de_fondo() in each background task); the object_ref filter of GET /audit resolves old entries by expediente/index UUID at read time. Guards: test_un_solo_validador_de_slug, test_el_gate_se_define_una_vez, test_el_asiento_declara_su_canal. Open: permission cache (no load profile asks for it).
  • [x] T16 — documentation and memory (2026-10-03, AREA-02-01, 14-01/02/03/04/06/07/08/09/10/11/12/13/14/15/16/17/19, OMI-07, 07-16/19, 09-17): the roadmap stops contradicting itself about final disposition, closes with evidence the write-gate and per-IP limiter findings, and no longer hand-writes the ADR count; multi-tenancy.md and ADR-002 stop prescribing Alembic/SQLAlchemy and an unvalidated SET search_path; institution onboarding is documented through scripts/init-tenant.sh (the gateway does not expose /tenants); ADRs 006/016/018/021 and the TSA, workflow, storage and OAI comments say what exists; deployment.md downgrades Kubernetes to "not supported" and DOCKER_REGISTRY.md moves to GitLab; the EN roadmap matches the ES open items and the EN security-audit.md gains the second pass. Code: per-unit loans paginated (+ BFF), signature migration 022 (xades_level COMMENT), the OAI EAD validated against the official XSD (21 real demo records, 0 invalid), scripts/carga/busqueda_sla.py (p95 of /search against RF-BUS-09: 3 s simple / 10 s complex; p95 ≤ 0.2 s over 222 radicados) and 20 documentation guardians. Open: the webhook_entregas table (ADR-018), streaming expediente export, a CCD level discriminator (domain decision), EN changelog parity (decision), a generated MCP coverage figure, and rebuilding archive-service and the frontend on the local stack (disk at 98 %).

UX review of the API↔UI drift closure (2026-08-02)

The ~20 screens built during the API↔UI drift closure (Phases 0–8) were implemented without going through the UX/accessibility reviewer, the last link in the project's agent chain. The review was done afterwards, in two parallel passes (the /admin panel and the operational screens). Result: a solid base — no unsanitized {@html}, no client-side calls to the gateway (rule D-05 intact), Modal/Drawer with correct focus trapping and restoration, confirmation proportional to the damage on irreversible acts — with these pending items:

  • [x] Batch loading reports no result under any circumstance (two independent defects that compound, both in /admin/interoperabilidad). (1) createJobPoller mutated a plain object that is never reassigned: Svelte 4 does not invalidate, and the panel stayed frozen on "Querying status…". (2) Both polling proxies requested ${API_V1}/batch/{jobId}/status, the shape from before the gateway routing fix, which matches no prefix → permanent 404. Fixed (batch 1): polling moved to reassigned component-level let variables, the URLs to the real nested shape (/batch/documents/{jobId}/status and /batch/expedientes/{jobId}/status), the four stale comments claiming the gateway did not route /api/v1/batch/ were deleted — that being the vector that produced the defect — and URL contract tests were added, the missing defense already paid for once with FORWARDED_REQUEST_HEADERS.
  • [x] Three /admin cards lead to a 403 — the same pattern already fixed in Colas, which turned out not to be an isolated case. Fixed (batch 1) by checking all 16 cards against the real require_permission of their router, not just the three observed: TRD / CCD and Metadatos move to USUA_PERM_TRD, and Interoperabilidad — whose gate is heterogeneous per tab — now opens with USUA_PERM_EXPEDIENTE || PERM_RADI and filters each tab with its own permission, so it no longer hides from a registration operator who can use it. The other thirteen matched. A false comment in metadatos/ claiming that the document-service routers did not gate by permission was corrected as well.
  • [x] Two screens built and never linked: /admin/preservacion (784 lines) appeared neither in the card index nor in the sidebar — it was only reachable by typing the URL — and /admin/seguridad/claves was missing from the index, which presents itself as the complete administration map. Fixed (batch 1): both added to ADMIN_AREAS with their permission verified against the router.
  • [x] The document batch-loading proxies never made it into version control (a repository-hygiene finding, surfaced during batch 1 and not by the UX review). The .gitignore pattern documentos/ — written for the documentos/ folder at the repo root — carried no leading slash, so it also hid frontend/src/routes/(app)/admin/interoperabilidad/api/lotes/documentos/: the folder stayed out of the Phase 8 commit despite being documented as delivered, and on main the screen called routes that did not exist. Fixed by anchoring the pattern to /documentos/.
  • [x] AA contrast: white on --op-primary (4.28:1) in permanent states, not just on :hover. Fixed (batch 2): the three Steppers (2FA, PKI credential, signing), the active turn in the signature chain and the filter chips in /borradores and /envios — which were literally inverted, with the dark tone on hover — now use --op-primary-dark (6.56:1), following what Button.svelte already got right. Four instances of the other failing pair were fixed as well: --op-text-secondary on --op-primary-light/--op-success-bg/--op-error-bg (~4.0:1).
  • [x] Wide tables unreachable (WCAG 1.4.10 Reflow and 2.1.1 Keyboard). Fixed (batch 2): tabindex="0" + role="region" + aria-label + :focus-visible on DataTable and CatalogCrudPanel — which propagate it to 8+ and 7 screens respectively — and on ~20 raw table wrappers; bandeja-table, the only one with overflow: hidden and no scrolling mechanism at all, gets its own wrapper with overflow-x: auto. The remaining overflow: hidden containers wrap a DataTable whose scrolling lives in the inner wrapper, so no tab stop was added to them: it would be mute and redundant. Every new tabindex trips a11y-no-noninteractive-tabindex, a rule that does not recognize WCAG technique SCR29; silenced with a justified svelte-ignore rather than left dangling.
  • [x] /perfil: seven field validation errors surface only as a floating toast. Fixed (batch 2): all seven are now associated with their field through FormField's error prop (aria-invalid/aria-describedby), and the five OTP code fields gain inputmode="numeric" + autocomplete="one-time-code" — which required extending FormField with those two props, as they did not exist.
  • [x] CatalogCrudPanel applied display: flex to a <td>, removing the cell from the table model in the accessibility tree. Fixed (batch 2) by moving the flex to a <div> inside the <td>; it propagates to the seven screens using the panel. The original finding also mentioned "four tables emitting one more cell than they have headers": a sweep of all 28 raw tables in the project, comparing <th> against <td> and including permission-conditional columns, could not reproduce it — it was most likely closed already when the shared pattern was adopted. Recorded as-is rather than forcing the count.
  • [x] AI suggestions appeared without being announced to screen readers. Fixed (batch 2) with aria-live="polite" on the TRD series chips and on the antecedentes panel. The content itself was not touched: ragInsert.js remains the sole builder of the fragment and already meets the regulatory requirement.
  • [x] There is no 2FA recovery code in the backend (verified: 0 occurrences in auth-service). Anyone who loses their phone is left unable to revoke or renew their signature credential, and the screen does not say whom to contact. This is a product gap, not an interface one: it must not be papered over in the frontend. Still open, deliberately left out of batch 2. Closed 2026-08-07: migration 015; activate issues 10 single-use scrypt-hashed recovery codes (same transaction as the activation), accepted as step-up in enroll and DELETE /me/totp, plus POST /me/totp/recovery-codes to regenerate the batch (see totp_service.py / totp_repository.py). The "0 occurrences" statement above is obsolete.

  • [x] Contract root cause — /auth/me discarded the CRUD level. The router resolved {permission: crud_level} and returned list(perms.keys()), so the store received a flat list and can("USUA_PERM_EXPEDIENTE") could not distinguish read from write, while the backends gate with min_crud=3. Both reviews reached this point independently and both refused to patch it screen by screen. Fixed (batch 3), in two halves: /auth/me adds permission_levels{} additively — permissions[] is kept, so no coordinated deployment is needed — and the store gains can(permission, minCrud) with an optional minCrud and an explicit fail-closed rule: if the level does not arrive (a session predating the contract, an older backend), the effective level is 0, never "I don't know ⇒ allow it". Applying it meant sweeping every write gate against the real require_permission of its router, and the result corrects the finding's own premise: the vast majority of mutations use min_crud=1, so only four screens needed level 3 (TVD and TRD in their convalidation circuit, deleting a tipo documental and a metadata element, and a new version of the preservation plan). Requiring level 3 where the backend does not would have been the same defect in the opposite direction. It also surfaced that in /bandeja the UI gate is stricter than the backend (return and visto bueno authorize by ownership of the step, with no require_permission): noted, not touched, because that is a different behavior change. Two collateral defects found and closed: radicar/+page.svelte evaluated can() inside a $: with no reactive dependency — can() uses get(), which is not reactive, so that line would never be re-evaluated after a session change within the same mount — and an em dash glued to an optional JSDoc parameter ([minCrud] —) broke the TypeScript parser. Also verified that context/switch does not alter permissions or levels, only active_depe_id.

Other frontend pending items

Every item is recorded with its file and its reason. Apart from the block above, none of them blocks the baseline.

Performance and assets

  • [x] Font Awesome subset: today +layout.svelte imports the full Font Awesome CSS. The app only uses solid and regular icons; replacing it with a subset (or per-icon imports) would lighten the initial download, which is the public landing page's first impression. Closed 2026-09-18: only the core and solid are imported (771 uses of fas/fa-solid, zero of far/fab), with a guardian.
  • [x] Brand asset: static/orpyca-logo.png is the circular emblem (100×97 px), not a full logotype with a wordmark. Until a wordmark exists, the landing page and /login show the emblem at large size and the brand name does not appear above the fold. Also, apple-touch-icon.png is 180×176 (not 180×180 as app.html declares) and is RGBA: iOS composites it over black. Closed (verified 2026-09-17): static/brand/ holds the wordmark lockup (PNG and SVG) and the standalone emblem; static/orpyca-logo.png no longer exists.

DataTable migration

  • [x] /bandeja: this is the system's core flow (process, return, annul, reply, sign-offs). It was deliberately left out of scope: migrating it requires revalidating that whole flow, not just the table's rendering. Decision taken, it is not migrated (the ticket was never ticked).
  • [x] /archivo-fisico: its listing is a tree structure (recursive locations), not a flat table; DataTable does not model hierarchy today. It first requires deciding whether tree support is added to the component or the screen keeps its own rendering. Decision taken 2026-09-19, it keeps its own rendering: the location tree loads children on demand, so DataTable's premise (the whole set in memory) does not hold.
  • [x] Server-side filtering/sorting in DataTable: today filtering and sorting refine the page already served. The component emits filterChange/sortChange so a screen can wire them to a gateway refetch; none does yet. That is why /busqueda does not enable them: it has its own filter form synced with the URL and the backend, and a second local filter would be a duplicated source of truth. Discarded 2026-09-18, the premise is false: 6 of the 7 tables receive the full set, and the screens that do paginate never enable those props; wiring the events to a refetch would be a regression.

Backend still missing to close the UI

  • [x] Alerts/due-dates endpoint: module 03 of the /dashboard has no endpoint of its own. It is derived from the real due_date field (RF-RAD-04) of the already-loaded open radicados — an explicitly declared proxy in dashboard/+page.server.js's docstring, not a simulation. When a cross-cutting endpoint exists (aggregating /signature/pending and /transferencias too, not just documents' due_date), that computation should be replaced: it is isolated to make the swap easy. Closed 2026-09-19: GET /documents gained due_from/due_to and order_by/order_dir, and the dashboard queries by due date; no cross-service aggregator was built (the gateway is a prefix proxy).
  • [x] "Assigned to me" filter / multiple statuses in GET /documents: module 01 makes two calls (status=distributed and status=in_progress) because the endpoint does not accept several statuses at once. With status[] or a real assignment filter it collapses into a single, more precise call. Closed 2026-09-19: status is repeatable in GET /documents; "assigned to me" already lives in workflow-service (/workflows/inbox).

Accessibility and consistency (Medium/Low audit backlog)

  • [x] Raw rgba(...) colors outside the token system in scrims and brand surfaces: Modal.svelte (rgba(33,36,33,.5)) vs Drawer.svelte (.35) vs AppLayout (.4) vs AssistantDock (.25) — four opacities for the same scrim, and the base color does not even match --op-text. Plus the translucent whites in the landing page's hero/CTA/footer. They are not hex values, but they are invisible to a per-tenant rebranding. Closed 2026-09-18: one scrim base color ($op-scrim-rgb) and two meaningful levels (--op-scrim, --op-scrim-soft), with a guardian; the landing/login brand gradients stay out on purpose.
  • [x] A monospace FAMILY token is missing: --op-font-mono is a size (12px), not a family. There are ~16 places with a bare font-family: monospace, precisely on the radicado number, the system's most identifying datum. Add --op-font-family-mono to _tokens.scss and migrate those 16 places. Closed 2026-08-22: --op-font-family-mono, 46 declarations migrated in 27 files, guardian styles/tokens-mono.test.js.
  • [x] <a role="button">: Button.svelte with an href renders a link announced as a button — it does not respond to the space bar. Affects the landing page CTAs, /403 and EmptyState's action links. Closed 2026-09-18: a link is announced as a link.
  • [x] Inconsistent H1 size across screens (--op-font-lg in 5 views, --op-font-xl in 8; none uses v1.0's H1, --op-font-2xl), and touch targets <44px still in DataTable's pagination buttons (28px), its selection checkboxes, the "Columns"/"Saved views" summary (36px) and AssistantDock's controls (~30px). Closed 2026-09-19: semantic token --op-font-title-page applied by a global rule on h1; touch targets are a global rule under pointer: coarse (guardian el-dedo-alcanza-los-controles.test.js).
  • [x] EmptyState reimplements .op-btn, copying Button.svelte's styles "in case Button is not imported": a third definition of the primary button that will drift out of sync. It is used in 9 of 10 screens. Closed 2026-09-18: EmptyState uses Button.
  • [x] ARIA details: aria-controls pointing at nodes that only exist while the panel is open (DataTable, /dashboard); role="list" with children that are not listitem; accessible names on generic <span>/<div> (StatusChip, AppLayout); DataTable checkboxes named by position ("Select row 3") instead of by record; and the in-cell editing aria-live announcement claiming "updated" before the consumer confirms against the gateway (it lies precisely when the gateway returns 403). Closed 2026-09-18 for aria-controls (the panel stays in the tree, hidden) and the KPI role="group".
  • [x] The / shortcut and focus traps: with AssistantDock (aria-modal) or the mobile drawer open, pressing / moves focus to the top-bar search box, outside the dialog's focus trap. Closed 2026-09-18: the guard asks for [aria-modal="true"] in the DOM.
  • [x] AssistantDock promises what it does not deliver: its placeholder claims that "every write action will ask for explicit confirmation", but no such mechanism exists in the component. It is harmless today (the v1 backend is read-only), but the promise must be implemented before enabling write tools, not after. Closed (verified 2026-09-17): the dock mounts ProposalCard and wires the typed-phrase confirmation; the placeholder is gone.
  • [x] Clear favorites on logout: resetFavorites/clearPersistedFavorites exist and are tested but no production code calls them; on a shared workstation the previous user's list survives the logout. Discarded 2026-09-18, the proposed fix would be a regression: favorites are keyed per tenant and user and logout is a full navigation, so in-memory state dies by itself.

Tooling and documentation debt

  • [x] npm run lint never reaches ESLint: the script is prettier --check . && eslint ., and there are 17 files with pre-existing formatting differences that break the chain at the &&. ESLint on its own reports 3 errors + 1 warning, all pre-existing and identical to those on HEAD. Run prettier --write over those 17 files in a separate formatting commit (or split the two scripts) so CI validates ESLint again. Closed 2026-08-05: every file formatted and ESLint now runs in CI.

Object store replacement — MinIO → RustFS (2026-09-25)

  • [x] MinIO archived its open-source project and pulled its images from every registry. The whole stack stopped booting and the installation could not be rebuilt from scratch. Replaced with RustFS 1.0.0 (Apache 2.0). Decision and rejected alternatives in ADR-027. The engine was chosen by tests, not by a datasheet: a WORM conformance battery against a real S3 (services/storage-service/tests/integration/test_worm_object_lock_real_s3.py) — RustFS 7/7, MinIO 7/7 (baseline), Garage 0/7 (negative control).
  • [x] backup.sh and restore.sh were broken and nobody had noticed: they used the minio/mc image, also removed. Migrated to aws-cli. Along the way, retention is now restored with the exact instant instead of days rounded up, which moved the expiry date on every restore.
  • [x] Retention read-back, missing since before this decision: the system ordered the retention and never looked again, yet recorded retention_mode='COMPLIANCE' and a PREMIS event as ok. It is now re-read, and aborts fail-closed on any mismatch.

  • [ ] Pending validation with the stack up (deliberately deferred: the machine could not sustain it). The battery already ran against all three engines in standalone containers, but the full service has not yet been exercised against RustFS.

  • [ ] The MINIO_* names outlive the engine that named them (13 source files, the compose service and the buckets). Kept on purpose so as not to compound the risk of this migration; declared debt, not an oversight.

English translation brought to parity (2026-09-25)

  • [x] docs/en/api.md was 21 endpoints behind the Spanish page. Translated and placed in their sections: third-party directory (duplicates, a record's radicados, merge), E23 templates and document merging, the draft circuit (preview, register, send back), E19 mail accounts and OAuth, signature visto bueno, a radicado's access history, internal file content, and the internal render/digest routes. Cross-check verified: 338 = 338, matching in both directions.
  • [x] docs/en/pantallas.md was missing three screens — /correo, /admin/terceros and the application-wide error page — and the citizen-procedures section. That last one was not formatting: the paragraph explaining why those screens ask for no account (Ley 1755/2015) had been lost and did not appear once in the English page. Restored.
  • [x] docs/en/getting-started.md was missing the signature-chain section. Translated.
  • [x] mcp-flujos.md and plan-pruebas.md exist only in Spanish and, with fallback_to_default: true, MkDocs served them in Spanish inside the English site without saying so. They now declare it in their header, as the changelog declares its own scope.
  • [x] Declared, not hidden: English prose is denser for a handful of endpoints — a bullet where the Spanish page gives a section with more rationale. No endpoint, parameter, status code or gate is missing; some of the why is. The scope note sits at the top of docs/en/api.md.