Skip to content

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 rename orfeomcpOrpycaMCP / orpycamcp before 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 — brand OrpycaMCP; data/infra token orpycamcp (DB orpycamcp_db, Keycloak realm orpycamcp, buckets orpycamcp-{slug}-*, Redis streams orpycamcp.*, Python package orpycamcp_common, network orpycamcp-net, containers orpycamcp-*); repo/images/Compose project orpyca-mcp; company/web/org Orpyca/orpyca.com/GitLab orpyca. Requires recreating volumes; the repo folder is renamed to orpyca-mcp (manual step)
  • docs(claude): CLAUDE.md synced with the real architecture — 11 microservices with correct ports; removed SQLAlchemy/Alembic references (contradicted ADR-003: asyncpg + raw SQL); phases updated per specDrive
  • chore(ci): CI unified on GitLab — removed .github/workflows/; README/badges/clone point to gitlab.com/orpyca/orpyca-mcp
  • chore(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 unchanged
  • fix(compose): signature-service and knowledge-service now wait for postgres healthy (depends_on: condition: service_healthy) — they previously exited at startup with ConnectionRefused

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/tvd mirror 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 /envios tray. 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}/events scoped 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 audit
  • feat(archive): expediente route map (E02 §9) — append-only expediente_eventos table (migration archive tenant/014), events on open/link/unlink/close/transfer, and GET /api/v1/expedientes/{id}/eventos scoped by clearance
  • feat(audit): public.audit_log coverage (E08 §9) — workflow and archive also emit to the cross-cutting forensic audit via orpycamcp_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) and GET /api/v1/expedientes/{id} (archive-service) scope the query with nivel_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) use max_clearance=3 by default (no restriction). knowledge-service: semantic search no longer trusts the client-supplied acl and now resolves clearance server-side (COALESCE(acl->>'nivel_seguridad',1) <= clearance always; client acl only additional narrowing); new knowledge-service/app/core/clearance.py. caller_clearance helper in archive (resolved after setting search_path); resolve_caller_clearance made 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 retrieval
  • fix(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: new radicados.nivel_seguridad column (migration document tenant/016, scale 1=PUBLICA/2=RESERVADA/3=CLASIFICADA = security_levels.code, DEFAULT 1 → no behavior break); GET /api/v1/search resolves the caller's clearance from the tenant schema (auth_users/auth_memberships/role_clearance, ADR-013: the DB authorizes) and filters nivel_seguridad <= clearance (no read-up, least privilege, fail-closed to PUBLICA); POST /api/v1/documents accepts nivel_seguridad (default 1) to classify; reusable app/core/clearance.py; 70 document tests green (7 new: helper + filter), migration validated on real pg
  • fix(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.list take max_clearance); GET /api/v1/expedientes/search (archive-service) filters by nivel_seguridad <= clearance with the new expedientes.nivel_seguridad column (migration archive tenant/013, DEFAULT 1) and helper archive-service/app/core/clearance.py (resolved after setting the tenant search_path); POST /api/v1/expedientes accepts nivel_seguridad (default 1); 61 archive tests green (6 new), migration validated on real pg. Read access control closed across radicado search+listing and expediente search
  • docs(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: new docs/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 with CONTRIBUTING.md before the first commit — Impact: ruff check now passes clean (fixed 36 findings: 18 unused imports, 10 unsorted import blocks, isinstance/annotations to X | Y syntax, zip(strict=), f-string without placeholder); added the missing .env.example files (knowledge-service, signature-service); obsolete scratch notes (config.txt) moved out of the versioned tree; compileall OK and shared tests green after the autofixes
  • chore(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: compileall OK and 347 tests green across the 11 services after the change
  • feat(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 via auth-service (/api/v1/auth/token ROPC and /api/v1/auth/refresh, public in the gateway) with expiry caching; precedence MCP_BEARER_TOKEN (static) → MCP_REFRESH_TOKENMCP_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 OAuth
  • feat(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.py registers resources (orfeo://normativa static + templates orfeo://radicado/{id}, orfeo://expediente/{id}, orfeo://expediente/{id}/indice resolved against the gateway via the catalog) and prompts (radicar_pqrsd, resumen_expediente, buscar_antecedentes); available on both transports; the server advertises tools/resources/prompts capabilities (verified over the real protocol); 23 tests green (7 new). Pending: only per-session OAuth
  • feat(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 /mcp endpoint mounted on the FastAPI app with StreamableHTTPSessionManager (stateless + JSON); the session context arrives via headers (Authorization/X-Tenant-Slug/X-User-Permissions/X-User-Roles) and is injected through a ContextVar (stdio still uses the environment); mcp_app.py refactored for a unified stdio/HTTP context; initialize handshake over /mcp verified in a test; 16 tests green (2 new). Pending: per-session OAuth + resources/prompts
  • feat(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 (an mcp.server.Server that 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 (entrypoint python -m app.mcp_stdio); mcp>=1.2.0 dependency; real stdio handshake verified (initialize protocolVersion 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/prompts
  • docs(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-service signer.py), WORM/Object-Lock (storage-service), PDF/A validation with veraPDF, MCP SDK binding (mcp-server), real embeddings/RAG (knowledge-service embeddings.py), real postal operator (document-service postal_provider.py); added to the nav; clean --strict build
  • docs(i18n): complete publishable documentation in Spanish and English — Why: the MkDocs site is bilingual but docs/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.md added to the nav; fixed broken links to documentos/specDrive/ in ADR-010/012 and accented anchors in deployment.md; clean mkdocs 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/radicados adds by_dependencia; new GET /api/v1/reports/radicados.csv exports the summary (type/status/month/dependencia sections) as CSV with Content-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 with metadata.canal=pqrs_ciudadano and 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: plantilla and borrador tables (migration document 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, marks radicado with radicado_id); routed in the gateway; 60 document tests green (4 new); migration validated on real pg
  • docs(api): document tenant administration (E14) — dependencias, catalogs, parameters, non-business days/holidays (GET/POST/DELETE /api/v1/config/holidays, /seed) and business-days/calculateWhy: the holidays CRUD already existed (config.py) but was not in api.md; the audit had flagged it as a gap, which was a false positive
  • feat(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_bueno table (migration workflow tenant/008); POST /api/v1/workflows/{id}/vistos-buenos creates the ordered chain of reviewers; POST .../vistos-buenos/decidir (the reviewer whose turn it is approves/rejects; 403 out of turn; a rejection stops the chain); GET .../vistos-buenos (global status en_revision|aprobado|rechazado); 48 workflow tests green (4 new); migration validated on real pg
  • feat(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_a column (migration document tenant/014); POST /api/v1/documents/{id}/respuesta creates a Salida radicado (reuses the radicación: numbering/events/audit) with subject RE: … and a link to the antecedent (in responde_a + metadata.antecedente); GET /api/v1/documents/{id}/respuestas lists the replying salidas; 56 document tests green (2 new); migration validated on real pg
  • feat(document): two-step radicado annulment (legacy parity) — Why: the law prohibits deleting radicados; they are annulled with supervisor approval — Impact: radicado_anulacion table (migration document tenant/013, partial unique index = one active request per radicado); POST /api/v1/documents/{id}/anulacion (request with cause/reason; 409 if already annulled or with a pending request), POST .../anulacion/aprobar (→ radicado moves to anulado atomically; the number is preserved), POST .../anulacion/rechazar, GET .../anulacion; traceability of who requested/decided; 54 document tests green (4 new); migration validated on real pg
  • feat(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 as returned and creates a new step toward the destination dependencia, with the cause (tenant catalog) recorded in the append-only history (flow_events, tipo_tx devolucion); 404 if 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: migration tenant/006 (4 tables + seed of 6 causes and methods/supports) registered in the generic catalog; available via GET/POST/PATCH/DELETE /api/v1/catalogos/{causales|formas-envio|soportes|mensajes-rapidos}; 47 tenant tests green; migration validated on real pg
  • feat(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}; 409 if the expediente is not open; 55 archive tests green (1 new); no migration
  • feat(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: aip table (migration storage tenant/004); aip_builder generates 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/aip packages 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_expediente generates the final version of the index and signs it via signature-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 gains signature_service_url in 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: new signature-service microservice (port 8008, in docker-compose.yml) with a per-tenant firma table (migration tenant/001); pluggable signature provider (nativa by 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) and POST /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 prefix workflow and the gateway only routed workflows/, leaving the rules engine unreachable via the gateway (404)Impact: added the prefix to proxy.py; +1 case in the routing test; 13 gateway tests green
  • docs(domain): update domain.md with the F3–F6 entities (electronic index, signature, physical archive/shelf-mark/loan, FUID, transfer, preservation, postal dispatch, webhook) + business rules + glossary; corrected the trazabilidad.md matrix (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 in metadata (source=email); parse_email (RFC 822, multipart/attachments), ImaplibClient (imaplib in a thread) behind an injectable ImapClient Protocol; IMAP_* config (disabled if IMAP_HOST empty → 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_code column (migration document 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_event tables (migration document tenant/012); pluggable operator provider (deterministic tracking-number stub); POST /api/v1/documents/{id}/envios (generates tracking number, status registrado), GET /api/v1/documents/{id}/envios, GET /api/v1/envios/{id} (with history), POST /api/v1/envios/{id}/estado (operator callback/polling with state machine registrado→en_transito→entregado|devuelto|fallido, 409 invalid transition); routed in the gateway; 50 document tests green (5 new); migration validated on real pg
  • docs(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 boundaries
  • feat(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: new knowledge-service microservice (port 8011, in docker-compose.yml) with pgvector per tenant (knowledge_chunk with embedding vector(64) + ivfflat cosine index; migrations global 001_pgvector + tenant 001_knowledge); pluggable embeddings provider (deterministic local stub by default, ADR-006, no LLM); POST /api/v1/knowledge/ingest (text+metadata+acl → embedding + persistence) and POST /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: new mcp-server microservice (port 8009, in docker-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 with X-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) and preservacion_evento (PREMIS-style, migration storage 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 API
  • feat(notification): signed outbound webhooks (E11/F5, ADR-018) — Why: to deliver domain events to external systems (portals, ERPs, postal operators E20) reactively — Impact: per-tenant webhook_subscription table (migration notification tenant/003); POST/GET/DELETE /api/v1/webhooks (subscriptions with url, event_types —empty=all— and secret); when consuming an event from the bus, notification-service POSTs the canonical envelope to the matching active subscriptions, with header X-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 pg
  • feat(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: transferencias table (migration archive tenant/012); cycle preparada→enviada→recibida|rechazada; POST /api/v1/transferencias (precondition: expediente closed → 409 if not), /enviar, /recibir (on receipt, the expediente moves to transferred and is frozen), /rechazar, GET list/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 pg
  • feat(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/fuid derives 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.xml exports in XML (namespace urn: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; vencido derived from the expected date) and movimiento_fisico (append-only, migration archive 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 pg
  • feat(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) and expediente_unidad (N:M with folio range) tables (migration archive 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") and GET /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 the valor_huella recorded 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: RadicadoLink accepts content_hash (SHA-256, validated); content_hash column in expediente_radicados (migration archive tenant/009); the index uses that value as the item's valor_huella; 36 tests green (1 new); migration validated on real pg
  • feat(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_radicado regenerate the index (new append-only version, idempotent) after each change; adding/excluding radicados from a non-open expediente returns 409 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) and expediente_indice_item (per-document snapshot with original order, migration archive tenant/008) tables; IndexBuilder generates the XML (namespace urn:orpycamcp:indice:v1, R.1.34 fields) with xml_sha256 hash (C14N) and content_hash of 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_huella from 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: signatures table (migration document tenant/011); POST /api/v1/documents/{id}/signatures signs (identity from the gateway claims + content_hash SHA-256 + reason); GET .../signatures lists; 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 pg
  • feat(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}/close closes (open→closed) and, if it has a TRD series, materializes the calculated disposition (E04) into the disposition JSONB column (migration archive tenant/007); POST /{id}/transfer (closed→transferred, 409 if the transition is invalid); GET /{id}/foliado returns the foliation (radicados ordered with a sequential folio; the normative electronic index is E15); 29 tests green (4 new); migration validated on real pg
  • docs(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); reuses trd_series/tipos_documentales without a rewrite
  • feat(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_series gains parent_id (subseries), archivo_gestion_years/archivo_central_years (two-phase retention, backfilled from the previous fields), version/valid_from (migration archive tenant/006); TrdSerieCreate accepts subseries + two phases + AGN disposition (CT/E/S/M, compatible with old values); new GET /api/v1/trd/{serie_id}/retention?closed_at= calculates fin_archivo_gestion/fin_archivo_central and the final disposition (a pure function over the series); 25 tests green (3 new); migration validated on real pg
  • feat(archive): full-text search of expedientes (E09/F2, ADR-014) — Why: to find any expediente, not just radicados — Impact: GET /api/v1/expedientes/search with optional q (FTS tsvector over code/name/description + ts_rank ranking), filters status/date_from/date_to/meta.<field>= (JSONB GIN), X-Total-Count; migration archive tenant/005 (generated search_vector column + 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/radicados with total + counts by_doc_type/by_status/by_month (SQL GROUP BY/date_trunc aggregations), 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/search rewritten: q optional (no text = a date-filtered query), filters doc_type/status/date_from/date_to/dest_dept_code/meta.<field>= (JSONB/GIN), ts_rank ranking 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-metadata were unreachable (404) via the gateway — Impact: the proxy routing table was extended to map each domain prefix to its service; _route_to_upstream test; 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 provisioning
  • feat(gateway): api-gateway — centralized proxy, JWT validation, Redis rate limiting, identity header injection
  • feat(document): document-service — radicación E/S/I, atomic numbering, management of attachment references
  • feat(storage): storage-service — upload to MinIO, SHA-256, deduplication, pre-signed URLs, per-tenant buckets
  • feat(workflow): workflow-service — distribution flows between dependencias, history, Redis Streams events
  • feat(archive): archive-service — expedientes with a life cycle, radicado linking, TRD with document retention
  • feat(notification): notification-service — Redis Streams consumer, SMTP, Redis-based history
  • refactor(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 library orpycamcp_common (ADR-010) — Why: identical audit and events across all services — Impact: internal package in shared/ with audit.append() (per-tenant hash chain) and events (canonical envelope + publish/consume); installed by Docker (pip install /shared); 12 tests green
  • feat(auth): canonical append-only public.audit_log + audit migration — Why: ADR-008/ADR-010 — Impact: migration 002_audit_log.sql (table partitioned by month, immutability trigger, hash chain); auth-service drops auth_audit_logs (obsolete) and audits via orpycamcp_common.audit
  • chore(infra): build context at the root + .dockerignoreWhy: to allow COPY shared in the Dockerfiles (ADR-010) — Impact: auth-service is built with context: .; a pattern to replicate in services that use the library
  • feat(infra): per-tenant migration runner (ADR-012) — Why: the per-tenant tables (tenant_{slug}) had no application mechanism; the runner only wrote to publicImpact: runner.py separates migrations/global/ (→ public) from migrations/tenant/ (→ each tenant_{slug} schema), idempotent, with tracking in public._tenant_migrations; service template updated
  • feat(auth): 4-table RBAC + URD + security classification (E08) — Why: Keycloak authenticates, the DB authorizes (RF-SEG-03/04/05) — Impact: tenant/ migrations with auth_users/auth_groups/auth_permissions/auth_memberships/auth_group_permissions, urd_usuaroldep+usua_historico, security_levels+role_clearance and 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_conn dependency (search_path by X-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_permission gate (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_admin JSONB + seed, non_business_days, catalogs, admin_audit) + global/002 (provisioning_status); calendar.py module (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}/clearance sets a group's maximum level (gated USUA_PERM_ADMIN); GET /api/v1/auth/clearance and /clearance/check?level=N resolve the caller's clearance (MAX over their groups; ROOT accesses everything) and verify access; uses the existing security_levels/role_clearance tables; 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 CRUD GET/POST/DELETE /api/v1/auth/users/{id}/urd (gated USUA_PERM_ADMIN; one URD per (user,depe), only one primary); GET /api/v1/auth/context and POST /api/v1/auth/context/switch (the user themselves via get_caller) change the active dependencia without reissuing a token (ADR-013) — persists auth_users.active_depe_id (migration tenant/006) and records usua_historico (CONTEXT_SWITCH); 400 no_urd_for_dependency if the user has no active URD there; 34 tests green (7 new); migration validated on real pg
  • feat(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/audit lists the tenant's public.audit_log (filters action/object_type/object_ref/actor + pagination + X-Total-Count); GET /api/v1/audit/verify recomputes the hash chain with orpycamcp_common.audit.verify_chain and reports {ok, broken_id}; both gated with USUA_PERM_ADMIN (ADR-013); read-only repository (writing remains a single point in orpycamcp_common.audit); 27 tests green (5 new: list, verify intact/broken, gate 403, 400 without tenant)
  • feat(auth): require_permission gate 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.py with get_caller (resolves keycloak_subauth_users→effective permissions from the tenant DB per request; 401 without identity, 403 if not provisioned) and require_permission(name,min_crud) (ROOT bypass); the RBAC router requires USUA_PERM_ADMIN (migration tenant/005); Keycloak remains the sole issuer (authenticates, the DB authorizes); 22 auth tests green
  • docs(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/switch will not reissue a token
  • feat(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 green
  • fix(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 handles workflow.step.overdue in 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_types catalog (migration tenant/007, 12 transactions seeded with their atomic permission and state effect); GET /api/v1/workflows/transaction-types lists the catalog; POST /api/v1/workflows/{radicado_id}/transactions {tipo_tx, comentario, detalles} executes a transaction: validates the type, applies target_step_status to the active step when appropriate (marcar_leido→in_progress, anular→cancelled) and records the append-only event, atomically (404 unknown 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 pg
  • feat(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_at per step (migration tenant/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/scan detects overdue steps not yet alerted, emits workflow.step.overdue to 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 consuming workflow.step.overdue
  • feat(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, with X-Total-Count (400 invalid_box); doc_type (E/S/I) is denormalized in flow_steps (migration tenant/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 pg
  • feat(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}/rollback cancels the current step (only if still pending) and reactivates the previous one, atomically, with append-only event tipo_tx=rollback (409 no_active_step / cannot_rollback_initial); POST /api/v1/workflows/reassign/cascade reassigns in one transaction all active steps of a user/dependencia to a new responsible party, with a reassign event 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 consumes orpycamcp.document.events (worker document_consumer, group workflow-service); on document.radicado.created it 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 with subject/doc_class/dest_dept/origin_dept/sender_*/pages so the rules can match; fix: the rules-service did not set search_path (broken at runtime after the ADR-012 reorg, it queried public) → now it sets it; 28 workflow + 23 document tests green (3 new for distribution)
  • feat(workflow): append-only process history flow_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 of flow_stepsImpact: flow_events table with a trigger that rejects UPDATE/DELETE (migration tenant/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}/events returns the immutable sequence (tipo_tx, from/to dept, actor, comentario, detalles JSONB, ts); create/complete_step are 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 operators eq/ne/contains/not_contains/in/gt/lt/gte/lte/regex over 8 radicado fields, combinable by match_mode (all=AND / any=OR); columns conditions JSONB + match_mode (migration tenant/003); legacy 4-column rules still work (fallback); POST /api/v1/workflow/rules/evaluate/batch evaluates 1-500 radicados with a single read of the rules; fixed the pre-existing bug in the rules endpoints (they read a non-existent request.state.auth_headers → now headers X-Tenant-Slug/X-User-Id); 24 tests green (13 new, the engine previously had no tests); migration validated on real pg
  • refactor(storage,workflow): migrations reorganized to tenant/ (ADR-012) — Why: both services applied their migrations with the old runner to public, sharing files/flow_steps/workflow_rules across 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 set search_path); validated on real pg (tables in tenant_{slug}, 0 in public); 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_retries retries up to SMTP_MAX_ATTEMPTS (default 3) with linear backoff (SMTP_RETRY_BACKOFF_SECONDS); persists attempts and last_error (migration tenant/002); status sent if any attempt succeeded, failed when exhausted; attempts exposed in the history response; 14 tests green (3 new). Pending E16: scheduled redelivery for long outages (a worker that retries the failed ones 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_conn for reads + acquire_tenant(slug) for the worker), migration tenant/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_notification persists in the tenant's schema (best-effort, does not bring down the worker); GET /history and GET /{id} read from the tenant DB (require X-Tenant-Slug); Redis remains only for the event stream; 11 tests green; migration validated on real pg. Pending E16: SMTP retries
  • feat(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>=value filters by JSONB containment (metadata @> …) using the GIN index; disposition JSONB column (migration tenant/009) managed with GET/PATCH /api/v1/documents/{id}/disposition (field merge: program, retention_until, action conservar/eliminar/transferir/seleccionar, confirmed, marked_for_deletion; 404 if the radicado does not exist); 29 tests green (3 new); migration validated on real pg
  • feat(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_documentales table (migration tenant/004) + CRUD /api/v1/tipos-documentales (POST/GET/GET{id}/PATCH/DELETE; filter by series/active; 409 duplicate, 404); trd_serie_id nullable (weak coupling with E04) and metadata_template references (by name) the document-service template; 19 tests green (4 new); migration validated on real pg
  • feat(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_elements table (migration tenant/008) + CRUD /api/v1/metadata/elements (POST/GET/GET{id}/PATCH/DELETE; 409 duplicate key, 404); includes the mapeo_dublin_core hook (RF-MET-09); 26 tests green (3 new); migration validated on real pg. Note: the immutability of values for modificable=false elements (R.3.7) will be enforced when setting values; here it is only cataloged
  • feat(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: fields tipo_anexo (E14 catalog code), es_principal and folios in anexos (migration tenant/007) and in POST /api/v1/documents (per attachment) and in the response; 23 tests green; migration validated on real pg
  • feat(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: each files row is an immutable version; columns version/is_current/root_id/replaces_id (migration tenant/002); POST /api/v1/storage/files/{id}/replace creates 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_version if it is not the current one; GET /files/{id}/versions returns 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}/verify downloads the object, recomputes the SHA-256 and compares it with the recorded one (409 integrity_check_failed if they differ); POST /upload validates the MIME against ALLOWED_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 event
  • feat(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_templates table (JSON Schema per trd_serie_id, one active per series); POST /api/v1/expedientes accepts metadata and validates it against its TRD series' active template (422 metadata_validation_failed on 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 to tenant/ (ADR-012) — Why: the old runner applied everything to public (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 to migrations/seeds/fondecund_trd.sql (not auto-applied — it is a tenant's data, it goes via scripts/migrate_trd_from_fondecund.py); validated on real pg (tables in tenant_{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 JSONB column + metadata_template_id + GIN index (migration tenant/006, validated on real pg); metadata_templates table (JSON Schema versioned per tipo_documental, one active per type); POST /api/v1/documents accepts metadata and validates it against the JSON Schema of the doc_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_schema if the schema is invalid); 23 tests green (4 new). Pending E03: the expediente side (archive), metadata_elements catalog, document types tied to TRD, disposition metadata, GIN query filter
  • refactor(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 adopts orpycamcp_common (root build context + COPY shared); _publish uses events.emit (canonical envelope, domain in payload); events renamed flow_step_createdworkflow.step.created, flow_step_completedworkflow.step.completed; notification-service now parses both streams (workflow+document) with events.parse_fields (the legacy flat format removed); 11 workflow + 10 notification tests green
  • feat(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 adopts orpycamcp_common (root build context + COPY shared); the worker now consumes TWO streams in a single xreadgroup (orpycamcp.workflow.events legacy flat + orpycamcp.document.events canonical envelope) dispatching by stream name; new handler process_document_event reacts to document.radicado.created (parses the envelope with events.parse_fields, notifies dependencia-{code}); groups created with events.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 adopts orpycamcp_common (root build context + COPY shared, like auth-service); when creating a radicado it writes to public.audit_log (document.radicado_created) atomically with the INSERT (same transaction) and publishes the document.radicado.created event on the orpycamcp.document.events stream (best-effort: a Redis failure does not roll back the radicación); Redis client in the lifespan, tenant-service/redis in depends_on; 19 tests green (2 new: emits event, survives Redis failure). Deployment note: public.audit_log is created by auth-service (the single source, ADR-008) — it must be migrated first
  • feat(document): link radicados to the dependencia org chart (E01) — Why: origin_dept/dest_dept were free text with no referential integrity against the tenant's org chart — Impact: optional fields origin_dept_code/dest_dept_code in POST /api/v1/documents; document-service validates them against tenant-service GET /dependencias/by-codigo/{codigo} via HTTP and denormalizes the dependencia nombre into the text fields (legal snapshot); columns origin_dept_code/dest_dept_code + indexes (migration tenant/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 field response_days in POST /api/v1/documents; document-service calculates due_date by consuming tenant-service GET /business-days/calculate via HTTP (TenantServiceClient client, forwards X-Tenant-Slug), without replicating the calendar; column due_date DATE + index (migration tenant/004); if tenant-service does not respond → 502 tenant_service_unavailable and it is not registered; 14 tests green (3 new: with/without deadline, 502 failure). TODO: resolve the default deadline from sgd_param_admin when response_days is not provided
  • fix(document): per-tenant isolation of radicación (ADR-012, E01) — Why: the old runner applied all migrations to public, so radicados, anexos, batch_jobs and above all tracking_sequences ended up SHARED across institutions (one tenant's numbering advanced with another's) — Impact: migrations reorganized into global/001_extensions.sql (pg_trgm/unaccent in public) + tenant/001_create_documents/002_batch_jobs/003_search_indexes; ADR-012 runner (identical to auth/tenant's); verified against real postgres (tables in tenant_{slug}, 0 in public, per-tenant sequence, idempotent with 2 tenants)
  • refactor(document): radicación/search/batch routers use get_tenant_conn (ADR-002/012, E01) — Why: search/batch queried without setting search_path (only worked with the tables in public) and batch read a non-existent request.state.auth_headersImpact: core/database.py exposes get_tenant_conn/tenant_schema (like auth/tenant); the repository no longer self-manages search_path; batch reads headers from request.headers; a missing tenant header → 400 invalid_tenant; 11 tests green (batch tests 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 filters activo/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-existent parent_id or 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: LLMProvider interface (ollama_local/ollama_cloud/openai_compatible/anthropic/disabled) selectable by global deployment configuration (AI_PROVIDER)
  • docs(adr): ADR-007 — template-validated JSONB metadata — Why: document types with variable fields without proliferating tables — Impact: metadata JSONB column + metadata_templates + GIN index; EAV is discarded
  • docs(adr): ADR-008 — immutable audit public.audit_logWhy: legal traceability required by Ley 594/2000 — Impact: append-only table partitioned by month with a hash chain per tenant_slug, consumed via the common audit library
  • docs(adr): ADR-009 — event-based flow history — Why: to reconstruct a document's journey without losing intermediate states — Impact: flow_events append-only (source of truth) + reconstructible flow_steps projection
  • docs(adr): ADR-010 — shared library orpycamcp_commonWhy: audit (hash chain) and the event envelope must be identical across all services — Impact: internal package in shared/ installed by Docker (COPY shared + pip install /shared); F1 scope = audit + events; auth-service migrates from auth_audit_logs to the canonical public.audit_log
  • docs(adr): ADR-011 — SvelteKit frontend in the monorepo — Why: to define where the web interface lives and with what stack — Impact: frontend/ folder (outside services/), client of the api-gateway, stack aligned with sgdINTI (SvelteKit SSR + Vite + Bulma); planning scaffold created, app pending its phase
  • scaffold(frontend): initial frontend/ 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.py with 150+ FondeCund series
  • feat(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 + tsvector
  • feat(workflow): Workflow Rules Engine — rules CRUD + automatic evaluation + audit log
  • docs(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.md
  • docs(contributing): CONTRIBUTING.md with workflow, testing and documentation requirements
  • docs(readme): README.md in English with quick start, architecture, features
  • docs(en): docs/en/getting-started.md + docs/en/api.md — bilingual documentation
  • infra(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 services
  • infra(health): orchestrated /health checks Redis + 7 services in parallel
  • test(e2e): docker-compose.e2e.yml with full-flow tests
  • docs(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