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. While building the screen it turned out batch loading could not work at all — the gateway had no route registered for those two endpoints. That was fixed (see Fixed below), but the fix changed the shape of the status URL and the screen kept the old one; combined with a reactivity defect in the progress panel, batch loading reported no result under any circumstance. The UX review of 2026-08-02 caught it, and it is now fixed in that review's own entry below. Export, import and postal tracking did work end to end from the start. — Impact: 1 new screen with 4 sections, 1 new button in postal shipments, 27 new automated tests.
  • feat(frontend): /admin/tvd screen + convalidation circuit shared with /admin/trd (ADR-026, frontend) — a structural mirror of the TRD screen with its own columns (fonds/producing unit, extreme dates, instrument state) and no active-archive column, treating the valuation rationale as the form's main field. The convalidation circuit (approve/return/convalidate/register RUSD/repeal) was built once as a shared InstrumentLifecycleActions component and wired into both screens — it did not exist in /admin/trd either, ADR-025 had left it pending — warning about irreversibility inside the confirmation modal and offering no button the current state does not allow.
  • fix(archive): remediation of the conformance + security verdict on the TRD convalidation circuit (ADR-025, migration 038) — the engine now hardens a convalidada row (the administrative act is immutable, estado may only move to derogada, RUSD/publication are fill-once); devolver (aprobada→borrador, with a mandatory reason) closes the dead end at aprobada; registrar-rusd records the post-convalidation registry and publication; derogar is blocked while open expedientes remain under the code; convalidation repeals the previous current version in the same transaction with collision-safe re-pointing of orphaned FKs; GET /trd/revision-pendiente gains no-read-up by clearance plus a query trace (a security regression: the report did not filter by security level); and the D5 deletion gate authorizes over the frozen snapshot rather than the live row — the original criterion was the opposite of the one the ADR requires and would have blocked legitimate deletions.
  • feat(frontend): PINAR MVP — Institutional Archives Plan in /admin/pinar (Phase 7, Acuerdo AGN 003/2015) — PINAR had 30 endpoints in tenant-service and no screen at all. The MVP covers the listing (state filter, creating a new version always in borrador) and a workspace with a life-cycle Stepper and a dashboard tab (progress per objective / per axis). Approval permanently freezes the plan's content (an entry in the immutable audit_log), so it is warned about before confirming and the confirm button stays disabled without an administrative act; the dashboard distinguishes "could not load" from a legitimately empty plan, and never paints zeros because of a network failure. Out of scope for the MVP: critical aspects, prioritization, objectives, projects, follow-up, instruments and road map.
  • feat(archive): TRD convalidation circuit — aprobar/convalidar/derogar + ratification of migrated series (ADR-025 Increment 2) — the state machine that turns the TRD from a configuration table into a convalidated instrument: Comité approval, Consejo convalidation (or ratification of a series backfilled as migrada), and repeal, each with its administrative act recorded and audited.
  • feat(frontend): semantic and precedent search in /busqueda (Phase 6, E21) — two new tabs beside the existing exact search, resolved by ?tab= (deep-linkable), with three SSR proxies including one that verifies the pivot radicado before searching. Non-negotiable interface honesty: results are presented as similar documents (vector similarity with an explained qualitative band, never an exact match), a network failure is never disguised as "no results", and knowledge-service's ACL trimming is invisible by design — no hidden-result count is announced, which would turn search into an existence oracle.
  • feat(frontend): generative help (RAG) integrated into drafting and classification (F6, E21) — RAG is not a separate module: a "Suggest from precedents" button in /radicar and /borradores calls POST /knowledge/rag, and suggested tipo documental chips next to the classification selector are fed by /knowledge/antecedentes. Three hard rules: generated text is always inserted marked as generated and with its citations; without citations, insertion is not offered (an unsourced assertion in an official document is worse than no suggestion); and the classification chip is never auto-applied. A knowledge-service failure stays contained in the component and never blocks registering or saving a draft.
  • fix(notification): hardened destination for outbound webhooks (Medium security finding, non-blocking) — the subscription URL is validated before delivery so the signed webhook cannot be pointed at internal targets.
  • fix(auth): High finding closed — self-escalation to USUA_PERM_ROOT; ROOT is now provisioned out of band (ADR-024) — an entity administrator could grant themselves the ceiling permission of their own entity. ROOT is no longer grantable through the API.
  • fix(tenant,gateway): app/core/authz.py + Channel A in tenant-service; /api/v1/tenants/ removed from the gateway (closes 2 Critical security-audit findings) — tenant-service had no authorization at all (0 gate occurrences against 52 in document-service), and /api/v1/tenants/ was the only leak between entities, closed by taking it out of the gateway. The docstrings claiming "the gateway authorizes" were deleted, not just supplemented: that false statement was the vector.
  • fix(document): USUA_PERM_TRD + auditing on the metadata catalog (E03, closes a High audit finding) — the metadata template/element routers wrote with no permission gate.
  • feat(auth): immutable auditing of RBAC/URD/clearance writes (E08, closes a traceability gap) — granting a permission, changing a membership or raising a clearance now leave an entry in the hash-chained audit_log.
  • feat(frontend): TRD/CCD, metadata, catalog and parameter administration (E03/E04/E14, Phase 5 of the API↔UI drift closure) — TRD/CCD reuses CatalogCrudPanel in two tabs, with series as an indented tree (the parent_id hierarchy is the data, not a flat list) and immutable codes; metadata splits with TRD (TRD = classification, metadata = the fields of those schemas) with create-only templates whose json_schema is validated for shape before submitting; catalogs use a master-detail layout that degrades to two navigable levels on mobile; parameters adds a holiday calendar (Ley 51/1983 preload with no external call) and a business-day calculator that reconstructs the breakdown of the days subtracted, because the backend only returns the resulting date. Domain finding reported, not papered over: document-service's metadata.py/metadata_elements.py and tenant-service's catalogos.py/config.py declare no require_permission — any authenticated tenant user can write. These screens gate their write buttons with USUA_PERM_ADMIN client-side as a conservative measure, which never offers less than the backend requires.
  • feat(auth): reading group memberships, permissions and clearance (E08, closes the RBAC read gap) — the backend previously only supported blind add/remove on those three collections.
  • feat(frontend): group and dependencia administration (E08/E14, Phase 5 of the API↔UI drift closure) — groups reuse CatalogCrudPanel with a members/permissions/clearance drawer that states explicitly what the backend does not expose; dependencias add a DependenciaTreeNode.svelte tree over GET /dependencias/tree with an explicit risk-confirmation modal (not a tooltip) when renaming or deactivating, because dependencias are correlated by name in workflow_rules.assign_to_dept and in in-flight flow_steps.
  • feat(frontend): index of classified and reserved information (E08, Ley 1712 art. 20) + final disposition of the radicado/expediente (RF-MET-08/E04, Phase 4 of the API↔UI drift closure).
  • feat(frontend): transfer detail with the acta as a certificate and personal signing by role + digital preservation administration (E12/E17/E10, Phase 4 of the API↔UI drift closure) — each signer's role is derived from their identity, never chosen in the UI.
  • fix(workflow): workflow_rules.assign_to_dept validated when the rule is configured + an audit_log trace of the silent degradation — a rule pointing at a non-existent dependencia used to fail quietly at routing time.
  • fix(document): auditing of the radicado annulment cycle (ADR-008/010).
  • fix(workflow): forensic auditing of returns, process transactions and cascade reassignment (ADR-008/010).
  • feat(workflow): mandatory reason when reverting an assignment (RF-FLU-08).
  • feat(frontend): OrpycaMCP Design System v1.0 (E22, ADR-020)_tokens.scss repainted to the definitive palette keeping every --op-* name (zero breakage across 19 screens), with calculated, not estimated, WCAG contrast: the primary is split by role (--op-primary for non-textual use at 4.28:1, --op-primary-dark for text at 6.56:1). v1.0 type scale, radii, shadows and motion + a global prefers-reduced-motion rule; self-hosted Space Grotesk + Public Sans (@fontsource, no CDN). Audited by svelte-ux-reviewer: 27 High/Critical findings remediated, including explicit confirmation before signing in /firmas, which had none.
  • fix(frontend): Font Awesome was never loaded and the favicon 404'd (E22) — its CSS was never imported, so every icon rendered as an empty box.
  • feat(frontend): public landing page at / (E22) — seven sections, with a 302 to /dashboard when a session exists; the site root previously did not exist.
  • feat(frontend): login, callback and /403 with the real brand (E22) — a split composition and Keycloak error messages mapped from a closed table, which broke the IdP↔/login redirect loop.
  • feat(frontend): global search in the top bar and configurable favorites in the sidebar (E22) — the search term travels in the URL (shortcut /, deep link ?q=), and the favorites store is namespaced per tenant + user and consumes the already RBAC-filtered list, so a favorite whose permission is withdrawn disappears on its own.
  • feat(frontend): dashboard redesigned around the four personal modules (E22) — tenant metrics move to a collapsible section gated by SGD_PERM_ESTADISTICA (UI mirroring the RBAC), and NAV_ITEMS is extracted to lib/utils/navItems.js as the single source.
  • feat(frontend): DataTable gains the design system's eight mandatory capabilities (E22) — filters, aria-sort ordering, CSV export with formula-injection protection, configurable columns, saved views, multiple selection, content-visibility virtualization and in-cell editing, all opt-in; /busqueda and the four /reportes panels migrated.
  • feat(archive): multi-level EAD 2002 / ISAD(G) archival description of expedientes in OAI-PMH (E11, RF-INT-06) — a new public endpoint GET /api/v1/public/archive/oai/{tenant} disseminates each public expediente as a multi-level EAD fragment, walking the trd_series.parent_id chain to build fonds→series→(subseries)→file, satisfying ISAD(G) 2.2 and 2.4. Anti-leak boundary: it stops at level="file" and never enumerates child radicados' tracking numbers (a public expediente may contain reserved radicados whose level archive does not know) — only an aggregate count, with a grep guard test. A depth guard was added to the recursive CCD CTE, since the endpoint is anonymous.
  • feat(document): EAD 2002 / ISAD(G) archival description in OAI-PMH (E11, RF-INT-06) — a second metadataPrefix=ead on the same OAI endpoint, disseminating each public radicado as a self-contained EAD fragment with <archdesc level="item"> and the six mandatory ISAD(G)/NTC 4095 interchange elements. Read-only, no migration: it reuses the _PUBLIC chokepoint, so the format is orthogonal to the security trimming — choosing ead neither bypasses the filter nor creates an oracle.
  • feat(document): interoperable import of radicados (E11, RF-INT-05)POST /api/v1/import takes the INT-01 ZIP as a raw body with all-or-nothing validation before touching the DB: each radicado validates against the service's own trusted JSON Schema (not the one shipped in the package, which could be lax) and every binary's SHA-256 is verified; a mismatch returns 422 with no partial state. It re-ingests preserving the original radicado number (a collision is skipped, never overwritten), rebuilds the E↔S relation by number, audits radicado.import inside the transaction, and guards against zip-slip / zip-bomb. Closes E11's Critical finding.
  • feat(document): interoperable export of radicados (E11, RF-INT-01)POST /api/v1/export produces a ZIP with a manifest, a published JSON Schema the records are validated against, the attachment binaries and per-member SHA-256 checksums, applying per-radicado no-read-up and recording over-clearance omissions content-free. The radicado number is preserved as a portable identity, which is what makes INT-05 possible. Mass egress is audited (radicado.export).
  • feat(document): minimal read-only CMIS 1.1 profile (E11, RF-INT-02)GET /api/v1/public/cmis/{tenant} (Browser Binding JSON), where getContentStream serves an attachment's bytes only when the radicado is public. Same _PUBLIC chokepoint as OAI, so a reserved id yields an indistinguishable 404.
  • feat(document): OAI-PMH — metadata harvesting (E11, RF-INT-02; SGDEA R.12.1) — the six verbs, Dublin Core, selective harvesting and resumptionToken at GET /api/v1/public/oai/{tenant}. Harvesting exposes only what is public (Ley 1712/2014 arts. 18–19): the trimming lives at the source in a single _PUBLIC filter, so the omission is indistinguishable by construction — completeListSize counts public records only and a reserved id returns idDoesNotExist.
  • feat(tenant): PINAR — Institutional Archives Plan (RF-ADM-08, E14; Acuerdo AGN 003/2015) — the AGN's six-step methodology as 9 pinar_* tables (tenant migration 007) under /api/v1/pinar: diagnosis → critical aspects with risk, five articulating axes, deterministic prioritization, vision, objectives, projects and append-only follow-up (DB trigger). State machine borrador→aprobado→en_ejecucion→cerrado with a single plan in execution per tenant, approval requiring an attributable administrative act, and content frozen on approval — all audited in the immutable audit_log via orpycamcp_common, the first use of the shared library in tenant-service.
  • fix(document) + feat(document,archive): declared debts closed (E08/E10/E02) — no-write-up when registering a classified radicado, SYSTEM_RECONCILER_ID attribution for reconciliation entries, and subject/type in the expediente export manifest via a clearance-aware internal endpoint.
  • feat(archive) + feat(storage): expediente export to ZIP — GET /api/v1/expedientes/{id}/export.zip (E02/export; Ley 594/2000 art. 19) — archive orchestrates and storage assembles via an internal D-02 endpoint: electronic index XML (best-effort) + manifiesto.csv including exclusions with their cause + a LEEME.txt scope note + checksums.txt + the attachment bytes. Per-radicado no-read-up: document-service filters nivel <= clearance and omits over-clearance radicados including their existence — closing a read-up leak that an expediente-level gate did not cover.
  • feat(document): index of classified and reserved information — GET /api/v1/reports/indice-reservado(.csv) (E08; Ley 1712/2014 art. 20) — the legally mandated register, content-free (never the subject) and orthogonal to clearance (the register must be complete), gated by PERM_RECLASIFICAR, with CSV formula-injection protection. Three conformance blockers were closed, chief among them that the legal grounds are now mandatory when classifying (whether registering or reclassifying, 422 if absent), so the index is never left with NULL grounds.
  • feat(document): postal operator as a webhook consumer — inbound callback POST /api/v1/public/postal/callback/{tenant_slug} (E20 F5; RF-POR-08; ADR-018) — per-tenant/per-operator HMAC-SHA256, symmetric to the outbound webhook and separate from the human dispatch permission; the tenant in the path fixes search_path and the secret lives in the tenant schema, so there is no cross-tenant forgery. Fail-closed (a missing credential and a bad signature return the same non-oracle 401), idempotent via UNIQUE (operador, event_id), with an atomic transition + audit entry; a late or backwards notification returns recorded_no_change instead of pretending.
  • feat(knowledge): generative RAG with citations — POST /knowledge/rag (E21 Inc. 3; ADR-006) — a pluggable LLMProvider (ollama_local as the sovereign default; external providers and anthropic with the native Citations API as options; disabled falls back to the citation-only pattern). Hard sovereignty barrier for generation: is_generatable excludes chunks at nivel_seguridad >= RESERVADA from the context when the provider is external — fail-closed, with no override, unlike the ADR's original soft model. Honest degradation with 503 rag_unavailable.
  • feat(knowledge): grounded retrieval with citations — POST /knowledge/antecedentes (E21 Inc. 2) — reuses search and its ACL pre-filter, with an anti-oracle 404 collapse on the pivot radicado (it does not distinguish "does not exist" from "exceeds your clearance").
  • feat(knowledge): real, local/sovereign embedding provider (E21 Inc. 1; ADR-006) — a pluggable EmbeddingProvider (local_st multilingual sentence-transformers as the deployment default, a deterministic stub forced in tests) replaces the stub as the only provider; EMBEDDING_DIM (384) is the single source of the vector dimension with a fail-closed startup guard, migration 007 and a python -m app.ops.reembed backfill job. With a local provider, classified material is embedded — it never leaves the installation — unlike with an external one.
  • feat(storage): PDF/A validation on attachment capture (E07, E10 C2b follow-up; RF-PRE-02).
  • feat(storage) + feat(archive): index-only AIP for 100% physical expedientes (E10 AIP wiring follow-up; OAIS ISO 14721; RFC 8493) — an expediente with no digital attachments records estado='omitido': its already-WORM signed index is its artefact.
  • feat(archive) + feat(storage): PDF/A profile configurable per documentary series / TRD (E10 C2b follow-up; RF-PRE-02)pdfa_profile is the explicit exception to the TRD's append-only trigger.
  • feat(archive) + feat(storage): WORM renewal of the AIP for Conservación Total series (E10; closes the High debt from the AIP wiring) — the reconciliation phase now covers all three artefacts (index, acta, AIP), so a CT's complete OAIS package no longer loses its immutability when the original lock expires.
  • feat(archive) + feat(document) + feat(storage): OAIS AIP wired to expediente closure (E10 debt; OAIS ISO 14721; PREMIS v3; Ac. AGN 001/2024 art. 4.3) — the endpoint existed but nothing triggered it. Because the AIP must be the complete package, attachment file_ids are resolved through a new internal document-service endpoint with no clearance gate (a system inventory act, unreachable through the gateway; no-read-up is enforced at the AIP's read boundary, not at packaging time). Preconditions: closed + signed index + WORM-protected; a mutated attachment aborts with a fixity 409 rather than producing a corrupt AIP; a session advisory lock closes the irreversible double-packaging race, verified against real concurrent PostgreSQL.
  • feat(storage): real veraPDF PDF/A validation via a hardened subprocess (E10 Increment C2b; RF-PRE-02)create_subprocess_exec with a fixed argument list and flavour whitelist, scrubbed ENV (the child never sees DATABASE_URL/MINIO_SECRET_KEY/tokens), start_new_session+killpg+reap guaranteed in finally including cancellation, a concurrency semaphore with JVM memory caps, a per-invocation 0700 tempdir, dual output caps against DoS, JSON-only (no XXE), and honest degradation to a not_evaluated stub when the binary is absent. The JRE + veraPDF ship only in the deploy image; CI exercises the real plumbing with a fake binary. Tested against real veraPDF 1.30.2.
  • feat(storage) + feat(archive): WORM retention renewal for Conservación Total series (E10 debt; Ley 594/2000 + Ac. AGN 001/2024 art. 4.3.2.6) — a COMPLIANCE object of a permanently conserved series became deletable again once its original retain_until elapsed. A new internal renovar-retencion endpoint requires a strictly greater extension (otherwise an idempotent no-op) and uses set_object_retention; archive gains a reconciliation phase with a local mirror of the retention date, a fail-closed CT filter in SQL, a rolling window and exponential backoff with an agotado alarm. Only CT/conserve dispositions are renewed — E/S/M expire and are legitimately disposed of. The irreversible public route was withdrawn (internal only) and a visibility endpoint surfaces CT artefacts at risk.
  • fix(workflow): GET /workflows/{id}/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(auth,frontend): the interface offered actions the server was going to reject, because it did not know what level each permission was granted at (third batch of the user-experience review) — a permission in this system is not a yes or a no: it has a level — look up, edit, create or delete. The server knows it and enforces it; the interface never received it, because the lookup that tells it who the user is resolved the level and then threw it away, sending only the list of names. The result: someone holding a read-only permission was offered Close, Transfer, Exclude or Sign acta buttons, and got a "you do not have permission" when they pressed them. The two user-experience reviews, carried out separately and blind to each other, reached the same conclusion, and both refused to fix it screen by screen: the problem was not in the screens but in what the server tells them. It was fixed in both halves. The server now also sends the level of each permission, without removing anything it already sent, so nothing already running breaks and no coordinated update is needed. And the interface learns to ask by level, with a deliberately strict rule: if the level does not arrive — a session opened before this change, for instance — the answer is no, never "I don't know, so I'll allow it". Applying it meant reviewing every button that writes, one by one, against what its service actually requires. That corrected the finding's own premise: most operations only ask for the lowest level, so only four screens needed the high restriction. Requiring more would have been the same mistake in reverse, hiding functions from people who are entitled to use them — and doing so silently. It was noted, without being touched, that the inbox has exactly that shape: the interface asks for a permission the server does not require, because there whoever may act is decided by holding the assigned step, not by a permission. — Why: the interface must be a faithful mirror of what the server authorizes; offering what is going to be rejected is a functional failure, not a cosmetic one. — Impact: no database changes. 199 authentication-service tests and 1370 frontend tests green, including one that reproduces exactly the reported case.
  • fix(frontend): accessibility: insufficient contrast, tables unreachable without a mouse, errors not announced on their field, and AI suggestions that were never announced (second batch of the user-experience review) — four problems that left part of the users out. One: white text on the brand green did not reach the minimum required contrast, and not in a transient effect but in permanent states — the current step number in the two-factor, signature-credential and signing wizards; the active turn in the signature chain; the active filters in drafts and dispatches. In several places it was the other way round: the legible tone was used only on mouse-over. Two: wide tables cut off the Status and Actions columns on the right, and those that did scroll could not be scrolled with the keyboard, so anyone not using a mouse could not reach them. It was fixed in the two shared table components, so the fix reaches most screens. Three: on the profile screen, all seven validation error messages appeared only as a floating notice and never next to the failing field — precisely on the screen that governs the electronic signature credential; they are now announced on the field, and the one-time codes open the numeric keypad and autofill. Four: AI suggestions appeared without notifying screen-reader users; they are now announced, without touching the content, which already met the regulatory requirement to cite sources and warn that it was AI-generated. Two honest caveats: the table containers left unmodified wrap a component whose scrolling is already reachable, and giving them another keyboard stop would have created an empty destination; and the original finding mentioned four tables with one cell too many, which a sweep of all 28 tables in the project could not reproduce — recorded as such rather than forcing the figure. — Why: an actions column invisible on mobile, or an error a screen reader cannot locate, are not finishing details: they prevent the task from being completed. — Impact: 1348 tests green. The two-factor recovery code is deliberately left out: it is a server-side gap and must not be papered over in the interface.
  • fix(frontend): batch loading never reported its result, three administration cards led to "no permission", and two screens were not linked (UX review, first batch of fixes) — the ~20 screens built to surface everything the backend already exposed were implemented without going through the UX/accessibility review, the last step of the project's process. The review was done afterwards and found three functional problems, not cosmetic ones; this is its first batch of fixes. One: document and expediente batch loading never reported its result, due to two compounding defects — the progress panel did not refresh, and the screen polled progress at an address that had ceased to exist when the routing was fixed. An administrator could load a thousand radicados and have no way of knowing whether they were created or whether everything failed. Two: three cards in the administration panel advertised a permission different from the one the server actually requires, so they led to a "you do not have permission" screen; the Interoperability case was doubly wrong, because it also hid the entire screen from a registration operator who could use part of it. All sixteen cards were checked, not just the three found, because this same pattern had already resurfaced four times. Three: two finished screens — digital preservation and signing keys — were missing from the administration index, which presents itself as the complete map, and were reachable only by typing the address by hand. The root cause of the first failure was fixed too: several code comments asserted things that had stopped being true (that the gateway did not route batch loading, that the metadata services did not require a permission). This is the third time in this release that a stale comment has caused a real defect, so they were deleted rather than left sitting next to the fix, and tests were added that pin the exact address each screen asks the server for, so a route change cannot silently break it again. — Why: a screen signed off as finished and documented as complete had its main function inoperative; building the UI reveals the capabilities, but only the review reveals the UI. — Impact: batch loading reports its result; the administration panel stops offering what it will deny and stops hiding what it authorizes; two screens stop being invisible. 1339 frontend tests green.
  • fix(repo): four files of the interoperability screen never made it into the repository — an exclusion rule written for one specific folder at the project root was written unanchored, so it applied to any folder with that name anywhere in the tree. It matched the folder holding the document batch-loading files, which therefore stayed out of the history despite being documented as delivered: in the official copy of the project, the screen called addresses that did not exist there. Fixed by anchoring the rule to the root. — Why: it surfaced when re-running the work on a clean copy; the earlier review could not see it, because it reads the working files and there they were present. — Impact: none on application behavior. The lesson is that this kind of exclusion fails silently: the file simply never shows up as pending to save.
  • fix(document): audit trail for batch radicado creation + reactivation of the batch test suite — batch radicado creation (POST /api/v1/batch/documents) left no trace whatsoever in the immutable audit log: hundreds of official documents, each with a unique tracking number, could be created with no record of who did it or when. It surfaced because the twin batch operation over expedientes, in another service, had done it from day one — two mirror functions, only one leaving a trace. Fixed by writing one audit entry per radicado created (not one per batch, so individual traceability is not lost), always attributed to the person who submitted the batch, and in such a way that a partially successful batch (say 480 of 500) records exactly what went in and what did not — never the original intent. In addition, this feature's automated test suite had been fully disabled since it was written (it referenced a test fixture that never existed). It was rewritten from scratch, also covering the permission gate that had recently been added with no test verifying it. — Why: mass-creating official documents with no record of authorship is precisely what the audit log exists to prevent; a disabled test suite protects nothing, least of all a freshly added permission gate. — Impact: no schema change (the audit table already existed). document-service suite: 284 passing, 20 skipped (previously 273 passing, 25 skipped — the remaining 20 are integration tests against a real database, unrelated to this change).
  • fix(api-gateway,document,archive): batch loading unreachable — missing gateway route and an ambiguous status contract between services — closes the item left open by the interoperability screen: the gateway had no route registered for /api/v1/batch/, so both document and expediente batch loading always returned "route not found". Investigating it surfaced a deeper problem: the two services serving batch loading share the same base path, and the job-status lookup (.../batch/{id}/status) had the identical shape in both — there is no way for the gateway to guess, from the job id alone, which of the two to ask. The fix nests the status lookup under the job type (documents or expedientes), just as creation already did, so the routing decision is unambiguous. Along the way, document batch loading now requires the same permission as creating a document one at a time — it previously required none, unlike the expediente equivalent. — Why: without that route the already-built batch screen could not work; the contract shared by the two services had to be disambiguated at the source, not patched around. — Impact: batch status URLs change shape — and the screen consuming them kept the old shape, fixed later in the UX review entry; document batch loading gains permission enforcement. What this change left open — batch radicado creation without an audit entry — is closed by the first entry in this release.
  • fix(frontend): accessibility sweep after the v1.0 redesign audit — 27 High/Critical findings (E22, WCAG 2.1 AA) — AA contrast in Button and 8 screens, ≥44px touch targets in 8 components, visible focus in UserPicker, a misaligned column on mobile in /busqueda, and explicit confirmation before signing in /firmas, which had none.
  • fix(frontend): redirect loop between Keycloak and /login (E22) — an IdP error returned to /login restarted the flow indefinitely; the error is now mapped from a closed table and shown with a "Retry" link.
  • fix(frontend): // comments inside <style lang="scss"> broke the raw SSR build (E22).
  • fix(migrations): idempotency guards that did not filter by schema broke the provisioning of the second tenant (multi-tenant) — guards keyed on constraint/column names repeat across schemas, so from tenant #2 onwards the object was silently skipped. Two severities were found: missing constraints (backfillable) and, critically, DLQ migrations that aborted provisioning without marking themselves, requiring a re-run. Validated on a real second tenant plus verification by mutation.
  • fix(knowledge): POST /api/v1/knowledge/search returned 500 against real PostgreSQL (E21) — the connection pool lacked a jsonb codec; a pass-through encoder avoids double-encoding.
  • fix(scripts): init_tenant.py provisioned incomplete tenants and aborted midway (E14/E21) — two defects: migrations were skipped (knowledge + tenant-service, which silently left PINAR unusable) and migration identity was keyed by bare filename, so collisions between services (archive/document, notification/signature) raised a UniqueViolation halfway through provisioning. Fixed by keying the registry on service/file plus a backfill.

Security

  • fix(workflow): blocking findings closed in workflow-service (RF-SEG-03/08; E05)execute_transaction ignored the per-type requires_permission (dynamic enforcement), and PERM_TRAMITAR/USUA_PERM_ADMIN were not enforced.
  • fix(notification): /send sealed + authorization on webhooks and history (RF-SEG-03; D-02; E11)/send is an inter-service endpoint (it carries 2FA OTPs) and is now sealed with an internal token rather than gated by a human permission; webhooks and delivery history require USUA_PERM_ADMIN.
  • fix(workflow): RBAC enforcement of process transactions and distribution routes (RF-SEG-03; E05) — the spec's claim that "enforcement is delegated to the gateway" was false: the gateway only authenticates.
  • fix(document): no-read-up closed on signatures, annulment, linked replies and shipments (RF-SEG-08; Ley 594/2000 art. 28).
  • fix(document): no-read-up on disposition and on PATCH of radicados (RF-SEG-08; RF-MET-08; Ley 594/2000 arts. 24 and 28) — the gate lives in the WHERE/INSERT, not in a post-filter.
  • fix(archive): the WORM renewal audit_log entry attributed to the system principal (E10; RF-SEG-08).
  • fix(archive) + fix(storage): no-read-up when querying preservation events per expediente (E10 Increment C; RF-SEG-08).
  • fix(archive) + fix(storage): no-read-up when downloading the preservation AIP (E10 Increment C; RF-SEG-08; Ley 594/2000 art. 22).
  • fix(workflow): automatic routing rules CRUD had no authorization whatsoever (RF-SEG-03; E05).
  • fix(document) + fix(storage) + fix(gateway): no-read-up when downloading attachments (RF-SEG-08; Ley 1712/2014; D-02) — document-service now mediates the download (streaming, gated by the current clearance) and storage is sealed: mediation over denormalization for content access.
  • fix(workflow): no-read-up in the inbox + step ownership in rollback + oracle closed in devolver (RF-SEG-08).
  • fix(document): no-read-up on linked Salidas — GET /documents/{id}/respuestas (RF-SEG-08; Ley 1712/2014).
  • fix(archive): transfer life cycle (E12) — lethal TOCTOU race closed + all four custody acts with an audit entry and an unambiguous identity (RF-SEG-08; Ac. AGN 001/2024 Tít. 4.4)update_estado gained an expected-state guard, closing the rechazar-vs-recibir race that could leave a transfer rejected while the expediente was already frozen; identity is unified on X-User-Id (fail-closed, no username fallback).

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