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]

Fixed

  • docs: English and deployment docs had fallen behind B9/B10. en/getting-started.md lacked the suspended-institution screen; deployment.md (es/en) listed Kubernetes as a requirement while its Option 2 declares it unsupported; architecture.md (es/en) did not state that the assistant calls Anthropic’s API directly behind the residency barrier, nor the gateway-signed identity (ADR-036) or edge enforcement of institution status (ADR-035), and described signatures as XAdES-B/T.
  • fix(document,notification,signature,knowledge,auth,storage,archive)!: Block B10 — a returned postal shipment now triggers a notice and is not lost with Redis down, a suspended entity's callback is queued instead of rejected, editing a document voids by event the signature circuits over the old text, semantic fragments are gated with the radicado's current level, the personal signature no longer spends the TOTP on a signature that does not verify, and the XAdES-LTA ArchiveTimeStamp is renewed. What changes: 1 (document + notification) — document.envio.* events go through the outbox (inside the change's transaction), including the manual return, which emitted nothing; notification emails the return to the producing department and to whoever registered it, with the clearance gate in the WHERE per recipient: without clearance the email carries no number, subject, addressee, address or reason; a reserved radicado goes out as a sensitive email. 2 (signature + document) — signature consumes documento.contenido_cambiado and voids the requests whose payload_sha256 is not the current hash (asked of document at that moment: a late or repeated event does not bring down the circuit opened over the current text), with a firma.circuito_invalidado entry in the same transaction, only if something was voided; the synchronous internal endpoint requires {cuerpo_sha256_vigente} (422 without it); signature's DLQ replay dispatches by event_type (it always applied the reclassification before). 3 (knowledge) — search and get_pivot_chunk gate with GREATEST(fragment level, current radicado level) in the WHERE; hourly monotonic sweep reconciliacion_nivel (raise-only; tombstones external-provider fragments ≥ RESERVADA) with a knowledge.nivel_reconciliado entry (channel system); the false docstring in ingestion.py is fixed. 4 (document) — a validly signed postal callback for a suspended entity answers 202 deferred and is queued (tenant migration 048); the sweep applies it on reactivation (phase 5) with the reception date; full queue (POSTAL_DIFERIDOS_MAX=10000) → 503 with Retry-After; body > 64 KB → 413 cuerpo_demasiado_grande. 6 (auth + signature) — the personal signature no longer spends the TOTP on a signature that does not verify: a prior ephemeral XMLDSig rehearsal, the certificate fingerprint checked before the TOTP (409 certificado_cambiado) and RSA verification inside the TOTP transaction (503 firma_digest_no_verifica, the code remains usable). Residue: the final self-verification after remote_sign answers 503 with totp_posiblemente_consumido and records firma.autoverificacion_fallida_tras_totp (chain signing too). 7 (signature + storage + archive) — renewal of the XAdES-LTA ArchiveTimeStamp (TimeStampValidationData + a new ATS over everything before, EN 319 132-1 §5.5.2): firma_sello_archivo chain (signature migration 026), storage migration 022 (firma_lta_renovada, renueva_a) with WORM inheritance and PREMIS RENOVACION event; /verify validates every ATS (rule archive_timestamp_cadena_rota) and accepts the original or any current XML of the chain; GET /firmas/{id} exposes minio_key_vigente and archive_timestamp_ordinal; firma.lta_renovada event (best-effort, no consumer yet); daily job (LTA_RENOVACION_MARGEN_DIAS=90, LTA_RENOVACION_INTERVALO_S=86400, 0 turns it off). The AIP embeds the index's current (renewed) XML; the export carries no signed XML (unchanged). Against EU DSS 6.5: an LTA renewed twice gives 3 intact ATS; the negative control, DataIntact=false. 8 (auth, test) — test_verify_audit_chains_job narrowed with --slug (1.35 s). Impact: apply tenant migrations document 048, signature 026 and storage 022. Deploy auth and signature together (cert_fingerprint required in sign-digest), storage before or with signature (renovar-artefacto-internal) and signature with document (body required in /invalidar). Debt: repackaging existing AIPs when the seal is renewed; the residue of the TOTP spent in the final self-verification; a consumer for firma.lta_renovada; the signed identity's 120 s window is not a nonce. B10 audit corrections (2026-10-17): P (gateway, document, notification, knowledge) — a path that does not stabilise when canonicalised answers 404 route_not_found, not 500 (residues declared in ADR-036: no query or body, %2F equivalence); the postal callback body accepts ocurrido_at (ISO 8601 with zone, inside the HMAC), stored as ocurrido_operador_at: ts is the reception of the operator's notice, not the date of the event; 413 cuerpo_demasiado_grande at 64 KiB also with an active entity (after the HMAC); the deferred-queue cap is checked under a per-tenant advisory lock; draining checks the body's sha256 (discards huella_no_coincide) and after POSTAL_DIFERIDO_MAX_INTENTOS=10 discards with a document.envio_callback_diferido_descartado entry without blocking the queue; the return notice also reaches the current holder (location mailbox + flujo_usuario) and, with responde_a, the inbound's mailbox and holder, deduplicated by address and gated in the WHERE per recipient; the /antecedentes pivot is gated in the WHERE (max_clearance). L (signature, storage; ADR-034 §5) — no renewal over an already expired TSA (link caducado, firma.lta_cadena_caducada entry); /verify judges the last ATS at the current date (lta_vigente; if it no longer proves, it drops to XAdES-LT with the archive_timestamp_caducado warning and no deferred validity); the TimeStampValidationData carries the CRL of the previous TSA's CA and without it there is no renewal (firma.lta_renovacion_sin_revocacion_tsa; the self-signed dev TSA goes without RevocationValues; CRL only, not OCSP); rejection, TSA-expiring and no-revocation entries are written once per reason and link (ultimo_aviso); becoming current leaves firma.lta_sello_vigente; the job walks active and suspended entities; only a 404 borrador_not_found counts as a vanished draft; link 0's expiry comes from the TSA fingerprint; a token without certificates leaves firma.lta_sin_eslabon without breaking the signature (signature migration 026 edited: caducado, ultimo_aviso, view). storage renews retention in cascade to the firma_lta_renovada (renueva_a) with PREMIS RENOVACION retencion_en_cascada, and renovar-artefacto-internal requires the previous object to be a signature and the new one XML (422 artefacto_no_es_de_firma) and inherits the chain's largest retain_until. L-H3 (storage, archive; ADR-035 §8) — a suspended entity keeps preserving: storage's get_db_conn admits, with X-Internal-Token and only for the explicit RUTAS_DE_CONSERVACION list (app/core/conservacion.py), the preservation operations, with an entidad.conservacion_admitida entry; inactive and everything else stay vetoed; archive's preservation phases (WORM retention, fixity) also walk suspended entities (list_tenants_conservacion); a new AIP waits for reactivation (it needs the attachments, which document vetoes for a suspended entity), while the existing AIP's retention continues; POST /indice/verify returns firma.lta_vigente. R2 — the document.envio_estado_operador entry (and _no_aplicada) records ocurrido_operador_at and recibido_at; an ocurrido_at later than reception + 5 min or earlier than the shipment's creation → 422 ocurrido_at_inverosimil (also with a suspended entity, when deferring); signature does not renew if the previous TSA is revoked in its CA's CRL (firma.lta_renovacion_tsa_revocada) nor if the CRL does not verify against the CA (firma.lta_renovacion_sin_revocacion_tsa; new variable TSA_CA_PEM_PATHS, comma-separated PEMs), and an expired chain leaves firma.lta_cadena_caducada only once even with several links; GET /expedientes/{id}/indice/verify verifies the current (renewed) XML and, if signature cannot resolve it, falls back to the original with the artefacto_vigente_no_resuelto warning; storage's preservation exception matches the exact path (fullmatch on scope["path"]) and compares the token as bytes (non-ASCII → 403), vetoes if the entry fails and records once per entity + method + path + day and process. Residue: radicados/auth_users unqualified with public in the search_path (SEG #12).
  • fix(shared,auth,gateway,document,archive,workflow,storage,mcp)!: batch B9 (archival and security audit closure, block N) — a suspended entity keeps its public reads and closes its writes with a clear 503, the postal callback verifies the signature before the state, the signed identity expires, archive/workflow/storage check the state at their own door and login issues no token to an entity that is not served (ADR-035). Why: the archival audit (AUD-ARCH #1, High) found that ADR-035's total lockout broke the rule: the objection period of the disposal inventory (AGN Agreement 004/2019; Law 594/2000 arts. 14 and 27) ran with the inventory invisible, and the seal on the disposal record certified a publicity that was never effective; switching off /verify removed the citizen's proof of integrity (Law 527/1999 art. 12 no. 1) and OAI/CMIS the access to public information (Law 1712/2014 arts. 9-11). The security audit (AUD-SEG #2) added that the signed identity carried no timestamp, so a captured header set was valid forever and, replayed against archive/workflow/storage, evaded the suspension; and (AUD-SEG #7) that the mcp-server folded 403 tenant_inactivo into 503 auth_service_error and login kept issuing tokens to a suspended entity. Testing live also showed that the first gate design (in require_permission) left reads that need no permission open: GET /workflows/inbox answered 200 for a suspended entity. What changes: N1 — orpycamcp_common.tenant.atencion_publica (COMPLETA | SOLO_LECTURA | NINGUNA): a suspended entity with a provisioned schema serves GET /public/{t}/verify/{code}, the archive disposal list and detail, the document and archive OAI and read-only CMIS; its public writes (POST /public/{t}/pqrs, POST /public/{t}/ventanilla and the three preflight reads) answer 503 canal_no_disponible + Retry-After before its schema is set. Non-existent, deactivated (inactive) and unprovisioned entities keep the neutral 404 not_found. The gateway now forwards Retry-After. N2 — the postal callback verifies the HMAC with the schema secret before deciding on the state: valid signature + suspended = 503 + Retry-After; invalid signature, operator without credential or unreadable body = neutral 404; with an operational entity the 401 firma_invalida is kept. N3 — X-Identity-Ts joins CAMPOS_FIRMADOS and identidad.verificar rejects an identity older than IDENTITY_MAX_AGE_SECONDS (120 s), more than IDENTITY_MAX_SKEW_SECONDS (30 s) in the future, or without an integer timestamp; and get_db_conn of archive, workflow and storage call veredicto_entidad before handing out the connection (only when the request carries a valid X-Tenant-Slug). N4 — POST /auth/token and /auth/refresh issue no token when the entity in the token claim is not operational (403 tenant_inactivo, 503 tenant_no_provisionado|tenant_estado_no_disponible, the gateway's codes), and the mcp-server's resolve_identity propagates those codes instead of 503 auth_service_error. Tests: document 1172, archive 1692, workflow 522, storage 625 (15 skipped), auth 444, mcp-server 276, api-gateway 187, shared/tests 540 passed. Lint clean. One mutation per fix, all fail. Live with demo suspended: login 403; verify and public disposal 200; PQRS and ventanilla/estado 503 with Retry-After; replayed identities (10 minutes old, rejuvenated or without timestamp) 401; demo restored to active/active. Impact: the whole stack verifies the signed identity with its timestamp: rebuild and deploy the gateway and the nine services that mount IdentidadFirmadaMiddleware TOGETHER; a lagging one answers 401 identidad_no_autentica to everything coming from the gateway. The frontend must show «Entidad suspendida» on the login and refresh 403 tenant_inactivo, and treat 503 canal_no_disponible on the window as «channel closed». B9 re-audit (2026-10-16, findings A-H): A (High, archive) the endorsement of a disposal batch counts the opposition period in operative days: the WHERE of avalar_lote_eliminacion rebuilds from the tenant.updated entries of public.audit_log the intervals in which the entity was not active (the ventanilla, the only opposition channel, answered 503) and requires the operative days between publicado_at and yesterday to reach the original period; otherwise 409 plazo_oposicion_interrumpido with dias_suspendidos and plazo_efectivo_hasta, and the record declares plazoOposicionEfectivoHasta/diasPlazoInterrumpido only when there was an interruption (no new endpoint: the effective period is derived). D (document) GET /public/{t}/ventanilla/politica becomes a read (get_public_tenant_conn) served while the entity is suspended; it reads sgd_param_admin directly because tenant-service vetoes a suspended entity (verified live: through it, it answered 503 politica_no_verificable). E (signature) the autouse _nivel_vigente_neutro fixture no longer reaches tests/integration. F the 025 test filters pg_constraint by conrelid = 'firma'::regclass. G (auth) a login/refresh rejected for a non-operative entity revokes the freshly issued refresh token in Keycloak (defence in depth; the real barrier is the gateway). H (signature) nivel_vigente_for_update returns (nivel, locked_copy) and the nivel_anterior of the firma.solicitud_reclasificada entry comes from the locked copy. Declared debt (B, Medium): the postal callback with the entity suspended answers 503 with a fixed Retry-After; the deferred event is pending (ADR-035 §7). Tests (re-audit): archive-service 1700 passed (RUN_INTEGRATION=1; 7 new integration tests with demonstrated mutation: without the WHERE condition 2 fail); document-service 1173 passed; signature-service 754 passed (7 skipped); auth-service 381 passed (66 skipped; +4 revocation tests, mutation: without logout 3 fail).
  • fix(shared,auth,gateway,document,archive,knowledge,notification,signature,tenant,mcp)!: batch B9 (entity state and MCP channel part) — suspending an entity now withdraws its access across the whole API, a missing schema is 503 instead of 500, and the MCP channel mark is bound to the token's tenant (ADR-035). Why: the B7 triage said no service enforced a suspended tenant; that was partly false (document-service did, with no test at all) but true for the other ten: the get_tenant_conn of auth, knowledge, notification, signature and tenant only validated the slug shape, archive/workflow/storage set the search_path in more than twenty places, a missing schema ended in UndefinedTableError (500), and every public route (/public/*, CMIS, OAI, archive disposal) used its own existence predicate, or none (public_eliminacion ignored status). Separately, the gateway bound the X-Origen-Canal mark to the client's X-Tenant-Slug header instead of the token's tenant (the mark only affects the canal label of an audit entry, but that label is evidentiary). What changes: B9-1 — orpycamcp_common.tenant.estado_entidad (OPERATIVA | INACTIVA | NO_PROVISIONADA, one query with to_regnamespace) and veredicto_entidad (fail-closed: a down registry is 503). /auth/validate returns tenant_estado and does not provision auth_users in a non-operational entity; the api-gateway enforces it at the start of _continuar_con_identidad (403 tenant_inactivo, 503 tenant_no_provisionado, 503 tenant_estado_no_disponible when the field is missing or desconocido; desconocido is not cached; a suspension takes effect after at most the 15 s validation-cache TTL); the six get_tenant_conn (auth, document, knowledge, notification, signature, tenant) call veredicto_entidad before SET search_path. Public routes: document uses get_public_tenant_conn/public_tenant_conn (malformed slug, non-existent, suspended or unprovisioned entity = the SAME 404 not_found with the same body (superseded by block N above: a suspended entity moves to public read-only); no more 400 invalid_tenant, nor empty list/habilitada=false for a missing entity), CMIS, OAI (document and archive) and archive's public disposal replace to_regclass/SELECT 1 FROM tenants with estado_entidad. Event consumers and background tasks are not blocked (closing reclassified convergence would open a leak; see ADR-035). B9-4 — derive_canal receives payload["tenant_slug"] and not request.headers["x-tenant-slug"]; the mcp-server is unchanged (it already signed with the token's tenant). Tests: api-gateway 183 passed; auth-service 432 passed (RUN_INTEGRATION=1; 5 estado_entidad cases on real Postgres and 2 on validate); document-service 1166 passed (RUN_INTEGRATION=1; includes the 403 tenant_inactivo of get_tenant_conn, previously untested, and the identical 404s of the public routes); archive-service 1681 passed; knowledge 336, notification 291, tenant 325, signature 717 (7 skipped), mcp-server 266 passed; shared/tests 506 passed (shape guardian over the six get_tenant_conn, plus estado_entidad/veredicto_entidad). Lint (ruff + mypy) clean on the eight touched services. One mutation per fix, all fail. Impact: rebuild and deploy api-gateway, auth-service, document-service (and knowledge, notification, signature, tenant, archive). The frontend must show «Entidad suspendida» on a 403 tenant_inactivo from /auth/me and login, and treat 404 on the public window as «entity not found». Known risk: a superadmin token carrying the suspended entity's tenant_slug cannot reactivate it through the gateway.
  • fix(frontend): batch B9 (frontend lane) — the front tells a suspended or not-ready entity apart, the public window says "no window available", and the dead sign-xml proxy is removed. What changes: (app)/+layout.server.js classifies the /auth/me rejection (utils/estadoEntidad.js: 403 tenant_inactivo = "The entity is suspended"; 503 tenant_no_provisionado = "The entity is not ready yet"; 503 tenant_estado_no_disponible = "The entity state could not be checked") and returns entidadNoOperativa; the layout renders EntidadNoOperativa.svelte (role="alert", Retry only when the state can change, POST sign-out) instead of an empty menu, with no login redirect and no session wipe. Public window: the 404 not_found on /radicacion, /eliminacion and the radicación and consulta proxies shows "This entity has no window available" without telling non-existent, suspended or unprovisioned apart. firmas/api/sign-xml (no consumer) and its suite are removed; nothing in the front read solicitud_not_found. Impact: rebuild the frontend. Also: with the entity suspended the window POST shows "The entity's service channel is not available right now" (503 canal_no_disponible), and CertificadoDrawer shows a retired-scheme notice for esquema_hmac_retirado (firma_criptograficamente_valida may be null). UX-review fix: with the entity suspended the /public/{t}/ventanilla/{tipos|politica} GETs and the POST return 503 canal_no_disponible; /radicacion shows a "reception temporarily suspended; queries and verifications remain available" notice linking to /consulta; admin/+layout.server.js no longer redirects to /403 when the entity is not operational, (app)/+error.svelte renders child-page error(403) inside the layout, and EntidadNoOperativa uses role="status", announces "still unavailable (hh:mm)" on retry and refocuses the title.
  • fix(signature)!: batch B9 (signature part) — the level governing a signature is re-read under lock, /sign-xml stops signing radicados and the key_version=0 HMAC scheme is retired Why: firmar and /challenge decided the XAdES gate, the mandatory 2FA at level ≥ 2, the e-mail veto and the audit entries from the firma_solicitud.nivel_seguridad copy read WITHOUT a lock, and that copy is synchronized by event (late, dead-lettered, or never when the chain was declared borrador over a radicado's UUID) (SEG-B4); /sign-xml accepted radicado|expediente|borrador and gave a holder of just PERM_FIRMA a verifiable firma row that bypassed the chain; key_version=0 was still alive. What changes: B9-2 nivel_vigente_for_update (FOR UPDATE + max(copy, live object level)) and _sol_con_nivel_vigente inside the firmar and /challenge transactions (clearance → neutral 404, XAdES gate, 2FA, e-mail veto, audit entries; the copy is corrected in the same transaction; firmar now locks the request row before the turn, like /challenge); B9-3 /sign-xml is institutional seal only (422 for any other objeto_tipo) and always requires X-Internal-Token; B9-5 scheme 0 retired (/verify → motivo=esquema_hmac_retirado, migration 024 drops the DEFAULT); the no-read-up 404 of the chain is solicitud_not_found in both /sign and /challenge. Audit corrections (signature, S1-S11): S1 chain reads (GET /requests/{id}, por-objeto, /pending with rows and X-Total-Count under the same condition) are gated with GREATEST(copy, live level) instead of the lagging copy (a metadata read-up); with a declassification pending the higher level wins until it converges (S9, documented). S2 the re-read that corrects the copy writes the firma.solicitud_reclasificada entry (origen=relectura_al_firmar, canal=system) in the same transaction, only when it touched rows. S3 that correction only RAISES (nivel_seguridad < $1, solo_subir=True): it no longer lowers sibling chains of the same object; the reclassified event keeps its semantics. S4 firma.turno_firmado carries nivel_seguridad (live) and nivel_copia, and /sign-personal's firma.creada carries nivel_seguridad. S5/S10 rechazar and dar_visto_bueno re-read the level under lock and take the request row before the turn (same order in all four paths). S6 /verify with a retired HMAC scheme returns firma_criptograficamente_valida=null (bool | None), not false. S7 FirmaRepository.insert requires an explicit key_version=1 for native signatures and tenant migration 025 deletes the synthetic v0 row and adds CHECK (provider <> 'nativa' OR key_version = 1). S8 providers/native.py docstring. S11 the 7 literal copies of the neutral 404 body in cadena_service.py become one helper, _solicitud_no_encontrada(). Tests S: signature-service 752 passed, 7 skipped (RUN_INTEGRATION=1) and 697 passed, 62 skipped without integration; new tests/test_correcciones_b9_signature.py (15) and tests/integration/test_correcciones_b9_integration.py (8), each with its demonstrated mutation (13 mutations, all fall). New autouse fixture lectura_vigente_real (neutralizes S1 in the mock suites). Impact: apply migration 025 per tenant. Tests: signature-service 717 passed, 7 skipped (RUN_INTEGRATION=1); 14 new tests, each with a demonstrated mutation. Impact: apply migration 024 per tenant; native signatures from before 020 stop verifying (accepted). Frontend lane pending: remove the unused /firmas/api/sign-xml proxy.

  • fix(signature,auth,shared,infra)!: batch B7 — /challenge demands what /sign does, /auth/me for an entity without a schema is no longer a 500, restore fails closed and the production stack finally has an entry point (ADR-033). Why: live verification of the production stack found that (a) POST /challenge issued AND EMAILED the OTP to a signer with an active personal certificate (whose signature requires TOTP, so the email never works) and over an already-modified document (/sign answers 409 documento_modificado); (b) GET /auth/me with the X-Tenant-Slug of an entity without a schema returned 500 UndefinedTableError; (c) restore.sh treated a dead pg_restore with no error lines as harmless and backup.sh accepted an empty mirror; and (d) the production compose published no port while the nginx template pointed at 127.0.0.1, had no entry for Keycloak, used a movable tag for third-party images and left two mandatory variables out of the example. What changes:

  • F1/F2 — signature: _precondiciones_de_turno is the single piece of code for membership, state, turn, signing role and annulled radicado for firmar and /challenge; /challenge now reads BEFORE the transaction (like firmar; inside only the FOR UPDATE re-read) and adds 409 turno_es_de_visto_bueno, 409 radicado_anulado, the current-fingerprint gate (409 documento_modificado…, 503 verificacion_documento_no_disponible, after membership and turn) and the personal credential (409 totp_requerido with no mail, 403 identidad_incoherente with a via=signing_key_status entry, 422 firma_xades_requerida / 503 canal_firma_personal_no_disponible under the gate). With no active turn /challenge now answers 409 solicitud_no_pendiente (like /sign) instead of 403 no_es_su_turno. 422 payload_no_fijado is removed from the contract: it no longer existed.
  • F3 — auth: get_optional_tenant_conn checks to_regclass(format('%I.auth_users', schema)) before SET search_path; an entity without a schema (pending or nonexistent) is 200 with permissions=[], permission_levels={} and user_id=sub. The 400 for a malformed slug stays.
  • P1/P2 — infra: an edge service (nginx:1.30-alpine@sha256:…) as the ONLY one with ports: (80:80, 443:443), template infra/nginx/orpyca.conf.template (replaces infra/nginx.conf.example) with a TLS server for frontend, API and Keycloak (only /realms/ and /resources/; /admin/ and /realms/master/ denied; larger header buffers), proxy_pass to variables with resolver 127.0.0.11 and network aliases for the three domains. The network declares ip_range 172.20.128.0/17 and gateway 172.20.0.1; the default TRUSTED_PROXIES changes from 172.20.0.0/16 to 172.20.128.0/17 so the network's gateway is not trusted and the IP sealed in audit_log cannot be the one the client forged.
  • P3/P4/P5: postgres, redis, keycloak, edge and amazon/aws-cli (previously :latest in backup.sh/restore.sh) by tag and digest, also in docker-compose.yml; infra/.env.example carries ORPYCA_REGISTRY and ORPYCA_TAG (active) and PDFA_PROFILE, SIGNER_LT_REQUIRE, SIGNER_OCSP_REQUIRE, WEBHOOK_SECRET_KEKS, WEBHOOK_SECRET_KEK_ACTIVE_VERSION (commented); whisper-service stays out of the v1 production manifest (intentional, declared).
  • R2/R5/R5b — backup: restore.sh tolerates a failed pg_restore only if there is at least one error and all of them are the inherited PK of the audit_log partitions; after recreating each protected bucket it checks with get-object-lock-configuration that locking is Enabled and aborts before touching data otherwise. backup.sh no longer uses || true when listing buckets and requires orpycamcp-<slug>-documents of every active tenant in the mirror.
  • Fixed before the batch, by the orchestrator: R1 restore.sh called restaurar_versiones_previas before loading lib-retencion-s3.sh and defining s3api ("command not found": every restore died after restoring Postgres); R3 s3 sync ... || true is now fail-closed; R4 limpiar_descifrado with [ ] && rm returned 1 in the EXIT trap under set -e (a complete restore exited rc=1); R5 network: backup.sh/restore.sh took the FIRST compose network (in production default, without the object store ⇒ empty mirror) and now use the minio service's; P0 docker-compose.prod.yml: the x-migracion anchor declared no network so the nine migrate-* landed in default, without postgres (no migration finished and no service started); M1 mailhog:latest (abandoned) replaced by axllent/mailpit:v1.31.4@sha256:b68349e3… with ports on 127.0.0.1 and SMTP_HOST=mailpit (red_interna.py and notification config updated).
  • Conformant XMLDSig (ADR-034) — signature: live verification against EU DSS gave TOTAL_FAILED / HASH_FAILURE on EVERYTHING we signed: signxml leaves the references to SignedProperties and KeyInfo without ds:Transforms but digests them in exc-c14n, and XMLDSig §4.4.3.2 says to canonicalise them with inclusive C14N 1.0. It was our defect (not "a signxml bug"), hidden by a shortcut in verify_xades (default_reference_c14n_method=EXCLUSIVE) that made only OrpycaMCP able to verify what it signed. Now _declarar_transforms adds exc-c14n to every reference without Transforms (digests do not change), _firmar_signed_info signs the already-patched SignedInfo —one path for the seal and the personal signature; the digest remote_sign receives comes from the final SignedInfo— and _autoverificar checks it with a default XAdESVerifier() before the timestamp (503 firma_no_conforme_xmldsig if it fails; nothing is persisted). verify_xades verifies only conformantly: the shortcut is gone; earlier signatures in the test corpus stop verifying (no migration, no "legacy" branch). _archive_ts_covered_bytes stops being a home-made concatenation and follows EN 319 132-1 §5.5.2 (octets of each reference + SignedInfo + SignatureValue + KeyInfo + preceding unsigned properties): DSS reported its MESSAGE_IMPRINT as not intact; of eight compositions tried, only the standard's validates. False docstrings fixed (local.py called our own defect "a signxml bug"). The "verifiable by third parties" promise is withdrawn from ADR-016/ADR-030, where it was not true until now. Tests: signature-service 668 passed, 3 skipped (RUN_INTEGRATION=1; 15 new for preconditions and /challenge↔/sign parity, plus the sequences of the existing challenge tests); auth-service 409 passed (RUN_INTEGRATION=1; 3 unit and 3 integration tests on real Postgres with an itest-… slug for /auth/me without a schema); shared/tests 479 passed (11 new for R2/R5/R5b that execute the shell functions, and new or hardened production-manifest guards). One mutation per fix, all killed. Lint clean (ruff + mypy) on signature-service and auth-service. Also fixed test_create_challenge_cooldown_returns_429, which measured against a time fixed at import and failed when run after the integration tier. B7-XMLDSig tests: signature-service 685 passed, 7 skipped (RUN_INTEGRATION=1; 17 new in test_xmldsig_conformidad.py —new signature verified with default signxml and with CANONICAL_XML_1_0, an independent lxml+cryptography verifier, the digest sent to remote_sign == the final SignedInfo, the ArchiveTimeStamp input rebuilt separately, negative control with a real pre-fix artefact— and 4 in test_dss_smoke.py, which only run with DSS_URL). Mutations, each knocked down by its own test: no-op patch, digest before patching, reintroducing the shortcut in verify_xades, LTA without reference octets. Validated live with EU DSS 6.5: an expediente index sealed by the real stack and JEFE's personal signature: SignatureIntact and every DataIntact, INDETERMINATE/NO_CERTIFICATE_CHAIN_FOUND (development CA); the negative control (earlier signature): TOTAL_FAILED/HASH_FAILURE.
  • Correction round after the audits (fit with reservations) — infra: SEG-M1 Keycloak through an allow-list (/realms/orpycamcp/ and /resources/; /realms/master;x/… and /realms/master returned 200 under the previous denial; now 404, and so is any path with ;); SEG-B1 /health/ready only from 127.0.0.1 (172.20.0.1 removed; probe via docker exec); SEG-M2 pg_restore is harmless only with exit code 1 and errors ignored on restore: N == inherited PKs (> 0); ARC-M3 the Object-Lock pre-flight now runs BEFORE stopping services and pg_restore (in the code it ran AFTER: "no data has been touched" was false); ARC-M4 RESTORE_OPERADOR mandatory (fail-closed before confirmation) and the storage.restauracion record names the operator (actor=operador:<name>) and declares the lost-trail window per tenant (end of audit_log noted before pg_restore vs the restored one; no capture, no record); ARC-B3 backup.sh also requires suspended tenants (status <> 'inactive'); ARC-B4 api-gateway refuses to start with APP_ENV=production if TRUSTED_PROXIES covers the network's gateway (GATEWAY_RED_DOCKER_GATEWAY or the container's default route; not checked in development). signature: ARC-M2/SEG-B3 nothing is signed with an expired or not-yet-valid certificate (seal: 503 sello_institucional_vencido; personal: 422 certificado_personal_vencido before remote_sign, without consuming the TOTP) and _autoverificar no longer clamps verification_time; ARC-M1 /verify of an XAdES-LTA with a valid ArchiveTimeStamp and a certificate that expired after signing judges validity at the SignatureTimeStamp date (with a warning; without a valid LTA it stays certificado_fuera_de_vigencia); SEG-B2/ARC-B1 XmldsigNoConformeError on the personal routes is 503 firma_no_conforme_xmldsig (declared: the TOTP is already consumed there); ARC-B2 time stamps are verified with the canonicalization they declare. ADR-016, ADR-033 and ADR-034 corrected. Round tests: signature-service 698 passed, 7 skipped (RUN_INTEGRATION=1; 13 new in test_vigencia_del_certificado_y_sellos.py, one rewritten), auth-service 414 passed (6 new integration tests of registrar_restauracion on a real Postgres), api-gateway 170 passed (11 new), shared/tests 489 passed (guards for the Keycloak allow-list, the probe, IPv6, pg_restore rc/N, pre-flight, operator, prior end-of-trail and suspended). Clean lint (ruff + mypy) on all three. One mutation per fix, all of them fail. EU DSS: tests/test_dss_smoke.py 4 passed against DSS at 127.0.0.1:18765, after deploying signature-service. Declared debt of the round: SEG-M3 (a suspended tenant is not enforced in any service: new gateway functionality), SEG-B4 (re-read nivel_seguridad under lock when signing), ArchiveTimeStamp renewal, self-verifying the personal signature before consuming the TOTP, and a real nginx -t (it also confirms the listen [::] lines, already written; the image was not pulled because of disk). Impact: an inherited infra/.env with TRUSTED_PROXIES=172.20.0.0/16 reopens the defect (change it to 172.20.128.0/17 or delete it); one certificate per domain must be issued before the first up and renewals move to webroot; Keycloak's console stops being public (SSH tunnel). infra/nginx.conf.example is removed. Deploy signature-service and auth-service. Not verified here: a real nginx -t or edge running (image not pulled because of disk state); run exec edge nginx -t on first deploy. Declared debt: Keycloak 24.0.5 is out of support; get_tenant_conn still returns 500 for a tenant without a schema on the other auth endpoints, and a suspended tenant with an intact schema still resolves permissions in /auth/me.

  • fix(document): B6 / leftovers — a Salida's origin is limited in one place, resending the same (now inactive) type is not a 422, and import accepts no foreign provenance — DocumentService.create requires, for doc_type=S with a human actor, origin_dept_code to be a dependency with an active URD of the actor (422 origen_fuera_de_sus_dependencias); covers POST /documents, quick reply, drafts (duplicate check removed) and each batch item (fails before the sequence). PATCH only validates the type when doc_class changes. INT-05 moves from_borrador/canal/consentimiento from the ZIP to metadata.procedencia_origen.

  • fix(document,auth,tenant,shared): B6 / backend correction round — a record can no longer be born with an invented document type, provenance keys are no longer the client's, and the inert 002 keys leave the allowlist. Why: the B6 audit found that only the public window validated doc_class against tipos_documentales (via API/MCP a record was born without a TRD series, i.e. without retention), that canal (feeds the Law 1581 publication gate) and consentimiento (evidentiary consent record) could be supplied in metadata, that from_borrador was not in the audit entry and the metadata PATCH could delete it, that a draft Salida accepted any origin_dept_code, and that the parameter allowlist admitted 10 keys from migration 002 that no service reads. What changes: document — app/core/tipos.py is the ONLY place that queries the catalogue (the table lives in the same tenant schema: no HTTP client, like urd_usuaroldep): create (before next_sequence), the retyping PATCH, every batch item and the public window (which still also requires admite_ventanilla_publica) → 422 tipo_documental_desconocido; CLAVES_RESERVADAS = {from_borrador, canal, consentimiento} is rejected in DocumentCreate, DocumentUpdate, quick reply and draft, and the system sets them via procedencia (the public window no longer sends them in metadata; they no longer go through the type's template); the metadata PATCH preserves them; document.radicado_created carries from_borrador; draft Salida: origin_dept_code limited to departments with an active URD (422 origen_fuera_de_sus_dependencias); the draft validates type and metadata BEFORE rendering and uploading (DocumentService.validar_tipo_y_metadata), leaving no orphan binary. auth/shared — the any-of gate goes through get_caller (make_require_any_permission(identity=)): 401 without identity and a usuario_no_aprovisionado entry, like require_permission; the test_escritura_exige_nivel.py guardian recognises require_any_permission( (an any-of write also requires min_crud>=2), with a positive control. tenant — the 10 inert 002 keys leave PARAMS_ADMITIDOS (PUT 422) and move to PARAMS_INERTES; the guardian requires seeded = admitted ∪ inert and that no service reads an inert key. Tests: each item with mutation (12 mutations, all killed). Impact: rebuild and deploy document, auth and tenant; a client sending canal/consentimiento/from_borrador in metadata now gets 422. Pending (roadmap): types without an active template store metadata unvalidated, and the frontend labels use the ACTIVE template rather than the saved version (metadata_template_id).

  • fix(auth,document,tenant,frontend): B6 / /admin guardian audit — three admin screens returned 403 to the administrator who does not register, PUT /config/params/{key} accepted any key and the frontend guardian could not read any-of gates. Why: the isolation auditor found 0 leaks but 3 UI-to-backend misalignments: GET /auth/users/pickable, GET /email/cuentas, GET /plantillas/campos and GET /plantillas required only PERM_RADI while /admin/flujos, /admin/grupos, /admin/correo and /admin/plantillas open with USUA_PERM_ADMIN; and the PUT /config/params/{key} upsert stored any key in a table every tenant user can read. What changes: orpycamcp_common.rbac gains check_any_permission (single = ANY query, fail-closed) and make_require_any_permission (denial audit entry like require_permission, perm_name="A|B"); auth and document instantiate it as require_any_permission. The four reads become any-of {PERM_RADI, USUA_PERM_ADMIN} with no extra exposure (the existing WHERE clauses decide the rows; ?todas=true still requires USUA_PERM_ADMIN in the body). PUT /config/params/{key} accepts only the 21 keys seeded by the tenant migrations (422 config_param_desconocido otherwise); a guardian (shared/tests/test_params_admitidos_son_los_sembrados.py) cross-checks them with the seed both ways. Frontend guardian: gatesFastapi.js reads require_any_permission (each Gate carries anyOf), contratoAdmin.js evaluates it per arm, obsolete exceptions are removed (pickable, email/cuentas, plantillas/campos); flujos GET /workflows/pending is relabelled as a scope gate in the body and the indice/firmar reason is corrected (the UI already hides the button without both permissions). Tests: any-of with mutation (admin without RADI 200, RADI only 200, neither 403, no identity 403, ?todas=true still 403 with RADI only), check_any_permission against real Postgres, allowlist 422/200 and shared/ guardians (require_any_permission counts as a gate; not redefined outside the library). Impact: deploy auth, document and tenant (only those three use the new orpycamcp_common code; the rest pick it up when rebuilt). /admin/parametros still offers a free-form "new key": its PUT now answers 422 (left for the frontend).

  • fix(document): B6 / item 23 — registering a draft whose document type has a metadata template with required fields returned 500. POST /borradores/{id}/radicar now maps MetadataValidationError to 422 metadata_validation_failed with field, before the sequence number is drawn (no number is spent; the audit entry stays atomic with the radicado). The body accepts metadata (BorradorRadicar); the from_borrador provenance no longer goes through the type's JSON Schema (DocumentService.create(..., procedencia=) merges it after validation) and clients cannot send it.

  • feat(frontend): B6 / items 23, 24 and 25 — registering a draft captures its minimum data, stored metadata is read with its template and the receipt error sits on its field. The register modal asks for document type (catalogue; the template pre-fills only when its type is in it), origin (outgoing; with no active department, registering is not offered) and the type's metadata with SchemaField; a 422 keeps the modal open with the error on its field (it used to close before the fetch and lose the input). The proxy forwards a closed list of 4 validated fields; the client rule mirrors REQUERIDOS_POR_TIPO with a parity guard against datos_minimos.py. New read-only SchemaValue in /expedientes/[id] (was [object Object] and raw key) and in the /bandeja detail (it loaded metadata and never showed it). /envios: the receipt error is tied to the file field (aria-describedby, aria-invalid, focus) plus 4 new modal cases. EditarMetadatosModal and TransaccionModal are not migrated: the backend does not allow editing those fields. Tests with mutation.

  • feat(tenant,document,workflow)!: batch B5 — the dependencia is correlated by CODE, not by name: renaming with live cases no longer breaks the inbox, codigo is immutable and an ambiguous name is never resolved "to the lowest code" (ADR-032). The workflow (flow_steps.to_dept, workflow_rules.assign_to_dept) and the inbox joined dependencias by NAME: renaming a dependencia with live cases dropped its steps out of every inbox and out of the location (tenant-service blocked it with a 409, a divergence from the legacy where everything joins on DEPE_CODI); namesakes were confused (borrador_service registered towards ORDER BY codigo LIMIT 1 silently); codigo was editable and 8+ tables in 5 services cite it with no FK. Now: tenant migration 013 (immutable-code trigger, name history, single resolver_codigo_dependencia), document 046 and workflow 019 (code columns, fail-closed backfill with insert-only evidence anchored in audit_log); PATCH rejects a code change (422 dependencia_codigo_inmutable) and renaming is free; deactivating/deleting are by code (409 dependencia_con_tramite_en_curso / dependencia_con_historia); new POST /dependencias/{id}/mapeos-nombre; APIs take *_dept_code and keep the name as a deprecated alias (Deprecation + Link) with 422 dependencia_desconocida|ambigua|inconsistente; the origin of a transfer is derived by the server (409 origen_desactualizado); events carry both fields. Impact: apply tenant 013 → document 046 → workflow 019; a tenant with live steps of an ambiguous name does not migrate until a manual mapping is registered. Not done: removing the alias (later increment); notification, knowledge, mcp-server, gateway and frontend are a separate batch. Correction round (security and archival audits): tenant 014 — a manual mapping now IS the answer of resolver_codigo_dependencia (it used to be added to the namesakes and never disambiguated), SET search_path FROM CURRENT on the function and triggers, insert-only dependencia_codigo_retirado (409 dependencia_codigo_retirado and a trigger when a deleted code is reused), history seeded from dependencia.actualizada audit entries; referencias() sweeps information_schema; mapeos-nombre rejects inactive targets and another active dependencia's current name (irreversible); PATCH takes motivo. document 047 / workflow 020: evidence with mapeado linked by historico_id and full antes/despues JSON. Dependencia report: the code-less group gets the fixed label "(sin código)". Last correction round: document 047 no longer changes resultado (it rewrote evidence anchored by 046: the anchor SHA-256 stopped reproducing); it only links historico_id and "mapeado" is derived from historico_id IS NOT NULL (the CHECK is back to the original). 047 and workflow 020 write their own anchor (document.dependencia_codigo.backfill_047, workflow.dependencia_codigo.backfill_020); 020 only fills new columns, none of those 019 anchors. tenant: mapeos-nombre only accepts orphan or ambiguous names (409 nombre_resuelto_a_otra_dependencia), PATCH rejects renaming to a name already mapped to another code (409 nombre_mapeado_a_otra_dependencia); referencias() uses pg_catalog, counts workflow_rules.conditions, and has_children runs inside the DELETE transaction.

  • fix(archive)!: B4 archive re-audit — the delivery package is now the complete delivery and is refused if any component fails its capture fingerprint; the custody trigger only exempts the referential action; a location with a received transfer cannot be deleted. The package adds the signed electronic index and references the AIP in the note by bucket, key and SHA-256 (not embedded, for volume; the FUID inventory is the frozen act). Missing AIP/index/Object-Lock is 409 componentes_sin_object_lock (clasificado/... paths are aggregated into componentes_clasificados, never listed). Before serving: sha256(acta_fuid_xml) is rechecked against acta_fuid_sha256, the act seal must sign that same act, and each component is read as BYTES and compared with the fingerprint storage recorded at upload; a mismatch is 409 componente_integridad_discrepante plus a transferencia.paquete_entrega_incidente audit entry (503 if capture fingerprints are unavailable). Tenant migration 067 limits the "update that only nulls" exemption to pg_trigger_depth() > 1 (a manual UPDATE now raises 23514); 068 adds a BEFORE DELETE trigger on ubicacion rejecting (23514) deletion of a location referenced by a recibida transfer.
  • feat(auth,notification,infra)!: batch B4 / P3 — previous COMPLIANCE versions survive a restore, every remapped object gets a PREMIS event, an empty target no longer verifies green and every webhook delivery leaves a record. What changes: infra — respaldar_versiones_previas copies the content of each previous version under live COMPLIANCE or legal hold into minio-versiones/<bucket>/<sha256(key+vid)> and lists it in minio-versiones.tsv (a separate file; the minio-retencion.tsv format is unchanged); it travels inside the encrypted tar; the seal carries versiones_previas_capturadas=SI|NO and backup.sh exits 5 if incomplete. restaurar_versiones_previas (before the sync) uploads them by ascending LastModified, verifies the sha256, restores retention on the RETURNED VersionId and explicitly put-objects the current version of those keys; the old→new map includes them. RESTORE_ACEPTAR_PERDIDA_VERSIONES remains only for older backups. auth — registrar_restauracion inserts, in the same transaction as the remap, a PREMIS REPLICA event (detalle.motivo='restauracion') per updated row; without preservacion_evento everything rolls back. verify_audit_chains exits 2 on an empty target unless --permitir-objetivo-vacio (which backup.sh/restore.sh do not pass). notification — migration 012 webhook_entregas (append-only; no FK to the subscription; body sha256 only) and GET /api/v1/webhooks/{id}/entregas; _deliver_one writes the envio row BEFORE the POST (if it cannot, nothing is sent) and resultado after. Not exercised: backup.sh/restore.sh end to end against the live store (restore is destructive). Retention/purge of webhook_entregas: pending (ADR-018).
  • fix(storage,archive)!: B4 / P2 — expediente export is built on disk and served in chunks instead of fully in memory (512 MB cap). Storage prevalidates the whole inventory with one query, writes the ZIP to a temp file (member by member, 1 MiB chunks, incremental sha256 for checksums.txt) and only then answers with Content-Length; the temp file is removed in finally and a background task, and every failure happens before the first byte (never a truncated 200). New settings EXPORT_MAX_BYTES (4 GiB), EXPORT_MIN_FREE_BYTES, EXPORT_TMP_DIR; 413 export_demasiado_grande / 507 espacio_insuficiente. Archive streams without buffering, maps storage 413/507/other to 413/507/502 before auditing/serving, and detects a body shorter than Content-Length. Per-radicado no-read-up and the archive.expediente_exportado audit entry are unchanged.
  • feat(archive)!: batch B4 / P1 — delivery package for the secondary transfer, physical custody enforced in the database, and attempt-numbered PREMIS dedup_key (ADR-030 §5). POST /api/v1/transferencias/{id}/paquete-entrega returns a ZIP (admin level 3, neutral 404 no-read-up, 409 paquete_solo_secundaria_recibida, 409 componentes_sin_object_lock fail-closed): frozen FUID act as-is, institutional seal and personal signatures, the whole classified chain (constancias, revocations, disclosure envelopes; Ley 1712 art. 18 lit. a), a deterministic nota-entrega.xml that references acta_fuid_sha256 without rewriting the act, and checksums.txt. Read and generation audit entries are written in one transaction before the first byte; never exposed through OAI/EAD/CMIS. Tenant migration 065 adds the custody trigger (same rules and codes as the service 422, ERRCODE 23514); 066 adds fixity_fallos_intentos so the failed-fixity PREMIS key is fixity-recepcion-fallo:{id}:{n}.
  • fix(document,storage,tenant,gateway,shared)!: batch B2 — the authorization record's IP is signed by the gateway, the processing policy belongs to each entity and a shipment's receipt belongs to whoever uploads it (ADR-031). Public-window authorization record (Law 1581) is now v2 (ip only from a gateway-signed header pair, ip_verificada, server-computed politica_huella/politica_url); GET /public/{tenant}/ventanilla/politica (strict read, 503 politica_no_verificable); filing re-reads the policy (409 politica_desactualizada / 409 politica_no_publicada) before uploads, idempotency claim and sequence number; GET /ventanilla/estado gains politica_publicada; a shipment receipt must be uploaded by the recorder (403 acuse_ajeno) and not already referenced (409 acuse_ya_referenciado, unique index tenant 045); storage POST /internal/files/procedencia; tenant migration 012 (three empty politica_tratamiento_* parameters). verificar-ventanilla.sh requires the policy and seed_demo.py publishes a demo one. Impact: apply tenant 012 and document 045, rebuild gateway/document/storage/tenant; an entity with no published policy stops accepting online filing until it publishes one; TRUSTED_PROXIES must include nginx and the SSR.
  • feat(frontend): online single window with the entity's policy and postal-receipt messages (batch B2). /radicacion reads the policy from GET /public/{tenant}/ventanilla/politica (the POLITICA_TRATAMIENTO_* variables are removed); the checkbox shows version and link, blocks submission when the entity has no published policy, reloads and asks again on 409 politica_desactualizada, and a fixed note links to #supresion. /envios translates 403 acuse_ajeno and 409 acuse_ya_referenciado; component test for the receipt modal. Impact: rebuild the frontend (needs the B2 backend deployed).
  • fix(workflow,signature,notification,knowledge,shared)!: the failed-event queue enforces no-read-up inside the WHERE, not as a Python filter over 1,000 candidates (AREA-10-08). The list used to fetch at most 1,000 rows, resolve their level in Python and paginate in memory, so with more than 1,000 recent restricted rows an old public one fell outside the window and the total lied; the same logic was copied in four services. orpycamcp_common.deadletter is now the single source (LEFT JOIN LATERAL against the LIVE radicado, max(live, embedded), doubt = level 3) used by list, count, detail, replay and discard; hidden rows answer the same 404 as an unknown event_id. X-Truncated is gone, a missing radicados table answers 503. Migrations: shared ix_edl_keyset index (workflow 018, signature 023, notification 011, knowledge 013). knowledge also purges terminal rows older than 90 days and (014) keeps nivel_evento_at so a stale replayed reclassified cannot lower a later level.
  • fix(auth,signature)!: /internal/totp/verify now requires X-Internal-Token, and signing keys maintain themselves. Why: the endpoint that verifies-and-consumes a TOTP was the only auth-service internal route without the service-to-service gate (anyone holding a bearer could burn or lock attempts from the internal network); and the purge of revoked credentials and the KEK re-wrap existed only as an ops tool, unscheduled. What changes: /internal/totp/verify applies require_internal_token (401 missing, 403 wrong, 503 unconfigured; same SIGNING_KEY_INTERNAL_TOKEN, no new secrets) and signature-service sends it. New job app/jobs/signing_key_maintenance.py in auth-service's lifespan (SIGNING_KEY_MAINTENANCE_ENABLED, SIGNING_KEY_MAINTENANCE_INTERVAL_SECONDS, default 21600): per active tenant and under pg_try_advisory_lock, it purges revoked credentials past SIGNING_KEY_PURGE_AFTER_DAYS and re-wraps only rows whose key_version is lower than the active one (re-wrap no longer goes backwards if the active version regresses); it writes signing_key.maintenance_run with counts when there was activity; without audit it mutates nothing, without a valid keyring it skips the re-wrap. No migrations. Tests: 401/403/503 and wire header; integration on real PG with mutations (purge before the window, no audit entry, backwards re-wrap => red). Impact: rebuild and deploy auth-service and signature-service together.
  • fix(frontend): the UI mirrors the level-3 requirement for dependencias, catalogs and PINAR writes. Write buttons/forms (new/edit/toggle dependencia; new/edit/delete catalog item; new plan, approve/run/close, content and instrument editing in PINAR) show only with USUA_PERM_ADMIN level >= 3 (permission_levels from /auth/me); below that the screen is read-only and a 403 is explained in Spanish. Helper lib/utils/nivelAdmin.js; test admin/nivel-admin-escritura.test.js.
  • fix(tenant,mcp)!: writes to dependencias, catalogues and PINAR now require USUA_PERM_ADMIN at CRUD level 3 (user decision). Level 1 (read-only) used to suffice. POST/PATCH/DELETE /dependencias, /catalogos/... and the 19 /pinar writes return 403 at level 1 or 2; reads stay at level 1. MCP tool registrar_avance_pinar declares min_crud=3. No migrations. Deploy tenant-service.
  • fix(archive)!: FIRMA-2, round 3 — orphan envelope purge and on-demand grounds read. The envelope is now uploaded AFTER the personal signature is validated; if the row never references it (trigger, race, audit failure) it is purged through storage /internal/purgar-huerfanos, and a reconciliation sweep picks up acta-transferencia-sobre-… files with no reference. GET /acta and /acta/verificacion no longer return the grounds or salt (nor record a read on screen visit); new GET .../{excepcion_id}/fundamento?acto= (admin 3, via=lectura entry).
  • feat(archive,storage)!: FIRMA-2, round 3 — grounds disclosure envelope. Why: the grounds text and its salt lived only in the database (append-only, no Object-Lock, outside the transfer): losing the database lost the grounds entirely (Medium finding of the archival audit). What changed: every v3 record and revocation now produces an XML {id, acto, texto, sal_hex, compromiso, algoritmo} (exclusive C14N), sealed with the institutional seal and WORM-protected with the chain anchor retention (storage 021: six acta_firma_*_sobre_<rol> types, control and multi-artefact; archive 064: sobre artefact in the _worm tables, reconciliation/renewal/retry like the other links). No seal: 503 and nothing written. Mediated read only: GET .../firma-inobtenible/{excepcion_id}/sobre (admin level 3, transferencia.acta_fundamento_leido entry with via=sobre). POST .../revocar no longer returns the grounds or salt of the original record. ADR-030 documents how it travels in the secondary transfer to the AGN (classified component, Ley 1712 art. 18 lit. a).
  • fix(archive)!: FIRMA-2, audit round — the grounds of the unobtainable-signature record are fingerprinted with a per-record salted commitment SHA-256(salt || text) (verifiable by third parties; the salt lives in the database and is shown to admin level 3 only, never in the XML or audit_log), what is signed/stored/hashed is the exclusive C14N, GET /acta/verificacion checks the chain links (sustituyeA, revocacionSha256, constanciaSha256, stored hash, signed artefact) and a broken link yields valida=false, every link of a role's chain shares one WORM retention anchor, agotado has an exit (POST .../worm/reintentar, admin level 3), incapacidad is written impedimento_sobreviniente, revocation requires a closed-vocabulary causa, and reading the plain grounds leaves a transferencia.acta_fundamento_leido entry. Migration tenant 063; record v3. Response fields fundamento_hmac* are replaced by fundamento_compromiso*/fundamento_sal.
  • feat(frontend): FIRMA-2 in the unobtainable-signature screen: chain/ordinal, resolved support and people, WORM state of the revocation, required annex, TOTP on declare/revoke, "Verify rationale" button (admin level 3); /admin/parametros rejects null and holiday writes require admin level 3. Final contract: salted commitment instead of HMAC, mandatory revocation cause, chain links and cadena_motivos, WORM "Retry protection" (new proxies), signed-envelope "Download" (admin level 3; new proxy) and the envelope as fourth copy in the grounds check. UX review: on-demand "Show grounds" (admin level 3; /acta no longer returns it), the salt is never shown, chain/WORM codes in plain Spanish, focus to the section title after declare/revoke, retry reloads the acta, exception ids validated as UUID in proxies.
  • fix(gateway,signature): FIRMA-2, ronda de auditoría — the gateway normalizes la ruta (decodifica hasta estabilizar, rechaza ./.., barras dobles, \\ y controles) y la reenvía normalizada; bloquea todo segmento internal o *-internal de cualquier servicio (sin distinguir mayúsculas); los prefijos de ruteo sin barra final exigen límite de segmento. signature retira POST /internal/hmac-fundamento[/verificar] (sin consumidor: archive usa compromiso con sal local; evita un oráculo de diccionario) y PROPOSITO_FUNDAMENTO.

  • fix(archive,storage)!: AREA-07-10, auditor remediation — the "signature unobtainable" record was a note (unsigned, no Object-Lock, free-text support) and becomes evidence: XAdES-T sealed, signed by the declarant per the entity policy, WORM-protected, with a real supporting document, revocable and free of conflict of interest (ADR-030). Archive migration 061 and storage migration 019; firma-inobtenible now takes referencia_radicado_id/referencia_anexo_id/totp_code, seals before writing (no seal: 503, nothing declared), and a new .../revocar act exists; the declarant cannot be any of the three parties; completitud gains sellada_con_excepcion; fundamento is shown only to USUA_PERM_ADMIN ≥ 3; verification reads the entity XAdES policy and propagates signature's advertencias. Verification: archive 1228 passed/275 skipped (1501 passed/2 failed with RUN_INTEGRATION=1, a pre-existing state leak between integration files), storage 526 passed/15 skipped, lint green, 31 mutations all caught. Impact: apply storage 019 then archive 061; rebuild both services; the frontend must adapt the act body. Frontend: /transferencias/[id] declares and revokes the "signature unobtainable" act (radicado/attachment pickers, TOTP per the entity policy, irreversible-act confirmation), tells valid from revoked exceptions apart, and labels sellada_con_excepcion as "no party signed; only the entity seal"; allowlisted proxies POST /transferencias/api/{id}/acta/firma-inobtenible[/revocar] and Spanish messages for all its 401/403/404/409/422/503.

  • feat(archive,storage)!: AREA-07-10 closed — a party of the handover record that cannot sign is declared by an administrative act (level 3, with grounds) and the record becomes "signed with exception"; and the record's personal signatures enter CT retention renewal (user decisions 2026-10-04). Why: if whoever had to sign the record died, retired or became incapacitated, it stayed forever without that signature and nothing said so: either left pending with no way out, or signed by someone else (forgery). And the XML of each personal signature had a fixed-date Object-Lock that was never renewed in permanent-conservation series: the lock expired silently. What changes: POST /transferencias/{id}/acta/firma-inobtenible (USUA_PERM_ADMIN ≥ 3; rol, closed-catalog motivo, fundamento ≥ 20 chars and referencia_documental mandatory). Tenant migration 059: immutable record (append-only by trigger and privileges), one per role, CHECK declarada_por <> parte_id (the declarant is not the party) and firma↔exception mutual exclusion in the engine (trigger with advisory lock) both ways. The party is derived by the server from the frozen record; record + archive.acta_firma_inobtenible_declarada entry in the same tx (the entry carries the hash of the grounds, not their text); a 403 leaves a denial entry. /acta/verificacion adds completitud=firmada_con_excepcion and excepciones[]; firmada_completa now requires zero exceptions of any role; GET /acta carries excepciones_firma. firmar-personal rejects with 409 before spending the TOTP. Effect on acta_firma_personal_require: the exception covers its party (with both covered it is no longer pending); a partial one does not switch the gate off. CT renewal: storage accepts acta_firma_{elabora,entrega,recibe} in RenovarRetencionRequest (no service change: it was already per (expediente, tipo) and monotonic); archive, migration 060 (state/backoff on transferencia_acta_firma_personal_worm), renovar_worm_firma_personal and a 4th advisory lock in run_once_worm_renovacion; mirror with GREATEST; acta_firma_personal_worm_retencion_renovada entry; pendientes-renovacion lists them with their tipo. Not done and why: (a) the screen to declare the exception and its BFF proxy (contract handed to frontend; frontend/ untouched). (b) An MCP tool for the act: it is an irreversible administrative act with its own confirmation ladder and was not opened here. (c) The frozen FUID XML is not rewritten (probative, hash anchored): the declaration lives in the record and in /acta. (d) The record has no Object-Lock of its own (immutable by engine and backed by the hash-chained entry). (e) The declarant being ANOTHER party of the handover is not prevented: the rule asked is that it is not the party that cannot sign; the admin role bounds it. Verification: archive-service 1144 passed/267 skipped and 243 passed with RUN_INTEGRATION=1 (real Postgres: record+entry atomicity, CHECK, mutual exclusion both ways, append-only, CT filter and clearance in the WHERE, monotonic mirror); storage-service 463 passed/64 skipped; ./scripts/lint.sh green on both. 15 mutations (declarant guard, engine CHECK, entry without tx, firmada_completa with exception, signing precheck, exclusion trigger, GREATEST, 4th phase, CT filter, clearance, grounds text in the entry, seal type instead of the signature's, wrong identity column, storage Literal): all fall. Impact: apply tenant migrations 059 and 060 with archive's runner (idempotent); rebuild and deploy archive-service and storage-service. Frontend must add the proxy and screen (contract in the hand-off).
  • fix(signature,tenant,document,archive,docs)!: Four user decisions on signing (2026-10-04): the XAdES policy belongs to the entity, ESSCertID is required from a cut-off date, the content is signed rather than the identifier, and an outsider can no longer probe a chain's state. What changes: entity-level XAdES policy — signature-service reads firma_personal_require_xades (off|clasificados|todos) from the tenant's sgd_param_admin before the signing transaction; the env var is only a fallback when the entity does not define it, and an unknown stored value is applied as todos (never relaxes). tenant-service: tenant migration 010_params_firma.sql (seeds "off") and PUT /config/params/firma_personal_require_xades validates the vocabulary (422 valor_invalido). Gate behaviour is unchanged: level ≥ 2 without a certificate is 422 firma_xades_requerida, channel down 503 canal_firma_personal_no_disponible (never native); off keeps the fallback and its firma.fallback_nativa_canal_caido entry. The generic /admin/parametros screen already lists it. ESSCertID cut-off — TSA_ESSCERTID_CORTE (ISO 8601, default 2026-10-04T00:00:00Z; global because the TSA is shared infrastructure) replaces the boolean: genTime ≥ cut-off without ESSCertID is rejected; earlier stamps stay valid and /verify returns advertencias[] (tsa_sin_esscertid_anterior_al_corte). Content, not identifier — document-service GET /api/v1/internal/documentos/radicados/{ref}/huella (SHA-256 of the current PDF) and archive-service GET /api/v1/internal/expedientes/{ref}/indice/huella (own router, X-Internal-Token): the fingerprint is fixed when the chain opens and re-read before every signature/approval (409 documento_modificado; producer down 503; nothing to seal 409; chains opened earlier 409 cadena_sin_huella). Guard order — 403 no_es_firmante before 409 solicitud_no_pendiente in firmar, rechazar, vobo and challenge. Not done: HSM/KMS (pending a provider; the hook is auth-service/app/core/signing_keys.py::sign_digest). Verification: signature-service 651 passed/3 skipped with RUN_INTEGRATION=1; tenant-service 186 passed; document-service 1002 passed with RUN_INTEGRATION=1; archive-service's 10 new tests pass but its full suite was not finished (/var free space below the floor); 22 mutations executed, all caught — mutation M1f (todos mode returning "gate inactive") first SURVIVED and was closed with two new todos tests at level 1 (422 without certificate, 503 with the channel down); M4c–M4o executed on 2026-10-08 (the original harness was lost; the 13 were rebuilt from their description on read-only copies): 13 of 13 caught (35 of 35 overall). Impact: apply tenant migration 010 before signature; export ARCHIVE_INTERNAL_TOKEN in production; TSA_ESSCERTID_OBLIGATORIO no longer exists; chains opened earlier over a radicado/expediente must be re-opened. Audit remediation (2026-10-08): (1) High — migration 010 seeded "off", which beats the environment fallback FIRMA_PERSONAL_REQUIRE_XADES; it now seeds JSON null and the new tenant migration 011_params_firma_null_no_off.sql nulls rows still at "off" with no admin_audit row for that key (the API always writes one, so an "off" the entity chose is kept); idempotent. (2) High — PUT /config/params/{key} now requires USUA_PERM_ADMIN at level 3, and relaxing firma_personal_require_xades requires motivo (≥ 10 chars, 422 motivo_requerido), recorded in the audit entry with relaja_politica. (3) /verify advertencias: list[str] stays an additive contract field (archive-service propagates it). (4) TSA_ESSCERTID_OBLIGATORIO present at startup logs an error naming TSA_ESSCERTID_CORTE; both new variables documented in infra/. (5) the radicado fingerprint is ONLY the es_principal annex (409 radicado_sin_documento otherwise). (6) python -m app.ops.invalidar_cadenas_sin_huella invalidates old NULL-fingerprint pending chains in bulk, one application-written audit entry each. (7) gate_modo and huella_documento_sha256 added to firma.turno_firmado; huella_documento_sha256 to firma.solicitud_creada. (8) the 503 for an unavailable producer no longer forwards its body or host. (9) the GET /signature/requests… scope (PERM_FIRMA + clearance) is declared accepted. Verification: tenant 186, signature 651/3 skipped, document 1002 passed (with RUN_INTEGRATION=1); lint green; 29 own mutations all caught (one survivor closed with a test). Impact: apply tenant migration 011; deploy tenant, document, signature; run the ops script once (--dry-run first). Frontend: PUT /config/params/{key} takes motivo, returns 403 below level 3 and 422 motivo_requerido. Frontend: /admin/parametros mirrors the USUA_PERM_ADMIN level 3 requirement to write parameters, edits firma_personal_require_xades with a selector (null = "inherited from the environment") and asks for the ≥ 10-character reason when relaxing it; verifications (/verify, /indice/verify, /acta/verificacion) show advertencias[], and the signing-chain 409/503 (radicado_sin_documento, indice_no_disponible, anexo_sin_huella, documento_modificado, cadena_sin_huella, verificacion_documento_no_disponible) are explained in Spanish.
  • fix(shared,auth,tenant,document,archive,storage,workflow,notification,signature,knowledge)!: T15 — cross-cutting work on orpycamcp_common: the tenant slug was validated in 14 places with five different bounds (and registration accepted -abc, which no service accepts), check_permission and resolve_caller_clearance lived as copies in nine services, and 191 audit_log entries said canal='api' even when they came from a job (AREA-02-05, AREA-01-17 and AREA-03-07).
  • T15 audit remediation: tenant-service global migration 003_slug_forma.sql. It aborts, listing the offending slugs, if public.tenants has any slug outside ^[a-z0-9][a-z0-9-]{1,53}$ (the shared validator is stricter than the copies it replaced, and such an entity's archive would become unreachable), then adds CHECK tenants_slug_forma_chk. SQL<->SLUG_PATTERN coincidence guard in test_slug_unico_contrato.py. Why: (1) every database.py (plus keys.py, auth_service.py, grant_root.py, document_repository.py and the storage repositories) carried its own regex ({0,54}, {0,98}, {1,62} and registration's ^[a-z0-9-]+$) with no guard checking they agree, and PreservacionWormRepository did not validate at all. A future copy without the anchor, or allowing _, would permit schema collisions or escape the quoting. (2) The four gate functions were byte-identical (same AST hash) in seven clearance.py files and tenant/authz.py: a twin diverging from the fail-closed policy in one copy would be an authorization hole no healthy service's tests can see. (3) audit.append(canal=None) fell back to 'api': purges, WORM renewals, reconciliation and consumers were recorded as API calls, and the legal trail lied about origin. What changes: slug — orpycamcp_common/tenant.py (SLUG_RE = ^[a-z0-9][a-z0-9-]{1,53}$, slug_valido, tenant_schema raising ValueError); the nine database.py, keys.py, auth_service.py, grant_root.py, registrar_restauracion.py, document_repository.py, the storage repositories and the TenantCreate schema import it (registration no longer accepts a leading hyphen: 422). Gate — orpycamcp_common/rbac.py holds check_permission, resolve_caller_clearance, nivel_efectivo and mensaje_denegacion; each service re-exports them from its core/clearance.py/authz.py and keeps only require_permission. Channel — audit.append(..., canal: str) is MANDATORY; audit.CANAL_PETICION declares "this act belongs to a request" (inherits the gateway's X-Canal, 'api' if absent) and "system" marks jobs, consumers and CLIs; request_log.canal_de_fondo() pins 'system' in each periodic loop's task (archive x3, document x2, storage) and spawn_supervised does it for every event consumer, so a mixed function (request and job) records correctly in both. The 191 callers without a channel were rewritten; the internal purge routes (document, storage) and the preservation routes fired by the reconciler (canal_del_acto(actor)) record system. object_ref — GET /api/v1/audit?object_ref=<code> now also returns the historical entries that name the expediente by UUID or by its index UUID (and a radicado's by its number), resolved at read time with subqueries (no extra round trip); history is not rewritten and the no-read-up trimming applies afterwards. Docs — ADR-013 (auto-provisioning is already decided; resolution is now shared), ADR-008, multi-tenancy.md, api.md and the service CLAUDE.md files. Not done and why: (a) permission cache: still out on purpose (ADR-013: a revoked permission must take effect on the next request; no load profile asks for it). (b) require_permission in common: it depends on each service's get_tenant_conn; it stays local. (c) the object_ref history: not rewritten (hash chain); resolved at read time. (d) append_denial keeps canal=CANAL_PETICION as its default: a gate denial is, by definition, a request's. Verification: new guards in shared/tests — test_un_solo_validador_de_slug (tree census + anti-vacuity + parametrized; mutations: reintroducing a local regex and dropping the anchor both fail), test_el_gate_se_define_una_vez (mutations: a check_permission definition in workflow and return True for a null user both fail) and test_el_asiento_declara_su_canal (AST census of 190+ calls; mutation: dropping canal= from index_service.py fails; a background task records system even if it declares CANAL_PETICION) — shared/tests 395 passed; on rebuilt images: document 752 passed/227 skipped, auth 316 passed/44 skipped (plus 3 integration tests of the object_ref filter against real Postgres, with the exact-= mutation failing; 3 pre-existing failures in test_verify_audit_chains_job caused by the local stack's NOLOGIN orpycamcp_app role), tenant 137/7, archive 1084/254, storage 447/64, workflow 355/102, notification 228/20; signature 503/15 and knowledge 262/56 with the code mounted :ro; ./scripts/lint.sh green (ruff + mypy) in all nine services. Live against the stack: GET /audit?object_ref=EXP-2026-0002 returns 51 entries, including the two naming the expediente by UUID and index; creating an expediente through the gateway records api; a background task records system; TenantCreate rejects -abc, a_b and 55 characters and accepts bogota-limpia (tenant_bogota_limpia). Impact: no migrations; all nine services rebuilt and deployed with the local stack's owner role. The internal contract of audit.append changes (canal mandatory): any caller outside this repository must declare it. Job entries now say system; historical ones still say api. A 1-character slug or one with a leading hyphen is no longer accepted anywhere (registration already required >= 2).
  • fix(docs,archive,frontend,signature,workflow,storage,document,shared): T16 — documentation and memory: the roadmap contradicted itself about final disposition and kept two closed findings open, multi-tenancy.md and ADR-002 prescribed Alembic, SQLAlchemy and an unvalidated SET search_path, the getting-started guide told people to onboard an institution through a route the gateway withdrew, ADRs 006/018/021 and the TSA comments said "no hard barrier", "recording deliveries" or "stub" about things that already exist or never existed, the EN roadmap was 18 open items behind the ES one, and the per-unit loans listing returned the whole history Why: every false statement was written when it was true and the code moved on; a text saying "not implemented" about something that exists reopens what is closed, and an unsafe recipe in an ADR gets copied by whoever reads it. The audit items (AREA-14-01/-02/-03/-04/-06/-07/-08/-09/-10/-11/-13/-14/-15/-16/-17/-19, AREA-02-01, OMI-07, AREA-07-19, AREA-07-16) are detailed in the Spanish changelog. What changes: docs — roadmap ES/EN (final disposition becomes history with the current state; the closed findings carry their evidence and the accepted residuals: invalid tokens are not cached, the limiter fails open without Redis; header dated 2026-10-03, no hand-written ADR count; the 18 stale EN open items closed with their reason); multi-tenancy.md ES/EN rewritten over the real pattern (tenant_schema with a fail-closed _SLUG_RE + SET search_path = "{schema}", public on asyncpg; onboarding through scripts/init-tenant.sh; the shared stream orpycamcp.{service}.events; documents and preservation buckets); ADR-002 gains a "partially superseded by ADR-003 and ADR-027" note with the safe snippet; ADRs 006/016/018/021 corrected without touching their decision; deployment.md downgrades Kubernetes to "not supported yet" and warns about in-memory state when scaling; DOCKER_REGISTRY.md rewritten for GitLab; getting-started no longer curls /api/v1/tenants; security-audit.md EN gets the second-pass audit and the citizen-window behaviour change. code (comments only) — TSA, workflow, storage, OAI and transfer-repository comments, two obsolete frontend TODOs. signature — tenant migration 022 (COMMENT ON COLUMN firma.xades_level). archive — GET /unidades/{id}/prestamos paginates (page/size ≤ 100, X-Total-Count) and the frontend BFF requests size=100 and keeps the total; tests/test_ead_xsd.py validates the disseminated EAD (no series, 1, 2, 3 and 5 levels) against the official EAD 2002 XSD vendored in tests/fixtures/. shared — test_la_documentacion_dice_lo_que_hay.py: 20 guardians tying each sentence to the code fact that would make it false. Not done and why: the durable webhook_entregas table (ADR-018 now states the truth and declares it pending), EN changelog parity (the project documents the English changelog as a selection, not a translation: open question), a generated MCP-coverage figure (needs the eleven OpenAPI schemas live; the split between "excluded on purpose" and "real gap" is the owner's call), a level discriminator for the CCD (trd_series has no level column, ADR-015), export streaming and a load suite (own batch). AREA-14-12 turned out to be closed by migration 028, which already rewrote that COMMENT. Verification: documentation guardian 20 passed (27 mutations reintroducing each false sentence: all fail); archive-service 1084 passed/254 skipped (+ real-Postgres integration); signature-service 503 passed/15 skipped; ruff+mypy green on the five services touched. Images were not rebuilt (/var at 98 %): tests ran on the existing image with sources mounted read-only. Impact: deploy archive-service and the frontend; signature-service applies 022 (a COMMENT only); the rest is comments and documentation.
  • fix(infra,auth): T14, audit remediation: a single broken audit_log row aborted every backup forever (leaving a clear-text .PARCIAL each night), unread retention was taken as "no retention", previous COMPLIANCE versions were lost with a warning and rc 0, restoring left no immutable trace, and production services carried the owner connection in their environment (1 High + 3 Medium; the fourth Medium, ungated knowledge, stays declared). What changes: verify_audit_chains accepts a responsible-approved baseline of historical breaks (--roturas-declaradas FILE, or ROTURA<TAB>slug<TAB>id<TAB>note lines in the anchor stdin): only NEW breaks, non-enumerable breaks, a count regression or a changed anchored hash count. backup.sh no longer aborts: on a failed verification it emits no anchor but completes (encryption, seal with verificacion_auditoria=FALLIDA, purge) and exits 3 at the end; baseline via BACKUP_AUDIT_ROTURAS_DECLARADAS. Retention reads distinguish "no configuration" from any other failure; a failure aborts that bucket, listed-versions vs captured rows must match, the backup is sealed with retencion_capturada_completa=NO and exits 4, and restore.sh refuses it unless RESTORE_ACEPTAR_RETENCION_INCOMPLETA=1. A previous version under live COMPLIANCE now fails the restore unless RESTORE_ACEPTAR_PERDIDA_VERSIONES=1; the new app.ops.registrar_restauracion updates preservacion_worm_objeto.version_id from the map and writes the storage.restauracion audit entry in ONE transaction. In production MIGRATIONS_DATABASE_URL (owner) leaves the 10 running services and stays only in migrate-<svc>; the guardian was inverted. Not done: gating knowledge /search, /antecedentes, /rag (product decision, stays declared); a PREMIS event per remapped object. Verification: shared/tests 333 passed; auth-service 362 passed with RUN_INTEGRATION=1; lint green; mutations fall as listed in the Spanish entry. Impact: rebuild auth-service; backup.sh may exit 3 or 4 with a complete sealed backup (treat as alert); production runtime can no longer run migrations with run <svc>: use migrate-<svc>.
  • fix(infra,auth,ci)!: T14 — CI and infrastructure guards: no non-negotiable rule had an executable guard, the CI matrix was a hand-written list (no signature in integration, no whisper in unit), production built on the server with no way back and no migrations at startup, the backup ANCHORED the audit chain without verifying it (and the restore.sh heredoc blew up with a TypeError), the restore accepted an unrestored retention as fine, and the dependency audit could not fail (AREA-01-18, 13-01/02/03/05/08/09/11/13/14, 08-10/12, 03-02/05/06, 05-08). New executable guards in shared/tests (every push): every route carries a gate or a declared exception with its reason (AST census of over 400 routes; knowledge's /search, /antecedentes, /rag and aceptar are DECLARED, not approved — gating them is a product decision), no ORM/python-jose and the version floors, repo bind mounts read-only, CI matrices derived from the tree, immutable images and migrations-before-serving in the production manifest, a safe production manifest (no ports:, Redis password, mandatory credentials, pinned third-party images). CI: whisper in test-unit, signature and workflow in test-integration, the real auth-service global migrations before each integration tier (the integration conftests now FAIL under CI instead of creating their own copy of the audit_log DDL), a storage-worm-real job (RustFS pinned by digest; it runs when a compose file changes), an automatic test-security-smoke, Trivy before every image push, a fail-closed dependency-audit with a deterministic threshold (scripts/audit_umbral.py; the model only drafts the report) and an npm-audit job. Production: image: ${ORPYCA_REGISTRY:?}/<svc>:${ORPYCA_TAG:?} for the 12 first-party services (no build:; local builds live in infra/docker-compose.build.yml) and a one-shot migrate-<svc> per database-backed service. Backup/restore: auth-service/app/ops/verify_audit_chains.py is invoked by backup.sh BEFORE anchoring (no anchor if it fails) and reused by restore.sh; infra/lib-retencion-s3.sh saves every version and the legal hold, restores and COUNTS (restore.sh exits non-zero if a valid retention or legal hold is left unrestored) and writes the old → new version_id map for the ADR-027 record. auth-service's /health/ready adds audit_particiones (degraded if next month's partition is missing; still 200). scripts/preflight-prod.sh checks TLS and ports from outside. seed_demo.py seeds a draft with a two-turn circuit whose text changes inside the invalidation window, and firma-gate-orden.spec.js exercises the order gate. Not done: gating knowledge's routes; migrations on startup in the development compose; an entrypoint inside the 12 Dockerfiles; previous versions with retention cannot be restored (reported). Impact: production needs ORPYCA_REGISTRY and ORPYCA_TAG on every docker compose; rebuild auth-service (it was NOT rebuilt on the local stack: /var had ~460 MB free); no new SQL migrations.
  • fix(document,frontend)!: T13 — document compliance: a radicado was born with no type and no department, the classified-information index admitted NULL legal bases, a level change was published after the commit and lost with Redis down, the public window kept no proof of the personal-data authorisation and had neither an automation brake nor a safe retry, the postal webhook secret was single and in clear, and publishing ex officio disclosed the first attachment (AREA-09-02/04/05/07/08/10/16, 07-12 and 02-07 — the latter only the honeypot and the Idempotency-Key). Why: (1) DocumentCreate and the batch left doc_class, dest_dept_code and origin_dept_code optional (AGN 060/2001 §5.2): a radicado could be born with no TRD series and no destination, in nobody's inbox. (2) The INT-05 import accepted nivel ≥ 2 with a null clasificacion.fundamento and the database did not prevent it. (3) Reclassification published its event after the commit and swallowed the failure: with Redis down right then, the level stayed raised and knowledge — which derives its fragments' level ONLY from the event — kept serving the fragment at the old level (R6, declared and deferred). (4) The single window and PQRS registered the sender with no proof of consent (Law 1581), no trap field and no idempotency key: a network retry spent two official sequence numbers. (5) postal_operator_credential.webhook_secret was clear TEXT, one row per operator, no endpoint wrote it and there was no rotation; a shipment could be moved to entregado by hand with no evidence. (6) get_public_anexo served the first attachment of any published radicado, and publishing only required nivel 1 (the DEFAULT of a PQRS). (7) documento.contenido_cambiado was never emitted. What changes: minimum data — app/core/datos_minimos.py (E/I → type + destination; S → type + origin) imported by DocumentCreate and BatchDocumentCreate (the batch gains origin/dest_dept_code and resolves the name in dependencias); internal producers derive or reject before spending a number (exigir_o_422): the quick reply inherits origin and type from its antecedent, a radicado from a draft derives type (template), destination (name) and origin (issuer's URD) and POST /borradores/{id}/radicar takes an optional body that wins; the mailbox intake requires it before fetching the message. Index — migration 041 (CHECK radicados_clasificado_con_fundamento NOT VALID + "PENDIENTE DE MOTIVAR…" backfill + VALIDATE; create now inserts the basis because the CHECK cannot be deferred), the import rejects the whole package (422) and the index adds dependencia and registrado_por. Outbox — migration 042 (eventos_salientes) + services/outbox.py: creation, reclassification and annulment enqueue the event in the SAME transaction as the change and its audit entry; fast path after the commit and a relay in the sweep (phase 4, SKIP LOCKED), same event_id on resend; BorradorService.editar emits documento.contenido_cambiado when the text fingerprint changes. Publication — migration 040 (anexos.publicable, DEFAULT FALSE, backfill of what was already published), PublicacionRequest.anexos_publicables, get_public_anexo requires a.publicable, and set_publicable excludes in the WHERE anything with channel ventanilla_publica or typed with a admite_ventanilla_publica type (neutral 404); classifying unmarks attachments. Public window — acepta_tratamiento: Literal[True] + politica_version (proof in metadata.consentimiento and the audit entry; ip_declarada), honeypot sitio_web, Idempotency-Key with public_submission_keys (migration 043, claimed inside the radicado's tx and before the sequence number; other content → 409), GET /ventanilla/estado and scripts/verificar-ventanilla.sh; the SvelteKit form carries the checkbox (with the entity's policy: POLITICA_TRATAMIENTO_VERSION/URL), the off-screen trap and one key per content. Postal — migration 044: two encrypted secret slots (AESGCM, AAD entity+operator), verify_any, PUT /envios/operadores/{op}/credencial (USUA_PERM_ADMIN crud≥5, never returns or audits the secret), acuse_file_id/acuse_sha256 mandatory for a manual entregado (the shipments screen uploads the receipt and sends it). Storage — REFERENCIAS_DE_ARCHIVO += postal_shipment.acuse_file_id (the guardian test_referencias_de_archivo_cubren_todo_file_id caught it: without the entry the orphan purge would not see the receipt's reference); the guardian test_eventos_de_radicado_declaran_nivel now also censuses outbox.encolar (otherwise the emitter census would silently empty out) and documento.contenido_cambiado declares nivel_seguridad. Docs — the R6 comment in knowledge-service/app/services/ingestion.py corrected (closed at the source). Not done and why: (a) the CAPTCHA provider (AREA-02-07): user decision (reCAPTCHA contradicts sovereignty; ALTCHA or another self-hosted proof of work, verified in document-service before opening the transaction). (b) not binding an anonymous submitter to an existing third party's record by document number (AREA-09-08 note): it changes the directory convergence the tests fix today; question: should the anonymous channel register with metadata.remitente and no record when the document already exists in the directory? (c) series/file level of the index and a closed catalogue of legal bases (AREA-09-04.3) and double control for declassification (AREA-09-05): entity decision. (d) fragment-level reconciliation sweep in knowledge (AREA-09-05), a document.envio.devuelto consumer in notification (AREA-09-10) and a documento.contenido_cambiado consumer in signature (AREA-09-16): outside this batch's services; the outbox closes the loss at the source and the synchronous signing guarantee already exists. (e) tipos_documentales.publicable_permitido (AREA-07-12): an archive-service column; admite_ventanilla_publica was reused, which already is the entity's declaration of "this comes from outside"; publication history stays in audit_log. (f) IP verified by trusted hops on public routes: the gateway does not inject it; ip_declarada is stored. (g) habilitada inside GET /ventanilla/tipos (AREA-09-07): it would have broken the response shape (a list) and an empty list cannot carry it; it is a new endpoint GET /ventanilla/estado. (h) type/department capture in the drafts form: without it a draft with no template type and no author URD answers 422 on radicar (the optional body solves it by API). Verification: document-service 741 passed/219 skipped and 219 passed with RUN_INTEGRATION=1 (real Postgres: 23 new tests — CHECK 041 and its backfill, the publication WHERE by type and by channel, anexos.publicable, the outbox with Redis down, the UPDATE rolling back if the event INSERT fails, the idempotency key under asyncio.gather over 4 connections, the draft derivation against the real tables, the batch with a missing department); frontend 2655 passed (235 files; 8 new tests: the checkbox, the hidden honeypot, the per-content key, the proxy and the receipt); ruff+mypy green (storage-service: 447 passed/63 skipped and ruff green; shared/tests 255 passed). 17 mutations that fall (all restored, image rebuilt): dropping the E branch / doc_class from S / the batch validator / the import check / CHECK 041 / AND a.publicable / the type / channel exclusion in the WHERE / moving encolar out of the tx / moving the key claim out of the tx / the honeypot / Literal[True] / a constant habilitada / the previous slot's expiry / the mandatory receipt / the enviar of contenido_cambiado. Verified on the live stack (tenant demo) end to end through the gateway and the frontend proxy: the 422/201 matrix of minimum data, the batch with a missing department, the index fields and the CHECK, the outbox fast path and a relay resend (same event_id), ventanilla (enabled only for the test and put back) with consent, honeypot, the same Idempotency-Key sequentially and with three simultaneous requests → one radicado and one sequence number, 409 on other content, publication by attachment and the exclusions, the HMAC rotation with the previous slot valid then expired (401) and the secret never in clear, entregado with and without the receipt, documento.contenido_cambiado in the stream, and radicar from a draft with and without data. Disk incident during deployment: rebuilding the frontend (its npm install cache had been pruned) and ~500 MB of WAL from the integration runs took /var to 0 and Postgres went down with a PANIC for lack of space; space was freed (WAL via pg_switch_wal+CHECKPOINT, max_wal_size lowered with ALTER SYSTEM to 64 MB and min_wal_size 32 MB — reset with ALTER SYSTEM RESET —, an unused python:3.12-slim image and the container-less latest images of tenant, notification, workflow, signature and auth, all rebuildable) and orpycamcp-postgres was started again (recovered with no loss). Impact: breaks clients that register without type/department (POST /documents, POST /batch/documents, the single window and PQRS without acepta_tratamiento/politica_version, a manual entregado without a receipt): warn integrations; a batch queued before the change without codes ends with those items failed. Migrations 040–044 per tenant (document-service's python migrations/runner.py; no cross-service dependency: tipos_documentales already existed from archive-service), rebuild document-service, frontend and storage-service (the latter a one-liner: REFERENCIAS_DE_ARCHIVO). Rotating the postal secret needs EMAIL_ENCRYPTION_KEY (otherwise 503). Publishing a radicado no longer publishes its attachments: mark them with anexos_publicables. Other services' integration tests that seed radicados with nivel_seguridad ≥ 2 through raw SQL must carry a basis.
  • fix(document,knowledge): T13, audit remediation: the outbox relay re-sent an old reclassification with its original level, the delivery receipt was recorded without checking it against the object store, and the 041 backfill left no audit entry (AREA-09-05, 09-10 and 09-04). Why: (1) retransmitir re-sent any pending row older than 60 s without checking whether a later reclassification of the same radicado had gone out; knowledge sets the EXACT level, so a late A (3→1) after a B (1→3) lowered the chunk and served it to people who should not see it. (2) acuse_file_id/acuse_sha256 were only shape-checked: an invented UUID marked the shipment «delivered» and was cited as proof. (3) The 041 UPDATE changes the Law 1712 art. 20 index field with no trace (invariant 6). What changes: the relay publishes the CURRENT nivel_nuevo read from radicados.nivel_seguridad in the same transaction (outbox._con_nivel_vivo; the event contract is unchanged). POST /envios/{id}/estado checks the receipt against storage (/internal/files/hashes, which only knows the tenant's files) before the transaction: 422 acuse_invalido if the file does not exist or the hash differs, 503 acuse_no_verificable if storage is down, and the hash returned by storage is the one recorded. The migration runner (asentar_backfill_fundamento) writes a document.clasificacion_fundamento_backfill audit entry (actor system:migration-041, de: null, a: <text>) for each row the 041 filled, idempotent and valid where 041 already ran; it lives in the runner, not in SQL, because the hash chain is computed by the application. Fixed the comment in knowledge-service/app/services/ingestion.py. Not done and why: (a) comparing occurred_at in knowledge as a second net (needs a column and migration in another service; the source no longer publishes stale levels). (b) requiring that the receipt was uploaded by whoever marks the shipment, and rejecting a file_id already used as an attachment: storage does not expose that per file and the cross-reference lives in another service; left as an improvement. (c) the rows filled by the 041 require the entity to give their grounds: the text «PENDIENTE DE MOTIVAR» appears verbatim in the index. Verification: document-service 979 passed with RUN_INTEGRATION=1 (real Postgres; 4 new unit and 4 new integration tests) and mutations that fail: removing _con_nivel_vivo, skipping the receipt check, removing the backfill's NOT EXISTS or nivel >= 2 filter; shared/tests 255 passed; ruff and mypy clean in document-service and knowledge-service.
  • fix(archive,storage)!: T12 — archival compliance: the index was signed without validating its XSD, a transfer was prepared with an unsigned index and its record did not say which signature covered it, accumulated holdings and the record's personal signatures had no Object-Lock, the AIP replica was never re-read, and annex deduplication revealed reserved annexes (AREA-07-01/03/04/05/06/08/09/11/14/15, 06-04/07, 08-02/04/05/09/15; 07-10 only the technical WORM). archive: index_xsd.py (lxml) validates the index before sealing, on close, manual retry and reconciliation (pendiente_firma/xsd_invalido, never signed) and exhausted sealing sends an email to whoever can repair it; create requires the signed index (409 indice_no_firmado), reads custody inside the transaction with FOR SHARE and rejects a pair with no custody (422 ubicacion_sin_tenencia); migration 053 ties indice_firma_id/indice_sha256 into the handover record (columns and the hashed XML) and cabecera_completa is derived on read; 054 revokes UPDATE/DELETE on the record from the app role, 055 adds a CHECK on objeto (history exempt: a bare NOT VALID is also evaluated on each UPDATE), 056 records the receipt's fixity check as a PREMIS event (post-commit, idempotent, reconciled), 057 gives each personal signature WORM wiring, 058 + a job complete legacy producer/format/size on open expedientes only (NULL columns only); accumulated holdings are wired to WORM with the extreme date and a clamp (today + 400 days, ADR-026 Inc.3); POST /expedientes/{id}/aip/reintentar; preservation events now include the AIP and per-file ones; PATCH /trd/{id} accepts both phases and the legacy set is derived. storage: deduplication is for the same author only (no more existence oracle), the AIP replica is re-read (retention + SHA-256) with a sanitised failure reason, startup guard for veraPDF with no binary, looks_like_pdf tolerates a 1 KiB prefix, PREMIS declares the PRONOM PUID, migration 018 (acta_firma_* types), POST /preservacion/eventos-internal, and a WORM-circuit test against real Postgres + RustFS. Not done: the firma_inobtenible act (07-10) and the two-level TRD hierarchy (06-07) are user decisions; preservationLevel/rights in PREMIS; a notification consumer for "index exhausted" (it is a direct email today). Deviation to confirm: the accumulated-holdings WORM uses a ~400-day clamp instead of "set no retention" (ADR-026 D4.4) — an irreversible COMPLIANCE over documentation whose schedule already expired. Verified on the live stack (preservation temporarily enabled, then restored): a 1985 holding ended under COMPLIANCE until exactly today + 400 days. Impact: rebuild storage and archive (adds lxml; watch disk); migrations in order — storage 018, then archive 053–058; census before deploying to a real tenant: closed expedientes with index ≠ firmado and active transfers (the local stack had 0 and 0). T12 audit remediation (2026-10-03, archive): a fondo acumulado rejects a future fecha_extrema_final (422; base_worm returns None fail-closed); a failed receipt fixity now records a PREMIS FIJACION event with resultado="fallo" (counts only), and a fixity with files lacking a hash is declared verificacion="parcial"; archive.vinculo_origen_completado carries the written valores, fuente and timestamp, and the backfill takes FOR SHARE on the expediente row to serialize with closure.
  • fix(frontend,storage,gateway,document,archive): T11 — BFF guards, cookies and RBAC mirror (AREA-12-01/03/04/05/06/07). A tree-derived guardian requires a session on all 290 verbs of the 219 (app) +server.js (was 21 hand-picked); a test asserts the session-cookie flags; admin/+layout.server.js redirects to /403 by each area's real permission (fail-closed USUA_PERM_ADMIN for undeclared areas); GET /api/v1/preservacion/estado (instance state, no per-expediente read) lets the Preservation tab say "disabled on this installation"; SchemaField supports arrays of scalars and one-level objects, and the 422 metadata_validation_failed envelope now carries field (document-service and archive-service); layout.scss uses --op-scrim and the scrim guardian covers .scss; PINAR gains three missing BFF doors plus a backend↔BFF parity guardian (the "36 endpoints, UI consumes 6" figure was false: 30). New e2e: restricted-RBAC (consulta) and axe (serious/critical fail). Not done: migrating inline-metadata views to SchemaField, arrays of objects, T-18 branding / bundle budget. Impact: rebuild storage, api-gateway, document, archive and frontend; no migrations.
  • fix(knowledge,mcp,gateway,shared,infra)!: what the assistant confirms was recorded as channel api with no way to tie it to its turn or proposal; the assistant did not check that the radicados in its text exist; all its calls shared one container's per-IP bucket; ollama_local was "sovereign" by name even with a public OLLAMA_URL; RAG suggestions had no human acceptance; and "per-tenant opt-in" was a false statement Why: (1) everything the mcp-server does on behalf of a person enters the gateway through /api/v1/<domain>, and derive_canal only knew mcp by PATH: a radicado created from the card ended up api, like a direct call; and even with the gateway saying mcp, audit.append defaulted to canal="api" and document-service did not pass it, so the channel was lost one hop later (AREA-11-04). (2) "do not invent numbers" was one line of the prompt: nothing checked the final text (AREA-11-04). (3) the mcp-server did not forward the client IP: the assistant calls of ALL entities fell into its container's per-IP bucket, the NAT problem of 9c2cc6a one hop further in; and a turn's cost was not measured (AREA-11-08). (4) is_generatable decided by the NAME ollama_local; with a public OLLAMA_URL, /rag sent level 2-3 chunks to that host and left only a warning (AREA-11-12). (5) kb_suggestions.aceptada_por/aceptada_en existed and no endpoint wrote them (AREA-11-11, CC-02). (6) main.py, the guide and ADR-006 said "per-tenant opt-in": it does not exist, ingestion is global (OMI-04). What changes: shared identidad.firmar_origen/verificar_origen (mark X-Origen-Canal: mcp.<ts>.<hmac> bound to the tenant and the sha256 of Authorization, 120 s validity, with no secret it is neither issued nor verified) and ip_real_firmada (X-Real-Ip only if the accompanying identity verifies); request_log captures X-Canal (only api|ui|mcp) and Idempotency-Key into ContextVars; audit.append/append_denial take canal from the request when not passed and, only on channel mcp, add payload.correlacion = {request_id, idempotency_key}. api-gateway derive_canal(path, origen, tenant=, authorization=): mcp by path or valid mark, api otherwise (the mark is not forwarded); forwards Idempotency-Key; routes /api/v1/knowledge/suggestions/. mcp-server services/origen.py::cabeceras_de_origen is the single place that builds outgoing headers (dispatch, /auth/me, scope frame): Authorization, X-Tenant-Slug, the mark, X-Request-Id and X-Forwarded-For with the IP the gateway signed; ContextoEntranteMiddleware + RequestLogMiddleware; the proposal jti travels as Idempotency-Key (in a procedure, <jti>:<n> per step); run_turn returns no_verificados (every {YYYY}-{TENANT}-{E|S|I}-{NNNNNN} in the text absent from the tool_results; what the person typed does not count) and appends a "No verificado" notice, and logs iteraciones/llamadas_gateway/propuestas/no_verificados per turn. knowledge url_es_local + is_local_llm_provider/is_generatable(…, ollama_url) (local host = loopback, private, container or *.local; with a public host RAG excludes level >= 2); migration 012_kb_suggestions_creada_por.sql; POST /api/v1/knowledge/suggestions/{id}/aceptar (only whoever generated it, neutral 404, 409 if already accepted, atomic rag.aceptada entry). Documentation: the "per-tenant opt-in" promise is withdrawn (main.py, integraciones-externas ES/EN, ADR-006) and the guide's "stub" embedding corrected to local_st; the mcp-server/health.py docstring said it did not install the shared library (it does). docker-compose*.yml: IDENTITY_SIGNING_SECRET on mcp-server (mandatory in production). Not done and why: (a) propagating expediente reclassification to the index (AREA-11-11) does not apply: ExpedienteUpdate does not admit nivel_seguridad, no UPDATE expedientes touches it and archive-service publishes no events —there is no producer to consume—; shared/tests/test_el_nivel_del_expediente_no_cambia_en_silencio.py makes the premise falsifiable. (b) OMI-04 without the per-entity switch: setting knowledge.habilitado with a false default would stop ingestion for current entities; that is a product decision. (c) The per-replica consumer name was already closed by T9. (d) RAG streaming, deferred. Verification: knowledge 259 passed/55 skipped (314 with RUN_INTEGRATION=1, real Postgres: atomic acceptance with ownership in the WHERE, barrier with a public OLLAMA_URL over a real knowledge_chunk), mcp-server 260 passed, api-gateway 126 passed, shared/tests 249 passed, document-service 682 passed/196 skipped (196 passed with RUN_INTEGRATION=1); ruff+mypy green on knowledge, mcp-server and api-gateway. Mutations that fall: is_local_llm_provider by name (9 unit + 1 integration), dropping creada_por from the WHERE (2 integration), the entry outside the acceptance transaction (1), verificar_origen without checking the signature (4), idempotency_key not sent (1), X-Forwarded-For not forwarded (1), radicado validation disabled (3), audit.append without correlation (1) and an UPDATE expedientes SET nivel_seguridad in archive (1). Against the live stack: a radicado confirmed through POST /mcp/proposals/execute is recorded with canal=mcp and correlacion.{request_id, idempotency_key=proposal jti}; a direct call, and one with a forged X-Origen-Canal, are api; the per-IP bucket of the assistant's internal calls is the client IP's (X-Forwarded-For: 203.0.113.50 at the edge), not the container's; POST /knowledge/suggestions/{id}/aceptar: someone else's 404, own 200, repeated 409, with its entry. Impact: apply knowledge migration 012 (python migrations/runner.py) and deploy knowledge, api-gateway and mcp-server; channel mcp only shows up in services rebuilt with the new shared library (document-service already is; archive, workflow, signature, notification and the rest will pick it up on their next rebuild, no migration). In production IDENTITY_SIGNING_SECRET becomes mandatory for mcp-server too. Entries with channel mcp now carry payload.correlacion. Pending: the invented-radicado check could not be exercised against the real model (the local stack has no ANTHROPIC_API_KEY); it is covered by tests with a simulated model.
  • fix(shared,mcp,gateway,knowledge,infra)!: the mcp-server, the most exposed service, held the MASTER identity secret; and the RAG acceptance entry did not fix WHAT was accepted (closure of audit findings of batch T10, AREA-11-04/08 and AREA-11-11). Why: (1) IDENTITY_SIGNING_SECRET signs X-User-Id/X-Tenant-Slug and every service trusts that signature; giving it to the mcp-server (which runs the LLM loop, talks to an external provider and serves external MCP clients) meant that compromising it was enough to impersonate any user of any entity by calling an internal service directly. (2) kb_suggestions is mutable and outside the hash chain: the rag.aceptada entry only carried aceptada_en, so the immutable record could not later prove that the accepted text is the one now in the row. What changes: dedicated secret MCP_ORIGIN_SECRET, only on api-gateway and mcp-server, and IDENTITY_SIGNING_SECRET removed from mcp-server in both compose files (mandatory in production; infra/.env.example). identidad.firmar_origen/verificar_origen use it; the gateway also signs X-Real-Ip into X-Real-Ip-Signature (firmar_ip_real, bound to IP + person + entity) and ip_real_firmada verifies that signature instead of requiring the full identity. With only MCP_ORIGIN_SECRET one can mark the channel and verify the IP, but not sign an identity. rag.aceptada now carries sha256_texto (of the suggested answer), proveedor, modelo and fuentes (the cited source_refs, without their content: Ley 1712), computed from the row locked with get_for_update in the same transaction. Not done and why: OMI-04 (per-entity knowledge.habilitado opt-in) stays open: implementing it changes visible behaviour (with default false it switches ingestion off for current entities) and the user's decision on that default is missing; meanwhile the promise is withdrawn from the docs and ingestion is global. When implemented: the consumer checks it before embedding and /search, /antecedentes and /rag answer a neutral 503, with a mutation that skips the check. Verification: knowledge 262 passed/56 skipped (318 with RUN_INTEGRATION=1, real Postgres), mcp-server 264, api-gateway 128, shared/tests 255; ruff+mypy green on knowledge, mcp-server and api-gateway. Mutations that fall: _secreto_origen reading the master secret (1), ip_real_firmada without comparing the signature (2), IP signature not bound to person/entity (1), the gateway without firmar_ip_real (1), mcp-server with the master secret as origin secret (6), entry without _que_se_acepto (2) and a fixed-text hash (2). Local stack: mcp-server starts without IDENTITY_SIGNING_SECRET and with MCP_ORIGIN_SECRET. Not verified against the live stack: the assistant's per-IP bucket end to end (needs a real Keycloak token); covered by the gateway and mcp-server tests. Impact: set MCP_ORIGIN_SECRET in the environment of api-gateway and mcp-server before deploying (production compose aborts if missing; dev has a default) and rebuild both. Without it the channel falls back to api and the IP is not forwarded (safe side). New rag.aceptada entries carry the extra fields; earlier ones are not rewritten.
  • fix(workflow,signature,notification,knowledge,shared,gateway)!: shared failed-event queue keyed by event_id alone silently dropped the second consumer's parking; knowledge could not inspect or replay its queue; fixed consumer names; orphaned dead-letters without a cause; false X-Truncated; DLQ size invisible (audit batch T9, AREA-10-06/07/08/09/10, 13-12). evento_dead_letter is one physical table for four consumers: primary key is now (origin_group, event_id) (identical idempotent migration in all four services, any order; ON CONFLICT (origin_group, event_id) in the four repositories); knowledge gains /api/v1/knowledge/admin/deadletter (list, detail, atomic replay via apply_event, discard; PERM_DLQ_ADMIN, writes at level 3, no-read-up, neutral 404) routed by the gateway; events.consumer_name() per replica; record_failure keeps the last error so orphaned dead-letters carry a cause; reclaim_stale(status_name=…) publishes the last sweep and /health/ready shows checks.dead_letter plus DLQ length (informational, never the 503); listings ask for cap + 1 rows so X-Truncated only fires beyond the cap. Verified: workflow 355/97 (452 with RUN_INTEGRATION=1), signature 503/13 (514), notification 228/19 (247), knowledge 223/43 (266), gateway 112, shared/tests 226, lint green; 13 mutations that fail tests; verified live on the local stack. Not done: clearance in the listing WHERE + keyset pagination, Prometheus/alerts. Audit remediation (T9): the knowledge replay no longer applies the archived envelope blindly: before apply_event it anchors reclassified to the LIVE radicados level (and created to max(event, live)); with no live radicado it answers 409 stale_event and the event stays pending (a parked reclassified(1) can no longer lower a chunk that went back to level 3). Knowledge 233/48 (281 with RUN_INTEGRATION=1), lint green; removing the anchor fails 4 tests.
  • fix(signature,auth,frontend)!: the second factor and the credential were checked against the bearer's owner while the signature was attributed to X-User-Id; whoever had a TOTP could drop to email on level ≥ 2 acts; the client chose the fingerprint of radicado/expediente chains; one secret served every entity and two uses; and no native signature from /sign-xml ever verified (T8 of the 2026-10-02 audit: AREA-04-05, -02, -09, -11, -12, -06, -10, -13, -08 and AREA-05-02, -07). Why: the OTP went to the bearer's mailbox, the TOTP was checked against the bearer's device and the personal credential was the bearer's key, while the signature was attributed to X-User-Id; the old comment called it "fail-safe" and it was the opposite — reachable from a compromised internal service, not from outside. An actor with an enrolled TOTP could sign level ≥ 2 acts with the email OTP. The client chose payload_sha256 for radicado/expediente. One signature_secret served the signature HMAC and the OTP HMAC for every entity, with a message naming neither entity nor object. A downed credentials channel degraded to native silently with the gate off. The HTTP call to auth-service ran under the turn's row lock. /verify was documented "public" and any entity user could tell 404 from 200 on reserved acts. Revocation was judged against the current state, not the signature's instant; the timestamp was not cross-checked with the signing certificate or the ESSCertID; acreditado was a literal false on verification. Found while testing live: /sign-xml on the native path persisted a different now() from the instant inside the HMAC, so no native signature from that path ever verified. What changes: signature — get_signer_identity (replaces get_signer_email; sends X-Tenant-Slug to /me and returns user_id) compared with the actor BEFORE creating the challenge: 403 identidad_incoherente + a firma.identidad_incoherente entry (also on the TOTP, personal-credential and /sign-personal paths). Level ≥ 2 with an active TOTP → 409 totp_requerido (fail-closed 503). radicado/expediente chains ignore the client's payload_sha256 and /sign rejects payload_xml where the chain fixes no fingerprint (422 payload_xml_no_admitido). HKDF keys per (purpose, entity), a message binding entity+type+object, an OTP HMAC binding challenge_id, migration 020_key_version_hkdf.sql (key_version, DEFAULT 0 = the previous scheme, which keeps verifying). The personal credential's state is resolved before the transaction; firma.fallback_nativa_canal_caido entry and a startup warning with the gate off. /verify and GET /firmas/{id} apply no-read-up inside the WHERE; acreditado derives from the persisted nivel_conformidad and only if the cryptography validates; a revocation AFTER the signature does not invalidate it (revocacion_posterior); the timestamp's genTime must fall in the signer certificate's validity and the ESSCertID must match (local TSA now emits it; absence is only rejected with TSA_ESSCERTID_OBLIGATORIO=true). auth — GET /internal/totp/status, user_id on the valid /internal/totp/verify verdict and on /internal/signing-key/status; totp_requerido_por_nivel removed. frontend — the assistant asks for the authenticator when the email challenge answers totp_requerido. docs — api.md ES/EN (with the payload_sha256 warning, guarded in shared/tests/), ADR-016, security audit, roadmap. Verification: signature-service 480 passed/10 skipped (422/4 before) and 8 passed with RUN_INTEGRATION=1 (real Postgres: migration 020, DEFAULT 0/CHECK and the no-read-up gate against real radicados/expedientes; it caught a real defect); auth-service 287/43; shared/tests guards 213 passed; ruff+mypy green; frontend SignAssistant.test.js 13 passed. Mutations that fail the tests (restored and rebuilt) cover every item. Live on the local stack (demo tenant, operador/jefe/consulta tokens): a native /sign-xml signature verifies (impossible before), as does a key_version=0 row with the literal previous HMAC; consulta (clearance 1) gets a 404 identical to a non-existent UUID for a level-3 radicado's signature; a new XAdES-T verifies; level-2 challenge/sign with an active TOTP → 409 totp_requerido; the operator's bearer with jefe's X-User-Id → 403 identidad_incoherente, no challenge, with an audit entry. Impact: deploy auth-service BEFORE signature-service (without user_id in its answers signature fails closed); apply migration 020 per tenant with signature-service's runner; rebuild signature-service, auth-service and frontend. Pending: enabling FIRMA_PERSONAL_REQUIRE_XADES=clasificados (the entity's decision, requires enrolling certificates), an external HSM/KMS, TSA_ESSCERTID_OBLIGATORIO=true once the dev corpus is re-stamped, and X-Internal-Token on /internal/totp/verify.
  • fix(signature,auth,frontend): closing the auditors' findings on T8 (AREA-04-13): a "later" revocation was measured against the server's own clock, and the revocation reason was free text Why: /verify kept valida=true when the key was revoked after the row's sello_tiempo, which the server writes and is not a trusted date (RFC 3161); and it did not tell a key compromise (which casts doubt on earlier signatures too, ETSI EN 319 102-1) from a staff departure. What changes: auth — motivo_revocacion is a closed vocabulary (RFC 5280: key_compromise, superseded, cessation_of_operation, affiliation_changed, privilege_withdrawn, unspecified): 422 outside it on self- and admin-revoke, tenant migration 019_motivo_revocacion_vocabulario.sql (earlier free text becomes unspecified, the original stays in the signing_key.revocada audit entry; idempotent CHECK), and GET /internal/signing-key/revocation-status also returns motivo_revocacion. signature — /verify takes ONLY a verified TSA's genTime as the signature instant: key_compromise → valida=false, motivo=clave_comprometida; no verified TSA → valida=false, motivo=instante_firma_no_cierto, revocacion_posterior=null; with a TSA and a non-compromise reason it keeps valida=true + revocacion_posterior=true. frontend — the reason is a <select> with the vocabulary (profile and /admin/seguridad/claves); the proxies drop anything outside it. Impact: deploy auth-service (and apply 019 per tenant) BEFORE signature-service and the frontend; clients sending free text in motivo get 422. Declared out: AREA-04-09 (closing the radicado/expediente hash) needs document- and archive-service to expose the radicado PDF and current index SHA-256 over internal HTTP — a three-service increment; meanwhile the huella_de_contenido=false audit entry keeps declaring that the identifier is what gets sealed.
  • fix(tenant,infra,auth)!: tenant-service connected as the owner (superuser) although the gateway routes to it; creating and suspending institutions left no audit entry; renaming a department with live cases orphaned the inbox; and parameter changes did not record the previous value (OMI-02, AREA-03-04, AREA-10-02, AREA-10-13, AREA-10-14; batch T7) Why: migration 008 excused tenant-service as "not routed by the gateway" — false: /dependencias, /catalogos, /config, /business-days, /pinar reach it, and as owner the append-only guard of audit_log did not bind it. Tenant create/patch ran in autocommit with no audit entry; PATCH /dependencias/{id} ignored flow_steps/workflow_rules, which match by NAME; PUT /config/params/{key} recorded only the new value. What changes: tenant-service uses the application role (dev and prod; the owner DSN is only for the migration runner). POST /tenants no longer runs DDL: it registers the row as provisioning_status='pending' and scripts/init_tenant.py (owner) creates the schema and activates it. Create/PATCH /tenants/{id} run in one transaction with a tenant.created/tenant.updated audit entry (before/after, fail-closed). PATCH /dependencias/{id} returns 409 dependencia_con_tramite_en_curso when renaming/deactivating with live steps or rules. PUT /config/params/{key} records antes/despues. New guard test derives gateway-routed services from proxy.py and requires the application role in both compose files. Impact: rebuild tenant-service; production now requires DB_APP_USER/DB_APP_PASSWORD for it. Institutions are provisioned only via scripts/init_tenant.py.

Audit closure (T7): DELETE /dependencias/{id} now applies the same 409 dependencia_con_tramite_en_curso as PATCH (row locked FOR UPDATE and count in the same transaction), and the dependencia.actualizada/dependencia.eliminada audit entries store the previous name and state (antes/despues). Name-based correlation (instead of code/id) remains declared design debt needing an ADR. - fix(archive,document,mcp,frontend)!: a single person could propose, endorse and execute the destruction of a batch; linking a radicado sealed into the signed index whatever the linker typed; and destruction could run without the record's lock (T6 of the 2026-10-02 audit: AREA-06-01, 06-01-B, 06-01-C, 06-02, 07-02, 06-08 partial, 02-08, 07-20) What changes: avalar/ejecutar require crud>=4; separation of duties (creado_por <> $5 in the transition's WHERE; executor ≠ endorser checked before the first expediente is destroyed; 409 separacion_de_funciones); publish/endorse/withdraw/mark-executed are one act with their audit entry; the public batch GET answers the same 404 for an unknown slug and an unknown batch; the elimination record's WORM schedules its retry (new run_once_actas_eliminacion_worm sweep) and ejecutar requires it (409 acta_sin_worm); linking resolves number, typology, producing dependency, format, size and hash server-side and cross-checks what was declared (404/422 metadato_no_coincide/503 fail-closed); physical-archive performed_by and index generado_por store the X-User-Id UUID, not the login; document-service's internal metadata returns tracking_number and doc_class; MCP and /admin/eliminacion mirror level 4. No migrations. Impact: deploy document-service BEFORE archive-service; grant level 4 on USUA_PERM_TRD to the Committee profile (at least two people) in real tenants; with PRESERVATION_ENABLED=false the record is never locked and ejecutar answers 409. Declared out: AREA-01-13 (loan prestado_a as UUID + clearance gate) and the retype-propagation half of AREA-06-08. T6 review follow-up: (a) linking honors no-read-up: resolution uses the CALLER's clearance and a radicado above it answers like a missing one (404, no desacuerdos), so typology/format/producer/hash of a reserved radicado can no longer be enumerated from its UUID; (b) a radicado without doc_class is not linked (422 radicado_sin_tipificar; the index XSD requires <tipologia>); (c) AREA-01-13 partial: POST /unidades/{id}/prestamos applies the lender's no-read-up in the INSERT … SELECT's WHERE (403 clearance_insuficiente, nothing written). Still open: prestado_a_user_id UUID + min_crud=3 (contract change, needs archival-compliance-auditor validation) and propagating a later re-typing (document.radicado.retyped).

  • fix(storage,document,notification): the orphan purge could delete an object another operation had just received through deduplication, any PERM_RADI could read and reprocess someone else's batch, and registering a draft did not require holding it (closing the two auditors' findings on the purge/batch/tenure/webhook round) Why: (1) upload deduplicates by SHA-256 and returns the same file_id; purge_orphan only saw COMMITTED references, so it deleted an object another import/upload/email had just received and not yet committed. (2) GET /status returned every item's subject —a batch radicado reclassified to reserved afterwards was readable with PERM_RADI:1— and any PERM_RADI saw/reprocessed other people's batches. (3) radicar (irreversible) did not require the tenure that editing does (AGN Agreement 060/2001). (4) reprocessing cleared the failure reason without leaving it in the audit entry, a batch radicado was indistinguishable from a hand-registered one after the 90-day retention, and the automatic resumption attributed the act to the user. (5) a webhook secret over 255 characters came back in clear in pydantic's 422 (input). What changes: storage — migration 017 (files.dedup_hit_at); upload's dedup branch does UPDATE ... RETURNING (takes the row lock, serialises with the purge's FOR UPDATE; if the purge wins, the upload stores a new object) and purge_orphan returns referenciado when the mark exists; referencias() fails closed when a listed table is missing (table(ausente)); the docstring that said "Object-Lock rejects it" is fixed (the bucket is versioned: a remove_object without versionId only creates a delete marker; what protects is preservacion_worm_objeto); guardian shared/tests/test_referencias_de_archivo_cubren_todo_file_id.py DERIVES the *file_id* UUID columns from every service's tenant migrations and compares them with REFERENCIAS_DE_ARCHIVO (parser positive control and anti-vacuity included). document — GET /batch/documents/{id}/status and POST .../reprocesar: only the batch's creator or PERM_RADI level 3, in the WHERE, neutral 404 lote_no_encontrado (in reprocess, BEFORE the lock so there is no 409 oracle); subject carries no-read-up in the SQL (LEFT JOIN radicados + CASE); lote_reprocesado keeps error_message (<=500) and intentos per item; a batch radicado_created carries origen:"lote", job_id, item_index; automatic resumption writes under the system actor, canal="system" and en_nombre_de; registering a draft requires the same tenure as editing (neutral 404; aprobar/devolver still do not); the PATCH UPDATE carries estado <> 'radicado' (409 if it was registered in between); an orphan binary going to agotado writes document.import_binario_agotado in the same transaction. notification — a global RequestValidationError handler strips input/ctx from the 422. Frontend contract (batches): the 404 of GET /status and POST /reprocesar is now {"detail": {"error": "lote_no_encontrado", "detail": "...", "status": 404}} (the GET's detail used to be a string); the GET requires X-User-Id; another person's batch returns 404 even if it exists; items[].subject may be null (radicado above the caller's level) and each item carries nivel_seguridad (int | null, null when there is no radicado or it is out of reach); the rest of the contract is unchanged. Verification: storage 413 passed/57 skipped + 457 passed/13 skipped with RUN_INTEGRATION=1 (real Postgres and two connections for the dedup↔purge race); notification 224/16 + 240 passed; document 679/195 + 874 passed; lint (ruff+mypy) green in all three; the new shared/tests guardian is green. Mutations that fail tests: removing the dedup mark in upload (2), ignoring it in the purge (1), continue on a missing table (1), removing the creator predicate (1), removing the subject gate (1), removing the pre-lock check (1), not writing error_message/origen/system actor/agotado (1 each), radicar without tenure (2), removing estado <> 'radicado' (1), removing the 422 handler (1) and removing a column from REFERENCIAS_DE_ARCHIVO (the guardian). Impact: apply storage migration 017 per tenant and rebuild storage-service, document-service and notification-service. Whoever reprocessed or looked at other people's batches with PERM_RADI now gets 404; whoever registered other people's drafts gets 404.

  • fix(document,storage): editing a draft now requires holding the radicado, a failed import now purges its orphan binaries, and radicado batches resume on their own and can be reprocessed without renumbering (user decisions of 2026-10-02: AREA-01-N2 follow-up, AREA-09-14, AREA-09-15) Why: (1) any PERM_RADI could edit any non-reserved draft in the tenant. (2) binaries uploaded by a rolled-back import were only recorded and stayed in the store forever. (3) a radicado batch ran in an asyncio.create_task: restarting the service left it pending/processing forever and a failed item could not be retried. What changes: (1) PATCH /borradores/{id} requires HOLDING the radicado (ADR-028 tenure replicated in SQL inside the UPDATE's WHERE: active step assigned to the actor, or unassigned and in their dependencia; with no active step, whoever held the last completed one; with no step at all, whoever registered the parent; with no parent, the creator); anyone else gets the same neutral 404 and the row is untouched. aprobar and devolver do NOT require it (review acts; approval is usually someone else's and is bound to the signature circuit). (2) storage-service POST /internal/purgar-huerfanos (X-Internal-Token): deletes only what that import created, with no reference in the tenant (attachments of any radicado, emails, drafts, migrations, WORM objects) and if the store accepts it, row+object+file.purgado_huerfano entry in one transaction. document-service queues the binaries in import_binario_huerfano (migration 038, atomic with the …_sin_radicado entry), tries to purge at once and, if storage fails, a background sweep retries (20 attempts → agotado); a confirmed purge writes document.import_binarios_purgados. (3) migration 039 + sweep: interrupted batches resume under a per-batch advisory lock (several replicas), each item is idempotent (FOR UPDATE claim; the consecutive number and the radicado share a transaction: no gaps, no double numbering); failed items stay failed with error_message, error_tipo and intentos; the batch ends completado_con_errores; GET .../status adds errores[], pending_items, reprocesable; new POST /batch/documents/{job_id}/reprocesar (same permission as creating, document.lote_reprocesado entry); retention of completed batches without errors (90 days). Batch status becomes VARCHAR(40). Verification: document-service unit 676 passed/186 skipped + integration (real Postgres) 186 passed; storage-service below; lint (ruff+mypy) green in both. Mutations that fail tests: removing the tenure gate, each predicate branch (last step, completed, active URD, creator), not passing the actor; removing the reference check, the «pre-existing» check, each reference source, the transaction; treating error as terminal, not queueing, not purging after rollback, not retrying; removing the item claim, the lock, the recount, the completado_con_errores state, the reprocess filter, the retention, the resumption and the endpoint permission. Impact: apply migrations 038 and 039 per tenant and rebuild document-service and storage-service. Whoever edited other people's drafts with PERM_RADI now gets 404. A batch that used to end failed because of failed items now ends completado_con_errores (the UI must handle it; the contract is in docs/en/api.md). Declared out: the race of two concurrent imports of the SAME binary where one fails and purges before the other commits (the reference check and subido_desde narrow it, they do not close it); the frontend batch screen does not yet show errors or the «Process the missing ones» button. Frontend: /admin/interoperabilidad ("Carga masiva de documentos" tab) shows "Terminó con errores" with the count of unregistered documents and a table of the failed ones (subject, reason in Spanish, technical text as secondary detail, attempts); when the backend says reprocesable and the user holds PERM_RADI, it offers "Procesar los faltantes" (process the remaining ones) with a confirmation stating how many will be processed and that already-registered documents keep their number, calls POST .../reprocesar through an allowlisted SSR proxy (D-05), polls again until it finishes and maps the 404/409 errors (lote_no_encontrado, lote_en_proceso, nada_que_reprocesar) to Spanish messages. Tests: proxy, utilities and screen; the mutation removing the PERM_RADI check fails a test.

  • fix(notification,auth,shared,infra)!: the webhook HMAC secret was stored in clear: anyone reading the database, a backup or an injection could forge deliveries the receiver would accept; it is now encrypted at rest with a versioned KEK (debt declared by the T5 closure of the 2026-10-02 audit) Why: webhook_subscription.secret was clear TEXT. A hash would not work: the service needs the original secret to sign each delivery (HMAC-SHA256). What changes: the same cryptographic scheme as auth-service's personal signing key custody, now in orpycamcp_common.kek (AES-256-GCM, AAD tenant:{slug}:webhook:{id}:secret, fresh nonce per encryption, versioned keyring v1 + WEBHOOK_SECRET_KEKS); auth-service delegates to it without changing a byte of what is already stored. Tenant migration 009 (secret_cifrado, secret_nonce, secret_kek_version, three CHECKs; chk_webhook_secret_sin_claro forbids writing a new clear secret). Creating a webhook encrypts (the id is minted by the application because it is part of the AAD; with no usable KEK it answers 503 webhook_secret_kek_unavailable); delivery decrypts in memory only to sign and fails closed: a row still in clear, or a ciphertext that does not decrypt, is neither delivered nor signed. No GET/list returns the secret, and neither logs nor audit entries carry it. WEBHOOK_SECRET_KEK is mandatory (the service does not start without it; in production, ${…:?}). Operations: python -m app.ops.cifrar_webhook_secrets (encrypts legacy rows, sets secret to NULL in the same UPDATE and writes the notification.webhook.secret_cifrado entry from the application; idempotent) and python -m app.ops.rewrap_webhook_secrets (KEK rotation, --verify per version). Verification: notification-service 223 passed/16 skipped (+ 16 integration against real Postgres); auth-service 280 passed/42 skipped + 46 integration; shared/tests 191 passed (the 3 reds belong to other batches); lint green on both. Mutations that fail tests: dropping the AAD (5), identity "encryption" (8), writing the clear value in the INSERT (5), returning the secret in GET (2), delivering clear rows (2), not nulling secret on encryption (1), AAD without the webhook id (2). Impact: define WEBHOOK_SECRET_KEK (openssl rand -base64 32) before rebuilding notification-service; apply migrations and run cifrar_webhook_secrets BEFORE up -d (see docs/es/deployment.md). Back the KEK up with the same discipline as the personal signing KEK: if it is lost, every webhook with a secret must be registered again. Declared out of scope: the clear secret column still exists (always NULL); dropping it is a later migration, once no deployment keeps unencrypted rows.

  • fix(document): corregir dest_dept por PATCH dejaba el código de otra dependencia, y el radicado aparecía en ?ubicacion_dept_code= donde ya no estaba (ADR-029, auditoría) Por qué: el PATCH cambiaba el nombre y no el código; la ubicación generada mezclaba ambos. Ahora el código se resuelve por nombre en el mismo UPDATE (único activo, si no NULL) y el PATCH de dest_dept sobre un radicado closed responde 409 dest_dept_immutable.

  • fix(notification,workflow,frontend)!: a webhook leaked the reservation cause and legal basis, the route sheet of an already-purged reserved radicado became public, and the review chain ignored the radicado's level (batch T5 of the 2026-10-02 audit: OMI-01, OMI-03, AREA-10-11, AREA-01-N1, AREA-01-06, AREA-10-03, AREA-01-07, AREA-02-06) The reclassification event was sent whole because the trimming looked for nivel_seguridad while the producer sends nivel_nuevo/nivel_anterior; workflow events now declare the level and an untyped document.*/workflow.* event goes out minimal. Webhook create/delete need level 3 and write an audit entry with the actor; delivery pins the validated IP (DNS-rebinding TOCTOU closed). The review chain (GET, decidir) enforces no-read-up in the query; orphaned flow_steps/flow_events default to level 3. See docs/es/changelog.md for the full entry. Rebuild notification-service, workflow-service and frontend.

  • fix(document,mcp)!: a failed radicado burned a number with no audit entry, import could take a whole year's registration down with another entity's number, and dispatching a shipment or marking it "delivered" left no audit entry and only needed read level (T4 of the 2026-10-02 audit: AREA-09-01, AREA-03-08 item 4, AREA-09-14, AREA-09-11, AREA-09-13, AREA-09-03, AREA-09-09, AREA-09-15 partial)
  • fix(document): revisión de T4: un cliente que goteaba el cuerpo de POST /import retenía el cupo de todos los tenants, un número propio en 999999 dejaba el siguiente radicado nativo en 7 dígitos, y los binarios de un import revertido quedaban sin asiento (cuerpo con plazo de 300 s → 408; techo propio 999998 → 422; asiento document.import_binarios_sin_radicado; purga de huérfanos queda como deuda) Why: the sequence was taken in autocommit before the radicado transaction (a non-existent tercero_id rolled back the radicado and its entry but not the number); import advanced tracking_sequences from the package number without checking the entity or bounding it (2026-OTRA-E-2147483647 left last_seq at INT_MAX and broke native registration for the year); manual shipment transitions and dispatch wrote no audit entry, needed only read level and the UPDATE did not compare the previous state (last writer overwrote a terminal state); import/export built the whole ZIP in memory and hashed on the event loop; the batch API bypassed the metadata template; the stub waybill came from the radicado so two shipments of the same radicado collided with 409. What changes: the sequence is taken inside conn.transaction(). import reconciles the counter only for this entity's code and rejects with 422 sequences above 999999 and years outside [1900, current]. Shipments: the dispatch and manual-status POSTs require PERM_RADI_SALIDA level 3, write document.envio_registrado / document.envio_estado (actor = X-User-Id) in the same transaction, and the UPDATE carries previous state and clearance in its WHERE (0 rows becomes 409 transicion_invalida; the operator callback uses the same guard). Waybills derive from a per-shipment uuid4. import/export require USUA_PERM_EXPEDIENTE level 3, run validation/hashing/ZIP building in threads, take a per-process slot (busy becomes 429 operacion_ocupada with Retry-After), cut the body read at 512 MiB and cap export attachments at 512 MiB. The batch validates the active template of the doc_class and stores metadata_template_id, and numbers with the registration-timezone year. MCP catalog: both shipment write tools declare level 3. No migration. Verification: document-service unit 620 plus integration against real Postgres 150 (770 total), mcp-server 236, shared/tests level guardian green, lint clean; every mutation (drop each fix) kills tests. Impact: rebuild document-service and mcp-server. Holders at level 1-2 now get 403; the /envios and interoperability screens do not mirror the level yet. Declared out: deleting orphan blobs of a rolled-back import (storage deduplicates by SHA-256), resuming/purging batch_jobs, async job/streaming for import-export, a dedicated dispatch permission, the gateway /auth/validate cache MAC and the audit-doc fix (T2/T14).

  • fix(document,mcp)!: a level-1 PERM_RADI could read and edit drafts answering reserved radicados, a reserved subject reached the assistant's provider through two listings, and the declarative "signature" stored a client-chosen hash (batch T3 of the 2026-10-02 audit: AREA-11-X1, AREA-11-X2, AREA-01-N2, AREA-05-03-S) Why: GET /terceros/{id}/radicados returned the subject of RESERVED radicados with no clearance gate, and the assistant's residency barrier only strips what declares nivel_seguridad, which the schema lacked. RespuestaItem had the same flaw. borrador had no level at all and editar wrote no audit entry. POST /documents/{id}/signatures stored the body hash, at read level, with no audit_log entry. What changes: the clearance gate goes in the same WHERE (with the merged-records OR parenthesised), items declare nivel_seguridad, and the read is audited. Migration 035_borrador_nivel_seguridad.sql: drafts inherit the parent's level; get/list/update/set_estado filter on the effective level (GREATEST(snapshot, parent's current level)), neutral 404; PATCH writes an atomic borrador.editado entry. Declarative signature: PERM_FIRMA level 2, mandatory anexo_id, server-resolved hash from storage (body content_hash ignored, 503 if storage is down), document.signature_created entry in the same transaction. New guardian shared/tests/test_contenido_declara_su_nivel.py (frozen exception list that can only shrink). Verification: document-service unit 576 plus integration against real Postgres, mcp-server 236, shared/tests 182, lint clean; every mutation (drop each gate, drop the parentheses, drop GREATEST, drop the audit entries, use the body hash, move the audit out of the transaction, drop level 2, drop nivel_seguridad from the schemas) kills tests. Impact: apply migration 035 per tenant and rebuild document-service and mcp-server. Declared out, on purpose: whether to retire the signature POST with 410 (user decision); whether editing a draft requires being its creator; SearchHit/RadicadoMetaItem still lack nivel_seguridad. Audit remediation (T3): (High) radicar created the registry entry at level 1 even when the draft answered a reserved one: it now inherits the CURRENT parent level (GREATEST with the snapshot), its basis/cause/term (a parent without basis is motivated by inheritance: never falls to PUBLIC) and the responde_a link. (Medium) migration 036_signatures_origen.sql: legacy declarative signatures become origen='cliente_no_verificado' and verify excludes them; new ones carry origen='servidor'. (Medium) aprobar (user and circuit-completed internal route) and devolver record borrador.aprobado / borrador.devuelto in the same transaction as the state change. Verification: document-service 744 (RUN_INTEGRATION=1), lint clean; mutations that break tests: dropping level inheritance, dropping the origen filter, renaming the audit action. Impact: apply 036 per tenant and rebuild document-service.

  • fix(workflow): closing an overdue procedure recorded the deadline as "unknown" (E05, ADR-028) Why: fuera_de_plazo in the workflow.finalizar audit entry only looked at flow_steps.due_at, which is almost never set. Now the step deadline wins if present, otherwise the radicado's legal due date (radicados.due_date); null only when neither exists. Verification: workflow-service 397 with RUN_INTEGRATION=1; new test with positive and negative control, killed by the mutation that ignores the radicado due date.

Coverage note (2026-08-29). This English changelog carries the most recent entries and the project's milestones, not every entry: the Spanish page (docs/es/changelog.md) has 410 entries against 241 here. The gap is historical —older increments that were never translated— and it is stated here rather than left for the reader to discover: a changelog that looks complete and is not is worse than one that declares its scope. New entries are written in both from now on.

  • feat(workflow,document,mcp): the workflow moves the radicado's status — distributing no longer leaves the radicado in registered (E05, E01, ADR-028) Why: radicado status and step progress were two machines that did not talk to each other (domain decision left open on 2026-10-01). Distributing, transferring and processing advanced flow_steps; radicados.status only changed if someone manually pressed "Distribute"/"In progress" —a PATCH with status— so the dashboard, inbox and search filtered on states the workflow never wrote. In Orfeo distributing is a status change. How: workflow-service writes radicados.status in the same transaction as the step, after mutating it and before the audit entry (if audit.append fails neither step nor status remains), through one pure function (estado_radicado.py::derivar_estado_radicado) whose literal twin is the backfill's CASE: active step pending → distributed, in_progress → in_progress. Assign, transfer, return, roll back, cascade reassign and the automatic distribution on registration all apply it; completing without marking as read leaves distributed (declared). in_progress only via the holder's marcar_leido, which required closing a pre-existing gap: execute_transaction did not check holdership, so anyone with PERM_TRAMITAR could mark someone else's file as read (transaction_types.requires_holder, migration 012; non-holder → the same 404 no_active_step, no oracle). The only close is POST /workflows/{id}/finalizar (PERM_TRAMITAR, holder only, mandatory comentario, tipo_tx=finalizar): not a transaction_type, because that catalogue is tenant-configurable and an irreversible act cannot live there. closed and anulado are terminal: any act that moves the step answers 409 radicado_terminal and rolls back; informar/agendar stay allowed. The cascade puts steps back to pending (Orfeo LEIDO=0), excludes terminals (skipped_terminal), takes locks in order and writes workflow.estado_radicado per changed radicado. After commit workflow.radicado.estado_cambiado is published (best-effort). document-service: the PATCH rejects status with 422 status_managed_by_workflow (dropping the field was not enough: Pydantic would have ignored the extra and the client would believe it changed). MCP: actualizar_radicado loses status; new finalizar_tramite (irreversible) → 207 tools, 75 writes, 27 irreversible. Backfill 013_radicado_estado_desde_flujo.sql with an evidence table radicado_estado_recalculo, no audit_log writes from SQL, idempotent. Verification: workflow-service 308 unit tests (18 integration skipped) and 18 integration tests against real Postgres (RUN_INTEGRATION=1, new tests/integration/ tier: atomicity with a forced audit.append failure, terminals leaving every count unchanged, cascade, backfill run twice —the 2nd, 0 rows— and every row compared with the Python function); mcp-server 228; shared/tests 171; lint.sh clean on the three services. Verified by mutation (7, each one fails tests): removing the sync from _create_step, moving it out of the transaction, removing the terminal guard, removing the holder check, inverting the backfill CASE, not excluding terminals in the cascade and ignoring finalizar's flag. Frontend: /bandeja drops the manual «Distribuir»/«Iniciar trámite» buttons (and their status PATCH, which now returns 422 status_managed_by_workflow). The detail drawer offers «Marcar en trámite» (marcar_leido), «Finalizar trámite» —mandatory 5-500 character comment plus a confirmation checkbox, new proxy POST /bandeja/api/{id}/finalizar— and «Devolver» only to the holder of the active step (assigned_to = me, or unassigned and in my dependencia, read from GET /current), with PERM_TRAMITAR; the backend re-validates. Spanish messages for 409 radicado_terminal, 404 no_active_step and 422 status_managed_by_workflow. After the audit: «Finalizar» additionally requires PERM_TRAMITAR level ≥3 (mirrors min_crud=3, read from permission_levels; not offered at level 2) and asks for a mandatory closing reason (selector: Respondido · No requiere respuesta (NRR) · Traslado por competencia · Otro) that the proxy validates (422 without calling the gateway) and forwards with the comment ({comentario, motivo_cierre}, allowlist). With no active step it is also offered to whoever held the last completed step of a non-terminal radicado (reads GET /history). The Board (WorkflowBoard/WorkflowCard) honours holdership: «En trámite», Trasladar, Trámite, Completar, Revertir and dragging to «En trámite» only for the holder; a colleague sees the card but cannot act on it. New messages for 404 step_not_found and 404 radicado_not_found. After R15–R21: «Asignar a flujo» is offered only with no active step and to whoever held the last completed step or, with no steps at all, whoever registered the radicado (registered_by from the detail compared with the internal user_id, not the sub); with a null registered_by (imported/ingest) or an unloaded history it is not offered (fail-closed; the backend answers 404 no_active_step). «Devolver» and «Revertir» follow the _holder_condition holdership (including a department's ownerless step; 403 not_current_holder no longer exists) and «Revertir» only while the step is pending. Spanish message for 404 no_flow (missing flow or radicado above the access level). Declared out of scope, on purpose: outgoing and internal radicados have no outward distribution; annulment did not cancel the active step (fixed in the remediation, below); rollback only from pending; completing without marking as read leaves distributed; finalizar/devolver on a closed radicado give the neutral 404 (a closed one has no step, and saying "it is closed" to someone who holds nothing would be an oracle); the INT-05 import keeps the package's status. Pending: the frontend ("Finish processing" button, removing manual "Distribute"/"In progress", translating the 409). Impact: apply migrations (012, 013 per tenant) and rebuild workflow-service, document-service and mcp-server. Any client sending status to PATCH /documents/{id} now gets 422. finalizar moves to level 3 (see remediation). With the remediation, also: apply 014/015 per tenant and document-service 034, and rebuild document-service and mcp-server as well; a development tenant that already applied 013 before the remediation must be recreated (013 had not been released and was edited instead of adding a corrective one). Audit remediation (2026-10-02, same ADR-028): after the security and archival-compliance reviews the backend was corrected: (1) assign/transfer are no longer the side door to closing —B could self-assign someone else's radicado (even a reserved one) and "Finalizar" then closed it—: inside the transaction and after the lock, the actor's clearance goes in the WHERE of the radicado read (404 radicado_not_found, same as a nonexistent radicado) and, with an active step, only its holder may distribute or transfer it (404 no_active_step); automatic distribution on registration is a system actor (sistema=True, only auto_distribute); forcibly redistributing someone else's step stays in the admin cascade. (2) The 409 radicado_terminal oracle is closed (the holder gate runs before synchronising; anular becomes requires_holder, migration 014, and any transaction that moves the step requires holding it). (3) finalizar requires PERM_TRAMITAR level 3 (was 1, the read level); it leaves SIN_NIVEL_DECLARADAS and the MCP catalog mirrors it. (4) complete_step uses the same holder definition (before, assigned_to IS NULL was enough to close another dependencia's ownerless step, and a closed step could be "re-closed"). (5) MCP finalizar_tramite is confirmed by typing the radicado (confirmar_con), not CONFIRMO. (6) The cascade takes one advisory scope lock (shared in every act, exclusive in the cascade) plus ordered row locks, instead of one per radicado, which exhausted max_locks_per_transaction. (7) Annulment cancels the active step in the same transaction (document-service, flow_events.tipo_tx='anulacion'); migration 015_sanear_pasos_zombi.sql cleans up existing ones with an insert-only evidence table (flow_step_saneamiento), and inbox/pending/overdue exclude terminal radicados. (8) INT-05 import rejects packages carrying anulado (closed enum in RADICADO_SCHEMA), records the status_origen in the audit entry, and migration 034_radicado_status_check.sql (document-service) closes the domain of radicados.status with a CHECK (NOT VALID + validation if the data allows). (9) The 013 backfill leaves insert-only evidence (tracking_number, paso_activo, origen), no longer touches updated_at, and the migration runner writes the workflow.estado_radicado.backfill anchor entry (count + SHA-256) to audit_log, once per tenant. (10-12) finalizar requires motivo_cierre (respondido|nrr|traslado_competencia|otro), its audit entry adds step_id, flow_event_id, tiene_respuesta, fuera_de_plazo and en_expediente, and it is allowed without an active step to whoever held the last completed step. (13) _cambios is emptied at the start of every act (the xfail test turned green). Pre-existing defect found while testing against real Postgres: AnulacionRepository.decide conditioned on estado = 'pendiente', a value the CHECK never admits, so no annulment approval could ever be applied (always 409 anulacion_ya_decidida); mocks hid it. Fixed to 'solicitada'.

Security re-audit (2026-10-02, same ADR-028; R15-R21 and RN-8): (A) finalizar, complete_step, marcar_leido, rollback, devolver and every holder-gated transaction check the holder's clearance (level in the WHERE; a level-1 member of the dependencia can no longer close a reserved radicado distributed to it with no owner); same neutral 404. (B) A radicado with no active step is no longer takeable by anyone with PERM_TRAMITAR: with history, whoever held the last step distributes it; with no step at all, whoever registered it (registered_by); a closed one gives 404 no_active_step on distribution (was 409). (C/H) Transactions without holder-gating (vobo, informar, nrr, agendar, solicitar_firma…) apply clearance and return 404 no_flow, the same as a nonexistent radicado or one with no steps: they no longer leak a reserved radicado's tracking_number/to_dept nor tell "exists" from "does not exist". (D/E) rollback/devolver read the step and the holder check inside the transaction after the lock and apply_* repeat the condition; both use the same holder definition (a dependencia member can now revert/return an ownerless step). (F) MCP finalizar_tramite declares motivo_cierre (mandatory, enum): every call used to give 422. (G) The race between two annulment decisions gives 409 anulacion_ya_decidida, not 500. RN-8 the auto_distribute guard is repeated inside the lock. Declared, not fixed: annulment may annul a closed radicado; USUA_PERM_ADMIN at min_crud=1 for the cascade; TRUNCATE not covered by the evidence triggers. Remediation verification: workflow-service 376 tests with RUN_INTEGRATION=1 (322 unit + 54 integration, 0 xfail), document-service 695 with integration (569 unit), mcp-server 230, shared/tests 171, lint.sh clean on all three. Mutations (each fails tests): remove assign's holder gate, remove the clearance from the WHERE, finalizar back to level 1 (the shared guard also fails), complete_step with the old definition, cascade with a lock per radicado, remove step cancellation from annulment, remove the enum from the import schema, remove the CHECK, and revert to 'pendiente'. Contracts that change for the frontend: POST /workflows/{id}/finalizar requires motivo_cierre (respondido|nrr|traslado_competencia|otro) besides comentario, and PERM_TRAMITAR level 3 (403 below it); assign/transfer answer 404 no_active_step to a non-holder of the active step and 404 radicado_not_found if the radicado is out of reach; PATCH /{step_id}/complete answers 404 step_not_found for someone else's step or an already closed one; POST /{id}/transactions with anular/marcar_leido (or any type that moves the step) answers 404 no_active_step to a non-holder, also on terminal radicados (no longer 409); inbox/pending no longer list steps of terminal radicados. Declared out of this batch: the "reopen" act (MoReq: controlled reopening with admin permission); outgoing/internal radicados without distribution; completing an in_progress step leaves the radicado in_progress with no active step (kept); a radicado with history and no active step may be distributed by anyone with PERM_TRAMITAR and clearance (R1 residual).

  • fix(frontend,mcp): the dashboard told someone with 101 pending steps they were "all caught up", alerts hid 127 overdue deadlines, and the assistant could not be opened (E22, E18, RF-RAD-04, RF-UI-06) — Three defects no suite could see, found by walking the app in a browser against the running stack. 1. The dashboard's "Pending" and "Alerts" modules filtered radicados in distributed/in_progress, but the workflow advances the steps, not the radicado, whose status only changes through manual buttons in the inbox List view: all 208 radicados were registered, the board had 101 pending steps, and 127 response deadlines were past with "no upcoming deadlines" on screen. Pending items now come from GET /workflows/inbox — the same query as the /bandeja board — and alerts include registered, since a response term runs from registration. The tests had enshrined the defect by building radicados in distributed; mutation-verified (8 fail with the old filter). 2. A scaffold-era global rule .assistant-dock { transform: translateX(100%) } pushed the panel off-screen (x=1901 in a 1916 viewport) while the backdrop dimmed the page, and the e2e test passed because Playwright's toBeVisible() accepts an element outside the viewport. It now asserts toBeInViewport, and a guard test forbids global sheets from styling the panel's root class. 3. With no model configured the panel promised the feature "will be enabled soon"; the backend now says which cause applies (no model configured vs. provider down), the proxy forwards it, and the panel shows it. Left open, as a domain decision rather than a fix: radicado status and workflow progress are two machines that do not talk to each other.

  • docs(api,architecture,roadmap): the tool catalogue grew fourfold and the reference stayed on the old snapshot (E18, ADR-019, ADR-020) — Why: both API references declared "45 tools, 16 writes" and a coverage of "367 operations, 41 covered". The real catalogue is 206 tools, 74 writes, covering 203 of the 342 exposed operations (59 %). The figure was not lost for lack of recording — the changelog logged every increment: 106, 112, 146, 157, 170, 201 — it was a hand-written number sitting next to one that moves, and it stayed that way for eight months. Three statements went with it from incomplete to false: that the assistant is "read-only" and excludes radicar_documento (it now offers all 206, and no write is dispatched: a signed proposal is returned for the person to confirm), that the acts left out are "annulling, transferring, repealing TRD/TVD…" (all of them exist today as tools), and the English roadmap's "Deferred: enabling write tools behind double confirmation", closed months ago. How: the 206-row table is generated from services/mcp-server/app/tools/catalog.py, grouped by epic, carrying the permission and CRUD level the catalogue itself declares, and marking the 26 irreversible acts and the 5 that require typing a value off the card. The exclusion list is replaced by the 15 acts the server publishes in mi_alcance, with the four criteria that separate them. In the English page the operation column is not translated, and the reason is stated: it is the label the server returns in the confirmation card, not prose.

  • docs(architecture,getting-started): the twelfth service was missing from the map (ADR-020) — Why: whisper-service has a folder, a Dockerfile, port 8012 and a service in docker-compose.yml, and appeared in neither architecture.md nor api.md nor CLAUDE.md, which still said "11 microservices". Only the changelog, the roadmap and ADR-020 named it — the pages that narrate, not the ones that describe the system. How: added to the port table and the diagram in both languages, with a section of its own covering its three deliberate traits (off by default under the assistant profile and with no transcription stub; no gateway route and no published port; the only service with no database and no per-tenant schema), plus docker compose --profile assistant in getting started.

  • docs(api): two endpoints with no entry — POST /signature/internal/circuitos/{tipo}/{id}/invalidar, which voids the live circuits of a draft whose text changed (best-effort and idempotent: the hard guarantee is the digest comparison at registration; this is what stops someone being left with a tray turn for a document that no longer exists), and GET /api/v1/catalogos, which says which catalogues exist. Verification, without Docker: mechanical code↔docs cross-check on four axes — 397 routes (the 27 residuals checked by hand as grouped notation), 206/206 tools present in ES and EN, 51/51 screens, 27/27 ADRs. And a trap of my own: the first extractor accused 95 endpoints of being undocumented; every one of them was there. The instrument gets checked before the subject.

  • feat(storage,infra)!: MinIO shut down its open-source project; the object store moves to RustFS, and the engine was chosen by tests (E10, E12, ADR-027, AGN Agreement 001/2024 art. 4.3.2.4 P1) — Why: MinIO archived its open-source project and pulled its images from every public registry. Verified with a positive control (alpine, node and keycloak downloaded fine at the same moment, so it was neither the network nor rate limiting): quay.io answers denied and lists zero tags, Docker Hub 404s, the dl.min.io binary returns 410 Gone, and even the digest recorded in the compose file no longer resolves. Without a local cache the whole stack stopped booting. The lesson outlives the incident: a pin protects you from having the version changed, not from having it taken away — a public registry is a distribution channel, not an archive. — How the replacement was chosen: not from a datasheet. A WORM conformance battery against a real S3 store (test_worm_object_lock_real_s3.py) with seven tests covering what the code actually uses (COMPLIANCE, legal hold, bucket Object Lock, versioning, copy_object inheriting the lock, re-sealing a locked key), each with a positive and a negative control. Measured: RustFS 1.0.0 → 7/7, MinIO (baseline) → 7/7, Garage → 0/7. The decisive test uses the root credential, because a delete that fails against root is what separates COMPLIANCE from GOVERNANCE — and under GOVERNANCE the credential that could lift the retention is the very one the writing service holds. — Rejected: SeaweedFS accepted PutObjectRetention in COMPLIANCE with a 200 and still allowed the delete, and its fixes are merged but unreleased; Garage has no Object Lock and structurally cannot; Ceph RGW stops being a Compose service; building MinIO from AGPL source was the recommended bridge while there was data to migrate, dropped once the whole corpus proved to be test data. — Collateral damage repaired, unnoticed until now: infra/backup.sh and infra/restore.sh invoked the minio/mc image, also removed — backup and restore were broken, including mc retention set, which is what reinstates WORM retention on restore. Migrated to aws-cli. And an improvement that came with it: mc retention set only took durations in days, so restoring a retention rounded it up and moved the expiry date on every restore; put-object-retention takes RetainUntilDate, so the term is again the one the institution set once. — Impact: the stack boots and rebuilds from scratch again; WORM conformance goes from a reputation to an executable proof, and stays as the adoption condition for any future engine.

  • fix(storage): the system ordered WORM retention and never looked at it again (E10, ADR-023, ADR-027) — Why: set_object_retention appeared in three files and get_object_retention in none. After writing the retention the service recorded retention_mode='COMPLIANCE' and a PREMIS FIJACION event as ok — it attested a protection it had never verified, on the store's word. With a store that accepts and does not enforce, the transfer record — signed, with its digest — asserts a guarantee that does not exist: a cryptographically impeccable document that is false in its content, which is worse than having no WORM at all. With a perverse twist: the disposition gate blocks legitimate deletions on the strength of that row, while nothing stops an illegitimate deletion in the store — the control works backwards. — How: verificar_retencion_aplicada re-reads the retention just written and aborts if the mode differs, if the date is earlier than requested, or if the store cannot answer at all; invoked fail-closed after protecting the index or the transfer record, after sealing the AIP (before replicating it, since replicating an object whose protection could not be confirmed would propagate the doubt to the second copy), and after renewing the retention. Same pattern as the pre-existing fixity check: when in doubt, do not seal and do not record.

  • refactor(rebrand): full rename orfeomcp → OrpycaMCP / 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

Deprecated

  • workflow,document,tenant: the dependencia name fields as a key (to_dept, from_dept, assign_to_dept, ?dept=, dest_dept on PATCH/drafts) remain as a deprecated alias for one cycle (Deprecation header + Link); the canonical field is *_dept_code (ADR-032).

Added

  • feat(document,workflow,mcp): the list, search and detail now say where the radicado is now, not just who it was addressed to (E05, E01, ADR-029) Why: 2026-DEMO-E-000117 showed the registration dependencia ("Oficina de Tecnología") after being distributed to Talento Humano and returned to Control Interno. dest_dept is a legal snapshot that does not change; where the radicado is could only be rebuilt by walking flow_steps. In Orfeo it is radi_depe_actu/radi_usua_actu: two columns on the radicado itself that every distribution rewrites. What changes: document-service migration 037_radicado_ubicacion.sql (columns flujo_dept_code/flujo_dept/flujo_usuario + two generated columns ubicacion_dept_code/ubicacion_dept, computed as pairs, indexed); workflow-service sincronizar_estado_radicado writes them in the same UPDATE as status (the cascade too), with the new pure function derivar_ubicacion (the active step; failing that, the most recently closed one, not the highest-numbered; terminal radicados freeze; the code only when the name matches one active dependencia) and migration 016_radicado_ubicacion_desde_flujo.sql (backfill, insert-only evidence radicado_ubicacion_recalculo, a workflow.ubicacion_radicado.backfill anchor written by the runner, RAISE if document-service has not migrated yet). GET /documents and GET /search accept ?ubicacion_dept_code= (same condition in rows and COUNT); responses carry flujo_* and ubicacion_*; flujo_usuario only in the detail. Reports: by_ubicacion (no closed or annulled) and the CSV section dependencia becomes dependencia_radicacion (+ ubicacion_actual). INT-01 export carries the location as informational data that import ignores. MCP: ubicacion_dept_code on listar_radicados and buscar_radicados; dest_dept[_code] described as "registration". None of this appears on public surfaces (verification by code, OAI-PMH, CMIS). Verification: workflow-service 441 passed (RUN_INTEGRATION=1; 347 + 94 integration skipped without it) including real-Postgres integration reproducing the real case (register in Tecnología → Talento Humano → return to Control Interno ⇒ Control Interno, dest_dept untouched), rollback, rollback + complete, finish, annulment freezing, cascade, duplicate name → NULL code, atomicity under an audit failure, and a backfill compared row by row with the Python function (2nd run: 0 changes; the trigger rejects UPDATE/DELETE); document-service 816 passed (RUN_INTEGRATION=1; 658 + 158 skipped without it); mcp-server 243; lint clean. Mutations (each one fails tests): remove the flujo_* write, order "last" by step_number, take the filter out of the COUNT, add the location to PublicVerifyResponse. Applied to the live stack: 2026-DEMO-E-000117 ends in Control Interno (code 907). Frontend: /bandeja (list and detail), /busqueda (column and ubicacion_dept_code filter), /reportes (new «Por ubicación actual (en trámite)» panel and the rename to «Por dependencia de radicación»), the main panel («Dónde está el trámite», on by_ubicacion) and the expediente radicado picker now say where the radicado is now, not who it was addressed to. The detail separates «Ubicación actual» (with a named responsible person, never the UUID; «Cerrado en X» for terminal states) from «Dependencia de radicación». Without ubicacion_dept the cell reads «—»: it never falls back to dest_dept, which would be inventing the location. The search proxies accept ubicacion_dept_code only as a positive integer. New helper lib/utils/ubicacionRadicado.js; types in lib/types/api.js. 2,237 frontend tests (19 new), svelte-check 0/0; each mutated separately and red with its test: the list and search cells going back to dest_dept, the «Cerrado en» block, the «Dependencia de radicación» block, the filter removed from each proxy and from the load, the reports panel and the dashboard card reading by_dependencia, the dest_dept fallback in the helper and the expediente picker column. UX review (2026-10-02): with no flow steps the detail now says so («Sin repartir — en X»; an outgoing/internal radicado with no destination reads «en la dependencia productora: X»), and a terminal that never went through the flow reads «Anulado/Cerrado sin pasar por el flujo — en X», instead of «aún en su dependencia de radicación»; «Ubicación actual» is now the first, highlighted row of the detail, ahead of the filing department; the location moves to an «En X» line under the subject in the /bandeja and /busqueda lists at ≤768 px, where the column is hidden; the /busqueda filter moves from a numeric code to a department-catalog selector («name — code»; it filters by code, so a radicado with an ambiguous name —no code— does not appear in it); /bandeja/api/users (requires PERM_RADI) is no longer called without it nor repeated after a failure, and the owner reads «A cargo de un responsable de la dependencia» with no error notice; the main panel says «No disponible» when the summary lacks by_ubicacion instead of «Sin radicados en trámite». Frontend tests with one mutation per major point turned red. Impact: apply 037 (document-service) and then 016 (workflow-service) per tenant; rebuild document-service, workflow-service and mcp-server. The reports CSV no longer has the dependencia section. Declared out of scope, on purpose: flow_steps.to_dept_code.
  • fix(auth): the audit entry for renaming or deleting an RBAC group now keeps the previous value (ADR-010, MoReq/ISO 15489). update_group/delete_group read the row FOR UPDATE inside the same transaction and record {campos, antes, despues} and {name, description}. No migration. auth-service 322 with RUN_INTEGRATION=1, lint clean, mutation verified (3 tests fail). Declared out: min_crud=3 on webhook/template writes and a dedicated audit-read permission (pre-existing; next batch).

  • feat(mcp): archival instruments in the MCP — TRD/TVD circuit, document types, metadata and PINAR (E18, F9) The census's largest block (69 operations, 6% → 55%). The TRD fixes how long each thing is kept and what happens to it at the end: it is the instrument everything else hangs from. 34 new tools: the full version circuit for TRD and TVD —create · new version · approve · return · validate · derogate · register RUSD—, the history, expired dispositions and their decision, expedientes pending review after a table change, document types, metadata elements and templates, and PINAR reads plus progress logging. Friction goes on the VERSION NUMBER. Validating and derogating change which table governs classification from then on, and the irreparable error is not validating —that is the point— but validating the wrong version: it cannot be corrected by another act; a new version must be opened and the whole circuit walked again, with the wrong table governing meanwhile. And the steps that have a way back ask for nothing. The circuit offers devolver to return from approved to draft: putting the irreversible act's friction there would spend it where a remedy exists. PINAR is read and progress-logged; it is not formulated. Formulating a PINAR is planning with the Committee present, not a sequence of inserts. Declared in the frame with its alternative. The shared guard caught two tools from the previous increment pointing at a SEALED endpoint: consultar_aip and eventos_preservacion targeted the storage routes, which require X-Internal-Token — they would have returned 401 to everyone. The right ones are archive-service's mediated routes. 191 mcp-server tests + 5 new friction tests, 17 shared guards, lint green. Why: without the instruments the assistant can process documents but cannot answer under which rules they are kept — the first question an archivist asks — Impact: 146 tools in total; F9 from 4 to 38, with friction placed on the datum whose error cannot be corrected.

  • feat(mcp): preservation in the MCP, and why WORM sealing is NOT offered (E18, F8) Closes the question left open when the confirmation ladder was built: which step does sealing an artefact fall on. The answer is not a step — it is that it is not offered, for a different reason than F7's ejecutar. In F7 the reason was the human guarantee. Here it is prior and simpler: proteger-indice and proteger-artefacto require the file_id of the signed artefact, an identifier only archive-service composes in the expediente's context — which is why the interface does not offer them either. A model would have to make it up, and sealing the wrong file under a ten-year lock cannot be undone: retention cannot be shortened. The card does not protect against that: it would show the person a UUID they have no way to check, and a card showing an uncheckable value is not a control, it is a formality. Not offering the act cannot mean leaving people blind: sealing happens on its own at closing, so what is offered is finding out — which indexes were left unsigned, which artefacts are near their retention expiry — and retrying the index signature. Eight new tools, and two distinctions that avoid spending friction where it is not needed: format migration is NOT irreversible (it creates a derivative and the original stays intact) and declaring the preservation plan is not either (append-only versioning); whereas writing to the PREMIS record is, even though it destroys nothing — the record admits no correction, so what is irreversible is the assertion. And the census had an inflated denominator: /api/v1/storage/* except upload is not routed by the gateway, and the …-internal twins are system triggers sealed with X-Internal-Token. Counting them as pending coverage made a properly closed service look like a gap. F8 goes from 24 operations to 11, and from 0% to 73% — the three missing ones are the three declared as not offered. 191 mcp-server tests (6 new), lint green across the 11 services, mkdocs --strict clean. Why: the question "which step does WORM sealing fall on?" had an answer that was not a step, and answering it with friction would have given a false sense of control over an invented identifier — Impact: 8 preservation tools, two more acts declared in NO_OFRECIDAS, a corrected census denominator, and 112 tools in total.

  • feat(mcp): records disposal in the MCP, and the confirmation ladder it demanded (E18, F7) F7 is the only procedure in the system that ends by destroying archival records: propose → publish → endorse → signed record of destruction → execute. Before exposing it, one question had to be answered that the catalogue had left open: what must be confirmed. The gate had a single step —the fixed phrase CONFIRMO for everything irreversible— and that is not enough, not because of severity but because of object: a fixed phrase can be typed from memory before reading anything, so it confirms "an act", not "this act". There are now three steps and a limit: (1) approve without typing, for the reversible; (2) CONFIRMO, for the irreversible without loss; (3) the object's identifier, taken from the card, for whatever enables a destruction; and (—) do not offer it at all. The third step is the leap: reproducing the batch identifier cannot be anticipated — you must have looked at the card. And what it does NOT do is stated in the code and the docs: it does not turn a model into a person; it raises the cost of carelessness, it does not prevent it. That is why ejecutar is not offered. It is the only path that deletes archival records and its human guarantee cannot depend on a third-party client having asked someone. The four prior phases are offered and the absence is DECLARED in the scope frame with its reason and its alternative path. Friction runs the opposite way to the usual. Withdrawing a batch —the act that STOPS the destruction— is the cheapest in all of F7. Endorsing, which enables it, is the most expensive. If stopping cost the same as proceeding, friction would push forward. And the card summarises instead of dumping. Forty UUIDs on a card do not inform, they obscure. Lists show as their size ("12 elements") and the detail still travels inside the signed proposal. 10 F7 tools (0% → 91%; only ejecutar missing, by decision). 185 mcp-server tests (12 new), mutation-red on both new mechanisms, 17 shared guards, lint green. Why: exposing deletion with the same ceremony as checking an inbox would have been this catalogue's worst possible mistake — Impact: confirmar_con in the catalogue, frase_requerida() in the gate, cards that summarise lists, NO_OFRECIDAS in the scope frame, and 106 tools in total.

  • feat(mcp): F4, F5 and F6 go from almost nothing to covered — expediente, physical storage and transfers, plus three more procedures (E18) Second increment on the workflow classification, in the order it set itself: F5 and F6 were the two workflows that could not be carried out from the assistant — an archivist cannot work without being able to shelve a box or close a transfer. 27 new tools. F5 (7% → 79%): locations, conservation units, placing an expediente with its folio range, movements and loan history. F6 (9% → 82%): the whole custody cycle — create, send, receive, reject, the frozen record of delivery and its signature verification. F4 (32% → 61%): exclude with cause, index as XML, integrity verification, versions, index signing, publication ex officio, access history and authorised groups. The three acts that transfer custody —send, receive, reject— are declared irreversible: they change who answers for the holdings. And preparar_transferencia does not receive, deliberately: receiving belongs to the receiving archive, and having one call do both ends would turn a handover between two responsible parties into a one-person errand. Three new invariants, and all three found something. That each step maps only arguments its tool declares —the real error was writing dest_dept, the endpoint's name, where the tool declares dest_dept_code—; that it supplies all required ones; and that no parameter asked of the person goes unused. The third uncovered a defect of mine: responder_radicado asked for the originating radicado and never linked it. Its description promised a link the draft circuit does not make, because the system creates the antecedent when CREATING the outgoing document, not afterwards. Renamed to proyectar_y_radicar_respuesta, with a description that tells the truth. And the same defect was in the corpus: seed_corpus.py read GET /expedientes/{id}/radicados, a route that does not exist, with opcional=True — the 404 read as "it has none" and every pass refilled already-full expedientes. Third appearance of the same pattern in two days: an optional read turns "I could not read" into "there is nothing". Verified against the live stack with corpus data: 103 tools visible to operador (96 tools + 7 procedures), the physical-storage ones return the 9 units and 6 locations seeded, and estado_del_expediente composes four queries into one over a real expediente. 173 mcp-server tests (3 new, mutation-verified), 17 shared guards, lint green across the 11 services, mkdocs --strict clean. Why: a workflow classification is not worth much as a document unless it becomes the tools that were missing, and the two least-covered workflows were exactly the ones leaving an archivist unable to work — Impact: 96 tools (from 69) and 7 procedures (from 4); total coverage 92 → 96 of 367 operations, concentrated in the six workflows a civil servant walks.

  • feat(mcp,shared): the API classified by WORKFLOWS, and 24 new tools derived from that classification (E18) Requested by the user: "classify by workflows in order to create the MCP tools". A service map tells you who serves each endpoint; what has to be decided is what someone doing a job needs — and a front-desk procedure touches four services. Hence the unit of analysis is the workflow, not the microservice. Fourteen workflows, censused mechanically by crossing the gateway OpenAPI with the MCP catalogue (scripts/censo_mcp.py, reproducible, with --detalle F4 for an operation-by-operation view). The document lives at docs/es/mcp-flujos.md. What stays OUT is declared with its reason, because a census counting all 367 as debt would lie: /internal/* is sealed service-to-service; /api/v1/tenants/* is deliberately unrouted (the tenant registry lives in public and any user of one institution could write another's); /api/v1/public/* is the citizen window, session-less, while the MCP always acts on behalf of an authenticated officer. And the census states its own limit: the gateway aggregator composes seven of the eleven services, so 367 is a floor. 24 new tools, in the three workflows a civil servant walks daily: F1 front desk (third parties with the DIVIPOLA cascade, frozen actors, motivated reclassification, publication ex officio and the whole annulment circuit), F2 routing (return with cause, approval chains, events, roll back an assignment) and F3 dispatch (shipment inbox, dispatch, status update). Coverage across those three goes from 24% to 61%. Permissions were not declared by eye: they were extracted from the real require_permission of each router. An invented permission makes a tool invisible to everyone but ROOT, and that already happened in this catalogue with a RADI_CREAR that did not exist. The guard meant to prevent that was half blind, and that is the deeper fix. shared/tests/test_permisos_declarados.py read only the DECORATOR's require_permission, not the router's own. It cut both ways: it accused the five third-party tools of over-declaring —their router does declare PERM_RADI— and, worse, it would not have caught a tool declaring LESS than a router-level gate, which is precisely what the guard exists to prevent. It also kept one prefix per file: in anulacion.py, which has two routers, the first router's endpoints hung off a foreign prefix and matched nothing — four tools went unchecked and it only surfaced because the overall count dropped. Fixed router by router, and verified by mutation. Verified against the live stack with corpus data: 73 tools visible to user operador, listar_departamentos returns the DANE's 33 and buscar_terceros finds the seeded ones. 170 mcp-server tests, 17 shared guards, lint green across the 11 services. Why: grouping by service answers who serves an endpoint, not what the worker needs; and a catalogue whose guard cannot see half the gates can lie about permissions without anything failing — Impact: new docs/es/mcp-flujos.md and scripts/censo_mcp.py, 24 more tools (69 total + 4 procedures), and the permission cross-check now reads router-level gates too.

  • feat(mcp,gateway,scripts): a real test corpus, and the MCP starts offering PROCEDURES rather than isolated calls (E18, ADR-019) Requested by the user: before touching the MCP, the system needed a database populated well enough to test it whole. scripts/seed_corpus.py now seeds 197 radicados (150 annexes across 78 of them, 28 reserved and 22 classified), 16 expedientes with deliberately uneven contents —24, 21, 20, 20, 12, 10, 7, 7, 5, 4, 3, 3, 2, 1, 1 and one empty, the edge screens tend to forget—, 40 third parties with DIVIPOLA geography spread across the country, 41 postal shipments in various states, and physical storage with an outstanding loan. Everything through the API, never through SQL. A direct INSERT bypasses atomic numbering, the business rules and the audit_log hash chain — in this installation the only non-conforming audit rows are, precisely, raw-SQL fixtures. And it converges: two runs leave the same archive, not a bigger one; a corpus that grows on every pass consumes radicado numbers, which are immutable and never returned. Seeding uncovered two defects no test had touched. POST /api/v1/terceros answered route_not_found: the prefix was missing from the gateway routing table, so third-party registration and the DIVIPOLA cascade —built, unit-tested and documented— were unreachable from the frontend. And migration tenant/027 had not been applied to the deployment: the tercero table did not exist. A capability built and not offered, again, this time with two stacked causes. Three defects in the script itself, and the costliest was mine: it asked for size=200 where the cap is 100, the resulting 422 fell into a read declared "optional", and "I could not read" was taken as "it does not exist" — every run recreated what already existed. The fix is not raising the cap: it is that a convergence check cannot be optional. Assuming emptiness is exactly what produces duplicates, and silently. The other two: the Keycloak token expired mid-run on a long seed (now renewed once per request), and reused expedientes stayed empty because "converge" had been read as "do nothing if they already exist". The MCP: 367 gateway operations, 41 covered. The census was done by crossing the aggregated OpenAPI against the catalogue, not by reading: 11% coverage. This increment does not close that gap —it is stated and is the work that follows— but it fixes what made it useless: the MCP offered isolated calls while a civil servant performs procedures. Registering a petition is four acts, not one; with isolated tools the model picks the order and links the steps, and when something fails halfway nobody knows what state the matter is in. Procedures declare that sequence server-side, in the same declarative shape as the catalogue (an entry, not code per procedure): tramitar_peticion_ciudadana, responder_radicado, conformar_expediente and estado_del_tramite. Three properties make them safe: every step carries the person's token —a procedure is an ordering, not a credential: it can do nothing its user cannot—; one confirmation for the whole act, because asking four in a row teaches people to confirm without reading; and atomicity is not faked. That last point is the core. No transaction spans HTTP calls to different services, and even if one did, registering consumes an immutable number that never returns to the counter. So the result is a step-by-step report, not a boolean. Verified live, and by the best kind of accident: while testing it I typed "Oficina Juridica" without its accent, radicado 2026-DEMO-E-000127 was created and the routing step failed with 422; the response said pasos_ejecutados: ['radicar'] and warned against repeating the whole thing. A bare "it failed" would have led to registering the same petition twice — the costliest and least reversible damage this system can do. A Protocol (Invocable) lets tools and procedures share the same argument validation, confirmation card and permission gate: duplicating them would have created two validations that diverge on the first change, offering or denying acts depending on which door the call came through. 170 mcp-server tests (11 new), 79 gateway tests (4 new routes), 430 document-service tests, 1,937 frontend tests, lint green across the 11 services. Mutated separately and red with their test: the terceros route removed from the gateway, a broken step link passing None, and a report that hides what was completed. Why: without a populated database the whole system cannot be tested, and an MCP offering only isolated calls forces the model to invent the procedure — Impact: new scripts/seed_corpus.py, /api/v1/terceros routed in the gateway, four MCP procedures with their runner and their step report, exposed over both transports (REST and the MCP protocol).

  • fix(archive,storage,frontend): two capabilities built that the screen could not reach — the per-document hash of the electronic index (E15 T-08), and blind group administration From the pending-items review the user asked for. Of the three noted, the first was not a defect: checked against the running stack, an invalid token, one with a forged signature and a missing header all three return the correct 401 — the gateway's 503 fires only when auth-service returns 5xx or does not answer, which is the right semantics. I had taken it as open by reading the != 200 branch and an old note, without testing it. 1) The electronic index carried no hash for anything linked from the interface. RT-15 art. 4.3.2.3 requires it, and the system has ALWAYS had it: storage-service computes the SHA-256 on upload. What was missing was someone to fetch it — the link proxy sends radicado_id and tracking_number, so content_hash stayed null. Result: "documents with a verified hash: 0 / N" on expedientes whose radicados did have files with a hash sitting right there. It is not asked of the client, for the same reason the department is derived from the municipality: a hash supplied by whoever links proves nothing about what the archive holds. The server resolves it —the radicado's annexes from document-service, their SHA-256 from storage— and the contract field remains for callers who know it first-hand (import with fixity, E11 INT-05): what is declared wins over what is resolved. What "the hash of a document" means when it carries several annexes is declared in one place (app/core/huella_documento.py): no annexes, no hash, and it is SAID (sin_huella_declarada, which is not an integrity discrepancy); one annex, its SHA-256 as is; several, the SHA-256 of their sorted hashes — a one-level Merkle tree. Sorting is what makes the hash a function of the set rather than of the order the database returned the rows: without it the same evidence would yield two values and verification would report an alteration that never happened. Best-effort on purpose: if document-service or storage do not answer, the link still happens, without a hash. Linking a radicado to its expediente is an archival act that cannot be held hostage to two other services being up, and a document with no hash is something the index knows how to declare; aborting would trade a missing datum for lost work. Resolution happens outside the transaction: that stretch is 100% DB so the tenant's pg_advisory_xact_lock is not held across a network round trip. 2) /admin/grupos administered blind, and the backend did expose the reads. The drawer added and removed members, set permissions and clearance without showing any of the stored state: instead it displayed a "this session" list of what it had itself done — the memory of the clicks, not the membership. All three GETs had existed in auth-service since the RBAC read mirror. What kept the hole open was a comment claiming they did not exist, repeated in the proxy, in +page.server.js and in the screen's own notice. Fourth time in this project that a false sentence in a comment is the vector: deleting it is part of the fix. A concrete consequence: since every permission selector showed 0, saving any one of them could silently downgrade another already granted at level 3. Verified in the browser against tenant_demo: linking 2026-DEMO-E-000015 (one annex) from the screen left the link with 3af08544…, exactly the file's sha256 in storage, and "Verify integrity" now says 1 / 1. In groups, "Demostración" shows its 4 members and everything at level 3; the positive control with "Consulta" shows 1 member, permissions at 0 except its own at 1, and clearance 1 — PUBLICA. 900 archive-service tests + 188 integration tests against real PostgreSQL (16 new), 349 storage-service tests (4 new), 1,937 frontend tests (6 new), svelte-check 0/0, lint green across the 11 services. Mutated separately and red with their test: the resolver disconnected from the single link, the resolved value overriding the declared one in the batch, and the batch asking about items that already carried a hash. Why: an index that declares itself hashless while the files sit next to it with their SHA-256 does not meet RT-15, and whoever administers permissions cannot work without seeing the ones already granted — Impact: new POST /storage/internal/files/hashes, server-side hash resolution on link (single and batch), three read proxies under /admin/grupos, and the drawer showing the real state.

  • fix(storage,frontend): the migration policy lived where the institution could not write it (RF-PRE-04) The periodic sweep stated —in its docstring, in the config and in the documentation— that "which format is obsolete is declared by the INSTITUTION, not by this code". And then it read an environment variable. An environment variable belongs to the DEPLOYMENT: the same for every institution sharing the installation, untouchable by the archivist, with no version, no author and no date. The rule was written down and the code did the opposite. The policy moves to preservacion_plan.formatos_origen, the other end of formatos_destino —present since the plan's first version— and it inherits the three properties that were missing: append-only versioning (changing the policy creates a new version; the previous one stays), authorship and date. Changing what gets converted in an institution's archive is an archival act and must be reviewable after the fact. It is declared from /admin/preservacion, where the archivist already administers the plan. Precedence runs one way only: the institution's plan wins over the deployment value. The variable remains an installation-wide default for institutions that have declared nothing — in a single-institution deployment the deployer is the institution; in a shared one each overrides it with its plan. The other way round, an institution could not change its own policy without asking whoever runs the containers. And the "no policy" short-circuit can no longer be global. Before, with the variable empty, the job returned before looking at the database: an institution that had declared its policy in its plan would have been ignored with nothing saying so. Now it asks institution by institution, and those declaring nothing are counted (sin_politica) instead of vanishing from the summary. Two new ways to believe something was converted, closed. A malformed MIME —"msword", "application/ msword"— matches no file: the sweep would convert nothing while the policy read as declared on screen. The type/subtype shape is validated and rejected with 422 rather than stored, in the schema and in the SSR proxy too. And the comparison is now case-insensitive (RFC 2045 §5.1): the stored MIME is whatever the uploader sent, and an Application/MSWord fell outside a policy that does name it, yielding zero candidates with nothing appearing broken. It costs no index — there is none on mime_type. The column arrives with DEFAULT '{}': a plan predating the migration declares nothing, and nobody starts converting because they upgraded. Verified against real PostgreSQL, which is where it shows. 345 storage-service tests + 30 integration tests against real PostgreSQL (4 new), 19 frontend tests under admin/preservacion (2 new), svelte-check 0/0, lint green. Each guarantee mutated separately and red with its test: inverted precedence, lower() removed, MIME validation disabled, the column not reaching the INSERT, and the field not reaching the PUT from the screen. Why: a policy only writable by whoever runs the containers is not the institution's policy, however loudly the comment says otherwise — Impact: migration storage tenant/016, formatos_origen on GET/PUT /api/v1/preservacion/plan, a new field on /admin/preservacion, and FORMAT_MIGRATION_MIME_TYPES demoted to an installation-wide default.

  • fix(document): the DIVIPOLA catalogue I loaded was not DANE's (RF-RAD-05) The user asked hours after delivery: "check whether DANE's DIVIPOLA changed". It had not merely changed — what I loaded was never the official catalogue. Migration 003 took it from the ICETEX Orfeo dump and verified it against four known cases: 05001 Medellín, 11001 Bogotá, 27150 Carmen del Darién, 91001 Leticia. All four were right. The rest was not checked. Four hits say nothing about 1,135 rows, and there was a downloadable official source I never even looked for. A mechanical comparison against DANE (Colombian Open Data, resource gdxc-w37w) found 38 differences: 15 different names, and one was not a spelling variant but the WRONG municipality: the dump carried (muni_codi=21, dpto=05, 'PASTO') — Pasto attached to Antioquia, under the code belonging to Alejandría. Pasto is 52001, in Nariño. Alejandría in turn appeared as 05076, which is not its code either. Verified in the dump itself: the source data is corrupt, it was not an extraction bug — it is the accumulated junk of a real installation, ironically the same kind of dirt this catalogue exists to prevent. 18 codes DIVIPOLA does not recognise: Sincelejo under department 15 (Boyacá), Solano as 18765 instead of 18756, corregimientos and villages mixed in with municipalities. 5 missing municipalities, including Tuchín (23815) and Nuevo Belén de Bajirá (27493), segregated from Riosucio and added by DANE in 2023. The 33 departments did match — they had been written by hand against the official denomination, which is exactly what was not done for the municipalities. A new migration 004; 003 is not rewritten. The public.schema_migrations ledger identifies by (service, file name) and stores no content hash: editing 003 would leave any deployment that already applied it with the bad catalogue and nothing to warn about it. That is the silent failure this project already knows. A code DANE does not recognise is not deleted: it is flagged vigente = false. Deleting it would break a record already pointing at it, and the policy of not deleting a suppressed municipality was declared by 003 itself. It drops out of the cascade and still resolves a historical dispatch's name. The missing guard COUNTS, which is what a single case does not do: 1,122 current entities (1,103 municipalities + 18 non-municipalised areas + 1 island) and 33 departments, plus code↔department coherence across the whole catalogue. Mutated by leaving the catalogue uncorrected: both new tests go red. Why: a closed vocabulary holding the wrong municipality is worse than free text — free text at least does not claim to be official — Impact: migration global/004, 1,122 entities from DANE's source, and two tests that count instead of looking at four rows.

  • feat(document,storage,frontend): geography is now chosen, not typed (RF-RAD-05, DIVIPOLA cascade) and format migration no longer needs to be invoked one file at a time (RF-PRE-04, periodic sweep) The two items left declared as pending when their increments closed. They have nothing in common except the shape of the defect: a capability that was built and had no door. 1) The other half of the duplication problem. RF-RAD-05's three defences tackled duplication of the SUBJECT. At the DESTINATION it stayed open: while municipio and departamento were free text, "Bogotá", "BOGOTA", "Bogota D.C." and "Santa Fe de Bogotá" were four different municipalities to every query, report and export. A uniqueness constraint does not close it — the four spellings ARE different and all of them pass; the only thing that closes it is the municipality no longer being something you type. The catalogue (33 departments, 1,135 municipalities) comes from the source Orfeo dump, which already carried the correct DIVIPOLA codes and accented names — verified against known cases: 05001 Medellín, 11001 Bogotá, 27150 Carmen del Darién, 91001 Leticia. It lives in public because it is national: the institution does not edit it, and replicating it per schema would copy data nobody can diverge. 2017 cut-off, and it is declared: 94343 appears as "Guaviare" and is today Barrancominas, created in 2019. The load is idempotent so a newer dump can update it. The asymmetry between the two tables is the substantive decision, and it is the frozen copy's lesson again. In tercero the codes carry foreign keys to the catalogue — a live record must point at a municipality that exists; in radicado_actor they do not, on purpose: that row says where something was sent and has to survive the catalogue changing, including the municipality ceasing to exist. Tying it would turn a territorial reorganisation into a corruption of the historical record — or, with a CASCADE, would carry it away. Proven with a code that is NOT in the catalogue: with a foreign key, that insert would fail. And the department is DERIVED from the municipality rather than believed from the client. The municipality code contains its department's, so accepting the latter separately would mean accepting a contradiction you then have to detect. Deriving it makes it impossible, and a CHECK (left(codigo,2) = departamento_codigo) finishes the job in the database — this is the error a badly written cascade produces silently: the department changes and the previous department's municipality stays. The stored name becomes the catalogue's official one, not what was typed, so search, reports, the electronic index and export —which still read the text columns— stop seeing four spellings of the same municipality without any of them changing. The code is optional: an address abroad has no DIVIPOLA and existing records remain valid — requiring it would have closed the mail room over a presentation detail. For the same reason the municipality joined identity+destination deduplication: "Calle 10 #5-20" exists in half the country, and without it two legitimate dispatches to that address in two municipalities were rejected as duplicates. One of my own tests passed with the mutation in place, and that was worth more than the fix. The "changing department clears the previous municipality" case looked at the <select>: since emptying the options list already blanks the dropdown, it green-lit a cascade still sending 05001 with department 11. Rewritten against the emitted value, which is what travels to the backend, and it now goes red. Fourth time a green test proved nothing. 2) Migration existed and could only be fired by hand, one file at a time. An institutional archive holds hundreds of thousands of files. The sweep walks the active tenants and queues what is missing, with a per-sweep cap — converting is expensive and without one the first sweep would eat the machine. What counts as obsolete is declared by the INSTITUTION, which is why the list ships empty. That is an archival judgement depending on each institution's holdings and its PINAR; a job deciding it alone would be converting somebody's archive according to a list guessed by the vendor. With the policy undeclared the sweep walks the tenants and touches not a single file. Same criterion as the disposition sweep —counts and warns, never deletes— with one difference worth keeping in mind: migrating destroys nothing; the original is kept and the derivative lives alongside it. The danger is not converting too much, it is believing something was converted — which is why the sweep promotes nothing that did not pass the validator. fallido and no_verificado are NOT the same, and conflating them has opposite consequences, none of them visible. fallido speaks about the FILE —it could not be converted, or the result was non-conformant— and retrying it nightly costs a subprocess per file and fixes nothing. no_verificado speaks about the DEPLOYMENT —it converted and there was no validator to check it— so the day veraPDF is installed the next sweep resolves it on its own. Treating them alike either leaves the machine reconverting the impossible, or abandons forever files that were only waiting for a tool. 430 document-service tests + 8 DIVIPOLA integration tests against real PostgreSQL, 364 storage-service tests + 6 queue integration tests, 14 frontend tests under terceros/, svelte-check 0/0, lint green across all 11 services. Mutated separately, each red under its own test: the membership CHECK, the absent foreign key on the frozen copy, the trigger over the code, clearing the municipality on department change, the guessed policy, and the fallido/no_verificado distinction. Why: a closed catalogue is the only thing that stops four spellings of one municipality from being four municipalities, and a conversion you must invoke file by file does not protect an archive of hundreds of thousands — Impact: two new catalogue endpoints, migrations global/003 and tenant/027, the cascade in /radicar, and a sweep that is off by default with its policy in FORMAT_MIGRATION_MIME_TYPES.

  • fix(frontend,infra): a browser poll was defeating the idle-session timeout Keycloak closes the session after 30 minutes of inactivity in production, and that control was correctly placed — on the server, not in the browser. What was not foreseen is that the application itself would switch it off. ssoSessionIdleTimeout resets every time the refresh token is used, and the transparent refresh in hooks.server.js fires on any request to the SvelteKit server — including the ones the app makes to itself. The mail inbox polls every 12 s while the tab is open. Result: a tab left open on a desk kept the session alive up to the absolute 10-hour cap, with the screen unlocked. Nobody decided that; it emerged from two individually reasonable things. The fix is NOT to log out from the browser — that would be a false control, bypassable by disabling JavaScript, and it would duplicate in the client a policy that already lives where it belongs. Instead the app stops faking activity: the poll halts after five minutes without real interaction, no refresh happens, and the server's timer does its job. Hiding the tab does not count as activity; returning to it does. The pause is announced on screen — an inbox that silently stops updating reads as an inbox with no news, which is the opposite of what is happening. Impact: the inbox poll pauses without real interaction; no change to the session policy, which was already correct.

  • fix(archive,frontend): the trail recorded who downloaded each document and the screen never asked for it (RF-BUS-10) The question was simple — "who accessed this document, who downloaded it?" — and the answer had been stored all along: document.radicado_consultado and document.anexo_descargado, written with actor, channel and security level. What failed was the door. The expediente's access history queried object_type ∈ {expediente, expediente_indice} by the expediente code, while those two traces use object_type='radicado' and object_ref=<tracking number>. The question had no answer on the screen built to answer it. The radicado actions admitted are an admission list, reads only, and the row now says what it was about — showing the reference and not the payload, which carries the document's classification level. The tab's label dictionary matched NO real action (read, download, verify…): everything fell through to humanize, and the "Download" entry made it look as though downloads were already shown. The map moves out of the .svelte into its own module — inside a template it could not be tested, which is why it drifted silently — with a test requiring every key to be a backend action name. A stale comment denounced an already-closed hole: the audit screen warned that AuditQueryRepository did not narrow by clearance. It does now (_clearance_clause, RF-SEG-08). The closure is written down rather than deleted — a warning about an open hole that is in fact plugged sends the next reader to audit what is already fine. Impact: the expediente's access history now includes reads and downloads of its documents.

  • feat(storage,frontend): format migration against obsolescence — the system can now produce preservation copies (RF-PRE-04) The last preservation requirement still in gap. PDF/A validation existed (real veraPDF since E10 C2b) and migration did not: the system could say a document was not PDF/A and do nothing about it. THE ORIGINAL IS NEVER TOUCHED. The migration produces a derived artifact alongside it — a system that replaces the original has lost the evidence the day someone disputes whether the conversion altered the content. The test proves it: the file repository is replaced by a double and the only call on the source must be the read. FIXITY IS CHECKED BEFORE CONVERTING, not after: migrating an already-corrupt file would propagate the corruption into the preservation copy and give it the appearance of being the good one. A mismatch answers 409 and records the attempt. A fourth guarantee the rule does not ask for and practice does: converting to PDF/A without checking the result IS PDF/A produces a copy nobody validated. Three outcomes, never conflated: migrado, fallido (bytes discarded) and no_verificado (converted but unverifiable — not promoted either). THERE IS NO "STUB MIGRATOR", AND THAT IS THE DECISION. The PDF/A validator's twin factory degrades to an honest stub because "not evaluated" is a real result. Here there is no equivalent: a stub would have to produce a file, and the only one it can produce without the tool is invented. The absence propagates as None up to a 503 that says what to install. The PREMIS MIGRACION event type had been in the vocabulary from the start with nobody writing it; now this does, with hash_before and hash_after. Impact: two new endpoints, migration 015, ghostscript in the production image, and a "Migrate to PDF/A" action per annex. RF-PRE-04 closed.

  • feat(storage,archive,frontend): the elimination record enters WORM — the debt phase 4 left declared (E10 + RF-TRD-07) The record was left with three local guarantees —append-only trigger, SHA anchored in audit_log, XAdES signature— and no Object-Lock. The reason was declared then and was one of model: the WORM store assumed every protected artifact belongs to an expediente, and an elimination record covers N expedientes that are also about to disappear. Pinning it under one of their ids was not awkward: it was wrong. Migration storage tenant/014 opens the model with a CHECK that enforces the pairing: objeto_tipo (expediente | lote) says what expediente_id refers to, and the constraint closes both impossible combinations. Without it a row could claim an electronic index belongs to a batch, and the purge gate —which queries by expediente— would stop seeing it with nothing to warn. expediente_id is not renamed, and the cost is declared: renaming would touch 142 references across ten modules, most of which are about actual expedientes. What does change is that the purge gate and the per-expediente listing now require objeto_tipo = 'expediente' explicitly — the two id spaces not colliding "in practice" is not a control. The record joins CONTROL_INSTRUMENT_TIPOS: like the transfer record it outlives the content and must not postpone its disposition — and all the more so, since its retention is permanent and treating it as content would make it the instrument blocking the very act it documents. A repo guard had a blind spot, and this change was its first exposure: test_preservacion_tipos_guard.py forces every new tipo in the CHECK to be classified, but read migration 010 by name. Migration 014 widens the CHECK and the guard would have kept reading 010, accepting an incomplete taxonomy without failing. A guard pointing at a fixed file expires the day someone writes the next one. Permanent preservation = 100 years, the contract's maximum, because Object-Lock demands a date, not an infinity. Renewable through the same path as Total Conservation series. Impact: objeto_tipo in the WORM store (backwards compatible: existing rows become expediente), two migrations, and the lock's state visible on screen. E04 has no loose ends left.

  • feat(frontend): PINAR stops being a dashboard that can never be filled (RF-ADM-08) The screen consumed 6 of the module's 31 endpoints: a plan could be created and walked through its lifecycle, but there was no way to create a critical aspect, an objective or a project from the browser. The dashboard was always empty and the page itself called it "a later PINAR phase". A dashboard that can never be filled is not an empty state: it is a false green. Three new tabs. The screen mirrors the service's two lifecycles, which never overlap: structure is editable only in borrador, follow-up only when aprobado/en_ejecucion. And the gate is two conditions, not one: the plan's state and USUA_PERM_ADMIN — the permission gate was missing in the first version and its own test caught it. The backend computes the priority; the table is shown already sorted by it, and "not prioritised" is not painted as priority zero. A project's progress is never typed in: it comes from its append-only follow-up cut-offs, which is why avance_pct is not in the create/edit allow-list. A proxy factory instead of eleven files: the allow-list is the anti mass-assignment barrier, and eleven copies of a barrier drift apart silently. Impact: 2 → 5 tabs, 6 → 31 endpoints consumed, no backend changes.

  • feat(archive,document,storage,knowledge,frontend): secure deletion — the retention schedule finally destroys what it orders destroyed (RF-TRD-07 phase 5 of 5: epic closed) The system preserved well and never destroyed. Constitution invariant 12 held without a crack, and that was simultaneously the non-compliance: keeping everything forever is not applying the retention schedule, it is not applying it (Law 594/2000 art. 24; AGN Agreement 001/2024 art. 4.3.2.5). It takes no body, the most deliberate design decision of the phase: in a destruction there is nothing for the client to parametrise. On screen the button requires typing the batch code. Order is half the design: bytes before rows (otherwise orphan objects in MinIO nobody can attribute — impossible to find and therefore to delete); documents before the expediente (otherwise the radicado links cascade away and there is no way left to know what had to be destroyed); the headstone before the deletion and in its own transaction (otherwise a destroyed expediente with no headstone, and silence is indistinguishable from "never existed"). All-or-nothing per expediente, and purge_for_disposition had had no door since it was written. Migration 049 released three foreign keys and one averted a silent loss: transferencias was ON DELETE CASCADE and would have taken transferencia_acta with it — a control instrument that storage-service's own code declares must outlive the content. Impact: RF-TRD-07 closed, and with it E04 complete.

  • feat(archive,signature,frontend): the elimination record — what outlives what was eliminated (RF-TRD-07 phase 4) Permanent preservation, append-only by trigger, canonical XML with its SHA-256 anchored in audit_log, XAdES signature. The record's inventory is complete, never clearance-narrowed — what is narrowed is who reads it, with the threshold derived from the highest level in the inventory itself. acta_eliminacion had to enter signature-service's XAdES allow-list: without it the record would have fallen back to the nativa HMAC with no error and no visible difference.

  • feat(archive,frontend): the Committee's endorsement, batch withdrawal, and the page that was missing (RF-TRD-07 phase 3) No endorsement while the publicity period is still open, guarded in the transition's own WHERE with the server clock. The 409 distinguishes all three reasons. And the phase-2 chokepoint was leaving a case out silently: a published-then-withdrawn batch vanished from the public view. Rewritten as the mark of the act (publicado_at IS NOT NULL).

  • feat(archive,frontend): prior publicity — the inventory of what is to be destroyed, in plain sight (RF-TRD-07, phase 2 of 5) A published batch stops being an internal matter: its inventory becomes visible without authentication, with the deadline to object, linked from the portal. Without prior publicity, the Committee endorses something nobody outside could look at. The objection channel is not invented: it is the institution's existing single window. A separate objection form would be a second inbox nobody watches. The chokepoint is the batch's STATE, not a deployment switch — deliberately not tied to public_interop_enabled: publishing the correspondence register is the institution's decision; publishing what it is about to destroy is a duty. A chokepoint is reused when the policy is the same, not because the name fits. A classified expediente's description is not published, but its row is, and the narrowing lives in the SELECT. Omitting the row would fail the duty precisely over what matters most to object to; what is withheld is the denomination. The page says so rather than leaving a mute gap. Publishing freezes the inventory, and publicado_hasta is mandatory and future, with no default. A test of mine did not discriminate, and mutation exposed it. The unit test mocked fetchrow returning None, so mutating the WHERE ... AND estado = 'borrador' left it green: the "0 rows" came from the mock. Fixed on both sides: the unit test now asserts on the emitted SQL, and the real race is proven in the integration lane against real PostgreSQL. Another non-existent colour token: --op-text-muted (it is --op-text-secondary). Fourth appearance of the same silent failure. 832 archive-service unit tests + 6 integration, 1837 frontend, svelte-check 0/0, lint green. Why: destroying a document without anyone outside being able to look at the inventory is not a procedure — Impact: new public page. Still nothing is deleted.

  • feat(archive,api-gateway,frontend): the elimination batch — first of the five phases of the only remaining requirement (RF-TRD-07) Elimination happens by batch because the record of proceedings and the Committee's endorsement belong to the batch. This phase proposes and freezes the inventory; publishing, endorsing, signing the record and executing secure deletion are the four that follow, and the last one is not built. ejecutado always travels as false. A resource of its own, not an appendix of /trd: a batch is a procedure with a lifecycle, and each transition has constancies enforced by CHECK — publishing without a deadline gives no time to object, and endorsing without a record, date and administrative act is an unverified assertion. The inventory is frozen, and not as a convenience copy: it is what gets published for third parties to object to, and the expediente is going to cease to exist. A trigger enforces it: never editable, and removable only while the batch is a draft. Five admission conditions: a confirmed due date (without a decision there is no proposal — the only barrier against automatic destruction); a disposition that ends in destruction (E, S, M; Conservación Total never enters); a frozen instrument that governs it; an expired WORM lock; and not already being in a live batch. There are THREE locks, and the longest one wins. An expediente may carry worm_retain_until on its signed index, its transfer record and its AIP; while one stands, COMPLIANCE mode lets nobody delete, not even the administrator. Taking the first one found would yield a batch that blows up on execution, with the Committee already convened. Rejections are reported, not dropped. Whoever proposes 40 and gets 31 must see which nine are missing and why. An expediente outside the proposer's clearance is reported as "not found", indistinguishable from a non-existent one. With its door on the same day: only rows already confirmed whose disposition destroys carry a checkbox, and the 409 with its reasons is forwarded as-is instead of collapsing into "could not create batch". Migration archive tenant/047 validated against real PostgreSQL. 823 archive-service tests, 1828 frontend, svelte-check 0/0, lint green. The five conditions verified by mutation one by one. Why: preserving everything forever is not complying with the TRD, it is failing to apply it — Impact: /api/v1/eliminacion/ joins the gateway. Still nothing is deleted.

  • feat(archive): the sweep that runs on its own, and the alert that reaches whoever is not looking (RF-TRD-08 + R.4.8) Fourth step of the increment that destroys nothing, closing the scheduled trigger. The due-disposition queue had to be visited; now the system sweeps by itself and alerts whoever can decide. No new infrastructure was needed: archive-service already had two background loops with exactly the right shape — a testable run_once, their own kill switch and interval, never dying on an exception. This is a third with the same mould, and its interval is the alert cadence, so there is no per-tenant "last sent" state to keep in sync. What silences the alert is deciding, not having read it. The plan's recommendation was false and was corrected before building on it. It said "start in-app", assuming E16 had a notifications channel. It does not: the frontend store is ephemeral session toasts and notification-service is email only. A toast is not an alert — it vanishes in four seconds and is seen only by someone already looking, precisely who the alert is not for. The alert is composed PER RECIPIENT, and there lay the trap the plan flagged in advance: the count is narrowed by the recipient's clearance. Telling someone with clearance for 9 that they have "14 pending" tells them 5 classified expedientes exist. Verified against real PostgreSQL: a classified overdue expediente counts for clearance 3 and not for 1 or 2. Whoever has nothing due gets no email. Recipients are resolved by RBAC query, with the same criterion as the deciding endpoint's gate, never a configured mailing list: a list drifts from the permissions silently. The email is deliberately poor: a number, a deadline and where to go. No tracking numbers, no subjects, no series — email leaves the institution and lands in mailboxes the SGDEA does not govern. It also states that coming due is not deleting. Failures isolated per tenant and per recipient. A form guardian verifies the job executes no disposition. 808 archive-service tests, lint green, migrations 045/046 applied and verified on the demo tenant. Three mutations verified. Why: a queue you must remember to visit does not satisfy "alert authorized users" — Impact: a third background loop that sends email; it requires NOTIFICATION_INTERNAL_TOKEN and refuses to alert without it. Still nothing is deleted.

  • feat(archive,frontend): the authorized user's confirmation — "no destruction is automatic" (RF-TRD-06) Third step of the increment that destroys nothing, and the one that turns the due-disposition queue into more than information: an authorized user now decides. It is rule 4 of the TRD requirements — "no destruction is automatic without an authorized user's confirmation" — and until today it existed nowhere. Confirming still deletes nothing, and the response says so. ejecutada: false travels explicitly with a note, because a 201 on an endpoint called "decide" invites the belief that something happened; the screen's modal repeats it before the person clicks. The ADR-025 D5 gate finally gets a caller. assert_disposicion_final_autorizada had been written, tested and called by nobody — its own docstring said so. It now blocks confirming a deletion whose frozen instrument does not govern it, which is the right moment: rejecting it at the end of the procedure, once the Committee has been convened, would be discovering the limit by failing. CT is exempt; E, S and M are not. What is not accepted from the client is the point. The due date and disposition code do not travel in the body — the backend resolves them from the frozen snapshot; otherwise confirming a deletion would be a matter of sending the right date. Out-of-reach expedientes answer 404, not 403. Deferring is a decision, not hiding something (Model SGDEA R.4.9): it requires a reason of at least 10 characters and a future return date, validated in the proxy, the schema and a migration CHECK. The item leaves the active queue and comes back on its own. Decisions live in disposicion_decisiones (migration archive tenant/046), append-only, storing the frozen due date they were made about — a 2026 confirmation cannot authorize a deletion a reformulated TRD orders in 2030. The immutable audit entry is written in the same transaction. Migration validated against real PostgreSQL. 801 archive-service tests, 1824 frontend, svelte-check 0/0, lint green. Five mutations verified separately. Why: a calendar that comes due with nobody deciding is an archive administering itself, and the rule requires the opposite — Impact: a new endpoint and two actions in the fourth tab of /admin/trd. Still nothing is deleted.

  • feat(archive,frontend): the system stored the retention calendar and never said it out loud — the due-disposition queue (RF-TRD-08) Second step of the increment that destroys nothing. On closing an expediente the system freezes its calendar — fin_archivo_gestion, fin_archivo_central, the disposition code and the act that validated the series — and there it stayed: correct data nobody ever read. GET /api/v1/trd/disposicion-vencida surfaces it, and a fourth tab in /admin/trd puts it in front of whoever decides. An endpoint with no door is the pattern this review has been chasing all along, so it ships with its own on day one. Coming due is not an order to destroy, and the UI cannot let it read that way. Under Conservación Total, the central archive coming due means transfer to the historical archive and preserve forever; showing that row beside the deletion ones undifferentiated would turn the queue into a "list of things to delete" containing precisely the expedientes that are never deleted. Every row therefore carries its due action, derived from the frozen snapshot and written by admission. gobierna_eliminacion does not reimplement the gate. The ADR-025 D5 criterion was extracted into a single predicate now shared by the assert that blocks the act and the queue that merely reports. Copying the condition into the query would have been reimplementing the thing under verification, and the divergence would surface the day someone touched one side. The delicate part is the count, not the page. No-read-up sits in the same condition as the rows, COUNT included: narrowing only the page would let the total betray how many classified expedientes are due. The query also leaves an aggregate trace (RF-BUS-10). A non-existent colour token caught before it shipped: the notice used var(--op-danger), which does not exist (it is --op-error). The browser would have dropped the whole declaration silently. 787 archive-service tests, 1811 frontend, svelte-check 0/0, lint green. Four mutations verified. Why: a calendar that lives only in the database governs nothing, because nobody looks at it — Impact: the institution sees for the first time what came due, when, under which act and what is due. No act is executed: effective deletion (RF-TRD-07) remains unbuilt.

  • fix(archive): an expediente closed without a TRD series left the lifecycle silently First increment of the largest remaining product gap: final disposition is never executed. The system has the calendar — on closing it freezes fin_archivo_gestion, fin_archivo_central, the disposition code and the act that validated the series into expedientes.disposition — and it has the authorizing gate (assert_disposicion_final_autorizada, ADR-025 D5, written and with no caller). What it lacks is the executing arm. This increment is deliberately the one that destroys nothing. And looking at it surfaced an older hole. Closing an expediente without a TRD series left disposition = NULL and trd_revision_requerida = false: no retention calendar, no disposition date and absent from every queue. That is not an over-preserved expediente — it is one that leaves the archival lifecycle silently, because nothing names it and nobody will look at it again. Confirmed on the demo tenant: 1 expediente in that state, invisible to /trd/revision-pendiente. The cierre_sin_snapshot_de_retencion reason had existed since migration 037, which used it to mark historical closures; the third tab of /admin/trd knew how to present it; and no code path ever wrote it afterwards. The backfill repaired the past and nothing protected the future — precisely the hole audit_log's three defences had, covering modification and deletion with none covering insertion. The fix is two halves, and the first alone would have been inert. The service now marks the calendar-less closure; but the revision marks lived inside if pin_trd:, true only when the expediente has a series — exactly the opposite case. They are now written on close; on transfer they are still left alone, because the method's defaults would erase a pending mark nobody has resolved. Both halves verified by mutation separately. It marks, it does not block, the same criterion this method already applies to migrada series (ADR-025 D6). Migration archive tenant/045 sweeps what slipped through between 037 and today, idempotent and validated against real PostgreSQL. 776 archive-service tests, lint green. Why: an expediente with no retention calendar is not preserved, it is unwatched — Impact: closures without a TRD series now surface in the third tab of /admin/trd, and the migration collects the earlier ones.

  • fix(archive,tenant,frontend): the system was hiding the PINAR and the TRD while serving them Closes the product decision that the negative sweep (B13) left open in docs/es/plan-pruebas.md §4.bis rather than settling on its own. The question: TRD and PINAR reads required no permission at all, while the sidebar hid the whole Administration section and /admin gated its cards. Gate them, or declare them open — both were legitimate — but what was there was not defensible. The first thing that changed was the size of the problem. The §4.bis count — "TRD 4, PINAR 11" — was written by hand. A census BY FORM over both services' routers found 33 ungated reads reachable through the gateway: TRD's 4, 4 TVD ones the question never mentioned, PINAR's 11, and 14 catalog reads. With the full list the dilemma dissolved, because those reads are not one thing. The classification vocabulary — TRD series and TVD groupings (list and detail), retention, document types, dependencias, catalogs, parameters — stays open to any authenticated user of the tenant, now declared in code and covered by a test: /radicar and /expedientes consume it in order to classify, and USUA_PERM_TRD belongs to whoever administers the instrument, not to whoever uses it. The validated instrument must be published anyway (Ac. AGN 004/2019, compiled by 001/2024; Ley 1712/2014 art. 9). The circuit is gated with USUA_PERM_TRD: GET /trd/{code}/versiones and its TVD twin return the full history — drafts, acta_comite, fecha_aprobacion_comite, instancia_convalidante and created_by of every version — the Committee's deliberation, not the published instrument. Its twin GET /revision-pendiente already required it. PINAR's 11 reads now require USUA_PERM_ADMIN, gated on the APIRouter itself — by admission, so tomorrow's endpoint inherits it — and deleting the docstring sentence that declared the opposite: a stale comment is the vector, not decoration. It closes rather than opens because a plan in borrador is the Committee's working material, and because publishing the approved PINAR (Decreto 1080/2015 art. 2.8.2.5.8) is an act directed outwards — a public screen — not an ungated internal GET that resembles one. On the frontend, /admin/pinar's load put the 403 in the same bucket as network failures; it now travels as sinPermiso, the screen names the missing permission, and it obeys the backend even when the session disagrees. 773 archive-service tests, 117 tenant-service, 1804 frontend; all five fixes verified by mutation. Why: a system that hides something and serves it at the same time has decided nothing — Impact: PINAR's 11 reads and the two TRD/TVD version histories now answer 403 without the permission; the classification vocabulary is unchanged and /radicar behaves as before.

  • fix(frontend): an unreachable inbox announced its list might be out of date Found in the browser, not in a test, while validating the freshly added scope gate: jefe (an administrator) opened another officer's personal mailbox inbox by direct URL. The backend refused with the neutral 404 — correct, and the screen explained it well — but it also showed, in amber, "we cannot check for news, so this list may be out of date; refresh it by hand before registering". Over an inbox that does not exist, with no list and nothing to register. The poller kept asking every 12s and its 404 read as a transient failure: an authorisation error presented as something else, the same defect that had /envios present a 403 as "no records". With the inbox unreachable there is no polling, the poller-down notice stays quiet, and "Consultar el buzón" no longer offers an impossible act. All three consequences hang off a single condition, with a test that the condition does not vanish; verified by mutation and checked live: no polling calls on the other person's account, and polling present on one's own. Why: a message blaming a network failure for what is a refusal of access sends you looking for the fault where it is not — Impact: whoever opens an inbox that is not theirs reads one explanation, and it is the true one.

  • fix(document,frontend): the mailbox inbox gate said who may register, not which mailbox A security finding, surfaced while documenting the inbox endpoints and discovering they were not documented at all. PERM_RADI authorises working with email; which mailbox is decided by a different cut — the WHERE in email_cuenta_repository, carefully written and tested, which hides an officer's personal account even from the administrator. Listing the inbox, reserving, releasing and discarding went straight to the message by id, bypassing it: anyone with PERM_RADI in the institution could read the subject and sender of another officer's private mail and set it aside. Only sincronizar and radicar crossed the cut, because they needed it to open the IMAP session — that is, the gate reached where it was technically required, not where it was required for safety. This is the second time in this repository that a correct cut fails to reach the door next to it. It is closed with a single guard in the service — the check repeated at every endpoint is the one that will one day be missing — answering 404 cuenta_no_encontrada, not 403: telling them apart would turn the endpoint into an oracle of which mailboxes the institution has and who works them. Plus a second layer in the mutation's WHERE: cuenta_id binds the message to the mailbox, because otherwise reaching one would be enough to operate on any other's mail knowing only its id. Both layers verified by mutation separately. Discarding a message now writes to the immutable chain, in the same transaction and not best-effort: if audit_log cannot take it, the message is not discarded. Deciding that a received communication is not registered is as much a decision as registering it — it is the one that makes the document disappear from the archive — and until now it left only an UPDATE on an ordinary table, rewritable afterwards without a trace. The entry carries neither subject nor sender: a discarded message was never registered, so it has no classification level with which to cut its readability, and in audit_log every USUA_PERM_ADMIN would see it. The reason does travel, because it is the text of the decision, not of the document. And yet another capability built and never offered: liberar() had existed in the repository with its test since Increment 2 with no way to call it. The effect was that opening a message, seeing it was not yours and closing the screen left it locked for fifteen minutes for the whole front desk, with no way out but waiting. There is now an endpoint, closing the form releases the reservation, and your own row offers "Soltarlo". document-service's integration lane gains the public.audit_log bootstrap ported from archive-service: the entry's atomicity cannot be checked with a double, because the rollback is produced by Postgres. And docs/{es,en}/api.md finally document all seven inbox endpoints, cross-checked against the running service's OpenAPI — not read. 466 document-service tests (14 integration ones over the inbox), lint green. Why: a permission that says "may register" does not say "may use this mailbox", and an officer's personal mail does not belong to the front desk — Impact: anyone without an assigned mailbox no longer reaches its inbox; personal accounts are visible only to their owner, from the inbox too.

  • refactor(document,frontend): the single-mailbox legacy route (IMAP_*) is retired Closes E19 Increment 3. Gone: the IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASSWORD, IMAP_USE_SSL, IMAP_MAILBOX and IMAP_TENANT_SLUG variables, the GET /api/v1/ingest/email/estado and POST /api/v1/ingest/email/poll endpoints, the imaplib client and ingestor behind them, and the "legacy mailbox" block of /admin/correo with its history table. Not cosmetic cleanup: that route could not do the job. One mailbox per deployment does not express what Acuerdo AGN 060/2001 art. 13 requires; the password lived in clear text in a container's environment; and above all it registered every unread message in one go — advertising and newsletters included — because it had nowhere to separate what gets registered from what does not. It is replaced by what the three increments built: one row per mailbox with its encrypted credential, its filtering folder, its "Radicados" folder, its mode and its responsible officer. A contradiction disappears at the root, too. The screen used to say "email ingestion is not configured" with a configured, working mailbox right below it: two sources of truth talking at once. That was patched by conditioning the notice; now the second source does not exist, which is the strong fix. The guardian that watched the condition now watches that. The record of what was registered from email is not lost with the history table: every radicado leaves its entry in the immutable audit trail, and each account's inbox keeps which message produced which number — and which mailbox it came from, something the poll history never distinguished. 409 document-service tests (the 15 of the retired route go with it), 9 for /admin/correo, svelte-check 0/0, lint green. Why: keeping two ingestion routes forces a decision about which one wins, and the legacy one registered the advertising — Impact: anyone with IMAP_* in their environment must configure their mailbox from /admin/correo; the variables are ignored and both endpoints return 404.

  • fix(document,frontend): an incoming document could be annulled with the permission alone — and that act does not exist for incoming documents A domain correction from the user, opening an axis this work had not been auditing. Every permission review of these days answers "is this person authorised?". There is a prior question: "is this act possible on this kind of document?". A radicado de Entrada is not annulled. If a document entered the institution, it entered: that is a fact, not a revisable decision. Annulment exists for what the institution produces — a Salida or Interno issued by mistake. The service only checked existence and prior annulment, so PERM_SOL_ANULAR was enough to annul an Entrada. Legacy Orfeo did distinguish (sgd_trad_codigo). The rule lives in app/core/reglas_tipo.py, written by admission (declare which types admit each act, never which are excluded — with an exclusion list the omission lands on the permissive side and says nothing). Not even ROOT bypasses it: ROOT's bypass is over authorisation, not over what is possible. 409, not 403: the caller is not missing a permission — granting one would change nothing. Both halves fixed: the inbox offered "Solicitar anulación" looking only at the permission, so it painted the button on an Entrada. Impact: 409 acto_no_aplica_al_tipo on an Entrada, with or without permission, with or without ROOT. Verified by mutation in both layers. Open: this is the first rule of its kind to be declared. If there are more per-type particularities, reglas_tipo.py is now where they belong — and enumerating them at once beats discovering them one at a time.

  • fix(document,auth): the permission catalogue promised nine controls and enforced one The question was what to do with PERM_RADI_ENTRADA / PERM_RADI_INTERNO. The inverse sweep — what is seeded that nobody requires, the reverse of the one that caught the six phantom permissions — found that of 26 seeded permissions, nine are required by no gate and no row. USUA_PERM_ROOT is legitimate (it is the is_root bypass). SGD_PERM_ESTADISTICA was a real hole: the UI honours it (the "Reportes" menu and the dashboard metrics) and the backend did not require it, so anyone authenticated could call the reports endpoints directly — the third appearance today of a control living only in the browser. Now gated, with the denial verified to happen before touching the database. The remaining seven have no reference anywhere in the repository. On the original question: the per-type trio cannot be enforced today because PERM_RADI_SALIDA is already taken — it gates the six postal-dispatch endpoints, not registering Salidas. Enforcing only two of three would be a half-control that looks whole; enforcing the third would either lock out everyone who registers Salidas or, backfilled, widen postal-dispatch access to everyone who can register — a security regression. Rewiring that name first is a product decision, not a fix. Descriptions are corrected rather than rows deleted: the FK is ON DELETE CASCADE, so deleting would silently drop grants an institution already made. A new guardian keeps the declared list of inert permissions honest, failing in both directions. Impact: reports require SGD_PERM_ESTADISTICA; migrations auth-service/018 (descriptions only, including PERM_RADI_SALIDA, whose name misleads about what it gates).

  • fix(document,storage): reclassifying with read permission, uploading files with no permission at all, and registering a draft nobody approved Closing the debt the MCP plan kept declaring without fixing. All six findings were verified against the code before being touched, and two dissolved on inspection — said anyway, because a false finding left in a report wastes as much time as a real one left unreported. PATCH /documents/{id}/security-level required PERM_RECLASIFICAR at the default min_crud (1 = read), so holding it for consultation alone was enough to raise or lower a record's classification. Both its twins already required 3 and said why. POST /storage/upload — the only endpoint of storage-service the gateway exposes — had no gate at all; it now requires PERM_RADI at level 1 (taken from its only real consumer, the radicación flow; nobody currently working loses access). radicar_borrador did not require the draft to be approved, although the UI does: a control living only in the browser is not a control (D-05). PERM_RADI_ENTRADA / PERM_RADI_INTERNO still restrict nothing (0 gates). Enforcing them is a product decision that collides with PERM_RADI_SALIDA currently meaning "manage postal dispatch"; retiring them would drop existing grants. Migration 017 fixes what is unacceptable meanwhile: the description an administrator reads before granting. Two of my own findings were false: /vistos-buenos/decidir authorises by identity (chain membership + turn), which is stronger than a generic permission; and the min_crud on expedientes is a coherent convention (3 = irreversible), not an oversight. Impact: reclassification now needs write level; uploads need PERM_RADI; drafts must be approved before registering. Migration auth-service/017 (descriptions only).

  • fix(auth,mcp): six workflow permissions the code required that nobody could hold — and the guardian meant to catch them had been skipping all along Found by the plan's end-to-end verification with the stack up and a real tenant: auth_permissions held 20 rows and the code required six that were absent (PERM_TRAMITAR, PERM_VOBO, PERM_ANULAR, PERM_CERRAR_EXP, PERM_INFORMAR, PERM_AGENDAR). A permission not in that table cannot be granted to any group, so the gate denies everyone but ROOT: the twelve seeded transaction types and the five workflow writes were ROOT-only. Fail-closed — the right side — and still capability that exists in code and no institution could distribute. It is exactly the RADI_CREAR defect that opened this plan, at larger scale. And the guardian written to prevent it was switched off: it lived in the mcp-server image, which does not mount the repository, so it could not find the migrations and called pytest.skip on every run — "11 passed, 1 skipped" reads just like "12 passed". Moved to shared/tests/ (the only CI lane that sees the whole repo, where skipping is impossible) and widened to all three sources of requirement: the MCP catalogue, every router's require_permission, and the requires_permission column of seeded rows. The six missing ones are declared by none of them but the last two — looking at one source is how they survived a control bearing their name. Impact: additive migration auth-service/016_permissions_tramite.sql; run the auth-service runner on every existing tenant or workflows stay ROOT-only. mcp-server goes from "142 passed, 1 skipped" to 143 passed, 0 skipped.

  • test(e2e): the plan's success criterion, verified against a running stack Gateway routes /api/v1/mcp/ (200 vs route_not_found on controls); the scope framework returns 15 executable / 29 readable / 1 denied with its reason from real permissions; invoking a write returns a signed proposal and the database does not change; confirming without the phrase gives confirmacion_requerida and it still does not change; with the phrase, 201 and a real tracking number; replaying the token gives propuesta_consumida; the record carries the real user's registered_by and its audit_log entry with the correct actor. Negative control: a tool whose permission the user lacks is absent from the catalogue and returns 403 without touching the database. Declared pending: the assistant turn with the real model needs ANTHROPIC_API_KEY and was not exercised; everything deterministic — where the gates live — was.

  • feat(frontend): voice dictation — and the button does not appear if dictating would send the voice to a third party VoiceInput.svelte in the assistant composer: es-CO recognition, interim results painted over a base so the phrase does not repeat word by word, and errors that say what they are (permission, microphone, language). The plan's premise was false, so the design changed. It justified browser recognition with "the audio never leaves the device" and claimed this served sovereignty better than ADR-020's Whisper transcribe endpoint. In Chrome and Edge the classic Web Speech API sends the audio to a Google recognition service. ADR-020 had decided the opposite in writing. Shipping it as planned would have sent a public servant's voice to a third party, in a system holding reserved records, under a written justification asserting the reverse. So the button is not offered because the API exists: only when the browser can recognise on device and says so itself (availableOnDevice for that language, plus processLocally). A downloadable model does not count. The check is fail-closed by construction — no availability API, or a throw, means no button. Worst case is no dictation; never dictation leaving the device unnoticed. Dictation never sends nor confirms: it fills the composer and stops there. A write proposal is approved by hand, on its card. ADR-020 is annotated with what was built and its limit: the Whisper transcribe endpoint remains unbuilt and is the only sovereign path for every browser. Why: last item of the plan, and its written justification did not hold — Impact: dictation where the browser recognises locally, invisible where it does not; 1488 vitest tests, svelte-check 0/0.

  • feat(mcp,frontend): the assistant promised a confirmation in writing that did not exist — the card is now built The assistant panel stated that "every write action will ask for explicit confirmation" over an interface that was never built: no proposal rendering, no dialog, no buttons. The backend solved it from the other side by forbidding all writes (v1 read-only). Both halves were consistent with each other and neither delivered the promise. Backend: the assistant no longer excludes write tools from what the model sees. A tool_use on one never reaches the dispatcher: it becomes a signed proposal carried in AssistantMessageResponse.propuestas (additive field, no coordinated deploy). The tool_result returned to the model states ejecutado: false and "NOTHING HAS BEEN EXECUTED… do not chain actions that depend on its result" — without that anchor the model reads its own tool_use as having taken effect and writes "I registered the document", a false claim about an official record. The proposal token is never put in the model's context. Irreversible acts require a typed phrase, enforced by the backend from the signed token — the client cannot claim an act was not irreversible, because it is not the client who says so. The UI disables the button as a courtesy; the barrier lives in write_gate, since a phrase validated only in the browser is theatre (D-05). In-band elicitation asks for the phrase too, so irreversible acts are not the only ones that path cannot approve. Frontend: ProposalCard.svelte in the dock, an /asistente/api/confirmar proxy, per-proposal state. One confirmation per act, chains included. The card is written by the catalogue and its values are rendered as text, never HTML — the model composed them from a conversation that may include an incoming document's body, i.e. a third party's text. Three of my own defects, each caught by its test: a ProposalError escaping and collapsing the whole turn with a 500; the confirm proxy's allowlist stripping the very phrase it needed to forward; and a CRUD-level test that survived its mutation because it checked the first barrier twice and the second one never. Why: a promise of confirmation with no interface behind it is worse than no promise — the person trusts a control that does not exist — Impact: the assistant can prepare registrations, expedientes, links, assignments and drafts; none executes until someone approves it on its card.

  • fix(mcp,gateway): the permissions header nobody produced, the env setting nobody consulted, and a scope framework only the web assistant could see Three places read X-User-Permissions, a header no component produces — the gateway drops it as forgeable — so it always arrived empty: GET /api/v1/mcp/tools, the endpoint that exists to tell someone what they can do, answered everyone with the permission-free tools. X-User-Roles does arrive, so ROOT saw everything and the defect stayed invisible to exactly the person who could spot it. Its stdio twin was worse: MCP_USER_PERMISSIONS/MCP_USER_ROLES could be set, so whoever configured them believed they were granting something, when all they got was tools offered that the backend would deny. Both settings are removed; identity comes from GET /auth/me on all three transports, permission_levels included (build_tools did not pass levels, so read-level PERM_RADI showed tipificar_radicado, which requires create level). The scope framework reaches MCP: mi_alcance tool + orfeo://alcance resource, both derived from build_scope — two doors to one datum. Offered always, even to someone who reaches no write: the tool set says what you can do and stays silent about the rest, and silence is how a limit ends up being discovered by failing. The gateway now routes /api/v1/mcp/, which it deliberately did not before — leaving "one catalogue and one gate for the integrated assistant and for external MCP clients" half-done. It is safe to expose because the write gate no longer depends on the client: invoking a write returns a signed proposal and executes nothing. A new shared/tests/ guardian forbids any tool from pointing at the MCP or assistant surface (recursion + gate bypass), which until now was impossible only because those paths 404'd. My own negative control ("forging the header grants nothing") survived re-reading the header — it passed for an unrelated reason. Replaced with a structural AST check, inspecting the tree and not the text, because the files are full of comments naming the header to explain its removal and a grep would have vouched for them. Why: a legitimate limit discovered only by failing wastes two people's time and looks like a breakdown — Impact: /api/v1/mcp/ is routed through the gateway; MCP_USER_PERMISSIONS/MCP_USER_ROLES no longer exist; rebuild mcp-server and api-gateway.

  • feat(mcp): instruments and physical archive — the catalogue closes at 45 tools 5 new read-only tools (plan block 8): consultar_serie, consultar_retencion, ubicacion_expediente, listar_prestamos, reporte_radicacion. The catalogue reaches 45 tools, 16 of them writes, and all 45 cross-check against their endpoint's gate. Two descriptions state what the tool is not: consultar_retencion is a computation over a series and a closing date, not the expediente's recorded disposition; reporte_radicacion returns aggregates only — no tracking number, subject or sender — so a count is never presented as an inventory. ubicacion_expediente says where an expediente is, not what it holds (the backend deliberately skips no-read-up there: custody management is orthogonal to read clearance). consultar_retencion is the first GET with a required non-path argument; a test now pins series-in-path, date-in-query and no body, verified by mutation. Why: these are the lookups the write blocks depend on — typing a radicado without being able to read the series' retention is signing blind — Impact: catalogue 40 → 45 tools, no new writes.

  • feat(mcp): the reply cycle — precedents, draft and registration — and a 422 that stopped being discovered by failing 10 new tools (plan block 7): buscar_antecedentes, redactar_con_antecedentes (text grounded in the archive, with citations, writing nothing), listar_respuestas, crear_respuesta_vinculada, listar_plantillas, listar_borradores, crear_borrador, editar_borrador, aprobar_borrador, radicar_borrador. The catalogue reaches 40 tools, 16 of them writes. The draft is what lets the assistant propose without committing: no tracking number, not in the index, edited or abandoned without consequence — only radicar_borrador consumes the immutable number. buscar_antecedentes takes the subject or the reference tracking number, never both and never neither. Announced as two plain optionals, the model sends both and gets a 422 after making the person wait; declared with exactly_one_of, coerce_args rejects it before the request leaves. Its own invariant guards the declaration, because a group naming a non-existent parameter would reject every call to the tool. Also declared rather than hidden: POST /borradores/{id}/radicar does not require the draft to be approved, so aprobar_borrador is described as a procedural marker and not as a barrier. Why: "draft me the reply with precedents" was part of the end-to-end success criterion — Impact: catalogue 30 → 40 tools; rebuild the mcp-server image.

  • fix(workflow,mcp): returning and rolling back a radicado required no permission at all, and the workflow block was the one missing to close a trámite end to end — POST /workflows/{id}/devolver and POST /workflows/{id}/rollback had no require_permission, while their three siblings in the same router — assign, transfer and complete — require PERM_TRAMITAR over the same radicado. Same file, same object, only some gated: omission, not design, and they are the positive control. Any authenticated user in the tenant could pull a radicado out of the dependencia handling it and send it back to origin, or undo someone else's last assignment. And a third hole, this one documented as a decision. In execute_transaction, a type with requires_permission = NULL was "open": it executed with no permission whatsoever, and a test asserted exactly that — so it is changed by declaring the change, not by deleting the test. The reason: transaction_types is a tenant catalogue, so a row with an empty column — seeded by a future migration, or by someone with database access — was executable by anyone. Leaving a field blank cannot be the easiest way to open a door. It now falls back to the PERM_TRAMITAR floor. Nothing observable changes today: all 12 seeded types declare their own. The floor lives in the service rather than as a route gate because the permission here is PER TYPE: requiring PERM_TRAMITAR for all of them would break someone who holds PERM_VOBO to sign off and nothing else. 8 new tools (block 6, the one that makes closing a trámite end to end possible, where previously there was not a single distribution tool): pendientes_equipo, tipos_transaccion, hoja_ruta_radicado, paso_actual, asignar_radicado, trasladar_radicado, completar_paso and ejecutar_transaccion. The catalogue now stands at 30 tools, 11 writes. completar_paso names its parameter for what it is — step_id, the id of the STEP, not of the radicado — instead of inheriting the common one: that route hangs off a different entity than the rest of the router, and it was the most likely mistake in the whole block. An exception that had to be declared rather than skipped. ejecutar_transaccion resolves its permission at runtime, so there is no decorator to compare and the test_permisos_declarados.py guard flagged it as "requires a permission its endpoint does not ask for". Resolved with an explicit catalogue field (permiso_en_runtime) plus a test of its own checking that the flag is not set on a route that does have a gate — because that is how a legitimate exception eventually becomes the way to silence findings. — Why: exposing an operation over MCP makes it more reachable, so the loose gates it would amplify get closed first, not later — Impact: devolver and rollback require PERM_TRAMITAR (anyone without it now gets a 403 where the call used to go through). Rebuild the workflow-service and mcp-server images. Two false statements corrected in docs/{es,en}/api.md: devolver's authorization and the "open type". Verified by mutation: removing each gate, restoring NULL to "open", and setting the runtime-permission flag where it does not belong each fail their own test.

  • feat(mcp): three tools were offered to people who would get a 403 — and now something watches for it — expanding the catalogue (plan blocks 4 and 5: from 9 tools to 22) surfaced the exact mirror image of the RADI_CREAR defect. consultar_expediente, indice_expediente and consultar_fuid declared "no permission" over endpoints gated with USUA_PERM_EXPEDIENTE. There, something usable was hidden; here, something unusable was advertised — worse in light of what this project committed to: the scope framework exists precisely so that nobody discovers a limit by failing, and a catalogue that promises what the backend denies turns it into a list of good intentions. The pre-existing invariant checks that the permission exists in the RBAC migrations — it would have caught RADI_CREAR and does not catch this: a None is a perfectly existing name. What was missing is comparing against that specific endpoint's gate, which needs both trees at once. shared/tests/test_permisos_declarados.py now does it, with one test per direction (under-declaring and over-declaring are two different failures with two different victims) and a third that measures cross-match coverage, without which the other two would be decorative: if path matching broke, everything would be skipped and they would stay green forever. Its twin, test_rutas_mcp_alcanzables.py, closes the gap that already slipped through once: bulk upload was built entirely against endpoints that existed while the gateway had no /api/v1/batch/ in its routing table, so it returned 404 route_not_found. A tool whose route is not routed gets offered, proposed, confirmed… and fails. Both live in shared/tests/ because it is the only CI lane that sees the whole repository: the mcp-server container has no gateway source and the gateway's has no catalogue. Neither keeps a list of its own — both sides are derived from the code. 13 new tools. Registration and classification: consultar_por_numero (the number is how people name it; the id is not on the stamp), listar_anexos, consultar_disposicion, actualizar_radicado, tipificar_radicado, listar_tipos_documentales. Expedientes: crear_expediente, buscar_expedientes, vincular_radicado, actualizar_expediente, cerrar_expediente, consultar_foliado, hoja_ruta_expediente. Parameters were verified against the real routers rather than the plan's table — and that cross-check is what surfaced both the typing gap and these three permissions. Two deliberate exclusions, said out loud: actualizar_expediente declares exactly ExpedienteUpdate's three fields and no more, because that schema carries extra="forbid" and status was deliberately withdrawn there (it was an alternate route to closing, without audit or disposition); and bulk linking (up to 500 radicados) stays out for as long as its confirmation card cannot be read — a card saying "500 radicados" does not let anyone confirm knowingly, which is the only reason the card exists. Two existing tests went red, for the right reason: they passed on a wrong premise (the test user only had PERM_RADI and the catalogue asked for no more). Fixed by granting the real permission, not by relaxing the assertion. — Why: offering a capability the backend will deny wastes the person's time and turns a legitimate limit into an apparent malfunction — the very defect this plan set out to correct, committed inside the plan itself — Impact: the catalogue goes from 9 to 22 tools (6 writes, all via signed proposal). cerrar_expediente is the only irreversible one admitted, at crud >= 3, because "archiving" was explicitly part of what was asked for. Rebuild the mcp-server image. Verified by mutation: returning a tool to "no permission" and declaring a non-existent permission each fail their own test; pointing a tool at an unrouted path fails its own.

  • feat(document,mcp): a radicado could not be typed after registration — the document type only existed at creation time — surfaced while verifying block 4 of the MCP plan against the real routers instead of trusting the plan's table. doc_class — the document type — was only set when creating the radicado and in bulk import; PATCH /api/v1/documents/{id} accepted subject, dest_dept, observations, status and pages, and the type was not among them. No endpoint did it, by this route or any other. A document mis-typed at the counter — or registered untyped, which the schema allows — stayed that way forever, and with it the TRD series governing its retention and final disposition (Ley 594/2000 art. 24). It was not a deliberate restriction; it was a hole. It also explains why the frontend only suggests a type at registration (TrdSuggestChips): afterwards there was nowhere to send the correction. Re-typing re-validates the metadata against the new type's template — the heart of the fix, not an extra: without it, re-typing would have been an UPDATE doc_class leaving the previous type's variable fields dangling, and the radicado would have gone on claiming conformance to a template that was no longer its own. It uses the same check create performs, deliberately: a re-typed radicado must be indistinguishable from one registered that way from the start. Metadata is replaced wholesale, not merged (a merge leaves residue from the old type, which is exactly what makes an expediente stop adding up), and the template version used is rewritten whenever re-validation happens — including the NULL when the new type has no active template; without that explicit NULL the radicado would keep pointing at the previous type's template. Sending only metadata re-validates too: otherwise registration's mandatory validation would be bypassable with a later PATCH. The new act gets its own gate without touching the existing ones: the PATCH is gated with PERM_RADI at level 1 — read — and stays that way, because raising it wholesale would have broken everyone who today corrects a subject or a page count, and aligning that threshold is a decision for the epic, not a side effect of this change. But doc_class decides how long the document is kept and whether it is ultimately destroyed, so it is checked separately at crud >= 3. A defect of my own, caught by my own tests: I added the failure mode (metadata validation) without its response — MetadataValidationError was not in the PATCH's except, only the POST's, so validation surfaced as a 500. A validation without its response mapping is not a validation, it is a crash. And a test that did not discriminate, caught by mutation: it checked that metadata_template_id appeared in the UPDATE SQL, and found it in the RETURNING clause — green with the write in place and removed. Corrected to metadata_template_id = $N plus checking the value sent. Same pattern yet again: the searched string appears elsewhere in the same text. — Why: the document type is not a descriptive field, it is what binds the radicado to its TRD series; leaving it immutable by omission turns a counter mistake into the wrong retention for the document's entire life — Impact: PATCH /api/v1/documents/{id} accepts doc_class and metadata; 422 metadata_validation_failed when they do not fit; 403 when typing without creation level. New MCP tools tipificar_radicado (write, min_crud=3, with its declared alternative route) and listar_tipos_documentales, which is where the name the former expects comes from. Rebuild the document-service and mcp-server images. Verified by mutation, each control failing its own test: removing the level gate, removing the re-validation, not rewriting the template version, and dropping doc_class from the audited fields.

  • feat(mcp): confirming "something" was not confirming "this" — writes become a signed proposal — the MCP write gate was X-Confirm-Write: true: a boolean any client could set, bound to no particular act. It counted the same for a query as for registering an official document. The MCP binding simply assumed it (a hardcoded confirm_write=True, "because the human-in-the-loop is supplied by the MCP client" — a guarantee turned into an assumption about someone else's software), while the assistant forbade writes outright. Three different behaviours depending on which door you came through guarantees one of them is wrong, and the wrong one was the least-watched. There is now one gate for all three transports: invoking a write returns a proposal and executes nothing. The proposal is an opaque HMAC-SHA256-signed token carrying the complete act — tool and already-validated arguments — and on confirmation the arguments are taken from the token, never from the request body: "what was signed" is "what runs" by construction, not by discipline. Returning a proposal instead of a 412 is deliberate: a legacy or hostile client that fabricates any header gets a description of the act, not an effect. Signed rather than stored in memory because the service's only store is a per-process dictionary, declared invalid for multiple replicas: with memory, proposing on one replica and confirming on another would break the button the person did press. Re-authorisation on confirmation is not redundant: between proposing and confirming lie the seconds it takes someone to read the card, and the permission may have been revoked in that gap — a proposal attests what was approved, not that whoever presents it may still do it; confusing the two is what turns a token into a free pass. And the card is worded by the catalogue, not the model: generated text could misdescribe the act being signed, which would be social engineering aimed at the very person confirming it. For external MCP clients, which have neither card nor endpoint of their own: a generic confirmar_accion(token) tool (offered only to users who can reach some write — suggesting it to someone who cannot write implies a power they do not have), standard annotations (readOnlyHint, destructiveHint), and in-band elicitation where the transport is bidirectional (stdio); elicitation approval does not execute by a different route — it goes through the same signed token, so it re-authorises and consumes identically. Two defects surfaced while implementing it, both of the "mechanism that reports success without verifying anything" kind: the execution endpoint was never actually inserted (a grep for a string that also appeared in a comment vouched for something that did not exist; verification is now by defined symbol), and proposals issued over the MCP transport were dead on arrival — signed with a session-dictionary key that never existed, so sub went out empty and verification later rejected them as belonging to someone else: an immaculate-looking, unconfirmable proposal whose failure only showed when the button was pressed. destructive is declared explicitly on every write tool, defaulting to None on purpose: with a False default, forgetting it would have amounted to asserting "this act can be undone" with nobody having checked, and an external client would have lowered its friction trusting that phantom claim. An invariant enforces it. — Why: a boolean in a header binds confirmation to no act, and a guarantee that depends on the client asking is not a guarantee — Impact: X-Confirm-Write is no longer read (senders get a proposal anyway). New POST /api/v1/mcp/proposals/execute and POST /api/v1/assistant/confirmar (the latter under /assistant/ because it is the only mcp-server prefix the gateway routes). MCP_PROPOSAL_SECRET is mandatory: the service refuses to start without it (a signature with an empty key is reproducible by anyone and would leave the gate decorative). This requires building mcp-server with the repo root as context so shared/ is installed. Rebuild its image. Declared limits: consumption by jti lives in process memory — it covers double-clicks and retries, not multi-replica — and Idempotency-Key is emitted as a hook, not a guarantee; over streamable HTTP (stateless) there is no return channel to elicit on, leaving the two-tool path.

  • fix(mcp): a path parameter containing ../ invoked a different endpoint with the user's token — the dispatcher interpolated path parameters with a raw str(). A document_id of ../../auth/me did not produce a 404: it made the mcp-server call another gateway endpoint with the user's token. Same normalisation defect that block 1 of the production plan closed in the gateway, alive one hop further in — and the reason the dispatcher review moved up in priority. Closed with quote(safe=""), in two layers: coerce_args validates format="uuid" first, so most such values never reach the escape. The dispatcher only knew GET and POST: anything that was not GET fell into an unconditional POST. Most of the system's writes are PATCH, and there are PUT and DELETE, so the catalogue could not express half the operations. There is now build_request (prepares, does not send — which will let us sign exactly what gets executed) and execute, with all four methods and argument placement by location. DELETE never carries a body: some proxies silently drop it, and a request whose meaning depends on something that may vanish in transit is not a reliable request. The argument whitelist moves from the assistant to the dispatcher, so all three transports inherit it. A test detail that matters more than the change: moving dispatch from client.get/client.post to client.request would have left the security assertions saying post.assert_not_called() meaningless without failing — the write gate and the assistant's defence in depth would have been watching a method nobody uses any more. They were migrated to request, now exclusive to dispatches (context reads stay on .get), so the assertion literally says "no tool was dispatched" and does not hollow out when a tool changes method. — Why: a layer that translates names into routes and does not escape what it interpolates is not a façade, it is an inward-facing open proxy — Impact: mcp-server only; no tool changes contract. Rebuild its image.

  • feat(mcp): the assistant now states what you can do before you start, instead of letting you find out by failing — foundations of the functional MCP layer (plan blocks 0, 1 and A). All three fix the same shape of defect: a capability that exists in the code and nobody can use, with nothing complaining. The MCP's only write had been switched off since its first commit: radicar_documento declared the permission RADI_CREAR, which does not exist in the system — it is in none of the five migrations that seed auth_permissions; the real one is PERM_RADI. visible_tools compared against a name that never appears in /auth/me's set, so for every non-ROOT user the tool was invisible and invoking it returned 403. The service's own tests missed it because they fabricated the permission by hand, self-consistent with the defect. An invariant now reads the real permissions from the migrations and fails if the catalogue declares one that does not exist. Identity was resolved three ways and two did not work: the HTTP router and the MCP binding read X-User-Permissions, a header nobody produces (the gateway injects six others and drops that one). Unified in app/services/identity.py, always from the validated token. Visibility now respects the CRUD level (real 0..5 scale), not just permission presence: holding PERM_RADI to read no longer enables what requires create — and asking for more hides the tool from those who can, which fails silently. _TOOL_ARGS is gone, the parameter table parallel to the catalogue; a tool with no entry there and no path parameters was dispatched with no arguments at all. Migrating against the real routers surfaced a drift — buscar_conocimiento advertised query of minimum 2 characters and top_k up to 20 when the backend accepts 1 and 50 — with a test freezing it. query is aligned; top_k stays at 20 but now said out loud: an assistant-side limit to avoid flooding its context, not a system one. The scope frame (GET /api/v1/assistant/alcance, a panel in the dock, injected into the prompt): what this person can do, can consult, and cannot — with their alternative route: "you cannot send it, but you can transfer the radicado to correspondence so they send it". It is derived from the catalogue crossed with /auth/me, /auth/clearance and /auth/context, so adding a tool updates the frame by itself. And it declares what the system does not distinguish, to avoid promising precision it lacks: the registration permission does not separate by document type (PERM_RADI_ENTRADA/_INTERNO exist and no gate uses them), and reads are bounded by security level, not by dependencia. — Why: a permission you only discover by failing wastes two people's time and turns a legitimate limit into an apparent malfunction; and a refusal without an alternative leaves the asker exactly as stuck — Impact: the assistant is still read-only — writes arrive with the confirmation gate of the following blocks — but it now states its scope. Rebuild the mcp-server and frontend images.

  • fix(tooling): the lint gate could fail without saying why — surfaced by running it while another instance was in flight: four services red, two of them with no changes at all, and not a single error message to read. The code was not at fault: the in-container ruff/mypy install ran with 2>/dev/null under set -e, so any failure of that install — network, contention from two concurrent gates, a slow package index — aborted the service and listed it as FAILED with no visible cause. Notably, the lesson was already written two lines below: the shared/ install carries a comment explaining it is deliberately not silenced, because a gate that quietly reduces its own coverage is worse than a red one. It had been applied to half the file. — Why: a gate that produces unexplainable red trains you to ignore it — the same failure mode as the pipeline that never ran the tests and the health check that returned a fixed 200 — Impact: tooling only. Run in isolation, all eleven services pass.

  • feat(archive,frontend): the publication act reaches expedientes — the last art. 12 asymmetry — closes the item both auditors independently flagged. The art. 12 correction had been applied to radicados and not here: archive-service still carried _PUBLIC = "nivel_seguridad = 1", so the multi-level EAD OAI kept proactively disseminating the code, name, description, extreme dates and radicado count of every unclassified expediente — the column's default. The asymmetry was also implicit, which is the worst kind: anyone reading document-service's cut would assume this service did the same. Migration 042 (publicable defaulting to FALSE plus publicado_at/publicado_por and a partial index), a cut requiring both conditions, POST /expedientes/{id}/publicacion with PERM_RECLASIFICAR at crud≥3 and a justification mandatory in both directions, act and audit entry in a single transaction, and the state exposed in ExpedienteResponse. The screen — a "Publicación" drawer in /expedientes/{id} — ships in the same change: closing the channel without leaving the legitimate door is the very defect this work fixed two commits ago. Two deliberate differences from radicados. The cut carries no status condition: an annulled radicado is withdrawn from circulation, but a closed/transferred expediente is not — those are normal lifecycle states — and copying status <> … for symmetry would have silently unpublished the whole closed archive, which is most of a fonds. And the level gate applies only when publishing: over a reserved expediente the screen does not offer publishing — and explains why — but does offer withdrawal, which is where it matters most. The radicado twin defect does not exist here: there is no expediente reclassification — nivel_seguridad is set at creation and no route mutates it (ExpedienteUpdate is extra="forbid"), so no level change can undo a publication. — Why: a publication scheme governing one object type and not the other is not a scheme, it is an exception — Impact: existing expedientes become non-publishable after migration 042. An institution using the expediente OAI must explicitly publish what it wants disseminated. Rebuild the archive-service and frontend images.

  • fix(document,shared,ci): the publication act governed metadata but not the bytes, and a reclassification undid it — findings from a cross audit (multi-tenant security + archival compliance), with both auditors independently converging on the same two blockers. All three defects are scope defects, not design ones: the pattern "explicit act + justification + audit entry in the same transaction + gate inside the WHERE" is right; what failed was not applying it everywhere it belonged. (1) CMIS served content without requiring the act. The _PUBLIC chokepoint gained publicable in block 11, but get_public_anexo — which feeds getContentStream, i.e. the attachment bytes — carried a hand-written copy of the WHERE that was never updated, and its docstring claimed to anchor on _PUBLIC, which made the divergence invisible on reading. With public interoperability enabled, anyone on the internet could download the attachment of any unclassified, non-annulled radicado the entity had never published — and tracking numbers are consecutive, so the channel is enumerable. Closed with _public(alias): a chokepoint that is built, not rewritten by hand. (2) Classifying left the publication latent and declassifying revived it. set_classification did not touch publicable: publish → classify (the flag stays TRUE, harmless while the cut requires both) → declassify months later for an unrelated reason → the radicado returns to the harvesting channels with no act, no justification and no audit entry. Classifying now withdraws the publication in the same transaction as its audit entry, because a reservation is a reasoned act that prevails (Ley 1712 arts. 18-19). (3) And an already-classified radicado could not be unpublished — the UPDATE required nivel_seguridad = 1 in both directions, so the state where withdrawal matters most was the only one without an exit. The gate is now conditional on direction. set_nivel_seguridad is removed (no production callers, and it did exactly the dangerous thing), along with the test comment describing it as current. (4) The single migration ledger self-attributed ambiguous names: a service runner sees only its own directories, so for one of the four colliding filenames it saw a single candidate — itself — and marked it applied: block 11's silent skip, now fossilised with full identity. Fixed with NOMBRES_AMBIGUOS plus a test that recomputes the set from the repository and fails if it diverges. (5) shared/tests/ never ran in CI: the test-unit matrix enumerates the eleven services and the shared library is not a service, so 61 tests covering the immutable audit log, the event envelope, fail-fast secrets and the migration ledger fell through the enumeration gap — the same failure mode as the type gate that covered 8 of 11. New test-unit-shared job. — Why: a publication opt-in that governs metadata but not content is not an opt-in; and an act another act can undo without justification is not an act — Impact: no migration. Radicados published and later classified keep the flag in the database: a sanitising UPDATE (publicable = FALSE WHERE nivel_seguridad > 1) is advisable before enabling public interoperability. Rebuild the document-service image.

  • fix(document,frontend): the publication act had no screen, and its result could not be read — closing block 11 changed publicable's default to FALSE — correctly, because publishing is a decision of the entity and not the consequence of not having classified (Ley 1712/2014 art. 12). What was missing was the legitimate door: POST /documents/{id}/publicacion existed, the gateway routed it, and no screen called it. An institution that updated was left with every radicado non-publishable and no way to publish any of them from the application: the publication scheme became unexecutable except through SQL. A leak was closed and the system was left without the door. A second defect surfaced while building the screen: publicable could be written but not read — it was in neither the radicado detail nor the listing, so neither could the UI offer the act over a known state nor could the entity attest through the API to what it had published. A publication scheme that cannot be consulted is not a scheme, it is a blind write; the mapping crosses three layers (SQL query → domain object → response) and the field was missing in all three. The screen is a side panel in the tray that reads the state from the server and always offers the act opposite to the current one — publish or withdraw — with a mandatory justification in both directions and an explicit warning of what publishing does: the radicado becomes harvestable over OAI-PMH and CMIS. Over a reserved or annulled radicado no form is offered: the backend answers with a neutral 404 (deliberately indistinguishable from "does not exist", so as not to be an oracle over classified material), and letting someone draft a justification only to receive "does not exist" afterwards would be a screen that deceives. The button requires the backend's real threshold — PERM_RECLASIFICAR at write level: asking for less produces a 403 on click, asking for more hides it from those who may act. — Why: a decision the law requires an entity to make, and to attest to afterwards, cannot depend on direct database access — Impact: radicados are still born non-publishable; there is now a way to publish them and a way to consult what was published. Rebuild the document-service and frontend images.

  • fix(shared,gateway,document,archive,auth,storage,infra): publishing stopped being a decision, and an incident could not be followed end to end (production audit, block 11) — What gets published is now decided, not inferred. Two harvesting channels let other institutions and the public collect the entity's records. Until now anything not marked as reserved went out through them — that is, having decided nothing was equivalent to having decided to publish: a freshly registered radicado, or one declassified for an unrelated purpose, left on its own. Ley 1712/2014 art. 12 asks for the opposite: a publication scheme, an act by which the entity decides what it disseminates. Each radicado now carries a publication flag that starts off and only turns on through an explicit, justified and recorded act, stamped with who did it and when; the UPDATE demands nivel_seguridad = 1 in its own WHERE, so a false claim cannot be stored that the next declassification would turn into publication. An incident could not be traced across services. X-Request-Id had always been on the forwarded-header list — and the gateway generated it in the response middleware, after proxying: the pipe was laid and nothing flowed through it. It is now assigned on the way in (pure ASGI, so block 4's chunked streaming survives), travels through every service, comes back to the browser, and each service writes one structured line with entity, user, operation and duration — never the query string. Example passwords that actually worked. This repository is public, and two secrets — the electronic-signature key and the object-store credentials — shipped with a working example value: an institution that forgot to change them got no warning. Both now default to "" and the service refuses to start without them. The identity-server sample config, which contained two published-password users (one an administrator), is renamed to realm-dev.json, and production starts from a realm-prod.json.example template that cannot be used unfilled. CORS * with credentials was hardcoded. Starlette cannot answer * with credentials, so it reflects the incoming Origin — that is not permissive, it is CSRF. It is now off by default (the product does not need it) and whoever opens it must enumerate the origins (CORS_ALLOW_ORIGINS). The deployment guide told you to copy a template that did not exist: infra/nginx.conf.example is now included and validated with a real nginx -t, with HSTS and X-Forwarded-Proto/Host/For — without it, login did not complete, the IP sealed into the immutable audit trail was the reverse proxy's own, and large downloads buffered in memory again. Migration bookkeeping was kept in duplicate. Two runners (services/*/migrations/runner.py and init_tenant.py) recorded applied work in three registries invisible to each other, keyed by the bare filename — and each service numbers its own series from 001, so four names collide (002_batch_jobs.sql between archive and document; the 005/006/007 dead-letter ones between notification and signature). The second service's runner therefore saw the name and skipped a migration of its own that had never been applied — silently, with no error. Harmless so far by coincidence (all four are idempotent and nearly identical). There is now a single ledger, public.schema_migrations, keyed by schema + service + filename, which imports the three old registries without re-applying anything; an ambiguous bare name is attributed to the first candidate with a warning, because silencing it would repeat the defect. The audit_log was never partitioned by month: migration 002 assumed a job nobody had written, everything piled into audit_log_default, and PostgreSQL refuses to create a partition whose range overlaps rows already in the default — the trap tightens over time. ensure_audit_partitions now creates the coming months' partitions and hardens each one against deletion: the immutability guards are per-table and a new partition does not inherit them. Three missing access controls: (1) POST /ingest/email/poll required only a valid JWT, and since the process has a single IMAP mailbox with the target entity taken from the header, a user of entity A could pull mail addressed to B into their own — and the poll marks it read, so it never comes back: not an undue read, an irreversible appropriation. The mailbox now declares its owning entity (IMAP_TENANT_SLUG, fail-closed) and the endpoint requires USUA_PERM_ADMIN, leaving an audit entry per poll; (2) PATCH /documents/{id}/disposition set «eliminar» with read permission — now min_crud=3, with a mandatory rationale, written in the same transaction as its audit entry; (3) the FUID went out with no header: entidad_productora is now resolved server-side from public.tenants, unidad_administrativa/oficina_productora/objeto are declared per report, and consultation frequency is computed from real accesses in the audit_log scoped by tenant_slug (the only table shared across entities) instead of estimated. — Why: publishing public information is a decision of the entity, not a side effect of not having classified; and a working example key is a known key — Impact: existing radicados become non-publishable until someone performs the publication act, so an institution using the harvesting channels must expressly publish what it wants disseminated; three environment variables become mandatory before deploying (SIGNATURE_SECRET, MINIO_ACCESS_KEY, MINIO_SECRET_KEY), plus IMAP_TENANT_SLUG if email ingestion is used.

  • fix(shared,gateway,*): automatic mail distribution could stop silently while the system kept reporting it was fine (production audit, blocks 9–10) — four background consumers react to what happens in the system: distributing incoming correspondence, sending email, sealing signatures and feeding the search index. If the internal message broker was unavailable at the exact moment of startup — or restarted afterwards — those consumers died and never came back. The system looked healthy: you could register, query and sign. But new documents stopped reaching dependencias, mail did not go out and search kept answering with stale data. Nothing warned. They now restart themselves with exponential backoff, and every failure is recorded. The health check returned an automatic "all good": three services reported healthy without checking anything — they said so with the database down. The opposite was also wrong: the gateway declared itself unhealthy when any service behind it failed, so the orchestrator restarted the one component still able to serve. There are now two distinct probes — liveness (/health, which governs restarts) and a new readiness probe reporting the real state of database, broker, mail and consumers, so the load balancer stops routing to a node that cannot serve without restarting it. Those probes also leaked internal hostnames and ports in their error text to unauthenticated callers; they now report state in one word, with the detail left in the server log. Login failures with no apparent cause: the pool asked PostgreSQL for more concurrent connections than it grants, on a server shared with the identity system — so under documentary load what failed was signing in. The arithmetic is now done, written down and tuned, and a stuck query no longer holds its connection indefinitely. — Why: a consumer that dies silently is official correspondence that stops being distributed with nobody noticing, and a health check that always says "fine" disables the very monitoring it appears to provide — Impact: images must be rebuilt and the updated production manifest deployed (it tunes the database configuration); anyone monitoring /health expecting dependency state must move to /health/ready.

  • fix(frontend): hovering the sign-out icon logged you out (production audit, block 9) — sign-out was a link, and the framework preloads a page's work when the pointer passes over it; since the logout happened during that preload, brushing the icon — without clicking — really ended the session. Worse: because the effect lived in a plain GET, a link or an image embedded in an email triggered it too. It now requires pressing a button, and typing the URL by hand lands on a confirmation screen. Error messages exposed the institution's internal network: hostnames, addresses and ports appeared on screen; the user now gets a clear message and a reference code to quote to support, with the technical detail confined to the server log, plus a branded error page (a broken link used to land on a blank page with raw text and no way back). The UI could wait forever: with no client timeout, screens neither errored nor recovered when the backend was slow — including the login screen. There is now a maximum wait, with large downloads (expediente export, preservation package, FUID) explicitly exempt: what gets cut off is a server that does not answer, not one that is sending a big file. The Back button showed the previous user's tray: the browser replayed cached pages, so at a shared counter the next officer could see the previous one's radicados and subjects — including classified documents they are not cleared for — and signing out did not prevent it. No screen carrying working data is cached now. A Content-Security-Policy is added as a second browser-level barrier against script injected through a document, a template or an assistant reply, even if the existing sanitisation failed. Finally, when permissions fail to load the application says so: the menu used to just render empty, indistinguishable from having had permissions revoked — the officer called the administrator, who saw their permissions intact and had nothing to fix. — Why: an action with consequences must not happen on hover, and a screen that hides the fact that it is half-working wastes two people's time — Impact: sign-out behaves the same when clicked; the frontend image must be rebuilt on deploy.

  • chore(deps,docker): dependencies brought up to date and services stop running as root (production audit, block 8) — the libraries of all eleven services were updated, including cryptography, the web server and the one that signs the electronic index; that is why the change was not accepted by reading the list: the 2,500+ tests and the real-database suites were run first. The frontend moves to Node 22 — the previous line lost support in April 2025 and was what shipped — and to the latest framework release, which carries the request-size fix from the previous block. Ten of the eleven services ran as root inside their container; all of them process input they do not control (documents, mail, forms), so a flaw in any library handed over the whole container rather than just the process. They now run as an unprivileged user. And the object-store image stops tracking the latest tag: irreversible retention protection lives there, and having it change by itself in a routine update is not acceptable. — Why: running as root voids container isolation exactly where untrusted input is processed, and an unsupported release gets no security fixes — Impact: no visible change in use; images must be rebuilt on deploy.

  • fix(gateway,auth,frontend): the IP address recorded in the audit trail was chosen by the visitor (production audit, block 7) — the system trusted a header anyone can write to decide where each request came from. Two consequences: rate limiting was evaded by changing it on each attempt and — worse — the address stamped into the immutable, evidentiary audit_log was whatever the visitor put there. A chain-of-custody field that can be forged is worse than an empty one, because the empty one deceives nobody. The address is now derived server-side by walking the chain of declared trusted proxies and passed internally over a path the visitor cannot alter; with no proxies declared the header is ignored entirely — a useless datum is preferable to a false one. A day-to-day defect went with it: every user's requests counted as coming from the same address, because the frontend did not forward the browser's, producing "too many requests" lockouts in an ordinary morning at the counter; and public PQRS intake gets its own stricter limit, since it creates radicados unauthenticated and consumes numbers that are never reused. Finally, the audit trail stops writing one entry per request to say the token was valid: that buried the facts that matter — accesses, signatures, reclassifications — under enormous noise, making the log unreadable for the very person who comes to investigate. Invalid-token attempts are still recorded. — Why: an evidentiary log where nearly every row is noise proves nothing, and neither does an address the visitor picks — Impact: the institution must declare its network intermediaries on deploy (TRUSTED_PROXIES); without it, per-address limiting does not distinguish users.

  • fix(ci): the automated tests had never run in continuous integration (production audit, block 6) — the pipeline meant to verify every change died on its first line, because of a configuration difference between how it invoked the lint gate and how the project does; since the tool stops at the first failure, the test run never happened at all — and the pipeline had been giving the opposite impression for a long time. CI now runs exactly the same gate a developer runs locally instead of a copy free to diverge; it runs the eleven services' suites in parallel; and it gains two lanes that did not exist: real-database integration tests (256 tests that were written but had never been executed) and frontend tests, which had none. Version-tagged image publishing is added too — that is what makes rolling back a failed update possible. Actually running it surfaced three problems reading had not revealed, two of them in the fix itself; they were corrected before accepting it. — Why: a pipeline that does not run the tests is not verification, it is decoration — and it grants confidence it has not earned — Impact: CI only; no change in system behaviour.

  • feat(infra): backup and restore now really exist, and have been exercised end to end (production audit, block 5) — the backup procedure lived only as an example inside the guide, and that example did not work: it connected as a test user, copied files to a directory that does not exist and — worst — if the dump failed it still produced an empty file and deleted the previous backups. Run nightly by cron, that means accumulating months of empty copies that look normal. The recovery procedure, for its part, shut the database down and then tried to restore onto it. There are now two real programs. Backup copies database and documents, verifies the copy can be read before accepting it, and only then rotates the old ones; if anything is interrupted the copy is marked incomplete and restore refuses to use it. Restore requires written confirmation and, on finishing, verifies the audit trail's hash chain: that the database starts does not prove the documentary trace is intact, and that is precisely what an entity must be able to attest. If something was tampered with, it says so and points at the entry. The full cycle — copy, destroy, restore, verify — has been executed against a real environment, and the verification was itself put to the test by deliberately altering an entry: it catches it. The upgrade and rollback procedure is added too, including the warning that rolling the program back does not roll back schema changes — that is what the backup is for. — Why: a backup whose restore has never been tested is not a backup, it is a file; and no institution should put official records into a system without that guarantee — Impact: deployment and operations only.

  • fix(gateway,storage,frontend): scanned documents can finally be attached: anything over 512 KB used to fail (production audit, block 4) — the frontend server shipped a 512 KB per-request body limit that had never been configured, and since attachments pass through it any real scanned PDF was rejected with a terse "upload failed". Bulk expediente import was outright unusable. Raising the limit alone would have been worse: each file was buffered whole in memory twice — once at the gateway and once in storage-service — with no size cap at all, so a large file would not have errored, it would have taken the service down. The change therefore does three things at once: files are now streamed in chunks without accumulating in memory, there is an explicit cap that returns a clear message when exceeded, and the frontend limit goes to 512 MB. — Why: a records-management system where a scanned document cannot be attached does not do its job — Impact: attachments and bulk import work; a file above the cap now gets an explicit error stating the maximum size instead of a generic failure.

  • fix(document): editing a radicado now leaves a trace, and an annulment is not undone by editing a field (production audit, block 3) — changing a radicado's subject, addressee, status or folio count was recorded nowhere — striking, because reading it was. An official document with an immutable number that can be edited without leaving a trace does not meet what the documentary trace requires. Moreover an annulled radicado — a decision that goes through request and supervisor approval — could return to active status through a plain edit, with no cause, no approver and no record. The system now rejects that and explains why: reversing an annulment, if it is ever warranted, is an act of its own with its own justification, not the side effect of editing a field. Each modification now records which field changed, from what value to what value, along with who did it; and if that record cannot be written for any reason, the modification is not applied — a change failing is preferable to a change happening untraced. — Why: an archival document's traceability admits no gaps, and an annulment undone through a side path is not an annulment — Impact: attempting to modify an annulled radicado now returns an explicit error.

  • fix(document,archive): the correspondence register stopped being public by default (production audit, block 2) — the two harvesting channels (OAI-PMH and CMIS), meant for other institutions and open-data portals to consult what the entity has decided to publish, were enabled from first boot, and what they exposed depended on each document being classified as reserved: since a radicado's default is "public", the practice was "everything is published unless somebody remembers". Out went each radicado's number, subject and dependencia, and attachment content could be downloaded with no record of who did it. These channels now ship off, and the entity turns them on when it decides to — which is the right order, because publishing the correspondence register is part of the entity's publication scheme (Ley 1712 art. 12), not part of installing the software. While off they answer as if they did not exist; once on, every query and every download is recorded in the audit trail, with what was asked for and how much was returned. — Why: what an entity publishes is its own decision, and it needs to be able to answer afterwards for who consulted its archive — Impact: if your institution already used these channels, they must be explicitly enabled after updating.

  • fix(gateway,tenant,storage): a crooked URL could reach a different place than the one the system thought it was authorizing (production audit, block 1) — the gateway decided where to route each request by looking at the path as it arrived, but the library that forwarded it normalized the path afterwards — the way .. in a folder path climbs a level. Result: a hand-crafted URL could be authorized as one thing and executed as another. That path reached the institution registry, deliberately kept out of public scope, and allowed replacing or removing a radicado's attachment without the trace recording who. The gateway now rejects such paths at the door, before authenticating and before choosing a destination. And that is not the only guard: the institution registry and the attachment-mutating operations additionally require their own service credential, so if one of the two protections failed the other still stands. — Why: if the front door authorizes one path and executes another, none of its decisions hold. And not appearing in a route list is not a protection, it is an absence — Impact: no visible change in normal use.

  • fix(infra): the production deployment manifest had never been executed (production audit, block 0) — the file describing how to bring OrpycaMCP up in a real institution was written but never run, and it failed in cascade: seven of the eleven services never built, database initialization pointed at a path that does not exist (and Docker, rather than warning, silently mounts an empty directory), the identity manager died at startup on an incomplete certificate configuration, and none of the secrets the services use to talk to each other were defined — which produced no visible error, just an apparently running system where not a single attachment downloads and nobody with a second factor can sign in. On top of that, any server reboot shut the institution down permanently. All of it is fixed and verified against real containers, not merely read: the services build, the database runs its initialization and the identity manager starts. The sample environment file now documents every secret, which two services share it, and which ones must be backed up with the same discipline as the database — losing the second-factor key forces the entire staff to re-enrol. — Why: with no manifest that starts there is no possible deployment, and no other security check can actually be verified — Impact: deployment only.

  • 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_TOKEN → MCP_AUTH_USERNAME/MCP_AUTH_PASSWORD; over HTTP the bearer still arrives via header; 27 tests green (4 new). E18 (MCP layer) complete: tools + resources + prompts + stdio + HTTP streamable + per-session 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/calculate — Why: 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(shared,gateway,archive,document,signature)!: Block B10 (item 5) — the signed identity the gateway injects (and internal clients propagate) is bound to the request's method and path (ADR-036). What changes: method and canonical path enter the HMAC base (v2) without travelling as headers; a captured identity is useless for another operation on the same service (401 identidad_no_autentica). The canonical path is the request path without the query, decoded until stable. AST guard: in every internal client the signed path is the HTTP call's path. Impact: atomic cut-over — the gateway and the nine services with IdentidadFirmadaMiddleware are rebuilt and deployed together; if two versions coexist, every authenticated request gets 401.
  • fix(auth,gateway)!: administering RBAC no longer works with the read-only permission, nobody can join a group with a higher clearance than their own, and the token is checked for typ/azp (AREA-01-01 and AREA-01-N3 high; AREA-02-04, AREA-03-03 and AREA-04-04 medium; AREA-02-02, AREA-02-12 and AREA-01-15 low; AREA-02-03 and AREA-13-07 tests) Why: USUA_PERM_ADMIN at level 1 (read) was enough to create users and groups, join groups and grant oneself permissions — and it is the same permission required to read the audit log; moreover POST /groups/{g}/members/{self} raised one's own clearance without going through the no-write-up that PUT /groups/{g}/clearance has (effective clearance is MAX(role_clearance) over memberships). The census in shared/tests/test_escritura_exige_nivel.py was blind to APIRouter(dependencies=[...]) gates, which is why nobody saw it. How: (1) every write in rbac.py, urd.py, PUT /groups/{g}/clearance and the administrative removal of ANOTHER person's 2FA and signing key require USUA_PERM_ADMIN min_crud=3; reads stay at level 1 (auditors keep access). The census now reads router gates (balanced parentheses, any *_router variable): it found 16 more writes open at read level (signature chain, webhooks, DLQ ×3, third-party creation) that are declared, not approved, in SIN_NIVEL_DECLARADAS. (2) add_membership_guarded: the no-write-up lives in the INSERT's WHERE (ROOT exempt; a group without role_clearance counts as 1) and a rejection is 403 no_write_up with no audit entry. (3) PATCH/DELETE /groups/{id} (409 if it has members or URDs, checked in the same statement that deletes). (4) validate_token requires typ=Bearer, azp in ALLOWED_AZP (default orpycamcp-frontend,api-gateway; empty = nothing passes) and mandatory exp/iat/iss/sub. (5) /validate emits expires_in and the gateway caps its cache TTL with it; without expires_in it does not cache (the cap by exp was inert: the response never carried it and the test used a dict the producer does not emit). (6) Validation-cache entries are sealed with HMAC-SHA256 bound to their key (AUTH_CACHE_MAC_SECRET, gateway-exclusive; without it the cache is off) and the cache path again requires valid is True. (7) /audit/verify returns only ok and total_roturas to callers below maximum clearance (total − X-Total-Count was the exact number of hidden classified entries). (8) totp.desenrolado, totp.desenrolado_con_recuperacion and totp.restablecido_por_admin are written inside the removal transaction and without a silent except (if the entry fails, the factor is not removed). Verification: auth-service 309 with RUN_INTEGRATION=1 (273 unit + 36 integration against real Postgres: grant_root, TOTP atomicity, membership no-write-up, group deletion), api-gateway 112, shared/tests 174, lint.sh clean. Mutations (each fails tests, restored and rebuilt): dropping min_crud=3 from the permissions PUT (the census also catches it), dropping the level condition from the INSERT, swapping the strict audit entry for the best-effort one, dropping the typ check, the azp check, HS256 in algorithms, emptying ALLOWED_AZP, returning total to everyone, dropping expires_in, moving grant_root's audit entry out of the transaction, dropping its idempotency, accepting any cache MAC, MAC without the key, dropping the valid recheck, widening the proxy allowlist with identity headers and TTL = max. One inventory mutation turned out equivalent: reversing the order of headers.update(extra) fails nothing with the current allowlist (client identity headers do not pass), so a test was added that widens the allowlist and pins the order. Impact (behaviour change): any profile that today administers RBAC with USUA_PERM_ADMIN at level 1 or 2 will get 403 on writes: raise it to 3 before deploying (demo groups already have 3). Tokens not from orpycamcp-frontend or api-gateway (e.g. a new service client) get 401 until added to ALLOWED_AZP. New mandatory production variable: AUTH_CACHE_MAC_SECRET on the api-gateway (${…:?} in the production compose; without it the gateway serves but without validation cache). Rebuild auth-service and api-gateway. Not done: a read-only audit permission separate from USUA_PERM_ADMIN (needs a migration with backfill and a naming decision); an opaque identifier in the audit listing (id gaps remain the declared RF-FIR-15 residue); raising min_crud on the signature chain, webhooks, DLQ and third parties; per-service Redis ACLs and asymmetric identity signing (AREA-02-02, structural recommendation).

  • fix(signature,storage,archive,gateway)!: the institutional seal and the Object-Lock are no longer reachable with a user JWT (AREA-04-07 critical, AREA-08-16 and AREA-08-07 high, AREA-08-06 medium) Why: POST /signature/sign-xml stamped the entity's seal on an arbitrary XML for any human holding PERM_FIRMA level 1 (bypassing archive's gates), and the gateway routed all of /preservacion/: a user with USUA_PERM_EXPEDIENTE:3 could take the irreversible WORM singleton (COMPLIANCE, up to 100 years) of any expediente with an arbitrary file_id and a short term, or set a legal_hold with no act to lift it, blocking final disposition forever. How: (1) /sign-xml additionally requires X-Internal-Token for the XAdES allow-set (indice, acta_transferencia, acta_eliminacion) and objeto_tipo becomes a closed set (422 otherwise); archive-service sends it in sign_xml/sign_xml_outcome alongside the signed identity. (2) storage seals /aip, /proteger-indice and /proteger-artefacto behind X-Internal-Token (archive keeps the signed identity for attribution). (3) The gateway routes /api/v1/preservacion/ through an allow-list (plan, eventos, migrar-formato, migraciones/); system acts return 404 route_not_found. (4) Defence in depth: a second protection of the same (expediente, tipo) with a different file_id → 409 worm_singleton_conflict, nothing uploaded. (5) legal_hold is no longer a body field of any protection route (422); it is set/lifted only by the internal act POST /preservacion/legal-hold-internal (mandatory motivo + acta_ref; enable/disable_object_legal_hold on the exact version_id; row + worm.legal_hold_fijado|levantado audit entry in one transaction). False comments fixed. Audit remediation of the batch: (a) /sign-xml no longer signs on behalf of non-existent or foreign objects: for chain types (radicado|expediente|borrador) the object must exist and be visible to the signer, with clearance inside the query (FILTER nivel_seguridad <= $2; neutral 404 objeto_no_encontrado; borrador is not resolvable in this service so it is not signed through this raw route) and the gate rises to PERM_FIRMA min_crud=2 (the frozen exception in test_escritura_exige_nivel.py is removed: 86 to 85). Declared behaviour change: a user holding PERM_FIRMA at level 1 who closes an expediente no longer seals the index under their own identity; it stays pendiente_firma and reconciliation seals it as SYSTEM. (b) legal hold now has a user act: POST /api/v1/expedientes/{id}/legal-hold in archive-service (USUA_PERM_ADMIN crud>=3, motivo >=10 and acta_ref mandatory both ways, expediente visible by clearance or neutral 404, own audit entry archive.expediente_legal_hold_fijado|levantado) orchestrating legal-hold-internal with token + signed identity. (c) Storage: fijar_legal_hold takes the (tenant, expediente) advisory lock (concurrent set/lift can no longer diverge from the row); it also reaches the AIP replica (a missing replica is tolerated, any other error aborts) and records copias_alcanzadas in the audit entry; and lifting audits first worm.legal_hold_levantamiento_solicitado (own tx) and only then touches the store, with worm.legal_hold_levantamiento_fallido if the store fails, so a released object is never left without an audit entry. Verification: signature-service 422 pass (4 skipped), storage-service 408 pass (43 skipped) and 33 integration against real Postgres, archive-service 925 pass (217 skipped), mcp-server 232, shared/tests 171; lint and mypy clean on signature, storage and archive. Mutations (each fails tests): remove min_crud=2, remove the object visibility check, remove the clearance FILTER, lift without the prior request entry, skip the replica, do not tolerate a missing replica, remove the lock, remove the failure entry, remove archive's entry, remove expediente visibility, swap the permission for USUA_PERM_EXPEDIENTE, drop the token from archive's client. Not done: the gateway allow-list still matches by prefix (/plan, /eventos; informational, no future subroute exploits it today); the legal hold act reuses USUA_PERM_ADMIN instead of its own permission (seeding one needs an auth-service migration and a product decision); and the act is deliberately not exposed as an MCP tool.

  • 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 + .dockerignore — Why: 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 public — Impact: 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_sub→auth_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_steps — Impact: 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_created→workflow.step.created, flow_step_completed→workflow.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_headers — Impact: 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_log — Why: 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_common — Why: 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