Roadmap¶
Updated as of 2026-10-03.
OrpycaMCP has followed two complementary plans:
- Foundational roadmap (Phases 1–6, below "Scaffolding"): stood up the 8 microservices with their basic CRUD, infrastructure and CI/CD. Complete.
- 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 writesflujo_dept/flujo_dept_code/flujo_usuarioin the sameUPDATEas the status,ubicacion_*are generated columns, andGET /documentsand/searchfilter by?ubicacion_dept_code=. Migrations037(document) and016(workflow, backfill with evidence), the "Current location" reports, informational INT-01 export and MCP: ✅. Frontend ✅ (/bandeja,/busquedawith 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.statusin the same transaction as the step: distributing, transferring, returning, rolling back and reassigning leavedistributed; the holder'smarcar_leidoleavesin_progress; and only "Finish processing" (POST /workflows/{id}/finalizar) closes (closed).closedandanuladoare terminal (409 radicado_terminal) and the radicadoPATCHrejectsstatus(422). Backend, MCP (finalizar_tramite), migrations (backfill013) 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);rollbackonly frompending; completing a step without marking it as read leavesdistributed; completing anin_progressstep leavesin_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/transferrequire clearance and holding the step,finalizarrequires level 3 andmotivo_cierre, annulment cancels the step (and migration015cleans up zombies), import rejectsanulado(+ migration034CHECK), 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_unavailablewithoutANTHROPIC_API_KEYor on SDK failure — never a simulated answer, RF-FIR-15). - Connected to E21 ✅: the
buscar_conocimientotool (POST /api/v1/knowledge/searchvia 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'ssubject/senderwith 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; acreatedevent never lowers a level). Purge-on-annulment ✅ (document.radicado.annulled→ chunk permanentlyvoided, excluded from search at any clearance; a latecreatednever re-indexes an annulled radicado). - Real embedding provider ✅ Inc. 1: a pluggable
EmbeddingProvider(local_stmultilingual sentence-transformers, local/sovereign, the deployment default; deterministicstub, forced default in tests; minimalollama, deferred) replaces the stub as the only provider —EMBEDDING_DIM(384) is the single source ofknowledge_chunk.embedding's dimension, with a fail-closed startup guard, migration007(nulls the incompatible dim-64 vectors +vector(384)+ HNSW index) and a backfill job (python -m app.ops.reembed).is_embeddablegeneralized toLOCAL_EMBEDDING_PROVIDERS: withlocal_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(queryXORradicado_ref) reusessearch(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_localsovereign default, minimal-viable externalollama_cloud/openai_compatible,anthropicwith the native Citations API,disabled→ Pattern A) behindPOST /knowledge/rag. Hard sovereignty barrier for GENERATION (decision taken 2026-07-20, reconciled in ADR-006):is_generatableexcludes chunks withnivel_seguridad >= RESERVADAfrom the context when the provider is external — fail-closed, with no override, unlike the ADR's original soft model.kb_suggestionstable (migration008) + anaudit_logentry. Honest degradation with503 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-formatowith 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 staysno_verificadoand is not promoted either. There is no "stub migrator": a stub would have to invent the file, so with no converter the endpoint answers503. The PREMISMIGRACIONevent 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 viaMINIO_SECONDARY_*— triggered bynum_copias≥2, non-fatal). - tenant-service without superuser and with audit ✅ (2026-10, T7): runs as the application role (
POST /tenantsno longer runs DDL; provisioning isinit_tenant.py), atomic audit for institution create/status change,409when renaming/deactivating a department with live cases,antes/despueson parameter changes. Pending: verify with the realorpycamcp_approle (local stack runs as owner by user decision) and the ADR to storecodigoinstead ofnombre. - 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 (migration020,key_version; scheme0was retired in B9, migration024: earlier rows no longer verify and/verifysaysesquema_hmac_retirado), the credential state is resolved outside the row lock,/verifyapplies no-read-up and is no longer called "public",acreditadocomes 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-xmlsignature ever verified. Closed afterwards (2026-10-04, user decisions): the XAdES policy became an entity parameter (firma_personal_require_xades, tenant-service migration010, 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 withadvertencias[]in/verify; a chain overradicado/expedientesigns the content (current PDF / current index, resolved by an internal endpoint in document/archive) and detects when it changed (409 documento_modificado); andfirmar/rechazar/vobo/challengecheck membership before state (an outsider cannot probe the state). Pending: HSM/KMS — pending a provider; not implemented. The hook exists inservices/auth-service/app/core/signing_keys.py::sign_digest(the single point a PKCS#11/KMS provider would replace) and inservices/signature-service/app/providers/base.py(institutional-seal provider); enabling theclasificadosgate 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-xmlrequiresX-Internal-Tokenfor the institutional seal andobjeto_tipois a closed set (since B9-3 the seal only: human signing of radicado/expediente/draft goes through the chain exclusively); storage seals/aip,/proteger-indiceand/proteger-artefactobehind the internal token, the gateway routes/preservacion/through an allow-list, a second protection with anotherfile_idanswers409, andlegal_holdleaves the body and is governed bylegal-hold-internal(motive + atomic audit entry). Pending: an archive user endpoint orchestrating the legal hold (permission TBD) and raisingPERM_FIRMA'smin_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}/radicadosand/respuestasdeclare and honournivel_seguridad; drafts inherit the parent level (migration 035) and are gated in theWHERE; draftPATCHis audited; the declarative signature requires level 2, resolves the hash server-side and audits atomically. Guardiantest_contenido_declara_su_nivel. Pending a decision: retire the signature POST with410; ownership when editing a draft;nivel_seguridadonSearchHit/RadicadoMetaItem. - Metadata: declared debt (2026-10, B6, correction round): (1) a document type without an active template stores
metadataunvalidated (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 theWHEREper recipient; (2) signature consumesdocumento.contenido_cambiadoand voids only the circuits over a text that is no longer current; (3) knowledge gates with the radicado's current level (GREATESTin theWHERE) 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-LTAArchiveTimeStamprenewal with WORM inheritance and PREMISRENOVACION, 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 afterremote_sign(recorded and reported); a consumer forfirma.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 onlyfirma.lta_*entries and the log;firma_sello_archivo.estado/ultimo_avisohave no endpoint);/verifydoes not evaluate theTimeStampValidationData;radicados/auth_usersunqualified withpublicin thesearch_path(SEG #12). Audit corrections (2026-10-17):404 route_not_foundinstead of500for paths that do not stabilise; signedocurrido_atin the postal callback (operator's notice ≠ date of the event),413also 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;/antecedentespivot gated in theWHERE; 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/validatereportstenant_estadoand 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 sixget_tenant_connre-check it before thesearch_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 answer503 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 inget_db_conn; the signed identity carriesX-Identity-Tsand expires after 120 s; login and refresh issue no token to a non-operational entity and the mcp-server propagates the entity code;derive_canaluses the token's tenant, not the client header. Pending: the frontend must show «Entidad suspendida» on403 tenant_inactivoat 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/readyonly fromedgeitself,TRUSTED_PROXIES=172.20.128.0/17without the network's gateway, third-party images by tag and digest, a complete.env.example;restore.shfails closed on anypg_restoreerror other than the inherited PKs and checks each protected bucket's Object-Lock before touching data;backup.shrequires the bucket of every tenant not decommissioned (active or suspended) in the mirror;restore.shrequiresRESTORE_OPERADOR, runs the Object-Lock pre-flight before stopping anything, only toleratespg_restorewith 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 ifTRUSTED_PROXIEScovers the network's gateway;POST /challengeapplies the same preconditions as/sign(personal credential, current fingerprint, visto bueno, annulled) and/auth/mefor an entity without a schema is 200 with empty permissions. Pending: a realnginx -t(it also confirms thelisten [::]) and bringingedgeup on a server; **(SEG-M3 closed in B9, ADR-035); upgrading Keycloak 24.0.5 (out of support); (theget_tenant_conn500 for a tenant without a schema and/auth/meof asuspendedtenant 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
Transformson the referencessignxmlleaves without them,SignedInfosigned after the patch (seal and personal signature), fail-closed self-verification with a defaultXAdESVerifier(), no shortcut when verifying, andArchiveTimeStampinput 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_vencidobefore the TOTP is consumed);/verifyof an LTA whose certificate expired after signing judges it at theSignatureTimeStampdate, with a warning; time stamps are verified with the canonicalization they declare. Validated live:SignatureIntactand everyDataIntact,INDETERMINATE/NO_CERTIFICATE_CHAIN_FOUNDbecause of the development CA. Pending: trust (chain to a trusted list / ONAC), bringing the DSS smoke into CI, renewing theArchiveTimeStamp(EN 319 132-1 §5.5.3), re-readingnivel_seguridadunder 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;
importneither moves the counter with another entity's numbers nor overflows it; shipments at level 3 with an audited actor and a previous-stateUPDATE; 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):
avalarandejecutaron an elimination batch requireUSUA_PERM_TRDlevel 4, and whoever proposes does not endorse nor does whoever endorses execute (409separacion_de_funciones, in the transition'sWHEREand before the first deletion); publish/endorse/withdraw/execute atomic with their audit entry; public batchGETwith no institution-enumeration oracle; the record's WORM with scheduled retry + its own sweep and required before destroying (409acta_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 aprestado_a_user_idwith a clearance gate in theINSERT: changes the contract and needs archival-compliance-auditor validation), propagating a later re-typing to the index (document.radicado.retypedevent), andbatch.pystill accepting client-supplied number and dependency. - Shared DLQ and Redis Streams consumers ✅ (2026-10, audit of 2026-10-02, T9):
evento_dead_letterprimary key(origin_group, event_id)(the second group's parking is no longer lost) with an idempotent migration in the 4 services and per-groupON CONFLICT; knowledge gains the admin surface for its DLQ (/api/v1/knowledge/admin/deadletter, atomic replay ofpurged/annulled); per-replicaevents.consumer_name(); orphaned dead-letters keeplast_error;X-Truncatedwithout false positives;/health/readywithdead_letterand last sweep. Pending: Prometheus metrics/alerts (operations decision) and clearance in theWHERE+ keyset pagination for the listing. - DLQ clearance gate inside the
WHERE(AREA-10-08) ✅ (2026-10): single sourceorpycamcp_common.deadletter, exact pagination and count with no 1,000 cap, indistinguishable404,X-Truncatedretired; knowledge gains terminal-DLQ retention and anoccurred_atsecond 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 migration012), is served byGET /ventanilla/politicawith a strict read (503politica_no_verificable) and re-read when filing (409politica_desactualizada/politica_no_publicada, before spending a sequence number); a shipment's receipt must have been uploaded by whoever records it (403acuse_ajeno) and not already be something else (409acuse_ya_referenciado, unique index045). Erasure-versus-retention text innormativa.mdand 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(signedX-Origen-Canalmark bound to tenant and token, verified by the gateway;audit.appendinherits the request's channel) and carriespayload.correlacion(request_id+ the proposaljti);POST /assistant/messagereturnsno_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_localis sovereign only ifOLLAMA_URLis local (with a public host RAG excludes level >= 2);POST /knowledge/suggestions/{id}/aceptar(CC-02, only whoever generated it, atomic with its entry, migration012); 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 channelmcpwhen rebuilt, and RAG streaming. Expediente reclassification does not exist, so there is nothing to propagate to the index (guardian inshared/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_stepsat level 3;get_actoron the workflow DLQ; Devolver mirrorsPERM_TRAMITAR; cross-entity isolation of the notification DLQ tested. Closed 2026-10-02: the webhooksecretis encrypted at rest (AES-256-GCM with the service's own versioned KEK, tenant+webhook AAD, re-wrap throughapp.ops.rewrap_webhook_secrets, scheme shared inorpycamcp_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_ADMINlevel 3; joining a group applies clearance no-write-up in theINSERT;PATCH/DELETE /groups/{id};typ/azp/mandatory claims in the JWT; gateway validation cache with realexpires_inand sealed with a MAC;/audit/verifywithouttotalor 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, raisingmin_crudon 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 toestado(archive migration029), atomic mark+entry; storage gains idempotency (partial unique index, migration009, + short-circuit + a session advisory lock per(tenant, expediente)preventing duplicate irreversible WORM objects under a race) and the internalproteger-indice-internalendpoint 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-indiceis generalized toproteger-artefactowithtipo ∈ {indice, acta_transferencia}(storage migration010;/proteger-indice[-internal]remain as hard aliases), archive gainsproteger_worm_acta(best-effort synchronous trigger + a fourth phase,run_once_actas_worm) withworm_*columns intransferencia_acta_firma(archive migration030), TRD TOTAL retention withfecha_inicio = firmado_atof 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_holdremains unconditional, fail-closed on an unclassifiedtipo(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_untilelapsed — a newPOST /preservacion/renovar-retencion-internalpair (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 usesset_object_retention(verified against real miniopy-async 1.23.5) — storage migration011. archive gains a fifth reconciliation phase (run_once_worm_renovacion): a localworm_retain_untilmirror (migrations031/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 anagotadostate (higher-severity alarm: risk of the lock expiring over a perpetual record). Fail-closed by design: onlydisposition ∈ {CT, conserve}is renewed — E/S/M expire and are legitimately disposed of. Audit remediation: the public/renovar-retencionroute was withdrawn (irreversible surface with no caller → internal only), the renewal entry is atomic, and a new visibility endpointGET /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):
SubprocessVeraPdfValidatorruns 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 seesPATH+JAVA_TOOL_OPTIONS, neverDATABASE_URL/MINIO_SECRET_KEY/tokens),start_new_session+killpg+reap guaranteed infinally(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 thenot_evaluatedstub when the binary is absent (RF-FIR-15). The JRE + veraPDF ship only in the deploy image (multi-stagebase/productionDockerfile, 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/aipexisted 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+firmadoindex +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_aiptable (archive migration033), a sixth phaserun_once_aip, storage migration012. - 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 inexpediente_aip(archive migration034),AipWiringService.renovar_worm_aip, fail-closed CT filter in SQL, rolling window, a third advisory lock;renovar-retencion-internalalready acceptedtipo='aip';pendientes-renovacioncovers 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 inxades:OCSPValuesand covered by theArchiveTimeStamp,certStatusderived from the CRL, fail-closed verification with responder pinning;acreditado=false, opt-in viaSIGNER_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
SignedInfodigest with the real key while atomically consuming the TOTP, so the private key never leaves = a seam to an HSM; enrollment requiresPERM_FIRMAand offline issuance with aca.keythat is never mounted; degrades to native HMAC without a certificate;acreditado=falseALWAYS. 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 atomicsigning_key.revocadaentry; 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_xadesgate with three modes (off|clasificados|todos, defaultofffor backward compatibility) — closes Inc. 2's fail-open by making it opt-in per security level: no certificate →422 firma_xades_requerida, auth down →503fail-closed, denial with an atomicfirma.xades_requerida_denegadaentry that does NOT consume a 2FA factor. Rollout: enableclasificados/todosONLY 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 frozenacta_fuid_xml(POST /transferencias/{id}/acta/firmar-personal, via the newPOST /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; therolparameter 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 explicitauto_trasladoflag,completitudhas five values withfirmada_completa= both handover signatures present, and the drafter is an additive signer reported in an orthogonalelaborador_firmadoflag. Migrations027/028; theacta_firma_personal_requiregate (defaultoff) never blocksrecibir(). 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_versioncolumn 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--verifycount of 0; migration013. 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) nullsprivate_key_cifrado/nonceon revoked rows while keeping everything public (verification and revocation status stay identical); migration014; 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_acreditadotoday), a fully EN 319 132ArchiveTimeStampprofile (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.xsdfiles 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 fixessearch_pathand 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 viaUNIQUE (operador, event_id); atomic transition +audit_logentry; Redis event for E16 after commit; a late or backwards notification →recorded_no_change(honest, RF-FIR-15).motivo_devolucionondevuelto; per-shipment lookupGET /envios/{id}/tracking(pull, stub). Tenant migration017. - Declared debt: a concrete E11 connector per operator (
consultar_estadois a stub today), the delivery acknowledgement (acuse_file_idwith 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 ondevuelto.
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 9pinar_*tables (tenant migration007): 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_pctprojected from the latest record), and articulation with instruments. - State machine
borrador→aprobado→en_ejecucion→cerradowith 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 IMMUTABLEaudit_log(E08 hash chain) viaorpycamcp_common— as are create/execute/close. Content is FROZEN on approval (_EDITABLE = {borrador}); structural mutations (only inborrador) are audited inadmin_audit. Invariants and the cross-schemaaudit_logwiring were validated against real PostgreSQL. Audited for security (2 Medium) and conformance (2 Medium, Ac. 003/2015) — no blockers, all remediated. To installorpycamcp_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 whenaprobado/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 fromarchival-compliance-auditor):UNIQUE(code)→UNIQUE(code, version);trd_seriesgainsestado(borrador/aprobada/convalidada/migrada/derogada) plus the administrative-act fields (Comité approval, Consejo convalidation, RUSD, publication); thetrg_trd_series_append_onlytrigger rejects substantiveUPDATE/DELETEoutsideborrador(explicit exception:pdfa_profile) and, since038, hardens aconvalidadarow at the engine level: the act is immutable,estadomay only move toderogada, andrusd_radicado/fecha_publicacionare fill-once.PATCH /trd/{id}no longer recomputes retroactively — it applies only toborrador;POST /trd/{code}/versionesclones the current row as a new one.expedientes.trd_serie_idis frozen at CLOSURE (trg_expedientes_trd_pin_immutable) — closing the retroactive leak that motivated the finding. Honest backfill: every pre-existing series becomesestado='migrada', and expedientes closed under it are flaggedtrd_revision_requerida. - Convalidation circuit:
aprobar(borrador→aprobada, requires the Comité minutes),devolver(aprobada→borrador, mandatory reason — closes the dead end ataprobada),convalidar(aprobada→convalidada, repealing the previous current version in the same transaction with collision-safe re-pointing of orphaned FKs; ormigrada→convalidadaas ratification),registrar-rusd, andderogar(convalidada/migrada→derogada, blocked whileopenexpedientes remain under the code). GET /trd/revision-pendiente— no-read-up by clearance + query trace added in038(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 thearchival-compliance-auditorfinding "the whole model is TRD-only, an accumulated fonds has nowhere to put its TVD"): the TVD is the same instrument as the TRD — atipo_instrumento ∈ {TRD, TVD}discriminator overtrd_series, not a table of its own (D1: forkingexpedientes.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 sharedcodespace with TRD (409 instrumento_code_en_uso, naming the occupant's type); (2) two viewsv_trd_series/v_tvd_agrupacioneswithWITH CHECK OPTION—list_trd_seriesnow readsFROM v_trd_series, closing the "forgotten filter" class of bug; (3) per-type CHECKs in both directions (a TVD requiresfondo_nombre/extreme dates/justificacion_valoracion+archivo_gestion_years=0; a TRD requires those four to be NULL); (4) thetrg_expedientes_pin_tipotrigger 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/tvdrouter is a 1:1 mirror of/api/v1/trd(same full circuit), withtipo_instrumentofixed by the router, the reusedUSUA_PERM_TRDpermission and distinct audit entries (archive.tvd_*). TheGET /api/v1/{trd,tvd}/{code}/versionestwin (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 toorigen↔tipo_instrumento,compute_retentionwith an explicit base date (fecha_extrema_final, neverclosed_at— D4, the design's highest-risk point), and a snapshot carryingtipo_instrumento/base_date_origen. Increment 3: clamping an already-expiredretain_until, and a gate requiring prior publication of the deletion inventory. -
Increment 1 frontend ✅: the
/admin/tvdscreen + 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
045sweeps 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), chainedarchive → 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 harvestingfrom/until/set(set = doc_type), paginatedresumptionToken, 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_PUBLICfilter) → neither the service nor the gateway ever sees a reserved record, so the omission is INDISTINGUISHABLE by construction:completeListSizecounts public records only,ListSetsdoes not enumerate a reserved-only set, andGetRecordover a reserved id returnsidDoesNotExist. Identifieroai:{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 rootcmis:folder,maxItems/skipCountpagination.getContentStreamserves the attachment's bytes (streaming viastorage_client) only if the radicado is public (consistent with Ley 1712: public information is downloadable). Same_PUBLICchokepoint 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/exportin document-service (authenticated,USUA_PERM_EXPEDIENTEgate + 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}.jsonvalidated against that schema (system + contextual E03 metadata + TRD disposition + ACL level/classification + attachments +audit_loghistory), the attachment binaries, andchecksums.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 asaction/fecha/actor/canalwithout the payload (which may carry the classification grounds). Remediated: egress auditing (radicado.exportinaudit_log, content-free), the E↔Santecedenteas a portable number, an anti-DoS cap (422 above 5000 items), a purged attachment recorded as a traceableausenteentry. - Interoperable import of radicados (INT-05) ✅ (RF-INT-05; closes the spec's Critical finding):
POST /api/v1/import(authenticated,USUA_PERM_EXPEDIENTEgate + 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 →422with 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 auditsradicado.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=eadon 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 reusesOaiRepositoryand its_PUBLICchokepoint → the format is ORTHOGONAL to the security trimming (choosingeaddoes not bypass the filter or create an oracle); theresumptionTokenpreserves themetadataPrefix. - 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 thetrd_series.parent_idchain (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 atlevel="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-levelreclassifiesnivel_seguridadwith thePERM_RECLASIFICARpermission + 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 byPERM_RECLASIFICARin 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 inradicados(migration018); 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 byPERM_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.createresolves the registrar's clearance and returns403 clasificacion_forbiddenifnivel_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 /expedienteslisting and inPATCH /{id}is closed (they filtered with the "no restriction" default); both now apply no-read-up with a filteredCOUNT/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 narrowedExpedienteDisposicionResultthat exposes no content. (M2)link/batch/unlink/rebuildresponses 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 /expedientesapplies no-write-up with an explicit403(not a clamp). - Atomicity of mutation ↔ audit entry ✅ across expediente creation (single and batch),
link/unlink/link_batch, andclose/transfer/recibir(the DB leg is wrapped inconn.transaction()with the entry as the last write; the XAdES sealing HTTP call is post-commit, best-effort). Also closed: the disposition TOCTOU (transition_expedientegainedexpected_status), the twinTransferenciaService.recibirroute (which froze the expediente with no transaction, event or entry), the missing actor in the transfer entry, and the alternatePATCH /expedientes/{id}route that ran the same transitions with a bareUPDATE—statuswas removed from the schema (withextra="forbid") and from the repository, as it was the second unaudited writer ofexpedientes.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/rechazarwrap mutation → event → entry inconn.transaction()with entries for both success and denial;transferencias.update_estadogained an expected-state guard, closing the lethalrechazar-vs-recibirrace; identity is unified inapp/core/actor.py(X-User-IdUUID 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_mismatchthat 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 (migration023) and theacta_fuid_sha256anchored in theaudit_loghash 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, fromtransient); a reconciliation job (lifespan task +python -m) with exponential backoff (migration025), a per-tenant advisory lock and idempotency; andGET /expedientes/indices/pendientes-firma(no-read-up, includesagotado) for operational visibility. System attribution: an internal routePOST /signature/internal/sign-indice(onlyX-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 stayspendiente_firmaindefinitely (reconcile_agotado, requiring human intervention) —firmadois never faked. Deferred: proactive alerting onagotadoitems.
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
qtext, 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_busquedafor list/search/replies +document.radicado_consultado) and processing in workflow-service (workflow.bandeja_consultada+workflow.hoja_ruta_consultada/tramite_consultado). All three useobject_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) + aLEEME.txtscope note +checksums.txt(SHA-256) + the attachment bytes; PER-RADICADO no-read-up (document-service filtersnivel <= clearanceand omits over-clearance radicados including their existence — closing a read-up leak that the expediente-level gate did not cover),USUA_PERM_EXPEDIENTEgate, 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.codigois 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"; tenant013/ document046/ workflow019migrations with fail-closed backfill and anchored evidence. Pending: remove the name alias (*_deptfields,?dept=) and the name indexes once theDeprecationheader is at zero for one cycle. Pending (repair): a manual mapping registered AFTER046/019is 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↔/loginredirect 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_ITEMSinlib/utils/navItems.jsis the single source shared bySidebarNavand 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
/adminpanel 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'shtml); with the backend absent it degrades to503 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'sjson_schemathrough a BFF proxy and renders dynamic fields (schemaField.jsmaps JSON Schema → control + coercion;SchemaField.svelte), populating themetadata:{}that used to travel empty;radicarwas migrated to the same single source, closing a real gap (its inline dynamic field only covered number/date/text, so anenum/booleandegraded to free text → 422). ~~Deferred~~ ✅ partly closed (AREA-12-05, 2026-10):arrayof scalars (repeatable list), one-levelobjectand per-field mapping of the 422 (the envelope carriesfield, a dotted path); still open: migrating views with inline metadata (expedientes/[id], inbox) toSchemaField, arrays of objects and nested objects. - RAG integrated into drafting and classification ✅ (F6, E21): a "Suggest from precedents" button in
/radicarand/borradorescallingPOST /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.scssrepainted 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-primarynon-textual 4.28:1 /--op-primary-darktext 6.56:1); v1.0 type scale, radii, shadows and motion + a globalprefers-reduced-motionrule; 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/dashboardredesigned around four personal modules with tenant metrics relegated to a collapsible section gated bySGD_PERM_ESTADISTICA;DataTablewith the eight mandatory capabilities (filters,aria-sortordering, CSV export with formula-injection protection, configurable columns, saved views, multiple selection,content-visibilityvirtualization, in-cell editing), all opt-in. -
Test coverage: ~1300 passing Vitest tests across 117 spec files + a Playwright E2E smoke suite;
svelte-checkreports 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.jsunder(app)(was 21 hand-picked proxies), a cookie-flags test (httpOnly/sameSite/secure),admin/+layout.server.jsredirecting to/403by each area's REAL permission, preservation state shown in the expediente tab (GET /preservacion/estado),SchemaFieldwith array/object andfieldin the 422, CSS tokens (--op-on-brand-*,layout.scsson 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 declarescabecera_completa; the receipt's fixity check is a PREMIS event (with reconciliation);REVOKEof UPDATE/DELETE on the record and aCHECKonobjeto(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): thefirma_inobtenibleact (07-10, migration059) and CT renewal of the record's personal signatures (060). Audit remediation (2026-10-09, ADR-030, archive061/ storage019): 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),preservationLevelandrightsin 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-Keyonly): 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 +CHECK041 + 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 fordocumento.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 ofdocumento.contenido_cambiadoin signature-service~~, ~~a notification alert fordocument.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 andnpm audit. The backup verifies the audit chain before anchoring it and does not anchor if it fails (app.ops.verify_audit_chains, also used byrestore.sh: its heredoc blew up), the restore fails if a retention or legal hold is left unrestored and emits theversion_idmap; auth's/health/readyflags 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_denegaciondefined once inorpycamcp_common.rbac(ADR-013 corrected: auto-provisioning is already decided), andaudit.appendwith no defaultcanal(191 callers rewritten;CANAL_PETICIONinherits the gateway's,systemfor jobs, CLIs and consumers, pinned bycanal_de_fondo()in each background task); theobject_reffilter ofGET /auditresolves 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.mdand ADR-002 stop prescribing Alembic/SQLAlchemy and an unvalidatedSET search_path; institution onboarding is documented throughscripts/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.mddowngrades Kubernetes to "not supported" andDOCKER_REGISTRY.mdmoves to GitLab; the EN roadmap matches the ES open items and the ENsecurity-audit.mdgains the second pass. Code: per-unit loans paginated (+ BFF), signature migration022(xades_levelCOMMENT), the OAI EAD validated against the official XSD (21 real demo records, 0 invalid),scripts/carga/busqueda_sla.py(p95 of/searchagainst RF-BUS-09: 3 s simple / 10 s complex; p95 ≤ 0.2 s over 222 radicados) and 20 documentation guardians. Open: thewebhook_entregastable (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)createJobPollermutated 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 → permanent404. Fixed (batch 1): polling moved to reassigned component-levelletvariables, the URLs to the real nested shape (/batch/documents/{jobId}/statusand/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 withFORWARDED_REQUEST_HEADERS. - [x] Three
/admincards lead to a403— the same pattern already fixed inColas, which turned out not to be an isolated case. Fixed (batch 1) by checking all 16 cards against the realrequire_permissionof their router, not just the three observed:TRD / CCDandMetadatosmove toUSUA_PERM_TRD, andInteroperabilidad— whose gate is heterogeneous per tab — now opens withUSUA_PERM_EXPEDIENTE || PERM_RADIand 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 inmetadatos/claiming that thedocument-servicerouters 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/claveswas missing from the index, which presents itself as the complete administration map. Fixed (batch 1): both added toADMIN_AREASwith 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
.gitignorepatterndocumentos/— written for thedocumentos/folder at the repo root — carried no leading slash, so it also hidfrontend/src/routes/(app)/admin/interoperabilidad/api/lotes/documentos/: the folder stayed out of the Phase 8 commit despite being documented as delivered, and onmainthe 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 threeSteppers (2FA, PKI credential, signing), the active turn in the signature chain and the filter chips in/borradoresand/envios— which were literally inverted, with the dark tone on hover — now use--op-primary-dark(6.56:1), following whatButton.sveltealready got right. Four instances of the other failing pair were fixed as well:--op-text-secondaryon--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-visibleonDataTableandCatalogCrudPanel— which propagate it to 8+ and 7 screens respectively — and on ~20 raw table wrappers;bandeja-table, the only one withoverflow: hiddenand no scrolling mechanism at all, gets its own wrapper withoverflow-x: auto. The remainingoverflow: hiddencontainers wrap aDataTablewhose scrolling lives in the inner wrapper, so no tab stop was added to them: it would be mute and redundant. Every newtabindextripsa11y-no-noninteractive-tabindex, a rule that does not recognize WCAG technique SCR29; silenced with a justifiedsvelte-ignorerather 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 throughFormField'serrorprop (aria-invalid/aria-describedby), and the five OTP code fields gaininputmode="numeric"+autocomplete="one-time-code"— which required extendingFormFieldwith those two props, as they did not exist. - [x]
CatalogCrudPanelapplieddisplay: flexto 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.jsremains 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: migration015;activateissues 10 single-use scrypt-hashed recovery codes (same transaction as the activation), accepted as step-up inenrollandDELETE /me/totp, plusPOST /me/totp/recovery-codesto regenerate the batch (seetotp_service.py/totp_repository.py). The "0 occurrences" statement above is obsolete. -
[x] Contract root cause —
/auth/mediscarded the CRUD level. The router resolved{permission: crud_level}and returnedlist(perms.keys()), so the store received a flat list andcan("USUA_PERM_EXPEDIENTE")could not distinguish read from write, while the backends gate withmin_crud=3. Both reviews reached this point independently and both refused to patch it screen by screen. Fixed (batch 3), in two halves:/auth/meaddspermission_levels{}additively —permissions[]is kept, so no coordinated deployment is needed — and the store gainscan(permission, minCrud)with an optionalminCrudand an explicit fail-closed rule: if the level does not arrive (a session predating the contract, an older backend), the effective level is0, never "I don't know ⇒ allow it". Applying it meant sweeping every write gate against the realrequire_permissionof its router, and the result corrects the finding's own premise: the vast majority of mutations usemin_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/bandejathe UI gate is stricter than the backend (return and visto bueno authorize by ownership of the step, with norequire_permission): noted, not touched, because that is a different behavior change. Two collateral defects found and closed:radicar/+page.svelteevaluatedcan()inside a$:with no reactive dependency —can()usesget(), 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 thatcontext/switchdoes not alter permissions or levels, onlyactive_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.svelteimports the full Font Awesome CSS. The app only usessolidandregularicons; 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 andsolidare imported (771 uses offas/fa-solid, zero offar/fab), with a guardian. - [x] Brand asset:
static/orpyca-logo.pngis the circular emblem (100×97 px), not a full logotype with a wordmark. Until a wordmark exists, the landing page and/loginshow the emblem at large size and the brand name does not appear above the fold. Also,apple-touch-icon.pngis 180×176 (not 180×180 asapp.htmldeclares) 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.pngno 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;DataTabledoes 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, soDataTable'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 emitsfilterChange/sortChangeso a screen can wire them to a gateway refetch; none does yet. That is why/busquedadoes 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
/dashboardhas no endpoint of its own. It is derived from the realdue_datefield (RF-RAD-04) of the already-loaded open radicados — an explicitly declared proxy indashboard/+page.server.js's docstring, not a simulation. When a cross-cutting endpoint exists (aggregating/signature/pendingand/transferenciastoo, not just documents'due_date), that computation should be replaced: it is isolated to make the swap easy. Closed 2026-09-19:GET /documentsgaineddue_from/due_toandorder_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=distributedandstatus=in_progress) because the endpoint does not accept several statuses at once. Withstatus[]or a real assignment filter it collapses into a single, more precise call. Closed 2026-09-19:statusis repeatable inGET /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)) vsDrawer.svelte(.35) vsAppLayout(.4) vsAssistantDock(.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-monois a size (12px), not a family. There are ~16 places with a barefont-family: monospace, precisely on the radicado number, the system's most identifying datum. Add--op-font-family-monoto_tokens.scssand migrate those 16 places. Closed 2026-08-22:--op-font-family-mono, 46 declarations migrated in 27 files, guardianstyles/tokens-mono.test.js. - [x]
<a role="button">:Button.sveltewith anhrefrenders a link announced as a button — it does not respond to the space bar. Affects the landing page CTAs,/403andEmptyState's action links. Closed 2026-09-18: a link is announced as a link. - [x] Inconsistent H1 size across screens (
--op-font-lgin 5 views,--op-font-xlin 8; none uses v1.0's H1,--op-font-2xl), and touch targets <44px still inDataTable's pagination buttons (28px), its selection checkboxes, the "Columns"/"Saved views"summary(36px) andAssistantDock's controls (~30px). Closed 2026-09-19: semantic token--op-font-title-pageapplied by a global rule onh1; touch targets are a global rule underpointer: coarse(guardianel-dedo-alcanza-los-controles.test.js). - [x]
EmptyStatereimplements.op-btn, copyingButton.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:EmptyStateusesButton. - [x] ARIA details:
aria-controlspointing at nodes that only exist while the panel is open (DataTable,/dashboard);role="list"with children that are notlistitem; accessible names on generic<span>/<div>(StatusChip,AppLayout);DataTablecheckboxes named by position ("Select row 3") instead of by record; and the in-cell editingaria-liveannouncement claiming "updated" before the consumer confirms against the gateway (it lies precisely when the gateway returns 403). Closed 2026-09-18 foraria-controls(the panel stays in the tree, hidden) and the KPIrole="group". - [x] The
/shortcut and focus traps: withAssistantDock(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]
AssistantDockpromises 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 mountsProposalCardand wires the typed-phrase confirmation; the placeholder is gone. - [x] Clear favorites on logout:
resetFavorites/clearPersistedFavoritesexist 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 lintnever reaches ESLint: the script isprettier --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 onHEAD. Runprettier --writeover 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.shandrestore.shwere broken and nobody had noticed: they used theminio/mcimage, also removed. Migrated toaws-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 asok. 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.mdwas 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.mdwas missing three screens —/correo,/admin/tercerosand 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.mdwas missing the signature-chain section. Translated. - [x]
mcp-flujos.mdandplan-pruebas.mdexist only in Spanish and, withfallback_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.