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.mdlacked 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-LTAArchiveTimeStampis 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 theWHEREper 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 consumesdocumento.contenido_cambiadoand voids the requests whosepayload_sha256is 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 afirma.circuito_invalidadoentry in the same transaction, only if something was voided; the synchronous internal endpoint requires{cuerpo_sha256_vigente}(422without it); signature's DLQ replay dispatches byevent_type(it always applied the reclassification before). 3 (knowledge) —searchandget_pivot_chunkgate withGREATEST(fragment level, current radicado level)in theWHERE; hourly monotonic sweepreconciliacion_nivel(raise-only; tombstones external-provider fragments ≥ RESERVADA) with aknowledge.nivel_reconciliadoentry (channelsystem); the false docstring iningestion.pyis fixed. 4 (document) — a validly signed postal callback for a suspended entity answers202 deferredand is queued (tenant migration048); the sweep applies it on reactivation (phase 5) with the reception date; full queue (POSTAL_DIFERIDOS_MAX=10000) →503withRetry-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 afterremote_signanswers503withtotp_posiblemente_consumidoand recordsfirma.autoverificacion_fallida_tras_totp(chain signing too). 7 (signature + storage + archive) — renewal of the XAdES-LTAArchiveTimeStamp(TimeStampValidationData+ a new ATS over everything before, EN 319 132-1 §5.5.2):firma_sello_archivochain (signature migration026), storage migration022(firma_lta_renovada,renueva_a) with WORM inheritance and PREMISRENOVACIONevent;/verifyvalidates every ATS (rulearchive_timestamp_cadena_rota) and accepts the original or any current XML of the chain;GET /firmas/{id}exposesminio_key_vigenteandarchive_timestamp_ordinal;firma.lta_renovadaevent (best-effort, no consumer yet); daily job (LTA_RENOVACION_MARGEN_DIAS=90,LTA_RENOVACION_INTERVALO_S=86400,0turns 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_jobnarrowed with--slug(1.35 s). Impact: apply tenant migrations document048, signature026and storage022. Deploy auth and signature together (cert_fingerprintrequired insign-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 forfirma.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 answers404 route_not_found, not500(residues declared in ADR-036: no query or body,%2Fequivalence); the postal callback body acceptsocurrido_at(ISO 8601 with zone, inside the HMAC), stored asocurrido_operador_at:tsis the reception of the operator's notice, not the date of the event;413 cuerpo_demasiado_grandeat 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 (discardshuella_no_coincide) and afterPOSTAL_DIFERIDO_MAX_INTENTOS=10discards with adocument.envio_callback_diferido_descartadoentry without blocking the queue; the return notice also reaches the current holder (location mailbox +flujo_usuario) and, withresponde_a, the inbound's mailbox and holder, deduplicated by address and gated in theWHEREper recipient; the/antecedentespivot is gated in theWHERE(max_clearance). L (signature, storage; ADR-034 §5) — no renewal over an already expired TSA (linkcaducado,firma.lta_cadena_caducadaentry);/verifyjudges the last ATS at the current date (lta_vigente; if it no longer proves, it drops to XAdES-LT with thearchive_timestamp_caducadowarning and no deferred validity); theTimeStampValidationDatacarries 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 withoutRevocationValues; CRL only, not OCSP); rejection, TSA-expiring and no-revocation entries are written once per reason and link (ultimo_aviso); becoming current leavesfirma.lta_sello_vigente; the job walksactiveandsuspendedentities; only a404 borrador_not_foundcounts as a vanished draft; link 0's expiry comes from the TSA fingerprint; a token without certificates leavesfirma.lta_sin_eslabonwithout breaking the signature (signature migration026edited:caducado,ultimo_aviso, view). storage renews retention in cascade to thefirma_lta_renovada(renueva_a) with PREMISRENOVACIONretencion_en_cascada, andrenovar-artefacto-internalrequires the previous object to be a signature and the new one XML (422 artefacto_no_es_de_firma) and inherits the chain's largestretain_until. L-H3 (storage, archive; ADR-035 §8) — a suspended entity keeps preserving: storage'sget_db_connadmits, withX-Internal-Tokenand only for the explicitRUTAS_DE_CONSERVACIONlist (app/core/conservacion.py), the preservation operations, with anentidad.conservacion_admitidaentry;inactiveand 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/verifyreturnsfirma.lta_vigente. R2 — thedocument.envio_estado_operadorentry (and_no_aplicada) recordsocurrido_operador_atandrecibido_at; anocurrido_atlater 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 variableTSA_CA_PEM_PATHS, comma-separated PEMs), and an expired chain leavesfirma.lta_cadena_caducadaonly once even with several links;GET /expedientes/{id}/indice/verifyverifies the current (renewed) XML and, if signature cannot resolve it, falls back to the original with theartefacto_vigente_no_resueltowarning; storage's preservation exception matches the exact path (fullmatchonscope["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_usersunqualified withpublicin thesearch_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/verifyremoved 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 folded403 tenant_inactivointo503 auth_service_errorand login kept issuing tokens to a suspended entity. Testing live also showed that the first gate design (inrequire_permission) left reads that need no permission open:GET /workflows/inboxanswered 200 for a suspended entity. What changes: N1 —orpycamcp_common.tenant.atencion_publica(COMPLETA | SOLO_LECTURA | NINGUNA): asuspendedentity with a provisioned schema servesGET /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}/ventanillaand the three preflight reads) answer503 canal_no_disponible+Retry-Afterbefore its schema is set. Non-existent, deactivated (inactive) and unprovisioned entities keep the neutral404 not_found. The gateway now forwardsRetry-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 = neutral404; with an operational entity the401 firma_invalidais kept. N3 —X-Identity-TsjoinsCAMPOS_FIRMADOSandidentidad.verificarrejects an identity older thanIDENTITY_MAX_AGE_SECONDS(120 s), more thanIDENTITY_MAX_SKEW_SECONDS(30 s) in the future, or without an integer timestamp; andget_db_connof archive, workflow and storage callveredicto_entidadbefore handing out the connection (only when the request carries a validX-Tenant-Slug). N4 —POST /auth/tokenand/auth/refreshissue 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'sresolve_identitypropagates those codes instead of503 auth_service_error. Tests: document 1172, archive 1692, workflow 522, storage 625 (15 skipped), auth 444, mcp-server 276, api-gateway 187,shared/tests540 passed. Lint clean. One mutation per fix, all fail. Live withdemosuspended: login 403;verifyand public disposal 200; PQRS andventanilla/estado503 withRetry-After; replayed identities (10 minutes old, rejuvenated or without timestamp) 401;demorestored toactive/active. Impact: the whole stack verifies the signed identity with its timestamp: rebuild and deploy the gateway and the nine services that mountIdentidadFirmadaMiddlewareTOGETHER; a lagging one answers401 identidad_no_autenticato everything coming from the gateway. The frontend must show «Entidad suspendida» on the login and refresh403 tenant_inactivo, and treat503 canal_no_disponibleon 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: theWHEREofavalar_lote_eliminacionrebuilds from thetenant.updatedentries ofpublic.audit_logthe intervals in which the entity was notactive(the ventanilla, the only opposition channel, answered503) and requires the operative days betweenpublicado_atand yesterday to reach the original period; otherwise409 plazo_oposicion_interrumpidowithdias_suspendidosandplazo_efectivo_hasta, and the record declaresplazoOposicionEfectivoHasta/diasPlazoInterrumpidoonly when there was an interruption (no new endpoint: the effective period is derived). D (document)GET /public/{t}/ventanilla/politicabecomes a read (get_public_tenant_conn) served while the entity is suspended; it readssgd_param_admindirectly because tenant-service vetoes a suspended entity (verified live: through it, it answered503 politica_no_verificable). E (signature) the autouse_nivel_vigente_neutrofixture no longer reachestests/integration. F the 025 test filterspg_constraintbyconrelid = '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_updatereturns(nivel, locked_copy)and thenivel_anteriorof thefirma.solicitud_reclasificadaentry comes from the locked copy. Declared debt (B, Medium): the postal callback with the entity suspended answers503with a fixedRetry-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 theWHEREcondition 2 fail); document-service 1173 passed; signature-service 754 passed (7 skipped); auth-service 381 passed (66 skipped; +4 revocation tests, mutation: withoutlogout3 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 asuspendedtenant; that was partly false (document-service did, with no test at all) but true for the other ten: theget_tenant_connof auth, knowledge, notification, signature and tenant only validated the slug shape, archive/workflow/storage set thesearch_pathin more than twenty places, a missing schema ended inUndefinedTableError(500), and every public route (/public/*, CMIS, OAI, archive disposal) used its own existence predicate, or none (public_eliminacionignoredstatus). Separately, the gateway bound theX-Origen-Canalmark to the client'sX-Tenant-Slugheader instead of the token's tenant (the mark only affects thecanallabel of an audit entry, but that label is evidentiary). What changes: B9-1 —orpycamcp_common.tenant.estado_entidad(OPERATIVA | INACTIVA | NO_PROVISIONADA, one query withto_regnamespace) andveredicto_entidad(fail-closed: a down registry is503)./auth/validatereturnstenant_estadoand does not provisionauth_usersin 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_disponiblewhen the field is missing ordesconocido;desconocidois not cached; a suspension takes effect after at most the 15 s validation-cache TTL); the sixget_tenant_conn(auth, document, knowledge, notification, signature, tenant) callveredicto_entidadbeforeSET search_path. Public routes: document usesget_public_tenant_conn/public_tenant_conn(malformed slug, non-existent, suspended or unprovisioned entity = the SAME404 not_foundwith the same body (superseded by block N above: a suspended entity moves to public read-only); no more400 invalid_tenant, nor empty list/habilitada=falsefor a missing entity), CMIS, OAI (document and archive) and archive's public disposal replaceto_regclass/SELECT 1 FROM tenantswithestado_entidad. Event consumers and background tasks are not blocked (closingreclassifiedconvergence would open a leak; see ADR-035). B9-4 —derive_canalreceivespayload["tenant_slug"]and notrequest.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; 5estado_entidadcases on real Postgres and 2 onvalidate); document-service 1166 passed (RUN_INTEGRATION=1; includes the403 tenant_inactivoofget_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/tests506 passed (shape guardian over the sixget_tenant_conn, plusestado_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 a403 tenant_inactivofrom/auth/meand login, and treat404on the public window as «entity not found». Known risk: a superadmin token carrying the suspended entity'stenant_slugcannot 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 deadsign-xmlproxy is removed. What changes:(app)/+layout.server.jsclassifies the/auth/merejection (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 returnsentidadNoOperativa; the layout rendersEntidadNoOperativa.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: the404 not_foundon/radicacion,/eliminacionand 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 readsolicitud_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), andCertificadoDrawershows a retired-scheme notice foresquema_hmac_retirado(firma_criptograficamente_validamay benull). UX-review fix: with the entity suspended the/public/{t}/ventanilla/{tipos|politica}GETs and the POST return503 canal_no_disponible;/radicacionshows a "reception temporarily suspended; queries and verifications remain available" notice linking to/consulta;admin/+layout.server.jsno longer redirects to/403when the entity is not operational,(app)/+error.svelterenders child-pageerror(403)inside the layout, andEntidadNoOperativausesrole="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-xmlstops signing radicados and thekey_version=0HMAC scheme is retired Why:firmarand/challengedecided the XAdES gate, the mandatory 2FA at level ≥ 2, the e-mail veto and the audit entries from thefirma_solicitud.nivel_seguridadcopy read WITHOUT a lock, and that copy is synchronized by event (late, dead-lettered, or never when the chain was declaredborradorover a radicado's UUID) (SEG-B4);/sign-xmlacceptedradicado|expediente|borradorand gave a holder of justPERM_FIRMAa verifiablefirmarow that bypassed the chain;key_version=0was still alive. What changes: B9-2nivel_vigente_for_update(FOR UPDATE+max(copy, live object level)) and_sol_con_nivel_vigenteinside thefirmarand/challengetransactions (clearance → neutral404, XAdES gate, 2FA, e-mail veto, audit entries; the copy is corrected in the same transaction;firmarnow locks the request row before the turn, like/challenge); B9-3/sign-xmlis institutional seal only (422for any otherobjeto_tipo) and always requiresX-Internal-Token; B9-5 scheme0retired (/verify→motivo=esquema_hmac_retirado, migration024drops theDEFAULT); the no-read-up404of the chain issolicitud_not_foundin both/signand/challenge. Audit corrections (signature, S1-S11): S1 chain reads (GET /requests/{id},por-objeto,/pendingwith rows andX-Total-Countunder the same condition) are gated withGREATEST(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 thefirma.solicitud_reclasificadaentry (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; thereclassifiedevent keeps its semantics. S4firma.turno_firmadocarriesnivel_seguridad(live) andnivel_copia, and/sign-personal'sfirma.creadacarriesnivel_seguridad. S5/S10rechazaranddar_visto_buenore-read the level under lock and take the request row before the turn (same order in all four paths). S6/verifywith a retired HMAC scheme returnsfirma_criptograficamente_valida=null(bool | None), notfalse. S7FirmaRepository.insertrequires an explicitkey_version=1for native signatures and tenant migration025deletes the synthetic v0 row and addsCHECK (provider <> 'nativa' OR key_version = 1). S8providers/native.pydocstring. S11 the 7 literal copies of the neutral 404 body incadena_service.pybecome one helper,_solicitud_no_encontrada(). Tests S: signature-service 752 passed, 7 skipped (RUN_INTEGRATION=1) and 697 passed, 62 skipped without integration; newtests/test_correcciones_b9_signature.py(15) andtests/integration/test_correcciones_b9_integration.py(8), each with its demonstrated mutation (13 mutations, all fall). New autouse fixturelectura_vigente_real(neutralizes S1 in the mock suites). Impact: apply migration025per tenant. Tests: signature-service 717 passed, 7 skipped (RUN_INTEGRATION=1); 14 new tests, each with a demonstrated mutation. Impact: apply migration024per tenant; native signatures from before020stop verifying (accepted). Frontend lane pending: remove the unused/firmas/api/sign-xmlproxy. -
fix(signature,auth,shared,infra)!: batch B7 —/challengedemands what/signdoes,/auth/mefor 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 /challengeissued 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 (/signanswers409 documento_modificado); (b)GET /auth/mewith theX-Tenant-Slugof an entity without a schema returned500 UndefinedTableError; (c)restore.shtreated a deadpg_restorewith no error lines as harmless andbackup.shaccepted an empty mirror; and (d) the production compose published no port while the nginx template pointed at127.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_turnois the single piece of code for membership, state, turn, signing role and annulled radicado forfirmarand/challenge;/challengenow reads BEFORE the transaction (likefirmar; inside only theFOR UPDATEre-read) and adds409 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_requeridowith no mail,403 identidad_incoherentewith avia=signing_key_statusentry,422 firma_xades_requerida/503 canal_firma_personal_no_disponibleunder the gate). With no active turn/challengenow answers409 solicitud_no_pendiente(like/sign) instead of403 no_es_su_turno.422 payload_no_fijadois removed from the contract: it no longer existed. - F3 — auth:
get_optional_tenant_connchecksto_regclass(format('%I.auth_users', schema))beforeSET search_path; an entity without a schema (pendingor nonexistent) is200withpermissions=[],permission_levels={}anduser_id=sub. The 400 for a malformed slug stays. - P1/P2 — infra: an
edgeservice (nginx:1.30-alpine@sha256:…) as the ONLY one withports:(80:80,443:443), templateinfra/nginx/orpyca.conf.template(replacesinfra/nginx.conf.example) with a TLSserverfor frontend, API and Keycloak (only/realms/and/resources/;/admin/and/realms/master/denied; larger header buffers),proxy_passto variables withresolver 127.0.0.11and network aliases for the three domains. The network declaresip_range 172.20.128.0/17andgateway 172.20.0.1; the defaultTRUSTED_PROXIESchanges from172.20.0.0/16to172.20.128.0/17so the network's gateway is not trusted and the IP sealed inaudit_logcannot be the one the client forged. - P3/P4/P5:
postgres,redis,keycloak,edgeandamazon/aws-cli(previously:latestinbackup.sh/restore.sh) by tag and digest, also indocker-compose.yml;infra/.env.examplecarriesORPYCA_REGISTRYandORPYCA_TAG(active) andPDFA_PROFILE,SIGNER_LT_REQUIRE,SIGNER_OCSP_REQUIRE,WEBHOOK_SECRET_KEKS,WEBHOOK_SECRET_KEK_ACTIVE_VERSION(commented);whisper-servicestays out of the v1 production manifest (intentional, declared). - R2/R5/R5b — backup:
restore.shtolerates a failedpg_restoreonly if there is at least one error and all of them are the inherited PK of theaudit_logpartitions; after recreating each protected bucket it checks withget-object-lock-configurationthat locking isEnabledand aborts before touching data otherwise.backup.shno longer uses|| truewhen listing buckets and requiresorpycamcp-<slug>-documentsof everyactivetenant in the mirror. - Fixed before the batch, by the orchestrator: R1
restore.shcalledrestaurar_versiones_previasbefore loadinglib-retencion-s3.shand definings3api("command not found": every restore died after restoring Postgres); R3s3 sync ... || trueis now fail-closed; R4limpiar_descifradowith[ ] && rmreturned 1 in the EXIT trap underset -e(a complete restore exited rc=1); R5 network:backup.sh/restore.shtook the FIRST compose network (in productiondefault, without the object store ⇒ empty mirror) and now use theminioservice's; P0docker-compose.prod.yml: thex-migracionanchor declared no network so the ninemigrate-*landed indefault, withoutpostgres(no migration finished and no service started); M1mailhog:latest(abandoned) replaced byaxllent/mailpit:v1.31.4@sha256:b68349e3…with ports on127.0.0.1andSMTP_HOST=mailpit(red_interna.pyand notification config updated). - Conformant XMLDSig (ADR-034) — signature: live verification against EU DSS gave
TOTAL_FAILED / HASH_FAILUREon EVERYTHING we signed:signxmlleaves the references toSignedPropertiesandKeyInfowithoutds:Transformsbut 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 inverify_xades(default_reference_c14n_method=EXCLUSIVE) that made only OrpycaMCP able to verify what it signed. Now_declarar_transformsadds exc-c14n to every reference withoutTransforms(digests do not change),_firmar_signed_infosigns the already-patchedSignedInfo—one path for the seal and the personal signature; the digestremote_signreceives comes from the finalSignedInfo— and_autoverificarchecks it with a defaultXAdESVerifier()before the timestamp (503 firma_no_conforme_xmldsigif it fails; nothing is persisted).verify_xadesverifies only conformantly: the shortcut is gone; earlier signatures in the test corpus stop verifying (no migration, no "legacy" branch)._archive_ts_covered_bytesstops 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 itsMESSAGE_IMPRINTas not intact; of eight compositions tried, only the standard's validates. False docstrings fixed (local.pycalled 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↔/signparity, 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 anitest-…slug for/auth/mewithout a schema);shared/tests479 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 fixedtest_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 intest_xmldsig_conformidad.py—new signature verified with default signxml and withCANONICAL_XML_1_0, an independent lxml+cryptography verifier, the digest sent toremote_sign== the finalSignedInfo, theArchiveTimeStampinput rebuilt separately, negative control with a real pre-fix artefact— and 4 intest_dss_smoke.py, which only run withDSS_URL). Mutations, each knocked down by its own test: no-op patch, digest before patching, reintroducing the shortcut inverify_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:SignatureIntactand everyDataIntact,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/masterreturned 200 under the previous denial; now 404, and so is any path with;); SEG-B1/health/readyonly from127.0.0.1(172.20.0.1removed; probe viadocker exec); SEG-M2pg_restoreis harmless only with exit code 1 anderrors ignored on restore: N== inherited PKs (> 0); ARC-M3 the Object-Lock pre-flight now runs BEFORE stopping services andpg_restore(in the code it ran AFTER: "no data has been touched" was false); ARC-M4RESTORE_OPERADORmandatory (fail-closed before confirmation) and thestorage.restauracionrecord names the operator (actor=operador:<name>) and declares the lost-trail window per tenant (end ofaudit_lognoted beforepg_restorevs the restored one; no capture, no record); ARC-B3backup.shalso requiressuspendedtenants (status <> 'inactive'); ARC-B4 api-gateway refuses to start withAPP_ENV=productionifTRUSTED_PROXIEScovers the network's gateway (GATEWAY_RED_DOCKER_GATEWAYor 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_vencidobeforeremote_sign, without consuming the TOTP) and_autoverificarno longer clampsverification_time; ARC-M1/verifyof an XAdES-LTA with a validArchiveTimeStampand a certificate that expired after signing judges validity at theSignatureTimeStampdate (with a warning; without a valid LTA it stayscertificado_fuera_de_vigencia); SEG-B2/ARC-B1XmldsigNoConformeErroron the personal routes is503 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 intest_vigencia_del_certificado_y_sellos.py, one rewritten), auth-service 414 passed (6 new integration tests ofregistrar_restauracionon a real Postgres), api-gateway 170 passed (11 new),shared/tests489 passed (guards for the Keycloak allow-list, the probe, IPv6,pg_restorerc/N, pre-flight, operator, prior end-of-trail andsuspended). Clean lint (ruff + mypy) on all three. One mutation per fix, all of them fail. EU DSS:tests/test_dss_smoke.py4 passed against DSS at127.0.0.1:18765, after deploying signature-service. Declared debt of the round: SEG-M3 (asuspendedtenant is not enforced in any service: new gateway functionality), SEG-B4 (re-readnivel_seguridadunder lock when signing),ArchiveTimeStamprenewal, self-verifying the personal signature before consuming the TOTP, and a realnginx -t(it also confirms thelisten [::]lines, already written; the image was not pulled because of disk). Impact: an inheritedinfra/.envwithTRUSTED_PROXIES=172.20.0.0/16reopens the defect (change it to172.20.128.0/17or delete it); one certificate per domain must be issued before the firstupand renewals move to webroot; Keycloak's console stops being public (SSH tunnel).infra/nginx.conf.exampleis removed. Deploy signature-service and auth-service. Not verified here: a realnginx -toredgerunning (image not pulled because of disk state); runexec edge nginx -ton first deploy. Declared debt: Keycloak 24.0.5 is out of support;get_tenant_connstill returns 500 for a tenant without a schema on the other auth endpoints, and asuspendedtenant 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.createrequires, fordoc_type=Swith a human actor,origin_dept_codeto be a dependency with an active URD of the actor (422 origen_fuera_de_sus_dependencias); coversPOST /documents, quick reply, drafts (duplicate check removed) and each batch item (fails before the sequence).PATCHonly validates the type whendoc_classchanges. INT-05 movesfrom_borrador/canal/consentimientofrom the ZIP tometadata.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 validateddoc_classagainsttipos_documentales(via API/MCP a record was born without a TRD series, i.e. without retention), thatcanal(feeds the Law 1581 publication gate) andconsentimiento(evidentiary consent record) could be supplied inmetadata, thatfrom_borradorwas not in the audit entry and the metadataPATCHcould delete it, that a draft Salida accepted anyorigin_dept_code, and that the parameter allowlist admitted 10 keys from migration 002 that no service reads. What changes: document —app/core/tipos.pyis the ONLY place that queries the catalogue (the table lives in the same tenant schema: no HTTP client, likeurd_usuaroldep):create(beforenext_sequence), the retypingPATCH, every batch item and the public window (which still also requiresadmite_ventanilla_publica) →422 tipo_documental_desconocido;CLAVES_RESERVADAS = {from_borrador, canal, consentimiento}is rejected inDocumentCreate,DocumentUpdate, quick reply and draft, and the system sets them viaprocedencia(the public window no longer sends them inmetadata; they no longer go through the type's template); the metadataPATCHpreserves them;document.radicado_createdcarriesfrom_borrador; draft Salida:origin_dept_codelimited 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 throughget_caller(make_require_any_permission(identity=)): 401 without identity and ausuario_no_aprovisionadoentry, likerequire_permission; thetest_escritura_exige_nivel.pyguardian recognisesrequire_any_permission((an any-of write also requiresmin_crud>=2), with a positive control. tenant — the 10 inert 002 keys leavePARAMS_ADMITIDOS(PUT422) and move toPARAMS_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 sendingcanal/consentimiento/from_borradorinmetadatanow gets 422. Pending (roadmap): types without an active template storemetadataunvalidated, and the frontend labels use the ACTIVE template rather than the saved version (metadata_template_id). -
fix(auth,document,tenant,frontend): B6 //adminguardian 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/camposandGET /plantillasrequired onlyPERM_RADIwhile/admin/flujos,/admin/grupos,/admin/correoand/admin/plantillasopen withUSUA_PERM_ADMIN; and thePUT /config/params/{key}upsert stored any key in a table every tenant user can read. What changes:orpycamcp_common.rbacgainscheck_any_permission(single= ANYquery, fail-closed) andmake_require_any_permission(denial audit entry likerequire_permission,perm_name="A|B"); auth and document instantiate it asrequire_any_permission. The four reads become any-of {PERM_RADI,USUA_PERM_ADMIN} with no extra exposure (the existingWHEREclauses decide the rows;?todas=truestill requiresUSUA_PERM_ADMINin the body).PUT /config/params/{key}accepts only the 21 keys seeded by the tenant migrations (422 config_param_desconocidootherwise); a guardian (shared/tests/test_params_admitidos_son_los_sembrados.py) cross-checks them with the seed both ways. Frontend guardian:gatesFastapi.jsreadsrequire_any_permission(eachGatecarriesanyOf),contratoAdmin.jsevaluates it per arm, obsolete exceptions are removed (pickable, email/cuentas, plantillas/campos);flujos GET /workflows/pendingis relabelled as a scope gate in the body and theindice/firmarreason 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=truestill 403 with RADI only),check_any_permissionagainst real Postgres, allowlist 422/200 andshared/guardians (require_any_permissioncounts as a gate; not redefined outside the library). Impact: deploy auth, document and tenant (only those three use the neworpycamcp_commoncode; the rest pick it up when rebuilt)./admin/parametrosstill offers a free-form "new key": itsPUTnow 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}/radicarnow mapsMetadataValidationErrorto422 metadata_validation_failedwithfield, before the sequence number is drawn (no number is spent; the audit entry stays atomic with the radicado). The body acceptsmetadata(BorradorRadicar); thefrom_borradorprovenance 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 withSchemaField; 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 mirrorsREQUERIDOS_POR_TIPOwith a parity guard againstdatos_minimos.py. New read-onlySchemaValuein/expedientes/[id](was[object Object]and raw key) and in the/bandejadetail (it loadedmetadataand never showed it)./envios: the receipt error is tied to the file field (aria-describedby,aria-invalid, focus) plus 4 new modal cases.EditarMetadatosModalandTransaccionModalare 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,codigois 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 joineddependenciasby 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 onDEPE_CODI); namesakes were confused (borrador_serviceregistered towardsORDER BY codigo LIMIT 1silently);codigowas editable and 8+ tables in 5 services cite it with no FK. Now: tenant migration013(immutable-code trigger, name history, singleresolver_codigo_dependencia), document046and workflow019(code columns, fail-closed backfill with insert-only evidence anchored inaudit_log);PATCHrejects 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); newPOST /dependencias/{id}/mapeos-nombre; APIs take*_dept_codeand keep the name as a deprecated alias (Deprecation+Link) with422 dependencia_desconocida|ambigua|inconsistente; the origin of a transfer is derived by the server (409 origen_desactualizado); events carry both fields. Impact: apply tenant013→ document046→ workflow019; 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): tenant014— a manual mapping now IS the answer ofresolver_codigo_dependencia(it used to be added to the namesakes and never disambiguated),SET search_path FROM CURRENTon the function and triggers, insert-onlydependencia_codigo_retirado(409 dependencia_codigo_retiradoand a trigger when a deleted code is reused), history seeded fromdependencia.actualizadaaudit entries;referencias()sweepsinformation_schema;mapeos-nombrerejects inactive targets and another active dependencia's current name (irreversible);PATCHtakesmotivo. document047/ workflow020: evidence withmapeadolinked byhistorico_idand fullantes/despuesJSON. Dependencia report: the code-less group gets the fixed label "(sin código)". Last correction round: document047no longer changesresultado(it rewrote evidence anchored by046: the anchor SHA-256 stopped reproducing); it only linkshistorico_idand "mapeado" is derived fromhistorico_id IS NOT NULL(theCHECKis back to the original).047and workflow020write their own anchor (document.dependencia_codigo.backfill_047,workflow.dependencia_codigo.backfill_020);020only fills new columns, none of those019anchors. tenant:mapeos-nombreonly accepts orphan or ambiguous names (409 nombre_resuelto_a_otra_dependencia),PATCHrejects renaming to a name already mapped to another code (409 nombre_mapeado_a_otra_dependencia);referencias()usespg_catalog, countsworkflow_rules.conditions, andhas_childrenruns inside theDELETEtransaction. 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 is409 componentes_sin_object_lock(clasificado/...paths are aggregated intocomponentes_clasificados, never listed). Before serving:sha256(acta_fuid_xml)is rechecked againstacta_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 is409 componente_integridad_discrepanteplus atransferencia.paquete_entrega_incidenteaudit entry (503if capture fingerprints are unavailable). Tenant migration067limits the "update that only nulls" exemption topg_trigger_depth() > 1(a manual UPDATE now raises23514);068adds aBEFORE DELETEtrigger onubicacionrejecting (23514) deletion of a location referenced by arecibidatransfer.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_previascopies the content of each previous version under live COMPLIANCE or legal hold intominio-versiones/<bucket>/<sha256(key+vid)>and lists it inminio-versiones.tsv(a separate file; theminio-retencion.tsvformat is unchanged); it travels inside the encrypted tar; the seal carriesversiones_previas_capturadas=SI|NOandbackup.shexits 5 if incomplete.restaurar_versiones_previas(before thesync) uploads them by ascendingLastModified, verifies the sha256, restores retention on the RETURNEDVersionIdand explicitlyput-objects the current version of those keys; the old→new map includes them.RESTORE_ACEPTAR_PERDIDA_VERSIONESremains only for older backups. auth —registrar_restauracioninserts, in the same transaction as the remap, a PREMISREPLICAevent (detalle.motivo='restauracion') per updated row; withoutpreservacion_eventoeverything rolls back.verify_audit_chainsexits 2 on an empty target unless--permitir-objetivo-vacio(whichbackup.sh/restore.shdo not pass). notification — migration012webhook_entregas(append-only; no FK to the subscription; body sha256 only) andGET /api/v1/webhooks/{id}/entregas;_deliver_onewrites theenviorow BEFORE the POST (if it cannot, nothing is sent) andresultadoafter. Not exercised:backup.sh/restore.shend to end against the live store (restore is destructive). Retention/purge ofwebhook_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 forchecksums.txt) and only then answers withContent-Length; the temp file is removed infinallyand a background task, and every failure happens before the first byte (never a truncated 200). New settingsEXPORT_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 thanContent-Length. Per-radicado no-read-up and thearchive.expediente_exportadoaudit entry are unchanged.feat(archive)!: batch B4 / P1 — delivery package for the secondary transfer, physical custody enforced in the database, and attempt-numbered PREMISdedup_key(ADR-030 §5).POST /api/v1/transferencias/{id}/paquete-entregareturns a ZIP (admin level 3, neutral404no-read-up,409 paquete_solo_secundaria_recibida,409 componentes_sin_object_lockfail-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 deterministicnota-entrega.xmlthat referencesacta_fuid_sha256without rewriting the act, andchecksums.txt. Read and generation audit entries are written in one transaction before the first byte; never exposed through OAI/EAD/CMIS. Tenant migration065adds the custody trigger (same rules and codes as the service422,ERRCODE 23514);066addsfixity_fallos_intentosso the failed-fixity PREMIS key isfixity-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 (iponly from a gateway-signed header pair,ip_verificada, server-computedpolitica_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/estadogainspolitica_publicada; a shipment receipt must be uploaded by the recorder (403 acuse_ajeno) and not already referenced (409 acuse_ya_referenciado, unique index tenant045); storagePOST /internal/files/procedencia; tenant migration012(three emptypolitica_tratamiento_*parameters).verificar-ventanilla.shrequires the policy andseed_demo.pypublishes a demo one. Impact: apply tenant012and document045, rebuild gateway/document/storage/tenant; an entity with no published policy stops accepting online filing until it publishes one;TRUSTED_PROXIESmust include nginx and the SSR.feat(frontend): online single window with the entity's policy and postal-receipt messages (batch B2)./radicacionreads the policy fromGET /public/{tenant}/ventanilla/politica(thePOLITICA_TRATAMIENTO_*variables are removed); the checkbox shows version and link, blocks submission when the entity has no published policy, reloads and asks again on 409politica_desactualizada, and a fixed note links to#supresion./enviostranslates 403acuse_ajenoand 409acuse_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 theWHERE, 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.deadletteris now the single source (LEFT JOIN LATERALagainst the LIVE radicado,max(live, embedded), doubt = level 3) used by list, count, detail, replay and discard; hidden rows answer the same404as an unknownevent_id.X-Truncatedis gone, a missingradicadostable answers503. Migrations: sharedix_edl_keysetindex (workflow 018,signature 023,notification 011,knowledge 013). knowledge also purges terminal rows older than 90 days and (014) keepsnivel_evento_atso a stale replayedreclassifiedcannot lower a later level.fix(auth,signature)!:/internal/totp/verifynow requiresX-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/verifyappliesrequire_internal_token(401 missing, 403 wrong, 503 unconfigured; sameSIGNING_KEY_INTERNAL_TOKEN, no new secrets) and signature-service sends it. New jobapp/jobs/signing_key_maintenance.pyin auth-service's lifespan (SIGNING_KEY_MAINTENANCE_ENABLED,SIGNING_KEY_MAINTENANCE_INTERVAL_SECONDS, default 21600): per active tenant and underpg_try_advisory_lock, it purges revoked credentials pastSIGNING_KEY_PURGE_AFTER_DAYSand re-wraps only rows whosekey_versionis lower than the active one (re-wrap no longer goes backwards if the active version regresses); it writessigning_key.maintenance_runwith 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 withUSUA_PERM_ADMINlevel >= 3 (permission_levelsfrom/auth/me); below that the screen is read-only and a403is explained in Spanish. Helperlib/utils/nivelAdmin.js; testadmin/nivel-admin-escritura.test.js.fix(tenant,mcp)!: writes to dependencias, catalogues and PINAR now requireUSUA_PERM_ADMINat CRUD level 3 (user decision). Level 1 (read-only) used to suffice.POST/PATCH/DELETE /dependencias,/catalogos/...and the 19/pinarwrites return403at level 1 or 2; reads stay at level 1. MCP toolregistrar_avance_pinardeclaresmin_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 upacta-transferencia-sobre-…files with no reference.GET /actaand/acta/verificacionno longer return the grounds or salt (nor record a read on screen visit); newGET .../{excepcion_id}/fundamento?acto=(admin 3,via=lecturaentry).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 (storage021: sixacta_firma_*_sobre_<rol>types, control and multi-artefact; archive064:sobreartefact in the_wormtables, reconciliation/renewal/retry like the other links). No seal:503and nothing written. Mediated read only:GET .../firma-inobtenible/{excepcion_id}/sobre(admin level 3,transferencia.acta_fundamento_leidoentry withvia=sobre).POST .../revocarno 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 commitmentSHA-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 oraudit_log), what is signed/stored/hashed is the exclusive C14N,GET /acta/verificacionchecks the chain links (sustituyeA,revocacionSha256,constanciaSha256, stored hash, signed artefact) and a broken link yieldsvalida=false, every link of a role's chain shares one WORM retention anchor,agotadohas an exit (POST .../worm/reintentar, admin level 3),incapacidadis writtenimpedimento_sobreviniente, revocation requires a closed-vocabularycausa, and reading the plain grounds leaves atransferencia.acta_fundamento_leidoentry. Migration tenant063; record v3. Response fieldsfundamento_hmac*are replaced byfundamento_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/parametrosrejectsnulland holiday writes require admin level 3. Final contract: salted commitment instead of HMAC, mandatory revocation cause, chain links andcadena_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;/actano 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 segmentointernalo*-internalde cualquier servicio (sin distinguir mayúsculas); los prefijos de ruteo sin barra final exigen límite de segmento. signature retiraPOST /internal/hmac-fundamento[/verificar](sin consumidor: archive usa compromiso con sal local; evita un oráculo de diccionario) yPROPOSITO_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 migration061and storage migration019;firma-inobteniblenow takesreferencia_radicado_id/referencia_anexo_id/totp_code, seals before writing (no seal: 503, nothing declared), and a new.../revocaract exists; the declarant cannot be any of the three parties;completitudgainssellada_con_excepcion;fundamentois shown only toUSUA_PERM_ADMIN≥ 3; verification reads the entity XAdES policy and propagates signature'sadvertencias. Verification: archive 1228 passed/275 skipped (1501 passed/2 failed withRUN_INTEGRATION=1, a pre-existing state leak between integration files), storage 526 passed/15 skipped, lint green, 31 mutations all caught. Impact: apply storage019then archive061; 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 labelssellada_con_excepcionas "no party signed; only the entity seal"; allowlisted proxiesPOST /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-catalogmotivo,fundamento≥ 20 chars andreferencia_documentalmandatory). Tenant migration059: 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_declaradaentry in the same tx (the entry carries the hash of the grounds, not their text); a 403 leaves a denial entry./acta/verificacionaddscompletitud=firmada_con_excepcionandexcepciones[];firmada_completanow requires zero exceptions of any role;GET /actacarriesexcepciones_firma.firmar-personalrejects with 409 before spending the TOTP. Effect onacta_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 acceptsacta_firma_{elabora,entrega,recibe}inRenovarRetencionRequest(no service change: it was already per(expediente, tipo)and monotonic); archive, migration060(state/backoff ontransferencia_acta_firma_personal_worm),renovar_worm_firma_personaland a 4th advisory lock inrun_once_worm_renovacion; mirror withGREATEST;acta_firma_personal_worm_retencion_renovadaentry;pendientes-renovacionlists them with theirtipo. 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 withRUN_INTEGRATION=1(real Postgres: record+entry atomicity, CHECK, mutual exclusion both ways, append-only, CT filter and clearance in theWHERE, monotonic mirror); storage-service 463 passed/64 skipped;./scripts/lint.shgreen on both. 15 mutations (declarant guard, engine CHECK, entry without tx,firmada_completawith 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, storageLiteral): all fall. Impact: apply tenant migrations059and060with 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 readsfirma_personal_require_xades(off|clasificados|todos) from the tenant'ssgd_param_adminbefore the signing transaction; the env var is only a fallback when the entity does not define it, and an unknown stored value is applied astodos(never relaxes). tenant-service: tenant migration010_params_firma.sql(seeds"off") andPUT /config/params/firma_personal_require_xadesvalidates the vocabulary (422 valor_invalido). Gate behaviour is unchanged: level ≥ 2 without a certificate is422 firma_xades_requerida, channel down503 canal_firma_personal_no_disponible(never native);offkeeps the fallback and itsfirma.fallback_nativa_canal_caidoentry. The generic/admin/parametrosscreen already lists it. ESSCertID cut-off —TSA_ESSCERTID_CORTE(ISO 8601, default2026-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/verifyreturnsadvertencias[](tsa_sin_esscertid_anterior_al_corte). Content, not identifier — document-serviceGET /api/v1/internal/documentos/radicados/{ref}/huella(SHA-256 of the current PDF) and archive-serviceGET /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 down503; nothing to seal409; chains opened earlier409 cadena_sin_huella). Guard order —403 no_es_firmantebefore409 solicitud_no_pendienteinfirmar,rechazar,voboandchallenge. Not done: HSM/KMS (pending a provider; the hook isauth-service/app/core/signing_keys.py::sign_digest). Verification: signature-service 651 passed/3 skipped withRUN_INTEGRATION=1; tenant-service 186 passed; document-service 1002 passed withRUN_INTEGRATION=1; archive-service's 10 new tests pass but its full suite was not finished (/varfree space below the floor); 22 mutations executed, all caught — mutation M1f (todosmode returning "gate inactive") first SURVIVED and was closed with two newtodostests 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 migration010before signature; exportARCHIVE_INTERNAL_TOKENin production;TSA_ESSCERTID_OBLIGATORIOno longer exists; chains opened earlier over a radicado/expediente must be re-opened. Audit remediation (2026-10-08): (1) High — migration010seeded"off", which beats the environment fallbackFIRMA_PERSONAL_REQUIRE_XADES; it now seeds JSONnulland the new tenant migration011_params_firma_null_no_off.sqlnulls rows still at"off"with noadmin_auditrow for that key (the API always writes one, so an"off"the entity chose is kept); idempotent. (2) High —PUT /config/params/{key}now requiresUSUA_PERM_ADMINat level 3, and relaxingfirma_personal_require_xadesrequiresmotivo(≥ 10 chars,422 motivo_requerido), recorded in the audit entry withrelaja_politica. (3)/verifyadvertencias: list[str]stays an additive contract field (archive-service propagates it). (4)TSA_ESSCERTID_OBLIGATORIOpresent at startup logs an error namingTSA_ESSCERTID_CORTE; both new variables documented ininfra/. (5) the radicado fingerprint is ONLY thees_principalannex (409 radicado_sin_documentootherwise). (6)python -m app.ops.invalidar_cadenas_sin_huellainvalidates old NULL-fingerprint pending chains in bulk, one application-written audit entry each. (7)gate_modoandhuella_documento_sha256added tofirma.turno_firmado;huella_documento_sha256tofirma.solicitud_creada. (8) the 503 for an unavailable producer no longer forwards its body or host. (9) theGET /signature/requests…scope (PERM_FIRMA+ clearance) is declared accepted. Verification: tenant 186, signature 651/3 skipped, document 1002 passed (withRUN_INTEGRATION=1); lint green; 29 own mutations all caught (one survivor closed with a test). Impact: apply tenant migration011; deploy tenant, document, signature; run the ops script once (--dry-runfirst). Frontend:PUT /config/params/{key}takesmotivo, returns 403 below level 3 and422 motivo_requerido. Frontend:/admin/parametrosmirrors theUSUA_PERM_ADMINlevel 3 requirement to write parameters, editsfirma_personal_require_xadeswith a selector (null= "inherited from the environment") and asks for the ≥ 10-character reason when relaxing it; verifications (/verify,/indice/verify,/acta/verificacion) showadvertencias[], 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 onorpycamcp_common: the tenant slug was validated in 14 places with five different bounds (and registration accepted-abc, which no service accepts),check_permissionandresolve_caller_clearancelived as copies in nine services, and 191audit_logentries saidcanal='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, ifpublic.tenantshas 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 addsCHECK tenants_slug_forma_chk. SQL<->SLUG_PATTERNcoincidence guard intest_slug_unico_contrato.py. Why: (1) everydatabase.py(pluskeys.py,auth_service.py,grant_root.py,document_repository.pyand 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, andPreservacionWormRepositorydid 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 sevenclearance.pyfiles andtenant/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_schemaraisingValueError); the ninedatabase.py,keys.py,auth_service.py,grant_root.py,registrar_restauracion.py,document_repository.py, the storage repositories and theTenantCreateschema import it (registration no longer accepts a leading hyphen: 422). Gate —orpycamcp_common/rbac.pyholdscheck_permission,resolve_caller_clearance,nivel_efectivoandmensaje_denegacion; each service re-exports them from itscore/clearance.py/authz.pyand keeps onlyrequire_permission. Channel —audit.append(..., canal: str)is MANDATORY;audit.CANAL_PETICIONdeclares "this act belongs to a request" (inherits the gateway'sX-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) andspawn_superviseddoes 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)) recordsystem.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.mdand 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_permissionincommon: it depends on each service'sget_tenant_conn; it stays local. (c) theobject_refhistory: not rewritten (hash chain); resolved at read time. (d)append_denialkeepscanal=CANAL_PETICIONas its default: a gate denial is, by definition, a request's. Verification: new guards inshared/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: acheck_permissiondefinition in workflow andreturn Truefor a null user both fail) andtest_el_asiento_declara_su_canal(AST census of 190+ calls; mutation: droppingcanal=fromindex_service.pyfails; a background task recordssystemeven if it declaresCANAL_PETICION) —shared/tests395 passed; on rebuilt images: document 752 passed/227 skipped, auth 316 passed/44 skipped (plus 3 integration tests of theobject_reffilter against real Postgres, with the exact-=mutation failing; 3 pre-existing failures intest_verify_audit_chains_jobcaused by the local stack's NOLOGINorpycamcp_approle), 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.shgreen (ruff + mypy) in all nine services. Live against the stack:GET /audit?object_ref=EXP-2026-0002returns 51 entries, including the two naming the expediente by UUID and index; creating an expediente through the gateway recordsapi; a background task recordssystem;TenantCreaterejects-abc,a_band 55 characters and acceptsbogota-limpia(tenant_bogota_limpia). Impact: no migrations; all nine services rebuilt and deployed with the local stack's owner role. The internal contract ofaudit.appendchanges (canalmandatory): any caller outside this repository must declare it. Job entries now saysystem; historical ones still sayapi. 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.mdand ADR-002 prescribed Alembic, SQLAlchemy and an unvalidatedSET 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.mdES/EN rewritten over the real pattern (tenant_schemawith a fail-closed_SLUG_RE+SET search_path = "{schema}", publicon asyncpg; onboarding throughscripts/init-tenant.sh; the shared streamorpycamcp.{service}.events;documentsandpreservationbuckets); 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.mddowngrades Kubernetes to "not supported yet" and warns about in-memory state when scaling;DOCKER_REGISTRY.mdrewritten for GitLab;getting-startedno longer curls/api/v1/tenants;security-audit.mdEN 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 migration022(COMMENT ON COLUMN firma.xades_level). archive —GET /unidades/{id}/prestamospaginates (page/size≤ 100,X-Total-Count) and the frontend BFF requestssize=100and keeps the total;tests/test_ead_xsd.pyvalidates the disseminated EAD (no series, 1, 2, 3 and 5 levels) against the official EAD 2002 XSD vendored intests/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 durablewebhook_entregastable (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_serieshas no level column, ADR-015), export streaming and a load suite (own batch). AREA-14-12 turned out to be closed by migration028, which already rewrote thatCOMMENT. 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 (/varat 98 %): tests ran on the existing image with sources mounted read-only. Impact: deploy archive-service and the frontend; signature-service applies022(aCOMMENTonly); the rest is comments and documentation.fix(infra,auth): T14, audit remediation: a single brokenaudit_logrow aborted every backup forever (leaving a clear-text.PARCIALeach 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, ungatedknowledge, stays declared). What changes:verify_audit_chainsaccepts a responsible-approved baseline of historical breaks (--roturas-declaradas FILE, orROTURA<TAB>slug<TAB>id<TAB>notelines in the anchor stdin): only NEW breaks, non-enumerable breaks, a count regression or a changed anchored hash count.backup.shno longer aborts: on a failed verification it emits no anchor but completes (encryption, seal withverificacion_auditoria=FALLIDA, purge) and exits 3 at the end; baseline viaBACKUP_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 withretencion_capturada_completa=NOand exits 4, andrestore.shrefuses it unlessRESTORE_ACEPTAR_RETENCION_INCOMPLETA=1. A previous version under live COMPLIANCE now fails the restore unlessRESTORE_ACEPTAR_PERDIDA_VERSIONES=1; the newapp.ops.registrar_restauracionupdatespreservacion_worm_objeto.version_idfrom the map and writes thestorage.restauracionaudit entry in ONE transaction. In productionMIGRATIONS_DATABASE_URL(owner) leaves the 10 running services and stays only inmigrate-<svc>; the guardian was inverted. Not done: gatingknowledge /search,/antecedentes,/rag(product decision, stays declared); a PREMIS event per remapped object. Verification:shared/tests333 passed; auth-service 362 passed withRUN_INTEGRATION=1; lint green; mutations fall as listed in the Spanish entry. Impact: rebuild auth-service;backup.shmay exit 3 or 4 with a complete sealed backup (treat as alert); production runtime can no longer run migrations withrun <svc>: usemigrate-<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 therestore.shheredoc blew up with aTypeError), 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 inshared/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,/ragandaceptarare DECLARED, not approved — gating them is a product decision), no ORM/python-joseand 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 (noports:, Redis password, mandatory credentials, pinned third-party images). CI: whisper intest-unit, signature and workflow intest-integration, the real auth-service global migrations before each integration tier (the integration conftests now FAIL underCIinstead of creating their own copy of theaudit_logDDL), astorage-worm-realjob (RustFS pinned by digest; it runs when a compose file changes), an automatictest-security-smoke, Trivy before every image push, a fail-closeddependency-auditwith a deterministic threshold (scripts/audit_umbral.py; the model only drafts the report) and annpm-auditjob. Production:image: ${ORPYCA_REGISTRY:?}/<svc>:${ORPYCA_TAG:?}for the 12 first-party services (nobuild:; local builds live ininfra/docker-compose.build.yml) and a one-shotmigrate-<svc>per database-backed service. Backup/restore:auth-service/app/ops/verify_audit_chains.pyis invoked bybackup.shBEFORE anchoring (no anchor if it fails) and reused byrestore.sh;infra/lib-retencion-s3.shsaves every version and the legal hold, restores and COUNTS (restore.shexits non-zero if a valid retention or legal hold is left unrestored) and writes the old → newversion_idmap for the ADR-027 record. auth-service's/health/readyaddsaudit_particiones(degradedif next month's partition is missing; still 200).scripts/preflight-prod.shchecks TLS and ports from outside.seed_demo.pyseeds a draft with a two-turn circuit whose text changes inside the invalidation window, andfirma-gate-orden.spec.jsexercises 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 needsORPYCA_REGISTRYandORPYCA_TAGon everydocker compose; rebuild auth-service (it was NOT rebuilt on the local stack:/varhad ~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 theIdempotency-Key). Why: (1)DocumentCreateand the batch leftdoc_class,dest_dept_codeandorigin_dept_codeoptional (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 acceptednivel ≥ 2with a nullclasificacion.fundamentoand 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_secretwas clear TEXT, one row per operator, no endpoint wrote it and there was no rotation; a shipment could be moved toentregadoby hand with no evidence. (6)get_public_anexoserved the first attachment of any published radicado, and publishing only requirednivel 1(the DEFAULT of a PQRS). (7)documento.contenido_cambiadowas never emitted. What changes: minimum data —app/core/datos_minimos.py(E/I → type + destination; S → type + origin) imported byDocumentCreateandBatchDocumentCreate(the batch gainsorigin/dest_dept_codeand resolves the name independencias); 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) andPOST /borradores/{id}/radicartakes an optional body that wins; the mailbox intake requires it before fetching the message. Index — migration041(CHECK radicados_clasificado_con_fundamentoNOT VALID+ "PENDIENTE DE MOTIVAR…" backfill +VALIDATE;createnow inserts the basis because the CHECK cannot be deferred), the import rejects the whole package (422) and the index addsdependenciaandregistrado_por. Outbox — migration042(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), sameevent_idon resend;BorradorService.editaremitsdocumento.contenido_cambiadowhen the text fingerprint changes. Publication — migration040(anexos.publicable, DEFAULT FALSE, backfill of what was already published),PublicacionRequest.anexos_publicables,get_public_anexorequiresa.publicable, andset_publicableexcludes in theWHEREanything with channelventanilla_publicaor typed with aadmite_ventanilla_publicatype (neutral 404); classifying unmarks attachments. Public window —acepta_tratamiento: Literal[True]+politica_version(proof inmetadata.consentimientoand the audit entry;ip_declarada), honeypotsitio_web,Idempotency-Keywithpublic_submission_keys(migration043, claimed inside the radicado's tx and before the sequence number; other content → 409),GET /ventanilla/estadoandscripts/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 — migration044: two encrypted secret slots (AESGCM, AAD entity+operator),verify_any,PUT /envios/operadores/{op}/credencial(USUA_PERM_ADMINcrud≥5, never returns or audits the secret),acuse_file_id/acuse_sha256mandatory for a manualentregado(the shipments screen uploads the receipt and sends it). Storage —REFERENCIAS_DE_ARCHIVO+=postal_shipment.acuse_file_id(the guardiantest_referencias_de_archivo_cubren_todo_file_idcaught it: without the entry the orphan purge would not see the receipt's reference); the guardiantest_eventos_de_radicado_declaran_nivelnow also censusesoutbox.encolar(otherwise the emitter census would silently empty out) anddocumento.contenido_cambiadodeclaresnivel_seguridad. Docs — the R6 comment inknowledge-service/app/services/ingestion.pycorrected (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 withmetadata.remitenteand 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), adocument.envio.devueltoconsumer in notification (AREA-09-10) and adocumento.contenido_cambiadoconsumer 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_publicawas reused, which already is the entity's declaration of "this comes from outside"; publication history stays inaudit_log. (f) IP verified by trusted hops on public routes: the gateway does not inject it;ip_declaradais stored. (g)habilitadainsideGET /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 endpointGET /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 withRUN_INTEGRATION=1(real Postgres: 23 new tests — CHECK 041 and its backfill, the publicationWHEREby type and by channel,anexos.publicable, the outbox with Redis down, the UPDATE rolling back if the event INSERT fails, the idempotency key underasyncio.gatherover 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/tests255 passed). 17 mutations that fall (all restored, image rebuilt): dropping the E branch /doc_classfrom S / the batch validator / the import check / CHECK 041 /AND a.publicable/ the type / channel exclusion in theWHERE/ movingencolarout of the tx / moving the key claim out of the tx / the honeypot /Literal[True]/ a constanthabilitada/ the previous slot's expiry / the mandatory receipt / theenviarofcontenido_cambiado. Verified on the live stack (tenantdemo) 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 (sameevent_id),ventanilla(enabled only for the test and put back) with consent, honeypot, the sameIdempotency-Keysequentially 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,entregadowith and without the receipt,documento.contenido_cambiadoin the stream, andradicarfrom a draft with and without data. Disk incident during deployment: rebuilding the frontend (itsnpm installcache had been pruned) and ~500 MB of WAL from the integration runs took/varto 0 and Postgres went down with aPANICfor lack of space; space was freed (WAL viapg_switch_wal+CHECKPOINT,max_wal_sizelowered withALTER SYSTEMto 64 MB andmin_wal_size32 MB — reset withALTER SYSTEM RESET—, an unusedpython:3.12-slimimage and the container-lesslatestimages of tenant, notification, workflow, signature and auth, all rebuildable) andorpycamcp-postgreswas started again (recovered with no loss). Impact: breaks clients that register without type/department (POST /documents,POST /batch/documents, the single window and PQRS withoutacepta_tratamiento/politica_version, a manualentregadowithout a receipt): warn integrations; a batch queued before the change without codes ends with those itemsfailed. Migrations040–044per tenant (document-service'spython migrations/runner.py; no cross-service dependency:tipos_documentalesalready existed from archive-service), rebuild document-service, frontend and storage-service (the latter a one-liner:REFERENCIAS_DE_ARCHIVO). Rotating the postal secret needsEMAIL_ENCRYPTION_KEY(otherwise503). Publishing a radicado no longer publishes its attachments: mark them withanexos_publicables. Other services' integration tests that seedradicadoswithnivel_seguridad ≥ 2through 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)retransmitirre-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_sha256were only shape-checked: an invented UUID marked the shipment «delivered» and was cited as proof. (3) The 041UPDATEchanges the Law 1712 art. 20 index field with no trace (invariant 6). What changes: the relay publishes the CURRENTnivel_nuevoread fromradicados.nivel_seguridadin the same transaction (outbox._con_nivel_vivo; the event contract is unchanged).POST /envios/{id}/estadochecks the receipt against storage (/internal/files/hashes, which only knows the tenant's files) before the transaction:422 acuse_invalidoif the file does not exist or the hash differs,503 acuse_no_verificableif storage is down, and the hash returned by storage is the one recorded. The migration runner (asentar_backfill_fundamento) writes adocument.clasificacion_fundamento_backfillaudit entry (actorsystem: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 inknowledge-service/app/services/ingestion.py. Not done and why: (a) comparingoccurred_atin 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 afile_idalready 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 withRUN_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'sNOT EXISTSornivel >= 2filter; 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;createrequires the signed index (409 indice_no_firmado), reads custody inside the transaction withFOR SHAREand rejects a pair with no custody (422 ubicacion_sin_tenencia); migration053tiesindice_firma_id/indice_sha256into the handover record (columns and the hashed XML) andcabecera_completais derived on read;054revokes UPDATE/DELETE on the record from the app role,055adds aCHECKonobjeto(history exempt: a bareNOT VALIDis also evaluated on each UPDATE),056records the receipt's fixity check as a PREMIS event (post-commit, idempotent, reconciled),057gives 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_pdftolerates a 1 KiB prefix, PREMIS declares the PRONOM PUID, migration018(acta_firma_*types),POST /preservacion/eventos-internal, and a WORM-circuit test against real Postgres + RustFS. Not done: thefirma_inobtenibleact (07-10) and the two-level TRD hierarchy (06-07) are user decisions;preservationLevel/rightsin 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 exactlytoday + 400 days. Impact: rebuild storage and archive (addslxml; watch disk); migrations in order — storage018, then archive053–058; census before deploying to a real tenant: closed expedientes with index ≠firmadoand active transfers (the local stack had 0 and 0). T12 audit remediation (2026-10-03, archive): a fondo acumulado rejects a futurefecha_extrema_final(422;base_wormreturnsNonefail-closed); a failed receipt fixity now records a PREMISFIJACIONevent withresultado="fallo"(counts only), and a fixity with files lacking a hash is declaredverificacion="parcial";archive.vinculo_origen_completadocarries the writtenvalores,fuenteand timestamp, and the backfill takesFOR SHAREon 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.jsredirects to/403by each area's real permission (fail-closedUSUA_PERM_ADMINfor undeclared areas);GET /api/v1/preservacion/estado(instance state, no per-expediente read) lets the Preservation tab say "disabled on this installation";SchemaFieldsupports arrays of scalars and one-level objects, and the 422metadata_validation_failedenvelope now carriesfield(document-service and archive-service);layout.scssuses--op-scrimand 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/criticalfail). Not done: migrating inline-metadata views toSchemaField, 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 channelapiwith 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_localwas "sovereign" by name even with a publicOLLAMA_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>, andderive_canalonly knewmcpby PATH: a radicado created from the card ended upapi, like a direct call; and even with the gateway sayingmcp,audit.appenddefaulted tocanal="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 of9c2cc6aone hop further in; and a turn's cost was not measured (AREA-11-08). (4)is_generatabledecided by the NAMEollama_local; with a publicOLLAMA_URL,/ragsent level 2-3 chunks to that host and left only a warning (AREA-11-12). (5)kb_suggestions.aceptada_por/aceptada_enexisted 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: sharedidentidad.firmar_origen/verificar_origen(markX-Origen-Canal: mcp.<ts>.<hmac>bound to the tenant and the sha256 ofAuthorization, 120 s validity, with no secret it is neither issued nor verified) andip_real_firmada(X-Real-Iponly if the accompanying identity verifies);request_logcapturesX-Canal(onlyapi|ui|mcp) andIdempotency-Keyinto ContextVars;audit.append/append_denialtakecanalfrom the request when not passed and, only on channelmcp, addpayload.correlacion = {request_id, idempotency_key}. api-gatewayderive_canal(path, origen, tenant=, authorization=):mcpby path or valid mark,apiotherwise (the mark is not forwarded); forwardsIdempotency-Key; routes/api/v1/knowledge/suggestions/. mcp-serverservices/origen.py::cabeceras_de_origenis the single place that builds outgoing headers (dispatch,/auth/me, scope frame):Authorization,X-Tenant-Slug, the mark,X-Request-IdandX-Forwarded-Forwith the IP the gateway signed;ContextoEntranteMiddleware+RequestLogMiddleware; the proposaljtitravels asIdempotency-Key(in a procedure,<jti>:<n>per step);run_turnreturnsno_verificados(every{YYYY}-{TENANT}-{E|S|I}-{NNNNNN}in the text absent from thetool_results; what the person typed does not count) and appends a "No verificado" notice, and logsiteraciones/llamadas_gateway/propuestas/no_verificadosper turn. knowledgeurl_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); migration012_kb_suggestions_creada_por.sql;POST /api/v1/knowledge/suggestions/{id}/aceptar(only whoever generated it, neutral404,409if already accepted, atomicrag.aceptadaentry). Documentation: the "per-tenant opt-in" promise is withdrawn (main.py,integraciones-externasES/EN, ADR-006) and the guide's "stub" embedding corrected tolocal_st; themcp-server/health.pydocstring said it did not install the shared library (it does).docker-compose*.yml:IDENTITY_SIGNING_SECRETon mcp-server (mandatory in production). Not done and why: (a) propagating expediente reclassification to the index (AREA-11-11) does not apply:ExpedienteUpdatedoes not admitnivel_seguridad, noUPDATE expedientestouches it and archive-service publishes no events —there is no producer to consume—;shared/tests/test_el_nivel_del_expediente_no_cambia_en_silencio.pymakes the premise falsifiable. (b) OMI-04 without the per-entity switch: settingknowledge.habilitadowith afalsedefault 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 withRUN_INTEGRATION=1, real Postgres: atomic acceptance with ownership in theWHERE, barrier with a publicOLLAMA_URLover a realknowledge_chunk), mcp-server 260 passed, api-gateway 126 passed,shared/tests249 passed, document-service 682 passed/196 skipped (196 passed withRUN_INTEGRATION=1); ruff+mypy green on knowledge, mcp-server and api-gateway. Mutations that fall:is_local_llm_providerby name (9 unit + 1 integration), droppingcreada_porfrom theWHERE(2 integration), the entry outside the acceptance transaction (1),verificar_origenwithout checking the signature (4),idempotency_keynot sent (1),X-Forwarded-Fornot forwarded (1), radicado validation disabled (3),audit.appendwithout correlation (1) and anUPDATE expedientes SET nivel_seguridadin archive (1). Against the live stack: a radicado confirmed throughPOST /mcp/proposals/executeis recorded withcanal=mcpandcorrelacion.{request_id, idempotency_key=proposal jti}; a direct call, and one with a forgedX-Origen-Canal, areapi; the per-IP bucket of the assistant's internal calls is the client IP's (X-Forwarded-For: 203.0.113.50at the edge), not the container's;POST /knowledge/suggestions/{id}/aceptar: someone else's404, own200, repeated409, with its entry. Impact: apply knowledge migration012(python migrations/runner.py) and deploy knowledge, api-gateway and mcp-server; channelmcponly 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 productionIDENTITY_SIGNING_SECRETbecomes mandatory for mcp-server too. Entries with channelmcpnow carrypayload.correlacion. Pending: the invented-radicado check could not be exercised against the real model (the local stack has noANTHROPIC_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_SECRETsignsX-User-Id/X-Tenant-Slugand 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_suggestionsis mutable and outside the hash chain: therag.aceptadaentry only carriedaceptada_en, so the immutable record could not later prove that the accepted text is the one now in the row. What changes: dedicated secretMCP_ORIGIN_SECRET, only on api-gateway and mcp-server, andIDENTITY_SIGNING_SECRETremoved from mcp-server in both compose files (mandatory in production;infra/.env.example).identidad.firmar_origen/verificar_origenuse it; the gateway also signsX-Real-IpintoX-Real-Ip-Signature(firmar_ip_real, bound to IP + person + entity) andip_real_firmadaverifies that signature instead of requiring the full identity. With onlyMCP_ORIGIN_SECRETone can mark the channel and verify the IP, but not sign an identity.rag.aceptadanow carriessha256_texto(of the suggested answer),proveedor,modeloandfuentes(the citedsource_refs, without their content: Ley 1712), computed from the row locked withget_for_updatein the same transaction. Not done and why: OMI-04 (per-entityknowledge.habilitadoopt-in) stays open: implementing it changes visible behaviour (with defaultfalseit 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,/antecedentesand/raganswer a neutral503, with a mutation that skips the check. Verification: knowledge 262 passed/56 skipped (318 withRUN_INTEGRATION=1, real Postgres), mcp-server 264, api-gateway 128,shared/tests255; ruff+mypy green on knowledge, mcp-server and api-gateway. Mutations that fall:_secreto_origenreading the master secret (1),ip_real_firmadawithout comparing the signature (2), IP signature not bound to person/entity (1), the gateway withoutfirmar_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 withoutIDENTITY_SIGNING_SECRETand withMCP_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: setMCP_ORIGIN_SECRETin 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 toapiand the IP is not forwarded (safe side). Newrag.aceptadaentries carry the extra fields; earlier ones are not rewritten.fix(workflow,signature,notification,knowledge,shared,gateway)!: shared failed-event queue keyed byevent_idalone silently dropped the second consumer's parking; knowledge could not inspect or replay its queue; fixed consumer names; orphaned dead-letters without a cause; falseX-Truncated; DLQ size invisible (audit batch T9, AREA-10-06/07/08/09/10, 13-12).evento_dead_letteris 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, atomicreplayviaapply_event,discard;PERM_DLQ_ADMIN, writes at level 3, no-read-up, neutral404) routed by the gateway;events.consumer_name()per replica;record_failurekeeps the last error so orphaned dead-letters carry a cause;reclaim_stale(status_name=…)publishes the last sweep and/health/readyshowschecks.dead_letterplus DLQ length (informational, never the503); listings ask forcap + 1rows soX-Truncatedonly fires beyond the cap. Verified: workflow 355/97 (452 withRUN_INTEGRATION=1), signature 503/13 (514), notification 228/19 (247), knowledge 223/43 (266), gateway 112,shared/tests226, lint green; 13 mutations that fail tests; verified live on the local stack. Not done: clearance in the listingWHERE+ keyset pagination, Prometheus/alerts. Audit remediation (T9): the knowledge replay no longer applies the archived envelope blindly: beforeapply_eventit anchorsreclassifiedto the LIVEradicadoslevel (andcreatedtomax(event, live)); with no live radicado it answers409 stale_eventand the event stays pending (a parkedreclassified(1)can no longer lower a chunk that went back to level 3). Knowledge 233/48 (281 withRUN_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 toX-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-xmlever 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 toX-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 chosepayload_sha256for radicado/expediente. Onesignature_secretserved 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 gateoff. The HTTP call to auth-service ran under the turn's row lock./verifywas 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;acreditadowas a literalfalseon verification. Found while testing live:/sign-xmlon the native path persisted a differentnow()from the instant inside the HMAC, so no native signature from that path ever verified. What changes: signature —get_signer_identity(replacesget_signer_email; sendsX-Tenant-Slugto/meand returnsuser_id) compared with the actor BEFORE creating the challenge:403 identidad_incoherente+ afirma.identidad_incoherenteentry (also on the TOTP, personal-credential and/sign-personalpaths). Level ≥ 2 with an active TOTP →409 totp_requerido(fail-closed503).radicado/expedientechains ignore the client'spayload_sha256and/signrejectspayload_xmlwhere the chain fixes no fingerprint (422 payload_xml_no_admitido). HKDF keys per (purpose, entity), a message binding entity+type+object, an OTP HMAC bindingchallenge_id, migration020_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_caidoentry and a startup warning with the gateoff./verifyandGET /firmas/{id}apply no-read-up inside theWHERE;acreditadoderives from the persistednivel_conformidadand only if the cryptography validates; a revocation AFTER the signature does not invalidate it (revocacion_posterior); the timestamp'sgenTimemust fall in the signer certificate's validity and the ESSCertID must match (local TSA now emits it; absence is only rejected withTSA_ESSCERTID_OBLIGATORIO=true). auth —GET /internal/totp/status,user_idon the valid/internal/totp/verifyverdict and on/internal/signing-key/status;totp_requerido_por_nivelremoved. frontend — the assistant asks for the authenticator when the email challenge answerstotp_requerido. docs —api.mdES/EN (with thepayload_sha256warning, guarded inshared/tests/), ADR-016, security audit, roadmap. Verification: signature-service 480 passed/10 skipped (422/4 before) and 8 passed withRUN_INTEGRATION=1(real Postgres: migration020, DEFAULT 0/CHECK and the no-read-up gate against realradicados/expedientes; it caught a real defect); auth-service 287/43;shared/testsguards 213 passed; ruff+mypy green; frontendSignAssistant.test.js13 passed. Mutations that fail the tests (restored and rebuilt) cover every item. Live on the local stack (demo tenant,operador/jefe/consultatokens): a native/sign-xmlsignature verifies (impossible before), as does akey_version=0row with the literal previous HMAC;consulta(clearance 1) gets a404identical to a non-existent UUID for a level-3 radicado's signature; a new XAdES-T verifies; level-2challenge/signwith an active TOTP →409 totp_requerido; the operator's bearer withjefe'sX-User-Id→403 identidad_incoherente, no challenge, with an audit entry. Impact: deploy auth-service BEFORE signature-service (withoutuser_idin its answers signature fails closed); apply migration020per tenant with signature-service's runner; rebuild signature-service, auth-service and frontend. Pending: enablingFIRMA_PERSONAL_REQUIRE_XADES=clasificados(the entity's decision, requires enrolling certificates), an external HSM/KMS,TSA_ESSCERTID_OBLIGATORIO=trueonce the dev corpus is re-stamped, andX-Internal-Tokenon/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:/verifykeptvalida=truewhen the key was revoked after the row'ssello_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_revocacionis a closed vocabulary (RFC 5280:key_compromise,superseded,cessation_of_operation,affiliation_changed,privilege_withdrawn,unspecified):422outside it on self- and admin-revoke, tenant migration019_motivo_revocacion_vocabulario.sql(earlier free text becomesunspecified, the original stays in thesigning_key.revocadaaudit entry; idempotent CHECK), andGET /internal/signing-key/revocation-statusalso returnsmotivo_revocacion. signature —/verifytakes ONLY a verified TSA'sgenTimeas 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 keepsvalida=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 apply019per tenant) BEFORE signature-service and the frontend; clients sending free text inmotivoget422. 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 thehuella_de_contenido=falseaudit 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: migration008excused tenant-service as "not routed by the gateway" — false:/dependencias,/catalogos,/config,/business-days,/pinarreach it, and as owner the append-only guard ofaudit_logdid not bind it. Tenant create/patch ran in autocommit with no audit entry;PATCH /dependencias/{id}ignoredflow_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 /tenantsno longer runs DDL: it registers the row asprovisioning_status='pending'andscripts/init_tenant.py(owner) creates the schema and activates it. Create/PATCH /tenants/{id}run in one transaction with atenant.created/tenant.updatedaudit entry (before/after, fail-closed).PATCH /dependencias/{id}returns409 dependencia_con_tramite_en_cursowhen renaming/deactivating with live steps or rules.PUT /config/params/{key}recordsantes/despues. New guard test derives gateway-routed services fromproxy.pyand requires the application role in both compose files. Impact: rebuild tenant-service; production now requiresDB_APP_USER/DB_APP_PASSWORDfor it. Institutions are provisioned only viascripts/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, anyPERM_RADIcould 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)uploaddeduplicates by SHA-256 and returns the samefile_id;purge_orphanonly saw COMMITTED references, so it deleted an object another import/upload/email had just received and not yet committed. (2)GET /statusreturned every item'ssubject—a batch radicado reclassified to reserved afterwards was readable withPERM_RADI:1— and anyPERM_RADIsaw/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 — migration017(files.dedup_hit_at);upload's dedup branch doesUPDATE ... RETURNING(takes the row lock, serialises with the purge'sFOR UPDATE; if the purge wins, the upload stores a new object) andpurge_orphanreturnsreferenciadowhen 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: aremove_objectwithoutversionIdonly creates a delete marker; what protects ispreservacion_worm_objeto); guardianshared/tests/test_referencias_de_archivo_cubren_todo_file_id.pyDERIVES the*file_id*UUID columns from every service's tenant migrations and compares them withREFERENCIAS_DE_ARCHIVO(parser positive control and anti-vacuity included). document —GET /batch/documents/{id}/statusandPOST .../reprocesar: only the batch's creator orPERM_RADIlevel 3, in theWHERE, neutral404 lote_no_encontrado(in reprocess, BEFORE the lock so there is no409oracle);subjectcarries no-read-up in the SQL (LEFT JOIN radicados+CASE);lote_reprocesadokeepserror_message(<=500) andintentosper item; a batchradicado_createdcarriesorigen:"lote",job_id,item_index; automatic resumption writes under the system actor,canal="system"anden_nombre_de; registering a draft requires the same tenure as editing (neutral 404;aprobar/devolverstill do not); thePATCHUPDATEcarriesestado <> 'radicado'(409 if it was registered in between); an orphan binary going toagotadowritesdocument.import_binario_agotadoin the same transaction. notification — a globalRequestValidationErrorhandler stripsinput/ctxfrom the 422. Frontend contract (batches): the404ofGET /statusandPOST /reprocesaris now{"detail": {"error": "lote_no_encontrado", "detail": "...", "status": 404}}(the GET'sdetailused to be a string); the GET requiresX-User-Id; another person's batch returns 404 even if it exists;items[].subjectmay benull(radicado above the caller's level) and each item carriesnivel_seguridad(int | null,nullwhen 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 withRUN_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 newshared/testsguardian is green. Mutations that fail tests: removing the dedup mark inupload(2), ignoring it in the purge (1),continueon a missing table (1), removing the creator predicate (1), removing thesubjectgate (1), removing the pre-lock check (1), not writingerror_message/origen/system actor/agotado(1 each),radicarwithout tenure (2), removingestado <> 'radicado'(1), removing the 422 handler (1) and removing a column fromREFERENCIAS_DE_ARCHIVO(the guardian). Impact: apply storage migration017per tenant and rebuild storage-service, document-service and notification-service. Whoever reprocessed or looked at other people's batches withPERM_RADInow gets404; whoever registered other people's drafts gets404. -
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) anyPERM_RADIcould 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 anasyncio.create_task: restarting the service left itpending/processingforever 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 theUPDATE'sWHERE: 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 neutral404and the row is untouched.aprobaranddevolverdo NOT require it (review acts; approval is usually someone else's and is bound to the signature circuit). (2) storage-servicePOST /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_huerfanoentry in one transaction. document-service queues the binaries inimport_binario_huerfano(migration 038, atomic with the…_sin_radicadoentry), tries to purge at once and, if storage fails, a background sweep retries (20 attempts →agotado); a confirmed purge writesdocument.import_binarios_purgados. (3) migration 039 + sweep: interrupted batches resume under a per-batch advisory lock (several replicas), each item is idempotent (FOR UPDATEclaim; the consecutive number and the radicado share a transaction: no gaps, no double numbering); failed items stayfailedwitherror_message,error_tipoandintentos; the batch endscompletado_con_errores;GET .../statusaddserrores[],pending_items,reprocesable; newPOST /batch/documents/{job_id}/reprocesar(same permission as creating,document.lote_reprocesadoentry); retention of completed batches without errors (90 days). BatchstatusbecomesVARCHAR(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; treatingerroras terminal, not queueing, not purging after rollback, not retrying; removing the item claim, the lock, the recount, thecompletado_con_erroresstate, the reprocess filter, the retention, the resumption and the endpoint permission. Impact: apply migrations038and039per tenant and rebuild document-service and storage-service. Whoever edited other people's drafts withPERM_RADInow gets404. A batch that used to endfailedbecause of failed items now endscompletado_con_errores(the UI must handle it; the contract is indocs/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 andsubido_desdenarrow 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 saysreprocesableand the user holdsPERM_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, callsPOST .../reprocesarthrough an allowlisted SSR proxy (D-05), polls again until it finishes and maps the404/409errors (lote_no_encontrado,lote_en_proceso,nada_que_reprocesar) to Spanish messages. Tests: proxy, utilities and screen; the mutation removing thePERM_RADIcheck 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.secretwas clearTEXT. 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 inorpycamcp_common.kek(AES-256-GCM, AADtenant:{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 migration009(secret_cifrado,secret_nonce,secret_kek_version, three CHECKs;chk_webhook_secret_sin_claroforbids writing a new clear secret). Creating a webhook encrypts (theidis minted by the application because it is part of the AAD; with no usable KEK it answers503 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_KEKis mandatory (the service does not start without it; in production,${…:?}). Operations:python -m app.ops.cifrar_webhook_secrets(encrypts legacy rows, setssecretto NULL in the same UPDATE and writes thenotification.webhook.secret_cifradoentry from the application; idempotent) andpython -m app.ops.rewrap_webhook_secrets(KEK rotation,--verifyper version). Verification: notification-service 223 passed/16 skipped (+ 16 integration against real Postgres); auth-service 280 passed/42 skipped + 46 integration;shared/tests191 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 nullingsecreton encryption (1), AAD without the webhook id (2). Impact: defineWEBHOOK_SECRET_KEK(openssl rand -base64 32) before rebuilding notification-service; apply migrations and runcifrar_webhook_secretsBEFOREup -d(seedocs/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 clearsecretcolumn still exists (always NULL); dropping it is a later migration, once no deployment keeps unencrypted rows. -
fix(document): corregirdest_deptpor 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 noNULL) y el PATCH dedest_deptsobre un radicadoclosedresponde 409dest_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 fornivel_seguridadwhile the producer sendsnivel_nuevo/nivel_anterior; workflow events now declare the level and an untypeddocument.*/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; orphanedflow_steps/flow_eventsdefault to level 3. Seedocs/es/changelog.mdfor the full entry. Rebuild notification-service, workflow-service and frontend. fix(document,mcp)!: a failed radicado burned a number with no audit entry,importcould 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 dePOST /importretení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; asientodocument.import_binarios_sin_radicado; purga de huérfanos queda como deuda) Why: the sequence was taken in autocommit before the radicado transaction (a non-existenttercero_idrolled back the radicado and its entry but not the number);importadvancedtracking_sequencesfrom the package number without checking the entity or bounding it (2026-OTRA-E-2147483647leftlast_seqat INT_MAX and broke native registration for the year); manual shipment transitions and dispatch wrote no audit entry, needed only read level and theUPDATEdid 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 insideconn.transaction().importreconciles the counter only for this entity's code and rejects with422sequences above 999999 and years outside [1900, current]. Shipments: the dispatch and manual-status POSTs requirePERM_RADI_SALIDAlevel 3, writedocument.envio_registrado/document.envio_estado(actor =X-User-Id) in the same transaction, and theUPDATEcarries previous state and clearance in itsWHERE(0 rows becomes409 transicion_invalida; the operator callback uses the same guard). Waybills derive from a per-shipment uuid4.import/exportrequireUSUA_PERM_EXPEDIENTElevel 3, run validation/hashing/ZIP building in threads, take a per-process slot (busy becomes429 operacion_ocupadawithRetry-After), cut the body read at 512 MiB and cap export attachments at 512 MiB. The batch validates the active template of thedoc_classand storesmetadata_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/testslevel guardian green, lint clean; every mutation (drop each fix) kills tests. Impact: rebuild document-service and mcp-server. Holders at level 1-2 now get403; the/enviosand interoperability screens do not mirror the level yet. Declared out: deleting orphan blobs of a rolled-back import (storage deduplicates by SHA-256), resuming/purgingbatch_jobs, async job/streaming for import-export, a dedicated dispatch permission, the gateway/auth/validatecache MAC and the audit-doc fix (T2/T14). -
fix(document,mcp)!: a level-1PERM_RADIcould 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}/radicadosreturned thesubjectof RESERVED radicados with no clearance gate, and the assistant's residency barrier only strips what declaresnivel_seguridad, which the schema lacked.RespuestaItemhad the same flaw.borradorhad no level at all andeditarwrote no audit entry.POST /documents/{id}/signaturesstored the body hash, at read level, with noaudit_logentry. What changes: the clearance gate goes in the sameWHERE(with the merged-recordsORparenthesised), items declarenivel_seguridad, and the read is audited. Migration035_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;PATCHwrites an atomicborrador.editadoentry. Declarative signature:PERM_FIRMAlevel 2, mandatoryanexo_id, server-resolved hash from storage (bodycontent_hashignored, 503 if storage is down),document.signature_createdentry in the same transaction. New guardianshared/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/tests182, lint clean; every mutation (drop each gate, drop the parentheses, dropGREATEST, drop the audit entries, use the body hash, move the audit out of the transaction, drop level 2, dropnivel_seguridadfrom the schemas) kills tests. Impact: apply migration035per tenant and rebuild document-service and mcp-server. Declared out, on purpose: whether to retire the signature POST with410(user decision); whether editing a draft requires being its creator;SearchHit/RadicadoMetaItemstill lacknivel_seguridad. Audit remediation (T3): (High)radicarcreated the registry entry at level 1 even when the draft answered a reserved one: it now inherits the CURRENT parent level (GREATESTwith the snapshot), its basis/cause/term (a parent without basis is motivated by inheritance: never falls to PUBLIC) and theresponde_alink. (Medium) migration036_signatures_origen.sql: legacy declarative signatures becomeorigen='cliente_no_verificado'andverifyexcludes them; new ones carryorigen='servidor'. (Medium)aprobar(user and circuit-completed internal route) anddevolverrecordborrador.aprobado/borrador.devueltoin the same transaction as the state change. Verification: document-service 744 (RUN_INTEGRATION=1), lint clean; mutations that break tests: dropping level inheritance, dropping theorigenfilter, renaming the audit action. Impact: apply036per tenant and rebuild document-service. -
fix(workflow): closing an overdue procedure recorded the deadline as "unknown" (E05, ADR-028) Why:fuera_de_plazoin theworkflow.finalizaraudit entry only looked atflow_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);nullonly when neither exists. Verification: workflow-service 397 withRUN_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 inregistered(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 advancedflow_steps;radicados.statusonly changed if someone manually pressed "Distribute"/"In progress" —aPATCHwithstatus— so the dashboard, inbox and search filtered on states the workflow never wrote. In Orfeo distributing is a status change. How: workflow-service writesradicados.statusin the same transaction as the step, after mutating it and before the audit entry (ifaudit.appendfails neither step nor status remains), through one pure function (estado_radicado.py::derivar_estado_radicado) whose literal twin is the backfill'sCASE: active steppending→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 leavesdistributed(declared).in_progressonly via the holder'smarcar_leido, which required closing a pre-existing gap:execute_transactiondid not check holdership, so anyone withPERM_TRAMITARcould mark someone else's file as read (transaction_types.requires_holder, migration 012; non-holder → the same404 no_active_step, no oracle). The only close isPOST /workflows/{id}/finalizar(PERM_TRAMITAR, holder only, mandatorycomentario,tipo_tx=finalizar): not atransaction_type, because that catalogue is tenant-configurable and an irreversible act cannot live there.closedandanuladoare terminal: any act that moves the step answers409 radicado_terminaland rolls back;informar/agendarstay allowed. The cascade puts steps back topending(OrfeoLEIDO=0), excludes terminals (skipped_terminal), takes locks in order and writesworkflow.estado_radicadoper changed radicado. After commitworkflow.radicado.estado_cambiadois published (best-effort). document-service: thePATCHrejectsstatuswith422 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_radicadolosesstatus; newfinalizar_tramite(irreversible) → 207 tools, 75 writes, 27 irreversible. Backfill013_radicado_estado_desde_flujo.sqlwith an evidence tableradicado_estado_recalculo, noaudit_logwrites from SQL, idempotent. Verification: workflow-service 308 unit tests (18 integration skipped) and 18 integration tests against real Postgres (RUN_INTEGRATION=1, newtests/integration/tier: atomicity with a forcedaudit.appendfailure, 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/tests171;lint.shclean 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 backfillCASE, not excluding terminals in the cascade and ignoringfinalizar's flag. Frontend:/bandejadrops the manual «Distribuir»/«Iniciar trámite» buttons (and theirstatusPATCH, which now returns422 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 proxyPOST /bandeja/api/{id}/finalizar— and «Devolver» only to the holder of the active step (assigned_to= me, or unassigned and in my dependencia, read fromGET /current), withPERM_TRAMITAR; the backend re-validates. Spanish messages for409 radicado_terminal,404 no_active_stepand422 status_managed_by_workflow. After the audit: «Finalizar» additionally requiresPERM_TRAMITARlevel ≥3 (mirrorsmin_crud=3, read frompermission_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 (422without 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 (readsGET /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 for404 step_not_foundand404 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_byfrom the detail compared with the internaluser_id, not thesub); with a nullregistered_by(imported/ingest) or an unloaded history it is not offered (fail-closed; the backend answers404 no_active_step). «Devolver» and «Revertir» follow the_holder_conditionholdership (including a department's ownerless step;403 not_current_holderno longer exists) and «Revertir» only while the step ispending. Spanish message for404 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);rollbackonly frompending; completing without marking as read leavesdistributed;finalizar/devolveron 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'sstatus. Pending: the frontend ("Finish processing" button, removing manual "Distribute"/"In progress", translating the 409). Impact: apply migrations (012,013per tenant) and rebuild workflow-service, document-service and mcp-server. Any client sendingstatustoPATCH /documents/{id}now gets422.finalizarmoves to level 3 (see remediation). With the remediation, also: apply014/015per tenant and document-service034, 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/transferare 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 theWHEREof 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, onlyauto_distribute); forcibly redistributing someone else's step stays in the admin cascade. (2) The409 radicado_terminaloracle is closed (the holder gate runs before synchronising;anularbecomesrequires_holder, migration 014, and any transaction that moves the step requires holding it). (3)finalizarrequiresPERM_TRAMITARlevel 3 (was 1, the read level); it leavesSIN_NIVEL_DECLARADASand the MCP catalog mirrors it. (4)complete_stepuses the same holder definition (before,assigned_to IS NULLwas enough to close another dependencia's ownerless step, and a closed step could be "re-closed"). (5) MCPfinalizar_tramiteis confirmed by typing the radicado (confirmar_con), notCONFIRMO. (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 exhaustedmax_locks_per_transaction. (7) Annulment cancels the active step in the same transaction (document-service,flow_events.tipo_tx='anulacion'); migration015_sanear_pasos_zombi.sqlcleans up existing ones with an insert-only evidence table (flow_step_saneamiento), andinbox/pending/overdue exclude terminal radicados. (8) INT-05 import rejects packages carryinganulado(closed enum inRADICADO_SCHEMA), records thestatus_origenin the audit entry, and migration034_radicado_status_check.sql(document-service) closes the domain ofradicados.statuswith aCHECK(NOT VALID+ validation if the data allows). (9) The 013 backfill leaves insert-only evidence (tracking_number,paso_activo,origen), no longer touchesupdated_at, and the migration runner writes theworkflow.estado_radicado.backfillanchor entry (count + SHA-256) toaudit_log, once per tenant. (10-12)finalizarrequiresmotivo_cierre(respondido|nrr|traslado_competencia|otro), its audit entry addsstep_id,flow_event_id,tiene_respuesta,fuera_de_plazoanden_expediente, and it is allowed without an active step to whoever held the last completed step. (13)_cambiosis emptied at the start of every act (thexfailtest turned green). Pre-existing defect found while testing against real Postgres:AnulacionRepository.decideconditioned onestado = 'pendiente', a value theCHECKnever admits, so no annulment approval could ever be applied (always409 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 indistributed/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 wereregistered, the board had 101 pending steps, and 127 response deadlines were past with "no upcoming deadlines" on screen. Pending items now come fromGET /workflows/inbox— the same query as the/bandejaboard — and alerts includeregistered, since a response term runs from registration. The tests had enshrined the defect by building radicados indistributed; 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=1901in a 1916 viewport) while the backdrop dimmed the page, and the e2e test passed because Playwright'stoBeVisible()accepts an element outside the viewport. It now assertstoBeInViewport, 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 excludesradicar_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 fromservices/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 inmi_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-servicehas a folder, a Dockerfile, port 8012 and a service indocker-compose.yml, and appeared in neitherarchitecture.mdnorapi.mdnorCLAUDE.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 theassistantprofile and with no transcriptionstub; no gateway route and no published port; the only service with no database and no per-tenant schema), plusdocker compose --profile assistantin 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), andGET /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,nodeandkeycloakdownloaded fine at the same moment, so it was neither the network nor rate limiting): quay.io answersdeniedand lists zero tags, Docker Hub 404s, thedl.min.iobinary 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_objectinheriting 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 acceptedPutObjectRetentionin 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.shandinfra/restore.shinvoked theminio/mcimage, also removed — backup and restore were broken, includingmc retention set, which is what reinstates WORM retention on restore. Migrated toaws-cli. And an improvement that came with it:mc retention setonly took durations in days, so restoring a retention rounded it up and moved the expiry date on every restore;put-object-retentiontakesRetainUntilDate, 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_retentionappeared in three files andget_object_retentionin none. After writing the retention the service recordedretention_mode='COMPLIANCE'and a PREMISFIJACIONevent asok— 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_aplicadare-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 renameorfeomcp→OrpycaMCP/orpycamcpbefore v0.01 — Why: the project is the evolution of Orfeo under the Orpyca concept ("Orfeo Pyme Calidad") with AI connectivity via MCP (Model Context Protocol) — Impact: by-surface convention — brandOrpycaMCP; data/infra tokenorpycamcp(DBorpycamcp_db, Keycloak realmorpycamcp, bucketsorpycamcp-{slug}-*, Redis streamsorpycamcp.*, Python packageorpycamcp_common, networkorpycamcp-net, containersorpycamcp-*); repo/images/Compose projectorpyca-mcp; company/web/orgOrpyca/orpyca.com/GitLaborpyca. Requires recreating volumes; the repo folder is renamed toorpyca-mcp(manual step) docs(claude):CLAUDE.mdsynced with the real architecture — 11 microservices with correct ports; removed SQLAlchemy/Alembic references (contradicted ADR-003: asyncpg + raw SQL); phases updated per specDrivechore(ci): CI unified on GitLab — removed.github/workflows/; README/badges/clone point togitlab.com/orpyca/orpyca-mcpchore(compose): dev published ports moved to a high, distinct range (19xxx / 15432 / 16379) to avoid clashes with other dev stacks; internal ports and Docker-network communication unchangedfix(compose):signature-serviceandknowledge-servicenow wait forpostgreshealthy (depends_on: condition: service_healthy) — they previously exited at startup withConnectionRefused
Deprecated¶
workflow,document,tenant: the dependencia name fields as a key (to_dept,from_dept,assign_to_dept,?dept=,dest_depton PATCH/drafts) remain as a deprecated alias for one cycle (Deprecationheader +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-000117showed the registration dependencia ("Oficina de Tecnología") after being distributed to Talento Humano and returned to Control Interno.dest_deptis a legal snapshot that does not change; where the radicado is could only be rebuilt by walkingflow_steps. In Orfeo it isradi_depe_actu/radi_usua_actu: two columns on the radicado itself that every distribution rewrites. What changes: document-service migration037_radicado_ubicacion.sql(columnsflujo_dept_code/flujo_dept/flujo_usuario+ two generated columnsubicacion_dept_code/ubicacion_dept, computed as pairs, indexed); workflow-servicesincronizar_estado_radicadowrites them in the sameUPDATEasstatus(the cascade too), with the new pure functionderivar_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 migration016_radicado_ubicacion_desde_flujo.sql(backfill, insert-only evidenceradicado_ubicacion_recalculo, aworkflow.ubicacion_radicado.backfillanchor written by the runner,RAISEif document-service has not migrated yet).GET /documentsandGET /searchaccept?ubicacion_dept_code=(same condition in rows andCOUNT); responses carryflujo_*andubicacion_*;flujo_usuarioonly in the detail. Reports:by_ubicacion(no closed or annulled) and the CSV sectiondependenciabecomesdependencia_radicacion(+ubicacion_actual). INT-01 export carries the location as informational data that import ignores. MCP:ubicacion_dept_codeonlistar_radicadosandbuscar_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_deptuntouched), rollback, rollback + complete, finish, annulment freezing, cascade, duplicate name →NULLcode, atomicity under an audit failure, and a backfill compared row by row with the Python function (2nd run: 0 changes; the trigger rejectsUPDATE/DELETE); document-service 816 passed (RUN_INTEGRATION=1; 658 + 158 skipped without it); mcp-server 243; lint clean. Mutations (each one fails tests): remove theflujo_*write, order "last" bystep_number, take the filter out of theCOUNT, add the location toPublicVerifyResponse. Applied to the live stack:2026-DEMO-E-000117ends in Control Interno (code 907). Frontend:/bandeja(list and detail),/busqueda(column andubicacion_dept_codefilter),/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», onby_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». Withoutubicacion_deptthe cell reads «—»: it never falls back todest_dept, which would be inventing the location. The search proxies acceptubicacion_dept_codeonly as a positive integer. New helperlib/utils/ubicacionRadicado.js; types inlib/types/api.js. 2,237 frontend tests (19 new),svelte-check0/0; each mutated separately and red with its test: the list and search cells going back todest_dept, the «Cerrado en» block, the «Dependencia de radicación» block, the filter removed from each proxy and from theload, the reports panel and the dashboard card readingby_dependencia, thedest_deptfallback 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/bandejaand/busquedalists at ≤768 px, where the column is hidden; the/busquedafilter 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(requiresPERM_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 lacksby_ubicacioninstead of «Sin radicados en trámite». Frontend tests with one mutation per major point turned red. Impact: apply037(document-service) and then016(workflow-service) per tenant; rebuild document-service, workflow-service and mcp-server. The reports CSV no longer has thedependenciasection. 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_groupread the rowFOR UPDATEinside the same transaction and record{campos, antes, despues}and{name, description}. No migration. auth-service 322 withRUN_INTEGRATION=1, lint clean, mutation verified (3 tests fail). Declared out:min_crud=3on 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 offersdevolverto 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_aipandeventos_preservaciontargeted the storage routes, which requireX-Internal-Token— they would have returned401to 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'sejecutar. In F7 the reason was the human guarantee. Here it is prior and simpler:proteger-indiceandproteger-artefactorequire thefile_idof 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/*exceptuploadis not routed by the gateway, and the…-internaltwins are system triggers sealed withX-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 --strictclean. 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 inNO_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 phraseCONFIRMOfor 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 whyejecutaris 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%; onlyejecutarmissing, 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_conin the catalogue,frase_requerida()in the gate, cards that summarise lists,NO_OFRECIDASin 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. Andpreparar_transferenciadoes 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 writingdest_dept, the endpoint's name, where the tool declaresdest_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_radicadoasked 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 toproyectar_y_radicar_respuesta, with a description that tells the truth. And the same defect was in the corpus:seed_corpus.pyreadGET /expedientes/{id}/radicados, a route that does not exist, withopcional=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 tooperador(96 tools + 7 procedures), the physical-storage ones return the 9 units and 6 locations seeded, andestado_del_expedientecomposes 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 --strictclean. 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 F4for an operation-by-operation view). The document lives atdocs/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 inpublicand 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 realrequire_permissionof each router. An invented permission makes a tool invisible to everyone but ROOT, and that already happened in this catalogue with aRADI_CREARthat did not exist. The guard meant to prevent that was half blind, and that is the deeper fix.shared/tests/test_permisos_declarados.pyread only the DECORATOR'srequire_permission, not the router's own. It cut both ways: it accused the five third-party tools of over-declaring —their router does declarePERM_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: inanulacion.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 useroperador,listar_departamentosreturns the DANE's 33 andbuscar_tercerosfinds 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: newdocs/es/mcp-flujos.mdandscripts/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.pynow 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 directINSERTbypasses atomic numbering, the business rules and theaudit_loghash 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/tercerosansweredroute_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 migrationtenant/027had not been applied to the deployment: thetercerotable 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 forsize=200where 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_expedienteandestado_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, radicado2026-DEMO-E-000127was created and the routing step failed with422; the response saidpasos_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. AProtocol(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 passingNone, 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: newscripts/seed_corpus.py,/api/v1/tercerosrouted 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 correct401— the gateway's503fires only when auth-service returns 5xx or does not answer, which is the right semantics. I had taken it as open by reading the!= 200branch 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 sendsradicado_idandtracking_number, socontent_hashstayed 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'spg_advisory_xact_lockis not held across a network round trip. 2)/admin/gruposadministered 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 threeGETs 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.jsand 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 againsttenant_demo: linking2026-DEMO-E-000015(one annex) from the screen left the link with3af08544…, exactly the file'ssha256in 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-check0/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: newPOST /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 topreservacion_plan.formatos_origen, the other end offormatos_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. Thetype/subtypeshape is validated and rejected with422rather 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 anApplication/MSWordfell outside a policy that does name it, yielding zero candidates with nothing appearing broken. It costs no index — there is none onmime_type. The column arrives withDEFAULT '{}': 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 underadmin/preservacion(2 new),svelte-check0/0, lint green. Each guarantee mutated separately and red with its test: inverted precedence,lower()removed, MIME validation disabled, the column not reaching theINSERT, and the field not reaching thePUTfrom 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: migrationstorage tenant/016,formatos_origenonGET/PUT /api/v1/preservacion/plan, a new field on/admin/preservacion, andFORMAT_MIGRATION_MIME_TYPESdemoted 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. Migration003took 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, resourcegdxc-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 migration004;003is not rewritten. Thepublic.schema_migrationsledger identifies by(service, file name)and stores no content hash: editing003would 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 flaggedvigente = false. Deleting it would break a record already pointing at it, and the policy of not deleting a suppressed municipality was declared by003itself. 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: migrationglobal/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: whilemunicipioanddepartamentowere 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 inpublicbecause 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. Intercerothe codes carry foreign keys to the catalogue — a live record must point at a municipality that exists; inradicado_actorthey 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 aCASCADE, 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 aCHECK (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 sending05001with department11. 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.fallidoandno_verificadoare NOT the same, and conflating them has opposite consequences, none of them visible.fallidospeaks 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_verificadospeaks 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 underterceros/,svelte-check0/0, lint green across all 11 services. Mutated separately, each red under its own test: the membershipCHECK, the absent foreign key on the frozen copy, the trigger over the code, clearing the municipality on department change, the guessed policy, and thefallido/no_verificadodistinction. 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, migrationsglobal/003andtenant/027, the cascade in/radicar, and a sweep that is off by default with its policy inFORMAT_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.ssoSessionIdleTimeoutresets every time the refresh token is used, and the transparent refresh inhooks.server.jsfires 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_consultadoanddocument.anexo_descargado, written with actor, channel and security level. What failed was the door. The expediente's access history queriedobject_type ∈ {expediente, expediente_indice}by the expediente code, while those two traces useobject_type='radicado'andobject_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 thepayload, which carries the document's classification level. The tab's label dictionary matched NO real action (read,download,verify…): everything fell through tohumanize, and the "Download" entry made it look as though downloads were already shown. The map moves out of the.svelteinto 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 thatAuditQueryRepositorydid 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 answers409and 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) andno_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 asNoneup to a503that says what to install. The PREMISMIGRACIONevent type had been in the vocabulary from the start with nobody writing it; now this does, withhash_beforeandhash_after. Impact: two new endpoints, migration015,ghostscriptin 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 inaudit_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. Migrationstorage tenant/014opens the model with aCHECKthat enforces the pairing:objeto_tipo(expediente|lote) says whatexpediente_idrefers 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_idis 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 requireobjeto_tipo = 'expediente'explicitly — the two id spaces not colliding "in practice" is not a control. The record joinsCONTROL_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.pyforces every newtipoin theCHECKto be classified, but read migration 010 by name. Migration 014 widens theCHECKand 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_tipoin the WORM store (backwards compatible: existing rows becomeexpediente), 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 inborrador, follow-up only whenaprobado/en_ejecucion. And the gate is two conditions, not one: the plan's state andUSUA_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 whyavance_pctis 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, andpurge_for_dispositionhad had no door since it was written. Migration 049 released three foreign keys and one averted a silent loss:transferenciaswasON DELETE CASCADEand would have takentransferencia_actawith 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 inaudit_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_eliminacionhad to enter signature-service's XAdES allow-list: without it the record would have fallen back to thenativaHMAC 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 ownWHEREwith the server clock. The409distinguishes 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 topublic_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 theSELECT. 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, andpublicado_hastais mandatory and future, with no default. A test of mine did not discriminate, and mutation exposed it. The unit test mockedfetchrowreturningNone, so mutating theWHERE ... 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.ejecutadoalways travels asfalse. A resource of its own, not an appendix of/trd: a batch is a procedure with a lifecycle, and each transition has constancies enforced byCHECK— 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 carryworm_retain_untilon 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 the409with its reasons is forwarded as-is instead of collapsing into "could not create batch". Migrationarchive tenant/047validated 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-servicealready had two background loops with exactly the right shape — a testablerun_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 andnotification-serviceis 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, migrations045/046applied 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 requiresNOTIFICATION_INTERNAL_TOKENand 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: falsetravels explicitly with a note, because a201on 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_autorizadahad 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.CTis exempt;E,SandMare 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 migrationCHECK. The item leaves the active queue and comes back on its own. Decisions live indisposicion_decisiones(migrationarchive 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-vencidasurfaces it, and a fourth tab in/admin/trdputs 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_eliminaciondoes not reimplement the gate. The ADR-025 D5 criterion was extracted into a single predicate now shared by theassertthat 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,COUNTincluded: 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 usedvar(--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 freezesfin_archivo_gestion,fin_archivo_central, the disposition code and the act that validated the series intoexpedientes.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 leftdisposition = NULLandtrd_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. Thecierre_sin_snapshot_de_retencionreason had existed since migration 037, which used it to mark historical closures; the third tab of/admin/trdknew how to present it; and no code path ever wrote it afterwards. The backfill repaired the past and nothing protected the future — precisely the holeaudit_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 insideif 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 tomigradaseries (ADR-025 D6). Migrationarchive tenant/045sweeps 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 indocs/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/admingated 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:/radicarand/expedientesconsume it in order to classify, andUSUA_PERM_TRDbelongs 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 withUSUA_PERM_TRD:GET /trd/{code}/versionesand its TVD twin return the full history — drafts,acta_comite,fecha_aprobacion_comite,instancia_convalidanteandcreated_byof every version — the Committee's deliberation, not the published instrument. Its twinGET /revision-pendientealready required it. PINAR's 11 reads now requireUSUA_PERM_ADMIN, gated on theAPIRouteritself — 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 inborradoris 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'sloadput the403in the same bucket as network failures; it now travels assinPermiso, 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/radicarbehaves 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 neutral404— 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 its404read as a transient failure: an authorisation error presented as something else, the same defect that had/enviospresent a403as "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_RADIauthorises working with email; which mailbox is decided by a different cut — theWHEREinemail_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 withPERM_RADIin the institution could read the subject and sender of another officer's private mail and set it aside. Onlysincronizarandradicarcrossed 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 — answering404 cuenta_no_encontrada, not403: 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'sWHERE:cuenta_idbinds 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: ifaudit_logcannot 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 anUPDATEon 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 inaudit_logeveryUSUA_PERM_ADMINwould 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 thepublic.audit_logbootstrap ported from archive-service: the entry's atomicity cannot be checked with a double, because the rollback is produced by Postgres. Anddocs/{es,en}/api.mdfinally 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: theIMAP_HOST,IMAP_PORT,IMAP_USER,IMAP_PASSWORD,IMAP_USE_SSL,IMAP_MAILBOXandIMAP_TENANT_SLUGvariables, theGET /api/v1/ingest/email/estadoandPOST /api/v1/ingest/email/pollendpoints, theimaplibclient and ingestor behind them, and the "legacy mailbox" block of/admin/correowith 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-check0/0, lint green. Why: keeping two ingestion routes forces a decision about which one wins, and the legacy one registered the advertising — Impact: anyone withIMAP_*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, soPERM_SOL_ANULARwas enough to annul an Entrada. Legacy Orfeo did distinguish (sgd_trad_codigo). The rule lives inapp/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, not403: 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_tipoon 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.pyis 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 withPERM_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_ROOTis legitimate (it is theis_rootbypass).SGD_PERM_ESTADISTICAwas 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 becausePERM_RADI_SALIDAis 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 isON 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 requireSGD_PERM_ESTADISTICA; migrationsauth-service/018(descriptions only, includingPERM_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-levelrequiredPERM_RECLASIFICARat the defaultmin_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 requiresPERM_RADIat level 1 (taken from its only real consumer, the radicación flow; nobody currently working loses access).radicar_borradordid 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_INTERNOstill restrict nothing (0 gates). Enforcing them is a product decision that collides withPERM_RADI_SALIDAcurrently meaning "manage postal dispatch"; retiring them would drop existing grants. Migration017fixes what is unacceptable meanwhile: the description an administrator reads before granting. Two of my own findings were false:/vistos-buenos/decidirauthorises by identity (chain membership + turn), which is stronger than a generic permission; and themin_crudon expedientes is a coherent convention (3 = irreversible), not an oversight. Impact: reclassification now needs write level; uploads needPERM_RADI; drafts must be approved before registering. Migrationauth-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_permissionsheld 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 theRADI_CREARdefect 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 calledpytest.skipon every run — "11 passed, 1 skipped" reads just like "12 passed". Moved toshared/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'srequire_permission, and therequires_permissioncolumn 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 migrationauth-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 vsroute_not_foundon 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 givesconfirmacion_requeridaand it still does not change; with the phrase, 201 and a real tracking number; replaying the token givespropuesta_consumida; the record carries the real user'sregistered_byand itsaudit_logentry 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 needsANTHROPIC_API_KEYand 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 partyVoiceInput.sveltein the assistant composer:es-COrecognition, 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 Whispertranscribeendpoint. 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 (availableOnDevicefor that language, plusprocessLocally). Adownloadablemodel 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 Whispertranscribeendpoint 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-check0/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. Atool_useon one never reaches the dispatcher: it becomes a signed proposal carried inAssistantMessageResponse.propuestas(additive field, no coordinated deploy). Thetool_resultreturned to the model statesejecutado: falseand "NOTHING HAS BEEN EXECUTED… do not chain actions that depend on its result" — without that anchor the model reads its owntool_useas 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 inwrite_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.sveltein the dock, an/asistente/api/confirmarproxy, 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: aProposalErrorescaping 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 readX-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-Rolesdoes 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_ROLEScould 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 fromGET /auth/meon all three transports,permission_levelsincluded (build_toolsdid not passlevels, so read-levelPERM_RADIshowedtipificar_radicado, which requires create level). The scope framework reaches MCP:mi_alcancetool +orfeo://alcanceresource, both derived frombuild_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 newshared/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_ROLESno 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_retencionis a computation over a series and a closing date, not the expediente's recorded disposition;reporte_radicacionreturns aggregates only — no tracking number, subject or sender — so a count is never presented as an inventory.ubicacion_expedientesays where an expediente is, not what it holds (the backend deliberately skips no-read-up there: custody management is orthogonal to read clearance).consultar_retencionis 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 — onlyradicar_borradorconsumes the immutable number.buscar_antecedentestakes 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 withexactly_one_of,coerce_argsrejects 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}/radicardoes not require the draft to be approved, soaprobar_borradoris 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}/devolverandPOST /workflows/{id}/rollbackhad norequire_permission, while their three siblings in the same router —assign,transferandcomplete— requirePERM_TRAMITARover 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. Inexecute_transaction, a type withrequires_permission = NULLwas "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_typesis 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 thePERM_TRAMITARfloor. 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: requiringPERM_TRAMITARfor all of them would break someone who holdsPERM_VOBOto 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_pasoandejecutar_transaccion. The catalogue now stands at 30 tools, 11 writes.completar_pasonames 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_transaccionresolves its permission at runtime, so there is no decorator to compare and thetest_permisos_declarados.pyguard 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:devolverandrollbackrequirePERM_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 indocs/{es,en}/api.md:devolver's authorization and the "open type". Verified by mutation: removing each gate, restoringNULLto "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 theRADI_CREARdefect.consultar_expediente,indice_expedienteandconsultar_fuiddeclared "no permission" over endpoints gated withUSUA_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 caughtRADI_CREARand does not catch this: aNoneis 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.pynow 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 returned404 route_not_found. A tool whose route is not routed gets offered, proposed, confirmed… and fails. Both live inshared/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_expedientedeclares exactlyExpedienteUpdate's three fields and no more, because that schema carriesextra="forbid"andstatuswas 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 hadPERM_RADIand 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_expedienteis the only irreversible one admitted, atcrud >= 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}acceptedsubject,dest_dept,observations,statusandpages, 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 anUPDATE doc_classleaving 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 checkcreateperforms, 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 theNULLwhen the new type has no active template; without that explicitNULLthe radicado would keep pointing at the previous type's template. Sending onlymetadatare-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 withPERM_RADIat 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. Butdoc_classdecides how long the document is kept and whether it is ultimately destroyed, so it is checked separately atcrud >= 3. A defect of my own, caught by my own tests: I added the failure mode (metadata validation) without its response —MetadataValidationErrorwas not in the PATCH'sexcept, 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 thatmetadata_template_idappeared in theUPDATESQL, and found it in theRETURNINGclause — green with the write in place and removed. Corrected tometadata_template_id = $Nplus 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}acceptsdoc_classandmetadata;422 metadata_validation_failedwhen they do not fit;403when typing without creation level. New MCP toolstipificar_radicado(write,min_crud=3, with its declared alternative route) andlistar_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 droppingdoc_classfrom the audited fields. -
feat(mcp): confirming "something" was not confirming "this" — writes become a signed proposal — the MCP write gate wasX-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 hardcodedconfirm_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 genericconfirmar_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), standardannotations(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 (agrepfor 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, sosubwent 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.destructiveis declared explicitly on every write tool, defaulting toNoneon purpose: with aFalsedefault, 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-Writeis no longer read (senders get a proposal anyway). NewPOST /api/v1/mcp/proposals/executeandPOST /api/v1/assistant/confirmar(the latter under/assistant/because it is the only mcp-server prefix the gateway routes).MCP_PROPOSAL_SECRETis 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 buildingmcp-serverwith the repo root as context soshared/is installed. Rebuild its image. Declared limits: consumption byjtilives in process memory — it covers double-clicks and retries, not multi-replica — andIdempotency-Keyis 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 rawstr(). Adocument_idof../../auth/medid 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 withquote(safe=""), in two layers:coerce_argsvalidatesformat="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 arePATCH, and there arePUTandDELETE, so the catalogue could not express half the operations. There is nowbuild_request(prepares, does not send — which will let us sign exactly what gets executed) andexecute, with all four methods and argument placement bylocation.DELETEnever 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 fromclient.get/client.posttoclient.requestwould have left the security assertions sayingpost.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 torequest, 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_documentodeclared the permissionRADI_CREAR, which does not exist in the system — it is in none of the five migrations that seedauth_permissions; the real one isPERM_RADI.visible_toolscompared 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 readX-User-Permissions, a header nobody produces (the gateway injects six others and drops that one). Unified inapp/services/identity.py, always from the validated token. Visibility now respects the CRUD level (real 0..5 scale), not just permission presence: holdingPERM_RADIto read no longer enables what requires create — and asking for more hides the tool from those who can, which fails silently._TOOL_ARGSis 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_conocimientoadvertisedqueryof minimum 2 characters andtop_kup to 20 when the backend accepts 1 and 50 — with a test freezing it.queryis aligned;top_kstays 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/clearanceand/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/_INTERNOexist 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-containerruff/mypyinstall ran with2>/dev/nullunderset -e, so any failure of that install — network, contention from two concurrent gates, a slow package index — aborted the service and listed it asFAILEDwith no visible cause. Notably, the lesson was already written two lines below: theshared/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-servicestill 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. Migration042(publicabledefaulting to FALSE pluspublicado_at/publicado_porand a partial index), a cut requiring both conditions,POST /expedientes/{id}/publicacionwithPERM_RECLASIFICARatcrud≥3and a justification mandatory in both directions, act and audit entry in a single transaction, and the state exposed inExpedienteResponse. 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 aclosed/transferredexpediente is not — those are normal lifecycle states — and copyingstatus <> …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_seguridadis set at creation and no route mutates it (ExpedienteUpdateisextra="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 migration042. 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 theWHERE" is right; what failed was not applying it everywhere it belonged. (1) CMIS served content without requiring the act. The_PUBLICchokepoint gainedpublicablein block 11, butget_public_anexo— which feedsgetContentStream, i.e. the attachment bytes — carried a hand-written copy of theWHEREthat 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_classificationdid not touchpublicable: 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 — theUPDATErequirednivel_seguridad = 1in 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_seguridadis 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 withNOMBRES_AMBIGUOSplus a test that recomputes the set from the repository and fails if it diverges. (5)shared/tests/never ran in CI: thetest-unitmatrix 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. Newtest-unit-sharedjob. — 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 sanitisingUPDATE(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 changedpublicable's default toFALSE— 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}/publicacionexisted, 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:publicablecould 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 neutral404(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_RECLASIFICARat 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; theUPDATEdemandsnivel_seguridad = 1in its ownWHERE, 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-Idhad 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 torealm-dev.json, and production starts from arealm-prod.json.exampletemplate that cannot be used unfilled. CORS*with credentials was hardcoded. Starlette cannot answer*with credentials, so it reflects the incomingOrigin— 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.exampleis now included and validated with a realnginx -t, with HSTS andX-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.pyandinit_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.sqlbetween archive and document; the005/006/007dead-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. Theaudit_logwas never partitioned by month: migration 002 assumed a job nobody had written, everything piled intoaudit_log_default, and PostgreSQL refuses to create a partition whose range overlaps rows already in the default — the trap tightens over time.ensure_audit_partitionsnow 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/pollrequired 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 requiresUSUA_PERM_ADMIN, leaving an audit entry per poll; (2)PATCH /documents/{id}/dispositionset «eliminar» with read permission — nowmin_crud=3, with a mandatory rationale, written in the same transaction as its audit entry; (3) the FUID went out with no header:entidad_productorais now resolved server-side frompublic.tenants,unidad_administrativa/oficina_productora/objetoare declared per report, and consultation frequency is computed from real accesses in theaudit_logscoped bytenant_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), plusIMAP_TENANT_SLUGif 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/healthexpecting 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 thelatesttag: 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, evidentiaryaudit_logwas 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/tvdmirror the TRD ones (create, edit, version, approve, return, convalidate, register RUSD, repeal), same permission as TRD. Full version-history lookup (GET .../{code}/versiones) was also added for both TRD and TVD — promised in the previous increment and never built. — Why: a records-compliance audit finding on the retention model: an entity with an accumulated fonds had no way to appraise it inside the system. feat(frontend):/admin/interoperabilidad— export/import of radicados, batch loading and postal tracking (Phase 8, last of the API↔UI drift closure) — five backend capabilities had been ready for a while with no screen using them: exporting a ZIP package of radicados, importing it back preserving the original tracking number, creating documents or expedientes in batch, and querying a shipment's tracking against the postal operator. The new screen covers the first four in tabs; the last one was added as an "Update tracking" button in the existing/enviostray. The most delicate part is import: it is all-or-nothing (if a single file in the package does not match its declared fingerprint, nothing is imported) and when that happens the screen shows exactly which file failed and why, never a generic "import failed". Batch loading, being a bulk write over official documents, requires confirming the exact item count before executing, and while processing it polls progress every two seconds, always distinguishing "could not query the status" from "no results yet" (a fabricated zero progress is never shown). Postal tracking, having no real operator integration yet, says so explicitly instead of faking a live query. While building the screen it turned out batch loading could not work at all — the gateway had no route registered for those two endpoints. That was fixed (see Fixed below), but the fix changed the shape of the status URL and the screen kept the old one; combined with a reactivity defect in the progress panel, batch loading reported no result under any circumstance. The UX review of 2026-08-02 caught it, and it is now fixed in that review's own entry below. Export, import and postal tracking did work end to end from the start. — Impact: 1 new screen with 4 sections, 1 new button in postal shipments, 27 new automated tests.feat(frontend):/admin/tvdscreen + convalidation circuit shared with/admin/trd(ADR-026, frontend) — a structural mirror of the TRD screen with its own columns (fonds/producing unit, extreme dates, instrument state) and no active-archive column, treating the valuation rationale as the form's main field. The convalidation circuit (approve/return/convalidate/register RUSD/repeal) was built once as a sharedInstrumentLifecycleActionscomponent and wired into both screens — it did not exist in/admin/trdeither, ADR-025 had left it pending — warning about irreversibility inside the confirmation modal and offering no button the current state does not allow.fix(archive): remediation of the conformance + security verdict on the TRD convalidation circuit (ADR-025, migration 038) — the engine now hardens aconvalidadarow (the administrative act is immutable,estadomay only move toderogada, RUSD/publication are fill-once);devolver(aprobada→borrador, with a mandatory reason) closes the dead end ataprobada;registrar-rusdrecords the post-convalidation registry and publication;derogaris blocked whileopenexpedientes remain under the code; convalidation repeals the previous current version in the same transaction with collision-safe re-pointing of orphaned FKs;GET /trd/revision-pendientegains no-read-up by clearance plus a query trace (a security regression: the report did not filter by security level); and the D5 deletion gate authorizes over the frozen snapshot rather than the live row — the original criterion was the opposite of the one the ADR requires and would have blocked legitimate deletions.feat(frontend): PINAR MVP — Institutional Archives Plan in/admin/pinar(Phase 7, Acuerdo AGN 003/2015) — PINAR had 30 endpoints intenant-serviceand no screen at all. The MVP covers the listing (state filter, creating a new version always inborrador) and a workspace with a life-cycleStepperand a dashboard tab (progress per objective / per axis). Approval permanently freezes the plan's content (an entry in the immutableaudit_log), so it is warned about before confirming and the confirm button stays disabled without an administrative act; the dashboard distinguishes "could not load" from a legitimately empty plan, and never paints zeros because of a network failure. Out of scope for the MVP: critical aspects, prioritization, objectives, projects, follow-up, instruments and road map.feat(archive): TRD convalidation circuit —aprobar/convalidar/derogar+ ratification of migrated series (ADR-025 Increment 2) — the state machine that turns the TRD from a configuration table into a convalidated instrument: Comité approval, Consejo convalidation (or ratification of a series backfilled asmigrada), and repeal, each with its administrative act recorded and audited.feat(frontend): semantic and precedent search in/busqueda(Phase 6, E21) — two new tabs beside the existing exact search, resolved by?tab=(deep-linkable), with three SSR proxies including one that verifies the pivot radicado before searching. Non-negotiable interface honesty: results are presented as similar documents (vector similarity with an explained qualitative band, never an exact match), a network failure is never disguised as "no results", and knowledge-service's ACL trimming is invisible by design — no hidden-result count is announced, which would turn search into an existence oracle.feat(frontend): generative help (RAG) integrated into drafting and classification (F6, E21) — RAG is not a separate module: a "Suggest from precedents" button in/radicarand/borradorescallsPOST /knowledge/rag, and suggested tipo documental chips next to the classification selector are fed by/knowledge/antecedentes. Three hard rules: generated text is always inserted marked as generated and with its citations; without citations, insertion is not offered (an unsourced assertion in an official document is worse than no suggestion); and the classification chip is never auto-applied. A knowledge-service failure stays contained in the component and never blocks registering or saving a draft.fix(notification): hardened destination for outbound webhooks (Medium security finding, non-blocking) — the subscription URL is validated before delivery so the signed webhook cannot be pointed at internal targets.fix(auth): High finding closed — self-escalation toUSUA_PERM_ROOT; ROOT is now provisioned out of band (ADR-024) — an entity administrator could grant themselves the ceiling permission of their own entity. ROOT is no longer grantable through the API.fix(tenant,gateway):app/core/authz.py+ Channel A in tenant-service;/api/v1/tenants/removed from the gateway (closes 2 Critical security-audit findings) — tenant-service had no authorization at all (0 gate occurrences against 52 in document-service), and/api/v1/tenants/was the only leak between entities, closed by taking it out of the gateway. The docstrings claiming "the gateway authorizes" were deleted, not just supplemented: that false statement was the vector.fix(document):USUA_PERM_TRD+ auditing on the metadata catalog (E03, closes a High audit finding) — the metadata template/element routers wrote with no permission gate.feat(auth): immutable auditing of RBAC/URD/clearance writes (E08, closes a traceability gap) — granting a permission, changing a membership or raising a clearance now leave an entry in the hash-chainedaudit_log.feat(frontend): TRD/CCD, metadata, catalog and parameter administration (E03/E04/E14, Phase 5 of the API↔UI drift closure) — TRD/CCD reusesCatalogCrudPanelin two tabs, with series as an indented tree (theparent_idhierarchy is the data, not a flat list) and immutable codes; metadata splits with TRD (TRD = classification, metadata = the fields of those schemas) with create-only templates whosejson_schemais validated for shape before submitting; catalogs use a master-detail layout that degrades to two navigable levels on mobile; parameters adds a holiday calendar (Ley 51/1983 preload with no external call) and a business-day calculator that reconstructs the breakdown of the days subtracted, because the backend only returns the resulting date. Domain finding reported, not papered over: document-service'smetadata.py/metadata_elements.pyand tenant-service'scatalogos.py/config.pydeclare norequire_permission— any authenticated tenant user can write. These screens gate their write buttons withUSUA_PERM_ADMINclient-side as a conservative measure, which never offers less than the backend requires.feat(auth): reading group memberships, permissions and clearance (E08, closes the RBAC read gap) — the backend previously only supported blind add/remove on those three collections.feat(frontend): group and dependencia administration (E08/E14, Phase 5 of the API↔UI drift closure) — groups reuseCatalogCrudPanelwith a members/permissions/clearance drawer that states explicitly what the backend does not expose; dependencias add aDependenciaTreeNode.sveltetree overGET /dependencias/treewith an explicit risk-confirmation modal (not a tooltip) when renaming or deactivating, because dependencias are correlated by name inworkflow_rules.assign_to_deptand in in-flightflow_steps.feat(frontend): index of classified and reserved information (E08, Ley 1712 art. 20) + final disposition of the radicado/expediente (RF-MET-08/E04, Phase 4 of the API↔UI drift closure).feat(frontend): transfer detail with the acta as a certificate and personal signing by role + digital preservation administration (E12/E17/E10, Phase 4 of the API↔UI drift closure) — each signer's role is derived from their identity, never chosen in the UI.fix(workflow):workflow_rules.assign_to_deptvalidated when the rule is configured + anaudit_logtrace of the silent degradation — a rule pointing at a non-existent dependencia used to fail quietly at routing time.fix(document): auditing of the radicado annulment cycle (ADR-008/010).fix(workflow): forensic auditing of returns, process transactions and cascade reassignment (ADR-008/010).feat(workflow): mandatory reason when reverting an assignment (RF-FLU-08).feat(frontend): OrpycaMCP Design System v1.0 (E22, ADR-020) —_tokens.scssrepainted to the definitive palette keeping every--op-*name (zero breakage across 19 screens), with calculated, not estimated, WCAG contrast: the primary is split by role (--op-primaryfor non-textual use at 4.28:1,--op-primary-darkfor text at 6.56:1). v1.0 type scale, radii, shadows and motion + a globalprefers-reduced-motionrule; self-hosted Space Grotesk + Public Sans (@fontsource, no CDN). Audited bysvelte-ux-reviewer: 27 High/Critical findings remediated, including explicit confirmation before signing in/firmas, which had none.fix(frontend): Font Awesome was never loaded and the favicon 404'd (E22) — its CSS was never imported, so every icon rendered as an empty box.feat(frontend): public landing page at/(E22) — seven sections, with a 302 to/dashboardwhen a session exists; the site root previously did not exist.feat(frontend): login, callback and/403with the real brand (E22) — a split composition and Keycloak error messages mapped from a closed table, which broke the IdP↔/loginredirect loop.feat(frontend): global search in the top bar and configurable favorites in the sidebar (E22) — the search term travels in the URL (shortcut /, deep link?q=), and the favorites store is namespaced per tenant + user and consumes the already RBAC-filtered list, so a favorite whose permission is withdrawn disappears on its own.feat(frontend): dashboard redesigned around the four personal modules (E22) — tenant metrics move to a collapsible section gated bySGD_PERM_ESTADISTICA(UI mirroring the RBAC), andNAV_ITEMSis extracted tolib/utils/navItems.jsas the single source.feat(frontend):DataTablegains the design system's eight mandatory capabilities (E22) — filters,aria-sortordering, CSV export with formula-injection protection, configurable columns, saved views, multiple selection,content-visibilityvirtualization and in-cell editing, all opt-in;/busquedaand the four/reportespanels migrated.feat(archive): multi-level EAD 2002 / ISAD(G) archival description of expedientes in OAI-PMH (E11, RF-INT-06) — a new public endpointGET /api/v1/public/archive/oai/{tenant}disseminates each public expediente as a multi-level EAD fragment, walking thetrd_series.parent_idchain to build fonds→series→(subseries)→file, satisfying ISAD(G) 2.2 and 2.4. Anti-leak boundary: it stops atlevel="file"and never enumerates child radicados' tracking numbers (a public expediente may contain reserved radicados whose level archive does not know) — only an aggregate count, with a grep guard test. A depth guard was added to the recursive CCD CTE, since the endpoint is anonymous.feat(document): EAD 2002 / ISAD(G) archival description in OAI-PMH (E11, RF-INT-06) — a secondmetadataPrefix=eadon the same OAI endpoint, disseminating each public radicado as a self-contained EAD fragment with<archdesc level="item">and the six mandatory ISAD(G)/NTC 4095 interchange elements. Read-only, no migration: it reuses the_PUBLICchokepoint, so the format is orthogonal to the security trimming — choosingeadneither bypasses the filter nor creates an oracle.feat(document): interoperable import of radicados (E11, RF-INT-05) —POST /api/v1/importtakes the INT-01 ZIP as a raw body with all-or-nothing validation before touching the DB: each radicado validates against the service's own trusted JSON Schema (not the one shipped in the package, which could be lax) and every binary's SHA-256 is verified; a mismatch returns422with no partial state. It re-ingests preserving the original radicado number (a collision is skipped, never overwritten), rebuilds the E↔S relation by number, auditsradicado.importinside the transaction, and guards against zip-slip / zip-bomb. Closes E11's Critical finding.feat(document): interoperable export of radicados (E11, RF-INT-01) —POST /api/v1/exportproduces a ZIP with a manifest, a published JSON Schema the records are validated against, the attachment binaries and per-member SHA-256 checksums, applying per-radicado no-read-up and recording over-clearance omissions content-free. The radicado number is preserved as a portable identity, which is what makes INT-05 possible. Mass egress is audited (radicado.export).feat(document): minimal read-only CMIS 1.1 profile (E11, RF-INT-02) —GET /api/v1/public/cmis/{tenant}(Browser Binding JSON), wheregetContentStreamserves an attachment's bytes only when the radicado is public. Same_PUBLICchokepoint as OAI, so a reserved id yields an indistinguishable 404.feat(document): OAI-PMH — metadata harvesting (E11, RF-INT-02; SGDEA R.12.1) — the six verbs, Dublin Core, selective harvesting andresumptionTokenatGET /api/v1/public/oai/{tenant}. Harvesting exposes only what is public (Ley 1712/2014 arts. 18–19): the trimming lives at the source in a single_PUBLICfilter, so the omission is indistinguishable by construction —completeListSizecounts public records only and a reserved id returnsidDoesNotExist.feat(tenant): PINAR — Institutional Archives Plan (RF-ADM-08, E14; Acuerdo AGN 003/2015) — the AGN's six-step methodology as 9pinar_*tables (tenant migration007) under/api/v1/pinar: diagnosis → critical aspects with risk, five articulating axes, deterministic prioritization, vision, objectives, projects and append-only follow-up (DB trigger). State machineborrador→aprobado→en_ejecucion→cerradowith a single plan in execution per tenant, approval requiring an attributable administrative act, and content frozen on approval — all audited in the immutableaudit_logviaorpycamcp_common, the first use of the shared library in tenant-service.fix(document)+feat(document,archive): declared debts closed (E08/E10/E02) — no-write-up when registering a classified radicado,SYSTEM_RECONCILER_IDattribution for reconciliation entries, and subject/type in the expediente export manifest via a clearance-aware internal endpoint.feat(archive)+feat(storage): expediente export to ZIP —GET /api/v1/expedientes/{id}/export.zip(E02/export; Ley 594/2000 art. 19) — archive orchestrates and storage assembles via an internal D-02 endpoint: electronic index XML (best-effort) +manifiesto.csvincluding exclusions with their cause + aLEEME.txtscope note +checksums.txt+ the attachment bytes. Per-radicado no-read-up: document-service filtersnivel <= clearanceand omits over-clearance radicados including their existence — closing a read-up leak that an expediente-level gate did not cover.feat(document): index of classified and reserved information —GET /api/v1/reports/indice-reservado(.csv)(E08; Ley 1712/2014 art. 20) — the legally mandated register, content-free (never the subject) and orthogonal to clearance (the register must be complete), gated byPERM_RECLASIFICAR, with CSV formula-injection protection. Three conformance blockers were closed, chief among them that the legal grounds are now mandatory when classifying (whether registering or reclassifying, 422 if absent), so the index is never left with NULL grounds.feat(document): postal operator as a webhook consumer — inbound callbackPOST /api/v1/public/postal/callback/{tenant_slug}(E20 F5; RF-POR-08; ADR-018) — per-tenant/per-operator HMAC-SHA256, symmetric to the outbound webhook and separate from the human dispatch permission; the tenant in the path fixessearch_pathand the secret lives in the tenant schema, so there is no cross-tenant forgery. Fail-closed (a missing credential and a bad signature return the same non-oracle 401), idempotent viaUNIQUE (operador, event_id), with an atomic transition + audit entry; a late or backwards notification returnsrecorded_no_changeinstead of pretending.feat(knowledge): generative RAG with citations —POST /knowledge/rag(E21 Inc. 3; ADR-006) — a pluggableLLMProvider(ollama_localas the sovereign default; external providers andanthropicwith the native Citations API as options;disabledfalls back to the citation-only pattern). Hard sovereignty barrier for generation:is_generatableexcludes chunks atnivel_seguridad >= RESERVADAfrom the context when the provider is external — fail-closed, with no override, unlike the ADR's original soft model. Honest degradation with503 rag_unavailable.feat(knowledge): grounded retrieval with citations —POST /knowledge/antecedentes(E21 Inc. 2) — reusessearchand its ACL pre-filter, with an anti-oracle 404 collapse on the pivot radicado (it does not distinguish "does not exist" from "exceeds your clearance").feat(knowledge): real, local/sovereign embedding provider (E21 Inc. 1; ADR-006) — a pluggableEmbeddingProvider(local_stmultilingual sentence-transformers as the deployment default, a deterministicstubforced in tests) replaces the stub as the only provider;EMBEDDING_DIM(384) is the single source of the vector dimension with a fail-closed startup guard, migration007and apython -m app.ops.reembedbackfill job. With a local provider, classified material is embedded — it never leaves the installation — unlike with an external one.feat(storage): PDF/A validation on attachment capture (E07, E10 C2b follow-up; RF-PRE-02).feat(storage)+feat(archive): index-only AIP for 100% physical expedientes (E10 AIP wiring follow-up; OAIS ISO 14721; RFC 8493) — an expediente with no digital attachments recordsestado='omitido': its already-WORM signed index is its artefact.feat(archive)+feat(storage): PDF/A profile configurable per documentary series / TRD (E10 C2b follow-up; RF-PRE-02) —pdfa_profileis the explicit exception to the TRD's append-only trigger.feat(archive)+feat(storage): WORM renewal of the AIP for Conservación Total series (E10; closes the High debt from the AIP wiring) — the reconciliation phase now covers all three artefacts (index, acta, AIP), so a CT's complete OAIS package no longer loses its immutability when the original lock expires.feat(archive)+feat(document)+feat(storage): OAIS AIP wired to expediente closure (E10 debt; OAIS ISO 14721; PREMIS v3; Ac. AGN 001/2024 art. 4.3) — the endpoint existed but nothing triggered it. Because the AIP must be the complete package, attachmentfile_ids are resolved through a new internal document-service endpoint with no clearance gate (a system inventory act, unreachable through the gateway; no-read-up is enforced at the AIP's read boundary, not at packaging time). Preconditions: closed + signed index + WORM-protected; a mutated attachment aborts with a fixity 409 rather than producing a corrupt AIP; a session advisory lock closes the irreversible double-packaging race, verified against real concurrent PostgreSQL.feat(storage): real veraPDF PDF/A validation via a hardened subprocess (E10 Increment C2b; RF-PRE-02) —create_subprocess_execwith a fixed argument list and flavour whitelist, scrubbed ENV (the child never seesDATABASE_URL/MINIO_SECRET_KEY/tokens),start_new_session+killpg+reap guaranteed infinallyincluding cancellation, a concurrency semaphore with JVM memory caps, a per-invocation 0700 tempdir, dual output caps against DoS, JSON-only (no XXE), and honest degradation to anot_evaluatedstub when the binary is absent. The JRE + veraPDF ship only in the deploy image; CI exercises the real plumbing with a fake binary. Tested against real veraPDF 1.30.2.feat(storage)+feat(archive): WORM retention renewal for Conservación Total series (E10 debt; Ley 594/2000 + Ac. AGN 001/2024 art. 4.3.2.6) — a COMPLIANCE object of a permanently conserved series became deletable again once its originalretain_untilelapsed. A new internalrenovar-retencionendpoint requires a strictly greater extension (otherwise an idempotent no-op) and usesset_object_retention; archive gains a reconciliation phase with a local mirror of the retention date, a fail-closed CT filter in SQL, a rolling window and exponential backoff with anagotadoalarm. OnlyCT/conservedispositions are renewed — E/S/M expire and are legitimately disposed of. The irreversible public route was withdrawn (internal only) and a visibility endpoint surfaces CT artefacts at risk.fix(workflow):GET /workflows/{id}/eventsscoped by clearance (E05 §10 / RF-SEG-08) — the radicado's route map is only visible to those who can read it (no read-up); operational Channel B, not forensic auditfeat(archive): expediente route map (E02 §9) — append-onlyexpediente_eventostable (migrationarchive tenant/014), events on open/link/unlink/close/transfer, andGET /api/v1/expedientes/{id}/eventosscoped by clearancefeat(audit):public.audit_logcoverage (E08 §9) — workflow and archive also emit to the cross-cutting forensic audit viaorpycamcp_common.audit(archive-service now installs the shared library)fix(security): full read shielding — detail GETs and semantic search (RF-SEG-08 / RF-BUS-04) — Why: close the read surfaces that still didn't apply clearance (direct access by id/tracking and semantic retrieval) — Impact:GET /api/v1/documents/{id}and/by-tracking/{tn}(document-service) andGET /api/v1/expedientes/{id}(archive-service) scope the query withnivel_seguridad <= clearance; an object above the caller's clearance returns 404 (does not reveal its existence, consistent with search hiding it). Internal/system callers (respuesta, anulación, transfers, index) usemax_clearance=3by default (no restriction). knowledge-service: semantic search no longer trusts the client-suppliedacland now resolves clearance server-side (COALESCE(acl->>'nivel_seguridad',1) <= clearancealways; clientaclonly additional narrowing); newknowledge-service/app/core/clearance.py.caller_clearancehelper in archive (resolved after settingsearch_path);resolve_caller_clearancemade robust with.get()for the detail path's double fetch. 71 document + 62 archive + 6 knowledge tests green (4 new). Read access control closed on ALL surfaces: search, listing, detail (radicado and expediente) and semantic retrievalfix(document): classification-based access control in search (RF-SEG-08 / RF-BUS-04) — Why: search only isolated by tenant (search_path); within a tenant any user saw every radicado regardless of their clearance — Impact: newradicados.nivel_seguridadcolumn (migrationdocument tenant/016, scale 1=PUBLICA/2=RESERVADA/3=CLASIFICADA =security_levels.code, DEFAULT 1 → no behavior break);GET /api/v1/searchresolves the caller's clearance from the tenant schema (auth_users/auth_memberships/role_clearance, ADR-013: the DB authorizes) and filtersnivel_seguridad <= clearance(no read-up, least privilege, fail-closed to PUBLICA);POST /api/v1/documentsacceptsnivel_seguridad(default 1) to classify; reusableapp/core/clearance.py; 70 document tests green (7 new: helper + filter), migration validated on real pgfix(archive): same clearance scoping on the radicado listing and expediente search (RF-SEG-08 / RF-BUS-04) — Why: finish read access control on the surfaces still exposing everything within the tenant — Impact:GET /api/v1/documents(document-service) now filters the listing by clearance (find_all/service.listtakemax_clearance);GET /api/v1/expedientes/search(archive-service) filters bynivel_seguridad <= clearancewith the newexpedientes.nivel_seguridadcolumn (migrationarchive tenant/013, DEFAULT 1) and helperarchive-service/app/core/clearance.py(resolved after setting the tenantsearch_path);POST /api/v1/expedientesacceptsnivel_seguridad(default 1); 61 archive tests green (6 new), migration validated on real pg. Read access control closed across radicado search+listing and expediente searchdocs(adr): ADR-020 — Orpyca design system + frontend conversational assistant (E22) — Why: lock down the cross-cutting design/UX and AI-integration decisions before coding the frontend, in a gradual and controlled way — Impact: newdocs/es/adr/ADR-020-sistema-diseno-orpyca-asistente.md(status Proposed); decides a single layout + Orpyca tokens as Sass variables and--op-*custom properties (per-tenant runtime rebranding), role-based UI mirroring the RBAC, and a text/voice assistant that translates natural language into MCP tools (ADR-019) — without executing actions itself — with a local LLM (Ollama) and Whisper STT, both pluggable and opt-in via Compose profiles; registered in the ADR index (ES+EN)chore(quality): pre-commit cleanup before the first source publication — Why: to leave the tree clean and compliant withCONTRIBUTING.mdbefore the first commit — Impact:ruff checknow passes clean (fixed 36 findings: 18 unused imports, 10 unsorted import blocks,isinstance/annotations toX | Ysyntax,zip(strict=), f-string without placeholder); added the missing.env.examplefiles (knowledge-service,signature-service); obsolete scratch notes (config.txt) moved out of the versioned tree;compileallOK andsharedtests green after the autofixeschore(license): attribution/license header across the whole source tree — Why: to record authorship and license terms in every file — Impact: added to 437 files (361.py, 63.sql, 11 Dockerfile, 2.js) a header «Desarrollo original de Orpyca; adaptado a partir de Orfeo bajo licencia GPL – aurigadl@gmail.com» with the AGPL v3 notice (the project's license, derived from the original Orfeo's GPL); applied using each type's comment syntax; verified:compileallOK and 347 tests green across the 11 services after the changefeat(mcp-server): per-session OAuth — the MCP obtains/refreshes the Keycloak token (E18, ADR-019) — Why: to complete E18 without relying on a static bearer: the mcp-server manages the token lifecycle — Impact:app/services/oauth.py(TokenManager) obtains/refreshes the access token viaauth-service(/api/v1/auth/tokenROPC and/api/v1/auth/refresh, public in the gateway) with expiry caching; precedenceMCP_BEARER_TOKEN(static) →MCP_REFRESH_TOKEN→MCP_AUTH_USERNAME/MCP_AUTH_PASSWORD; over HTTP the bearer still arrives via header; 27 tests green (4 new). E18 (MCP layer) complete: tools + resources + prompts + stdio + HTTP streamable + per-session OAuthfeat(mcp-server): MCP resources and prompts (E18, ADR-019) — Why: to complete the three MCP capabilities (tools + resources + prompts) so the agent can read data by URI and use reusable templates — Impact:app/mcp_resources.pyregisters resources (orfeo://normativastatic + templatesorfeo://radicado/{id},orfeo://expediente/{id},orfeo://expediente/{id}/indiceresolved against the gateway via the catalog) and prompts (radicar_pqrsd,resumen_expediente,buscar_antecedentes); available on both transports; the server advertisestools/resources/promptscapabilities (verified over the real protocol); 23 tests green (7 new). Pending: only per-session OAuthfeat(mcp-server): MCP HTTP streamable transport for remote clients (E18, ADR-019) — Why: to complete the MCP binding with the remote transport in addition to stdio — Impact:POST /mcpendpoint mounted on the FastAPI app withStreamableHTTPSessionManager(stateless + JSON); the session context arrives via headers (Authorization/X-Tenant-Slug/X-User-Permissions/X-User-Roles) and is injected through aContextVar(stdio still uses the environment);mcp_app.pyrefactored for a unified stdio/HTTP context;initializehandshake over/mcpverified in a test; 16 tests green (2 new). Pending: per-session OAuth + resources/promptsfeat(mcp-server): working MCP server over stdio (E18, ADR-019) — Why: to bind the official MCP SDK over the existing catalog/dispatcher so agents/LLMs can consume OrpycaMCP via the MCP protocol — Impact:app/mcp_app.py(anmcp.server.Serverthat exposes the catalog as MCP tools with inputSchema, filtered by permissions, and runs them via the dispatcher against the gateway propagating token+tenant) +app/mcp_stdio.py(entrypointpython -m app.mcp_stdio);mcp>=1.2.0dependency; real stdio handshake verified (initializeprotocolVersion 2024-11-05 +tools/list→ 8 tools); session context via environment (MCP_BEARER_TOKEN/MCP_TENANT_SLUG/MCP_USER_PERMISSIONS/MCP_USER_ROLES); 14 tests green (5 new). Pending: HTTP streamable transport + per-session OAuth + resources/promptsdocs(ops): external-integration operations notes (integraciones-externas.md, ES+EN) — Why: to guide activating the deferred external components once the environment is available — Impact: documents, for each pluggable capability, the swap point, the stable contract and the steps: PKI/XAdES qualified signature (signature-servicesigner.py), WORM/Object-Lock (storage-service), PDF/A validation with veraPDF, MCP SDK binding (mcp-server), real embeddings/RAG (knowledge-serviceembeddings.py), real postal operator (document-servicepostal_provider.py); added to the nav; clean--strictbuilddocs(i18n): complete publishable documentation in Spanish and English — Why: the MkDocs site is bilingual butdocs/en/only had 3 stale pages; the rest fell back to Spanish — Impact: all 14 nav pages translated to English (index, domain, normativa, lifecycle, getting-started, architecture, multi-tenancy, adrs, api, deployment, roadmap, contributing, security-audit, changelog);deployment.mdadded to the nav; fixed broken links todocumentos/specDrive/in ADR-010/012 and accented anchors indeployment.md; cleanmkdocs build --strict(es + en, no warnings). The 19 ADR detail pages still fall back to Spanish (technical decision records)feat(document): extended statistical dashboard + CSV export (E09, AGN-mandated) — Why: entities periodically report statistics to oversight bodies — Impact:GET /api/v1/reports/radicadosaddsby_dependencia; newGET /api/v1/reports/radicados.csvexports the summary (type/status/month/dependencia sections) as CSV withContent-Disposition; filters?date_from=&date_to=; 63 document tests green (1 new)feat(document): citizen PQRS registration (public, Ley 1755/2015) — Why: a legal requirement for public entities; a citizen must be able to file a petition without logging in and then track it — Impact:POST /api/v1/public/{tenant}/pqrs(unauthenticated, tenant in the path) creates an Entrada radicado withmetadata.canal=pqrs_ciudadanoand type (petición/queja/reclamo/sugerencia/denuncia), reusing the radicación and verification code from E13; returns{radicado_id, tracking_number, verification_code}for tracking via/public/{tenant}/verify/{code}; already routed as public in the gateway; 62 document tests green (2 new)feat(document): Salida templates + drafts (legacy parity) — Why: to produce Salida documents (create/edit/approve/register) before assigning them an official number; templates provide reusable content — Impact:plantillaandborradortables (migrationdocument tenant/015);GET/POST/DELETE /api/v1/plantillas;POST/GET /api/v1/borradores(optional creation from a template),GET/PATCH /api/v1/borradores/{id}(edit only if not yet registered),POST .../aprobar(draft→approved),POST .../radicar(→ generates the real radicado reusing the radicación, marksradicadowithradicado_id); routed in the gateway; 60 document tests green (4 new); migration validated on real pgdocs(api): document tenant administration (E14) — dependencias, catalogs, parameters, non-business days/holidays (GET/POST/DELETE /api/v1/config/holidays,/seed) andbusiness-days/calculate— Why: the holidays CRUD already existed (config.py) but was not inapi.md; the audit had flagged it as a gap, which was a false positivefeat(workflow): sequential approvals (vistos buenos) (legacy parity) — Why: approval governance; several reviewers give their sign-off (VoBo) in order before a document can be sent/signed — Impact:visto_buenotable (migrationworkflow tenant/008);POST /api/v1/workflows/{id}/vistos-buenoscreates the ordered chain of reviewers;POST .../vistos-buenos/decidir(the reviewer whose turn it is approves/rejects;403out of turn; a rejection stops the chain);GET .../vistos-buenos(global statusen_revision|aprobado|rechazado); 48 workflow tests green (4 new); migration validated on real pgfeat(document): quick reply — Salida linked to its Entrada antecedent (legacy parity) — Why: covers 30–50% of daily Salida production; a shortcut to reply to a radicado inheriting context — Impact:responde_acolumn (migrationdocument tenant/014);POST /api/v1/documents/{id}/respuestacreates a Salida radicado (reuses the radicación: numbering/events/audit) with subjectRE: …and a link to the antecedent (inresponde_a+metadata.antecedente);GET /api/v1/documents/{id}/respuestaslists the replying salidas; 56 document tests green (2 new); migration validated on real pgfeat(document): two-step radicado annulment (legacy parity) — Why: the law prohibits deleting radicados; they are annulled with supervisor approval — Impact:radicado_anulaciontable (migrationdocument tenant/013, partial unique index = one active request per radicado);POST /api/v1/documents/{id}/anulacion(request with cause/reason;409if already annulled or with a pending request),POST .../anulacion/aprobar(→ radicado moves toanuladoatomically; the number is preserved),POST .../anulacion/rechazar,GET .../anulacion; traceability of who requested/decided; 54 document tests green (4 new); migration validated on real pgfeat(workflow): return to sender (legacy parity) — Why: re-routing a misassigned radicado is the most frequent action after receiving it; without it the inbox gets stuck — Impact:POST /api/v1/workflows/{radicado_id}/devolver{to_dept, causal, comentario}closes the active step asreturnedand creates a new step toward the destination dependencia, with the cause (tenant catalog) recorded in the append-only history (flow_events, tipo_txdevolucion);404if there is no active step; 44 workflow tests green (2 new)feat(tenant): operator catalogs — causes, dispatch methods, supports, quick messages (legacy parity, E14) — Why: causes are a required lookup for return/annulment/reassignment (the next operator functions) — Impact: migrationtenant/006(4 tables + seed of 6 causes and methods/supports) registered in the generic catalog; available viaGET/POST/PATCH/DELETE /api/v1/catalogos/{causales|formas-envio|soportes|mensajes-rapidos}; 47 tenant tests green; migration validated on real pgfeat(archive): bulk inclusion of radicados into an expediente (legacy parity) — Why: linking many radicados at once is common when assembling an expediente — Impact:POST /api/v1/expedientes/{id}/radicados/batch{radicados:[...]}links up to 500 (idempotent: skips already-linked ones), regenerates the index only once, returns{vinculados, omitidos, total};409if the expediente is notopen; 55 archive tests green (1 new); no migrationfeat(storage): AIP packaging (BagIt + PREMIS manifest) (E10 RF-PRE-05/07, ISO 14721 OAIS) — Why: the OAIS model requires packaging the expediente with its fixity and preservation metadata for long-term archiving — Impact:aiptable (migrationstorage tenant/004);aip_buildergenerates the BagIt content (bagit.txt + manifest-sha256 + bag-info) and the PREMIS manifest (objects with SHA-256 fixity + ingest event) using the stdlib, plus the fixity of the AIP itself;POST /api/v1/preservacion/aippackages and records the INGESTA event;GET /api/v1/preservacion/aip/{expediente_id}retrieves the current AIP; 25 storage tests green (3 new); migration validated on real pg. (Real WORM/Object-Lock and veraPDF validation: infra, pending.)feat(archive): electronic index signing on expediente closure (E15-F3, RF-EXP-06) — Why: conforming closure (Acuerdo 001/2024 Art. 4.3.2.4) requires that the index be signed and immutable — Impact:close_expedientegenerates the final version of the index and signs it viasignature-service(SignatureClient.sign_xml, best-effort: if the service does not respond the closure is not blocked and the index remains current);expediente_indice.estado='firmado'+firma_id(IndexRepository.mark_signed);IndexService.firmar_indice; archive gainssignature_service_urlin config; 54 archive tests green (1 new)feat(signature-service): new electronic signature service (E06, ADR-016) — Why: to realize the deferred signature provider from ADR-016 and unblock index signing on closure — Impact: newsignature-servicemicroservice (port 8008, indocker-compose.yml) with a per-tenantfirmatable (migrationtenant/001); pluggable signature provider (nativaby default: SHA-256 hash + identity + seal; PKI XAdES/PAdES/TSA as a future swap);POST /api/v1/signature/sign-xml(signs a payload and persists the signature) andPOST /api/v1/signature/verify(recomputes and detects tampering); routed in the gateway (/api/v1/signature/); 4 tests green; migration validated on real pg. (Qualified X.509+TSA digital signature: pending via pluggable provider. Index-signing hook on closure: next increment.)fix(gateway): route the rules engine/api/v1/workflow/rules/(E05) — Why: the router uses the singular prefixworkflowand the gateway only routedworkflows/, leaving the rules engine unreachable via the gateway (404) — Impact: added the prefix toproxy.py; +1 case in the routing test; 13 gateway tests green-
docs(domain): updatedomain.mdwith the F3–F6 entities (electronic index, signature, physical archive/shelf-mark/loan, FUID, transfer, preservation, postal dispatch, webhook) + business rules + glossary; corrected thetrazabilidad.mdmatrix (E06/E10 → PARTIAL with real scope) -
docs(adr): ADR-014 — search with PostgreSQL FTS (tsvector + pg_trgm + JSONB), not Elasticsearch — Why: to enable E09 without external infra, consistent with schema isolation (ADR-002) and strong consistency (the index is derived from the DB); a reversible exit door via events if an institution exceeds the scale feat(document): IMAP email ingestion → Entrada radicado (E19/F2) — Why: to automatically register communications received by email — Impact:POST /api/v1/ingest/email/poll(internal, requires JWT) fetches the unread messages from the configured IMAP mailbox, creates an Entrada radicado per email (reusing numbering/audit/events) and marks them as read; the sender and attachments go inmetadata(source=email);parse_email(RFC 822, multipart/attachments),ImaplibClient(imaplib in a thread) behind an injectableImapClientProtocol;IMAP_*config (disabled ifIMAP_HOSTempty → 503); routed in the gateway (/api/v1/ingest/); 41 document tests green (4 new). (Uploading attachments to MinIO: a later evolution.)feat(document): public lookup by verification code (E13/F2) — Why: a citizen must be able to verify the traceability of their radicado WITHOUT logging in (transparency, Ley 1712/2014) — Impact:GET /api/v1/public/{tenant}/verify/{code}public (no JWT), exposes only NON-sensitive traceability (tracking_number, doc_type, status, registered_at, dest_dept);verification_codecolumn (migrationdocument tenant/010, a 32-char token generated in the DB by default, unique); the tenant travels in the path and the slug is validated (anti-injection in search_path); gateway:/api/v1/public/marked public and routed to document; 37 document + 13 gateway tests green (4 new)docs(adr): ADR-017 — physical archive: Location (recursive address) ≠ Conservation unit (movable container), ArchivesSpace pattern — Why: to reorganize the repository without touching expedientes, variable depth, hybrid expediente support (Acuerdo AGN 001/2024, FUID 042/2002)feat(document): postal dispatch of radicados (E20/F5, via E11) — Why: to dispatch outgoing radicados via a postal operator (4-72/Servientrega) and track the delivery status — Impact:postal_shipment+postal_shipment_eventtables (migrationdocument tenant/012); pluggable operator provider (deterministic tracking-number stub);POST /api/v1/documents/{id}/envios(generates tracking number, statusregistrado),GET /api/v1/documents/{id}/envios,GET /api/v1/envios/{id}(with history),POST /api/v1/envios/{id}/estado(operator callback/polling with state machineregistrado→en_transito→entregado|devuelto|fallido,409invalid transition); routed in the gateway; 50 document tests green (5 new); migration validated on real pgdocs(adr): ADR-019 — MCP layer as a thin client facade over the gateway (declarative tool catalog, no business logic or DB) — Why: to expose the API to agents/LLMs without duplicating RBAC/tenant/audit or breaking layer boundariesfeat(knowledge-service): new knowledge-layer service (E21/F6, ADR-006) — Why: to exploit institutional knowledge as a derived asset (semantic retrieval) without compromising the SGDEA guarantees — Impact: newknowledge-servicemicroservice (port 8011, indocker-compose.yml) with pgvector per tenant (knowledge_chunkwithembedding vector(64)+ ivfflat cosine index; migrations global001_pgvector+ tenant001_knowledge); pluggable embeddings provider (deterministic local stub by default, ADR-006, no LLM);POST /api/v1/knowledge/ingest(text+metadata+acl → embedding + persistence) andPOST /api/v1/knowledge/search(kNN semantic retrieval with ACL pre-filtering by JSONB containment); an advisory/derived and opt-in layer (not a source of truth); 5 tests green; migrations validated on real pgvector (kNN cosine score 1.000). (Event-driven ingestion, backfill, RAG with citations and a real model are later increments.)feat(mcp-server): new MCP-layer service (E18/F6, ADR-019) — Why: the epic that justifies the product's "MCP" — exposing OrpycaMCP as tools to agents/LLMs — Impact: newmcp-servermicroservice (port 8009, indocker-compose.yml), an HTTP client of the api-gateway (never DB/MinIO/Redis); declarative tool catalog (register/search/query radicado/expediente/index/TRD/inbox/FUID) mapped 1:1 to REST endpoints;GET /api/v1/mcp/tools(filters by the user's permissions; ROOT sees all),POST /api/v1/mcp/tools/{name}/invoke(propagates Keycloak token + tenant to the gateway, write gate withX-Confirm-Write, per-permission scope → 403/412); 9 tests green. (The official MCP SDK binding —stdio/HTTP-streamable, initialize/capabilities— wraps this core and remains the next increment.)feat(storage): digital preservation plan + PREMIS events (E10/F5, RF-PRE-01/07, AGN 001/2024 Art. 4.3.2.6, ISO 14721 OAIS) — Why: to guarantee the document's five qualities over the long term with a Digital Preservation Plan and an immutable record of interventions — Impact:preservacion_plan(append-only versioning) andpreservacion_evento(PREMIS-style, migrationstorage tenant/003) tables;GET/PUT /api/v1/preservacion/plan(current/new version) +/plan/versions;POST/GET /api/v1/preservacion/eventos(INGESTA/FIJACION/MIGRACION/VALIDACION_PDFA/WORM/REPLICA with before/after hash and JSONB detail); routed in the gateway; 22 storage tests green (4 new); migration validated on real pg. (AIP/BagIt, real Object Lock/WORM and veraPDF validation require infra/libs and are addressed separately.)docs(adr): ADR-018 — outbound interoperability via signed HTTP webhooks (HMAC-SHA256) over the event bus, subscribed per tenant — Why: to integrate external systems in a decoupled and secure way without exposing the internal bus or polling the APIfeat(notification): signed outbound webhooks (E11/F5, ADR-018) — Why: to deliver domain events to external systems (portals, ERPs, postal operators E20) reactively — Impact: per-tenantwebhook_subscriptiontable (migrationnotification tenant/003);POST/GET/DELETE /api/v1/webhooks(subscriptions withurl,event_types—empty=all— andsecret); when consuming an event from the bus, notification-service POSTs the canonical envelope to the matching active subscriptions, with headerX-OrpycaMCP-Signature: sha256=<hmac>, best-effort with retries that never blocks processing; routed in the gateway; 20 notification tests green (5 new); migration validated on real pgfeat(archive): primary/secondary documentary transfers (E12/F5, AGN 001/2024 Art. 4.4.x) — Why: to transfer expedientes between the three archives of the life cycle (management→central→historical) with FUID as a deliverable and freezing of the expediente — Impact:transferenciastable (migrationarchive tenant/012); cyclepreparada→enviada→recibida|rechazada;POST /api/v1/transferencias(precondition: expedienteclosed→ 409 if not),/enviar,/recibir(on receipt, the expediente moves totransferredand is frozen),/rechazar,GETlist/detail,GET /{id}/fuid(deliverable, consumes the E17 FUID filtered by the expediente); routed in the gateway; 53 archive tests green (5 new); migration validated on real pgfeat(archive): FUID — Formato Único de Inventario Documental (E17 RF-ARF-10, AGN 042/2002) — Why: a canonical inventory of the holdings (the single source that E12 transfers consume) — Impact:GET /api/v1/fuidderives the inventory (expediente↔unit↔shelf-mark↔TRD series) in JSON with a consecutive order and AGN fields (subject, series, shelf-mark, unit, folios, support, extreme dates); filters?ubicacion_id=&trd_serie_id=;GET /api/v1/fuid.xmlexports in XML (namespaceurn:orpycamcp:fuid:v1, C14N); routed in the gateway; 48 archive tests green (2 new); no migration (read-only). (PDF/XLSX export and import: later increments.)feat(archive): loans and physical movement history (E17 RF-ARF-07/08) — Why: to control the checkout/return of physical units and leave an unalterable trace of their movements — Impact:prestamo(prestado→devuelto;vencidoderived from the expected date) andmovimiento_fisico(append-only, migrationarchive tenant/011) tables;POST /api/v1/unidades/{id}/prestamos(409 if already on loan),POST /api/v1/prestamos/{id}/devolver,GET /api/v1/prestamos?estado=prestado|devuelto|vencido,GET /api/v1/unidades/{id}/prestamos,GET /api/v1/unidades/{id}/movimientos; every loan/return records a movement (RETIRADO/DEVUELTO); routed in the gateway; 46 archive tests green (4 new); migration validated on real pgfeat(archive): core physical archive model (E17/F5, ADR-017) — Why: to safeguard and locate the paper holdings by linking the intellectual↔physical hierarchy — Impact:ubicacion(recursive),unidad_conservacion(with topographic shelf-mark) andexpediente_unidad(N:M with folio range) tables (migrationarchive tenant/010); endpoints/api/v1/ubicaciones(CRUD + path derived by recursive CTE),/api/v1/unidades(shelf-mark derived from the path + code, unique per tenant),POST /unidades/{id}/expedientes(link),GET /unidades/{id}/expedientes("what is in the box") andGET /expedientes/{id}/unidades("where the expediente is"); routed in the gateway; 42 archive tests green (4 new); migration validated on real pg. (Loans, history, FUID, label, capacity: later increments of E17.)feat(archive): index integrity verification (E15 T-14) — Why: to check that documents have not been altered since the index version — Impact:GET /api/v1/expedientes/{id}/indice/verify(?version=N) contrasts thevalor_huellarecorded in the index with the current hash of each document (expediente_radicados.content_hash) and reports{valido, documentos:[{document_id, huella_indice, huella_actual, coincide}], firma:{presente, valida}, fecha_verificacion}; 38 tests green (2 new: intact index / altered document)feat(archive): per-document hash value in the index (E15 T-08) — Why: the electronic index requires the SHA-256 hash of each document (charter §5.8); the hash travels with the link (provided by whoever knows the content) instead of having archive query storage internals — Impact:RadicadoLinkacceptscontent_hash(SHA-256, validated);content_hashcolumn inexpediente_radicados(migrationarchive tenant/009); the index uses that value as the item'svalor_huella; 36 tests green (1 new); migration validated on real pgfeat(archive): automatic index update + immutability on link/unlink (E15 RF-EXP-04) — Why: the index must always reflect the current documentary set and not allow changes on closed expedientes — Impact:link_radicado/unlink_radicadoregenerate the index (new append-only version, idempotent) after each change; adding/excluding radicados from a non-openexpediente returns409 expediente_not_open; 35 tests green (1 new)feat(archive): electronic index of the expediente (E15, Acuerdo AGN 001/2024) — Why: the piece that gives the electronic expediente legal validity (equivalent to foliation + control sheet) — Impact:expediente_indice(append-only versioning) andexpediente_indice_item(per-document snapshot with original order, migrationarchive tenant/008) tables;IndexBuildergenerates the XML (namespaceurn:orpycamcp:indice:v1, R.1.34 fields) withxml_sha256hash (C14N) andcontent_hashof the set (idempotency); index version 0 on expediente creation;GET /api/v1/expedientes/{id}/indice(current or?version=N),/indice.xml,/indice/versions,POST /indice/rebuild(idempotent: does not create a version if the set did not change); 34 tests green (5 new); migration validated on real pg. (The E02 foliation endpoint was renamed to/foliado.) Pending E15:valor_huellafrom storage fixity (T-08), XAdES signing on closure (F3), strict XSD validation (lxml)docs(adr): ADR-016 — native electronic signature (SHA-256 hash + identity + timestamp), PKI/PDF (PortableSigner) deferred — Why: a verifiable electronic signature (Ley 527/1999, Decreto 2364/2012) without a JVM dependency, reusing identity (ADR-013), SHA-256 integrity (E07) and immutable audit (ADR-008)feat(document): electronic signature of radicados/attachments (E06/F3, ADR-016) — Why: to leave verifiable evidence of who signed what and when — Impact:signaturestable (migrationdocument tenant/011);POST /api/v1/documents/{id}/signaturessigns (identity from the gateway claims +content_hashSHA-256 + reason);GET .../signatureslists;GET .../signatures/verify?content_hash=verifies that the signed hash matches the current one (tampering detection); 45 tests green (4 new); migration validated on real pgfeat(archive): expediente life cycle — closure/transfer/index (E02/F3, ADR-015) — Why: to apply the TRD disposition on closure and support transfer and foliation (Ley 594/2000) — Impact:POST /api/v1/expedientes/{id}/closecloses (open→closed) and, if it has a TRD series, materializes the calculated disposition (E04) into thedisposition JSONBcolumn (migrationarchive tenant/007);POST /{id}/transfer(closed→transferred,409if the transition is invalid);GET /{id}/foliadoreturns the foliation (radicados ordered with a sequential folio; the normative electronic index is E15); 29 tests green (4 new); migration validated on real pgdocs(adr): ADR-015 — TRD/CCD model (series↔subseries hierarchy, two-phase retention management/central, AGN disposition CT/E/S/M, versioning) — Why: the legal basis of the life cycle (Ley 594/2000, Acuerdo AGN 004/2013); reusestrd_series/tipos_documentaleswithout a rewritefeat(archive): TRD/CCD model and retention calculation (E04/F3, ADR-015) — Why: the SGDEA must classify and calculate the final disposition of expedientes — Impact:trd_seriesgainsparent_id(subseries),archivo_gestion_years/archivo_central_years(two-phase retention, backfilled from the previous fields),version/valid_from(migrationarchive tenant/006);TrdSerieCreateaccepts subseries + two phases + AGN disposition (CT/E/S/M, compatible with old values); newGET /api/v1/trd/{serie_id}/retention?closed_at=calculatesfin_archivo_gestion/fin_archivo_centraland the final disposition (a pure function over the series); 25 tests green (3 new); migration validated on real pgfeat(archive): full-text search of expedientes (E09/F2, ADR-014) — Why: to find any expediente, not just radicados — Impact:GET /api/v1/expedientes/searchwith optionalq(FTStsvectorover code/name/description +ts_rankranking), filtersstatus/date_from/date_to/meta.<field>=(JSONB GIN),X-Total-Count; migrationarchive tenant/005(generatedsearch_vectorcolumn + GIN index, without pg_trgm to avoid depending on extensions); route registered before/{expediente_id}to avoid colliding with the path param; 22 tests green (3 new); migration validated on real pg (FTS matches)feat(document): radicado reports/statistics (E09/F2) — Why: the SGDEA must offer document-management statistics — Impact:GET /api/v1/reports/radicadoswithtotal+ countsby_doc_type/by_status/by_month(SQLGROUP BY/date_truncaggregations), filters?date_from=&date_to=; routed in the gateway (/api/v1/reports/→document); 34 tests green (2 new)feat(document): advanced search (E09/F2, ADR-014) — Why: to find any radicado combining text and filters — Impact:GET /api/v1/searchrewritten:qoptional (no text = a date-filtered query), filtersdoc_type/status/date_from/date_to/dest_dept_code/meta.<field>=(JSONB/GIN),ts_rankranking when there is text,X-Total-Count; clean parameterized SQL (the prior fragile assembly was removed); 32 tests green (3 new; search had no tests)-
fix(gateway): routing of all domain prefixes (F1 consolidation) — Why: the proxy only knew 7 narrow prefixes; endpoints like/api/v1/audit,/dependencias,/business-days,/config,/catalogos,/metadata,/search,/expedientes,/tipos-documentales,/trd,/expediente-metadatawere unreachable (404) via the gateway — Impact: the proxy routing table was extended to map each domain prefix to its service;_route_to_upstreamtest; full F1 sweep green (~221 tests across 8 services) -
feat(auth): auth-service — Keycloak ROPC, JWT validation, audit log, endpoints/token/refresh/logout/validate/me feat(tenant): tenant-service — institution CRUD + per-tenant PostgreSQL schema provisioningfeat(gateway): api-gateway — centralized proxy, JWT validation, Redis rate limiting, identity header injectionfeat(document): document-service — radicación E/S/I, atomic numbering, management of attachment referencesfeat(storage): storage-service — upload to MinIO, SHA-256, deduplication, pre-signed URLs, per-tenant bucketsfeat(workflow): workflow-service — distribution flows between dependencias, history, Redis Streams eventsfeat(archive): archive-service — expedientes with a life cycle, radicado linking, TRD with document retentionfeat(notification): notification-service — Redis Streams consumer, SMTP, Redis-based historyrefactor(all): drop SQLAlchemy/Alembic — direct asyncpg and numbered SQL migration files
Fixed¶
fix(auth,frontend): the interface offered actions the server was going to reject, because it did not know what level each permission was granted at (third batch of the user-experience review) — a permission in this system is not a yes or a no: it has a level — look up, edit, create or delete. The server knows it and enforces it; the interface never received it, because the lookup that tells it who the user is resolved the level and then threw it away, sending only the list of names. The result: someone holding a read-only permission was offered Close, Transfer, Exclude or Sign acta buttons, and got a "you do not have permission" when they pressed them. The two user-experience reviews, carried out separately and blind to each other, reached the same conclusion, and both refused to fix it screen by screen: the problem was not in the screens but in what the server tells them. It was fixed in both halves. The server now also sends the level of each permission, without removing anything it already sent, so nothing already running breaks and no coordinated update is needed. And the interface learns to ask by level, with a deliberately strict rule: if the level does not arrive — a session opened before this change, for instance — the answer is no, never "I don't know, so I'll allow it". Applying it meant reviewing every button that writes, one by one, against what its service actually requires. That corrected the finding's own premise: most operations only ask for the lowest level, so only four screens needed the high restriction. Requiring more would have been the same mistake in reverse, hiding functions from people who are entitled to use them — and doing so silently. It was noted, without being touched, that the inbox has exactly that shape: the interface asks for a permission the server does not require, because there whoever may act is decided by holding the assigned step, not by a permission. — Why: the interface must be a faithful mirror of what the server authorizes; offering what is going to be rejected is a functional failure, not a cosmetic one. — Impact: no database changes. 199 authentication-service tests and 1370 frontend tests green, including one that reproduces exactly the reported case.fix(frontend): accessibility: insufficient contrast, tables unreachable without a mouse, errors not announced on their field, and AI suggestions that were never announced (second batch of the user-experience review) — four problems that left part of the users out. One: white text on the brand green did not reach the minimum required contrast, and not in a transient effect but in permanent states — the current step number in the two-factor, signature-credential and signing wizards; the active turn in the signature chain; the active filters in drafts and dispatches. In several places it was the other way round: the legible tone was used only on mouse-over. Two: wide tables cut off the Status and Actions columns on the right, and those that did scroll could not be scrolled with the keyboard, so anyone not using a mouse could not reach them. It was fixed in the two shared table components, so the fix reaches most screens. Three: on the profile screen, all seven validation error messages appeared only as a floating notice and never next to the failing field — precisely on the screen that governs the electronic signature credential; they are now announced on the field, and the one-time codes open the numeric keypad and autofill. Four: AI suggestions appeared without notifying screen-reader users; they are now announced, without touching the content, which already met the regulatory requirement to cite sources and warn that it was AI-generated. Two honest caveats: the table containers left unmodified wrap a component whose scrolling is already reachable, and giving them another keyboard stop would have created an empty destination; and the original finding mentioned four tables with one cell too many, which a sweep of all 28 tables in the project could not reproduce — recorded as such rather than forcing the figure. — Why: an actions column invisible on mobile, or an error a screen reader cannot locate, are not finishing details: they prevent the task from being completed. — Impact: 1348 tests green. The two-factor recovery code is deliberately left out: it is a server-side gap and must not be papered over in the interface.fix(frontend): batch loading never reported its result, three administration cards led to "no permission", and two screens were not linked (UX review, first batch of fixes) — the ~20 screens built to surface everything the backend already exposed were implemented without going through the UX/accessibility review, the last step of the project's process. The review was done afterwards and found three functional problems, not cosmetic ones; this is its first batch of fixes. One: document and expediente batch loading never reported its result, due to two compounding defects — the progress panel did not refresh, and the screen polled progress at an address that had ceased to exist when the routing was fixed. An administrator could load a thousand radicados and have no way of knowing whether they were created or whether everything failed. Two: three cards in the administration panel advertised a permission different from the one the server actually requires, so they led to a "you do not have permission" screen; the Interoperability case was doubly wrong, because it also hid the entire screen from a registration operator who could use part of it. All sixteen cards were checked, not just the three found, because this same pattern had already resurfaced four times. Three: two finished screens — digital preservation and signing keys — were missing from the administration index, which presents itself as the complete map, and were reachable only by typing the address by hand. The root cause of the first failure was fixed too: several code comments asserted things that had stopped being true (that the gateway did not route batch loading, that the metadata services did not require a permission). This is the third time in this release that a stale comment has caused a real defect, so they were deleted rather than left sitting next to the fix, and tests were added that pin the exact address each screen asks the server for, so a route change cannot silently break it again. — Why: a screen signed off as finished and documented as complete had its main function inoperative; building the UI reveals the capabilities, but only the review reveals the UI. — Impact: batch loading reports its result; the administration panel stops offering what it will deny and stops hiding what it authorizes; two screens stop being invisible. 1339 frontend tests green.fix(repo): four files of the interoperability screen never made it into the repository — an exclusion rule written for one specific folder at the project root was written unanchored, so it applied to any folder with that name anywhere in the tree. It matched the folder holding the document batch-loading files, which therefore stayed out of the history despite being documented as delivered: in the official copy of the project, the screen called addresses that did not exist there. Fixed by anchoring the rule to the root. — Why: it surfaced when re-running the work on a clean copy; the earlier review could not see it, because it reads the working files and there they were present. — Impact: none on application behavior. The lesson is that this kind of exclusion fails silently: the file simply never shows up as pending to save.fix(document): audit trail for batch radicado creation + reactivation of the batch test suite — batch radicado creation (POST /api/v1/batch/documents) left no trace whatsoever in the immutable audit log: hundreds of official documents, each with a unique tracking number, could be created with no record of who did it or when. It surfaced because the twin batch operation over expedientes, in another service, had done it from day one — two mirror functions, only one leaving a trace. Fixed by writing one audit entry per radicado created (not one per batch, so individual traceability is not lost), always attributed to the person who submitted the batch, and in such a way that a partially successful batch (say 480 of 500) records exactly what went in and what did not — never the original intent. In addition, this feature's automated test suite had been fully disabled since it was written (it referenced a test fixture that never existed). It was rewritten from scratch, also covering the permission gate that had recently been added with no test verifying it. — Why: mass-creating official documents with no record of authorship is precisely what the audit log exists to prevent; a disabled test suite protects nothing, least of all a freshly added permission gate. — Impact: no schema change (the audit table already existed). document-service suite: 284 passing, 20 skipped (previously 273 passing, 25 skipped — the remaining 20 are integration tests against a real database, unrelated to this change).fix(api-gateway,document,archive): batch loading unreachable — missing gateway route and an ambiguous status contract between services — closes the item left open by the interoperability screen: the gateway had no route registered for/api/v1/batch/, so both document and expediente batch loading always returned "route not found". Investigating it surfaced a deeper problem: the two services serving batch loading share the same base path, and the job-status lookup (.../batch/{id}/status) had the identical shape in both — there is no way for the gateway to guess, from the job id alone, which of the two to ask. The fix nests the status lookup under the job type (documents or expedientes), just as creation already did, so the routing decision is unambiguous. Along the way, document batch loading now requires the same permission as creating a document one at a time — it previously required none, unlike the expediente equivalent. — Why: without that route the already-built batch screen could not work; the contract shared by the two services had to be disambiguated at the source, not patched around. — Impact: batch status URLs change shape — and the screen consuming them kept the old shape, fixed later in the UX review entry; document batch loading gains permission enforcement. What this change left open — batch radicado creation without an audit entry — is closed by the first entry in this release.fix(frontend): accessibility sweep after the v1.0 redesign audit — 27 High/Critical findings (E22, WCAG 2.1 AA) — AA contrast inButtonand 8 screens, ≥44px touch targets in 8 components, visible focus inUserPicker, a misaligned column on mobile in/busqueda, and explicit confirmation before signing in/firmas, which had none.fix(frontend): redirect loop between Keycloak and/login(E22) — an IdP error returned to/loginrestarted the flow indefinitely; the error is now mapped from a closed table and shown with a "Retry" link.fix(frontend)://comments inside<style lang="scss">broke the raw SSR build (E22).fix(migrations): idempotency guards that did not filter by schema broke the provisioning of the second tenant (multi-tenant) — guards keyed on constraint/column names repeat across schemas, so from tenant #2 onwards the object was silently skipped. Two severities were found: missing constraints (backfillable) and, critically, DLQ migrations that aborted provisioning without marking themselves, requiring a re-run. Validated on a real second tenant plus verification by mutation.fix(knowledge):POST /api/v1/knowledge/searchreturned 500 against real PostgreSQL (E21) — the connection pool lacked ajsonbcodec; a pass-through encoder avoids double-encoding.fix(scripts):init_tenant.pyprovisioned incomplete tenants and aborted midway (E14/E21) — two defects: migrations were skipped (knowledge + tenant-service, which silently left PINAR unusable) and migration identity was keyed by bare filename, so collisions between services (archive/document, notification/signature) raised aUniqueViolationhalfway through provisioning. Fixed by keying the registry onservice/fileplus a backfill.
Security¶
fix(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 withIdentidadFirmadaMiddlewareare rebuilt and deployed together; if two versions coexist, every authenticated request gets401.-
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 fortyp/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_ADMINat 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; moreoverPOST /groups/{g}/members/{self}raised one's own clearance without going through the no-write-up thatPUT /groups/{g}/clearancehas (effective clearance isMAX(role_clearance)over memberships). The census inshared/tests/test_escritura_exige_nivel.pywas blind toAPIRouter(dependencies=[...])gates, which is why nobody saw it. How: (1) every write inrbac.py,urd.py,PUT /groups/{g}/clearanceand the administrative removal of ANOTHER person's 2FA and signing key requireUSUA_PERM_ADMINmin_crud=3; reads stay at level 1 (auditors keep access). The census now reads router gates (balanced parentheses, any*_routervariable): it found 16 more writes open at read level (signature chain, webhooks, DLQ ×3, third-party creation) that are declared, not approved, inSIN_NIVEL_DECLARADAS. (2)add_membership_guarded: the no-write-up lives in theINSERT'sWHERE(ROOT exempt; a group withoutrole_clearancecounts as 1) and a rejection is403 no_write_upwith no audit entry. (3)PATCH/DELETE /groups/{id}(409if it has members or URDs, checked in the same statement that deletes). (4)validate_tokenrequirestyp=Bearer,azpinALLOWED_AZP(defaultorpycamcp-frontend,api-gateway; empty = nothing passes) and mandatoryexp/iat/iss/sub. (5)/validateemitsexpires_inand the gateway caps its cache TTL with it; withoutexpires_init does not cache (the cap byexpwas 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 requiresvalid is True. (7)/audit/verifyreturns onlyokandtotal_roturasto callers below maximum clearance (total−X-Total-Countwas the exact number of hidden classified entries). (8)totp.desenrolado,totp.desenrolado_con_recuperacionandtotp.restablecido_por_adminare written inside the removal transaction and without a silentexcept(if the entry fails, the factor is not removed). Verification: auth-service 309 withRUN_INTEGRATION=1(273 unit + 36 integration against real Postgres:grant_root, TOTP atomicity, membership no-write-up, group deletion), api-gateway 112,shared/tests174,lint.shclean. Mutations (each fails tests, restored and rebuilt): droppingmin_crud=3from the permissionsPUT(the census also catches it), dropping the level condition from theINSERT, swapping the strict audit entry for the best-effort one, dropping thetypcheck, theazpcheck,HS256inalgorithms, emptyingALLOWED_AZP, returningtotalto everyone, droppingexpires_in, movinggrant_root's audit entry out of the transaction, dropping its idempotency, accepting any cache MAC, MAC without the key, dropping thevalidrecheck, widening the proxy allowlist with identity headers and TTL = max. One inventory mutation turned out equivalent: reversing the order ofheaders.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 withUSUA_PERM_ADMINat level 1 or 2 will get403on writes: raise it to 3 before deploying (demo groups already have 3). Tokens not fromorpycamcp-frontendorapi-gateway(e.g. a new service client) get 401 until added toALLOWED_AZP. New mandatory production variable:AUTH_CACHE_MAC_SECRETon 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 fromUSUA_PERM_ADMIN(needs a migration with backfill and a naming decision); an opaque identifier in the audit listing (idgaps remain the declared RF-FIR-15 residue); raisingmin_crudon 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-xmlstamped the entity's seal on an arbitrary XML for any human holdingPERM_FIRMAlevel 1 (bypassing archive's gates), and the gateway routed all of/preservacion/: a user withUSUA_PERM_EXPEDIENTE:3could take the irreversible WORM singleton (COMPLIANCE, up to 100 years) of any expediente with an arbitraryfile_idand a short term, or set alegal_holdwith no act to lift it, blocking final disposition forever. How: (1)/sign-xmladditionally requiresX-Internal-Tokenfor the XAdES allow-set (indice,acta_transferencia,acta_eliminacion) andobjeto_tipobecomes a closed set (422otherwise); archive-service sends it insign_xml/sign_xml_outcomealongside the signed identity. (2) storage seals/aip,/proteger-indiceand/proteger-artefactobehindX-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 return404 route_not_found. (4) Defence in depth: a second protection of the same(expediente, tipo)with a differentfile_id→409 worm_singleton_conflict, nothing uploaded. (5)legal_holdis no longer a body field of any protection route (422); it is set/lifted only by the internal actPOST /preservacion/legal-hold-internal(mandatorymotivo+acta_ref;enable/disable_object_legal_holdon the exactversion_id; row +worm.legal_hold_fijado|levantadoaudit entry in one transaction). False comments fixed. Audit remediation of the batch: (a)/sign-xmlno 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; neutral404 objeto_no_encontrado;borradoris not resolvable in this service so it is not signed through this raw route) and the gate rises toPERM_FIRMAmin_crud=2(the frozen exception intest_escritura_exige_nivel.pyis removed: 86 to 85). Declared behaviour change: a user holdingPERM_FIRMAat level 1 who closes an expediente no longer seals the index under their own identity; it stayspendiente_firmaand reconciliation seals it as SYSTEM. (b)legal holdnow has a user act:POST /api/v1/expedientes/{id}/legal-holdin archive-service (USUA_PERM_ADMINcrud>=3,motivo>=10 andacta_refmandatory both ways, expediente visible by clearance or neutral404, own audit entryarchive.expediente_legal_hold_fijado|levantado) orchestratinglegal-hold-internalwith token + signed identity. (c) Storage:fijar_legal_holdtakes 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 recordscopias_alcanzadasin the audit entry; and lifting audits firstworm.legal_hold_levantamiento_solicitado(own tx) and only then touches the store, withworm.legal_hold_levantamiento_fallidoif 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/tests171; lint and mypy clean on signature, storage and archive. Mutations (each fails tests): removemin_crud=2, remove the object visibility check, remove the clearanceFILTER, 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 forUSUA_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); thelegal holdact reusesUSUA_PERM_ADMINinstead 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_transactionignored the per-typerequires_permission(dynamic enforcement), andPERM_TRAMITAR/USUA_PERM_ADMINwere not enforced. fix(notification):/sendsealed + authorization on webhooks and history (RF-SEG-03; D-02; E11) —/sendis an inter-service endpoint (it carries 2FA OTPs) and is now sealed with an internal token rather than gated by a human permission; webhooks and delivery history requireUSUA_PERM_ADMIN.fix(workflow): RBAC enforcement of process transactions and distribution routes (RF-SEG-03; E05) — the spec's claim that "enforcement is delegated to the gateway" was false: the gateway only authenticates.fix(document): no-read-up closed on signatures, annulment, linked replies and shipments (RF-SEG-08; Ley 594/2000 art. 28).fix(document): no-read-up on disposition and onPATCHof radicados (RF-SEG-08; RF-MET-08; Ley 594/2000 arts. 24 and 28) — the gate lives in theWHERE/INSERT, not in a post-filter.fix(archive): the WORM renewalaudit_logentry attributed to the system principal (E10; RF-SEG-08).fix(archive)+fix(storage): no-read-up when querying preservation events per expediente (E10 Increment C; RF-SEG-08).fix(archive)+fix(storage): no-read-up when downloading the preservation AIP (E10 Increment C; RF-SEG-08; Ley 594/2000 art. 22).fix(workflow): automatic routing rules CRUD had no authorization whatsoever (RF-SEG-03; E05).fix(document)+fix(storage)+fix(gateway): no-read-up when downloading attachments (RF-SEG-08; Ley 1712/2014; D-02) — document-service now mediates the download (streaming, gated by the current clearance) and storage is sealed: mediation over denormalization for content access.fix(workflow): no-read-up in the inbox + step ownership inrollback+ oracle closed indevolver(RF-SEG-08).fix(document): no-read-up on linked Salidas —GET /documents/{id}/respuestas(RF-SEG-08; Ley 1712/2014).fix(archive): transfer life cycle (E12) — lethal TOCTOU race closed + all four custody acts with an audit entry and an unambiguous identity (RF-SEG-08; Ac. AGN 001/2024 Tít. 4.4) —update_estadogained an expected-state guard, closing therechazar-vs-recibirrace that could leave a transfer rejected while the expediente was already frozen; identity is unified onX-User-Id(fail-closed, no username fallback).
Phase 1 — Foundations (in progress, 2026-06-16)¶
feat(shared): shared libraryorpycamcp_common(ADR-010) — Why: identical audit and events across all services — Impact: internal package inshared/withaudit.append()(per-tenant hash chain) andevents(canonical envelope + publish/consume); installed by Docker (pip install /shared); 12 tests greenfeat(auth): canonical append-onlypublic.audit_log+ audit migration — Why: ADR-008/ADR-010 — Impact: migration002_audit_log.sql(table partitioned by month, immutability trigger, hash chain); auth-service dropsauth_audit_logs(obsolete) and audits viaorpycamcp_common.auditchore(infra): build context at the root +.dockerignore— Why: to allowCOPY sharedin the Dockerfiles (ADR-010) — Impact: auth-service is built withcontext: .; a pattern to replicate in services that use the libraryfeat(infra): per-tenant migration runner (ADR-012) — Why: the per-tenant tables (tenant_{slug}) had no application mechanism; the runner only wrote topublic— Impact:runner.pyseparatesmigrations/global/(→public) frommigrations/tenant/(→ eachtenant_{slug}schema), idempotent, with tracking inpublic._tenant_migrations; service template updatedfeat(auth): 4-table RBAC + URD + security classification (E08) — Why: Keycloak authenticates, the DB authorizes (RF-SEG-03/04/05) — Impact:tenant/migrations withauth_users/auth_groups/auth_permissions/auth_memberships/auth_group_permissions,urd_usuaroldep+usua_historico,security_levels+role_clearanceand a seed of 15 permissions; verified across 2 tenants-
feat(auth): permission resolution + RBAC administration endpoints (E08) — Why: to materialize "the DB authorizes" — Impact:RBACService.resolve_effective_permissions(MAX(crud) per permission, ROOT first),get_tenant_conndependency (search_path byX-Tenant-Slug), endpoints/api/v1/auth/users|groups|permissions|groups/{}/members/{}|groups/{}/permissions/{}and/users/{}/permissions; 19 tests green + MAX(crud) resolution validated against the real DB. Pending:require_permissiongate (awaits the enriched JWT) -
feat(tenant): per-tenant configuration — business-day calendar, parameters and catalogs (E14) — Why: E01 needs dependencias, radicado format and a due date in business days (RF-RAD-04) — Impact:tenant/migrations (dependencias,sgd_param_adminJSONB + seed,non_business_days, catalogs,admin_audit) +global/002(provisioning_status);calendar.pymodule (Colombian holidays Ley 51/1983 + business-day calculation); endpoints/api/v1/config/holidays(+/seed),/api/v1/business-days/calculate,/api/v1/config/params; per-tenant runner verified on the real DB (8 tables, 10 params, 5 media) feat(auth): security classification and access verification (E08, RF-SEG-08, Ley 1712/2014) — Why: Reservada/Clasificada documents only accessible to authorized roles (least privilege) — Impact:GET /api/v1/auth/security-levels(catalog Pública/Reservada/Clasificada);PUT /api/v1/auth/groups/{id}/clearancesets a group's maximum level (gatedUSUA_PERM_ADMIN);GET /api/v1/auth/clearanceand/clearance/check?level=Nresolve the caller's clearance (MAX over their groups; ROOT accesses everything) and verify access; uses the existingsecurity_levels/role_clearancetables; 40 tests green (6 new)feat(auth): multi-dependencia URD + context switch (E08, RF-SEG-04, ADR-013) — Why: a user can have a role in several dependencias and must be able to set their active dependencia — Impact: URD CRUDGET/POST/DELETE /api/v1/auth/users/{id}/urd(gatedUSUA_PERM_ADMIN; one URD per (user,depe), only one primary);GET /api/v1/auth/contextandPOST /api/v1/auth/context/switch(the user themselves viaget_caller) change the active dependencia without reissuing a token (ADR-013) — persistsauth_users.active_depe_id(migrationtenant/006) and recordsusua_historico(CONTEXT_SWITCH);400 no_urd_for_dependencyif the user has no active URD there; 34 tests green (7 new); migration validated on real pgfeat(auth): audit query and verification (E08, ADR-008) — Why: legal traceability (Ley 594/2000) must be queryable and verifiable, not just writable — Impact:GET /api/v1/auditlists the tenant'spublic.audit_log(filters action/object_type/object_ref/actor + pagination +X-Total-Count);GET /api/v1/audit/verifyrecomputes the hash chain withorpycamcp_common.audit.verify_chainand reports{ok, broken_id}; both gated withUSUA_PERM_ADMIN(ADR-013); read-only repository (writing remains a single point inorpycamcp_common.audit); 27 tests green (5 new: list, verify intact/broken, gate 403, 400 without tenant)feat(auth):require_permissiongate on RBAC endpoints + decision R4 (E08, ADR-013) — Why: the administration endpoints did not require the caller's permission (RF-SEG-04) — Impact:core/authz.pywithget_caller(resolveskeycloak_sub→auth_users→effective permissions from the tenant DB per request; 401 without identity, 403 if not provisioned) andrequire_permission(name,min_crud)(ROOT bypass); the RBAC router requiresUSUA_PERM_ADMIN(migrationtenant/005); Keycloak remains the sole issuer (authenticates, the DB authorizes); 22 auth tests greendocs(adr): ADR-013 — authorization resolved in the DB per request — Why: to fix the authorization model (decision R4) — Impact: own signing and token-exchange are discarded; permissions always fresh (immediate revocation), no stale tokens;/auth/context/switchwill not reissue a tokenfeat(tenant): reference catalog CRUD (E14) — Why: identification/sender/attachment types and reception media are parameterizable per institution — Impact: generic endpoints/api/v1/catalogos(list of catalogs) and/api/v1/catalogos/{catalogo}(GET/POST/PATCH/DELETE) over a table whitelist (tipos-identificacion,tipos-remitente,medios-recepcion,tipos-anexo); 404 on an unknown catalog, 409 on a duplicate code; 8 tests greenfix(tenant): tenant endpoints returned 404 with a trailing slash — Why:redirect_slashes=False+ routes registered without a slash; the tests called/api/v1/tenants/— Impact: tests aligned to the/api/v1/{resource}convention without a trailing slash (7 tests that were red); full tenant-service suite green (46)feat(notification): process-due alerts (E16↔E05, RF-FLU-07) — Why: to close the alert loop: the due-date sweep emits and the recipient must be notified — Impact: notification-service handlesworkflow.step.overduein its workflow-stream consumer → sends a notice email to the responsible dependencia; 15 tests green (1 new)feat(workflow): process transaction engine (E05, RF-FLU-01) — Why: the SGD needs to execute process transactions beyond assign/transfer (inform, NRR, schedule, mark read, VoBo, annul, close exp…) leaving an unalterable trace — Impact:transaction_typescatalog (migrationtenant/007, 12 transactions seeded with their atomic permission and state effect);GET /api/v1/workflows/transaction-typeslists the catalog;POST /api/v1/workflows/{radicado_id}/transactions {tipo_tx, comentario, detalles}executes a transaction: validates the type, appliestarget_step_statusto the active step when appropriate (marcar_leido→in_progress,anular→cancelled) and records the append-only event, atomically (404unknown type / radicado without flow); the atomic permission stays in the catalog (enforcement delegated to E08/gateway); 42 tests green (4 new); migration validated on real pgfeat(workflow): process due dates and traffic lights (E05, RF-FLU-07) — Why: the SGD must alert about overdue deadlines (PQRSD terms, Ley 1755/2015) — Impact:due_atper step (migrationtenant/006, set on manual assignment and by distribution from the radicado's deadline); traffic light computed at read time (green >3d / yellow ≤3d / red ≤1d / overdue) exposed in step and inbox responses;POST /api/v1/workflows/overdue/scandetects overdue steps not yet alerted, emitsworkflow.step.overdueto the bus (→ E16) and marks them (overdue_notified) to avoid duplicate alerts — designed for periodic invocation by a scheduler; 38 tests green (3 new); migration validated on real pg. Pending: notification-service consumingworkflow.step.overduefeat(workflow): typed process inboxes (E05, RF-FLU-03) — Why: the operator needs to see their pending items split by type (Entrada/Salida/Internos) — Impact:GET /api/v1/workflows/inbox?box=entrada|salida|internos&dept=&assigned_to=lists the active steps of the type, prioritized by age, withX-Total-Count(400 invalid_box);doc_type(E/S/I) is denormalized inflow_steps(migrationtenant/005) set on assignment (manual and by automatic distribution) to avoid querying document-service's radicados table; 35 tests green (2 new); migration validated on real pgfeat(workflow): assignment rollback + cascade reassignment (E05, RF-FLU-08) — Why: to undo the last erroneous assignment and reassign en masse without orphan radicados (role change/user deactivation) — Impact:POST /api/v1/workflows/{radicado_id}/rollbackcancels the current step (only if stillpending) and reactivates the previous one, atomically, with append-only eventtipo_tx=rollback(409 no_active_step/cannot_rollback_initial);POST /api/v1/workflows/reassign/cascadereassigns in one transaction all active steps of a user/dependencia to a new responsible party, with areassignevent per radicado (422 if no source is indicated); 33 tests green (5 new)feat(workflow): automatic distribution by router (E05, RF-FLU-04) — Why: when a document is registered it must be assigned automatically to the correct dependencia per the rules, without manual intervention — Impact: workflow-service consumesorpycamcp.document.events(workerdocument_consumer, groupworkflow-service); ondocument.radicado.createdit evaluates the active rules and, if they match, auto-assigns the radicado (step + append-only event,assigned_by=system); idempotent (does not redistribute if the radicado already has steps); document-service enriches the event withsubject/doc_class/dest_dept/origin_dept/sender_*/pagesso the rules can match; fix: the rules-service did not setsearch_path(broken at runtime after the ADR-012 reorg, it queriedpublic) → now it sets it; 28 workflow + 23 document tests green (3 new for distribution)feat(workflow): append-only process historyflow_events(E05, RF-FLU-02) — Why: the chain of custody requires an unalterable trace (Ley 594/2000, Acuerdo AGN 060/2001), not the mutable projection offlow_steps— Impact:flow_eventstable with a trigger that rejects UPDATE/DELETE (migrationtenant/004); every process transaction (assign/transfer/return/close/complete) writes its event in the same transaction as the step (there is no step without an event);GET /api/v1/workflows/{radicado_id}/eventsreturns the immutable sequence (tipo_tx, from/to dept, actor, comentario, detalles JSONB, ts);create/complete_stepare now atomic; 25 tests green (1 new + event-write verification); migration validated on real pg (table + trigger)feat(workflow): rules engine with operators, AND/OR and batch evaluation (E05) — Why: the engine only supported equality/substring on 4 fixed columns with AND and evaluated one radicado at a time — Impact:rules_engine.py(pure engine) with operatorseq/ne/contains/not_contains/in/gt/lt/gte/lte/regexover 8 radicado fields, combinable bymatch_mode(all=AND /any=OR); columnsconditions JSONB+match_mode(migrationtenant/003); legacy 4-column rules still work (fallback);POST /api/v1/workflow/rules/evaluate/batchevaluates 1-500 radicados with a single read of the rules; fixed the pre-existing bug in the rules endpoints (they read a non-existentrequest.state.auth_headers→ now headersX-Tenant-Slug/X-User-Id); 24 tests green (13 new, the engine previously had no tests); migration validated on real pgrefactor(storage,workflow): migrations reorganized totenant/(ADR-012) — Why: both services applied their migrations with the old runner topublic, sharingfiles/flow_steps/workflow_rulesacross institutions (broken isolation, the same debt as document/archive) — Impact:storage/migrations/tenant/001_create_files.sql;workflow/migrations/tenant/001_create_workflow.sql+002_workflow_rules.sql; ADR-012 runner in both; no code changes (the repos already setsearch_path); validated on real pg (tables intenant_{slug}, 0 inpublic); suites green (storage 13, workflow 11)feat(notification): SMTP retries with backoff (E16) — Why: a transient mailserver failure must not lose the notification — Impact:_send_with_retriesretries up toSMTP_MAX_ATTEMPTS(default 3) with linear backoff (SMTP_RETRY_BACKOFF_SECONDS); persistsattemptsandlast_error(migrationtenant/002); statussentif any attempt succeeded,failedwhen exhausted;attemptsexposed in the history response; 14 tests green (3 new). Pending E16: scheduled redelivery for long outages (a worker that retries thefailedones in deferred mode)feat(notification): notification history persisted in the per-tenant DB (E16, ADR-002/012) — Why: the history lived only in Redis (volatile, 30-day TTL); it must be durable and isolated per institution — Impact: notification-service gains a DB layer (previously had no DB):core/database.py(pool +get_tenant_connfor reads +acquire_tenant(slug)for the worker), migrationtenant/001_notifications.sql(per-tenant table, without a redundant tenant_slug column) + ADR-012 runner,DATABASE_URL+postgres in compose/depends_on, asyncpg in requirements;send_notificationpersists in the tenant's schema (best-effort, does not bring down the worker);GET /historyandGET /{id}read from the tenant DB (requireX-Tenant-Slug); Redis remains only for the event stream; 11 tests green; migration validated on real pg. Pending E16: SMTP retriesfeat(document): query filter by metadata (GIN) + document disposition (E03, RF-MET-02/08) — Why: to query radicados by a metadata field and persist disposition metadata (program/dates/action/marks) — Impact:GET /api/v1/documents?meta.<field>=valuefilters by JSONB containment (metadata @> …) using the GIN index;disposition JSONBcolumn (migrationtenant/009) managed withGET/PATCH /api/v1/documents/{id}/disposition(field merge: program, retention_until, action conservar/eliminar/transferir/seleccionar, confirmed, marked_for_deletion;404if the radicado does not exist); 29 tests green (3 new); migration validated on real pgfeat(archive): document type catalog (3rd TRD level) (E03, RF-MET-07) — Why: the document type is the third TRD level, mandatory/optional per series, with an associated metadata template — Impact:tipos_documentalestable (migrationtenant/004) + CRUD/api/v1/tipos-documentales(POST/GET/GET{id}/PATCH/DELETE; filter by series/active; 409 duplicate, 404);trd_serie_idnullable (weak coupling with E04) andmetadata_templatereferences (by name) the document-service template; 19 tests green (4 new); migration validated on real pgfeat(document): metadata element catalog (E03, RF-MET-03) — Why: to define metadata fields and their attributes (type, occurrences, modifiable, default, options, order, searchable, Dublin Core mapping) in a reusable way — Impact:metadata_elementstable (migrationtenant/008) + CRUD/api/v1/metadata/elements(POST/GET/GET{id}/PATCH/DELETE; 409 duplicate key, 404); includes themapeo_dublin_corehook (RF-MET-09); 26 tests green (3 new); migration validated on real pg. Note: the immutability of values formodificable=falseelements (R.3.7) will be enforced when setting values; here it is only catalogedfeat(document): attachment classification and foliation (E07, RF-DIG-01/RF-DIG-10) — Why: an attachment needs its document type, principal mark and folio count (input for expediente foliation) — Impact: fieldstipo_anexo(E14 catalog code),es_principalandfoliosinanexos(migrationtenant/007) and inPOST /api/v1/documents(per attachment) and in the response; 23 tests green; migration validated on real pgfeat(storage): immutable attachment versioning (E07, RF-DIG-01) — Why: "replacing" an attachment must create a new version while keeping the previous ones (an auditable chain), not overwrite — Impact: eachfilesrow is an immutable version; columnsversion/is_current/root_id/replaces_id(migrationtenant/002);POST /api/v1/storage/files/{id}/replacecreates a new version with its own SHA-256 and MinIO object (atomic: marks the previous one not current) →201; if the content is identical it does not create a version (200);409 not_current_versionif it is not the current one;GET /files/{id}/versionsreturns the chain; deduplication of new uploads only applies to current versions; 18 tests green (5 new); migration validated on real pg. Pending E07: attachment type catalog, folios, PDF/OCR conversion (infra)feat(storage): download integrity + format validation (E07, RF-DIG-02/04) — Why: to guarantee that the object in MinIO has not been altered and to reject unsupported formats on upload — Impact:GET /api/v1/storage/files/{id}/verifydownloads the object, recomputes the SHA-256 and compares it with the recorded one (409 integrity_check_failedif they differ);POST /uploadvalidates the MIME againstALLOWED_MIME_TYPES(configurable,*=all) →415 unsupported_media_type; 13 tests green (4 new). Pending E07: attachment versioning (immutable replacement), attachment type catalog, PDF conversion and preview, integrity-alert eventfeat(archive): flexible expediente metadata (E03, ADR-007) — Why: the expediente also requires per-series validated metadata, just like the radicado — Impact:expedientes.metadata JSONB+metadata_template_id+ GIN index;expediente_metadata_templatestable (JSON Schema pertrd_serie_id, one active per series);POST /api/v1/expedientesacceptsmetadataand validates it against its TRD series' active template (422 metadata_validation_failedon failure); CRUD in/api/v1/expediente-metadata/templates; 15 tests green (includes a fix for a pre-existing trailing-slash issue in 5 tests)refactor(archive): migrations reorganized totenant/(ADR-012) — Why: the old runner applied everything topublic(broken isolation, same as document-service) and auto-applied the FondeCund seed to everyone — Impact:migrations/tenant/001_create_archive,002_batch_jobs,003_expediente_metadata; ADR-012 runner (global/+tenant/); the FondeCund institutional seed moved tomigrations/seeds/fondecund_trd.sql(not auto-applied — it is a tenant's data, it goes viascripts/migrate_trd_from_fondecund.py); validated on real pg (tables intenant_{slug}, seed not applied)feat(document): flexible metadata per document type (E03, ADR-007) — Why: each document type has different metadata; the standard requires capturing, validating and querying it without migrating the schema for each type — Impact:radicados.metadata JSONBcolumn +metadata_template_id+ GIN index (migrationtenant/006, validated on real pg);metadata_templatestable (JSON Schema versioned pertipo_documental, one active per type);POST /api/v1/documentsacceptsmetadataand validates it against the JSON Schema of thedoc_class's active template (jsonschema) — failure →422 metadata_validation_failed, without registering; minimal template CRUD in/api/v1/metadata/templates(POST/GET,422 invalid_json_schemaif the schema is invalid); 23 tests green (4 new). Pending E03: the expediente side (archive),metadata_elementscatalog, document types tied to TRD, disposition metadata, GIN query filterrefactor(workflow): migration to the canonical event envelope (E05, ADR-010) — Why: workflow-service emitted its own flat 7-field dict; the bus must be uniform (the same envelope as document-service) so consumers parse with a single contract — Impact: workflow-service adoptsorpycamcp_common(root build context +COPY shared);_publishusesevents.emit(canonical envelope, domain inpayload); events renamedflow_step_created→workflow.step.created,flow_step_completed→workflow.step.completed; notification-service now parses both streams (workflow+document) withevents.parse_fields(the legacy flat format removed); 11 workflow + 10 notification tests greenfeat(notification): consumption of the document canonical stream (E16, ADR-010) — Why: to close the event loop — when a document is registered, its destination dependencia must be notified — Impact: notification-service adoptsorpycamcp_common(root build context +COPY shared); the worker now consumes TWO streams in a singlexreadgroup(orpycamcp.workflow.eventslegacy flat +orpycamcp.document.eventscanonical envelope) dispatching by stream name; new handlerprocess_document_eventreacts todocument.radicado.created(parses the envelope withevents.parse_fields, notifiesdependencia-{code}); groups created withevents.ensure_group; 10 tests green (2 new: notifies on registration, ignores other types). Pending E16: persist history in the DB + SMTP retries (today history is in Redis)feat(document): immutable audit + domain events in the radicación (ADR-008/010, E01) — Why: each radicación must leave a chained legal trace and propagate to other services (notifications, projections) — Impact: document-service adoptsorpycamcp_common(root build context +COPY shared, like auth-service); when creating a radicado it writes topublic.audit_log(document.radicado_created) atomically with the INSERT (same transaction) and publishes thedocument.radicado.createdevent on theorpycamcp.document.eventsstream (best-effort: a Redis failure does not roll back the radicación); Redis client in the lifespan,tenant-service/redisindepends_on; 19 tests green (2 new: emits event, survives Redis failure). Deployment note:public.audit_logis created by auth-service (the single source, ADR-008) — it must be migrated firstfeat(document): link radicados to the dependencia org chart (E01) — Why:origin_dept/dest_deptwere free text with no referential integrity against the tenant's org chart — Impact: optional fieldsorigin_dept_code/dest_dept_codeinPOST /api/v1/documents; document-service validates them againsttenant-service GET /dependencias/by-codigo/{codigo}via HTTP and denormalizes the dependencianombreinto the text fields (legal snapshot); columnsorigin_dept_code/dest_dept_code+ indexes (migrationtenant/005, validated on real pg); a non-existent code or inactive dependencia →400 invalid_dependencia; 17 tests green (3 new: valid, non-existent, inactive)feat(document): due date in business days (RF-RAD-04, E01) — Why: radicados must have a legal response deadline calculated in business days (Colombian holidays + the tenant's non-working days) — Impact: new optional fieldresponse_daysinPOST /api/v1/documents; document-service calculatesdue_dateby consumingtenant-service GET /business-days/calculatevia HTTP (TenantServiceClientclient, forwardsX-Tenant-Slug), without replicating the calendar; columndue_date DATE+ index (migrationtenant/004); if tenant-service does not respond →502 tenant_service_unavailableand it is not registered; 14 tests green (3 new: with/without deadline, 502 failure). TODO: resolve the default deadline fromsgd_param_adminwhenresponse_daysis not providedfix(document): per-tenant isolation of radicación (ADR-012, E01) — Why: the old runner applied all migrations topublic, soradicados,anexos,batch_jobsand above alltracking_sequencesended up SHARED across institutions (one tenant's numbering advanced with another's) — Impact: migrations reorganized intoglobal/001_extensions.sql(pg_trgm/unaccent inpublic) +tenant/001_create_documents/002_batch_jobs/003_search_indexes; ADR-012 runner (identical to auth/tenant's); verified against real postgres (tables intenant_{slug}, 0 inpublic, per-tenant sequence, idempotent with 2 tenants)refactor(document): radicación/search/batch routers useget_tenant_conn(ADR-002/012, E01) — Why:search/batchqueried without settingsearch_path(only worked with the tables inpublic) andbatchread a non-existentrequest.state.auth_headers— Impact:core/database.pyexposesget_tenant_conn/tenant_schema(like auth/tenant); the repository no longer self-managessearch_path;batchreads headers fromrequest.headers; a missing tenant header → 400invalid_tenant; 11 tests green (batchtests marked skip: incomplete Phase 6 scaffolding)feat(tenant): hierarchical dependencia CRUD (org chart, E14) — Why: E01 numbers radicados per dependencia (depe_codi) and needs the org chart — Impact: endpoints/api/v1/dependencias(list with filtersactivo/parent_id, POST, GET, partial PATCH, DELETE),/dependencias/tree(hierarchical tree) and/dependencias/by-codigo/{codigo}(consumed by E01); 409 on a duplicate code or deletion with children, 400 on a non-existentparent_idor self-reference; 13 tests green
Architecture decisions — ADR (2026-06-15)¶
docs(adr): ADR-006 — configurable multi-provider AI provider — Why: to enable the knowledge layer (RAG/AI) without tying the project to a provider; local sovereignty by default, recommended — Impact:LLMProviderinterface (ollama_local/ollama_cloud/openai_compatible/anthropic/disabled) selectable by global deployment configuration (AI_PROVIDER)docs(adr): ADR-007 — template-validatedJSONBmetadata — Why: document types with variable fields without proliferating tables — Impact:metadata JSONBcolumn +metadata_templates+ GIN index; EAV is discardeddocs(adr): ADR-008 — immutable auditpublic.audit_log— Why: legal traceability required by Ley 594/2000 — Impact: append-only table partitioned by month with a hash chain pertenant_slug, consumed via the common audit librarydocs(adr): ADR-009 — event-based flow history — Why: to reconstruct a document's journey without losing intermediate states — Impact:flow_eventsappend-only (source of truth) + reconstructibleflow_stepsprojectiondocs(adr): ADR-010 — shared libraryorpycamcp_common— Why: audit (hash chain) and the event envelope must be identical across all services — Impact: internal package inshared/installed by Docker (COPY shared+pip install /shared); F1 scope = audit + events; auth-service migrates fromauth_audit_logsto the canonicalpublic.audit_logdocs(adr): ADR-011 — SvelteKit frontend in the monorepo — Why: to define where the web interface lives and with what stack — Impact:frontend/folder (outsideservices/), client of the api-gateway, stack aligned with sgdINTI (SvelteKit SSR + Vite + Bulma); planning scaffold created, app pending its phasescaffold(frontend): initialfrontend/structure — Why: to fix tooling and structure without implementing yet — Impact:package.json(manifest),svelte.config.js/vite.config.js,Dockerfile/Dockerfile.dev,src/tree and README; dependencies NOT installed (Docker-only)
Phase 6 — Advanced Features (2026-06-05)¶
feat(phase6): TRD seed data migration —scripts/migrate_trd_from_fondecund.pywith 150+ FondeCund seriesfeat(batch): Batch Documents —POST /api/v1/batch/documents(1-1000 radicados, 202 Accepted, job tracking)feat(batch): Batch Expedientes —POST /api/v1/batch/expedientes(1-100 expedientes with radicados)feat(search): Full-Text Search —GET /api/v1/search?q=...with PostgreSQL pg_trgm + tsvectorfeat(workflow): Workflow Rules Engine — rules CRUD + automatic evaluation + audit logdocs(adr): ADR-005 documents the Batch API decision (async jobs vs sync)
Phase 5 — Community and Publication (2026-06-04)¶
infra(deploy): docker-compose.prod.yml + infra/.env.example + docs/es/deployment.mddocs(contributing): CONTRIBUTING.md with workflow, testing and documentation requirementsdocs(readme): README.md in English with quick start, architecture, featuresdocs(en): docs/en/getting-started.md + docs/en/api.md — bilingual documentationinfra(ci): GitHub Actions workflows — docker-build.yml + ci.yml (lint, test, E2E)feat(openapi): spec aggregation in api-gateway (GET /api/v1/openapi.json)infra(registry): infra/DOCKER_REGISTRY.md — GHCR guide + tagging strategy
Phase 4 — Quality and Integration (2026-06-04)¶
infra(lint): centralized pyproject.toml — ruff + mypy across all 8 servicesinfra(health): orchestrated/healthchecks Redis + 7 services in paralleltest(e2e): docker-compose.e2e.yml with full-flow testsdocs(adr): ADR-003 documents the asyncpg vs ORM decision
[0.1.0] — 2026-06-03¶
Added¶
- Base project structure with 8 FastAPI microservices
- docker-compose.yml with a complete development infrastructure
- Keycloak realm with RBAC roles and test users
- Per-tenant PostgreSQL schema isolation
- GitLab CI/CD pipeline with automatic documentation
- Bilingual es/en MkDocs Material
- ADR-001 and ADR-002 documented