Roadmap¶
Updated as of 2026-08-04.
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 (~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_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). - 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'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). - 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 viaMINIO_SECONDARY_*— triggered bynum_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 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 — 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 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.
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
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: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
/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).
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. -
[ ] 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/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
- [ ] 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. - [ ] 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.
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;DataTabledoes 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 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.
Backend still missing to close the UI
- [ ] 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. - [ ] "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.
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)) 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. - [ ] 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. - [ ]
<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. - [ ] 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). - [ ]
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. - [ ] 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). - [ ] 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. - [ ]
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. - [ ] 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.
Tooling and documentation debt
- [ ]
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.