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. Known open defect: 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 currently reports no result under any circumstance. Detected in the UX review of 2026-08-02 and left open. Export, import and postal tracking do work end to end. — Impact: 1 new screen with 4 sections, 1 new button in postal shipments, 27 new automated tests.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(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, see the known defect in the interoperability 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.
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