Referencia de API¶
Base URL en desarrollo (desde el host, vía el puerto publicado del gateway):
http://localhost:19080/api/v1Todos los endpoints (excepto los públicos) requierenAuthorization: Bearer {token}.Los puertos "puerto NNNN" indicados en cada sección de abajo son los puertos internos del contenedor dentro de la red Docker
orpycamcp-net— nunca se acceden directo desde el host, siempre a través delapi-gateway(puerto publicado 19080). Ver el mapeo completo interno↔publicado en Arquitectura.
Convenciones¶
| Convención | Valor |
|---|---|
| Autenticación | Authorization: Bearer {JWT} |
| Paginación | ?page=1&size=20 — respuesta incluye X-Total-Count |
| Fechas | ISO 8601 UTC — 2024-01-15T10:30:00Z |
| IDs | UUID v4 |
| Multi-tenancy | Header X-Tenant-Slug inyectado por el gateway tras validar el JWT |
Formato de error estándar¶
{
"error": "document_not_found",
"detail": "Radicado 2024-ICETEX-E-000001 no encontrado",
"status": 404
}
Estado de la entidad (ADR-035)¶
Una entidad que no está operativa no se atiende. El api-gateway lo hace cumplir en cada petición autenticada con el tenant_estado que le devuelve /auth/validate, y cada get_tenant_conn (auth, document, knowledge, notification, signature, tenant) y el get_db_conn de archive, workflow y storage lo comprueban de nuevo antes de fijar el search_path.
| HTTP | error |
Cuándo |
|---|---|---|
403 |
tenant_inactivo |
La entidad está suspended/inactive en public.tenants (o no existe) |
503 |
tenant_no_provisionado |
provisioning_status != active o aún no hay schema |
503 |
tenant_estado_no_disponible |
El registro no responde, o /auth/validate no informó el estado (fail-closed) |
404 |
not_found |
Rutas públicas (/api/v1/public/{tenant}/..., CMIS, OAI de document y archive, callback postal): cuerpo idéntico byte a byte para una entidad inexistente, dada de baja (inactive) o sin aprovisionar (en OAI, el mismo 400 badArgument que un slug mal formado) |
503 |
canal_no_disponible |
Rutas públicas de escritura (POST /public/{tenant}/pqrs, POST /public/{tenant}/ventanilla y sus consultas previas ventanilla/tipos y ventanilla/estado) de una entidad suspendida (ventanilla/politica es lectura y se sirve); trae Retry-After. El callback postal con firma HMAC válida ya no: se encola y responde 202 deferred (B10); solo con la cola llena responde este 503 |
Entidad suspendida y rutas públicas (solo lectura). Una entidad suspended con su schema aprovisionado sigue sirviendo, sin autenticar, GET /public/{tenant}/verify/{code}, la lista y el detalle de GET /public/archive/eliminacion/{tenant}/lotes[/{id}], el OAI de document y de archive y el CMIS de lectura: el plazo de oposición del inventario de eliminación y la verificación de un radicado no se detienen por una suspensión (ADR-035). Inexistente, dada de baja y sin aprovisionar siguen con el 404 neutro.
La suspensión surte efecto como mucho tras AUTH_CACHE_TTL (15 s) en el gateway, y de inmediato en los servicios que consultan el estado en su propia puerta (los seis get_tenant_conn y el get_db_conn de archive, workflow y storage); un estado desconocido no se cachea. Un token sin tenant_slug (superadmin sin entidad) no se veta en el gateway: los servicios exigen la cabecera. Los consumidores de eventos y las tareas en segundo plano no se bloquean (ADR-035).
Login. POST /api/v1/auth/token y POST /api/v1/auth/refresh no entregan token si la entidad del claim del token no es operativa: 403 tenant_inactivo, 503 tenant_no_provisionado o 503 tenant_estado_no_disponible, con el mismo cuerpo {"error","detail","status"} que el gateway (dentro de detail). Un token sin tenant_slug (superadmin global) no se comprueba. El mcp-server propaga esos mismos códigos desde /auth/me (GET /api/v1/mcp/tools, /propuestas/..., asistente) en vez de 503 auth_service_error.
Identidad firmada con instante. El gateway estampa X-Identity-Ts (epoch en segundos) en la identidad que firma; el servicio que la verifica rechaza con 401 identidad_no_autentica una firma de más de IDENTITY_MAX_AGE_SECONDS (120 s) o más de IDENTITY_MAX_SKEW_SECONDS (30 s) en el futuro. Un cliente interno debe firmar al enviar, no reutilizar cabeceras guardadas. Desde B10 (ADR-036) las cabeceras X-Identity-* solo valen para el método y la ruta para los que se firmaron (la ruta canónica: sin query, decodificada hasta estabilizar), y durante esos 120 s: el método y la ruta entran en la base del HMAC (v2) sin viajar como cabeceras, y presentadas en otra operación del mismo servicio dan 401 identidad_no_autentica. Una ruta que no estabiliza al canonizar responde 404 route_not_found (el mismo que el proxy), no 500 (residuos declarados en ADR-036).
Rutas transversales de todos los servicios¶
GET /health·GET /health/ready— sonda de vida y de disponibilidad, en los once servicios./healthresponde si el proceso está en pie;/health/readyademás comprueba sus dependencias (pool de PostgreSQL, Redis, almacén de objetos según el servicio). En los servicios con consumidor de eventos (workflow, signature, notification, knowledge),/health/readyañadechecks.dead_letter(ok/degraded:degradedsi la DLQ de Redis tiene entradas o el último barrido de reclamación aparcó mensajes) y un objetoconsumercondead_letter_stream_len,reclamados,dead_lettered_ultimo_barrido,dead_lettered_total,ultimo_barridoyreinicios; solo informa, nunca decide el503. El/health/readyde auth-service añadeaudit_particiones(ok/degraded/unknown):degraded—y"degraded": ["audit_particiones"]en la raíz— si falta la partición del mes siguiente deaudit_log(el cron externo descripts/audit_partitions.pydejó de correr); también solo informa y responde200. Las usa elhealthcheckde Docker Compose y el arranque ordenado: un servicio que responde/healthpero no/health/readyestá vivo y todavía no debe recibir tráfico, y confundirlas produce el arranque en cascada que parece un fallo intermitente.GET /api/v1/openapi.json— el esquema OpenAPI del servicio, servido por FastAPI. En el gateway es el que agrega la documentación interactiva de/docs.
Endpoints internos (/internal/... y sufijo -internal): sellados con X-Internal-Token (D-02) y no enrutados por el gateway —esa cabecera no está en FORWARDED_REQUEST_HEADERS—, así que no son alcanzables desde fuera de la red interna por mucho que se conozca la ruta. Se documentan uno a uno en la sección de su servicio; los que existen hoy son:
| Endpoint interno | Servicio | Para qué |
|---|---|---|
POST /api/v1/internal/documentos/anexos |
document | Inventario TOTAL de anexos de un lote de radicados (acto de sistema, sin recorte de clearance) — lo pide archive al exportar o empaquetar |
POST /api/v1/internal/documentos/metadata |
document | Metadata (subject/doc_type) de radicados visibles, para enriquecer el manifiesto de exportación — este sí recorta por clearance. Desde 2026-10 devuelve además tracking_number y doc_class: archive-service los coteja al vincular un radicado (T6 / AREA-07-02, AREA-06-08), y sin tracking_number falla cerrado, así que document-service se despliega primero |
POST /api/v1/internal/documentos/purgar |
document | Destruye radicados y sus anexos por disposición final (RF-TRD-07 fase 5) |
GET /api/v1/internal/documentos/radicados/{ref}/huella |
document | SHA-256 del documento principal de un radicado (ref = número o UUID; SOLO el anexo es_principal, el más reciente si hubiera varios; un PDF de soporte no cuenta: sin principal, 409 radicado_sin_documento; la huella la calculó storage al subirlo). 404 radicado_not_found · 409 radicado_sin_documento/anexo_sin_huella · 503 huella_no_verificable (el cuerpo del 503 no reenvía lo que respondió storage). Lo consulta signature-service al abrir y al firmar una cadena (AREA-04-09) |
GET /api/v1/internal/expedientes/{ref}/indice/huella |
archive | SHA-256 (xml_sha256, C14N) del índice electrónico vigente de un expediente (ref = código o UUID; última versión). 404 expediente_not_found · 409 indice_no_disponible. Router propio con gate X-Internal-Token (INTERNAL_SERVICE_TOKEN, = ARCHIVE_INTERNAL_TOKEN de signature); sin token configurado, 401 fail-closed (AREA-04-09) |
POST /api/v1/storage/internal/artefacto |
storage | Sube un artefacto producido por el SISTEMA (índice firmado, .eml original), con su propia lista de formatos — no la de anexos |
POST /api/v1/storage/internal/anexo-externo |
storage | Anexo recibido por la ventanilla única pública |
POST /api/v1/storage/internal/purgar-disposicion |
storage | Borrado físico del almacén de objetos + fila, con gate WORM |
POST /api/v1/storage/internal/purgar-huerfanos |
storage | Borrado seguro de binarios huérfanos de una importación fallida: solo lo creado por esa importación y sin ninguna referencia en el tenant (deduplicación por SHA-256) y que ningún upload reutilizó por deduplicación (files.dedup_hit_at: lo compartido se conserva siempre; una tabla de referencias ausente falla cerrado); resultado por archivo |
GET /api/v1/storage/files/{id}/content |
storage | Bytes para procesar servicio-a-servicio (mediación de descarga, AIP) |
POST /api/v1/preservacion/proteger-indice-internal · proteger-artefacto-internal · renovar-retencion-internal |
storage | Cableado y renovación WORM desde la reconciliación desatendida |
POST /api/v1/preservacion/renovar-artefacto-internal |
storage | Registra el XML de una firma XAdES-LTA renovada (ATS nuevo) como objeto WORM que hereda la retención del anterior (B10) |
POST /api/v1/preservacion/aip-internal |
storage | Empaquetado del AIP conducido por la reconciliación (SYSTEM_RECONCILER_ID) |
POST /api/v1/preservacion/export-zip-internal |
storage | Exportación del expediente en ZIP pedida por archive-service |
GET /internal/signing-key/revocation-status · POST /internal/signing-key/sign-digest |
auth | Estado de revocación y firma de un digest con la clave custodiada |
GET /api/v1/signature/internal/circuitos/{tipo}/{id} |
signature | Estado del circuito de aprobación (lo consulta document-service antes de aprobar un borrador) |
POST /api/v1/signature/internal/circuitos/{tipo}/{id}/invalidar |
signature | Deja sin efecto los circuitos vivos de un objeto cuyo texto cambió; lo pide document-service al editar un borrador ya sometido a firma; exige {cuerpo_sha256_vigente} (B10) |
POST /api/v1/signature/internal/sign-indice |
signature | Sellado XAdES diferido del índice/acta |
auth-service — puerto 8001¶
POST /api/v1/auth/token — público¶
Autenticación con credenciales. Retorna par de tokens JWT.
// Request
{ "username": "operador", "password": "orpycamcp_dev", "tenant_slug": "demo" }
// Response 200
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 300
}
POST /api/v1/auth/refresh — público¶
Renueva el access token con un refresh token válido.
POST /api/v1/auth/logout¶
Invalida el refresh token en Keycloak.
GET /api/v1/auth/validate¶
Valida un Bearer token. Usado internamente por el api-gateway.
// Response 200
{
"valid": true,
"user_id": "uuid",
"username": "operador",
"tenant_slug": "demo",
"roles": ["operator"],
"expires_in": 287,
"tenant_estado": "operativa"
}
tenant_estado (ADR-035) es operativa | inactiva | no_provisionada | desconocido, o null si el token no trae tenant_slug; desconocido es que el registro de entidades no respondió. Con una entidad que no es operativa, valid sigue siendo true (el token es válido) pero no se resuelve ni se aprovisiona auth_users (user_id = sub); el veredicto de servir o no lo toma el gateway (tabla de arriba).
expires_in son los segundos que le quedan al token (exp − ahora, ≥ 0): el api-gateway acota con él el TTL de su caché de validación (Redis), y no cachea si falta o es ≤ 0. Las entradas de esa caché se sellan con HMAC-SHA256 (AUTH_CACHE_MAC_SECRET, secreto exclusivo del gateway); sin secreto la caché queda apagada. El token debe ser un access token (typ=Bearer) emitido por un cliente declarado (azp ∈ ALLOWED_AZP, por defecto orpycamcp-frontend,api-gateway) y traer exp, iat, iss y sub; un ID token o el de otro cliente da valid:false.
GET /api/v1/auth/me¶
Información del usuario autenticado extraída del token, incluyendo sus permisos efectivos resueltos desde la BD del tenant (D-07).
Requiere Authorization: Bearer <token>. Si se proporciona X-Tenant-Slug, se resuelven los permisos desde la BD del tenant. Sin esa cabecera (superadmin / llamada directa), permissions devuelve [] y permission_levels {} (fail-closed). Una cabecera mal formada es 400 invalid_tenant. Entidad sin schema (registrada como pending antes de init_tenant, o inexistente) ⇒ 200 con permissions y permission_levels vacíos y user_id = sub (B7): es el mismo caso de «usuario no aprovisionado» y no se distingue de él (no es un oráculo); antes era un 500 UndefinedTableError, y /me es el arranque del frontend.
// Response 200
{
"user_id": "uuid-keycloak-sub",
"username": "operador",
"email": "operador@demo.orpycamcp.local",
"tenant_slug": "demo",
"roles": ["operator"],
"permissions": ["PERM_RADI", "USUA_PERM_CONSULTA"],
"permission_levels": { "PERM_RADI": 3, "USUA_PERM_CONSULTA": 1 }
}
permissions: lista de nombres de permisos concrud > 0asignados al usuario a través de sus grupos. Los usuariosis_root=truesiempre incluyenUSUA_PERM_ROOT, coherente conhas_permission()yGET /users/{id}/permissions.permission_levels: mismas claves quepermissions, con el nivel CRUD de cada una —0ninguno ·1leer ·2editar ·3crear/borrar ·4nivel_1 ·5nivel_2 (auth_group_permissions.crud, vermigrations/tenant/001_rbac.sql). ROOT resuelveUSUA_PERM_ROOT: 5. Es el campo que un espejo RBAC de interfaz debe consultar. Aviso de alcance real (medido en B13, septiembre de 2026):require_permissiontienemin_crud=1por defecto, y 85 de las 135 escrituras con gate no suben ese valor — es decir, hoy las puede ejecutar quien tenga el permiso en modo lectura. Los gates que SÍ exigen nivel (min_crud=2editar,min_crud=3crear/borrar, RF-SEG-03) niegan con403nombrando el nivel que falta; los demás no niegan nada. La lista de las 85 está declarada, endpoint por endpoint, enshared/tests/test_escritura_exige_nivel.py, que impide que crezca. Hasta que se cierre, un espejo de interfaz no puede dar por hecho que el backend rechazará una escritura solo porque el nivel sea 1.permissionsse conserva por compatibilidad con los consumidores existentes; ambos campos son aditivos entre sí, no alternativos.- Si el usuario no está aprovisionado en el tenant,
permissionses[]ypermission_levelses{}(fail-closed). Ninguno de los dos se omite ni se devuelvenull: un consumidor nunca debe poder leer su ausencia como "sin restricción".
2FA TOTP — credencial del usuario (E06 Inc.6, RF-FIR-13)¶
El secreto TOTP (RFC 6238) es una credencial de identidad durable y vive en auth-service (tabla auth_user_totp por tenant, cifrada con AESGCM; AAD tenant:{slug}:user:{id}:totp). El secreto en claro se revela una sola vez en el otpauth:// de enroll (Cache-Control: no-store); nunca vuelve a salir. Todos son auto-servicio (bearer del propio usuario).
POST /api/v1/auth/me/totp/enroll— genera el secreto (20 bytes, base32) en estadopendiente_activaciony devuelve{otpauth_uri, estado}(para QR en un autenticador). Re-enrolar sobre un TOTP activo exige step-up (un código actual válido):409 totp_ya_activo/401 step_up_invalido.POST /api/v1/auth/me/totp/activate{code}— activa la credencial con un primer código válido →{estado:"activo", activated_at}.409 totp_no_pendiente;401 codigo_invalido(suma afailed_attempts);423 totp_bloqueado.GET /api/v1/auth/me/totp—{estado, activated_at, locked_until}. Nunca devuelve el secreto.DELETE /api/v1/auth/me/totp{step_up_totp_code | step_up_recovery_code}— revoca la credencial activa exigiendo step-up →{estado:"revocado"}.401 step_up_invalido;404 no_totp.POST /api/v1/auth/me/totp/recovery-codes{step_up_totp_code | step_up_recovery_code}— regenera el lote de códigos de recuperación, invalidando el anterior por completo.200 {recovery_codes:[…]}conCache-Control: no-store.409 totp_no_activo;401 step_up_invalido;423 totp_bloqueado.POST /api/v1/auth/users/{user_id}/totp/revoke{motivo}— restablecimiento administrativo (USUA_PERM_ADMINnivel 3): retira el segundo factor de otra persona para que pueda volver a registrarlo ella misma desde/perfil, y anula sus códigos de recuperación en la misma transacción.200 {estado:"revocado", usuario_id, estado_previo, codigos_recuperacion_eliminados};404 no_totp;409 restablecimiento_propio_no_permitido;422si elmotivono llega a 8 caracteres. Nunca devuelve un secreto ni enrola en nombre de nadie — quien pudiera enrolar por otro tendría de hecho su firma personal. El asientototp.restablecido_por_admines atómico con la baja (misma transacción: si el asiento falla, el factor no se retira; igualtotp.desenrolado*en el auto-servicio) y se atribuye al administrador (actor), con el usuario afectado enobject_refy elmotivoen elpayload.- Por qué existe, y por qué no se resolvió admitiendo códigos de recuperación al firmar: los códigos de recuperación reponen el factor pero no sirven para firmar (
/internal/totp/verifysolo clasifica un TOTP), yenroll/DELETE /me/totpexigen el código actual o uno de recuperación. Quien perdía el teléfono y los códigos quedaba encerrado, sin más salida que tocar la base de datos a mano. Dejar que un código de papel, de vida larga, firmara habría rebajado el segundo factor de la firma justo en el acto que da fe: se repone el factor, no se degrada. - No se puede usar sobre uno mismo (
409): un administrador que se restableciera el suyo podría retirárselo sin demostrar posesión del dispositivo y enrolar otro acto seguido — quien robase su sesión se quedaría con el segundo factor de su firma personal. Hacen falta dos personas; el propio administrador usa/perfilcomo todo el mundo.
Códigos de recuperación (E06, migración 015). El TOTP es un factor de posesión, y hasta esta migración todas sus salidas —revocarlo, reenrolarlo, y revocar la clave personal de firma— exigían un código TOTP vigente, es decir, exigían el mismo teléfono que se había perdido: quien lo perdía quedaba encerrado, sin más salida que una intervención manual sobre la base. Ahora activate devuelve 10 códigos de un solo uso (recovery_codes, Cache-Control: no-store) y cualquiera de ellos vale como step_up_recovery_code en enroll (reenrolar) y en DELETE /me/totp (revocar).
- Se guardan hasheados (scrypt con sal por código), no cifrados: a diferencia del secreto TOTP, el servidor no necesita recuperarlos en claro, solo comprobarlos — así una copia de la tabla no permite autenticarse. Se muestran exactamente una vez;
GET /me/totpdevuelverecovery_codes_restantes(un conteo), nunca los códigos. - Un solo uso, garantizado por un
UPDATE ... WHERE used_at IS NULL: dos peticiones simultáneas con el mismo código compiten por la fila y solo una la gana. - Se emiten al activar, no al enrolar (hasta entonces la credencial no protege nada) y en la misma transacción que la activación — no existe un instante con TOTP activo y sin vía de recuperación. Revocar o reenrolar borra el lote en la misma transacción.
- Frontera deliberada: no sirven para FIRMAR.
POST /internal/totp/verify, que signature-service invoca al firmar, no los acepta (sucodeestá validado como TOTP de 6 dígitos). Un código de recuperación está escrito en papel o en un gestor de contraseñas: no es posesión de un dispositivo, y admitirlo como segundo factor de un acto de firma debilitaría la atribución que RF-FIR-13 persigue. Recuperar el acceso y firmar son actos distintos y se exigen pruebas distintas. - (Interno, servicio-a-servicio en la red Docker, no enrutado por el gateway)
GET /internal/totp/status— (AREA-04-02){activo, user_id}: ¿el dueño del bearer tiene un TOTP activo? Content-free; doble gateX-Internal-Token+ identidad derivada del bearer; sin usuario aprovisionado o sin tenant →activo=falsesinuser_id. Lo consulta signature-service para vetar el OTP-correo en actos de nivel ≥ 2.POST /internal/totp/verify{code, contexto?}— lo llama signature-service al firmar; mismo doble gate que/status:X-Internal-Token(SIGNING_KEY_INTERNAL_TOKEN; ausente ⇒401, erróneo ⇒403, sin configurar ⇒503) más el bearer del usuario, comprobados antes de leer el bearer (la identidad sigue saliendo de él) (la respuesta válida lleva ahorauser_id, elauth_users.idde quien se verificó, que signature-service cruza con el actor — AREA-04-05;GET /internal/signing-key/statuslleva el mismouser_id); deriva la identidad del bearer reenviado (no de unuser_idde cuerpo → sin IDOR), honralocked_untilantes de comparar, y avanza el anti-replaylast_timestepen un únicoUPDATEatómico. Lockout trasTOTP_MAX_FAILED_ATTEMPTS(default 5) duranteTOTP_LOCKOUT_SECONDS(default 900). Ventana de skew ±TOTP_SKEW_STEPS(default 1).
Clave de firma PERSONAL PKI (E06/E17 F4, Inc.1 — custodia servidor, acreditado=false). La clave privada de firma de cada usuario vive solo en auth-service cifrada AESGCM (KEK SIGNING_KEY_ENCRYPTION_KEY separada de la del TOTP), y NUNCA sale del servicio.
POST /api/v1/auth/me/signing-key/enroll— genera el par RSA-2048 en memoria, cifra la privada, emite un CSR (subject = identidad del usuario +cargomejor-esfuerzo desde grupos RBAC) y quedapendiente_emision; devuelve solo{csr_pem, estado}— nunca la clave privada. ExigePERM_FIRMA. Un re-enroll revoca la clave activa previa (endurecimiento de step-up diferido a Inc.2). La emisión del cert es out-of-band (scripts/gen-user-cert.sh, corre conca.keyfuera de todo contenedor; sub-CA de usuarios distinta de la del sello).GET /api/v1/auth/me/signing-key—{estado, certificado_fingerprint?, not_after?}. Nunca devuelve la clave privada.DELETE /api/v1/auth/me/signing-key{step_up_totp_code, motivo?}— revoca la clave propia exigiendo step-up TOTP (verifica-y-consume; sin código →422) →{estado:"revocado"}(E06/E17 Inc.2). Deja asientosigning_key.revocada(conrevocado_at/motivo) atómico con el UPDATE.motivoes un código cerrado (RFC 5280:key_compromise,superseded,cessation_of_operation,affiliation_changed,privilege_withdrawn,unspecified;422fuera de él; igual en el endpoint de admin).404si no hay clave.POST /api/v1/auth/admin/users/{user_id}/signing-key/revoke{motivo?}— un admin (USUA_PERM_ADMINnivel 3) revoca la clave de otro usuario (caso "empleado desvinculado"): eluser_idobjetivo va en el path, el actor sale del token (no confundibles). Asiento atómico conrevocado_por.- (Interno, no enrutado por el gateway)
GET /internal/signing-key/revocation-status?fingerprint=<hex>— lo consulta signature-service al verificar una firma personal (X-Internal-Token+X-Tenant-Slug): devuelve solo{status, revocado_at?}(status ∈ vigente|revocado|desconocido) — nunca cert ni clave. Aislado por tenant: un fingerprint de otro tenant / inexistente →desconocido(fail-closed, nuncavigente). La activación pendiente→activo persisteuser_ca_fingerprint+certificado_fingerprinty validaleaf.verify_directly_issued_by(sub_ca)fail-closed. - (Interno, no enrutado por el gateway)
POST /internal/signing-key/sign-digest{digest_b64, alg:"RSA-SHA256", prehashed:true, totp_code, cert_fingerprint}— lo llama signature-service al firmar un acto personal de la cadena: deriva el usuario del bearer reenviado (sin IDOR), verifica-y-consume el TOTP atómicamente con la firma, descifra la clave solo en memoria (vida mínima), firma el digest, y devuelve{signature_value_b64, certificado_pem, certificado_fingerprint, serial, not_after}— la privada nunca se devuelve. Es la interfaz "firmar un digest" de un HSM (seam a firma cualificada).X-Internal-Tokencomparado concompare_digest. La activación pendiente→activo deja asientosigning_key.activadaatómico. B10:cert_fingerprint(SHA-256 hex del certificado con el que signature preparó la firma) es obligatorio y se coteja antes del TOTP (409 certificado_cambiado); la firma RSA se verifica dentro de la tx del TOTP (503 firma_digest_no_verifica: el TOTP no se consume y el código sigue sirviendo).
Gestión de usuarios RBAC (E14)¶
POST /api/v1/auth/users·GET /api/v1/auth/users(paginado,X-Total-Count) ·GET /api/v1/auth/users/{id}/permissions— administración de usuarios. Todo el router exigeUSUA_PERM_ADMIN(lecturas nivel 1;POSTnivel 3).POSTdeja asientorbac.usuario_creadoenaudit_log, atómico con el INSERT (ver nota de auditoría abajo).POSTconis_root: true→403 forbiddensi el llamante no es ROOT (ADR-024, cierre de hallazgo Alto de auto-escalada). UnUSUA_PERM_ADMINno-ROOT nunca puede crear un usuario ROOT; ROOT sí. El primer ROOT de un tenant se aprovisiona fuera de banda (app/ops/grant_root.py, no HTTP).GET /api/v1/auth/users/pickable?size=N— proyección mínima[{id, username}]de usuarios activos, para selectores de UI (p. ej. elUserPickerde cadenas de visto bueno, T-08c). Gateado conPERM_RADIoUSUA_PERM_ADMIN(any-of, B6: el administrador de/admin/flujosy/admin/gruposelige usuario aunque no radique; no gana exposición, ya lista a todos conGET /users); no exponeemailni metadatos administrativos (D-10).sizeen[1, 500]. EmiteX-Total-Countcon los usuarios activos que hay, no con los que caben en la respuesta: este endpoint no pagina, recorta con unLIMIT, así que la cabecera es lo único que permite al llamante saber que está viendo un recorte. Sin ella, quien no entrara en la ventana no existía para quien busca — y en un selector eso no se lee como una lista corta, se lee como que esa persona no está en el sistema.
URD y contexto de dependencia (E08)¶
GET /api/v1/auth/users/{user_id}/urd·POST /api/v1/auth/users/{user_id}/urd·DELETE /api/v1/auth/users/{user_id}/urd/{urd_id}— administra las asignaciones Usuario-Rol-Dependencia (una por (usuario, dependencia); una sola principal). ExigeUSUA_PERM_ADMIN(POST/DELETEnivel 3).POST/DELETEdejan asientourd.creada/urd.eliminada, atómico con la mutación.GET /api/v1/auth/context— contexto del llamante:{user_id, active_depe_id, available_depts[]}.POST /api/v1/auth/context/switch{ "depe_id": 120 }— fija la dependencia activa del propio usuario. No reemite token (ADR-013): persiste el contexto y lo audita enusua_historico(Canal B, noaudit_log).400 no_urd_for_dependencysi no tiene URD activa allí.
Grupos, membresías y permisos (E08)¶
Todo el router exige USUA_PERM_ADMIN: las lecturas con nivel 1 y toda escritura con nivel 3 (403 con el permiso a nivel de lectura: quien lo recibe solo para consultar la auditoría ya no administra RBAC).
POST /api/v1/auth/groups·GET /api/v1/auth/groups— alta y listado de grupos.POSTdeja asientorbac.grupo_creado.PATCH /api/v1/auth/groups/{group_id}{name?, description?}— actualiza solo los campos enviados (400 empty_patchsin ninguno,409por nombre duplicado,404); asientorbac.grupo_actualizadocon{campos, antes, despues}.DELETE /api/v1/auth/groups/{group_id}— elimina un grupo vacío:409si tiene miembros o URD (se comprueba en la misma sentencia que borra, sin ventana),404si no existe,204con asientorbac.grupo_eliminado({name, description}del grupo borrado). Ambos exigenUSUA_PERM_ADMINnivel 3.GET /api/v1/auth/permissions— catálogo de permisos ({id, nombre, descripcion}).USUA_PERM_ROOTse omite del catálogo si el llamante no es ROOT (ADR-024) — no se lista una opción que elPUT .../permissions/{id}de abajo va a rechazar.POST /api/v1/auth/groups/{group_id}/members/{user_id}·DELETE .../members/{user_id}— alta/baja de un usuario en un grupo. Dejan asientorbac.miembro_agregado/rbac.miembro_removido. No-write-up (RF-SEG-08): el alta de un llamante no-ROOT en un grupo cuyo techo de clearance (role_clearance, 1 si no tiene) supera su clearance efectivo da403 no_write_up, sin asiento; el gate vive en elWHEREdelINSERT. Ya ser miembro no es error.GET /api/v1/auth/groups/{group_id}/members(paginado,X-Total-Count) — usuarios del grupo. Espejo de lectura del alta/baja anterior.PUT /api/v1/auth/groups/{group_id}/permissions/{permission_id}{ "crud": 3 }— fija el CRUD (0-5) de un permiso para el grupo. Deja asientorbac.permiso_grupo_asignado(payload conpermission_id/crud).- Conceder (
crud > 0)USUA_PERM_ROOT→403 forbiddensi el llamante no es ROOT (ADR-024). Revocar (crud = 0) siempre está permitido, incluso a un no-ROOT — solo la concesión está bloqueada. Cierra la vía de auto-escalada vía el grupo del que el propio actor es miembro. GET /api/v1/auth/groups/{group_id}/permissions— permisos asignados al grupo:[{permission_id, nombre, descripcion, crud}]. Espejo de lectura delPUTanterior.group_idde un grupo inexistente o de otro tenant →404indistinguible (elsearch_pathya acota el schema del tenant; nunca403, que confirmaría la existencia del grupo ajeno).
Auditoría de escrituras RBAC/URD (E08, cierra hueco de trazabilidad): todo POST/PUT/DELETE de esta sección y de "URD" deja asiento en public.audit_log dentro de la misma transacción que la mutación (async with conn.transaction()) — no best-effort: si audit.append falla, la mutación se revierte; si la librería de auditoría no está instalada, la escritura responde 500 auditoria_no_disponible antes de tocar ninguna fila (mismo patrón fail-closed que signing-key/totp). tenant_slug es obligatorio internamente (X-Tenant-Slug, ya exigido por get_tenant_conn); el actor es el X-User-Id resuelto por el gate.
Clasificación de seguridad (E08, RF-SEG-08, Ley 1712/2014)¶
GET /api/v1/auth/security-levels— catálogo de niveles (1 Pública, 2 Reservada, 3 Clasificada).PUT /api/v1/auth/groups/{group_id}/clearance{ "max_level": 2 }— fija el nivel máximo accesible de un grupo (exigeUSUA_PERM_ADMINnivel 3). Deja asientoclearance.grupo_actualizadocon{group_id, previous_max_level, new_max_level}(atómico, mismas garantías fail-closed que RBAC/URD arriba) — el nivel anterior permite distinguir una subida de una bajada.- No-write-up →
403 forbiddensimax_levelexcede el clearance efectivo del llamante (ADR-024). Un admin con clearance RESERVADA (2) no puede fijar CLASIFICADA (3) en ningún grupo, incluido el suyo propio; ROOT no tiene este límite (bypass total,_MAX_LEVEL=3). GET /api/v1/auth/groups/{group_id}/clearance— clearance vigente del grupo:{group_id, max_level}(max_level: nullsi el grupo no tiene fila enrole_clearance, lo que implica PUBLICA=1 por defecto). ExigeUSUA_PERM_ADMIN; mismo404indistinguible que elPUTpara grupo inexistente/de otro tenant.GET /api/v1/auth/clearance— clearance del llamante:{user_id, max_level, accessible_levels[]}(MAX sobre sus grupos; ROOT = 3).GET /api/v1/auth/clearance/check?level=N—{level, max_level, allowed}(mínimo privilegio:allowed = max_level ≥ level).
GET /api/v1/audit¶
Consulta la auditoría inmutable del tenant (ADR-008), más recientes primero. Exige USUA_PERM_ADMIN. Filtros: ?action=&object_type=&object_ref=&actor=&page=1&size=20. Cabecera X-Total-Count.
Recortado por clearance (no-read-up, RF-SEG-08). USUA_PERM_ADMIN es competencia administrativa, no habilitación de seguridad: administrar el tenant no autoriza a conocer los asuntos clasificados que en él se tramitan. Las entradas cuyo objeto es un radicado o un expediente por encima del clearance efectivo del llamante no se listan ni se cuentan (X-Total-Count refleja el mismo recorte; de lo contrario el total sería un oráculo de cuántos documentos clasificados existen). Detalles del recorte:
- Lista de admisión, no de denegación: se listan los tipos que no pueden referirse a un documento ni a un expediente (identidad, RBAC, estructura orgánica, instrumentos y catálogos:
usuario,grupo,membresia,dependencia,trd_serie,parametro,auth_session…) más los clasificables que resuelvan a un nivel alcanzable. Todo lo demás queda fuera. El motivo:audit_logla escriben once servicios y varios tipos que no denotan un radicado llevan su identidad en elpayload—evento(dead-letter) serializa el sobre íntegro conbusiness.tracking_number,importguarda la lista literal de números importados,fileguarda el nombre del anexo. Con lista de denegación, cada tipo o servicio nuevo nace visible y el olvido es silencioso. - El nivel se resuelve en vivo contra
radicados/expedientesdel schema del tenant — nunca denormalizado, para que una reclasificación surta efecto de inmediato. El schema se ancla altenant_slugdel request, no alsearch_pathde la conexión. - Fail-closed: una
object_refque no resuelve a ninguna fila (objeto eliminado, referencia de otro formato) queda fuera; lo que no se puede clasificar no se declara público. Igual si el tenant no tiene las tablas fuente. - Filtrar por la
object_refde un documento fuera de alcance devuelve cero entradas, indistinguible de que no exista. object_refpor código, UUID o índice (AREA-03-07): un expediente se nombra enaudit_logde tres formas (el código los asientos nuevos; el UUID del expediente y el del índice, los viejos, que no se reescriben). Filtrar por el código del expediente o por el número de un radicado trae también los asientos que lo nombran por UUID, y el del expediente los de su índice (expediente_indice); el recorte no-read-up se aplica después sobre la misma fila, así que ensanchar la referencia no ensancha el acceso. Cada asiento nuevo declara sucanal(api/ui/mcp;systemen jobs, workers y herramientas de línea de comandos): ya no hay valor por omisión en la librería.- Con clearance máximo (3 = CLASIFICADA, y ROOT) no se aplica recorte alguno: la auditoría se entrega íntegra, que es lo que su habilitación autoriza.
- Residuo declarado:
ides una secuencia monótona, así que los huecos entre ids visibles revelan cuántas entradas se ocultaron y, por lostsvecinos, cuándo./audit/verifyya no es un segundo canal para ese dato (ver abajo); eliddel listado sigue crudo: cerrarlo exige un identificador opaco por entrada y migrar a quien lo consume.
GET /api/v1/audit/verify no se recorta (ver abajo).
[
{
"id": 42,
"ts": "2024-01-15T10:30:00Z",
"service": "document-service",
"actor": "uuid",
"canal": "api",
"action": "document.radicado_created",
"object_type": "radicado",
"object_ref": "2024-ICETEX-E-000001",
"payload": { "...": "..." },
"hash": "sha256..."
}
]
GET /api/v1/audit/verify¶
Recalcula la cadena de hash del tenant y reporta su integridad (ADR-008). Exige USUA_PERM_ADMIN. La verificación no se recorta por clearance: la cadena solo tiene valor probatorio si se recalcula entera, y sobre un subconjunto siempre daría «rota». La respuesta es content-free, así que no revela nada del objeto: ni actor, ni acción, ni referencia. Pero el detalle solo es para el clearance máximo (AREA-03-03): total menos el X-Total-Count del listado recortado era el número exacto de asientos clasificados ocultos, y los id son una secuencia global entre entidades. Quien no tiene el clearance máximo recibe solo ok y total_roturas; total, verificados y broken_id van null y roturas vacío.
{
"tenant_slug": "icetex", "ok": false, "broken_id": 41004,
"total": 2056, "verificados": 2052, "total_roturas": 4,
"roturas": [{ "id": 41004, "motivo": "hash+prev_hash" }],
"roturas_omitidas": 0
}
- Reporta TODAS las roturas, no solo la primera.
audit_loges append-only por ley y una rotura no se puede reparar nunca: parando en la primera, una sola fila mala dejaba la verificación inservible para el resto de la vida del sistema —la herramienta dejaba de servir justo después de un incidente— y decía menos de lo que la entidad puede demostrar ante un juez. - Tras una rotura se sigue encadenando desde el hash almacenado, no desde el recomputado: así cada fila se juzga contra su predecesor tal como está escrito y las roturas quedan localizadas, en vez de que una fila mala pinte de rotas todas las siguientes.
motivo∈hash(el contenido no produce el hash guardado) ·prev_hash(no encadena con la anterior) ·hash+prev_hash.roturasse topa en 100 entradas enumeradas;roturas_omitidascuenta el resto — un registro masivamente corrupto no puede parecer que tiene exactamente 100 problemas.total_roturases la suma.broken_id(=roturas[0].id) se conserva por compatibilidad; los demás campos son aditivos.
tenant-service — puerto 8002¶
POST /api/v1/tenants — NO enrutado por el gateway¶
Cierre de hallazgo Crítico (auditoría 2026-07): el registro de tenants vive en el schema
public(sinsearch_pathde tenant) y no tenía ningún gate de autorización — cualquier usuario autenticado de la entidad A podía enumerar (GET) y escribir (PATCH) el registro de la entidad B. La corrección quirúrgica fue sacar el prefijo/api/v1/tenants/de la tabla de ruteo del gateway (api-gateway/app/routers/proxy.py): no hay consumidor legítimo externo (el frontend no lo llama, y el alta de tenants la hacescripts/init_tenant.pyescribiendo directo apublic.tenants— operación de plataforma, no de aplicación). Los endpoints siguen existiendo en tenant-service para ese script/uso interno, pero ya no son alcanzables desdehttp://localhost:19080— una petición a/api/v1/tenants/responde404 route_not_foundsin llegar siquiera a validar el token.
Registra una institución en public.tenants (provisioning_status='pending') y deja asiento tenant.created en public.audit_log en la misma transacción. No crea el schema tenant_{slug}: el rol de aplicación con el que opera el servicio no ejecuta DDL; la provisión (schema, migraciones, GRANT USAGE) es de scripts/init_tenant.py, que además activa la entidad.
// Request
{ "slug": "icetex", "name": "Instituto Colombiano de Crédito Educativo", "code": "ICETEX" }
// Response 201
{
"id": "uuid",
"slug": "icetex",
"name": "Instituto Colombiano de Crédito Educativo",
"code": "ICETEX",
"status": "active",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
slug: solo minúsculas, números y guiones (^[a-z0-9-]+$) — inmutablecode: mayúsculas alfanuméricas (^[A-Z0-9_]+$) — aparece en números de radicado — inmutable- 409 si slug o code ya existen
GET /api/v1/tenants — NO enrutado por el gateway¶
Lista instituciones. Query: ?status=active&page=1&size=20. Header X-Total-Count.
GET /api/v1/tenants/{id} — NO enrutado por el gateway¶
Obtener por UUID.
GET /api/v1/tenants/by-slug/{slug} — NO enrutado por el gateway¶
Obtener por slug. Usado por otros servicios para validar el tenant del JWT.
PATCH /api/v1/tenants/{id} — NO enrutado por el gateway¶
Actualizar name o status. slug y code son inmutables. Deja asiento tenant.updated (antes/después) atómico con el cambio.
Administración del tenant (E14)¶
Autorización (cierre de hallazgo Crítico, auditoría 2026-07): el servicio no tenía ningún gate de autorización (
app/core/authz.pyno existía) — cualquier autenticado del tenant podía crear/editar/borrar dependencias, catálogos, festivos y parámetros. Las escrituras ahora exigenUSUA_PERM_ADMIN(app/core/authz.py, mismo patrónrequire_permissionque document-service/archive-service) y dejan asiento enpublic.audit_logdentro de la misma transacción (fail-closed, patrónRBACService). LosGETquedan solo autenticados: son catálogos/calendario que la UI de radicación y el cálculo de plazos (RF-RAD-04) necesitan para cualquier usuario, no solo administradores.
- Dependencias (organigrama):
GET /api/v1/dependencias(autenticado) ·POST/PATCH/DELETE /api/v1/dependencias(USUA_PERM_ADMINa nivel CRUD 3; con nivel 1 o 2,403) ·GET /api/v1/dependencias/by-codigo/{codigo}(jerárquicas, autenticado). - Catálogos (lookup):
GET /api/v1/catalogos(autenticado) — qué catálogos hay: devuelve la lista de nombres admitidos, para no tener que adivinarlos ni mantener una copia en el cliente. ·GET /api/v1/catalogos/{catalogo}(autenticado) ·POST/PATCH/DELETE /api/v1/catalogos/{catalogo}(USUA_PERM_ADMINa nivel CRUD 3) —tipos-identificacion,tipos-remitente,medios-recepcion,tipos-anexo,causales,formas-envio,soportes,mensajes-rapidos. causalesdistingue el acto al que sirve (ambito, migración tenant 008):devolucion,anulacion,reasignacion,devolucion_postalogeneral.GET /api/v1/catalogos/causales?ambito=devolucionfiltra en la fuente, yambitoviaja en el cuerpo dePOST/PATCH. Es el único catálogo que lo admite: pedirlo sobre otro responde 422ambito_no_aplicaen vez de ignorarlo en silencio. Una causal sin ámbito declarado queda engeneraly la ofrecen todos los actos.- Parámetros:
GET /api/v1/config/params,GET /api/v1/config/params/{key}(autenticados) ·PUT /api/v1/config/params/{key}(USUA_PERM_ADMINa nivel ≥ 3: un administrador en modo lectura o edición recibe403; el asiento llevaantes/despues). Claves cerradas (B6):PUTsolo admite las que siembran las migraciones de tenant (009:entidad_nombre,_sigla,_nit,_direccion,_telefono,_email,_web; 010:firma_personal_require_xades; 012:politica_tratamiento_version,_url,_texto); cualquier otra da422 config_param_desconocido, porque la lectura es abierta a todo el tenant y una clave libre sería un buzón de secretos legible por cualquiera. Cuerpo{value, motivo?};value: nullda422 valor_nulopara cualquier clave (el JSONnullsolo lo escribe la siembra).firma_personal_require_xades(off·clasificados·todos) es la política XAdES de la entidad que lee signature-service en cada firma de cadena; la migración010la siembra en JSONnull(= «la entidad no lo define»: manda el respaldo del entornoFIRMA_PERSONAL_REQUIRE_XADES) y la011repara las entidades donde una010anterior la había sembrado en"off"sin que nadie la cambiara por la API; cualquier otro valor da422 valor_invalido(un valor desconocido se aplicaría comotodos). Relajar la política (bajar la exigencia, p. ej.todos→clasificados, o fijar un valor menor cuando el vigente esnull/desconocido, que se supone el más estricto) exigemotivode al menos 10 caracteres: sin él,422 motivo_requerido; el asientoconfig.parametro_actualizadollevamotivoyrelaja_politica. Endurecer no lo pide. La pantalla genérica/admin/parametrosya lo lista: no hay pantalla propia. Los parámetros de numeración (formato_numero_radicado,depe_digits,consec_digits,expediente_number_format) son inertes: ningún servicio los lee todavía. Inertes declaradas (B6): la 002 siembra además 10 claves heredadas de Orfeo que NINGÚN servicio lee (unicoconsecutivo,formato_numero_radicado,depe_digits,consec_digits,dias_bloqueo,expediente_number_format,notificaciones_mail,archivado_requiere_exp,reasigna_requiere_exp,firma_digital_provider); se conservan sembradas pero elPUTlas rechaza con el mismo422(editarlas prometería un efecto inexistente); para volver a configurarlas, un servicio debe leerlas y pasar a la lista admitida (PARAMS_INERTESenconfig.py). - Dependencias —
PATCHyDELETE /api/v1/dependencias/{id}(B5, ADR-032):codigoes inmutable (PATCHcon otro valor →422 dependencia_codigo_inmutable; el mismo valor es un no-op; para corregirlo se crea la dependencia nueva y se desactiva la vieja).nombre(≤ 100 caracteres) se puede cambiar libremente, también con trámite vivo: el nombre anterior queda endependencia_nombre_historico(solo inserción) en la misma transacción que el asiento. Desactivar (activo=false) con pasos de flujopending/in_progresso reglas que la citan por código responde409 dependencia_con_tramite_en_cursoconpasos_vivosyreglas. Eliminar responde409 dependencia_con_historia(conreferencias) si cualquier tabla la cita —pasos, eventos, reglas, radicados, borradores, URD, expedientes—: una dependencia usada se desactiva, no se borra (Ley 594). POST /api/v1/dependencias/{id}/mapeos-nombre{nombre, motivo}(USUA_PERM_ADMINnivel 3,motivo≥ 10 caracteres, asientodependencia.mapeo_nombre): decisión humana de quenombre(ambiguo o huérfano para el relleno de las migraciones de workflow/document) es esta dependencia. Es la salida de una migración que abortó con «nombres ambiguos o inexistentes»;201 {codigo, nombre, origen:"mapeo_manual"}. Irreversible (historial de solo inserción) y el mapeo gana a las coincidencias textuales del nombre:409 dependencia_inactivasi la dependencia está inactiva y409 nombre_de_otra_dependenciasi el nombre es el vigente de otra dependencia activa (no si el destino también lo lleva: homónimas).PATCHacepta ademásmotivo(10–1000 caracteres, opcional) cuando cambia el nombre: va al historial y al asiento.POST /dependenciascon un código eliminado →409 dependencia_codigo_retirado(un código eliminado no se reutiliza);DELETEregistra el código endependencia_codigo_retiradoy sureferenciasbarre por catálogo todas las columnas de código (transferencias, índice electrónico, plantillas, usuarios activos y el propio historial de nombres).- Días no hábiles / festivos:
GET /api/v1/config/holidays(?year=, autenticado) ·POST /api/v1/config/holidays,DELETE /api/v1/config/holidays/{id},POST /api/v1/config/holidays/seed({year_from, year_to}→ siembra los festivos colombianos calculados automáticamente, con asientoconfig.festivos_sembradosen la misma transacción) — las 3 escrituras exigenUSUA_PERM_ADMINa nivel ≥ 3 (FIRMA-2: un administrador en lectura o edición recibe403; el calendario fija los plazos legales de la entidad). - Días hábiles:
GET /api/v1/business-days/calculate?from=&days=(autenticado) — calcula la fecha de vencimiento aplicando fines de semana + festivos + no laborables del tenant (base de RF-RAD-04).
PINAR — Plan Institucional de Archivos (E14, RF-ADM-08, Acuerdo AGN 003/2015)¶
Instrumento de planeación archivística por tenant. Todas las escrituras (ciclo de
vida del plan, aspectos, priorización, objetivos, proyectos, seguimiento,
instrumentos) exigen USUA_PERM_ADMIN a nivel CRUD 3 (octubre de 2026; antes bastaba el nivel 1, de lectura; las lecturas siguen a nivel 1) (app/core/authz.py, cierre del mismo
hallazgo Crítico que catálogos/dependencias/config — antes no había ningún gate
de rol, pese a que la spec §10.3 restringe la gestión al administrador). Desde
2026-08-28 los GET también (§4.bis del plan de pruebas): el gate vive en el
propio APIRouter, así que lo hereda todo endpoint que se añada. Antes las 11
lecturas quedaban «solo autenticadas» mientras la barra lateral y las tarjetas de
/admin escondían la pantalla — un sistema que esconde algo y lo sirve a la vez
no ha decidido nada. Se cierra en vez de abrirse porque un plan en borrador es
material de trabajo del Comité, y porque publicar el PINAR aprobado (Decreto
1080/2015 art. 2.8.2.5.8) es un acto hacia fuera —una pantalla pública— y no un
GET interno sin gate que se le parece por accidente. El actor imputable llega en
X-User-Id.
- Ejes (catálogo fijo, seed de 5):
GET /api/v1/pinar/ejes. - Plan:
POST /api/v1/pinar/planes(crea enborrador,version = max+1) ·GET /api/v1/pinar/planes(?estado=) ·GET /api/v1/pinar/planes/{id}·PATCH /api/v1/pinar/planes/{id}(visión/vigencia, solo enborrador). - Transiciones:
POST /planes/{id}/aprobar(borrador→aprobado, exigeacto_administrativo→ 422 si falta) ·POST /planes/{id}/ejecutar(aprobado→en_ejecucion, un único plan en ejecución por tenant → 409plan_en_ejecucion_existe) ·POST /planes/{id}/cerrar(terminal). Cada acto de ciclo de vida es imputable (X-User-Id obligatorio → 400 si falta) y queda en laaudit_logINMUTABLE (E08). Al aprobar el contenido se congela (no más edición estructural, solo seguimiento). - Aspectos críticos (diagnóstico):
POST/GET /planes/{id}/aspectos,PATCH/DELETE /aspectos/{id}. - Priorización (matriz aspecto×eje, impacto 1–10):
PUT /planes/{id}/priorizacion(carga la matriz) ·GET /planes/{id}/priorizacion→ cada aspecto con su prioridad = Σ impactos, ordenada desc (define el mapa de ruta). - Objetivos:
POST/GET /planes/{id}/objetivos,PATCH/DELETE /objetivos/{id}. - Proyectos:
POST/GET /planes/{id}/proyectos,PATCH/DELETE /proyectos/{id},PUT /proyectos/{id}/aspectos(vínculo a los aspectos que mitiga). - Seguimiento (append-only):
POST /proyectos/{id}/seguimiento(proyecta elavance_pctdel proyecto) ·GET /proyectos/{id}/seguimiento. - Tableros:
GET /planes/{id}/tablero(avance por objetivo y por eje) ·GET /planes/{id}/mapa-ruta(cronograma por fechas). - Articulación (por referencia, no copia):
POST/GET /planes/{id}/instrumentos,DELETE /planes/{id}/instrumentos/{id}—tipo ∈ {ccd, trd, fuid, plan_preservacion, plan_contingencia}.
Edición estructural permitida solo en borrador (→ 409 plan_no_editable en aprobado/en_ejecucion/cerrado: el contenido se congela al aprobar); seguimiento permitido en {aprobado, en_ejecucion} (→ 409 plan_no_seguible).
La interfaz consume ya el módulo completo (2026-08-29). Hasta esta fecha la pantalla usaba 6 de los 31 endpoints: se podía crear un plan y recorrer su ciclo de vida, pero no había forma de crear un aspecto, un objetivo ni un proyecto desde el navegador — así que el tablero salía siempre vacío y la propia pantalla lo declaraba como «fase posterior de PINAR». Un tablero que no puede llenarse nunca no es un estado vacío: es un falso verde. Ahora
/admin/pinar/[id]tiene tres pestañas más —aspectos con su matriz, objetivos y proyectos con seguimiento, e instrumentos articulados— y espeja los dos ciclos de vida del servicio: la estructura solo se edita enborradory el seguimiento solo con el planaprobadooen_ejecucion, que nunca coinciden.
Eliminación documental — lotes (RF-TRD-07 completo, 2026-08-29)¶
POST /api/v1/eliminacion/lotes · GET /api/v1/eliminacion/lotes · GET /api/v1/eliminacion/lotes/{id}
Recurso propio y no un apéndice de /trd (mismo criterio que la cola de anulaciones): un lote no es una lectura del instrumento, es un procedimiento con ciclo de vida borrador → publicado → avalado → ejecutado, o anulado. Proponer y listar exigen USUA_PERM_TRD; proponer además crud≥3 y actor imputable.
Nada de esto elimina nada. Se elimina por lotes porque el acta y el aval del Comité son del lote; esta fase propone y congela el inventario —código, nombre, serie, disposición, fecha de vencimiento, fechas extremas, nivel y volumen—, que es lo que después se publica para que un tercero pueda oponerse y lo que el acta describirá. No se lee en vivo del expediente: el expediente va a dejar de existir.
Cinco condiciones de admisión, y lo rechazado se informa con su motivo en vez de descartarse en silencio (sin_confirmacion, disposicion_no_destruye, instrumento_no_gobierna, worm_vigente, ya_en_otro_lote, no_encontrado):
- El vencimiento del archivo central está confirmado por un usuario autorizado (RF-TRD-06) — sin decisión no hay propuesta.
- La disposición termina en destrucción (
E,S,M). Conservación Total jamás entra. - El instrumento congelado la gobierna (ADR-025 D5).
- El candado WORM ya expiró — y es el máximo de los tres que puede tener un expediente (índice firmado, acta de transferencia y AIP): mientras uno siga vigente, en modo COMPLIANCE no puede borrar nadie, ni el administrador.
- No está ya en otro lote vivo.
Un expediente fuera del clearance del proponente se informa como no_encontrado, sin distinguirlo de uno inexistente. Al abrir un lote, el inventario se recorta por clearance pero total_items sale de la base y no de contar lo devuelto: recalcularlo dejaría que la resta delatara cuántos clasificados contiene. El listado de lotes no se recorta — un lote no es un expediente: su código, su estado y su acta no dicen nada del archivo.
Fase 2 — publicidad previa (2026-08-28). POST /api/v1/eliminacion/lotes/{id}/publicar (USUA_PERM_TRD crud≥3) transita borrador → publicado y congela el inventario: el trigger de la migración 047 deja de admitir que se quite una fila, porque lo publicado es sobre lo que un tercero decidió no oponerse. publicado_hasta es obligatorio y futuro — no hay default: no se inventa cuántos días da una entidad para oponerse a que destruyan un documento.
El inventario queda visible sin autenticar en GET /api/v1/public/archive/eliminacion/{tenant}/lotes y GET /api/v1/public/archive/eliminacion/{tenant}/lotes/{lote_id}. El chokepoint es el ESTADO del lote, no un interruptor de despliegue: deliberadamente no cuelga de public_interop_enabled (la cosecha OAI/CMIS), porque publicar el registro de correspondencia es una decisión de la entidad y publicar el inventario de lo que va a eliminar es una obligación del procedimiento — atarlas al mismo interruptor dejaría a una entidad sin poder eliminar por no activar una cosecha que no le interesa. El chokepoint está escrito por la marca del acto (publicado_at IS NOT NULL) y no por una lista de estados: es público todo lote que llegó a publicarse, y lo sigue siendo después — avalado, ejecutado y también anulado. La constancia de qué se eliminó no puede desaparecer al eliminarlo, y la de que algo no se eliminó le importa a quien leyó que iba a eliminarse. Un lote en borrador responde 404, indistinguible de un identificador inventado.
Un slug que no existe y un lote que no existe dan lo mismo (T6 / AREA-06-01-C). GET …/{tenant}/lotes/{lote_id} comprueba primero que la entidad esté alojada (public.tenants) y responde el mismo 404 lote_no_encontrado que para un UUID inventado; antes un slug inexistente fallaba con UndefinedTable (500) y uno real daba 404, así que cualquiera sin token averiguaba qué entidades aloja la instalación (el listado ya lo tenía cerrado).
La descripción de un expediente clasificado no se publica, pero la fila sí. El recorte va en el SELECT (CASE WHEN nivel_seguridad > 1 THEN NULL), no en quien pinta. Omitir la fila dejaría de cumplir la publicidad previa justo sobre los expedientes que más importa que alguien pueda objetar; lo que se retiene es la denominación, que es donde vive el contenido. Va marcada con nombre_reservado para que la pantalla lo diga en vez de dejar un hueco mudo.
Pantalla pública en (public)/eliminacion, enlazada desde el portal. El canal de oposición no se inventa: es la ventanilla única que la entidad ya tiene — un formulario aparte sería una segunda bandeja que nadie mira, y una oposición es exactamente lo que una ventanilla existe para recibir.
Fase 3 — el aval del Comité y la retirada (2026-08-29).
POST /api/v1/eliminacion/lotes/{id}/avalar (USUA_PERM_TRD crud≥4, actor imputable) transita publicado → avalado con las tres constancias obligatorias —acta_comite, fecha_aval (no futura) y acto_administrativo—, exigidas en el schema, en el servicio y en un CHECK de la migración 047: un aval sin ellas es una afirmación no verificada, el mismo criterio que la convalidación de una versión de TRD. El sistema no decide; registra que un Comité decidió y con qué acto (Ac. AGN 001/2024 art. 4.3.2.5).
No se avala con el plazo de publicidad todavía abierto. Es la regla de fondo de la fase: autorizar la destrucción antes de que venza el tiempo que la propia entidad dio para oponerse convierte la publicidad previa en un adorno. La guarda vive en el WHERE de la transición (AND publicado_hasta < CURRENT_DATE, con la fecha del servidor), no en una comprobación previa en Python que dejaría una ventana entre leer y escribir. El 409 distingue los tres motivos: publicidad_en_curso dice hasta cuándo, lote_no_avalable dice en qué estado está, y un identificador desconocido da 404.
El plazo de oposición se cuenta en días operativos (ADR-035, re-auditoría B9). La ventanilla es el canal de oposición y responde 503 mientras la entidad está suspendida; un plazo no puede vencer con ese canal cerrado. La guarda del WHERE del aval exige que los días en que la entidad fue operativa (reconstruidos de los asientos tenant.updated de public.audit_log) entre publicado_at y ayer alcancen la longitud del plazo original. Si no: 409 plazo_oposicion_interrumpido con dias_suspendidos y plazo_efectivo_hasta (el aval vuelve a ser posible el día siguiente a esa fecha). No hay endpoint nuevo: el plazo efectivo se deriva, no se edita. El acta de eliminación declara plazoOposicionEfectivoHasta y diasPlazoInterrumpido en publicidadPrevia solo cuando hubo interrupción.
Separación de funciones (2026-10, T6 / AREA-06-01). avalar y ejecutar piden crud≥4 —el nivel que habilita lo irreversible— y, sobre todo, quien propuso el lote no lo avala (409 separacion_de_funciones): subir el nivel solo acota quién puede, y una sola persona con nivel 4 lo haría todo. La guarda vive en el WHERE de la transición (AND creado_por <> $5), no en una comprobación previa. El intento deja asiento archive.eliminacion_separacion_denegada. LoteEliminacionResponse añade avalado_por para que la interfaz sepa a quién no ofrecerle ejecutar. Atomicidad: publicar, avalar, anular y marcar ejecutado son un solo acto con su asiento (conn.transaction()); antes el UPDATE se confirmaba solo y un fallo del asiento dejaba el lote en su estado nuevo sin traza.
POST /api/v1/eliminacion/lotes/{id}/anular retira el lote con un motivo de al menos 10 caracteres. Se puede hasta el último momento, incluso ya avalado —la condición se escribe por admisión de los tres estados vivos, no negando ejecutado—, pero nunca después de ejecutarlo: lo destruido no se des-destruye. Si el lote llegó a publicarse sigue siendo público tras anularse, con su motivo a la vista.
Pantalla en /admin/eliminacion (listado, detalle con el inventario recortado por clearance, y los tres actos). Vive fuera de /admin/trd por el mismo criterio que el router: un lote no es una lectura del instrumento. Lo único que se queda en TRD es proponer, porque se propone desde la cola de vencimientos.
Fase 4 — el acta de eliminación (2026-08-29).
POST /api/v1/eliminacion/lotes/{id}/acta (USUA_PERM_TRD crud≥3) · GET /api/v1/eliminacion/lotes/{id}/acta (USUA_PERM_TRD) · POST /api/v1/eliminacion/lotes/{id}/acta/firmar (crud≥3)
El acta es lo que sobrevive a lo eliminado. Cuando la fase 5 borre los bytes, será —junto con la lápida y el audit_log— la única constancia de qué se destruyó, bajo qué acto y quién lo autorizó. Es de conservación permanente: sin calendario de retención y sin disposición final. Se genera antes del borrado y no después, porque el inventario que describe sale de expedientes que van a dejar de existir.
Son dos tablas (migración 048), por el mismo criterio que el acta de transferencia: eliminacion_acta guarda el contenido congelado y es append-only por trigger —ni se edita ni se borra—, y eliminacion_acta_firma guarda el estado mutable del sellado. Mezclarlas obligaría a que el contenido fuera mutable para que la firma pudiera avanzar. Consecuencia deliberada y verificada: con el acta escrita, la fila del lote ya no se puede borrar (el ON DELETE CASCADE choca con el trigger).
El XML (urn:orpycamcp:acta-eliminacion:v1) se serializa canónicamente y con el inventario ordenado por código: dos construcciones con los mismos datos dan la misma cadena byte a byte, o el acta_sha256 no significaría nada. Ese SHA se ancla además en audit_log, que es indeleble.
El inventario del acta va completo, sin recortar por clearance — un acta a la que le faltan los clasificados no da fe de lo que se destruyó. Lo que se acota es quién la lee: el umbral se deriva del contenido (el nivel de seguridad más alto del inventario, nunca un campo declarado) y quien no lo despeja recibe 403 con el motivo, no un acta recortada: romper la firma sería peor que negarla.
metodo_borrado es obligatorio, de al menos 10 caracteres y sin valor por defecto en ninguna de las tres capas: el acta debe decir cómo se destruye (Ac. AGN 001/2024 art. 4.3.2.5), y una frase inventada por el sistema sería una afirmación que nadie hizo sobre un acto irreversible.
El sellado XAdES corre post-commit, best-effort y acotado, con los tres desenlaces del acta de transferencia (firmado / sello_ausente sin reintentar / transient agotado), el motivo del último fallo visible y reintento manual. acta_eliminacion se añadió al allow-set XADES_ELIGIBLE_OBJETO_TIPOS de signature-service: sin estar en él, el acta habría caído al HMAC nativa sin error y sin diferencia visible, quedando marcada «firmada» siendo estructuralmente más débil. acreditado viaja explícito y es siempre false (art. 7 de la Ley 527/1999, nunca art. 28).
WORM del acta: cerrado el 2026-08-29. Era la deuda declarada al cerrar esta fase, y la razón era de modelo: el almacén de Object-Lock asumía que todo artefacto protegido pertenece a un expediente, y un acta de eliminación cubre N expedientes que además van a dejar de existir — clavarla bajo el id de uno de ellos no era incómodo, era incorrecto.
La migración
storage tenant/014añadeobjeto_tipo(expediente|lote) con unCHECKque impone la pareja: unacta_eliminacionpertenece siempre a un lote y todo lo demás siempre a un expediente, así que no puede existir la fila que diga que un índice electrónico pertenece a un lote.expediente_idno se renombra —era ya un id de propietario sin clave foránea, y renombrarlo tocaría 142 referencias de las que la mayoría hablan de expedientes de verdad—, pero el gate de purga y el listado por expediente ahora exigenobjeto_tipo = 'expediente'explícitamente: sin eso, una fila cuyo id es de un lote podría decidir sobre la purga de un expediente. Que los dos espacios de identificadores no coincidan «en la práctica» no es un control.El acta entra además en
CONTROL_INSTRUMENT_TIPOS: como el acta de transferencia, sobrevive al contenido y no debe postergar su disposición — y con más razón, porque su retención es permanente y sería el instrumento impidiendo el acto que él mismo documenta. La conservación permanente se materializa como 100 años, el máximo del contrato: Object-Lock exige una fecha, no un infinito; es renovable por la misma vía que las series CT.El cableado corre tras el sellado, es best-effort y nunca revierte la firma (que ya es un hecho consumado), anota el motivo si falla, y
/admin/eliminacionmuestra si el acta está protegida o solo firmada — durante una fase entera esa diferencia estuvo declarada como pendiente y no se veía.
Fase 5 — el borrado seguro (2026-08-29). Esto destruye.
POST /api/v1/eliminacion/lotes/{id}/ejecutar (USUA_PERM_TRD crud≥4) · GET /api/v1/eliminacion/lotes/{id}/lapidas (USUA_PERM_TRD)
Es el único camino del sistema que borra documentos de archivo, y no acepta ningún cuerpo: en una destrucción no hay nada que el cliente pueda parametrizar. Qué se destruye lo dice el inventario congelado al proponer; bajo qué acto, el aval. Un dry_run o un forzar serían justo los parámetros que un día alguien pasa mal.
Seis precondiciones, todas comprobadas antes de destruir el primer expediente: lote avalado, ejecutor distinto del avalador (409 separacion_de_funciones), acta existente, acta firmada, acta protegida bajo WORM (409 acta_sin_worm, T6 / AREA-06-02) e inventario congelado como única fuente. Exigir la firma y el candado puede bloquear la ejecución si signature-service o el almacén están caídos, y es deliberado — el último acto irreversible es el momento de no seguir con una constancia a medias, y sin candado el objeto firmado podría borrarse del almacén justo después de destruir lo que describe. El cableado WORM del acta es best-effort pero ya no de un solo intento: un fallo programa su reintento con backoff exponencial (worm_reconcile_proximo_at), la barrida run_once_actas_eliminacion_worm (mismo ciclo que el WORM del acta de transferencia) lo repite, agotado el umbral queda worm_reconcile_agotado con asiento de sistema, y el reintento manual POST …/acta/firmar también completa el candado de un acta ya firmada.
La cadena, y su orden: archive-service → document-service (POST /internal/documentos/purgar) → storage-service (POST /storage/internal/purgar-disposicion, que por fin da puerta a purge_for_disposition, escrito hacía tiempo y sin ningún llamante) → evento document.radicado.purged → knowledge-service purga el vector. Dentro de cada tramo, los bytes van antes que las filas: al revés quedarían objetos huérfanos en el almacén de objetos que nadie sabe ya a qué radicado pertenecían — imposibles de encontrar y por tanto de eliminar. Y los documentos van antes que el expediente: al revés, los vínculos a radicados caerían en cascada y ya no habría forma de saber qué había que destruir.
Todo-o-nada por expediente. El que falle queda intacto y sin lápida, y viaja en fallidos con su código; el lote no pasa a ejecutado mientras quede uno pendiente. Una lápida sobre bytes vivos afirmaría para siempre una destrucción que no ocurrió. El candado WORM aborta la purga entera del expediente, no la del archivo protegido.
La lápida (expediente_lapida, migración 049) se escribe antes de borrar el expediente y en la misma transacción: al revés, un fallo entre ambos dejaría el expediente destruido y sin lápida, y el silencio es indistinguible de «nunca existió». Es content-free: código, serie, fechas extremas, volumen y el acto que lo autorizó — la denominación no, que es contenido. Append-only por trigger, y sin FK a expedientes a propósito.
La migración 049 soltó tres claves foráneas, y una de ellas evitó una pérdida silenciosa.
eliminacion_lote_itemsydisposicion_decisionesbloqueaban el borrado;transferenciasiba enON DELETE CASCADEy se habría llevado por delante las transferencias y, en cascada,transferencia_acta— un instrumento de control que el propio código de storage-service ya declara que debe sobrevivir al contenido (Ley 594/2000 art. 26). Criterio único: lo que debe sobrevivir conserva elexpediente_idsin clave foránea, comoexpediente_eventosdesde siempre. Y una al revés:expediente_aippasó aCASCADEporque empaqueta el contenido y no describe nada sin él.Por qué la purga del vector usa el centinela y no un
DELETE, aunque el documento ya no exista: bajo entrega at-least-once, uncreatedreentregado después delDELETEreinsertaría el fragmento desde cero, sin marcador, y el texto del documento eliminado volvería a ser consultable. ElDELETEborraría el propio centinela que hace la purga irreversible. El centinela no conserva texto, ni embedding, ni metadatos.
Lo que sobrevive: la lápida, el acta firmada, el inventario congelado, las decisiones de disposición, la hoja de ruta del expediente (expediente_eventos), las transferencias con su acta, y el audit_log con un asiento por radicado destruido —con su número de radicado, que es lo único que queda de cada documento—.
document-service — puerto 8003¶
Requiere headers
X-Tenant-SlugyX-User-Id(inyectados por el gateway).
POST /api/v1/documents¶
Registra un nuevo radicado. Asigna número de radicado atómico.
Datos mínimos (T13 / AREA-09-02, Acuerdo AGN 060/2001 §5.2). doc_class es obligatorio en los tres tipos; Entrada e Interno exigen además dest_dept_code y Salida exige origin_dept_code. Si falta alguno: 422 (DocumentCreate y BatchDocumentCreate comparten la misma regla, app/core/datos_minimos.py; sin consumir consecutivo). Los productores internos la cumplen así: la respuesta rápida hereda origen (el destino de la Entrada) y tipo del antecedente; el radicado desde borrador deriva el tipo de la plantilla, el destino por el nombre de dest_dept y el origen de la dependencia de quien radica (ver POST /borradores/{id}/radicar); la bandeja de correo exige doc_class y dest_dept_code en el cuerpo (422 datos_minimos_incompletos).
// Request
{
"doc_type": "E",
"subject": "Solicitud de certificado de notas",
"doc_class": "Derechos de Petición",
"dest_dept_code": 120,
"sender_name": "María García",
"sender_entity": "Ciudadana",
"pages": 2,
"response_days": 15,
"nivel_seguridad": 1,
"metadata": { "area": "juridica", "prioridad": "alta" },
"anexos": [
{ "file_id": "uuid", "filename": "solicitud.pdf", "file_size": 45231, "mime_type": "application/pdf",
"tipo_anexo": "soporte", "es_principal": true, "folios": 3 }
]
}
// Response 201
{
"id": "uuid",
"tracking_number": "2024-ICETEX-E-000001",
"doc_type": "E",
"year": 2024,
"sequence": 1,
"subject": "Solicitud de certificado de notas",
"status": "registered",
"nivel_seguridad": 1,
"registered_at": "2024-01-15T10:30:00Z",
"due_date": "2024-02-05",
"dest_dept": "Registro y Control",
"dest_dept_code": 120,
"anexos": [{ "id": "uuid", "file_id": "uuid", "filename": "solicitud.pdf", ... }]
}
doc_type:E(Entrada) |S(Salida) |I(Interno)tracking_number: inmutable una vez asignadoresponse_days(opcional): plazo de respuesta en días hábiles (RF-RAD-04). Si se indica, document-service calculadue_dateconsultandotenant-service(GET /api/v1/business-days/calculate, que aplica festivos colombianos y no laborables del tenant). Si se omite,due_dateesnull.due_date: fecha de vencimiento (ISOYYYY-MM-DD) onull.origin_dept_code/dest_dept_code(opcionales; canónicos, ADR-032): código (depe_codi) de la dependencia en el organigrama. Si además llegaorigin_dept/dest_depty no es el nombre de ese código:422 dependencia_inconsistente. Solo con el nombre (alias deprecado) y sin código, un nombre que corresponde a dos dependencias es422 dependencia_ambigua; uno que no es dependencia alguna se conserva como texto (la Salida puede ir a un tercero). Si se indican, document-service los valida contratenant-service(GET /api/v1/dependencias/by-codigo/{codigo}) y denormaliza el nombre de la dependencia enorigin_dept/dest_dept(snapshot legal). Código inexistente o dependencia inactiva →400 invalid_dependencia.- Si
tenant-serviceno está disponible al resolver plazo o dependencia, la creación responde502 tenant_service_unavailabley no se radica el documento. metadata(opcional): metadatos variables del tipo documental (ADR-007). Si eldoc_classtiene una plantilla activa, los valores se validan contra su JSON Schema antes de radicar; si no cumplen →422 metadata_validation_failedy no se radica; el sobre del 422 llevafield(ruta con puntos de la propiedad que incumple, p. ej.ubicacion.ciudadoetiquetas.1; ausente si el fallo es de la raíz) para que la pantalla marque el control (POST /expedienteslo lleva igual). Sin plantilla, se almacenan tal cual. La columna está indexada con GIN. Claves reservadas (B6):from_borrador,canalyconsentimientoson procedencia del sistema (la última es la constancia de la Ley 1581): el cliente no puede aportarlas ni porPOST /documents, ni porPATCHdemetadata, ni porrespuesta, ni por borrador (422); el sistema las pone porprocedencia(ventanilla, borrador) y elPATCHdemetadatalas preserva tal cual aunque reemplace el resto.doc_classdebe ser un código activo del catálogotipos_documentales(422 tipo_documental_desconocido, antes de consumir el consecutivo; lo mismo al retipificar porPATCHy en cada ítem del lote). El asientodocument.radicado_createdllevafrom_borradorcuando el radicado nace de un borrador. Origen de la Salida (B6): endoc_type=Scon actor humano,origin_dept_codedebe ser una dependencia donde el actor tiene URD activa (422 origen_fuera_de_sus_dependencias); el límite vive enDocumentService.createy cubrePOST /documents, respuesta rápida, borrador y cada ítem del lote (el ítem falla antes de gastar consecutivo). ElPATCHsolo exige tipo activo si eldoc_classcambia (reenviar el mismo tipo, aunque esté inactivo, no da 422). La importación INT-05 no guardafrom_borrador/canal/consentimientodel ZIP como claves de primer nivel: pasan ametadata.procedencia_origen.nivel_seguridad(opcional, default1): clasificación de seguridad del radicado (RF-SEG-08) —1=PUBLICA,2=RESERVADA,3=CLASIFICADA (=security_levels.code). La búsqueda oculta a cada usuario los radicados por encima de su clearance (ver Full-Text Search).
Control de acceso (catálogo de metadatos): las mutaciones —
POST /api/v1/metadata/templatesyPOST/PATCH/DELETE /api/v1/metadata/elements— exigenUSUA_PERM_TRD(fail-closed:403sin tenant/user-id/permiso), el mismo permiso que gobierna su gemeloPOST /api/v1/expediente-metadata/templatesen archive-service (una plantilla que relaja la validación obligatoria de un tipo documental es tan consecuente como la de un expediente).DELETE /metadata/elements/{id}exige ademáscrud≥3(RF-SEG-03; precedenteDELETE /tipos-documentales/{id}). Las cuatro escrituras dejan asiento enaudit_log. Las lecturas GET quedan abiertas (solo tenant).
POST /api/v1/metadata/templates¶
Define la plantilla de metadatos (JSON Schema) de un tipo documental. Una sola plantilla activa por tipo_documental. Exige USUA_PERM_TRD.
// Request
{
"tipo_documental": "Factura",
"version": 1,
"activo": true,
"json_schema": {
"type": "object",
"required": ["valor"],
"properties": { "valor": { "type": "number" } }
}
}
422 invalid_json_schemasijson_schemano es un JSON Schema válido;409 conflictsi ya existe esa versión o una activa para el tipo.
GET /api/v1/metadata/templates¶
Lista plantillas; filtro opcional ?tipo_documental=Factura.
/api/v1/metadata/elements (catálogo de elementos de metadato, RF-MET-03)¶
CRUD de definiciones reutilizables de campos de metadato (POST/GET/GET {id}/PATCH {id}/DELETE {id}). Atributos: clave, etiqueta, tipo_dato (text/number/date/boolean/select), longitud, ocurrencia_min/max, modificable, valor_default, opciones, orden, searchable, mapeo_dublin_core (gancho RF-MET-09). 409 si la clave ya existe. POST/PATCH exigen USUA_PERM_TRD; DELETE exige USUA_PERM_TRD con crud≥3.
GET /api/v1/documents/{id}/historial-acceso¶
Quién leyó y quién descargó este radicado (RF-BUS-10; Ley 594/2000 art. 22). Paginado (page, size) con X-Total-Count.
Devuelve solo ts, service, actor, actor_nombre, canal, action, object_ref. No payload —lleva nivel_seguridad y el file_id del anexo, y no se filtra al serializar: no se lee de la base, que es la única forma de que no se escape el día que alguien le añada un campo— ni id/prev_hash/hash, que son la mecánica de la cadena y viven en /admin/auditoria.
- Solo ACCESOS, por lista de admisión:
document.radicado_consultado(abrió la ficha) ydocument.anexo_descargado(bajó un fichero). Crear, anular o reclasificar son actos sobre el documento, no lecturas de él; mezclarlos convertiría esto en un historial de todo y enterraría lo que se viene a buscar. Es la misma lista que usa archive-service para el historial del expediente — dos listas que respondieran distinto a «¿esto es un acceso?» darían dos historiales del mismo hecho. - No-read-up ANTES de tocar
audit_log: un radicado por encima del clearance responde404 document_not_found, el mismo cuerpo que un id inexistente. Un 403 sería una confirmación: quien no puede ver el radicado sabría que existe y cuánta gente lo ha mirado. - Solo se registran los canales de persona (
api,ui,mcp); las lecturas internas del sistema no ensucian la pista. actor_nombrees elusername, resuelto aquí. El asiento guarda el identificador interno y así debe seguir —unusernamecambia y un asiento no se toca nunca—, pero una respuesta a «quién ha visto esto» hecha de UUID no responde. El JOIN esLEFTy por texto:actorno siempre es un usuario del catálogo, y castearlo auuidharía fallar la consulta entera por una fila que ni se estaba preguntando. Sin nombre resoluble viajanully la pantalla cae al identificador — decir menos es aceptable, inventar un nombre no.actorsigue viajando: es lo que permite cruzar este historial con/admin/auditoria.
La traza existía desde siempre. Hasta 2026-09 solo se podía consultar desde /admin/auditoria —que exige USUA_PERM_ADMIN y saber por qué acción filtrar— o, si el radicado estaba en un expediente, desde el historial de ese expediente; un radicado suelto, que son la mayoría, no tenía respuesta.
GET /api/v1/documents¶
Listar radicados. Filtros: ?doc_type=E&status=registered&page=1&size=20. Filtro por metadato (RF-MET-02): ?meta.<campo>=valor (uno o varios) usa contención JSONB sobre el índice GIN, p. ej. ?meta.area=juridica. Control de acceso (RF-SEG-08): el listado se acota por la clasificación del radicado igual que la búsqueda — cada usuario solo ve los de nivel_seguridad ≤ su clearance (fail-closed a PUBLICA).
Ubicación actual (ADR-029). ?ubicacion_dept_code= (1-999999) filtra por dónde está ahora el radicado: la dependencia donde lo dejó el flujo (repartir, devolver, revertir, reasignar) o, si el flujo nunca lo movió, la de radicación. No es dest_dept_code, que es a quién iba dirigido el documento y no cambia al repartir. El filtro vive en la misma condición que las filas y el COUNT (junto al clearance): X-Total-Count nunca cuenta lo que la página no puede mostrar. Cada radicado trae flujo_dept_code/flujo_dept (lo que escribió el flujo; null si nunca lo movió), ubicacion_dept_code/ubicacion_dept (la que hay que leer: flujo_* si existe, si no la de radicación) y dest_dept/dest_dept_code (la de radicación, instantánea legal que no cambia). ubicacion_dept_code es null cuando el nombre del paso no casa con una sola dependencia activa (el nombre queda en ubicacion_dept). El poseedor (flujo_usuario) no se devuelve en los listados.
status es repetible: ?status=distributed&status=in_progress trae los de cualquiera de los dos (status = ANY(...)). Una bandeja de trabajo pendiente no es un estado, son varios; pedirlos por separado da dos ventanas de size que no se pueden ordenar entre sí, y obliga a sumar dos X-Total-Count — un total correcto solo mientras los estados sean disjuntos.
Plazo de respuesta (RF-RAD-04): ?due_from=2026-01-01&due_to=2026-03-31 acota por due_date, con ambos extremos inclusivos. El endpoint no decide qué significa «vencido» y lo declara quien pregunta: due_to=<ayer> deja fuera el que vence hoy, due_to=<hoy> lo incluye. Es deliberado — el sistema usa hoy las dos convenciones opuestas (la TRD cuenta vencido el día en que se cumple el plazo, los préstamos no), y hornear una aquí obligaría a la otra mitad a pedir lo contrario de lo que quiere decir. Un radicado sin due_date no entra en ningún rango: la pregunta no le aplica.
Orden: ?order_by=registered_at|due_date&order_dir=asc|desc (por defecto registered_at desc). Cualquier otro valor es 422 — el catálogo es cerrado porque un ORDER BY no admite parámetro enlazado. due_date lleva NULLS LAST en los dos sentidos, y todos los órdenes desempatan por id: sin una clave única final, dos filas con el mismo plazo pueden salir en distinto orden en dos consultas y la paginación repetiría una y perdería otra.
Sin orden por plazo, un informe de vencimientos solo alcanzaba los radicados más recientes: el que llevaba más tiempo vencido se radicó antes, así que salía después. Era invisible por construcción.
GET / PATCH /api/v1/documents/{id}/disposition¶
Metadatos de disposición del documento (RF-MET-08): programa, retention_until, accion (conservar/eliminar/transferir/seleccionar), confirmado, marcado_eliminacion, fundamento. PATCH hace merge de los campos provistos. GET exige USUA_PERM_EXPEDIENTE; PATCH exige además crud≥3 — antes bastaba el nivel de lectura para marcar un documento oficial para destrucción, mismo permiso y mismo nivel que para consultarlo (mismo umbral que las disposiciones de storage-service).
fundamento es obligatorio cuando accion="eliminar" o marcado_eliminacion=true (422 si falta): la eliminación es irreversible (Ley 594/2000 art. 24) y debe quedar motivada. No se exige en el camino no destructivo, para que el campo no se devalúe a base de rellenarlo por rutina. Todo PATCH deja asiento document.disposition_set en audit_log con el estado anterior, en la misma transacción que la escritura.
Alcance declarado: la disposición no se deriva todavía de la TRD. La tabla de retención vive en
archive-servicey el radicado no guarda su serie, así que derivarla exige un cambio de modelo y un contrato entre servicios; el fundamento y el asiento son, mientras tanto, el control compensatorio.
Control de acceso (RF-SEG-08): 404 tanto si el radicado no existe como si supera el clearance del llamante — indistinguibles, para no revelar la existencia de material clasificado.
POST /api/v1/documents/{id}/publicacion¶
Acto de publicación de oficio (Ley 1712/2014 art. 12). Cuerpo: {publicar: bool, fundamento: str} — el fundamento es obligatorio en ambos sentidos (422 si falta), porque retirar una publicación también es una decisión que hay que poder explicar. Respuesta: {tracking_number, publicable, publicado_at}.
Exige PERM_RECLASIFICAR con crud≥3: el mismo permiso que decide la reserva, porque publicar y clasificar son las dos caras de la competencia sobre el régimen de acceso del documento; repartirlas entre permisos distintos permitiría que quien no puede desclasificar publicara igualmente.
Antes de este endpoint, «público» se deducía: un radicado con nivel_seguridad=1 y no anulado era cosechable por OAI-PMH y servible por CMIS, es decir, la ausencia de reserva se leía como decisión de publicar. Ahora la columna publicable arranca en FALSE y solo cambia por este acto, que queda asentado (document.publicacion_activada / document.publicacion_retirada).
El recorte público exige las tres condiciones a la vez (publicable AND nivel_seguridad = 1 AND status <> 'anulado', chokepoint único en oai_repository._PUBLIC), y el UPDATE exige además nivel_seguridad = 1 en su propio WHERE: marcar como publicable un documento reservado no es un caso a filtrar después, es una operación que no debe existir — si se guardara, la próxima desclasificación lo publicaría sin que nadie lo hubiera decidido. 404 neutro para «no existe», «supera tu clearance», «no es de nivel público» y «está anulado».
Compatibilidad: los radicados existentes quedan no publicables tras la migración
019_radicado_publicable.sql. Una institución que use los canales de cosecha debe publicar expresamente lo que quiera difundir.
Lectura del estado. publicable y publicado_at viajan en DocumentResponse — es decir, en GET /documents/{id} y en el listado. Añadirlos no fue cosmético: el acto se podía ejecutar pero no consultar, así que ni la interfaz podía ofrecerlo sobre un estado conocido ni la entidad acreditar qué había publicado. Un esquema de publicación que no se puede leer no es un esquema, es una escritura ciega.
Interacción con la reserva (corregido tras auditoría, 2026-08-13): clasificar un radicado (PATCH /{id}/security-level a nivel ≥ 2) retira su publicación en la misma transacción — antes la marca quedaba latente y la siguiente desclasificación, hecha con otro propósito, lo devolvía a los canales sin acto. Y el UPDATE exige nivel_seguridad = 1 solo al publicar: retirar una publicación nunca está bloqueado, ni siquiera sobre un radicado ya reservado, que es donde más urge. El recorte de contenido de CMIS (getContentStream) exige ahora publicable igual que el de metadatos: llevaba una copia del WHERE escrita a mano que no se actualizó.
Pantalla: el acto se ejecuta desde el drawer «Publicación» de /bandeja (ver docs/es/pantallas.md), que ofrece siempre el acto contrario al estado vigente y no ofrece formulario alguno sobre un radicado reservado o anulado — el 404 neutro del backend es correcto, pero dejar que alguien redacte un fundamento para recibir después «no existe» sería una pantalla que engaña.
Control por anexo y exclusiones (T13 / AREA-07-12, migración 040). Publicar el radicado no publica sus soportes: el cuerpo admite anexos_publicables: [anexos.id] (por omisión, ninguno) y CMIS (getContentStream) solo sirve un anexo con anexos.publicable = TRUE de un radicado publicado (oai_repository.get_public_anexo). El acto deja el conjunto en un estado conocido (marca los indicados y desmarca el resto; un anexo ajeno al radicado → 422 anexo_no_pertenece y revierte todo), la respuesta añade anexos_publicados, el asiento lleva anexos_publicables, retirar los desmarca todos y clasificar (nivel ≥ 2) también. No se publica de oficio (404 neutro, sin asiento) lo que entró por la ventanilla pública (metadata.canal = 'ventanilla_publica') ni lo tipificado con un tipo documental admite_ventanilla_publica (información de terceros, Ley 1581): se lee en vivo, así que sigue la configuración de la entidad. La migración conserva la difusión de los anexos de radicados que ya estaban publicados.
GET /api/v1/assistant/alcance¶
Marco de alcance del llamante: qué puede hacer, qué puede consultar y qué no puede — con su vía alternativa. Sin permiso propio: describe las capacidades de quien pregunta, resueltas de su token.
Existe porque los permisos venían siendo un filtro silencioso: el catálogo escondía lo que el usuario no podía usar, y el límite se descubría fallando —o esperando una acción que nunca iba a ocurrir—. El marco lo declara antes de empezar y ofrece la salida cuando la hay («no puede enviar, pero puede trasladar el radicado a correspondencia»).
Se deriva, no se mantiene: sale del catálogo de tools —que declara permiso, nivel, operación de dominio y vía alternativa— cruzado con /auth/me, /auth/clearance y /auth/context. Añadir una tool actualiza el marco sola.
Incluye limites_declarados: lo que el sistema no distingue, para no prometer precisión inexistente — el permiso de radicación no separa por tipo de documento (PERM_RADI_ENTRADA/PERM_RADI_INTERNO existen y ningún gate los usa) y las lecturas se acotan por nivel de seguridad, no por dependencia.
Vive bajo /api/v1/assistant/ porque es el único prefijo del mcp-server que el gateway enruta. No exige que el asistente esté disponible: saber qué se puede hacer no depende de que haya un modelo detrás.
POST /api/v1/expedientes/{id}/legal-hold¶
Declara o levanta el legal hold (freeze de litigio) de los objetos WORM del expediente (AREA-08-07; Ley 594/2000 art. 4, Ac. AGN 001/2024 4.3.2.6). Cuerpo {activo: bool, motivo (≥10), acta_ref} (campos extra → 422) → {expediente_id, code, activo, objetos_afectados, objetos_sin_cambio}. USUA_PERM_ADMIN con crud≥3. Motivo y acta son obligatorios en ambos sentidos. Expediente inexistente o por encima del clearance → 404 neutro; sin objetos WORM protegidos → 404 sin_objetos_protegidos; storage caído o preservación apagada → 503 storage_unavailable (no se asienta nada en archive). Asiento propio archive.expediente_legal_hold_fijado|levantado (con motivo, acta y recuentos) además de los de storage (ver legal-hold-internal).
POST /api/v1/expedientes/{id}/publicacion¶
Acto de publicación de oficio del expediente (Ley 1712/2014 art. 12, migración archive 042). Mismo contrato que el gemelo de radicados: {publicar: bool, fundamento: str} → {code, publicable, publicado_at}, PERM_RECLASIFICAR con crud≥3, fundamento obligatorio en ambos sentidos, acto y asiento en una sola transacción.
Cierra la asimetría que destapó la auditoría cruzada: la corrección del art. 12 se había aplicado a los radicados y no aquí, así que oai_repository._PUBLIC de archive-service seguía siendo nivel_seguridad = 1 y el OAI EAD multinivel difundía de oficio todo expediente no clasificado — el valor por defecto. La asimetría era además implícita: quien leyera el recorte de document-service supondría que este servicio hace lo mismo.
Dos diferencias con radicados, deliberadas:
- El recorte no lleva condición de estado. Un radicado anulado está retirado de la circulación; un expediente
closed/transferredno lo está — son estados normales de su ciclo de vida. Copiarstatus <> …por simetría habría despublicado en silencio todo el archivo cerrado, que es la mayor parte de un fondo. - Lo que se difunde es de nivel agregado: código, nombre, descripción, fechas extremas y el número de radicados. Los
tracking_numberhijos no se enumeran nunca (un expediente público puede contener radicados reservados).
publicable y publicado_at viajan en ExpedienteResponse. Compatibilidad: los expedientes existentes quedan no publicables tras la migración 042.
Pantalla: drawer «Publicación» en /expedientes/{id}. Sobre un expediente reservado no ofrece publicar —y lo explica— pero sí ofrece retirar, que es la asimetría real del backend.
GET /api/v1/documents/{id}¶
Obtener por UUID. Incluye lista de anexos. Control de acceso (RF-SEG-08): si el radicado supera el clearance del llamante, responde 404 (no revela su existencia). ADR-029: incluye la ubicación actual (flujo_dept_code, flujo_dept, ubicacion_dept_code, ubicacion_dept) y, solo aquí y en by-tracking, flujo_usuario (auth_users.id de quien tiene el paso; null si el paso es de una dependencia sin dueño). Nada de esto aparece en las superficies públicas (verificación por código, OAI-PMH, CMIS).
GET /api/v1/documents/by-tracking/{tracking_number}¶
Obtener por número de radicado (ej: 2024-ICETEX-E-000001). Misma acotación por clearance que el detalle por UUID (404 si excede el nivel del llamante).
PATCH /api/v1/documents/{id}¶
Actualizar subject, dest_dept, observations, pages, doc_class y metadata.
tracking_number, doc_type, year, sequence son inmutables.
Corregir el destino (ADR-029, ADR-032) se envía como dest_dept_code (canónico; dest_dept es el alias deprecado) y el par (dest_dept, dest_dept_code) se reescribe entero y desde el catálogo en el mismo UPDATE (cambiar solo uno dejaba el código de otra dependencia): código inexistente o inactivo y nombre desconocido → 422 dependencia_desconocida, nombre con dos candidatas → 422 dependencia_ambigua, código y nombre discordantes → 422 dependencia_inconsistente (antes un nombre sin candidato único dejaba el texto con código null en silencio). Sobre un radicado closed, el destino es dato histórico: 409 dest_dept_immutable.
status ya no se acepta (ADR-028): 422 status_managed_by_workflow. El estado del radicado (registered → distributed → in_progress → closed) lo mueve el flujo de trámite en la misma transacción que el paso —asignar, trasladar, devolver, revertir, reasignar, marcar leído, finalizar—, no se edita a mano. El campo no se ignora en silencio: un cliente que lo envía recibe el error y no toca la base. El estado es de solo lectura en GET /documents y se sigue pudiendo filtrar por él (?status=).
Tipificar (doc_class) exige PERM_RADI con crud >= 3; el resto del PATCH, crud >= 1. El tipo documental fija la serie TRD, y con ella la retención y la disposición final del radicado (Ley 594/2000 art. 24): un acto con esa consecuencia no puede ejercerse con permiso de lectura. El umbral se comprueba solo cuando doc_class viaja en el cuerpo, así que quien corrige un asunto o un número de folios conserva el nivel que siempre le bastó.
Retipificar revalida los metadatos. Los metadatos efectivos —los que vengan en el cuerpo, o los que el radicado ya tuviera— se validan contra la plantilla activa del tipo nuevo antes de escribir nada; si no encajan, 422 metadata_validation_failed y no se toca el radicado. Un documento no puede quedar tipificado como X arrastrando los campos variables de Y. metadata se reemplaza entero, no se fusiona, y la versión de plantilla usada se reescribe siempre que haya revalidación —incluido el NULL cuando el tipo nuevo no tiene plantilla activa—, para que el radicado no siga apuntando al esquema del tipo anterior. Enviar solo metadata también revalida: si no, la validación de la radicación sería puenteable con un PATCH posterior.
Control de acceso (RF-SEG-08): 404 si el radicado no existe o supera el clearance del llamante. El gate es imprescindible porque la respuesta devuelve el radicado completo (asunto, remitente, anexos): sin él, la escritura sería también una lectura.
409 radicado_anulado: un radicado anulado no admite modificaciones. Hasta ADR-028 status aceptaba registered, así que sin este guard un PATCH deshacía una anulación aprobada por el circuito de dos pasos, sin causal ni aprobador. El desanulado, si procediera, es un acto propio — no el efecto lateral de editar un campo. Es un 409 y no un 404 porque en ese punto el radicado ya pasó el gate de clearance: decir que está anulado no revela nada que el llamante no pudiera leer.
Auditoría: deja asiento document.radicado_actualizado con el antes y el después de cada campo tocado, en la MISMA transacción que la mutación — si el asiento falla, la modificación revierte.
PATCH /api/v1/documents/{id}/security-level¶
Reclasificación del nivel de seguridad de un radicado ya radicado (E08, paridad con el Cambio Nivel de Seguridad de Orfeo). Cambia nivel_seguridad (1=PUBLICA, 2=RESERVADA, 3=CLASIFICADA) sin alterar contenido ni tracking_number (inmutabilidad intacta). Requiere permiso PERM_RECLASIFICAR.
- Body:
nivel_seguridad(1..3, obligatorio),motivo(string no vacío, obligatorio — el acto de reserva debe motivarse, Ley 1712 art.19/28), y opcionalescausal_reserva,fundamento_juridico,plazo_reserva_meses. - Control de acceso (RF-SEG-08): no-read-up — si el clearance del llamante es menor que el nivel actual del radicado responde
404neutro (no revela existencia ni nivel); no-write-up — no puede fijar un nivel mayor que su propio clearance (403). Si el nivel solicitado coincide con el actual, es no-op idempotente (sin auditoría ni evento). - Traza: registra en el
audit_loginmutable (document.radicado_reclassified) elnivel_anterior,nivel_nuevo,motivoy el fundamento jurídico, de forma atómica con el cambio. - Outbox transaccional (T13 / AREA-09-05, migración
042): el evento ya NO es best-effort. Se inserta eneventos_salientes(por tenant) en la misma transacción que elUPDATEdel nivel y el asiento: si alguna falla, no existe ninguno de los tres. Tras el commit se publica por el camino rápido y se marcaenviado_at; si Redis cae justo ahí, un relé del barrido (barrido.py, fase 4,SKIP LOCKED, pasada la gracia de 60 s) lo reenvía con el mismoevent_id/dedup_key(entrega at-least-once; los consumidores ya deduplican). Unareclassifiedreenviada lleva elnivel_nuevovigente deradicados.nivel_seguridady no el de cuando se encoló, para que un reenvío tardío no baje el nivel que otra reclasificación ya subió. El mismo mecanismo cubredocument.radicado.createdydocument.radicado.annulled, y emitedocumento.contenido_cambiado(E23, AREA-09-16) cuandoPATCH /borradores/{id}cambia la huella del texto (no por cambiar solo el asunto): payload{borrador_id, radicado_padre_id, cuerpo_sha256, cuerpo_sha256_anterior, version}; es limpieza de bandejas —la garantía síncrona sigue siendo el409 documento_modificadode signature-service—. El doble control para desclasificar y un barrido de reconciliación en knowledge siguen pendientes. - Propagación: emite
document.radicado.reclassified; el signature-service re-sincroniza el snapshotnivel_seguridadde las cadenas de firma del objeto (cierra el residual de staleness de RF-SEG-08).
GET /api/v1/documents/{id}/anexos/{anexo_id}/download¶
Descarga de un anexo mediada por document-service, que es la decisión de fondo: storage-service queda sellado (su contenido solo se alcanza con X-Internal-Token) y el no-read-up lo decide quien conoce el radicado y su nivel de clasificación. La alternativa —denormalizar el nivel de seguridad hacia storage— habría creado una segunda fuente de verdad que se desincroniza en silencio con cada reclasificación.
Terceros y actores de la comunicación (E01, RF-RAD-05) — document-service¶
GET /api/v1/terceros?q=&tipo=&limit= · POST /api/v1/terceros · GET /api/v1/terceros/radicado/{id}/actores · POST /api/v1/terceros/radicado/{id}/actores · GET /api/v1/terceros/divipola/departamentos · GET /api/v1/terceros/divipola/municipios?departamento=&q= · GET /api/v1/terceros/duplicados?limit= · GET /api/v1/terceros/{id}/radicados?limit= · POST /api/v1/terceros/{id}/fusionar
Los actores viajan en la radicación. POST /api/v1/documents acepta actores: [{tercero_id, rol, principal?, canal?, …contacto}] y los congela dentro de la misma transacción que crea el radicado y su asiento: un tercero_id inexistente revierte la radicación entera (404), en vez de dejar un radicado sin remitente con el número ya consumido. POST /terceros/radicado/{id}/actores se conserva para añadirlos después. Hasta 2026-09 ninguna pantalla llamaba a ninguna de las dos y radicado_actor estaba vacía sobre 182 radicados — el directorio no aprendía de nadie.
Administración del directorio (/admin/terceros):
GET /terceros/duplicados— fichas vivas que probablemente son la misma persona, por dos ejes que no valen lo mismo:documentoes certeza (misma cédula = misma persona) ynombre—normalizado sin tildes ni mayúsculas— es sospecha, porque puede haber homónimos. Solo informa; el sistema nunca fusiona solo. Excluye las ya fusionadas, o la lista de pendientes no bajaría nunca.GET /terceros/{id}/radicados— dónde aparece la ficha y con qué rol. Incluye los de las fichas fusionadas en ella: sin ese arrastre, consolidar dos fichas haría desaparecer de la vista los radicados de la absorbida. No-read-up (RF-SEG-08): solo se devuelven los radicados cuyonivel_seguridadalcanza el clearance del llamante (cada ítem traenivel_seguridad; la barrera de residencia del asistente recorta el asunto en 2-3); la lectura deja el asientodocument.tercero_radicados_consultado.POST /terceros/{id}/fusionar{absorbe_id}— consolida un duplicado; el de la ruta sobrevive. ExigePERM_RADIconmin_crud=3. No reescribe nada: marca al absorbido confusionado_eny nada más, porqueradicado_actores inmutable por trigger (migración 026) incluido sutercero_id— a qué ficha del catálogo se atendió a alguien también es parte de lo que ocurrió, ya que ese día existían dos. La fusión añade lo que se supo después y la resolución ocurre al leer. Devuelvevinculos_heredados. Errores:422 fusion_consigo_mismo,404 tercero_no_encontrado,409 tercero_ya_fusionado(no se encadenan: una cadena con un ciclo colgaría al lector) y409 documentos_distintoscuando las dos fichas traen número de documento —ux_tercero_documentoes UNIQUE entre las vivas, así que dos números distintos son dos personas y consolidarlas destruiría una identidad legítima; si uno está mal capturado se corrige el documento y después se fusiona. El mensaje nombra los dos números. Asientodocument.tercero_fusionadoatómico y con los nombres de ambas — la absorbida deja de aparecer en los listados, y un id suelto no se podría resolver.
Gate PERM_RADI en el propio APIRouter, por admisión: quien radica es quien registra remitentes y destinatarios, y colgarlo del router cubre también las lecturas — un catálogo de ciudadanos con nombre, documento, dirección y teléfono no es un dato público de la entidad. Vincular actores exige además crud≥3.
La regla que gobierna el modelo: identidad por referencia, datos de contacto por copia congelada. Sale de dos problemas reales del Orfeo del que deriva este sistema:
- El mismo sujeto llegaba a aparecer treinta veces en el catálogo, con direcciones distintas y variaciones de tildes o de escritura. La causa no era que faltara una restricción de unicidad: era atar la identidad a los datos de contacto, de modo que cada dirección nueva obligaba a crear un tercero nuevo.
- La dirección con la que se envió una correspondencia no se puede cambiar después. Si se actualizara la ficha, todos los envíos anteriores pasarían a decir que se enviaron a otra parte: información errónea sobre un acto hecho con un dato que era verdad entonces, sin importar que hoy sea otro.
De ahí el modelo (migración 026): tercero guarda quién es —con las cinco tipologías del requisito y su documento de identidad— y radicado_actor guarda a dónde se envió, con nombre, entidad, correo, teléfono y dirección congelados e inmutables por trigger. tercero_id viaja en el vínculo para poder responder «¿qué le hemos enviado a esta persona?» aunque su ficha cambie con los años.
Tres defensas contra el duplicado, y las tres hacen falta:
| Defensa | Dónde | Qué ataja |
|---|---|---|
| Índice único por documento de identidad | ux_tercero_documento |
Dos fichas con la misma cédula, escritas distinto |
| Búsqueda sin acentos ni mayúsculas | spanish_unaccent |
La mayoría: los duplicados no se crean por saltarse un índice, se crean por no encontrar lo que ya está |
| Único por identidad + destino | ux_radicado_actor_sin_duplicado |
El mismo destinatario, en la misma dirección, dos veces en el mismo radicado |
Nótese la tercera: deduplica por (radicado, tercero, rol, dirección, correo) y no solo por identidad. El mismo señor en dos direcciones distintas es un envío legítimo —pasa, y hay que poder hacerlo—; el mismo señor en la misma dirección dos veces es un duplicado. Añadirlo repetido es un no-op, no un error: el resultado que se quería ya se cumple.
Al vincular se puede corregir el contacto para ese envío sin tocar el catálogo: al radicar se busca a alguien y se le ajusta el correo o la dirección, porque pasa. Ese ajuste es lo que se congela; actualizar la ficha es un acto aparte y explícito, y no arrastra nada hacia atrás. El asiento de auditoría anota cuántos actores se congelaron con un contacto distinto al de la ficha, que es la señal de que alguien cambió una dirección al radicar.
El alta es idempotente por documento: si ya existe un tercero con esa cédula se devuelve ese, con creado=false, en vez de un conflicto — de paso cierra la puerta al duplicado por reintento.
El canal de envío es una condición material, no una preferencia. Un destinatario se atiende por correo electrónico, en físico o por los dos, y cada canal exige su dato: sin correo no hay envío electrónico posible, sin dirección no hay envío físico posible. Lo imponen dos CHECK —un envío imposible no se puede ni registrar—, porque dejarlo anotado haría que el radicado prometiera algo incumplible y el fallo aparecería al despachar, cuando ya no hay a quién preguntarle el dato.
canal = NULL significa todavía sin decidir, y se admite a propósito: obligar a elegirlo al vincular empujaría a poner un canal cualquiera para poder seguir, que es peor que decir que no se sabe. Se puede fijar después, y solo si el dato está.
La comprobación va sobre la copia congelada, no sobre la ficha: el catálogo puede tener un correo que no se aportó a este envío, y despachar por un dato que no viaja en el radicado sería enviar a un sitio que el radicado no dice. El 422 nombra al destinatario y el dato que falta — uno genérico sobre una lista de veinte obliga a buscarlos de uno en uno.
La geografía se ELIGE, no se escribe — cascada DIVIPOLA (2026-08-30)¶
Las tres defensas de arriba atajan el duplicado del sujeto. Quedaba abierta la otra mitad, en el destino: mientras municipio y departamento fueron texto libre, «Bogotá», «BOGOTA», «Bogota D.C.» y «Santa Fe de Bogotá» eran cuatro municipios distintos para cualquier consulta, cualquier reporte y cualquier exportación. Una restricción de unicidad no lo cierra, porque las cuatro escrituras SON distintas y todas pasan. Lo único que lo cierra es que el municipio deje de ser algo que se escribe.
GET /terceros/divipola/departamentos devuelve los 33 departamentos; GET /terceros/divipola/municipios?departamento=05 los de ese departamento, con q opcional que ignora acentos (quien escribe «monteria» encuentra «Montería»). El departamento es obligatorio para pedir municipios: los 1.135 de golpe serían la lista que nadie recorre. Ambos heredan el gate PERM_RADI del router.
El catálogo (migraciones globales 003 + 004, schema public) es nacional: no lo edita la entidad, no varía entre entidades, y replicarlo por schema sería copiar un dato que nadie puede divergir. Son 1.122 entidades vigentes —1.103 municipios, 18 áreas no municipalizadas y 1 isla— tomadas de la fuente oficial del DANE (Datos Abiertos, recurso gdxc-w37w, consultado el 2026-08-30). Actualizarlo es un INSERT ... ON CONFLICT sobre las mismas tablas, y la carga es idempotente para eso. Un municipio suprimido no se borra —hay envíos históricos que lo nombran— sino que se marca vigente = false, y así desaparece de la cascada sin dejar de resolver el nombre de un envío pasado.
La
003cargó el catálogo del volcado del Orfeo de origen y no era el del DANE. Se verificó contra cuatro casos conocidos, los cuatro daban bien, y el resto no se comprobó: escondía 38 diferencias, entre ellas Pasto colgado de Antioquia con el código que pertenece a Alejandría, 18 códigos que DIVIPOLA no reconoce, y 5 municipios ausentes —Tuchín y Nuevo Belén de Bajirá, este incorporado por el DANE en 2023—. La004corrige desde la fuente oficial; no se reescribió la003porque el libroschema_migrationsidentifica por nombre de archivo y no guarda hash, así que un despliegue que ya la aplicó se habría quedado con el catálogo malo en silencio.
Dos garantías que impiden la incoherencia silenciosa (migración 027):
| Garantía | Cómo | Qué evita |
|---|---|---|
| El código de municipio lleva dentro el de su departamento | CHECK (left(codigo,2) = departamento_codigo) |
Un municipio colgado de otro departamento — el error que una cascada mal programada produce sin avisar |
| El departamento se deriva del municipio, no se acepta del cliente | Capa de servicio | Aceptar una contradicción que luego hay que detectar |
Y una asimetría deliberada entre las dos tablas: en tercero los códigos llevan clave foránea al catálogo —la ficha viva debe apuntar a un municipio que existe—; en radicado_actor no la llevan. Esa fila es la copia congelada de a dónde se envió y tiene que sobrevivir a que el catálogo cambie, incluido que el municipio deje de existir: atarla convertiría una reorganización territorial en una corrupción del registro histórico. Es la misma razón por la que el contacto se copia en vez de leerse por referencia.
El nombre que se guarda pasa a ser el oficial del catálogo, no el tecleado, así que la búsqueda, los reportes, el índice electrónico y la exportación —que siguen leyendo las columnas de texto— dejan de ver cuatro escrituras del mismo municipio sin que ninguno tenga que cambiar. El código es opcional: una dirección en el extranjero no tiene DIVIPOLA y las fichas anteriores al catálogo siguen siendo válidas — exigirlo habría cerrado la ventanilla por un dato de presentación. Por lo mismo, el municipio entró en la deduplicación por identidad+destino: «Calle 10 #5-20» existe en medio país, y sin él dos envíos legítimos a esa dirección en dos municipios se rechazaban como duplicado.
sender_nameysender_entityNO se tocan. Cuarenta y tres archivos las leen (búsqueda, reportes, exportación, índice electrónico) y arrancarlas de golpe convertiría una mejora en una migración de riesgo. El modelo nuevo es aditivo.
storage-service — puerto 8005¶
Requiere header
X-Tenant-Slug. Archivos almacenados en bucket del almacén de objetosorpycamcp-{slug}-documents.
POST /api/v1/storage/internal/artefacto¶
Interno (X-Internal-Token, no enrutado por el gateway). Sube un artefacto que produce el propio sistema —hoy, el XML del índice electrónico firmado— con su propia lista de formatos (allowed_system_mime_types, application/xml). Es un camino distinto del de los anexos a propósito: la lista blanca de /upload acota lo que entra desde fuera (RF-DIG-04) y no admite XML, así que el índice firmado no se podía almacenar.
POST /api/v1/storage/upload¶
Sube un archivo (multipart/form-data, campo file).
- Calcula SHA-256; la deduplicación es solo del MISMO autor (AREA-08-09): si quien sube ya había subido ese contenido, retorna su registro con
200. Si el contenido existe como anexo de otro usuario (o de un anónimo/sistema), se crea una fila nueva con sufilenamey su autor y se responde201: antes la respuesta200confirmaba que un contenido adivinado existía como anexo reservado y devolvía sufilenameoriginal y suid. Sin autor identificado (ventanilla pública, artefactos del sistema) no se deduplica nunca - Crea el bucket del tenant si no existe
- Retorna
201en upload nuevo - Valida el formato contra la lista de MIME admitidos (
ALLOWED_MIME_TYPES, configurable;*= todos) →415 unsupported_media_typesi no está permitido (RF-DIG-04)
// Response 201
{
"id": "uuid",
"filename": "solicitud.pdf",
"mime_type": "application/pdf",
"file_size": 45231,
"sha256": "e3b0c44298fc...",
"uploaded_at": "2024-01-15T10:30:00Z"
}
GET /api/v1/storage/files/{file_id}¶
Metadata del archivo (sin contenido).
GET /api/v1/storage/files/{file_id}/content¶
(Interno, servicio-a-servicio; no alcanzable desde el gateway — X-Internal-Token.) Devuelve el flujo de bytes del archivo para que otro servicio lo procese sin pasar por el navegador (lo usa document-service al mediar la descarga de un anexo, y el empaquetado del AIP). Es la ruta que permite que la mediación de la descarga exista: storage queda sellado y el no-read-up se decide en quien conoce el expediente.
GET /api/v1/storage/files/{file_id}/download¶
Retorna URL pre-firmada del almacén de objetos válida 1 hora.
{
"url": "http://minio:9000/orpycamcp-icetex-documents/uuid/solicitud.pdf?X-Amz-...",
"expires_in": 3600
}
GET /api/v1/storage/files/{file_id}/verify¶
Reverifica la integridad (RF-DIG-02): descarga el objeto del almacén de objetos, recalcula el SHA-256 y lo compara con el registrado.
409 integrity_check_failedsi el objeto en el almacén de objetos no coincide con la huella registrada (alerta de integridad).
POST /api/v1/storage/files/{file_id}/replace¶
Reemplaza un anexo creando una nueva versión inmutable (RF-DIG-01; multipart/form-data, campo file). La versión anterior se conserva.
201con la nueva versión (versionincrementado);200si el contenido es idéntico (no se crea versión);409 not_current_versionsi{file_id}no es la versión vigente;415si el formato no está admitido.
GET /api/v1/storage/files/{file_id}/versions¶
Devuelve la cadena de versiones del anexo (ordenada por version). Cada elemento incluye version e is_current.
DELETE /api/v1/storage/files/{file_id}¶
Elimina del almacén de objetos y de la base de datos. Responde 204.
Preservación digital (E10, OAIS/AGN 001/2024)¶
POST /api/v1/storage/internal/files/procedencia{file_id}— interno (X-Internal-Token; B2, ADR-031):{sha256, uploaded_by, referencias}de un fichero vigente (404 file_not_foundsi no existe o está borrado).referenciasson las fuentes (tabla.columna) que lo usan hoy (anexos, correo, borrador, acuse de envío, WORM…; una tabla ausente cuenta comotabla(ausente)= referenciado, fail-closed);uploaded_bypuede sernull(subida anónima o de sistema). Lo consume document-service para cotejar el acuse de un envío.-
POST /api/v1/storage/internal/files/hashes— interno (X-Internal-Token): SHA-256 de un lote de ficheros. Lo consumearchive-serviceal vincular un radicado para poblar elvalor_huellade cada documento del índice electrónico (E15 T-08, RT-15 art. 4.3.2.3). Un id ausente de la respuesta significa «no hay huella que dar» (borrado o inexistente): quien pregunta lo declara como sin huella, no lo inventa. -
GET /api/v1/preservacion/estado→{enabled: bool}— estado de instancia (PRESERVATION_ENABLED), no por expediente: no consulta ni filtra nada por expediente, así que no abre un oráculo no-read-up. Gate de lecturaUSUA_PERM_EXPEDIENTE(el mismo que el mediador del AIP). La pestaña Preservación del expediente lo consulta antes de pedir el AIP y, conenabled=false, dice «desactivada en esta instalación» en lugar del 404 neutro («sin AIP o sin visibilidad»), que con la preservación apagada hacía pasar por normal que nada se empaqueta jamás. Admitido por subruta en el gateway. GET /api/v1/preservacion/plan(vigente) ·PUT /api/v1/preservacion/plan(nueva versión) ·GET /api/v1/preservacion/plan/versions— Plan de Preservación Digital versionado:formatos_destino,formatos_origen,num_copias,periodicidad_fixity_dias,politica_migracion,contingencia.formatos_origen(tipos MIME) es la política de obsolescencia de la entidad que consume el barrido de migración (RF-PRE-04): se valida la formatipo/subtipoy se normaliza a minúsculas sin repetidos — un"msword"a secas no casaría con ningún archivo y la política aparecería como declarada sin convertir nada, así que se rechaza con422en vez de guardarse.POST /api/v1/preservacion/eventos{object_ref, tipo, resultado, hash_before?, hash_after?, detalle?}·GET /api/v1/preservacion/eventos?object_ref=(repetible:?object_ref=a&object_ref=b, hasta 200 por petición; AREA-07-15) — registro inmutable PREMIS. (Interno, sellado conX-Internal-Token, no enrutado por el gateway)POST /api/v1/preservacion/eventos-internal{object_ref, tipo, resultado, detalle?, agente?, dedup_key?}registra un evento que produce otro servicio sin una persona detrás de la petición (archive: el fixity-check de la recepción de una transferencia, AREA-07-05); es idempotente pordedup_key(201el primero,200con el existente después: la reconciliación reintenta sobre un registro append-only).tipo∈ INGESTA/FIJACION/MIGRACION/VALIDACION_PDFA/WORM/REPLICA;resultado∈ok/fallo/no_evaluado(tercer estado desde E10 C2a, migración008: la validación PDF/A con el stub por defecto reportano_evaluadohonestamente —"not evaluated" ≠ "passed"). Al empaquetar el AIP, cada documento PDF (detectado por magic bytes%PDF) emite unVALIDACION_PDFA(advisory — un PDF no-PDF/A no bloquea el AIP) + un evento PREMISvalidation. Config:PRESERVATION_PDFA_VALIDATION_ENABLED(defaultfalse),PDFA_VALIDATOR(stub|verapdf, defaultstub),PDFA_PROFILE(default2b). El validador veraPDF real (subprocess) está implementado (E10 C2b): conPDFA_VALIDATOR=verapdf+VERAPDF_BINARY(ruta absoluta al binario, presente en la imagen de deploy — la de dev degrada honestamente a stubno_evaluado),SubprocessVeraPdfValidatorejecuta veraPDF con modelo de seguridad endurecido (ENV scrubbeado,killpg+reap garantizado, semáforo, tempdir 0700, timeout→no_evaluado, JSON-only). Tuning:VERAPDF_TIMEOUT_SECONDS(60),VERAPDF_MAX_CONCURRENCY(1),VERAPDF_JVM_MAX_HEAP_MB(512),VERAPDF_JVM_MAX_METASPACE_MB(256).POST /api/v1/preservacion/aip{expediente_id, documentos:[{document_id, file_id, nombre}], indice_file_id?, retain_until? | (retencion_anios + fecha_inicio)}— (E10 Increment B, ADR-023) empaqueta el AIP OAIS real: por cada documento resuelve su objeto porfile_id, descarga los bytes del almacén de objetos, recalcula la fixity (SHA-256 vsfiles.sha256; mismatch →409+ eventoFIJACIONfallo), arma un Bag BagIt RFC 8493 (data/con los bytes +bagit.txt+bag-info.txtcon Payload-Oxum real +manifest-sha256.txt+premis.xml[PREMIS v3, namespace oficial, validado contra un perfil XSD local] +indice-firmado.xml[siindice_file_id] +tagmanifest-sha256.txt), lo serializa ZIP, lo sube al bucket WORM de preservación conRetention(COMPLIANCE, retain_until)derivada de la TRD , y lo registra enpreservacion_worm_objeto(tipo='aip'→ cubierto por el gate de disposición). SoloX-Internal-Token(D-02, AREA-08-16): no se enruta por el gateway; la llama archive-service.nombrese valida (sin/,.., control chars) y debe ser único dentro dedocumentos(422).201con{id, expediente_id, aip_sha256 (del ZIP), num_documentos, estado, bucket, object_key, aip_bytes, formato, retain_until, retention_mode, legal_hold, premis_xml, replicado, replica_target, replica_copias_hechas, created_at};400retención inválida;404/422file_idinexistente onombreduplicado;409fixity mismatch oworm_singleton_conflict(el(expediente, tipo)ya está protegido con otrofile_id; no sube nada);422si el body traelegal_hold;503siPRESERVATION_ENABLED=false. REPLICA (C1b): siPRESERVATION_REPLICA_ENABLED=truey el plan tienenum_copias≥2, tras subir el AIP primario se crea una réplica inmutable en un segundo bucket WORM (replica_target="local"= segundo bucket del mismo almacén, no copia geográfica;="cross-site"siMINIO_SECONDARY_ENDPOINT). Es best-effort no-fatal: un fallo de réplica no revierte el AIP (replicado=false+ eventopreservacion_eventoREPLICAresultado=fallo);num_copias≤1→ sin réplica.
POST /api/v1/preservacion/migrar-formato · GET /api/v1/preservacion/migraciones/{file_id}¶
Migración de formato contra la obsolescencia (RF-PRE-04; Decreto 2609/2012 art. 32). Gate USUA_PERM_EXPEDIENTE crud≥3, el mismo que proteger un artefacto: produce un artefacto nuevo sobre el archivo de un expediente.
La norma pide tres cosas y las tres son invariantes del endpoint:
- Conservar el original. La migración produce un artefacto derivado que convive con él; el de origen no se toca, no se marca y no se reemplaza. Un sistema que sustituye el original ha perdido la evidencia el día que alguien discuta si la conversión alteró el contenido.
- Registrar versión origen y destino con su cadena de procedencia (
preservacion_migracion, migración015) y su evento PREMISMIGRACION— un tipo que llevaba en el vocabulario desde el principio y que hasta ahora nadie escribía. - Verificar la integridad antes y después. El SHA-256 del origen se recalcula sobre los bytes descargados y se compara con el almacenado antes de convertir: migrar un archivo ya corrupto propagaría la corrupción a la copia de preservación y le daría además el aspecto de ser la buena. Un desajuste responde
409y deja constancia del intento — un archivo que falla la fixity es justo el que hay que ir a mirar.
Y una cuarta que la norma no dice y el oficio sí: convertir a PDF/A y no comprobar que el resultado ES PDF/A produce una copia que nadie ha validado. El derivado pasa por veraPDF, y hay tres desenlaces que no se confunden: migrado (conforme), fallido (no conforme — los bytes se descartan) y no_verificado (se convirtió pero no había validador disponible: tampoco se promueve a copia de preservación).
503si el despliegue no tiene convertidor, y no hay «migrador stub». La factory gemela del validador PDF/A degrada a un stub honesto porque «no evaluado» es un resultado real; aquí no existe el equivalente — un stub tendría que producir un archivo, y el único que puede producir sin la herramienta es uno inventado. Es la misma asimetría deliberada que la transcripción de voz. La imagen de producción instalaghostscripty declaraFORMAT_MIGRATOR=ghostscript.
El perfil se acota a las variantes b de PDF/A (1b/2b/3b, por defecto 2b): las a/u exigen texto etiquetado o Unicode garantizado, y una conversión automática no puede prometerlo. GET /migraciones/{file_id} devuelve la cadena de procedencia incluyendo los intentos fallidos: «se intentó y no se pudo» es lo que dice qué documentos siguen esperando una migración que no sale.
Puerta en la interfaz: acción «Migrar a PDF/A» por anexo en el workspace del expediente, solo sobre PDF, y el aviso de éxito dice que se creó una copia — no que el anexo «se convirtió», que es lo que se entendería si no se dijera.
Barrido periódico, y de quién es la decisión (2026-08-30)¶
Hasta aquí la conversión solo se disparaba a mano, archivo por archivo. Un archivo institucional tiene cientos de miles de ficheros: una capacidad que hay que invocar uno a uno es una capacidad construida y no ofrecida. El barrido (FORMAT_MIGRATION_ENABLED, cadencia FORMAT_MIGRATION_INTERVAL_SECONDS, diaria por defecto) recorre los tenants activos y encola lo que falte.
Qué formato está obsoleto lo declara la entidad, no este código. Es un juicio archivístico que depende del parque documental de cada institución y de su PINAR (Ac. AGN 003/2015), así que el job no tiene lista propia y sin política declarada no migra nada.
Dónde se declara, y en qué orden se lee:
- El plan de preservación de la entidad —
preservacion_plan.formatos_origen, que se escribe desde/admin/preservacion(PUT /api/v1/preservacion/plan). Es el sitio propio: queda versionado append-only y dice quién lo declaró y cuándo. - El despliegue —
FORMAT_MIGRATION_MIME_TYPES, solo si el plan de esa entidad no dice nada. Es un valor por defecto de la instalación: en una instalación de una sola entidad, quien despliega es la entidad; en una compartida, cada entidad lo sustituye declarando su plan.
Nunca al revés. Que la política viviera solo en una variable de entorno contradecía la propia regla: una variable de entorno es la misma para todas las entidades de la instalación, no la puede tocar el archivista, no queda versionada y no dice quién la puso. La comparación del tipo MIME es insensible a mayúsculas (RFC 2045 §5.1): el valor guardado es el que mandó quien subió el archivo, y un Application/MSWord fuera de la política habría dado cero candidatos sin que nada pareciera roto. Con la política sin declarar el barrido recorre los tenants y no toca un solo archivo. Es el mismo criterio que el barrido de disposición —que cuenta y avisa pero no elimina— con una diferencia que conviene tener presente: migrar no destruye; el original se conserva intacto y el derivado convive con él. Lo peligroso aquí no es convertir de más, es creer que se convirtió — por eso el barrido tampoco promueve nada que no haya pasado el validador.
fallido y no_verificado no se tratan igual, y la distinción decide qué se reintenta:
| Estado | De qué habla | Qué hace el barrido |
|---|---|---|
migrado |
Hay copia verificada | No vuelve a la cola |
fallido |
Del archivo: no se pudo convertir, o el resultado no era conforme | No se reintenta — reintentarlo cada noche cuesta un subproceso por archivo sin arreglar nada; hace falta que alguien lo mire |
no_verificado |
Del despliegue: se convirtió y no había validador con qué comprobarlo | Sí se reintenta — el día que veraPDF esté instalado, la barrida siguiente lo resuelve sola |
FORMAT_MIGRATION_BATCH_SIZE (50 por defecto) acota cada barrida: convertir es caro y sin tope la primera se comería la máquina. Lo que no entra en una barrida entra en la siguiente. Un fallo queda contenido en su archivo y una barrida fallida en su tenant; si no hay convertidor, la barrida entera se corta en el primer intento en vez de gastar una descarga por archivo para llegar al mismo 503.
GET /api/v1/expedientes/{id}/historial-acceso¶
Quién leyó y quién descargó (RF-BUS-10; Ley 594/2000 art. 22; Ley 1712/2014). Gate USUA_PERM_EXPEDIENTE + no-read-up: un expediente por encima del clearance del llamante responde 404 —no 403— antes de tocar audit_log.
Ampliado el 2026-08-29 con los accesos a los DOCUMENTOS del expediente. Hasta entonces la consulta filtraba solo por el código del expediente (object_type ∈ {expediente, expediente_indice}), mientras las dos trazas de acceso a documentos —document.radicado_consultado y document.anexo_descargado, escritas por document-service con actor, canal y nivel de seguridad— usan object_type='radicado' y object_ref=<número de radicado>. El resultado era que la pregunta «¿quién descargó los documentos de este expediente?» no tenía respuesta en la pantalla hecha para responderla, aunque el dato estuviera guardado desde el principio.
Las acciones de radicado admitidas son una lista de admisión y solo de lecturas: un radicado acumula además creación, anulación y reclasificación, y volcarlas aquí convertiría el historial de acceso en un historial de todo. Una acción nueva no entra sola — hay que decidir si es un acceso.
No abre ninguna fuga: los números de radicado ya se listan en la vista del expediente para quien supera su gate, y de cada asiento se devuelven actor, acción, instante y referencia; el payload —que lleva el nivel de clasificación del documento— no se pinta.
El recuento y la página comparten una sola condición. Si vivieran separadas, el total podría contar entradas que la página no muestra, y nadie se enteraría.
GET /api/v1/expedientes/{id}/aip(archive-service) — el AIP del expediente tal y como lo consume la pantalla de preservación; delega en storage-service y exigeUSUA_PERM_EXPEDIENTE, así que respeta el no-read-up del expediente antes de exponer el PREMIS.GET /api/v1/expedientes/{id}/preservacion/eventos(archive-service) — los eventos PREMIS del expediente (validaciones PDF/A, protecciones WORM, renovaciones, empaquetados, el fixity-check de la recepción de su transferencia), en orden. Mismo gate. AREA-07-15: ya no solo los keyed por el expediente: el servidor resuelve el conjunto deobject_ref—el expediente, el id de su AIP y losfile_idde los anexos de sus radicados vigentes y visibles para el llamante— y lo pide a storage; los eventos por fichero (validación PDF/A, migración de formato) aparecen. Si document-service no responde,503 document_service_unavailable(nunca una lista parcial presentada como completa).POST /api/v1/expedientes/{id}/aip/reintentar(archive-service, AREA-07-14) — devuelve apendienteun AIP que el job dejóagotado(el job solo lo pasa aagotado; no había camino de vuelta). ExigeUSUA_PERM_EXPEDIENTEcrud ≥ 3. Un soloUPDATEcon el estadoagotadoy el clearance del llamante en elWHERE: sobre un AIPempaquetado/omitido/pendiente, o un expediente que el llamante no alcanza, responde un404 expediente_not_foundneutro.intentosvuelve a 0 y la siguiente barrida lo empaqueta; asientoarchive.aip_reintento_manual(conintentos_previos/error_previo) en la misma transacción.200 {expediente_id, estado: "pendiente"}.GET /api/v1/preservacion/aip/{expediente_id}— AIP vigente (requiereUSUA_PERM_EXPEDIENTEmin_crud=1— exponepremis_xmlcon nombres/hashes de documentos y ubicación WORM). Los campos de ubicación WORM sonnullen filas AIP previas al Increment B (retrocompat).POST /api/v1/preservacion/proteger-indice{file_id, expediente_id, retain_until? | (retencion_anios + fecha_inicio)}— (E10 Increment A, ADR-023) protege el índice electrónico firmado (ya subido afiles, referenciado porfile_id) bajo WORM / Object-Lock (S3). Aprovisiona (idempotente) el bucket de preservación por tenantorpycamcp-{slug}-preservation(nombre solo del claim JWT), verifica fixity (recalcula el SHA-256 sobre los bytes y lo contrasta contra el hash registrado en la subida original — el llamador no declara ningún hash; mismatch →409+ eventoFIJACIONfallo, no sube), copia el objeto conRetention(COMPLIANCE, retain_until)por-objeto , registra enpreservacion_worm_objetoy emite eventos PREMISWORM/FIJACION+audit_log. La retención la aporta el caller derivada de la TRD (storage no llama a archive):retain_untildirecto oretencion_anios(1–100) +fecha_inicio(aritmética de años calendario), nunca ambos. SoloX-Internal-Token(D-02, AREA-08-16): ya no se autoriza con el JWT de un usuario y el gateway no la enruta (lista de admisión de subrutas de/preservacion/); la llama archive-service con la identidad firmada del usuario que cierra. Idempotente (E10 Inc.1 wiring):201con la ubicación WORM si es la primera protección;200con la fila existente si el índice de ese expediente YA está protegido (no re-sube — corte-circuito antes delput_object, respaldado por el índice único parcialWHERE tipo='indice'y un advisory lock de sesión por(tenant, expediente)que serializacheck→put→insertpara no duplicar objetos WORM irreversibles bajo carrera).400retención inválida (ausente/pasada/fuera de cotas o fuentes en conflicto);404si elfile_idno existe en el tenant;409fixity mismatch oworm_singleton_conflict(el(expediente, tipo)ya está protegido con otrofile_id; no sube nada);422si el body traelegal_hold;503siPRESERVATION_ENABLED=false. Frontera de confianza: la correspondenciafile_id ↔ expediente_id(quefile_idsea el índice firmado del expediente) la garantiza archive-service (único invocador); storage valida que elfile_idexista pero no modela expedientes. Efecto en disposición: mientras el índice esté bajoretain_untilfuturo olegal_hold, la disposición final del expediente queda bloqueada (la eliminación no puede ocurrir antes de expirar la retención — Ley 594/2000, Acuerdo AGN 060/2001).- (Interno, servicio-a-servicio; no alcanzable desde el gateway —
X-Internal-Tokenno está enFORWARDED_REQUEST_HEADERS)POST /api/v1/preservacion/proteger-indice-internal— gemelo interno (D-02) deproteger-indicecon el MISMO servicio/idempotencia, autenticado solo porX-Internal-Tokenen vez derequire_permission(crud≥3)y atribuido al principal de sistemaSYSTEM_RECONCILER_ID. Existe porque el cableado WORM del índice (IndexService.proteger_worm_indice, archive-service, E10 Inc.1) tiene dos orígenes: el intento síncrono best-effort tras firmar el índice al cierre (origen="close", usuario real → ruta pública) y la 3ª fase de reconciliación (run_once_worm,origen="reconciliation",SYSTEM_RECONCILER_IDsin filaauth_users→ esta ruta interna;require_permissionla rechazaría siempre). La retención se deriva de la TRD del expediente (archivo_gestion_years + archivo_central_years,fecha_inicio=cierre); fail-closed si la TRD no resuelve (no protege, marcatrd_sin_retencion, recuperable por el barrido). Espeja/signature/internal/sign-indice. POST /api/v1/preservacion/proteger-artefacto{file_id, expediente_id, tipo, retain_until? | (retencion_anios + fecha_inicio)}— (E10 Inc.2 wiring, ADR-023) forma generalizada deproteger-indice: protege bajo WORM/Object-Lock cualquier artefacto de preservación del expediente, contipo ∈ {indice,acta_transferencia,acta_eliminacion,acta_firma_elabora,acta_firma_entrega,acta_firma_recibe}(defaultindicesi se omite; los tresacta_firma_*son las firmas personales del acta —AREA-07-10, migración018de storage—, un tipo por rol porque la protección es singleton por(expediente, tipo); son instrumentos de control: no posponen la disposición del contenido, pero ellegal_holdlas cubre). MISMO servicio, fixity, retención derivada de la TRD por el caller, soloX-Internal-Token, e idempotencia que elproteger-indice, ahora por(expediente_id, tipo)(índice único parcialWHERE tipo='acta_transferencia'enpreservacion_worm_objeto, migración010) y con el advisory lock de sesión por(tenant, expediente, tipo)— dos tipos del mismo expediente (índice y acta) no colisionan pero cada uno serializa su propiocheck→put→insert./proteger-indicese conserva como alias duro que fuerzatipo="indice"sea cual sea el body (retrocompat del Inc.1). Códigos y frontera de confianza idénticos aproteger-indice. El acta de transferencia firmada (sello XAdES,transferencia_acta_firma) la cablea archive víaTransferenciaService.proteger_worm_acta(disparo síncrono best-effort tras el sello + 4ª fase de reconciliaciónrun_once_actas_worm); su retención se deriva de la TRD del expediente de la transferencia confecha_inicio = firmado_atdel acta (no el cierre — un acta puede firmarse mucho después en transferencias secundarias).- (Interno, servicio-a-servicio; no alcanzable desde el gateway)
POST /api/v1/preservacion/legal-hold-internal{expediente_id, activo, motivo (≥10), acta_ref}— (AREA-08-07) único camino para fijar o levantar el legal hold de todos los objetos WORM de un expediente.legal_holddejó de ser un campo deaip/proteger-*/renovar-retencion-internal(un freeze fijado al proteger no tenía acto inverso y bloqueaba la disposición final para siempre;422si llega). Llama aenable/disable_object_legal_holdsobre laversion_idexacta de cada fila (fuera de la tx) y, en una mismaconn.transaction(), actualizapreservacion_worm_objeto.legal_holdy asientaworm.legal_hold_fijado/worm.legal_hold_levantado(conmotivoyacta_ref). Idempotente: las filas que ya están en el estado pedido no se tocan.200 {expediente_id, activo, objetos_afectados, objetos_sin_cambio};404 worm_object_not_found;422sin motivo;503siPRESERVATION_ENABLED=false. SoloX-Internal-Token. Orden de los pasos: fijar = almacén (primaria y réplica del AIP) → fila + asiento en una tx; levantar = asientoworm.legal_hold_levantamiento_solicitado→ almacén → fila +worm.legal_hold_levantado(worm.legal_hold_levantamiento_fallidosi el almacén falla; la fila sigue bloqueando y el acto es reintentable). Serializado por advisory lock(tenant, expediente); la ausencia de réplica se tolera. El asiento incluyecopias_alcanzadas. Lo orquestaPOST /api/v1/expedientes/{id}/legal-hold(archive-service). - (Interno, servicio-a-servicio; no alcanzable desde el gateway)
POST /api/v1/preservacion/proteger-artefacto-internal— gemelo interno deproteger-artefacto(contipoen el body), autenticado solo porX-Internal-Token, atribuido aSYSTEM_RECONCILER_ID. Lo usa la reconciliación desatendida del acta (run_once_actas_worm,origen="reconciliation"), igual queproteger-indice-internalpara el índice./proteger-indice-internalsigue siendo el alias durotipo="indice". - (Interno, servicio-a-servicio; no alcanzable desde el gateway)
POST /api/v1/preservacion/renovar-artefacto-internal{file_id_anterior, file_id_nuevo, sha256_nuevo, firma_id, ordinal}— (B10) registra el XML de una firma XAdES-LTA con elArchiveTimeStamprenovado como objeto nuevo bajo WORM (el original sigue bajo su retención), que hereda la retención y el legal hold del anterior (firma_lta_renovada,renueva_a; migración tenant022) y deja el evento PREMISRENOVACION. Idempotente. Responde{protegido, retain_until, evento_id, ya_registrada}.422 artefacto_no_es_de_firmasi el anterior no es un artefacto de firma o el nuevo no es XML; hereda el mayorretain_untilde toda la cadena. Renovar la retención del anterior (renovar-retencion-internal) se propaga en cascada a susfirma_lta_renovada(PREMISRENOVACIONconretencion_en_cascada). SoloX-Internal-Token; lo llama el job de renovación de signature-service. Con la entidad suspendida se admite (excepción de conservación, ADR-035 §8). - (Interno, servicio-a-servicio; no alcanzable desde el gateway —
X-Internal-Tokenno está enFORWARDED_REQUEST_HEADERS)POST /api/v1/preservacion/renovar-retencion-internal{expediente_id, tipo, retencion_anios + fecha_inicio | retain_until}— (E10 debt: renovación de retención WORM para series de Conservación Total) extiende (nunca acorta) elretain_untilde un artefacto ya protegido bajo WORM, identificado por(expediente_id, tipo)(tipo ∈ {indice, acta_transferencia}). ReusaRetentionMixin. Semántica monotónica: si la fecha resuelta no supera estrictamente elretain_untilya persistido, es un no-op idempotente (200,renovado=false, sin tocar el almacén). Si la supera, llama aMinio.set_object_retention(extensión real; COMPLIANCE rechaza acortar de todos modos) y responde200conrenovado=true; el UPDATE + evento PREMISRENOVACION+ asientofile.worm_retencion_renovadavan en una tx (elset_object_retentionqueda fuera, antes). Autenticado solo porX-Internal-Token(atribuido aSYSTEM_RECONCILER_ID): la renovación es un acto de sistema conducido por la 5ª fase de reconciliación de archive (run_once_worm_renovacion), único que conoce qué serie es CT — NO hay ruta pública (se retiró en la remediación: storage no puede imponer el filtro CT, así que una ruta pública conretain_untildirecto sería superficie irreversible sin garantía CT).404worm_object_not_found;400retención inválida;503siPRESERVATION_ENABLED=false.retention_mode/legal_holdno cambian. Respuesta:{id, expediente_id, tipo, bucket, object_key, retain_until, retention_mode, renovado, renovaciones, renovado_at}. GET /api/v1/expedientes/indices/pendientes-renovacion?page=&size=— (E10 debt: visibilidad de la renovación CT) lista los artefactos WORM de series CT (índice + acta) cuya renovación está agotada (worm_renovacion_agotado=TRUE) o en riesgo (worm_retain_untilvence dentro deworm_renovacion_ventana_dias) — para que un archivista vea un lock COMPLIANCE de conservación permanente a punto de expirar sin renovar (espejo dependientes-firma). GateUSUA_PERM_EXPEDIENTE+ no-read-up (el filtronivel_seguridad <= clearancevive en el SQL de ambas consultas;X-Total-Countrefleja el conteo ya filtrado por clearance — no es oráculo). Ordena agotados primero, luego vencimiento más próximo. Por fila:{expediente_id, expediente_code, tipo, worm_retain_until, worm_renovacion_intentos, worm_renovacion_agotado, worm_renovacion_ultimo_error};tipo ∈ {indice, acta_transferencia, aip, acta_firma_elabora, acta_firma_entrega, acta_firma_recibe}— el XML de cada firma personal del acta (E10 debt, AREA-07-10) entra también a la renovación CT: la 5.ª fase la barre con su propio advisory lock (worm_renovacion_firma_personal) y backoff (transferencia_acta_firma_personal_worm.worm_renovacion_*, migración060), llama arenovar-retencion-internalcontipo=acta_firma_<rol>(storage lo acepta desde ahora; la fila se identifica por(expediente_id, tipo)), monotónica (storage solo extiende si la fecha resuelta supera la vigente; el espejo local usaGREATEST) y cada extensión real deja asientoarchive.acta_firma_personal_worm_retencion_renovada(actor sistema).- (Interno, servicio-a-servicio; no alcanzable desde el gateway)
POST /api/v1/preservacion/renovar-retencion-internal— gemelo interno derenovar-retencion, autenticado solo porX-Internal-Token, atribuido aSYSTEM_RECONCILER_ID. Único llamador en este incremento: la 5ª fase de reconciliación de archive-service (run_once_worm_renovacion), que barre — por cada tenant activo — los índices y actas de series de disposición Conservación Total (CT) cuyoretain_untilvence dentro deWORM_RENOVACION_VENTANA_DIAS(default 400 días) y llama a este endpoint conretencion_anios=total_TRD+fecha_inicio=hoy(ventana rodante, nunca una fecha lejana/9999). El filtro CT es fail-closed en la consulta SQL del lado de archive (disposition ∈ {CT, conserve}) — seriesE/S/Mnunca entran al barrido, aunque estén próximas a vencer (expiran y se disponen legítimamente). Backoff exponencial propio (worm_renovacion_intentos/worm_renovacion_agotadoenexpediente_indice/transferencia_acta_firma, migraciones031/032) — unagotado=trueen este eje es una alarma de mayor severidad que en el sellado/protección inicial: implica riesgo de que el lock COMPLIANCE de un registro de conservación permanente venza sin haberse podido extender. AIP queda fuera de alcance de la renovación (sin espejo en archive-service, deuda futura).
workflow-service — puerto 8006¶
Requiere headers
X-Tenant-SlugyX-User-Id.Distribución automática (RF-FLU-04): además de los endpoints, workflow-service consume el stream
orpycamcp.document.events. Al radicarse un documento (document.radicado.created) evalúa las reglas activas y, si alguna casa, autoasigna el radicado a su dependencia (paso + evento append-only, autor = sistema). Es idempotente (no redistribuye si el radicado ya tiene pasos).
Dependencias por código¶
(B5, ADR-032.) La dependencia se correlaciona por su código (dependencias.codigo), no por su nombre: el flujo
(bandeja, tenencia, ubicación), las reglas, los eventos y los borradores usan *_dept_code; *_dept es la
instantánea del nombre en el momento del acto (las respuestas llevan siempre los dos). Renombrar una
dependencia ya no afecta a ningún trámite.
- Alias deprecado. Mientras dura un ciclo, donde antes se enviaba el nombre (
to_dept,from_dept,dest_dept,assign_to_dept,?dept=) se sigue aceptando; se resuelve a código solo entre las dependencias activas con ese nombre actual y la respuesta llevaDeprecation: trueyLink: </docs/es/api.md#dependencias-por-codigo>; rel="deprecation". El nombre que se guarda lo pone el servidor desde el catálogo, nunca el texto del cliente. - Errores
422:dependencia_desconocida(no existe o está inactiva),dependencia_ambigua(el nombre corresponde a dos o más; incluyecandidatos— jamás se elige «la de menor código») ydependencia_inconsistente(código y nombre que no son la misma dependencia). - Origen derivado. El origen de un traslado lo deriva el servidor del paso activo; el cliente solo puede
confirmarlo (
from_dept_code) y, si ya no coincide,409 origen_desactualizado. - Eventos.
orpycamcp.workflow.events(step.created/completed/overdue) yorpycamcp.document.events(radicado.created) llevan ambos campos (to_dept_code/from_dept_code,dest_dept_code/origin_dept_code); el consumidor prefiere el código y, en un evento anterior, resuelve el nombre con historial (nombres anteriores y mapeos manuales). Si no resuelve a una sola dependencia, el código queda ausente y las condiciones por código no casan.
POST /api/v1/workflows/assign¶
Destino por código (ADR-032).
to_dept_codees el campo canónico yto_deptel alias deprecado; ver «Dependencias por código» más abajo.
Asigna un radicado a una dependencia (primer paso o reasignación). Exige PERM_TRAMITAR; 403 forbidden si falta permiso o tenant (RF-SEG-03). ADR-028 (remediación): el clearance del actor se aplica en el WHERE de la lectura del radicado (RF-SEG-08, no-write-up): un radicado inexistente o por encima de su nivel da el mismo 404 radicado_not_found. Con un paso activo, solo su poseedor puede repartirlo (404 no_active_step neutro para cualquier otro); sin paso activo tampoco es de cualquiera (R16): con historial, quien poseía el último paso (completado, radicado no terminal); sin ningún paso, quien radicó (radicados.registered_by = actor; un radicado con registered_by nulo no lo reparte nadie a mano). Un cerrado da 404 no_active_step, ya no 409. Redistribuir el paso de otra persona es la reasignación en cascada (USUA_PERM_ADMIN), no esta ruta. El reparto automático al radicar es un actor de sistema y no pasa por estos gates.
// Request
{
"radicado_id": "uuid",
"tracking_number": "2024-ICETEX-E-000001",
"to_dept_code": 120,
"assigned_to": "uuid-usuario",
"action": "assign",
"notes": "Para concepto jurídico"
}
// Response 201 — FlowStepResponse (to_dept_code + to_dept = nombre del catálogo)
POST /api/v1/workflows/transfer¶
Transfiere el radicado a otra dependencia (to_dept_code). El origen ya no es obligatorio: lo deriva el servidor del paso activo (from_dept/from_dept_code quedan en el paso y el evento); si el cliente lo envía y no coincide, 409 origen_desactualizado. Exige PERM_TRAMITAR (RF-SEG-03). Mismos gates de clearance y tenencia que assign.
GET /api/v1/workflows/{radicado_id}/history¶
Historial completo de pasos ordenado por step_number ASC.
{
"radicado_id": "uuid",
"tracking_number": "2024-ICETEX-E-000001",
"steps": [
{ "step_number": 1, "from_dept": null, "to_dept": "Ventanilla", "action": "assign", "status": "completed", ... },
{ "step_number": 2, "from_dept": "Ventanilla", "to_dept": "Dirección Jurídica", "action": "transfer", "status": "pending", ... }
]
}
GET /api/v1/workflows/inbox¶
Bandeja tipada de pasos activos (RF-FLU-03). ?box=entrada|salida|internos (mapea a doc_type E/S/I), &dept_code=&assigned_to=&page=&size= (dept=<nombre> es el alias deprecado: un nombre ambiguo es 422 dependencia_ambigua, ya no una lista vacía). Prioriza por antigüedad; cabecera X-Total-Count. 400 invalid_box si el box no es válido. Cada paso incluye due_at y semaforo (verde/amarillo/rojo/vencido, RF-FLU-07).
Acotado por dependencia (RF-SEG-08): sin dept/assigned_to, el llamante ve lo suyo — pasos asignados a él o de una dependencia a la que pertenece (URD activa). Pasar dept y/o assigned_to amplía el alcance a otra dependencia u otro usuario y exige PERM_TRAMITAR o USUA_PERM_ADMIN; sin ese permiso, 403 forbidden (no se ignora el parámetro en silencio). X-Total-Count siempre refleja el mismo filtro que las filas devueltas.
POST /api/v1/workflows/overdue/scan¶
Barrido de vencimientos (RF-FLU-07): detecta pasos activos vencidos no alertados, emite workflow.step.overdue al bus (→ notificaciones) y los marca para no duplicar. Idempotente por paso; pensado para invocación periódica por un scheduler. Responde { "alerted": N }. Exige USUA_PERM_ADMIN (barrido transversal del tenant). La reasignación masiva POST /api/v1/workflows/reassign/cascade (RF-FLU-08) exige el mismo permiso de administración.
Vistos buenos secuenciales (paridad legado)¶
POST /api/v1/workflows/{radicado_id}/vistos-buenos{revisores: [uuid,...]}— crea la cadena ordenada de revisión (1..20 revisores, sin duplicados). ExigePERM_RADI(D-09);403 forbiddensi falta permiso, identidad o tenant.409 cadena_existentesi el radicado ya tiene cadena.422 revisor_sin_habilitacionsi algún revisor tiene un clearance menor que el nivel del radicado (su turno quedaría bloqueado con404endecidir); el mensaje no revela el nivel.POST /api/v1/workflows/{radicado_id}/vistos-buenos/decidir{aprobar, comentario?}— el revisor de turno (menor orden pendiente) aprueba/rechaza. RequiereX-User-Idválido (400 si falta).403 not_a_reviewersi el actor no está en la cadena de revisores de ese radicado;403 no_es_su_turnosi está en la cadena pero no es su turno; un rechazo detiene la cadena.GET /api/v1/workflows/{radicado_id}/vistos-buenos— cadena + estado global (en_revision|aprobado|rechazado). No-read-up: un radicado por encima del clearance del llamante (o sin identidad, nivel PUBLICA) da404 sin_cadena, igual que uno sin cadena.decidiry la creación aplican la misma regla (404 sin_pendiente/sin_cadena).
POST /api/v1/workflows/{radicado_id}/devolver (devolución al remitente)¶
Re-enruta un radicado mal asignado: { "to_dept_code": 120, "causal": "DEV-DEP", "comentario"? } (to_dept es el alias deprecado). Cierra el paso activo como returned y crea uno nuevo hacia esa dependencia, con la causal en el historial. Exige PERM_TRAMITAR (como assign, transfer y complete; esta ruta y rollback eran las dos únicas escrituras de trámite del router sin ningún gate). Autorización por tenencia además del permiso: solo el responsable actual del paso activo puede devolver. Tenencia = la misma definición de poseedor de finalizar (asignado al actor, o paso de su dependencia sin dueño) y el radicado al alcance de su clearance (R15, en el WHERE); paso y tenencia se leen dentro de la transacción tras el cerrojo (R18). 404 no_active_step neutro para quien no es el tenedor (ya no hay 403 not_current_holder), para quien no alcanza el radicado y si no hay paso activo.
POST /api/v1/workflows/{radicado_id}/finalizar (finalizar trámite, ADR-028)¶
{ "comentario": "...", "motivo_cierre": "respondido|nrr|traslado_competencia|otro" } (ambos obligatorios; comentario de 5-500 caracteres; 422 si falta o es otro el motivo). Exige PERM_TRAMITAR nivel 3 (403 con un nivel menor: el cierre es irreversible) y ser el poseedor del paso activo (assigned_to = actor, o paso de dependencia sin dueño y el actor pertenece a ella) —o, sin paso activo, haber sido el poseedor del último paso completado (completó el suyo y no repartió) si el radicado no es terminal—. Cierra el paso activo como completed y el radicado pasa a closed, atómicamente, con evento tipo_tx=finalizar (detalles.motivo_cierre) y asiento workflow.finalizar cuyo payload lleva motivo_cierre, step_id, flow_event_id, tiene_respuesta (existe una Salida con responde_a = este radicado), fuera_de_plazo (manda el plazo del paso, due_at vs. ahora, si existe; si no, el vencimiento legal del radicado, radicados.due_date < hoy; null solo si no consta ninguno) y en_expediente (null = no consta, p. ej. el tenant aún no tiene esa tabla). Es el único acto que cierra un radicado y no se deshace. Un no-poseedor, quien no alcanza el nivel del radicado (R15), o un radicado sin paso activo (incluido uno ya cerrado), recibe el mismo 404 no_active_step (no-oráculo). 409 radicado_terminal si el radicado está anulado. Responde el paso cerrado.
Estado del radicado y actos de trámite (ADR-028)¶
Ubicación actual (ADR-029). En el mismo UPDATE que radicados.status —misma transacción, antes del asiento— el workflow-service escribe flujo_dept, flujo_dept_code y flujo_usuario: el paso activo (mayor step_number) o, sin paso activo, el último cerrado (completed_at, no el número de paso: tras un rollback el cancelado tiene el número más alto pero cerró antes). Cubre asignar, trasladar, devolver, revertir, completar, finalizar y la reasignación en cascada. Los radicados terminales se congelan: uno cerrado queda donde se finalizó, uno anulado donde estaba al anularse. El código solo se resuelve cuando el nombre del paso casa con una dependencia activa; si no, flujo_dept_code es null y el nombre queda como instantánea. Ver GET /api/v1/documents y ADR-029.
radicados.status lo escribe el workflow-service en la misma transacción que el paso —antes del asiento de auditoría, así que si el asiento falla revierte todo—: paso activo pending → distributed; in_progress → in_progress (solo por marcar_leido del poseedor); completar sin marcar leído deja distributed (declarado). closed y anulado son terminales: assign, transfer, complete, devolver, rollback, finalizar y las transacciones que mueven el paso responden 409 radicado_terminal (El radicado está cerrado o anulado y no admite actos de trámite) y no cambian ni un paso, evento o asiento; informar/agendar siguen permitidas. El cambio queda en el payload del asiento existente ("estado_radicado": {"antes", "despues"}) y, tras el commit, se publica workflow.radicado.estado_cambiado en orpycamcp.workflow.events (dedup {radicado_id}:{despues}:{flow_event_id}). Los GET nunca sincronizan. inbox, pending y los vencidos excluyen los pasos de radicados terminales. Las salidas (S) y los internos (I) no tienen reparto fuera: su estado solo se mueve cuando un paso los toca. La anulación cancela el paso activo (en la misma transacción, flow_events.tipo_tx=anulacion).
Transacciones de trámite (RF-FLU-01)¶
GET /api/v1/workflows/transaction-types— catálogo (informar, NRR, agendar, no_agendar, change_folder, marcar_leido, validate_trd_send, solicitar_firma, vobo, cerrar_exp, anular…), con su permiso atómico, efecto de estado yrequires_holder(ADR-028, R17: toda transacción —con o sin tenencia— aplica el clearance del actor en la lectura del último paso: un radicado inalcanzable da404 no_flow, igual que uno inexistente o sin pasos, sin devolver sutracking_number/to_dept. ADR-028: si estrue—hoymarcar_leidoyanular— únicamente el poseedor del paso activo puede ejecutarla; un no-poseedor recibe el mismo404 no_active_stepque si no hubiera paso, también sobre un radicado terminal —no se le revela con un409 radicado_terminal—. Además, toda transacción que mueve el paso (target_step_status) exige tenencia, la declare o no su fila: el catálogo es configurable por el tenant).POST /api/v1/workflows/{radicado_id}/transactions{ "tipo_tx": "anular", "comentario": "...", "detalles": {} }— ejecuta la transacción: aplica el efecto al paso activo (p. ej.marcar_leido→in_progress,anular→cancelled) y registra el evento append-only. Enforcement RBAC por-tipo (RF-SEG-03): cada tipo declara entransaction_types.requires_permissionel permiso que exige (anular→PERM_ANULAR,cerrar_exp→PERM_CERRAR_EXP,vobo→PERM_VOBO…); sin él,403 forbiddeny la transacción no se aplica. Un tipo conrequires_permission = NULLcae al sueloPERM_TRAMITAR, no es abierto:transaction_typeses un catálogo del tenant, y dejar la columna vacía no puede ser la manera más fácil de abrir la puerta. Hoy los 12 tipos sembrados declaran el suyo, así que el suelo no cambia nada observable. No hay gate de ruta fijo a propósito: exigirPERM_TRAMITARa todas rompería a quien tienePERM_VOBOpara dar vistos buenos y nada más.404 unknown_transaction/no_flow.PATCH /{step_id}/completeexigePERM_TRAMITAR.
POST /api/v1/workflows/{radicado_id}/rollback¶
{motivo} (obligatorio, 5-500 caracteres). Exige PERM_TRAMITAR. Revierte la última asignación (RF-FLU-08): cancela el paso vigente —solo si sigue pending— y reactiva el anterior, atómicamente, con evento tipo_tx=rollback. El motivo queda registrado en dos trazas atómicas con la mutación (una sola transacción): flow_events.comentario (hoja de ruta operativa) y public.audit_log.payload (traza legal forense, mismo patrón que la asignación). Misma tenencia y mismo clearance que devolver (R15, R18, R19). 409 no_active_step (sin paso activo, no poseedor o radicado inalcanzable: neutro) o cannot_rollback_initial (es la radicación inicial). 422 si falta el motivo o no alcanza el mínimo de caracteres.
POST /api/v1/workflows/reassign/cascade¶
Reasigna en una transacción todos los pasos activos de un usuario o dependencia a un nuevo responsable, sin dejar radicados huérfanos (RF-FLU-08).
// Request (indicar from_assigned_to o from_dept_code; from_dept es el alias deprecado)
{ "from_dept_code": 120, "to_assigned_to": "uuid" }
// Response 200
{ "reassigned": 12, "skipped_terminal": 0 }
ADR-028: reasignar equivale a que el nuevo responsable no ha leído el radicado (Orfeo LEIDO=0): los pasos vuelven a pending y el radicado a distributed, con un asiento workflow.estado_radicado por radicado cuyo estado cambió. Los radicados terminales (closed/anulado) se excluyen —su paso no se reasigna— y se cuentan en skipped_terminal (pasos omitidos). Concurrencia: un único cerrojo advisory de alcance del tenant (exclusivo aquí, compartido en cada acto de trámite) más cerrojos de fila ordenados por id sobre los radicados —no uno advisory por radicado, que agotaba max_locks_per_transaction en un barrido grande—; mientras dura, los demás actos de trámite del tenant esperan.
GET /api/v1/workflows/{radicado_id}/events¶
Historial append-only de transacciones del radicado (RF-FLU-02, inalterable). A diferencia de /history (proyección de pasos), registra cada transacción y su tabla rechaza UPDATE/DELETE. Control de acceso (E05 §10 / RF-SEG-08): es la hoja de ruta operativa (Canal B) — visible a cualquier usuario que pueda leer el radicado, acotada por su clearance (no read-up); exige X-User-Id y un radicado por encima del clearance devuelve historial vacío. No requiere permiso de administrador (eso es la auditoría forense audit_log).
[
{
"id": "uuid",
"tipo_tx": "assign",
"from_dept": null,
"to_dept": "Ventanilla",
"actor": "uuid",
"comentario": "...",
"detalles": { "step_number": 1 },
"ts": "2024-01-15T10:30:00Z"
}
]
GET /api/v1/workflows/{radicado_id}/current¶
Paso activo actual del radicado. 404 si está cerrado o sin asignar.
GET /api/v1/workflows/pending¶
Radicados pendientes. Filtros: ?dept_code=120&assigned_to={uuid}&page=1&size=20 (dept=<nombre> es el alias deprecado, mismo criterio que /inbox).
Acotado por dependencia (RF-SEG-08): mismo criterio que /inbox — por defecto, lo propio (asignado al llamante o a una dependencia a la que pertenece por URD activa); dept/assigned_to amplían el alcance y exigen PERM_TRAMITAR o USUA_PERM_ADMIN (403 forbidden sin el permiso).
PATCH /api/v1/workflows/{step_id}/complete¶
Marca un paso como completado. ADR-028: solo el poseedor (assigned_to = actor, o paso de dependencia sin dueño y el actor pertenece a ella; la misma definición que finalizar y marcar_leido) y solo un paso activo: un paso ajeno, inexistente, ya cerrado o de un radicado por encima del clearance del actor (R15, en el mismo WHERE) da el mismo 404 step_not_found.
// Request
{ "notes": "Concepto emitido, se adjunta" }
// Response 200 — FlowStepResponse actualizado
archive-service — puerto 8004¶
Requiere headers
X-Tenant-SlugyX-User-Id.
Expedientes¶
Control de acceso: todos los endpoints de expedientes (12: create/list/search/get/foliado/eventos/close/transfer/update/link/batch/unlink) y los de
batch.py(POST /api/v1/batch/expedientes,GET /api/v1/batch/expedientes/{job_id}/status) requierenUSUA_PERM_EXPEDIENTE(fail-closed:403sinX-Tenant-Slug, sinX-User-Id, o sin el permiso). Mínimo privilegio (RF-SEG-03, nivelcrud0-5): las operaciones de disposición —close,transfer,unlink, y elPATCHque transiciona aclosed/transferred— exigenUSUA_PERM_EXPEDIENTEconcrud≥3(Crear/Borrar); el resto (lecturas, create, link, PATCH de nombre/serie) basta concrud≥1. Enlist/get/eventos/searchy elPATCHla clearance (RF-SEG-08, no read-up) filtra además lo visible por nivel de seguridad: cada usuario solo ve/edita expedientes denivel_seguridad ≤su clearance (fail-closed a PUBLICA sinX-User-Id), un expediente por encima devuelve 404 (no revela su existencia), y enlist/searchelX-Total-Counttambién queda filtrado (no revela cuántos clasificados existen). Toda consulta delist/searchdeja una traza agregada enaudit_log(RF-BUS-10: usuario, criterios, total; sin el texto crudo deq), separada de la pista por-expediente. Enlink/batch/unlink/indice/rebuild(M2, RF-SEG-08) la mutación es legítima sin clearance de lectura (componer/ordenar el expediente es gestión archivística, ortogonal a leer su contenido) y siempre ocurre y siempre audita; pero la respuesta se acota si el llamante no puede leer el expediente (nivel_seguridad >su clearance): counts/huella/added_at→null, y se suprimen los oráculos (409radicado_already_linked, 404radicado_link_not_found, 409expediente_not_open) devolviendo un ack uniforme e indistinguible sea cual sea el estado — un no-lector no infiere pertenencia, volumen ni ciclo de vida de un expediente clasificado. El permiso se resuelve en la BD del tenant (el api-gateway no gatea por ruta).
POST /api/v1/expedientes¶
Crea expediente. Genera código automático EXP-{AÑO}-{SEQ:04d}.
// Request
{ "name": "Pensión María García - 2024", "description": "...", "trd_serie_id": "uuid",
"metadata": { "tipo_contrato": "prestación" } }
// Response 201
{ "id": "uuid", "code": "EXP-2024-0001", "name": "...", "status": "open", "metadata": { ... }, ... }
metadata(opcional, ADR-007): si la serie TRD (trd_serie_id) tiene una plantilla activa, los valores se validan contra su JSON Schema →422 metadata_validation_failedsi no cumplen. Columna indexada con GIN.- Fondo acumulado (ADR-026):
origen: "fondo_acumulado"exigefecha_extrema_finalytrd_serie_id(de una TVD) desde la creación;fecha_extrema_iniciales opcional.422si falta alguno, sifecha_extrema_finales futura —es la base de unretain_untilObject-Lock COMPLIANCE irreversible (schemas/archive.py): una fecha futura bloquearía la disposición del acervo por décadas— o sifecha_extrema_inicial > fecha_extrema_final. Conorigen: "corriente"(el valor por defecto) las fechas extremas no se aceptan (422). La misma regla rige en la carga masiva.
POST /api/v1/expediente-metadata/templates¶
Define la plantilla de metadatos (JSON Schema) de una serie TRD. Una sola activa por serie. 422 invalid_json_schema / 409 conflict. GET lista (filtro ?trd_serie_id=).
Transferencias documentales (E12)¶
Control de acceso (D-08): todos los endpoints requieren
USUA_PERM_EXPEDIENTE. Sin él:403 {"error": "forbidden"}.
POST /api/v1/transferencias{tipo: primaria|secundaria, expediente_id, objeto, ubicacion_origen_id?, ubicacion_destino_id?, observacion?}— crea (precondición: expedienteclosedcon el índice electrónicofirmado:409 expediente_not_closedsi no está cerrado y409 indice_no_firmado—AREA-07-03— si el índice vigente no está firmado, con asiento de denegacióntransferencia_preparada_failed; antes solorecibirlo exigía y el defecto avanzaba hasta la última fase). Ciclopreparada→enviada→recibida|rechazada. Cada acto (crear/enviar/recibir/rechazar) deja asiento inmutable enaudit_log(éxito y denegación) con el actor identificado unívocamente porX-User-Id(UUID; sin fallback a username). Integridad (E12 H-L/H-M): a lo sumo una transferencia activa (preparada/enviada) por expediente — una 2ª da409 transferencia_activa_existente(índice único parcial; tras unrechazadase puede re-preparar); y si se aportanubicacion_origen_id/ubicacion_destino_iddeben ir ambas y ser coherentes con el tipo (primaria: origengestion→destinocentral;secundaria:central→historico; Ley 594/2000 art. 23), conorigen != destino— si no:422(ubicaciones_par_incompleto|ubicaciones_origen_destino_iguales|ubicacion_not_found|ubicacion_incoherente_con_tipo). Obligatoriedad condicionada a la tenencia física: si el expediente tiene ubicación física localizada (víaexpediente_unidad, que es M2M), el par es obligatorio (422 ubicaciones_requeridas_fisico) yubicacion_origen_iddebe cotejar — estar entre las ubicaciones actuales del expediente (422 ubicacion_origen_no_actual; vale cualquiera de las M2M); si el expediente es puramente electrónico (sin unidades físicas), la transferencia es lógica y NO lleva par: aportarlo sin tenencia que lo respalde es422 ubicacion_sin_tenencia(AREA-07-06; antes se aceptaba sin cotejo y el acta FUID atestiguaba un movimiento que ningún vínculo sustentaba). La tenencia se lee dentro de la transacción del acto conFOR SHARE(no antes: una unidad que se movía entre el cotejo y elINSERTdejaba el acta cotejada contra una ubicación que ya no era la actual). Si la ubicación se borra en la carrera, la FK ya no da500sin asiento: es422 ubicacion_not_foundcon asiento (AREA-07-08). El destino no se coteja (solo coherencia de tipo — es a dónde va). Lectura de topología física sin clearance (custodia ortogonal).transferencias.objetoes obligatorio también en la base (CHECKdesde la migración055, solo para filas nuevas: el histórico sigue operable).-
POST /{id}/enviar,POST /{id}/recibir,POST /{id}/rechazar. Al recibir, dentro de una única transacción: se re-verifica la fixity del acervo (SHA-256 de cada documento vs la huella del índice + re-hash del XML del índice;409 fixity_mismatchque revierte sin congelar si el acervo se alteró entre el cierre y la recepción — la respuesta lleva solonum_documentos_fallidos, el detalle por-documento queda enaudit_log), el expediente pasa atransferred(congelado), y se congela el acta de entrega (ver/{id}/acta). El rechazo exige{motivo}(obligatorio,min_length=3, no vacío →422); el motivo se guarda en columna propia sin sobrescribir la observación de preparación. Cabecera del Anexo FUID (migración 052, Ac. AGN 001/2024):objetoes obligatorio (3–500 caracteres) — encabeza el acta de entrega y lo escribe quien prepara, porque es un dato humano que ninguna derivación puede inventar. Laoficina_productoray launidad_administrativaNO se aceptan del cliente: las resuelve el servidor desde la dependencia activa de quien prepara (auth_users.active_depe_id, con caída a su URD principal) y su dependencia padre respectivamente; una oficina raíz es su propia unidad administrativa. Se congelan como instantánea en la preparación —no al recibir— porque entre las dos cosas pueden pasar semanas. Si los catálogos de auth/tenant no están en el esquema, la cabecera queda vacía y la preparación sigue (degradación declarada, nunca un error). -
GET /api/v1/transferencias?estado=&tipo=,GET /{id},GET /{id}/fuid(FUID en vivo del expediente, regenerado al vuelo). GET /{id}/acta(?verify=true) — acta de entrega CONGELADA al recibir (E12 H-I, Ac. AGN 001/2024 Anexo FUID; Ley 594/2000 arts. 15/26). Devuelve el snapshot inmutable escrito en la recepción (nunca lo regenera):TransferenciaActaResponsecon el XML canónico del FUID, suacta_fuid_sha256(fixity del acta, recomputable por el cliente), los tres responsableselaborado_por/entregado_por/recibido_porcon sus fechas, eindice_versiontransferida. AREA-07-04 (migración053): ademásindice_firma_id(el sello XAdES que ampara el índice entregado) eindice_sha256(la huella del XML que ese sello cubre), leídos de la misma fila deexpediente_indiceque usó la fixity y embebidos en el XML que se hashea y se sella (<indiceElectronico version firmaId sha256>en la cabecera); una versión del índice que no esté firmada impide recibir (409 indice_no_firmado).nullen las actas anteriores (no se inventa un vínculo en un acta ya sellada). AREA-07-11:cabecera_completa(derivado al leer):falsesi el XML congelado trae vacía la unidad administrativa, la oficina productora o el objeto —las actas anteriores a la migración052se sellaron así y no se rehacen—; se declara el hueco en vez de esconderlo. AREA-07-05: la recepción registra además su fixity-check como evento PREMISFIJACIONen storage (post-commit, best-effort, con reconciliación). Bloquefirma(E06 H-H):{presente, valida, xades_level, acreditado, motivo}— el acta lleva un sello XAdES-B/T/LT/LTA + OCSP institucional del tenant (enveloped sobre el XML canónico, cubre los bytes cuyo SHA se ancla enaudit_log);presente=truecuando el sellado convergió afirmado; con?verify=truese computavalidaon-demand (descarga el XML firmado del almacén de objetos y lo verifica contra signature-service).acreditadosiemprefalse(sello no acreditado ONAC — fecha cierta verificable, no oponible en sentido fuerte;xades_levelse lee junto aacreditado). Sellado best-effort (la firma es aditiva): si aún no se selló,firma.presente=falseconmotivo(pendiente_firma/sello_ausente/…) — el acta sigue íntegra por fixity + inmutabilidad + anclaje. No-read-up en la lectura (el acta expone asunto/serie/signatura = contenido):404total si el expediente supera el clearance del llamante — asimétrico con la generación, que ocurre con clearance sin restricción (custodia). La inmutabilidad del acta es forzada por trigger a nivel de motor (BEFORE UPDATE/DELETE/TRUNCATE, migración023) además de verificable por fixity y anclada en la hash-chain. Constancias de firma inobtenible:excepciones_firma[](mismo objeto quefirma-inobtenible); elfundamento(yrevocacion_fundamento) completo solo se devuelve aUSUA_PERM_ADMINnivel ≥ 3 (dato sensible: salud, fallecimiento) — al resto le lleganmotivo,referencia_documentalyfundamento_sha256confundamento=null; igual en/acta/verificacion.POST /{id}/acta/firmar— reintento manual del sellado XAdES del actapendiente_firma(E06 H-H).USUA_PERM_EXPEDIENTE, atribuido al humano que lo dispara (origen="manual").409 acta_ya_firmadasi ya está firmada (idempotente),503si sigue sin poder sellarse. Re-arma el backoff de reconciliación para que el job automático reanude. La firma del acta también converge sola vía el job de reconciliación generalizado (cadencia menor que el índice).POST /{id}/acta/firmar-personal{totp_code, rol?}— firma electrónica PERSONAL PKI de un RESPONSABLE del FUID (ELABORADOR "Elaborado por" rolelabora, REMITENTE "Entregado por" rolentrega, o RECEPTOR "Recibido por" rolrecibe) sobre el acta de entrega (E17/F4 Inc.1-3; Ac. AGN 001/2024 Anexo FUID; Ac. AGN 042/2002; Ley 527/1999 art. 7). Paso explícito posterior arecibir()(nunca inline: preserva la tx de custodia sin 2FA); los roles se firman en cualquier orden, cada uno como acto atómico independiente. El rol se DERIVA de la identidad congelada:actor == creada_por→elabora,actor == enviada_por→entrega,actor == decidida_por→recibe. Elroldel body es opcional y solo desempata cuando la misma identidad tiene varios roles (auto-traslado:400 rol_requeridosi falta): NUNCA concede un rol — un tercero que no es ninguno de los tres responsables recibe403 firmante_no_es_parte(y también si pide víarolun papel que su identidad no otorga), antes de consumir el TOTP.USUA_PERM_EXPEDIENTE+ no-read-up real sobre el expediente para todos los roles (404neutro sobre-clearance);409 transferencia_no_recibidasi aún no estárecibida. Archive media: reenvía elAuthorization/TOTP del responsable a signature-servicePOST /signature/sign-personal(verifica-y-consume el TOTP atómico y firma elacta_fuid_xmlcongelado con su cert personal). Idempotente sobre(transferencia_id, rol): repetir un rol ya firmado devuelve200con la fila existente, sin re-firmar ni re-quemar TOTP.503 canal_firma_personal_no_disponiblesi signature no responde. DevuelveTransferenciaActaFirmaPersonalResponse(rol,firmante_id,firma_id,xades_level,acreditadosiemprefalse). Registrada entransferencia_acta_firma_personal(sin reconciliación desatendida de la FIRMA — requiere TOTP interactivo). AREA-07-10: el XML de cada firma personal entra al almacén WORM (tipoacta_firma_<rol>en storage), post-commit y best-effort, con reconciliación con backoff propia (transferencia_acta_firma_personal_worm, migración057; antes solo el sello institucional tenía Object-Lock). Si una parte no puede firmar, ver el actofirma-inobtenible(más abajo). El gateacta_firma_personal_require(defaultoff) nunca bloquearecibir()ni la firma de otro rol; solo modula el veredicto de conformidad.GET /{id}/acta/verificacion— verificación agregada del acta (E17/F4 Inc.1-3). Compone la verificación del sello institucional + la de cada firma personal presente (descarga los XML firmados del almacén de objetos y los verifica contra signature-service). Devuelve{sello: {...}, firmas_personales: [{rol, firmante_id, valida, revocacion_status, advertencias}], completitud, valida, acreditado, auto_traslado, elaborador_firmado, excepciones, advertencias, constancias_sin_firma, politica_xades};advertenciasson los avisos de signature/verifyde una firma que sigue siendo válida (p. ej.tsa_sin_esscertid_anterior_al_corte), sin repetir;constancias_sin_firmacuenta las excepciones vigentes anteriores a la migración061(sin sello: no se dan por válidas yvalidaesfalse):completitud ∈ {sellada, firma_personal_pendiente, firmada_receptor, firmada_remitente, firmada_completa, firmada_con_excepcion, sellada_con_excepcion}(derivada, presencia factual —firmada_completa= ambas PARTES DEL TRASPASO (entrega+recibe) firmaron y no hay ninguna excepción declarada = atribución bilateral reforzada, NO conformidad jurídica plena;firmada_con_excepcion= cada parte firmó o tiene una excepción de firma vigente (/acta/firma-inobtenible), al menos una de las dos firmó y existe alguna excepción vigente de cualquier rol — nunca se promueve afirmada_completa;sellada_con_excepcion= las dos partes están cubiertas solo por excepción (ninguna firmó: el acta conserva únicamente el sello de la entidad, no se la llama «firmada»); una excepción revocada no cubre a su parte; la lista de constancias —las revocadas incluidas, marcadas— va enexcepciones(mismo objeto que la respuesta defirma-inobtenible, consello_validayfirma_personal_validaverificados al momento).firma_personal_pendientese calcula con la política XAdES de la ENTIDAD (firma_personal_require_xadesdesgd_param_admin; el entorno solo respalda si no la definió; valor corrupto =todos), que se devuelve enpolitica_xades. El ELABORADOR es firmante ADITIVO (no parte del traspaso): su firma NO condicionacompletitud, se reporta en el booleano ORTOGONALelaborador_firmado.valida= cripto + revocación de todo lo presente (incluida la firma del elaborador, fail-closed → un cert de elaborador revocado davalida=falseaun confirmada_completa; los dos ejes de ortogonalidad difieren a propósito),acreditadosiemprefalse,auto_traslado=truecuando la misma identidad envió y recibió (observación de conformidad: sin control dual — el FUID no exige personas naturales distintas, se señala pero no se impide). No-read-up idéntico a/{id}/acta(404neutro sobre-clearance; los cuerpos no enumeran radicados reservados).POST /{id}/acta/firma-inobtenible{rol, motivo, fundamento, referencia_radicado_id, referencia_anexo_id, totp_code?}→201constancia — acto administrativo «firma inobtenible» (AREA-07-10, ADR-030): una parte del acta (elabora/entrega/recibe) que no puede firmar (fallecimiento, retiro, incapacidad) se declara de forma explícita. GateUSUA_PERM_ADMINnivel ≥ 3 (no el permiso de transferencias con el que firman las partes) yPERM_FIRMA≥ 2 (el sello se estampa a nombre del declarante:403 declarante_sin_permiso_de_firma).motivo ∈ {fallecimiento, retiro, incapacidad, otro};fundamento(20–2000 caracteres) obligatorio. El soporte documental ya no es texto libre:referencia_radicado_id(UUID de un radicado) yreferencia_anexo_id(obligatorio desde FIRMA-2:file_idde uno de sus anexos, del que el servidor trae el SHA-256 desde storage;503 soporte_documental_sin_huellasi no puede) los resuelve el servidor contra document-service con el clearance del declarante —422 referencia_documental_invalida(un solo cuerpo para «no existe» y «no lo puede ver»),503 document_service_no_disponible; se guarda en forma canónicaradicado:<uuid>[#anexo:<uuid>]. La identidad de la parte la deriva el servidor del rol (enviada_por/decidida_por/creada_por), nunca el cliente. La constancia es PRUEBA: un XML en C14N exclusiva (urn:orpycamcp:acta-firma-excepcion:v3: huella del acta congelada, transferencia, rol, parte, motivo —incapacidadse escribeimpedimento_sobreviniente—, compromiso del fundamento, soporte, declarante, instante) sellado con el sello institucional XAdES-T (mismo camino que el acta; si no hay sello →503 sello_institucional_no_disponible/sellado_constancia_no_obtenidoy no se declara nada) y, si el declarante envíatotp_codey tiene credencial activa, firmado también por él (XAdES personal sobre los mismos bytes). La firma personal es OBLIGATORIA cuando la política de la ENTIDAD (firma_personal_require_xades, parámetro de tenant) estodos, oclasificadoscon expediente de nivel ≥ 2 (422 firma_personal_declarante_requeridasintotp_code;422 sin_certificado_activosin credencial). Ambos XML entran al almacén WORM (tiposacta_firma_excepcion_<rol>yacta_firma_excepcion_personal_<rol>en storage, migración019), post-commit y best-effort, con reconciliación con backoff y renovación CT (transferencia_acta_firma_excepcion_worm, migración061). Deja una constancia inmutable (transferencia_acta_firma_excepcion, migraciones059/061: append-only por trigger y por privilegios, una por rol) y un asientoarchive.acta_firma_inobtenible_declaradaen la misma transacción; el asiento lleva las huellas del fundamento y de la constancia, no el texto (el registro es permanente y puede aludir a salud o fallecimiento). El acta congelada y su huella no se tocan. Conflicto de interés: quien declara no puede ser ninguna de las tres partes de la transferencia (enviada_por,decidida_por,creada_por):403 declarante_es_parte_de_la_transferenciacon asientoarchive.acta_firma_inobtenible_denegada; lo repite un trigger en el motor. Exclusión mutua en el motor (trigger con advisory lock por (transferencia, rol)): una parte que ya firmó no admite excepción (409 parte_ya_firmo) y una parte con excepción vigente ya no firma (409 firma_excepcion_declaradaenfirmar-personal, antes de gastar el TOTP). Cadena (FIRMA-2, ADR-030 addendum, migración062): sobre una excepción vigente es409 excepcion_vigente; tras revocarla se puede declarar de nuevo (ordinaln+1,sustituye_a= la revocada;UNIQUE (transferencia_id, rol, ordinal)yUNIQUE (sustituye_a): cadena lineal) solo si la revocación está sellada y todos sus artefactos protegidos WORM (409 revocacion_sin_proteger; lo repite un trigger conCONSTRAINT ck_firma_excepcion_revocacion_sin_proteger); si la parte firmó personalmente —también durante la ventana revocada— la cadena está cerrada (409 parte_ya_firmo: la firma prevalece). Constancia v3 autosuficiente (urn:orpycamcp:acta-firma-excepcion:v3): lleva el número del radicado y el SHA-256 del anexo, nombre y cargo de declarante y parte como instantánea resuelta por el servidor (CN del certificado de firma activo, ousername;fuente=certificado/usuario/no_resuelta), y el fundamento se huella con un compromiso con sal por registro,SHA-256(sal_32B || texto_utf8)(ronda de auditoría, migración063): la sal es aleatoria, vive en la base (no WORM) y nunca va al XML ni aaudit_log; un tercero verifica si se le entregan texto y sal de ese registro.fundamento_sha256yfundamento_hmac*desaparecen de la respuesta y del asiento.huella_fundamento_no_disponibleya no existe (se calcula en local). El XML firmado es la C14N exclusiva yconstancia_sha256su SHA-256: un tercero que quite lads:Signaturey canonicalice obtiene la misma huella. El asientoarchive.acta_firma_inobtenible_declaradaya no lleva el número del radicado de soporte (quedan el id y la huella del anexo). El declarante con certificado de firma activo siempre firma personalmente (422 firma_personal_declarante_requeridasintotp_code), no solo por política. Las referencias (referencia_*) se recortan por el clearance del lector (nullsi no ve el radicado, indistinguible de «no existe» o de un error de document-service). Solo sobre un acta ya congelada (409 transferencia_no_recibida); no-read-up real sobre el expediente (404neutro). Respuesta:TransferenciaActaFirmaExcepcion{id, transferencia_id, rol, parte_id, motivo, fundamento, ordinal, sustituye_a, constancia_version, fundamento_compromiso, fundamento_compromiso_algoritmo, fundamento_sal (solo admin ≥ 3), referencia_documental, referencia_radicado_id, referencia_radicado_numero, referencia_anexo_id, referencia_anexo_sha256, declarada_por, declarada_en, declarante_nombre/cargo/fuente, parte_nombre/cargo/fuente, firmada, constancia_sha256, xades_level, firma_personal, xades_level_personal, firma_personal_requerida, worm_protegido, worm_personal_protegido, worm_retain_until, sello_valida, firma_personal_valida, enlace_valido, enlace_motivo, advertencias, revocada, revocacion_id, revocada_por, revocada_en, revocacion_fundamento, revocacion_causa, revocacion_fundamento_compromiso, revocacion_fundamento_compromiso_algoritmo, revocacion_fundamento_sal (solo admin ≥ 3), revocante_nombre/cargo/fuente, revocacion_firmada, revocacion_sha256, revocacion_worm_protegido, revocacion_worm_retain_until, ...}. Retención común de la cadena: todos los eslabones de un (transferencia, rol) se protegen hasta el mayor entre el sellado del acta y el último acto de la cadena, y al protegerse uno nuevo se alinean (monotónico, con asiento..._retencion_renovada,origen=alineacion_cadena) los anteriores. Privacidad: el motivoincapacidadviaja neutro en XML y asiento; estos artefactos no se publican por OAI-PMH, EAD ni CMIS. Efecto sobreacta_firma_personal_require: la excepción cubre a su parte, pero una excepción parcial no apaga el gate por sí sola.POST /{id}/acta/firma-inobtenible/revocar{rol, causa, fundamento, totp_code?}→201— revoca la excepción vigente de un rol (se declaró por error, o la parte sí puede firmar). Misma puerta que declarar (USUA_PERM_ADMIN≥ 3) y misma regla de conflicto de interés (403 declarante_es_parte_de_la_transferencia, asientoarchive.acta_firma_inobtenible_revocacion_denegada). Es una fila nueva append-only (transferencia_acta_firma_excepcion_revocacion, migración061): la constancia original y su Object-Lock no se tocan, y el asientoarchive.acta_firma_inobtenible_revocada(con la huella del fundamento de la revocación) va en la misma transacción. Desde ese momento la excepción deja de contar: el trigger de exclusión mutua solo considera excepciones no revocadas, la parte puede volver a firmar (firmar-personal) ycompletitudla trata como no cubierta.404 excepcion_not_foundsi el rol no tiene excepción;409 excepcion_ya_revocada. La revocación es prueba (FIRMA-2):causa ∈ {error_de_declaracion, parte_puede_firmar, otro}obligatoria (422si falta o no es del vocabulario; viaja dentro del XML firmado); XML en C14N exclusiva (urn:orpycamcp:acta-firma-excepcion-revocacion:v3) ligado a la huella de la constancia que revoca, con el sello institucional (sin sello:503y no queda ni fila ni asiento) y la firma personal de quien revoca si tiene certificado de firma activo (422 firma_personal_revocante_requeridasintotp_code); exige ademásPERM_FIRMA≥ 2 (403 revocante_sin_permiso_de_firma). Sus artefactos entran al WORM (tiposacta_firma_revocacion[_personal]_<rol>, storage migración020, con reconciliación y renovación CT propias:transferencia_acta_firma_excepcion_revocacion_worm). Tras revocar y proteger se puede declarar de nuevo (ver la cadena arriba). La respuesta es la misma constancia conrevocada=true.GET /{id}/acta/firma-inobtenible/{excepcion_id}/fundamento/verificacion→FundamentoVerificacion{excepcion_id, verificable, motivo, algoritmo, columna, xml, asiento, revocacion_columna, revocacion_xml, revocacion_asiento, coincide}— verificación del fundamento (FIRMA-2;USUA_PERM_ADMINnivel ≥ 3, no-read-up404): recalcula en local el compromisoSHA-256(sal || texto)del fundamento guardado y lo coteja contra tres copias independientes (la columna, el XML firmado y el asiento de auditoría; y lo mismo con la revocación). Una copia ausente o no comprobable (null) no cuenta como coincidencia; una constancia anterior a la v3 esverificable=false(constancia_sin_compromiso_salado). No devuelve el texto ni la sal. Deja el asiento de lecturatransferencia.acta_fundamento_leido(también lo dejanGET /actayGET /acta/verificacioncuando sirven el fundamento en claro; sin texto ni sal; sin asiento no hay lectura).GET /{id}/acta/firma-inobtenible/{excepcion_id}/sobre?acto=constancia|revocacion→SobreFundamentoResponse{excepcion_id, acto, sobre_sha256, xades_level, worm_protegido, xml_firmado}— sobre de revelación del fundamento (FIRMA-2 ronda 3, migración archive064): el XMLurn:orpycamcp:acta-firma-excepcion-sobre:v1{id, acto, texto, salHex, compromiso, algoritmo}sellado con el sello institucional y guardado bajo Object-Lock como objeto propio (retención alineada al ancla de la cadena). Único camino de lectura:USUA_PERM_ADMINnivel ≥ 3, no-read-up404(y404 sobre_not_foundsi el acto es anterior a la064),503 sobre_no_disponiblesi el almacén no responde, y el asientotransferencia.acta_fundamento_leidoconvia="sobre"se escribe antes de servirlo. Nunca por OAI-PMH/EAD/CMIS ni la verificación pública. Las vistas de la constancia (GET /acta,/acta/verificacion, declarar, revocar) añaden solo metadatos:sobre,sobre_sha256,sobre_xades_level,worm_sobre_protegidoyrevocacion_sobre*(equivalentes de la revocación). La verificación del fundamento añade la cuarta copiasobre/revocacion_sobre. Declarar/revocar sellan el sobre en el mismo flujo: sin sello,503 sellado_sobre_no_obtenidoy nada escrito..../worm/reintentartambién repone el artefactosobre.POST .../revocarya no devuelvefundamentonifundamento_salde la constancia original (null; la vista completa está enGET /acta, que asienta la lectura).POST /{id}/paquete-entrega→application/zip— paquete de entrega de la transferencia secundaria (B4, ADR-030 §5):USUA_PERM_ADMINnivel ≥ 3, no-read-up con404neutro (el clearance va en elWHEREdel expediente),409 paquete_solo_secundaria_recibidasi no es secundaria o no está recibida,409 componentes_sin_object_lock(componentes: rutas sin Object-Lock confirmado, oaip;componentes_clasificados: recuento de lasclasificado/...sin enumerarlas; fail-closed, no se descarga nada),409 componente_integridad_discrepante(acta, sello o componente que no reproduce su huella de captura; deja el asientotransferencia.paquete_entrega_incidente) y503 componente_no_disponible. También llevaindice/indice-electronico.xades.xmly la nota referencia el AIP (<aip>: bucket, clave y huella; no viaja en el ZIP). Contenido:acta/acta-fuid.xml(acta congelada, tal cual),acta/acta-fuid.xades.xml,acta/firma-personal-<rol>.xml,clasificado/constancias|revocaciones/<rol>-<n>.xml(cadena completa, también las revocadas, más-personal.xmlsi hay firma personal),clasificado/sobres/<rol>-<n>-<acto>.xml,nota-entrega.xml(urn:orpycamcp:nota-entrega-transferencia:v1: referenciaacta_fuid_sha256, y por componenteruta,sha256,acto,rol,ordinal,clasificacion="clasificada",fundamento="Ley 1712/2014 art. 18 lit. a",acceso="restringido a la entidad receptora";sobre="ausente"en constancias v2/legadas) ychecksums.txt. Los asientostransferencia.acta_fundamento_leido(via="paquete", uno por constancia cuyo sobre viaja) ytransferencia.paquete_entrega_generado(nota_sha256y huellas) se escriben en una transacción antes del primer byte: sin asiento no hay paquete. Nunca por OAI-PMH/EAD/CMIS.GET /{id}/acta/firma-inobtenible/{excepcion_id}/fundamento?acto=constancia|revocacion→FundamentoLectura{excepcion_id, acto, fundamento}— lectura a petición del texto del fundamento (FIRMA-2 ronda 3;USUA_PERM_ADMINnivel ≥ 3, no-read-up404,404 fundamento_not_foundsi no existe): deja el asientotransferencia.acta_fundamento_leidoconvia="lectura"antes de servir (fail-closed). Cambio de contrato:GET /actayGET /acta/verificacionya no devuelvenfundamento,fundamento_sal,revocacion_fundamentonirevocacion_fundamento_sal(siemprenull) y por tanto ya no asientan lectura al visitar la pantalla. La sal no se devuelve por ninguna vía de lectura: solo viaja dentro del sobre sellado. La verificación del fundamento no cambia.POST /{id}/acta/firma-inobtenible/{excepcion_id}/worm/reintentaryPOST /{id}/acta/firma-inobtenible/{excepcion_id}/revocacion/worm/reintentar→WormReintentoResponse{excepcion_id, acto, repuestos, artefactos}— salida deagotadodel Object-Lock de la constancia / de su revocación (ronda de auditoría A4;USUA_PERM_ADMINnivel ≥ 3, no-read-up404): repone a pendiente los artefactos agotados (protección o renovación CT) con el asientoarchive.acta_firma_{excepcion|revocacion}_worm_reintento_manualen la misma transacción, reintenta la protección y devuelve el estado real de cada artefacto (protegido/pendiente/agotado/omitido).404 excepcion_not_found/revocacion_not_found. Con la preservación apagada seguirá fallando: es lo correcto.
Archivo físico (E17, ADR-017)¶
Control de acceso (D-08): todos los endpoints requieren
USUA_PERM_EXPEDIENTE. Sin él:403 {"error": "forbidden"}.GET /expedientes/{id}/unidadestambién lo requiere; el frontend lo llama viaPromise.allSettledy trata el 403 como error de faceta sin bloquear la vista.
POST/GET /api/v1/ubicaciones,GET /api/v1/ubicaciones/{id}— topología recursiva (sede→…→gaveta);codigoúnico por tenant;rutaderivada (SED-001 / DEP-001 / EST-003), resuelta para todas las filas del listado con un CTE, no una consulta por fila. Filtros?parent_id=&activo=&solo_raiz=.solo_raizno equivale a omitirparent_id. Sinparent_idel listado no filtra por padre —lo natural en una API de listado, y lo que necesita quien puebla un desplegable con todo el archivo—;solo_raiz=truefiltraparent_id IS NULL, que es lo que necesita quien pinta el primer nivel de un árbol. Confundirlas hacía que la pantalla listara cada gaveta también como raíz. Pedir las dos juntas responde 422filtros_incompatibles: es una petición sin significado, y elegir una mitad en silencio sería ignorar la otra.POST/GET /api/v1/unidades,GET /api/v1/unidades/{id}— unidades de conservación (caja|carpeta|…); al indicarubicacion_idse deriva la signatura topográfica (DEP01-E05-C0124, única). Filtros?ubicacion_id=&tipo=.POST /api/v1/unidades/{id}/expedientes— vincular expediente a unidad (folio_inicio/fin).GET /api/v1/unidades/{id}/expedientes— qué contiene la unidad; filtrado por fila por clearance (RF-SEG-08, no-read-up): enumerar el contenido de una unidad revela la existencia de sus expedientes clasificados, así que un no-lector no ve los denivel_seguridad >su clearance (Ley 1712/2014 art. 19; fail-closed a PUBLICA,403sinX-User-Id).GET /api/v1/expedientes/{id}/unidades— dónde está el expediente (signatura +tipo/codigode la unidad + rango de folios; la signatura dice en qué entrepaño mirar, el código dice qué caja sacar); sin filtro de clearance a propósito: se consulta por unexpediente_idya conocido y solo devuelve ubicación física (signatura/folios, no asunto/serie) — es gestión de custodia ortogonal al clearance de lectura (mismo criterio que la transferencia).- Préstamos (RF-ARF-07):
POST /api/v1/unidades/{id}/prestamos(409 si ya prestada),POST /api/v1/prestamos/{id}/devolver,GET /api/v1/prestamos?estado=prestado|devuelto|vencido,GET /api/v1/unidades/{id}/prestamos.vencidose deriva defecha_devolucion_esperadacon< CURRENT_DATE: el préstamo que vence hoy todavía está en plazo. (La TRD usa la convención contraria,<=; unificarlas es una decisión de producto abierta.)GET /prestamospagina (?page=1&size=20, máx. 100) y emiteX-Total-Count—prestadoyvencidoestán acotados por la operación, perodevueltoes el histórico y crece con cada préstamo que se cierra; sin paginación el único recuento posible eraitems.length, que es el tamaño de la ventana haciéndose pasar por el de la consulta.GET /unidades/{id}/prestamostambién pagina (T16 / AREA-07-19:?page=1&size=20, máx. 100,X-Total-Countcon el total de esa unidad; antes devolvía todo su histórico con el argumento de que «la cota la pone el objeto», una cota supuesta y no impuesta). El BFF del frontend pidesize=100por omisión y conserva el total. Quién prestó/devolvió (T6 / AREA-02-08):movimiento_fisico.performed_byguarda el UUID deX-User-Id, no el login deX-Username(Keycloak permite renombrar a un usuario y el nombre es reasignable; la traza de custodia atribuida a un nombre deja de ser reconstruible). Igual elgenerado_pordePOST /expedientes/{id}/indice/rebuild. Las filas anteriores conservan el texto que tenían. Las cuatro rutas devuelvenunidad_codigoyunidad_signaturaademás delunidad_id: un préstamo se consulta para saber qué falta del estante, y en un depósito nadie identifica una caja por su UUID. - Historial (RF-ARF-08):
GET /api/v1/unidades/{id}/movimientos— movimientos append-only (RETIRADO/DEVUELTO/…). - FUID (RF-ARF-10):
GET /api/v1/fuid— inventario documental (expediente↔unidad↔signatura↔serie) en JSON con orden consecutivo y campos AGN 042/2002 —incluidosunidad_tipoyunidad_codigo, las fechas extremas y el rangofolio_inicio–folio_fin—; filtros?ubicacion_id=&trd_serie_id=.GET /api/v1/fuid.xmlexporta en XML. Cabecera del formato (Ac. AGN 042/2002, Anexo):entidad_productorala resuelve el servidor desdepublic.tenants—encabeza un documento archivístico oficial, y un campo libre convertiría el inventario de una entidad en el de cualquier otra—, mientras queunidad_administrativa,oficina_productorayobjetollegan como parámetros de consulta y son declarativos: los rellena quien elabora el inventario, igual que en papel. Cada fila lleva ademásfrecuencia_consulta(columna 9 del formato:alta/media/baja), derivada de los accesos registrados enaudit_logdurante los últimos 12 meses en vez de estimada — el conteo está acotado portenant_slug, porqueaudit_loges la única tabla compartida entre entidades y los códigos de expediente son consecutivos que coinciden entre ellas. Control de acceso (RF-SEG-08, no-read-up): el FUID describe contenido (asunto/serie/signatura), así que se filtra por fila por la clasificación del expediente — cada usuario solo ve en el inventario los denivel_seguridad ≤su clearance (totalcuenta solo lo visible; fail-closed a PUBLICA,403sinX-User-Id). EnGET /transferencias/{id}/fuid(un solo expediente) el filtro es un 404 total si el expediente supera el clearance (indistinguible de inexistente). La transferencia en sí (create/enviar/recibir/rechazar) es acto de custodia, ortogonal al clearance de lectura (no filtra por él, como el cierre/transferencia de expedientes).
TRD/CCD — la TRD como instrumento convalidado (E04, ADR-025)¶
Control de acceso (config archivística): las mutaciones de config (
POST/PATCH /api/v1/trd,POST /trd/{code}/versiones, el circuito de convalidación de abajo,POST/PATCH/DELETE /api/v1/tipos-documentales,POST /api/v1/expediente-metadata/templates) requierenUSUA_PERM_TRD(fail-closed:403sin tenant/user-id/permiso) y dejan asiento enaudit_log. Disposición de config —DELETE /tipos-documentales/{id},PATCH /trd/{id},POST /trd/{code}/versionesy el circuito de convalidación — exigecrud≥3(RF-SEG-03). Las lecturas GET no son todas iguales, y desde 2026-08-28 el código lo refleja (§4.bis del plan de pruebas). El vocabulario de clasificación —GET /api/v1/trd,GET /trd/{id},GET /trd/{id}/retentiony sus gemelos de TVD, másGET /api/v1/tipos-documentalesyGET /api/v1/expediente-metadata/templates— queda abierto a cualquier usuario autenticado del tenant, por decisión declarada: lo consumen/radicary/expedientespara poder clasificar, yUSUA_PERM_TRDlo tiene quien administra el instrumento, no quien lo usa —exigirlo aquí dejaría sin radicar a todo operador ordinario—; además el instrumento convalidado es de publicación obligatoria (Ac. AGN 004/2019, compilado por 001/2024; Ley 1712/2014 art. 9). El circuito sí exigeUSUA_PERM_TRD(crud≥1):GET /trd/revision-pendienteyGET /trd/{code}/versiones+GET /tvd/{code}/versiones, porque el historial devuelve los borradores, elacta_comite, lafecha_aprobacion_comite, lainstancia_convalidantey elcreated_byde cada versión —la deliberación del Comité, no el instrumento publicado—.Máquina de estados del instrumento (ADR-025, Ac. AGN 001/2024 que compila el 004/2019):
borrador → aprobada → convalidada, conmigradacomo estado de backfill (series preexistentes a la migración 037, "el sistema no sabe si fueron convalidadas") que puede ratificarse directamente aconvalidadasin pasar poraprobada, yaprobada → borrador(devolver, migración 038) como vía honesta de devolución con observaciones.borrador/aprobada/derogadano gobiernan un cierre (409 trd_version_no_vigente);convalidada/migradasí. La eliminación efectiva (disposición final, sin job todavía) exigirá que el snapshot congelado describa un instrumento convalidado —convalidada, oderogadacon acto (D5, gate corregido en la 038: verapp/core/trd_disposition.py) — el resto de las reglas están en el ADR.
GET /api/v1/trd/disposicion-vencida — cola de vencimientos (RF-TRD-08, 2026-08-28)¶
?fase=central|gestion (por defecto central) · ?disposition_code=CT|E|S|M · ?page · ?size · X-Total-Count. Exige USUA_PERM_TRD.
Expedientes cuyo calendario de retención ya venció, leído del snapshot congelado al cerrar (expedientes.disposition) y nunca de la fila viva de la serie: el calendario que gobierna a un expediente es el que se congeló, y una convalidación posterior no adelanta ni retrasa un vencimiento ya fijado (ADR-025 D3/D4). fase=gestion mira fin_archivo_gestion (toca transferir al archivo central) y fase=central mira fin_archivo_central (toca la disposición final) — dos colas con dos actos distintos.
Cada fila trae accion (qué corresponde, derivado del código de disposición: un vencimiento no es una orden de destruir — en CT significa transferir al histórico y conservar permanentemente) y gobierna_eliminacion, que responde exactamente lo mismo que responderá assert_disposicion_final_autorizada cuando exista el brazo ejecutor, porque ambos llaman al mismo predicado.
No-read-up (RF-SEG-08) en la misma condición que las filas, COUNT incluido: recortar solo la página dejaría que el total delatara cuántos expedientes clasificados hay vencidos. Deja traza agregada de consulta (RF-BUS-10, archive.expediente_disposicion_vencida_listado): enumerar lo que está a un paso de eliminarse debe dejar rastro de quién lo hizo.
Este endpoint no ejecuta nada. La eliminación efectiva (RF-TRD-07: borrado seguro con acta, aval del Comité y publicidad previa del inventario) sigue sin construirse.
POST /api/v1/trd/disposicion-vencida/{expediente_id}/decidir — la confirmación (RF-TRD-06/08, 2026-08-28)¶
Body {fase, decision, motivo?, revisar_de_nuevo_el?} · 201 · Exige USUA_PERM_TRD con crud≥3 (el mismo nivel que el circuito de convalidación: decidir es la precondición de una destrucción, no una anotación) y actor imputable (X-User-Id fail-closed).
«Ninguna destrucción es automática sin confirmación de usuario autorizado» (RT 04, regla 4). Dos decisiones, las dos no destructivas:
confirmada— el usuario autorizado declara que procede la acción de la TRD. No elimina nada: la respuesta traeejecutada: falsey una nota que lo dice, porque un200sobre un endpoint llamado «decidir» invita a creer que algo ocurrió.aplazada— R.4.9 del Modelo SGDEA. Exigemotivo(≥10 caracteres) yrevisar_de_nuevo_elfutura: un aplazamiento sin fecha de regreso es un olvido con mejor nombre. El vencimiento sale de la cola activa y vuelve solo en esa fecha (o antes, con?incluir_aplazados=true).
Lo que no se acepta del cliente: la fecha del vencimiento y el código de disposición no viajan en el cuerpo, salen del snapshot congelado en la base — si viajaran, confirmar una eliminación sería cuestión de mandar la fecha adecuada. El expediente se resuelve acotado por clearance y uno fuera de alcance responde 404, no 403.
Si la acción termina en destrucción (E, S o M — selección elimina lo no seleccionado y microfilmación elimina el original), se exige que el instrumento congelado la gobierne: llama a assert_disposicion_final_autorizada (ADR-025 D5) y responde 409 trd_version_no_convalidada_para_disposicion_final. Sobre CT el gate no aplica: conservación total no termina en destrucción. La decisión queda en la tabla append-only disposicion_decisiones (migración 046) y en la audit_log inmutable, en la misma transacción — si la cadena no puede recibir el asiento, la decisión no se toma.
POST/GET/PATCH /api/v1/trd— series y subseries documentales. Campos:code,name,parent_id(subserie),archivo_gestion_years/archivo_central_years(retención en dos fases),disposition(AGN:CT/E/S/M),version,valid_from,estado,valid_to,acto_administrativo,acta_comite,fecha_aprobacion_comite,fecha_convalidacion,instancia_convalidante,rusd_radicado,fecha_publicacion,supersede_a,created_by.POSTcrea siempreversion=1enborrador(estadono es settable por el cliente). Dos juegos de retención (AREA-06-07): el cálculo del calendario usa las dos fases (archivo_gestion_years/archivo_central_years);retention_years/total_retentionson el juego legado y la respuesta los deriva de las fases cuando están pobladas (retention_years= gestión,total_retention= gestión + central), así que nunca quedan en desacuerdo.PATCHacepta ahora las dos fases; unPATCHsolo del juego legado se traduce a ellas como en el alta, y elUPDATEreescribe las columnas legadas derivadas. Las fases son sustantivas: sobre una versión noborradordan409 trd_version_inmutable.PATCHes append-only (D1): solo aplicable sobreborrador— cualquier otro estado responde409 trd_version_inmutable, salvo que el único campo enviado seapdfa_profile(política de preservación técnica, editable en cualquier estado).is_activeya no se acepta enPATCH(la baja de una versión esderogar, abajo).POST /api/v1/trd/{code}/versiones— clona como fila nuevaversion+1enborradorla versión vigente (convalidada/migrada) del código; si el código no tiene vigente (todas sus versionesderogada), cae a la última versión por número, cualquier estado (migración 038 — un código derogado sin sucesora nunca queda sin camino de vuelta).404 trd_code_not_foundsolo si el código nunca existió;409 trd_version_en_tramitesi ya hay una versiónborrador/aprobadaen trámite.GET /api/v1/trd/{serie_id}/retention?closed_at=YYYY-MM-DD— calendario de retención:fin_archivo_gestion,fin_archivo_centraly disposición final (disposition_code/disposition_label) derivados de la serie y la fecha de cierre, más la procedencia del instrumento (serie_version,serie_estado,acto_administrativo,fecha_convalidacion,instancia_convalidante,rusd_radicado) — se declara tal cual está en la fila, nunca se afirma una convalidación que no ocurrió (RF-FIR-15).GET /api/v1/trd/{code}/versiones— (ADR-026, gemelo declarado por ADR-025 y nunca implementado) historia completa (cualquier estado) de un código, más reciente primero; lista vacía si el código nunca existió (no es un404).
Circuito de convalidación (ADR-025 D2/D6, Increment 2 + migración 038) — máquina de estados aislada en el service layer, validada antes de tocar BD (409 trd_transicion_invalida en toda transición fuera de las listadas):
POST /api/v1/trd/{code}/versiones/{version}/aprobar{acta_comite, fecha_aprobacion_comite}—borrador → aprobada(aprobación del Comité Institucional de Gestión y Desempeño; ambos campos obligatorios, migración 038 — antes el endpoint no recibía body y el sistema no podía registrar el primer acto del circuito). Condición necesaria y no suficiente para gobernar un cierre.POST /api/v1/trd/{code}/versiones/{version}/devolver{motivo}— nuevo (038):aprobada → borrador, conmotivoobligatorio. Vía honesta de "devolución con observaciones" (Ac. AGN 001/2024) — antes la única salida deaprobadaeraconvalidar, que exige declarar un acto que tal vez no existe todavía. Limpiaacta_comite/fecha_aprobacion_comite(la aprobación queda VOID).POST /api/v1/trd/{code}/versiones/{version}/convalidar{acto_administrativo, fecha_convalidacion, instancia_convalidante, rusd_radicado?, fecha_publicacion?}— una misma acción, dos orígenes: (a) desdeaprobada(circuito normal, exige queacta_comite/fecha_aprobacion_comiteya consten —409 trd_aprobacion_comite_requeridasi no, migración 038):aprobada → convalidada, y en la misma transacción deroga la versión previamente vigente del mismo código (si existía:valid_to=hoy,estado='derogada',is_active=false; si la derogación pierde una carrera concurrente, aborta409en vez de dejar FK huérfanas) y re-apunta (UPDATE, no clona, colisión-segura — una clave que ya existe en la versión nueva se omite, nunca revienta la tx) las FK detipos_documentales/expediente_metadata_templatesde la versión derogada a la nueva; (b) desdemigrada(ratificación D6):migrada → convalidadasin crear versión nueva ni derogar nada — la misma fila gana los campos del acto, y se limpiantrd_revision_requerida/trd_revision_motivode los expedientesclosed/transferredamarrados a ella cuyo motivo sea exactamentecerrado_bajo_serie_migrada(otros motivos, p. ej.serie_no_resoluble, no se resuelven así).acto_administrativo/fecha_convalidacion/instancia_convalidanteson obligatorios en el body (422si faltan; el CHECKtrd_series_convalidada_acto_checklos exige también a nivel BD);rusd_radicado/fecha_publicacionopcionales. Una vezconvalidada, el trigger de la 038 blinda a nivel de MOTOR el acto (acto_administrativo/fecha_convalidacion/instancia_convalidanteinmutables,estadosolo puede ir aderogada).POST /api/v1/trd/{code}/versiones/{version}/registrar-rusd{rusd_radicado?, fecha_publicacion?}(al menos uno) — nuevo (038): registra la inscripción RUSD y/o la fecha de publicación sobre una versiónconvalidada— la norma da hasta 30 días hábiles DESPUÉS de la convalidación para completarlos, y antes de la 038 el único momento en que el sistema los aceptaba eraconvalidar. Cada campo solo se puede rellenar una vez (NULL → valor);409 trd_rusd_ya_registrado/trd_fecha_publicacion_ya_registradasi ya tenía valor — el trigger de la 038 lo blinda también a nivel de motor.POST /api/v1/trd/{code}/versiones/{version}/derogar—convalidada/migrada→derogada(baja explícita del instrumento, sin sucesora;valid_to=hoy,is_active=false). Decisión (038, hallazgo #3 del dictamen): bloqueada con409 trd_derogacion_bloqueada_expedientes_abiertossi algún expedienteopenestá clasificado bajo el código — derogar el único instrumento vigente los dejaría sin ninguno que gobierne su cierre. No exige una sucesora ya convalidada (ese requisito bloquearía el caso de uso legítimo de retirar una serie que ya no produce documentos); el código nunca queda sin camino de vuelta gracias al fallback dePOST /versionesde arriba. No afecta expedientes ya cerrados bajo esta versión (snapshot congelado, D3/D4).GET /api/v1/trd/revision-pendiente(?page=1&size=50,X-Total-Count) — informe de expedientes contrd_revision_requerida=true(cualquier motivo:cerrado_bajo_serie_migrada,cierre_sin_snapshot_de_retencion,serie_no_resoluble), paginado. No-read-up (RF-SEG-08, corregido en la 038): filtra pornivel_seguridad ≤ clearancedel llamante en elCOUNTy en la página — antes cualquier portador deUSUA_PERM_TRD(permiso ORTOGONAL al clearance) enumeraba RESERVADA/CLASIFICADA; deja además traza agregada de consulta (RF-BUS-10) cuandoX-User-Id/X-Canalllegan. Pantalla: tercera pestaña de/admin/trd(«Revisión pendiente»), que distingue los tres motivos por lo que los resuelve — solocerrado_bajo_serie_migradase limpia al convalidar la serie; los otros dos no tienen vía automática y la interfaz lo declara en vez de ofrecer una acción inútil.
/api/v1/tvd — Tablas de Valoración Documental de fondo acumulado (E04, ADR-026, Increment 1)¶
La TVD es el mismo instrumento que la TRD (Ac. AGN 001/2024, que compila el 004/2019: mismo circuito Comité → Consejo → RUSD → publicación), modelado como discriminador tipo_instrumento ∈ {TRD, TVD} sobre la misma tabla trd_series — no hay tabla propia (ADR-026 D1: bifurcar el seam expedientes.trd_serie_id, que alimenta retain_until WORM COMPLIANCE en siete puntos irreversibles, es el diseño que el ADR descarta). Rutas espejo 1:1 de /api/v1/trd, mismo permiso USUA_PERM_TRD (misma potestad archivística, mismo Comité — crear un permiso separado exigiría una migración de RBAC por una distinción que la norma no hace), tipo_instrumento fijado por el router, nunca aceptado del cliente.
Qué distingue a la TVD (campos propios, todos obligatorios al crear y NULL por CHECK en toda TRD — trd_series_tvd_forma_check/trd_series_trd_forma_check, migración 039): fondo_nombre (la entidad productora, a menudo extinta), fecha_extrema_inicial/fecha_extrema_final (fechas extremas del fondo — la TVD no tiene fase de archivo de gestión, archivo_gestion_years queda siempre NULL/0), justificacion_valoracion (el objeto del instrumento — el fundamento que se citaría en un acta de eliminación, RF-FIR-15). El espacio de code es único y compartido con la TRD (Capa 1 del ADR): registrar una TVD con un código que ya usa una TRD responde 409 instrumento_code_en_uso indicando el tipo del ocupante.
Propiedad que define el Increment 1: una TVD todavía no gobierna ningún expediente. El trigger de amarre (trg_expedientes_pin_tipo) rechaza en el motor cualquier trd_serie_id/trd_id de expediente que apunte a una fila TVD — riesgo WORM cero, garantizado por construcción, no por omisión. La entidad puede registrar, aprobar, convalidar, inscribir en RUSD y publicar su TVD (que era el bloqueo original) sin que exista todavía ninguna ruta por la que gobierne un cálculo de retención. El Increment 2 (fuera de este incremento) relajará el trigger a expedientes.origen ↔ tipo_instrumento y dará a compute_retention una fecha base explícita (fecha_extrema_final, nunca closed_at — D4 del ADR).
POST /api/v1/tvd{code, name, description?, parent_id?, disposition, total_retention, archivo_central_years?, pdfa_profile?, fondo_nombre, fecha_extrema_inicial, fecha_extrema_final, justificacion_valoracion}— crea la agrupación versión 1 enborrador. No aceptaarchivo_gestion_years,estadonitipo_instrumento.409 instrumento_code_en_usosi el código ya pertenece a otro instrumento.GET /api/v1/tvd?page=&size=&is_active=— leev_tvd_agrupaciones(nunca la tabla base): el aislamiento entre TRD/TVD vive en elFROM, no en unWHEREolvidable.X-Total-Count.GET /api/v1/tvd/{id}—404si el id existe pero es TRD (lee la vista: no "se filtra", directamente no está).PATCH /api/v1/tvd/{id}— soloborrador(+pdfa_profileen cualquier estado), mismo gate append-only que TRD (409 trd_version_inmutable).POST /api/v1/tvd/{code}/versiones— clona la vigente comoborradorversion+1.GET /api/v1/tvd/{code}/versiones— historia completa, más reciente primero.POST /api/v1/tvd/{code}/versiones/{version}/aprobar{acta_comite, fecha_aprobacion_comite}—borrador → aprobada.POST /api/v1/tvd/{code}/versiones/{version}/devolver{motivo}—aprobada → borrador.POST /api/v1/tvd/{code}/versiones/{version}/convalidar{acto_administrativo, fecha_convalidacion, instancia_convalidante, rusd_radicado?, fecha_publicacion?}—aprobada → convalidada(deroga la vigente previa del mismo código en la misma transacción, re-apunta FK huérfanas). A diferencia de TRD, el único origen esaprobada: una TVD nunca alcanzamigrada(nace enborrador, la ratificación D6 de ADR-025 no aplica — la población TVD nace vacía).POST /api/v1/tvd/{code}/versiones/{version}/registrar-rusd{rusd_radicado?, fecha_publicacion?}.POST /api/v1/tvd/{code}/versiones/{version}/derogar—convalidada → derogada.
Nombres de asiento de auditoría distintos a propósito (archive.tvd_*, objeto tvd_agrupacion): en audit_log la decisión es la contraria a la del modelo de datos — quien filtra archive.trd_version_convalidada no debe tener que acordarse de mirar además un campo del payload.
POST /api/v1/expedientes/{id}/radicados/batch (inclusión masiva)¶
Vincula varios radicados de una vez {radicados:[{radicado_id, tracking_number, content_hash?, formato?, tamano_bytes?, folios?, tipologia?, dependencia_productora_codigo?, dependencia_productora_nombre?}]} (hasta 500; idempotente, omite los ya vinculados — a los ya vinculados no les actualiza metadatos); regenera el índice una sola vez. Los campos RT-15 (formato/tamano_bytes/folios) y la dependencia productora (código+nombre, v4) son opcionales pero código y nombre deben ir juntos o ninguno (validación Pydantic 422, espejo de RadicadoLink). Devuelve {vinculados, omitidos, total}. 409 si el expediente no está open (para un llamante sin clearance de lectura sobre un expediente clasificado, vinculados/omitidos llegan null y el 409 se sustituye por el ack acotado — ver la nota de control de acceso arriba).
Ciclo de vida del expediente (E02)¶
POST /api/v1/expedientes/{id}/close— cierra el expediente (open→closed), materializa la disposición TRD endispositiony genera+firma (XAdES-B) la versión final del índice electrónico vía signature-service (E06 Inc.1). El sellado es best-effort y no bloquea el cierre: si la firma se obtiene dejaexpediente_indice.estado='firmado'; si falla (sello no aprovisionado, servicio inalcanzable) dejaestado='pendiente_firma'y emite el auditarchive.indice_firma_pendiente. El audit de cierre incluyeindice_estado/firma_id. Respuesta:ExpedienteDisposicionResultacotado (id,code,status,disposition,closed_at,transferred_at) — no el expediente completo: la disposición es ortogonal al clearance de lectura (RF-SEG-08 Opción B), así que la respuesta no exponemetadata/description/radicados/opened_by, y un usuario con autoridad de disposición (crud≥3) pero sin clearance sobre un CLASIFICADA recibe 200 (antes: 404 espurio pese a cerrar con éxito).POST /api/v1/expedientes/{id}/indice/firmar— reintenta el sellado XAdES-B de un índicependiente_firma(E06 Inc.1). Idempotente:409si yafirmado,503si el sellado sigue sin obtenerse. RequiereUSUA_PERM_EXPEDIENTE+PERM_FIRMAyX-User-Id(400 si el UUID es malformado).POST /api/v1/expedientes/{id}/transfer— transfiere (closed→transferred).409si la transición es inválida o si el índice no estáfirmado(un índicependiente_firmabloquea la transferencia — Acuerdo AGN 001/2024 art. 4.3.2.4). El mismo bloqueo aplica en elPATCHgenérico constatus=transferredy en la recepción de transferencias (E12). Respuesta:ExpedienteDisposicionResultacotado (igual que/close), no el expediente completo.GET /api/v1/expedientes/{id}/eventos— hoja de ruta append-only del expediente (E02 §9, RF-EXP-09): lista cronológica de{id, accion, actor, comentario, detalles, ts}conaccion∈abierto|radicado_vinculado|radicado_excluido|cerrado|transferido|.... Control de acceso (Canal B / RF-SEG-08): visible a quien pueda leer el expediente, acotada por su clearance (no read-up); no requiere admin. Distinta deaudit_log(auditoría forense) y de/unidades/{id}/movimientos(movimientos físicos, E17).GET /api/v1/expedientes/{id}/foliado— foliado: radicados ordenados confoliosecuencial ({expediente_id, code, total_folios, items[]}). (El índice electrónico normativo es E15, abajo.)
Índice electrónico (E15, Acuerdo AGN 001/2024)¶
Perfil v4 "conforme completo" (E15, ADR-022; XSD
schemas/indice-v4.xsd): los índices nuevos se generan enurn:orpycamcp:indice:v4, que cierra los 4 residuales de v3. Por documento: metadatos RT-15 (<formato>/<tamano>/<folios>/<fechaIncorporacion>),<orden>estable (inmutable),<dependenciaProductora codigo="N">(aportada al vincular; se omite en filas legacy sin dato) y<fechaDeclaracion>(=added_at, declaración ≡ incorporación). En cabecera:<contexto>(fechaPrimerUso+ serie/subserie por nombre),<listaControlAcceso>con<nivelSeguridad>+<politicaAcceso modelo="no-read-up" requiere="USUA_PERM_EXPEDIENTE" clearanceMinimo="N">(política estable/portable — la matriz RBAC viva NO se incrusta, se consulta por endpoint),<pistaAuditoria registro="public.audit_log" objetoRef cadenaVerificable>(atesta la pista, no incrusta entradas vivas) y<conformidad perfil="v4" productoraPresente pistaAccesoDesde>(marcador per-instancia;productoraPresente="false"sobre conjunto vacío). El content_hash v4 canonicaliza el árbol completo (v3 solo<documentos>). La exclusión de un radicado es soft-delete (<documento excluido="true">con ordinal +<fechaExclusion>/<causalExclusion>→ hueco trazable, AGN 001/2024 art. 4.3.2.3). Los índices v1/v2/v3/v4 ya firmados quedan inmutables (verifyagnóstico por versión). Label: "conforme completo — aplicabilidad prospectiva" (instancias legacy pueden omitir productora y su pista de acceso inicia en la instrumentación; cada índice lo auto-declara en<conformidad>— RF-FIR-15, no sobre-declarar). Todas las lecturas del índice exigenUSUA_PERM_EXPEDIENTE+ clearance (no-read-up, RF-SEG-08) y dejan asiento de acceso enaudit_log(archive.indice_consultado, solo actor real + canalapi|ui|mcp). AREA-07-01: el XML se valida contra su XSD (lxml,app/services/index_xsd.py, esquema elegido por el espacio de nombres v2/v3/v4) antes de pedir el sello —al cerrar, en el reintento manual y en la reconciliación—: si no valida, el índice quedapendiente_firmacon motivoxsd_invalido(y el primer error en el asiento) y no se firma; un espacio de nombres sin XSD no se juzga. Cuando el sellado se agota (reconcile_agotado) se avisa por correo a quien puede repararlo (USUA_PERM_EXPEDIENTE+PERM_FIRMAy clearance del expediente; el correo no lleva códigos ni asuntos), además del asientoarchive.indice_reconcile_agotado(AREA-07-03). Los vínculos legados de expedientes abiertos sin productora/formato/tamaño se completan con lo que sabe document-service (solo columnas NULL, un intento por vínculo, asientoarchive.vinculo_origen_completado; los cerrados son probatorios y no se tocan).
GET /api/v1/expedientes/{id}/indice(?version=N) — índice vigente o por versión:{version, estado, num_documentos, xml_sha256, algoritmo, items[]}. Cada item incluyeformato,tamano_bytes,folios,fecha_incorporacion(nulos si no se declararon al vincular).GET /api/v1/expedientes/{id}/indice.xml(?version=N) — cuerpo XML (application/xml, namespaceurn:orpycamcp:indice:v4en índices nuevos;v1/v2/v3en los ya firmados).GET /api/v1/expedientes/{id}/export.zip— exportación del expediente a ZIP (E02/export, Ley 594/2000 art. 19). Copia de CONSULTA/ENTREGA (distinta del AIP de preservación E10 y de la transferencia FUID E12). El ZIP contiene:indice-electronico.xml(E15, si está firmado — best-effort),manifiesto.csv(radicados vinculados y excluidos —estos con su causal, sin bytes—, conorden_documental/folios/formato/dependencia_productora/fecha),documentos/{tracking}/{idx}-{filename}(bytes de los anexos),LEEME.txt(leyenda de alcance) ychecksums.txt(SHA-256 por miembro, para validar integridad). RequiereUSUA_PERM_EXPEDIENTE. No-read-up POR-RADICADO (RF-SEG-08):404si el expediente supera el clearance del llamante, y los radicados individuales por encima del clearance se omiten por completo (ni su existencia se enumera en el manifiesto) — no basta el gate a nivel de expediente. archive orquesta; el ensamblado de bytes lo hace storage-service (endpoint interno D-02, inalcanzable desde el gateway) sin persistir nada;arcnamesaneado anti zip-slip. La exportación deja una entrada de auditoría agregada (archive.expediente_exportado, RF-BUS-10). El ZIP se arma en un temporal en disco (sin tope de memoria; límiteEXPORT_MAX_BYTES, 4 GiB) y se sirve por trozos conContent-Length; errores previos al primer byte:413 export_demasiado_grande,507 espacio_insuficiente,502 export_no_disponible. Deuda: PDF combinado.GET /api/v1/expedientes/{id}/historial-acceso(?page&size, E15 v4 R3) — pista de acceso del expediente: leepublic.audit_log(solo lectura, filtrotenant_slugexplícito) filtrado porobject_ref = codeyobject_type ∈ (expediente, expediente_indice), ordents DESC, conX-Total-Count. Devuelve{ts, actor, canal, action, ...}. RequiereUSUA_PERM_EXPEDIENTE+ no-read-up (404 si el expediente supera el clearance del llamante, no 403 — no filtra existencia).actor_nombreacompaña alactordesde 2026-09: el asiento guarda el identificador interno —y así debe seguir, porque unusernamecambia y un asiento no se toca nunca— pero un historial que contesta con un UUID no contesta quién. Se resuelve conLEFT JOINpor TEXTO contraauth_users: el actor no siempre es una persona (hay asientos del propio sistema) y castear auuidharía fallar la consulta entera por una fila que ni se preguntaba; sin nombre resoluble viajanully la pantalla cae al identificador. Gemelo del dedocuments/{id}/historial-acceso, y un guardián enshared/tests/exige que ambos lo resuelvan.GET /api/v1/expedientes/{id}/roles-autorizados(E15 v4 R4) — snapshot vivo (no firmado) de la matriz de acceso: grupos que tienenUSUA_PERM_EXPEDIENTE(crud≥1) yrole_clearance.max_level ≥ nivel_seguridaddel expediente, como[{grupo, clearanceMax, crud}]ordenado por nombre. Mismo gate (USUA_PERM_EXPEDIENTE+ no-read-up). Es el dato que el índice firmado deliberadamente no incrusta (evita churn del artefacto probatorio y fuga de topología IAM en transferencias AGN).GET /api/v1/expedientes/{id}/indice/versions— historial de versiones (append-only) conX-Total-Count.GET /api/v1/expedientes/{id}/indice/verify(?version=N) — verifica integridad: contrasta elvalor_huelladel índice contra el hash actual de cada documento y re-hashea el XML almacenado contraxml_sha256(huella_indice_ok— detecta alteración del propio XML del índice, incluso no firmado). Con firma XAdES-B descarga el XML firmado del almacén de objetos y lo verifica contra signature-service — el bloquefirmaincluye{presente, valida, nivel_conformidad, acreditado, motivo, advertencias}(motivo='signed_xml_unavailable'→valida=false;advertenciaspropaga las de signature/verify, vacío si no hay, y puede incluirartefacto_vigente_no_resuelto;lta_vigente(B10):nullsi la firma no es LTA,falsesi el último sello ya no prueba y el nivel bajó a XAdES-LT). Se verifica el XML vigente (renovado) de la firma, y a él se refierelta_vigente; si signature no lo resuelve, se verifica el original y se añade la advertenciaartefacto_vigente_no_resuelto.validoes la conjunción de fixity por documento +huella_indice_ok+ firma (si presente). RequiereUSUA_PERM_EXPEDIENTE.GET /api/v1/expedientes/indices/pendientes-firma— lista operacional (paginada,X-Total-Count) de los índices en estadopendiente_firmade expedientesclosed/transferred, con sus contadores de reconciliación (reconcile_intentos,reconcile_agotado,reconcile_ultimo_error, versión,expediente_code) — visibilidad directa para el archivista de los índices que aún no se sellaron, incluidos losreconcile_agotado=trueque exigen intervención humana (montar el sello + retry manual). No-read-up: filtra pornivel_seguridad <= clearancedel llamante (enCOUNTy filas), un clearance bajo no ve índices de expedientes clasificados. RequiereUSUA_PERM_EXPEDIENTE(E15/E06 Inc.8).POST /api/v1/expedientes/{id}/indice/firmar— reintento manual del sellado XAdES del índicependiente_firma(exigeUSUA_PERM_EXPEDIENTE+PERM_FIRMA, atribuido al humano).409 indice_ya_firmadosi ya está firmado (idempotente),503 index_signing_failedsi sigue sin poder sellarse. Un retry manual re-arma el backoff de reconciliación (reconcile_agotado→false) para que el job automático reanude tras corregir la causa raíz. El asientoarchive.indice_firmadollevaorigen="manual".POST /api/v1/expedientes/{id}/indice/rebuild— regenera (nueva versión si cambió el conjunto o los metadatos;{unchanged:true}si idéntico).409 expediente_not_opensi el expediente no estáopen. Se crea la versión 0 automáticamente al crear el expediente. RequiereUSUA_PERM_EXPEDIENTE. Cada regeneración deja un asientoarchive.indice_regenerated(conxsd_version) enaudit_log.- Al vincular un radicado (
POST /api/v1/expedientes/{id}/radicados) se pueden declarar (el servidor los resuelve y coteja: ver el bullet siguiente)content_hash(SHA-256,valor_huella), los metadatos RT-15formato(MIME),tamano_bytesyfolios(opcionales,ge=0) y, en v4, ladependencia_productora_codigo(INTEGER) +dependencia_productora_nombre(texto) — opcionales pero juntos o ninguno (422el caso parcial). Desde 2026-09 tambiéntipologia(ladoc_classdel radicado, ≤100): el elemento<tipologia>del índice es obligatorio en el XSD y de tipoxs:string—que admite cadena vacía—, así que hasta ahora el índice se generaba, validaba contra el perfil v4 y se firmaba declarando tipología vacía para todos sus documentos;IndexService._items_fromconstruía todos los demás campos y omitía este. Viaja con el vínculo por la misma razón que sus hermanas: archive-service no lee la tablaradicados(cero consultas, frontera intacta) y resolverla al generar metería una llamada de red dentro de un artefacto que se firma. El buscador (SearchHit) devuelvedoc_classjustamente para que quien vincula pueda aportarla.NULLsigue significando «no declarada». - El vínculo no sella lo que escriba quien vincula (2026-10, T6 / AREA-07-02 y AREA-06-08). El índice se firma con XAdES, y hasta aquí «lo explícito manda»: número de radicado, tipología, dependencia productora, formato, tamaño y huella se aceptaban tal cual y ni siquiera se comprobaba que el radicado existiera (el cliente Keycloak del frontend es público —PKCE— y el MCP expone campos libres, así que el allowlist del BFF no protege el backend). Ahora archive-service resuelve todo en el servidor (document-service
POST /internal/documentos/metadata+ storage) y coteja lo declarado: radicado desconocido →404 radicado_no_encontrado; un valor distinto del resuelto →422 metadato_no_coincidecondesacuerdos: {radicado_id: [campos]}(nombra los campos, nunca el valor resuelto: no es un oráculo de números de radicado) y asientoarchive.radicado_vinculo_denegado; document-service sin responder (o sintracking_numberen la respuesta) →503 document_service_no_disponible—fail-closed para existencia y número—. Lo persistido es SIEMPRE lo resuelto; solofolioses del declarante. Mismo cotejo en…/radicados/batch, todo-o-nada. No-read-up: la resolución se hace con el clearance del llamante; un radicado por encima de su nivel da el mismo404 radicado_no_encontradoque uno inexistente (sindesacuerdos), y uno sindoc_classda422 radicado_sin_tipificar(la tipología es obligatoria en el índice y no se declara al vincular).POST /unidades/{id}/prestamosresponde403 clearance_insuficientesi la unidad contiene un expediente por encima del clearance de quien presta (gate en elWHEREdelINSERT). Límite declarado: si un radicado se retipifica DESPUÉS de vincularlo (PATCH /documents/{id}condoc_class), el índice ya generado conserva la tipología del momento del vínculo; propagar el cambio (eventodocument.radicado.retyped+ marca «requiere revisión TRD») sigue abierto.
Desde 2026-09-09 la dependencia_productora y el formato/tamano_bytes NO se esperan del cliente: los resuelve el servidor al vincular, preguntándoselos a document-service por su endpoint interno de metadata (origin_dept/origin_dept_code del radicado, y el MIME y el tamaño de su primer anexo por fecha). El motivo es el mismo por el que la huella tampoco se le pide al cliente: alimentan un índice que se firma, y quien vincula suele ser un archivista central y no la oficina que produjo el documento — el dato que aportaría sería el equivocado por construcción. Los campos siguen en el contrato y lo explícito manda (importación con metadatos propios, E11 INT-05). La resolución es de mejor esfuerzo: si document-service no responde, el vínculo se hace igual y los campos quedan vacíos. Se persiste un snapshot inmutable por versión del índice, de modo que GET /indice?version=N muestra la productora tal como estaba al generarse esa versión. Vincular/excluir regenera el índice automáticamente; sobre un expediente no open devuelve 409.
GET /api/v1/expedientes/search (búsqueda FTS, E09)¶
Búsqueda full-text de expedientes (ADR-014). q opcional (tsvector sobre code/name/description + ranking ts_rank); filtros ?status=&date_from=&date_to=&meta.<campo>=. Cabecera X-Total-Count. Sin texto = consulta filtrada ordenada por opened_at. Control de acceso (RF-SEG-08 / RF-BUS-04): solo se devuelven expedientes con nivel_seguridad ≤ el clearance del llamante (fail-closed a PUBLICA). El nivel se fija al crear el expediente (POST /api/v1/expedientes, campo nivel_seguridad, default 1).
/api/v1/tipos-documentales (3.er nivel TRD, RF-MET-07)¶
CRUD del catálogo de tipos documentales (POST/GET/GET {id}/PATCH {id}/DELETE {id}). Campos: code, nombre, trd_serie_id (serie/subserie), obligatorio, metadata_template (plantilla asociada). Filtros ?trd_serie_id=&activo=. 409 si la (serie, code) ya existe.
GET /api/v1/expedientes¶
Listar con filtros ?status=open&page=1&size=20.
GET /api/v1/expedientes/{id}¶
Obtener por UUID. Incluye lista de radicados vinculados. Control de acceso (RF-SEG-08): si el expediente supera el clearance del llamante, responde 404.
PATCH /api/v1/expedientes/{id}¶
Actualizar name, description, trd_serie_id, status.
Transiciones válidas: open → closed → transferred. No se puede reabrir.
POST /api/v1/expedientes/{id}/radicados¶
Vincular radicado al expediente.
DELETE /api/v1/expedientes/{id}/radicados/{radicado_id} (?causal=…)¶
Excluir (desvincular) radicado. Es soft-delete (E15 v3): marca estado='excluido' (+ excluido_at, excluido_causal del query param opcional) — no borra la fila; el orden_documental no se reutiliza y el índice deja el hueco trazable. 204. Exige USUA_PERM_EXPEDIENTE con crud≥3 (disposición). 404 si no estaba vinculado.
TRD¶
Ver la sección "TRD/CCD — la TRD como instrumento convalidado" más arriba (§ archive-service) para el contrato completo y actualizado (ADR-025: versionado append-only, máquina de estados, circuito de convalidación). Los ejemplos de aquí quedan solo como referencia rápida de forma del body.
POST /api/v1/trd¶
Crear serie documental (siempre version=1, estado='borrador').
{
"code": "100",
"name": "Contratos",
"retention_years": 5,
"total_retention": 10,
"disposition": "conserve"
}
GET /api/v1/trd¶
Listar series. Filtro: ?is_active=true&page=1&size=50.
GET /api/v1/trd/{id}¶
Obtener serie por UUID.
PATCH /api/v1/trd/{id}¶
Actualizar serie — solo aplicable si estado='borrador' (append-only, ADR-025 D1); en cualquier otro estado usar POST /trd/{code}/versiones.
notification-service — puerto 8007¶
POST /api/v1/notifications/send¶
Envío de notificación por email. Responde 202 (aceptado para envío async). El historial se persiste en la BD del tenant_slug indicado.
{
"recipient_email": "juridica@icetex.gov.co",
"subject": "Nuevo radicado",
"body": "...",
"tenant_slug": "icetex",
"sensible": false
}
Endpoint servicio-a-servicio (D-02): es la vía por la que signature-service despacha los OTP de 2FA (los demás envíos entran por el stream de eventos, no por HTTP). Exige X-Internal-Token (secreto compartido INTERNAL_SERVICE_TOKEN) — sin él 401, inválido 403. Fail-closed: sin el token configurado en el despliegue, rechaza toda llamada. El sello impide que un usuario externo, vía el gateway, envíe correo arbitrario con la identidad SMTP de la institución.
sensible(opcional, defaultfalse): marca el correo como sensible (p. ej. el OTP del 2FA de firma, RF-FIR-13). Consensible=trueel correo se envía por SMTP con el body real pero el historial persiste elbodyredactado ([contenido sensible omitido]) y unlast_errorgenérico — el secreto nunca queda en BD/backups. Todo emisor de correos con secretos debe fijar este flag.
GET /api/v1/notifications/history¶
Historial de notificaciones del tenant (persistido en BD por institución). Requiere X-Tenant-Slug. Query: ?limit=20. Exige USUA_PERM_ADMIN: devuelve TODAS las notificaciones del tenant sin filtro por usuario (asuntos/cuerpos de correos ajenos), así que es una vista administrativa/auditoría, no un buzón personal.
GET /api/v1/notifications/{id}¶
Obtener notificación por UUID. Requiere X-Tenant-Slug y USUA_PERM_ADMIN (misma razón que el historial). 404 si no existe.
Firma electrónica — signature-service, puerto 8008 (E06, ADR-016)¶
Proveedor enchufable (app/providers/): nativa (HMAC hash+identidad, default para todo objeto) y xades_local (XAdES-B enveloped real vía signxml, activo solo para el índice electrónico). Selección: xades_local cuando objeto_tipo=="indice" y SIGNER_PROVIDER=="xades_local" (default; kill-switch SIGNER_PROVIDER=nativa); cualquier otro caso → nativa. Ver ADR-016 addendum Inc.1.
POST /api/v1/signature/sign-xml{payload_xml, objeto_tipo, objeto_id?, formato?, indice_id?, version?}— firma el payload y persiste la firma. Conobjeto_tipo="indice"produce XAdES-T (XAdES-B con el sello institucional del tenant + sello de tiempo RFC 3161 de una TSA, E06 Inc.4) y, siSIGNER_LT_ENABLED=truey el sello del tenant trae material de CA (ca.crt+crl.der), escala a XAdES-LTA (E06 Inc.5): añadexades:CertificateValues(solo la CA) +xades:RevocationValues(CRL) +xadesv141:ArchiveTimeStamp. ConSIGNER_OCSP_ENABLED=true(defaultfalse) y el par de responder OCSP montado (ocsp.crt+ocsp.key, per-tenant junto al sello), añade además una respuesta OCSP stapled (RFC 6960) comoxades:OCSPValues/EncapsulatedOCSPValuehermana del CRL dentro del mismoRevocationValues(E06 Inc.7) — firmada por un responder delegado emitido offline por la CA del tenant, concertStatusderivado de la CRL, y queda cubierta por elArchiveTimeStamp. ExigePERM_FIRMAconcrud≥2+X-User-Id(el firmante nunca sale del cuerpo, RF-FIR-11). Solo sello institucional (B9-3):objeto_tipo∈indice|acta_transferencia|acta_eliminacion(conjunto cerrado; cualquier otro, tambiénradicado|expediente|borrador|documento, →422) yX-Internal-Tokenes obligatorio siempre (AREA-04-07, D-02): lo envía archive-service junto a la identidad firmada; un usuario con soloPERM_FIRMArecibe401/403(antes podía estampar el sello de la entidad sobre un XML arbitrario) y503si el servidor no lo tiene configurado (fail-closed). La firma humana de radicados, expedientes y borradores va solo por la cadena (/requests: turno, 2FA y gate XAdES); antes este endpoint daba, con soloPERM_FIRMA, una filafirmaverificable que esquivaba todo ese circuito. Devuelve{firma_id, payload_sha256, provider, minio_key_firmado?, xades_level?, nivel_conformidad?, tipo_firma?, certificado_fingerprint?, acreditado, tsa_timestamp?, lt_material_present?, archive_timestamp_present?, crl_next_update?, revocation_provenance?, ocsp_present?, ...}.xades_level∈XAdES-B|XAdES-T|XAdES-LT|XAdES-LTAsegún lo alcanzado (nunca lo intencionado; OCSP no cambia el nivel, enriquece la evidencia de revocación); sin material CA la firma se queda en XAdES-T y deja auditfirma.lt_downgrade_no_ca; con OCSP habilitado pero sin par de responder deja auditfirma.ocsp_downgrade_no_responder(se queda en LT/LTA con CRL sola).acreditadosale de lo persistido al firmar (nivel_conformidad, que decide el sello montado: solo un sello conacreditacion.json{"acreditado": true}en un volumen de solo lectura daxades_*_acreditado); con la CA/TSA/responder OCSP de dev esfalse(no ONAC — material de validación verificable, no acreditado, RF-FIR-15;nivel_conformidad∈xades_b|xades_lt|xades_lta_no_acreditado, revocación de dev marcada conrevocation_provenance="dev").payload_sha256es el SHA-256 de los BYTES que se firmaron, no del documento (AREA-05-07): paraxades_personalson los del envoltorio<acto xmlns="urn:orpyca:firma-personal" …>, para la firmanativade un borrador es el hash del texto hexadecimal de la huella, y pararadicado/expedienteel digest de"{objeto_tipo}:{objeto_id}"; solo es comparable entre firmas del mismoprovidery no sirve para cotejar un PDF en la mano (la huella del documento esfirma_solicitud.payload_sha256). Desde la migración020la firmanativase produce conkey_version=1: clave HKDF-SHA256 derivada por entidad (signature_secretsigue siendo el secreto raíz, no se rota) y mensaje que atatenant|objeto_tipo|objeto_id(AREA-04-11) — una fila copiada a otro schema deja de verificar; el esquema anterior (key_version=0) se retiró en B9 (migración024, que además quita elDEFAULTde la columna): una firmanativacon0ya no verifica y/verifyrespondevalida=false,motivo=esquema_hmac_retiradoyfirma_criptograficamente_valida=null(nofalse: no se evaluó nada, yfalseacusaría una manipulación; tampocohmac_no_coincide, que también la acusaría).VerifyResponse.firma_criptograficamente_validaesbool | null; la migración tenant025añade elCHECK (provider <> 'nativa' OR key_version = 1)yFirmaRepository.insertexigekey_version=1explícito a la nativa (sin default silencioso).0queda solo como «sin HMAC de servicio» en las firmas no nativas.503 signing_seal_unavailablesi falta el sello;503 timestamp_authority_unavailablesi la TSA no está disponible;503 lt_material_requiredsiSIGNER_LT_REQUIRE=truey falta material CA;503 ocsp_material_requiredsiSIGNER_OCSP_REQUIRE=truey no se produjo respuesta OCSP (falta material de CA o de responder);503 signed_index_upload_failedsi storage rechaza la subida.POST /api/v1/signature/sign-personal{payload_xml, objeto_tipo, objeto_id?, totp_code, firmante_id?}— firma personal PKI de un payload XML arbitrario con el certificado del actor autenticado (E17/F4 Inc.1; primitivopersonal_signing.sign_personal_xmlreutilizado por la cadena de radicados y por la firma del acta de transferencia). Autenticado como usuario (get_actor+PERM_FIRMA,X-User-Id/JWT — noX-Internal-Token: lleva identidad + TOTP del firmante real, a diferencia de/internal/sign-indice). ASSERTfirmante_id == user(JWT): si el body traefirmante_idy difiere del actor derivado del token →403 firmante_no_coincide(no suplantable por el body; la fila persistida usa siempre el actor del JWT). Arma el XAdES con clave RSA efímera + el cert público real, pide a auth-service que firme el digest deSignedInfoconsumiendo el TOTP atómicamente (split firma-remota), y sube el XML firmado a el almacén de objetos con nombre determinista que incluye elfirma_id(patrón H-H, sin sobrescritura por carrera). Devuelve elFirmaResponseestándar (firma_id,minio_key_firmado,xades_level,provider="xades_personal",acreditadosiemprefalse,nivel_conformidad='xades_personal_no_acreditado'). El gate no-read-up ya no depende deobjeto_tipo:objeto_tipoes texto libre aquí (tiene que serlo — por esta ruta pasan las actas institucionales), y mientras el gate colgaba de== "radicado", declarar cualquier otro valor sobre el identificador de un radicado CLASIFICADA lo evaluaba contra nivel 1 y lo saltaba. Ahora el nivel se resuelve por elobjeto_idcontraradicados/expedientes, sea cual sea el tipo declarado; si no resuelve, es un objeto sin nivel propio (acta, borrador) y queda en PUBLICA, y para esos el no-read-up lo sigue aplicando el servicio mediador (archive) antes de llamar — verificado que una llamada directa no es bypass (solo firma el payload que el propio caller aporta, no inyecta en las tablas de dominio).403 identidad_incoherentesi el dueño del bearer reenviado (que es de quien es la credencial personal y el TOTP) no es el actorX-User-Idal que se atribuye la firma (AREA-04-05): se corta antes de pedir ningún factor y deja asientofirma.identidad_incoherente.4xxsi el TOTP es inválido;503si el canal de auth cae.POST /api/v1/signature/verify{firma_id, payload_xml}— verifica. Firmasxades_local:payload_xmldebe ser el XML firmado completo (el deminio_key_firmado); valida criptografía + cobertura de la raíz + vigencia del cert + huella del sello + coincidencia consigned_xml_sha256; XAdES-T valida además el token RFC 3161 (cubre elds:SignatureValue, firma CMS, cert TSA atado afirma.tsa_cert_fingerprint); XAdES-LT/LTA valida además (fail-closed) la cadenaseal→CA(anclada a la CA del tenant vía pinningca_fingerprint— un sello de la CA de otro tenant falla), la firma y no-revocación de la CRL (serial del sello no revocado), y elArchiveTimeStampsobre el conjunto cubierto determinista;crl_next_updatese expone sin hard-fail si caducó (no acreditado). Si la firma traexades:OCSPValues(E06 Inc.7), valida además (fail-closed) la respuesta OCSP (RFC 6960):responseStatus=successful, responder emitido por la CA del tenant + vigencia, EKUOCSPSigning+id-pkix-ocsp-nocheck, firma delBasicOCSPResponse, CertID atado al sello (serial + issuer hashes),certStatus=good, y pinning del fingerprint del responder contrafirma.ocsp_responder_fingerprint(sin él → fail-closed); su ausencia es retrocompat (LT válida sobre CRL sola). Firmasxades_personal(firma personal PKI por-usuario, E06/E17 Inc.1-2): valida cripto + cobertura + vigencia + huella del leaf; si la firma embebe la sub-CA (xades:CertificateValues, Inc.2) ancla además la cadena leaf→sub-CA de usuarios del tenant con pinninguser_ca_fingerprint(rama separada de la del sello — no exige CRL embebida); y consulta la revocación en línea (estado en BD, sin caché) →valida=false(motivo=certificado_revocado) si el cert fue revocado. El veredicto se desglosa:firma_criptograficamente_valida(cripto+cadena a secas) yrevocacion_status ∈ {vigente, revocado, no_verificable, desconocido};valida = cripto AND revocacion_status=='vigente'salvo revocación POSTERIOR a la firma (AREA-04-13): si el certificado se revocó después del instante de la firma (SOLO elgenTimede una TSA verificada: elsello_tiempodel servidor no es fecha cierta) la firma sigue siendo válida (valida=true,revocacion_status="revocado") y la respuesta llevarevocacion_posterior=true; revocada antes (o sinrevocado_atlegible) →valida=false. Si el motivo de la revocación eskey_compromiseinvalida hacia atrás (valida=false,motivo=clave_comprometida); sin TSA verificada no hay instante cierto:valida=false,motivo=instante_firma_no_cierto,revocacion_posterior=null; un canal de revocación caído →valida=false/motivo=revocacion_no_verificableperofirma_criptograficamente_valida=true(default fail-closed,verify_personal_revocation_require). Firmasxades_personalde Inc.1 (sinCertificateValues) verifican como XAdES-B/T (grandfather) pero siguen siendo revocables por identidad del cert. Firmasnativa:payload_xmles el original en claro; se recomputa el HMAC con el esquema de lakey_versionde la fila (solo1; una filanativacon otra versión respondeesquema_hmac_retiradosin recomputar), y con la entidad de la petición. Sello de tiempo (XAdES-T, AREA-04-08): elgenTimedebe caer dentro de la vigencia del certificado firmante (motivointernogentime_fuera_de_vigencia_firmante→sello_tiempo_valido=false) y, si el token lleva el atributosigning-certificate-v2(ESSCertID, RFC 5035), su hash debe coincidir con el certificado de la TSA; su ausencia se rechaza (tsa_sin_esscertid) si elgenTimedel sello es igual o posterior a la fecha de corteTSA_ESSCERTID_CORTE(ISO 8601, sin zona = UTC; global —la TSA es infraestructura del despliegue—; default = fecha de despliegue de este cambio,2026-10-04T00:00:00Z); antes del corte el sello sigue valiendo y la respuesta lo declara enadvertencias(tsa_sin_esscertid_anterior_al_corte: los tokens de la TSA local previos a AREA-04-08 no lo llevan y están embebidos, inmutables, en firmas ya emitidas). Sustituye al booleanoTSA_ESSCERTID_OBLIGATORIO.acreditadose deriva de lo persistido al firmar (nivel_conformidad∈xades_{b,lt,lta}_acreditado) y solo si la criptografía valida (AREA-05-02); antes era un literalfalse. Devuelve{firma_id, valida, provider, xades_level?, nivel_conformidad?, acreditado, motivo?, sello_tiempo_valido?, tsa_timestamp?, lt_material_present?, archive_timestamp_present?, crl_next_update?, revocation_provenance?, ocsp_present?, ocsp_cert_status?, firma_criptograficamente_valida?, revocacion_status?, revocacion_posterior?, lta_vigente?, advertencias[]}(lta_vigente: bool|null, B10:nullsi la firma no es LTA;falsesi el certificado de la TSA del últimoArchiveTimeStampya caducó a la fecha actual: el nivel verificado baja aXAdES-LT, se añade la advertenciaarchive_timestamp_caducadoy no se aplica la vigencia diferida) (advertencias: list[str]es aditivo y los servicios que consumen/verifydeben conservarlo: archive-service lo propaga en suVerifyResult) (en/verifyel campo autoritativo del nivel realmente verificado esxades_level, derivado del resultado). No es público (AREA-04-10): por el gateway exige JWT y el tenant sale del token; la verificación verdaderamente pública va por/api/v1/public/con código de verificación (E13). Dentro de la entidad aplica el no-read-up: la firma de un radicado/expediente por encima del clearance del llamante da el mismo404 firma_not_foundque unfirma_idinexistente (el nivel va en elWHEREde la consulta); lo mismo enGET /api/v1/signature/firmas/{firma_id}. Las llamadas de servicio a servicio sinX-User-Id(archive-service verificando el sellado de un índice) no tienen clearance que aplicar.404si la firma no existe.- Conformidad XMLDSig (ADR-034, B7). Toda firma XAdES de
sign-xml/sign-personal/cadena declarads:Transformsexc-c14n en cadads:Reference(documento,SignedProperties,KeyInfo), firma elSignedInfoya parcheado y se autoverifica antes de sellar el tiempo con unXAdESVerifier()por defecto: si no la verifica un validador estándar,503 firma_no_conforme_xmldsigy no se persiste nada.verifyverifica solo conforme al estándar —sin atajo de canonicalización—; una firma anterior a ADR-034 (referencias sinTransforms) respondevalida=false,motivo=firma_invalida: …. Validado contra EU DSS: criptografía íntegra (SignatureIntacty todos losDataIntactde referencias y sellos), conINDETERMINATE/NO_CERTIFICATE_CHAIN_FOUNDcomo mucho por la CA de desarrollo (sin confianza ni acreditación:acreditado=false). ElArchiveTimeStampde XAdES-LTA sella la entrada de EN 319 132-1 §5.5.2 (octetos de cada referencia,SignedInfo,SignatureValue,KeyInfoy propiedades no firmadas previas). Vigencia (ronda de corrección B7): no se firma con un certificado fuera de vigencia —sign-xml/internal/sign-indicecon el sello caducado responden503 sello_institucional_vencido;sign-personaly la cadena con el certificado personal caducado,422 certificado_personal_vencido, antes de que auth-service verifique el TOTP—;XmldsigNoConformeErroren las rutas personales es también503 firma_no_conforme_xmldsig(el TOTP ya está consumido en ese caso). Enverify, el certificado se juzga a «ahora» salvo en una firma XAdES-LTA cuyoArchiveTimeStampvalida y cuyo certificado caducó después de firmar: se juzga a la fecha delSignatureTimeStampyadvertenciasincluyecertificado_vencido_vigencia_juzgada_al_sello_de_firma; sin LTA válido sigue siendomotivo=certificado_fuera_de_vigencia.SignatureTimeStampyArchiveTimeStampse verifican con la canonicalización que declaran. GET /api/v1/signature/firmas/{firma_id}— metadatos de una firma (mismos campos quesign-xml, sincertificado_pemcompleto). Autenticado (JWT, sin permiso específico; no es ruta pública del gateway; no-read-up).404si no existe. Desde B10 exponeminio_key_vigenteyarchive_timestamp_ordinal(el XML vigente tras renovar elArchiveTimeStamp).- Renovación del
ArchiveTimeStamp(B10, ADR-034 §5). Un job diario de signature renueva el ATS de XAdES-LTA antes de que caduque el certificado de su TSA (TimeStampValidationData+ ATS nuevo sobre todo lo previo)./verifyacepta el XML original o cualquier XML vigente de la cadena de sellos (anclado a las TSA registradas) y valida cada ATS (archive_timestamp_cadena_rotasi uno se renovó tarde). Cada renovación emitefirma.lta_renovadaenorpycamcp.signature.events(firma_id, ordinal, minio_key, xml_sha256, gen_time; best-effort). - (Interno, servicio-a-servicio; no alcanzable desde el gateway —
X-Internal-Tokenno está enFORWARDED_REQUEST_HEADERS)POST /api/v1/signature/internal/sign-indice{payload_xml, objeto_id, version, objeto_tipo?, indice_id?}— sella un objeto XAdES-elegible atribuido al principal de sistemaSYSTEM_RECONCILER_ID(no a un humano), para los jobs de reconciliación de archive-service (índice E15/E06 Inc.8, y acta de transferencia H-H). Autenticado solo porX-Internal-Token(fail-closed: settings vacío → 503, header ausente → 401, errado → 403); no exigePERM_FIRMAniX-User-Id.objeto_tipo(default"indice") se valida contra el allow-set{"indice","acta_transferencia"}(422fuera de él) → solo esos dos tipos producen el sello institucional del tenant (que NO deriva del actor); estructuralmente no puede firmar la cadena personal. El asientofirma.creadallevaorigen="reconciliation". La firma personal (/requests/{id}/sign,/sign-xml) NUNCA acepta este token.
Cadena de firma (bandeja del firmante, RF-FIR-04). Todas exigen PERM_FIRMA; el firmante se deriva del JWT (nunca del cuerpo, RF-FIR-11). El turno activo es el de menor orden aún pendiente. La firma personal exige 2FA (RF-FIR-13): OTP-correo (E06 Inc.2) o, más fuerte, TOTP RFC 6238 (posesión de dispositivo, E06 Inc.6, ver ADR-016 addendum Inc.6). El secreto TOTP vive en auth-service (cifrado AESGCM); ver POST /api/v1/auth/me/totp/* abajo. Formato de la firma personal (E06/E17 F4, Inc.1): si el firmante tiene una clave de firma PKI activa (ver /me/signing-key/*), la cadena produce una firma XAdES-B/T por-usuario con su propio certificado (verificable por terceros vía /verify, provider=xades_personal); si no, degrada a la firma HMAC nativa (provider=nativa, retrocompat). Ambas son firma electrónica art. 7 no acreditada (acreditado=false) — la PKI por-usuario añade verificabilidad/formato/separación criptográfica, no valor legal reforzado (custodia servidor ⇒ sin sole-control). Gate XAdES por nivel (Inc.3; política de la entidad: parámetro firma_personal_require_xades ∈ off|clasificados|todos, default off, editable con PUT /config/params/firma_personal_require_xades; el env FIRMA_PERSONAL_REQUIRE_XADES solo respalda a la entidad que no lo define, y un valor desconocido en la tabla se aplica como todos): con clasificados, un acto de nivel_seguridad>=2 ya no degrada a nativa — sin cert activo → 422 firma_xades_requerida (con guía de enrolamiento), auth-service caído → 503 canal_firma_personal_no_disponible (fail-closed); con todos, aplica en cualquier nivel. La denegación deja asiento firma.xades_requerida_denegada atómico y no consume ningún factor 2FA. En batch se evalúa por ítem. El env legacy bool se acepta (false→off, true→todos); un valor no reconocido aborta el arranque. Habilitar clasificados solo tras completar el enrolamiento de los firmantes con clearance>=2 (la emisión del cert es offline).
POST /api/v1/signature/requests{objeto_tipo, objeto_id, titulo?, payload_sha256?, firmantes:[uuid,…], roles?:["visto_bueno"|"firma",…], nivel_seguridad?}— crea la cadena (turnosorden1..N).409 cadena_existentesi el objeto ya tiene una cadena abierta.409 radicado_anuladosi el objeto es un radicado sin efecto: un documento anulado no recoge firmas. El mismo control corre al firmar un turno y al dar visto bueno, no solo aquí — anular puede ocurrir entre la apertura de la cadena y cada turno, y comprobarlo solo en la creación dejaría firmables todas las cadenas ya abiertas. Se evalúa después del no-read-up, para no confirmarle la existencia del objeto a quien no lo alcanza.rolesdescribe los mismos turnos quefirmantesy debe tener su misma longitud, con al menos unfirma: un circuito de solo vistos buenos no produce documento definitivo. De dónde salepayload_sha256, que no es lo mismo en todos los tipos. Paradocumentoes obligatorio en el cuerpo, porque no hay productor a quien preguntárselo. Paraborradorlo resuelve el servidor contra document-service y el del cuerpo se ignora — una huella que el llamante elige no vincula a nadie —, así que no se le pide: exigirlo obligaba a mandar un valor destinado a la basura, es decir, a inventarse una huella. Y la de un borrador es la del TEXTO (cuerpo_sha256), no la del PDF/A: el PDF solo existe al radicar, así que anclar al documento hacía el circuito inalcanzable —solo podía abrirse después de radicar, cuando aprobar ya no sirve—. Los turnos de visto bueno aprueban un texto; el turno de firma produce el documento definitivo.409 borrador_sin_textosi el cuerpo está vacío. Invalidación (RF-PRO-11): al resolver cualquier turno se relee la huella vigente y se compara; si el texto cambió →409 documento_modificadoy las aprobaciones anteriores no se arrastran. Es la defensa que manda, síncrona: el evento equivalente es at-least-once y queda como limpieza de bandejas, no como garantía. No poder comprobar deniega (503). Nota: para firmar con 2FA la solicitud debe tenerpayload_sha256estable.- Se firma el contenido, no el identificador (AREA-04-09). Para
radicadoyexpedienteelpayload_sha256del cuerpo se ignora: la huella la resuelve el SERVIDOR al abrir la cadena —SHA-256 del documento principal (anexoes_principal) del radicado (document-service) o del índice electrónico vigente del expediente (archive-service), ambos por endpoint interno conX-Internal-Token— y es la que se firma. Antes de cada firma y de cada visto bueno se relee: si cambió,409 documento_modificado(hay que abrir una cadena nueva). Fail-closed: productor caído →503 verificacion_documento_no_disponible; sin contenido que sellar →409 radicado_sin_documento/indice_no_disponible/anexo_sin_huella; objeto inexistente → el404neutro. Cadenas abiertas antes de este cambio (huellaNULL) no se firman a ciegas:409 cadena_sin_huella; mientras siganpendienteocupan el índice de cadena abierta, así que el operador las invalida en lote conpython -m app.ops.invalidar_cadenas_sin_huella [--dry-run] [--tenant slug](estadoinvalidada,invalidada_motivo='cadena_sin_huella', un asientofirma.cadena_invalidadapor cadena en la misma transacción; idempotente) y entonces se abre la de reemplazo. El 503verificacion_documento_no_disponiblenombra solo al productor (document-service/archive-service) y el código HTTP: nunca reenvía el cuerpo que respondió. Los asientosfirma.solicitud_creadayfirma.turno_firmadollevanhuella_documento_sha256(la fijada al abrir la cadena) yfirma.turno_firmadoademásgate_modo(la política XAdES efectiva). Y donde la cadena no fija huella,/signrechazapayload_xmlcon422 payload_xml_no_admitido: el firmante no elige los bytes al firmar. objeto_tipoacotado aradicado·expediente·borrador(422fuera del conjunto).documentosalió del conjunto (2026-09-12): estaba admitido sin productor, de modo que una cadena sobre él se creaba (201) y no se podía firmar nunca (409 documento_desaparecido, porque la guarda de vigencia lo buscaba como borrador). Vuelve el día que exista un artefacto canónico que firmar, con su fuente de huella. No es validación cosmética: el nivel de seguridad de la cadena se resolvía comparando este campo —texto libre del body— contra el literal"radicado", así que"Radicado"bastaba para saltarse la fuente autoritativa (RF-SEG-08).- El nivel lo decide el objeto, no el body. Si
objeto_idresuelve contraradicadosoexpedientes, manda elnivel_seguridadreal de esa fila y el del body se ignora — se resuelve sin mirarobjeto_tipo, de modo que declararborradorsobre el identificador de un radicado CLASIFICADA tampoco rebaja nada.nivel_seguridadsolo surte efecto para objetos sin registrar (hoyborrador), donde no hay tabla que consultar. - Fail-closed:
objeto_tipo∈ {radicado,expediente} cuyoobjeto_idno resuelve →404 solicitud_not_found, el mismo neutro del no-read-up (no distingue «no existe» de «no lo alcanzas»). Antes se caía al nivel declarado en el body. - El nivel que rige el acto se relee bajo bloqueo (B9-2, SEG-B4). En
/requests/{id}/signy en/challenge, dentro de la transacción y con la fila de la solicitud bloqueada (FOR UPDATE), el nivel es el mayor entre la copia de la cadena (se sincroniza por evento y puede ir atrasada) y el vigente del objeto (radicados/expedientes, sin mirarobjeto_tipo). Con él se reevalúan el clearance del actor (404 solicitud_not_foundsi lo supera, sin consumir nada), el gate XAdES por nivel, el 2FA obligatorio de nivel ≥ 2, el veto del correo con TOTP activo y los asientos; la copia se corrige en la misma transacción. Es monótono (max): una desclasificación aún no sincronizada se trata con más rigor, nunca con menos. - Mismo nivel efectivo en
rechazar,/voboy las lecturas (B9-S1/S5/S10).POST /requests/{id}/rejecty/voborelee el nivel bajo bloqueo igual que/signy toman la solicitud antes que el turno (el orden de bloqueo es el mismo en los cuatro caminos; antesreject/vobolo invertían y dos peticiones cruzadas podían interbloquearse).GET /requests/{id},GET /requests/por-objeto/...y la bandejaGET /pending(fila yX-Total-Countcon la misma condición) se gatean conGREATEST(copia, nivel vivo): la copia atrasada ya no deja leer los metadatos de una cadena cuyo objeto el actor no alcanza. Con una desclasificación pendiente de sincronizar gana el nivel mayor hasta que converja el eventoreclassified(B9-S9): es deliberado (rigor, nunca menos) y se corrige solo cuando el consumidor aplica el evento. - Asientos (B9-S2/S4). La relectura que corrige la copia deja
firma.solicitud_reclasificada(nivel_anterior,nivel_nuevo,filas,origen=relectura_al_firmar,canal=system,actor= quien firmaba) en la misma transacción, solo si tocó filas, y solo sube (no baja cadenas hermanas del mismo objeto).firma.turno_firmadollevanivel_seguridad(el vigente que rigió el acto) ynivel_copia;firma.creadade/sign-personalllevanivel_seguridad. GET /api/v1/signature/requests/{id}— estado de la cadena: cabecera + items{orden, firmante_id, estado, firma_id, comentario, decidido_at}. Alcance aceptado (decisión de producto, no defecto): lo ve cualquiera conPERM_FIRMAcuyo clearance alcance el objeto (no-read-up), sea o no firmante de la cadena; así un jefe de área o un auditor puede seguir un trámite. No expone contenido, solo estado y turnos. Restringirlo a los participantes sería una decisión de producto aparte.GET /api/v1/signature/requests?objeto_tipo=&objeto_id=— cadena abierta de un objeto (404si no hay). Mismo alcance aceptado que la lectura por id (PERM_FIRMA+ clearance).GET /api/v1/signature/pending?page=&size=— bandeja: solicitudes cuyo turno activo es del usuario autenticado (X-Total-Count).POST /api/v1/signature/requests/{id}/challenge— (RF-FIR-13) emite el reto 2FA: genera un OTP de 6 dígitos ligado a(solicitud, firmante del turno activo, hash_documento), lo envía por correo al firmante y devuelve{challenge_id, factor_tipo:"otp_email", canal:"j***@dominio", expira_at}(TTL 5 min). El OTP nunca aparece en la respuesta ni en logs. Exige las mismas precondiciones que/sign(B7), con un único código compartido (_precondiciones_de_turno) y un guardián de paridad que exige el mismoerroren los dos caminos; ninguna crea un desafío, envía correo ni consume un factor. En este orden:404 solicitud_not_found(neutro; también bajo el nivel de acceso del actor; el mismo código que/sign—antes/challengedecíasolicitud_no_encontrada—);403 no_es_firmante(antes que el estado: a un ajeno no se le cuenta nada);409 solicitud_no_pendiente(también si no hay turno activo);403 no_es_su_turno;409 turno_es_de_visto_bueno(pedir un OTP para un visto bueno es correo desperdiciado:firmarlo rechazaría);409 radicado_anulado;- gate de huella vigente (el mismo que
/sign):409 cadena_sin_huella/documento_desaparecido/documento_modificado,503 verificacion_documento_no_disponible(después de la pertenencia y el turno: a un ajeno no se le confirma que el texto cambió); - credencial personal (la misma consulta que
/sign): con certificado personal activo,409 totp_requerido(«use el código de su app autenticadora; el correo no sirve para este acto») y no se envía correo; si la credencial es de otra persona que el actor,403 identidad_incoherentecon asientofirma.identidad_incoherente(via=signing_key_status); sin certificado y bajo el gate XAdES,422 firma_xades_requeridao503 canal_firma_personal_no_disponible(sin el asientofirma.xades_requerida_denegada, que es del acto de firmar). Con el canal de credenciales caído y el gate enoff,/signdegrada a nativa y/challengesigue: el correo sí servirá.
Después, en transacción con relectura bloqueada: 429 challenge_cooldown si se reemite antes de 30s; 403 identidad_incoherente si el dueño del bearer (a cuyo correo saldría el código) no es el actor (AREA-04-05; asiento firma.identidad_incoherente); 409 totp_requerido si el acto es de nivel_seguridad ≥ 2 y el actor ya tiene TOTP activo (AREA-04-02); 503 canal_2fa_no_disponible si el correo o su consulta no están disponibles (fail-closed). El OTP del correo se guarda como HMAC con clave por entidad que ata el challenge_id (key_version=1, AREA-04-11). Se retiró 422 payload_no_fijado: el desafío se ata al identificador cuando la cadena no fija huella.
- POST /api/v1/signature/requests/{id}/sign {payload_xml?, challenge_id?, otp?, totp_code?} — firma el turno activo (reusa el firmante nativo HMAC) exigiendo el 2FA verificado; avanza la cadena y, al firmar el último turno, la solicitud pasa a firmado. Selección de factor: si viene totp_code (6 dígitos) y el firmante tiene TOTP activo → se verifica contra auth-service (no requiere /challenge previo); si vienen challenge_id+otp → OTP-correo; si viene totp_code pero el firmante no tiene TOTP activo → 422 totp_no_activo (no cae en silencio al correo); ambos → prevalece TOTP; ninguno → 409 2fa_requerido. Errores TOTP: 401 totp_incorrecto {intentos_restantes}, 423 totp_bloqueado, 503 canal_2fa_no_disponible si auth no responde (fail-closed → rollback). Errores OTP-correo: 401 otp_incorrecto {intentos_restantes} (5 intentos → challenge_bloqueado), 409 challenge_expirado/challenge_consumido/challenge_documento_distinto/challenge_acto_distinto, 403 challenge_ajeno. Comunes: 403 no_es_firmante y 403 no_es_su_turno; 409 si la solicitud no está pendiente o se pierde la carrera del turno (lock FOR UPDATE). Orden de los guardas (AREA-04-09): 404 neutro (no-read-up) → 403 no_es_firmante → 409 solicitud_no_pendiente: un ajeno a la cadena recibe el mismo 403 sea cual sea el estado y no puede sondear si está abierta, firmada o rechazada (igual en /reject, /vobo, /challenge y por ítem en /batch). 409 turno_es_de_visto_bueno si el turno activo es de revisión, no de firma — el gemelo del gate que /visto-bueno tenía desde el principio en la dirección contraria, y que faltaba justo en la peligrosa: firmar un turno de visto bueno produce una firma que compromete a la entidad por un acto que su autor entendía como una revisión. El rechazo ocurre antes de consultar el estado de la credencial, así que ningún factor 2FA se consume en un acto que se va a rechazar. El 2FA es no desactivable para nivel_seguridad ≥ 2 (RESERVADA/CLASIFICADA) — ambos factores lo satisfacen, salvo que el actor ya tenga TOTP activo: entonces el correo no vale y se responde 409 totp_requerido (AREA-04-02; antes bastaba controlar el buzón para saltarse el autenticador). El TOTP verificado, la credencial personal y el correo deben ser del actor al que se atribuye la firma: si el dueño del bearer es otro → 403 identidad_incoherente con asiento firma.identidad_incoherente y sin marcar el turno (AREA-04-05). Con la política de la entidad en off, si el canal de credenciales (/internal/signing-key/status) está caído y la firma cae a nativa, queda el asiento firma.fallback_nativa_canal_caido en la misma transacción que la firma (AREA-04-12; activar el gate clasificados es decisión de la entidad y exige enrolar certificados antes). El estado de la credencial personal se consulta antes del row-lock del turno (AREA-04-06).
- POST /api/v1/signature/requests/{id}/reject {motivo} — rechaza el turno activo y detiene la cadena (solicitud rechazado).
- POST /api/v1/signature/batch {items:[{solicitud_id, challenge_id?, otp?, totp_code?}, …]} — firma en lote los turnos del usuario, un segundo factor por documento (RF-FIR-13): OTP-correo (challenge_id+otp) o totp_code; no aborta ante un fallo individual ({resultados:[{solicitud_id, resultado:'firmado'|'omitido', detalle?}]}). (Contrato cambiado en E06 Inc.2: antes {solicitud_ids:[…]}.)
Envíos postales (E20) — document-service¶
Ciclo de vida del despacho físico de un radicado de salida. Máquina de estados registrado → en_transito → entregado/devuelto/fallido (entregado/devuelto/fallido terminales). Las rutas humanas exigen PERM_RADI_SALIDA y aplican no-read-up por clearance.
POST /api/v1/documents/{document_id}/envios{operador, destinatario?, direccion?}— despacha el radicado con un operador (guía autogenerada).201. Resuelve el clearance del llamante →404 radicado_not_foundsi el radicado no existe o lo supera (indistinguibles): no se despacha lo que no se puede ver. Desde T4: exigePERM_RADI_SALIDAnivel 3; asientadocument.envio_registrado(actor =X-User-Id) en la misma transacción; la guía deriva de un id propio del envío, así que caben varios envíos del mismo radicado y operador.GET /api/v1/documents/{document_id}/envios— envíos del radicado (filtrados por clearance).GET /api/v1/envios?estado=&page=&size=— listado transversal del tenant (X-Total-Count, no-read-up).GET /api/v1/envios/{shipment_id}— detalle (404si el radicado supera el clearance del llamante). CadaShipmentEventtrae{estado, descripcion, ts, ocurrido_operador_at}:tses cuándo el sistema recibió el aviso del operador (o el cambio manual);ocurrido_operador_at(B10), la fecha del hecho que declaró el operador en el campo firmadoocurrido_at(nullsi no la mandó).PUT /api/v1/envios/operadores/{operador}/credencial{secreto (≥32), gracia_horas = 24 (0-168)}— (T13 / AREA-09-10) rota el secreto HMAC del webhook del operador (4-72|servientrega|otro). GateUSUA_PERM_ADMINcrud≥5. Dos ranuras: el secreto nuevo pasa a vigente y el anterior sigue valiendo hastaanterior_caduca_at(gracia_horas; 0 = corte): el callback acepta cualquiera de las ranuras vigentes (verify_any, sin cortocircuito) y la caducidad se decide en Python con el reloj del servicio. Los secretos se guardan cifrados (AESGCM, AAD = entidad + operador, misma clave maestraEMAIL_ENCRYPTION_KEY; sin ella503 custodia_no_configurada: no se guarda nada en claro); el secreto nunca se devuelve ni se asienta (asientodocument.postal_credencial_rotadacongracia_horas/primera_vez). Respuesta{operador, rotada_at, anterior_vigente_hasta}. Migración044; la columna en claro heredada se vacía en la primera rotación.POST /api/v1/envios/{shipment_id}/estado{estado, descripcion?, motivo?, acuse_file_id?, acuse_sha256?}—entregadoa mano exige el acuse de recibo (T13 / AREA-09-10):acuse_file_id(elfile_idsubido a storage) yacuse_sha256(64 hex) →422si faltan; el servidor los coteja con storage (B2, ADR-031): el fichero debe existir en el tenant y la huella coincidir (422 acuse_invalido, solo si el fichero es suyo), lo debe haber subido quien registra la entrega (403 acuse_ajeno, también si el fichero no tiene autor o no existe: se responde igual para no revelar quéfile_idexisten; se comprueba antes que la huella) y no puede ser ya otra cosa —anexo de un radicado, borrador, correo, el acuse de otro envío…— (409 acuse_ya_referenciado; solo el reintento del MISMO envío con su propio acuse es idempotente);503 acuse_no_verificablesi storage no responde. Storage deduplica por autor, así que volver a subir el mismo PDF da el mismofile_idy cae en 409: es la misma prueba. Un índice único parcial sobrepostal_shipment(acuse_file_id)(migración045) cierra la carrera entre dos registros simultáneos (→409 acuse_ya_referenciado) y se asienta la huella que devuelve storage; se persisten enpostal_shipment.acuse_*, viajan en la respuesta y en el asientodocument.envio_estado. El callback firmado del operador no los necesita (su firma es su prueba). Transición manual (despacho humano).motivoalimentamotivo_devolucion—la causal en su propio campo, consultable— igual que ya hacía el webhook entrante del operador; sin él la causal solo quedaba dentro del texto libre dedescripcion. Resuelve el clearance del llamante →404si el radicado lo supera (cierre D-14);409 transicion_invalidapara un salto no permitido. Desde T4: exigePERM_RADI_SALIDAnivel 3, asientadocument.envio_estadoen la misma transacción, y elUPDATEcompara estado previo y clearance: si el envío cambió mientras tanto responde409 transicion_invalida.GET /api/v1/envios/{shipment_id}/tracking— consulta por guía (pull, F5) contra el operador (stubconsultar_estado); devuelve{estado_interno_actual, estado_operador, estado_interno_mapeado, operador_conectado}. Solo lectura, no aplica la transición.POST /api/v1/public/postal/callback/{tenant_slug}{operador, guia, estado_operador, event_id, descripcion?, motivo?, ocurrido_at?}— webhook ENTRANTE del operador (F5, RF-POR-08). Sin JWT (bajo/api/v1/public/); autenticación HMAC-SHA256 con cabeceraX-OrpycaMCP-Signature: sha256=<hex>sobre el cuerpo crudo, con el secreto per-tenant/per-operador depostal_operator_credential(identidad propia del operador, separada del permiso humano — cierra D-14 #3). Eltenant_slugdel path fija elsearch_path; el secreto vive en el schema de ese tenant. Idempotente por(operador, event_id)(reintento → no-op). Mapeaestado_operadora la máquina interna (422 estado_operador_no_mapeablesi no mapea). Devuelve{status: "applied"|"duplicate"|"recorded_no_change"|"deferred", shipment_id, estado}. Entidad suspendida (B10, ADR-035 §7): con firma válida el cuerpo se encola y responde202 {"status":"deferred", shipment_id, estado}; se aplica al reactivar la entidad, con la fecha de recepción; firma válida y esquema inválido →422; cola llena →503 canal_no_disponibleconRetry-After(el tope se comprueba bajo advisory lock por tenant); al drenar se coteja el sha256 del cuerpo encolado (descartahuella_no_coincide) y trasPOSTAL_DIFERIDO_MAX_INTENTOS(10) intentos fallidos se descarta con asientodocument.envio_callback_diferido_descartado, sin bloquear la cola. Cuerpo de más de 64 KiB →413 cuerpo_demasiado_grande, también con la entidad activa (tras verificar el HMAC).ocurrido_at(B10, opcional): ISO 8601 con zona, dentro del cuerpo firmado: la fecha del hecho postal según el operador, que se guarda comoocurrido_operador_at; eltsdel evento es la recepción del aviso del operador, no la fecha del hecho. Unocurrido_atposterior a la recepción + 5 min o anterior a la creación del envío →422 ocurrido_at_inverosimil(también con la entidad suspendida, antes de encolar).401 firma_invalida(sin credencial o firma mala, indistinguibles);404 envio_no_encontrado(guía desconocida);400 cuerpo_invalido. Una notificación que implicaría un retroceso (o sobre un terminal) se registra comorecorded_no_changesin mutar el estado. Cada transición emite un eventodocument.envio.*para E16 y deja asiento enaudit_log(actor=postal:<operador>).
Interoperabilidad — OAI-PMH (E11, RF-INT-02/INT-06) — document-service¶
APAGADO POR DEFECTO desde la auditoría de producción. Estos endpoints publican de forma anónima el registro de correspondencia (número, asunto, dependencia, fecha) y, vía CMIS
cmisselector=content, los bytes de los anexos. Su único recorte esnivel_seguridad = 1, que es el valor por defecto de la columna: la polaridad efectiva era «público salvo que alguien se acuerde de clasificar». Se habilitan conPUBLIC_INTEROP_ENABLED=trueen document-service Y archive-service (mismo nombre de variable en ambos a propósito, para no quedarse a medias). Apagados responden 404, indistinguible de una ruta inexistente. Encendidos, cada cosecha y cada descarga dejan asiento (document.oai_cosecha,document.cmis_consulta,document.cmis_descarga_anexo,archive.oai_cosecha) con el verbo, los argumentos y el tamaño entregado. Publicar el registro es el esquema de publicación de la entidad (Ley 1712, art. 12), no un efecto de desplegar el software.
GET /api/v1/public/oai/{tenant}?verb=...— cosecha de metadatos OAI-PMH 2.0 de los radicados públicos del tenant. Sin JWT (bajo/api/v1/public/, como el webhook postal); eltenantdel path fija elsearch_path. Respuestaapplication/xml. Verbos:Identify,ListMetadataFormats,ListIdentifiers,ListRecords,GetRecord(argsidentifier+metadataPrefix),ListSets. Cosecha selectivafrom/until(fecha OAI) yset(=doc_typeE/S/I). Identificador OAIoai:{tenant}:{tracking_number}. Paginación porresumptionToken(100/página; el token preserva elmetadataPrefix). Errores OAI estándar (badVerb,badArgument,cannotDisseminateFormat,idDoesNotExist,noRecordsMatch,badResumptionToken).- Dos formatos de diseminación (RF-INT-06):
metadataPrefix=oai_dc— Dublin Core simple (title=asunto, identifier=número de radicado, type, date, publisher, language).metadataPrefix=ead— EAD 2002 / ISAD(G) (namespaceurn:isbn:1-931666-22-9): cada radicado se disemina como un fragmento EAD autocontenido con<archdesc level="item">(unidad documental simple) y los 6 elementos obligatorios de ISAD(G)/NTC 4095 para intercambio — 3.1.1<unitid countrycode="CO" repositorycode>(código de referencia país+repositorio+número), 3.1.2<unittitle>(asunto), 3.1.3<unitdate normal>(fecha), 3.1.4@level="item", 3.1.5<physdesc>(soporte), 3.2.1<origination>(dependencia origen) — más 3.4.1<accessrestrict>(público, Ley 1712/2014), 3.7.2<descrules>(ISAD(G) 2ª ed. / NTC 4095),<langmaterial>y<repository>. Deuda declarada (roadmap): descripción monolvelitem; el contexto multinivel fondo→sección→serie→expediente (archive-service, TRD/CCD) se inyectará en un fast-follow.
- Control de acceso (CRÍTICO, Ley 1712/2014 arts. 18-19, RF-SEG-08): el recorte (
_PUBLICen la fuente) es ortogonal al formato — elegireadno sortea el filtro ni crea oráculo de reservados; un id reservado/anulado/inexistente →idDoesNotExistindistinguible en ambos formatos. - Control de acceso (CRÍTICO, Ley 1712/2014 arts. 18-19, RF-SEG-08): la cosecha solo expone lo público (
nivel_seguridad=1, no anulado). El recorte se aplica en la fuente, así la omisión de reservados/clasificados es indistinguible:completeListSizecuenta solo público,ListSetsno enumera un set solo-reservado, yGetRecord/ListMetadataFormatssobre un identificador reservado devuelvenidDoesNotExist(no confirman su existencia). No hay credencial de tercero con clearance en este alcance (cosecha pública).
Interoperabilidad — OAI-PMH de expedientes / EAD multinivel (E11, RF-INT-06) — archive-service¶
GET /api/v1/public/archive/oai/{tenant}?verb=...— cosecha de metadatos OAI-PMH 2.0 de los EXPEDIENTES públicos del tenant, con descripción archivística EAD 2002 / ISAD(G) MULTINIVEL. Sin JWT (bajo/api/v1/public/; el gateway rutea/api/v1/public/archive/a archive-service antes del catch-all de document-service). Respuestaapplication/xml. Verbos OAI completos;set=statusdel expediente (open/closed/transferred). Identificadoroai:{tenant}:expediente:{code}. Formatosoai_dcyead.- EAD multinivel (
metadataPrefix=ead): caminando la cadenatrd_series.parent_id(el CCD, serie↔subserie), cada expediente se disemina anidado de lo general a lo específico —<archdesc level="fonds">(institución) →<dsc>→<c level="series">→ (<c level="subseries">…) →<c level="file">(el expediente) — cumpliendo ISAD(G) 2.2 (general→específico) y 2.4 (vínculo al nivel superior). El nivelfilelleva los 6 obligatorios ISAD(G)/NTC 4095 (<unitid countrycode="CO" repositorycode>=code,<unittitle>=nombre,<unitdate normal>=apertura→cierre,@level="file",<physdesc>=conteo de documentos,<origination>=institución productora) +<scopecontent>(descripción),<accessrestrict>(público, Ley 1712),<descrules>(ISAD(G)/NTC 4095). Sintrd_serie_id→<archdesc level="file">plano (fallback honesto). - Complementa el EAD item-level de radicados (RF-INT-06 en document-service): document = radicados (unidad documental simple), archive = expedientes (unidad documental compuesta con su contexto de clasificación).
- Control de acceso (CRÍTICO, Ley 1712/2014 arts. 18-19, RF-SEG-08): chokepoint
_PUBLIC = "nivel_seguridad = 1"enexpedientes, aplicado en la fuente → un expediente reservado es indistinguible (idDoesNotExist,completeListSize/sets solo público). Frontera anti-fuga item-level: la descripción para enlevel="file"— un expediente público puede contener radicados reservados y archive no tiene su nivel de seguridad, así que nunca enumera lostracking_numberhijos (solo un conteo agregado en<physdesc>). CTE de la cadena de series acotada a profundidad 50 (guarda anti-DoS de ciclos en el endpoint anónimo).
Interoperabilidad — CMIS 1.1 perfil mínimo de lectura (E11, RF-INT-02) — document-service¶
Browser Binding (JSON). Solo lectura sobre los radicados públicos del tenant. Sin JWT (bajo /api/v1/public/); tenant por path.
GET /api/v1/public/cmis/{tenant}— repositoryInfo:{repositoryId: {repositoryName, cmisVersionSupported: "1.1", rootFolderId: "root", capabilities}}. Lascapabilitiesreflejan solo-lectura (query/escritura/versionado ennone/false).GET /api/v1/public/cmis/{tenant}/root?cmisselector=children[&maxItems=&skipCount=]— getChildren de la raíz:{objects:[{object:{properties}}], numItems, hasMoreItems}(radicados públicos comocmis:document).GET /api/v1/public/cmis/{tenant}/root?cmisselector=object&objectId={numeroRadicado}— getObject: propiedades CMIS (cmis:objectId= número de radicado,cmis:name= asunto,cmis:baseTypeId, fechas en millis).objectId=root→ la carpeta raíz sintética.GET /api/v1/public/cmis/{tenant}/root?cmisselector=content&objectId={numeroRadicado}— getContentStream: descarga (streaming) el primer anexo del radicado.objectIdsin valor →400 invalidArgument; selector no soportado →400 notSupported.- Control de acceso (CRÍTICO, Ley 1712/2014 arts. 18-19, RF-SEG-08): mismo recorte que OAI (
_PUBLICen la fuente).getObject/getContentStreamsobre un id reservado/anulado/inexistente →404 objectNotFoundindistinguible;getChildren/numItemssolo cuentan público. El content stream nunca sirve bytes de un anexo de un radicado no público (get_public_anexo, JOIN público-only).
Interoperabilidad — Exportación de paquete (E11, RF-INT-01) — document-service¶
-
POST /api/v1/export{comentario?, year?, doc_type?}— exporta radicados a un paquete interoperable (ZIP). Autenticado (no público); gateUSUA_PERM_EXPEDIENTE+ no-read-up por-radicado (solo se exporta lo que el llamante puede leer; los sobre-clearance se omiten y se reportan content-free en el manifiesto). Respuestaapplication/zipcon cabecerasX-Export-UUID,X-Export-Count,X-Export-Excluded(las tres reenviadas por el gateway: están enFORWARDED_RESPONSE_HEADERSy un guardián deshared/testscensa los servicios para que ninguna cabecera propia se quede fuera de esa lista — el ZIP viaja como cuerpo, así que son lo único que dice cuánto se exportó y cuánto recortó el clearance). Contenido del ZIP:manifiesto.json(UUID de exportación, comentario, marcas inicio/fin, lista de radicados con SHA-256, excluidos),esquema/radicado.schema.json(JSON Schema draft 2020-12 publicado),radicados/{numero}.json(datos por radicado que validan contra el esquema: metadata sistema+contextual, disposición, clasificación, anexos, historial),anexos/{numero}/{i}-{archivo}(binarios),checksums.txt(SHA-256 por miembro). El número de radicado se preserva como identidad (Ac. AGN 060/2001). Empaquetado físico ZIP provisional (BagIt/OAIS+WORM = E10). Filtros opcionalesyear(2000–2100) ydoc_type(E/S/I). Desde T4: gateUSUA_PERM_EXPEDIENTEnivel 3; un import/export a la vez por proceso (429 operacion_ocupadaconRetry-After); tope de 512 MiB de anexos (422 export_demasiado_grande). -
Ubicación en el paquete (ADR-029).
metadata_sistema.ubicacion_dept/ubicacion_dept_codeson informativos (dónde estaba el radicado al exportar).POST /api/v1/importlos ignora: la ubicación de lo importado la deriva el flujo propio de la entidad receptora. El poseedor (flujo_usuario) nunca sale del sistema en un paquete. -
POST /api/v1/import— importa un paquete interoperable de radicados. Autenticado; gateUSUA_PERM_EXPEDIENTE+ no-write-up por clearance. Recibe el ZIP de INT-01 como cuerpo crudo (Content-Type: application/zip, tope 512 MiB). Validación TODO-o-NADA antes de ingerir: valida cada radicado contra el JSON Schema de confianza del servicio y verifica el SHA-256 de cada binario; si algo falla →422 paquete_invalidosin dejar estado parcial. Reingiere preservando el número de radicado (una colisión se omite, no se sobreescribe); reconstruye la relación E↔S (responde_a) por número; sube los binarios a storage; auditaradicado.import. Guardas anti zip-slip/zip-bomb. Respuesta{import_uuid, importados, omitidos_existentes, omitidos_clearance, relaciones_reconstruidas}. Un radicado connivel_seguridad> clearance del importador se omite (omitidos_clearance). ADR-028:metadata_sistema.statussolo admiteregistered|distributed|in_progress|closed: un paquete conanulado(o cualquier otro valor) se rechaza entero con422—no trae el acto de anulación que lo justifique—; el asientoradicado.importregistra elstatus_origende cada radicado importado. Desde T4: gateUSUA_PERM_EXPEDIENTEnivel 3;429 operacion_ocupadasi hay otro import/export en curso;413en cuanto el cuerpo supera 512 MiB (sin bufferizarlo entero); el contador de secuencias solo avanza con números de ESTA entidad, y una secuencia > 999999 o un año fuera de [1900, año actual] se rechaza con422 paquete_invalido. Revisión de T4: un número con el código de ESTA entidad y secuencia > 999998 también es422(el siguiente nativo saldría de 7 dígitos); el cuerpo debe llegar en 300 s o responde408 cuerpo_demasiado_lento(un cliente que gotea ya no retiene el cupo de todos los tenants). Binarios huérfanos (AREA-09-14): los binarios subidos cuyo radicado no llegó a existir (reversión total u omisión por carrera) se asientan comodocument.import_binarios_sin_radicado(file_id+sha256, content-free) y ahora además se purgan, no solo se asientan. Se encolan enimport_binario_huerfano(migración 038, en la misma transacción que el asiento) y document-service pide a storagePOST /storage/internal/purgar-huerfanos(X-Internal-Token): storage borra un objeto solo si lo creó esa importación (uploaded_at >= subido_desde), NINGÚN anexo/correo/borrador/migración/objeto WORM del tenant lo referencia (storage deduplica por SHA-256), NINGÚNuploadlo devolvió por deduplicación (files.dedup_hit_at, migración storage 017: la marca la escribe unUPDATEque toma el bloqueo de fila, así que se serializa con elFOR UPDATEde la purga —sin ella la purga solo veía referencias commiteadas y podía borrar un objeto que otra operación acababa de recibir—; si la purga gana, la subida deja un objeto nuevo y no unfile_idcolgante), una tabla de referencias ausente en el tenant se trata como «no sé» (no se borra) y el almacén acepta el borrado, todo en su transacción, con el asientofile.purgado_huerfano; la purga confirmada dejadocument.import_binarios_purgados. Si storage no responde o un archivo daerror, la fila quedapendientey el barrido la reintenta (hasta 20 veces →agotado, a revisión manual, y ese paso dejadocument.import_binario_agotadoen la misma transacción); el asiento…_sin_radicadoya está escrito. Deuda declarada: la ventana de numeración propia (no hay tope de salto sobrelast_seq, para no impedir restaurar un tenant vacío).
Capa MCP (E18, ADR-019) — mcp-server, puerto 8009¶
Expone la API como tools a agentes/LLM, siendo él mismo cliente del gateway. Protocolo MCP disponible por stdio (python -m app.mcp_stdio) y HTTP streamable.
Se enruta por el gateway (/api/v1/mcp/ → mcp-server), lo que antes no ocurría: el catálogo y el protocolo quedaban fuera, de modo que un cliente MCP externo no tenía por dónde entrar salvo hablando con el servicio a espaldas del gateway, sin validación JWT ni X-Tenant-Slug. Se puede exponer porque el gate de escritura ya no depende del cliente (propuesta firmada, ver arriba). El transporte streamable se monta en POST /api/v1/mcp/stream además de en /mcp.
El contexto de sesión son dos cabeceras: Authorization y X-Tenant-Slug. X-User-Permissions y X-User-Roles ya no se leen — la primera no la producía nadie y el gateway la descarta por forjable, así que llegaba vacía y toda tool con permiso quedaba invisible por ese transporte; los permisos salen de GET /api/v1/auth/me con el token del llamante, y con ellos el nivel CRUD. Los ajustes de entorno equivalentes (MCP_USER_PERMISSIONS, MCP_USER_ROLES) se han retirado.
Marco de alcance: la tool mi_alcance y el resource orfeo://alcance devuelven lo que la persona puede ejecutar, lo que puede consultar y lo que no puede con su motivo y su vía alternativa. Se ofrece siempre, aunque no alcance ninguna escritura: el conjunto de tools dice qué se puede hacer y calla lo demás, y callar es cómo un límite acaba descubriéndose fallando.
API HTTP simple del catálogo:
GET /api/v1/mcp/tools— catálogo de tools que ESTE llamante puede usar, resuelto contra/auth/me(permiso y nivel). Incluye los procedimientos (method: "PROCEDIMIENTO"), listados junto a las tools y no en un catálogo aparte: para quien consume esto, «radicar un documento» y «tramitar una petición» son dos cosas que puede pedir.
Procedimientos: el trámite entero, no la llamada suelta¶
Una tool traduce a un endpoint. Eso alcanza para consultar y se queda corto para lo que hace un funcionario: radicar una petición son cuatro actos —radicar, repartir, expedientar, avisar—. Con tools sueltas quien los encadena es el modelo: elige el orden, inventa el enlace entre un paso y el siguiente, y si algo falla a mitad nadie sabe en qué estado quedó el trámite.
Un procedimiento declara esa secuencia en el servidor, con nombre y orden fijo, y sigue siendo declarativo (una entrada en una tabla, no código nuevo por procedimiento). Disponibles hoy: tramitar_peticion_ciudadana, responder_radicado, conformar_expediente y estado_del_tramite (solo lectura, compone cinco consultas en una respuesta).
Tres propiedades, y las tres importan:
- Cada paso viaja con el token de la persona. Un procedimiento es un orden, no una credencial: si el usuario no puede asignar, ese paso devuelve
403igual que si hubiera llamado a la tool a mano. No puede hacer nada que su usuario no pueda. - Una sola confirmación para el acto completo. El procedimiento de escritura devuelve una propuesta firmada con la cadena de pasos visible —quien confirma tiene derecho a saber qué está aprobando—. Pedir una confirmación por paso enseñaría a confirmar sin leer.
- No se finge atomicidad. No hay transacción que abarque llamadas HTTP a servicios distintos, y radicar consume un número inmutable que no vuelve al mostrador. Por eso el resultado es un parte paso por paso (
pasos,pasos_ejecutados,completado,advertencia) y no un booleano: quien pregunta necesita saber que tiene un radicado con número y sin repartir, no un «falló» que le llevaría a radicarlo otra vez.
Cobertura declarada: el censo del gateway son 405 operaciones, de las cuales 63 son actos de sistema sellados (reconciliación, rutas internas entre servicios) que ningún canal humano expone. Sobre las 342 restantes el catálogo MCP cubre 203 — el 59 %. El desglose por flujo de trabajo, con lo que falta en cada uno, está en API por flujos; se dice aquí en vez de dejar que se descubra pidiendo algo que no existe. El censo se deriva del código en cada revisión, nunca se copia: la cifra anterior de esta página (367 operaciones, 41 tools) se quedó ocho meses detrás del catálogo mientras el changelog sí registraba cada crecimiento — un número escrito a mano al lado de uno que se mueve.
- POST /api/v1/mcp/tools/{name}/invoke {...args} — invoca la tool: propaga Authorization + X-Tenant-Slug al gateway. La identidad y los permisos se resuelven contra GET /api/v1/auth/me con el token del llamante (permiso y nivel CRUD; 403 si no alcanza). El catálogo completo —207 tools— está en la tabla de más abajo, agrupado por épica.
Las operaciones de escritura NO se ejecutan aquí: devuelven una propuesta. X-Confirm-Write ya no se lee — era un booleano que cualquier cliente podía poner y que no ataba la confirmación a ningún acto concreto (confirmaba «algo», no «esto»). La respuesta es {kind:"proposal", ejecutado:false, proposal:{id, tool, operacion, campos, expira_en, token}, aviso}: campos son las etiquetas legibles del acto —redactadas por el catálogo, nunca por el modelo— y token es un valor opaco firmado con HMAC-SHA256 que lleva dentro la tool y los argumentos ya validados. Vence a los 120 s y está atado al usuario y a la entidad que lo pidieron.
- POST /api/v1/mcp/proposals/execute {token} — ejecuta el acto que va firmado en el token. El cuerpo lleva solo el token: los argumentos salen de él, así que lo ejecutado es exactamente lo descrito. Se reautoriza al confirmar (entre proponer y confirmar pueden haberle retirado el permiso: la propuesta acredita qué se aprobó, no que quien la presenta siga pudiendo hacerlo) y se consume antes de despachar. Errores: 400 propuesta_invalida, 403 propuesta_ajena / permiso_insuficiente, 409 propuesta_consumida, 410 propuesta_expirada.
El mismo acto por el protocolo MCP: las escrituras devuelven la propuesta como resultado estructurado y se confirman con la tool confirmar_accion(token); donde el transporte tiene canal de vuelta (stdio) se pregunta en banda por elicitación con esos mismos campos. En HTTP streamable (stateless) no hay sesión que preguntar y queda el camino de dos tools — límite declarado. Las tools llevan annotations MCP (readOnlyHint, destructiveHint) como pistas para que el cliente ponga su propia fricción: la garantía sigue viviendo en la propuesta firmada, no en ellas.
Límite declarado: el registro de consumo vive en memoria del proceso — cubre el doble clic y el reintento, no el multi-réplica. Idempotency-Key: <jti> se emite hacia el gateway como gancho para que cada épica lo implemente, no como garantía.
El catálogo actual: 207 tools, 75 de escritura — de ellas 27 declaradas irreversibles y 6 que exigen teclear un valor de la propia tarjeta (no una frase fija: reproducir el identificador del lote, la versión o el radicado confirma este acto y no se puede anticipar de memoria). Toda escritura devuelve una propuesta firmada y exige confirmación explícita (ver más arriba). La tabla se genera desde services/mcp-server/app/tools/catalog.py: el permiso y el nivel CRUD de cada fila son los que el catálogo declara, y shared/tests/test_permisos_declarados.py comprueba en los dos sentidos que sean los que el endpoint exige.
W = escritura · R = lectura · el nivel CRUD en negrita (3) es el de crear/borrar.
E01 · Radicación y correspondencia — 27 tools, 11 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
actualizar_radicado |
Corregir los datos de un radicado | PATCH /documents/{document_id} |
PERM_RADI (1) |
W |
aprobar_anulacion |
Aprobar la anulación de un radicado | POST /documents/{document_id}/anulacion/aprobar |
PERM_PANU_CODI (1) |
W irreversible |
aprobar_borrador |
Aprobar un borrador de salida | POST /borradores/{borrador_id}/aprobar |
PERM_RADI (1) |
W |
crear_borrador |
Preparar un borrador de salida | POST /borradores |
PERM_RADI (1) |
W |
crear_respuesta_vinculada |
Responder un radicado con una Salida vinculada | POST /documents/{document_id}/respuesta |
PERM_RESPUESTA_VINCULADA (1) |
W irreversible |
editar_borrador |
Corregir un borrador de salida | PATCH /borradores/{borrador_id} |
PERM_RADI (1) |
W |
radicar_borrador |
Radicar un borrador (le asigna número definitivo) | POST /borradores/{borrador_id}/radicar |
PERM_RADI (1) |
W irreversible |
radicar_documento |
Radicar un documento de entrada, salida o interno | POST /documents |
PERM_RADI (1) |
W irreversible |
rechazar_anulacion |
Rechazar la anulación de un radicado | POST /documents/{document_id}/anulacion/rechazar |
PERM_PANU_CODI (1) |
W |
registrar_tercero |
Registrar un tercero en el catálogo | POST /terceros |
PERM_RADI (1) |
W |
solicitar_anulacion |
Solicitar la anulación de un radicado | POST /documents/{document_id}/anulacion |
PERM_SOL_ANULAR (1) |
W |
buscar_terceros |
Buscar un tercero en el catálogo | GET /terceros |
PERM_RADI (1) |
R |
cola_anulaciones |
Consultar la cola de anulaciones pendientes | GET /anulaciones |
PERM_PANU_CODI (1) |
R |
consultar_actores |
Consultar los actores de un radicado | GET /terceros/radicado/{radicado_id}/actores |
PERM_RADI (1) |
R |
consultar_anulacion |
Consultar la anulación de un radicado | GET /documents/{document_id}/anulacion |
sin permiso | R |
consultar_borrador |
Consultar un borrador | GET /borradores/{borrador_id} |
PERM_RADI (1) |
R |
consultar_por_numero |
Consultar un radicado por su número | GET /documents/by-tracking/{tracking_number} |
sin permiso | R |
consultar_radicado |
Consultar un radicado | GET /documents/{document_id} |
sin permiso | R |
historial_acceso_radicado |
Ver quién ha consultado un radicado | GET /documents/{document_id}/historial-acceso |
sin permiso | R |
listar_borradores |
Ver los borradores de salida | GET /borradores |
PERM_RADI (1) |
R |
listar_departamentos |
Consultar los departamentos DIVIPOLA | GET /terceros/divipola/departamentos |
PERM_RADI (1) |
R |
listar_municipios |
Consultar los municipios de un departamento | GET /terceros/divipola/municipios |
PERM_RADI (1) |
R |
listar_plantillas |
Consultar las plantillas de redacción | GET /plantillas |
PERM_RADI (1) |
R |
listar_radicados |
Listar radicados | GET /documents |
sin permiso | R |
listar_respuestas |
Ver qué respuestas se han dado a un radicado | GET /documents/{document_id}/respuestas |
sin permiso | R |
radicados_de_un_tercero |
Ver en qué radicados aparece un contacto | GET /terceros/{tercero_id}/radicados |
PERM_RADI (1) |
R |
terceros_duplicados |
Ver posibles contactos duplicados | GET /terceros/duplicados |
PERM_RADI (1) |
R |
E02 · Expedientes — 9 tools, 5 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
actualizar_expediente |
Corregir los datos de un expediente | PATCH /expedientes/{expediente_id} |
USUA_PERM_EXPEDIENTE (2) |
W |
cerrar_expediente |
Cerrar un expediente (irreversible) | POST /expedientes/{expediente_id}/close |
USUA_PERM_EXPEDIENTE (3) |
W irreversible |
crear_expediente |
Abrir un expediente | POST /expedientes |
USUA_PERM_EXPEDIENTE (3) |
W |
excluir_radicado |
Excluir un radicado de un expediente | DELETE /expedientes/{expediente_id}/radicados/{radicado_id} |
USUA_PERM_EXPEDIENTE (3) |
W irreversible |
vincular_radicado |
Vincular un radicado a un expediente | POST /expedientes/{expediente_id}/radicados |
USUA_PERM_EXPEDIENTE (2) |
W |
consultar_expediente |
Consultar un expediente | GET /expedientes/{expediente_id} |
USUA_PERM_EXPEDIENTE (1) |
R |
consultar_foliado |
Consultar la foliación de un expediente | GET /expedientes/{expediente_id}/foliado |
USUA_PERM_EXPEDIENTE (1) |
R |
hoja_ruta_expediente |
Consultar la hoja de ruta de un expediente | GET /expedientes/{expediente_id}/eventos |
USUA_PERM_EXPEDIENTE (1) |
R |
listar_expedientes |
Listar expedientes | GET /expedientes |
USUA_PERM_EXPEDIENTE (1) |
R |
E03 · Metadatos — 3 tools, 1 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
tipificar_radicado |
Tipificar un radicado (asignar o corregir su tipo documental) | PATCH /documents/{document_id} |
PERM_RADI (3) |
W |
listar_elementos_metadato |
Listar los elementos de metadato | GET /metadata/elements |
sin permiso | R |
listar_plantillas_metadato |
Listar las plantillas de metadatos | GET /metadata/templates |
sin permiso | R |
E04 · TRD / TVD y disposición — 38 tools, 21 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
anular_lote_eliminacion |
Retirar un lote del procedimiento de eliminación | POST /eliminacion/lotes/{lote_id}/anular |
USUA_PERM_TRD (3) |
W |
aprobar_version_trd |
Aprobar una versión de la TRD | POST /trd/{code}/versiones/{version}/aprobar |
USUA_PERM_TRD (3) |
W |
aprobar_version_tvd |
Aprobar una versión de la TVD | POST /tvd/{code}/versiones/{version}/aprobar |
USUA_PERM_TRD (3) |
W |
avalar_lote_eliminacion |
Registrar el aval del Comité sobre un lote de eliminación | POST /eliminacion/lotes/{lote_id}/avalar |
USUA_PERM_TRD (4) |
W teclear lote_id |
convalidar_version_trd |
Convalidar una versión de la TRD | POST /trd/{code}/versiones/{version}/convalidar |
USUA_PERM_TRD (3) |
W teclear version |
convalidar_version_tvd |
Convalidar una versión de la TVD | POST /tvd/{code}/versiones/{version}/convalidar |
USUA_PERM_TRD (3) |
W teclear version |
crear_agrupacion_tvd |
Crear una agrupación de la TVD | POST /tvd |
USUA_PERM_TRD (1) |
W |
crear_serie_trd |
Crear una serie de la TRD | POST /trd |
USUA_PERM_TRD (1) |
W |
crear_tipo_documental |
Crear un tipo documental | POST /tipos-documentales |
USUA_PERM_TRD (1) |
W |
decidir_disposicion_vencida |
Decidir sobre una disposición vencida | POST /trd/disposicion-vencida/{expediente_id}/decidir |
USUA_PERM_TRD (3) |
W irreversible |
derogar_version_trd |
Derogar una versión de la TRD | POST /trd/{code}/versiones/{version}/derogar |
USUA_PERM_TRD (3) |
W teclear version |
derogar_version_tvd |
Derogar una versión de la TVD | POST /tvd/{code}/versiones/{version}/derogar |
USUA_PERM_TRD (3) |
W teclear version |
devolver_version_trd |
Devolver una versión de la TRD con observaciones | POST /trd/{code}/versiones/{version}/devolver |
USUA_PERM_TRD (3) |
W |
devolver_version_tvd |
Devolver una versión de la TVD con observaciones | POST /tvd/{code}/versiones/{version}/devolver |
USUA_PERM_TRD (3) |
W |
generar_acta_eliminacion |
Generar el acta de eliminación de un lote | POST /eliminacion/lotes/{lote_id}/acta |
USUA_PERM_TRD (3) |
W irreversible |
nueva_version_agrupacion_tvd |
Crear una versión nueva de una agrupación TVD | POST /tvd/{code}/versiones |
USUA_PERM_TRD (3) |
W |
nueva_version_serie_trd |
Crear una versión nueva de una serie TRD | POST /trd/{code}/versiones |
USUA_PERM_TRD (3) |
W |
proponer_lote_eliminacion |
Proponer un lote de eliminación | POST /eliminacion/lotes |
USUA_PERM_TRD (3) |
W |
publicar_inventario_lote |
Publicar el inventario de un lote de eliminación | POST /eliminacion/lotes/{lote_id}/publicar |
USUA_PERM_TRD (3) |
W irreversible |
registrar_rusd_trd |
Registrar la inscripción RUSD de una versión de la TRD | POST /trd/{code}/versiones/{version}/registrar-rusd |
USUA_PERM_TRD (3) |
W |
reintentar_firma_acta_eliminacion |
Reintentar la firma del acta de eliminación | POST /eliminacion/lotes/{lote_id}/acta/firmar |
USUA_PERM_TRD (3) |
W |
consultar_acta_eliminacion |
Consultar el acta de eliminación | GET /eliminacion/lotes/{lote_id}/acta |
USUA_PERM_TRD (1) |
R |
consultar_disposicion |
Consultar la disposición final de un radicado | GET /documents/{document_id}/disposition |
USUA_PERM_EXPEDIENTE (1) |
R |
consultar_lote_eliminacion |
Consultar un lote de eliminación | GET /eliminacion/lotes/{lote_id} |
USUA_PERM_TRD (1) |
R |
consultar_retencion |
Calcular el calendario de retención de una serie | GET /trd/{serie_id}/retention |
sin permiso | R |
consultar_serie |
Consultar una serie de la TRD | GET /trd/{serie_id} |
sin permiso | R |
consultar_tipo_documental |
Consultar un tipo documental | GET /tipos-documentales/{tipo_id} |
sin permiso | R |
consultar_tvd |
Consultar una agrupación de la TVD | GET /tvd/{agrupacion_id} |
sin permiso | R |
disposicion_vencida |
Consultar los expedientes con disposición vencida | GET /trd/disposicion-vencida |
USUA_PERM_TRD (1) |
R |
lapidas_lote |
Consultar las lápidas de un lote eliminado | GET /eliminacion/lotes/{lote_id}/lapidas |
USUA_PERM_TRD (1) |
R |
listar_lotes_eliminacion |
Listar los lotes de eliminación | GET /eliminacion/lotes |
USUA_PERM_TRD (1) |
R |
listar_tipos_documentales |
Consultar los tipos documentales de la TRD | GET /tipos-documentales |
sin permiso | R |
listar_trd |
Consultar la Tabla de Retención Documental | GET /trd |
sin permiso | R |
listar_tvd |
Listar las agrupaciones de la TVD | GET /tvd |
sin permiso | R |
retencion_tvd |
Consultar la retención de una agrupación TVD | GET /tvd/{agrupacion_id}/retention |
sin permiso | R |
revision_trd_pendiente |
Consultar los expedientes pendientes de revisión de TRD | GET /trd/revision-pendiente |
USUA_PERM_TRD (1) |
R |
versiones_agrupacion_tvd |
Consultar el historial de versiones de una agrupación TVD | GET /tvd/{code}/versiones |
USUA_PERM_TRD (1) |
R |
versiones_serie_trd |
Consultar el historial de versiones de una serie TRD | GET /trd/{code}/versiones |
USUA_PERM_TRD (1) |
R |
E05 · Flujos de trabajo — 16 tools, 9 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
asignar_radicado |
Asignar un radicado a una dependencia | POST /workflows/assign |
PERM_TRAMITAR (1) |
W |
completar_paso |
Completar un paso de trámite | PATCH /workflows/{step_id}/complete |
PERM_TRAMITAR (1) |
W |
crear_cadena_vobo |
Crear la cadena de vistos buenos | POST /workflows/{radicado_id}/vistos-buenos |
PERM_RADI (1) |
W |
decidir_visto_bueno |
Dar o negar el visto bueno | POST /workflows/{radicado_id}/vistos-buenos/decidir |
PERM_VOBO (1) |
W |
devolver_radicado |
Devolver un radicado con causal | POST /workflows/{radicado_id}/devolver |
PERM_TRAMITAR (1) |
W |
ejecutar_transaccion |
Ejecutar una transacción de trámite | POST /workflows/{radicado_id}/transactions |
PERM_TRAMITAR (1) |
W |
finalizar_tramite |
Finalizar el trámite de un radicado (irreversible; motivo_cierre obligatorio: respondido, nrr, traslado_competencia u otro) |
POST /workflows/{radicado_id}/finalizar |
PERM_TRAMITAR (3) |
W irreversible (se confirma tecleando el radicado) |
revertir_asignacion |
Revertir la última asignación | POST /workflows/{radicado_id}/rollback |
PERM_TRAMITAR (1) |
W |
trasladar_radicado |
Trasladar un radicado a otra dependencia | POST /workflows/transfer |
PERM_TRAMITAR (1) |
W |
bandeja_tramite |
Consultar la bandeja de trámite | GET /workflows/inbox |
sin permiso | R |
consultar_cadena_vobo |
Consultar la cadena de vistos buenos | GET /workflows/{radicado_id}/vistos-buenos |
sin permiso | R |
eventos_radicado |
Consultar los eventos de flujo de un radicado | GET /workflows/{radicado_id}/events |
sin permiso | R |
hoja_ruta_radicado |
Consultar la hoja de ruta de un radicado | GET /workflows/{radicado_id}/history |
sin permiso | R |
paso_actual |
Consultar el paso de trámite activo de un radicado | GET /workflows/{radicado_id}/current |
sin permiso | R |
pendientes_equipo |
Consultar los pendientes de trámite del equipo | GET /workflows/pending |
sin permiso | R |
tipos_transaccion |
Consultar los tipos de transacción de trámite | GET /workflows/transaction-types |
sin permiso | R |
E06 · Firma electrónica — 10 tools, 2 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
abrir_cadena_firma |
Abrir un circuito de firmas | POST /signature/requests |
PERM_FIRMA (1) |
W |
firmar_indice |
Firmar el índice electrónico | POST /expedientes/{expediente_id}/indice/firmar |
USUA_PERM_EXPEDIENTE (1) |
W |
cadena_firma_de_un_objeto |
Ver si un documento ya está en un circuito de firma | GET /signature/requests |
PERM_FIRMA (1) |
R |
consultar_cadena_firma |
Consultar una cadena de firmas | GET /signature/requests/{solicitud_id} |
PERM_FIRMA (1) |
R |
consultar_comprobante_firma |
Ver el comprobante de una firma | GET /signature/firmas/{firma_id} |
sin permiso | R |
estado_mi_credencial_de_firma |
Consultar el estado de mi credencial de firma | GET /auth/me/signing-key |
sin permiso | R |
estado_mi_segundo_factor |
Consultar el estado de mi segundo factor | GET /auth/me/totp |
sin permiso | R |
firmas_de_un_radicado |
Ver las firmas de un radicado | GET /documents/{document_id}/signatures |
PERM_FIRMA (1) |
R |
firmas_pendientes |
Ver qué tengo pendiente de firmar | GET /signature/pending |
PERM_FIRMA (1) |
R |
indices_pendientes_firma |
Consultar los índices pendientes de firma | GET /expedientes/indices/pendientes-firma |
USUA_PERM_EXPEDIENTE (1) |
R |
E07 · Anexos — 1 tools
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
listar_anexos |
Consultar los anexos de un radicado | GET /documents/{document_id}/anexos |
sin permiso | R |
E08 · Seguridad, RBAC y auditoría — 15 tools, 1 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
reclasificar_radicado |
Reclasificar el nivel de seguridad de un radicado | PATCH /documents/{document_id}/security-level |
PERM_RECLASIFICAR (3) |
W irreversible |
clearance_de_grupo |
Consultar el nivel de acceso de un grupo | GET /auth/groups/{group_id}/clearance |
USUA_PERM_ADMIN (1) |
R |
consultar_auditoria |
Consultar la traza de auditoría | GET /audit |
USUA_PERM_ADMIN (1) |
R |
listar_grupos |
Listar los grupos de la entidad | GET /auth/groups |
USUA_PERM_ADMIN (1) |
R |
listar_niveles_seguridad |
Consultar los niveles de clasificación | GET /auth/security-levels |
sin permiso | R |
listar_permisos_del_sistema |
Listar los permisos del sistema | GET /auth/permissions |
USUA_PERM_ADMIN (1) |
R |
listar_urd_usuario |
Consultar las unidades de reparto de una persona | GET /auth/users/{user_id}/urd |
USUA_PERM_ADMIN (1) |
R |
listar_usuarios |
Listar los usuarios de la entidad | GET /auth/users |
USUA_PERM_ADMIN (1) |
R |
listar_usuarios_asignables |
Listar usuarios a los que asignar | GET /auth/users/pickable |
PERM_RADI (1) |
R |
mi_clearance |
Consultar mi nivel de acceso | GET /auth/clearance |
sin permiso | R |
mi_contexto |
Consultar mi contexto de trabajo | GET /auth/context |
sin permiso | R |
miembros_de_grupo |
Consultar los miembros de un grupo | GET /auth/groups/{group_id}/members |
USUA_PERM_ADMIN (1) |
R |
permisos_de_grupo |
Consultar los permisos de un grupo | GET /auth/groups/{group_id}/permissions |
USUA_PERM_ADMIN (1) |
R |
permisos_de_usuario |
Consultar los permisos efectivos de una persona | GET /auth/users/{user_id}/permissions |
USUA_PERM_ADMIN (1) |
R |
verificar_cadena_auditoria |
Verificar la integridad de la auditoría | GET /audit/verify |
USUA_PERM_ADMIN (1) |
R |
E09 · Búsqueda y reportes — 3 tools
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
buscar_expedientes |
Buscar expedientes | GET /expedientes/search |
USUA_PERM_EXPEDIENTE (1) |
R |
buscar_radicados |
Buscar radicados | GET /search |
sin permiso | R |
reporte_radicacion |
Ver el resumen estadístico de radicación | GET /reports/radicados |
SGD_PERM_ESTADISTICA (1) |
R |
E10 · Preservación (WORM/PREMIS) — 9 tools, 3 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
declarar_plan_preservacion |
Declarar una versión del plan de preservación | PUT /preservacion/plan |
USUA_PERM_EXPEDIENTE (3) |
W |
migrar_formato |
Migrar un archivo a PDF/A | POST /preservacion/migrar-formato |
USUA_PERM_EXPEDIENTE (3) |
W |
registrar_evento_preservacion |
Registrar un evento de preservación | POST /preservacion/eventos |
USUA_PERM_EXPEDIENTE (3) |
W irreversible |
artefactos_pendientes_renovacion |
Consultar los artefactos pendientes de renovación WORM | GET /expedientes/indices/pendientes-renovacion |
USUA_PERM_EXPEDIENTE (1) |
R |
cadena_migraciones |
Consultar la cadena de migraciones de un archivo | GET /preservacion/migraciones/{file_id} |
USUA_PERM_EXPEDIENTE (1) |
R |
consultar_aip |
Consultar el AIP de preservación de un expediente | GET /expedientes/{expediente_id}/aip |
USUA_PERM_EXPEDIENTE (1) |
R |
consultar_plan_preservacion |
Consultar el plan de preservación digital | GET /preservacion/plan |
USUA_PERM_EXPEDIENTE (1) |
R |
eventos_preservacion |
Consultar los eventos de preservación de un expediente | GET /expedientes/{expediente_id}/preservacion/eventos |
USUA_PERM_EXPEDIENTE (1) |
R |
versiones_plan_preservacion |
Consultar las versiones del plan de preservación | GET /preservacion/plan/versions |
USUA_PERM_EXPEDIENTE (1) |
R |
E11 · Interoperabilidad — 3 tools
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
consultar_envio |
Consultar un envío | GET /envios/{shipment_id} |
PERM_RADI_SALIDA (1) |
R |
listar_webhooks |
Listar los canales de salida configurados | GET /webhooks |
USUA_PERM_ADMIN (1) |
R |
rastrear_envio |
Rastrear un envío con el operador | GET /envios/{shipment_id}/tracking |
PERM_RADI_SALIDA (1) |
R |
E12 · Transferencias — 9 tools, 4 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
crear_transferencia |
Crear una transferencia | POST /transferencias |
USUA_PERM_EXPEDIENTE (3) |
W |
enviar_transferencia |
Enviar una transferencia al receptor | POST /transferencias/{transferencia_id}/enviar |
USUA_PERM_EXPEDIENTE (3) |
W irreversible |
rechazar_transferencia |
Rechazar una transferencia | POST /transferencias/{transferencia_id}/rechazar |
USUA_PERM_EXPEDIENTE (3) |
W irreversible |
recibir_transferencia |
Recibir una transferencia | POST /transferencias/{transferencia_id}/recibir |
USUA_PERM_EXPEDIENTE (3) |
W irreversible |
consultar_acta_transferencia |
Consultar el acta de una transferencia | GET /transferencias/{transferencia_id}/acta |
USUA_PERM_EXPEDIENTE (1) |
R |
consultar_fuid |
Consultar el inventario documental (FUID) de una transferencia | GET /transferencias/{transferencia_id}/fuid |
USUA_PERM_EXPEDIENTE (1) |
R |
consultar_transferencia |
Consultar una transferencia | GET /transferencias/{transferencia_id} |
USUA_PERM_EXPEDIENTE (1) |
R |
listar_transferencias |
Listar las transferencias | GET /transferencias |
USUA_PERM_EXPEDIENTE (1) |
R |
verificar_acta_transferencia |
Verificar las firmas del acta | GET /transferencias/{transferencia_id}/acta/verificacion |
USUA_PERM_EXPEDIENTE (1) |
R |
E13 · Ventanilla única pública — 2 tools, 2 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
publicar_de_oficio |
Publicar de oficio un radicado | POST /documents/{document_id}/publicacion |
PERM_RECLASIFICAR (3) |
W irreversible |
publicar_expediente |
Publicar de oficio un expediente | POST /expedientes/{expediente_id}/publicacion |
PERM_RECLASIFICAR (3) |
W irreversible |
E14 · Administración y PINAR — 19 tools, 1 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
registrar_avance_pinar |
Registrar el avance de un proyecto PINAR | POST /pinar/proyectos/{proyecto_id}/seguimiento |
USUA_PERM_ADMIN (3) |
W |
arbol_dependencias |
Consultar el árbol de dependencias | GET /dependencias/tree |
sin permiso | R |
calcular_dias_habiles |
Calcular el vencimiento en días hábiles | GET /business-days/calculate |
sin permiso | R |
consultar_dependencia |
Consultar una dependencia | GET /dependencias/{dependencia_id} |
sin permiso | R |
consultar_dependencia_por_codigo |
Consultar una dependencia por su código | GET /dependencias/by-codigo/{codigo} |
sin permiso | R |
consultar_parametro |
Consultar un parámetro de la entidad | GET /config/params/{key} |
sin permiso | R |
consultar_plan_pinar |
Consultar un plan PINAR | GET /pinar/planes/{plan_id} |
USUA_PERM_ADMIN (1) |
R |
listar_catalogos |
Listar los catálogos de vocabulario | GET /catalogos |
sin permiso | R |
listar_dependencias |
Listar las dependencias de la entidad | GET /dependencias |
sin permiso | R |
listar_festivos |
Consultar los festivos de la entidad | GET /config/holidays |
sin permiso | R |
listar_items_catalogo |
Consultar los valores de un catálogo | GET /catalogos/{catalogo} |
sin permiso | R |
listar_parametros |
Consultar los parámetros de la entidad | GET /config/params |
sin permiso | R |
listar_planes_pinar |
Listar los planes PINAR | GET /pinar/planes |
USUA_PERM_ADMIN (1) |
R |
mapa_ruta_pinar |
Consultar el mapa de ruta de un plan PINAR | GET /pinar/planes/{plan_id}/mapa-ruta |
USUA_PERM_ADMIN (1) |
R |
objetivos_pinar |
Consultar los objetivos de un plan PINAR | GET /pinar/planes/{plan_id}/objetivos |
USUA_PERM_ADMIN (1) |
R |
priorizacion_pinar |
Consultar la priorización de un plan PINAR | GET /pinar/planes/{plan_id}/priorizacion |
USUA_PERM_ADMIN (1) |
R |
proyectos_pinar |
Consultar los proyectos de un plan PINAR | GET /pinar/planes/{plan_id}/proyectos |
USUA_PERM_ADMIN (1) |
R |
seguimiento_proyecto_pinar |
Consultar el seguimiento de un proyecto PINAR | GET /pinar/proyectos/{proyecto_id}/seguimiento |
USUA_PERM_ADMIN (1) |
R |
tablero_pinar |
Consultar el tablero de un plan PINAR | GET /pinar/planes/{plan_id}/tablero |
USUA_PERM_ADMIN (1) |
R |
E15 · Índice electrónico — 6 tools
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
historial_acceso_expediente |
Consultar el historial de acceso al expediente | GET /expedientes/{expediente_id}/historial-acceso |
USUA_PERM_EXPEDIENTE (1) |
R |
indice_expediente |
Consultar el índice electrónico de un expediente | GET /expedientes/{expediente_id}/indice |
USUA_PERM_EXPEDIENTE (1) |
R |
indice_xml |
Obtener el índice electrónico en XML | GET /expedientes/{expediente_id}/indice.xml |
USUA_PERM_EXPEDIENTE (1) |
R |
roles_autorizados_expediente |
Consultar los grupos autorizados a leer el expediente | GET /expedientes/{expediente_id}/roles-autorizados |
USUA_PERM_EXPEDIENTE (1) |
R |
verificar_indice |
Verificar la integridad del índice electrónico | GET /expedientes/{expediente_id}/indice/verify |
USUA_PERM_EXPEDIENTE (1) |
R |
versiones_indice |
Listar las versiones del índice electrónico | GET /expedientes/{expediente_id}/indice/versions |
USUA_PERM_EXPEDIENTE (1) |
R |
E16 · Notificaciones — 6 tools, 2 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
descartar_evento_atascado |
Descartar un evento fallido | POST /notification/admin/deadletter/{event_id}/discard |
PERM_DLQ_ADMIN (1) |
W irreversible |
reintentar_evento_atascado |
Reintentar un evento fallido | POST /notification/admin/deadletter/{event_id}/replay |
PERM_DLQ_ADMIN (1) |
W irreversible |
consultar_evento_atascado |
Consultar un evento fallido | GET /notification/admin/deadletter/{event_id} |
PERM_DLQ_ADMIN (1) |
R |
consultar_notificacion |
Consultar una notificación | GET /notifications/{notification_id} |
USUA_PERM_ADMIN (1) |
R |
historial_notificaciones |
Ver el historial de notificaciones | GET /notifications/history |
USUA_PERM_ADMIN (1) |
R |
listar_eventos_atascados |
Ver la cola de eventos fallidos | GET /notification/admin/deadletter/ |
PERM_DLQ_ADMIN (1) |
R |
E17 · Archivo físico — 13 tools, 5 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
crear_ubicacion |
Crear una ubicación en el archivo físico | POST /ubicaciones |
USUA_PERM_EXPEDIENTE (1) |
W |
crear_unidad |
Crear una unidad de conservación | POST /unidades |
USUA_PERM_EXPEDIENTE (1) |
W |
devolver_prestamo |
Registrar la devolución de un préstamo | POST /prestamos/{prestamo_id}/devolver |
USUA_PERM_EXPEDIENTE (1) |
W |
registrar_prestamo |
Registrar el préstamo de una unidad | POST /unidades/{unidad_id}/prestamos |
USUA_PERM_EXPEDIENTE (1) |
W |
ubicar_expediente_en_unidad |
Ubicar un expediente en una unidad | POST /unidades/{unidad_id}/expedientes |
USUA_PERM_EXPEDIENTE (1) |
W |
consultar_ubicacion |
Consultar una ubicación | GET /ubicaciones/{ubicacion_id} |
USUA_PERM_EXPEDIENTE (1) |
R |
contenido_unidad |
Consultar el contenido de una unidad | GET /unidades/{unidad_id}/expedientes |
USUA_PERM_EXPEDIENTE (1) |
R |
inventario_documental |
Consultar el inventario documental | GET /fuid |
USUA_PERM_EXPEDIENTE (1) |
R |
listar_prestamos |
Consultar los préstamos del archivo físico | GET /prestamos |
USUA_PERM_EXPEDIENTE (1) |
R |
listar_ubicaciones |
Listar las ubicaciones del archivo físico | GET /ubicaciones |
USUA_PERM_EXPEDIENTE (1) |
R |
listar_unidades |
Listar las unidades de conservación | GET /unidades |
USUA_PERM_EXPEDIENTE (1) |
R |
movimientos_unidad |
Consultar los movimientos de una unidad | GET /unidades/{unidad_id}/movimientos |
USUA_PERM_EXPEDIENTE (1) |
R |
ubicacion_expediente |
Localizar físicamente un expediente | GET /expedientes/{expediente_id}/unidades |
USUA_PERM_EXPEDIENTE (1) |
R |
E19 · Ingesta de correo — 8 tools, 5 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
descartar_correo |
Descartar un correo sin radicarlo | POST /email/cuentas/{cuenta_id}/bandeja/{mensaje_id}/descartar |
PERM_RADI (1) |
W irreversible |
liberar_correo |
Liberar la reserva de un correo | POST /email/cuentas/{cuenta_id}/bandeja/{mensaje_id}/liberar |
PERM_RADI (1) |
W |
radicar_correo |
Radicar un correo como documento de entrada | POST /email/cuentas/{cuenta_id}/bandeja/{mensaje_id}/radicar |
PERM_RADI (1) |
W irreversible |
reservar_correo |
Reservar un correo para trabajarlo | POST /email/cuentas/{cuenta_id}/bandeja/{mensaje_id}/reservar |
PERM_RADI (1) |
W |
sincronizar_buzon |
Traer los correos nuevos de un buzón | POST /email/cuentas/{cuenta_id}/bandeja/sincronizar |
PERM_RADI (1) |
W |
estado_bandeja_correo |
Consultar el resumen de un buzón | GET /email/cuentas/{cuenta_id}/bandeja/estado |
PERM_RADI (1) |
R |
listar_buzones_correo |
Listar los buzones de correo que puedo trabajar | GET /email/cuentas |
PERM_RADI (1) |
R |
listar_correos_pendientes |
Ver los correos de un buzón | GET /email/cuentas/{cuenta_id}/bandeja |
PERM_RADI (1) |
R |
E20 · Operador postal — 4 tools, 2 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
actualizar_estado_envio |
Actualizar el estado de un envío | POST /envios/{shipment_id}/estado |
PERM_RADI_SALIDA (3) |
W |
despachar_envio |
Despachar un radicado por correo postal | POST /documents/{document_id}/envios |
PERM_RADI_SALIDA (3) |
W |
bandeja_envios |
Consultar la bandeja de envíos | GET /envios |
PERM_RADI_SALIDA (1) |
R |
consultar_envios_radicado |
Consultar los envíos de un radicado | GET /documents/{document_id}/envios |
PERM_RADI_SALIDA (1) |
R |
E21 · Conocimiento y RAG — 3 tools
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
buscar_antecedentes |
Buscar antecedentes de un asunto o de un radicado | POST /knowledge/antecedentes |
sin permiso | R |
buscar_conocimiento |
Buscar en la base de conocimiento documental | POST /knowledge/search |
sin permiso | R |
redactar_con_antecedentes |
Redactar un texto apoyado en antecedentes, con citas | POST /knowledge/rag |
sin permiso | R |
E23 · Producción documental — 3 tools, 1 de escritura
| Tool | Qué hace | Endpoint | Permiso (crud) | |
|---|---|---|---|---|
devolver_borrador_a_edicion |
Devolver un borrador aprobado a edición | POST /borradores/{borrador_id}/devolver |
PERM_RADI (1) |
W irreversible |
campos_de_plantilla |
Ver los campos disponibles de una plantilla | GET /plantillas/campos |
PERM_RADI (1) |
R |
combinar_plantilla |
Previsualizar una plantilla combinada | POST /plantillas/{plantilla_id}/combinar |
PERM_RADI (1) |
R |
Exclusión mutua declarada, no descubierta con un 422. buscar_antecedentes acepta el asunto (query) o el radicado de referencia (radicado_ref), nunca ambos ni ninguno — lo impone AntecedentesRequest con un model_validator, y JSON Schema no lo expresa con las piezas del catálogo. El catálogo lo declara con exactly_one_of y coerce_args responde parametros_excluyentes antes de que salga la petición: el 422 del backend dice lo mismo, pero solo después de hacer esperar a la persona.
aprobar_borrador SÍ es requisito (corregido el 2026-08-17). El ciclo del borrador es crear → editar → aprobar → radicar, y ahora lo exige el backend: POST /borradores/{id}/radicar responde 409 borrador_sin_aprobar si el borrador no está aprobado. Antes solo lo exigía la pantalla (canRadicar = estado === "aprobado"), de modo que un POST directo saltaba la aprobación y consumía el número igualmente — un control que vive solo en el navegador no es un control.
Actos irreversibles: frase tecleada, exigida por el backend. Una propuesta cuya tool declara destructive lleva irreversible: true y frase_requerida: "CONFIRMO", y ejecutarla exige enviar esa frase ({token, frase}). Quién dice que el acto es irreversible no es el cliente: es el token firmado, del que sale la tool — así no hay manera de confirmarlo declarando que no lo era. La interfaz deshabilita el botón por cortesía; la barrera vive en write_gate, porque una frase validada solo en el navegador es teatro.
El asistente conversacional también propone. POST /api/v1/assistant/message devuelve propuestas: [] (campo aditivo) con los actos que el modelo preparó. El tool_result que vuelve al modelo dice explícitamente ejecutado: false para que no afirme haber hecho algo que no hizo, y el token no entra en su contexto. La confirmación es POST /api/v1/assistant/confirmar con {token, frase?}.
Trazabilidad del canal (AREA-11-04). Todo lo que el mcp-server hace en nombre de una persona —el asistente, el MCP, la confirmación de una propuesta— llega al gateway por /api/v1/<dominio> y antes se asentaba como canal api, igual que una llamada directa. Ahora cada llamada saliente lleva X-Origen-Canal: mcp.<ts>.<hmac>: una marca firmada con MCP_ORIGIN_SECRET (secreto dedicado, solo del gateway y del mcp-server, distinto del maestro IDENTITY_SIGNING_SECRET), ligada al tenant del token (el validado por el gateway, nunca la cabecera X-Tenant-Slug del cliente) y al token (sha256 del Authorization) y con vigencia de 120 s. El gateway la verifica y solo entonces deriva X-Canal: mcp (derive_canal); sin marca, con firma mala, vencida o de otro token/entidad, el canal es api, y sin secreto configurado nunca se atribuye mcp. La cabecera no se reenvía aguas abajo. Además, los asientos de canal mcp llevan payload.correlacion = {request_id, idempotency_key} (el X-Request-Id del turno y el jti de la propuesta emitido como Idempotency-Key), para atar el asiento al turno y a la propuesta que lo autorizó; el resto de canales no cambia de contenido. Lo que el modelo no puede inventar: POST /assistant/message devuelve además no_verificados: string[] (aditivo, vacío casi siempre): todo número con forma {YYYY}-{TENANT}-{E|S|I}-{NNNNNN} del texto final que no aparezca en algún tool_result de la conversación —lo que el sistema devolvió; lo que escribió la persona no cuenta— se lista ahí y el texto añade un aviso «No verificado». No se elimina del texto: la persona debe ver qué se le dijo.
IP del cliente (AREA-11-08). El mcp-server reenvía al gateway la IP real del cliente en X-Forwarded-For —solo la que el gateway firmó con el secreto de origen MCP_ORIGIN_SECRET (X-Real-Ip + X-Real-Ip-Signature, ligada a IP, persona y entidad; el mcp-server NO posee el secreto maestro de identidad); sin firma o sin secreto no reenvía nada—, de modo que las llamadas del asistente de todas las entidades ya no caen en el cubo por IP del contenedor del mcp-server. Cada turno registra en el log del servicio iteraciones, llamadas_gateway (lo que gasta del cubo de la persona), propuestas y no_verificados.
Fuera del catálogo por decisión, no por olvido — 15 actos declarados. No están simplemente ausentes: el servidor los publica con su motivo y su vía alternativa en mi_alcance y en el recurso orfeo://alcance, porque un catálogo recortado en silencio hace que el límite se descubra pidiendo algo que nunca va a ocurrir. Los criterios son cuatro, y protegen cosas distintas:
- Actos cuya garantía es que haya una persona presente. La tarjeta de confirmación encarece el descuido; no convierte a un modelo en una persona. Aquí entran ejecutar el borrado de un lote de eliminación (se ofrecen las cuatro fases previas —proponer, publicar, avalar y el acta—, nunca la ejecución), firmar un turno de una cadena, darle el visto bueno o rechazarlo, firmar personalmente el acta de una transferencia y comprobar la integridad de una firma.
- Actos que mueven la propia frontera de autorización. Todo el marco consiste en trabajar dentro de los límites de quien pregunta, y un acto que los ensancha los deja de tener: conceder o retirar permisos, cambiar el nivel de acceso de un grupo, crear personas y grupos.
- Actos que sacan un secreto de un solo uso a un canal que no lo custodia: dar de alta o revocar credenciales personales —TOTP, códigos de recuperación, llave de firma—. Se revelan exactamente una vez por diseño; entregarlos aquí los dejaría escritos en el historial de una conversación.
- La regla con la que se mide todo lo demás, que no es un acto peligroso sino el patrón: cambiar el calendario de festivos o los parámetros de la entidad (un festivo añadido hoy mueve vencimientos que ya están corriendo) y añadir valores a los catálogos o cambiar la estructura de dependencias (un catálogo al que se le añaden entradas desde una conversación deja de ser cerrado, que era lo único que lo hacía servir para algo).
Completan la lista los actos que este canal no puede presentar honestamente: sellar un artefacto en WORM y empaquetar el AIP (exigen un identificador interno que quien lo pidiera tendría que inventarse, y un candado de diez años no se acorta), armar la estructura de un plan PINAR (es planeación con el Comité delante, no una secuencia de altas), ver el PDF de un borrador y crear o borrar plantillas (un PDF no cabe en una conversación), configurar los buzones de correo, trabajar un buzón personal y dar de alta o retirar un webhook. Y la vinculación masiva (/radicados/batch, hasta 500) queda fuera mientras su tarjeta de confirmación no pueda leerse: una tarjeta que dice «500 radicados» no permite confirmar con conocimiento de causa, que es justo para lo que existe.
Dos invariantes que cruzan servicios viven en shared/tests/, el único carril de CI que ve el repositorio entero: test_rutas_mcp_alcanzables.py (toda ruta del catálogo está en la tabla de ruteo del gateway — el hueco que ya se coló con la carga masiva) y test_permisos_declarados.py (lo que el catálogo declara es lo que el endpoint exige, en los dos sentidos: declarar de menos ofrece la tool a quien recibirá un 403; declarar de más la esconde a quien sí puede).
Asistente conversacional (E18, ADR-019/ADR-020) — vía gateway¶
A diferencia del catálogo/protocolo MCP (que no se expone por el gateway), el asistente conversacional sí se enruta por el api-gateway (/api/v1/assistant/ → mcp-server), de modo que hereda su validación JWT y la inyección de X-Tenant-Slug. El mcp-server orquesta un loop LLM (Claude) que ejecuta las tools de solo lectura del catálogo como llamadas al gateway re-propagando el token del usuario — el asistente corre as-the-user, nunca con credenciales propias, y cada tool revalida su RBAC/clearance en el backend.
POST /api/v1/assistant/message{mensaje: string(1..4000), conversation_id?: string}— envía un turno del usuario. Devuelve{conversation_id, message: {id, role:"assistant", texto, formato:"markdown", ts}}. Elconversation_id(generado si se omite) mantiene el hilo; el historial es efímero por proceso y aislado por tenant (unconversation_idnunca cruza tenants). El asistente ofrece las 207 tools, y las 75 de escritura NO se despachan: al recibir unatool_usede escritura el servidor firma una propuesta y devuelve al modelo untool_resultque diceejecutado: false(assistant.py). Ese anclaje importa: sin él, el modelo lee su propiatool_usecomo si hubiera surtido efecto y redacta «he radicado el documento», afirmando un hecho falso sobre un registro oficial — la regla de no-invención aplicada a sus propios actos. El token firmado no entra en su contexto; quien lo confirma es la persona, enProposalCard. Los argumentos se recortan además al esquema declarado antes de despachar (una clave no declarada en el body no viaja, aunque el techo de clearance ya la impediría). El contenido devuelto por las tools (p. ej. radicados de Entrada redactados por terceros) es dato, nunca instrucción (frontera anti-inyección en el system prompt).503 assistant_unavailablesi el asistente no está configurado (sinANTHROPIC_API_KEY) o el SDK falla — degradación honesta, nunca una respuesta simulada (RF-FIR-15);401sin token;409 conversation_limital agotar el tope de turnos del hilo. Respuestas de tool403/404se comunican de forma genérica ("no encontré ese registro"), sin revelar la existencia de registros clasificados (no-oráculo, RF-SEG-08 / Ley 1712/2014).
Capa de conocimiento (E21, ADR-006) — knowledge-service, puerto 8011¶
Capa advisoria/derivada y opt-in (no es fuente de verdad). Embeddings con proveedor pluggable (stub local por defecto).
POST /api/v1/knowledge/ingest{source_type, source_ref, texto, metadata?, acl?}— calcula el embedding y persiste el fragmento (pgvector) con su ACL. Para proteger contenido clasificado, el ingestor debe incluir el nivel enacl.nivel_seguridad(1–3); ausente = PUBLICA (1).POST /api/v1/knowledge/search{query, top_k?, acl?}— recuperación semántica (kNN coseno). Control de acceso resuelto en el servidor (RF-SEG-08 / RF-BUS-04): se devuelven siempre solo fragmentos connivel_seguridad ≤el clearance del llamante (derivado del JWT/BD, no del cuerpo de la petición); elaclopcional del cliente es solo narrow adicional (p. ej. por dependencia), nunca amplía el acceso. Devuelve[{id, source_type, source_ref, texto, metadata, score}]. Los tombstones (fragmentos purgados por reclasificación) quedan excluidos de la búsqueda. Desde B10 elWHEREcompara el clearance conGREATEST(nivel del fragmento, nivel vigente del radicado): un radicado reclasificado deja de servirse aunque el evento no haya llegado; si falta la tablaradicadosresponde503.POST /api/v1/knowledge/antecedentes{query|radicado_ref, source_type?, top_k?}(XORquery/radicado_ref, E21 Inc.2, Patrón A) — recuperación pura ANCLADA y CITADA, sin LLM: reusasearch(mismo pre-filtro ACL), devuelve{antecedentes:[{source_type, source_ref, snippet, score}], advisory}con un aviso fijo (CC-01/CC-02).radicado_refinexistente o que excede el clearance del solicitante colapsan al mismo404 antecedente_source_not_found(anti-oráculo, sin 403 diferenciado).POST /api/v1/knowledge/rag{query, top_k?}(E21 Inc.3, Patrón B) — RAG generativo con citas. Recupera con el mismo pre-filtro ACL desearchy, siAI_PROVIDERes un proveedor de generación real (ollama_localdefault soberano,ollama_cloud/openai_compatibleexternos,anthropiccon Citations API nativa), sintetiza una respuesta ANCLADA. Barrera dura de soberanía (sin override): con proveedor externo, excluye del contexto enviado al LLM los fragmentosnivel_seguridad ≥ RESERVADA— visibles por ACL, pero nunca egresados a un tercero; si no queda contexto generable, cae a recuperación pura (Patrón A) sobre todo lo recuperado.AI_PROVIDER=disabled(o no configurado) también cae siempre a Patrón A. Devuelve{answer, citations:[{cited_text, source_type, source_ref, score}], proveedor, modelo, confianza, suggestion_id, advisory}—answer/modelo/suggestion_idsonnullcuando no hubo generación real. Cada generación real se registra enkb_suggestions(CC-03:proveedor/modeloen columnas separadas) y enaudit_log.503 rag_unavailableante cualquier fallo del proveedor (SDK ausente, credencial ausente, error de red/API) — degradación honesta (RF-FIR-15), nunca una respuesta simulada.ollama_locales soberano por su destino, no por su nombre (AREA-11-12): solo se trata como local si el host deOLLAMA_URLes loopback, de red privada, de contenedor o*.local; con un host público (https://ollama.ejemplo.com) el RAG excluye del contexto todo chunknivel_seguridad ≥ RESERVADA, igual que con un proveedor externo.POST /api/v1/knowledge/suggestions/{id}/aceptar(AREA-11-11, CC-02) — aceptación humana de una sugerencia del RAG: la capa sugiere, la persona decide. Solo la acepta quien la generó (kb_suggestions.creada_por, migración012; la condición va en elWHERE):404 suggestion_not_foundneutro si no existe, es de otra persona o es anterior a la migración (sin creador, no aceptable),409 suggestion_already_acceptedsi ya estaba aceptada,401sin identidad. Devuelve{id, aceptada_por, aceptada_en}—nunca el contenido— y deja el asientorag.aceptadaenaudit_logen la misma transacción que la aceptación, conpayload = {aceptada_en, sha256_texto, proveedor, modelo, fuentes}: la huella del texto aceptado, el proveedor/modelo y lossource_refcitados (sin su contenido), para que el registro inmutable pruebe qué se aceptó.
Ingesta event-driven (E21, MVP): además del
POST /ingestmanual, el knowledge-service consumeorpycamcp.document.events(consumer groupknowledge-ingest+ DLQ, ADR-021) y auto-indexa cada radicado al crearse (document.radicado.created→subject/sender,source_type="radicado",source_ref=tracking_number). Elnivel_seguridadse replica del evento (fail-closed a CLASIFICADA si falta) y se mantiene consistente ante reclasificación (document.radicado.reclassified): una subida a RESERVADA+ con proveedor de embeddings externo purga el contenido (tombstone), y el nivel de un fragmento nunca baja salvo por una reclasificación explícita, sea cual sea el orden de llegada de los eventos. Material RESERVADA+ no se embebe con un proveedor externo (queda registrado enaudit_log). Un radicado anulado (document.radicado.annulled) se marca voided de forma permanente y queda excluido de la búsqueda a cualquier clearance (no se re-indexa aunque un evento de creación se reprocese después). Es una capa derivada/advisoria (ADR-006): no es fuente de verdad; el registro legal vive en document/archive.
Webhooks salientes (E11, ADR-018)¶
POST /api/v1/webhooks{url, event_types: [...], secret?}— suscribe un endpoint externo (event_typesvacío = todos los eventos). Elsecretse guarda cifrado en reposo (AES-256-GCM, KEK propia de notification-service, AAD de tenant+webhook) y nunca se devuelve (ni al crear ni enGET /webhooks); la firmaX-OrpycaMCP-Signaturede cada entrega se calcula descifrándolo solo en memoria.503 webhook_secret_kek_unavailablesi el servicio no tiene una KEK utilizable al crear. Una suscripción cuyo secreto no se puede descifrar (KEK perdida, fila heredada aún sin cifrar) no se entrega hasta repararla. ExigeUSUA_PERM_ADMINen nivel 3 (crear/borrar; listar basta con nivel 1) y deja el asientonotification.webhook.createdcon el actor (X-User-Id,401si falta) en la misma transacción — el asiento guarda soloscheme://host:portmásurl_sha256(nunca credenciales, query ni path, donde Slack/Teams/Discord llevan el token) y nunca elsecret; sin módulo de auditoría responde503 audit_unavailabley no se crea nada: registrar una suscripción entrega cada evento del tenant a la URL indicada, así que es config de administración — sin el gate sería un canal de exfiltración (y SSRF).422nuevo siurlapunta a un destino interno/no enrutable (loopback127.0.0.0/8/::1/localhost, link-local169.254.0.0/16/fe80::/10— incluye el endpoint de metadata cloud, privado10.0.0.0/8/172.16.0.0/12/192.168.0.0/16/fc00::/7, un nombre de servicio de la red Docker interna de OrpycaMCP, o un esquema distinto dehttp/https): eldetailexplica el motivo concreto del rechazo, no un "URL inválida" genérico. Estricto por defecto; relajable solo en desarrollo conWEBHOOK_ALLOW_PRIVATE_TARGETS=trueen notification-service (kill-switch, nunca en producción).GET /api/v1/webhooks— lista las suscripciones (X-Total-Count). ExigeUSUA_PERM_ADMIN.GET /api/v1/webhooks/{id}/entregas?page=1&size=20— constancia de entregas de una suscripción (más recientes primero;X-Total-Count;size≤ 200). ExigeUSUA_PERM_ADMIN. Cada intento deja dos filas inmutables (webhook_entregas, append-only):fase=envio—escrita ANTES del POST; si no puede escribirse, no se envía— yfase=resultadoconentregado/http_error/error_red/destino_rechazado/secreto_inutilizable(estos dos últimos no llegan a enviar y solo dejanresultado). Campos:entrega_id,webhook_id,url_destino(soloscheme://host:port) +url_sha256,event_id,event_type,intento,body_sha256(nunca el cuerpo),recortado,firmado,http_status,error(≤ 500, sin URLs),at. No responde404si la suscripción ya no existe: la constancia sobrevive al borrado. Retención/purga: pendiente (ADR-018).DELETE /api/v1/webhooks/{id}— elimina una suscripción. ExigeUSUA_PERM_ADMINnivel 3 y dejanotification.webhook.deletedcon el actor (misma URL reducida +url_sha256).404 webhook_not_foundsi no existe.- Contenido del webhook: el sobre se recorta por el nivel efectivo (máximo de
nivel_seguridad,nivel_nuevo,nivel_anterior): desde RESERVADA (2) se quitan asunto, remitente, observaciones y la ruta de dependencias (from_dept,to_dept,assigned_to,dest_dept,dest_dept_code,origin_dept);causal_reserva,fundamento_juridicoyplazo_reserva_mesesno salen nunca. Un eventodocument.*/workflow.*que no declara nivel sale mínimo (tipo, id, fechas). La entrega conecta a la IP validada (con elHosty el SNI originales) y no sigue redirecciones. - Al ocurrir un evento de dominio, OrpycaMCP hace
POSTdel sobre canónico a las suscripciones que coinciden, firmado en la cabeceraX-OrpycaMCP-Signature: sha256=<hmac>(best-effort con reintentos). El receptor debe ser idempotente. Cada intento de entrega re-valida el destino con resolución DNS real (no solo al crear la suscripción): cierra el caso de un dominio que resolvía a IP pública al suscribirse y luego se re-apunta a una IP interna (DNS rebinding), y también cubre suscripciones preexistentes a este endurecimiento — si el destino ya no es válido, la entrega se omite y se registra, pero la suscripción no se borra ni se desactiva automáticamente. Limitación aceptada: queda una ventana TOCTOU entre esa revalidación y la conexión real dehttpx(verapp/core/webhook_security.py).
Eventos Redis Streams¶
El workflow-service publica en orpycamcp.workflow.events cuando cambia el estado de un flujo:
{
"event_type": "flow_step_created",
"radicado_id": "uuid",
"tracking_number": "2024-ICETEX-E-000001",
"to_dept": "Dirección Jurídica",
"action": "assign",
"tenant_slug": "icetex",
"timestamp": "2024-01-15T10:30:00Z"
}
El notification-service consume este stream (consumer group notification-service) y envía email al departamento destino.
Gestión del dead-letter (Fase 5, ADR-021)¶
Los eventos que no se pueden procesar tras max_deliveries reintentos quedan en la tabla evento_dead_letter (por tenant) con status='pending'. Cada servicio consumidor expone una superficie admin para inspeccionarlos y resolverlos, gateada por el permiso PERM_DLQ_ADMIN (rol de plataforma; asignación restringida). Implementado en signature-service, workflow-service, notification-service y knowledge-service (este último, desde la tanda T9 / AREA-10-10: /api/v1/knowledge/admin/deadletter, con replay/discard a nivel 3 y replay de document.radicado.created|reclassified|annulled|purged en la misma transacción que la transición y el asiento; un tipo que knowledge no aplica da 422 unsupported_event_type). evento_dead_letter es una tabla física compartida: cada servicio filtra por su origin_group para no operar sobre los dead-letters de otro, y su clave primaria es (origin_group, event_id) (T9 / AREA-10-07): el mismo evento aparcado por dos grupos deja una fila y un asiento evento.dead_letter por grupo; repetir el mismo par sigue siendo idempotente.
| Método | Ruta | Descripción |
|---|---|---|
GET |
/api/v1/{signature\| Lista paginada (X-Total-Count). Solo cabeceras (sin payload de negocio) y **filtrada por clearance del llamante en elWHERE** (nivel VIVO del radicado referenciado; duda = nivel 3): una fila por encima de su nivel no aparece ni cuenta en el total, y el detalle/replay/descarte de esa fila responde el mismo404que unevent_idinexistente. Página y total exactos, sin tope de candidatas; la cabeceraX-Truncatedse retiró.503si falta la tablaradicados. |notification\|knowledge}/admin/deadletter?status=pending&origin_stream=&page=&size= |
Lista paginada (X-Total-Count). Solo cabeceras (sin payload de negocio) y filtrada por clearance del llamante (no-read-up fila-a-fila sobre el nivel vivo del radicado referenciado, misma resolución que el detalle): una fila cuyo objeto excede el nivel del admin se omite por completo (no aparece ni cuenta en X-Total-Count), consistente con el 404 neutro del detalle. Fail-closed ante cualquier error de resolución. La paginación se aplica en la capa de aplicación, después del filtrado. El conjunto de candidatas se acota a 1000 antes de filtrar; si hay MÁS de 1000 (el repositorio pide 1001 para distinguirlo de «exactamente 1000») la respuesta incluye el header X-Truncated: true (advierte que X-Total-Count puede sub-reportar); con exactamente 1000 no se emite. |
GET |
/api/v1/{signature\|workflow\|notification\|knowledge}/admin/deadletter/{event_id} |
Detalle (sobre íntegro + fallo). Gateado por clearance (no-read-up sobre el nivel vivo del radicado; 404 neutro si insuficiente o inexistente —cuerpo byte-idéntico—). Una vista autorizada deja un asiento evento.dead_letter.viewed en audit_log (una vista bloqueada NO audita, para no confirmar existencia). |
POST |
/api/v1/{signature\|workflow\|notification\|knowledge}/admin/deadletter/{event_id}/replay |
Reprocesa el evento. 200 → status='resolved'; 409 si no está pending; 404 si no existe o sin clearance. knowledge: ancla reclassified/created al nivel vivo del radicado; 409 stale_event si ya no existe. signature/workflow: reproceso idempotente y atómico; 502 si falla (queda pending). notification (correo no transaccional): best-effort claim-then-send —limpia el dedup y reenvía—; 422 si el origin_stream es desconocido o el tenant no cuadra, 502 si el envío falla (queda pending). |
POST |
/api/v1/{signature\|workflow\|notification\|knowledge}/admin/deadletter/{event_id}/discard |
Cierra como discarded. Body { "reason": "..." } (obligatorio, ≥10 caracteres tras strip). Mismos códigos. |
Toda acción (replay/discard/viewed) se registra en el audit_log inmutable con el actor humano (X-User-Id). En notification la auditoría es dependencia dura de la disposición: si el módulo de auditoría no está disponible, replay/discard responden 503 antes de transicionar (no se dispone sin dejar traza legal). Los eventos malformados sin tenant_slug (SYSTEM_TENANT) no son replayables (gestión de plataforma).
Batch API — document-service¶
Permite crear múltiples radicados o expedientes en una sola llamada. El procesamiento es asincrónico.
Contrato de ruteo (fix de desfase, 2026-08).
document-serviceyarchive-servicecomparten el prefijo raíz/api/v1/batch, pero cada uno expone un tipo de trabajo disjunto. El estado del job queda anidado bajo el tipo (/batch/documents/{job_id}/status,/batch/expedientes/{job_id}/status) — antes ambos servicios exponían la misma formaGET /api/v1/batch/{job_id}/status, lo que hacía imposible que el api-gateway decidiera a qué upstream reenviar por prefijo (eljob_idno basta). ElPOSTya distinguía por subruta (/documentsvs/expedientes); ahora elGETsigue el mismo criterio. RequierePERM_RADI(igual quePOST /api/v1/documents/, la creación individual) — antes esta ruta no exigía ningún permiso.
POST /api/v1/batch/documents¶
Crea un job para registrar 1-1000 radicados. Retorna 202 Accepted inmediatamente. Requiere PERM_RADI.
Cada ítem exige los datos mínimos del radicado (T13 / AREA-09-02): doc_class y, según el tipo, dest_dept_code (E, I) u origin_dept_code (S). Un ítem que no los trae rechaza toda la petición con 422; el nombre de la dependencia se resuelve contra la tabla dependencias del tenant (una inexistente o inactiva deja el ítem en failed antes de gastar consecutivo). Un lote encolado antes de este cambio, sin códigos, termina con esos ítems failed (reprocesables solo tras corregirlos).
El procesamiento corre en una tarea de fondo con su propia conexión del pool — nunca la de la petición, que se devuelve al pool en cuanto se responde (ver acquire_tenant en app/core/database.py). Un job que no puede procesarse queda en failed con su motivo en error_message: pending significa que sigue en cola, no que algo salió mal. pages es opcional en la petición y se registra como 0 cuando no se envía, igual que en el camino síncrono. Desde T4 cada ítem se valida contra la plantilla de metadatos activa de su doc_class (si no encaja, el ítem queda failed con error_tipo: MetadataValidationError), el radicado guarda metadata_template_id y el año del número es el de la zona horaria de radicación, no el UTC.
// Request
{
"documents": [
{ "doc_type": "E", "subject": "Solicitud 1", "sender_name": "Juan Pérez" },
{ "doc_type": "E", "subject": "Solicitud 2", "sender_name": "Ana Gómez" }
]
}
// Response 202
{ "job_id": "uuid", "job_type": "documents", "status": "pending", "total_items": 2, "created_at": "..." }
GET /api/v1/batch/documents/{job_id}/status¶
Monitorea el progreso del job y expone el detalle de errores por ítem. Requiere PERM_RADI. Alcance (auditoría 2026-10): solo lo ve quien creó el lote o quien tiene PERM_RADI con nivel CRUD >= 3; para cualquier otro el lote «no existe»: 404 {"error": "lote_no_encontrado", "detail": "No existe el trabajo solicitado", "status": 404} (el mismo cuerpo que un id inventado; antes detail era una cadena). Pide X-User-Id (400 sin él). No-read-up del subject: el asunto de un ítem cuyo radicado quedó por encima del nivel de acceso del llamante (p. ej. se reclasificó a reservado después) viene null (igual que su nivel_seguridad, campo nuevo por ítem: null si no hay radicado o no se alcanza); el de un ítem sin radicado (pendiente o fallido) es el que envió el propio llamante.
// Response 200
{
"job_id": "uuid",
"job_type": "documents",
"status": "completado_con_errores", // pending | processing | completed | completado_con_errores | failed
"total_items": 3,
"processed_items": 2, // ítems en 'success' (recontados desde los ítems)
"failed_items": 1, // ítems en 'failed'
"pending_items": 0, // faltantes: aún sin procesar (lote interrumpido)
"reprocesable": true, // hay fallidos/faltantes Y el lote ya terminó -> ofrecer «Procesar los faltantes»
"reanudaciones": 0, // veces que el barrido retomó este lote tras un reinicio
"reprocesado_at": null,
"items": [
{ "item_index": 0, "status": "success", "result_id": "uuid-radicado", "subject": "Solicitud 1", "intentos": 1 },
{ "item_index": 1, "status": "failed", "subject": "Solicitud 2", "intentos": 1,
"error_message": "Falta el campo obligatorio expediente_ref", "error_tipo": "MetadataValidationError" },
{ "item_index": 2, "status": "success", "result_id": "uuid-radicado", "subject": "Solicitud 3", "intentos": 1 }
],
"errores": [ /* subconjunto de `items` con status = 'failed', mismas claves */ ]
}
Estados del lote: pending (en cola o interrumpido; lo retoma el barrido), processing, completed (sin fallos), completado_con_errores (terminó, hay ítems failed: ver errores), failed (reservado al lote que reventó fuera de los ítems, con su motivo en error_message). Estados del ítem: pending | success | failed. Contrato para la UI: mostrar errores[] (asunto + error_message), y cuando reprocesable sea true ofrecer «Procesar los faltantes» → POST .../reprocesar; mientras el lote esté pending/processing no se ofrece (lo retoma el barrido solo).
POST /api/v1/batch/documents/{job_id}/reprocesar¶
Reprocesa solo los ítems failed (y los pending faltantes) del lote. Exige PERM_RADI y ser quien creó el lote, o tener PERM_RADI con nivel CRUD >= 3 (auditoría 2026-10: antes bastaba cualquier PERM_RADI). Para cualquier otro el lote no existe: 404 lote_no_encontrado, y se comprueba antes de tomar el lock, así que un tercero no distingue «existe» por un 409 lote_en_proceso. Retorna 202:
Idempotente y sin doble numeración: los ítems ya radicados (success) no se tocan y conservan su número; cada ítem se procesa en su propia transacción (el consecutivo, el radicado, la marca de éxito y el asiento van juntos, o ninguno), de modo que un ítem que falla no deja hueco y uno interrumpido no deja nada. Deja el asiento document.lote_reprocesado (actor = X-User-Id; los radicados nuevos de ese reproceso se registran a su nombre) con, por cada ítem fallido, item_index, error_tipo, error_message (truncado a 500) e intentos: la fila los limpia al volver a pending y el asiento es lo único que conserva por qué falló. Errores: 404 lote_no_encontrado, 409 lote_en_proceso (otro proceso lo está procesando ahora), 409 nada_que_reprocesar (sin fallidos ni faltantes). (La ruta va bajo /documents/ para que el api-gateway la reenvíe a document-service por prefijo, como el status.)
Reanudación y varias réplicas (AREA-09-15). El lote ya no depende del proceso que lo encoló: un barrido (al arrancar, tras 20 s, y cada 5 min) retoma los lotes pending/processing con más de 120 s que nadie procesa (document.lote_reanudado; el asiento va a nombre del sistema —actor = 00000000-0000-0000-0000-000000005ec1, canal = system— con en_nombre_de = quien encoló el lote en el payload, y lo mismo vale para los document.radicado_created de los ítems que radica esa reanudación; el registered_by del radicado sigue siendo el creador del lote), y retira los completed sin errores con más de 90 días (BATCH_RETENCION_DIAS; los completado_con_errores y failed se conservan para revisarlos). Cada lote se procesa bajo un advisory lock de sesión por (tenant, lote): dos réplicas no lo procesan a la vez, y si el proceso muere el lock cae con su conexión. Configuración: BARRIDO_ENABLED, BARRIDO_INTERVALO_SEGUNDOS, BATCH_REANUDAR_DESPUES_SEGUNDOS, BATCH_RETENCION_DIAS.
Auditoría (cerrado 2026-08). La creación masiva de radicados deja un asiento
document.radicado_createdenpublic.audit_logpor cada radicado creado con éxito (no uno por lote) — espejo dePOST /api/v1/documents/(creación individual) y debatch/expedientesen archive-service.actores elX-User-Idque validó el router al encolar el job, nunca un campo del body. El payload llevaorigen: "lote",job_ideitem_index(el lote y sus ítems se retiran a los 90 días; el asiento no, y sin esto un radicado de lote era indistinguible de uno radicado a mano). Cada ítem se procesa en su propia transacción (secuencia +INSERT+ asiento, todo o nada): un lote parcialmente exitoso deja tantos asientos como ítems realmente creados, y los fallidos quedan marcadosfailedsin asiento.
Batch API — archive-service¶
POST /api/v1/batch/expedientes¶
Crea 1-100 expedientes con radicados vinculados. Retorna 202 Accepted. Requiere USUA_PERM_EXPEDIENTE.
// Request
{
"expedientes": [
{
"name": "Pensión García 2024",
"trd_serie_id": "uuid",
"radicado_ids": ["uuid1", "uuid2"],
"tracking_numbers": ["2024-ICETEX-E-000001", "2024-ICETEX-E-000002"]
}
]
}
// Response 202 — mismo formato que batch documents
GET /api/v1/batch/expedientes/{job_id}/status¶
Monitorea el progreso del job. Requiere USUA_PERM_EXPEDIENTE (deja asiento en audit_log por expediente creado).
Full-Text Search & Reportes — document-service¶
Plantillas y borradores de Salida (paridad legado)¶
GET/POST/DELETE /api/v1/plantillas— plantillas reutilizables (nombre,cuerpo,tipo_documental).POSTvalida los marcadores de combinación: un cuerpo con un marcador que no existe en el vocabulario se rechaza con422 marcador_desconocidoy la lista de los que sobran (E23, RF-PRO-01). El rechazo ocurre al guardar, no al usar: en el legado un marcador mal escrito no fallaba nunca y acababa impreso en el oficio que recibía el ciudadano.GET /api/v1/plantillaslleva gate any-ofPERM_RADIoUSUA_PERM_ADMIN(B6) y acepta?todas=true, que exigeUSUA_PERM_ADMIN(403si falta). Sin él, el listado se recorta a las plantillas que el llamante alcanza desde sus dependencias (E23, RF-PRO-06): globales, más las asignadas a alguna de sus URD activas. No es una restricción de seguridad —una plantilla no contiene datos de nadie— sino de uso: con cuarenta en la lista, la que hace falta no se encuentra. Un usuario sin URD activa ve solo las globales, no todas: el vacío no es un atajo a «sin restricción». Un recorte que el propio llamante pudiera apagar con un parámetro no sería un recorte, de ahí el permiso.PUT /api/v1/plantillas/{id}/alcance{es_global, dependencias[]}— a qué dependencias alcanza una plantilla (paridad consgd_plan_plantillas.depe_codi). RequiereUSUA_PERM_ADMIN. Poneres_global=trueborra la lista: mantenerla guardada dejaría una asignación invisible que reaparecería al volver a restringir. Restringir a ninguna dependencia es un estado válido — retira la plantilla de circulación sin borrar los documentos ya producidos con ella.POST /api/v1/plantillas/importar(multipart,file) →{cuerpo, perdido[], marcadores_desconocidos[]}— importa un.docx/.odtcomo cuerpo de plantilla (RF-PRO-07, paridad conimport_docx_editor.php). RequiereUSUA_PERM_ADMIN. No guarda nada: devuelve el cuerpo para revisarlo antes de crear la plantilla, y con él los marcadores que rechazarían el guardado — dichos antes, no después. Mejor esfuerzo declarado: conserva párrafos, negritas, listas y tablas del.docx; del.odtsolo el texto por párrafos.perdido[]dice qué no llegó — encabezados y pies de página no se importan a propósito: los pone la plantilla de página del sistema (RF-PRO-04), y traérselos duplicaría el membrete en cada oficio. Una importación que promete fidelidad total y no la da es peor que una que avisa. Se acota el tamaño del fichero y el descomprimido: unos pocos KB comprimidos pueden expandirse a gigabytes, y esa segunda defensa es la que importa..docbinario y.rtfno se admiten (exigirían LibreOffice, decisión D-1).GET /api/v1/plantillas/campos— vocabulario de combinación: los marcadores admitidos connombre,marcador(*NOM_R*),alias({{nom_r}}),descripcion,grupoydiferido. RequierePERM_RADIoUSUA_PERM_ADMIN(any-of, B6: el administrador de plantillas no necesita radicar). Los dos camposdiferido(RAD_S,F_RAD_S) no existen hasta consumir el número y se resuelven en una segunda pasada al radicar.POST /api/v1/plantillas/{id}/combinar{radicado_padre_id?, dest_dept_code?}— devuelve{cuerpo, faltantes[]}: el cuerpo de la plantilla con los campos ya resueltos en el servidor y la lista de marcadores que quedaron sin valor. RequierePERM_RADI. No-read-up (RF-SEG-08): el radicado padre se lee con el clearance del llamante; si lo supera, sus campos salen enfaltantesigual que si no existiera — sin ese gate, combinar sería una vía de exfiltración, porque el asunto del padre acaba escrito dentro del cuerpo.POST /api/v1/borradores{doc_type, subject, dest_dept_code?, dest_dept?, cuerpo?, plantilla_id?, radicado_padre_id?}— crea un borrador. El destino se guarda por código (borrador.dest_dept_code, migración document046;dest_deptes el alias deprecado y el nombre guardado es el del catálogo;PATCHreescribe el par entero). Un nombre ambiguo es422 dependencia_ambigua; un destino que no es dependencia (Salida a un tercero) se conserva como texto sin código. Si se daplantilla_idy nocuerpo, el cuerpo se combina contra el contexto (radicado padre, remitente, dependencia, fechas) en vez de copiarse literal. Conplantilla_idla combinación se ejecuta siempre, aunque elcuerpovenga puesto: es de ahí de donde salen losfaltantes[], que se guardan en el borrador. El cliente NO los envía — es un dato que acabaría impreso en un documento que da fe, y no puede depender de quien llama. Elcuerpoque se almacena sigue siendo el del cliente, que ha podido editarlo.GET /api/v1/borradores(?estado=),GET /{id},PATCH /{id}(editar solo si no radicado). No-read-up (RF-SEG-08): el borrador heredanivel_seguridaddel radicado padre (sin padre, 1;BorradorResponse.nivel_seguridad). Listar, leer, editar, aprobar, devolver, previsualizar y radicar filtran por nivel en elWHERE(nivel efectivo = el mayor entre el heredado y el vigente del padre, de modo que reclasificar el padre cierra también sus borradores);404neutro si no se alcanza. Crear sobre un padre inalcanzable da404(antes201con contexto vacío).PATCHasientaborrador.editadoen la misma transacción. Quién puede EDITAR (PATCH /{id}, AREA-01-N2, 2026-10-02):PERM_RADI(gate de la ruta) ya no basta; además hay que tener el radicado. Si el borrador responde a un radicado padre, lo edita quien lo tiene según la tenencia de ADR-028 (el paso de flujo activo asignado al actor, o sin asignar y de una dependencia suya); sin paso activo, quien poseía el último paso completado; sin ningún paso, quien radicó el padre (R16). Sin padre, su creador. Quien no lo tiene recibe el mismo404neutro que un borrador inexistente (sin tocar la fila) y la regla va en elWHEREdelUPDATE.aprobarydevolverNO exigen tenencia a propósito: son actos de revisión (aprobar suele ser de otra persona y está atado al circuito de firma); siguen conPERM_RADI+ clearance + estado + circuito.radicarSÍ exige la misma tenencia (auditoría 2026-10, Acuerdo AGN 060/2001): es el acto irreversible —consume un número inmutable—, así que quien no tiene el borrador recibe el404neutro, sin consumir número ni tocar la fila (la tenencia va en elWHEREde la lectura). Y elUPDATEdePATCHllevaestado <> 'radicado': si el borrador se radica entre la guarda y la escritura, responde409 borrador_radicadoy no edita.POST /api/v1/borradores/{id}/aprobar(borrador → aprobado) — el circuito de aprobación ata (RF-PRO-09). Antes de marcar el estado se consulta a signature-service si hay un circuito de visto bueno abierto sobre este borrador; si lo hay,409 circuito_pendiente. El gate es vinculante si existe, no obligatorio: sin circuito abierto se aprueba directo como siempre — hay entidades donde una salida no pasa por visto bueno, y exigirlo a todas convertiría en requisito un paso que nadie pidió. Lo que no puede ocurrir es abrir un circuito deliberadamente y luego saltárselo: sin este gate el visto bueno de los funcionarios era decorativo, y el gatedocumento_modificadoque protege cada turno no protegía nada, porque nadie estaba obligado a pasar por él.503 circuito_no_verificablesi signature-service no responde, y no se aprueba. Suponer «no hay circuito» reabriría el agujero por la puerta de atrás, justo cuando el sistema está degradado; la única consecuencia de no saber es no poder aprobar. Resuelto el último turno, signature-service deja el borrador aprobado por su cuenta (POST /api/v1/internal/documentos/borradores/{id}/aprobar, sellado conX-Internal-Token, idempotente y solo desdeborrador). Ese aviso es best-effort a propósito y puede serlo sin abrir ninguna ventana: si se pierde, el botón vuelve a estar disponible porque la consulta ve el circuito ya cerrado. Se pierde un clic, no la garantía — la autoridad es la consulta, no el aviso.POST /api/v1/borradores/{id}/previsualizar→application/pdfcon la cabeceraX-Faltantes(lista separada por comas): los campos guardados al combinar la plantilla más los que sigan sin resolver en el texto de ahora. Sin los guardados esta cabecera salía siempre vacía — el cuerpo se almacena ya combinado, así que los huecos son cadenas vacías y no quedan marcadores que reportar. Combina y renderiza sin persistir nada: es lo que permite ver el documento antes de gastar el número.*RAD_S*se queda a la vista a propósito — sustituirlo por un número de ejemplo enseñaría un documento que no es el que se va a producir.503 render_no_disponible/422 render_fallido.POST /api/v1/borradores/{id}/devolver→ devuelve un borrador aprobado a edición (aprobado → borrador).409si ya está radicado o si no estaba aprobado. Sin esta vuelta el ciclo era de una sola dirección y el texto quedaba congelado al aprobar, así que un circuito de firmas no se podía revertir por cambios en el documento. No toca el circuito: reabrir no invalida nada por sí mismo — devolver y volver a aprobar sin tocar una letra deja las aprobaciones intactas. Lo que las tira es editar el texto, porque el disparador recalculacuerpo_sha256y la cadena deja de casar.- El circuito abierto también bloquea aquí (RF-PRO-09):
409 circuito_pendientesi quedan turnos sin resolver, y el gate va antes de combinar, renderizar y pedir el consecutivo. Poner el gate solo en/aprobarno bastaba: el circuito se abre sobre un borrador ya aprobado —ese es el orden del trámite—, así que la vía real para saltárselo era radicar, no aprobar dos veces. Y radicar es peor: aprobar de más se deshace devolviendo a edición; un número consumido no se devuelve nunca. POST /api/v1/borradores/{id}/radicar{tipo_documental?, origin_dept_code?, dest_dept_code?, metadata?}(cuerpo opcional, T13 / AREA-09-02;metadataB6/23: valores del tipo, validados contra su plantilla activa de metadatos; un incumplimiento es422 metadata_validation_failedconfield—antes era un 500— y no consume número;from_borradorlo pone el sistema y el cuerpo que lo traiga se rechaza) — el borrador no guarda tipo ni dependencias, y el radicado los exige (Ac. AGN 060/2001 §5.2): lo que no se envíe se deriva (tipo de la plantilla; destino por eldest_dept_codedel borrador (o, en uno anterior a la046, por el único candidato dedest_dept: dos homónimas activas son422 dependencia_ambigua, ya no «la de menor código»); origen de la Salida = la dependencia de quien radica: la que recibió el padre si es suya, si no la de menor código entre sus URD activas) y lo que se envíe manda. Sin dato derivable →422 datos_minimos_incompletoscon loscamposque faltan, antes de consumir el número; una dependencia suprimida →400 invalid_dependencia. Genera el radicado real con su PDF/A anexado como soporte principal (tipo_anexo='documento_principal',es_principal=true) y marca el borradorradicado. Nivel heredado (T3): el radicado nuevo toma el nivel vigente del padre (el mayor entre la instantánea del borrador y el del padre), su fundamento, causal y plazo de reserva —si el padre no tiene fundamento se motiva por herencia: nunca cae a PUBLICA— y el vínculoresponde_a.aprobar(por persona, por circuito completado y por la ruta interna) ydevolverdejan los asientosborrador.aprobado/borrador.devueltoen la misma transacción que el cambio de estado. B6: en una Salida,origin_dept_codedebe ser una dependencia donde el usuario tiene URD activa (422 origen_fuera_de_sus_dependencias; sin URD activa tampoco se admite); el tipo y los metadatos se validan antes de renderizar y subir el documento (un 422 no deja binario huérfano en el almacén). El orden importa y es la garantía del endpoint: combinar → renderizar → subir → número → segunda pasada*RAD_S*→ anexar. El render va antes de pedir el número para que un fallo no consuma un consecutivo: un número de radicado es inmutable por ley y un hueco en la secuencia es permanente. Sin renderizador se responde503y no se radica — degradar a «radicar sin documento» dejaría a la entidad creyendo que produjo un oficio que no existe. Solo se renderiza dos veces si el cuerpo usa*RAD_S*/*F_RAD_S*, que no existen hasta consumir el número. El contexto con que se combinó queda congelado enborrador.combinacion, y la huella del documento enborrador.documento_sha256: cambiar después la plantilla o la ficha de un tercero no reescribe lo ya producido (mismo criterio que la dirección de un envío).
Generación del documento de salida — storage-service (E23)¶
POST /api/v1/render/documento-internal{cuerpo_html, pagina{}, perfil?}→{formato, herramienta, contenido_b64, bytes}— genera el documento en PDF/A a partir del cuerpo ya combinado y ya saneado. Ruta interna sellada conX-Internal-Token(D-02), no gateada por permiso: el único llamante es document-service, que ya autorizó al funcionario antes de componer el cuerpo — un endpoint inter-servicio se sella con token, no se gatea por permiso.- El documento nace en formato de conservación (D-2): se genera PDF/A-2b directamente, no PDF más una migración posterior. El barrido de preservación existe para el fondo heredado, no para lo que la entidad produce hoy.
- El membrete, el pie con los datos de la entidad y la numeración
Pagina N de Mlos pone la plantilla de página del renderizador, no el editor (RF-PRO-04; Ac. AGN 060/2001 art. 10; GTC 185): no pueden quedar a merced de que el funcionario se acuerde de escribirlos. 503 render_no_disponiblecuando el despliegue no declaraRENDER_ENGINE. No hay stub y es deliberado: un validador puede decir honestamente «no evaluado», pero un renderizador falso tendría que inventarse el documento. Un oficio que la entidad cree haber producido y no existe es peor que un error visible, porque el fallo aparece cuando el ciudadano reclama.422 render_fallidosi el perfil no es producible (las variantesaexigen texto etiquetado que un render automático no puede prometer). Un resultado fallido nunca lleva bytes dentro.
Respuesta rápida (paridad legado)¶
POST /api/v1/documents/{id}/respuesta{subject?, dest_dept?, dest_dept_code?, origin_dept_code?, doc_class?, observations?, response_days?}— (T13 / AREA-09-02: la Salida exige origen y tipo; si el cuerpo no los trae hereda del antecedenteorigin_dept_code = dest_dept_code de la Entradaydoc_class; sin dato heredable →422 datos_minimos_incompletos; una dependencia heredada ya inactiva →400 invalid_dependencia, no500) crea un radicado de Salida en respuesta a este (hereda asuntoRE: …y dependencia si se omiten), enlazado al antecedente (responde_a). Requiere permisoPERM_RESPUESTA_VINCULADA.403 forbiddensi falta. Devuelve el nuevo radicado. No-read-up (RF-SEG-08):404neutro si el antecedente no existe o supera el clearance del llamante — precisamente porque la Salida hereda el asunto del antecedente, responder sin ese gate exfiltraría el asunto de una Entrada reservada a un documento legible.GET /api/v1/documents/{id}/respuestas— salidas que responden a este radicado. No-read-up (RF-SEG-08): el clearance del llamante acota qué salidas se listan (fail-closed a PUBLICA sin identidad) y cada ítem traenivel_seguridad, para que la barrera de residencia del asistente recorte el asunto en niveles 2-3.
Circuito de visto bueno y firma (E23 Inc.4)¶
En una entidad varias personas dan visto bueno y una firma: la que compromete a la entidad y produce el documento definitivo. Los dos actos viven en una sola cadena ordenada (firma_cadena.rol ∈ visto_bueno | firma) y tienen endpoints distintos — presentarlos igual atribuiría a un revisor un compromiso que no adquirió.
POST /api/v1/signature/requestsacepta ahoraroles[], paralelo afirmantes[]. Si se omite, todos los turnos son defirma(así eran todas las cadenas antes de E23).422si las dos listas no casan —un desajuste repartiría los roles corridos y el revisor quedaría de firmante— o si no hay ningún turno defirma.payload_sha256es obligatorio para losobjeto_tipocon fichero propio —hoy soloborrador—. Sin huella, la firma recaería sobre la cadena"tipo:id"—el nombre del objeto— y seguiría siendo válida después de que el texto cambie. En la práctica no se le pide al llamante ninguna: la delborradorla resuelve su productor y la del cuerpo se ignora, así que el422por huella ausente ya no es alcanzable — y ese es el estado correcto, porque pedírsela al llamante es pedirle que se la invente. Todo tipo que exija huella debe tener quién se la resuelva; los dos conjuntos están atados por guardián.- Para
borrador, además, la huella la resuelve el servidor contra document-service y la del cuerpo se ignora: una huella que el llamante elige no vincula a nadie — bastaría con enviar la del documento anterior para que la cadena siguiera pareciendo válida tras editar. Es el gemelo exacto de la resolución autoritativa del nivel de seguridad.409 documento_sin_producirsi el borrador todavía no tiene PDF/A;503si no se puede resolver (una cadena con la huella equivocada es peor que ninguna: parece que protege). POST /api/v1/signature/requests/{id}/vobo{comentario?}— resuelve un turno derol='visto_bueno'. No producefirmani genera el documento definitivo. El comentario es opcional, al revés que el motivo de un rechazo: aprobar no exige explicación, detener el trámite de otro sí.409 turno_es_de_firmasobre un turno de firma — resolverlo por aquí sería la vía para comprometer a la entidad sin firmar.- El segundo factor no se exige al visto bueno (D-6): lo obligatorio siempre es el anclaje a la huella, que es lo que convierte «aprobó» en «aprobó este texto». Exigir credencial de firma y 2FA a cada revisor es viable en una entidad de tres personas y no en una de cuarenta; la huella más
audit_logya prueban qué vio cada uno. El firmante final sí firma con PKI: es quien compromete a la entidad. - Firmar o aprobar sobre un texto modificado da
409 documento_modificado. El gate relee la huella vigente y compara. Corre después de las guardas de pertenencia y turno y antes de la transacción y del segundo factor: así ningún 2FA se consume en un acto que se va a rechazar igual —que es la razón de fondo de dónde va— y a la vez un actor ajeno a la cadena recibe403 no_es_firmantey no el estado del documento con su número de versión.POST /challengecorre este mismo gate (B7): antes emitía y enviaba el OTP sobre un texto ya modificado. No poder comprobar deniega (503): si un fallo de red se leyera como «no ha cambiado», bastaría con que document-service estuviera caído para poder firmar sobre un texto ya editado. GET /api/v1/internal/documentos/borradores/{id}/huella(document-service,X-Internal-Token) — huella vigente del cuerpo y del documento. Content-free (dos huellas, una versión y un booleano), por eso no lleva gate de clearance: no hay nada que recortar por nivel de seguridad.POST /api/v1/signature/internal/circuitos/{objeto_tipo}/{objeto_id}/invalidar(signature-service,X-Internal-Token) — deja sin efecto los circuitos vivos de un objeto cuyo texto cambió. Lo llama document-service al editar un borrador ya sometido a firma. Rehacer un documento firmado es el trámite, no un abuso: se recogen los vistos buenos, alguien recuerda un anexo o corrige una cifra, y hay que volver a empezar; lo que no puede quedar en pie son las firmas anteriores, dadas sobre otro texto. Desde B10 exige el cuerpo{"cuerpo_sha256_vigente": "<64 hex>"}(422sin él) e invalida solo las solicitudes cuyopayload_sha256no es esa huella, con asientofirma.circuito_invalidadoen la misma tx; signature hace lo mismo al consumirdocumento.contenido_cambiado. La garantía dura no vive aquí: es la comparación de huellas al radicar (documento_modificado), que se niega aunque este aviso se pierda. Esto es lo que hace humano el trámite: sin ello, los turnos de un circuito superado seguían apareciendo en la bandeja de quien los tenía, pidiéndole firmar un documento que ya no existe, y desaparecían después sin explicación — «se esfumó» sin motivo es lo que hace que la gente desconfíe de la herramienta. Por eso es best-effort e idempotente: sobre un objeto sin circuitos vivos no hace nada y devuelve{invalidadas: 0}. Busca por objeto, no por el tipo que declare quien pregunta — el UUID identifica solo, y filtrar por ambos dejaba invisible una cadena declarada con otra etiqueta sobre el mismo borrador.
Anulación de radicado en dos pasos (paridad legado)¶
La ley prohíbe borrar radicados: se anulan con aprobación supervisora.
POST /api/v1/documents/{id}/anulacion{causal, motivo?}— solicita (paso 1). Requiere permisoPERM_SOL_ANULAR.403 forbiddensi falta.409si ya está anulado o hay solicitud pendiente. Las cuatro rutas de anulación aplican no-read-up (RF-SEG-08): un radicado que supera el clearance del llamante devuelve el mismo404que uno inexistente — la causal y el motivo describen el documento.POST /api/v1/documents/{id}/anulacion/aprobar{observacion?}— aprueba (paso 2; nivel jefe): el radicado pasa aanulado(el número se conserva) y su paso de flujo activo pasa acancelleden la misma transacción (eventoflow_events.tipo_tx=anulacion; sin ello el paso quedaba como un zombi en bandeja y vencidos). RequierePERM_PANU_CODI.403 forbiddensi falta.POST /api/v1/documents/{id}/anulacion/rechazar{observacion?}— rechaza. RequierePERM_PANU_CODI.403 forbiddensi falta.GET /api/v1/documents/{id}/anulacion— última solicitud.GET /api/v1/anulaciones?page&size— cola del aprobador: solicitudes en estadosolicitada, la más antigua primero (FIFO — una anulación pendiente bloquea el ciclo de vida del documento). RequierePERM_PANU_CODI, el mismo permiso que decidir. Cada fila trae los datos de la solicitud y los del radicado (tracking_number,subject,doc_type,radicado_status).X-Total-Countcon el total. No-read-up (RF-SEG-08) aplicado por igual a las filas y al recuento: contar sin filtrar delataría cuántas solicitudes hay sobre radicados que el llamante no puede ver.
Ventanilla única pública (sin autenticar):
GET /api/v1/public/{tenant}/ventanilla/politica— (B2, ADR-031) política de tratamiento de datos vigente de la entidad:{publicada, version, url, texto, huella}. La política es un parámetro de la entidad (politica_tratamiento_version/_url/_texto, migración tenant012, editable porPUT /config/params/{key}, administrador nivel 3).urlsolo se devuelve si eshttp(s)://(elPUTdepolitica_tratamiento_urlrechaza otro esquema con422 url_invalida). Un tenant inexistente respondepublicada=falseigual que/estado. Es lectura: se sirve también con la entidad suspendida (re-auditoría B9, ADR-035).publicada=falsesi falta la versión o si hay versión pero ni URL ni texto (un aviso que no apunta a nada no informa).huella = sha256(version ␞ texto ␞ url)la calcula el servidor. Lectura estricta: si tenant-service no responde,503 politica_no_verificable(un error no es «sin política»).GET /api/v1/public/{tenant}/ventanilla/estado— (T13 / AREA-09-07) verificación previa:{habilitada, dependencia_receptora, tipos_admitidos, politica_publicada}.politica_publicada(B2) no forma parte dehabilitada, que sigue siendo organigrama + catálogo; sin política,POSTresponde 409.503 politica_no_verificablesi tenant-service no responde.habilitadaes la MISMA condición queradicaraplica por dentro (una dependencia activa condepe_rad_tp2y al menos un tipo admitido), calculada sin radicar nada; un tenant inexistente respondehabilitada=false.scripts/verificar-ventanilla.shla recorre por entidad y sale ≠ 0 si alguna no está habilitada o no tiene política publicada (para engancharlo al despliegue).GET /api/v1/public/{tenant}/ventanilla/tipos— qué tipos de trámite declara recibir la entidad (tipos_documentales.admite_ventanilla_publica, opt-in). No expone el catálogo interno.POST /api/v1/public/{tenant}/ventanilla— radicación externa. El tipo debe estar declarado admisible (422tipo_no_admitido_en_ventanillasi no; nunca se reclasifica en silencio). Se enruta a la dependencia condepe_rad_tp2(«habilita radicación web»); si la entidad no ha declarado ninguna, 503ventanilla_no_habilitada— entrar sin destino dejaría el documento fuera de toda bandeja. El remitente puede ser persona o entidad (sender_entity). Quien se identifica debe indicar un medio de respuesta; la radicación anónima es legítima. Devuelve número de radicado, código de verificación y dependencia destino. Multipart: los campos van en la partedatos(JSON) y los anexos en partesanexos. Una sola puerta para el caso simple y para la tutela con soportes. Límites: 5 archivos, 10 MB cada uno, 25 MB el conjunto; solo PDF, JPG, PNG y TIFF, comprobados por el contenido —no por la extensión ni por elContent-Type, que los escribe el cliente— y el MIME que se guarda es el deducido. No hay antivirus. Los anexos se trasladan ANTES de asignar número: si el almacén falla, 503anexo_no_almacenadoy no se radica nada, porque un número de radicado es inmutable y no puede quedar emitido sin sus soportes. El fichero se guarda sin actor (uploaded_by = NULL).- Autorización de tratamiento, honeypot e idempotencia de la radicación pública (T13 / AREA-09-08, AREA-02-07).
POST /ventanillayPOST /pqrsexigenacepta_tratamiento: true(Literal[True]: no hay valor «falso» válido →422) ypolitica_version(1-50 caracteres): la versión que el cliente mostró (la deGET /ventanilla/politica). El servidor relee la política vigente (B2, ADR-031): sin política publicada →409 politica_no_publicada(fail-closed: sin aviso de privacidad no hay autorización informada, Ley 1581 art. 12);politica_versiondistinta de la vigente →409 politica_desactualizada(el cuerpo traepolitica_vigente); tenant-service caído →503 politica_no_verificable. Todo se valida antes de subir anexos, de reclamar laIdempotency-Keyy de consumir el consecutivo (un 409/503 no deja rastro); un reintento idempotente de un radicado ya hecho devuelve lo ya radicado sin releer la política. El servidor persiste la constancia v2 enmetadata.consentimiento = {norma: "Ley 1581/2012", version_constancia: 2, politica_version, politica_huella, politica_url, instante, ip, ip_verificada}y en el payload del asientodocument.radicado_created(el que tiene valor probatorio). Versión, huella y URL salen del servidor: lo que el cliente mande como huella o URL se ignora.ipes la que el gateway FIRMÓ (X-Real-Ip+X-Real-Ip-Ts+X-Public-Ip-Signature, HMAC conIDENTITY_SIGNING_SECRETligado a la ruta y válido 120 s; el gateway la deriva por saltos de confianza conTRUSTED_PROXIES, y document-service nunca leeX-Forwarded-For). Sin firma válida (llamada directa, firma vencida o de otra ruta, sin secreto) →ip = null,ip_verificada = false, y la radicación no se bloquea: la IP es evidencia complementaria, no requisito de la autorización. Las constancias v1 (ip_declarada) se conservan tal cual. Además, un campo honeypotsitio_web(invisible en el formulario) debe llegar vacío: con contenido →422genérico, validado en el esquema, antes de resolver el tipo, la dependencia o consumir número. Y la cabecera opcionalIdempotency-Key(16-128 caracteres[A-Za-z0-9_-]; mal formada →422 idempotency_key_invalida, no se ignora en silencio) hace que un reintento devuelva el mismo radicado: la clave se reclama (public_submission_keys, migración043) conON CONFLICT DO NOTHINGdentro de la transacción del radicado y antes del consecutivo, de modo que dos envíos simultáneos se serializan en el índice único (el segundo espera el commit y devuelve el existente,201con el mismo número; un solo consecutivo gastado). La misma clave con otro contenido →409 idempotency_key_reutilizada. El proveedor de CAPTCHA (reCAPTCHA contradice la soberanía; la alternativa autoalojada es una prueba de trabajo tipo ALTCHA) sigue siendo decisión de la entidad; el texto de la política, ahora, lo publica la entidad en sus parámetros. POST /api/v1/public/{tenant}/pqrs— legado, se conserva por ser superficie pública ya publicada; delega en la ventanilla y no se amplía.
Firma electrónica (E06, ADR-016)¶
POST /api/v1/documents/{id}/signatures{ "anexo_id", "reason"? }— firma declarativa de un anexo (firma electrónica simple; la probatoria vive en signature-service): registra identidad (claims del gateway), huella y sello de tiempo. ExigePERM_FIRMAnivel >= 2 (escritura). El servidor resuelve la huella del SHA-256 que storage calculó al subir: uncontent_hashen el cuerpo, si llega, se ignora (una huella elegida por el cliente no prueba nada).422 anexo_requeridosinanexo_id;404neutro si el radicado no existe, supera el clearance o el anexo no es suyo;503 huella_no_verificablesi storage no responde;409 anexo_sin_huella. El registro y el asientodocument.signature_createdenpublic.audit_logson un solo acto (misma transacción). Pendiente de decisión del usuario: retirar el endpoint con410(no tiene consumidores). Las tres rutas de firma exigenPERM_FIRMA(el mismo permiso que gobierna signature-service) y aplican no-read-up:404neutro si el radicado no existe o supera el clearance.GET /api/v1/documents/{id}/signatures— lista las firmas (X-Total-Count).404si el radicado no es visible;[](200) si es visible y aún no tiene firmas — un sub-clearance recibe siempre404, nunca[], así que el par no es oráculo.GET /api/v1/documents/{id}/signatures/verify?content_hash=<hex>—{content_hash, valid, signatures[]}:valid=truesi alguna firma del radicado coincide con ese hash (si el contenido cambió, deja de verificar). Gateado igual que el listado: sin el filtro, elvalidtrue/false sería un oráculo de contenido sobre documentos reservados. Solo cuentan las firmas conorigen='servidor': las heredadas con huella declarada por el cliente (cliente_no_verificado, migración 036) no verifican.
Envíos postales (E20)¶
POST /api/v1/documents/{id}/envios{operador, destinatario?, direccion?}— despacha el radicado por operador postal (4-72/servientrega/otro); genera la guía y estadoregistrado.GET /api/v1/documents/{id}/envios·GET /api/v1/envios/{id}— envíos del radicado / detalle con historial de eventos.POST /api/v1/envios/{id}/estado{estado, descripcion?}— actualiza el estado de entrega (callback/sondeo del operador). Máquina de estadosregistrado→en_transito→entregado|devuelto|fallido;409si la transición es inválida.
Cuentas de correo (E19, USUA_PERM_ADMIN)¶
Sustituyen a la cuenta única de las antiguas variables IMAP_* (ya retiradas), que no podía expresar lo que exige el Acuerdo AGN 060/2001 art. 13 —«las entidades […] asignarán responsabilidades de acuerdo con la cantidad de cuentas habilitadas»— y guardaba la contraseña en claro en el entorno.
POST|GET /api/v1/email/apps-oauth— aplicación OAuth que la ENTIDAD registró en su propio Google Cloud o Azure AD.{proveedor, client_id, client_secret, azure_directory_id?, redirect_uri, notas?}; una fila por proveedor (409 app_ya_registrada), y la misma sirve para todas las cuentas de ese proveedor. Microsoft exigeazure_directory_id; Google no lo usa (422en ambos sentidos). Elclient_secretse cifra en reposo y nunca se devuelve: la respuesta solo dicetiene_secreto.
Por qué la registra la entidad y no el despliegue. Google exige verificación y una evaluación de seguridad CASA anual por un tercero acreditado a cualquier aplicación que pida el scope
https://mail.google.com/(el que cubre IMAP), pero exime a las internas: «the app is only used by people in your Google Workspace or Cloud Identity organization […] its OAuth Consent Screen must be configured for internal use». Con una aplicación central de OrpycaMCP habría que sostener esa acreditación, y OrpycaMCP es software que cada institución despliega. No se pide en el alta de la entidad: al crearla nadie sabe aún qué correo usará, quien la da de alta rara vez tiene los permisos de Azure o Google Cloud a mano, y muchas entidades usarán IMAP de hosting sin necesitarlo.
POST|GET /api/v1/email/cuentas— buzones. Los dos verbos NO piden lo mismo: el alta exigeUSUA_PERM_ADMIN, el listadoPERM_RADIoUSUA_PERM_ADMIN(any-of, B6:/admin/correoya no da 403 al administrador que no radica) —PERM_RADIes el mismo permiso que la bandeja, porque es la única forma de que un funcionario de correspondencia sepa qué buzones tiene y con qué identificador; con el gate de administrador, la pantalla a la que la bandeja está dirigida era inalcanzable para su público. Aflojarlo no afloja nada: qué cuentas devuelve no lo decide el permiso, lo decide elWHERE(personal para su dueño, institucional para los asignados, y el administrador ve las institucionales pero nunca las personales de nadie). Ningún secreto sale en la lectura: solotiene_credencial. Tres tipos: institucional compartida (con funcionarios asignados), personal del funcionario y varias de recepción a la vez.carpeta_origenes el filtro humano —la carpeta a la que alguien arrastra lo que sí se radica, dejando fuera publicidad y spam— ycarpeta_radicadosa dónde se mueve el correo tras radicarlo; no pueden ser la misma.modoesasistida(un funcionario revisa y radica, con lo que existe el «funcionario responsable del trámite» del art. 2) oautomatica.direccionyusuario_loginson campos distintos: el login puede serDOMINIO\usuarioo solo la parte local.
La credencial de una cuenta personal no se guarda en absoluto — se pide al entrar y vive en la sesión; custodiarla convertiría al sistema en titular del correo entero de un funcionario. Las institucionales se cifran con AESGCM y AAD tenant|cuenta. Crear una cuenta gmail o microsoft con contraseña se rechaza con la razón: esas plataformas ya no la aceptan por IMAP.
Visibilidad: una cuenta personal la ve solo su dueño —ni el administrador—, y una institucional, sus funcionarios asignados más el administrador. El recorte va en el WHERE, y 404 no distingue «no existe» de «no la alcanzas».
-
POST /api/v1/email/cuentas/{id}/probar{password?}— abre una sesión y devuelve las carpetas del buzón, sin tocar ningún correo. Las carpetas son la mitad del valor: la de filtrado se elige de esa lista en vez de escribirse a mano, y una errata se manifestaría como «la ingesta no trae nada», indistinguible de «no ha llegado correo».passwordsolo para cuentas personales. -
POST /api/v1/email/cuentas/{id}/oauth/iniciar— devuelve{authorization_url}, la URL del proveedor a la que mandar al administrador. Lleva PKCE S256 (RFC 7636) y unstatede un solo uso (RFC 6749 §10.12), ylogin_hintcon la dirección del buzón: sin él, quien tenga varias cuentas abiertas autoriza con la que el proveedor elija y el token queda emitido para el buzón equivocado. Para Google añadeaccess_type=offlineyprompt=consent, sin los cuales no hay token de refresco y el acceso caducaría en una hora. -
POST /api/v1/email/cuentas/{id}/oauth/canjear{code, state}— cierra el flujo y guarda el token de refresco cifrado. Elstatese valida y se marca usado en el mismoUPDATE, con cuatro condiciones (un solo uso, no caducado, misma cuenta, mismo iniciador), de modo que una redirección reproducida no encuentra fila.400 state_invalidono dice cuál de las cuatro falló. Lo invoca la propia aplicación tras recibir la redirección, con la sesión ya establecida, para no abrir superficie sin autenticar en el gateway.
Scopes: Google https://mail.google.com/; Microsoft https://outlook.office.com/IMAP.AccessAsUser.All más offline_access — no outlook.office365.com, que es el del flujo de credenciales de aplicación y produce un token que el servidor IMAP rechaza.
Bandeja del buzón (E19). El espejo del buzón en la base, que es lo que permite que varios funcionarios trabajen la misma cuenta sin pisarse. Gate PERM_RADI; el recorte de qué cuentas alcanza cada quien es el mismo de arriba, en el WHERE.
-
POST /api/v1/email/cuentas/{id}/bandeja/sincronizar{password?}— trae de la carpeta de filtrado los correos que aún no están en la bandeja. Primero lista identificadores —que no descarga nada— y solo baja los que faltan: sondear un buzón sin novedades no mueve un byte de mensaje. La identidad estable es elMessage-ID(RFC 5322 §3.6.4), no eluid, que solo identifica mientras elUIDVALIDITYde la carpeta no cambie (RFC 3501 §2.3.1.1); antes de traer o mover se comprueba, o una restauración del buzón radicaría otro correo bajo este asunto. La fecha de recibo sale delINTERNALDATEdel servidor de la entidad, no de la cabeceraDate, que la pone el cliente del remitente y puede fecharse en el pasado para ganar plazo. -
GET /api/v1/email/cuentas/{id}/bandeja?estado=— los correos, FIFO por fecha de recibo.X-Total-Count. -
GET /api/v1/email/cuentas/{id}/bandeja/estado— pendientes, ocupados yultima_novedad, para que la pantalla sepa si tiene que recargar sin traerse la lista entera. -
POST /api/v1/email/cuentas/{id}/bandeja/{mensaje_id}/reservar— reserva de 15 minutos. Es lo que impide que dos funcionarios radiquen el mismo correo dos veces: la reserva se toma en elWHERE, así que el «cero filas» distingue «lo tomé» de «alguien se me adelantó» sin ventana de TOCTOU. -
POST /api/v1/email/cuentas/{id}/bandeja/{mensaje_id}/liberar— suelta la reserva propia; no toca la de otro, y por eso responde204tanto si soltó algo como si no había nada que soltar: quien abre un correo, ve que no le corresponde y cierra, no debe dejarlo bloqueado un cuarto de hora para toda la ventanilla. -
POST /api/v1/email/cuentas/{id}/bandeja/{mensaje_id}/radicar— crea la Entrada. Orden por reversibilidad: primero los binarios (anexos y el.emloriginal, que Ley 527/1999 art. 12 obliga a conservar en su formato con origen, destino, fecha y hora), después el radicado, después el movimiento a la carpeta «Radicados», y por último la constancia al remitente si el buzón la tiene activada. Vuelve a comprobar la reserva antes de cerrar. La dependencia competente viaja comodest_dept_code—el código del organigrama—, no como el nombre endest_dept: document-service valida el código y denormaliza el nombre, y sin él el radicado nacido de un correo queda sin vínculo con la dependencia (no aparece al repartir por código, y una tilde de diferencia crea dos áreas).doc_classes el código del tipo documental, que es lo que lo hace casar con una serie de la TRD. -
POST /api/v1/email/cuentas/{id}/bandeja/{mensaje_id}/descartar{motivo}— aparta un correo que llegó a la carpeta y no debía radicarse. Exige reserva y motivo, y deja asientoemail.mensaje.descartadoenaudit_logen la misma transacción: no es best-effort. Decidir que una comunicación recibida no se registra es tan decisión como registrarla —de hecho es la que hace desaparecer el documento del archivo—, y hasta aquí solo dejaba unUPDATEsobre una tabla corriente, reescribible después sin rastro. El asiento no lleva el asunto ni el remitente: un correo descartado nunca se radicó, así que no tiene nivel de clasificación con el que recortar su lectura, y enaudit_loglo vería todoUSUA_PERM_ADMIN. El motivo sí, porque es el texto de la decisión, no el del documento.
Ruta heredada retirada. Existieron
GET /api/v1/ingest/email/estadoyPOST /api/v1/ingest/email/poll, que operaban sobre un único buzón declarado en el entorno con las variablesIMAP_*y radicaban de golpe todo lo no leído. Están retiradas junto con esas variables: un buzón por despliegue no expresa lo que exige el Acuerdo AGN 060/2001 art. 13, la contraseña vivía en claro en el entorno, y radicar la bandeja entera radicaba también la publicidad. Los sustituyen los endpoints de cuentas y bandeja de arriba, donde la entidad separa lo que se radica de lo que no.
POST /api/v1/public/{tenant}/pqrs (radicación ciudadana, Ley 1755/2015)¶
Sin autenticación. Un ciudadano radica una PQRS: { "tipo": "peticion|queja|reclamo|sugerencia|denuncia", "nombre", "identificacion"?, "email"?, "asunto", "descripcion" }. Crea un radicado de Entrada (metadata.canal=pqrs_ciudadano) y devuelve {radicado_id, tracking_number, verification_code} para seguimiento posterior con el endpoint de verificación.
GET /api/v1/public/{tenant}/verify/{code} (consulta pública, E13)¶
Sin autenticación. Verifica la trazabilidad de un radicado por su código de verificación (token no adivinable, distinto del número de radicado). El tenant viaja en la ruta. Devuelve solo información NO sensible: tracking_number, doc_type, status, registered_at, dest_dept. 404 si el código no existe; 400 si el slug de tenant es inválido.
GET /api/v1/reports/radicados¶
Resumen estadístico de radicados del tenant (E09): total y conteos by_doc_type, by_status, by_month (YYYY-MM), by_dependencia (la de radicación: a quién iba dirigido, cuenta todos los radicados) y by_ubicacion (ADR-029: dónde está AHORA lo que sigue en trámite, sin cerrados ni anulados: la carga real de cada dependencia). Filtros opcionales ?date_from=&date_to=. Agregaciones SQL nativas (GROUP BY/date_trunc).
GET /api/v1/reports/radicados.csv¶
Exporta el resumen en CSV (seccion,clave,valor con secciones tipo/estado/mes/dependencia_radicacion/ubicacion_actual; la sección dependencia se renombró dependencia_radicacion por ambigua — ADR-029 —) — para reportes periódicos al AGN/entes de control. Mismos filtros ?date_from=&date_to=.
{
"total": 8,
"by_doc_type": { "E": 5, "S": 3 },
"by_status": { "registered": 6, "archived": 2 },
"by_month": [
{ "month": "2024-01", "count": 4 },
{ "month": "2024-02", "count": 4 }
]
}
GET /api/v1/assistant/transcribe · POST /api/v1/assistant/transcribe¶
Dictado por voz del asistente (E18, ADR-020). El GET es una sonda de disponibilidad, no un adorno: responde si hay proveedor de transcripción configurado y alcanzable, para que la interfaz decida si ofrecer el micrófono en vez de ofrecerlo y fallar al soltar el botón. El POST recibe el audio y devuelve el texto.
No hay proveedor stub, y la asimetría con el resto del sistema es deliberada: un validador puede decir honestamente «no evaluado», pero un transcriptor de mentira tendría que inventarse lo que se dijo. Sin proveedor, el POST responde 503 y el GET lo anuncia antes.
GET /api/v1/dependencias/tree¶
El organigrama completo como árbol (parent_id resuelto), no como lista plana: la jerarquía es lo que consumen el selector de dependencia y la distribución de radicados, y reconstruirla en cliente a partir de una página parcial produce nodos huérfanos.
GET /api/v1/reports/indice-reservado · GET /api/v1/reports/indice-reservado.csv¶
Índice de información clasificada y reservada (E08, Ley 1712/2014 art. 20 + Decreto 1081/2015). Registro de todos los radicados nivel_seguridad ≥ 2 con la metadata del acto de clasificación. Gate PERM_RECLASIFICAR. CONTENT-FREE (nunca expone el asunto/contenido) y ORTOGONAL al clearance (el registro es COMPLETO por mandato legal — es una publicación de metadata, no una lectura de contenido). Mapeo: nivel 2 = reservada → art. 19, nivel 3 = clasificada → art. 18. Cada ítem: {numero_radicado, tipo_radicado (E/S/I, NO serie TRD), nivel_seguridad, clasificacion, excepcion_ley_1712, fundamento_juridico, causal_reserva, plazo_reserva_meses, fecha_clasificacion, fecha_radicacion}. La metadata se puebla al radicar clasificado o reclasificar (ambos exigen fundamento al pasar a nivel≥2, 422 si falta — Ley 1712 art. 19/28) y se limpia al desclasificar. El .csv neutraliza inyección de fórmulas en los campos de texto libre. La generación deja una entrada agregada en audit_log (RF-BUS-10).
Índice completo y «nunca NULL» (T13 / AREA-09-04, migración 041). Cada fila añade dependencia (la que recibe E/I o la que produce S) y registrado_por (identificador opaco de usuario; null para lo que entró sin usuario). La promesa de que ningún clasificado carece de fundamento ya la sostiene la base: CHECK radicados_clasificado_con_fundamento (NOT VALID + backfill con el texto explícito «PENDIENTE DE MOTIVAR…» para lo heredado, validado en la migración) y la importación INT-05 rechaza con 422 todo el paquete si algún radicado trae nivel_seguridad ≥ 2 sin clasificacion.fundamento. El nivel serie/expediente y el catálogo cerrado de fundamentos siguen pendientes (decisión de la entidad).
GET /api/v1/search¶
Búsqueda avanzada de radicados (E09, ADR-014: PostgreSQL FTS). Texto completo con ranking (tsvector/ts_rank) + difuso (pg_trgm), combinable con filtros estructurados.
Busca en: tracking_number (peso A), subject (B), sender_name / sender_entity (C), dest_dept / observations (D).
Query params (todos opcionales): q (texto, min 2 chars — opcional: sin texto es una consulta filtrada ordenada por fecha), doc_type, status, date_from/date_to (rango sobre registered_at), dest_dept_code (dependencia de radicación), ubicacion_dept_code (dónde está ahora, ADR-029; mismo WHERE en filas y COUNT), meta.<campo>=valor (filtro por metadato, contención JSONB/GIN), page, size. Cabecera X-Total-Count. Cada resultado trae ubicacion_dept_code/ubicacion_dept además de dest_dept.
Control de acceso (RF-SEG-08 / RF-BUS-04): además del aislamiento por tenant (search_path), los resultados se acotan por la clasificación del radicado: cada usuario solo ve los de nivel_seguridad ≤ su clearance (nivel máximo accesible = MAX de la matriz rol→nivel role_clearance sobre sus grupos; is_root accede a todo). El clearance lo resuelve la BD por request (ADR-013); política fail-closed a PUBLICA si no hay identidad o clearance asignado. La misma acotación se aplica al listado GET /api/v1/documents y a la búsqueda de expedientes GET /api/v1/expedientes/search.
// Response 200
{
"query": "tutela derechos",
"total": 3,
"page": 1,
"size": 20,
"hits": [
{
"id": "uuid",
"tracking_number": "2024-ICETEX-E-000042",
"doc_type": "E",
"subject": "Acción de tutela derechos fundamentales",
"sender_name": "María García",
"status": "registered",
"registered_at": "2024-06-01T10:30:00Z",
"rank": 0.756
}
]
}
Workflow Rules Engine — workflow-service¶
Configura reglas de enrutamiento automático sin código. Cuando llega un radicado, se evalúan las reglas activas por orden de prioridad.
Requiere X-Tenant-Slug (y X-User-Id para registrar el autor).
POST /api/v1/workflow/rules¶
Crear una regla. Dos formas de condición (compatibles):
- Motor con operadores (
conditions+match_mode): lista de{field, op, value}combinada porall(AND) oany(OR). Campos:doc_type, doc_class, dest_dept_code, origin_dept_code, dest_dept, origin_dept, subject, sender_name, sender_entity, pages. Una dependencia se compara por código (ADR-032):eq/ne/insobredest_dept/origin_dept(nombre) se rechazan con422 condicion_por_nombre_obsoleta(cambiaban de sentido al renombrar);contains/not_contains/regexsobre el texto siguen permitidos. Si el radicado no trae código resoluble, ninguna condición por código casa (tampocone). El destino esassign_to_dept_code(canónico;assign_to_deptes el alias deprecado) y debe ser una dependencia activa. Operadores:eq, ne, contains, not_contains, in, gt, lt, gte, lte, regex. - Legada (columnas fijas
doc_type/doc_class/dest_dept/subject_contains, AND): se usa siconditionsestá vacío.
// Request (motor con operadores)
{
"name": "Tutelas voluminosas → Jurídica",
"priority": 10,
"match_mode": "all",
"conditions": [
{ "field": "doc_type", "op": "in", "value": "E,S" },
{ "field": "subject", "op": "contains", "value": "tutela" },
{ "field": "pages", "op": "gt", "value": 10 }
],
"assign_to_dept": "Dirección Jurídica",
"assign_notes": "Responder en máximo 10 días hábiles"
}
// Response 201 — WorkflowRuleResponse con id, conditions, match_mode, timestamps
GET /api/v1/workflow/rules¶
Listar reglas. Query: ?active_only=true&page=1&size=50.
GET /api/v1/workflow/rules/{id}¶
Obtener regla por UUID.
PATCH /api/v1/workflow/rules/{id}¶
Actualizar parcialmente (ej: cambiar prioridad, activar/desactivar). El asiento registra solo los campos que la petición tocó, cada uno con su valor antes y despues; un PATCH sin campos no muta nada y no asienta.
DELETE /api/v1/workflow/rules/{id}¶
Eliminar regla. Responde 204. El borrado es físico, así que el asiento lleva el retrato completo de la regla — es el único sitio donde sobrevive lo que decía.
Los tres actos de escritura dejan asiento en
public.audit_log(ADR-010), atómico con la escritura:workflow.regla_creada,workflow.regla_modificada,workflow.regla_eliminada, conobject_type="workflow_rule"yobject_ref= el UUID de la regla (no su nombre: renombrarla no puede partir su historial en dos). Si la cadena inmutable no puede recibir el asiento, la escritura revierte.Una regla decide a qué dependencia se asigna cada radicado entrante: crearla es redirigir correspondencia oficial que aún no ha llegado, y borrarla es dejar de redirigirla. Los tres exigen
X-User-Id— un asiento sin actor no responde quién decidió el enrutamiento, que es lo que se le pregunta a la traza.
POST /api/v1/workflow/rules/evaluate¶
Evaluar reglas contra un radicado (retorna la primera que coincida).
// Request
{
"radicado_id": "uuid",
"tracking_number": "2024-ICETEX-E-000042",
"doc_type": "E",
"subject": "Acción de tutela por mora en servicio"
}
// Response 200 — si hay coincidencia
{ "matched": true, "rule_id": "uuid", "rule_name": "Tutelas → Dirección Jurídica", "assign_to_dept": "Dirección Jurídica", "assign_notes": "..." }
// Response 200 — sin coincidencia
{ "matched": false }
POST /api/v1/workflow/rules/evaluate/batch¶
Evalúa varios radicados (1-500) contra el mismo conjunto de reglas en una sola llamada.
// Request
{ "items": [ { "radicado_id": "uuid", "tracking_number": "T1", "doc_type": "E" }, ... ] }
// Response 200
{ "results": [ { "matched": true, "radicado_id": "uuid", "assign_to_dept": "...", ... }, ... ] }
Asistente conversacional — contrato front↔gateway (E18/E22, ADR-020)¶
Corrección de una afirmación caducada (2026-08-07): este apartado se titulaba "contrato definido, backend PENDIENTE" y declaraba el
mcp-servercomo scaffold. Es falso desde E18: el backend está implementado y documentado más arriba (véase Asistente conversacional (E18, ADR-019/ADR-020) — vía gateway), con un loop LLM real que ejecuta las tools de solo lectura as-the-user. Se conserva esta sección porque documenta la otra mitad del contrato —lo que el proxy SSR del frontend hace con la respuesta—, que no está en la del backend.El
503 assistant_unavailablesigue existiendo, pero ya no significa "el backend no está construido": lo devuelve el propio mcp-server cuando le faltaANTHROPIC_API_KEYo el SDK falla (degradación honesta, RF-FIR-15). Las dos causas comparten código pero no mensaje, porque piden cosas distintas a quien lee: sin modelo configurado,detaildice que la instalación no lo tiene y que quien la administra puede activarlo —esperar no lo arregla—; con el proveedor caído, que se reintente en unos minutos. El proxy SvelteKit reenvía ese motivo al panel (solo el del sobre propio del asistente, acotado a 300 caracteres) y traduce cualquier otra ausencia de ruta (404/501/5xx/conexión rehusada) a «El servicio del asistente no responde». Hasta 2026-10 el proxy lo sustituía por un texto fijo y la interfaz prometía que la función «se activará próximamente», un resto de cuando el backend no existía.
POST /api/v1/assistant/message¶
Envía un turno del usuario y devuelve la respuesta del asistente. Autorización: autenticado (sin permiso --op fino; cada acción que el asistente dispare hacia adelante se re-valida contra el RBAC/clearance del usuario en el servicio que la ejecute). Headers de identidad (X-Tenant-Slug/X-User-Id/X-Username) los inyecta el proxy SSR desde locals, nunca del body.
// Request (allowlist en el proxy: solo estos dos campos)
{ "mensaje": "string (≤4000, trim)", "conversation_id": "uuid (opcional)" }
// Response 200
{
"conversation_id": "uuid",
"message": { "id": "uuid", "role": "assistant", "texto": "respuesta (AUTORITATIVO)", "formato": "markdown|text", "ts": "ISO-8601" },
"propuestas": [],
"no_verificados": [] // radicados del texto que ningún tool_result devolvió (AREA-11-04)
}
textoes la fuente de verdad;formatoes una pista de render. El proxy convierteformato:"markdown"a HTML conmarkedy lo sanea server-side (sanitizeHtml) antes de cruzar al cliente — el output del asistente es contenido no confiable (XSS/prompt-injection). El backend no debe enviar HTML; si lo hiciera, el proxy lo ignora.- Reservado para el futuro (el slice actual los ignora sin romperse):
acciones[],citations[],usage. - Preparado para streaming (no construido): un
POST .../message/stream(NDJSON/SSE) con el mismoconversation_idymessage.idestables por turno permitiría appendear deltas al mismo turno sin cambiar el shape. - Nota de contrato (a reconciliar): el proxy envía
X-Usernamedesde el claimpreferred_username(elSessionUserdel front no tieneusername).