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
}
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"]
}
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 [] (fail-closed).
// 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"]
}
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.- Si el usuario no está aprovisionado en el tenant,
permissionses[](fail-closed).
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 estado pendiente_activacion y 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 a failed_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} — revoca la credencial activa exigiendo step-up (código actual) → {estado:"revocado"}. 401 step_up_invalido; 404 no_totp.
- (Interno, servicio-a-servicio en la red Docker, no enrutado por el gateway) POST /internal/totp/verify {code, contexto?} — lo llama signature-service al firmar; deriva la identidad del bearer reenviado (no de un user_id de cuerpo → sin IDOR), honra locked_until antes de comparar, y avanza el anti-replay last_timestep en un único UPDATE atómico. Lockout tras TOTP_MAX_FAILED_ATTEMPTS (default 5) durante TOTP_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}— 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.404si no hay clave.POST /api/v1/auth/admin/users/{user_id}/signing-key/revoke{motivo?}— un admin (USUA_PERM_ADMIN) 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}— 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.
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.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_RADI(no admin); no exponeemailni metadatos administrativos (D-10).sizeen[1, 500].
URD y contexto de dependencia (E08)¶
GET /api/v1/auth/users/{user_id}/urd·POST·DELETE /{urd_id}— administra las asignaciones Usuario-Rol-Dependencia (una por (usuario, dependencia); una sola principal). ExigeUSUA_PERM_ADMIN.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.
- POST /api/v1/auth/groups · GET /api/v1/auth/groups — alta y listado de grupos. POST deja asiento rbac.grupo_creado.
- GET /api/v1/auth/permissions — catálogo de permisos ({id, nombre, descripcion}). USUA_PERM_ROOT se omite del catálogo si el llamante no es ROOT (ADR-024) — no se lista una opción que el PUT .../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 asiento rbac.miembro_agregado/rbac.miembro_removido.
- 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 asiento rbac.permiso_grupo_asignado (payload con permission_id/crud).
- Conceder (crud > 0) USUA_PERM_ROOT → 403 forbidden si 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 del PUT anterior.
- group_id de un grupo inexistente o de otro tenant → 404 indistinguible (el search_path ya acota el schema del tenant; nunca 403, 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_ADMIN). 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.
[ { "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.
ok=false con broken_id = id de la primera fila cuya cadena no concuerda.
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.
Crea una institución y provisiona su schema PostgreSQL tenant_{slug}.
// 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.
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_ADMIN) ·GET /api/v1/dependencias/by-codigo/{codigo}(jerárquicas, autenticado). - Catálogos (lookup):
GET /api/v1/catalogos/{catalogo}(autenticado) ·POST/PATCH/DELETE /api/v1/catalogos/{catalogo}(USUA_PERM_ADMIN) —tipos-identificacion,tipos-remitente,medios-recepcion,tipos-anexo,causales,formas-envio,soportes,mensajes-rapidos. - Parámetros:
GET /api/v1/config/params,GET /api/v1/config/params/{key}(autenticados) ·PUT /api/v1/config/params/{key}(USUA_PERM_ADMIN). - 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) — las 3 escrituras exigenUSUA_PERM_ADMIN. - 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 (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). Los
GET (tableros, mapa de ruta, catálogo de ejes) quedan solo autenticados. 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).
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.
// 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): código (depe_codi) de la dependencia en el organigrama. 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. Sin plantilla, se almacenan tal cual. La columna está indexada con GIN.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_schema si json_schema no es un JSON Schema válido; 409 conflict si 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¶
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).
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. PATCH hace merge de los campos provistos. Exige USUA_PERM_EXPEDIENTE. 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. El gate aplica también al PATCH: la disposición decide conservación total frente a eliminación (Ley 594/2000 art. 24), así que quien no puede leer el radicado tampoco puede fijar su destino.
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).
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, status, pages.
tracking_number, doc_type, year, sequence son inmutables.
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.
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 opcionales causal_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 404 neutro (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_log inmutable (document.radicado_reclassified) el nivel_anterior, nivel_nuevo, motivo y el fundamento jurídico, de forma atómica con el cambio.
- Propagación: emite document.radicado.reclassified; el signature-service re-sincroniza el snapshot nivel_seguridad de las cadenas de firma del objeto (cierra el residual de staleness de RF-SEG-08).
storage-service — puerto 8005¶
Requiere header
X-Tenant-Slug. Archivos almacenados en MinIO bucketorpycamcp-{slug}-documents.
POST /api/v1/storage/upload¶
Sube un archivo (multipart/form-data, campo file).
- Calcula SHA-256; si ya existe en el tenant retorna el registro existente con
200(deduplicación) - 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}/download¶
Retorna URL pre-firmada de MinIO 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 de MinIO, recalcula el SHA-256 y lo compara con el registrado.
-409 integrity_check_failed si el objeto en MinIO 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.
- 201 con la nueva versión (version incrementado); 200 si el contenido es idéntico (no se crea versión); 409 not_current_version si {file_id} no es la versión vigente; 415 si 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 de MinIO y de la base de datos. Responde 204.
Preservación digital (E10, OAIS/AGN 001/2024)¶
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,num_copias,periodicidad_fixity_dias,politica_migracion,contingencia.POST /api/v1/preservacion/eventos{object_ref, tipo, resultado, hash_before?, hash_after?, detalle?}·GET /api/v1/preservacion/eventos?object_ref=— registro inmutable PREMIS.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), legal_hold?}— (E10 Increment B, ADR-023) empaqueta el AIP OAIS real: por cada documento resuelve su objeto porfile_id, descarga los bytes de MinIO, 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 +legal_hold, y lo registra enpreservacion_worm_objeto(tipo='aip'→ cubierto por el gate de disposición). RequiereUSUA_PERM_EXPEDIENTEconcrud≥3.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;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 MinIO, 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.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), legal_hold?}— (E10 Increment A, ADR-023) protege el índice electrónico firmado (ya subido afiles, referenciado porfile_id) bajo WORM / Object-Lock (MinIO). 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 +legal_hold, 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. RequiereUSUA_PERM_EXPEDIENTEconcrud≥3(umbral de disposición) + clearance. 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;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), legal_hold?}— (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}(defaultindicesi se omite). MISMO servicio, fixity, retención derivada de la TRD por el caller, RBACUSUA_PERM_EXPEDIENTE crud≥3+ clearance, 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/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 —
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 MinIO). 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}.- (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).
POST /api/v1/workflows/assign¶
Asigna un radicado a una dependencia (primer paso o reasignación). Exige PERM_TRAMITAR; 403 forbidden si falta permiso o tenant (RF-SEG-03).
// Request
{
"radicado_id": "uuid",
"tracking_number": "2024-ICETEX-E-000001",
"to_dept": "Dirección Jurídica",
"assigned_to": "uuid-usuario",
"action": "assign",
"notes": "Para concepto jurídico"
}
// Response 201 — FlowStepResponse
POST /api/v1/workflows/transfer¶
Transfiere el radicado a otra dependencia. Requiere from_dept. Exige PERM_TRAMITAR (RF-SEG-03).
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=&assigned_to=&page=&size=. 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.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).
POST /api/v1/workflows/{radicado_id}/devolver (devolución al remitente)¶
Re-enruta un radicado mal asignado: { "to_dept": "...", "causal": "DEV-DEP", "comentario"? }. Cierra el paso activo como returned y crea uno nuevo hacia to_dept, con la causal en el historial. Autorización por tenencia: solo el responsable actual del paso activo puede devolver. 403 not_current_holder si el actor no es el tenedor. 404 si no hay paso activo.
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 y efecto de estado.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 = NULLes abierto.404 unknown_transaction/no_flow.PATCH /{step_id}/completeexigePERM_TRAMITAR.
POST /api/v1/workflows/{radicado_id}/rollback¶
{motivo} (obligatorio, 5-500 caracteres). 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). 409 no_active_step (sin paso activo) 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)
{ "from_dept": "Dirección Jurídica", "to_assigned_to": "uuid" }
// Response 200
{ "reassigned": 12 }
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=Dirección+Jurídica&assigned_to={uuid}&page=1&size=20.
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.
// 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.
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, ubicacion_origen_id?, ubicacion_destino_id?}— crea (precondición: expedienteclosed;409 expediente_not_closedsi no). 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), el par es opcional (transferencia lógica). El destino no se coteja (solo coherencia de tipo — es a dónde va). Lectura de topología física sin clearance (custodia ortogonal).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.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. 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 de MinIO 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.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 — requiere TOTP interactivo). 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 de MinIO y los verifica contra signature-service). Devuelve{sello: {...}, firmas_personales: [{rol, firmante_id, valida, revocacion_status}], completitud, valida, acreditado, auto_traslado, elaborador_firmado}:completitud ∈ {sellada, firma_personal_pendiente, firmada_receptor, firmada_remitente, firmada_completa}(derivada, presencia factual —firmada_completa= ambas PARTES DEL TRASPASO (entrega+recibe) firmaron = atribución bilateral reforzada, NO conformidad jurídica plena). 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).
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. Filtros?parent_id=&activo=.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 + rango); 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_esperada. - 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; filtros?ubicacion_id=&trd_serie_id=.GET /api/v1/fuid.xmlexporta en XML. 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 (incluidaGET /trd/revision-pendiente) quedanUSUA_PERM_TRDde solo lectura (crud≥1): alimentan la clasificación en radicación (dropdowns de TRD/tipos) y la validación de plantillas.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. -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).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, con motivo obligatorio. Vía honesta de "devolución con observaciones" (Ac. AGN 001/2024) — antes la única salida de aprobada era convalidar, que exige declarar un acto que tal vez no existe todavía. Limpia acta_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) desde aprobada (circuito normal, exige que acta_comite/fecha_aprobacion_comite ya consten — 409 trd_aprobacion_comite_requerida si 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, aborta 409 en 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 de tipos_documentales/expediente_metadata_templates de la versión derogada a la nueva; (b) desde migrada (ratificación D6): migrada → convalidada sin crear versión nueva ni derogar nada — la misma fila gana los campos del acto, y se limpian trd_revision_requerida/trd_revision_motivo de los expedientes closed/transferred amarrados a ella cuyo motivo sea exactamente cerrado_bajo_serie_migrada (otros motivos, p. ej. serie_no_resoluble, no se resuelven así). acto_administrativo/fecha_convalidacion/instancia_convalidante son obligatorios en el body (422 si faltan; el CHECK trd_series_convalidada_acto_check los exige también a nivel BD); rusd_radicado/fecha_publicacion opcionales. Una vez convalidada, el trigger de la 038 blinda a nivel de MOTOR el acto (acto_administrativo/fecha_convalidacion/instancia_convalidante inmutables, estado solo puede ir a derogada).
- 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ón convalidada — 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 era convalidar. Cada campo solo se puede rellenar una vez (NULL → valor); 409 trd_rusd_ya_registrado/trd_fecha_publicacion_ya_registrada si 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 con 409 trd_derogacion_bloqueada_expedientes_abiertos si algún expediente open está 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 de POST /versiones de 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 con trd_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 por nivel_seguridad ≤ clearance del llamante en el COUNT y en la página — antes cualquier portador de USUA_PERM_TRD (permiso ORTOGONAL al clearance) enumeraba RESERVADA/CLASIFICADA; deja además traza agregada de consulta (RF-BUS-10) cuando X-User-Id/X-Canal llegan.
/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?, 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). -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). Deuda: PDF combinado, streaming para expedientes grandes,asunto/tipo documentalen el manifiesto. -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). -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 desde MinIO y lo verifica contra signature-service — el bloquefirmaincluye{presente, valida, nivel_conformidad, acreditado, motivo}(motivo='signed_xml_unavailable'→valida=false).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 puede aportarcontent_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). Se persiste un snapshot inmutable por versión del índice, de modo queGET /indice?version=Nmuestra la productora tal como estaba al generarse esa versión. Vincular/excluir regenera el índice automáticamente; sobre un expediente noopendevuelve409.
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. Con objeto_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, si SIGNER_LT_ENABLED=true y el sello del tenant trae material de CA (ca.crt+crl.der), escala a XAdES-LTA (E06 Inc.5): añade xades:CertificateValues (solo la CA) + xades:RevocationValues (CRL) + xadesv141:ArchiveTimeStamp. Con SIGNER_OCSP_ENABLED=true (default false) 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) como xades:OCSPValues/EncapsulatedOCSPValue hermana del CRL dentro del mismo RevocationValues (E06 Inc.7) — firmada por un responder delegado emitido offline por la CA del tenant, con certStatus derivado de la CRL, y queda cubierta por el ArchiveTimeStamp. Exige PERM_FIRMA + X-User-Id (el firmante nunca sale del cuerpo, RF-FIR-11). 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-LTA segú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 audit firma.lt_downgrade_no_ca; con OCSP habilitado pero sin par de responder deja audit firma.ocsp_downgrade_no_responder (se queda en LT/LTA con CRL sola). acreditado siempre false (CA/TSA/responder OCSP de dev, 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 con revocation_provenance="dev"). 503 signing_seal_unavailable si falta el sello; 503 timestamp_authority_unavailable si la TSA no está disponible; 503 lt_material_required si SIGNER_LT_REQUIRE=true y falta material CA; 503 ocsp_material_required si SIGNER_OCSP_REQUIRE=true y no se produjo respuesta OCSP (falta material de CA o de responder); 503 signed_index_upload_failed si 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; primitivo personal_signing.sign_personal_xml reutilizado por la cadena de radicados y por la firma del acta de transferencia). Autenticado como usuario (get_actor + PERM_FIRMA, X-User-Id/JWT — no X-Internal-Token: lleva identidad + TOTP del firmante real, a diferencia de /internal/sign-indice). ASSERT firmante_id == user(JWT): si el body trae firmante_id y 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 de SignedInfo consumiendo el TOTP atómicamente (split firma-remota), y sube el XML firmado a MinIO con nombre determinista que incluye el firma_id (patrón H-H, sin sobrescritura por carrera). Devuelve el FirmaResponse estándar (firma_id, minio_key_firmado, xades_level, provider="xades_personal", acreditado siempre false, nivel_conformidad='xades_personal_no_acreditado'). El gate de clearance real solo se resuelve para objeto_tipo="radicado"; para otros tipos (p. ej. acta_transferencia_personal) el gate no-read-up lo aplica 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). 4xx si el TOTP es inválido; 503 si el canal de auth cae.
- POST /api/v1/signature/verify {firma_id, payload_xml} — verifica. Firmas xades_local: payload_xml debe ser el XML firmado completo (el de minio_key_firmado); valida criptografía + cobertura de la raíz + vigencia del cert + huella del sello + coincidencia con signed_xml_sha256; XAdES-T valida además el token RFC 3161 (cubre el ds:SignatureValue, firma CMS, cert TSA atado a firma.tsa_cert_fingerprint); XAdES-LT/LTA valida además (fail-closed) la cadena seal→CA (anclada a la CA del tenant vía pinning ca_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 el ArchiveTimeStamp sobre el conjunto cubierto determinista; crl_next_update se expone sin hard-fail si caducó (no acreditado). Si la firma trae xades:OCSPValues (E06 Inc.7), valida además (fail-closed) la respuesta OCSP (RFC 6960): responseStatus=successful, responder emitido por la CA del tenant + vigencia, EKU OCSPSigning + id-pkix-ocsp-nocheck, firma del BasicOCSPResponse, CertID atado al sello (serial + issuer hashes), certStatus=good, y pinning del fingerprint del responder contra firma.ocsp_responder_fingerprint (sin él → fail-closed); su ausencia es retrocompat (LT válida sobre CRL sola). Firmas xades_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 pinning user_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) y revocacion_status ∈ {vigente, revocado, no_verificable, desconocido}; valida = cripto AND revocacion_status=='vigente'; un canal de revocación caído → valida=false/motivo=revocacion_no_verificable pero firma_criptograficamente_valida=true (default fail-closed, verify_personal_revocation_require). Firmas xades_personal de Inc.1 (sin CertificateValues) verifican como XAdES-B/T (grandfather) pero siguen siendo revocables por identidad del cert. Firmas nativa: payload_xml es el original en claro. 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?} (en /verify el campo autoritativo del nivel realmente verificado es xades_level, derivado del resultado). Endpoint público (sin auth). 404 si la firma no existe.
- GET /api/v1/signature/firmas/{firma_id} — metadatos de una firma (mismos campos que sign-xml, sin certificado_pem completo). Público. 404 si no existe.
- (Interno, servicio-a-servicio; no alcanzable desde el gateway — X-Internal-Token no está en FORWARDED_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 sistema SYSTEM_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 por X-Internal-Token (fail-closed: settings vacío → 503, header ausente → 401, errado → 403); no exige PERM_FIRMA ni X-User-Id. objeto_tipo (default "indice") se valida contra el allow-set {"indice","acta_transferencia"} (422 fuera 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 asiento firma.creada lleva origen="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, FIRMA_PERSONAL_REQUIRE_XADES ∈ off|clasificados|todos, default off): 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,…]} — crea la cadena (turnos orden 1..N). 409 cadena_existente si el objeto ya tiene una cadena abierta. Nota: para firmar con 2FA la solicitud debe tener payload_sha256 estable.
- GET /api/v1/signature/requests/{id} — estado de la cadena: cabecera + items {orden, firmante_id, estado, firma_id, comentario, decidido_at}.
- GET /api/v1/signature/requests?objeto_tipo=&objeto_id= — cadena abierta de un objeto (404 si no hay).
- 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). Solo el firmante del turno activo (403 no_es_su_turno); 404 solicitud_no_encontrada (neutro); 422 payload_no_fijado si la solicitud no tiene payload_sha256; 429 challenge_cooldown si se reemite antes de 30s; 503 canal_2fa_no_disponible si el correo no se puede enviar (fail-closed). El OTP nunca aparece en la respuesta ni en logs.
- 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_su_turno; 409 si la solicitud no está pendiente o se pierde la carrera del turno (lock FOR UPDATE). El 2FA es no desactivable para nivel_seguridad ≥ 2 (RESERVADA/CLASIFICADA) — ambos factores lo satisfacen; TOTP es el más fuerte.
- 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_found si el radicado no existe o lo supera (indistinguibles): no se despacha lo que no se puede ver.
- 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 (404 si el radicado supera el clearance del llamante).
- POST /api/v1/envios/{shipment_id}/estado {estado, descripcion?} — transición manual (despacho humano). Resuelve el clearance del llamante → 404 si el radicado lo supera (cierre D-14); 409 transicion_invalida para un salto no permitido.
- GET /api/v1/envios/{shipment_id}/tracking — consulta por guía (pull, F5) contra el operador (stub consultar_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?} — webhook ENTRANTE del operador (F5, RF-POR-08). Sin JWT (bajo /api/v1/public/); autenticación HMAC-SHA256 con cabecera X-OrpycaMCP-Signature: sha256=<hex> sobre el cuerpo crudo, con el secreto per-tenant/per-operador de postal_operator_credential (identidad propia del operador, separada del permiso humano — cierra D-14 #3). El tenant_slug del path fija el search_path; el secreto vive en el schema de ese tenant. Idempotente por (operador, event_id) (reintento → no-op). Mapea estado_operador a la máquina interna (422 estado_operador_no_mapeable si no mapea). Devuelve {status: "applied"|"duplicate"|"recorded_no_change", shipment_id, estado}. 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 como recorded_no_change sin mutar el estado. Cada transición emite un evento document.envio.* para E16 y deja asiento en audit_log (actor=postal:<operador>).
Interoperabilidad — OAI-PMH (E11, RF-INT-02/INT-06) — document-service¶
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. 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). -
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).
Capa MCP (E18, ADR-019) — mcp-server, puerto 8009¶
Servicio separado (no detrás del gateway): 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 (POST /mcp, contexto por cabeceras Authorization/X-Tenant-Slug/X-User-Permissions/X-User-Roles). API HTTP simple del catálogo:
- GET /api/v1/mcp/tools — catálogo de tools visibles para el usuario (filtra por X-User-Permissions; X-User-Roles con ROOT ve todas).
- POST /api/v1/mcp/tools/{name}/invoke {...args} — invoca la tool: propaga Authorization + X-Tenant-Slug al gateway. Operaciones de escritura exigen X-Confirm-Write: true (412 si falta) y el permiso correspondiente (403 si falta). Tools: radicar_documento, consultar_radicado, buscar_radicados, consultar_expediente, indice_expediente, listar_trd, bandeja_tramite, consultar_fuid, buscar_conocimiento (búsqueda semántica E21; POST /api/v1/knowledge/search, solo lectura, ACL server-side).
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}}. El conversation_id (generado si se omite) mantiene el hilo; el historial es efímero por proceso y aislado por tenant (un conversation_id nunca cruza tenants). v1 = SOLO LECTURA: la única tool de escritura del catálogo (radicar_documento) se excluye del conjunto ofrecido al modelo (defensa en profundidad: filtrada al construir el prompt y denegada de nuevo en el despacho). 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_unavailable si el asistente no está configurado (sin ANTHROPIC_API_KEY) o el SDK falla — degradación honesta, nunca una respuesta simulada (RF-FIR-15); 401 sin token; 409 conversation_limit al agotar el tope de turnos del hilo. Respuestas de tool 403/404 se 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 en acl.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 con nivel_seguridad ≤ el clearance del llamante (derivado del JWT/BD, no del cuerpo de la petición); el acl opcional 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.
- POST /api/v1/knowledge/antecedentes {query|radicado_ref, source_type?, top_k?} (XOR query/radicado_ref, E21 Inc.2, Patrón A) — recuperación pura ANCLADA y CITADA, sin LLM: reusa search (mismo pre-filtro ACL), devuelve {antecedentes:[{source_type, source_ref, snippet, score}], advisory} con un aviso fijo (CC-01/CC-02). radicado_ref inexistente o que excede el clearance del solicitante colapsan al mismo 404 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 de search y, si AI_PROVIDER es un proveedor de generación real (ollama_local default soberano, ollama_cloud/openai_compatible externos, anthropic con Citations API nativa), sintetiza una respuesta ANCLADA. Barrera dura de soberanía (sin override): con proveedor externo, excluye del contexto enviado al LLM los fragmentos nivel_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_id son null cuando no hubo generación real. Cada generación real se registra en kb_suggestions (CC-03: proveedor/modelo en columnas separadas) y en audit_log. 503 rag_unavailable ante cualquier fallo del proveedor (SDK ausente, credencial ausente, error de red/API) — degradación honesta (RF-FIR-15), nunca una respuesta simulada.
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). Elsecretno se devuelve. ExigeUSUA_PERM_ADMIN(todo el CRUD): 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.DELETE /api/v1/webhooks/{id}— elimina una suscripción. ExigeUSUA_PERM_ADMIN.- 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 y notification-service (roadmap ADR-021 completo). 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.
| Método | Ruta | Descripción |
|---|---|---|
GET |
/api/v1/{signature\|workflow\|notifications}/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 se alcanza ese tope la respuesta incluye el header X-Truncated: true (advierte que X-Total-Count puede sub-reportar). |
GET |
/api/v1/{signature\|workflow\|notifications}/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\|notifications}/admin/deadletter/{event_id}/replay |
Reprocesa el evento. 200 → status='resolved'; 409 si no está pending; 404 si no existe o sin clearance. 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\|notifications}/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.
// 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. Requiere PERM_RADI.
// Response 200
{
"job_id": "uuid", "job_type": "documents",
"status": "completed", // pending | processing | completed | failed
"total_items": 2, "processed_items": 2, "failed_items": 0,
"items": [
{ "item_index": 0, "status": "success", "result_id": "uuid-radicado" },
{ "item_index": 1, "status": "failed", "error_message": "..." }
]
}
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. 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).POST /api/v1/borradores{doc_type, subject, dest_dept?, cuerpo?, plantilla_id?}— crea un borrador (si se daplantilla_idy nocuerpo, hereda el cuerpo de la plantilla).GET /api/v1/borradores(?estado=),GET /{id},PATCH /{id}(editar solo si no radicado),POST /{id}/aprobar(borrador→aprobado),POST /{id}/radicar(→ genera el radicado real y marca el borradorradicado).
Respuesta rápida (paridad legado)¶
POST /api/v1/documents/{id}/respuesta{subject?, dest_dept?, observations?, response_days?}— 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.
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 permiso PERM_SOL_ANULAR. 403 forbidden si falta. 409 si 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 mismo 404 que 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 a anulado (el número se conserva). Requiere PERM_PANU_CODI. 403 forbidden si falta.
- POST /api/v1/documents/{id}/anulacion/rechazar {observacion?} — rechaza. Requiere PERM_PANU_CODI. 403 forbidden si falta. GET /api/v1/documents/{id}/anulacion — última solicitud.
Firma electrónica (E06, ADR-016)¶
POST /api/v1/documents/{id}/signatures{ "content_hash": "<sha256 hex>", "anexo_id"?, "reason"? }— firma el radicado/anexo: registra identidad (claims del gateway),content_hashy sello de tiempo.422si el hash no es SHA-256. 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.
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.
POST /api/v1/ingest/email/poll (ingesta de correo, E19)¶
Operación interna (requiere JWT). Trae los correos no leídos de la bandeja IMAP configurada, crea un radicado de Entrada por cada uno (metadata.source=email, remitente y anexos en metadata) y los marca como leídos. Tenant en X-Tenant-Slug. Respuesta { "ingested": n, "tracking_numbers": [...] }. 503 si IMAP no está configurado (IMAP_HOST vacío).
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. 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) — 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/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).
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, meta.<campo>=valor (filtro por metadato, contención JSONB/GIN), page, size. Cabecera X-Total-Count.
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, origin_dept, subject, sender_name, sender_entity, pages. 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).
DELETE /api/v1/workflow/rules/{id}¶
Eliminar regla. Responde 204.
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 — mcp-server (E18/E22, ADR-020) — contrato definido, backend PENDIENTE¶
El frontend del asistente está implementado (E22: store, service, proxy BFF, UI de chat en
AssistantDock); el backend (mcp-server, LLM/knowledge reales) es scaffold pendiente de roadmap. El proxy SvelteKit apunta a este contrato y, mientras el gateway no lo exponga, traduce la ausencia (404/501/5xx/conexión rehusada) a un503 assistant_unavailable— la UI muestra "asistente no disponible", nunca una respuesta simulada.
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" }
}
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).