Changelog¶
All notable changes are documented here.
Format: Keep a Changelog
Domain glossary (Colombian SGDEA — Sistema de Gestión de Documentos Electrónicos de Archivo, electronic records management system): radicado = an officially registered document with a unique, immutable tracking number; expediente = a logical case file grouping related radicados; TRD (Tabla de Retención Documental) = document retention schedule; TVD (Tabla de Valoración Documental) = valuation schedule for an accumulated/backlog fonds (documentation already produced without archival criteria, typically from suppressed units or liquidated entities); dependencia = organizational unit; FUID (Formato Único de Inventario Documental) = standard documentary inventory form; PQRS = citizen petitions, complaints, claims and suggestions; índice electrónico = electronic index that gives the electronic expediente legal validity.
[Unreleased]¶
Changed¶
refactor(rebrand): full renameorfeomcp→OrpycaMCP/orpycamcpbefore v0.01 — Why: the project is the evolution of Orfeo under the Orpyca concept ("Orfeo Pyme Calidad") with AI connectivity via MCP (Model Context Protocol) — Impact: by-surface convention — brandOrpycaMCP; data/infra tokenorpycamcp(DBorpycamcp_db, Keycloak realmorpycamcp, bucketsorpycamcp-{slug}-*, Redis streamsorpycamcp.*, Python packageorpycamcp_common, networkorpycamcp-net, containersorpycamcp-*); repo/images/Compose projectorpyca-mcp; company/web/orgOrpyca/orpyca.com/GitLaborpyca. Requires recreating volumes; the repo folder is renamed toorpyca-mcp(manual step)docs(claude):CLAUDE.mdsynced with the real architecture — 11 microservices with correct ports; removed SQLAlchemy/Alembic references (contradicted ADR-003: asyncpg + raw SQL); phases updated per specDrivechore(ci): CI unified on GitLab — removed.github/workflows/; README/badges/clone point togitlab.com/orpyca/orpyca-mcpchore(compose): dev published ports moved to a high, distinct range (19xxx / 15432 / 16379) to avoid clashes with other dev stacks; internal ports and Docker-network communication unchangedfix(compose):signature-serviceandknowledge-servicenow wait forpostgreshealthy (depends_on: condition: service_healthy) — they previously exited at startup withConnectionRefused
Added¶
feat(archive): TVD — valuation schedule for an accumulated fonds (ADR-026, Increment 1: registration and convalidation, migration 039) — an entity holding an accumulated fonds (documentation already produced without archival criteria, typical of suppressed units or liquidated entities) had nowhere to register its valuation instrument, and therefore could neither close nor dispose of anything in that fonds. The TVD enters as the same instrument as the TRD, with the same convalidation circuit (Comité → Consejo → RUSD → publication): a discriminator on the existing table, not a new table, so the single point on which seven irreversible WORM retention writes depend is not forked. Four layers protect it: a series code can belong to only one instrument; listings are always read through a view that already filters by type; each instrument's own content (fonds, extreme dates, valuation rationale for the TVD) is mandatory or forbidden depending on the type, enforced by the database; and — most important in this increment — no expediente can yet be classified under a TVD (the database blocks it outright), so the irreversible-retention risk is zero until that piece exists. Routes/api/v1/tvdmirror the TRD ones (create, edit, version, approve, return, convalidate, register RUSD, repeal), same permission as TRD. Full version-history lookup (GET .../{code}/versiones) was also added for both TRD and TVD — promised in the previous increment and never built. — Why: a records-compliance audit finding on the retention model: an entity with an accumulated fonds had no way to appraise it inside the system.feat(frontend):/admin/interoperabilidad— export/import of radicados, batch loading and postal tracking (Phase 8, last of the API↔UI drift closure) — five backend capabilities had been ready for a while with no screen using them: exporting a ZIP package of radicados, importing it back preserving the original tracking number, creating documents or expedientes in batch, and querying a shipment's tracking against the postal operator. The new screen covers the first four in tabs; the last one was added as an "Update tracking" button in the existing/enviostray. The most delicate part is import: it is all-or-nothing (if a single file in the package does not match its declared fingerprint, nothing is imported) and when that happens the screen shows exactly which file failed and why, never a generic "import failed". Batch loading, being a bulk write over official documents, requires confirming the exact item count before executing, and while processing it polls progress every two seconds, always distinguishing "could not query the status" from "no results yet" (a fabricated zero progress is never shown). Postal tracking, having no real operator integration yet, says so explicitly instead of faking a live query. While building the screen it turned out batch loading could not work at all — the gateway had no route registered for those two endpoints. That was fixed (see Fixed below), but the fix changed the shape of the status URL and the screen kept the old one; combined with a reactivity defect in the progress panel, batch loading reported no result under any circumstance. The UX review of 2026-08-02 caught it, and it is now fixed in that review's own entry below. Export, import and postal tracking did work end to end from the start. — Impact: 1 new screen with 4 sections, 1 new button in postal shipments, 27 new automated tests.feat(frontend):/admin/tvdscreen + convalidation circuit shared with/admin/trd(ADR-026, frontend) — a structural mirror of the TRD screen with its own columns (fonds/producing unit, extreme dates, instrument state) and no active-archive column, treating the valuation rationale as the form's main field. The convalidation circuit (approve/return/convalidate/register RUSD/repeal) was built once as a sharedInstrumentLifecycleActionscomponent and wired into both screens — it did not exist in/admin/trdeither, ADR-025 had left it pending — warning about irreversibility inside the confirmation modal and offering no button the current state does not allow.fix(archive): remediation of the conformance + security verdict on the TRD convalidation circuit (ADR-025, migration 038) — the engine now hardens aconvalidadarow (the administrative act is immutable,estadomay only move toderogada, RUSD/publication are fill-once);devolver(aprobada→borrador, with a mandatory reason) closes the dead end ataprobada;registrar-rusdrecords the post-convalidation registry and publication;derogaris blocked whileopenexpedientes remain under the code; convalidation repeals the previous current version in the same transaction with collision-safe re-pointing of orphaned FKs;GET /trd/revision-pendientegains no-read-up by clearance plus a query trace (a security regression: the report did not filter by security level); and the D5 deletion gate authorizes over the frozen snapshot rather than the live row — the original criterion was the opposite of the one the ADR requires and would have blocked legitimate deletions.feat(frontend): PINAR MVP — Institutional Archives Plan in/admin/pinar(Phase 7, Acuerdo AGN 003/2015) — PINAR had 30 endpoints intenant-serviceand no screen at all. The MVP covers the listing (state filter, creating a new version always inborrador) and a workspace with a life-cycleStepperand a dashboard tab (progress per objective / per axis). Approval permanently freezes the plan's content (an entry in the immutableaudit_log), so it is warned about before confirming and the confirm button stays disabled without an administrative act; the dashboard distinguishes "could not load" from a legitimately empty plan, and never paints zeros because of a network failure. Out of scope for the MVP: critical aspects, prioritization, objectives, projects, follow-up, instruments and road map.feat(archive): TRD convalidation circuit —aprobar/convalidar/derogar+ ratification of migrated series (ADR-025 Increment 2) — the state machine that turns the TRD from a configuration table into a convalidated instrument: Comité approval, Consejo convalidation (or ratification of a series backfilled asmigrada), and repeal, each with its administrative act recorded and audited.feat(frontend): semantic and precedent search in/busqueda(Phase 6, E21) — two new tabs beside the existing exact search, resolved by?tab=(deep-linkable), with three SSR proxies including one that verifies the pivot radicado before searching. Non-negotiable interface honesty: results are 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.feat(frontend): generative help (RAG) integrated into drafting and classification (F6, E21) — RAG is not a separate module: a "Suggest from precedents" button in/radicarand/borradorescallsPOST /knowledge/rag, and suggested tipo documental chips next to the classification selector are fed by/knowledge/antecedentes. Three hard rules: generated text is always inserted marked as generated and with its citations; without 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. A knowledge-service failure stays contained in the component and never blocks registering or saving a draft.fix(notification): hardened destination for outbound webhooks (Medium security finding, non-blocking) — the subscription URL is validated before delivery so the signed webhook cannot be pointed at internal targets.fix(auth): High finding closed — self-escalation toUSUA_PERM_ROOT; ROOT is now provisioned out of band (ADR-024) — an entity administrator could grant themselves the ceiling permission of their own entity. ROOT is no longer grantable through the API.fix(tenant,gateway):app/core/authz.py+ Channel A in tenant-service;/api/v1/tenants/removed from the gateway (closes 2 Critical security-audit findings) — tenant-service had no authorization at all (0 gate occurrences against 52 in document-service), and/api/v1/tenants/was the only leak between entities, closed by taking it out of the gateway. The docstrings claiming "the gateway authorizes" were deleted, not just supplemented: that false statement was the vector.fix(document):USUA_PERM_TRD+ auditing on the metadata catalog (E03, closes a High audit finding) — the metadata template/element routers wrote with no permission gate.feat(auth): immutable auditing of RBAC/URD/clearance writes (E08, closes a traceability gap) — granting a permission, changing a membership or raising a clearance now leave an entry in the hash-chainedaudit_log.feat(frontend): TRD/CCD, metadata, catalog and parameter administration (E03/E04/E14, Phase 5 of the API↔UI drift closure) — TRD/CCD reusesCatalogCrudPanelin two tabs, with series as an indented tree (theparent_idhierarchy is the data, not a flat list) and immutable codes; metadata splits with TRD (TRD = classification, metadata = the fields of those schemas) with create-only templates whosejson_schemais validated for shape before submitting; catalogs use a master-detail layout that degrades to two navigable levels on mobile; parameters adds a holiday calendar (Ley 51/1983 preload with no external call) and a business-day calculator that reconstructs the breakdown of the days subtracted, because the backend only returns the resulting date. Domain finding reported, not papered over: document-service'smetadata.py/metadata_elements.pyand tenant-service'scatalogos.py/config.pydeclare norequire_permission— any authenticated tenant user can write. These screens gate their write buttons withUSUA_PERM_ADMINclient-side as a conservative measure, which never offers less than the backend requires.feat(auth): reading group memberships, permissions and clearance (E08, closes the RBAC read gap) — the backend previously only supported blind add/remove on those three collections.feat(frontend): group and dependencia administration (E08/E14, Phase 5 of the API↔UI drift closure) — groups reuseCatalogCrudPanelwith a members/permissions/clearance drawer that states explicitly what the backend does not expose; dependencias add aDependenciaTreeNode.sveltetree overGET /dependencias/treewith an explicit risk-confirmation modal (not a tooltip) when renaming or deactivating, because dependencias are correlated by name inworkflow_rules.assign_to_deptand in in-flightflow_steps.feat(frontend): index of classified and reserved information (E08, Ley 1712 art. 20) + final disposition of the radicado/expediente (RF-MET-08/E04, Phase 4 of the API↔UI drift closure).feat(frontend): transfer detail with the acta as a certificate and personal signing by role + digital preservation administration (E12/E17/E10, Phase 4 of the API↔UI drift closure) — each signer's role is derived from their identity, never chosen in the UI.fix(workflow):workflow_rules.assign_to_deptvalidated when the rule is configured + anaudit_logtrace of the silent degradation — a rule pointing at a non-existent dependencia used to fail quietly at routing time.fix(document): auditing of the radicado annulment cycle (ADR-008/010).fix(workflow): forensic auditing of returns, process transactions and cascade reassignment (ADR-008/010).feat(workflow): mandatory reason when reverting an assignment (RF-FLU-08).feat(frontend): OrpycaMCP Design System v1.0 (E22, 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-primaryfor non-textual use at 4.28:1,--op-primary-darkfor text at 6.56:1). v1.0 type scale, radii, shadows and motion + a globalprefers-reduced-motionrule; self-hosted Space Grotesk + Public Sans (@fontsource, no CDN). Audited bysvelte-ux-reviewer: 27 High/Critical findings remediated, including explicit confirmation before signing in/firmas, which had none.fix(frontend): Font Awesome was never loaded and the favicon 404'd (E22) — its CSS was never imported, so every icon rendered as an empty box.feat(frontend): public landing page at/(E22) — seven sections, with a 302 to/dashboardwhen a session exists; the site root previously did not exist.feat(frontend): login, callback and/403with the real brand (E22) — a split composition and Keycloak error messages mapped from a closed table, which broke the IdP↔/loginredirect loop.feat(frontend): global search in the top bar and configurable favorites in the sidebar (E22) — the search term travels in the URL (shortcut /, deep link?q=), and the favorites store is namespaced per tenant + user and consumes the already RBAC-filtered list, so a favorite whose permission is withdrawn disappears on its own.feat(frontend): dashboard redesigned around the four personal modules (E22) — tenant metrics move to a collapsible section gated bySGD_PERM_ESTADISTICA(UI mirroring the RBAC), andNAV_ITEMSis extracted tolib/utils/navItems.jsas the single source.feat(frontend):DataTablegains the design system's eight mandatory capabilities (E22) — filters,aria-sortordering, CSV export with formula-injection protection, configurable columns, saved views, multiple selection,content-visibilityvirtualization and in-cell editing, all opt-in;/busquedaand the four/reportespanels migrated.feat(archive): multi-level EAD 2002 / ISAD(G) archival description of expedientes in OAI-PMH (E11, RF-INT-06) — a new public endpointGET /api/v1/public/archive/oai/{tenant}disseminates each public expediente as a multi-level EAD fragment, walking thetrd_series.parent_idchain to build fonds→series→(subseries)→file, satisfying ISAD(G) 2.2 and 2.4. Anti-leak boundary: it stops atlevel="file"and never enumerates child radicados' tracking numbers (a public expediente may contain reserved radicados whose level archive does not know) — only an aggregate count, with a grep guard test. A depth guard was added to the recursive CCD CTE, since the endpoint is anonymous.feat(document): EAD 2002 / ISAD(G) archival description in OAI-PMH (E11, RF-INT-06) — a secondmetadataPrefix=eadon the same OAI endpoint, disseminating each public radicado as a self-contained EAD fragment with<archdesc level="item">and the six mandatory ISAD(G)/NTC 4095 interchange elements. Read-only, no migration: it reuses the_PUBLICchokepoint, so the format is orthogonal to the security trimming — choosingeadneither bypasses the filter nor creates an oracle.feat(document): interoperable import of radicados (E11, RF-INT-05) —POST /api/v1/importtakes the INT-01 ZIP as a raw body with all-or-nothing validation before touching the DB: each radicado validates against the service's own trusted JSON Schema (not the one shipped in the package, which could be lax) and every binary's SHA-256 is verified; a mismatch returns422with no partial state. It re-ingests preserving the original radicado number (a collision is skipped, never overwritten), rebuilds the E↔S relation by number, auditsradicado.importinside the transaction, and guards against zip-slip / zip-bomb. Closes E11's Critical finding.feat(document): interoperable export of radicados (E11, RF-INT-01) —POST /api/v1/exportproduces a ZIP with a manifest, a published JSON Schema the records are validated against, the attachment binaries and per-member SHA-256 checksums, applying per-radicado no-read-up and recording over-clearance omissions content-free. The radicado number is preserved as a portable identity, which is what makes INT-05 possible. Mass egress is audited (radicado.export).feat(document): minimal read-only CMIS 1.1 profile (E11, RF-INT-02) —GET /api/v1/public/cmis/{tenant}(Browser Binding JSON), wheregetContentStreamserves an attachment's bytes only when the radicado is public. Same_PUBLICchokepoint as OAI, so a reserved id yields an indistinguishable 404.feat(document): OAI-PMH — metadata harvesting (E11, RF-INT-02; SGDEA R.12.1) — the six verbs, Dublin Core, selective harvesting andresumptionTokenatGET /api/v1/public/oai/{tenant}. Harvesting exposes only what is public (Ley 1712/2014 arts. 18–19): the trimming lives at the source in a single_PUBLICfilter, so the omission is indistinguishable by construction —completeListSizecounts public records only and a reserved id returnsidDoesNotExist.feat(tenant): PINAR — Institutional Archives Plan (RF-ADM-08, E14; Acuerdo AGN 003/2015) — the AGN's six-step methodology as 9pinar_*tables (tenant migration007) under/api/v1/pinar: diagnosis → critical aspects with risk, five articulating axes, deterministic prioritization, vision, objectives, projects and append-only follow-up (DB trigger). State machineborrador→aprobado→en_ejecucion→cerradowith a single plan in execution per tenant, approval requiring an attributable administrative act, and content frozen on approval — all audited in the immutableaudit_logviaorpycamcp_common, the first use of the shared library in tenant-service.fix(document)+feat(document,archive): declared debts closed (E08/E10/E02) — no-write-up when registering a classified radicado,SYSTEM_RECONCILER_IDattribution for reconciliation entries, and subject/type in the expediente export manifest via a clearance-aware internal endpoint.feat(archive)+feat(storage): expediente export to ZIP —GET /api/v1/expedientes/{id}/export.zip(E02/export; Ley 594/2000 art. 19) — archive orchestrates and storage assembles via an internal D-02 endpoint: electronic index XML (best-effort) +manifiesto.csvincluding exclusions with their cause + aLEEME.txtscope note +checksums.txt+ 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 an expediente-level gate did not cover.feat(document): index of classified and reserved information —GET /api/v1/reports/indice-reservado(.csv)(E08; Ley 1712/2014 art. 20) — the legally mandated register, content-free (never the subject) and orthogonal to clearance (the register must be complete), gated byPERM_RECLASIFICAR, with CSV formula-injection protection. Three conformance blockers were closed, chief among them that the legal grounds are now mandatory when classifying (whether registering or reclassifying, 422 if absent), so the index is never left with NULL grounds.feat(document): postal operator as a webhook consumer — inbound callbackPOST /api/v1/public/postal/callback/{tenant_slug}(E20 F5; RF-POR-08; ADR-018) — per-tenant/per-operator HMAC-SHA256, symmetric to the outbound webhook and separate from the human dispatch permission; the tenant in the path fixessearch_pathand the secret lives in the tenant schema, so there is no cross-tenant forgery. Fail-closed (a missing credential and a bad signature return the same non-oracle 401), idempotent viaUNIQUE (operador, event_id), with an atomic transition + audit entry; a late or backwards notification returnsrecorded_no_changeinstead of pretending.feat(knowledge): generative RAG with citations —POST /knowledge/rag(E21 Inc. 3; ADR-006) — a pluggableLLMProvider(ollama_localas the sovereign default; external providers andanthropicwith the native Citations API as options;disabledfalls back to the citation-only pattern). Hard sovereignty barrier for generation:is_generatableexcludes chunks atnivel_seguridad >= RESERVADAfrom the context when the provider is external — fail-closed, with no override, unlike the ADR's original soft model. Honest degradation with503 rag_unavailable.feat(knowledge): grounded retrieval with citations —POST /knowledge/antecedentes(E21 Inc. 2) — reusessearchand its ACL pre-filter, with an anti-oracle 404 collapse on the pivot radicado (it does not distinguish "does not exist" from "exceeds your clearance").feat(knowledge): real, local/sovereign embedding provider (E21 Inc. 1; ADR-006) — a pluggableEmbeddingProvider(local_stmultilingual sentence-transformers as the deployment default, a deterministicstubforced in tests) replaces the stub as the only provider;EMBEDDING_DIM(384) is the single source of the vector dimension with a fail-closed startup guard, migration007and apython -m app.ops.reembedbackfill job. With a local provider, classified material is embedded — it never leaves the installation — unlike with an external one.feat(storage): PDF/A validation on attachment capture (E07, E10 C2b follow-up; RF-PRE-02).feat(storage)+feat(archive): index-only AIP for 100% physical expedientes (E10 AIP wiring follow-up; OAIS ISO 14721; RFC 8493) — an expediente with no digital attachments recordsestado='omitido': its already-WORM signed index is its artefact.feat(archive)+feat(storage): PDF/A profile configurable per documentary series / TRD (E10 C2b follow-up; RF-PRE-02) —pdfa_profileis the explicit exception to the TRD's append-only trigger.feat(archive)+feat(storage): WORM renewal of the AIP for Conservación Total series (E10; closes the High debt from the AIP wiring) — the reconciliation phase now covers all three artefacts (index, acta, AIP), so a CT's complete OAIS package no longer loses its immutability when the original lock expires.feat(archive)+feat(document)+feat(storage): OAIS AIP wired to expediente closure (E10 debt; OAIS ISO 14721; PREMIS v3; Ac. AGN 001/2024 art. 4.3) — the endpoint existed but nothing triggered it. Because the AIP must be the complete package, attachmentfile_ids are resolved through a new internal document-service endpoint with no clearance gate (a system inventory act, unreachable through the gateway; no-read-up is enforced at the AIP's read boundary, not at packaging time). Preconditions: closed + signed index + WORM-protected; a mutated attachment aborts with a fixity 409 rather than producing a corrupt AIP; a session advisory lock closes the irreversible double-packaging race, verified against real concurrent PostgreSQL.feat(storage): real veraPDF PDF/A validation via a hardened subprocess (E10 Increment C2b; RF-PRE-02) —create_subprocess_execwith a fixed argument list and flavour whitelist, scrubbed ENV (the child never seesDATABASE_URL/MINIO_SECRET_KEY/tokens),start_new_session+killpg+reap guaranteed infinallyincluding cancellation, a concurrency semaphore with JVM memory caps, a per-invocation 0700 tempdir, dual output caps against DoS, JSON-only (no XXE), and honest degradation to anot_evaluatedstub when the binary is absent. The JRE + veraPDF ship only in the deploy image; CI exercises the real plumbing with a fake binary. Tested against real veraPDF 1.30.2.feat(storage)+feat(archive): WORM retention renewal for Conservación Total series (E10 debt; Ley 594/2000 + Ac. AGN 001/2024 art. 4.3.2.6) — a COMPLIANCE object of a permanently conserved series became deletable again once its originalretain_untilelapsed. A new internalrenovar-retencionendpoint requires a strictly greater extension (otherwise an idempotent no-op) and usesset_object_retention; archive gains a reconciliation phase with a local mirror of the retention date, a fail-closed CT filter in SQL, a rolling window and exponential backoff with anagotadoalarm. OnlyCT/conservedispositions are renewed — E/S/M expire and are legitimately disposed of. The irreversible public route was withdrawn (internal only) and a visibility endpoint surfaces CT artefacts at risk.fix(workflow):GET /workflows/{id}/eventsscoped by clearance (E05 §10 / RF-SEG-08) — the radicado's route map is only visible to those who can read it (no read-up); operational Channel B, not forensic auditfeat(archive): expediente route map (E02 §9) — append-onlyexpediente_eventostable (migrationarchive tenant/014), events on open/link/unlink/close/transfer, andGET /api/v1/expedientes/{id}/eventosscoped by clearancefeat(audit):public.audit_logcoverage (E08 §9) — workflow and archive also emit to the cross-cutting forensic audit viaorpycamcp_common.audit(archive-service now installs the shared library)fix(security): full read shielding — detail GETs and semantic search (RF-SEG-08 / RF-BUS-04) — Why: close the read surfaces that still didn't apply clearance (direct access by id/tracking and semantic retrieval) — Impact:GET /api/v1/documents/{id}and/by-tracking/{tn}(document-service) andGET /api/v1/expedientes/{id}(archive-service) scope the query withnivel_seguridad <= clearance; an object above the caller's clearance returns 404 (does not reveal its existence, consistent with search hiding it). Internal/system callers (respuesta, anulación, transfers, index) usemax_clearance=3by default (no restriction). knowledge-service: semantic search no longer trusts the client-suppliedacland now resolves clearance server-side (COALESCE(acl->>'nivel_seguridad',1) <= clearancealways; clientaclonly additional narrowing); newknowledge-service/app/core/clearance.py.caller_clearancehelper in archive (resolved after settingsearch_path);resolve_caller_clearancemade robust with.get()for the detail path's double fetch. 71 document + 62 archive + 6 knowledge tests green (4 new). Read access control closed on ALL surfaces: search, listing, detail (radicado and expediente) and semantic retrievalfix(document): classification-based access control in search (RF-SEG-08 / RF-BUS-04) — Why: search only isolated by tenant (search_path); within a tenant any user saw every radicado regardless of their clearance — Impact: newradicados.nivel_seguridadcolumn (migrationdocument tenant/016, scale 1=PUBLICA/2=RESERVADA/3=CLASIFICADA =security_levels.code, DEFAULT 1 → no behavior break);GET /api/v1/searchresolves the caller's clearance from the tenant schema (auth_users/auth_memberships/role_clearance, ADR-013: the DB authorizes) and filtersnivel_seguridad <= clearance(no read-up, least privilege, fail-closed to PUBLICA);POST /api/v1/documentsacceptsnivel_seguridad(default 1) to classify; reusableapp/core/clearance.py; 70 document tests green (7 new: helper + filter), migration validated on real pgfix(archive): same clearance scoping on the radicado listing and expediente search (RF-SEG-08 / RF-BUS-04) — Why: finish read access control on the surfaces still exposing everything within the tenant — Impact:GET /api/v1/documents(document-service) now filters the listing by clearance (find_all/service.listtakemax_clearance);GET /api/v1/expedientes/search(archive-service) filters bynivel_seguridad <= clearancewith the newexpedientes.nivel_seguridadcolumn (migrationarchive tenant/013, DEFAULT 1) and helperarchive-service/app/core/clearance.py(resolved after setting the tenantsearch_path);POST /api/v1/expedientesacceptsnivel_seguridad(default 1); 61 archive tests green (6 new), migration validated on real pg. Read access control closed across radicado search+listing and expediente searchdocs(adr): ADR-020 — Orpyca design system + frontend conversational assistant (E22) — Why: lock down the cross-cutting design/UX and AI-integration decisions before coding the frontend, in a gradual and controlled way — Impact: newdocs/es/adr/ADR-020-sistema-diseno-orpyca-asistente.md(status Proposed); decides a single layout + Orpyca tokens as Sass variables and--op-*custom properties (per-tenant runtime rebranding), role-based UI mirroring the RBAC, and a text/voice assistant that translates natural language into MCP tools (ADR-019) — without executing actions itself — with a local LLM (Ollama) and Whisper STT, both pluggable and opt-in via Compose profiles; registered in the ADR index (ES+EN)chore(quality): pre-commit cleanup before the first source publication — Why: to leave the tree clean and compliant withCONTRIBUTING.mdbefore the first commit — Impact:ruff checknow passes clean (fixed 36 findings: 18 unused imports, 10 unsorted import blocks,isinstance/annotations toX | Ysyntax,zip(strict=), f-string without placeholder); added the missing.env.examplefiles (knowledge-service,signature-service); obsolete scratch notes (config.txt) moved out of the versioned tree;compileallOK andsharedtests green after the autofixeschore(license): attribution/license header across the whole source tree — Why: to record authorship and license terms in every file — Impact: added to 437 files (361.py, 63.sql, 11 Dockerfile, 2.js) a header «Desarrollo original de Orpyca; adaptado a partir de Orfeo bajo licencia GPL – aurigadl@gmail.com» with the AGPL v3 notice (the project's license, derived from the original Orfeo's GPL); applied using each type's comment syntax; verified:compileallOK and 347 tests green across the 11 services after the changefeat(mcp-server): per-session OAuth — the MCP obtains/refreshes the Keycloak token (E18, ADR-019) — Why: to complete E18 without relying on a static bearer: the mcp-server manages the token lifecycle — Impact:app/services/oauth.py(TokenManager) obtains/refreshes the access token viaauth-service(/api/v1/auth/tokenROPC and/api/v1/auth/refresh, public in the gateway) with expiry caching; precedenceMCP_BEARER_TOKEN(static) →MCP_REFRESH_TOKEN→MCP_AUTH_USERNAME/MCP_AUTH_PASSWORD; over HTTP the bearer still arrives via header; 27 tests green (4 new). E18 (MCP layer) complete: tools + resources + prompts + stdio + HTTP streamable + per-session OAuthfeat(mcp-server): MCP resources and prompts (E18, ADR-019) — Why: to complete the three MCP capabilities (tools + resources + prompts) so the agent can read data by URI and use reusable templates — Impact:app/mcp_resources.pyregisters resources (orfeo://normativastatic + templatesorfeo://radicado/{id},orfeo://expediente/{id},orfeo://expediente/{id}/indiceresolved against the gateway via the catalog) and prompts (radicar_pqrsd,resumen_expediente,buscar_antecedentes); available on both transports; the server advertisestools/resources/promptscapabilities (verified over the real protocol); 23 tests green (7 new). Pending: only per-session OAuthfeat(mcp-server): MCP HTTP streamable transport for remote clients (E18, ADR-019) — Why: to complete the MCP binding with the remote transport in addition to stdio — Impact:POST /mcpendpoint mounted on the FastAPI app withStreamableHTTPSessionManager(stateless + JSON); the session context arrives via headers (Authorization/X-Tenant-Slug/X-User-Permissions/X-User-Roles) and is injected through aContextVar(stdio still uses the environment);mcp_app.pyrefactored for a unified stdio/HTTP context;initializehandshake over/mcpverified in a test; 16 tests green (2 new). Pending: per-session OAuth + resources/promptsfeat(mcp-server): working MCP server over stdio (E18, ADR-019) — Why: to bind the official MCP SDK over the existing catalog/dispatcher so agents/LLMs can consume OrpycaMCP via the MCP protocol — Impact:app/mcp_app.py(anmcp.server.Serverthat exposes the catalog as MCP tools with inputSchema, filtered by permissions, and runs them via the dispatcher against the gateway propagating token+tenant) +app/mcp_stdio.py(entrypointpython -m app.mcp_stdio);mcp>=1.2.0dependency; real stdio handshake verified (initializeprotocolVersion 2024-11-05 +tools/list→ 8 tools); session context via environment (MCP_BEARER_TOKEN/MCP_TENANT_SLUG/MCP_USER_PERMISSIONS/MCP_USER_ROLES); 14 tests green (5 new). Pending: HTTP streamable transport + per-session OAuth + resources/promptsdocs(ops): external-integration operations notes (integraciones-externas.md, ES+EN) — Why: to guide activating the deferred external components once the environment is available — Impact: documents, for each pluggable capability, the swap point, the stable contract and the steps: PKI/XAdES qualified signature (signature-servicesigner.py), WORM/Object-Lock (storage-service), PDF/A validation with veraPDF, MCP SDK binding (mcp-server), real embeddings/RAG (knowledge-serviceembeddings.py), real postal operator (document-servicepostal_provider.py); added to the nav; clean--strictbuilddocs(i18n): complete publishable documentation in Spanish and English — Why: the MkDocs site is bilingual butdocs/en/only had 3 stale pages; the rest fell back to Spanish — Impact: all 14 nav pages translated to English (index, domain, normativa, lifecycle, getting-started, architecture, multi-tenancy, adrs, api, deployment, roadmap, contributing, security-audit, changelog);deployment.mdadded to the nav; fixed broken links todocumentos/specDrive/in ADR-010/012 and accented anchors indeployment.md; cleanmkdocs build --strict(es + en, no warnings). The 19 ADR detail pages still fall back to Spanish (technical decision records)feat(document): extended statistical dashboard + CSV export (E09, AGN-mandated) — Why: entities periodically report statistics to oversight bodies — Impact:GET /api/v1/reports/radicadosaddsby_dependencia; newGET /api/v1/reports/radicados.csvexports the summary (type/status/month/dependencia sections) as CSV withContent-Disposition; filters?date_from=&date_to=; 63 document tests green (1 new)feat(document): citizen PQRS registration (public, Ley 1755/2015) — Why: a legal requirement for public entities; a citizen must be able to file a petition without logging in and then track it — Impact:POST /api/v1/public/{tenant}/pqrs(unauthenticated, tenant in the path) creates an Entrada radicado withmetadata.canal=pqrs_ciudadanoand type (petición/queja/reclamo/sugerencia/denuncia), reusing the radicación and verification code from E13; returns{radicado_id, tracking_number, verification_code}for tracking via/public/{tenant}/verify/{code}; already routed as public in the gateway; 62 document tests green (2 new)feat(document): Salida templates + drafts (legacy parity) — Why: to produce Salida documents (create/edit/approve/register) before assigning them an official number; templates provide reusable content — Impact:plantillaandborradortables (migrationdocument tenant/015);GET/POST/DELETE /api/v1/plantillas;POST/GET /api/v1/borradores(optional creation from a template),GET/PATCH /api/v1/borradores/{id}(edit only if not yet registered),POST .../aprobar(draft→approved),POST .../radicar(→ generates the real radicado reusing the radicación, marksradicadowithradicado_id); routed in the gateway; 60 document tests green (4 new); migration validated on real pgdocs(api): document tenant administration (E14) — dependencias, catalogs, parameters, non-business days/holidays (GET/POST/DELETE /api/v1/config/holidays,/seed) andbusiness-days/calculate— Why: the holidays CRUD already existed (config.py) but was not inapi.md; the audit had flagged it as a gap, which was a false positivefeat(workflow): sequential approvals (vistos buenos) (legacy parity) — Why: approval governance; several reviewers give their sign-off (VoBo) in order before a document can be sent/signed — Impact:visto_buenotable (migrationworkflow tenant/008);POST /api/v1/workflows/{id}/vistos-buenoscreates the ordered chain of reviewers;POST .../vistos-buenos/decidir(the reviewer whose turn it is approves/rejects;403out of turn; a rejection stops the chain);GET .../vistos-buenos(global statusen_revision|aprobado|rechazado); 48 workflow tests green (4 new); migration validated on real pgfeat(document): quick reply — Salida linked to its Entrada antecedent (legacy parity) — Why: covers 30–50% of daily Salida production; a shortcut to reply to a radicado inheriting context — Impact:responde_acolumn (migrationdocument tenant/014);POST /api/v1/documents/{id}/respuestacreates a Salida radicado (reuses the radicación: numbering/events/audit) with subjectRE: …and a link to the antecedent (inresponde_a+metadata.antecedente);GET /api/v1/documents/{id}/respuestaslists the replying salidas; 56 document tests green (2 new); migration validated on real pgfeat(document): two-step radicado annulment (legacy parity) — Why: the law prohibits deleting radicados; they are annulled with supervisor approval — Impact:radicado_anulaciontable (migrationdocument tenant/013, partial unique index = one active request per radicado);POST /api/v1/documents/{id}/anulacion(request with cause/reason;409if already annulled or with a pending request),POST .../anulacion/aprobar(→ radicado moves toanuladoatomically; the number is preserved),POST .../anulacion/rechazar,GET .../anulacion; traceability of who requested/decided; 54 document tests green (4 new); migration validated on real pgfeat(workflow): return to sender (legacy parity) — Why: re-routing a misassigned radicado is the most frequent action after receiving it; without it the inbox gets stuck — Impact:POST /api/v1/workflows/{radicado_id}/devolver{to_dept, causal, comentario}closes the active step asreturnedand creates a new step toward the destination dependencia, with the cause (tenant catalog) recorded in the append-only history (flow_events, tipo_txdevolucion);404if there is no active step; 44 workflow tests green (2 new)feat(tenant): operator catalogs — causes, dispatch methods, supports, quick messages (legacy parity, E14) — Why: causes are a required lookup for return/annulment/reassignment (the next operator functions) — Impact: migrationtenant/006(4 tables + seed of 6 causes and methods/supports) registered in the generic catalog; available viaGET/POST/PATCH/DELETE /api/v1/catalogos/{causales|formas-envio|soportes|mensajes-rapidos}; 47 tenant tests green; migration validated on real pgfeat(archive): bulk inclusion of radicados into an expediente (legacy parity) — Why: linking many radicados at once is common when assembling an expediente — Impact:POST /api/v1/expedientes/{id}/radicados/batch{radicados:[...]}links up to 500 (idempotent: skips already-linked ones), regenerates the index only once, returns{vinculados, omitidos, total};409if the expediente is notopen; 55 archive tests green (1 new); no migrationfeat(storage): AIP packaging (BagIt + PREMIS manifest) (E10 RF-PRE-05/07, ISO 14721 OAIS) — Why: the OAIS model requires packaging the expediente with its fixity and preservation metadata for long-term archiving — Impact:aiptable (migrationstorage tenant/004);aip_buildergenerates the BagIt content (bagit.txt + manifest-sha256 + bag-info) and the PREMIS manifest (objects with SHA-256 fixity + ingest event) using the stdlib, plus the fixity of the AIP itself;POST /api/v1/preservacion/aippackages and records the INGESTA event;GET /api/v1/preservacion/aip/{expediente_id}retrieves the current AIP; 25 storage tests green (3 new); migration validated on real pg. (Real WORM/Object-Lock and veraPDF validation: infra, pending.)feat(archive): electronic index signing on expediente closure (E15-F3, RF-EXP-06) — Why: conforming closure (Acuerdo 001/2024 Art. 4.3.2.4) requires that the index be signed and immutable — Impact:close_expedientegenerates the final version of the index and signs it viasignature-service(SignatureClient.sign_xml, best-effort: if the service does not respond the closure is not blocked and the index remains current);expediente_indice.estado='firmado'+firma_id(IndexRepository.mark_signed);IndexService.firmar_indice; archive gainssignature_service_urlin config; 54 archive tests green (1 new)feat(signature-service): new electronic signature service (E06, ADR-016) — Why: to realize the deferred signature provider from ADR-016 and unblock index signing on closure — Impact: newsignature-servicemicroservice (port 8008, indocker-compose.yml) with a per-tenantfirmatable (migrationtenant/001); pluggable signature provider (nativaby default: SHA-256 hash + identity + seal; PKI XAdES/PAdES/TSA as a future swap);POST /api/v1/signature/sign-xml(signs a payload and persists the signature) andPOST /api/v1/signature/verify(recomputes and detects tampering); routed in the gateway (/api/v1/signature/); 4 tests green; migration validated on real pg. (Qualified X.509+TSA digital signature: pending via pluggable provider. Index-signing hook on closure: next increment.)fix(gateway): route the rules engine/api/v1/workflow/rules/(E05) — Why: the router uses the singular prefixworkflowand the gateway only routedworkflows/, leaving the rules engine unreachable via the gateway (404) — Impact: added the prefix toproxy.py; +1 case in the routing test; 13 gateway tests green-
docs(domain): updatedomain.mdwith the F3–F6 entities (electronic index, signature, physical archive/shelf-mark/loan, FUID, transfer, preservation, postal dispatch, webhook) + business rules + glossary; corrected thetrazabilidad.mdmatrix (E06/E10 → PARTIAL with real scope) -
docs(adr): ADR-014 — search with PostgreSQL FTS (tsvector + pg_trgm + JSONB), not Elasticsearch — Why: to enable E09 without external infra, consistent with schema isolation (ADR-002) and strong consistency (the index is derived from the DB); a reversible exit door via events if an institution exceeds the scale feat(document): IMAP email ingestion → Entrada radicado (E19/F2) — Why: to automatically register communications received by email — Impact:POST /api/v1/ingest/email/poll(internal, requires JWT) fetches the unread messages from the configured IMAP mailbox, creates an Entrada radicado per email (reusing numbering/audit/events) and marks them as read; the sender and attachments go inmetadata(source=email);parse_email(RFC 822, multipart/attachments),ImaplibClient(imaplib in a thread) behind an injectableImapClientProtocol;IMAP_*config (disabled ifIMAP_HOSTempty → 503); routed in the gateway (/api/v1/ingest/); 41 document tests green (4 new). (Uploading attachments to MinIO: a later evolution.)feat(document): public lookup by verification code (E13/F2) — Why: a citizen must be able to verify the traceability of their radicado WITHOUT logging in (transparency, Ley 1712/2014) — Impact:GET /api/v1/public/{tenant}/verify/{code}public (no JWT), exposes only NON-sensitive traceability (tracking_number, doc_type, status, registered_at, dest_dept);verification_codecolumn (migrationdocument tenant/010, a 32-char token generated in the DB by default, unique); the tenant travels in the path and the slug is validated (anti-injection in search_path); gateway:/api/v1/public/marked public and routed to document; 37 document + 13 gateway tests green (4 new)docs(adr): ADR-017 — physical archive: Location (recursive address) ≠ Conservation unit (movable container), ArchivesSpace pattern — Why: to reorganize the repository without touching expedientes, variable depth, hybrid expediente support (Acuerdo AGN 001/2024, FUID 042/2002)feat(document): postal dispatch of radicados (E20/F5, via E11) — Why: to dispatch outgoing radicados via a postal operator (4-72/Servientrega) and track the delivery status — Impact:postal_shipment+postal_shipment_eventtables (migrationdocument tenant/012); pluggable operator provider (deterministic tracking-number stub);POST /api/v1/documents/{id}/envios(generates tracking number, statusregistrado),GET /api/v1/documents/{id}/envios,GET /api/v1/envios/{id}(with history),POST /api/v1/envios/{id}/estado(operator callback/polling with state machineregistrado→en_transito→entregado|devuelto|fallido,409invalid transition); routed in the gateway; 50 document tests green (5 new); migration validated on real pgdocs(adr): ADR-019 — MCP layer as a thin client facade over the gateway (declarative tool catalog, no business logic or DB) — Why: to expose the API to agents/LLMs without duplicating RBAC/tenant/audit or breaking layer boundariesfeat(knowledge-service): new knowledge-layer service (E21/F6, ADR-006) — Why: to exploit institutional knowledge as a derived asset (semantic retrieval) without compromising the SGDEA guarantees — Impact: newknowledge-servicemicroservice (port 8011, indocker-compose.yml) with pgvector per tenant (knowledge_chunkwithembedding vector(64)+ ivfflat cosine index; migrations global001_pgvector+ tenant001_knowledge); pluggable embeddings provider (deterministic local stub by default, ADR-006, no LLM);POST /api/v1/knowledge/ingest(text+metadata+acl → embedding + persistence) andPOST /api/v1/knowledge/search(kNN semantic retrieval with ACL pre-filtering by JSONB containment); an advisory/derived and opt-in layer (not a source of truth); 5 tests green; migrations validated on real pgvector (kNN cosine score 1.000). (Event-driven ingestion, backfill, RAG with citations and a real model are later increments.)feat(mcp-server): new MCP-layer service (E18/F6, ADR-019) — Why: the epic that justifies the product's "MCP" — exposing OrpycaMCP as tools to agents/LLMs — Impact: newmcp-servermicroservice (port 8009, indocker-compose.yml), an HTTP client of the api-gateway (never DB/MinIO/Redis); declarative tool catalog (register/search/query radicado/expediente/index/TRD/inbox/FUID) mapped 1:1 to REST endpoints;GET /api/v1/mcp/tools(filters by the user's permissions; ROOT sees all),POST /api/v1/mcp/tools/{name}/invoke(propagates Keycloak token + tenant to the gateway, write gate withX-Confirm-Write, per-permission scope → 403/412); 9 tests green. (The official MCP SDK binding —stdio/HTTP-streamable, initialize/capabilities— wraps this core and remains the next increment.)feat(storage): digital preservation plan + PREMIS events (E10/F5, RF-PRE-01/07, AGN 001/2024 Art. 4.3.2.6, ISO 14721 OAIS) — Why: to guarantee the document's five qualities over the long term with a Digital Preservation Plan and an immutable record of interventions — Impact:preservacion_plan(append-only versioning) andpreservacion_evento(PREMIS-style, migrationstorage tenant/003) tables;GET/PUT /api/v1/preservacion/plan(current/new version) +/plan/versions;POST/GET /api/v1/preservacion/eventos(INGESTA/FIJACION/MIGRACION/VALIDACION_PDFA/WORM/REPLICA with before/after hash and JSONB detail); routed in the gateway; 22 storage tests green (4 new); migration validated on real pg. (AIP/BagIt, real Object Lock/WORM and veraPDF validation require infra/libs and are addressed separately.)docs(adr): ADR-018 — outbound interoperability via signed HTTP webhooks (HMAC-SHA256) over the event bus, subscribed per tenant — Why: to integrate external systems in a decoupled and secure way without exposing the internal bus or polling the APIfeat(notification): signed outbound webhooks (E11/F5, ADR-018) — Why: to deliver domain events to external systems (portals, ERPs, postal operators E20) reactively — Impact: per-tenantwebhook_subscriptiontable (migrationnotification tenant/003);POST/GET/DELETE /api/v1/webhooks(subscriptions withurl,event_types—empty=all— andsecret); when consuming an event from the bus, notification-service POSTs the canonical envelope to the matching active subscriptions, with headerX-OrpycaMCP-Signature: sha256=<hmac>, best-effort with retries that never blocks processing; routed in the gateway; 20 notification tests green (5 new); migration validated on real pgfeat(archive): primary/secondary documentary transfers (E12/F5, AGN 001/2024 Art. 4.4.x) — Why: to transfer expedientes between the three archives of the life cycle (management→central→historical) with FUID as a deliverable and freezing of the expediente — Impact:transferenciastable (migrationarchive tenant/012); cyclepreparada→enviada→recibida|rechazada;POST /api/v1/transferencias(precondition: expedienteclosed→ 409 if not),/enviar,/recibir(on receipt, the expediente moves totransferredand is frozen),/rechazar,GETlist/detail,GET /{id}/fuid(deliverable, consumes the E17 FUID filtered by the expediente); routed in the gateway; 53 archive tests green (5 new); migration validated on real pgfeat(archive): FUID — Formato Único de Inventario Documental (E17 RF-ARF-10, AGN 042/2002) — Why: a canonical inventory of the holdings (the single source that E12 transfers consume) — Impact:GET /api/v1/fuidderives the inventory (expediente↔unit↔shelf-mark↔TRD series) in JSON with a consecutive order and AGN fields (subject, series, shelf-mark, unit, folios, support, extreme dates); filters?ubicacion_id=&trd_serie_id=;GET /api/v1/fuid.xmlexports in XML (namespaceurn:orpycamcp:fuid:v1, C14N); routed in the gateway; 48 archive tests green (2 new); no migration (read-only). (PDF/XLSX export and import: later increments.)feat(archive): loans and physical movement history (E17 RF-ARF-07/08) — Why: to control the checkout/return of physical units and leave an unalterable trace of their movements — Impact:prestamo(prestado→devuelto;vencidoderived from the expected date) andmovimiento_fisico(append-only, migrationarchive tenant/011) tables;POST /api/v1/unidades/{id}/prestamos(409 if already on loan),POST /api/v1/prestamos/{id}/devolver,GET /api/v1/prestamos?estado=prestado|devuelto|vencido,GET /api/v1/unidades/{id}/prestamos,GET /api/v1/unidades/{id}/movimientos; every loan/return records a movement (RETIRADO/DEVUELTO); routed in the gateway; 46 archive tests green (4 new); migration validated on real pgfeat(archive): core physical archive model (E17/F5, ADR-017) — Why: to safeguard and locate the paper holdings by linking the intellectual↔physical hierarchy — Impact:ubicacion(recursive),unidad_conservacion(with topographic shelf-mark) andexpediente_unidad(N:M with folio range) tables (migrationarchive tenant/010); endpoints/api/v1/ubicaciones(CRUD + path derived by recursive CTE),/api/v1/unidades(shelf-mark derived from the path + code, unique per tenant),POST /unidades/{id}/expedientes(link),GET /unidades/{id}/expedientes("what is in the box") andGET /expedientes/{id}/unidades("where the expediente is"); routed in the gateway; 42 archive tests green (4 new); migration validated on real pg. (Loans, history, FUID, label, capacity: later increments of E17.)feat(archive): index integrity verification (E15 T-14) — Why: to check that documents have not been altered since the index version — Impact:GET /api/v1/expedientes/{id}/indice/verify(?version=N) contrasts thevalor_huellarecorded in the index with the current hash of each document (expediente_radicados.content_hash) and reports{valido, documentos:[{document_id, huella_indice, huella_actual, coincide}], firma:{presente, valida}, fecha_verificacion}; 38 tests green (2 new: intact index / altered document)feat(archive): per-document hash value in the index (E15 T-08) — Why: the electronic index requires the SHA-256 hash of each document (charter §5.8); the hash travels with the link (provided by whoever knows the content) instead of having archive query storage internals — Impact:RadicadoLinkacceptscontent_hash(SHA-256, validated);content_hashcolumn inexpediente_radicados(migrationarchive tenant/009); the index uses that value as the item'svalor_huella; 36 tests green (1 new); migration validated on real pgfeat(archive): automatic index update + immutability on link/unlink (E15 RF-EXP-04) — Why: the index must always reflect the current documentary set and not allow changes on closed expedientes — Impact:link_radicado/unlink_radicadoregenerate the index (new append-only version, idempotent) after each change; adding/excluding radicados from a non-openexpediente returns409 expediente_not_open; 35 tests green (1 new)feat(archive): electronic index of the expediente (E15, Acuerdo AGN 001/2024) — Why: the piece that gives the electronic expediente legal validity (equivalent to foliation + control sheet) — Impact:expediente_indice(append-only versioning) andexpediente_indice_item(per-document snapshot with original order, migrationarchive tenant/008) tables;IndexBuildergenerates the XML (namespaceurn:orpycamcp:indice:v1, R.1.34 fields) withxml_sha256hash (C14N) andcontent_hashof the set (idempotency); index version 0 on expediente creation;GET /api/v1/expedientes/{id}/indice(current or?version=N),/indice.xml,/indice/versions,POST /indice/rebuild(idempotent: does not create a version if the set did not change); 34 tests green (5 new); migration validated on real pg. (The E02 foliation endpoint was renamed to/foliado.) Pending E15:valor_huellafrom storage fixity (T-08), XAdES signing on closure (F3), strict XSD validation (lxml)docs(adr): ADR-016 — native electronic signature (SHA-256 hash + identity + timestamp), PKI/PDF (PortableSigner) deferred — Why: a verifiable electronic signature (Ley 527/1999, Decreto 2364/2012) without a JVM dependency, reusing identity (ADR-013), SHA-256 integrity (E07) and immutable audit (ADR-008)feat(document): electronic signature of radicados/attachments (E06/F3, ADR-016) — Why: to leave verifiable evidence of who signed what and when — Impact:signaturestable (migrationdocument tenant/011);POST /api/v1/documents/{id}/signaturessigns (identity from the gateway claims +content_hashSHA-256 + reason);GET .../signatureslists;GET .../signatures/verify?content_hash=verifies that the signed hash matches the current one (tampering detection); 45 tests green (4 new); migration validated on real pgfeat(archive): expediente life cycle — closure/transfer/index (E02/F3, ADR-015) — Why: to apply the TRD disposition on closure and support transfer and foliation (Ley 594/2000) — Impact:POST /api/v1/expedientes/{id}/closecloses (open→closed) and, if it has a TRD series, materializes the calculated disposition (E04) into thedisposition JSONBcolumn (migrationarchive tenant/007);POST /{id}/transfer(closed→transferred,409if the transition is invalid);GET /{id}/foliadoreturns the foliation (radicados ordered with a sequential folio; the normative electronic index is E15); 29 tests green (4 new); migration validated on real pgdocs(adr): ADR-015 — TRD/CCD model (series↔subseries hierarchy, two-phase retention management/central, AGN disposition CT/E/S/M, versioning) — Why: the legal basis of the life cycle (Ley 594/2000, Acuerdo AGN 004/2013); reusestrd_series/tipos_documentaleswithout a rewritefeat(archive): TRD/CCD model and retention calculation (E04/F3, ADR-015) — Why: the SGDEA must classify and calculate the final disposition of expedientes — Impact:trd_seriesgainsparent_id(subseries),archivo_gestion_years/archivo_central_years(two-phase retention, backfilled from the previous fields),version/valid_from(migrationarchive tenant/006);TrdSerieCreateaccepts subseries + two phases + AGN disposition (CT/E/S/M, compatible with old values); newGET /api/v1/trd/{serie_id}/retention?closed_at=calculatesfin_archivo_gestion/fin_archivo_centraland the final disposition (a pure function over the series); 25 tests green (3 new); migration validated on real pgfeat(archive): full-text search of expedientes (E09/F2, ADR-014) — Why: to find any expediente, not just radicados — Impact:GET /api/v1/expedientes/searchwith optionalq(FTStsvectorover code/name/description +ts_rankranking), filtersstatus/date_from/date_to/meta.<field>=(JSONB GIN),X-Total-Count; migrationarchive tenant/005(generatedsearch_vectorcolumn + GIN index, without pg_trgm to avoid depending on extensions); route registered before/{expediente_id}to avoid colliding with the path param; 22 tests green (3 new); migration validated on real pg (FTS matches)feat(document): radicado reports/statistics (E09/F2) — Why: the SGDEA must offer document-management statistics — Impact:GET /api/v1/reports/radicadoswithtotal+ countsby_doc_type/by_status/by_month(SQLGROUP BY/date_truncaggregations), filters?date_from=&date_to=; routed in the gateway (/api/v1/reports/→document); 34 tests green (2 new)feat(document): advanced search (E09/F2, ADR-014) — Why: to find any radicado combining text and filters — Impact:GET /api/v1/searchrewritten:qoptional (no text = a date-filtered query), filtersdoc_type/status/date_from/date_to/dest_dept_code/meta.<field>=(JSONB/GIN),ts_rankranking when there is text,X-Total-Count; clean parameterized SQL (the prior fragile assembly was removed); 32 tests green (3 new; search had no tests)-
fix(gateway): routing of all domain prefixes (F1 consolidation) — Why: the proxy only knew 7 narrow prefixes; endpoints like/api/v1/audit,/dependencias,/business-days,/config,/catalogos,/metadata,/search,/expedientes,/tipos-documentales,/trd,/expediente-metadatawere unreachable (404) via the gateway — Impact: the proxy routing table was extended to map each domain prefix to its service;_route_to_upstreamtest; full F1 sweep green (~221 tests across 8 services) -
feat(auth): auth-service — Keycloak ROPC, JWT validation, audit log, endpoints/token/refresh/logout/validate/me feat(tenant): tenant-service — institution CRUD + per-tenant PostgreSQL schema provisioningfeat(gateway): api-gateway — centralized proxy, JWT validation, Redis rate limiting, identity header injectionfeat(document): document-service — radicación E/S/I, atomic numbering, management of attachment referencesfeat(storage): storage-service — upload to MinIO, SHA-256, deduplication, pre-signed URLs, per-tenant bucketsfeat(workflow): workflow-service — distribution flows between dependencias, history, Redis Streams eventsfeat(archive): archive-service — expedientes with a life cycle, radicado linking, TRD with document retentionfeat(notification): notification-service — Redis Streams consumer, SMTP, Redis-based historyrefactor(all): drop SQLAlchemy/Alembic — direct asyncpg and numbered SQL migration files
Fixed¶
fix(auth,frontend): the interface offered actions the server was going to reject, because it did not know what level each permission was granted at (third batch of the user-experience review) — a permission in this system is not a yes or a no: it has a level — look up, edit, create or delete. The server knows it and enforces it; the interface never received it, because the lookup that tells it who the user is resolved the level and then threw it away, sending only the list of names. The result: someone holding a read-only permission was offered Close, Transfer, Exclude or Sign acta buttons, and got a "you do not have permission" when they pressed them. The two user-experience reviews, carried out separately and blind to each other, reached the same conclusion, and both refused to fix it screen by screen: the problem was not in the screens but in what the server tells them. It was fixed in both halves. The server now also sends the level of each permission, without removing anything it already sent, so nothing already running breaks and no coordinated update is needed. And the interface learns to ask by level, with a deliberately strict rule: if the level does not arrive — a session opened before this change, for instance — the answer is no, never "I don't know, so I'll allow it". Applying it meant reviewing every button that writes, one by one, against what its service actually requires. That corrected the finding's own premise: most operations only ask for the lowest level, so only four screens needed the high restriction. Requiring more would have been the same mistake in reverse, hiding functions from people who are entitled to use them — and doing so silently. It was noted, without being touched, that the inbox has exactly that shape: the interface asks for a permission the server does not require, because there whoever may act is decided by holding the assigned step, not by a permission. — Why: the interface must be a faithful mirror of what the server authorizes; offering what is going to be rejected is a functional failure, not a cosmetic one. — Impact: no database changes. 199 authentication-service tests and 1370 frontend tests green, including one that reproduces exactly the reported case.fix(frontend): accessibility: insufficient contrast, tables unreachable without a mouse, errors not announced on their field, and AI suggestions that were never announced (second batch of the user-experience review) — four problems that left part of the users out. One: white text on the brand green did not reach the minimum required contrast, and not in a transient effect but in permanent states — the current step number in the two-factor, signature-credential and signing wizards; the active turn in the signature chain; the active filters in drafts and dispatches. In several places it was the other way round: the legible tone was used only on mouse-over. Two: wide tables cut off the Status and Actions columns on the right, and those that did scroll could not be scrolled with the keyboard, so anyone not using a mouse could not reach them. It was fixed in the two shared table components, so the fix reaches most screens. Three: on the profile screen, all seven validation error messages appeared only as a floating notice and never next to the failing field — precisely on the screen that governs the electronic signature credential; they are now announced on the field, and the one-time codes open the numeric keypad and autofill. Four: AI suggestions appeared without notifying screen-reader users; they are now announced, without touching the content, which already met the regulatory requirement to cite sources and warn that it was AI-generated. Two honest caveats: the table containers left unmodified wrap a component whose scrolling is already reachable, and giving them another keyboard stop would have created an empty destination; and the original finding mentioned four tables with one cell too many, which a sweep of all 28 tables in the project could not reproduce — recorded as such rather than forcing the figure. — Why: an actions column invisible on mobile, or an error a screen reader cannot locate, are not finishing details: they prevent the task from being completed. — Impact: 1348 tests green. The two-factor recovery code is deliberately left out: it is a server-side gap and must not be papered over in the interface.fix(frontend): batch loading never reported its result, three administration cards led to "no permission", and two screens were not linked (UX review, first batch of fixes) — the ~20 screens built to surface everything the backend already exposed were implemented without going through the UX/accessibility review, the last step of the project's process. The review was done afterwards and found three functional problems, not cosmetic ones; this is its first batch of fixes. One: document and expediente batch loading never reported its result, due to two compounding defects — the progress panel did not refresh, and the screen polled progress at an address that had ceased to exist when the routing was fixed. An administrator could load a thousand radicados and have no way of knowing whether they were created or whether everything failed. Two: three cards in the administration panel advertised a permission different from the one the server actually requires, so they led to a "you do not have permission" screen; the Interoperability case was doubly wrong, because it also hid the entire screen from a registration operator who could use part of it. All sixteen cards were checked, not just the three found, because this same pattern had already resurfaced four times. Three: two finished screens — digital preservation and signing keys — were missing from the administration index, which presents itself as the complete map, and were reachable only by typing the address by hand. The root cause of the first failure was fixed too: several code comments asserted things that had stopped being true (that the gateway did not route batch loading, that the metadata services did not require a permission). This is the third time in this release that a stale comment has caused a real defect, so they were deleted rather than left sitting next to the fix, and tests were added that pin the exact address each screen asks the server for, so a route change cannot silently break it again. — Why: a screen signed off as finished and documented as complete had its main function inoperative; building the UI reveals the capabilities, but only the review reveals the UI. — Impact: batch loading reports its result; the administration panel stops offering what it will deny and stops hiding what it authorizes; two screens stop being invisible. 1339 frontend tests green.fix(repo): four files of the interoperability screen never made it into the repository — an exclusion rule written for one specific folder at the project root was written unanchored, so it applied to any folder with that name anywhere in the tree. It matched the folder holding the document batch-loading files, which therefore stayed out of the history despite being documented as delivered: in the official copy of the project, the screen called addresses that did not exist there. Fixed by anchoring the rule to the root. — Why: it surfaced when re-running the work on a clean copy; the earlier review could not see it, because it reads the working files and there they were present. — Impact: none on application behavior. The lesson is that this kind of exclusion fails silently: the file simply never shows up as pending to save.fix(document): audit trail for batch radicado creation + reactivation of the batch test suite — batch radicado creation (POST /api/v1/batch/documents) left no trace whatsoever in the immutable audit log: hundreds of official documents, each with a unique tracking number, could be created with no record of who did it or when. It surfaced because the twin batch operation over expedientes, in another service, had done it from day one — two mirror functions, only one leaving a trace. Fixed by writing one audit entry per radicado created (not one per batch, so individual traceability is not lost), always attributed to the person who submitted the batch, and in such a way that a partially successful batch (say 480 of 500) records exactly what went in and what did not — never the original intent. In addition, this feature's automated test suite had been fully disabled since it was written (it referenced a test fixture that never existed). It was rewritten from scratch, also covering the permission gate that had recently been added with no test verifying it. — Why: mass-creating official documents with no record of authorship is precisely what the audit log exists to prevent; a disabled test suite protects nothing, least of all a freshly added permission gate. — Impact: no schema change (the audit table already existed). document-service suite: 284 passing, 20 skipped (previously 273 passing, 25 skipped — the remaining 20 are integration tests against a real database, unrelated to this change).fix(api-gateway,document,archive): batch loading unreachable — missing gateway route and an ambiguous status contract between services — closes the item left open by the interoperability screen: the gateway had no route registered for/api/v1/batch/, so both document and expediente batch loading always returned "route not found". Investigating it surfaced a deeper problem: the two services serving batch loading share the same base path, and the job-status lookup (.../batch/{id}/status) had the identical shape in both — there is no way for the gateway to guess, from the job id alone, which of the two to ask. The fix nests the status lookup under the job type (documents or expedientes), just as creation already did, so the routing decision is unambiguous. Along the way, document batch loading now requires the same permission as creating a document one at a time — it previously required none, unlike the expediente equivalent. — Why: without that route the already-built batch screen could not work; the contract shared by the two services had to be disambiguated at the source, not patched around. — Impact: batch status URLs change shape — and the screen consuming them kept the old shape, fixed later in the UX review entry; document batch loading gains permission enforcement. What this change left open — batch radicado creation without an audit entry — is closed by the first entry in this release.fix(frontend): accessibility sweep after the v1.0 redesign audit — 27 High/Critical findings (E22, WCAG 2.1 AA) — AA contrast inButtonand 8 screens, ≥44px touch targets in 8 components, visible focus inUserPicker, a misaligned column on mobile in/busqueda, and explicit confirmation before signing in/firmas, which had none.fix(frontend): redirect loop between Keycloak and/login(E22) — an IdP error returned to/loginrestarted the flow indefinitely; the error is now mapped from a closed table and shown with a "Retry" link.fix(frontend)://comments inside<style lang="scss">broke the raw SSR build (E22).fix(migrations): idempotency guards that did not filter by schema broke the provisioning of the second tenant (multi-tenant) — guards keyed on constraint/column names repeat across schemas, so from tenant #2 onwards the object was silently skipped. Two severities were found: missing constraints (backfillable) and, critically, DLQ migrations that aborted provisioning without marking themselves, requiring a re-run. Validated on a real second tenant plus verification by mutation.fix(knowledge):POST /api/v1/knowledge/searchreturned 500 against real PostgreSQL (E21) — the connection pool lacked ajsonbcodec; a pass-through encoder avoids double-encoding.fix(scripts):init_tenant.pyprovisioned incomplete tenants and aborted midway (E14/E21) — two defects: migrations were skipped (knowledge + tenant-service, which silently left PINAR unusable) and migration identity was keyed by bare filename, so collisions between services (archive/document, notification/signature) raised aUniqueViolationhalfway through provisioning. Fixed by keying the registry onservice/fileplus a backfill.
Security¶
fix(workflow): blocking findings closed in workflow-service (RF-SEG-03/08; E05) —execute_transactionignored the per-typerequires_permission(dynamic enforcement), andPERM_TRAMITAR/USUA_PERM_ADMINwere not enforced.fix(notification):/sendsealed + authorization on webhooks and history (RF-SEG-03; D-02; E11) —/sendis an inter-service endpoint (it carries 2FA OTPs) and is now sealed with an internal token rather than gated by a human permission; webhooks and delivery history requireUSUA_PERM_ADMIN.fix(workflow): RBAC enforcement of process transactions and distribution routes (RF-SEG-03; E05) — the spec's claim that "enforcement is delegated to the gateway" was false: the gateway only authenticates.fix(document): no-read-up closed on signatures, annulment, linked replies and shipments (RF-SEG-08; Ley 594/2000 art. 28).fix(document): no-read-up on disposition and onPATCHof radicados (RF-SEG-08; RF-MET-08; Ley 594/2000 arts. 24 and 28) — the gate lives in theWHERE/INSERT, not in a post-filter.fix(archive): the WORM renewalaudit_logentry attributed to the system principal (E10; RF-SEG-08).fix(archive)+fix(storage): no-read-up when querying preservation events per expediente (E10 Increment C; RF-SEG-08).fix(archive)+fix(storage): no-read-up when downloading the preservation AIP (E10 Increment C; RF-SEG-08; Ley 594/2000 art. 22).fix(workflow): automatic routing rules CRUD had no authorization whatsoever (RF-SEG-03; E05).fix(document)+fix(storage)+fix(gateway): no-read-up when downloading attachments (RF-SEG-08; Ley 1712/2014; D-02) — document-service now mediates the download (streaming, gated by the current clearance) and storage is sealed: mediation over denormalization for content access.fix(workflow): no-read-up in the inbox + step ownership inrollback+ oracle closed indevolver(RF-SEG-08).fix(document): no-read-up on linked Salidas —GET /documents/{id}/respuestas(RF-SEG-08; Ley 1712/2014).fix(archive): transfer life cycle (E12) — lethal TOCTOU race closed + all four custody acts with an audit entry and an unambiguous identity (RF-SEG-08; Ac. AGN 001/2024 Tít. 4.4) —update_estadogained an expected-state guard, closing therechazar-vs-recibirrace that could leave a transfer rejected while the expediente was already frozen; identity is unified onX-User-Id(fail-closed, no username fallback).
Phase 1 — Foundations (in progress, 2026-06-16)¶
feat(shared): shared libraryorpycamcp_common(ADR-010) — Why: identical audit and events across all services — Impact: internal package inshared/withaudit.append()(per-tenant hash chain) andevents(canonical envelope + publish/consume); installed by Docker (pip install /shared); 12 tests greenfeat(auth): canonical append-onlypublic.audit_log+ audit migration — Why: ADR-008/ADR-010 — Impact: migration002_audit_log.sql(table partitioned by month, immutability trigger, hash chain); auth-service dropsauth_audit_logs(obsolete) and audits viaorpycamcp_common.auditchore(infra): build context at the root +.dockerignore— Why: to allowCOPY sharedin the Dockerfiles (ADR-010) — Impact: auth-service is built withcontext: .; a pattern to replicate in services that use the libraryfeat(infra): per-tenant migration runner (ADR-012) — Why: the per-tenant tables (tenant_{slug}) had no application mechanism; the runner only wrote topublic— Impact:runner.pyseparatesmigrations/global/(→public) frommigrations/tenant/(→ eachtenant_{slug}schema), idempotent, with tracking inpublic._tenant_migrations; service template updatedfeat(auth): 4-table RBAC + URD + security classification (E08) — Why: Keycloak authenticates, the DB authorizes (RF-SEG-03/04/05) — Impact:tenant/migrations withauth_users/auth_groups/auth_permissions/auth_memberships/auth_group_permissions,urd_usuaroldep+usua_historico,security_levels+role_clearanceand a seed of 15 permissions; verified across 2 tenants-
feat(auth): permission resolution + RBAC administration endpoints (E08) — Why: to materialize "the DB authorizes" — Impact:RBACService.resolve_effective_permissions(MAX(crud) per permission, ROOT first),get_tenant_conndependency (search_path byX-Tenant-Slug), endpoints/api/v1/auth/users|groups|permissions|groups/{}/members/{}|groups/{}/permissions/{}and/users/{}/permissions; 19 tests green + MAX(crud) resolution validated against the real DB. Pending:require_permissiongate (awaits the enriched JWT) -
feat(tenant): per-tenant configuration — business-day calendar, parameters and catalogs (E14) — Why: E01 needs dependencias, radicado format and a due date in business days (RF-RAD-04) — Impact:tenant/migrations (dependencias,sgd_param_adminJSONB + seed,non_business_days, catalogs,admin_audit) +global/002(provisioning_status);calendar.pymodule (Colombian holidays Ley 51/1983 + business-day calculation); endpoints/api/v1/config/holidays(+/seed),/api/v1/business-days/calculate,/api/v1/config/params; per-tenant runner verified on the real DB (8 tables, 10 params, 5 media) feat(auth): security classification and access verification (E08, RF-SEG-08, Ley 1712/2014) — Why: Reservada/Clasificada documents only accessible to authorized roles (least privilege) — Impact:GET /api/v1/auth/security-levels(catalog Pública/Reservada/Clasificada);PUT /api/v1/auth/groups/{id}/clearancesets a group's maximum level (gatedUSUA_PERM_ADMIN);GET /api/v1/auth/clearanceand/clearance/check?level=Nresolve the caller's clearance (MAX over their groups; ROOT accesses everything) and verify access; uses the existingsecurity_levels/role_clearancetables; 40 tests green (6 new)feat(auth): multi-dependencia URD + context switch (E08, RF-SEG-04, ADR-013) — Why: a user can have a role in several dependencias and must be able to set their active dependencia — Impact: URD CRUDGET/POST/DELETE /api/v1/auth/users/{id}/urd(gatedUSUA_PERM_ADMIN; one URD per (user,depe), only one primary);GET /api/v1/auth/contextandPOST /api/v1/auth/context/switch(the user themselves viaget_caller) change the active dependencia without reissuing a token (ADR-013) — persistsauth_users.active_depe_id(migrationtenant/006) and recordsusua_historico(CONTEXT_SWITCH);400 no_urd_for_dependencyif the user has no active URD there; 34 tests green (7 new); migration validated on real pgfeat(auth): audit query and verification (E08, ADR-008) — Why: legal traceability (Ley 594/2000) must be queryable and verifiable, not just writable — Impact:GET /api/v1/auditlists the tenant'spublic.audit_log(filters action/object_type/object_ref/actor + pagination +X-Total-Count);GET /api/v1/audit/verifyrecomputes the hash chain withorpycamcp_common.audit.verify_chainand reports{ok, broken_id}; both gated withUSUA_PERM_ADMIN(ADR-013); read-only repository (writing remains a single point inorpycamcp_common.audit); 27 tests green (5 new: list, verify intact/broken, gate 403, 400 without tenant)feat(auth):require_permissiongate on RBAC endpoints + decision R4 (E08, ADR-013) — Why: the administration endpoints did not require the caller's permission (RF-SEG-04) — Impact:core/authz.pywithget_caller(resolveskeycloak_sub→auth_users→effective permissions from the tenant DB per request; 401 without identity, 403 if not provisioned) andrequire_permission(name,min_crud)(ROOT bypass); the RBAC router requiresUSUA_PERM_ADMIN(migrationtenant/005); Keycloak remains the sole issuer (authenticates, the DB authorizes); 22 auth tests greendocs(adr): ADR-013 — authorization resolved in the DB per request — Why: to fix the authorization model (decision R4) — Impact: own signing and token-exchange are discarded; permissions always fresh (immediate revocation), no stale tokens;/auth/context/switchwill not reissue a tokenfeat(tenant): reference catalog CRUD (E14) — Why: identification/sender/attachment types and reception media are parameterizable per institution — Impact: generic endpoints/api/v1/catalogos(list of catalogs) and/api/v1/catalogos/{catalogo}(GET/POST/PATCH/DELETE) over a table whitelist (tipos-identificacion,tipos-remitente,medios-recepcion,tipos-anexo); 404 on an unknown catalog, 409 on a duplicate code; 8 tests greenfix(tenant): tenant endpoints returned 404 with a trailing slash — Why:redirect_slashes=False+ routes registered without a slash; the tests called/api/v1/tenants/— Impact: tests aligned to the/api/v1/{resource}convention without a trailing slash (7 tests that were red); full tenant-service suite green (46)feat(notification): process-due alerts (E16↔E05, RF-FLU-07) — Why: to close the alert loop: the due-date sweep emits and the recipient must be notified — Impact: notification-service handlesworkflow.step.overduein its workflow-stream consumer → sends a notice email to the responsible dependencia; 15 tests green (1 new)feat(workflow): process transaction engine (E05, RF-FLU-01) — Why: the SGD needs to execute process transactions beyond assign/transfer (inform, NRR, schedule, mark read, VoBo, annul, close exp…) leaving an unalterable trace — Impact:transaction_typescatalog (migrationtenant/007, 12 transactions seeded with their atomic permission and state effect);GET /api/v1/workflows/transaction-typeslists the catalog;POST /api/v1/workflows/{radicado_id}/transactions {tipo_tx, comentario, detalles}executes a transaction: validates the type, appliestarget_step_statusto the active step when appropriate (marcar_leido→in_progress,anular→cancelled) and records the append-only event, atomically (404unknown type / radicado without flow); the atomic permission stays in the catalog (enforcement delegated to E08/gateway); 42 tests green (4 new); migration validated on real pgfeat(workflow): process due dates and traffic lights (E05, RF-FLU-07) — Why: the SGD must alert about overdue deadlines (PQRSD terms, Ley 1755/2015) — Impact:due_atper step (migrationtenant/006, set on manual assignment and by distribution from the radicado's deadline); traffic light computed at read time (green >3d / yellow ≤3d / red ≤1d / overdue) exposed in step and inbox responses;POST /api/v1/workflows/overdue/scandetects overdue steps not yet alerted, emitsworkflow.step.overdueto the bus (→ E16) and marks them (overdue_notified) to avoid duplicate alerts — designed for periodic invocation by a scheduler; 38 tests green (3 new); migration validated on real pg. Pending: notification-service consumingworkflow.step.overduefeat(workflow): typed process inboxes (E05, RF-FLU-03) — Why: the operator needs to see their pending items split by type (Entrada/Salida/Internos) — Impact:GET /api/v1/workflows/inbox?box=entrada|salida|internos&dept=&assigned_to=lists the active steps of the type, prioritized by age, withX-Total-Count(400 invalid_box);doc_type(E/S/I) is denormalized inflow_steps(migrationtenant/005) set on assignment (manual and by automatic distribution) to avoid querying document-service's radicados table; 35 tests green (2 new); migration validated on real pgfeat(workflow): assignment rollback + cascade reassignment (E05, RF-FLU-08) — Why: to undo the last erroneous assignment and reassign en masse without orphan radicados (role change/user deactivation) — Impact:POST /api/v1/workflows/{radicado_id}/rollbackcancels the current step (only if stillpending) and reactivates the previous one, atomically, with append-only eventtipo_tx=rollback(409 no_active_step/cannot_rollback_initial);POST /api/v1/workflows/reassign/cascadereassigns in one transaction all active steps of a user/dependencia to a new responsible party, with areassignevent per radicado (422 if no source is indicated); 33 tests green (5 new)feat(workflow): automatic distribution by router (E05, RF-FLU-04) — Why: when a document is registered it must be assigned automatically to the correct dependencia per the rules, without manual intervention — Impact: workflow-service consumesorpycamcp.document.events(workerdocument_consumer, groupworkflow-service); ondocument.radicado.createdit evaluates the active rules and, if they match, auto-assigns the radicado (step + append-only event,assigned_by=system); idempotent (does not redistribute if the radicado already has steps); document-service enriches the event withsubject/doc_class/dest_dept/origin_dept/sender_*/pagesso the rules can match; fix: the rules-service did not setsearch_path(broken at runtime after the ADR-012 reorg, it queriedpublic) → now it sets it; 28 workflow + 23 document tests green (3 new for distribution)feat(workflow): append-only process historyflow_events(E05, RF-FLU-02) — Why: the chain of custody requires an unalterable trace (Ley 594/2000, Acuerdo AGN 060/2001), not the mutable projection offlow_steps— Impact:flow_eventstable with a trigger that rejects UPDATE/DELETE (migrationtenant/004); every process transaction (assign/transfer/return/close/complete) writes its event in the same transaction as the step (there is no step without an event);GET /api/v1/workflows/{radicado_id}/eventsreturns the immutable sequence (tipo_tx, from/to dept, actor, comentario, detalles JSONB, ts);create/complete_stepare now atomic; 25 tests green (1 new + event-write verification); migration validated on real pg (table + trigger)feat(workflow): rules engine with operators, AND/OR and batch evaluation (E05) — Why: the engine only supported equality/substring on 4 fixed columns with AND and evaluated one radicado at a time — Impact:rules_engine.py(pure engine) with operatorseq/ne/contains/not_contains/in/gt/lt/gte/lte/regexover 8 radicado fields, combinable bymatch_mode(all=AND /any=OR); columnsconditions JSONB+match_mode(migrationtenant/003); legacy 4-column rules still work (fallback);POST /api/v1/workflow/rules/evaluate/batchevaluates 1-500 radicados with a single read of the rules; fixed the pre-existing bug in the rules endpoints (they read a non-existentrequest.state.auth_headers→ now headersX-Tenant-Slug/X-User-Id); 24 tests green (13 new, the engine previously had no tests); migration validated on real pgrefactor(storage,workflow): migrations reorganized totenant/(ADR-012) — Why: both services applied their migrations with the old runner topublic, sharingfiles/flow_steps/workflow_rulesacross institutions (broken isolation, the same debt as document/archive) — Impact:storage/migrations/tenant/001_create_files.sql;workflow/migrations/tenant/001_create_workflow.sql+002_workflow_rules.sql; ADR-012 runner in both; no code changes (the repos already setsearch_path); validated on real pg (tables intenant_{slug}, 0 inpublic); suites green (storage 13, workflow 11)feat(notification): SMTP retries with backoff (E16) — Why: a transient mailserver failure must not lose the notification — Impact:_send_with_retriesretries up toSMTP_MAX_ATTEMPTS(default 3) with linear backoff (SMTP_RETRY_BACKOFF_SECONDS); persistsattemptsandlast_error(migrationtenant/002); statussentif any attempt succeeded,failedwhen exhausted;attemptsexposed in the history response; 14 tests green (3 new). Pending E16: scheduled redelivery for long outages (a worker that retries thefailedones in deferred mode)feat(notification): notification history persisted in the per-tenant DB (E16, ADR-002/012) — Why: the history lived only in Redis (volatile, 30-day TTL); it must be durable and isolated per institution — Impact: notification-service gains a DB layer (previously had no DB):core/database.py(pool +get_tenant_connfor reads +acquire_tenant(slug)for the worker), migrationtenant/001_notifications.sql(per-tenant table, without a redundant tenant_slug column) + ADR-012 runner,DATABASE_URL+postgres in compose/depends_on, asyncpg in requirements;send_notificationpersists in the tenant's schema (best-effort, does not bring down the worker);GET /historyandGET /{id}read from the tenant DB (requireX-Tenant-Slug); Redis remains only for the event stream; 11 tests green; migration validated on real pg. Pending E16: SMTP retriesfeat(document): query filter by metadata (GIN) + document disposition (E03, RF-MET-02/08) — Why: to query radicados by a metadata field and persist disposition metadata (program/dates/action/marks) — Impact:GET /api/v1/documents?meta.<field>=valuefilters by JSONB containment (metadata @> …) using the GIN index;disposition JSONBcolumn (migrationtenant/009) managed withGET/PATCH /api/v1/documents/{id}/disposition(field merge: program, retention_until, action conservar/eliminar/transferir/seleccionar, confirmed, marked_for_deletion;404if the radicado does not exist); 29 tests green (3 new); migration validated on real pgfeat(archive): document type catalog (3rd TRD level) (E03, RF-MET-07) — Why: the document type is the third TRD level, mandatory/optional per series, with an associated metadata template — Impact:tipos_documentalestable (migrationtenant/004) + CRUD/api/v1/tipos-documentales(POST/GET/GET{id}/PATCH/DELETE; filter by series/active; 409 duplicate, 404);trd_serie_idnullable (weak coupling with E04) andmetadata_templatereferences (by name) the document-service template; 19 tests green (4 new); migration validated on real pgfeat(document): metadata element catalog (E03, RF-MET-03) — Why: to define metadata fields and their attributes (type, occurrences, modifiable, default, options, order, searchable, Dublin Core mapping) in a reusable way — Impact:metadata_elementstable (migrationtenant/008) + CRUD/api/v1/metadata/elements(POST/GET/GET{id}/PATCH/DELETE; 409 duplicate key, 404); includes themapeo_dublin_corehook (RF-MET-09); 26 tests green (3 new); migration validated on real pg. Note: the immutability of values formodificable=falseelements (R.3.7) will be enforced when setting values; here it is only catalogedfeat(document): attachment classification and foliation (E07, RF-DIG-01/RF-DIG-10) — Why: an attachment needs its document type, principal mark and folio count (input for expediente foliation) — Impact: fieldstipo_anexo(E14 catalog code),es_principalandfoliosinanexos(migrationtenant/007) and inPOST /api/v1/documents(per attachment) and in the response; 23 tests green; migration validated on real pgfeat(storage): immutable attachment versioning (E07, RF-DIG-01) — Why: "replacing" an attachment must create a new version while keeping the previous ones (an auditable chain), not overwrite — Impact: eachfilesrow is an immutable version; columnsversion/is_current/root_id/replaces_id(migrationtenant/002);POST /api/v1/storage/files/{id}/replacecreates a new version with its own SHA-256 and MinIO object (atomic: marks the previous one not current) →201; if the content is identical it does not create a version (200);409 not_current_versionif it is not the current one;GET /files/{id}/versionsreturns the chain; deduplication of new uploads only applies to current versions; 18 tests green (5 new); migration validated on real pg. Pending E07: attachment type catalog, folios, PDF/OCR conversion (infra)feat(storage): download integrity + format validation (E07, RF-DIG-02/04) — Why: to guarantee that the object in MinIO has not been altered and to reject unsupported formats on upload — Impact:GET /api/v1/storage/files/{id}/verifydownloads the object, recomputes the SHA-256 and compares it with the recorded one (409 integrity_check_failedif they differ);POST /uploadvalidates the MIME againstALLOWED_MIME_TYPES(configurable,*=all) →415 unsupported_media_type; 13 tests green (4 new). Pending E07: attachment versioning (immutable replacement), attachment type catalog, PDF conversion and preview, integrity-alert eventfeat(archive): flexible expediente metadata (E03, ADR-007) — Why: the expediente also requires per-series validated metadata, just like the radicado — Impact:expedientes.metadata JSONB+metadata_template_id+ GIN index;expediente_metadata_templatestable (JSON Schema pertrd_serie_id, one active per series);POST /api/v1/expedientesacceptsmetadataand validates it against its TRD series' active template (422 metadata_validation_failedon failure); CRUD in/api/v1/expediente-metadata/templates; 15 tests green (includes a fix for a pre-existing trailing-slash issue in 5 tests)refactor(archive): migrations reorganized totenant/(ADR-012) — Why: the old runner applied everything topublic(broken isolation, same as document-service) and auto-applied the FondeCund seed to everyone — Impact:migrations/tenant/001_create_archive,002_batch_jobs,003_expediente_metadata; ADR-012 runner (global/+tenant/); the FondeCund institutional seed moved tomigrations/seeds/fondecund_trd.sql(not auto-applied — it is a tenant's data, it goes viascripts/migrate_trd_from_fondecund.py); validated on real pg (tables intenant_{slug}, seed not applied)feat(document): flexible metadata per document type (E03, ADR-007) — Why: each document type has different metadata; the standard requires capturing, validating and querying it without migrating the schema for each type — Impact:radicados.metadata JSONBcolumn +metadata_template_id+ GIN index (migrationtenant/006, validated on real pg);metadata_templatestable (JSON Schema versioned pertipo_documental, one active per type);POST /api/v1/documentsacceptsmetadataand validates it against the JSON Schema of thedoc_class's active template (jsonschema) — failure →422 metadata_validation_failed, without registering; minimal template CRUD in/api/v1/metadata/templates(POST/GET,422 invalid_json_schemaif the schema is invalid); 23 tests green (4 new). Pending E03: the expediente side (archive),metadata_elementscatalog, document types tied to TRD, disposition metadata, GIN query filterrefactor(workflow): migration to the canonical event envelope (E05, ADR-010) — Why: workflow-service emitted its own flat 7-field dict; the bus must be uniform (the same envelope as document-service) so consumers parse with a single contract — Impact: workflow-service adoptsorpycamcp_common(root build context +COPY shared);_publishusesevents.emit(canonical envelope, domain inpayload); events renamedflow_step_created→workflow.step.created,flow_step_completed→workflow.step.completed; notification-service now parses both streams (workflow+document) withevents.parse_fields(the legacy flat format removed); 11 workflow + 10 notification tests greenfeat(notification): consumption of the document canonical stream (E16, ADR-010) — Why: to close the event loop — when a document is registered, its destination dependencia must be notified — Impact: notification-service adoptsorpycamcp_common(root build context +COPY shared); the worker now consumes TWO streams in a singlexreadgroup(orpycamcp.workflow.eventslegacy flat +orpycamcp.document.eventscanonical envelope) dispatching by stream name; new handlerprocess_document_eventreacts todocument.radicado.created(parses the envelope withevents.parse_fields, notifiesdependencia-{code}); groups created withevents.ensure_group; 10 tests green (2 new: notifies on registration, ignores other types). Pending E16: persist history in the DB + SMTP retries (today history is in Redis)feat(document): immutable audit + domain events in the radicación (ADR-008/010, E01) — Why: each radicación must leave a chained legal trace and propagate to other services (notifications, projections) — Impact: document-service adoptsorpycamcp_common(root build context +COPY shared, like auth-service); when creating a radicado it writes topublic.audit_log(document.radicado_created) atomically with the INSERT (same transaction) and publishes thedocument.radicado.createdevent on theorpycamcp.document.eventsstream (best-effort: a Redis failure does not roll back the radicación); Redis client in the lifespan,tenant-service/redisindepends_on; 19 tests green (2 new: emits event, survives Redis failure). Deployment note:public.audit_logis created by auth-service (the single source, ADR-008) — it must be migrated firstfeat(document): link radicados to the dependencia org chart (E01) — Why:origin_dept/dest_deptwere free text with no referential integrity against the tenant's org chart — Impact: optional fieldsorigin_dept_code/dest_dept_codeinPOST /api/v1/documents; document-service validates them againsttenant-service GET /dependencias/by-codigo/{codigo}via HTTP and denormalizes the dependencianombreinto the text fields (legal snapshot); columnsorigin_dept_code/dest_dept_code+ indexes (migrationtenant/005, validated on real pg); a non-existent code or inactive dependencia →400 invalid_dependencia; 17 tests green (3 new: valid, non-existent, inactive)feat(document): due date in business days (RF-RAD-04, E01) — Why: radicados must have a legal response deadline calculated in business days (Colombian holidays + the tenant's non-working days) — Impact: new optional fieldresponse_daysinPOST /api/v1/documents; document-service calculatesdue_dateby consumingtenant-service GET /business-days/calculatevia HTTP (TenantServiceClientclient, forwardsX-Tenant-Slug), without replicating the calendar; columndue_date DATE+ index (migrationtenant/004); if tenant-service does not respond →502 tenant_service_unavailableand it is not registered; 14 tests green (3 new: with/without deadline, 502 failure). TODO: resolve the default deadline fromsgd_param_adminwhenresponse_daysis not providedfix(document): per-tenant isolation of radicación (ADR-012, E01) — Why: the old runner applied all migrations topublic, soradicados,anexos,batch_jobsand above alltracking_sequencesended up SHARED across institutions (one tenant's numbering advanced with another's) — Impact: migrations reorganized intoglobal/001_extensions.sql(pg_trgm/unaccent inpublic) +tenant/001_create_documents/002_batch_jobs/003_search_indexes; ADR-012 runner (identical to auth/tenant's); verified against real postgres (tables intenant_{slug}, 0 inpublic, per-tenant sequence, idempotent with 2 tenants)refactor(document): radicación/search/batch routers useget_tenant_conn(ADR-002/012, E01) — Why:search/batchqueried without settingsearch_path(only worked with the tables inpublic) andbatchread a non-existentrequest.state.auth_headers— Impact:core/database.pyexposesget_tenant_conn/tenant_schema(like auth/tenant); the repository no longer self-managessearch_path;batchreads headers fromrequest.headers; a missing tenant header → 400invalid_tenant; 11 tests green (batchtests marked skip: incomplete Phase 6 scaffolding)feat(tenant): hierarchical dependencia CRUD (org chart, E14) — Why: E01 numbers radicados per dependencia (depe_codi) and needs the org chart — Impact: endpoints/api/v1/dependencias(list with filtersactivo/parent_id, POST, GET, partial PATCH, DELETE),/dependencias/tree(hierarchical tree) and/dependencias/by-codigo/{codigo}(consumed by E01); 409 on a duplicate code or deletion with children, 400 on a non-existentparent_idor self-reference; 13 tests green
Architecture decisions — ADR (2026-06-15)¶
docs(adr): ADR-006 — configurable multi-provider AI provider — Why: to enable the knowledge layer (RAG/AI) without tying the project to a provider; local sovereignty by default, recommended — Impact:LLMProviderinterface (ollama_local/ollama_cloud/openai_compatible/anthropic/disabled) selectable by global deployment configuration (AI_PROVIDER)docs(adr): ADR-007 — template-validatedJSONBmetadata — Why: document types with variable fields without proliferating tables — Impact:metadata JSONBcolumn +metadata_templates+ GIN index; EAV is discardeddocs(adr): ADR-008 — immutable auditpublic.audit_log— Why: legal traceability required by Ley 594/2000 — Impact: append-only table partitioned by month with a hash chain pertenant_slug, consumed via the common audit librarydocs(adr): ADR-009 — event-based flow history — Why: to reconstruct a document's journey without losing intermediate states — Impact:flow_eventsappend-only (source of truth) + reconstructibleflow_stepsprojectiondocs(adr): ADR-010 — shared libraryorpycamcp_common— Why: audit (hash chain) and the event envelope must be identical across all services — Impact: internal package inshared/installed by Docker (COPY shared+pip install /shared); F1 scope = audit + events; auth-service migrates fromauth_audit_logsto the canonicalpublic.audit_logdocs(adr): ADR-011 — SvelteKit frontend in the monorepo — Why: to define where the web interface lives and with what stack — Impact:frontend/folder (outsideservices/), client of the api-gateway, stack aligned with sgdINTI (SvelteKit SSR + Vite + Bulma); planning scaffold created, app pending its phasescaffold(frontend): initialfrontend/structure — Why: to fix tooling and structure without implementing yet — Impact:package.json(manifest),svelte.config.js/vite.config.js,Dockerfile/Dockerfile.dev,src/tree and README; dependencies NOT installed (Docker-only)
Phase 6 — Advanced Features (2026-06-05)¶
feat(phase6): TRD seed data migration —scripts/migrate_trd_from_fondecund.pywith 150+ FondeCund seriesfeat(batch): Batch Documents —POST /api/v1/batch/documents(1-1000 radicados, 202 Accepted, job tracking)feat(batch): Batch Expedientes —POST /api/v1/batch/expedientes(1-100 expedientes with radicados)feat(search): Full-Text Search —GET /api/v1/search?q=...with PostgreSQL pg_trgm + tsvectorfeat(workflow): Workflow Rules Engine — rules CRUD + automatic evaluation + audit logdocs(adr): ADR-005 documents the Batch API decision (async jobs vs sync)
Phase 5 — Community and Publication (2026-06-04)¶
infra(deploy): docker-compose.prod.yml + infra/.env.example + docs/es/deployment.mddocs(contributing): CONTRIBUTING.md with workflow, testing and documentation requirementsdocs(readme): README.md in English with quick start, architecture, featuresdocs(en): docs/en/getting-started.md + docs/en/api.md — bilingual documentationinfra(ci): GitHub Actions workflows — docker-build.yml + ci.yml (lint, test, E2E)feat(openapi): spec aggregation in api-gateway (GET /api/v1/openapi.json)infra(registry): infra/DOCKER_REGISTRY.md — GHCR guide + tagging strategy
Phase 4 — Quality and Integration (2026-06-04)¶
infra(lint): centralized pyproject.toml — ruff + mypy across all 8 servicesinfra(health): orchestrated/healthchecks Redis + 7 services in paralleltest(e2e): docker-compose.e2e.yml with full-flow testsdocs(adr): ADR-003 documents the asyncpg vs ORM decision
[0.1.0] — 2026-06-03¶
Added¶
- Base project structure with 8 FastAPI microservices
- docker-compose.yml with a complete development infrastructure
- Keycloak realm with RBAC roles and test users
- Per-tenant PostgreSQL schema isolation
- GitLab CI/CD pipeline with automatic documentation
- Bilingual es/en MkDocs Material
- ADR-001 and ADR-002 documented