Skip to content

Roadmap

Updated as of 2026-08-04.

OrpycaMCP has followed two complementary plans:

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

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


specDrive plan — SGDEA conformance (current)

Global status: the core of all 21 epics is implemented, tested in Docker (~300 passing tests across 11 microservices) and with migrations validated against real PostgreSQL/pgvector. 25 ADRs recorded (see Architecture decisions).

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)

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 catalog's read-only tools as gateway calls re-propagating the user's token (as-the-user; every tool revalidates RBAC/clearance). Prompt-injection boundary (tool content is data, never instructions), RF-SEG-08 non-oracle responses, ephemeral history isolated per tenant, and honest degradation (503 assistant_unavailable without ANTHROPIC_API_KEY or on SDK failure — never a simulated answer, RF-FIR-15).
  • Connected to E21 ✅: the buscar_conocimiento tool (POST /api/v1/knowledge/search via the gateway, read-only) performs semantic retrieval as-the-user with server-side ACL; results are labelled as hints from a derived index, to be confirmed against the authoritative source (ADR-006).
  • Deferred: SSE streaming; persistent history (today in-process memory, not multi-replica → move to Redis); enabling write tools behind double confirmation.

E21 — Knowledge layer (knowledge-service)

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

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

  • Real Object-Lock/WORM on MinIO ✅ Increment A (ADR-023: per-tenant preservation bucket, signed index under Retention(COMPLIANCE) derived from the TRD, disposition gate, legal hold).
  • Real AIP + PREMIS v3 ✅ Increment B (BagIt RFC 8493 with real bytes + recomputed fixity, uploaded to the WORM bucket and covered by the disposition gate), full PREMIS v3 conformance ✅ Increment C1a (validated against the LoC's official vendored premis.xsd), and a best-effort AIP replica ✅ Increment C1b (one immutable WORM copy to a configurable target — by default a second bucket on the same MinIO, honestly labelled "local replica, not cross-site"; cross-site via MINIO_SECONDARY_* — triggered by num_copias≥2, non-fatal).
  • WORM wiring of the index on closure ✅ Inc. 1 (Ac. AGN 001/2024 art. 4.3.2.6): the WORM capability existed but nothing triggered it → the signed index was deletable despite its seal. archive now protects it after signing at closure (best-effort synchronous trigger + a third reconciliation phase, run_once_worm, which never blocks closure), with a TOTAL retention derived from the TRD (gestión+central, fecha_inicio=closure) and fail-closed when there is no TRD (never a default over an irreversible COMPLIANCE lock); worm_* columns orthogonal to estado (archive migration 029), atomic mark+entry; storage gains idempotency (partial unique index, migration 009, + short-circuit + a session advisory lock per (tenant, expediente) preventing duplicate irreversible WORM objects under a race) and the internal proteger-indice-internal endpoint for reconciliation. MinIO Object-Lock in dev — NOT an accredited repository.
  • WORM wiring of the signed transfer acta ✅ Inc. 2 (Anexo FUID): a mirror of Inc. 1 over the instrument that evidences the FUID handover — proteger-indice is generalized to proteger-artefacto with tipo ∈ {indice, acta_transferencia} (storage migration 010; /proteger-indice[-internal] remain as hard aliases), archive gains proteger_worm_acta (best-effort synchronous trigger + a fourth phase, run_once_actas_worm) with worm_* columns in transferencia_acta_firma (archive migration 030), TRD TOTAL retention with fecha_inicio = firmado_at of the acta (not closure — an acta is signed later in secondary transfers) and fail-closed without a TRD.
  • Disposition-gate remedy ✅ (Ley 594/2000 arts. 24–26): the Medium finding from Inc. 2 (acta and index do not expire together → mandated deletion was postponed until the acta expired) is closed with a CONTENT (indice/aip, blocking) vs CONTROL INSTRUMENT (acta_transferencia, outlives the content and does not block it) taxonomy; legal_hold remains unconditional, fail-closed on an unclassified tipo (guard test in CI).
  • WORM retention renewal for Conservación Total series ✅ (Ley 594/2000 + Ac. AGN 001/2024 art. 4.3.2.6): closes the hole where a COMPLIANCE object of a CT (permanent conservation) series became deletable again once its original retain_until elapsed — a new POST /preservacion/renovar-retencion-internal pair (storage, D-02, min_crud=3/X-Internal-Token) identifies the row by (expediente_id, tipo), requires a strictly greater extension (otherwise an idempotent 200 no-op that never touches MinIO) and uses set_object_retention (verified against real miniopy-async 1.23.5) — storage migration 011. archive gains a fifth reconciliation phase (run_once_worm_renovacion): a local worm_retain_until mirror (migrations 031/032), a fail-closed CT filter in SQL (app/core/disposition.py::CT_DISPOSITION_CODES), a rolling window (nueva = now() + total_TRD, never a far-future/9999 date), exponential backoff and an agotado state (higher-severity alarm: risk of the lock expiring over a perpetual record). Fail-closed by design: only disposition ∈ {CT, conserve} is renewed — E/S/M expire and are legitimately disposed of. Audit remediation: the public /renovar-retencion route was withdrawn (irreversible surface with no caller → internal only), the renewal entry is atomic, and a new visibility endpoint GET /expedientes/indices/pendientes-renovacion (no-read-up in SQL, verified non-oracle) surfaces CT artefacts whose renewal is exhausted or at risk.
  • Real veraPDF PDF/A validation ✅ Increment C2b (RF-PRE-02): SubprocessVeraPdfValidator runs veraPDF over untrusted PDFs under a hardened security model — create_subprocess_exec (no shell) with a fixed argument list and a flavour whitelist, scrubbed ENV (the child only sees PATH+JAVA_TOOL_OPTIONS, never DATABASE_URL/MINIO_SECRET_KEY/tokens), start_new_session+killpg+reap guaranteed in finally (including cancellation), a concurrency semaphore + -Xmx/-XX:MaxMetaspaceSize, a 0700 per-invocation tempdir, a dual output cap against DoS, JSON-only (no XXE) and honest degradation to the not_evaluated stub when the binary is absent (RF-FIR-15). The JRE + veraPDF ship only in the deploy image (multi-stage base/production Dockerfile, non-root; the dev image stays slim) plus a CI job with a fake veraPDF exercising the real plumbing. Tested against real veraPDF 1.30.2. Increment C2a's honest stub (no_evaluado, never faked) remains the fallback contract.
  • AIP wiring on closure ✅ (OAIS ISO 14721; Ac. AGN 001/2024 art. 4.3): POST /preservacion/aip existed but nothing triggered it → archive now triggers it when the expediente closes (reconciliation-only, best-effort, expensive). Because the AIP must be the complete preservation package, resolving the attachments' file_ids uses a new internal endpoint in document-service (POST /internal/documentos/anexos, require_internal_token, with no clearance gate — a system/full-inventory act, unreachable through the gateway; no-read-up is enforced at the AIP's read boundary, not at packaging time). Preconditions: closed + firmado index + worm_protegido; TRD TOTAL retention, fail-closed; a 100% physical expediente (0 attachments) → estado='omitido' (its already-WORM signed index is its artefact); a mutated attachment → fixity 409 → abort + backoff (never a corrupt AIP). expediente_aip table (archive migration 033), a sixth phase run_once_aip, storage migration 012.
  • WORM renewal of the AIP for CT series ✅ (closes the High debt raised by the conformance audit of Increment B): the fifth phase now covers all three artefacts (index, acta, AIP) — worm_retain_until+worm_renovacion_* mirror in expediente_aip (archive migration 034), AipWiringService.renovar_worm_aip, fail-closed CT filter in SQL, rolling window, a third advisory lock; renovar-retencion-internal already accepted tipo='aip'; pendientes-renovacion covers all three types. A CT's complete OAIS package no longer loses its immutability when the lock expires. Remaining minor debt: a uniform index-only AIP for 100% physical expedientes, if ever required.
  • Pending: an accredited WORM repository (today MinIO Object-Lock in dev), and PDF/A format migration (PRE-04).

E15 / E06 / E17 — Electronic index and signature

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

E20 — Postal operator

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

E14 — PINAR (Institutional Archives Plan)

  • ✅ (2026-07, RF-ADM-08; Acuerdo AGN 003/2015): OrpycaMCP manages the PINAR as a planning instrument (modelling, prioritizing, versioning and tracking it) and articulates it by reference with CCD/TRD (E04), FUID/IUD (E12/E17) and the preservation plan (E10) — without duplicating the owner's data. In tenant-service, prefix /api/v1/pinar. The AGN's 6-step methodology maps to 9 pinar_* tables (tenant migration 007): diagnosis → critical aspects with risk, 5 articulating axes (seeded), deterministic prioritization (aspect×axis matrix, priority = Σ impacts, which orders the road map), vision, objectives, projects (target/indicator/owner/resource/timeline/progress), append-only follow-up (DB trigger; avance_pct projected from the latest record), and articulation with instruments.
  • State machine borrador→aprobado→en_ejecucion→cerrado with a single plan in execution per tenant (partial unique index → 409), versioning on reformulation, approval requiring an administrative act (422 if missing) that is attributable (400 without an actor) and audited in the IMMUTABLE audit_log (E08 hash chain) via orpycamcp_common — as are create/execute/close. Content is FROZEN on approval (_EDITABLE = {borrador}); structural mutations (only in borrador) are audited in admin_audit. Invariants and the cross-schema audit_log wiring were validated against real PostgreSQL. Audited for security (2 Medium) and conformance (2 Medium, Ac. 003/2015) — no blockers, all remediated. To install orpycamcp_common, tenant-service's build moved to the root context (ADR-010 pattern).
  • PINAR UI — MVP ✅ (Phase 7): listing + workspace with life cycle and dashboard. Pending: the SurveyJS Form Builder (RF-ADM-06) and the rest of the PINAR surface (critical aspects, prioritization, objectives, projects, follow-up, instruments, road map).

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

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

E11 — Interoperability

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

E08 — Security and classification

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

E09 — Query trace (RF-BUS-10)

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

E17 — Physical archive (minor pending items)

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

Foundational scaffolding (Phases 1–6) ✅ Complete

These phases built the base on which specDrive raises conformance.

Phase 1 — Framework and Architecture

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

Phase 2 — Core Domain

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

Phase 3 — Workflows and Notifications

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

Phase 4 — Quality and Integration

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

Phase 5 — Community and Publication

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

Phase 6 — Advanced Features (scaffolding)

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

Beyond specDrive 📋 Planned

  • [ ] Native mobile app (iOS/Android)
  • [x] ~~Document signing~~ — native electronic signature + signature chain / signer's inbox done (E06: ordered turns, GET /pending, sign/reject/batch); XAdES-B of the electronic index + institutional seal done (Inc. 1); email-OTP 2FA (Inc. 2) and TOTP RFC 6238 2FA (Inc. 6) done; XAdES-T (local TSA) done (Inc. 4) and XAdES-LT/LTA (local dev CA + CRL + ArchiveTimeStamp, not accredited) done (Inc. 5); stapled OCSP (RFC 6960) in XAdES-LT done (Inc. 7); per-user PKI personal signature XAdES-B/T (server custody, local dev sub-CA, split remote signing = a seam to an HSM) done (E17/F4 Inc. 1; acreditado=false, art. 7, no sole control). Still pending: ONAC accreditation (CA/TSA/OCSP responder + qualified art. 28 personal signing via an HSM), a fully EN 319 132 LTA profile, online OCSP from an independent responder, and WebAuthn
  • [ ] Corporate SSO integration (Entra ID, Okta, LDAP)
  • [x] Frontend (E22, frontend/, ADR-011/020) — essentially complete (~14k LOC), see the section below
  • [x] ~~Webhooks API~~ — signed outbound webhooks done (E11), plus the inbound postal callback (E20)
  • [x] ~~Export expedientes to PDF/ZIP~~ — ZIP done (E02/export): GET /api/v1/expedientes/{id}/export.zip (archive orchestrates, storage assembles via an internal D-02 endpoint) with the electronic index XML (best-effort) + manifiesto.csv (including exclusions with their cause) + a LEEME.txt scope note + checksums.txt (SHA-256) + the attachment bytes; PER-RADICADO no-read-up (document-service filters nivel <= clearance and omits over-clearance radicados including their existence — closing a read-up leak that the expediente-level gate did not cover), USUA_PERM_EXPEDIENTE gate, zip-slip protection, aggregate auditing. Pending: a combined PDF (render engine), streaming for large expedientes
  • [x] ~~Reports and statistics~~ — radicado reports done (E09); advanced dashboard/indicators pending

Frontend (E22)

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

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

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

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

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

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

Other frontend pending items

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

Performance and assets

  • [ ] Font Awesome subset: today +layout.svelte imports the full Font Awesome CSS. The app only uses solid and regular icons; replacing it with a subset (or per-icon imports) would lighten the initial download, which is the public landing page's first impression.
  • [ ] Brand asset: static/orpyca-logo.png is the circular emblem (100×97 px), not a full logotype with a wordmark. Until a wordmark exists, the landing page and /login show the emblem at large size and the brand name does not appear above the fold. Also, apple-touch-icon.png is 180×176 (not 180×180 as app.html declares) and is RGBA: iOS composites it over black.

DataTable migration

  • [ ] /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.
  • [ ] /archivo-fisico: its listing is a tree structure (recursive locations), not a flat table; DataTable does not model hierarchy today. It first requires deciding whether tree support is added to the component or the screen keeps its own rendering.
  • [ ] Server-side filtering/sorting in DataTable: today filtering and sorting refine the page already served. The component emits filterChange/sortChange so a screen can wire them to a gateway refetch; none does yet. That is why /busqueda does not enable them: it has its own filter form synced with the URL and the backend, and a second local filter would be a duplicated source of truth.

Backend still missing to close the UI

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

Accessibility and consistency (Medium/Low audit backlog)

  • [ ] Raw rgba(...) colors outside the token system in scrims and brand surfaces: Modal.svelte (rgba(33,36,33,.5)) vs Drawer.svelte (.35) vs AppLayout (.4) vs AssistantDock (.25) — four opacities for the same scrim, and the base color does not even match --op-text. Plus the translucent whites in the landing page's hero/CTA/footer. They are not hex values, but they are invisible to a per-tenant rebranding.
  • [ ] A monospace FAMILY token is missing: --op-font-mono is a size (12px), not a family. There are ~16 places with a bare font-family: monospace, precisely on the radicado number, the system's most identifying datum. Add --op-font-family-mono to _tokens.scss and migrate those 16 places.
  • [ ] <a role="button">: Button.svelte with an href renders a link announced as a button — it does not respond to the space bar. Affects the landing page CTAs, /403 and EmptyState's action links.
  • [ ] Inconsistent H1 size across screens (--op-font-lg in 5 views, --op-font-xl in 8; none uses v1.0's H1, --op-font-2xl), and touch targets <44px still in DataTable's pagination buttons (28px), its selection checkboxes, the "Columns"/"Saved views" summary (36px) and AssistantDock's controls (~30px).
  • [ ] EmptyState reimplements .op-btn, copying Button.svelte's styles "in case Button is not imported": a third definition of the primary button that will drift out of sync. It is used in 9 of 10 screens.
  • [ ] ARIA details: aria-controls pointing at nodes that only exist while the panel is open (DataTable, /dashboard); role="list" with children that are not listitem; accessible names on generic <span>/<div> (StatusChip, AppLayout); DataTable checkboxes named by position ("Select row 3") instead of by record; and the in-cell editing aria-live announcement claiming "updated" before the consumer confirms against the gateway (it lies precisely when the gateway returns 403).
  • [ ] The / shortcut and focus traps: with AssistantDock (aria-modal) or the mobile drawer open, pressing / moves focus to the top-bar search box, outside the dialog's focus trap.
  • [ ] AssistantDock promises what it does not deliver: its placeholder claims that "every write action will ask for explicit confirmation", but no such mechanism exists in the component. It is harmless today (the v1 backend is read-only), but the promise must be implemented before enabling write tools, not after.
  • [ ] Clear favorites on logout: resetFavorites/clearPersistedFavorites exist and are tested but no production code calls them; on a shared workstation the previous user's list survives the logout.

Tooling and documentation debt

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