actualizacion

This commit is contained in:
JOSE ANASTACIO ESQUIVEL SALAS 2026-10-28 08:37:46 -06:00
parent 62401d3791
commit 21f5861e1b
202 changed files with 13256 additions and 106 deletions

View File

@ -4,9 +4,32 @@
DB_URL=jdbc:postgresql://server02:30432/orquestador
DB_USERNAME=pgadmin
DB_PASSWORD=
# Para separar migraciones y runtime, configurar un rol propietario distinto:
# DB_MIGRATION_USERNAME=orchestrator_migrator
# DB_MIGRATION_PASSWORD=
SPRING_PROFILES_ACTIVE=postgres96
JWT_ISSUER_URI=http://keycloakic.ddns.net/auth/realms/controlms
JWT_JWK_SET_URI=http://keycloakic.ddns.net/auth/realms/controlms/protocol/openid-connect/certs
JWT_AUDIENCE=orquestador-nmp
APP_BIND_ADDRESS=127.0.0.1
APP_PORT=8080
# T07: claim de negocio obligatorio para JWT autenticados; no es el scope OAuth.
JWT_BUSINESS_SCOPE_CLAIM=business_scope
# Habilitar solo con DB_USERNAME restringido y DB_MIGRATION_USERNAME separado.
# pgadmin no puede usarse como runtime cuando la administracion esta habilitada.
FLOW_ADMIN_ENABLED=false
# API de solicitudes independiente de administracion; exige el mismo runtime DB restringido.
REQUEST_API_ENABLED=false
WORKER_ENABLED=false
INTEGRATION_ROUTES_PATH=/app/config/integration-routes.json
WORKER_CONCURRENCY=10
WORKER_BATCH=10
WORKER_POLL_MS=500
WORKER_LEASE_SECONDS=60
WORKER_RENEW_SECONDS=15
WORKER_SHUTDOWN_SECONDS=20
# Lista de ambitos autorizados para administrar el catalogo global; vacia deniega todo.
ADMIN_BUSINESS_SCOPES=
# Ruta dentro del contenedor; compose monta ./config como solo lectura.
FLOW_CATALOG_PATH=/app/config/publication-catalog.json

View File

@ -7,6 +7,7 @@ permissions:
jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
@ -14,8 +15,14 @@ jobs:
distribution: temurin
java-version: '21'
cache: maven
- name: Verify
- name: Docker available for Testcontainers
run: docker info
- name: Verify acceptance checker
run: python3 -m unittest discover -s scripts -p 'test_*.py'
- name: Verify with isolated PostgreSQL Testcontainers
run: sh ./mvnw --batch-mode --no-transfer-progress clean verify
- name: Require A01-A20 without skipped tests
run: python3 scripts/verify_acceptance.py --require-testcontainers
- name: Test reports
if: always()
uses: actions/upload-artifact@v4
@ -24,4 +31,7 @@ jobs:
path: |
target/surefire-reports/
target/failsafe-reports/
if-no-files-found: ignore
target/acceptance-report.json
specs/acceptance-cases.json
if-no-files-found: error
retention-days: 30

1
.gitignore vendored
View File

@ -4,3 +4,4 @@
/.env
*.log
/.tools/
__pycache__/

101
README.md
View File

@ -30,10 +30,11 @@ Salud: `http://localhost:8080/actuator/health/readiness`. El puerto publicado se
- [Arquitectura](specs/ARCHITECTURE.md): paquetes y fronteras.
- [Decisiones](specs/DECISIONS.md): versiones y motivos.
- [Administración interna T06](specs/FLOW_PUBLICATION.md): validación, publicación, catálogo y transacciones; REST seguro pendiente de T07.
- [Plan T01–T17](specs/IMPLEMENTATION_PLAN.md): dependencias, subtareas y criterios de cierre.
- [Seguimiento](specs/PROGRESS.md): estado real, verificaciones y siguiente paso.
Surefire ejecuta `*Test` en `test`; Failsafe ejecuta `*IT` en `verify`. T01 incorpora un smoke de despliegue con cinco comprobaciones; T03 incorporará Testcontainers. CI ejecuta `clean verify` sin acceder a servidores privados. Para verificar explícitamente un servicio iniciado, usar JDK 21 y ejecutar:
Surefire ejecuta `*Test` en `test`; Failsafe ejecuta `*IT` en `verify`. T01 incorpora un smoke de despliegue con cinco comprobaciones; T03 incorpora Testcontainers PostgreSQL 9.6.24 y requiere Docker para verify. CI ejecuta `clean verify` sin acceder a servidores privados. Para verificar explícitamente un servicio iniciado, usar JDK 21 y ejecutar:
```powershell
.\mvnw.cmd clean verify -Pdeployment-smoke '-Dsmoke.baseUrl=http://server02:8787'
@ -41,10 +42,104 @@ Surefire ejecuta `*Test` en `test`; Failsafe ejecuta `*IT` en `verify`. T01 inco
Este perfil comprueba salud, readiness, liveness y rechazo de solicitudes sin token/con token malformado. No contiene todavía pruebas de negocio ni de JWT válido.
`clean verify` valida los contratos y sus ejemplos sin servicios externos. Consultar [convenciones REST](contracts/README.md), [semántica del DSL](schemas/README.md) y [fixtures T02](examples/contracts/README.md). Los ejemplos de `examples/http/contracts.http` son contratos objetivo, no endpoints implementados. Los flujos de crédito completos corresponden a T14.
`test` valida los contratos y sus ejemplos sin servicios externos; `clean verify` agrega migraciones e integración PostgreSQL. Sin Docker local, usar el perfil migration-tests-external descrito en RUNBOOK. Consultar [convenciones REST](contracts/README.md), [semántica del DSL](schemas/README.md) y [fixtures T02](examples/contracts/README.md). Los ejemplos de `examples/http/contracts.http` son contratos objetivo, no endpoints implementados. Los flujos de crédito completos corresponden a T14.
El Dockerfile incluye contratos/schemas/fixtures en la etapa de construcción para ejecutar las mismas pruebas y ejecuta con JRE 21 como usuario no root. Compose no administra PostgreSQL ni sus volúmenes. Migraciones, mocks y roles de mínimo privilegio siguen pendientes de T03/T11/T07.
El Dockerfile incluye contratos/schemas/fixtures en la etapa de construcción para ejecutar las mismas pruebas y ejecuta con JRE 21 como usuario no root. Compose no administra PostgreSQL ni sus volúmenes. T03 incluye migraciones y permisos PostgreSQL; mocks y autorización funcional siguen pendientes de T11/T07.
## Límites funcionales previstos
Nuevos umbrales, destinos y pasos con capacidades existentes se publicarán como configuración. Una nueva capacidad de validación puede requerir un adapter; un nuevo tipo de tarea puede requerir soporte del front. No incluye UI administrativa, Kafka, Drools ni migración automática de instancias.
## Seguridad y administración (T07)
La administración REST requiere habilitación explícita y credenciales PostgreSQL
separadas de migración/ejecución. Ver [SECURITY.md](specs/SECURITY.md) y las variables
nuevas de `.env.example`. Compose conserva únicamente el servicio y monta `config/`
como solo lectura; PostgreSQL sigue externo. No se ha desplegado esta versión.
## Solicitudes (T08)
Implementados POST y GET de solicitudes, con versión fijada, idempotencia durable,
validación de datos y autorización. Habilitar REQUEST_API_ENABLED=true únicamente
con runtime DB restringido; administración puede permanecer apagada.
[Configuración y semántica](specs/REQUESTS.md); [ejemplo HTTP](examples/http/requests.http).
No se ha desplegado esta versión. Eventos y worker continúan pendientes.
## Eventos (T09)
POST /requests/{id}/events acepta comandos idempotentes y retorna 202/Location.
Ejecución PENDING, revisión y auditoría se confirman juntas. Ver
[EVENTS.md](specs/EVENTS.md) y [ejemplo](examples/http/events.http).
El worker y GET de ejecución continúan pendientes en T10: 202 no indica que se
haya procesado la acción. No se desplegó esta versión.
## Worker y consulta de ejecución (T10)
GET /api/v1/executions/{id} ya permite seguimiento autorizado. WORKER_ENABLED=true
activa procesamiento durable con leases y checkpoints; defaults y límites en
[WORKER.md](specs/WORKER.md). V3 conserva clasificación de efectos de acciones.
Los adapters T11 requieren contratos y rutas configurados; los registros por
defecto están vacíos. Flujos sin acciones pueden concluir.
No se ha desplegado esta versión ni se han invocado proveedores reales.
## Integraciones (T11)
INVOKE_INTEGRATION ejecuta POST JSON con contratos/perfiles fijados y límites de
tiempo y tamaño. REGISTER_DOCUMENT verifica metadatos y registra referencias de
forma atómica. NO_OP se limita a simulación.
[Configuración y límites](specs/INTEGRATIONS.md);
[ejemplos de contratos](examples/integrations/publication-catalog.json) y
[rutas](examples/integrations/integration-routes.example.json).
Los ejemplos usan dominios .invalid y datos sintéticos; requieren adaptación al
proveedor real. Compose sigue levantando únicamente el servicio con PostgreSQL
externo.
## Reintentos y conciliación (T12)
El worker programa reintentos durables para fallos transitorios seguros, con
backoff, jitter y Retry-After acotados. Los efectos inciertos mantienen bloqueada
la solicitud hasta una resolución con evidencia por EXECUTION_OPERATOR.
[Operación y límites](specs/RECONCILIATION.md);
[ejemplos HTTP](examples/http/reconciliation.http).
La resolución requiere administración habilitada y ámbito autorizado; no permite
elegir manualmente el estado de negocio. La evidencia es verificada por el operador,
sin consulta automática a un proveedor real.
## Eventos disponibles y documentos (T13)
GET /api/v1/requests/{id}/available-events devuelve eventos, schemas y metadatos
FORM/DOCUMENT_UPLOAD de la versión fijada; oculta tareas con ejecución activa.
No incluye desarrollar una interfaz gráfica. El worker verifica también los
límites documentales de la tarea publicada.
[Semántica y pruebas](specs/AVAILABLE_EVENTS.md);
[ejemplo HTTP](examples/http/available-events.http).
## Flujos de demostración (T14)
[Crédito v1/v2 y compra](examples/flows/README.md) incluyen definiciones completas,
catálogo y ejemplos HTTP. DemoFlowsIT publica y recorre los archivos exactos usando
API, worker, PostgreSQL de pruebas y proveedores HTTP simulados. La v2 incorpora
licencia por configuración; las solicitudes existentes conservan v1. No hay front
ni cobro real. La integración de proveedores reales requiere validación separada.
## Auditoría y observabilidad (T15)
GET /api/v1/requests/{id}/history consulta el historial del propietario y ámbito
con filtros y cursor. Actuator metrics requiere EXECUTION_OPERATOR y un ámbito
incluido en ADMIN_BUSINESS_SCOPES. Incluye estados de cola, edad del pendiente,
leases vencidos, resultados HTTP y latencia de integraciones sin etiquetas por UUID.
[Operación y límites](specs/OBSERVABILITY.md);
[ejemplos HTTP](examples/http/history-operations.http).
WORKER_SHUTDOWN_SECONDS controla el drenaje (1–20 s, predeterminado 20).
## Verificación integral y simulación (T16)
[Guía y matriz ejecutable A01–A20](specs/ACCEPTANCE.md). Después de clean verify,
`python scripts/verify_acceptance.py --require-testcontainers` exige evidencia de
cada caso y ningún test omitido. El perfil externo identifica explícitamente su
entorno y no pasa esa puerta Docker. CI archiva reportes y matriz.
POST /api/v1/admin/flows/{id}/simulate evalúa fixtures con FLOW_EDITOR y ámbito
administrativo autorizado. Usa la proyección compartida con el worker, sin proveedores
ni escrituras de negocio. [Semántica](specs/SIMULATION.md) y
[ejemplo HTTP](examples/http/simulate.http).

View File

@ -9,12 +9,29 @@ services:
DB_URL: ${DB_URL:?Configura DB_URL en .env}
DB_USERNAME: ${DB_USERNAME:?Configura DB_USERNAME en .env}
DB_PASSWORD: ${DB_PASSWORD:?Configura DB_PASSWORD en .env}
DB_MIGRATION_USERNAME: ${DB_MIGRATION_USERNAME:-${DB_USERNAME}}
DB_MIGRATION_PASSWORD: ${DB_MIGRATION_PASSWORD:-${DB_PASSWORD}}
JWT_ISSUER_URI: ${JWT_ISSUER_URI:?Configura JWT_ISSUER_URI en .env}
JWT_JWK_SET_URI: ${JWT_JWK_SET_URI:?Configura JWT_JWK_SET_URI en .env}
JWT_AUDIENCE: ${JWT_AUDIENCE:?Configura JWT_AUDIENCE en .env}
JWT_BUSINESS_SCOPE_CLAIM: ${JWT_BUSINESS_SCOPE_CLAIM:-business_scope}
FLOW_ADMIN_ENABLED: ${FLOW_ADMIN_ENABLED:-false}
REQUEST_API_ENABLED: ${REQUEST_API_ENABLED:-false}
WORKER_ENABLED: ${WORKER_ENABLED:-false}
INTEGRATION_ROUTES_PATH: ${INTEGRATION_ROUTES_PATH:-/app/config/integration-routes.json}
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-10}
WORKER_BATCH: ${WORKER_BATCH:-10}
WORKER_POLL_MS: ${WORKER_POLL_MS:-500}
WORKER_LEASE_SECONDS: ${WORKER_LEASE_SECONDS:-60}
WORKER_RENEW_SECONDS: ${WORKER_RENEW_SECONDS:-15}
WORKER_SHUTDOWN_SECONDS: ${WORKER_SHUTDOWN_SECONDS:-20}
ADMIN_BUSINESS_SCOPES: ${ADMIN_BUSINESS_SCOPES:-}
FLOW_CATALOG_PATH: ${FLOW_CATALOG_PATH:-/app/config/publication-catalog.json}
SERVER_PORT: "8080"
ports:
- "${APP_BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-8080}:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./config:/app/config:ro
stop_grace_period: 40s

12
config/README.md Normal file
View File

@ -0,0 +1,12 @@
# Catálogo confiable
publication-catalog.json configura la publicación T06/T07. Compose monta este
directorio en /app/config con permisos de solo lectura. El ejemplo permite roles
ANALISTA y no registra capacidades externas. No contiene secretos ni mocks.
Campos raíz obligatorios: roles (array), handlers, integrations y documents (objetos).
PublicationCatalogLoader valida campos exactos y rechaza claves duplicadas.
Las estructuras tipadas y las reglas de publicación se describen en
../specs/FLOW_PUBLICATION.md. El registro de capacidades y contratos de proveedor
debe acompañar a handlers reales en T11; declararlos aquí no implementa ejecución.
Para habilitar las rutas administrativas ver ../specs/SECURITY.md.

View File

@ -0,0 +1 @@
{"routes":[]}

View File

@ -0,0 +1,6 @@
{
"roles": ["ANALISTA"],
"handlers": {},
"integrations": {},
"documents": {}
}

View File

@ -32,3 +32,30 @@ Conciliación sólo permite RESUME con resultados verificados por acción o CLOS
Con JDK 21: `./mvnw clean verify` (Windows: `.\mvnw.cmd clean verify`). ContractValidationTest comprueba el schema oficial OpenAPI 3.1, parseo Swagger, resolución local de referencias, meta-schema 2020-12, fixtures positivos/negativos, ejemplos embebidos y cabeceras/rutas obligatorias. Los validadores tienen scope test; no agregan dependencias Jackson 2 al runtime.
Swagger Parser 2.1.35 se usa sin expansión recursiva (`resolve=false`): expandir el AST produjo un error de profundidad. Las referencias se verifican explícitamente y networknt valida los ejemplos con los schemas reales; no se ignoran referencias rotas. Las pruebas normales no contactan server02, PostgreSQL, Keycloak ni proveedores. No sustituyen T05/T06/T07/T16.
## Estado de implementación T07
Crear, editar, validar, publicar, activar y retirar flujos tienen controlador REST
protegido. Simulación continúa pendiente (T14), conciliación en T12 y rutas de
negocio en T08–T15. Configuración, permisos DB y política por recurso:
[SECURITY.md](../specs/SECURITY.md). SecurityHttpIT verifica JWT RSA mediante JWKS
local, HTTP real y PostgreSQL aislado; no valida aún la configuración real de controlms.
Desde T06 networknt y Jackson 2 se usan también en infraestructura de runtime para
validar DSL; Swagger Parser permanece solo en test. Esto sustituye la descripción
histórica de todos los validadores como dependencias exclusivamente de test.
T08 implementa POST /requests y GET /requests/{id}, con JWT, propiedad/ámbito y
ETag. Creación repetida retorna snapshot original; consulta retorna estado actual.
Tipo inexistente 404; no asignable/cambio concurrente 409. Ver
[REQUESTS.md](../specs/REQUESTS.md) para habilitación y protocolo transaccional.
T09 implementa POST /requests/{id}/events con 202/Location, bloqueo de solicitud,
replay antes de revisión y persistencia atómica. Ver EVENTS.md en specs.
GET /executions/{id} sigue como contrato objetivo y se implementará en T10;
no se debe interpretar el 202 como procesamiento completado.
T10 implementa GET /executions/{id} con autorización por recurso y ExecutionView,
y worker opcional. Sustituye el estado pendiente descrito en T09. 202 sigue siendo
aceptación; consultar ejecución para resultado y luego refrescar solicitud/ETag.
Ver specs/WORKER.md para efectos, checkpoints, límites y configuración.

View File

@ -3,7 +3,7 @@ jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
info:
title: Orquestador NMP
version: 1.0.0
description: Contrato objetivo del MVP. Las rutas de negocio aún no están implementadas. JWT y autorización por
description: Contrato objetivo del MVP. Solicitudes, eventos, ejecuciones, conciliación, tareas e historial implementados hasta T15; simulación administrativa implementada en T16. JWT y autorización por
recurso obligatorios. Campos opcionales ausentes se omiten; no se usa null salvo en datos configurables.
servers:
- url: /api/v1
@ -18,9 +18,9 @@ paths:
operationId: createRequest
summary: Crear solicitud y fijar flujo activo
description: 'Owner y businessScope se derivan del JWT autorizado. Idempotencia durable por actor/scope: repetición
idéntica retorna la misma solicitud (201); otro hash retorna 409. data se valida con initialDataSchema y
idéntica retorna la respuesta original de creación (201, Location y ETag originales), incluso si cambió el flujo activo o avanzó la solicitud; otro hash retorna 409. GET devuelve el estado actual. data se valida con initialDataSchema y
dataSchema del flujo. Máximo de datos 256 KiB. No se aceptan estado, propietario o resultados de proveedor
como autoridad del cliente.'
como autoridad del cliente. Tipo inexistente retorna 404; tipo deshabilitado, sin flujo publicado activo o cambiado durante la asignación retorna 409 INVALID_FLOW_STATE. Un 409 por cambio concurrente permite reintentar la misma clave, que no queda reservada.'
tags:
- Requests
parameters:
@ -63,6 +63,8 @@ paths:
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'413':
@ -133,7 +135,7 @@ paths:
operationId: availableEvents
summary: Consultar tareas y eventos permitidos
description: 'Orientativo: el POST revalida roles y guardias. Con ejecución activa, events vacío y blockedByExecutionId
presente. Sólo se exponen causas de bloqueo no sensibles.'
presente. Sólo se exponen causas de bloqueo no sensibles. Filtra por propietario, ámbito y roles; usa la versión fijada incluso retirada. Guardias dependientes de event.data se difieren al POST. Cache-Control no-store, sin ETag; revision sirve para If-Match del evento.'
tags:
- Requests
parameters:
@ -168,7 +170,7 @@ paths:
description: 'Una transición por evento; sin HTTP externo en la transacción de aceptación. El hash incluye
evento, datos y revisión esperada canónicos. Primero comprobar acceso e idempotencia y después revisión;
un replay idéntico devuelve el mismo executionId/status actual incluso si cambió revision. Nueva clave mientras
existe ejecución activa: REQUEST_BUSY. Validar data con schema del evento. 202 no equivale a éxito de negocio.'
existe ejecución activa: REQUEST_BUSY (se comprueba revisión antes de busy). Validar data con schema del evento. Reintentar conservando If-Match original. 202 no equivale a éxito de negocio; GET de ejecución permite seguimiento; el worker se habilita explícitamente con WORKER_ENABLED.'
tags:
- Requests
parameters:
@ -268,7 +270,7 @@ paths:
operationId: getHistory
summary: Consultar auditoría paginada y filtrada
description: Orden ascendente (createdAt,id), cursor opaco asociado a solicitud y filtros. Máximo 100. Omitir
nextCursor al terminar. from inclusivo y to exclusivo; actor/detalle filtrados según autorización.
nextCursor al terminar. from inclusivo y to exclusivo, UTC Z. Propietario y ámbito obligatorios, sin bypass administrativo. Detail se proyecta por lista permitida (IDs de reglas/acciones, razones y códigos); no entrega evidencia ni resultados completos. Cache-Control no-store. Cursor representa posición, no autorización; no promete snapshot entre páginas concurrentes.
tags:
- Requests
parameters:
@ -828,7 +830,7 @@ paths:
summary: Simular evaluación pura
description: Sin HTTP ni efectos persistentes. requestData/actor/results son fixtures administrativos, nunca
comandos de negocio. Evaluar guardia/decisión/updates con resultados suministrados; rechazo de tipos y ambigüedad
como problem 422. NO_OP sólo permitido en simulación.
como problem 422. Body máximo 1 MiB; requestData/event.data/cada resultado máximo 256 KiB. results usa exactamente los resultKey externos de la transición. NO_OP sólo permitido en simulación, toma output de la definición sin fixture externo. Cache-Control no-store. Actor de fixture no sustituye JWT FLOW_EDITOR ni ámbito administrativo.
tags:
- Administration
parameters:
@ -1236,8 +1238,8 @@ paths:
summary: Conciliar una ejecución incierta
description: Sólo RECONCILIATION_REQUIRED, con acceso por scope. Evidencia referenciada y verificada por operador
autorizado; no admite elegir estado destino. RESUME registra resultados verificados y reencola PENDING;
CLOSE_WITHOUT_TRANSITION verifica ausencia/compensación de efectos y cierra FAILED conservando negocio.
Idempotencia durable por ejecución/clave y comando canónico; replay idéntico devuelve misma resolución.
CLOSE_WITHOUT_TRANSITION registra ausencia/compensación verificada por el operador y cierra FAILED conservando negocio. No consulta automáticamente al proveedor.
Idempotencia durable por ejecución/clave y comando canónico; replay exige el mismo sujeto operador y devuelve la misma resolución.
Auditoría obligatoria.
tags:
- Administration
@ -1266,6 +1268,8 @@ paths:
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'412':
$ref: '#/components/responses/PreconditionFailed'
'413':
$ref: '#/components/responses/PayloadTooLarge'
'422':

View File

@ -1,5 +1,53 @@
# Flujos pendientes de T14
# Flujos demostrativos T14
Entregar credito-v1.json, credito-v2-licencia.json y compra-v1.json completos y publicables.
La versión 2 incorpora licencia antes del buró utilizando capacidades existentes.
No presentar fragmentos como flujos funcionales.
| Archivo | Recorrido |
| --- | --- |
| credito-v1.json | Cliente → INE → estado de cuenta → buró → PASO_N/PASO_M/REVISION_MANUAL |
| credito-v2-licencia.json | Mismo recorrido; agrega licencia antes del buró |
| compra-v1.json | Cliente → confirmación → COMPRA_CONFIRMADA/COMPRA_CANCELADA |
Compra demuestra un segundo tipo; no cobra ni reserva inventario. Score >70 lleva
a PASO_N; >60 y <=70 a PASO_M; <=60 a REVISION_MANUAL. Score ausente/no numérico
es fallo técnico. INVALID documental conserva tarea y reasonCodes; cliente ausente
conserva CREADA.
Usar el [catálogo completo](../integrations/publication-catalog.json). Los perfiles
INE, ESTADO_CUENTA y LICENCIA_CONDUCIR de DOCUMENTO_VALIDA están fijados en versión 1.
Máximo documental: 5 MiB; estado de cuenta solo PDF; INE/licencia PDF, JPEG o PNG.
El API recibe documentId; verifica metadata del proveedor, no recibe archivos.
consentimientoId es obligatorio; la validez real debe verificarse con el proveedor.
## Prueba reproducible
Con JDK 21 y MIGRATION_TEST_DB_URL/USER/PASSWORD apuntando a una base de pruebas:
```powershell
./mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external '-Dtest=DemoFlowTest' '-Dit.test=DemoFlowsIT'
```
Inicia API con JWT/JWKS local, publica los JSON exactos, activa ambos tipos, crea
solicitudes y ejecuta eventos/worker/SQL. Cuatro proveedores HTTP loopback exclusivos
de test devuelven datos sintéticos. Crea y elimina esquemas/roles temporales.
Cubre coexistencia v1/v2, licencia inválida/válida, score 71/70/61/60, cliente ausente,
score mal formado y ambos finales de compra. No requiere Keycloak ni buró real.
## Recorrido manual en entorno de demo
1. Configurar FLOW_CATALOG_PATH con el catálogo de ejemplo, FLOW_ADMIN_ENABLED,
REQUEST_API_ENABLED y WORKER_ENABLED, JWT y ADMIN_BUSINESS_SCOPES según README.
Usar usuario DB runtime restringido y rutas de proveedores de demo confiables.
integration-routes.example.json contiene hosts .invalid, no servicios operativos.
2. Crear tipos con [seed-request-types.sql](seed-request-types.sql) como dueño del
esquema. Ajustar el esquema al configurado; no es una migración automática.
3. [demo-flows.http](../http/demo-flows.http) contiene publicación, activación y
recorridos. Copiar IDs/ETags de respuestas y proporcionar tokens de cada rol.
4. Crear crédito con v1, activar v2 y crear otro. Cada solicitud conserva su flowId;
después del estado de cuenta solo la nueva exige REGISTRAR_LICENCIA.
5. Para cada evento usar If-Match de la consulta actual. Esperar SUCCEEDED antes de
continuar: 202 solo significa aceptado. Reintento de transporte conserva clave;
nueva operación de negocio usa una clave nueva. DocumentId debe existir en el
proveedor documental de demo y pertenecer a la solicitud/ámbito correspondiente.
No invoca /simulate: recorre la API operativa contra mocks. No modifica Java de
producción, migraciones ni columnas. Proveedores reales pendientes de contrato y
verificación independiente. Docker permanece con un servicio y PostgreSQL externo.

View File

@ -0,0 +1,272 @@
{
"schemaVersion": 1,
"flowKey": "COMPRA",
"version": 1,
"initialState": "CREADA",
"initialDataSchema": {
"type": "object",
"properties": {
"client": {
"type": "string",
"minLength": 1
},
"orderId": {
"type": "string",
"minLength": 1
},
"amount": {
"type": "number",
"exclusiveMinimum": 0
}
},
"required": [
"client",
"orderId",
"amount"
],
"additionalProperties": false
},
"dataSchema": {
"type": "object",
"properties": {
"client": {
"type": "string",
"minLength": 1
},
"orderId": {
"type": "string",
"minLength": 1
},
"amount": {
"type": "number",
"exclusiveMinimum": 0
},
"cliente": {
"type": "object",
"properties": {
"found": {
"type": "boolean"
},
"customerId": {
"type": "string",
"minLength": 1
}
},
"required": [
"found"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"found": {
"const": true
}
}
},
"then": {
"required": [
"customerId"
]
}
}
]
},
"confirmada": {
"type": "boolean"
}
},
"required": [
"client",
"orderId",
"amount"
],
"additionalProperties": false
},
"states": [
{
"key": "CREADA",
"terminal": false
},
{
"key": "CONFIRMACION_PENDIENTE",
"terminal": false
},
{
"key": "COMPRA_CONFIRMADA",
"terminal": true
},
{
"key": "COMPRA_CANCELADA",
"terminal": true
}
],
"events": [
{
"key": "ASIGNAR_CLIENTE",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}
},
{
"key": "CONFIRMAR_COMPRA",
"inputSchema": {
"type": "object",
"properties": {
"confirmada": {
"type": "boolean"
}
},
"required": [
"confirmada"
],
"additionalProperties": false
}
}
],
"transitions": [
{
"id": "asignar-cliente",
"from": "CREADA",
"event": "ASIGNAR_CLIENTE",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "cliente",
"type": "INVOKE_INTEGRATION",
"integrationKey": "CLIENTES_CONSULTA",
"contractVersion": "1",
"inputs": {
"client": {
"path": "/request/data/client"
}
},
"resultKey": "cliente"
}
],
"decision": "CLIENTE_EXISTE",
"destinations": {
"CLIENTE_ENCONTRADO": "CONFIRMACION_PENDIENTE",
"CLIENTE_NO_ENCONTRADO": "CREADA"
},
"updates": [
{
"target": "/cliente",
"value": {
"path": "/results/cliente"
}
}
]
},
{
"id": "confirmar-compra",
"from": "CONFIRMACION_PENDIENTE",
"event": "CONFIRMAR_COMPRA",
"allowedRoles": [
"ANALISTA"
],
"actions": [],
"decision": "CONFIRMACION",
"destinations": {
"CONFIRMADA": "COMPRA_CONFIRMADA",
"CANCELADA": "COMPRA_CANCELADA"
},
"updates": [
{
"target": "/confirmada",
"value": {
"path": "/event/data/confirmada"
}
}
]
}
],
"decisions": [
{
"id": "CLIENTE_EXISTE",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "encontrado",
"when": {
"op": "EQ",
"left": {
"path": "/results/cliente/found"
},
"right": {
"literal": true
}
},
"output": "CLIENTE_ENCONTRADO"
}
],
"defaultOutput": "CLIENTE_NO_ENCONTRADO"
},
{
"id": "CONFIRMACION",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "confirmada",
"when": {
"op": "EQ",
"left": {
"path": "/event/data/confirmada"
},
"right": {
"literal": true
}
},
"output": "CONFIRMADA"
}
],
"defaultOutput": "CANCELADA"
}
],
"tasks": [
{
"state": "CREADA",
"event": "ASIGNAR_CLIENTE",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"ui": {
"component": "FORM",
"title": "Consultar cliente",
"fieldOrder": []
}
},
{
"state": "CONFIRMACION_PENDIENTE",
"event": "CONFIRMAR_COMPRA",
"inputSchema": {
"type": "object",
"properties": {
"confirmada": {
"type": "boolean"
}
},
"required": [
"confirmada"
],
"additionalProperties": false
},
"ui": {
"component": "FORM",
"title": "Confirmar o cancelar compra de demostración",
"fieldOrder": [
"confirmada"
]
}
}
]
}

View File

@ -0,0 +1,622 @@
{
"schemaVersion": 1,
"flowKey": "CREDITO",
"version": 1,
"initialState": "CREADA",
"initialDataSchema": {
"type": "object",
"properties": {
"client": {
"type": "string",
"minLength": 1
},
"consentimientoId": {
"type": "string",
"minLength": 1
}
},
"required": [
"client",
"consentimientoId"
],
"additionalProperties": false
},
"dataSchema": {
"type": "object",
"properties": {
"client": {
"type": "string",
"minLength": 1
},
"consentimientoId": {
"type": "string",
"minLength": 1
},
"cliente": {
"type": "object",
"properties": {
"found": {
"type": "boolean"
},
"customerId": {
"type": "string",
"minLength": 1
}
},
"required": [
"found"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"found": {
"const": true
}
}
},
"then": {
"required": [
"customerId"
]
}
}
]
},
"documentos": {
"type": "object",
"properties": {
"ine": {
"type": "string",
"format": "uuid"
},
"cuenta": {
"type": "string",
"format": "uuid"
}
},
"required": [],
"additionalProperties": false
},
"validaciones": {
"type": "object",
"properties": {
"ine": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"VALID",
"INVALID"
]
},
"reasonCodes": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"required": [
"status",
"reasonCodes"
],
"additionalProperties": false
},
"cuenta": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"VALID",
"INVALID"
]
},
"reasonCodes": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"required": [
"status",
"reasonCodes"
],
"additionalProperties": false
},
"buro": {
"type": "object",
"properties": {
"score": {
"type": "number"
},
"consultationId": {
"type": "string",
"minLength": 1
}
},
"required": [
"score",
"consultationId"
],
"additionalProperties": false
}
},
"required": [],
"additionalProperties": false
}
},
"required": [
"client",
"consentimientoId"
],
"additionalProperties": false
},
"states": [
{
"key": "CREADA",
"terminal": false
},
{
"key": "INE_PENDIENTE",
"terminal": false
},
{
"key": "ESTADO_CUENTA_PENDIENTE",
"terminal": false
},
{
"key": "BURO_PENDIENTE",
"terminal": false
},
{
"key": "PASO_N",
"terminal": true
},
{
"key": "PASO_M",
"terminal": true
},
{
"key": "REVISION_MANUAL",
"terminal": true
}
],
"events": [
{
"key": "ASIGNAR_CLIENTE",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}
},
{
"key": "REGISTRAR_INE",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
}
},
{
"key": "REGISTRAR_ESTADO_CUENTA",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
}
},
{
"key": "VALIDAR_BURO",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}
}
],
"transitions": [
{
"id": "asignar-cliente",
"from": "CREADA",
"event": "ASIGNAR_CLIENTE",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "cliente",
"type": "INVOKE_INTEGRATION",
"integrationKey": "CLIENTES_CONSULTA",
"contractVersion": "1",
"inputs": {
"client": {
"path": "/request/data/client"
}
},
"resultKey": "cliente"
}
],
"decision": "CLIENTE_EXISTE",
"destinations": {
"CLIENTE_ENCONTRADO": "INE_PENDIENTE",
"CLIENTE_NO_ENCONTRADO": "CREADA"
},
"updates": [
{
"target": "/cliente",
"value": {
"path": "/results/cliente"
}
}
]
},
{
"id": "documento-ine",
"from": "INE_PENDIENTE",
"event": "REGISTRAR_INE",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "registro",
"type": "REGISTER_DOCUMENT",
"documentType": "INE",
"inputs": {
"documentId": {
"path": "/event/data/documentId"
}
},
"resultKey": "registro"
},
{
"id": "validacion",
"type": "INVOKE_INTEGRATION",
"integrationKey": "DOCUMENTO_VALIDA",
"contractVersion": "1",
"profile": "INE",
"profileVersion": "1",
"inputs": {
"documentId": {
"path": "/results/registro/documentId"
}
},
"resultKey": "validacion"
}
],
"decision": "VALIDAR_INE",
"destinations": {
"DOCUMENTO_VALIDO": "ESTADO_CUENTA_PENDIENTE",
"DOCUMENTO_INVALIDO": "INE_PENDIENTE"
},
"updates": [
{
"target": "/documentos/ine",
"value": {
"path": "/results/registro/documentId"
}
},
{
"target": "/validaciones/ine",
"value": {
"path": "/results/validacion"
}
}
]
},
{
"id": "documento-cuenta",
"from": "ESTADO_CUENTA_PENDIENTE",
"event": "REGISTRAR_ESTADO_CUENTA",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "registro",
"type": "REGISTER_DOCUMENT",
"documentType": "ESTADO_CUENTA",
"inputs": {
"documentId": {
"path": "/event/data/documentId"
}
},
"resultKey": "registro"
},
{
"id": "validacion",
"type": "INVOKE_INTEGRATION",
"integrationKey": "DOCUMENTO_VALIDA",
"contractVersion": "1",
"profile": "ESTADO_CUENTA",
"profileVersion": "1",
"inputs": {
"documentId": {
"path": "/results/registro/documentId"
}
},
"resultKey": "validacion"
}
],
"decision": "VALIDAR_ESTADO_CUENTA",
"destinations": {
"DOCUMENTO_VALIDO": "BURO_PENDIENTE",
"DOCUMENTO_INVALIDO": "ESTADO_CUENTA_PENDIENTE"
},
"updates": [
{
"target": "/documentos/cuenta",
"value": {
"path": "/results/registro/documentId"
}
},
{
"target": "/validaciones/cuenta",
"value": {
"path": "/results/validacion"
}
}
]
},
{
"id": "consultar-buro",
"from": "BURO_PENDIENTE",
"event": "VALIDAR_BURO",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "buro",
"type": "INVOKE_INTEGRATION",
"integrationKey": "BURO_CONSULTA",
"contractVersion": "1",
"inputs": {
"client": {
"path": "/request/data/cliente/customerId"
},
"consentimientoId": {
"path": "/request/data/consentimientoId"
}
},
"resultKey": "buro"
}
],
"decision": "RUTA_SCORING",
"destinations": {
"N": "PASO_N",
"M": "PASO_M",
"MANUAL": "REVISION_MANUAL"
},
"updates": [
{
"target": "/validaciones/buro",
"value": {
"path": "/results/buro"
}
}
]
}
],
"decisions": [
{
"id": "CLIENTE_EXISTE",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "encontrado",
"when": {
"op": "EQ",
"left": {
"path": "/results/cliente/found"
},
"right": {
"literal": true
}
},
"output": "CLIENTE_ENCONTRADO"
}
],
"defaultOutput": "CLIENTE_NO_ENCONTRADO"
},
{
"id": "VALIDAR_INE",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "valido",
"when": {
"op": "EQ",
"left": {
"path": "/results/validacion/status"
},
"right": {
"literal": "VALID"
}
},
"output": "DOCUMENTO_VALIDO"
}
],
"defaultOutput": "DOCUMENTO_INVALIDO"
},
{
"id": "VALIDAR_ESTADO_CUENTA",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "valido",
"when": {
"op": "EQ",
"left": {
"path": "/results/validacion/status"
},
"right": {
"literal": "VALID"
}
},
"output": "DOCUMENTO_VALIDO"
}
],
"defaultOutput": "DOCUMENTO_INVALIDO"
},
{
"id": "RUTA_SCORING",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "alto",
"when": {
"op": "GT",
"left": {
"path": "/results/buro/score"
},
"right": {
"literal": 70
}
},
"output": "N"
},
{
"id": "medio",
"when": {
"op": "AND",
"args": [
{
"op": "GT",
"left": {
"path": "/results/buro/score"
},
"right": {
"literal": 60
}
},
{
"op": "LTE",
"left": {
"path": "/results/buro/score"
},
"right": {
"literal": 70
}
}
]
},
"output": "M"
}
],
"defaultOutput": "MANUAL"
}
],
"tasks": [
{
"state": "CREADA",
"event": "ASIGNAR_CLIENTE",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"ui": {
"component": "FORM",
"title": "Consultar cliente",
"fieldOrder": []
}
},
{
"state": "INE_PENDIENTE",
"event": "REGISTRAR_INE",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
},
"ui": {
"component": "DOCUMENT_UPLOAD",
"title": "Identificación INE",
"documentType": "INE",
"acceptedMimeTypes": [
"application/pdf",
"image/jpeg",
"image/png"
],
"maxBytes": 5242880
}
},
{
"state": "ESTADO_CUENTA_PENDIENTE",
"event": "REGISTRAR_ESTADO_CUENTA",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
},
"ui": {
"component": "DOCUMENT_UPLOAD",
"title": "Estado de cuenta",
"documentType": "ESTADO_CUENTA",
"acceptedMimeTypes": [
"application/pdf"
],
"maxBytes": 5242880
}
},
{
"state": "BURO_PENDIENTE",
"event": "VALIDAR_BURO",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"ui": {
"component": "FORM",
"title": "Consultar buró con consentimiento registrado",
"fieldOrder": []
}
}
]
}

View File

@ -0,0 +1,772 @@
{
"schemaVersion": 1,
"flowKey": "CREDITO",
"version": 2,
"initialState": "CREADA",
"initialDataSchema": {
"type": "object",
"properties": {
"client": {
"type": "string",
"minLength": 1
},
"consentimientoId": {
"type": "string",
"minLength": 1
}
},
"required": [
"client",
"consentimientoId"
],
"additionalProperties": false
},
"dataSchema": {
"type": "object",
"properties": {
"client": {
"type": "string",
"minLength": 1
},
"consentimientoId": {
"type": "string",
"minLength": 1
},
"cliente": {
"type": "object",
"properties": {
"found": {
"type": "boolean"
},
"customerId": {
"type": "string",
"minLength": 1
}
},
"required": [
"found"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"found": {
"const": true
}
}
},
"then": {
"required": [
"customerId"
]
}
}
]
},
"documentos": {
"type": "object",
"properties": {
"ine": {
"type": "string",
"format": "uuid"
},
"cuenta": {
"type": "string",
"format": "uuid"
},
"licencia": {
"type": "string",
"format": "uuid"
}
},
"required": [],
"additionalProperties": false
},
"validaciones": {
"type": "object",
"properties": {
"ine": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"VALID",
"INVALID"
]
},
"reasonCodes": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"required": [
"status",
"reasonCodes"
],
"additionalProperties": false
},
"cuenta": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"VALID",
"INVALID"
]
},
"reasonCodes": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"required": [
"status",
"reasonCodes"
],
"additionalProperties": false
},
"licencia": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"VALID",
"INVALID"
]
},
"reasonCodes": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"required": [
"status",
"reasonCodes"
],
"additionalProperties": false
},
"buro": {
"type": "object",
"properties": {
"score": {
"type": "number"
},
"consultationId": {
"type": "string",
"minLength": 1
}
},
"required": [
"score",
"consultationId"
],
"additionalProperties": false
}
},
"required": [],
"additionalProperties": false
}
},
"required": [
"client",
"consentimientoId"
],
"additionalProperties": false
},
"states": [
{
"key": "CREADA",
"terminal": false
},
{
"key": "INE_PENDIENTE",
"terminal": false
},
{
"key": "ESTADO_CUENTA_PENDIENTE",
"terminal": false
},
{
"key": "LICENCIA_PENDIENTE",
"terminal": false
},
{
"key": "BURO_PENDIENTE",
"terminal": false
},
{
"key": "PASO_N",
"terminal": true
},
{
"key": "PASO_M",
"terminal": true
},
{
"key": "REVISION_MANUAL",
"terminal": true
}
],
"events": [
{
"key": "ASIGNAR_CLIENTE",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}
},
{
"key": "REGISTRAR_INE",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
}
},
{
"key": "REGISTRAR_ESTADO_CUENTA",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
}
},
{
"key": "REGISTRAR_LICENCIA",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
}
},
{
"key": "VALIDAR_BURO",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}
}
],
"transitions": [
{
"id": "asignar-cliente",
"from": "CREADA",
"event": "ASIGNAR_CLIENTE",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "cliente",
"type": "INVOKE_INTEGRATION",
"integrationKey": "CLIENTES_CONSULTA",
"contractVersion": "1",
"inputs": {
"client": {
"path": "/request/data/client"
}
},
"resultKey": "cliente"
}
],
"decision": "CLIENTE_EXISTE",
"destinations": {
"CLIENTE_ENCONTRADO": "INE_PENDIENTE",
"CLIENTE_NO_ENCONTRADO": "CREADA"
},
"updates": [
{
"target": "/cliente",
"value": {
"path": "/results/cliente"
}
}
]
},
{
"id": "documento-ine",
"from": "INE_PENDIENTE",
"event": "REGISTRAR_INE",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "registro",
"type": "REGISTER_DOCUMENT",
"documentType": "INE",
"inputs": {
"documentId": {
"path": "/event/data/documentId"
}
},
"resultKey": "registro"
},
{
"id": "validacion",
"type": "INVOKE_INTEGRATION",
"integrationKey": "DOCUMENTO_VALIDA",
"contractVersion": "1",
"profile": "INE",
"profileVersion": "1",
"inputs": {
"documentId": {
"path": "/results/registro/documentId"
}
},
"resultKey": "validacion"
}
],
"decision": "VALIDAR_INE",
"destinations": {
"DOCUMENTO_VALIDO": "ESTADO_CUENTA_PENDIENTE",
"DOCUMENTO_INVALIDO": "INE_PENDIENTE"
},
"updates": [
{
"target": "/documentos/ine",
"value": {
"path": "/results/registro/documentId"
}
},
{
"target": "/validaciones/ine",
"value": {
"path": "/results/validacion"
}
}
]
},
{
"id": "documento-cuenta",
"from": "ESTADO_CUENTA_PENDIENTE",
"event": "REGISTRAR_ESTADO_CUENTA",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "registro",
"type": "REGISTER_DOCUMENT",
"documentType": "ESTADO_CUENTA",
"inputs": {
"documentId": {
"path": "/event/data/documentId"
}
},
"resultKey": "registro"
},
{
"id": "validacion",
"type": "INVOKE_INTEGRATION",
"integrationKey": "DOCUMENTO_VALIDA",
"contractVersion": "1",
"profile": "ESTADO_CUENTA",
"profileVersion": "1",
"inputs": {
"documentId": {
"path": "/results/registro/documentId"
}
},
"resultKey": "validacion"
}
],
"decision": "VALIDAR_ESTADO_CUENTA",
"destinations": {
"DOCUMENTO_VALIDO": "LICENCIA_PENDIENTE",
"DOCUMENTO_INVALIDO": "ESTADO_CUENTA_PENDIENTE"
},
"updates": [
{
"target": "/documentos/cuenta",
"value": {
"path": "/results/registro/documentId"
}
},
{
"target": "/validaciones/cuenta",
"value": {
"path": "/results/validacion"
}
}
]
},
{
"id": "documento-licencia",
"from": "LICENCIA_PENDIENTE",
"event": "REGISTRAR_LICENCIA",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "registro",
"type": "REGISTER_DOCUMENT",
"documentType": "LICENCIA_CONDUCIR",
"inputs": {
"documentId": {
"path": "/event/data/documentId"
}
},
"resultKey": "registro"
},
{
"id": "validacion",
"type": "INVOKE_INTEGRATION",
"integrationKey": "DOCUMENTO_VALIDA",
"contractVersion": "1",
"profile": "LICENCIA_CONDUCIR",
"profileVersion": "1",
"inputs": {
"documentId": {
"path": "/results/registro/documentId"
}
},
"resultKey": "validacion"
}
],
"decision": "VALIDAR_LICENCIA_CONDUCIR",
"destinations": {
"DOCUMENTO_VALIDO": "BURO_PENDIENTE",
"DOCUMENTO_INVALIDO": "LICENCIA_PENDIENTE"
},
"updates": [
{
"target": "/documentos/licencia",
"value": {
"path": "/results/registro/documentId"
}
},
{
"target": "/validaciones/licencia",
"value": {
"path": "/results/validacion"
}
}
]
},
{
"id": "consultar-buro",
"from": "BURO_PENDIENTE",
"event": "VALIDAR_BURO",
"allowedRoles": [
"ANALISTA"
],
"actions": [
{
"id": "buro",
"type": "INVOKE_INTEGRATION",
"integrationKey": "BURO_CONSULTA",
"contractVersion": "1",
"inputs": {
"client": {
"path": "/request/data/cliente/customerId"
},
"consentimientoId": {
"path": "/request/data/consentimientoId"
}
},
"resultKey": "buro"
}
],
"decision": "RUTA_SCORING",
"destinations": {
"N": "PASO_N",
"M": "PASO_M",
"MANUAL": "REVISION_MANUAL"
},
"updates": [
{
"target": "/validaciones/buro",
"value": {
"path": "/results/buro"
}
}
]
}
],
"decisions": [
{
"id": "CLIENTE_EXISTE",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "encontrado",
"when": {
"op": "EQ",
"left": {
"path": "/results/cliente/found"
},
"right": {
"literal": true
}
},
"output": "CLIENTE_ENCONTRADO"
}
],
"defaultOutput": "CLIENTE_NO_ENCONTRADO"
},
{
"id": "VALIDAR_INE",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "valido",
"when": {
"op": "EQ",
"left": {
"path": "/results/validacion/status"
},
"right": {
"literal": "VALID"
}
},
"output": "DOCUMENTO_VALIDO"
}
],
"defaultOutput": "DOCUMENTO_INVALIDO"
},
{
"id": "VALIDAR_ESTADO_CUENTA",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "valido",
"when": {
"op": "EQ",
"left": {
"path": "/results/validacion/status"
},
"right": {
"literal": "VALID"
}
},
"output": "DOCUMENTO_VALIDO"
}
],
"defaultOutput": "DOCUMENTO_INVALIDO"
},
{
"id": "VALIDAR_LICENCIA_CONDUCIR",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "valido",
"when": {
"op": "EQ",
"left": {
"path": "/results/validacion/status"
},
"right": {
"literal": "VALID"
}
},
"output": "DOCUMENTO_VALIDO"
}
],
"defaultOutput": "DOCUMENTO_INVALIDO"
},
{
"id": "RUTA_SCORING",
"hitPolicy": "UNIQUE",
"rules": [
{
"id": "alto",
"when": {
"op": "GT",
"left": {
"path": "/results/buro/score"
},
"right": {
"literal": 70
}
},
"output": "N"
},
{
"id": "medio",
"when": {
"op": "AND",
"args": [
{
"op": "GT",
"left": {
"path": "/results/buro/score"
},
"right": {
"literal": 60
}
},
{
"op": "LTE",
"left": {
"path": "/results/buro/score"
},
"right": {
"literal": 70
}
}
]
},
"output": "M"
}
],
"defaultOutput": "MANUAL"
}
],
"tasks": [
{
"state": "CREADA",
"event": "ASIGNAR_CLIENTE",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"ui": {
"component": "FORM",
"title": "Consultar cliente",
"fieldOrder": []
}
},
{
"state": "INE_PENDIENTE",
"event": "REGISTRAR_INE",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
},
"ui": {
"component": "DOCUMENT_UPLOAD",
"title": "Identificación INE",
"documentType": "INE",
"acceptedMimeTypes": [
"application/pdf",
"image/jpeg",
"image/png"
],
"maxBytes": 5242880
}
},
{
"state": "ESTADO_CUENTA_PENDIENTE",
"event": "REGISTRAR_ESTADO_CUENTA",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
},
"ui": {
"component": "DOCUMENT_UPLOAD",
"title": "Estado de cuenta",
"documentType": "ESTADO_CUENTA",
"acceptedMimeTypes": [
"application/pdf"
],
"maxBytes": 5242880
}
},
{
"state": "LICENCIA_PENDIENTE",
"event": "REGISTRAR_LICENCIA",
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
},
"ui": {
"component": "DOCUMENT_UPLOAD",
"title": "Licencia de conducir",
"documentType": "LICENCIA_CONDUCIR",
"acceptedMimeTypes": [
"application/pdf",
"image/jpeg",
"image/png"
],
"maxBytes": 5242880
}
},
{
"state": "BURO_PENDIENTE",
"event": "VALIDAR_BURO",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"ui": {
"component": "FORM",
"title": "Consultar buró con consentimiento registrado",
"fieldOrder": []
}
}
]
}

View File

@ -0,0 +1,4 @@
-- Solo entorno de demo, como dueño del esquema; ajustar orchestrator al esquema configurado.
INSERT INTO orchestrator.request_type(type_key)
VALUES ('CREDITO'), ('COMPRA')
ON CONFLICT (type_key) DO NOTHING;

View File

@ -0,0 +1,27 @@
@baseUrl = http://localhost:8787
@requestId = 00000000-0000-0000-0000-000000000001
@jwt = REPLACE_WITH_OWNER_JWT
@revision = 0
@event = SUBMIT
@documentId = 00000000-0000-0000-0000-000000000002
### Requiere REQUEST_API_ENABLED=true. Usa la versión fijada a esta solicitud.
GET {{baseUrl}}/api/v1/requests/{{requestId}}/available-events
Authorization: Bearer {{jwt}}
### Solo si no hay blockedByExecutionId. Sustituir evento/datos según inputSchema.
### documentId debe existir y pertenecer a esta solicitud en el servicio documental.
POST {{baseUrl}}/api/v1/requests/{{requestId}}/events
Authorization: Bearer {{jwt}}
Idempotency-Key: document-submission-demo-1
If-Match: "{{revision}}"
Content-Type: application/json
{
"event": "{{event}}",
"data": {"documentId": "{{documentId}}"}
}
### Tras completar la ejecución, consultar datos/razones de negocio y las tareas otra vez.
GET {{baseUrl}}/api/v1/requests/{{requestId}}
Authorization: Bearer {{jwt}}

View File

@ -0,0 +1,176 @@
# Entorno de demo exclusivamente. Sustituir tokens e IDs con respuestas reales.
@base = http://localhost:8080
@editor = REEMPLAZAR
@publisher = REEMPLAZAR
@manager = REEMPLAZAR
@analista = REEMPLAZAR
@creditoV1 = REEMPLAZAR_UUID
@creditoV2 = REEMPLAZAR_UUID
@compraV1 = REEMPLAZAR_UUID
@requestId = REEMPLAZAR_UUID
@executionId = REEMPLAZAR_UUID
@documentId = REEMPLAZAR_UUID
@revision = 0
@typeRevision = 0
@eventKey = REEMPLAZAR_CLAVE_UNICA
### Crear credito-v1; copiar id a creditoV1
POST {{base}}/api/v1/admin/flows
Authorization: Bearer {{editor}}
Content-Type: application/json
< ../flows/credito-v1.json
### Validar credito-v1
POST {{base}}/api/v1/admin/flows/{{creditoV1}}/validate
Authorization: Bearer {{editor}}
### Publicar credito-v1; revisión inicial 0, publicado 1
POST {{base}}/api/v1/admin/flows/{{creditoV1}}/publish
Authorization: Bearer {{publisher}}
If-Match: "0"
### Crear credito-v2-licencia; copiar id a creditoV2
POST {{base}}/api/v1/admin/flows
Authorization: Bearer {{editor}}
Content-Type: application/json
< ../flows/credito-v2-licencia.json
### Validar credito-v2-licencia
POST {{base}}/api/v1/admin/flows/{{creditoV2}}/validate
Authorization: Bearer {{editor}}
### Publicar credito-v2-licencia; revisión inicial 0, publicado 1
POST {{base}}/api/v1/admin/flows/{{creditoV2}}/publish
Authorization: Bearer {{publisher}}
If-Match: "0"
### Crear compra-v1; copiar id a compraV1
POST {{base}}/api/v1/admin/flows
Authorization: Bearer {{editor}}
Content-Type: application/json
< ../flows/compra-v1.json
### Validar compra-v1
POST {{base}}/api/v1/admin/flows/{{compraV1}}/validate
Authorization: Bearer {{editor}}
### Publicar compra-v1; revisión inicial 0, publicado 1
POST {{base}}/api/v1/admin/flows/{{compraV1}}/publish
Authorization: Bearer {{publisher}}
If-Match: "0"
### Activar creditoV1. Para v2: crear primero una solicitud v1; usar revisión actual del tipo.
PUT {{base}}/api/v1/admin/request-types/CREDITO/active-flow
Authorization: Bearer {{manager}}
Content-Type: application/json
If-Match: "{{typeRevision}}"
{"flowId":"{{creditoV1}}"}
### Activar compraV1. Para v2: crear primero una solicitud v1; usar revisión actual del tipo.
PUT {{base}}/api/v1/admin/request-types/COMPRA/active-flow
Authorization: Bearer {{manager}}
Content-Type: application/json
If-Match: "{{typeRevision}}"
{"flowId":"{{compraV1}}"}
### Activar creditoV2. Para v2: crear primero una solicitud v1; usar revisión actual del tipo.
PUT {{base}}/api/v1/admin/request-types/CREDITO/active-flow
Authorization: Bearer {{manager}}
Content-Type: application/json
If-Match: "{{typeRevision}}"
{"flowId":"{{creditoV2}}"}
### Crear crédito (ejecutar antes y después de activar v2, con claves distintas)
POST {{base}}/api/v1/requests
Authorization: Bearer {{analista}}
Content-Type: application/json
Idempotency-Key: {{eventKey}}
{"typeKey":"CREDITO","data":{"client":"synthetic-client","consentimientoId":"synthetic-consent"}}
### Crear compra
POST {{base}}/api/v1/requests
Authorization: Bearer {{analista}}
Content-Type: application/json
Idempotency-Key: {{eventKey}}
{"typeKey":"COMPRA","data":{"client":"synthetic-client","orderId":"synthetic-order","amount":100}}
### Consultar solicitud: copiar revisión a revision, comprobar flow.id y currentState
GET {{base}}/api/v1/requests/{{requestId}}
Authorization: Bearer {{analista}}
### Consultar tarea disponible y restricciones documentales
GET {{base}}/api/v1/requests/{{requestId}}/available-events
Authorization: Bearer {{analista}}
### Consultar ejecución hasta SUCCEEDED; FAILED/RETRY_WAIT requieren atender causa
GET {{base}}/api/v1/executions/{{executionId}}
Authorization: Bearer {{analista}}
### ASIGNAR_CLIENTE: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra
POST {{base}}/api/v1/requests/{{requestId}}/events
Authorization: Bearer {{analista}}
Content-Type: application/json
If-Match: "{{revision}}"
Idempotency-Key: {{eventKey}}
{"event":"ASIGNAR_CLIENTE","data":{}}
### REGISTRAR_INE: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra
POST {{base}}/api/v1/requests/{{requestId}}/events
Authorization: Bearer {{analista}}
Content-Type: application/json
If-Match: "{{revision}}"
Idempotency-Key: {{eventKey}}
{"event":"REGISTRAR_INE","data":{"documentId":"{{documentId}}"}}
### REGISTRAR_ESTADO_CUENTA: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra
POST {{base}}/api/v1/requests/{{requestId}}/events
Authorization: Bearer {{analista}}
Content-Type: application/json
If-Match: "{{revision}}"
Idempotency-Key: {{eventKey}}
{"event":"REGISTRAR_ESTADO_CUENTA","data":{"documentId":"{{documentId}}"}}
### REGISTRAR_LICENCIA: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra
POST {{base}}/api/v1/requests/{{requestId}}/events
Authorization: Bearer {{analista}}
Content-Type: application/json
If-Match: "{{revision}}"
Idempotency-Key: {{eventKey}}
{"event":"REGISTRAR_LICENCIA","data":{"documentId":"{{documentId}}"}}
### VALIDAR_BURO: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra
POST {{base}}/api/v1/requests/{{requestId}}/events
Authorization: Bearer {{analista}}
Content-Type: application/json
If-Match: "{{revision}}"
Idempotency-Key: {{eventKey}}
{"event":"VALIDAR_BURO","data":{}}
### CONFIRMAR_COMPRA: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra
POST {{base}}/api/v1/requests/{{requestId}}/events
Authorization: Bearer {{analista}}
Content-Type: application/json
If-Match: "{{revision}}"
Idempotency-Key: {{eventKey}}
{"event":"CONFIRMAR_COMPRA","data":{"confirmada":true}}
# Probar confirmada:false en otra compra para COMPRA_CANCELADA.
# Configurar proveedor mock con score 71/70/61/60 para N/M/M/REVISION_MANUAL.
# INVALID + reasonCodes:["EXPIRED"] conserva LICENCIA_PENDIENTE; nuevo evento VALID avanza.
# Cada ensayo de score usa otra solicitud y documentos propios. No enviar score desde el cliente.
# DemoFlowsIT automatiza estas variantes sin editar rutas ni contactar proveedores reales.

18
examples/http/events.http Normal file
View File

@ -0,0 +1,18 @@
### Obtener revision desde GET /requests/{id}; conservarla al reintentar este comando.
### Variables: baseUrl, token, requestId, eventKey, requestRevision.
POST {{baseUrl}}/api/v1/requests/{{requestId}}/events
Authorization: Bearer {{token}}
Idempotency-Key: demo-event-001
If-Match: "{{requestRevision}}"
Content-Type: application/json
{"event":"{{eventKey}}","data":{}}
### Ajustar data al inputSchema del evento. 202 confirma aceptación, no ejecución.
### T10 procesa cuando WORKER_ENABLED=true; configurar adapters T11 antes de flujos externos.
GET {{baseUrl}}/api/v1/requests/{{requestId}}
Authorization: Bearer {{token}}
### T10: consultar ejecucion aceptada (requiere REQUEST_API_ENABLED=true).
GET {{baseUrl}}/api/v1/executions/{{executionId}}
Authorization: Bearer {{token}}

View File

@ -0,0 +1,48 @@
@base = http://localhost:8080
@analista = REEMPLAZAR_TOKEN_PROPIETARIO
@operator = REEMPLAZAR_TOKEN_EXECUTION_OPERATOR
@requestId = REEMPLAZAR_UUID
@executionId = REEMPLAZAR_UUID
@cursor = REEMPLAZAR_NEXT_CURSOR
### Página inicial: copiar nextCursor sin modificarlo
GET {{base}}/api/v1/requests/{{requestId}}/history?limit=50
Authorization: Bearer {{analista}}
### Página siguiente, con los mismos filtros
GET {{base}}/api/v1/requests/{{requestId}}/history?limit=50&cursor={{cursor}}
Authorization: Bearer {{analista}}
### Filtros; from inclusivo, to exclusivo. Cursor nuevo para estos filtros.
GET {{base}}/api/v1/requests/{{requestId}}/history?kind=TRANSITION_COMPLETED&executionId={{executionId}}&from=2026-01-01T00:00:00Z&to=2027-01-01T00:00:00Z
Authorization: Bearer {{analista}}
### Salud pública, sin detalles
GET {{base}}/actuator/health/liveness
### Readiness incluye PostgreSQL
GET {{base}}/actuator/health/readiness
### Requiere rol operador y ámbito incluido en ADMIN_BUSINESS_SCOPES
GET {{base}}/actuator/metrics
Authorization: Bearer {{operator}}
### Estados técnicos (no sumar réplicas que consultan la misma DB)
GET {{base}}/actuator/metrics/orchestrator.executions?tag=state:PENDING
Authorization: Bearer {{operator}}
### Antigüedad del pendiente más viejo, segundos
GET {{base}}/actuator/metrics/orchestrator.pending.oldest.seconds
Authorization: Bearer {{operator}}
### Estado de la muestra SQL; 0 indica que los gauges de cola son desconocidos
GET {{base}}/actuator/metrics/orchestrator.queue.sample.available
Authorization: Bearer {{operator}}
### Comandos HTTP (incluye replays); contador local al proceso
GET {{base}}/actuator/metrics/orchestrator.commands?tag=command:event&tag=result:accepted
Authorization: Bearer {{operator}}
### Timer disponible tras invocar una integración; solo claves configuradas
GET {{base}}/actuator/metrics/orchestrator.integration.duration?tag=integration:BURO_CONSULTA
Authorization: Bearer {{operator}}

View File

@ -0,0 +1,39 @@
@baseUrl = http://localhost:8787
@executionId = 00000000-0000-0000-0000-000000000001
@operatorJwt = REPLACE_WITH_EXECUTION_OPERATOR_JWT
### Solo después de verificar el efecto y el resultado con el proveedor.
POST {{baseUrl}}/api/v1/admin/executions/{{executionId}}/resolve
Authorization: Bearer {{operatorJwt}}
Idempotency-Key: case-demo-resume-1
Content-Type: application/json
{
"mode": "RESUME",
"evidence": {
"reference": "CASE-DEMO-001",
"reason": "Ejemplo sintético: sustituir por evidencia verificada del caso real."
},
"verifiedActions": [
{
"actionId": "lookup",
"result": {"score": 70},
"providerReference": "PROVIDER-VERIFIED-REFERENCE"
}
]
}
### Alternativa excluyente: cerrar únicamente tras verificar ausencia/compensación.
POST {{baseUrl}}/api/v1/admin/executions/{{executionId}}/resolve
Authorization: Bearer {{operatorJwt}}
Idempotency-Key: case-demo-close-1
Content-Type: application/json
{
"mode": "CLOSE_WITHOUT_TRANSITION",
"evidence": {
"reference": "CASE-DEMO-002",
"reason": "Ejemplo sintético: sustituir por evidencia de compensación verificada."
},
"effectStatus": "COMPENSATED_VERIFIED"
}

View File

@ -0,0 +1,13 @@
### Requiere tipo ya activado, REQUEST_API_ENABLED=true y JWT válido con business_scope.
### Variables del cliente HTTP: baseUrl, token, typeKey, requestId.
POST {{baseUrl}}/api/v1/requests
Authorization: Bearer {{token}}
Idempotency-Key: demo-create-001
Content-Type: application/json
{"typeKey":"{{typeKey}}","data":{}}
### Repetir POST con la misma clave/cuerpo retorna la respuesta original.
### Ajustar data al initialDataSchema/dataSchema del flujo publicado.
GET {{baseUrl}}/api/v1/requests/{{requestId}}
Authorization: Bearer {{token}}

View File

@ -0,0 +1,24 @@
@base = http://localhost:8080
@editor = REEMPLAZAR_TOKEN_FLOW_EDITOR
@flowId = REEMPLAZAR_UUID_CREDITO_V1
### Simulación pura de una transición, usando fixtures sintéticos
POST {{base}}/api/v1/admin/flows/{{flowId}}/simulate
Authorization: Bearer {{editor}}
Content-Type: application/json
{
"state": "BURO_PENDIENTE",
"requestData": {
"client": "synthetic-client",
"consentimientoId": "synthetic-consent",
"cliente": {"found": true, "customerId": "synthetic-customer"}
},
"event": {"event": "VALIDAR_BURO", "data": {}},
"actor": {"subject": "synthetic-analyst", "businessScope": "demo", "roles": ["ANALISTA"]},
"results": {"buro": {"score": 71, "consultationId": "synthetic-consultation"}}
}
# Cambiar score a 70, 61 y 60: PASO_M, PASO_M y REVISION_MANUAL.
# score string o ausente es un error de contrato, sin ruta por defecto.
# El actor de fixture no sustituye el JWT del editor. No invoca buró real.

View File

@ -0,0 +1,82 @@
{
"routes": [
{
"integrationKey": "CLIENTES_CONSULTA",
"contractVersion": "1",
"profile": "",
"profileVersion": "",
"url": "https://provider.example.invalid/clientes_consulta",
"tokenEnvironment": "PROVIDER_TOKEN",
"connectMs": 1000,
"timeoutMs": 5000,
"maxBytes": 262144,
"outputMappings": {},
"notFoundOutput": null
},
{
"integrationKey": "DOCUMENTO_VALIDA",
"contractVersion": "1",
"profile": "LICENCIA_CONDUCIR",
"profileVersion": "1",
"url": "https://provider.example.invalid/documento_valida",
"tokenEnvironment": "PROVIDER_TOKEN",
"connectMs": 1000,
"timeoutMs": 5000,
"maxBytes": 262144,
"outputMappings": {},
"notFoundOutput": null
},
{
"integrationKey": "BURO_CONSULTA",
"contractVersion": "1",
"profile": "",
"profileVersion": "",
"url": "https://provider.example.invalid/buro_consulta",
"tokenEnvironment": "PROVIDER_TOKEN",
"connectMs": 1000,
"timeoutMs": 5000,
"maxBytes": 262144,
"outputMappings": {},
"notFoundOutput": null
},
{
"integrationKey": "DOCUMENTS_METADATA",
"contractVersion": "1",
"profile": "",
"profileVersion": "",
"url": "https://provider.example.invalid/documents_metadata",
"tokenEnvironment": "PROVIDER_TOKEN",
"connectMs": 1000,
"timeoutMs": 5000,
"maxBytes": 262144,
"outputMappings": {},
"notFoundOutput": null
},
{
"integrationKey": "DOCUMENTO_VALIDA",
"contractVersion": "1",
"profile": "INE",
"profileVersion": "1",
"url": "https://provider.example.invalid/documento_valida",
"tokenEnvironment": "PROVIDER_TOKEN",
"connectMs": 1000,
"timeoutMs": 5000,
"maxBytes": 262144,
"outputMappings": {},
"notFoundOutput": null
},
{
"integrationKey": "DOCUMENTO_VALIDA",
"contractVersion": "1",
"profile": "ESTADO_CUENTA",
"profileVersion": "1",
"url": "https://provider.example.invalid/documento_valida",
"tokenEnvironment": "PROVIDER_TOKEN",
"connectMs": 1000,
"timeoutMs": 5000,
"maxBytes": 262144,
"outputMappings": {},
"notFoundOutput": null
}
]
}

View File

@ -0,0 +1,361 @@
{
"roles": [
"ANALISTA"
],
"handlers": {
"INVOKE_INTEGRATION": {
"effect": "READ_ONLY",
"configurationSchema": {},
"inputSchema": {},
"outputSchema": {}
},
"REGISTER_DOCUMENT": {
"effect": "READ_ONLY",
"configurationSchema": {},
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
},
"requestId": {
"type": "string",
"format": "uuid"
},
"businessScope": {
"type": "string",
"minLength": 1
},
"documentType": {
"type": "string",
"minLength": 1
},
"storageDocumentId": {
"type": "string",
"minLength": 1
},
"mimeType": {
"type": "string",
"minLength": 1
},
"sizeBytes": {
"type": "integer",
"minimum": 0
},
"checksum": {
"type": "string",
"pattern": "^[a-fA-F0-9]{64}$"
}
},
"required": [
"documentId",
"requestId",
"businessScope",
"documentType",
"storageDocumentId",
"mimeType",
"sizeBytes",
"checksum"
],
"additionalProperties": false
}
}
},
"integrations": {
"CLIENTES_CONSULTA": {
"defaultVersion": "1",
"contracts": {
"1": {
"inputSchema": {
"type": "object",
"properties": {
"client": {
"type": "string",
"minLength": 1
}
},
"required": [
"client"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"found": {
"type": "boolean"
},
"customerId": {
"type": "string",
"minLength": 1
}
},
"required": [
"found"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"found": {
"const": true
}
}
},
"then": {
"required": [
"customerId"
]
}
}
]
},
"effect": "READ_ONLY",
"profiles": {},
"defaultProfiles": {}
}
}
},
"DOCUMENTO_VALIDA": {
"defaultVersion": "1",
"contracts": {
"1": {
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
}
},
"required": [
"documentId"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"VALID",
"INVALID"
]
},
"reasonCodes": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"required": [
"status",
"reasonCodes"
],
"additionalProperties": false
},
"effect": "READ_ONLY",
"profiles": {
"LICENCIA_CONDUCIR": [
"1"
],
"INE": [
"1"
],
"ESTADO_CUENTA": [
"1"
]
},
"defaultProfiles": {
"LICENCIA_CONDUCIR": "1",
"INE": "1",
"ESTADO_CUENTA": "1"
}
}
}
},
"BURO_CONSULTA": {
"defaultVersion": "1",
"contracts": {
"1": {
"inputSchema": {
"type": "object",
"properties": {
"client": {
"type": "string",
"minLength": 1
},
"consentimientoId": {
"type": "string",
"minLength": 1
}
},
"required": [
"client",
"consentimientoId"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"score": {
"type": "number"
},
"consultationId": {
"type": "string",
"minLength": 1
}
},
"required": [
"score",
"consultationId"
],
"additionalProperties": false
},
"effect": "READ_ONLY",
"profiles": {},
"defaultProfiles": {}
}
}
},
"DOCUMENTS_METADATA": {
"defaultVersion": "1",
"contracts": {
"1": {
"inputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
},
"requestId": {
"type": "string",
"format": "uuid"
},
"businessScope": {
"type": "string",
"minLength": 1
}
},
"required": [
"documentId",
"requestId",
"businessScope"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"documentId": {
"type": "string",
"format": "uuid"
},
"requestId": {
"type": "string",
"format": "uuid"
},
"businessScope": {
"type": "string",
"minLength": 1
},
"documentType": {
"type": "string",
"minLength": 1
},
"storageDocumentId": {
"type": "string",
"minLength": 1
},
"mimeType": {
"type": "string",
"minLength": 1
},
"sizeBytes": {
"type": "integer",
"minimum": 0
},
"checksum": {
"type": "string",
"pattern": "^[a-fA-F0-9]{64}$"
},
"scanStatus": {
"type": "string",
"enum": [
"CLEAN",
"PENDING",
"INFECTED"
]
}
},
"required": [
"documentId",
"requestId",
"businessScope",
"documentType",
"storageDocumentId",
"mimeType",
"sizeBytes",
"checksum",
"scanStatus"
],
"additionalProperties": false
},
"effect": "READ_ONLY",
"profiles": {},
"defaultProfiles": {}
}
}
}
},
"documents": {
"DOCUMENT": {
"mimeTypes": [
"application/pdf"
],
"maxBytes": 1000
},
"LICENCIA_CONDUCIR": {
"mimeTypes": [
"application/pdf",
"image/jpeg",
"image/png"
],
"maxBytes": 5242880
},
"INE": {
"mimeTypes": [
"application/pdf",
"image/jpeg",
"image/png"
],
"maxBytes": 5242880
},
"ESTADO_CUENTA": {
"mimeTypes": [
"application/pdf"
],
"maxBytes": 5242880
}
}
}

18
pom.xml
View File

@ -30,10 +30,15 @@
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.networknt</groupId><artifactId>json-schema-validator</artifactId><version>1.5.9</version><scope>test</scope></dependency>
<dependency><groupId>com.networknt</groupId><artifactId>json-schema-validator</artifactId><version>1.5.9</version></dependency>
<dependency><groupId>io.swagger.parser.v3</groupId><artifactId>swagger-parser-v3</artifactId><version>2.1.35</version><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-postgresql</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<resources>
<resource><directory>src/main/resources</directory></resource>
<resource><directory>schemas</directory><targetPath>schemas</targetPath><includes><include>*.schema.json</include></includes></resource>
</resources>
<plugins>
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin>
<plugin>
@ -47,14 +52,25 @@
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version>
<configuration><failIfNoTests>true</failIfNoTests></configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-failsafe-plugin</artifactId><version>3.5.4</version>
<configuration><failIfNoTests>true</failIfNoTests></configuration>
<executions><execution><goals><goal>integration-test</goal><goal>verify</goal></goals></execution></executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>migration-tests-external</id>
<build><plugins><plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-failsafe-plugin</artifactId>
<configuration><systemPropertyVariables>
<migration.test.external>true</migration.test.external>
</systemPropertyVariables></configuration>
</plugin></plugins></build>
</profile>
<profile>
<id>postgres96-check</id>
<build><plugins><plugin>

View File

@ -25,7 +25,7 @@ Condiciones:
- AND/OR: op y args con al menos dos condiciones ordenadas.
- NOT: op y arg con una condición.
Tipos, BigDecimal, cortocircuito, null frente a ausencia, IN homogéneo, solapamientos y UNIQUE ambiguo se evaluarán en T05. UNIQUE requiere defaultOutput; FIRST sólo se habilita seleccionándolo explícitamente como hitPolicy.
T05 implementa tipos, BigDecimal, cortocircuito, null frente a ausencia, IN homogéneo, análisis de intervalos simples y UNIQUE ambiguo. Ver [evaluador y límites](../specs/DECISION_EVALUATOR.md); el parser/publicador completo corresponde a T06. UNIQUE requiere defaultOutput; FIRST sólo se habilita seleccionándolo explícitamente como hitPolicy.
Transiciones: id/from/event/allowedRoles/actions. Una ruta fija usa to + outcome; una ruta calculada usa decision + destinations y el output seleccionado es el outcome de negocio. No pueden mezclarse. Actions están ordenadas y pueden estar vacías para una captura pura. Guard es opcional y se evalúa antes de aceptar el evento. Updates opcionales se aplican tras las acciones/decisión.

View File

@ -0,0 +1,35 @@
import tempfile
from pathlib import Path
import unittest
from verify_acceptance import inspect
class ReportGateTest(unittest.TestCase):
def test_missing_reports_cannot_pass(self):
with tempfile.TemporaryDirectory() as tmp:
result = inspect(Path(tmp), {f"A{i:02}": ["Suite#case"] for i in range(1, 21)})
self.assertFalse(result["passed"])
def test_passed_skipped_failed_and_external_reports(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for folder in ("surefire-reports", "failsafe-reports"):
(root / folder).mkdir()
(root / folder / "TEST-Suite.xml").write_text('<testsuite name="Suite"><testcase classname="example.Suite" name="case(int)[1]"/></testsuite>')
manifest = {f"A{i:02}": ["Suite#case"] for i in range(1, 21)}
self.assertTrue(inspect(root, manifest)["passed"])
extra = root / "surefire-reports/TEST-Extra.xml"
extra.write_text('<testsuite name="Extra"><testcase classname="example.Extra" name="unmapped"><skipped/></testcase></testsuite>')
self.assertFalse(inspect(root, manifest)["passed"], "Even an unmapped skipped test must fail the gate")
extra.unlink()
for status in ("failure", "error", "skipped"):
(root / "failsafe-reports/TEST-Suite.xml").write_text(f'<testsuite name="Suite"><testcase classname="example.Suite" name="case(int)[1]"><{status}/></testcase></testsuite>')
self.assertFalse(inspect(root, manifest)["passed"])
(root / "failsafe-reports/TEST-Suite.xml").write_text('<testsuite name="Suite"><properties><property name="migration.test.external" value="true"/></properties><testcase classname="example.Suite" name="case(int)[1]"/></testsuite>')
self.assertEqual(["external-postgresql"], inspect(root, manifest)["environment"])
manifest["A03"] = ["Suite#missing"]
self.assertFalse(inspect(root, manifest)["passed"])
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,77 @@
"""Check actual Maven reports against A01-A20; no third-party packages or credentials."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import sys
import xml.etree.ElementTree as ET
def inspect(root, manifest):
failures, found, totals, environments = [], {}, {"unit": 0, "integration": 0}, set()
expected = {f"A{i:02}" for i in range(1, 21)}
if set(manifest) != expected:
failures.append("Manifest must contain exactly A01-A20")
for group, folder in (("unit", "surefire-reports"), ("integration", "failsafe-reports")):
paths = sorted((root / folder).glob("TEST-*.xml"))
if not paths:
failures.append(f"Missing {folder}")
for path in paths:
suite = ET.parse(path).getroot()
for flag in ("failures", "errors", "skipped"):
if int(suite.get(flag, "0")):
failures.append(f"{suite.get('name')}: {flag}={suite.get(flag)}")
tests = suite.findall("testcase")
totals[group] += len(tests)
if group == "integration":
external = any(p.get("name") == "migration.test.external" and p.get("value") == "true"
for p in suite.findall("./properties/property"))
environments.add("external-postgresql" if external else "testcontainers")
for test in tests:
method = re.split(r"[\[(]", test.get("name", ""), maxsplit=1)[0]
key = test.get("classname", "").rsplit(".", 1)[-1] + "#" + method
ok = not any(test.find(tag) is not None for tag in ("failure", "error", "skipped"))
if not ok:
failures.append(f"Unsuccessful testcase: {key}")
found.setdefault(key, []).append(ok)
cases = {}
for case, references in sorted(manifest.items()):
if not references:
failures.append(f"{case}: no test references")
evidence = {}
for reference in references:
runs = found.get(reference, [])
evidence[reference] = {"executed": len(runs), "passed": sum(runs)}
if not runs or not all(runs):
failures.append(f"{case}: missing/failed/skipped {reference}")
cases[case] = evidence
if len(environments) != 1:
failures.append("Missing or mixed integration environments")
return {"passed": not failures, "environment": sorted(environments), "tests": totals,
"cases": cases, "failures": failures}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--reports", type=Path, default=Path("target"))
parser.add_argument("--manifest", type=Path, default=Path("specs/acceptance-cases.json"))
parser.add_argument("--output", type=Path, default=Path("target/acceptance-report.json"))
parser.add_argument("--require-testcontainers", action="store_true")
args = parser.parse_args()
result = inspect(args.reports, json.loads(args.manifest.read_text(encoding="utf-8-sig")))
if args.require_testcontainers and result["environment"] != ["testcontainers"]:
result["passed"] = False
result["failures"].append("Docker/Testcontainers evidence required; external PostgreSQL is not equivalent")
result["manifestSha256"] = hashlib.sha256(args.manifest.read_bytes()).hexdigest()
jar = args.reports / "orquestador-nmp-0.1.0-SNAPSHOT.jar"
if jar.is_file():
result["jarSha256"] = hashlib.sha256(jar.read_bytes()).hexdigest()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps({k: result[k] for k in ("passed", "environment", "tests", "failures")}))
return 0 if result["passed"] else 1
if __name__ == "__main__":
sys.exit(main())

77
specs/ACCEPTANCE.md Normal file
View File

@ -0,0 +1,77 @@
# Verificación integral T16
La fuente ejecutable es [acceptance-cases.json](acceptance-cases.json). Cada caso
A01–A20 referencia métodos de prueba concretos. El checker exige ejecuciones reales
en XML Surefire/Failsafe, rechaza cualquier fallo/error/skip y rechaza métodos
faltantes. No considera suficiente que exista el código fuente de una prueba.
## Ejecución con Docker/Testcontainers (criterio de cierre)
Requiere JDK 21, Python 3 y motor Docker operativo:
```sh
docker info
sh ./mvnw --batch-mode --no-transfer-progress clean verify
python3 scripts/verify_acceptance.py --require-testcontainers
```
Windows: usar mvnw.cmd y python. La suite crea contenedores PostgreSQL 9.6.24
temporales, JWT/JWKS locales, proveedores HTTP loopback y procesos JVM exclusivos
de pruebas. No necesita PostgreSQL productivo ni Keycloak real. Failsafe no omite
tests si Docker falta: falla. CI ubuntu-24.04 ejecuta estas mismas puertas, limita
el job a 30 min y conserva reportes/manifest por 30 días incluso al fallar Maven.
Una configuración de CI guardada no demuestra que el job remoto ya se ejecutó.
## Verificación alternativa con PostgreSQL de pruebas
```powershell
# Definir MIGRATION_TEST_DB_URL/USER/PASSWORD en el entorno del proceso.
# URL permitida por los fixtures: jdbc:postgresql://HOST:PORT/orquestador_test
./mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external
python scripts/verify_acceptance.py
```
Usa esquemas/roles temporales propios y los elimina al finalizar. Nunca usar una
base de runtime. Los secretos no se escriben en reportes de aceptación. El checker
infiere external-postgresql de las propiedades reales de Failsafe; si se agrega
--require-testcontainers a esta evidencia, devuelve error. La corrida alternativa
verifica comportamiento SQL/HTTP/JVM, pero no satisface por sí sola el requisito
de reproducibilidad Docker de T16.
Ejecutar siempre clean verify antes del checker: no combinar reportes de corridas
parciales anteriores. acceptance-report.json guarda los casos, métodos/parametrizaciones
ejecutadas, totales, entorno y hashes de manifest/JAR. Archivar XML y reporte juntos.
El checker tiene pruebas propias: `python -m unittest discover -s scripts -p 'test_*.py'`.
## Cobertura
| Caso | Evidencia principal |
| --- | --- |
| A01 | DemoFlowsIT.creditBoundaries: cliente encontrado, persistencia y recorrido |
| A02 | DemoFlowsIT.missingCustomerRemainsCreatedAndCanBeRetried: SUCCEEDED/CLIENTE_NO_ENCONTRADO |
| A03 | DemoFlowsIT.customerApiFailureRetriesAndNeverBecomesCustomerNotFound: mock 503, tres intentos, FAILED |
| A04 | DemoFlowsIT.creditBoundaries + DecisionEvaluatorTest: 71/70/61/60 y decimales |
| A05 | DemoFlowsIT.invalidProviderScoreNeverDefaultsToManualReview |
| A06 | DecisionEvaluatorTest UNIQUE + FlowSimulationTest.ambiguousDraftDecisionIsNotConvertedIntoDefault |
| A07 | DemoFlowsIT.repeatedEventHasOneExecutionAndOneProviderCallEvenAfterCompletion |
| A08 | EventAcceptanceIT.concurrentDifferentPayloadsConflictAndNumericCanonicalReplayIsStable |
| A09 | EventAcceptanceIT.concurrentIdenticalAndDifferentKeysHaveOneAcceptance |
| A10 | DemoFlowsIT.existingCreditKeepsV1WhileNewCreditRequiresLicense |
| A11 | DocumentTasksIT: INVALID, documento ajeno/no conforme y referencia no reasignable |
| A12 | RetryReconciliationIT: caídas reales de JVM; WorkerIT: checkpoint durable |
| A13 | RetryReconciliationIT + WorkerIT: clave externa estable o conciliación |
| A14 | WorkerIT: SKIP LOCKED, renovación y fencing de worker obsoleto |
| A15 | EventAcceptanceIT, ObservabilityIT, SimulationIT: roles/ámbitos/propiedad |
| A16 | FlowPublicationIT.databaseAndApplicationBothPreventPublishedMutation |
| A17 | FlowPublicationIT: activación/retiro/asignación concurrentes |
| A18 | DemoFlowTest y DemoFlowsIT: v1/v2 con mismo motor/binario; licencia en JSON/perfil |
| A19 | WorkerIT y RetryReconciliationIT: rollback auditado y recuperación |
| A20 | DocumentTasksIT.clientCannotSubmitProviderFieldsOrReplaceMetadata |
Las carreras usan barreras/latches y bloqueos DB; los retries adelantan next_attempt_at
en el esquema de pruebas sin esperar backoff real. CrashWorkerProcess usa Runtime.halt
en JVM hija; el padre reconstruye el worker desde PostgreSQL. No es una prueba de
reinicio de contenedor ni una garantía de exactly-once de un proveedor real.
Pendiente histórico de T07 cerrado en implementación: [simulación](SIMULATION.md).
T17 conserva empaquetado/demo desde checkout limpio y verificación de imagen Docker.

View File

@ -1,4 +1,4 @@
# Arquitectura
# Arquitectura
Un único módulo Maven, paquete raíz `com.organizacion.orchestrator`. La implementación sigue la especificación; esta entrega establece sus fronteras, no implementa todavía el motor.
@ -28,4 +28,48 @@ Una sola ejecución activa por solicitud. La incertidumbre de un efecto externo
Publicación inmutable; instancias fijan flow_id. Activo se resuelve coherentemente en PostgreSQL. JSONB extiende datos; no permite scripts, clases, URLs o credenciales arbitrarios. Cambios de licencia con capacidades existentes no requieren columnas ni condiciones Java específicas.
JWT se valida por firma, expiración, issuer y audiencia. Actualmente sólo se expone salud mínima. Roles, scope y propiedad se implementarán antes de los endpoints de negocio. Sin entidades abiertas durante llamadas HTTP (`open-in-view=false`).
JWT se valida por firma, expiración, issuer y audiencia. T07 expone administración protegida y T08 creación/consulta de solicitudes. Roles, scope y propiedad se verifican antes de acceder a recursos; ambos grupos de rutas requieren habilitación explícita y runtime DB restringido. Sin entidades abiertas durante llamadas HTTP (`open-in-view=false`).
## Dominio implementado en T04
Consultar [modelo y puertos](DOMAIN_MODEL.md). El dominio y los puertos son Java
puro; los records contienen snapshots inmutables. flow/api/ValidationReportResponse
es un DTO separado con conversión explícita y prueba contra el esquema REST.
Los adapters JPA/HTTP y servicios de aplicación se implementarán en sus tareas;
ningún record del dominio es entidad JPA ni se expone automáticamente por REST.
## Administración interna de flujos T06
Consultar [validación y publicación](FLOW_PUBLICATION.md). FlowAdministration y sus
puertos son independientes de infraestructura. PostgresFlowStore implementa el
protocolo de locks/auditoría con JDBC transaccional (ADR-010). La exposición de
controladores administrativos queda ligada a T07, con autenticación/autorización
verificada; los contratos REST existentes no se modifican.
## Solicitudes T08
Requests y RequestStore mantienen la frontera pura de aplicación. PostgresRequestStore
comparte DataSource/transacción con FlowStore, serializa claves idempotentes en
PostgreSQL y confirma solicitud/auditoría/ledger en conjunto. RequestView es un DTO
explícito; su consulta usa un snapshot SQL para instancia, flujo fijado y ejecución
activa. Configuración de infraestructura común independiente del interruptor admin.
Detalles y decisiones: REQUESTS.md, SECURITY.md y ADR-012.
## Aceptación de eventos T09
Events/EventStore y EventPolicy conservan aplicación/dominio puros. PostgresEventStore
bloquea la solicitud autorizada y confirma ejecución, revisión y auditoría juntas.
El replay lee estado actual antes de revisar la revisión esperada; las acciones
permanecen sin ejecutar. La definición se obtiene por flow_id fijado, también tras
retiro, sin invertir el orden de locks de publicación. Ver EVENTS.md y ADR-013.
## Worker T10
WorkerStore separa reclamación, renovación, carga/checkpoints y confirmación atómica.
PostgresWorkerStore implementa fencing con tiempo DB. WorkerEngine resuelve mappings,
reutiliza decisiones T05 y valida datos finales. WorkerLoop limita concurrencia y
renueva durante acciones fuera de transacción. WorkerActions es el puerto para
adapters T11; el fallback no inventa resultados. ExecutionController expone una
proyección autorizada del estado. V3 conserva effect para recuperación segura.
Ver WORKER.md y ADR-014.

83
specs/AVAILABLE_EVENTS.md Normal file
View File

@ -0,0 +1,83 @@
# Eventos disponibles y tareas — T13
GET /api/v1/requests/{id}/available-events está habilitado con
REQUEST_API_ENABLED=true. Retorna AvailableEvents del contrato existente:
requestId, revision, events, blocked y, cuando aplica, blockedByExecutionId.
Requiere JWT válido y propiedad/ámbito de la solicitud; FLOW_ADMIN no permite
consultar recursos ajenos.
Cada evento incluye su inputSchema publicado y, si hay una tarea asociada,
metadatos ui de FORM o DOCUMENT_UPLOAD. No se crea una interfaz gráfica, un
servidor de archivos ni otro motor de tareas. La aplicación cliente puede usar
estos datos para mostrar su formulario/carga documental.
## Selección y consistencia
- Usa el flow_id fijado a la solicitud, incluso si está RETIRED o existe una nueva
versión activa para otras solicitudes.
- Solo incluye transiciones del estado actual con un rol permitido del actor.
Los eventos de otros roles se omiten, sin exponer sus schemas o guardias.
- PENDING, RUNNING, RETRY_WAIT y RECONCILIATION_REQUIRED ocultan todos los eventos
y tareas; blockedByExecutionId identifica la ejecución activa.
- Una guardia evaluable con datos actuales de solicitud/actor que es falsa
devuelve GUARD_NOT_SATISFIED en blocked. Un error de evaluación devuelve
EVALUATION_ERROR, sin rutas, valores ni detalle interno.
- Si cualquier parte de la guardia depende de event.data, se difiere la guardia
completa al POST. El evento permanece visible para capturar esos datos. Esto
también aplica a EXISTS/NOT: ausencia de payload en GET no significa que el
futuro evento carecerá del campo.
Una sola sentencia SQL obtiene solicitud, definición y ejecución activa con una
vista MVCC consistente, sin locks de fila. No llama proveedores. La respuesta es
orientativa y puede quedar desactualizada inmediatamente: POST revalida siempre
roles, revisión, estado, guardia y ausencia de otra ejecución.
Cache-Control: no-store. No se emite ETag en este endpoint: disponibilidad cambia
por roles y estado técnico, aunque revision no cambie. Usar revision de la respuesta
para formar If-Match al enviar el evento, o volver a consultar GET de solicitud.
El POST puede responder 409/412 si hubo una carrera.
## Documentos
El cliente envía documentId conforme al inputSchema. No envía MIME, tamaño,
checksum, resultado antimalware, score o resultado de validación como autoridad.
REGISTER_DOCUMENT obtiene los metadatos del servicio documental configurado y
comprueba identidad, solicitud/ámbito, tipo, MIME, tamaño, checksum SHA-256 y
scanStatus=CLEAN.
DocumentMetadata centraliza la verificación pura. El worker aplica adicionalmente
los límites más restrictivos del DOCUMENT_UPLOAD publicado antes de guardar
referencia/checkpoint, y también al reutilizar un checkpoint. Ejemplo: si el
catálogo admite 5 MiB y PNG/PDF, pero la tarea solo 1000 bytes/PDF, 1001 bytes o PNG
se rechazan. La referencia y su checkpoint mantienen la transacción/fencing T11.
Los registros existentes se reutilizan únicamente si todos sus metadatos coinciden.
MIME real, checksum y análisis antimalware provienen del servicio documental
confiable. El orquestador no descarga bytes para recalcularlos ni ejecuta un
antivirus local. INFECTED/PENDING o metadatos inválidos no generan referencia ni
se convierten en un resultado de negocio VALID/INVALID. Sin proveedor configurado
no se fabrica éxito; los mocks existen únicamente en las pruebas.
DOCUMENTO_VALIDA es una acción posterior independiente. INVALID técnicamente
válido puede producir SUCCEEDED/DOCUMENTO_INVALIDO y mantener el mismo estado/tarea;
las razones quedan en los datos de solicitud mediante mappings explícitos del
flujo. GET de solicitud expone esos datos autorizados. No se agregan campos de
razón libres al contrato de available-events.
Un timeout/503 del validador produce reintentos técnicos y bloquea eventos; al
agotarlos de forma segura, FAILED conserva el estado previo y permite volver a
intentar. No se inventa INVALID ni se sobrescribe la última validación de negocio.
El checkpoint documental previo se conserva y no repite la consulta ya exitosa.
## Verificación
AvailableEventPolicyTest verifica selección pura; AvailableEventsIT comprueba
HTTP/JWT/PostgreSQL, versiones, roles, guardias y lectura frente a escritura
concurrente. DocumentTasksIT recorre registro→validación→decisión→tarea con HTTP
mock y PostgreSQL real: INVALID→nuevo evento VALID, referencias sin duplicar,
documentos ajenos, MIME/tamaño de tarea, checksum/antimalware, error técnico y
rechazo de payloads con datos de proveedor (A11/A20).
Ejecutar mvnw.cmd clean verify -Pmigration-tests-external con credenciales de
orquestador_test solo en MIGRATION_TEST_DB_*. Sin ese perfil, verify requiere
Docker/Testcontainers. No se desplegó ni se usaron documentos o proveedores reales.

View File

@ -82,3 +82,437 @@ Flyway 12.4.0 inspeccionado admite desde PostgreSQL 9.0 y la validación real so
Verificación: clean verify con Java 21 (92 tests) y postgres96-check (1 prueba de integración compuesta). La prueba inicia el servicio local con pool read-only, Flyway automático desactivado, SQL init desactivado y validación Hibernate; después invoca exclusivamente Flyway.validate() con createSchemas=false. No ejecuta migrate/create/clean ni modifica la instancia externa. La primera propuesta de arranque fue rechazada por la revisión automática por riesgo de migraciones; se usó esta alternativa explícitamente de sólo lectura, aprobada y ejecutada.
Fuentes: [dialectos y módulo comunitario Hibernate](https://docs.hibernate.org/stable/core/dialect/), [versiones de bases de datos Flyway](https://documentation.red-gate.com/fd/supported-databases-and-versions-143754067.html). La compatibilidad se comprobó con las dependencias reales del proyecto, no sólo con la documentación.
## ADR-007 — Modelo persistente y pruebas aisladas T03 (2026-10-26)
Decisión: implementar V1/V2 compatibles con PostgreSQL 9.6 y usar el esquema
orchestrator para tablas e historial Flyway. El arquetipo anterior no tenía
migraciones de negocio; si un entorno contiene historial real en public, requiere
un traslado explícito antes de actualizar, nunca baseline/repair automático.
Separar credenciales del migrador y runtime mediante DB_MIGRATION_*; conservar
fallback a DB_* para el entorno de desarrollo existente. Los roles se provisionan
externamente y grant_runtime_access concede permisos a un rol existente.
La ausencia de Docker local se resuelve ejecutando las mismas pruebas sobre la
base orquestador_test cuya creación confirmó el usuario. Cada ejecución crea y
elimina su esquema t03_<UUID>, sin tocar orquestador ni esquemas compartidos. La
prueba de permisos crea un rol NOLOGIN y revierte su transacción. La ruta normal
permanece en Testcontainers postgres:9.6.24 para CI; no se ejecutó localmente por
falta de Docker. La evidencia real corresponde a PostgreSQL 9.6.2 externo.
Se rechazó usar H2 o cerrar por compilación: se probaron FKs, JSONB, historial
inmutable, permisos, migraciones desde cero, rollback de DDL, una carrera real de
inserciones y FOR UPDATE SKIP LOCKED con conexiones independientes. Los triggers
protegen integridad de almacenamiento; T06/T09/T10/T12 aún deben implementar
validación semántica, auditoría de negocio, aceptación, fencing y conciliación.
No se cambian contratos REST ni se declaran cumplidos A01–A20 por estas pruebas.
## ADR-008 — Contratos internos inmutables (2026-10-26)
T04 utiliza records y variantes selladas de Java 21. Se elige una representación
JSON propia e inmutable con BigDecimal, NullValue explícito y Lookup.Missing
separado, en lugar de exponer JsonNode mutable o Map<String,Object> en los puertos.
Esto mantiene snapshots confiables y permite compilar dominio/puertos sin librerías
externas. Los adapters futuros deberán convertir JSON y persistencia explícitamente.
FlowDefinition modela configuración; FlowVersion modela su metadata persistida.
Las identidades FlowId/RequestId/ExecutionId/ActionExecutionId impiden intercambios
accidentales de UUID. Rutas, AST y acciones son variantes tipadas; estados/eventos
siguen siendo claves configurables. El catálogo confiable aporta clasificación
de efectos por separado, nunca mediante configuración editable del flujo.
Errores técnicos y resultados de negocio tienen variantes distintas. Los puertos
mínimos se declaran sin implementaciones simuladas; T05/T06/T11 implementarán sus
comportamientos. El DTO de validación convierte el modelo al contrato publicado
sin exponer detalles internos. No se modifican DSL, OpenAPI, SQL, idempotencia,
versionado o política de autorización. Pruebas de arquitectura y de esquema
protegen estas fronteras. Ver DOMAIN_MODEL.md para invariantes y límites.
## ADR-009 — Evaluación pura y presupuestos de AST/datos (2026-10-26)
T05 implementa el puerto sin dependencias externas ni evaluación de expresiones
libres. Los once operadores usan JSON tipado; BigDecimal.compareTo evita coerción
y diferencias por escala. La ausencia es un error salvo EXISTS; null solo participa
en EQ/NEQ. Se concreta igualdad estructural para arrays/objetos y homogeneidad total
de IN, sin ocultar tipos inválidos tras una coincidencia temprana.
El preflight iterativo limita la definición completa antes de aplicar cortocircuito:
profundidad 20 y 1000 nodos, incluyendo expresiones path/literal. Los límites pueden
reducirse por construcción. Se limita además IN a maxNodes elementos y la igualdad
estructural a maxNodes pares por comparación, para no recorrer datos arbitrariamente
grandes aunque el AST tenga pocas hojas. Exceso retorna EVALUATION_ERROR. Esta
restricción operativa queda explícita y no cambia schemas/SQL ni admite un default
como sustituto de un error. Límites de bytes de recepción quedan en T06/T09.
Alternativas descartadas: recursión durante validación de profundidad (podría
desbordar antes del rechazo), validación solo de ramas ejecutadas (permitiría omitir
límites), igualdad BigDecimal.equals (depende de escala), coerciones y evaluación
arbitraria de scripts. IntervalOverlapAnalyzer analiza solo intervalos simples;
la ambigüedad UNIQUE sigue comprobándose al ejecutar. No promete resolver SAT.
La evaluación conserva IDs de reglas para auditoría futura y snapshots intactos.
No se modifican las reglas de idempotencia, seguridad ni versionado. Detalle de
operadores, alcance de A04–A06 y conteo de límites: DECISION_EVALUATOR.md.
## ADR-010 — Publicación transaccional y exposición administrativa (2026-10-26)
T06 implementa un servicio interno de administración y un catálogo inmutable
proporcionado por el despliegue. No incluye controladores ni un catálogo ficticio
predeterminado. Aunque A16 menciona API, su evidencia en esta fase se obtiene en
la API Java de aplicación y en PostgreSQL; las rutas REST y la comprobación JWT
se realizan en T07/T16. Esta secuencia resuelve la exigencia explícita de seguridad
antes de exposición; no se da por aprobada la prueba HTTP que aún no existe.
Desviación del uso propuesto de JPA para agregados: las operaciones administrativas
se implementan con el JDBC ya incluido en el stack y TransactionTemplate, detrás
de FlowStore. El motivo es mantener explícitos los bloqueos flujo/tipo, revalidación
de referencias, JSON canónico y auditoría en una única transacción sin flush implícito.
Alternativa: entidades JPA más queries nativas y control manual de flush; añade dos
modelos de escritura al mismo protocolo sin una necesidad actual. No se elimina JPA
ni se reemplaza el stack; otros agregados podrán usarlo sin cambiar los contratos.
Activación y asignación bloquean flujo antes que tipo; retiro sigue ese orden y
limpia solo referencias que todavía apuntan a la versión. Incrementa revision de
los tipos desactivados y audita sus claves. Instancias existentes permanecen fijadas.
T08 debe completar creación/idempotencia/auditoría en la misma transacción; el puerto
no debe usarse como lectura desconectada seguida de una inserción posterior.
La canonicalización es propia, determinista, con objetos ordenados, arrays ordenados
y números BigDecimal normalizados; no se anuncia RFC 8785. Defaults y contratos se
resuelven antes del hash. Entradas iniciales/de evento cerradas y protección de rutas
escritas desde results impiden confiar en campos de proveedor recibidos del cliente.
La validación estática de tipos no sustituye validación de valores en runtime.
networknt 1.5.9 se reutiliza en runtime, con Jackson 2 aislado en infraestructura.
Schemas se empaquetan desde los archivos contractuales existentes. No cambian las
migraciones, idempotencia ni credenciales. Las pruebas reales usan exclusivamente
orquestador_test y esquemas temporales propios conforme a la autorización vigente.
Detalles, límites y protocolos: FLOW_PUBLICATION.md.
## ADR-011 — Identidad y autorización T07 (2026-10-27)
Aceptado para implementación local verificable. JWT RS256 validado por JWKS, issuer,
audience y exp; sub y un ámbito de negocio único son obligatorios. Se toma
business_scope por defecto, configurable con JWT_BUSINESS_SCOPE_CLAIM mientras se
confirma el claim real de controlms. OAuth scope no sustituye el ámbito ni concede
roles. Se admiten roles del realm y del cliente identificado por JWT_AUDIENCE.
Alternativa descartada: confiar en actor/scope enviados en el comando o usar la
clave pública estática sin posibilidad de actualización por JWKS.
Los recursos exigen propietario Y ámbito, con 404 para ausencia o acceso ajeno.
No existe bypass implícito FLOW_ADMIN. EXECUTION_OPERATOR tiene una puerta separada
de conciliación dentro de su ámbito, sin requerir ser propietario; la operación
funcional y evidencia permanecen en T12. Alternativa descartada: cualquier usuario
del ámbito puede acceder a todas las solicitudes, pues ampliaría acceso sin una
regla de negocio explícita.
El catálogo de flujos/tipos es global según el modelo actual. Su administración
requiere rol técnico y un ámbito en ADMIN_BUSINESS_SCOPES, lista vacía por defecto.
No se inventa tenancy para flow_definition. FLOW_ADMIN_ENABLED=false conserva las
rutas cerradas hasta que el operador configure catálogo, ámbito y credenciales DB
separadas. Con administración habilitada, un verificador rechaza al runtime elevado
y permisos de mutación de historiales; Flyway usa su propio usuario propietario.
No se cambia automáticamente la cuenta pgadmin existente ni permisos globales.
Las seis operaciones administrativas T06 se exponen en T07. Simulación sigue pendiente
para T14 y conciliación para T12. A15 se verifica con la puerta de autorización real,
JWT firmado, recursos persistidos y un controlador exclusivo de test no distribuido;
las rutas funcionales posteriores deben reutilizarla y T16 repetirá la aceptación
integral. A16 se comprueba ahora por HTTP. Detalles: SECURITY.md.
## ADR-012 — Creación idempotente y snapshot T08 (2026-10-27)
La clave de creación se particiona por sujeto/ámbito y el hash incluye typeKey/data
canónicos, usando la semántica numérica T06. Se serializa con advisory lock
transaccional PostgreSQL de 64 bits y búsqueda/restricción única por identidad
completa. Una colisión del lock no mezcla identidades, solo retrasa transacciones.
Alternativas: mutex JVM (no protege varias instancias), reserva anticipada de ledger
(exigiría respuesta/solicitud incompletas o una migración adicional) e intentar insertar
sin serializar (recuperación de conflicto más compleja después de abortar transacción).
La respuesta original queda durable en el ledger y se reproduce con 201/Location/ETag
originales; GET devuelve el estado actual. Se verifica acceso actual antes del replay.
La resolución de activo y validación por schema ocurren después de comprobar replay,
para que cambios posteriores de catálogo no invaliden una creación ya aceptada. La
clave distinta de contenido produce IDEMPOTENCY_CONFLICT. Fallos no consumen la clave.
Solicitud, REQUEST_CREATED y ledger se guardan en una transacción READ COMMITTED,
con una misma DataSource que FlowStore. Se conserva orden flujo/tipo de T06. El cambio
de activo detectado durante asignación retorna 409 INVALID_FLOW_STATE reintentable,
sin registro parcial. El aislamiento de usuario/scope se aplica a cada consulta.
Se extiende el adapter JDBC tras puertos de aplicación puros, en continuidad con
ADR-010, para compartir bloqueos, JSONB y commit con FlowStore. No se añade JPA para
un segundo modelo de escritura del mismo protocolo ni se altera el stack existente.
No hay nuevas migraciones: V1/V2 ya contienen ledger, FKs, auditoría y permisos.
REQUEST_API_ENABLED es independiente de administración. Ambos requieren runtime DB
restringido; PersistenceAccessConfiguration factoriza este guard y los puertos comunes.
Creación exige identidad JWT válida, sin inventar rol nuevo ni ACL por tipo; los roles
de negocio se revalidan en eventos T09. Ver REQUESTS.md y contrato actualizado.
## ADR-013 — Aceptación durable y revisión T09 (2026-10-27)
Se mantiene el protocolo de la sección 8.1: bloquear solicitud autorizada, buscar
idempotencia antes de revisión, validar transición/rol/schema/guardia, insertar
PENDING, incrementar revisión y auditar en una transacción READ COMMITTED. El índice
único de ejecución activa y la clave request_id/idempotency_key existentes son
respaldo SQL; no hay mutex en memoria ni HTTP externo dentro de aceptación.
accepted_revision guarda la revisión posterior a aceptación, If-Match + 1. El
worker deberá comparar esa revisión al confirmar; no es una revisión adicional
independiente. Hash SHA-256 de evento/data/revisión esperada canónicos, con misma
semántica decimal de T06/T08. El replay verifica acceso actual y actor original,
retorna status actual y no vuelve a exigir el rol/guardia de la aceptación previa:
no está autorizando un nuevo efecto. Otra revisión esperada con la misma clave
es otro comando y produce IDEMPOTENCY_CONFLICT.
La definición se lee por flow_id fijado, incluyendo RETIRED, sin bloquear el flujo
ni consultar el tipo activo. No se altera estado/data de negocio al aceptar. Guardias
usan results vacío y los snapshots confiables de solicitud/evento/actor; errores de
evaluación no se convierten a false. No se guarda JWT completo, solo atributos
necesarios de identidad. Persistencia JDBC tras puertos puros continúa ADR-010/012.
Alternativas descartadas: procesar acciones antes del 202 (mezcla transacción/HTTP),
revalidar revisión antes del replay (rompe reintento perdido), devolver un éxito de
negocio al aceptar y almacenar tokens para reintentos. No hay migración nueva:
V1/V2 cubren tabla, índices, FKs, permisos e inmutabilidad de auditoría.
Location conserva el contrato de ejecución; su consulta GET se incorpora como
subtarea explícita T10 para seguimiento del worker. En T09 solo se implementa POST
de aceptación y GET de solicitud T08 permite ver activeExecutionId; no se anuncia
procesamiento ni consulta de ejecución implementados. Ver EVENTS.md.
## ADR-014 — Worker, fencing y recuperación inicial T10 (2026-10-27)
WorkerStore reclama PENDING/RETRY_WAIT vencidos y RUNNING expirados con SKIP LOCKED,
tiempo DB y token creciente. Los executors locales solo despachan capacidad libre;
PostgreSQL conserva cola, leases y checkpoints. Toda mutación exige owner/token
vigentes; checkpoints bloquean ejecución, finalización/fallo bloquean solicitud y
luego ejecución. Se comprueban revisión/estado al cargar y al confirmar. Nunca se
mantiene una transacción abierta mientras el adapter ejecuta una acción.
V3 agrega effect al checkpoint, obtenido del adapter confiable y comparado al
reutilizarlo. Alternativa descartada: inferir seguridad desde el JSON editable o
suponer que un checkpoint antiguo era read-only. Los anteriores reciben clasificación
conservadora NON_IDEMPOTENT_WRITE. Hash/clave/effect son consistentes entre reintentos.
RUNNING/UNKNOWN no idempotentes requieren conciliación. Éxitos se reutilizan; cambios
de hash/effect se rechazan. Escrituras previas/potenciales impiden liberar como FAILED.
El procesamiento secuencial resuelve inputs, valida contratos, guarda checkpoint,
evalúa decisiones y aplica updates sobre copia. Commit final incluye datos/estado,
revisión, resultado y auditoría con reglas/acciones. Errores DB conservan leases y
checkpoints para recuperar; no se transforman en éxito o en liberación incierta.
JSON Pointer permite actualizar objetos y posiciones existentes de arrays, sin
crear arrays dispersos. Se valida schema/256 KiB antes de confirmar. No hay claims
de exactly-once externo; la clave executionId:actionId solo funciona si el proveedor
respeta su contrato de idempotencia.
WORKER_ENABLED independiente de API/admin, deshabilitado por defecto. Registro
productivo sin adapters hasta T11: ausencia produce INTEGRATION_UNAVAILABLE, nunca
mock de éxito. Flujos sin acciones ya pueden completarse. T12 conserva la política
completa de retry/backoff/Retry-After/conciliación; T10 implementa recuperación de
caídas y reconoce RETRY_WAIT existente. TechnicalFailure se termina de manera segura
sin anunciar retry HTTP aún. GET de ejecución ya se implementa con owner/scope y
proyección de respuesta, resolviendo la subtarea agregada en ADR-013.
Pruebas: PostgreSQL real, dos reclamantes, checkpoints, retorno tardío y heartbeats;
adapters exclusivamente de test. SimulatedCrash y reconstrucción de repositorio no
se presentan como reinicio de JVM. WORKER.md documenta configuración y límites.
## ADR-015 — T11: transporte acotado y contratos de despliegue (2026-10-27)
Se conserva Spring Boot y sus beans, usando HttpClient del JDK 21 dentro del
adapter HTTP en lugar del cliente Spring propuesto en la especificación. Motivo:
controlar cancelación y límite de bytes antes de acumular una respuesta, además
de un deadline que incluya el cuerpo completo. No se agrega otra librería.
Alternativas: cliente Spring síncrono con lectura InputStream (requiere controlar
por separado el deadline de lectura) o WebClient (agrega stack reactivo).
La API BodySubscriber documenta cuándo se completa el cuerpo y cómo cancelar la
suscripción: [documentación JDK 21](https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpResponse.BodySubscriber.html).
BoundedHttp usa un subscriber limitado y espera finita del futuro; las pruebas
incluyen headers inmediatos con cuerpo demorado y payload excesivo.
La ruta es configuración confiable, ligada a tupla exacta de contrato/perfil.
No hay URLs, secretos ni selección de métodos HTTP en el DSL. El protocolo local
propuesto usa POST JSON y bearer opcional por referencia a variable de entorno.
No se inventa compatibilidad con proveedores existentes: ejemplos y mocks
definen el contrato que deberá confirmarse o adaptarse antes de habilitar rutas.
Nuevas versiones conviven; no cambiar el significado de una versión usada.
REGISTER_DOCUMENT usa DOCUMENTS_METADATA/1 como protocolo fijo del handler,
porque el DSL de esta acción no tiene campos de versión. Cambiar ese protocolo
requiere versionar el handler/DSL mediante otra decisión, nunca seguir un default
mutable. Guarda referencia y checkpoint juntos bajo fencing. Su efecto remoto es
READ_ONLY; el registro local es idempotente y no sube archivos. T13 completa la
exposición documental. NO_OP permanece solo en simulación.
A03 se divide explícitamente: T11 acredita transporte, clasificación y ausencia
de falsos negativos; T12 implementa programación de reintentos/backoff/Retry-After.
No se anuncia A03 integral terminado hasta T12/T16. No cambian las claves
idempotentes, versiones publicadas o autorización. Compose conserva un servicio
y PostgreSQL externo; sin despliegue ni llamadas reales.
## ADR-016 — T12: reintento durable y resolución atómica (2026-10-27)
Se programa RETRY_WAIT con reloj PostgreSQL en la misma transacción que el estado
del intento y RETRY_SCHEDULED, bajo fencing. No se duerme dentro del worker ni de
una transacción. Se conserva el límite publicado por acción, máximo tres intentos
totales; backoff exponencial con jitter 80–120 %, limitado por maxBackoff, y
Retry-After numérico/fecha HTTP con techo de 120 s.
READ_ONLY e IDEMPOTENT_WRITE pueden reintentarse tras fallo transitorio. UNKNOWN y
RUNNING recuperado de NON_IDEMPOTENT_WRITE se concilian antes de evaluar agotamiento:
alcanzar el límite no demuestra ausencia de efecto. Un intento FAILED de escritura
idempotente agotada también conserva incertidumbre. Un resultado normalizado
negativo sigue siendo SUCCEEDED. Claves externas, hashes de entrada y versiones
publicadas no cambian. El hint Retry-After se añade al resultado técnico interno,
sin alterar el contrato público de ejecución.
La evidencia de resolve es una declaración del operador autorizado con referencia
y razón; no existe servicio real configurado para verificarla automáticamente.
No fabricar una consulta de estado ni afirmar verificación remota. El operador
EXECUTION_OPERATOR accede por ámbito, incluso si no es propietario; además requiere
el interruptor administrativo y ADMIN_BUSINESS_SCOPES existentes.
RESUME valida contratos fijados, efectos persistidos y cobertura de todos los
checkpoints no exitosos. No crea acciones no iniciadas, cambia resultados exitosos
ni inserta referencias documentales manuales. Reencola PENDING; el worker decide
el destino con el flujo publicado. CLOSE_WITHOUT_TRANSITION conserva negocio y
exige declaración ABSENT_VERIFIED/COMPENSATED_VERIFIED; ausencia contradicha por
una escritura SUCCEEDED durable se rechaza. No se agregan cambios de estado manuales.
Se bloquea solicitud antes que ejecución. Checkpoints, ledger append-only,
auditoría y reencolado/cierre son atómicos; el fencing_token aumenta al resolver.
Replay se busca antes de comprobar estado actual, exige mismo comando canónico y
sujeto operador, y devuelve respuesta original. Otro sujeto con igual clave no
hereda el replay. El esquema V1 ya soporta esta semántica: no se necesita migración.
API informa 409 para estado no conciliable y 412 si se detecta revisión/estado
de solicitud incoherentes al reanudar; OpenAPI refleja ambas respuestas.
Alternativas descartadas: reintentar toda la transición (duplica efectos previos),
liberar al agotar intentos sin considerar escrituras (pierde incertidumbre) y
permitir seleccionar destino desde resolve (evade reglas publicadas). La prueba
de caída usa una JVM hija real terminada con Runtime.halt; proveedor HTTP sintético
con efecto lógico por clave. No prueba un proveedor real ni un despliegue productivo.
## ADR-017 — T13: disponibilidad orientativa y límites documentales publicados (2026-10-27)
available-events es una proyección de backend del flujo fijado a la solicitud.
No se implementa interfaz gráfica. Una sentencia SQL obtiene revisión, estado,
datos, definición inmutable y ejecución activa en el mismo snapshot MVCC; no se
bloquean filas ni se consulta disponibilidad de proveedores. Se conserva acceso
por propietario/ámbito, sin bypass FLOW_ADMIN, y se omiten transiciones de otros roles.
Con ejecución activa se ocultan todos los eventos y metadatos. Guardias que solo
usan solicitud/actor se evalúan; falso y error se proyectan como códigos genéricos.
Guardias con cualquier dependencia de event.data se difieren completas al POST.
Evaluarlas con un objeto vacío produciría falsos bloqueos, especialmente en
EXISTS/NOT. No se promete que un evento anunciado será aceptado: POST revalida
revisión, rol, guardia, estado e inactividad.
No-store y ausencia de ETag en available-events: roles y estado técnico pueden
cambiar sin variar revision. Un ETag basado solo en revisión podría producir un
304 incorrecto. Se devuelve revision en el cuerpo para la precondición del POST.
No se cambia el contrato/ETag de GET de solicitud.
DocumentMetadata extrae la verificación pura existente de T11. Se mantiene la
autoridad del servicio documental para MIME real/checksum/antimalware, sin descargar
archivos ni simular éxito productivo. El worker ahora verifica también MIME/tamaño
del DOCUMENT_UPLOAD publicado antes de guardar referencia/checkpoint y al
reutilizarlo. T11 aplicaba únicamente el límite general del catálogo; esto cerraba
insuficientemente tareas que anunciaban restricciones más estrictas. Se aplican
ambas políticas, no se amplía la autorización.
El fixture documental separa documentId de entrada y registeredDocumentId verificado,
con validation protegida como salida del proveedor. No se relaja la regla de
publicación que impide escribir resultados desde datos del cliente. Los enums
status/scanStatus del catálogo de ejemplo agregan type:string explícito para el
análisis estático; sus valores aceptados no cambian.
INVALID de DOCUMENTO_VALIDA conserva tarea/estado con razón mapeada a datos de
solicitud. Fallo técnico conserva los datos previos y sigue T12, sin fabricar
INVALID. Referencias y checkpoints conservan atomicidad/fencing T11; no se agrega
tabla ni endpoint de subida/descarga de archivos. Compose mantiene solo el servicio
con PostgreSQL externo. Proveedor documental real y antivirus real siguen fuera
de la evidencia local con mocks.
## ADR-018 — Demostración por configuración y segundo tipo (2026-10-28)
T14 entrega crédito v1/v2 y compra v1 como definiciones completas, sin modificar
src/main ni columnas. Se amplía solo el catálogo de ejemplo con INE/ESTADO_CUENTA
versión 1; LICENCIA_CONDUCIR ya existe. Todos los contratos y perfiles quedan
explícitos en JSON. Límites documentales: 5 MiB y MIME según catálogo/tarea.
La especificación no define políticas de compra. El ejemplo usa consulta de cliente
y confirmación booleana, con finales confirmada/cancelada; no cobra ni reserva.
Alternativa descartada: inventar una integración de pagos o reglas comerciales.
Crédito exige una referencia de consentimiento; no afirma validar consentimiento
real. Los cuatro proveedores HTTP son fixtures sintéticos exclusivos de pruebas;
no se empaquetan en el servicio ni se activa configuración de demo por defecto.
Se verifica la demo mediante API operativa y worker, con esquema temporal PostgreSQL,
en vez de presentar una simulación como recorrido persistente. El endpoint puro
/admin/flows/{id}/simulate sigue pendiente (anotado desde T07); no forma parte de
la evidencia A10/A18 ni se declara implementado. Se registra explícitamente para
la revisión integral T16 antes de la entrega final T17.
## ADR-019 — Historial y observabilidad operativa (2026-10-28)
El historial se proyecta desde audit_entry append-only con keyset (created_at,id),
límite 100 y filtros kind/execution/from/to. ResourceAccess autoriza propietario y
ámbito en cada página, sin bypass administrativo. El cursor codifica posición y
huella de solicitud/filtros; no es un token de acceso ni una firma criptográfica.
Se elige estabilidad entre reinicios/réplicas sin añadir secretos para una posición
que no amplía acceso. Alternativa: cursores firmados; no aporta autorización al
recurso y exige rotación compartida. Navegación no promete snapshot ante commits
concurrentes tardíos. La consulta tiene timeout SQL de 5 s.
Se entrega lista permitida de detail; evidencia de conciliación y payloads no se
proyectan. Nuevas transiciones agregan reasonCodes presentes en resultados verificados
a la misma escritura auditada; no se alteran auditorías existentes. Actor se entrega
al propietario autorizado. Sin nueva migración ni modificación de semántica de
idempotencia/versiones. Retención no borra datos sin política de organización.
Actuator metrics se habilita y protege con EXECUTION_OPERATOR + ámbito de operación
configurado, independiente de edición de flujos. Otros actuators se deniegan salvo
salud. Micrometer ya transitivo: sin dependencia nueva, Prometheus o servicio extra.
Contadores de comandos describen respuestas HTTP incluyendo replays; no representan
exactamente commits nuevos. Timers usan integrationKey confiable y resultado fijo;
estados DB son gauges agregados sin IDs/sujetos/ámbitos, con muestra compartida de
5 s y timeout SQL 3 s. Fallos dan available=0 y NaN, nunca un cero de cola ficticio.
Logs propios usan campos permitidos y códigos; decisiones/razones quedan en auditoría.
Un log de acción exitosa no equivale a commit; phase=committed se emite después del
commit durable. Correlación HTTP queda ligada a executionId por EVENT_ACCEPTED.
WorkerShutdown amplía el drenaje existente con presupuesto configurable 1–20 s y
espera adicional de 1 s tras interrupción. Mantiene renovaciones durante el drenaje,
fencing/checkpoints y recuperación de incertidumbre. No se afirma cancelar efectos
externos no cooperativos ni probar Docker productivo mediante fixtures locales.
## ADR-020 — Simulación y evidencia ejecutable de aceptación (2026-10-28)
T16 cierra el endpoint de simulación pendiente desde T07. SimulationController exige
la seguridad administrativa existente; actor/results del body son fixtures. Se lee
el flujo y se libera el lock antes de evaluar. FlowSimulation no depende de HTTP,
secretos, worker actions o puertos de escritura. Valida schemas, versiones/perfiles,
roles/guardias y mappings; NO_OP usa output configurado solo en simulación.
Se extrae mecánicamente la proyección final del worker a TransitionEvaluator,
compartiendo decisiones/updates/validación final/reglas y razones. Alternativa
rechazada: duplicar el evaluador en el endpoint, con riesgo de divergencia. La
extracción no modifica idempotencia, leases, efectos, transacciones o semántica
numérica. Simulación permite draft ambiguo para devolver AMBIGUOUS_DECISION, pero
publicación conserva su validación estricta. No crea auditoría ni registros de
negocio; es evaluación de fixtures, no evidencia de proveedor/documento real.
acceptance-cases.json liga A01–A20 a métodos concretos. verify_acceptance.py inspecciona
XML real y falla por ausencia/error/skip; registra entorno y hashes. CI exige
Testcontainers y conserva artefactos 30 días. FailIfNoTests se habilita en ambos
plugins Maven. No se cambian versiones de stack ni se incorporan librerías nuevas.
Docker no fue localizado en PATH, servicio o instalación estándar de este equipo.
Se solicitó un motor autorizado mientras se completa verificación con orquestador_test.
Una corrida externa aprobada no se rotula Docker ni satisface su criterio: T16 no
puede declararse DONE hasta verificar Testcontainers o una modificación explícita
del criterio por el usuario. CI configurado tampoco equivale a CI ejecutado.

View File

@ -0,0 +1,92 @@
# Evaluador de decisiones (T05)
`PureDecisionEvaluator` implementa `DecisionEvaluator` sin Spring, I/O ni
persistencia. Es reutilizable entre workers; no guarda contexto ni resultados
entre llamadas. Devuelve `Selected(output, matchedRuleIds)` o `Failure(code)`.
`ConditionEvaluator` permite evaluar guardias: devuelve boolean o lanza
DomainException con EVALUATION_ERROR. No convierte errores en condiciones falsas.
## Semántica ejecutable
- EQ/NEQ comparan tipos JSON compatibles; números mediante BigDecimal.compareTo,
sin depender de la escala. No hay conversión de strings a números o fechas.
- Null es un valor presente. Solo EQ/NEQ lo comparan: null EQ null es true,
null EQ un valor no nulo es false. Una ruta ausente produce EVALUATION_ERROR
en cualquier comparación, incluso si el otro operando es null.
- EQ/NEQ admiten objetos y arrays estructurales, conservando el orden del array
e ignorando el orden de claves del objeto. Valores anidados con tipos
incompatibles producen error; formas/tamaños distintos producen desigualdad.
- GT/GTE/LT/LTE requieren dos números. No existe orden implícito de strings,
fechas, booleanos, objetos o null.
- IN requiere array homogéneo y un operando izquierdo compatible con sus elementos.
Revisa toda la lista antes de buscar: una coincidencia temprana no oculta tipos
mezclados. Null no participa en IN. Una lista vacía retorna false para un valor
no nulo. La igualdad numérica tampoco depende de escala dentro de IN.
- EXISTS retorna false solo para un path sin valor; un null explícito sí existe.
AND/OR cortocircuitan en orden; NOT invierte el resultado y propaga errores.
- UNIQUE examina reglas hasta detectar una segunda coincidencia, que retorna
AMBIGUOUS_DECISION incluso si ambas salidas son iguales. Si ninguna coincide,
usa defaultOutput. Un error previo a decidir el resultado no usa el default.
- FIRST se habilita únicamente con hitPolicy=FIRST y selecciona la primera regla
coincidente, sin evaluar los datos de reglas posteriores. La comprobación
estructural y de límites sí cubre la definición completa previamente.
- Selected conserva el ID de la regla elegida; lista vacía identifica el default.
La persistencia de esa evidencia en auditoría pertenece a las tareas posteriores.
## JSON Pointer y contexto
El resolver solo admite las raíces tipadas /request/data, /event/data, /results y
/actor. Desescapa ~1 y ~0 una sola vez y admite claves vacías y caracteres Unicode.
En arrays acepta índices decimales canónicos sin ceros iniciales; índices fuera de
rango, '-' y acceso a un hijo de un escalar no resuelven valor. No ejecuta expresiones.
Actor expone subject, businessScope y roles provenientes del snapshot confiable;
roles se ordenan para que su representación sea determinista. Los campos del evento
no sustituyen esa identidad. Resolver raíz /request/data devuelve únicamente data,
no identidad, estado, revisión ni ACL de la solicitud.
## Límites
EvaluationLimits permite reducir límites hasta los techos operativos: profundidad
20 y 1000 nodos. Se configura al construir el evaluador; el wiring del motor vendrá
con los servicios que lo consumen. No es configuración editable del flujo.
La raíz de cada condición tiene profundidad 1. Cada condición y cada expresión
path/literal cuenta como nodo; una comparación tiene tres nodos y profundidad 2.
Los literales son hojas del AST. Se suman todas las reglas y ocurrencias, aunque
compartan el mismo objeto Java. Los IDs duplicados son error. El preflight usa una
pila explícita y rechaza también ramas que un cortocircuito habría omitido.
Para acotar comparaciones de datos, IN admite hasta maxNodes elementos y la
igualdad estructural hasta maxNodes pares visitados por comparación. Exceder el
presupuesto devuelve EVALUATION_ERROR. Esto no sustituye límites de bytes al
recibir/deserializar: definición 1 MiB y datos 256 KiB siguen pendientes de T06/T09.
## Solapamientos estáticos
IntervalOverlapAnalyzer reconoce EQ/GT/GTE/LT/LTE numéricos de path frente a literal,
operandos invertidos y conjunciones AND del mismo path. Intersecta extremos abiertos
y cerrados con BigDecimal y descarta intervalos vacíos. Devuelve parejas de IDs y
path con solapamiento demostrado, además de IDs de reglas fuera del subconjunto.
No analiza OR/NOT/EXISTS/IN/NEQ ni compara intervalos sobre paths diferentes.
La ausencia de solapamientos encontrados no certifica disjunción general. T06
consumirá estos diagnósticos al publicar; FIRST puede tener precedencia intencional.
UNIQUE mantiene obligatoriamente detección de ambigüedad en runtime.
## Evidencia y alcance
DecisionEvaluatorTest usa el flujo scoring.json contractual para 71/70/61/60 y
fracciones de precisión alta. Las fixtures missing-score/string-score se rechazan
por el schema BureauResult y, si llegan al evaluador, producen EVALUATION_ERROR,
nunca una salida de scoring bajo. No es todavía una prueba de adapter de buró.
Las suites incluyen los once operadores, null/ausencia, IN heterogéneo, condiciones
anidadas, cortocircuito, FIRST/UNIQUE, límites exactos, 50000 niveles de entrada,
uso concurrente, JSON Pointer y extremos de intervalos. La prueba de arquitectura
incluye el evaluador de application y lo compila con únicamente java.base.
No hay nuevos endpoints, llamadas externas, cambios SQL ni escritura de solicitudes.
El mapeo JSON a DSL usado por la prueba scoring es exclusivamente un fixture de
pruebas; el parser/validador completo de publicación corresponde a T06. Los criterios
A04–A06 se verifican al nivel de decisiones/contrato, no como flujo REST completo.

94
specs/DOMAIN_MODEL.md Normal file
View File

@ -0,0 +1,94 @@
# Modelo de dominio y puertos (T04)
El dominio utiliza Java 21 y no depende de Spring, serializadores, HTTP, JDBC ni
JPA. Sus records son snapshots inmutables, no entidades administradas ni cuerpos
REST. Las escrituras y los bloqueos pertenecen a servicios de aplicación futuros.
| Paquete | Modelos y responsabilidades |
|---|---|
| shared/domain | Identidades UUID distintas por recurso, Actor confiable, JSON inmutable, ErrorCode y DomainException |
| flow/domain | DSL FlowDefinition, estados/eventos, transiciones, rutas fijas o por decisión, tareas, schemas, FlowVersion y ValidationReport |
| decision/domain | AST cerrado de condiciones/valores, reglas ordenadas, política explícita UNIQUE/FIRST, contexto y resultado |
| action/domain | Configuración por capacidad, reintentos acotados, contexto, resultado y metadatos de catálogo confiable |
| request/domain | RequestInstance fijada a FlowId y RequestType con activación opcional |
| execution/domain | Estado técnico, ejecución aceptada, lease/fencing y checkpoint de acción |
| */application/port | DecisionEvaluator, ActionHandler y FlowDefinitionValidator |
| flow/api | ValidationReportResponse y conversión explícita desde dominio al contrato REST |
## Invariantes y representación
- Los estados y eventos de negocio son claves configurables, no enums Java por
proceso. Los enums representan únicamente conceptos técnicos cerrados.
- FlowDefinition representa el DSL; FlowVersion agrega identidad, estado de
publicación, revisión, checksum y fechas. RequestInstance conserva solo FlowId,
sin duplicar la versión. RETIRED es ejecutable para instancias existentes y no
asignable a nuevas solicitudes.
- List.copyOf y Map.copyOf/Set.copyOf impiden cambios por alias. Los hijos JSON
también son inmutables: objetos, listas, strings, BigDecimal, boolean y null
explícito. Lookup.Missing no es un valor JSON; no se confunde con NullValue.
- Los números se construyen con BigDecimal, sin coerción implícita desde strings.
La comparación numérica, resolución de pointers y límites de evaluación son T05.
- Condiciones, acciones y rutas son variantes selladas. EXISTS recibe un path;
NOT un operando; AND/OR conservan orden y requieren al menos dos argumentos.
Una transición tiene ruta fija o decisión/destinos, nunca ambas.
- Los paths de lectura se restringen a request.data, event.data, results y actor.
Updates.target es relativo a request.data; /id significa data.id y no request.id.
La detección de destinos solapados y la validación de datos corresponden a T06.
- Actor solo se construirá desde identidad verificada. No incluye JWT ni secretos
y no se toma del evento. EvaluationContext no expone servicios externos.
- Optional representa campos ausentes del modelo interno; los adapters deben
omitirlos o traducirlos según el contrato, no serializar el dominio directamente.
Versiones de contrato/perfil y schemas pueden faltar en borradores; T06 debe
resolverlos y fijarlos al publicar. SchemaVersion desconocida se diagnostica
en validación, no se interpreta como otra versión del proceso.
- ActionCapability procede del catálogo confiable. Su clasificación de efectos
y schemas no forman parte de ActionDefinition editable. NO_OP solo es para
simulación; T06 debe impedir publicarlo y T11 controlar su ejecución.
- ActionResult.Completed admite resultados negativos normalizados del negocio.
TechnicalFailure expresa fallo técnico TRANSIENT, PERMANENT o UNKNOWN_EFFECT.
La elegibilidad de reintento también requiere revisar efectos y checkpoints;
el record no autoriza por sí solo un reintento.
- ExecutionStatus.RECONCILIATION_REQUIRED sigue activo; SUCCEEDED/FAILED liberan
la exclusión. RUNNING exige lease y otros estados no lo admiten. SUCCEEDED
exige resultado, aunque el resultado conserve el estado anterior del negocio.
- Los hashes usan 64 caracteres hexadecimales en minúsculas, como SQL y API.
Los contadores no son negativos y los checkpoints exitosos exigen resultado y
fecha final. El worker debe verificar tokens y tiempos de PostgreSQL en T10.
## Errores y adaptadores
ErrorCode mantiene códigos de máquina independientes de HTTP. DomainException
expone solo el código, sin incorporar entradas ni respuestas crudas del proveedor.
Los adaptadores API resolverán status, correlationId y mensajes permitidos.
DecisionResult.Failure solo acepta EVALUATION_ERROR o AMBIGUOUS_DECISION; un
fallo nunca se representa mediante la ruta default.
ValidationReport contiene incidencias con JSON Pointer, código y severidad.
ValidationReportResponse convierte solo errores al arreglo errors del contrato;
las advertencias internas no invalidan el flujo ni agregan campos no publicados.
El DTO se comprueba contra api.schema.json. No hay endpoint nuevo en T04.
No hay anotaciones JPA ni entidades persistentes disfrazadas de records de dominio.
Los futuros adapters de persistencia deberán mapear explícitamente sus entidades
hacia estos snapshots; tampoco deben devolver entidades por REST.
## Alcance verificado
DomainModelTest cubre inmutabilidad, AST, pointers, ausencia/null, clasificación de
resultados, versiones, leases/checkpoints, reintentos y errores. DomainBoundaryTest
comprueba el DTO contra el esquema real. DomainArchitectureTest recompila todos
los archivos domain y application/port con classpath vacío y solo java.base;
además prohíbe referencias java.net/java.sql. Así detecta dependencias accidentales
de infraestructura, API, Spring/JPA o clientes HTTP.
No hay implementaciones ficticias de los puertos ni llamadas a proveedores.
T05 implementará el evaluador; T06 el validador/publicación; T11 los handlers.
Los constructors garantizan integridad local del objeto; no sustituyen validación
del DSL, autorización, transacciones, canonicalización ni A01–A20.
## Evaluación implementada en T05
PureDecisionEvaluator implementa el puerto de decisiones. ConditionEvaluator,
JsonPointerResolver, AstLimits y IntervalOverlapAnalyzer son componentes puros.
Ver [semántica y límites](DECISION_EVALUATOR.md). T06/T11 conservan la implementación
del validador y handlers; no se añadió ninguna llamada HTTP ni persistencia.

86
specs/EVENTS.md Normal file
View File

@ -0,0 +1,86 @@
# Aceptación durable de eventos — T09
Implementado `POST /api/v1/requests/{id}/events`, habilitado por
`REQUEST_API_ENABLED=true` junto con T08. Requiere JWT válido, propietario y ámbito
coincidentes, `Idempotency-Key` y un `If-Match` fuerte como `"0"`.
```json
{"event":"SUBMIT","data":{}}
```
El evento y sus datos deben corresponder al flujo publicado de la solicitud.
No se aceptan estado, roles, identidad, resultados o transición desde el cliente.
Se aplican los mismos límites de T08: datos canónicos de 256 KiB, cuerpo acotado
a 256 KiB + 4096 bytes y profundidad JSON 64; rechazo de duplicados y UTF-8 inválido.
## Protocolo
1. Verificar acceso; bloquear la solicitud con SELECT FOR UPDATE, filtrando otra
vez propietario/ámbito dentro de la transacción.
2. Buscar la clave por request_id/idempotency_key. Mismo hash y sujeto autorizado:
devolver executionId y estado técnico actual. Hash distinto: 409
IDEMPOTENCY_CONFLICT. El hash SHA-256 incluye evento, datos canónicos y revisión
esperada; cambiar If-Match también cambia el comando.
3. Sin replay, comprobar revisión (412) y ausencia de ejecución activa (409
REQUEST_BUSY). La revisión se comprueba antes de busy, por lo que una carrera
con dos claves y la misma revisión produce un aceptado y otro 412.
4. Leer la definición inmutable fijada por flow_id, sin resolver el activo del tipo.
PUBLISHED y RETIRED pueden ejecutar instancias existentes. Resolver la transición
para estado/evento, exigir uno de allowedRoles (403), validar inputSchema (422)
y evaluar guardia pura con request.data/event.data/actor confiables.
5. Insertar execution PENDING con identidad mínima, evento, transición y estado
origen. Incrementar revisión y updated_at de solicitud, insertar EVENT_ACCEPTED.
Confirmar las tres escrituras en una sola transacción READ COMMITTED.
6. Retornar 202 con Location `/api/v1/executions/{id}` y AcceptedEvent contractual.
accepted_revision guarda la revisión **posterior a aceptar** (If-Match + 1), que
T10 deberá verificar al confirmar trabajo. La solicitud conserva datos y estado
de negocio; T09 no ejecuta acciones, transiciones finales ni HTTP a proveedores.
No se guarda el JWT: actor_snapshot contiene subject, businessScope y roles.
La auditoría registra transición, revisión y correlationId, sin copiar datos del evento.
## Reintentos y errores
El replay se comprueba antes de revisión, busy, transición, roles y guardia actuales.
Devuelve el mismo executionId y status actual, incluso después de avanzar la
solicitud o terminar la ejecución. Exige siempre acceso actual y el sujeto original;
una revocación del rol de transición no vuelve a autorizar efectos, pues el replay
solo consulta la aceptación anterior. No incrementa revisión ni duplica auditoría.
Para reintentar un comando perdido, conservar también el If-Match original.
Un evento nuevo usa otra clave. Mientras haya PENDING, RUNNING, RETRY_WAIT o
RECONCILIATION_REQUIRED no se admite otra ejecución. Las restricciones SQL existentes
son defensa adicional. Un error de persistencia revierte ejecución, revisión y
auditoría; no consume la clave.
Evento no válido en el estado: 409 INVALID_EVENT. Guardia falsa: 422
GUARD_NOT_SATISFIED; ruta ausente/tipo incompatible: 422 EVALUATION_ERROR, no una
condición falsa ni resultado de negocio. Sin If-Match: 428; débil/múltiple/inválido:
400. Recurso ajeno/inexistente: 404; sin autenticación: 401; infraestructura: 503
sin exponer SQL. Revisión agotada en Long.MAX_VALUE no se incrementa y rechaza 409.
## Alcance y verificación
El worker T10 y las integraciones T11 permanecen pendientes. Sin worker, los eventos
aceptados permanecen PENDING. GET /requests/{id} muestra la revisión nueva y la
ejecución activa. La URI Location es la contractual; el endpoint GET /executions/{id}
todavía no está implementado y se completará con el seguimiento de ejecución T10.
No se afirma idempotencia de efectos externos hasta implementar sus protocolos.
EventAcceptanceIT usa PostgreSQL real, login restringido, JWT RSA/JWKS local y HTTP
real. Reutiliza únicamente el fixture de T07/T08 en orquestador_test. Cubre A07–A09
en aceptación: replay/409, carreras simultáneas, schemas y guardias, acceso/roles,
flujo retirado fijado, estados activos y rollback inducido al insertar ejecución,
actualizar revisión e insertar auditoría. Los cambios a SUCCEEDED/RUNNING/etc. son
fixtures SQL explícitos de prueba; no simulan un worker terminado en producción.
Una instancia nueva de adapter recupera el replay desde DB; no se anuncia una prueba
de reinicio del proceso ni procesamiento externo. T16 verificará el recorrido completo.
Comando: `mvnw.cmd clean verify -Pmigration-tests-external` con variables
MIGRATION_TEST_DB_URL/USER/PASSWORD en entorno. Los esquemas/roles temporales propios
se eliminan al terminar. No se modifica orquestador ni se despliega el servicio.
Desde T10 GET /executions/{id} y worker opcional ya están implementados. La sección
anterior de alcance corresponde al cierre de T09. Ver WORKER.md: sin WORKER_ENABLED
los aceptados siguen PENDING; con worker habilitado los flujos sin acciones pueden
concluir y las integraciones no registradas fallan explícitamente, sin mocks.

132
specs/FLOW_PUBLICATION.md Normal file
View File

@ -0,0 +1,132 @@
# Validación y administración de flujos (T06)
T06 entrega servicios internos verificables: FlowAdministration, FlowPreparation,
ConfiguredFlowValidator, FlowJsonCodec y PostgresFlowStore. Todavía no hay
controladores administrativos ni beans de catálogo predeterminados: T07 conectará
la identidad JWT verificada y la autorización antes de exponer REST. No se ofrece
un catálogo simulado en producción. Las firmas del servicio reciben Actor confiable,
revisión esperada y correlationId; nunca deben mapearse esos atributos desde el body.
## Operaciones
| Operación interna | Rol técnico | Efecto atómico |
|---|---|---|
| create | FLOW_EDITOR | Borrador y auditoría CREATE |
| edit | FLOW_EDITOR | Solo DRAFT, revisión esperada, identidad fija y auditoría EDIT |
| validate | FLOW_EDITOR o FLOW_PUBLISHER | Reporte de errores por JSON Pointer |
| publish | FLOW_PUBLISHER | Validar, fijar contratos/perfiles, checksum, PUBLISHED y auditoría |
| activate | FLOW_ADMIN | Flujo PUBLISHED, tipo habilitado/existente, revisión del tipo y auditoría |
| retire | FLOW_ADMIN | PUBLISHED a RETIRED, desactivar referencias activas y auditar |
Los tipos se provisionan por migración/configuración administrativa, como establece
el contrato T02. Al retirar, cada tipo que aún apunta a esa versión queda sin flujo
activo e incrementa revision; su clave queda en admin_audit.detail.deactivatedTypeKeys.
La operación no cambia ninguna instancia existente ni desactiva tipos que ya apuntan
a otra versión. Toda escritura se revierte si falla insertar la auditoría.
REVISION_CONFLICT es el código interno de revisión; el futuro adaptador REST lo
traducirá al REVISION_MISMATCH contractual (412). INVALID_FLOW, RESOURCE_NOT_FOUND,
ACCESS_DENIED y FlowValidationException tienen mensajes seguros. El adaptador debe
traducirlos al contrato y no devolver causas SQL. No reutilizar entidades/snapshots
como DTO REST. A16 tiene evidencia en servicio y DB; la comprobación HTTP con JWT
real se completa al exponer las rutas en T07/T16.
## Catálogo confiable y publicación
PublicationCatalog es un snapshot inmutable aportado por configuración confiable:
roles de negocio, handlers y sus schemas, integraciones por clave/versiones,
versiones de perfiles y políticas documentales (MIME/tamaño). No contiene endpoints,
headers ni secretos; no consulta disponibilidad de proveedores durante publicación.
Versiones explícitas deben existir. Versiones omitidas en borrador se resuelven a
los defaults del catálogo y se guardan dentro de la definición publicada antes del
checksum. Rotar el default no cambia versiones ya guardadas. El despliegue deberá
conservar los contratos/perfiles requeridos por versiones en ejecución. Las fixtures
de prueba describen contratos de ejemplo; no son adapters ni respuestas de un buró.
Se validan:
- Estructura DSL, schemaVersion, límites, claves duplicadas y schemas embebidos.
- Identificadores únicos, estado inicial, destinos, alcanzabilidad, terminales,
estados sin salida y una transición por pareja estado/evento.
- Roles conocidos, handlers/contratos/perfiles resolubles y prohibición de NO_OP.
- Inputs obligatorios/permitidos, referencias a resultados anteriores, tipos de
mappings y literales contra su schema. No se permiten referencias adelantadas.
- Cobertura exacta de salidas de decisiones, límites del AST, tipos básicos y
solapamientos UNIQUE demostrables mediante intervalos simples. FIRST conserva
precedencia explícita; el evaluador conserva controles de runtime.
- Updates relativos a data, destinos existentes y no solapados. Las rutas escritas
desde results se consideran datos de proveedor: no pueden ser aceptadas en
initialDataSchema/eventos ni escritas luego desde un origen no confiable.
- Tareas ligadas a transición/evento y equivalencia canónica de inputSchema.
DOCUMENT_UPLOAD requiere documentId UUID obligatorio, mapping desde el evento,
REGISTER_DOCUMENT del mismo tipo y límites dentro de la política confiable.
Publicación exige dataSchema de objeto e inputs iniciales/de eventos con type=object
expreso y additionalProperties=false. Si initialDataSchema falta se utiliza dataSchema;
si este permite recibir campos de proveedor, la publicación se rechaza hasta añadir
un schema inicial restrictivo. Los ejemplos estructurales de T02 no se convierten
silenciosamente en flujos publicables ni sustituyen los recorridos completos T14.
La compatibilidad estática comprueba tipos y literales, no implica demostrar inclusión
arbitraria entre JSON Schemas ni resolver SAT. T08 debe validar datos iniciales contra
ambos schemas, T11 salidas normalizadas y T10 datos candidatos antes del commit.
La existencia de mappings válidos no autoriza a omitir esas validaciones en runtime.
## JSON y checksum
El decoder rechaza claves duplicadas, tokens adicionales, JSON de más de 1 MiB,
nesting JSON mayor a 64 y números fuera del presupuesto. El límite de AST de T05
se aplica adicionalmente. Se rechazan NUL y surrogates aislados que no pueden
conservarse de forma segura en UTF-8/JSONB. Se usan los schemas originales empaquetados
en el JAR, sin duplicarlos ni aceptar referencias externas suministradas por un flujo.
El formato canónico de la aplicación ordena claves de objetos, conserva orden de
arrays y normaliza decimales con BigDecimal sin ceros finales. Conjuntos del modelo
(roles/MIME) se ordenan. Defaults de reintento quedan explícitos al guardar el modelo.
SHA-256 se calcula sobre UTF-8 de esa representación. No se afirma conformidad JCS
(RFC 8785), cuyo modelo numérico es diferente. Cambiar esta canonicalización en el
futuro requiere versionar/migrar con cuidado, no recalcular versiones publicadas.
## Transacciones y orden de bloqueos
PostgresFlowStore usa JDBC y TransactionTemplate con READ COMMITTED. El puerto y
FlowAdministration siguen siendo Java puro. El motivo de esta decisión frente al
JPA propuesto para agregados está registrado en ADR-010; no se introduce otro stack.
1. Edición/publicación bloquean flow_definition y verifican revision/estado.
2. Activación bloquea primero el flujo destino y después request_type; vuelve a
verificar revisión/habilitación antes de escribir.
3. Retiro bloquea el flujo y los tipos referenciados en orden de clave. El UPDATE
solo borra referencias que todavía apuntan a la versión retirada.
4. Para asignar una nueva instancia, assignableFlow lee el candidato sin bloqueo,
bloquea el flujo, bloquea el tipo y vuelve a verificar la referencia, enabled y
PUBLISHED. Un cambio concurrente produce conflicto, nunca una asignación ciega.
T08 deberá invocar assignableFlow e insertar solicitud, idempotencia y auditoría
**dentro de la misma transacción y DataSource**. No obtener el FlowVersion, cerrar la
transacción y usarlo después para crear la solicitud. La sesión rechaza uso después
de su cierre. Si la creación gana el bloqueo, su instancia queda fijada y el retiro
posterior la respeta; si gana el retiro, no se permite la nueva asignación.
No hay caché de activo. La resolución consulta PostgreSQL. No se modificaron V1/V2:
los triggers y permisos existentes continúan protegiendo versiones e historial.
Los administradores de DB conservan sus facultades; el servicio debe usar el rol
limitado documentado en db/migration/README.md.
## Ejecutar verificaciones
Con JDK 21, `./mvnw test` comprueba contratos, modelo, decisiones y validación T06.
`./mvnw clean verify` añade las dos suites PostgreSQL con Testcontainers. Sin Docker,
usar migration-tests-external y las variables MIGRATION_TEST_DB_* descritas en RUNBOOK.
FlowPublicationIT solo opera sobre orquestador_test en un esquema t06_<UUID> propio,
que elimina al finalizar. Incluye fallos reales de auditoría mediante un trigger
exclusivo del esquema de pruebas, barreras de concurrencia y ambos órdenes de la
carrera asignación/retiro. No ejecuta migraciones sobre orquestador ni despliega el JAR.
networknt 1.5.9 pasa de dependencia de test a runtime para validar publicación.
Jackson 2 queda confinado en ese adaptador; los records de dominio no dependen de él.
La configuración HTTP de Spring Boot continúa usando sus componentes existentes.
La prueba de arquitectura recompila también la administración de aplicación con
solo java.base, sin JDBC, Spring, serializadores ni API.

View File

@ -57,155 +57,192 @@ Dependencias: T02. Entregables: db/migration/, pruebas *IT.
Entorno objetivo actualizado por el usuario: PostgreSQL 9.6.2 disponible en server02; ver ADR-006. Usar DDL compatible con 9.6 y un entorno aislado para pruebas de escritura. La comprobación de conexión/arranque E11 no cierra esta tarea.
- [ ] Crear tablas, FKs e índices.
- [ ] Añadir idempotencia de creación/admin_audit/reconciliation_resolution.
- [ ] Proteger inmutabilidad e historial con roles y triggers.
- [ ] Probar migraciones desde cero.
- [x] Crear tablas, FKs e índices.
- [x] Añadir idempotencia de creación/admin_audit/reconciliation_resolution.
- [x] Proteger inmutabilidad e historial con roles y triggers.
- [x] Probar migraciones desde cero.
Verificación: Testcontainers PostgreSQL, rollback y migraciones. Cierre: Restricciones e inmutabilidad verificadas en PostgreSQL real.
Cerrado el 2026-10-26: 92 contratos y 40 pruebas de integración aprobadas mediante clean verify -Pmigration-tests-external sobre PostgreSQL 9.6.2 real en orquestador_test. Migraciones, rollback, permisos y concurrencia verificados; aislamiento y alternativa a Docker local documentados en ADR-007/E12.
## T04 — Modelo de dominio y puertos
Dependencias: T02,T03. Entregables: flow/, request/, execution/, decision/, action/.
- [ ] Definir records y errores estables.
- [ ] Separar DTOs y entidades.
- [ ] Implementar puertos DecisionEvaluator, ActionHandler y FlowDefinitionValidator.
- [x] Definir records y errores estables.
- [x] Separar DTOs y entidades.
- [x] Implementar puertos DecisionEvaluator, ActionHandler y FlowDefinitionValidator.
Verificación: Pruebas de dominio y dependencias entre capas. Cierre: Dominio sin HTTP/JPA y contratos tipados.
Cerrado el 2026-10-26: mvnw test con Java 21; 134 pruebas aprobadas, incluidas 42 de dominio/arquitectura/frontera. Dominio y puertos recompilados sin infraestructura y DTO validado contra el contrato existente. No hay entidades JPA ni endpoints nuevos; implementaciones de puertos pendientes de T05/T06/T11. Evidencia E13 y ADR-008.
## T05 — Evaluador de decisiones
Dependencias: T04. Entregables: decision/, pruebas de AST.
- [ ] Implementar operadores/JSON Pointer/BigDecimal.
- [ ] Distinguir null y ausente.
- [ ] Aplicar límites y cortocircuito.
- [ ] Resolver UNIQUE/FIRST y ambigüedad.
- [x] Implementar operadores/JSON Pointer/BigDecimal.
- [x] Distinguir null y ausente.
- [x] Aplicar límites y cortocircuito.
- [x] Resolver UNIQUE/FIRST y ambigüedad.
Verificación: JUnit parametrizado: 71/70/61/60, tipos y solapamientos. Cierre: A04–A06 y errores de evaluación comprobados.
Cerrado el 2026-10-26: mvnw test con Java 21; 216 pruebas aprobadas, incluidas 82 nuevas de decisiones/pointers/intervalos. A04–A06 verificados al nivel de evaluador puro y contrato de salida; no como flujo REST completo. Límites, cortocircuito y arquitectura comprobados. Ver E14, ADR-009 y DECISION_EVALUATOR.md.
## T06 — Validador, publicación y activación
Dependencias: T03,T04,T05. Entregables: flow/, admin_audit, pruebas *IT.
- [ ] Validar grafo, mappings, roles y capacidades.
- [ ] Editar borradores con revision.
- [ ] Publicar checksum canónico e inmutabilidad.
- [ ] Activar/retirar con bloqueos y auditoría.
- [x] Validar grafo, mappings, roles y capacidades.
- [x] Editar borradores con revision.
- [x] Publicar checksum canónico e inmutabilidad.
- [x] Activar/retirar con bloqueos y auditoría.
Verificación: Pruebas PostgreSQL de carreras publicación/activación/retiro. Cierre: A16–A17; catálogo resoluble y publicación consistente.
Cerrado el 2026-10-26 para servicios internos: clean verify -Pmigration-tests-external, 258 pruebas unitarias + 51 IT PostgreSQL aprobadas (309 total). A16 verificado en servicio/DB y A17 con asignación transaccional real; exposición REST/JWT queda en T07 conforme a ADR-010. No se anuncia A16 HTTP completo ni integración de proveedor real. Evidencia E15 y FLOW_PUBLICATION.md.
## T07 — Seguridad y autorización por recurso
Dependencias: T04. Entregables: shared/infrastructure/security/, autorización por capacidad.
- [ ] Validar JWT real (firma/issuer/audience/exp).
- [ ] Mapear permisos técnicos y roles.
- [ ] Comprobar propiedad/scope en cada recurso.
- [ ] Separar rol DB de ejecución.
- [x] Validar JWT real (firma/issuer/audience/exp).
- [x] Mapear permisos técnicos y roles.
- [x] Conectar catálogo confiable/servicios T06 y exponer administración REST con pruebas JWT; completar A16 HTTP (ADR-010).
- [x] Comprobar propiedad/scope en cada recurso.
- [x] Separar rol DB de ejecución.
Verificación: Tokens firmados válidos/expirados, otro issuer/audience, scope/rol ajenos. Cierre: A15; rutas protegidas antes de exposición.
Cerrado el 2026-10-27: 265 unitarias + 57 IT (322 total), sin fallos/errores/omisiones. Evidencia E16, SECURITY.md y ADR-011. A15 verificado mediante puerta real de recursos y controlador exclusivo de test; endpoints de negocio futuros deben integrarla. Keycloak real y despliegue pendientes. Simulación HTTP pendiente; seguimiento explícito en T16 (ADR-018).
## T08 — Crear y consultar solicitudes
Dependencias: T06,T07. Entregables: request/, repositorios y API.
- [ ] Resolver y fijar flujo activo.
- [ ] Validar datos iniciales.
- [ ] Idempotencia durable de creación.
- [ ] Exponer consulta y ETag con acceso por recurso.
- [x] Resolver y fijar flujo activo.
- [x] Validar datos iniciales.
- [x] Idempotencia durable de creación.
- [x] Exponer consulta y ETag con acceso por recurso.
Verificación: IT creación repetida/conflictiva y activación concurrente. Cierre: Creación atómica e idempotente, versión fijada.
Cerrado el 2026-10-27: clean verify -Pmigration-tests-external, 268 unitarias + 66 IT (334 total), cero fallos/errores/omisiones. Creación/replay/consulta HTTP, activación y retiro con barreras, rollback SQL. Evidencia E17, REQUESTS.md y ADR-012. Sin despliegue.
## T09 — Aceptar eventos idempotentes
Dependencias: T08. Entregables: execution/api, aceptación, auditoría.
- [ ] Bloquear solicitud.
- [ ] Verificar hash antes de revisión.
- [ ] Validar If-Match/rol/guardia.
- [ ] Insertar PENDING, revision y auditoría atómicamente.
- [ ] Responder 202/Location.
- [x] Bloquear solicitud.
- [x] Verificar hash antes de revisión.
- [x] Validar If-Match/rol/guardia.
- [x] Insertar PENDING, revision y auditoría atómicamente.
- [x] Responder 202/Location.
Verificación: IT duplicados, conflictos, concurrencia y rollback. Cierre: A07–A09; aceptación durable sin HTTP.
Cerrado el 2026-10-27: clean verify -Pmigration-tests-external, 268 unitarias + 76 IT (344 total), cero fallos/errores/omisiones. A07–A09 cubiertos en aceptación, sin acciones externas. Evidencia E18, EVENTS.md y ADR-013. Worker y GET ejecución pendientes T10; sin despliegue.
## T10 — Worker PostgreSQL y leases
Dependencias: T09. Entregables: execution/infrastructure, worker y repositorios.
- [ ] Reclamar con SKIP LOCKED.
- [ ] Renovar lease con tiempo DB.
- [ ] Exigir owner/token en escrituras.
- [ ] Checkpoints por acción y recuperación inicial.
- [x] Reclamar con SKIP LOCKED.
- [x] Renovar lease con tiempo DB.
- [x] Exigir owner/token en escrituras.
- [x] Checkpoints por acción y recuperación inicial.
- [x] Exponer GET /executions/{id} con autorización y estado técnico (seguimiento del Location T09; ADR-013).
Verificación: IT dos workers, lease vencido y checkpoint; no @Async en memoria. Cierre: Cola durable multiinstancia; worker obsoleto no confirma.
Cerrado el 2026-10-27. Suite completa: 268 unitarias + 91 IT; tras dos controles adicionales, verificación final: 268 unitarias + 17 WorkerIT. Evidencia vigente de 361 casos distintos (268 + 93), todos aprobados. Ver E19, WORKER.md y ADR-014. Sin despliegue ni proveedores reales; adapters T11 y política completa de retry T12 pendientes.
## T11 — Handlers e integraciones HTTP
Dependencias: T10. Entregables: action/, integration/, mocks, compose.yaml.
- [ ] Implementar catálogo confiable y REGISTER_DOCUMENT/INVOKE_INTEGRATION/NO_OP sólo simulación.
- [ ] Fijar contratos/perfiles.
- [ ] Mapear entradas/salidas y timeouts.
- [ ] Añadir mocks.
- [x] Implementar catálogo confiable y REGISTER_DOCUMENT/INVOKE_INTEGRATION/NO_OP sólo simulación.
- [x] Fijar contratos/perfiles.
- [x] Mapear entradas/salidas y timeouts.
- [x] Añadir mocks.
Verificación: Mocks de cliente encontrado/no encontrado, timeout y salida inválida. Cierre: A01–A03; fallos técnicos separados del resultado de negocio.
Verificación: Mocks HTTP de cliente encontrado/no encontrado, timeout y salida
inválida; worker con PostgreSQL real, contratos fijados y rollback documental.
Cierre T11: A01/A02 y separación técnica de A03. La programación completa de
reintentos de A03 corresponde a T12; ver ADR-015. Proveedores reales requieren
contratos/credenciales confirmados y no son sustituidos por evidencia de mocks.
Cerrado el 2026-10-27: 301 unitarias + 99 IT = 400 aprobadas en la suite completa, sin fallos/errores/omisiones. Evidencia E20, INTEGRATIONS.md y ADR-015. Mocks HTTP locales y PostgreSQL real; integración real pendiente de contratos/credenciales. Siguiente T12.
## T12 — Reintentos y conciliación
Dependencias: T11. Entregables: execution/, endpoint resolve, pruebas *IT.
- [ ] Limitar intentos/backoff/jitter.
- [ ] Recuperar leases según clasificación de efecto.
- [ ] Tratar UNKNOWN.
- [ ] Conciliar con evidencia y autorización/idempotencia.
- [x] Limitar intentos/backoff/jitter.
- [x] Recuperar leases según clasificación de efecto.
- [x] Tratar UNKNOWN.
- [x] Conciliar con evidencia y autorización/idempotencia.
Verificación: Fault injection antes/después de envío, checkpoint y commit. Cierre: A12–A14,A19; efectos inciertos no liberan solicitud.
Cerrado el 2026-10-27: 303 unitarias + 116 IT = 419 aprobadas; 17 casos T12 incluyen cinco caídas reales de JVM, retries, conciliación concurrente y rollback. Ver E21, RECONCILIATION.md y ADR-016. Sin despliegue ni proveedor real. Siguiente T13.
## T13 — Documentos y tareas para front
Dependencias: T11,T12. Entregables: document/, request/available-events.
- [ ] Verificar pertenencia/tipo/MIME/tamaño/checksum/antimalware.
- [ ] Guardar referencias.
- [ ] Exponer schemas/UI y bloqueos.
- [ ] Impedir escritura de resultados del proveedor.
- [x] Verificar pertenencia/tipo/MIME/tamaño/checksum/antimalware.
- [x] Guardar referencias.
- [x] Exponer schemas/UI y bloqueos.
- [x] Impedir escritura de resultados del proveedor.
Verificación: Documento ajeno/inválido, ejecución activa y payload con score. Cierre: A11,A20; INVALID conserva tarea, error técnico no equivale a INVALID.
Cerrado el 2026-10-27: 308 unitarias + 137 IT = 445 aprobadas; incluye 8 casos de disponibilidad y 13 documentales con PostgreSQL real y mocks HTTP. Ver E22, AVAILABLE_EVENTS.md y ADR-017. Backend únicamente; sin interfaz gráfica, despliegue ni documentos reales. Siguiente T14.
## T14 — Flujos demostrativos v1/v2
Dependencias: T05,T06,T13. Entregables: examples/flows/, examples/http/.
- [ ] Crear crédito v1, crédito v2 con licencia y compra v1 completos.
- [ ] Publicar/activar y recorrer.
- [ ] Demostrar coexistencia de versiones sin cambios Java/columnas.
- [x] Crear crédito v1, crédito v2 con licencia y compra v1 completos.
- [x] Publicar/activar y recorrer.
- [x] Demostrar coexistencia de versiones sin cambios Java/columnas.
Verificación: Recorridos completos de v1/v2, licencia válida/inválida y fronteras. Cierre: A10,A18; dos tipos de solicitud publicables.
Cerrado el 2026-10-28: 311 unitarias + 29 IT = 340 aprobadas; demo 10, documentos 13 y HTTP 6. Ver E23 y ADR-018. Tres flujos publicados/activados y recorridos con mocks; src/main y JAR idénticos a T13. Siguiente T15.
## T15 — Auditoría y observabilidad
Dependencias: T09,T10,T11,T12,T13,T14. Entregables: audit/, métricas, logs, health.
- [ ] Completar consultas paginadas/filtradas.
- [ ] Métricas sin cardinalidad por solicitud.
- [ ] Logs sin PII.
- [ ] Readiness/liveness y apagado ordenado.
- [x] Completar consultas paginadas/filtradas.
- [x] Métricas sin cardinalidad por solicitud.
- [x] Logs sin PII.
- [x] Readiness/liveness y apagado ordenado.
Verificación: Rollback/auditoría, acceso a historial, métricas y apagado. Cierre: Auditoría atómica desde T06 y operación observable.
Cerrado el 2026-10-28: 314 unitarias + 163 IT = 477 aprobadas, más 4 unitarias + 1 IT para el ajuste final de cursor. Ver E24, OBSERVABILITY.md y ADR-019. Historial/ACL, métricas, logs, DB-health, apagado y rollback verificados localmente; siguiente T16.
## T16 — Verificación integral
Dependencias: T15. Entregables: src/test/, CI, matriz A01–A20.
- [ ] Automatizar A01–A20.
- [ ] Reinicios y concurrencia deterministas.
- [ ] Verificar fronteras de rollback y seguridad.
- [ ] Conservar reportes.
- [x] Automatizar A01–A20.
- [x] Reinicios y concurrencia deterministas.
- [x] Verificar fronteras de rollback y seguridad.
- [x] Conservar reportes.
- [x] Cerrar pendiente del endpoint puro /admin/flows/{id}/simulate, registrado desde T07 (ADR-018); implementado/verificado por SimulationIT y FlowSimulationTest (ADR-020).
- [ ] Ejecutar suite completa y gate A01–A20 con Docker/Testcontainers operativo.
Verificación: ./mvnw clean verify con Docker, sin skips de integración. Cierre: Todos los casos obligatorios pasan en entorno reproducible.
Estado 2026-10-28: BLOCKED solo por entorno Docker. Implementación/simulación, CI y matriz completos; 493 pruebas Java y 2 del checker aprobadas con PostgreSQL externo. Testcontainers confirmó ausencia de motor válido. Ver E25, ACCEPTANCE.md y ADR-020; no equivale a cierre Docker.
## T17 — Empaquetado y documentación
Dependencias: T16. Entregables: Dockerfile, compose.yaml, README, RUNBOOK, examples/http, CI.

88
specs/INTEGRATIONS.md Normal file
View File

@ -0,0 +1,88 @@
# Integraciones HTTP — T11
El worker utiliza `RegisteredWorkerActions`: `INVOKE_INTEGRATION` invoca HTTP y
`REGISTER_DOCUMENT` verifica metadatos y guarda una referencia bajo fencing.
`NO_OP` existe exclusivamente como utilidad de simulación y no está registrado
en Spring ni en el worker; la publicación continúa rechazándolo.
## Configuración de despliegue
Se necesitan dos archivos confiables, fuera de los eventos/definiciones editables:
- `FLOW_CATALOG_PATH`: contratos JSON Schema, efectos, perfiles/versiones y políticas documentales.
- `INTEGRATION_ROUTES_PATH`: endpoint y límites para cada combinación exacta de integración, contrato, perfil y versión.
Los archivos por defecto en `config/` tienen registros vacíos: no habilitan llamadas.
Los ejemplos de `examples/integrations/` describen un protocolo local propuesto,
no contratos confirmados de proveedores. Sus dominios `.invalid` son marcadores.
Copiar/adaptar ambos archivos, mantener todas las versiones usadas por solicitudes
vigentes y configurar las rutas antes de habilitar `WORKER_ENABLED=true`.
Cambiar un default no modifica las versiones fijadas al publicar. No sustituir
el significado de una versión existente: crear otra versión/tupla.
Compose conserva un único servicio y PostgreSQL externo. Monta `./config:/app/config:ro`;
la ruta predeterminada dentro del contenedor es `/app/config/integration-routes.json`.
Los tokens se referencian por nombre de variable con `tokenEnvironment`.
Declarar explícitamente esa variable en el entorno del servicio Docker (un valor
en el .env de Compose no se transmite automáticamente al contenedor). No guardar
el token en los JSON, los flujos ni los eventos. Cadena vacía permite un endpoint
sin bearer cuando el contrato de despliegue lo requiera.
## Protocolo y límites
- POST JSON con inputs resueltos por el worker, validados contra el contrato.
- Headers: Idempotency-Key estable por ejecución/acción, X-Correlation-Id,
X-Integration-Profile y X-Profile-Version cuando corresponda; bearer opcional.
- HTTPS; HTTP solo loopback con perfil Spring `local` o `test`. Sin redirecciones,
userinfo, query o fragment en la URL.
- connectMs positivo; timeoutMs cubre headers y cuerpo completo, máximo 30 s y
menor que el lease. maxBytes limita memoria antes de superar 256 KiB.
- Respuesta JSON objeto, UTF-8 estricto, sin claves duplicadas ni coerción de
strings a números. Decimales conservan precisión.
- outputMappings mapea campos superiores de salida a JSON Pointers de la
respuesta. Vacío significa identidad. La salida normalizada debe cumplir schema.
- Contratos inline: no se resuelven referencias remotas desde el worker.
Un 2xx válido con `found=false` o `status=INVALID` es éxito técnico y resultado
de negocio negativo. Un 404 es error salvo `notFoundOutput` explícito, permitido
solo para contratos READ_ONLY; ese resultado también se valida. No habilitar esa
traducción sin confirmar el contrato real.
Timeout/429/500/502/503/504 son transitorios; con NON_IDEMPOTENT_WRITE se considera
efecto incierto. Una salida inválida tras escritura también es incierta. Los
códigos internos no exponen cuerpo remoto, URL o token. La clasificación no
garantiza idempotencia del proveedor: solo declarar IDEMPOTENT_WRITE si existe
garantía contractual para la clave enviada.
**T12:** el worker programa RETRY_WAIT para fallos transitorios seguros, con
backoff/jitter y Retry-After acotados, hasta el máximo publicado de tres intentos
por acción. Conserva checkpoints; efectos inciertos requieren conciliación.
Consultar RECONCILIATION.md para el endpoint y la semántica de A03.
## Documentos
REGISTER_DOCUMENT consulta el protocolo fijo `DOCUMENTS_METADATA` contrato `1`,
sin perfil, con documentId/requestId/businessScope. El servicio documental debe
responder identidad de documento/solicitud/ámbito, tipo, identificador de storage,
MIME, tamaño entero, checksum SHA-256 hexadecimal y scanStatus=CLEAN. El handler
compara pertenencia y política; no confía en metadatos enviados por el cliente.
La inserción en document_reference y el checkpoint SUCCEEDED comparten transacción
y fencing, con bloqueo de solicitud antes de ejecución. Una referencia existente
solo se reutiliza si coincide completamente. Fallo SQL revierte ambas escrituras.
No se descargan/suben archivos ni se ejecuta un antivirus: se verifica el dictamen
del servicio documental confiable. T13 completa eventos disponibles y tareas de front.
## Verificación reproducible
`mvnw.cmd test` ejecuta mocks HTTP loopback sin PostgreSQL ni proveedores reales.
`mvnw.cmd clean verify -Pmigration-tests-external` agrega PostgreSQL real utilizando
MIGRATION_TEST_DB_URL/USER/PASSWORD en el proceso y base exclusiva de pruebas.
Sin ese perfil, verify utiliza Testcontainers y requiere Docker.
HttpIntegrationTest prueba transporte y contratos; RegisterDocumentTest verifica
metadatos y carga de ejemplos; HttpIntegrationIT conecta publicación, API de
solicitudes/eventos, worker, HTTP y PostgreSQL con rollback inducido.
Los estados START/END del fixture representan los destinos de A01/A02; los flujos
demostrativos con nombres funcionales corresponden a T14. No hay integración real
aprobada de clientes, documentos, buró o Keycloak ni despliegue en server02:8787.

100
specs/OBSERVABILITY.md Normal file
View File

@ -0,0 +1,100 @@
# Auditoría y operación (T15)
## Historial
`GET /api/v1/requests/{id}/history` requiere REQUEST_API_ENABLED y JWT válido.
Aplica la misma propiedad y businessScope que GET de solicitud; FLOW_ADMIN y
EXECUTION_OPERATOR no tienen un bypass para consultar historial ajeno. Un recurso
ajeno/inexistente responde 404. La respuesta cumple HistoryPage y usa no-store.
Filtros opcionales: kind, executionId, from inclusivo y to exclusivo (UTC terminado
en Z). limit por defecto 50, rango 1–100. Orden ascendente por (createdAt,id),
mediante keyset; cursor opaco de máximo 2000 caracteres ligado a solicitud y filtros.
Se permite cambiar limit conservando los demás filtros. Al terminar se omite
nextCursor. Cursor inválido o usado con otros filtros devuelve 400.
El cursor representa posición, no autorización ni una credencial firmada. Cada
página vuelve a autorizar el recurso; modificar una posición nunca amplía su acceso.
Los inserts concurrentes pueden aparecer en páginas posteriores; no es una foto
transaccional de toda la navegación. Reiniciar desde from permite releer períodos
cuando hay transacciones que confirmaron tarde. El consumidor deduplica por id.
Proyección de detail permitida: correlationId, transitionId, acceptedRevision,
ruleIds, reasonCodes, actionIds, hitPolicy, code, actionId, delayMs, mode y resolutionId.
Se omiten datos de entrada, resultados completos, tokens y evidencia del operador.
actorSubject se entrega únicamente al propietario autorizado. Nuevas transiciones
guardan reasonCodes de resultados de acciones cuando existen; no se reconstruyen
razones para auditorías históricas que no las contenían.
Se mantienen las escrituras atómicas T06–T12 y triggers append-only V2. No hay una
segunda escritura de auditoría posterior al commit ni una migración nueva. El rol
runtime no modifica ni elimina historial. Retención/eliminación requiere una política
de datos aprobada y procedimiento privilegiado aparte; este servicio no borra
auditoría automáticamente ni interpreta un TTL como permiso de borrado.
## Métricas
`/actuator/metrics` y `/actuator/metrics/{name}` requieren EXECUTION_OPERATOR y
businessScope incluido en ADMIN_BUSINESS_SCOPES. No dependen de habilitar edición
de flujos. Actuator restante está denegado salvo las tres rutas públicas de salud.
No se agrega servidor Prometheus ni dependencia nueva; Micrometer/Actuator ya están
incluidos en el servicio.
| Nombre | Tipo/etiquetas | Significado |
| --- | --- | --- |
| orchestrator.commands | Counter: command=create/event; result=accepted/rejected/error | Respuestas HTTP de comandos, incluidos replays; accepted es 2xx, error 5xx y rejected resto |
| orchestrator.executions | Gauge: state fijo | Filas durables por estado técnico, compartidas entre réplicas |
| orchestrator.pending.oldest.seconds | Gauge sin etiquetas | Edad DB del PENDING más antiguo; cero sin pendientes |
| orchestrator.leases.expired | Gauge sin etiquetas | RUNNING con lease vencido; no implica efecto externo duplicado |
| orchestrator.queue.sample.available | Gauge sin etiquetas | 1 muestra SQL disponible; 0 fallo; demás gauges NaN al fallar |
| orchestrator.integration.duration | Timer: integration, result=completed/failed/error | Tiempo del adapter HTTP, incluido contrato/mapeo; solo claves del catálogo/rutas permitidas |
Los gauges se consultan bajo demanda y comparten una muestra SQL por proceso de
5 segundos; query timeout 3 segundos. Son estados actuales, no contadores históricos
de transiciones. RECONCILIATION_REQUIRED expone conciliaciones pendientes y RETRY_WAIT
reintentos programados. No hay etiquetas por UUID, sujeto, ámbito, URL, perfil,
excepción libre o datos de proveedor. Las claves de integración pertenecen a
configuración confiable. Contadores/timers locales se reinician con el proceso;
no son el ledger durable. Para varias réplicas no sumar gauges de la misma DB.
## Logs y correlación
El filtro genera X-Correlation-Id; no confía en uno suministrado por el cliente.
Comandos registran tipo, resultado HTTP, código de estado, correlación y duración.
Worker registra requestId, executionId, flowId, versión; acciones añaden actionId,
intento, resultado técnico y duración. `phase=committed` se emite después del commit.
Un log de acción completada puede preceder un rollback: consultar ejecución/historial
para el resultado durable. No registra payload, documento, sujeto, token, score,
evidencia ni excepción con body de proveedor. correlationId de EVENT_ACCEPTED enlaza
HTTP con executionId del historial y del worker; el adapter propaga executionId
como X-Correlation-Id estable entre intentos. Las decisiones y razones quedan en
auditoría restringida, no en logs.
## Salud y apagado
`/actuator/health/liveness` consulta disponibilidad del proceso, sin DB/proveedores.
`/actuator/health/readiness` añade PostgreSQL; falla 503 cuando DB no está disponible.
`/actuator/health` es salud agregada. Las rutas públicas omiten detalles/componentes.
No se llama a clientes/documentos/buró para determinar salud.
Spring usa server.shutdown=graceful y 30 s por fase. El worker deja de reclamar,
cierra el poller (hasta 2 s) y conserva heartbeats mientras drena trabajos durante
WORKER_SHUTDOWN_SECONDS (1–20, predeterminado 20). Al agotar, interrumpe trabajos y
espera 1 s adicional; cancela renovaciones. Los adapters deben respetar interrupción
y sus límites de tiempo. Una operación no cooperativa puede seguir hasta su timeout;
no se presume cancelación de un efecto externo. Cola/checkpoints permanecen durables
y se recuperan por lease/fencing y conciliación T12. No se liberan leases por apagar.
Compose mantiene stop_grace_period=40s para la JVM y PostgreSQL externo.
## Verificación
ObservabilityIT usa API/JWT real, esquema y rol temporales en orquestador_test:
historial/ACL/filtros/cursor/empates, métricas SQL, Actuator y logs con sentinelas.
Cierra únicamente su propio pool para demostrar readiness 503/liveness 200.
ObservabilityTest usa latches y worker real con store controlado para drenaje,
renovación e interrupción dentro del presupuesto; HTTP loopback prueba timers.
WorkerIT conserva pruebas reales de rollback de auditoría, recuperación y fencing.
No es una prueba de despliegue Docker ni de caída de PostgreSQL de producción.
Fuentes de API: [Micrometer](https://docs.micrometer.io/micrometer/reference/concepts/meters.html)
y [Actuator Health](https://docs.spring.io/spring-boot/api/rest/actuator/health.html).

View File

@ -1,6 +1,6 @@
# Seguimiento de implementación
Actualizado: 2026-10-26. T01 y T02 DONE. Siguiente tarea: T03 (PostgreSQL/Flyway). El motor y endpoints de negocio no están implementados.
Actualizado: 2026-10-28. T01–T15 DONE. T16 BLOCKED: implementación y A01–A20 aprobados con PostgreSQL externo; falta corrida Docker/Testcontainers. Ver E25. T17 PENDING; ninguna tarea principal IN_PROGRESS.
Estados permitidos: PENDING, IN_PROGRESS, BLOCKED, DONE. No más de una tarea principal IN_PROGRESS. Consultar IMPLEMENTATION_PLAN para subtareas y criterios.
@ -8,20 +8,20 @@ Estados permitidos: PENDING, IN_PROGRESS, BLOCKED, DONE. No más de una tarea pr
|---|---|---|---|---|---|---|---|
| T01 | DONE | — | pom.xml, mvnw*, .mvn, configuración, DeploymentSmokeCheck.java, ADR-001/004 | E09: clean verify con JDK 21 y deployment-smoke | BUILD SUCCESS; 5 pruebas, 0 fallos, 0 errores, 0 omitidas | Ninguno para T01 | Iniciar T02: OpenAPI y JSON Schema |
| T02 | DONE | T01 DONE | contracts/openapi.yaml, schemas/*.schema.json, examples/contracts, ContractValidationTest.java, ADR-005 | E10: clean verify con Java 21 | BUILD SUCCESS; 92 comprobaciones, 0 fallos/errores/omisiones | Ninguno para T02 | Iniciar T03: migraciones PostgreSQL y Testcontainers |
| T03 | PENDING | T02 DONE | db/migration/, pruebas *IT | E11 verifica conexión/arranque 9.6; migraciones pendientes | Compatibilidad inicial aprobada; modelo no implementado | Falta entorno aislado para pruebas de migraciones | Crear DDL compatible con PostgreSQL 9.6 según ADR-006 |
| T04 | PENDING | T02,T03 | flow/, request/, execution/, decision/, action/ | Pendiente: Pruebas de dominio y dependencias entre capas | No ejecutado | Dependencias sin completar | Definir records y errores estables |
| T05 | PENDING | T04 | decision/, pruebas de AST | Pendiente: JUnit parametrizado: 71/70/61/60, tipos y solapamientos | No ejecutado | Dependencias sin completar | Implementar operadores/JSON Pointer/BigDecimal |
| T06 | PENDING | T03,T04,T05 | flow/, admin_audit, pruebas *IT | Pendiente: Pruebas PostgreSQL de carreras publicación/activación/retiro | No ejecutado | Dependencias sin completar | Validar grafo, mappings, roles y capacidades |
| T07 | PENDING | T04 | shared/infrastructure/security/, autorización por capacidad | Pendiente: Tokens firmados válidos/expirados, otro issuer/audience, scope/rol ajenos | No ejecutado | Dependencias sin completar | Validar JWT real (firma/issuer/audience/exp) |
| T08 | PENDING | T06,T07 | request/, repositorios y API | Pendiente: IT creación repetida/conflictiva y activación concurrente | No ejecutado | Dependencias sin completar | Resolver y fijar flujo activo |
| T09 | PENDING | T08 | execution/api, aceptación, auditoría | Pendiente: IT duplicados, conflictos, concurrencia y rollback | No ejecutado | Dependencias sin completar | Bloquear solicitud |
| T10 | PENDING | T09 | execution/infrastructure, worker y repositorios | Pendiente: IT dos workers, lease vencido y checkpoint; no @Async en memoria | No ejecutado | Dependencias sin completar | Reclamar con SKIP LOCKED |
| T11 | PENDING | T10 | action/, integration/, mocks, compose.yaml | Pendiente: Mocks de cliente encontrado/no encontrado, timeout y salida inválida | No ejecutado | Dependencias sin completar | Implementar catálogo confiable y REGISTER_DOCUMENT/INVOKE_INTEGRATION/NO_OP sólo simulación |
| T12 | PENDING | T11 | execution/, endpoint resolve, pruebas *IT | Pendiente: Fault injection antes/después de envío, checkpoint y commit | No ejecutado | Dependencias sin completar | Limitar intentos/backoff/jitter |
| T13 | PENDING | T11,T12 | document/, request/available-events | Pendiente: Documento ajeno/inválido, ejecución activa y payload con score | No ejecutado | Dependencias sin completar | Verificar pertenencia/tipo/MIME/tamaño/checksum/antimalware |
| T14 | PENDING | T05,T06,T13 | examples/flows/, examples/http/ | Pendiente: Recorridos completos de v1/v2, licencia válida/inválida y fronteras | No ejecutado | Dependencias sin completar | Crear crédito v1, crédito v2 con licencia y compra v1 completos |
| T15 | PENDING | T09,T10,T11,T12,T13,T14 | audit/, métricas, logs, health | Pendiente: Rollback/auditoría, acceso a historial, métricas y apagado | No ejecutado | Dependencias sin completar | Completar consultas paginadas/filtradas |
| T16 | PENDING | T15 | src/test/, CI, matriz A01–A20 | Pendiente: ./mvnw clean verify con Docker, sin skips de integración | No ejecutado | Dependencias sin completar | Automatizar A01–A20 |
| T03 | DONE | T02 DONE | V1/V2 SQL, DatabaseMigrationIT.java, configuración, ADR-007, RUNBOOK | E12: clean verify -Pmigration-tests-external con JDK 21 | 92 contratos + 40 IT aprobados en PostgreSQL 9.6.2; 0 fallos/errores/omisiones | Ninguno para T03; ruta Docker no ejecutada localmente | T04: modelo de dominio y puertos |
| T04 | DONE | T02,T03 DONE | */domain, */application/port, flow/api/ValidationReportResponse, Domain*Test, DOMAIN_MODEL.md, ADR-008 | E13: mvnw test con JDK 21; compilación aislada y DTO contra schema | 134 pruebas aprobadas; 0 fallos/errores/omisiones | Ninguno | T05: implementar evaluador de decisiones |
| T05 | DONE | T04 DONE | decision/domain, PureDecisionEvaluator, tres suites T05, DECISION_EVALUATOR.md, ADR-009 | E14: mvnw test con JDK 21 | 216 pruebas aprobadas, 0 fallos/errores/omisiones; A04–A06 a nivel evaluador/contrato | Ninguno | T06: validador, publicación y activación |
| T06 | DONE | T03,T04,T05 DONE | flow/application, flow/infrastructure, PublicationCatalog, FlowValidationTest, FlowPublicationIT, FLOW_PUBLICATION.md, ADR-010 | E15: clean verify -Pmigration-tests-external | 258 unitarias + 51 IT aprobadas; A16 servicio/DB y A17 asignación; REST pendiente T07 | Ninguno para servicios internos T06 | T07: JWT, autorización por recurso y exposición administrativa |
| T07 | DONE | T04 DONE | shared/infrastructure/security, ResourceAccess, flow/api, SecurityPolicyTest, SecurityHttpIT, SECURITY.md, ADR-011 | E16: clean verify -Pmigration-tests-external | 265 unitarias + 57 IT aprobadas; JWT/HTTP/DB y A15 puerta de recursos/A16 HTTP | Keycloak real y despliegue pendientes, fuera de verificación local | T08: reutilizar autorización para solicitudes |
| T08 | DONE | T06,T07 DONE | request/application, request/api, PostgresRequestStore, RequestJson, PersistenceAccessConfiguration, RequestCreationIT, REQUESTS.md, ADR-012 | E17: clean verify -Pmigration-tests-external | 268 unitarias + 66 IT aprobadas; creación atómica, replay, concurrencia, ETag/ACL | Ninguno para implementación local; despliegue pendiente | T09: aceptar eventos idempotentes |
| T09 | DONE | T08 DONE | execution/api, Events/EventStore, EventPolicy, PostgresEventStore, EventAcceptanceIT, EVENTS.md, ADR-013 | E18: clean verify -Pmigration-tests-external | 268 unitarias + 76 IT aprobadas; A07–A09 aceptación durable, guardias, roles, rollback | Ninguno para aceptación local; worker y despliegue pendientes | T10: worker, leases y consulta de ejecución |
| T10 | DONE | T09 DONE | WorkerStore/PostgresWorkerStore, WorkerEngine/WorkerLoop, ExecutionController, V3, WorkerIT, WORKER.md, ADR-014 | E19: suite completa + verificación final WorkerIT | 268 unitarias y 93 IT distintos aprobados; worker final 17 casos, fencing/checkpoints/recuperación | Ninguno para worker local; adapters T11 y retry T12 pendientes | T11: handlers e integraciones HTTP |
| T11 | DONE | T10 DONE | action/infrastructure, integration/infrastructure, PostgresWorkerStore, mocks HTTP, examples/integrations, INTEGRATIONS.md, ADR-015 | E20: clean verify -Pmigration-tests-external | 301 unitarias + 99 IT = 400 aprobadas; 0 fallos/errores/omisiones | Integración real de proveedores pendiente de contratos/credenciales; sin bloqueo local | T12: reintentos y conciliación |
| T12 | DONE | T11 DONE | RetryDelay, WorkerEngine/Store, Resolutions/PostgresResolutionStore, ResolutionController, RetryReconciliationIT, RECONCILIATION.md, ADR-016 | E21: clean verify -Pmigration-tests-external | 303 unitarias + 116 IT = 419 aprobadas; retries, resolución atómica y 5 caídas de JVM reales | Ninguno local; proveedores reales fuera de alcance | T13: documentos y tareas para front |
| T13 | DONE | T11,T12 DONE | AvailableEvents/Policy/Store/Controller, DocumentMetadata, WorkerEngine, AvailableEventsIT/DocumentTasksIT, AVAILABLE_EVENTS.md, ADR-017 | E22: clean verify -Pmigration-tests-external | 308 unitarias + 137 IT = 445 aprobadas; A11/A20 y tareas/bloqueos verificados | Ninguno local; documental real fuera de alcance | T14: flujos demostrativos v1/v2 |
| T14 | DONE | T05,T06,T13 DONE | examples/flows, examples/integrations, demo-flows.http, DemoFlowTest/DemoFlowsIT, ADR-018 | E23: verify -Pmigration-tests-external -Dit.test=DemoFlowsIT,DocumentTasksIT,HttpIntegrationIT | 311 unitarias + 29 IT = 340 aprobadas; A10/A18, fronteras y dos tipos completos | Proveedores reales fuera del alcance local; simulate registrado para T16 | T15: auditoría y observabilidad |
| T15 | DONE | T09,T10,T11,T12,T13,T14 DONE | audit/api, HistoryStore, QueueMetrics, CommandObservationFilter, Worker/HTTP, ObservabilityTest/IT, OBSERVABILITY.md, ADR-019 | E24: clean verify -Pmigration-tests-external + check final cursor | 314 unitarias + 163 IT = 477; ajuste final 4 unitarias + 1 IT aprobados, sin fallos ni omisiones | Ninguno local; Docker/proveedores reales fuera de evidencia | T16: verificación integral |
| T16 | BLOCKED | T15 DONE | FlowSimulation/SimulationController, TransitionEvaluator, SimulationTest/IT, acceptance-cases.json, verify_acceptance.py, CI, ACCEPTANCE.md, ADR-020 | E25: clean verify externo + gate A01–A20 + prueba Docker | 326 unitarias + 167 IT = 493 aprobadas; A01–A20 PASS externo; 2 tests Python aprobados | Testcontainers no encuentra entorno Docker válido; CI remoto no ejecutado | Habilitar motor Docker autorizado y ejecutar clean verify + gate --require-testcontainers |
| T17 | PENDING | T16 | Dockerfile, compose.yaml, README, RUNBOOK, examples/http, CI | Pendiente: Build de imagen, compose, verify y demo completa de sección 16 | No ejecutado | Dependencias sin completar | Construir imagen y Compose completos |
## E11 — Adaptación PostgreSQL 9.6.2 — 2026-10-26 13:23 (America/Mexico_City)
@ -96,9 +96,339 @@ Con la configuración del repositorio, readiness incluye PostgreSQL. Esta prueba
- Siguiente paso: completar .env, ejecutar docker compose config --quiet y docker compose up --build -d; comprobar readiness y logs. T01 continúa BLOCKED y T17 PENDING hasta satisfacer sus criterios completos.
1. Leer este registro, especificación y cambios del repositorio.
2. T01/T02 DONE. Preparar T03: migraciones desde cero, constraints, índices, triggers y entorno PostgreSQL/Testcontainers.
3. Registrar evidencia de T03 y mantener una única tarea principal IN_PROGRESS. No repetir verificaciones aprobadas sin cambios o fallos que lo justifiquen.
2. T01–T10 DONE. Siguiente tarea habilitada: T11, handlers e integraciones HTTP.
3. Registrar evidencia de la siguiente tarea y mantener una única tarea principal IN_PROGRESS. No repetir verificaciones aprobadas sin cambios o fallos que lo justifiquen.
## Plantilla de evidencia adicional
`Fecha | ID | Archivos | Comando/check exacto | Resultado y reporte | Bloqueo | Próximo paso`
## E12 — Cierre T03 — 2026-10-26 14:12 (America/Mexico_City)
- Entorno autorizado: server02:30432/orquestador_test, PostgreSQL 9.6.2 real; conexión inicial de solo lectura confirmó nombre y versión. No se modificó la base orquestador ni se desplegó el servicio remoto.
- Archivos: V1__orchestrator_tables.sql, V2__immutability_and_runtime_privileges.sql, DatabaseMigrationIT.java, pom.xml, application.yaml, compose.yaml, .env.example, probe de compatibilidad y documentación.
- Comando: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1 y credenciales MIGRATION_TEST_DB_* en entorno de proceso.
- Resultado final: BUILD SUCCESS, 55.245 s, 92 pruebas Surefire y 40 Failsafe, cero fallos/errores/omisiones. Reportes: target/surefire-reports/com.organizacion.orchestrator.ContractValidationTest.txt y target/failsafe-reports/com.organizacion.orchestrator.DatabaseMigrationIT.txt.
- V1/V2 aplicadas desde esquema vacío y segunda ejecución sin migraciones pendientes. Verificadas diez tablas, FKs, JSONB/checks, idempotencia, índice activo incluyendo conciliación, flujos publicados/retirados inmutables, históricos append-only, rol runtime sin DDL ni cambios de auditoría, rollback atómico, rollback de migración fallida, carrera de dos inserciones y SKIP LOCKED.
- La primera corrida encontró una comparación textual false/f en la prueba JDBC de permisos; se corrigió con cast explícito a text. La suite completa final pasó, incluyendo los checks restantes del rol.
- El ERROR de Flyway V999 es intencional: el test comprueba que DDL e historial se revierten. V999 es temporal, no se entrega como migración.
- Esquema final aislado t03_304ad66fc7c3485ca63e474f2fe76c19 eliminado por AfterAll; rol temporal revertido y su ausencia comprobada. No se guardaron contraseñas en archivos del proyecto.
- Testcontainers 2.0.5/PostgreSQL 9.6.24 preparado como ruta normal de verify/CI; ejecución local no realizada por ausencia de Docker. ADR-007 documenta alternativa real externa y límites.
- Siguiente: T04 PENDING con dependencias satisfechas. T06 conserva la validación semántica/publicación y T07 la autorización por recurso.
## E13 — Cierre T04 — 2026-10-26 15:52 (America/Mexico_City)
- Criterios previos: records y errores estables; DTO separado de dominio/persistencia; puertos DecisionEvaluator, ActionHandler y FlowDefinitionValidator; dominio sin dependencias HTTP/JPA. Pruebas pertinentes: invariantes e inmutabilidad, recompilación aislada y compatibilidad DTO/schema.
- Implementación: 30 archivos de dominio/puertos bajo shared, flow, request, execution, decision y action; un DTO de validación en flow/api. JSON sellado e inmutable con BigDecimal y null/ausencia distintos; AST y acciones tipadas; identidad por recurso; snapshots de flujo, solicitud, ejecución y checkpoint. Ver DOMAIN_MODEL.md y ADR-008.
- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress test`, JAVA_HOME configurado al JDK local 21.0.12.1.
- Resultado: BUILD SUCCESS, 22.456 s, 134 tests (92 contractuales existentes + 39 DomainModelTest + 1 DomainArchitectureTest + 2 DomainBoundaryTest), cero fallos/errores/omisiones. Reportes en target/surefire-reports.
- Arquitectura: los 30 archivos domain/application/port recompilaron con classpath aislado y únicamente java.base, sin fuentes API/infraestructura. Se prohíben además referencias java.net/java.sql. No se añadió dependencia Maven.
- Verificado: copias inmutables de colecciones, precisión decimal, null/ausencia, pointers permitidos, orden de AST/reglas/acciones, políticas de reintento acotadas, publicación/retiro, ejecución activa y conciliación, lease consistente, resultado/checkpoint exitoso obligatorio, separación de fallo técnico/resultado negativo y hashes compatibles con DB/API. DTO de validación comprobado contra api.schema.json.
- Primera ejecución: 129 pruebas aprobadas. Revisión posterior alineó hashes a hexadecimal minúscula y añadió el DTO y cinco casos adicionales; la ejecución final de 134 pruebas aprobó todo.
- Alcance: sin cambios a SQL, contratos, configuración de despliegue o credenciales. No se accedió a PostgreSQL ni se repitieron sus IT de E12, al no cambiar persistencia. No se desplegó el servicio. Los puertos no tienen implementaciones simuladas; T05/T06/T11 conservan sus criterios funcionales y A01–A20 siguen pendientes.
- Siguiente tarea: T05 PENDING con dependencia satisfecha. No hay tarea principal IN_PROGRESS.
## E14 — Cierre T05 — 2026-10-26 17:05 (America/Mexico_City)
- Criterios previos: once operadores, JSON Pointer, BigDecimal, tipos estrictos, null/ausencia, cortocircuito, límites, UNIQUE/FIRST y ambigüedad. Verificar scoring 71/70/61/60 y solapamientos simples, con dominio independiente de infraestructura.
- Archivos: PureDecisionEvaluator, ConditionEvaluator, JsonPointerResolver, AstLimits, EvaluationLimits, IntervalOverlapAnalyzer; DecisionEvaluatorTest, JsonPointerResolverTest, IntervalOverlapAnalyzerTest; ampliación de DomainArchitectureTest; documentación y ADR-009.
- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress test`, JDK 21.0.12.1. BUILD SUCCESS, 13.414 s. 216 pruebas, cero fallos/errores/omisiones: 92 contratos + 56 decisiones + 17 pointers + 9 intervalos + 39 dominio + 1 arquitectura + 2 DTO/schema.
- Reportes: target/surefire-reports/com.organizacion.orchestrator.DecisionEvaluatorTest.txt, JsonPointerResolverTest.txt e IntervalOverlapAnalyzerTest.txt (mismo prefijo de paquete); pruebas previas también aprobadas.
- A04: fixture contractual scoring.json produce N/M/M/MANUAL para 71/70/61/60, además de fracciones precisas y valores fuera del rango ilustrativo. No se impuso rango universal 0–100.
- A05: fixtures de proveedor con score ausente/string rechazan BureauResult; si llegan al evaluador producen EVALUATION_ERROR, nunca default. Null también falla. No es aún prueba de adapter HTTP/proveedor.
- A06: varias coincidencias UNIQUE producen AMBIGUOUS_DECISION, incluso con igual output. FIRST conserva precedencia y regla aplicada; errores no se convierten a negocio negativo. Evaluación pura sin escrituras.
- Verificados límites exactos de 20 niveles/1000 nodos, suma de reglas y ramas no ejecutadas, entrada de 50000 niveles rechazada sin stack overflow, comparación estructural/IN acotados, cortocircuito ordenado y 40 evaluaciones concurrentes con la misma instancia/contexto intacto.
- Intervalos: extremos abiertos/cerrados, conjunciones, intervalos vacíos y operandos invertidos. Fuera del subconjunto se informa alcance desconocido; no se afirma satisfacibilidad general. Arquitectura recompila también el evaluador application con java.base y sin I/O/HTTP/JPA.
- Primera corrida: 215 pruebas aprobadas; se añadió rechazo temprano por número de reglas y una prueba de frontera exacta de nodos. Corrida final: 216 aprobadas.
- Sin cambios SQL, contratos REST/DSL ni dependencias; no se accedió a PostgreSQL ni proveedores, no hubo despliegue. Los IT PostgreSQL de E12 no se repitieron porque no cambió persistencia. A04–A06 están cubiertos al nivel T05; la aceptación integral REST permanece en T16.
- Siguiente: T06 PENDING con dependencias satisfechas. Ninguna tarea principal IN_PROGRESS.
## E15 — Cierre T06 (servicios internos) — 2026-10-26 21:58 (America/Mexico_City)
- Criterios previos: validar grafo/schemas/roles/capacidades/mappings; edición con revisión; publicar definición inmutable y checksum con contratos/perfiles fijados; activar/retirar con bloqueos y auditoría atómica. Verificar fallos y carreras en PostgreSQL real. ADR-010 explicita la separación entre servicio interno T06 y exposición REST segura T07.
- Archivos: PublicationCatalog, FlowJsonCodec, ConfiguredFlowValidator, FlowPreparation/FlowStore, CatalogFlowPreparation, FlowAdministration/FlowValidationException, PostgresFlowStore, FlowFixtures/FlowValidationTest/FlowPublicationIT, DomainArchitectureTest, pom.xml y documentación.
- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JAVA_HOME en JDK 21.0.12.1, credenciales MIGRATION_TEST_DB_* solo en entorno de proceso.
- Resultado final: BUILD SUCCESS, 2:07 min. 258 pruebas Surefire y 51 Failsafe, cero fallos/errores/omisiones. FlowValidationTest: 42 casos; FlowPublicationIT: 11 casos reales (47.86 s); DatabaseMigrationIT: 40 casos (58.63 s). Reportes: target/surefire-reports y target/failsafe-reports/com.organizacion.orchestrator.FlowPublicationIT.txt / DatabaseMigrationIT.txt.
- Validación: grafo, terminales/sumideros/alcanzabilidad, IDs/parejas únicas, roles, schemas cerrados, catálogo/perfiles, NO_OP, inputs y resultados previos, tipos, updates solapados, protección de datos de proveedor, tareas documentales, decisiones y límites. Decoder completo del DSL y roundtrip de fixtures T02, rechazo de claves duplicadas/JSON excesivo/referencias externas/Unicode incompatible, canonicalización y hash estable.
- Persistencia: ciclo crear/editar/publicar/activar/retirar, revisiones obsoletas, inmutabilidad de servicio y DB, permisos técnicos internos, contratos/perfiles fijados, retiro sin alterar instancias existentes y limpieza auditada de referencias activas. Cada escritura administrativa se revirtió ante un fallo SQL real inducido en admin_audit.
- Concurrencia con barreras: dos publicadores; editar/publicar; dos activaciones; activar/retirar; asignar antes del retiro; retiro antes de asignación. Ninguna asignación posterior al retiro ni cambio parcial. T08 debe conservar la misma transacción para solicitud/idempotencia/auditoría, según FLOW_PUBLICATION.md.
- A16 acreditado a nivel API interna y DB; comprobación REST/JWT no ejecutada ni anunciada como aprobada. A17 acreditado con puerta de asignación e inserción real de solicitudes en transacción. T07/T08/T16 completarán las rutas y el recorrido HTTP. No se inició T07.
- Aislamiento: únicamente orquestador_test. Último esquema T06: t06_ef065d4bc2c34f5dbbe3f839e7d1d96a, eliminado por AfterAll; también se limpió el esquema propio T03. Trigger de fallo solo en esquema de prueba. No se tocó orquestador ni se hizo redeploy.
- La primera solicitud de ejecución remota agotó el plazo de revisión automática y no se inició. El reintento permitido fue aprobado y pasó (255 unitarias + 50 IT); tras ampliar controles/auditoría y añadir el orden inverso de la carrera, la corrida final pasó con 309 pruebas. No queda bloqueo de aprobación.
- Artefacto: target/orquestador-nmp-0.1.0-SNAPSHOT.jar, SHA256 2E60C16853ECB1118922294996DF1041CA79A9CC9D71C394FD8C848EAF53B5DA. Inspección ZIP confirmó tres schemas empaquetados, networknt 1.5.9 y Jackson 2.21.7 junto al Jackson 3.1.7 de Boot. No se ejecutó Docker ni arranque/redeploy de este JAR.
- Decisión técnica: JDBC transaccional para administración, detrás de puertos puros, en lugar del JPA propuesto para estos agregados; motivo/alternativas documentados en ADR-010. No se añadieron migraciones ni se cambiaron contratos REST. No hay proveedor real ni catálogo simulado registrado en producción.
- Siguiente: T07 PENDING, dependencias satisfechas. Ninguna tarea principal IN_PROGRESS.
## E16 — Cierre T07 — 2026-10-27 02:47 (America/Mexico_City)
- Criterios: JWT firmado (firma/issuer/audience/exp), roles técnicos y scope, puerta de propiedad para solicitud/ejecución/documento/auditoría, runtime DB restringido y seis rutas administrativas T06 con A16 por HTTP. ADR-011 registra decisiones de alcance y autorización.
- Archivos: SecurityConfiguration, JwtActorMapper, RuntimeRoleVerifier, ResourceAccess/ResourceOwnershipStore/ResourceOwner, PostgresResourceOwnershipStore, FlowAdministrationController/FlowView, CorrelationFilter/ProblemResponse/ApiExceptionHandler, PublicationCatalogLoader/FlowAdministrationConfiguration, config/, compose.yaml, application.yaml, .env.example, SecurityPolicyTest, SecurityHttpIT, ResourceProbe exclusivo de test, DomainArchitectureTest y SECURITY.md.
- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1; credenciales MIGRATION_TEST_DB_* solo en entorno de proceso. BUILD SUCCESS, 2:10 min, finalizado 2026-10-27T02:47:52-06:00.
- Resultado: 265 pruebas Surefire + 57 Failsafe = 322, cero fallos/errores/omisiones. IT: DatabaseMigrationIT 40, FlowPublicationIT 11, SecurityHttpIT 6. SecurityPolicyTest 7. Reportes target/surefire-reports y target/failsafe-reports.
- JWT RSA real y JWKS HTTP local: firma ajena, issuer/audience incorrectos, expiración, nbf futuro, ausencia de exp/sub/ámbito y token malformado/ausente denegados. Roles realm/cliente correcto aceptados; otros clientes y OAuth scope no conceden permisos. Ámbito administrativo ajeno denegado.
- HTTP administrativo: crear/validar/publicar/activar/retirar; rol editor no publica; ETag y precondiciones 428/400/412; editar publicado 409 (A16); JSON duplicado/malformado 400, schema inválido 422, cuerpo excesivo 413. Health anónimo 200, ausencia de cookies de sesión en errores autenticados.
- A15: los cuatro recursos persistidos resuelven propietario/ámbito a través de PostgreSQL. Dueño del mismo ámbito accede; mismo dueño de otro ámbito, otro dueño incluso FLOW_ADMIN y recurso inexistente reciben 404. Conciliación exige operador y ámbito, con delegación explícita de propietario. Se verifica por HTTP con ResourceProbe, controlador solo de test fuera del escaneo productivo, no son las rutas funcionales futuras.
- DB: conexión independiente con LOGIN temporal no elevado, concesiones V2, migración con propietario distinto; RuntimeRoleVerifier acepta runtime y rechaza propietario. DDL, DELETE admin_audit y lectura flyway_schema_history rechazados por PostgreSQL real.
- Aislamiento: únicamente orquestador_test; schema final t07_64e1695df2154c3ba0ad28c4530c0ef0 y rol t07_runtime_64e1695df2154c3ba0ad28c4530c0ef0 eliminados por AfterAll. Esquemas propios T03/T06 también limpiados. No se modificó orquestador, no se desplegó ni se invocaron proveedores.
- Corrida inicial aprobada: 265 unitarias; integración dirigida 4 casos aprobados el 2026-10-26. Se amplió con recursos persistidos y roles de cliente; la corrida final incluye esos 6 casos y todas las regresiones. El wrapper requirió ejecución fuera del sandbox para acceder a la caché/JDK; revisión automática aprobó ejecución. No queda bloqueo técnico.
- Configuración local: administración deshabilitada por defecto; al habilitar exige usuario DB restringido, catálogo y lista ADMIN_BUSINESS_SCOPES. business_scope es el claim por defecto configurable, pendiente de confirmar contra un JWT real de controlms. Verificar emisión real Keycloak y desplegar no se presentan como pruebas aprobadas.
- Sin nuevas migraciones ni cambio de idempotencia/versionado. Compose conserva un único servicio y PostgreSQL externo. Simulación se mantiene pendiente T14 y resolución funcional T12. T08 debe integrar la puerta ResourceAccess y desacoplar su wiring del interruptor administrativo conservando la verificación DB. Siguiente: T08 PENDING; ninguna tarea principal IN_PROGRESS.
## E17 — Cierre T08 — 2026-10-27 03:03 (America/Mexico_City)
- Criterios previos: resolver/fijar flujo publicado activo, validar initialDataSchema y dataSchema, idempotencia por actor/ámbito, solicitud/auditoría/ledger atómicos, consulta con ETag/ejecución activa y autorización. Pruebas de carreras reales, rollback y HTTP firmado; no cerrar por compilación.
- Archivos: Requests/RequestStore/RequestSnapshot, RequestJson/PostgresRequestStore/RequestConfiguration, RequestController/RequestView, PersistenceAccessConfiguration, ajustes FlowAdministrationConfiguration/ApiExceptionHandler, flags application.yaml/compose/.env.example; RequestJsonTest, RequestCreationIT, fixture SecurityHttpIT y DomainArchitectureTest. Contrato OpenAPI/README, REQUESTS.md, SECURITY.md, ADR-012 y examples/http/requests.http actualizados.
- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`; JDK 21.0.12.1, MIGRATION_TEST_DB_* solo en entorno de proceso. BUILD SUCCESS, 2:34 min, finalizado 2026-10-27T03:03:36-06:00.
- Resultado: 268 unitarias Surefire + 66 IT Failsafe = 334; cero fallos/errores/omisiones. IT: DatabaseMigrationIT 40, FlowPublicationIT 11, RequestCreationIT 9, SecurityHttpIT 6. Reportes target/surefire-reports y target/failsafe-reports.
- HTTP: 201/Location/ETag, GET autorizado, RequestView/Problem contra schema contractual; replay idéntico con respuesta original después de activar v2, retirar v1 o avanzar revisión; GET conserva versión fijada y expone ejecución activa/ETag actuales. Conflicto 409 por otro contenido; clave independiente por actor/ámbito; scope/owner ajenos 404, sin JWT 401. Administración apagada 403 mientras solicitudes funcionan.
- Entrada: command cerrado, identidad cliente rechazada, ambos schemas de datos, tipos estrictos, score no permitido, tamaño UTF-8/canónico, JSON duplicado y decimal preciso. Ausencia de clave 400, schema 422, tamaño 413, tipo inexistente 404, tipo deshabilitado/sin activo 409. Fallos no consumen clave; replay no depende de disponibilidad del catálogo activo.
- Concurrencia real: dos POST de igual clave generan una única solicitud/respuesta; comandos distintos en carrera producen 201/409. Barreras dentro de la asignación comprueban retención de locks flujo/tipo hasta commit ante activación y retiro. Solicitud aceptada conserva versión anterior; posteriores usan nueva versión o rechazan tipo retirado. Regresiones de carreras T06 también aprobadas.
- Atomicidad: triggers exclusivos de prueba fallan realmente al insertar auditoría o ledger. No queda solicitud/ledger parcial; reintento tras fallo de auditoría funciona. Implementación JDBC comparte DataSource/READ COMMITTED con FlowStore y usa advisory lock transaccional más clave única completa; ADR-012 registra elección y semántica de replay.
- Aislamiento: únicamente orquestador_test. Se reutilizó fixture T07 con schema t07_704fbd4eaf8e4f649d08b40a78a6bf4c y rol LOGIN t07_runtime_704fbd4eaf8e4f649d08b40a78a6bf4c, eliminados por AfterAll de cada suite; T07 recrea/limpia esos objetos propios para regresión. T03/T06 limpian sus esquemas propios. No se tocó orquestador ni se desplegó en server02:8787.
- Primera corrida: 268 unitarias aprobadas; integración dirigida inicial de 7 casos aprobada (51.694 s). Se añadieron validación de esquema inicial y retiro concurrente; corrida final de 9 casos T08 y todas las regresiones aprobada. No quedan fallos conocidos ni bloqueos externos de prueba.
- No se necesitaron nuevas migraciones: V1/V2 contienen tabla idempotente, auditoría, permisos y restricciones. No hay proveedores ni mocks de resultados de negocio. JWT real firmado con JWKS local, no emisión real de controlms. Docker no ejecutado; Compose solo servicio con PostgreSQL externo y REQUEST_API_ENABLED=false por defecto. Para habilitar, configurar runtime restringido; independiente de administración.
- Siguiente: T09 PENDING con dependencia DONE. Ninguna tarea principal IN_PROGRESS. Eventos, worker y recorrido integral siguen pendientes; no se anuncian cubiertos por T08.
## E18 — Cierre T09 — 2026-10-27 03:36 (America/Mexico_City)
- Criterios previos: bloquear solicitud autorizada, replay antes de revisión, If-Match/rol/schema/guardia, ejecución PENDING + revisión + EVENT_ACCEPTED atómicos, respuesta 202/Location. Verificar A07–A09 con PostgreSQL real y rollback, sin HTTP de proveedores.
- Archivos: AcceptedEvent/EventPolicy, Events/EventStore, PostgresEventStore/EventConfiguration, EventController, ApiExceptionHandler; EventAcceptanceIT y ampliación DomainArchitectureTest. Contrato OpenAPI, EVENTS.md, ADR-013, ejemplos HTTP y documentación actualizados. No se reemplazó trabajo previo.
- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1 y credenciales MIGRATION_TEST_DB_* solo en entorno de proceso. BUILD SUCCESS, 7:31 min; finalizado 2026-10-27T03:36:50-06:00.
- Resultado: 268 unitarias Surefire + 76 IT Failsafe = 344, cero fallos/errores/omisiones. IT: DatabaseMigrationIT 40, EventAcceptanceIT 10, FlowPublicationIT 11, RequestCreationIT 9, SecurityHttpIT 6. Reportes target/surefire-reports y target/failsafe-reports, particularmente com.organizacion.orchestrator.EventAcceptanceIT.txt.
- A07: reintento devuelve mismo executionId y status actual sin nuevas escrituras, incluso tras cambiar estado/revisión/rol. Acceso actual y sujeto original se comprueban siempre. Nueva instancia del adapter recupera replay desde PostgreSQL; no se anuncia reinicio del proceso. Orden de JSON y escala decimal equivalente no alteran hash; revisión esperada sí lo altera.
- A08: clave repetida con evento/datos/revisión distintos produce 409 IDEMPOTENCY_CONFLICT. Carrera simultánea con payloads distintos produce un 202 y un 409; no duplica ejecución ni revisión.
- A09: dos envíos idénticos simultáneos comparten ejecución; dos claves con igual revisión producen 202/412. PENDING/RUNNING/RETRY_WAIT/RECONCILIATION_REQUIRED impiden nuevo evento con revisión actual (409 REQUEST_BUSY); replay sigue permitido.
- Seguridad/validación: JWT ausente 401, owner/scope ajenos 404, rol ajeno 403, identidad/roles en comando rechazados, campos de proveedor fuera del schema 422. Guardia falsa GUARD_NOT_SATISFIED y valor ausente EVALUATION_ERROR, ambos 422 sin escrituras. If-Match ausente 428, inválido 400, obsoleto 412; payload excesivo 413. AcceptedEvent y Problem comprobados contra schemas.
- Atomicidad: fallos SQL reales inducidos al insertar ejecución, actualizar revisión e insertar auditoría revierten todo; auditoría previa de creación se conserva. Reintento tras fallo de auditoría funciona. Error SQL por HTTP retorna 503 seguro. Estado y data de negocio permanecen intactos al aceptar; accepted_revision es la nueva revisión (If-Match + 1).
- Flujo fijado RETIRED sigue aceptando eventos de instancias previas. Caso con INVOKE_INTEGRATION configurado acepta PENDING sin checkpoints, resultado ni actualización de datos: no existe adapter HTTP de proveedor invocado. Los cambios SQL a estados técnicos usados por pruebas son fixtures explícitos, no un worker implementado.
- Aislamiento: solo orquestador_test. Fixture compartido T07/T08 usa schema t07_b8dc4f7d95774fbc92a43745b161065d y LOGIN t07_runtime_b8dc4f7d95774fbc92a43745b161065d, creados/eliminados por cada suite propia. T03/T06 también limpian sus esquemas. No se tocó orquestador, no hubo despliegue ni uso de datos personales/proveedores.
- Corridas previas: 268 unitarias aprobadas; IT dirigida inicial 8 casos aprobados en 2:09 min. Se añadieron carrera con payload distinto/canonicalización y flujo con integración declarada; corrida final de 10 casos T09 y regresiones aprobada. No quedan fallos conocidos.
- JAR generado: target/orquestador-nmp-0.1.0-SNAPSHOT.jar; SHA256 552B5273EB904EA6ED447BD48B18C98C0E33ED97FFA0353A4CFD2ED5F1609F16. Inspección ZIP confirma ausencia de clases/controladores de prueba. git diff --check aprobado.
- Sin nuevas migraciones; usa V1/V2 y REQUEST_API_ENABLED existente con runtime restringido. ADR-013 documenta replay, accepted_revision y alcance. GET /executions/{id} queda como subtarea explícita T10; Location mantiene contrato objetivo. GET de solicitud ya muestra activeExecutionId, pero sin worker los eventos permanecen PENDING. A07–A09 de efectos externos requieren T10/T11/T16.
- Siguiente: T10 PENDING con dependencia DONE. Ninguna tarea principal IN_PROGRESS. No se inició worker ni procesamiento externo.
## E19 — Cierre T10 — 2026-10-27 08:52 (America/Mexico_City)
- Criterios previos: reclamar con SKIP LOCKED, leases por tiempo DB, fencing en todas las escrituras, checkpoints/reuso y recuperación inicial; dos workers, lease vencido, retorno tardío, rollback final y GET de ejecución autorizado. No confundir executor local con cola durable ni declarar providers implementados.
- Archivos: WorkerState, WorkerStore/WorkerActions, PostgresWorkerStore, WorkerEngine/WorkerLoop/WorkerConfiguration, ExecutionController, PersistenceAccessConfiguration; V3__action_effect_checkpoint.sql, configuración application.yaml/compose/.env.example; WorkerIT y expectativas V3 de DatabaseMigrationIT; WORKER.md, ADR-014, contrato, ejemplos y documentación.
- Suite completa: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`; BUILD SUCCESS, 5:15 min, finalizado 2026-10-27T08:49:43-06:00. 268 unitarias + 91 IT (WorkerIT 15), cero fallos/errores/omisiones. Migraciones 40, eventos 10, publicación 11, creación 9, seguridad 6 y worker 15.
- Ajuste posterior acotado: fallar controladamente datos finales excesivos y validar tipo de capability; actualización contractual/documental. Se añadieron dos pruebas de worker (límite de datos y checkpoint faltante) y se verificó el código final con `.\mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external '-Dit.test=WorkerIT'`. BUILD SUCCESS, 2:36 min, finalizado 2026-10-27T08:52:38-06:00; 268 unitarias + 17 WorkerIT, sin fallos/errores/omisiones. No se repitieron IT ajenos al ajuste ya aprobados. Evidencia vigente: 361 casos distintos = 268 unitarias + 93 IT; no se presenta como una sola corrida final de 361.
- Entorno: JDK 21.0.12.1 y MIGRATION_TEST_DB_* solo en variables de proceso. Reportes target/surefire-reports y target/failsafe-reports; WorkerIT.txt refleja los 17 casos finales.
- Reclamación: dos stores/owners concurrentes reciben ejecuciones distintas; una fila bloqueada se omite; RETRY_WAIT futuro no se reclama hasta tiempo DB vencido. Renovación no revive lease vencido; recuperación incrementa fencing_token. Token antiguo no puede renovar, cargar, iniciar/guardar checkpoint, fallar ni confirmar.
- Procesamiento: ejecución sin acciones completa estado/datos/revisión/resultado/auditoría; decisión T05 aplica updates y conserva regla aplicada. Resultado del worker viejo que retorna después del nuevo no sobrescribe datos. Finalización rechaza snapshot/revisión obsoletos y checkpoints faltantes. Límites finales producen fallo controlado, no recuperación infinita.
- Checkpoints: caída simulada tras primer éxito conserva ese resultado, reintenta solo la acción incompleta con clave estable e input_hash compatible. Reconstrucción de repositorio/engine recupera desde PostgreSQL. Cambio de hash bloquea reuso. READ_ONLY/IDEMPOTENT_WRITE recuperables; NON_IDEMPOTENT_WRITE incierto no se repite y exige conciliación. Doble de proveedor idempotente registra dos llamadas pero un efecto por clave; esto no acredita un proveedor real.
- Atomicidad: fallo SQL inducido al auditar finalización revierte datos/estado/revisión/resultado finales; checkpoint de acción permanece SUCCEEDED y recuperación no repite llamada. Escritura exitosa seguida de conflicto final conserva solicitud bloqueada para conciliación. Adapter ausente falla explícitamente INTEGRATION_UNAVAILABLE, sin fabricar éxito.
- Scheduler real de prueba: reclama trabajo, renueva dos veces durante acción bloqueada por barrera, finaliza y se detiene; solicitud posterior queda PENDING. Heartbeat cancelado no interrumpe otro trabajo al reutilizar thread. Apagado drena con renovación hasta 20 s y deja trabajo restante para recuperación por lease.
- GET /executions/{id}: Pending/Succeeded/Failed contra schema ExecutionView, owner/scope ajenos 404, sin JWT 401. No se retornan snapshots de identidad, inputs, lease_owner o payload crudo de proveedor. Location T09 ya es consultable.
- Migración V3: persiste clasificación de efecto confiable; V1/V2 intactas, checkpoints previos conservadoramente NON_IDEMPOTENT_WRITE. Tres migraciones aplicadas/validadas e idempotentes; pruebas de permisos/rollback previas aprobadas con V3.
- Aislamiento: exclusivamente orquestador_test, fixtures temporales propios. Suite final WorkerIT: schema t07_b639d9ea7fe94294beead496e8b1d64b y rol t07_runtime_b639d9ea7fe94294beead496e8b1d64b eliminados por AfterAll. Suite completa usó t07_3a45246f4c704a54b855f3c40003427d más schemas propios T03/T06, también limpiados. No se tocó orquestador ni se desplegó.
- Límites explícitos: SimulatedCrash abandona intento y se reconstruye repositorio; no es reinicio de JVM. Adapters/proveedor deduplicador son dobles de test, no registros productivos. T11 implementará adapters reales/mocks HTTP; T12 política completa de errores transitorios/backoff/Retry-After/conciliación. T10 reconoce RETRY_WAIT y recuperación de lease, pero no anuncia retry HTTP completo. Docker y Keycloak reales no ejecutados en esta tarea.
- Defaults: WORKER_ENABLED=false, lease 60 s, renovación 15 s, concurrencia/lote 10, polling 500 ms. Puede correr sin API/admin, exige runtime DB restringido. Flujos sin acciones funcionan; integraciones no registradas fallan, por lo que se mantiene apagado para esos flujos hasta T11.
- Siguiente: T11 PENDING, dependencia DONE. Ninguna tarea principal IN_PROGRESS; no se inició integración de proveedor.
## E20 — Cierre T11 — 2026-10-27 11:40 (America/Mexico_City)
- Criterios previos: registro de handlers confiable, contratos/perfiles fijados, POST HTTP acotado, separación negocio/error, NO_OP solo simulación y referencias documentales verificadas. Pruebas HTTP reales contra mocks loopback y PostgreSQL real; sin proveedores ni despliegue.
- Archivos: HttpRoutes/BoundedHttp/HttpIntegrationHandler/IntegrationConfiguration, RegisteredWorkerActions/RegisterDocumentHandler/NoOpSimulationHandler, PublicationCatalogConfiguration, WorkerConfiguration y PostgresWorkerStore.save; config/integration-routes.json, application.yaml/compose/.env.example; HttpProviderFixture/HttpIntegrationTest/RegisterDocumentTest/HttpIntegrationIT, examples/integrations, INTEGRATIONS.md, WORKER.md, README y ADR-015.
- Prueba dirigida final: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dit.test=HttpIntegrationIT -Dtest=HttpIntegrationTest,RegisterDocumentTest`; BUILD SUCCESS, 2:13 min, finalizado 2026-10-27T11:25:47-06:00. 33 tests + 6 IT, cero fallos/errores/omisiones.
- Correcciones durante prueba: el fixture decimal usaba Double y se sustituyó por parser decimal estricto; el JSON documental del fixture se corrigió a inputs.documentId conforme al DSL existente. Las pruebas finales dirigidas pasan; no se alteró el contrato para aceptar el fixture.
- Transporte: JSON duplicado/malformado/schema inválido rechazado; precisión decimal, mappings, headers de perfil/clave estable, bearer desde entorno, secreto ausente sin envío, versiones no fijadas/ajenas sin envío, 404 explícito, 429/5xx, rechazo de redirecciones, límite de memoria y timeout del cuerpo después de headers. Escritura incierta no se transforma en resultado de negocio.
- Negocio: cliente encontrado persiste datos y avanza START→END; cliente no encontrado SUCCEEDED/CLIENTE_NO_ENCONTRADO conserva START; timeout/503/contrato inválido FAILED sin customer ni outcome negativo. DOCUMENTO_VALIDA INVALID es resultado de negocio; BURO_CONSULTA exige score numérico. Son contratos propuestos con datos sintéticos, no integración real aprobada.
- Documentos: valida pertenencia a solicitud/ámbito, identidad, tipo, MIME, tamaño, checksum y scanStatus. Guarda referencia y checkpoint en una transacción con fencing. Trigger SQL inducido revierte ambos; recuperación READ_ONLY repite consulta y completa una única referencia. Servicio documental mock, sin subir/descargar archivos ni ejecutar antivirus local.
- Configuración: wiring Spring del registro comprobado con worker activo y administración apagada; catálogos vacíos predeterminados no autorizan endpoints. Ejemplos cargados por loaders reales; URLs .invalid, sin credenciales. Compose conserva un servicio, montaje config de solo lectura y PostgreSQL externo. No se requieren nuevas migraciones; V1/V2/V3 intactas.
- Alcance: A01/A02 y separación técnica de A03 cubiertos en fixtures; retries/backoff/Retry-After de A03 pendientes T12 y recorrido completo T14/T16. Integración real de clientes/documentos/buró requiere contratos/credenciales; no se contactó proveedor real, Keycloak ni runtime orquestador/server02:8787. Docker no ejecutado.
- Regresión completa final: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1; BUILD SUCCESS, 13:54 min, finalizado 2026-10-27T11:40:22-06:00. 301 pruebas Surefire + 99 Failsafe = 400; cero fallos/errores/omisiones. IT: migraciones 40, eventos 10, publicación 11, HTTP T11 6, solicitudes 9, seguridad 6 y worker 17. Reportes en target/surefire-reports y target/failsafe-reports.
- Aislamiento: credenciales MIGRATION_TEST_DB_* solo en entorno de proceso; exclusivamente orquestador_test. Schemas temporales t03/t06/t07 y roles runtime propios limpiados por AfterAll. La regresión usó t03_f14d09faa2f443e5929e85f05eec3e6d y fixture compartido t07_e677f961a04a4e83b6814d6f21c4a72b, recreado/limpiado por cada suite correspondiente. No se modificó la base runtime.
- Artefacto: target/orquestador-nmp-0.1.0-SNAPSHOT.jar, SHA256 2F9CC8EEB70FB7B966276BD98445B94732A9E70EEBB6424A105B18571646F0A0. Inspección ZIP confirma ausencia de mocks/clases de prueba; git diff --check aprobado.
- T11 DONE; siguiente T12 PENDING con dependencia DONE. Ninguna tarea principal IN_PROGRESS. Proveedores reales y despliegue siguen pendientes, no se presentan como pruebas aprobadas.
## E21 — Cierre T12 — 2026-10-27 16:22 (America/Mexico_City)
- Criterios previos: máximo publicado de tres intentos totales por acción; backoff/jitter/Retry-After acotados; RETRY_WAIT durable y atómico; conservar checkpoints y clasificar leases vencidos por efecto; no liberar UNKNOWN; resolve protegido, idempotente, con evidencia y sin elegir destino; fault injection y concurrencia con PostgreSQL real.
- Archivos: ActionResult/RetryDelay, WorkerStore/PostgresWorkerStore/WorkerEngine, HttpIntegrationHandler; Resolutions/ResolutionStore/PostgresResolutionStore/ResolutionConfiguration/ResolutionController y ApiExceptionHandler. Pruebas RetryPolicyTest/RetryReconciliationIT/CrashWorkerProcess y ampliaciones WorkerIT/HttpProviderFixture/HttpIntegrationIT; fixtures SecurityHttpIT/RequestCreationIT. Contrato OpenAPI, RECONCILIATION.md, ejemplos HTTP, README, WORKER/INTEGRATIONS, ADR-016 y seguimiento actualizados.
- Primera corrida dirigida: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dit.test=RetryReconciliationIT -Dtest=RetryPolicyTest,HttpIntegrationTest`; BUILD SUCCESS, 1:41 min, finalizado 2026-10-27T16:12:00-06:00. 25 unitarias + 8 IT, cero fallos/errores/omisiones. Se amplió después la cobertura de límites, concurrencia y fronteras de caída; incluida en la regresión final.
- Retry: estado de intento, próxima fecha con reloj DB y RETRY_SCHEDULED comparten transacción; fencing impide reencolar con token obsoleto. READ_ONLY/IDEMPOTENT_WRITE transitorios elegibles; claves/hash se conservan; éxitos previos no se repiten. Agotamiento sin efectos termina FAILED; escrituras inciertas requieren conciliación incluso tras agotar intentos.
- Resolve: requiere administración habilitada, EXECUTION_OPERATOR y ámbito permitido/coincidente; operador puede diferir del propietario. RESUME valida contrato/versiones/efecto y cobertura de acciones pendientes; CLOSE exige ausencia/compensación declarada con evidencia. Rechaza ausencia frente a escritura SUCCEEDED durable. No se inyectan referencias documentales ni estados destino.
- Atomicidad/replay: ledger append-only, checkpoints, cola y auditoría se confirman juntos. Mismo sujeto/comando/clave devuelve respuesta original tras completar; cambios de comando/sujeto confligen. Bloqueo solicitud→ejecución serializa resoluciones; fencing aumenta. La evidencia es declaración verificada por operador, no llamada automatizada al proveedor.
- Caídas: JVM hija exclusiva de prueba termina con Runtime.halt en fronteras anteriores/posteriores al HTTP y con checkpoint previo. El proceso padre reconstruye store/worker usando la DB durable; mock deduplica efecto lógico por clave. No es una prueba de reinicio de contenedor ni idempotencia de un proveedor real.
- Sin migraciones nuevas: V1/V2/V3 ya contienen estados/ledger/permisos. Compose permanece como un único servicio con PostgreSQL externo; flags por defecto sin cambios. Pruebas únicamente en orquestador_test, sin desplegar ni tocar runtime orquestador/server02:8787 ni proveedores reales.
- Regresión final: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1 y credenciales MIGRATION_TEST_DB_* solo en el proceso. BUILD SUCCESS, 8:25 min, finalizado 2026-10-27T16:22:45-06:00. 303 unitarias Surefire + 116 IT Failsafe = 419, cero fallos/errores/omisiones. IT: migraciones 40, eventos 10, publicación 11, HTTP T11 6, solicitudes 9, RetryReconciliationIT 17, seguridad 6 y worker 17. Reportes en target/surefire-reports y target/failsafe-reports.
- Evidencia T12: Retry-After 429 real de mock programa 120 s, luego éxito con clave estable; tres fallos READ_ONLY agotan en FAILED sin outcome negativo; escritura idempotente agotada no libera solicitud. Éxitos previos no se repiten. Rollback inducido en auditoría de retry y de resolución conserva checkpoint/cola/ledger anteriores. Replay tras SUCCEEDED conserva respuesta, dos claves concurrentes producen 200/409 y una clave compartida produce una resolución. Datos/estado/revisión de negocio se conservan al cerrar.
- Cinco caídas reales de JVM: READ_ONLY antes de envío; IDEMPOTENT_WRITE después de HTTP y después de un checkpoint previo; NON_IDEMPOTENT_WRITE antes/después de HTTP. Los seguros recuperan; los no seguros exigen conciliación sin repetir llamada. Checkpoint previo conserva attempt_count=1; mock registra un efecto lógico por clave. WorkerIT confirma token obsoleto rechazado también al programar retry y mantiene pruebas de rollback final (A19).
- Aislamiento: esquemas temporales propios y roles LOGIN eliminados por AfterAll. Suite final utilizó t03_fa507cbe77934497a335700d620079c6 y fixture compartido t07_6687499ffd2b4582963ebdaccc2425c8 (recreado/limpiado por suites), además del schema propio T06. No se tocó runtime ni se desplegó; Docker y proveedores reales no ejecutados.
- Artefacto: target/orquestador-nmp-0.1.0-SNAPSHOT.jar, SHA256 7781CC0F17909D712E6FF34B262D2AEF52C34CF65955A2B938A7FF69A91ACC59. ZIP sin CrashWorkerProcess, mocks o clases de prueba. git diff --check aprobado.
- T12 DONE; siguiente T13 PENDING con dependencias DONE. Ninguna tarea principal IN_PROGRESS. A03 y A12–A14/A19 cubiertos localmente con los límites de proveedor mock indicados; T16 repetirá la matriz integral.
## E22 — Cierre T13 — 2026-10-27 16:50 (America/Mexico_City)
- Alcance confirmado con usuario: backend exclusivamente, sin desarrollar una interfaz gráfica. Criterios previos: consulta autorizada de eventos/schema/ui y bloqueos, versión fijada/retirada, guardias sin datos inventados, documentos ajenos sin vincular, INVALID conserva tarea/motivo, fallo técnico no produce INVALID y payload con resultados de proveedor rechazado (A11/A20).
- Archivos: AvailableEvents/View/Policy/AvailabilitySnapshot/AvailabilityStore/PostgresAvailabilityStore/AvailableEventsController y RequestConfiguration; document/domain/DocumentMetadata, RegisterDocumentHandler, WorkerEngine. AvailableEventPolicyTest/AvailableEventsIT/DocumentTasksIT, catálogo documental de ejemplo, OpenAPI, AVAILABLE_EVENTS.md, ejemplos HTTP, README, ADR-017 y seguimiento.
- Verificación dirigida final: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dit.test=AvailableEventsIT,DocumentTasksIT -Dtest=AvailableEventPolicyTest,RegisterDocumentTest,DomainArchitectureTest`; BUILD SUCCESS, 2:46 min, finalizado 2026-10-27T16:40:04-06:00. 16 unitarias + 20 IT (8 disponibilidad y 12 documentos), cero fallos/errores/omisiones. Después se añadió el caso de referencia ya vinculada a otra solicitud; incluido en regresión completa.
- Correcciones de fixtures: publicación rechazó el flujo documental inicial. Se separó documentId de entrada de registeredDocumentId verificado y se declararon tipos string explícitos en enums status/scanStatus del catálogo de ejemplo para el análisis estático. No se relajaron las reglas de datos protegidos ni cambiaron valores aceptados del contrato. Un test puro comprueba la validez del fixture.
- Disponibilidad: usa una sentencia SQL para revisión/estado/definición fijada/ejecución activa coherentes; una escritura no confirmada no mezcla versiones de esos datos. Conserva ui anterior tras activar v2 y retirar v1. Filtra propietario/ámbito/roles; FLOW_ADMIN no accede a recurso ajeno. FORM y DOCUMENT_UPLOAD se ajustan al schema público. Los cuatro estados activos devuelven events vacío y blockedByExecutionId.
- Guardias: falso/error conocidos se proyectan como códigos genéricos; cualquier dependencia de event.data se difiere al POST. Prueba HTTP demuestra que un evento anunciado se rechaza con agree=false y se acepta con agree=true. No-store, sin ETag en esta ruta porque la disponibilidad puede cambiar sin revisión nueva; revision sigue disponible para If-Match.
- Documentos: verificador puro centraliza pertenencia/tipo/MIME/tamaño/checksum/CLEAN. Worker añade límites más restrictivos de la tarea publicada antes de insertar referencia y al reutilizar checkpoint; tamaño 1001 frente a tarea 1000 y MIME PNG permitido por catálogo pero no por tarea se rechazan. Conserva transacción/fencing de referencia+checkpoint T11.
- Negocio: recorrido real API/worker/HTTP mock/SQL INVALID deja SUCCEEDED/DOCUMENTO_INVALIDO con razón EXPIRED en datos y tarea visible; nuevo evento VALID avanza y oculta tarea sin duplicar referencia. Error 503 reintenta sin repetir metadata exitosa, bloquea tareas durante RETRY_WAIT y al agotar queda FAILED sin fabricar INVALID. Payloads con score/validation/MIME/scanStatus/identidad no crean ejecución.
- Límites: no se reciben archivos, no se calcula MIME/checksum desde bytes ni se ejecuta antivirus; se verifica el dictamen del servicio documental confiable. Solo mocks de prueba y datos sintéticos. Sin nuevas migraciones, despliegue o cambios al Compose de un servicio con PostgreSQL externo. No se toca runtime orquestador/server02:8787.
- Regresión completa final: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1; BUILD SUCCESS, 9:53 min, finalizado 2026-10-27T16:50:46-06:00. 308 unitarias Surefire + 137 IT Failsafe = 445; cero fallos/errores/omisiones. IT: disponibilidad 8, migraciones 40, documentos 13, eventos 10, publicación 11, HTTP 6, solicitudes 9, retries/conciliación 17, seguridad 6 y worker 17. Reportes en target/surefire-reports y target/failsafe-reports.
- Caso adicional aprobado: una referencia ya vinculada a solicitud A no se reasigna a B aunque el mock entregue metadatos contradictorios; la referencia original permanece, B no obtiene vínculo, su checkpoint falla y no se invoca el validador posterior.
- Aislamiento: solo orquestador_test; credenciales MIGRATION_TEST_DB_* en entorno de proceso. Fixtures temporales t07_f89ebc09f5764cc397a280dff4436f93 (recreado/limpiado por suites), t03_0ba9b05d7ca24ad6839dd222fb795062 y schema propio T06; roles/schemas propios limpiados por AfterAll. Sin despliegue, Docker ni proveedor/antivirus real.
- Artefacto target/orquestador-nmp-0.1.0-SNAPSHOT.jar: SHA256 FD5B3A7718745754910BBB9A81C2338F0CEF7A4577590C5A2334C9DCFDB0AA8E. ZIP sin mocks, suites ni proceso de caída de prueba. git diff --check aprobado.
- T13 DONE; siguiente T14 PENDING con dependencias DONE. Ninguna tarea principal IN_PROGRESS. A11/A20 cubiertos con servicio documental mock; integración real pendiente de contrato/configuración.
## E23 — Cierre T14 — 2026-10-28 (America/Mexico_City)
- Criterios: tres JSON completos publicables, recorridos persistentes por API/worker/SQL,
crédito v1/v2 coexistente (A10), licencia por configuración (A18), compra y fronteras
71/70/61/60. Solo backend, proveedores HTTP simulados y datos sintéticos.
- Archivos: examples/flows/*.json y README/seed; examples/integrations/catalog y rutas;
examples/http/demo-flows.http; DemoFlowTest/DemoFlowsIT; README y ADR-018.
- Validación inicial: DemoFlowTest, 3 casos aprobados (2026-10-27 23:51).
- Primera integración: 9 de 10 casos aprobados; la comprobación SQL usaba el nombre
flow_version_id en vez de flow_id. Corregido exclusivamente en test.
- Segunda integración: 8 de 10 casos aprobados; segunda licencia usaba otro UUID con
el mismo storageDocumentId sintético, infringiendo unicidad. Corregido el fixture
para identificar cada documento. Un caso quedó RETRY_WAIT; se amplió el plazo HTTP
del fixture demo de conexión 100 ms/respuesta 2 s a 1 s/5 s. Producción sin cambios.
- Hash agregado de src/main antes/después: 203974E7294C38BE32A19B44C51A6EDD371081B76A355A6688F59DDF32143B49.
Igualdad verificada: no cambios Java, recursos o migraciones de producción en T14.
- Pendiente resultado de verificación final: todas las unitarias + DemoFlowsIT,
DocumentTasksIT y HttpIntegrationIT. Se conserva E22 como regresión completa base;
no se atribuirá esa corrida anterior a T14.
- Alcance pendiente independiente: /admin/flows/{id}/simulate no implementado; revisar
en T16 antes de entrega T17 (ADR-018). La demo actual usa API operativa con mocks.
- DemoFlowsIT final: 10/10 aprobados, 166.8 s. Publicación/activación HTTP de los tres
archivos exactos; ambos créditos terminan después de activar v2, con flow_id distinto.
Rechaza buró prematuro en v2, conserva EXPIRED/LICENCIA_PENDIENTE y permite reemplazo
válido. Compra confirma/cancela sin invocar documentos/buró. Score ausente/textual
termina FAILED y conserva BURO_PENDIENTE; no produce REVISION_MANUAL.
- Surefire final: 311/311 aprobados. JAR sin suites/mocks/proceso de caída; SHA256
FD5B3A7718745754910BBB9A81C2338F0CEF7A4577590C5A2334C9DCFDB0AA8E,
idéntico al artefacto T13. Pendiente únicamente finalizar regresión documental/HTTP.
- Verificación final completada: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dit.test=DemoFlowsIT,DocumentTasksIT,HttpIntegrationIT`.
BUILD SUCCESS, 5:53 min, finalizado 2026-10-28T01:14:02-06:00. 311 unitarias +
29 IT = 340, cero fallos/errores/omisiones. IT: demo 10, documentos 13, HTTP 6.
Esta corrida sustituye los resultados fallidos de desarrollo descritos arriba.
No se repitieron las demás IT de E22: src/main y JAR permanecieron idénticos;
se verificó la regresión de consumidores del catálogo de ejemplo modificado.
- Reportes: target/surefire-reports, target/failsafe-reports y target/t14-final.log.
JDK 21 local. Solo orquestador_test; schema temporal t07_41df5db3c155418d99cbe30611c405aa
y rol propio limpiados por AfterAll (fixture recreado por suite). Credenciales
exclusivamente en entorno de proceso; no se tocó runtime ni se desplegó Docker.
- T14 DONE. T15 PENDING con dependencias DONE; ninguna tarea principal IN_PROGRESS.
Los pendientes históricos de los párrafos anteriores quedan resueltos por esta
corrida, salvo proveedores reales y endpoint simulate explícitamente indicados.
## E24 — Cierre T15 — 2026-10-28 (America/Mexico_City)
- Criterios previos: historial paginado/filtrado con ACL por recurso, auditoría
atómica, métricas sin cardinalidad por solicitud, logs sin PII, readiness/liveness
y apagado del worker. Única tarea principal IN_PROGRESS. Sin despliegue.
- Implementación: audit/api/HistoryController e infrastructure/HistoryStore;
QueueMetrics/ObservabilityConfiguration, CommandObservationFilter; seguridad de
Actuator; timers HTTP, logs/códigos/razones del worker y presupuesto de drenaje.
OBSERVABILITY.md, ADR-019, OpenAPI, ejemplo HTTP, README/RUNBOOK/SECURITY y env/Compose.
- Primera dirigida: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dtest=ObservabilityTest,DomainArchitectureTest,HttpIntegrationTest -Dit.test=ObservabilityIT`.
BUILD SUCCESS, 1:54 min, finalizado 2026-10-28T01:47:22-06:00; 27 unitarias y
15 IT, cero fallos/errores/omisiones. Apagado: latches prueban renovación durante
drenaje, interrupción por presupuesto y ausencia de nuevas reclamaciones.
Historial: empates de timestamp, filtros, binding de cursor, ACL, redacción y
triggers append-only; gauges SQL/DB-time y acceso a Actuator. Pool propio cerrado
como fallo controlado: readiness 503/liveness 200. No es caída de DB productiva.
- Se añadió después captura de logs con cliente y score centinela, y comprobación
durable de reasonCodes en DocumentTasksIT. Están incluidas en regresión final.
- Regresión completa en curso: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`.
314 unitarias ya aprobadas; IT pendientes de terminar. No marcar DONE hasta
resultado final. Usa exclusivamente orquestador_test y esquemas/roles temporales;
secretos solo en entorno del proceso. Proveedores HTTP mock y datos sintéticos.
- Regresión completa: ObservabilityIT 16/16 aprobados, incluida captura de logs;
DocumentTasksIT 13/13 aprueba reasonCodes en auditoría, y DemoFlowsIT 10/10.
- Revisión final detectó binding ambiguo entre kind omitido y literal "null".
Se distingue ausencia con cadena vacía (inválida como kind) y se agrega aserción
HTTP específica. La corrida completa ya había compilado la versión previa;
al terminar se ejecutará nuevamente el caso de cursor y tests de operación/
arquitectura contra el código final. No se atribuye ese ajuste a la corrida base.
- Regresión completa terminada: BUILD SUCCESS, 12:45 min, finalizado
2026-10-28T02:01:05-06:00. Surefire 314 + Failsafe 163 = 477; cero
fallos/errores/omisiones. IT: disponibilidad 8, migraciones 40, demo 10,
documentos 13, eventos 10, publicación 11, HTTP 6, observabilidad 16,
solicitudes 9, reintentos/conciliación 17, seguridad 6 y worker 17.
- WorkerIT conserva 17 casos aprobados: fallo inducido de inserción auditada hace
rollback de estado/revisión; recuperación reutiliza checkpoint exitoso, reglas
y actualización se confirman junto con auditoría, renovación y fencing vigentes.
- Reportes de la corrida completa conservados en target/t15-full-reports.zip antes
del check final dirigido del cursor. Log completo t15-final.log (ignorado por git).
- Check del código final: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dtest=ObservabilityTest,DomainArchitectureTest -Dit.test=ObservabilityIT#historyAuthorizationAndCursorBindingNeverExposeForeignResource`.
BUILD SUCCESS, 59.678 s, finalizado 2026-10-28T02:02:47-06:00; 4 unitarias +
1 IT, sin fallos/errores/omisiones. Cursor con kind omitido vs literal null
ya no comparte binding; también rechaza otro filtro y otra solicitud, y aplica ACL.
- Evidencia final: target/t15-cursor-final.log y reportes actuales; corrida completa
preservada en target/t15-full-reports.zip y t15-final.log. JAR final
target/orquestador-nmp-0.1.0-SNAPSHOT.jar SHA256
B71C97EB0D16504069B326088F270F9546E4DD7379B876C4F05AF412B148370E.
ZIP sin suites/mocks/proceso de caída. git diff --check aprobado.
- Sin nuevas migraciones; historial/razones conservan transacción de negocio y
permisos append-only. Runtime orquestador/server02:8787 y proveedores reales
no usados. Compose sigue con un servicio y PostgreSQL externo; no se desplegó.
- T15 DONE. Los pendientes de verificación descritos arriba quedan resueltos por
las corridas finales. T16 PENDING con dependencias DONE, ninguna tarea IN_PROGRESS.
T16 conserva el pendiente explícito de simulate de ADR-018; Docker/integraciones
reales y política organizacional de retención no se declaran verificados aquí.
## E25 — T16: implementación verificada, cierre bloqueado por Docker — 2026-10-28 (America/Mexico_City)
- Criterios: matriz A01–A20 ejecutada sin skips, reinicios/concurrencia/rollback/ACL,
reportes conservados y CI reproducible con Docker; cerrar /simulate pendiente.
Única tarea principal IN_PROGRESS. T17 no iniciado, sin despliegue.
- Implementación: FlowSimulation/SimulationController; TransitionEvaluator extraído
mecánicamente de WorkerEngine para compartir proyección pura. Contrato y fixtures
administrativos, sin HTTP ni escrituras. NO_OP solo en simulación. Runtime sigue
con contratos fijados, idempotencia, checkpoints, leases y auditoría existentes.
- Pruebas nuevas: FlowSimulationTest (12 casos), SimulationIT (2), DemoFlowsIT
agrega API cliente 503 con agotamiento y replay antes/después de ejecutar con
una llamada al proveedor; cliente ausente comprueba outcome explícito.
- Matriz ejecutable specs/acceptance-cases.json y scripts/verify_acceptance.py.
Rechaza métodos ausentes, fallos/errores/skips; distingue external-postgresql de
testcontainers según XML real. 2 tests Python del checker aprobados. CI exige
docker info, clean verify y gate A01–A20; artefactos por 30 días. Maven falla si
no encuentra tests. No se afirma ejecución del workflow remoto.
- Verificación dirigida: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dtest=FlowSimulationTest,DomainArchitectureTest -Dit.test=SimulationIT`.
BUILD SUCCESS, 1:00 min, finalizado 2026-10-28T06:27:09-06:00. 13 unitarias +
2 IT, cero fallos/errores/omisiones. El test de referencia externa se alineó al
rechazo estructural previo del DSL, sin relajar el filtro ni resolver la URL.
- Docker: Get-Command docker no encuentra ejecutable; no servicio Docker ni archivo
en instalación estándar; DOCKER_HOST/TLS/CERT_PATH/HOST_OVERRIDE sin configurar.
Se solicitó motor autorizado al usuario. Pendiente prueba directa Testcontainers.
- Regresión completa en curso con JDK 21 y perfil migration-tests-external,
exclusivamente orquestador_test. Hasta ahora 326 unitarias, 40 migraciones y
12 recorridos demo aprobados. Proveedores/JWKS mock; secretos solo en proceso.
- Archivos operativos: SIMULATION.md, ACCEPTANCE.md, ADR-020, README, OpenAPI,
examples/http/simulate.http, .github/workflows/ci.yaml, pom.xml y seguimiento.
- Regresión completa externa: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`.
BUILD SUCCESS, 18:22 min, finalizado 2026-10-28T06:48:10-06:00. 326 unitarias +
167 IT = 493; cero fallos/errores/omisiones. IT: disponibilidad 8, migraciones 40,
demo 12, documentos 13, eventos 10, publicación 11, HTTP 6, observabilidad 16,
solicitudes 9, retry/conciliación 17, seguridad 6, simulación 2 y worker 17.
- `python scripts/verify_acceptance.py`: passed=true, environment=external-postgresql,
todos los métodos/parametrizaciones de A01–A20 ejecutados y aprobados. Dos tests
Python del checker también aprobados; incluye rechazo de un skip no mapeado.
- Puerta Docker con esos mismos reportes: `python scripts/verify_acceptance.py --require-testcontainers --output target/acceptance-docker-gate.json` devuelve passed=false
por entorno externo. Es el resultado esperado: no atribuir evidencia Testcontainers
a esta corrida. Reportes completos conservados ANTES de la prueba Docker en
target/t16-external-reports.zip; JSON target/acceptance-report.json y log t16-final.log.
- JAR comprobado: SHA256 FA50938E56D5ACCDE505E2FEEB4F76F1F9011BA01B90895084101350C9D16DAC.
Prueba directa de detección de Docker en curso con perfil predeterminado.
- Prueba Docker directa: `mvnw.cmd --batch-mode --no-transfer-progress verify -Dtest=FlowSimulationTest -Dit.test=SimulationIT` (sin perfil externo).
BUILD FAILURE, 9.795 s, finalizado 2026-10-28T06:49:37-06:00. Falla setup de
SimulationIT con `Could not find a valid Docker environment`; 1 error de entorno,
sin skips. No inicia PostgreSQL ni invoca runtime externo. La suite externa
anterior conserva su evidencia aprobada en ZIP/JSON. Los reportes XML actuales
contienen la prueba negativa de Docker y no deben confundirse con aquel archivo.
- Bloqueo externo confirmado, no defecto funcional ni prueba Docker aprobada.
T16 BLOCKED. No se marca DONE ni se inicia T17. Para cerrar: motor Docker local
o remoto autorizado; `mvnw.cmd --batch-mode --no-transfer-progress clean verify`
y `python scripts/verify_acceptance.py --require-testcontainers`, sin skips.
- Sin migraciones nuevas, cambios al Compose de un servicio, despliegue productivo
o llamadas a proveedores reales. Conservadas auditoría/idempotencia/versiones;
extracción del evaluador respaldada por regresión completa. git diff --check OK.

77
specs/RECONCILIATION.md Normal file
View File

@ -0,0 +1,77 @@
# Reintentos y conciliación — T12
## Reintentos durables
Los fallos TRANSIENT de READ_ONLY e IDEMPOTENT_WRITE se reencolan en RETRY_WAIT.
La programación, el fallo del intento y RETRY_SCHEDULED se confirman juntos, con
fencing y tiempo de PostgreSQL. No hay un sleep del worker ni una cola en memoria.
La solicitud permanece ocupada y conserva estado/datos de negocio.
La política publicada limita a tres intentos totales por acción (incluido el
primero). Defaults: base exponencial 2 s/4 s, jitter entre 80 % y 120 %, limitado
por maxBackoff. Se respeta Retry-After numérico o fecha HTTP, acotado a 120 s;
valores inválidos se ignoran. El mayor de backoff con jitter y Retry-After define
el retraso. Los checkpoints SUCCEEDED no se repiten y la clave externa
executionId:actionId permanece estable.
Al agotar intentos READ_ONLY sin escrituras pendientes se termina FAILED.
Escrituras inciertas, incluyendo una IDEMPOTENT_WRITE agotada, mantienen
RECONCILIATION_REQUIRED. Una operación NON_IDEMPOTENT_WRITE RUNNING al perder
lease jamás se repite automáticamente; incluso una caída antes del envío exige
conciliación si no existe prueba durable de ausencia de efecto. El límite de
intentos no puede liberar esa incertidumbre.
## Resolución por operador
POST /api/v1/admin/executions/{id}/resolve requiere:
- FLOW_ADMIN_ENABLED=true y runtime DB restringido.
- JWT válido, rol EXECUTION_OPERATOR, ámbito en ADMIN_BUSINESS_SCOPES y coincidente
con la solicitud. El operador puede diferir de su propietario.
- Idempotency-Key obligatorio y comando ReconciliationCommand del contrato.
- Ejecución RECONCILIATION_REQUIRED para una resolución nueva.
RESUME registra resultados verificados de acciones existentes y reencola PENDING.
La salida debe cumplir el contrato/perfil fijado y el efecto del checkpoint debe
coincidir con el catálogo confiable. No se permiten acciones inventadas, IDs
duplicados, incertidumbres pendientes ni sobrescribir un checkpoint exitoso con
otro resultado. Una acción ya exitosa puede incluirse únicamente con el mismo
resultado; no vuelve a ejecutarse. No se inyectan resultados manuales de
REGISTER_DOCUMENT: sus referencias deben provenir de la verificación documental.
La solicitud debe conservar estado y revisión aceptados.
CLOSE_WITHOUT_TRANSITION exige ABSENT_VERIFIED o COMPENSATED_VERIFIED y cierra
FAILED sin transición, cambios de datos ni incremento de revisión. ABSENT_VERIFIED
se rechaza cuando existe una escritura SUCCEEDED durable; en ese caso corresponde
documentar compensación. La solicitud vuelve a admitir eventos.
La evidencia exige reference y reason no vacíos. **La verificación la realiza el
operador fuera del servicio**: estos campos son una declaración autorizada y
auditada, no una consulta automática al proveedor. Usar una referencia a un caso
verificable; no adjuntar documentos personales, JWT o secretos.
Resolución, checkpoints, cola y EXECUTION_RESOLVED comparten transacción. El ledger
es append-only. Idempotencia por executionId/clave; replay exige mismo comando
canónico y mismo sujeto operador, y conserva respuesta original incluso tras
completar la ejecución. Otro comando u operador con esa clave recibe 409.
Dos operadores con claves distintas no pueden aplicar simultáneamente dos
resoluciones nuevas. La resolución incrementa fencing_token.
No existe campo para elegir un estado destino. El worker continúa aplicando la
definición publicada y confirma la transición de forma normal.
## Pruebas
RetryPolicyTest cubre límites y parsing. RetryReconciliationIT usa HTTP/JWT,
PostgreSQL real y triggers de fallos de auditoría. CrashWorkerProcess es una JVM
exclusiva de test que termina con Runtime.halt antes/después de llamar a un mock
HTTP: el proceso padre reconstruye el worker y recupera desde DB. El mock registra
un efecto lógico por clave; no acredita idempotencia de un proveedor real.
WorkerIT conserva las pruebas de lease vencido, worker obsoleto, fallo después del
checkpoint y rollback de confirmación final. HttpIntegrationIT ahora espera
RETRY_WAIT en timeout/503 y FAILED en salida inválida.
Ejecutar mvnw.cmd clean verify -Pmigration-tests-external con MIGRATION_TEST_DB_*
en el proceso y base orquestador_test. Sin ese perfil se requiere Docker para
Testcontainers. No se despliega ni se utiliza un proveedor real.

88
specs/REQUESTS.md Normal file
View File

@ -0,0 +1,88 @@
# Creación y consulta de solicitudes — T08
Implementados `POST /api/v1/requests` y `GET /api/v1/requests/{id}` según el contrato
OpenAPI. No se implementan todavía eventos, worker, historial ni tareas del front.
## Habilitación
`REQUEST_API_ENABLED=true` habilita ambas rutas y exige un runtime PostgreSQL
restringido con credenciales de migración separadas (ver SECURITY.md). Es
independiente de `FLOW_ADMIN_ENABLED`: se puede atender solicitudes sin exponer
administración ni cargar un catálogo de publicación local. Ambos interruptores
son false por defecto para conservar el despliegue previo con pgadmin hasta que
se configure la cuenta de ejecución correcta. No se modificaron cuentas remotas.
El JWT debe contener sub, audiencia, firma y ámbito válidos. Cualquier actor así
autenticado puede crear una solicitud de un tipo habilitado; propietario y ámbito
se obtienen del token. Los roles de transición serán exigidos al aceptar eventos
en T09. No se introduce un rol creador nuevo ni una ACL de tipos inexistente.
## Contrato
POST recibe exclusivamente `typeKey` y `data`, con `Idempotency-Key` obligatorio
(1–200 caracteres ASCII visibles, sin espacios; una sola cabecera). Retorna 201,
Location y ETag fuerte. UUID, estado inicial, flujo, propietario, ámbito y revisión
son asignados por el servidor. Los datos deben satisfacer initialDataSchema y
dataSchema de la versión publicada; si falta initialDataSchema se usa dataSchema.
No se aplican defaults ni conversiones implícitas. Los schemas cerrados rechazados
en publicación protegen campos provenientes de proveedores.
Límite de datos: 256 KiB de JSON canónico UTF-8; cuerpo: 256 KiB + 4096 bytes,
leído de forma acotada también sin Content-Length. Profundidad JSON máxima 64.
Se rechazan claves duplicadas, JSON adicional, Unicode incompatible con JSONB,
campos adicionales de comando y números fuera de los límites del codec T06.
Tamaño excesivo retorna 413; sintaxis/comando inválido 400; datos fuera del schema
422. Tipo inexistente: 404. Sin flujo publicado activo, deshabilitado o cambiado
concurrentemente durante la asignación: 409 INVALID_FLOW_STATE. Un comando fallido
no reserva su clave y puede reintentarse tras corregir la causa.
GET retorna versión fijada, estado, datos, revisión, fechas y activeExecutionId
si existe ejecución PENDING/RUNNING/RETRY_WAIT/RECONCILIATION_REQUIRED. La lectura
SQL obtiene instancia/flujo/ejecución activa en un solo snapshot. Exige propietario
y ámbito, incluso para FLOW_ADMIN; inexistente y ajeno retornan 404. El DTO omite
owner/scope y los campos opcionales ausentes. La creación no copia la versión de
flujo a request_instance: la consulta la obtiene de flow_id.
## Idempotencia y transacción
Identidad de la clave: actor + ámbito + Idempotency-Key. Hash SHA-256 del comando
canónico typeKey/data usando el codec T06: orden de propiedades y escala decimal
equivalente no cambian el hash. Otra identidad tiene un espacio de claves distinto.
El repositorio usa READ COMMITTED y la misma DataSource para RequestStore y
FlowStore. Antes de consultar idempotencia toma pg_advisory_xact_lock con 64 bits
de SHA-256 sobre schema/actor/ámbito/clave encuadrados por longitud. Una colisión
solo serializa claves distintas; la búsqueda y restricción única SQL siguen usando
la identidad completa. No hay locks de proceso ni caché de respuestas en memoria.
Si existe registro y coincide el hash, se verifica acceso actual a la solicitud y
se devuelve el snapshot original de creación (201, Location y ETag originales),
aunque el flujo se haya retirado, el tipo esté deshabilitado o la solicitud haya
avanzado. GET obtiene el estado actual. Un hash distinto retorna 409
IDEMPOTENCY_CONFLICT antes de resolver de nuevo el flujo activo.
Sin registro previo, la puerta T06 bloquea flujo y tipo en ese orden; luego valida
datos, inserta solicitud con revisión 0, REQUEST_CREATED e idempotencia con respuesta
original. Todo confirma o revierte junto. La auditoría incluye correlationId y
referencias, sin duplicar datos personales. No se invocan servicios externos.
Un cambio de activo detectado al tomar los bloqueos produce conflicto reintentable;
no se asigna silenciosamente el flujo leído antes de la carrera. Un retiro espera
una creación que ya tiene sus locks; una vez confirmado impide nuevas asignaciones.
## Verificación
`mvnw.cmd clean verify -Pmigration-tests-external`, con MIGRATION_TEST_DB_* solo en
entorno, ejecuta la suite contra orquestador_test. RequestCreationIT reutiliza el
fixture aislado T07 (schema t07_UUID, LOGIN temporal y JWT RSA/JWKS local) con
administración apagada y API de solicitudes encendida. No se prueba una emisión
real de Keycloak ni se despliega el servicio en server02:8787.
Casos: contrato RequestView/Problem, replay y conflicto, versiones fijadas, retiro,
separación de claves por actor/ámbito, acceso ajeno, validación/tamaño, concurrencia
misma clave y comandos distintos, activación y retiro con barreras, ejecución activa,
y rollback SQL real en auditoría/registro idempotente. RequestJsonTest verifica
parseo estricto, canonicalización decimal y límites UTF-8.
Desde T09 se implementa también POST de eventos (EVENTS.md). La referencia anterior
al carácter pendiente de eventos describe el alcance de T08; worker, tareas e
historial siguen pendientes. REQUEST_API_ENABLED habilita ambos módulos.

View File

@ -51,7 +51,7 @@ docker compose stop # Detener el servicio
docker compose down # Eliminar contenedor y red de Compose
```
Estos comandos no administran la base externa. Flyway permanece habilitado y aplicará las migraciones incluidas al arrancar; actualmente no existen migraciones SQL de negocio. La base debe existir antes del arranque. La separación de roles de migración/ejecución queda pendiente de T03/T07.
Estos comandos no administran la base externa. Flyway permanece habilitado y aplicará las migraciones incluidas al arrancar; se incluyen V1/V2 del modelo T03. La base debe existir antes del arranque. Las credenciales de migración pueden separarse según las instrucciones al final de este documento.
## Ejecución desde el IDE
@ -94,3 +94,94 @@ Son cinco pruebas de sólo lectura; resultados en `target/failsafe-reports`. El
- Fallo de build: comprobar acceso a los repositorios de dependencias.
Registrar evidencia en PROGRESS. No considerar verificado el arranque si no se ejecutó contra Docker y PostgreSQL externo.
## Pruebas T03 sobre PostgreSQL externo
Con JDK 21, sin Docker local, usar exclusivamente la base aislada creada para
pruebas. Las credenciales se suministran como variables de proceso:
```powershell
$env:MIGRATION_TEST_DB_URL='jdbc:postgresql://server02:30432/orquestador_test'
$env:MIGRATION_TEST_DB_USER='pgadmin'
# Establecer MIGRATION_TEST_DB_PASSWORD mediante el entorno local.
.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external
```
El perfil no utiliza DB_URL y rechaza cualquier base distinta de orquestador_test.
Crea un esquema t03_<UUID>, ejecuta las migraciones y lo elimina al finalizar.
No toca otros esquemas. Crea un rol NOLOGIN temporal dentro de una transacción
revertida. Requiere permiso de crear esquemas y roles. Si el proceso se interrumpe
abruptamente, un administrador puede revisar el esquema temporal que haya quedado;
no borrar otros esquemas ni ejecutar Flyway clean.
Sin ese perfil, verify requiere Docker y ejecuta la misma suite con Testcontainers
postgres:9.6.24. No hay fallback silencioso ni pruebas omitidas sin Docker.
El perfil deployment-smoke agrega comprobaciones HTTP; postgres96-check agrega
una comprobación de arranque de solo lectura, que exige migraciones ya aplicadas.
Ambos conservan los IT normales y pueden combinarse con migration-tests-external.
## Esquema y credenciales de migración
Flyway aplica V1/V2 al esquema orchestrator. DB_MIGRATION_USERNAME y
DB_MIGRATION_PASSWORD permiten separar migrador de usuario de aplicación; si se
omiten, se reutilizan las credenciales DB_USERNAME/DB_PASSWORD. Para aplicar
mínimo privilegio seguir [roles y migraciones](../src/main/resources/db/migration/README.md).
El historial nuevo es orchestrator.flyway_schema_history. El arquetipo anterior
no incluía SQL de negocio. Si existe historial en public con migraciones reales,
detener la actualización y preparar su traslado explícito; no usar baseline ni
repair para ocultarlo. Esta implementación no despliega ni migra automáticamente
el servicio remoto: las migraciones se ejecutarán cuando se arranque el nuevo JAR.
## Administración interna T06
El JAR incluye el validador y servicios internos de flujos, sin nuevas rutas
administrativas. Ver [publicación y catálogo](FLOW_PUBLICATION.md) para el alcance,
roles, canonicalización y orden de bloqueos. T07 incorporará el wiring y acceso
REST con identidad verificada; T06 no configura contratos reales de proveedores.
El perfil migration-tests-external ejecuta tanto DatabaseMigrationIT como
FlowPublicationIT. Esta última crea y elimina únicamente su esquema t06_<UUID>
en orquestador_test, incluyendo un trigger de fallo de auditoría exclusivo de prueba.
La configuración de esquema normal del servicio sigue siendo orchestrator.
T07: antes de habilitar FLOW_ADMIN_ENABLED revisar SECURITY.md. Un runtime pgadmin
será rechazado; provisionar rol restringido y separar las credenciales Flyway.
ADMIN_BUSINESS_SCOPES vacío deniega administración. JWT_BUSINESS_SCOPE_CLAIM debe
coincidir con el claim firmado por Keycloak; el valor por defecto es business_scope.
T08: REQUEST_API_ENABLED=true habilita creación/consulta, independientemente de
FLOW_ADMIN_ENABLED. Se exige rol DB restringido también con administración apagada.
Provisionar y activar un tipo antes de crear; REQUESTS.md documenta respuestas,
reintentos y pruebas. Un replay POST trae revisión original; refrescar con GET para
obtener el estado/ETag actual antes de aceptar futuros eventos.
T09: REQUEST_API_ENABLED habilita también POST de eventos. Conservar Idempotency-Key,
cuerpo e If-Match originales al reintentar un envío sin respuesta. GET de solicitud
muestra revisión/ejecución activa. Hasta implementar T10, PENDING no se procesa y GET
de ejecución no está disponible. Ver EVENTS.md; no invocar endpoints de proveedores.
T10: aplicar V3 con usuario de migraciones antes de habilitar WORKER_ENABLED.
GET de ejecución se habilita con REQUEST_API_ENABLED. Revisar WORKER.md para
leases, checkpoints, apagado y recuperación. Dejar worker apagado en flujos con
integraciones hasta configurar adapters T11. Defaults 60 s lease / 15 s renovación;
los timeouts externos futuros deben ser menores y acotados. No hay retry HTTP T12 aún.
## Seguimiento T15
Consultar /api/v1/requests/{id}/history como propietario para enlazar correlationId
con executionId, ruleIds y reasonCodes. Usar nextCursor con los mismos filtros;
from inclusivo/to exclusivo requieren UTC Z. Consultar OBSERVABILITY.md y
examples/http/history-operations.http.
El operador autorizado consulta Actuator metrics. Vigilar la edad del pendiente,
leases expirados y RECONCILIATION_REQUIRED; no liberar ni reintentar ciegamente
escrituras inciertas. Si queue.sample.available=0, los gauges NaN son desconocidos,
no una cola vacía. No sumar estados DB de réplicas conectadas a la misma base.
Al detener, Compose concede 40 s; el worker drena por WORKER_SHUTDOWN_SECONDS (1–20),
renueva los trabajos vigentes y luego interrumpe. Un trabajo incompleto conserva
lease/checkpoints: recuperar según T12. Readiness depende de DB; liveness no.
No cambiar el timeout de HTTP a uno mayor que el lease ni presumir cancelación
externa al interrumpir. Retención/borrado de auditoría requiere política aprobada
con procedimiento privilegiado separado, sin borrado automático por runtime.

109
specs/SECURITY.md Normal file
View File

@ -0,0 +1,109 @@
# Seguridad T07
El servicio valida tokens RS256 mediante JWKS, emisor exacto, audiencia configurada,
expiración obligatoria y sujeto no vacío. También valida nbf cuando está presente;
el validador temporal de Spring admite 60 segundos de desfase. Las pruebas usan
tokens RSA realmente firmados y un servidor JWKS local. No acreditan todavía una
emisión real de Keycloak controlms: faltan un token de prueba y su configuración
de audiencia/claims, sin necesidad de entregar contraseñas al repositorio.
`JWT_AUDIENCE` identifica la audiencia y el cliente cuyos roles se aceptan desde
`resource_access`. También se aceptan `realm_access.roles`. Roles de otros clientes
y el claim OAuth `scope` no conceden permisos técnicos. `JWT_BUSINESS_SCOPE_CLAIM`
selecciona el claim de negocio (por defecto `business_scope`); debe ser un string
no vacío. Identidad, roles y ámbito proceden exclusivamente del JWT verificado.
## Administración
`FLOW_ADMIN_ENABLED=false` mantiene deshabilitados los controladores administrativos.
Para habilitarlos se necesita un usuario DB de ejecución restringido, credenciales
de migración separadas, catálogo confiable y `ADMIN_BUSINESS_SCOPES` con ámbitos
permitidos separados por comas. Una lista vacía deniega todas las operaciones.
Los flujos y tipos son un catálogo global: pertenecer a uno de esos ámbitos autoriza
administrarlo según el rol; no implica aislamiento de flujos por tenant.
| Operación implementada | Rol requerido |
|---|---|
| POST /api/v1/admin/flows; PUT /api/v1/admin/flows/{id} | FLOW_EDITOR |
| POST /api/v1/admin/flows/{id}/validate | FLOW_EDITOR o FLOW_PUBLISHER |
| POST /api/v1/admin/flows/{id}/publish | FLOW_PUBLISHER |
| POST /api/v1/admin/flows/{id}/retire | FLOW_ADMIN |
| PUT /api/v1/admin/request-types/{key}/active-flow | FLOW_ADMIN |
Edición, publicación, retiro y activación requieren un ETag fuerte `If-Match`.
Ausente: 428; sintaxis inválida: 400; revisión obsoleta: 412. Editar un flujo publicado
retorna 409. JSON inválido/duplicado: 400; definición fuera de schema: 422;
cuerpo superior a 1 MiB: 413. Respuestas de error no exponen SQL ni tokens y llevan
un correlationId generado por el servidor. Sesiones y caché de peticiones deshabilitadas.
`config/publication-catalog.json` es configuración de despliegue de solo lectura en
Docker. No puede reemplazarse desde HTTP. El ejemplo registra ANALISTA y ningún
handler, integración ni documento; no simula proveedores ni permite publicar
acciones no registradas. Reiniciar aplica una nueva configuración. Simulación y
resolución siguen pendientes de sus tareas funcionales; la matriz de roles no
constituye una implementación de esas rutas.
## Recursos de negocio
`ResourceAccess` exige propietario (`sub`) **y** ámbito coincidentes para solicitud,
ejecución, documento y auditoría. El adapter resuelve los tres últimos contra su
solicitud padre en PostgreSQL. Recurso inexistente o ajeno retorna el mismo 404;
FLOW_ADMIN no permite eludir esta política. La conciliación tiene una autorización
separada: EXECUTION_OPERATOR y mismo ámbito, aunque el propietario sea otro.
T08 integra esta puerta en la consulta de solicitudes y filtra también propietario/ámbito
en el SELECT que devuelve los datos. PersistenceAccessConfiguration comparte codec,
FlowStore, ResourceAccess y la verificación DB cuando administración o solicitudes
estén habilitadas; REQUEST_API_ENABLED es independiente del interruptor administrativo.
T09/T12/T13/T15 deben reutilizar la puerta antes de devolver datos o ejecutar comandos.
Las pruebas T07 de ejecución/documento/auditoría usan un controlador exclusivo de test,
fuera del paquete escaneado y ausente del JAR; sus endpoints funcionales siguen pendientes.
T08 prueba POST/GET reales de solicitudes (REQUESTS.md). No se acepta identidad en cuerpos.
## PostgreSQL externo
Configurar `DB_USERNAME`/`DB_PASSWORD` para un rol LOGIN sin privilegios elevados,
sin propiedad ni CREATE en el schema orchestrator, sin pertenencia a roles elevados.
Configurar `DB_MIGRATION_USERNAME`/`DB_MIGRATION_PASSWORD` para el propietario que
aplica Flyway. El DBA provisiona ambos; la aplicación no crea roles automáticamente.
Después de las migraciones, el propietario concede los permisos de V2 mediante:
```sql
SELECT orchestrator.grant_runtime_access('orchestrator_app'::name);
```
El nombre es ilustrativo: el rol debe existir previamente. `RuntimeRoleVerifier`
rechaza al iniciar administración un runtime elevado, propietario, con DDL en el
schema, capaz de modificar historiales append-only o acceder a flyway_schema_history.
Esta comprobación no configura permisos de otras bases/schemas: el DBA controla
también los permisos heredados de PUBLIC, particularmente en PostgreSQL 9.6.
No cambiar permisos globales del servidor para ejecutar las pruebas.
Prueba reproducible: `mvnw.cmd clean verify -Pmigration-tests-external`, con
MIGRATION_TEST_DB_URL/USER/PASSWORD en entorno de proceso. Solo admite la base
orquestador_test. T07 crea schema t07_<UUID> y rol LOGIN t07_runtime_<UUID>, contraseña
aleatoria y concesiones limitadas; los elimina en AfterAll. Las pruebas se conectan
con ese rol real, no con SET ROLE ni una simulación de permisos. Sin perfil externo,
verify requiere Docker y usa PostgreSQL de Testcontainers.
T09 integra ResourceAccess en aceptación de eventos y vuelve a filtrar propietario/
ámbito bajo SELECT FOR UPDATE. allowedRoles del flujo se comprueba para eventos
nuevos. El replay requiere acceso actual y sujeto original, pero no vuelve a evaluar
roles/guardias: solo consulta una aceptación anterior. actor_snapshot guarda atributos
mínimos verificados; nunca JWT. Ver EVENTS.md.
T10: GET de ejecución usa ResourceAccess y repite owner/scope en la consulta SQL;
no devuelve inputs, identidad snapshot, leases ni respuestas crudas. WORKER_ENABLED
habilita infraestructura común y RuntimeRoleVerifier incluso con ambas APIs apagadas.
El worker usa identidad mínima persistida al aceptar, no reutiliza JWT vencidos.
## T15: historial y métricas
El historial de solicitudes exige propietario y businessScope iguales al JWT;
FLOW_ADMIN/EXECUTION_OPERATOR no obtienen acceso a historial ajeno. La proyección
no entrega evidencia de conciliación ni resultados completos de proveedores.
/actuator/metrics y descendientes requieren EXECUTION_OPERATOR y ámbito autorizado
en ADMIN_BUSINESS_SCOPES, incluso con FLOW_ADMIN_ENABLED=false. Las tres rutas
health/liveness/readiness permanecen públicas sin detalles. Resto de /actuator/**
se deniega. Ver OBSERVABILITY.md para etiquetas permitidas, cursor y logs.

37
specs/SIMULATION.md Normal file
View File

@ -0,0 +1,37 @@
# Simulación administrativa
POST /api/v1/admin/flows/{id}/simulate requiere FLOW_ADMIN_ENABLED, FLOW_EDITOR
y un businessScope permitido por ADMIN_BUSINESS_SCOPES. El actor del JSON es un
fixture para evaluar roles/guardias del flujo; no autentica al llamador. No acepta
un token dentro del JSON como autoridad. Flujos DRAFT/PUBLISHED/RETIRED pueden
evaluarse; nunca se cambia estado de publicación, checksum o revisión.
Se lee una versión del flujo y se libera el bloqueo antes de evaluar. Body UTF-8
máximo 1 MiB, sin claves duplicadas; requestData/event.data y cada resultado tienen
límite 256 KiB. Aplica SimulationInput/SimulationResult del contrato. Un error de
sintaxis es 400, tamaño 413, contrato/tipos/ambigüedad 422; rol de negocio del fixture
no permitido 403 y evento inexistente 409. Respuesta no-store.
La simulación recorre una transición: valida datos de estado actual (dataSchema),
evento, roles/guardia y mappings de entradas; luego valida fixtures de resultados
contra contratos/perfiles del catálogo confiable. results usa exactamente los
resultKey de acciones externas seleccionadas; no se permiten resultados sobrantes
ni omitidos. NO_OP toma output de su definición y no necesita una entrada results.
Sigue prohibido publicar/ejecutar NO_OP en el worker.
Los drafts usan versión de contrato/perfil indicada o default del catálogo. Las
publicadas usan sus versiones fijadas. No requiere rutas HTTP ni secretos; catálogo
de contratos sí. No resuelve referencias de schemas a red/archivos. Las guardias se
evalúan antes de resultados, como al aceptar un evento real. Las decisiones, updates,
validación final y reglas coincidentes usan TransitionEvaluator compartido con el
worker. UNIQUE ambiguo devuelve AMBIGUOUS_DECISION, no una ruta de negocio.
No crea solicitudes, ejecuciones, checkpoints, referencias documentales, auditorías
ni eventos. No invoca proveedores ni ejecuta reintentos. Las respuestas documentales
son fixtures, no prueba de propiedad/antivirus de un documento real. La simulación
valida MIME/tamaño/tipo configurados, no verifica almacenamiento o consentimiento.
No equivale a publicar un draft válido ni a un recorrido persistente de demo.
Ejemplo: [simulate.http](../examples/http/simulate.http). FlowSimulationTest cubre
fronteras, contrato, guardias, NO_OP y ambigüedad; SimulationIT comprueba JWT/ACL,
schemas HTTP, límites y que los conteos/revisión permanecen iguales tras simular.

121
specs/WORKER.md Normal file
View File

@ -0,0 +1,121 @@
# Worker durable y consulta de ejecución — T10
El worker usa PostgreSQL como cola durable. `WORKER_ENABLED=true` activa polling,
reclamación, renovación y recuperación; false por defecto. Es independiente de
REQUEST_API_ENABLED y FLOW_ADMIN_ENABLED, pero siempre exige el runtime DB
restringido y Flyway separado. Puede desplegarse como proceso solo worker usando
la misma imagen. No se ha desplegado esta versión.
Defaults: 10 workers, lote máximo 10, polling 500 ms, lease 60 s y renovación cada
15 s. Variables WORKER_CONCURRENCY/BATCH/POLL_MS/LEASE_SECONDS/RENEW_SECONDS en
.env.example y compose.yaml. La renovación debe ser menor que la mitad del lease.
El scheduler reclama únicamente capacidad libre; no acumula una cola local de
trabajos ya reclamados que caduquen esperando un thread. Los executors locales
despachan trabajo; execution/action_execution conservan toda la evidencia durable.
## Protocolo y concurrencia
Un CTE con FOR UPDATE SKIP LOCKED reclama PENDING/RETRY_WAIT vencidos y RUNNING con
lease expirado. Orden next_attempt_at/created_at/id, límite de lote. En la misma
transacción incrementa attempts/fencing_token y fija owner/lease con clock_timestamp
de PostgreSQL. Se confirma antes de evaluar o invocar un adapter.
Renovar requiere RUNNING, executionId/requestId, owner y token exactos, y lease aún
vigente. Un lease vencido no revive por renovación. Reclamarlo aumenta el token;
el propietario anterior no puede cargar, iniciar/guardar checkpoints, fallar ni
confirmar el trabajo. Escrituras de checkpoint bloquean la ejecución y verifican
lease con tiempo DB al inicio y antes del commit. Finalización/fallo bloquean
solicitud y después ejecución; no hay transacciones abiertas mientras corre el adapter.
El worker carga estado/revisión aceptados y la definición por flow_id fijado.
Rechaza una solicitud modificada antes de llamar al adapter y vuelve a comprobar
estado, revisión, versión y lease al finalizar. accepted_revision es la revisión
posterior a aceptar T09. Un worker obsoleto que retorna después de otro no puede
sobrescribir el resultado, aunque el proveedor haya procesado su llamada.
## Acciones y checkpoints
WorkerActions es un registro de adapters confiable, consciente de versión, que
declara capability/schemas/effect y ejecuta la acción. ActionContext contiene inputs
resueltos, identidad mínima y clave externa estable `executionId:actionId`.
No se aceptan capacidades ni URLs del evento. NO_OP sigue prohibido fuera de simulación.
Antes de invocar se valida input y persiste checkpoint RUNNING, input_hash SHA-256,
clave externa, clasificación de efecto e intento. Al retornar, valida output y lo
guarda SUCCEEDED antes de avanzar. Tras recuperación, SUCCEEDED se reutiliza solo
si hash/clave/effect coinciden; no vuelve a ejecutar el adapter. Los mappings de
acciones posteriores usan los resultados recuperados. Cambio incompatible bloquea
con error de contrato y conciliación; nunca se usa silenciosamente un resultado ajeno.
V3 agrega action_execution.effect. Lo declara el adapter confiable, no el flujo.
Checkpoints anteriores a V3 se clasifican conservadoramente NON_IDEMPOTENT_WRITE;
no se inventa que eran seguros. V1/V2 no se alteran. Migración/rollback y permisos
se verifican contra PostgreSQL 9.6.2 en la base de pruebas.
Recuperación inicial:
- Acción READ_ONLY o IDEMPOTENT_WRITE en curso puede retomarse con misma clave y
hash, respetando maxAttempts del contrato de acción.
- NON_IDEMPOTENT_WRITE RUNNING o UNKNOWN no se repite: ejecución pasa a
RECONCILIATION_REQUIRED y la solicitud permanece bloqueada.
- Un fallo posterior a escrituras exitosas o pendientes tampoco libera la
solicitud como FAILED; se conserva para conciliación.
- Fallo de persistencia final deja RUNNING/checkpoints existentes para recuperación
por lease. Al reanudar no se repiten acciones ya SUCCEEDED.
T12 programa reintentos transitorios seguros con backoff/jitter y Retry-After
acotados. El checkpoint del intento, RETRY_WAIT y auditoría se confirman juntos;
no se pierden éxitos anteriores. Agotamiento seguro termina FAILED; escrituras
inciertas conservan el bloqueo para resolución administrativa. RECONCILIATION.md
documenta límites, evidencia, replay y autorización.
Los adapters T11 limitan sus timeouts y presupuesto de ejecución por debajo
del lease y respetar la idempotencia externa; el token local no evita duplicados
en el proveedor.
## Finalización
Tras las acciones, el evaluador puro T05 decide destino/outcome. Los updates se
calculan desde el snapshot original y resultados de acciones, se aplican sobre una
copia y se valida dataSchema y límite de 256 KiB antes de confirmar. JSON Pointer
admite objetos y posiciones existentes de arrays; no crea índices dispersos ni
interpreta append `-`. Padres de objeto ausentes se crean como objetos y el schema
final decide su validez. Nunca se actualiza identidad/ACL desde estos mappings.
Transacción final comprueba todos los checkpoints esperados SUCCEEDED y actualiza
data/estado/revisión de solicitud, resultado SUCCEEDED de execution y auditoría
TRANSITION_COMPLETED con transitionId, actionIds, hitPolicy y ruleIds. Cualquier
fallo revierte esos cambios juntos, conservando los checkpoints previos.
## Consulta y operación
GET /api/v1/executions/{id} ya implementado bajo REQUEST_API_ENABLED. Reutiliza
ResourceAccess y filtra owner/scope en SQL. Retorna ExecutionView con estado,
resultado de negocio permitido y código de error seguro; no devuelve inputs de
acciones, actor_snapshot, tokens, lease_owner ni respuesta cruda del proveedor.
Recurso ajeno/inexistente retorna 404. Location de T09 ya puede consultarse.
Apagado: detiene reclamación, conserva renovaciones durante hasta 20 s de drenaje,
interrumpe trabajos restantes y deja de renovar. Checkpoints/leases permiten la
recuperación. Un heartbeat que termina tarde no interrumpe otro trabajo que reutilice
el thread. Los adapters deben tener tiempo de espera finito; la interrupción no
garantiza cancelar un efecto externo, por eso se conserva la clasificación durable.
T11 incorpora adapters HTTP y verificación documental en el registro productivo.
Una acción sin contrato/ruta configurados falla con INTEGRATION_UNAVAILABLE.
Los catálogos predeterminados están vacíos. Configurar contratos y rutas antes de
habilitar integraciones; consultar INTEGRATIONS.md. NO_OP no puede ejecutarse.
## Evidencia y límites
WorkerIT usa PostgreSQL real/JWT local y adapters exclusivamente de prueba. Las
barreras verifican reclamantes concurrentes, SKIP LOCKED, fencing, retorno tardío,
renovación durante acción, checkpoints tras caída, clave externa estable, efecto
no seguro, rollback final, decisiones y autorización HTTP. SimulatedCrash abandona
el intento sin guardar resultado; se reconstruye el adapter de persistencia y se
recupera desde DB. No se anuncia una prueba de caída del proceso/JVM ni integración
real con proveedor. La idempotencia del proveedor se demuestra solo con un doble
explícito que deduplica por clave; T11/T16 comprobarán los contratos externos.
Ejecutar `mvnw.cmd clean verify -Pmigration-tests-external` con credenciales solo en
MIGRATION_TEST_DB_* y base orquestador_test. Sin ese perfil, verify necesita Docker
para Testcontainers. Los esquemas y roles temporales propios se eliminan al finalizar.

View File

@ -0,0 +1,77 @@
{
"A01": [
"DemoFlowsIT#creditBoundaries"
],
"A02": [
"DemoFlowsIT#missingCustomerRemainsCreatedAndCanBeRetried"
],
"A03": [
"DemoFlowsIT#customerApiFailureRetriesAndNeverBecomesCustomerNotFound"
],
"A04": [
"DemoFlowsIT#creditBoundaries",
"DecisionEvaluatorTest#a04ScoringUsesActualContractExampleAndExactDecimalBoundaries"
],
"A05": [
"DemoFlowsIT#invalidProviderScoreNeverDefaultsToManualReview"
],
"A06": [
"DecisionEvaluatorTest#uniqueRejectsMultipleMatchesEvenWithIdenticalOutputAndFirstPreservesDeclaredOrder",
"FlowSimulationTest#ambiguousDraftDecisionIsNotConvertedIntoDefault"
],
"A07": [
"DemoFlowsIT#repeatedEventHasOneExecutionAndOneProviderCallEvenAfterCompletion"
],
"A08": [
"EventAcceptanceIT#concurrentDifferentPayloadsConflictAndNumericCanonicalReplayIsStable"
],
"A09": [
"EventAcceptanceIT#concurrentIdenticalAndDifferentKeysHaveOneAcceptance"
],
"A10": [
"DemoFlowsIT#existingCreditKeepsV1WhileNewCreditRequiresLicense"
],
"A11": [
"DocumentTasksIT#invalidRetainsTaskAndReasonThenValidCompletesWithoutDuplicateReference",
"DocumentTasksIT#untrustedOrNoncompliantMetadataNeverCreatesReference",
"DocumentTasksIT#anExistingReferenceCannotBeReassignedEvenIfRemoteMetadataDisagrees"
],
"A12": [
"RetryReconciliationIT#realChildJvmCrashAtEffectBoundaryRecoversOrRequiresResolution",
"WorkerIT#durableCheckpointSurvivesAdapterRestartAndUsesStableExternalKey"
],
"A13": [
"RetryReconciliationIT#realChildJvmCrashAtEffectBoundaryRecoversOrRequiresResolution",
"WorkerIT#idempotentWriteReusesProviderKeyAfterLostCheckpoint"
],
"A14": [
"WorkerIT#twoClaimantsHaveDisjointJobsAndLockedRowsAreSkipped",
"WorkerIT#renewalAndReclaimFenceEveryStaleMutation",
"WorkerIT#oldWorkerReturningAfterNewWorkerCannotOverwriteResult"
],
"A15": [
"EventAcceptanceIT#ownerScopeAndTransitionRolesCannotBeForged",
"ObservabilityIT#historyAuthorizationAndCursorBindingNeverExposeForeignResource",
"SimulationIT#simulationIsAuthorizedReadOnlyAndConformsToContract"
],
"A16": [
"FlowPublicationIT#databaseAndApplicationBothPreventPublishedMutation",
"SecurityHttpIT#rolesScopeAndImmutablePublicationOverHttp"
],
"A17": [
"FlowPublicationIT#activationRetirementRaceNeverLeavesRetiredVersionActive",
"FlowPublicationIT#retirementBeforeAssignmentPreventsANewRequestEvenWhenTransactionsOverlap"
],
"A18": [
"DemoFlowsIT#existingCreditKeepsV1WhileNewCreditRequiresLicense",
"DemoFlowTest#exactExampleIsPublishable"
],
"A19": [
"WorkerIT#finalCommitFailureRollsBackAndRecoveryDoesNotRepeatSucceededAction",
"RetryReconciliationIT#retryAuditFailureRollsBackScheduleAndRecovers",
"RetryReconciliationIT#resolutionAuditFailureRollsBackCheckpointLedgerAndQueue"
],
"A20": [
"DocumentTasksIT#clientCannotSubmitProviderFieldsOrReplaceMetadata"
]
}

View File

@ -0,0 +1,8 @@
package com.organizacion.orchestrator.action.application.port;
import com.organizacion.orchestrator.action.domain.*;
public interface ActionHandler {
String type();
ActionResult execute(ActionDefinition definition, ActionContext context);
}

View File

@ -0,0 +1,15 @@
package com.organizacion.orchestrator.action.domain;
import com.organizacion.orchestrator.flow.domain.InlineSchema;
import com.organizacion.orchestrator.shared.domain.Checks;
import java.util.Objects;
/** Trusted deployment catalog entry. Never deserialized from an editable flow. */
public record ActionCapability(String type, Effect effect, InlineSchema configurationSchema,
InlineSchema inputSchema, InlineSchema outputSchema) {
public enum Effect { READ_ONLY, IDEMPOTENT_WRITE, NON_IDEMPOTENT_WRITE }
public ActionCapability {
Checks.key(type); Objects.requireNonNull(effect); Objects.requireNonNull(configurationSchema);
Objects.requireNonNull(inputSchema); Objects.requireNonNull(outputSchema);
}
}

View File

@ -0,0 +1,16 @@
package com.organizacion.orchestrator.action.domain;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.util.Objects;
/** Inputs already resolved and validated by the application; stable external key survives retries. */
public record ActionContext(ExecutionId executionId, RequestId requestId, Actor actor,
ObjectValue inputs, String externalIdempotencyKey, int attempt) {
public ActionContext {
Objects.requireNonNull(executionId); Objects.requireNonNull(requestId);
Objects.requireNonNull(actor); Objects.requireNonNull(inputs);
Checks.text(externalIdempotencyKey, 200); Checks.positive(attempt);
}
}

View File

@ -0,0 +1,43 @@
package com.organizacion.orchestrator.action.domain;
import com.organizacion.orchestrator.decision.domain.ValueExpression;
import com.organizacion.orchestrator.shared.domain.Checks;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Editable configuration deliberately excludes URLs, credentials and effect classification. */
public sealed interface ActionDefinition {
String id();
String resultKey();
String type();
record Integration(String id, String resultKey, String integrationKey,
Optional<String> contractVersion, Optional<String> profile, Optional<String> profileVersion,
Map<String, ValueExpression> inputs, RetryPolicy retry) implements ActionDefinition {
public Integration {
Checks.key(id); Checks.key(resultKey); Checks.key(integrationKey);
Objects.requireNonNull(contractVersion).ifPresent(v -> Checks.text(v, 100));
Objects.requireNonNull(profile).ifPresent(Checks::key);
Objects.requireNonNull(profileVersion).ifPresent(v -> Checks.text(v, 100));
if (profileVersion.isPresent() && profile.isEmpty()) throw new IllegalArgumentException("Profile version without profile");
inputs = Map.copyOf(inputs); inputs.keySet().forEach(Checks::key);
Objects.requireNonNull(retry);
}
public String type() { return "INVOKE_INTEGRATION"; }
}
record RegisterDocument(String id, String resultKey, String documentType,
ValueExpression documentId, RetryPolicy retry) implements ActionDefinition {
public RegisterDocument {
Checks.key(id); Checks.key(resultKey); Checks.key(documentType);
Objects.requireNonNull(documentId); Objects.requireNonNull(retry);
}
public String type() { return "REGISTER_DOCUMENT"; }
}
/** Only simulation may execute this action. Publication must reject it. */
record NoOp(String id, String resultKey, ObjectValue output) implements ActionDefinition {
public NoOp { Checks.key(id); Checks.key(resultKey); Objects.requireNonNull(output); }
public String type() { return "NO_OP"; }
}
}

View File

@ -0,0 +1,25 @@
package com.organizacion.orchestrator.action.domain;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.util.Objects;
import java.util.Optional;
public sealed interface ActionResult {
/** Includes normalized negative business outcomes such as found=false or status=INVALID. */
record Completed(ObjectValue output, Optional<String> providerReference) implements ActionResult {
public Completed {
Objects.requireNonNull(output);
Objects.requireNonNull(providerReference).ifPresent(v -> Checks.text(v, 200));
}
}
enum FailureKind { TRANSIENT, PERMANENT, UNKNOWN_EFFECT }
/** Retry eligibility also depends on trusted capability and checkpoints, not just kind. */
record TechnicalFailure(ErrorCode code, FailureKind kind, Optional<java.time.Duration> retryAfter) implements ActionResult {
public TechnicalFailure(ErrorCode code,FailureKind kind){this(code,kind,Optional.empty());}
public TechnicalFailure {
Objects.requireNonNull(code); Objects.requireNonNull(kind); Objects.requireNonNull(retryAfter);
retryAfter.ifPresent(d->{if(d.isNegative()||d.compareTo(java.time.Duration.ofSeconds(120))>0)throw new IllegalArgumentException("Invalid retry hint");});
}
}
}

View File

@ -0,0 +1,15 @@
package com.organizacion.orchestrator.action.domain;
import java.time.Duration;
import java.util.Optional;
/** Bounded exponential delay. Caller supplies randomness, making boundary tests deterministic. */
public final class RetryDelay {
private RetryDelay(){}
public static Duration after(RetryPolicy policy,int completedAttempts,Optional<Duration> hint,double random){
if(completedAttempts<1||completedAttempts>=policy.maxAttempts()||random<0||random>=1)throw new IllegalArgumentException("No eligible retry");
long base=Math.min(policy.maxBackoff().toMillis(),policy.initialBackoff().toMillis()*(1L<<(completedAttempts-1)));
long jittered=Math.min(policy.maxBackoff().toMillis(),Math.max(100,(long)(base*(0.8+random*0.4))));
return Duration.ofMillis(Math.min(120000,Math.max(jittered,hint.map(Duration::toMillis).orElse(0L))));
}
}

View File

@ -0,0 +1,17 @@
package com.organizacion.orchestrator.action.domain;
import java.time.Duration;
import java.util.Objects;
public record RetryPolicy(int maxAttempts, Duration initialBackoff, Duration maxBackoff) {
public static final RetryPolicy DEFAULT = new RetryPolicy(3, Duration.ofSeconds(2), Duration.ofSeconds(4));
public RetryPolicy {
Objects.requireNonNull(initialBackoff); Objects.requireNonNull(maxBackoff);
if (maxAttempts < 1 || maxAttempts > 3
|| initialBackoff.compareTo(Duration.ofMillis(100)) < 0
|| initialBackoff.compareTo(Duration.ofSeconds(60)) > 0
|| maxBackoff.compareTo(initialBackoff) < 0
|| maxBackoff.compareTo(Duration.ofSeconds(120)) > 0)
throw new IllegalArgumentException("Invalid bounded retry policy");
}
}

View File

@ -0,0 +1,14 @@
package com.organizacion.orchestrator.action.infrastructure;
import com.organizacion.orchestrator.action.application.port.ActionHandler;
import com.organizacion.orchestrator.action.domain.*;
import java.util.Optional;
/** Explicit simulation-only utility. Never a Spring bean or member of the worker registry. */
public final class NoOpSimulationHandler implements ActionHandler {
public String type(){return "NO_OP";}
public ActionResult execute(ActionDefinition definition,ActionContext context){
if(!(definition instanceof ActionDefinition.NoOp noOp))throw new IllegalArgumentException("Simulation NO_OP required");
return new ActionResult.Completed(noOp.output(),Optional.empty());
}
}

View File

@ -0,0 +1,36 @@
package com.organizacion.orchestrator.action.infrastructure;
import com.organizacion.orchestrator.action.application.port.ActionHandler;
import com.organizacion.orchestrator.action.domain.*;
import com.organizacion.orchestrator.flow.domain.PublicationCatalog;
import com.organizacion.orchestrator.integration.infrastructure.HttpIntegrationHandler;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.*;
import java.util.*;
/** Verifies metadata through fixed DOCUMENTS_METADATA/v1 protocol. The worker persists the reference under fencing. */
public final class RegisterDocumentHandler implements ActionHandler {
private final PublicationCatalog catalog;private final HttpIntegrationHandler metadata;
public RegisterDocumentHandler(PublicationCatalog catalog,HttpIntegrationHandler metadata){this.catalog=catalog;this.metadata=metadata;}
public String type(){return "REGISTER_DOCUMENT";}
private ActionDefinition.Integration lookup(ActionDefinition.RegisterDocument d){return new ActionDefinition.Integration(d.id(),d.resultKey(),"DOCUMENTS_METADATA",Optional.of("1"),Optional.empty(),Optional.empty(),Map.of(),d.retry());}
public Optional<ActionCapability> capability(ActionDefinition.RegisterDocument definition){
var capability=catalog.handlers().get(type());var lookup=metadata.capability(lookup(definition));
if(capability==null||capability.effect()!=ActionCapability.Effect.READ_ONLY||!catalog.documents().containsKey(definition.documentType())
||lookup.isEmpty()||lookup.get().effect()!=ActionCapability.Effect.READ_ONLY)return Optional.empty();return Optional.of(capability);
}
public ActionResult execute(ActionDefinition definition,ActionContext context){
if(!(definition instanceof ActionDefinition.RegisterDocument document)||capability(document).isEmpty())return fail(ErrorCode.INTEGRATION_UNAVAILABLE);
try{
String id=((StringValue)context.inputs().fields().get("documentId")).value();UUID parsed=UUID.fromString(id);
var input=new ObjectValue(Map.of("documentId",new StringValue(parsed.toString()),"requestId",new StringValue(context.requestId().value().toString()),"businessScope",new StringValue(context.actor().businessScope())));
var response=metadata.execute(lookup(document),new ActionContext(context.executionId(),context.requestId(),context.actor(),input,context.externalIdempotencyKey(),context.attempt()));
if(!(response instanceof ActionResult.Completed completed))return response;
var policy=catalog.documents().get(document.documentType());
var verified=com.organizacion.orchestrator.document.domain.DocumentMetadata.verify(parsed,context.requestId().value(),context.actor().businessScope(),document.documentType(),policy.mimeTypes(),policy.maxBytes(),completed.output());
return new ActionResult.Completed(verified,Optional.empty());
}catch(DomainException e){return fail(e.code());
}catch(RuntimeException e){return fail(ErrorCode.INTEGRATION_CONTRACT_ERROR);}
}
private static ActionResult fail(ErrorCode code){return new ActionResult.TechnicalFailure(code,ActionResult.FailureKind.PERMANENT);}
}

View File

@ -0,0 +1,22 @@
package com.organizacion.orchestrator.action.infrastructure;
import com.organizacion.orchestrator.execution.application.port.WorkerActions;
import com.organizacion.orchestrator.action.domain.*;
import com.organizacion.orchestrator.integration.infrastructure.HttpIntegrationHandler;
import com.organizacion.orchestrator.shared.domain.ErrorCode;
import java.util.Optional;
public final class RegisteredWorkerActions implements WorkerActions {
private final HttpIntegrationHandler integrations;private final RegisterDocumentHandler documents;
public RegisteredWorkerActions(HttpIntegrationHandler integrations,RegisterDocumentHandler documents){this.integrations=integrations;this.documents=documents;}
public Optional<ActionCapability> capability(ActionDefinition definition){return switch(definition){
case ActionDefinition.Integration i->integrations.capability(i);
case ActionDefinition.RegisterDocument d->documents.capability(d);
case ActionDefinition.NoOp ignored->Optional.empty();
};}
public ActionResult execute(ActionDefinition definition,ActionContext context){return switch(definition){
case ActionDefinition.Integration i->integrations.execute(i,context);
case ActionDefinition.RegisterDocument d->documents.execute(d,context);
case ActionDefinition.NoOp ignored->new ActionResult.TechnicalFailure(ErrorCode.ACTION_FAILED,ActionResult.FailureKind.PERMANENT);
};}
}

View File

@ -0,0 +1,37 @@
package com.organizacion.orchestrator.audit.api;
import com.organizacion.orchestrator.audit.infrastructure.HistoryStore;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.shared.application.ResourceAccess;
import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore.Kind;
import com.organizacion.orchestrator.shared.infrastructure.security.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import javax.sql.DataSource;
import java.time.Instant;
import java.util.UUID;
@RestController
@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true")
public class HistoryController {
private final HistoryStore store;private final ResourceAccess access;private final JwtActorMapper actors;
public HistoryController(DataSource source,FlowJsonCodec codec,ResourceAccess access,JwtActorMapper actors,RuntimeRoleVerifier verified,
@Value("${orchestrator.database.schema:orchestrator}")String schema){this.store=new HistoryStore(source,schema,codec);this.access=access;this.actors=actors;}
@GetMapping(value="/api/v1/requests/{id}/history",produces=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> get(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt,
@RequestParam(defaultValue="50")int limit,@RequestParam(required=false)String cursor,
@RequestParam(required=false)String kind,@RequestParam(required=false)UUID executionId,
@RequestParam(required=false)String from,@RequestParam(required=false)String to){
access.require(actors.actor(jwt),Kind.REQUEST,id);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(store.page(id,limit,kind,executionId,instant(from),instant(to),cursor));
}
private static Instant instant(String value){
if(value==null)return null;
try{if(!value.endsWith("Z"))throw new IllegalArgumentException("UTC required");return Instant.parse(value);}
catch(java.time.DateTimeException e){throw new IllegalArgumentException("Invalid time");}
}
}

View File

@ -0,0 +1,55 @@
package com.organizacion.orchestrator.audit.infrastructure;
import com.fasterxml.jackson.databind.node.*;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.*;
import java.nio.charset.StandardCharsets;
import java.security.*;
/** Read-only keyset projection. Cursor is a position, never an authorization credential. */
public final class HistoryStore {
private final JdbcTemplate jdbc;private final String s;private final FlowJsonCodec codec;
private static final Set<String> DETAILS=Set.of("correlationId","transitionId","acceptedRevision","ruleIds","reasonCodes","actionIds","hitPolicy","code","actionId","delayMs","mode","resolutionId");
public HistoryStore(DataSource source,String schema,FlowJsonCodec codec){
if(!schema.matches("[a-z_][a-z0-9_]*"))throw new IllegalArgumentException("Invalid schema");
jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(5);s="\""+schema+"\".";this.codec=codec;
}
public String page(UUID request,int limit,String kind,UUID execution,Instant from,Instant to,String cursor){
if(limit<1||limit>100||kind!=null&&!kind.matches("[A-Za-z][A-Za-z0-9_.-]{0,99}")||from!=null&&to!=null&&!from.isBefore(to))throw new IllegalArgumentException("Invalid filters");
String binding=digest(request+"|"+(kind==null?"":kind)+"|"+execution+"|"+from+"|"+to);
var args=new ArrayList<Object>();args.add(request);
var sql=new StringBuilder("SELECT * FROM "+s+"audit_entry WHERE request_id=?");
if(kind!=null){sql.append(" AND kind=?");args.add(kind);}
if(execution!=null){sql.append(" AND execution_id=?");args.add(execution);}
if(from!=null){sql.append(" AND created_at>=?");args.add(Timestamp.from(from));}
if(to!=null){sql.append(" AND created_at<?");args.add(Timestamp.from(to));}
if(cursor!=null){
if(cursor.length()>2000)throw new IllegalArgumentException("Invalid cursor");
String[] parts=new String(Base64.getUrlDecoder().decode(cursor),StandardCharsets.UTF_8).split("\\|",-1);
if(parts.length!=4||!parts[0].equals("1")||!parts[1].equals(binding))throw new IllegalArgumentException("Invalid cursor");
try{sql.append(" AND (created_at,id)>(?,?)");args.add(Timestamp.from(Instant.parse(parts[2])));args.add(UUID.fromString(parts[3]));}
catch(java.time.DateTimeException e){throw new IllegalArgumentException("Invalid cursor");}
}
sql.append(" ORDER BY created_at,id LIMIT ?");args.add(limit+1);
var rows=jdbc.query(sql.toString(),(rs,n)->{
var item=JsonNodeFactory.instance.objectNode().put("id",rs.getString("id")).put("requestId",request.toString())
.put("kind",rs.getString("kind")).put("actorSubject",rs.getString("actor_subject")).put("flowId",rs.getString("flow_id"))
.put("createdAt",rs.getTimestamp("created_at").toInstant().toString());
for(var field:Map.of("executionId","execution_id","fromState","from_state","toState","to_state").entrySet())
if(rs.getString(field.getValue())!=null)item.put(field.getKey(),rs.getString(field.getValue()));
var detail=item.putObject("detail");
try{var raw=new com.fasterxml.jackson.databind.ObjectMapper().readTree(rs.getString("detail"));DETAILS.forEach(k->{if(raw.has(k))detail.set(k,raw.get(k));});}
catch(java.io.IOException e){throw new IllegalStateException("Invalid stored audit",e);}
return item;
},args.toArray());
var root=JsonNodeFactory.instance.objectNode();var items=root.putArray("items");rows.stream().limit(limit).forEach(items::add);
if(rows.size()>limit){var last=rows.get(limit-1);String position="1|"+binding+"|"+last.get("createdAt").asText()+"|"+last.get("id").asText();
root.put("nextCursor",Base64.getUrlEncoder().withoutPadding().encodeToString(position.getBytes(StandardCharsets.UTF_8)));}
return codec.canonical(root);
}
private static String digest(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}}
}

View File

@ -0,0 +1,36 @@
package com.organizacion.orchestrator.decision.application;
import com.organizacion.orchestrator.decision.application.port.DecisionEvaluator;
import com.organizacion.orchestrator.decision.domain.*;
import com.organizacion.orchestrator.shared.domain.*;
import java.util.Objects;
/** Stateless and reusable across workers. No I/O, persistence or provider access. */
public final class PureDecisionEvaluator implements DecisionEvaluator {
private final EvaluationLimits limits;
private final ConditionEvaluator conditions;
public PureDecisionEvaluator() { this(EvaluationLimits.DEFAULT); }
public PureDecisionEvaluator(EvaluationLimits limits) {
this.limits = Objects.requireNonNull(limits);
this.conditions = new ConditionEvaluator(limits);
}
@Override
public DecisionResult evaluate(DecisionDefinition definition, EvaluationContext context) {
Objects.requireNonNull(definition); Objects.requireNonNull(context);
try {
AstLimits.validate(definition, limits);
DecisionDefinition.Rule selected = null;
for (var rule : definition.rules()) {
if (!conditions.evaluate(rule.when(), context)) continue;
if (selected != null) return new DecisionResult.Failure(ErrorCode.AMBIGUOUS_DECISION);
selected = rule;
if (definition.hitPolicy() == DecisionDefinition.HitPolicy.FIRST) break;
}
return selected == null
? new DecisionResult.Selected(definition.defaultOutput(), java.util.List.of())
: new DecisionResult.Selected(selected.output(), java.util.List.of(selected.id()));
} catch (DomainException error) {
return new DecisionResult.Failure(error.code());
}
}
}

View File

@ -0,0 +1,7 @@
package com.organizacion.orchestrator.decision.application.port;
import com.organizacion.orchestrator.decision.domain.*;
public interface DecisionEvaluator {
DecisionResult evaluate(DecisionDefinition definition, EvaluationContext context);
}

View File

@ -0,0 +1,48 @@
package com.organizacion.orchestrator.decision.domain;
import com.organizacion.orchestrator.shared.domain.DomainException;
import com.organizacion.orchestrator.shared.domain.ErrorCode;
import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.List;
/** Iterative preflight includes branches that runtime short-circuiting will not visit. */
public final class AstLimits {
private AstLimits() {}
private record Node(Object value, int depth) {}
public static void validate(DecisionDefinition definition, EvaluationLimits limits) {
if (definition.rules().size() > limits.maxNodes()) fail();
var ids = new HashSet<String>();
for (var rule : definition.rules()) if (!ids.add(rule.id())) fail();
validate(definition.rules().stream().map(DecisionDefinition.Rule::when).toList(), limits);
}
public static void validate(Condition condition, EvaluationLimits limits) {
validate(List.of(condition), limits);
}
private static void validate(List<Condition> conditions, EvaluationLimits limits) {
var pending = new ArrayDeque<Node>();
if (conditions.size() > limits.maxNodes()) fail();
conditions.forEach(c -> pending.add(new Node(c, 1)));
int count = 0;
while (!pending.isEmpty()) {
var node = pending.removeLast();
if (++count > limits.maxNodes() || node.depth() > limits.maxDepth()) fail();
int depth = node.depth() + 1;
switch (node.value()) {
case Condition.Compare c -> {
pending.add(new Node(c.left(), depth)); pending.add(new Node(c.right(), depth));
}
case Condition.Exists e -> pending.add(new Node(e.value(), depth));
case Condition.Not n -> pending.add(new Node(n.arg(), depth));
case Condition.Logical l -> {
if (l.args().size() > limits.maxNodes()) fail();
l.args().forEach(c -> pending.add(new Node(c, depth)));
}
case ValueExpression ignored -> { }
default -> throw new IllegalArgumentException("Unknown AST node");
}
if (pending.size() + count > limits.maxNodes()) fail();
}
}
private static void fail() { throw new DomainException(ErrorCode.EVALUATION_ERROR); }
}

View File

@ -0,0 +1,25 @@
package com.organizacion.orchestrator.decision.domain;
import java.util.List;
import java.util.Objects;
/** Shape-safe AST. Evaluation, type checking and operational limits belong to T05. */
public sealed interface Condition {
enum Comparison { EQ, NEQ, GT, GTE, LT, LTE, IN }
enum Junction { AND, OR }
record Compare(Comparison op, ValueExpression left, ValueExpression right) implements Condition {
public Compare { Objects.requireNonNull(op); Objects.requireNonNull(left); Objects.requireNonNull(right); }
}
record Exists(ValueExpression.Path value) implements Condition {
public Exists { Objects.requireNonNull(value); }
}
record Logical(Junction op, List<Condition> args) implements Condition {
public Logical {
Objects.requireNonNull(op); args = List.copyOf(args);
if (args.size() < 2) throw new IllegalArgumentException("Logical operator needs at least two arguments");
}
}
record Not(Condition arg) implements Condition {
public Not { Objects.requireNonNull(arg); }
}
}

View File

@ -0,0 +1,108 @@
package com.organizacion.orchestrator.decision.domain;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.*;
import java.util.ArrayDeque;
import java.util.Objects;
/** Pure evaluator. Invalid data raises a safe domain code, never a false business condition. */
public final class ConditionEvaluator {
private final EvaluationLimits limits;
private final JsonPointerResolver pointers = new JsonPointerResolver();
public ConditionEvaluator() { this(EvaluationLimits.DEFAULT); }
public ConditionEvaluator(EvaluationLimits limits) { this.limits = Objects.requireNonNull(limits); }
public boolean evaluate(Condition condition, EvaluationContext context) {
Objects.requireNonNull(condition); Objects.requireNonNull(context);
AstLimits.validate(condition, limits);
return evaluateChecked(condition, context);
}
private boolean evaluateChecked(Condition condition, EvaluationContext context) {
return switch (condition) {
case Condition.Exists e -> pointers.resolve(e.value(), context) instanceof Lookup.Present;
case Condition.Not n -> !evaluateChecked(n.arg(), context);
case Condition.Logical l -> {
boolean and = l.op() == Condition.Junction.AND;
boolean result = and;
for (var arg : l.args()) {
result = evaluateChecked(arg, context);
if (and ? !result : result) break;
}
yield result;
}
case Condition.Compare c -> compare(c.op(), value(c.left(), context), value(c.right(), context));
};
}
private JsonValue value(ValueExpression expression, EvaluationContext context) {
if (expression instanceof ValueExpression.Literal literal) return literal.value();
var resolved = pointers.resolve((ValueExpression.Path) expression, context);
if (resolved instanceof Lookup.Present present) return present.value();
throw error();
}
private boolean compare(Condition.Comparison op, JsonValue left, JsonValue right) {
if (op == Condition.Comparison.IN) return contains(left, right);
if (op == Condition.Comparison.EQ || op == Condition.Comparison.NEQ) {
boolean equal = equal(left, right);
return op == Condition.Comparison.EQ ? equal : !equal;
}
if (!(left instanceof NumberValue a) || !(right instanceof NumberValue b)) throw error();
int comparison = a.value().compareTo(b.value());
return switch (op) {
case GT -> comparison > 0;
case GTE -> comparison >= 0;
case LT -> comparison < 0;
case LTE -> comparison <= 0;
default -> throw error();
};
}
private boolean contains(JsonValue left, JsonValue right) {
if (!(right instanceof ArrayValue array) || left == NullValue.INSTANCE) throw error();
if (array.values().size() > limits.maxNodes()) throw error();
// Check the WHOLE list before membership: an early match cannot hide malformed input.
Class<?> type = array.values().isEmpty() ? left.getClass() : array.values().getFirst().getClass();
for (var item : array.values())
if (item == NullValue.INSTANCE || item.getClass() != type) throw error();
if (left.getClass() != type) throw error();
for (var item : array.values()) if (equal(left, item)) return true;
return false;
}
private record Pair(JsonValue left, JsonValue right) {}
private boolean equal(JsonValue left, JsonValue right) {
var pending = new ArrayDeque<Pair>();
pending.add(new Pair(left, right));
int nodes = 0;
boolean same = true;
while (!pending.isEmpty()) {
if (++nodes > limits.maxNodes()) throw error();
var pair = pending.removeLast();
JsonValue a = pair.left(), b = pair.right();
if (a == NullValue.INSTANCE || b == NullValue.INSTANCE) { same &= a == b; continue; }
if (a.getClass() != b.getClass()) throw error();
switch (a) {
case NumberValue n -> same &= n.value().compareTo(((NumberValue) b).value()) == 0;
case StringValue s -> same &= s.value().equals(((StringValue) b).value());
case BooleanValue v -> same &= v.value() == ((BooleanValue) b).value();
case ArrayValue arr -> {
var other = ((ArrayValue) b).values();
if (arr.values().size() != other.size()) { same = false; break; }
if (arr.values().size() + pending.size() + nodes > limits.maxNodes()) throw error();
for (int i = 0; i < other.size(); i++) pending.add(new Pair(arr.values().get(i), other.get(i)));
}
case ObjectValue obj -> {
var other = ((ObjectValue) b).fields();
if (!obj.fields().keySet().equals(other.keySet())) { same = false; break; }
if (obj.fields().size() + pending.size() + nodes > limits.maxNodes()) throw error();
obj.fields().forEach((key, val) -> pending.add(new Pair(val, other.get(key))));
}
case NullValue ignored -> throw error(); // handled above
}
}
return same;
}
private static DomainException error() { return new DomainException(ErrorCode.EVALUATION_ERROR); }
}

View File

@ -0,0 +1,17 @@
package com.organizacion.orchestrator.decision.domain;
import com.organizacion.orchestrator.shared.domain.Checks;
import java.util.List;
import java.util.Objects;
public record DecisionDefinition(String id, HitPolicy hitPolicy, List<Rule> rules, String defaultOutput) {
public enum HitPolicy { UNIQUE, FIRST }
public DecisionDefinition {
Checks.key(id); Objects.requireNonNull(hitPolicy); Checks.key(defaultOutput);
rules = List.copyOf(rules);
if (rules.isEmpty()) throw new IllegalArgumentException("Decision requires rules");
}
public record Rule(String id, Condition when, String output) {
public Rule { Checks.key(id); Objects.requireNonNull(when); Checks.key(output); }
}
}

View File

@ -0,0 +1,21 @@
package com.organizacion.orchestrator.decision.domain;
import com.organizacion.orchestrator.shared.domain.Checks;
import com.organizacion.orchestrator.shared.domain.ErrorCode;
import java.util.List;
public sealed interface DecisionResult {
record Selected(String output, List<String> matchedRuleIds) implements DecisionResult {
public Selected {
Checks.key(output); matchedRuleIds = List.copyOf(matchedRuleIds);
matchedRuleIds.forEach(Checks::key);
}
public boolean usedDefault() { return matchedRuleIds.isEmpty(); }
}
record Failure(ErrorCode code) implements DecisionResult {
public Failure {
if (code != ErrorCode.EVALUATION_ERROR && code != ErrorCode.AMBIGUOUS_DECISION)
throw new IllegalArgumentException("Not a decision error");
}
}
}

View File

@ -0,0 +1,13 @@
package com.organizacion.orchestrator.decision.domain;
import com.organizacion.orchestrator.shared.domain.Actor;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.util.Objects;
/** Data snapshots only; no clock, repository or HTTP capability is exposed to decisions. */
public record EvaluationContext(ObjectValue requestData, ObjectValue eventData, ObjectValue results, Actor actor) {
public EvaluationContext {
Objects.requireNonNull(requestData); Objects.requireNonNull(eventData);
Objects.requireNonNull(results); Objects.requireNonNull(actor);
}
}

View File

@ -0,0 +1,10 @@
package com.organizacion.orchestrator.decision.domain;
/** Deployment limits may be tightened, never raised above the MVP operational ceilings. */
public record EvaluationLimits(int maxDepth, int maxNodes) {
public static final EvaluationLimits DEFAULT = new EvaluationLimits(20, 1000);
public EvaluationLimits {
if (maxDepth < 1 || maxDepth > 20 || maxNodes < 1 || maxNodes > 1000)
throw new IllegalArgumentException("Evaluation limits outside operational bounds");
}
}

View File

@ -0,0 +1,82 @@
package com.organizacion.orchestrator.decision.domain;
import com.organizacion.orchestrator.shared.domain.JsonValue.NumberValue;
import java.math.BigDecimal;
import java.util.*;
/** Sound overlap detection for numeric intervals on the SAME path, not a general SAT solver. */
public final class IntervalOverlapAnalyzer {
public record Overlap(String firstRuleId, String secondRuleId, String path) {}
public record Analysis(List<Overlap> overlaps, List<String> unsupportedRuleIds) {
public Analysis { overlaps = List.copyOf(overlaps); unsupportedRuleIds = List.copyOf(unsupportedRuleIds); }
}
private record Interval(String path, BigDecimal lower, boolean lowerClosed, BigDecimal upper, boolean upperClosed) {
boolean empty() {
if (lower == null || upper == null) return false;
int c = lower.compareTo(upper);
return c > 0 || (c == 0 && !(lowerClosed && upperClosed));
}
Interval intersect(Interval other) {
BigDecimal lo = lower; boolean lc = lowerClosed;
if (lo == null || (other.lower != null && other.lower.compareTo(lo) > 0)) {
lo = other.lower; lc = other.lowerClosed;
} else if (other.lower != null && lo.compareTo(other.lower) == 0) lc &= other.lowerClosed;
BigDecimal hi = upper; boolean hc = upperClosed;
if (hi == null || (other.upper != null && other.upper.compareTo(hi) < 0)) {
hi = other.upper; hc = other.upperClosed;
} else if (other.upper != null && hi.compareTo(other.upper) == 0) hc &= other.upperClosed;
return new Interval(path, lo, lc, hi, hc);
}
}
public Analysis analyze(DecisionDefinition definition) {
AstLimits.validate(definition, EvaluationLimits.DEFAULT);
var intervals = new LinkedHashMap<String, Interval>();
var unsupported = new ArrayList<String>();
for (var rule : definition.rules()) {
var interval = interval(rule.when());
if (interval == null) unsupported.add(rule.id()); else intervals.put(rule.id(), interval);
}
var overlaps = new ArrayList<Overlap>();
var entries = new ArrayList<>(intervals.entrySet());
for (int i = 0; i < entries.size(); i++) for (int j = i + 1; j < entries.size(); j++) {
var first = entries.get(i); var second = entries.get(j);
if (first.getValue().path.equals(second.getValue().path)
&& !first.getValue().intersect(second.getValue()).empty())
overlaps.add(new Overlap(first.getKey(), second.getKey(), first.getValue().path));
}
return new Analysis(overlaps, unsupported);
}
private Interval interval(Condition condition) {
if (condition instanceof Condition.Logical logical && logical.op() == Condition.Junction.AND) {
Interval result = null;
for (var arg : logical.args()) {
var next = interval(arg);
if (next == null || (result != null && !next.path.equals(result.path))) return null;
result = result == null ? next : result.intersect(next);
}
return result;
}
if (!(condition instanceof Condition.Compare c)) return null;
Condition.Comparison op = c.op();
ValueExpression left = c.left(), right = c.right();
if (left instanceof ValueExpression.Literal && right instanceof ValueExpression.Path) {
var swap = left; left = right; right = swap;
op = switch (op) {
case GT -> Condition.Comparison.LT; case GTE -> Condition.Comparison.LTE;
case LT -> Condition.Comparison.GT; case LTE -> Condition.Comparison.GTE;
default -> op;
};
}
if (!(left instanceof ValueExpression.Path path) || !(right instanceof ValueExpression.Literal literal)
|| !(literal.value() instanceof NumberValue number)) return null;
var n = number.value();
return switch (op) {
case EQ -> new Interval(path.pointer(), n, true, n, true);
case GT -> new Interval(path.pointer(), n, false, null, false);
case GTE -> new Interval(path.pointer(), n, true, null, false);
case LT -> new Interval(path.pointer(), null, false, n, false);
case LTE -> new Interval(path.pointer(), null, false, n, true);
default -> null;
};
}
}

View File

@ -0,0 +1,47 @@
package com.organizacion.orchestrator.decision.domain;
import com.organizacion.orchestrator.shared.domain.JsonValue;
import com.organizacion.orchestrator.shared.domain.JsonValue.*;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** RFC 6901 token unescaping, restricted to the four trusted context roots. */
public final class JsonPointerResolver {
public Lookup resolve(ValueExpression.Path path, EvaluationContext context) {
Objects.requireNonNull(path); Objects.requireNonNull(context);
String[] tokens = path.pointer().substring(1).split("/", -1);
int start;
JsonValue current;
switch (tokens[0]) {
case "request" -> { current = context.requestData(); start = 2; }
case "event" -> { current = context.eventData(); start = 2; }
case "results" -> { current = context.results(); start = 1; }
case "actor" -> {
List<JsonValue> roles = context.actor().roles().stream().sorted()
.map(r -> (JsonValue) new StringValue(r)).toList();
current = new ObjectValue(Map.of(
"subject", new StringValue(context.actor().subject()),
"businessScope", new StringValue(context.actor().businessScope()),
"roles", new ArrayValue(roles)));
start = 1;
}
default -> throw new IllegalArgumentException("Unsupported root");
}
for (int i = start; i < tokens.length; i++) {
String token = tokens[i].replace("~1", "/").replace("~0", "~");
if (current instanceof ObjectValue object) {
current = object.fields().get(token);
if (current == null) return Lookup.Missing.INSTANCE;
} else if (current instanceof ArrayValue array) {
if (!token.matches("0|[1-9][0-9]*")) return Lookup.Missing.INSTANCE;
// Overflow and an out-of-bounds array index are unresolved paths, not coercions.
if (token.length() > 10) return Lookup.Missing.INSTANCE;
long index = Long.parseLong(token);
if (index >= array.values().size()) return Lookup.Missing.INSTANCE;
current = array.values().get((int) index);
} else return Lookup.Missing.INSTANCE;
}
return new Lookup.Present(current);
}
}

View File

@ -0,0 +1,17 @@
package com.organizacion.orchestrator.decision.domain;
import com.organizacion.orchestrator.shared.domain.JsonValue;
import java.util.Objects;
public sealed interface ValueExpression {
record Path(String pointer) implements ValueExpression {
public Path {
Objects.requireNonNull(pointer);
if (!pointer.matches("^/(?:request/data|event/data|results|actor)(?:/(?:[^~/]|~[01])*)*$"))
throw new IllegalArgumentException("Invalid context pointer");
}
}
record Literal(JsonValue value) implements ValueExpression {
public Literal { Objects.requireNonNull(value); }
}
}

View File

@ -0,0 +1,29 @@
package com.organizacion.orchestrator.document.domain;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.*;
import java.util.*;
/** Pure verification of trusted documentary-service metadata; client payload never supplies these fields. */
public final class DocumentMetadata {
private DocumentMetadata(){}
public static ObjectValue verify(UUID document,UUID request,String scope,String type,Set<String> mimeTypes,long maximum,ObjectValue metadata){
try{
var fields=metadata.fields();
if(!UUID.fromString(text(fields,"documentId")).equals(document)||!UUID.fromString(text(fields,"requestId")).equals(request)
||!text(fields,"businessScope").equals(scope))throw new DomainException(ErrorCode.ACCESS_DENIED);
requireLimits(type,mimeTypes,maximum,metadata);
if(!text(fields,"checksum").matches("[a-fA-F0-9]{64}")||!text(fields,"scanStatus").equals("CLEAN"))throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR);
Checks.text(text(fields,"storageDocumentId"),200);
var output=new HashMap<String,JsonValue>();
for(String name:List.of("documentId","requestId","businessScope","documentType","storageDocumentId","mimeType","sizeBytes","checksum"))output.put(name,fields.get(name));
return new ObjectValue(output);
}catch(DomainException e){throw e;}catch(RuntimeException e){throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR);}
}
public static void requireLimits(String type,Set<String> mimeTypes,long maximum,ObjectValue metadata){
try{
var fields=metadata.fields();long size=((NumberValue)fields.get("sizeBytes")).value().longValueExact();
if(!type.equals(text(fields,"documentType"))||!mimeTypes.contains(text(fields,"mimeType"))||size<0||size>maximum)throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR);
}catch(DomainException e){throw e;}catch(RuntimeException e){throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR);}
}
private static String text(Map<String,JsonValue> fields,String key){return ((StringValue)fields.get(key)).value();}
}

View File

@ -0,0 +1,50 @@
package com.organizacion.orchestrator.execution.api;
import com.organizacion.orchestrator.execution.application.Events;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.request.infrastructure.RequestJson;
import com.organizacion.orchestrator.shared.api.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.charset.*;
import java.util.UUID;
@RestController
@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true")
public class EventController {
private final Events events;private final JwtActorMapper actors;private final FlowJsonCodec codec;private final RequestJson json;
public EventController(Events events,JwtActorMapper actors,FlowJsonCodec codec){this.events=events;this.actors=actors;this.codec=codec;json=new RequestJson(codec);}
public record AcceptedEventView(UUID executionId,UUID requestId,String status){}
@PostMapping(value="/api/v1/requests/{id}/events",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<AcceptedEventView> accept(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws IOException{
String key=single(request,"Idempotency-Key",400,"INVALID_IDEMPOTENCY_KEY");
if(!key.matches("[!-~]{1,200}"))throw new ApiException(400,"INVALID_IDEMPOTENCY_KEY");
String match=single(request,"If-Match",428,"PRECONDITION_REQUIRED");long revision;
if(!match.matches("\"(0|[1-9][0-9]*)\""))throw new ApiException(400,"INVALID_IF_MATCH");
try{revision=Long.parseLong(match.substring(1,match.length()-1));}catch(NumberFormatException e){throw new ApiException(400,"INVALID_IF_MATCH");}
if(request.getContentLengthLong()>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE");
var bytes=request.getInputStream().readNBytes(RequestJson.MAX_BODY_BYTES+1);
if(bytes.length>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE");String body;
try{body=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();}
catch(CharacterCodingException e){throw new ApiException(400,"INVALID_REQUEST");}
var n=json.parse(body);
if(n==null||!n.isObject()||n.size()!=2||!n.path("event").isTextual()||!n.path("data").isObject())throw new ApiException(400,"INVALID_REQUEST");
var result=events.accept(new RequestId(id),n.get("event").textValue(),(ObjectValue)codec.value(n.get("data")),key,revision,actors.actor(jwt),CorrelationFilter.id(request));
return ResponseEntity.accepted().location(URI.create("/api/v1/executions/"+result.executionId().value()))
.body(new AcceptedEventView(result.executionId().value(),result.requestId().value(),result.status().name()));
}
private static String single(HttpServletRequest request,String header,int missing,String code){
var values=request.getHeaders(header);if(!values.hasMoreElements())throw new ApiException(missing,code);
String value=values.nextElement();if(values.hasMoreElements())throw new ApiException(400,"INVALID_REQUEST");return value;
}
}

View File

@ -0,0 +1,46 @@
package com.organizacion.orchestrator.execution.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.request.infrastructure.RequestJson;
import com.organizacion.orchestrator.shared.application.ResourceAccess;
import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore.Kind;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.infrastructure.security.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import javax.sql.DataSource;
import java.time.Instant;
import java.util.*;
/** Read projection only: never returns action inputs, provider payloads, lease credentials or identity snapshots. */
@RestController
@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true")
public class ExecutionController {
private final JdbcTemplate jdbc;private final ResourceAccess access;private final JwtActorMapper actors;private final RequestJson json;private final String s;
public ExecutionController(DataSource source,ResourceAccess access,JwtActorMapper actors,FlowJsonCodec codec,RuntimeRoleVerifier verified,
@Value("${orchestrator.database.schema:orchestrator}")String schema){
if(!schema.matches("orchestrator|t(?:07|08|09|10)_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema");
this.jdbc=new JdbcTemplate(source);this.access=access;this.actors=actors;json=new RequestJson(codec);s="\""+schema+"\".";
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record View(UUID id,UUID requestId,String event,String status,Map<String,Object> result,Map<String,String> error,Instant createdAt,Instant updatedAt){}
@GetMapping(value="/api/v1/executions/{id}",produces="application/json")
public View get(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){
var actor=actors.actor(jwt);access.require(actor,Kind.EXECUTION,id);
var rows=jdbc.query("SELECT e.* FROM "+s+"execution e JOIN "+s+"request_instance r ON r.id=e.request_id WHERE e.id=? AND r.owner_subject=? AND r.business_scope=?",(r,i)->{
Map<String,Object> result=null;Map<String,String> error=null;String status=r.getString("status");
if(status.equals("SUCCEEDED")){
var node=json.parse(r.getString("result"));result=new LinkedHashMap<>();
for(String name:List.of("outcome","fromState","toState"))result.put(name,node.path(name).asText());
if(node.path("reasonCodes").isArray()){var reasons=new ArrayList<String>();node.get("reasonCodes").forEach(n->{if(n.isTextual()&&n.asText().matches("[A-Za-z][A-Za-z0-9_.-]{0,99}")&&reasons.size()<100)reasons.add(n.asText());});result.put("reasonCodes",reasons);}
}else if(r.getString("error_code")!=null){String code;try{code=ErrorCode.valueOf(r.getString("error_code")).name();}catch(IllegalArgumentException e){code=ErrorCode.ACTION_FAILED.name();}error=Map.of("code",code);}
return new View(r.getObject("id",UUID.class),r.getObject("request_id",UUID.class),r.getString("event_key"),status,result,error,r.getTimestamp("created_at").toInstant(),r.getTimestamp("updated_at").toInstant());
},id,actor.subject(),actor.businessScope());
if(rows.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);return rows.getFirst();
}
}

View File

@ -0,0 +1,36 @@
package com.organizacion.orchestrator.execution.api;
import com.organizacion.orchestrator.execution.application.Resolutions;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.request.infrastructure.RequestJson;
import com.organizacion.orchestrator.shared.api.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.nio.*;
import java.nio.charset.*;
import java.util.UUID;
@RestController
@ConditionalOnProperty(name="orchestrator.admin.enabled",havingValue="true")
public class ResolutionController {
private final Resolutions resolutions;private final JwtActorMapper actors;private final FlowJsonCodec codec;private final RequestJson json;
public ResolutionController(Resolutions resolutions,JwtActorMapper actors,FlowJsonCodec codec){this.resolutions=resolutions;this.actors=actors;this.codec=codec;json=new RequestJson(codec);}
@PostMapping(value="/api/v1/admin/executions/{id}/resolve",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> resolve(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws java.io.IOException{
var keys=request.getHeaders("Idempotency-Key");
if(!keys.hasMoreElements())throw new ApiException(400,"INVALID_IDEMPOTENCY_KEY");
String key=keys.nextElement();if(keys.hasMoreElements()||!key.matches("[!-~]{1,200}"))throw new ApiException(400,"INVALID_IDEMPOTENCY_KEY");
if(request.getContentLengthLong()>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE");
byte[] bytes=request.getInputStream().readNBytes(RequestJson.MAX_BODY_BYTES+1);if(bytes.length>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE");
String body;try{body=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();}
catch(CharacterCodingException e){throw new ApiException(400,"INVALID_REQUEST");}
var n=json.parse(body);if(n==null||!n.isObject())throw new ApiException(400,"INVALID_REQUEST");
var result=resolutions.resolve(id,key,(ObjectValue)codec.value(n),actors.actor(jwt),CorrelationFilter.id(request));
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(codec.canonical(codec.tree(result)));
}
}

View File

@ -0,0 +1,19 @@
package com.organizacion.orchestrator.execution.application;
import com.organizacion.orchestrator.execution.application.port.EventStore;
import com.organizacion.orchestrator.execution.domain.AcceptedEvent;
import com.organizacion.orchestrator.shared.application.ResourceAccess;
import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore.Kind;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
public final class Events {
private final EventStore store;private final ResourceAccess access;
public Events(EventStore store,ResourceAccess access){this.store=store;this.access=access;}
public AcceptedEvent accept(RequestId request,String event,ObjectValue data,String key,long revision,Actor actor,String correlation){
access.require(actor,Kind.REQUEST,request.value());Checks.key(event);Checks.nonNegative(revision);Checks.text(correlation,200);
if(key==null||!key.matches("[!-~]{1,200}"))throw new IllegalArgumentException("Invalid idempotency key");
return store.accept(request,event,data,key,revision,actor,correlation);
}
}

View File

@ -0,0 +1,16 @@
package com.organizacion.orchestrator.execution.application;
import com.organizacion.orchestrator.execution.application.port.ResolutionStore;
import com.organizacion.orchestrator.shared.application.ResourceAccess;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.util.UUID;
public final class Resolutions {
private final ResolutionStore store;private final ResourceAccess access;
public Resolutions(ResolutionStore store,ResourceAccess access){this.store=store;this.access=access;}
public ObjectValue resolve(UUID execution,String key,ObjectValue command,Actor actor,String correlationId){
access.requireResolution(actor,execution);
if(key==null||!key.matches("[!-~]{1,200}"))throw new IllegalArgumentException("Invalid key");
return store.resolve(execution,key,command,actor,correlationId);
}
}

View File

@ -0,0 +1,11 @@
package com.organizacion.orchestrator.execution.application.port;
import com.organizacion.orchestrator.execution.domain.AcceptedEvent;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
public interface EventStore {
/** Lock authorized request, replay first, then validate and atomically accept without invoking actions. */
AcceptedEvent accept(RequestId request,String event,ObjectValue data,String key,long expectedRevision,Actor actor,String correlation);
}

View File

@ -0,0 +1,8 @@
package com.organizacion.orchestrator.execution.application.port;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.util.UUID;
public interface ResolutionStore {
ObjectValue resolve(UUID execution,String key,ObjectValue command,Actor actor,String correlationId);
}

View File

@ -0,0 +1,10 @@
package com.organizacion.orchestrator.execution.application.port;
import com.organizacion.orchestrator.action.domain.*;
import java.util.Optional;
/** Trusted version-aware adapter registry; implementations must bound calls below lease and honor external keys. */
public interface WorkerActions {
Optional<ActionCapability> capability(ActionDefinition definition);
ActionResult execute(ActionDefinition definition,ActionContext context);
}

View File

@ -0,0 +1,20 @@
package com.organizacion.orchestrator.execution.application.port;
import com.organizacion.orchestrator.execution.domain.WorkerState.*;
import com.organizacion.orchestrator.action.domain.ActionCapability.Effect;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.time.Duration;
import java.util.*;
public interface WorkerStore {
List<Token> claim(String owner,int limit,Duration lease);
void renew(Token token,Duration lease);
void retry(Token token,String action,ErrorCode error,Duration delay);
Work load(Token token);
List<Checkpoint> checkpoints(Token token);
Checkpoint start(Token token,String action,String inputHash,String externalKey,Effect effect);
void save(Token token,String action,String inputHash,ObjectValue output,Optional<String> providerReference);
void complete(Token token,Completion completion);
void fail(Token token,ErrorCode error,boolean uncertain);
}

View File

@ -0,0 +1,8 @@
package com.organizacion.orchestrator.execution.domain;
import com.organizacion.orchestrator.shared.domain.Identifiers.*;
import java.util.Objects;
public record AcceptedEvent(ExecutionId executionId,RequestId requestId,ExecutionStatus status){
public AcceptedEvent{Objects.requireNonNull(executionId);Objects.requireNonNull(requestId);Objects.requireNonNull(status);}
}

View File

@ -0,0 +1,26 @@
package com.organizacion.orchestrator.execution.domain;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
public record ActionExecution(ActionExecutionId id, ExecutionId executionId, String actionId,
Status status, int attemptCount, String externalIdempotencyKey, String inputHash,
Optional<ObjectValue> result, Optional<String> providerReference, Optional<ErrorCode> error,
Optional<Instant> startedAt, Optional<Instant> finishedAt) {
public enum Status { PENDING, RUNNING, SUCCEEDED, FAILED, UNKNOWN }
public ActionExecution {
Objects.requireNonNull(id); Objects.requireNonNull(executionId); Checks.key(actionId);
Objects.requireNonNull(status); Checks.nonNegative(attemptCount); Checks.text(externalIdempotencyKey, 200);
if (inputHash == null || !inputHash.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Invalid input hash");
Objects.requireNonNull(result); Objects.requireNonNull(providerReference).ifPresent(v -> Checks.text(v, 200));
Objects.requireNonNull(error); Objects.requireNonNull(startedAt); Objects.requireNonNull(finishedAt);
if (status == Status.SUCCEEDED && (result.isEmpty() || finishedAt.isEmpty()))
throw new IllegalArgumentException("Incomplete successful checkpoint");
if (startedAt.isPresent() && finishedAt.isPresent() && finishedAt.get().isBefore(startedAt.get()))
throw new IllegalArgumentException("Checkpoint time order");
}
}

View File

@ -0,0 +1,20 @@
package com.organizacion.orchestrator.execution.domain;
import com.organizacion.orchestrator.flow.domain.*;
import com.organizacion.orchestrator.decision.domain.*;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
/** Pure selection and authorization; input schema is validated by the adapter before evaluating the guard. */
public final class EventPolicy {
public Transition transition(FlowDefinition flow,String state,String event,Actor actor){
var transition=flow.transitions().stream().filter(t->t.from().equals(state)&&t.event().equals(event)).findFirst()
.orElseThrow(()->new DomainException(ErrorCode.INVALID_EVENT));
if(transition.allowedRoles().stream().noneMatch(actor.roles()::contains))throw new DomainException(ErrorCode.ACCESS_DENIED);
return transition;
}
public void guard(Transition transition,ObjectValue request,ObjectValue event,Actor actor){
if(transition.guard().isPresent()&&!new ConditionEvaluator().evaluate(transition.guard().get(),
new EvaluationContext(request,event,ObjectValue.empty(),actor)))throw new DomainException(ErrorCode.GUARD_NOT_SATISFIED);
}
}

View File

@ -0,0 +1,33 @@
package com.organizacion.orchestrator.execution.domain;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
public record Execution(ExecutionId id, RequestId requestId, String idempotencyKey, String payloadHash,
Actor actor, String eventKey, ObjectValue eventData, String transitionId, String fromState,
long acceptedRevision, ExecutionStatus status, int attempts, Instant nextAttemptAt,
Optional<Lease> lease, long fencingToken, Optional<BusinessResult> result, Optional<ErrorCode> error,
Instant createdAt, Instant updatedAt) {
public Execution {
Objects.requireNonNull(id); Objects.requireNonNull(requestId); Checks.text(idempotencyKey, 200);
if (payloadHash == null || !payloadHash.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Invalid payload hash");
Objects.requireNonNull(actor); Checks.key(eventKey); Objects.requireNonNull(eventData);
Checks.key(transitionId); Checks.key(fromState); Checks.nonNegative(acceptedRevision);
Objects.requireNonNull(status); Checks.nonNegative(attempts); Objects.requireNonNull(nextAttemptAt);
Objects.requireNonNull(lease); Checks.nonNegative(fencingToken); Objects.requireNonNull(result);
Objects.requireNonNull(error); Objects.requireNonNull(createdAt); Objects.requireNonNull(updatedAt);
if ((status == ExecutionStatus.RUNNING) != lease.isPresent()) throw new IllegalArgumentException("Inconsistent lease");
if (status == ExecutionStatus.SUCCEEDED && result.isEmpty()) throw new IllegalArgumentException("Missing business result");
}
public record Lease(String owner, Instant until) {
public Lease { Checks.text(owner, 200); Objects.requireNonNull(until); }
}
/** A negative outcome may keep the same state while execution succeeds technically. */
public record BusinessResult(String outcome, String toState, ObjectValue data) {
public BusinessResult { Checks.key(outcome); Checks.key(toState); Objects.requireNonNull(data); }
}
}

View File

@ -0,0 +1,8 @@
package com.organizacion.orchestrator.execution.domain;
public enum ExecutionStatus {
PENDING, RUNNING, RETRY_WAIT, SUCCEEDED, FAILED, RECONCILIATION_REQUIRED;
public boolean active() {
return this == PENDING || this == RUNNING || this == RETRY_WAIT || this == RECONCILIATION_REQUIRED;
}
}

View File

@ -0,0 +1,16 @@
package com.organizacion.orchestrator.execution.domain;
import com.organizacion.orchestrator.action.domain.ActionCapability.Effect;
import com.organizacion.orchestrator.flow.domain.*;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import java.util.*;
public final class WorkerState {
private WorkerState(){}
public record Token(ExecutionId executionId,RequestId requestId,String owner,long fence){}
public record Work(Token token,FlowId flowId,FlowDefinition flow,Transition transition,ObjectValue data,ObjectValue event,Actor actor,long revision){}
public record Checkpoint(String actionId,String status,String inputHash,String externalKey,Effect effect,int attempts,Optional<ObjectValue> output){}
public record Completion(FlowId flowId,String toState,ObjectValue data,ObjectValue result,ObjectValue audit){}
}

View File

@ -0,0 +1,18 @@
package com.organizacion.orchestrator.execution.infrastructure;
import com.organizacion.orchestrator.execution.application.Events;
import com.organizacion.orchestrator.execution.application.port.EventStore;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.shared.application.ResourceAccess;
import com.organizacion.orchestrator.shared.infrastructure.security.RuntimeRoleVerifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.*;
import javax.sql.DataSource;
@Configuration(proxyBeanMethods=false)
@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true")
public class EventConfiguration {
@Bean EventStore eventStore(DataSource source,FlowJsonCodec codec,RuntimeRoleVerifier verified,@Value("${orchestrator.database.schema:orchestrator}")String schema){return new PostgresEventStore(source,codec,schema);}
@Bean Events events(EventStore store,ResourceAccess access){return new Events(store,access);}
}

View File

@ -0,0 +1,74 @@
package com.organizacion.orchestrator.execution.infrastructure;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.*;
import com.organizacion.orchestrator.execution.application.port.EventStore;
import com.organizacion.orchestrator.execution.domain.*;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.request.infrastructure.RequestJson;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.*;
public final class PostgresEventStore implements EventStore {
private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final String schema;
private final FlowJsonCodec codec;private final RequestJson json;private final EventPolicy policy=new EventPolicy();
private static final ObjectMapper JSON=new ObjectMapper();
public PostgresEventStore(DataSource source,FlowJsonCodec codec,String schema){
if(!schema.matches("orchestrator|t0[789]_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema");
this.schema="\""+schema+"\".";this.codec=codec;json=new RequestJson(codec);jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(15);
var manager=new DataSourceTransactionManager(source);manager.setValidateExistingTransaction(true);tx=new TransactionTemplate(manager);
tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);tx.setTimeout(30);
}
public AcceptedEvent accept(RequestId request,String event,ObjectValue data,String key,long expected,Actor actor,String correlation){
String encoded=json.data(data);var command=JSON.createObjectNode().put("event",event).put("expectedRevision",expected);
command.set("data",codec.tree(data));String hash;
try{hash=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(codec.canonical(command).getBytes(StandardCharsets.UTF_8)));}
catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}
return tx.execute(status->{
// Recheck ownership under the row lock; never rely solely on the preflight resource check.
var rows=jdbc.query("SELECT flow_id,current_state,data::text,revision FROM "+schema+"request_instance WHERE id=? AND owner_subject=? AND business_scope=? FOR UPDATE",
(r,i)->new Locked(r.getObject(1,UUID.class),r.getString(2),(ObjectValue)codec.value(json.parse(r.getString(3))),r.getLong(4)),
request.value(),actor.subject(),actor.businessScope());
if(rows.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);var current=rows.getFirst();
var replay=jdbc.query("SELECT id,payload_hash,actor_subject,status FROM "+schema+"execution WHERE request_id=? AND idempotency_key=?",
(r,i)->new Replay(r.getObject(1,UUID.class),r.getString(2),r.getString(3),ExecutionStatus.valueOf(r.getString(4))),request.value(),key);
if(!replay.isEmpty()){
var previous=replay.getFirst();if(!previous.actor.equals(actor.subject()))throw new DomainException(ErrorCode.ACCESS_DENIED);
if(!previous.hash.equals(hash))throw new DomainException(ErrorCode.IDEMPOTENCY_CONFLICT);
return new AcceptedEvent(new ExecutionId(previous.id),request,previous.status);
}
if(current.revision!=expected)throw new DomainException(ErrorCode.REVISION_CONFLICT);
if(Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM "+schema+"execution WHERE request_id=? AND status IN ('PENDING','RUNNING','RETRY_WAIT','RECONCILIATION_REQUIRED'))",Boolean.class,request.value())))
throw new DomainException(ErrorCode.REQUEST_BUSY);
if(expected==Long.MAX_VALUE)throw new DomainException(ErrorCode.INVALID_EVENT);
var definitions=jdbc.query("SELECT definition::text FROM "+schema+"flow_definition WHERE id=? AND status IN ('PUBLISHED','RETIRED')",(r,i)->codec.decode(r.getString(1)),current.flow);
if(definitions.isEmpty())throw new DomainException(ErrorCode.INVALID_FLOW);var flow=definitions.getFirst();
var transition=policy.transition(flow,current.state,event,actor);
var input=flow.events().stream().filter(e->e.key().equals(event)).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_EVENT)).inputSchema();
var validator=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(codec.tree(input.value()),SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build());
if(!validator.validate(codec.tree(data)).isEmpty())throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR);
policy.guard(transition,current.data,data,actor);
UUID id=UUID.randomUUID();long acceptedRevision=expected+1;
var identity=JSON.createObjectNode().put("subject",actor.subject()).put("businessScope",actor.businessScope());
var roles=identity.putArray("roles");actor.roles().stream().sorted().forEach(roles::add);
jdbc.update("INSERT INTO "+schema+"execution(id,request_id,idempotency_key,payload_hash,actor_subject,actor_snapshot,event_key,event_data,transition_id,from_state,accepted_revision,status) VALUES (?,?,?,?,?,?::jsonb,?,?::jsonb,?,?,?,'PENDING')",
id,request.value(),key,hash,actor.subject(),codec.canonical(identity),event,encoded,transition.id(),current.state,acceptedRevision);
jdbc.update("UPDATE "+schema+"request_instance SET revision=?,updated_at=now() WHERE id=?",acceptedRevision,request.value());
var detail=JSON.createObjectNode().put("correlationId",correlation).put("transitionId",transition.id()).put("acceptedRevision",acceptedRevision);
jdbc.update("INSERT INTO "+schema+"audit_entry(id,request_id,execution_id,kind,actor_subject,flow_id,from_state,detail) VALUES (?,?,?,'EVENT_ACCEPTED',?,?,?,?::jsonb)",
UUID.randomUUID(),request.value(),id,actor.subject(),current.flow,current.state,codec.canonical(detail));
return new AcceptedEvent(new ExecutionId(id),request,ExecutionStatus.PENDING);
});
}
private record Locked(UUID flow,String state,ObjectValue data,long revision){}
private record Replay(UUID id,String hash,String actor,ExecutionStatus status){}
}

View File

@ -0,0 +1,101 @@
package com.organizacion.orchestrator.execution.infrastructure;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.*;
import com.networknt.schema.*;
import com.organizacion.orchestrator.action.domain.*;
import com.organizacion.orchestrator.execution.application.port.ResolutionStore;
import com.organizacion.orchestrator.flow.domain.*;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.request.infrastructure.RequestJson;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
/** Operator attestation is durable evidence, not an automated provider verification. No external IO in this transaction. */
public final class PostgresResolutionStore implements ResolutionStore {
private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final String s;
private final FlowJsonCodec codec;private final RequestJson json;private final PublicationCatalog catalog;
private final JsonSchema commandSchema;
public PostgresResolutionStore(DataSource source,String schema,FlowJsonCodec codec,PublicationCatalog catalog){
if(!schema.matches("orchestrator|t07_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema");
s="\""+schema+"\".";this.codec=codec;json=new RequestJson(codec);this.catalog=catalog;
jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(15);tx=new TransactionTemplate(new DataSourceTransactionManager(source));tx.setIsolationLevel(2);tx.setTimeout(30);
commandSchema=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(SchemaLocation.of("classpath:schemas/api.schema.json#/$defs/ReconciliationCommand"));
}
public ObjectValue resolve(UUID id,String key,ObjectValue command,Actor actor,String correlation){
if(!actor.roles().contains("EXECUTION_OPERATOR"))throw new DomainException(ErrorCode.ACCESS_DENIED);
String canonical=json.data(command);JsonNode n=codec.tree(command);
if(!commandSchema.validate(n).isEmpty()||n.path("evidence").path("reference").asText().isBlank()||n.path("evidence").path("reason").asText().isBlank())throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR);
String hash=hash(canonical);
return tx.execute(status->{
var ids=jdbc.queryForList("SELECT request_id FROM "+s+"execution WHERE id=?",UUID.class,id);
if(ids.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);
var request=jdbc.queryForMap("SELECT * FROM "+s+"request_instance WHERE id=? FOR UPDATE",ids.getFirst());
if(!actor.businessScope().equals(request.get("business_scope")))throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);
var execution=jdbc.queryForMap("SELECT * FROM "+s+"execution WHERE id=? FOR UPDATE",id);
var prior=jdbc.queryForList("SELECT payload_hash,actor_subject,result::text FROM "+s+"reconciliation_resolution WHERE execution_id=? AND idempotency_key=?",id,key);
if(!prior.isEmpty()){
var previous=prior.getFirst();
if(!hash.equals(previous.get("payload_hash"))||!actor.subject().equals(previous.get("actor_subject")))throw new DomainException(ErrorCode.IDEMPOTENCY_CONFLICT);
return (ObjectValue)codec.value(json.parse((String)previous.get("result")));
}
if(!execution.get("status").equals("RECONCILIATION_REQUIRED"))throw new DomainException(ErrorCode.RECONCILIATION_REQUIRED);
boolean resume=n.get("mode").asText().equals("RESUME");
if(!resume&&n.get("effectStatus").asText().equals("ABSENT_VERIFIED")
&&Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM "+s+"action_execution WHERE execution_id=? AND effect<>'READ_ONLY' AND status='SUCCEEDED')",Boolean.class,id)))throw invalid();
if(resume){
if(!request.get("revision").equals(execution.get("accepted_revision"))||!request.get("current_state").equals(execution.get("from_state")))throw new DomainException(ErrorCode.REVISION_CONFLICT);
var flow=codec.decode(jdbc.queryForObject("SELECT definition::text FROM "+s+"flow_definition WHERE id=?",String.class,request.get("flow_id")));
var transition=flow.transitions().stream().filter(t->t.id().equals(execution.get("transition_id"))).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW));
var definitions=new HashMap<String,ActionDefinition>();transition.actions().forEach(a->definitions.put(a.id(),a));
var checkpoints=jdbc.queryForList("SELECT * FROM "+s+"action_execution WHERE execution_id=? ORDER BY action_id FOR UPDATE",id);
var remaining=new HashSet<String>();for(var c:checkpoints)if(!c.get("status").equals("SUCCEEDED"))remaining.add((String)c.get("action_id"));
var seen=new HashSet<String>();
for(var verified:n.get("verifiedActions")){
String action=verified.get("actionId").asText();
if(!seen.add(action))throw invalid();
var checkpoint=checkpoints.stream().filter(c->action.equals(c.get("action_id"))).findFirst().orElseThrow(PostgresResolutionStore::invalid);
var definition=definitions.get(action);if(definition==null)throw invalid();
String output=json.data((ObjectValue)codec.value(verified.get("result")));
if(checkpoint.get("status").equals("SUCCEEDED")){
String existing=codec.canonical(json.parse(checkpoint.get("result").toString()));
if(!existing.equals(output)||(verified.has("providerReference")&&!verified.get("providerReference").asText().equals(checkpoint.get("provider_reference"))))throw invalid();
continue;
}
// Document references must originate from metadata verification, never manual output injection.
if(!(definition instanceof ActionDefinition.Integration integration))throw invalid();
var entry=catalog.integrations().get(integration.integrationKey());
var contract=entry==null?null:entry.contracts().get(integration.contractVersion().orElse(""));
if(contract==null||!contract.effect().name().equals(checkpoint.get("effect")))throw invalid();
if(integration.profile().isPresent()&&!contract.profiles().getOrDefault(integration.profile().get(),Set.of()).contains(integration.profileVersion().orElse("")))throw invalid();
JsonNode outputSchema=codec.tree(contract.output().value());inline(outputSchema);
if(!JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(outputSchema,SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()).validate(verified.get("result")).isEmpty())throw invalid();
jdbc.update("UPDATE "+s+"action_execution SET status='SUCCEEDED',result=?::jsonb,provider_reference=?,error_code=NULL,finished_at=clock_timestamp() WHERE execution_id=? AND action_id=?",output,verified.has("providerReference")?verified.get("providerReference").asText():null,id,action);
remaining.remove(action);
}
if(!remaining.isEmpty())throw invalid();
}
String target=resume?"PENDING":"FAILED";UUID resolution=UUID.randomUUID();
String at=jdbc.queryForObject("SELECT clock_timestamp()",java.sql.Timestamp.class).toInstant().toString();
var view=JsonNodeFactory.instance.objectNode().put("resolutionId",resolution.toString()).put("executionId",id.toString()).put("status",target).put("resolvedAt",at);
jdbc.update("UPDATE "+s+"execution SET status=?,error_code=?,next_attempt_at=clock_timestamp(),lease_owner=NULL,lease_until=NULL,fencing_token=fencing_token+1,updated_at=clock_timestamp() WHERE id=?",target,resume?null:ErrorCode.ACTION_FAILED.name(),id);
if(!resume)jdbc.update("UPDATE "+s+"action_execution SET status='FAILED',error_code=?,finished_at=clock_timestamp() WHERE execution_id=? AND status IN ('RUNNING','UNKNOWN')",ErrorCode.ACTION_FAILED.name(),id);
jdbc.update("INSERT INTO "+s+"reconciliation_resolution(id,execution_id,idempotency_key,payload_hash,mode,actor_subject,evidence,verified_actions,effect_status,result) VALUES (?,?,?,?,?,?,?::jsonb,?::jsonb,?,?::jsonb)",
resolution,id,key,hash,n.get("mode").asText(),actor.subject(),codec.canonical(n.get("evidence")),resume?codec.canonical(n.get("verifiedActions")):"[]",resume?null:n.get("effectStatus").asText(),codec.canonical(view));
var detail=JsonNodeFactory.instance.objectNode().put("resolutionId",resolution.toString()).put("mode",n.get("mode").asText()).put("correlationId",correlation);
jdbc.update("INSERT INTO "+s+"audit_entry(id,request_id,execution_id,kind,actor_subject,flow_id,from_state,detail) VALUES (?,?,?,'EXECUTION_RESOLVED',?,?,?,?::jsonb)",
UUID.randomUUID(),ids.getFirst(),id,actor.subject(),request.get("flow_id"),request.get("current_state"),codec.canonical(detail));
return (ObjectValue)codec.value(view);
});
}
private static DomainException invalid(){return new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR);}
private static void inline(JsonNode n){if(n.has("$ref")||n.has("$dynamicRef"))throw invalid();if(n.isContainerNode())n.forEach(PostgresResolutionStore::inline);}
private static String hash(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
}

View File

@ -0,0 +1,134 @@
package com.organizacion.orchestrator.execution.infrastructure;
import com.organizacion.orchestrator.execution.application.port.WorkerStore;
import com.organizacion.orchestrator.execution.domain.WorkerState.*;
import com.organizacion.orchestrator.action.domain.ActionCapability.Effect;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.request.infrastructure.RequestJson;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.sql.DataSource;
import java.sql.*;
import java.time.Duration;
import java.util.*;
import java.util.function.Supplier;
/** Every worker mutation is fenced under the execution row lock, with DB-time lease checks before commit. */
public final class PostgresWorkerStore implements WorkerStore {
private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final String s;private final FlowJsonCodec codec;private final RequestJson json;
public PostgresWorkerStore(DataSource source,FlowJsonCodec codec,String schema){
if(!schema.matches("orchestrator|t(?:07|08|09|10)_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema");
s="\""+schema+"\".";this.codec=codec;json=new RequestJson(codec);jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(15);
tx=new TransactionTemplate(new DataSourceTransactionManager(source));tx.setIsolationLevel(2);tx.setTimeout(30);
}
private String owned(){return "id=? AND request_id=? AND status='RUNNING' AND lease_owner=? AND fencing_token=? AND lease_until>clock_timestamp()";}
private Object[] args(Token t){return new Object[]{t.executionId().value(),t.requestId().value(),t.owner(),t.fence()};}
private void check(Token t,boolean lock){
if(jdbc.query("SELECT id FROM "+s+"execution WHERE "+owned()+(lock?" FOR UPDATE":""),(r,i)->r.getObject(1),args(t)).isEmpty())throw new DomainException(ErrorCode.LEASE_LOST);
}
private <T>T fenced(Token t,Supplier<T> body){return tx.execute(status->{check(t,true);T result=body.get();check(t,false);return result;});}
private static long seconds(Duration duration){long n=duration.toSeconds();if(n<1||n>3600)throw new IllegalArgumentException("Invalid lease");return n;}
public List<Token> claim(String owner,int limit,Duration lease){
Checks.text(owner,200);if(limit<1||limit>100)throw new IllegalArgumentException("Invalid batch");long seconds=seconds(lease);
return tx.execute(status->jdbc.query("WITH candidates AS (SELECT id FROM "+s+"execution WHERE "
+"(status IN ('PENDING','RETRY_WAIT') AND next_attempt_at<=clock_timestamp()) OR (status='RUNNING' AND lease_until<=clock_timestamp()) "
+"ORDER BY next_attempt_at,created_at,id LIMIT ? FOR UPDATE SKIP LOCKED) UPDATE "+s+"execution e SET status='RUNNING',lease_owner=?,"
+"lease_until=clock_timestamp()+(? * interval '1 second'),fencing_token=e.fencing_token+1,attempts=e.attempts+1,updated_at=clock_timestamp() "
+"FROM candidates c WHERE e.id=c.id RETURNING e.id,e.request_id,e.fencing_token",
(r,i)->new Token(new ExecutionId(r.getObject(1,UUID.class)),new RequestId(r.getObject(2,UUID.class)),owner,r.getLong(3)),limit,owner,seconds));
}
public void renew(Token t,Duration lease){long seconds=seconds(lease);
int count=jdbc.update("UPDATE "+s+"execution SET lease_until=clock_timestamp()+(? * interval '1 second') WHERE "+owned(),seconds,t.executionId().value(),t.requestId().value(),t.owner(),t.fence());
if(count!=1)throw new DomainException(ErrorCode.LEASE_LOST);
}
public Work load(Token t){return fenced(t,()->{
var row=jdbc.queryForMap("SELECT e.event_data::text,e.actor_snapshot::text,e.transition_id,e.accepted_revision,e.from_state,r.current_state,r.revision,r.flow_id,r.data::text,f.definition::text FROM "+s+"execution e JOIN "+s+"request_instance r ON r.id=e.request_id JOIN "+s+"flow_definition f ON f.id=r.flow_id WHERE e.id=?",t.executionId().value());
if(!row.get("accepted_revision").equals(row.get("revision"))||!row.get("from_state").equals(row.get("current_state")))throw new DomainException(ErrorCode.REVISION_CONFLICT);
var flow=codec.decode((String)row.get("definition"));var identity=json.parse((String)row.get("actor_snapshot"));var roles=new HashSet<String>();identity.path("roles").forEach(n->roles.add(n.asText()));
var actor=new Actor(identity.get("subject").asText(),identity.get("businessScope").asText(),roles);
var transition=flow.transitions().stream().filter(v->v.id().equals(row.get("transition_id"))).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW));
return new Work(t,new FlowId((UUID)row.get("flow_id")),flow,transition,value((String)row.get("data")),value((String)row.get("event_data")),actor,((Number)row.get("accepted_revision")).longValue());
});}
private ObjectValue value(String text){return (ObjectValue)codec.value(json.parse(text));}
private String encode(ObjectValue data){return codec.canonical(codec.tree(data));}
private Checkpoint checkpoint(ResultSet r,int i)throws SQLException{
return new Checkpoint(r.getString("action_id"),r.getString("status"),r.getString("input_hash"),r.getString("external_idempotency_key"),Effect.valueOf(r.getString("effect")),r.getInt("attempt_count"),Optional.ofNullable(r.getString("result")).map(this::value));
}
public List<Checkpoint> checkpoints(Token t){return fenced(t,()->jdbc.query("SELECT * FROM "+s+"action_execution WHERE execution_id=? ORDER BY action_id",this::checkpoint,t.executionId().value()));}
public Checkpoint start(Token t,String action,String hash,String external,Effect effect){return fenced(t,()->{
var existing=jdbc.query("SELECT * FROM "+s+"action_execution WHERE execution_id=? AND action_id=?",this::checkpoint,t.executionId().value(),action);
if(!existing.isEmpty()){
var c=existing.getFirst();if(!c.inputHash().equals(hash)||!c.externalKey().equals(external)||c.effect()!=effect)throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR);
if(c.status().equals("SUCCEEDED"))return c;
if(c.status().equals("UNKNOWN")||(c.status().equals("RUNNING")&&effect==Effect.NON_IDEMPOTENT_WRITE))throw new DomainException(ErrorCode.RECONCILIATION_REQUIRED);
jdbc.update("UPDATE "+s+"action_execution SET status='RUNNING',attempt_count=attempt_count+1,started_at=clock_timestamp(),finished_at=NULL,error_code=NULL WHERE execution_id=? AND action_id=?",t.executionId().value(),action);
}else jdbc.update("INSERT INTO "+s+"action_execution(id,execution_id,action_id,status,attempt_count,external_idempotency_key,input_hash,effect,started_at) VALUES (?,?,?,'RUNNING',1,?,?,?,clock_timestamp())",UUID.randomUUID(),t.executionId().value(),action,external,hash,effect.name());
return jdbc.queryForObject("SELECT * FROM "+s+"action_execution WHERE execution_id=? AND action_id=?",this::checkpoint,t.executionId().value(),action);
});}
public void save(Token t,String action,String hash,ObjectValue output,Optional<String> provider){tx.executeWithoutResult(status->{var request=lockRequest(t);fenced(t,()->{
int count=jdbc.update("UPDATE "+s+"action_execution SET status='SUCCEEDED',result=?::jsonb,provider_reference=?,finished_at=clock_timestamp() WHERE execution_id=? AND action_id=? AND input_hash=? AND status='RUNNING'",encode(output),provider.orElse(null),t.executionId().value(),action,hash);
if(count!=1)throw new DomainException(ErrorCode.LEASE_LOST);
registerDocument(t,action,output,request);return null;
});});}
private void registerDocument(Token token,String actionId,ObjectValue output,Map<String,Object> request){
var execution=jdbc.queryForMap("SELECT transition_id,actor_subject FROM "+s+"execution WHERE id=?",token.executionId().value());
var flow=codec.decode(jdbc.queryForObject("SELECT definition::text FROM "+s+"flow_definition WHERE id=?",String.class,request.get("flow_id")));
var action=flow.transitions().stream().filter(t->t.id().equals(execution.get("transition_id"))).flatMap(t->t.actions().stream()).filter(a->a.id().equals(actionId)).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW));
if(!(action instanceof com.organizacion.orchestrator.action.domain.ActionDefinition.RegisterDocument document))return;
var n=codec.tree(output);
if(!token.requestId().value().toString().equals(n.path("requestId").asText())||!request.get("business_scope").equals(n.path("businessScope").asText())||!document.documentType().equals(n.path("documentType").asText()))throw new DomainException(ErrorCode.ACCESS_DENIED);
UUID id=UUID.fromString(n.path("documentId").asText());String storage=Checks.text(n.path("storageDocumentId").asText(),200);
String mime=Checks.text(n.path("mimeType").asText(),100),checksum=Checks.text(n.path("checksum").asText(),128);
long size=n.path("sizeBytes").decimalValue().longValueExact();
jdbc.update("INSERT INTO "+s+"document_reference(id,request_id,document_type,storage_document_id,mime_type,size_bytes,checksum,created_by) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING",id,token.requestId().value(),document.documentType(),storage,mime,size,checksum,execution.get("actor_subject"));
if(!Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM "+s+"document_reference WHERE id=? AND request_id=? AND document_type=? AND storage_document_id=? AND mime_type=? AND size_bytes=? AND checksum=?)",Boolean.class,id,token.requestId().value(),document.documentType(),storage,mime,size,checksum)))throw new DomainException(ErrorCode.ACCESS_DENIED);
}
private Map<String,Object> lockRequest(Token t){
var rows=jdbc.queryForList("SELECT * FROM "+s+"request_instance WHERE id=? FOR UPDATE",t.requestId().value());if(rows.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);return rows.getFirst();
}
public void complete(Token t,Completion completion){tx.executeWithoutResult(status->{
var request=lockRequest(t);check(t,true);var execution=jdbc.queryForMap("SELECT * FROM "+s+"execution WHERE id=?",t.executionId().value());
if(!request.get("flow_id").equals(completion.flowId().value())||!request.get("current_state").equals(execution.get("from_state"))
||!request.get("revision").equals(execution.get("accepted_revision")))throw new DomainException(ErrorCode.REVISION_CONFLICT);
// Every action in the pinned transition must have its durable successful checkpoint.
var flow=codec.decode(jdbc.queryForObject("SELECT definition::text FROM "+s+"flow_definition WHERE id=?",String.class,request.get("flow_id")));
var transition=flow.transitions().stream().filter(v->v.id().equals(execution.get("transition_id"))).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW));
var expected=new HashSet<String>();transition.actions().forEach(a->expected.add(a.id()));
var checkpoints=jdbc.query("SELECT * FROM "+s+"action_execution WHERE execution_id=?",this::checkpoint,t.executionId().value());
var actual=new HashSet<String>();checkpoints.forEach(c->actual.add(c.actionId()));
if(!actual.equals(expected)||checkpoints.stream().anyMatch(c->!c.status().equals("SUCCEEDED")))throw new DomainException(ErrorCode.ACTION_FAILED);
jdbc.update("UPDATE "+s+"request_instance SET current_state=?,data=?::jsonb,revision=revision+1,updated_at=clock_timestamp() WHERE id=?",completion.toState(),json.data(completion.data()),t.requestId().value());
audit(t,"TRANSITION_COMPLETED",(String)execution.get("actor_subject"),(UUID)request.get("flow_id"),(String)execution.get("from_state"),completion.toState(),completion.audit());
int changed=jdbc.update("UPDATE "+s+"execution SET status='SUCCEEDED',result=?::jsonb,error_code=NULL,lease_owner=NULL,lease_until=NULL,updated_at=clock_timestamp() WHERE "+owned(),encode(completion.result()),t.executionId().value(),t.requestId().value(),t.owner(),t.fence());
if(changed!=1)throw new DomainException(ErrorCode.LEASE_LOST);
});}
private void audit(Token t,String kind,String actor,UUID flow,String from,String to,ObjectValue detail){
jdbc.update("INSERT INTO "+s+"audit_entry(id,request_id,execution_id,kind,actor_subject,flow_id,from_state,to_state,detail) VALUES (?,?,?,?,?,?,?,?,?::jsonb)",UUID.randomUUID(),t.requestId().value(),t.executionId().value(),kind,actor,flow,from,to,encode(detail));
}
public void fail(Token t,ErrorCode error,boolean uncertain){tx.executeWithoutResult(status->{
var request=lockRequest(t);check(t,true);var execution=jdbc.queryForMap("SELECT actor_subject,from_state FROM "+s+"execution WHERE id=?",t.executionId().value());
// Completed writes and in-flight writes are never silently released as FAILED.
boolean writes=Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM "+s+"action_execution WHERE execution_id=? AND effect<>'READ_ONLY' AND status IN ('RUNNING','SUCCEEDED','UNKNOWN','FAILED'))",Boolean.class,t.executionId().value()));
boolean reconcile=uncertain||writes;String target=reconcile?"RECONCILIATION_REQUIRED":"FAILED";
jdbc.update("UPDATE "+s+"action_execution SET status=?,error_code=?,finished_at=clock_timestamp() WHERE execution_id=? AND status='RUNNING'",reconcile?"UNKNOWN":"FAILED",error.name(),t.executionId().value());
audit(t,target,(String)execution.get("actor_subject"),(UUID)request.get("flow_id"),(String)execution.get("from_state"),null,new ObjectValue(Map.of("code",new JsonValue.StringValue(error.name()))));
int changed=jdbc.update("UPDATE "+s+"execution SET status=?,error_code=?,lease_owner=NULL,lease_until=NULL,updated_at=clock_timestamp() WHERE "+owned(),target,error.name(),t.executionId().value(),t.requestId().value(),t.owner(),t.fence());
if(changed!=1)throw new DomainException(ErrorCode.LEASE_LOST);
});}
public void retry(Token t,String action,ErrorCode error,Duration delay){
long millis=delay.toMillis();if(millis<100||millis>120000)throw new IllegalArgumentException("Invalid retry delay");
tx.executeWithoutResult(status->{var request=lockRequest(t);check(t,true);
var execution=jdbc.queryForMap("SELECT actor_subject,from_state FROM "+s+"execution WHERE id=?",t.executionId().value());
int count=jdbc.update("UPDATE "+s+"action_execution SET status='FAILED',error_code=?,finished_at=clock_timestamp() WHERE execution_id=? AND action_id=? AND status='RUNNING' AND effect IN ('READ_ONLY','IDEMPOTENT_WRITE')",error.name(),t.executionId().value(),action);
if(count!=1)throw new DomainException(ErrorCode.RECONCILIATION_REQUIRED);
audit(t,"RETRY_SCHEDULED",(String)execution.get("actor_subject"),(UUID)request.get("flow_id"),(String)execution.get("from_state"),null,
new ObjectValue(Map.of("actionId",new JsonValue.StringValue(action),"code",new JsonValue.StringValue(error.name()),"delayMs",new JsonValue.NumberValue(java.math.BigDecimal.valueOf(millis)))));
int changed=jdbc.update("UPDATE "+s+"execution SET status='RETRY_WAIT',error_code=?,next_attempt_at=clock_timestamp()+(? * interval '1 millisecond'),lease_owner=NULL,lease_until=NULL,updated_at=clock_timestamp() WHERE "+owned(),error.name(),millis,t.executionId().value(),t.requestId().value(),t.owner(),t.fence());
if(changed!=1)throw new DomainException(ErrorCode.LEASE_LOST);
});
}
}

View File

@ -0,0 +1,18 @@
package com.organizacion.orchestrator.execution.infrastructure;
import com.organizacion.orchestrator.execution.application.Resolutions;
import com.organizacion.orchestrator.flow.domain.PublicationCatalog;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.shared.application.ResourceAccess;
import com.organizacion.orchestrator.shared.infrastructure.security.RuntimeRoleVerifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.*;
import javax.sql.DataSource;
@Configuration(proxyBeanMethods=false)
@ConditionalOnProperty(name="orchestrator.admin.enabled",havingValue="true")
public class ResolutionConfiguration {
@Bean Resolutions resolutions(DataSource source,FlowJsonCodec codec,PublicationCatalog catalog,ResourceAccess access,RuntimeRoleVerifier verified,@Value("${orchestrator.database.schema:orchestrator}")String schema){
return new Resolutions(new PostgresResolutionStore(source,schema,codec,catalog),access);
}
}

View File

@ -0,0 +1,69 @@
package com.organizacion.orchestrator.execution.infrastructure;
import com.organizacion.orchestrator.execution.domain.WorkerState.*;
import com.organizacion.orchestrator.decision.application.PureDecisionEvaluator;
import com.organizacion.orchestrator.decision.domain.*;
import com.organizacion.orchestrator.flow.domain.*;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.request.infrastructure.RequestJson;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.*;
import com.networknt.schema.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import java.util.*;
/** Deterministic projection shared by durable execution and administrative simulation. No I/O. */
public final class TransitionEvaluator {
private final FlowJsonCodec codec;private final RequestJson json;
public TransitionEvaluator(FlowJsonCodec codec){this.codec=codec;json=new RequestJson(codec);}
private EvaluationContext context(Work w,Map<String,JsonValue> results){return new EvaluationContext(w.data(),w.event(),new ObjectValue(results),w.actor());}
private JsonValue value(ValueExpression expression,EvaluationContext context){
if(expression instanceof ValueExpression.Literal l)return l.value();
var found=new JsonPointerResolver().resolve((ValueExpression.Path)expression,context);
if(found instanceof Lookup.Present present)return present.value();throw new DomainException(ErrorCode.EVALUATION_ERROR);
}
private void validate(InlineSchema schema,ObjectValue data){
var validator=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(codec.tree(schema.value()),SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build());
if(!validator.validate(codec.tree(data)).isEmpty())throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR);
}
public Completion complete(Work work,Map<String,JsonValue> results){
var context=context(work,results);String outcome,to;List<String> rules=List.of();String policy="FIXED";
if(work.transition().routing() instanceof Transition.Routing.Fixed fixed){outcome=fixed.outcome();to=fixed.to();}
else{
var route=(Transition.Routing.Decision)work.transition().routing();
var definition=work.flow().decisions().stream().filter(d->d.id().equals(route.decision())).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW));
var decision=new PureDecisionEvaluator().evaluate(definition,context);
if(decision instanceof DecisionResult.Failure failed)throw new DomainException(failed.code());
var selected=(DecisionResult.Selected)decision;outcome=selected.output();rules=selected.matchedRuleIds();policy=definition.hitPolicy().name();to=route.destinations().get(outcome);
if(to==null)throw new DomainException(ErrorCode.INVALID_FLOW);
}
JsonNode data=codec.tree(work.data()).deepCopy();
for(var update:work.transition().updates())put(data,update.target(),codec.tree(value(update.value(),context)));
ObjectValue candidate=(ObjectValue)codec.value(data);json.data(candidate);
try{validate(work.flow().dataSchema().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)),candidate);}
catch(DomainException e){throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR);}
var business=new ObjectValue(Map.of("outcome",new StringValue(outcome),"fromState",new StringValue(work.transition().from()),"toState",new StringValue(to)));
var auditFields=new HashMap<String,JsonValue>(Map.of("transitionId",new StringValue(work.transition().id()),"hitPolicy",new StringValue(policy),
"ruleIds",new ArrayValue(rules.stream().map(v->(JsonValue)new StringValue(v)).toList()),
"actionIds",new ArrayValue(work.transition().actions().stream().map(a->(JsonValue)new StringValue(a.id())).toList())));
var reasons=results.values().stream().filter(v->v instanceof ObjectValue).map(v->((ObjectValue)v).fields().get("reasonCodes"))
.filter(v->v instanceof ArrayValue).flatMap(v->((ArrayValue)v).values().stream()).filter(v->v instanceof StringValue).distinct().toList();
if(!reasons.isEmpty())auditFields.put("reasonCodes",new ArrayValue(reasons));
return new Completion(work.flowId(),to,candidate,business,new ObjectValue(auditFields));
}
private static void put(JsonNode root,String pointer,JsonNode value){
String[] parts=pointer.substring(1).split("/",-1);JsonNode current=root;
for(int i=0;i<parts.length;i++){
String key=parts[i].replace("~1","/").replace("~0","~");boolean last=i==parts.length-1;
if(current instanceof ObjectNode object){
if(last){object.set(key,value);return;}
if(!object.has(key))object.set(key,JsonNodeFactory.instance.objectNode());current=object.get(key);
}else if(current instanceof ArrayNode array){
if(!key.matches("0|[1-9][0-9]{0,8}"))throw new DomainException(ErrorCode.EVALUATION_ERROR);int index=Integer.parseInt(key);
if(index>=array.size())throw new DomainException(ErrorCode.EVALUATION_ERROR);if(last){array.set(index,value);return;}current=array.get(index);
}else throw new DomainException(ErrorCode.EVALUATION_ERROR);
}
}
}

View File

@ -0,0 +1,22 @@
package com.organizacion.orchestrator.execution.infrastructure;
import com.organizacion.orchestrator.execution.application.port.*;
import com.organizacion.orchestrator.action.domain.*;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.shared.infrastructure.security.RuntimeRoleVerifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.*;
import org.springframework.context.annotation.*;
import javax.sql.DataSource;
import java.util.Optional;
@Configuration(proxyBeanMethods=false)
@ConditionalOnProperty(name="orchestrator.worker.enabled",havingValue="true")
public class WorkerConfiguration {
@Bean WorkerStore workerStore(DataSource source,FlowJsonCodec codec,RuntimeRoleVerifier verified,@Value("${orchestrator.database.schema:orchestrator}")String schema){return new PostgresWorkerStore(source,codec,schema);}
@Bean WorkerEngine workerEngine(WorkerStore store,WorkerActions actions,FlowJsonCodec codec){return new WorkerEngine(store,actions,codec);}
@Bean WorkerLoop workerLoop(WorkerStore store,WorkerEngine engine,@Value("${orchestrator.worker.concurrency:10}")int concurrency,
@Value("${orchestrator.worker.batch:10}")int batch,@Value("${orchestrator.worker.poll-ms:500}")long poll,
@Value("${orchestrator.worker.lease-seconds:60}")long lease,@Value("${orchestrator.worker.renew-seconds:15}")long renew,
@Value("${orchestrator.worker.shutdown-seconds:20}")int shutdown){return new WorkerLoop(store,engine,concurrency,batch,poll,lease,renew,shutdown);}
}

View File

@ -0,0 +1,98 @@
package com.organizacion.orchestrator.execution.infrastructure;
import com.organizacion.orchestrator.execution.application.port.*;
import com.organizacion.orchestrator.execution.domain.WorkerState.*;
import com.organizacion.orchestrator.action.domain.*;
import com.organizacion.orchestrator.action.domain.ActionCapability.Effect;
import com.organizacion.orchestrator.decision.application.PureDecisionEvaluator;
import com.organizacion.orchestrator.decision.domain.*;
import com.organizacion.orchestrator.flow.domain.*;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.request.infrastructure.RequestJson;
import com.organizacion.orchestrator.shared.domain.*;
import com.organizacion.orchestrator.shared.domain.JsonValue.*;
import com.networknt.schema.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.*;
/** Executes outside database transactions. All effects pass through an explicit trusted adapter registry. */
public final class WorkerEngine {
private static final org.slf4j.Logger log=org.slf4j.LoggerFactory.getLogger(WorkerEngine.class);
private final WorkerStore store;private final WorkerActions actions;private final FlowJsonCodec codec;private final RequestJson json;
public WorkerEngine(WorkerStore store,WorkerActions actions,FlowJsonCodec codec){this.store=store;this.actions=actions;this.codec=codec;json=new RequestJson(codec);}
public void run(Token token){
try{
var work=store.load(token);var results=new LinkedHashMap<String,JsonValue>();
log.info("worker requestId={} executionId={} flowId={} version={} phase=loaded",token.requestId().value(),token.executionId().value(),work.flowId().value(),work.flow().version());
for(var action:work.transition().actions()){
if(action instanceof ActionDefinition.NoOp)throw new DomainException(ErrorCode.ACTION_FAILED);
var capability=actions.capability(action).orElseThrow(()->new DomainException(ErrorCode.INTEGRATION_UNAVAILABLE));
if(!capability.type().equals(action.type()))throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR);
ObjectValue inputs=inputs(action,context(work,results));validate(capability.inputSchema(),inputs);
String hash=hash(inputs),external=token.executionId().value()+":"+action.id();
var previous=store.checkpoints(token).stream().filter(c->c.actionId().equals(action.id())).findFirst();
var retry=action instanceof ActionDefinition.Integration i?i.retry():((ActionDefinition.RegisterDocument)action).retry();
int max=retry.maxAttempts();
if(previous.isPresent()&&(previous.get().status().equals("UNKNOWN")||previous.get().status().equals("RUNNING")&&previous.get().effect()==Effect.NON_IDEMPOTENT_WRITE)){store.fail(token,ErrorCode.RECONCILIATION_REQUIRED,true);return;}
if(previous.isPresent()&&!previous.get().status().equals("SUCCEEDED")&&previous.get().attempts()>=max){store.fail(token,ErrorCode.ACTION_FAILED,false);return;}
var checkpoint=store.start(token,action.id(),hash,external,capability.effect());
ObjectValue output;
if(checkpoint.status().equals("SUCCEEDED"))output=checkpoint.output().orElseThrow(()->new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR));
else{
ActionResult response;
long started=System.nanoTime();
try{response=actions.execute(action,new ActionContext(token.executionId(),token.requestId(),work.actor(),inputs,external,checkpoint.attempts()));}
catch(RuntimeException e){response=new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE,capability.effect()==Effect.READ_ONLY?ActionResult.FailureKind.TRANSIENT:ActionResult.FailureKind.UNKNOWN_EFFECT);}
log.info("action requestId={} executionId={} flowId={} version={} actionId={} attempt={} result={} durationMs={}",token.requestId().value(),token.executionId().value(),work.flowId().value(),work.flow().version(),action.id(),checkpoint.attempts(),response instanceof ActionResult.Completed?"completed":"technical_failure",(System.nanoTime()-started)/1_000_000);
if(response instanceof ActionResult.TechnicalFailure failure){
if(failure.kind()==ActionResult.FailureKind.TRANSIENT&&capability.effect()!=Effect.NON_IDEMPOTENT_WRITE&&checkpoint.attempts()<max){
store.retry(token,action.id(),failure.code(),RetryDelay.after(retry,checkpoint.attempts(),failure.retryAfter(),java.util.concurrent.ThreadLocalRandom.current().nextDouble()));
log.info("worker requestId={} executionId={} actionId={} attempt={} phase=retry_scheduled code={}",token.requestId().value(),token.executionId().value(),action.id(),checkpoint.attempts(),failure.code());
}else{store.fail(token,failure.code(),failure.kind()==ActionResult.FailureKind.UNKNOWN_EFFECT);
log.info("worker requestId={} executionId={} actionId={} attempt={} phase=failure_recorded code={}",token.requestId().value(),token.executionId().value(),action.id(),checkpoint.attempts(),failure.code());}
return;
}
if(!(response instanceof ActionResult.Completed completed))throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR);
output=completed.output();validateOutput(work,action,capability,output);json.data(output);
store.save(token,action.id(),hash,output,completed.providerReference());
}
validateOutput(work,action,capability,output);results.put(action.resultKey(),output);
}
store.complete(token,new TransitionEvaluator(codec).complete(work,results));
log.info("worker requestId={} executionId={} phase=committed",token.requestId().value(),token.executionId().value());
}catch(DomainException e){
if(e.code()!=ErrorCode.LEASE_LOST)store.fail(token,e.code(),e.code()==ErrorCode.RECONCILIATION_REQUIRED||e.code()==ErrorCode.INTEGRATION_CONTRACT_ERROR);
}catch(RequestJson.DataTooLarge|IllegalArgumentException e){
store.fail(token,ErrorCode.SCHEMA_VALIDATION_ERROR,false);
}
// Database failures deliberately escape: lease recovery reuses committed checkpoints instead of releasing uncertain work.
}
private EvaluationContext context(Work w,Map<String,JsonValue> results){return new EvaluationContext(w.data(),w.event(),new ObjectValue(results),w.actor());}
private JsonValue value(ValueExpression expression,EvaluationContext context){
if(expression instanceof ValueExpression.Literal l)return l.value();
var found=new JsonPointerResolver().resolve((ValueExpression.Path)expression,context);
if(found instanceof Lookup.Present present)return present.value();throw new DomainException(ErrorCode.EVALUATION_ERROR);
}
private ObjectValue inputs(ActionDefinition action,EvaluationContext context){
var values=new HashMap<String,JsonValue>();
if(action instanceof ActionDefinition.Integration i)i.inputs().forEach((k,v)->values.put(k,value(v,context)));
else if(action instanceof ActionDefinition.RegisterDocument d)values.put("documentId",value(d.documentId(),context));
else throw new DomainException(ErrorCode.ACTION_FAILED);return new ObjectValue(values);
}
private String hash(ObjectValue input){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.data(input).getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}}
private void validate(InlineSchema schema,ObjectValue data){
var validator=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(codec.tree(schema.value()),SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build());
if(!validator.validate(codec.tree(data)).isEmpty())throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR);
}
private void validateOutput(Work work,ActionDefinition action,ActionCapability capability,ObjectValue output){
validate(capability.outputSchema(),output);
if(action instanceof ActionDefinition.RegisterDocument document)
work.flow().tasks().stream().filter(t->t.state().equals(work.transition().from())&&t.event().equals(work.transition().event()))
.map(TaskDefinition::ui).filter(ui->ui instanceof TaskDefinition.Ui.DocumentUpload)
.map(ui->(TaskDefinition.Ui.DocumentUpload)ui).filter(ui->ui.documentType().equals(document.documentType()))
.forEach(ui->com.organizacion.orchestrator.document.domain.DocumentMetadata.requireLimits(ui.documentType(),ui.acceptedMimeTypes(),ui.maxBytes(),output));
}
}

View File

@ -0,0 +1,58 @@
package com.organizacion.orchestrator.execution.infrastructure;
import com.organizacion.orchestrator.execution.application.port.WorkerStore;
import com.organizacion.orchestrator.execution.domain.WorkerState.Token;
import org.springframework.context.SmartLifecycle;
import org.slf4j.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
/** Bounded local executors only dispatch jobs already durably claimed in PostgreSQL. */
public final class WorkerLoop implements SmartLifecycle {
private static final Logger log=LoggerFactory.getLogger(WorkerLoop.class);
private final WorkerStore store;private final WorkerEngine engine;private final int concurrency,batch;private final long pollMs,renewMs;private final Duration lease;
private final String owner="worker-"+UUID.randomUUID();private final AtomicBoolean running=new AtomicBoolean();
private ScheduledExecutorService poller,renewals;private ExecutorService jobs;private Semaphore slots;
private final int shutdownSeconds;
public WorkerLoop(WorkerStore store,WorkerEngine engine,int concurrency,int batch,long pollMs,long leaseSeconds,long renewSeconds){
this(store,engine,concurrency,batch,pollMs,leaseSeconds,renewSeconds,20);
}
public WorkerLoop(WorkerStore store,WorkerEngine engine,int concurrency,int batch,long pollMs,long leaseSeconds,long renewSeconds,int shutdownSeconds){
if(concurrency<1||concurrency>100||batch<1||batch>100||pollMs<100||leaseSeconds<3||leaseSeconds>3600||renewSeconds<1||renewSeconds*2>=leaseSeconds)throw new IllegalArgumentException("Invalid worker settings");
if(shutdownSeconds<1||shutdownSeconds>20)throw new IllegalArgumentException("Invalid shutdown budget");this.shutdownSeconds=shutdownSeconds;
this.store=store;this.engine=engine;this.concurrency=concurrency;this.batch=batch;this.pollMs=pollMs;lease=Duration.ofSeconds(leaseSeconds);renewMs=renewSeconds*1000;
}
public synchronized void start(){
if(!running.compareAndSet(false,true))return;slots=new Semaphore(concurrency);jobs=Executors.newFixedThreadPool(concurrency);
renewals=Executors.newScheduledThreadPool(Math.min(concurrency,4));poller=Executors.newSingleThreadScheduledExecutor();
poller.scheduleWithFixedDelay(this::tick,0,pollMs,TimeUnit.MILLISECONDS);
}
private void tick(){
if(!running.get())return;int free=Math.min(batch,slots.availablePermits());if(free==0)return;
try{for(Token token:store.claim(owner,free,lease)){
slots.acquireUninterruptibly();try{jobs.execute(()->run(token));}catch(RejectedExecutionException e){slots.release();}
}}catch(RuntimeException e){log.warn("Worker claim failed: {}",e.getClass().getSimpleName());}
}
private void run(Token token){
Thread thread=Thread.currentThread();Object gate=new Object();AtomicBoolean active=new AtomicBoolean(true);
var heartbeat=renewals.scheduleAtFixedRate(()->{
try{store.renew(token,lease);}catch(RuntimeException e){
synchronized(gate){if(active.get())thread.interrupt();}
log.warn("Worker renewal failed: {}",e.getClass().getSimpleName());
}
},renewMs,renewMs,TimeUnit.MILLISECONDS);
try{engine.run(token);}catch(RuntimeException e){log.warn("Worker processing stopped; lease recovery pending: {}",e.getClass().getSimpleName());}
finally{synchronized(gate){active.set(false);}heartbeat.cancel(false);slots.release();Thread.interrupted();}
}
public synchronized void stop(){
if(!running.compareAndSet(true,false))return;poller.shutdownNow();
try{poller.awaitTermination(2,TimeUnit.SECONDS);}catch(InterruptedException e){Thread.currentThread().interrupt();}
jobs.shutdown();try{if(!jobs.awaitTermination(shutdownSeconds,TimeUnit.SECONDS)){jobs.shutdownNow();jobs.awaitTermination(1,TimeUnit.SECONDS);}}catch(InterruptedException e){jobs.shutdownNow();Thread.currentThread().interrupt();}
renewals.shutdownNow(); // Unfinished work keeps its durable lease/checkpoints and is recovered after expiry.
}
public void stop(Runnable callback){try{stop();}finally{callback.run();}}
public boolean isRunning(){return running.get();}
public int getPhase(){return Integer.MAX_VALUE;}
}

View File

@ -0,0 +1,90 @@
package com.organizacion.orchestrator.flow.api;
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import com.organizacion.orchestrator.flow.application.FlowAdministration;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.organizacion.orchestrator.shared.api.*;
import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId;
import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URI;
import java.nio.*;
import java.nio.charset.*;
import java.util.*;
@RestController
@RequestMapping("/api/v1/admin")
@ConditionalOnProperty(name="orchestrator.admin.enabled",havingValue="true")
public class FlowAdministrationController {
private final FlowAdministration service;private final JwtActorMapper actors;private final FlowJsonCodec codec;
private final ObjectMapper json=new ObjectMapper(JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build())
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
public FlowAdministrationController(FlowAdministration service,JwtActorMapper actors,FlowJsonCodec codec){this.service=service;this.actors=actors;this.codec=codec;}
@PostMapping(value="/flows",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<FlowView> create(@AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws IOException{
String document=body(request);syntax(document);
com.organizacion.orchestrator.flow.domain.FlowVersion flow;
try{flow=service.create(document,actors.actor(jwt),CorrelationFilter.id(request));}
catch(IllegalArgumentException e){throw new ApiException(422,"SCHEMA_VALIDATION_ERROR");}
return ResponseEntity.created(URI.create("/api/v1/admin/flows/"+flow.id().value())).eTag(Long.toString(flow.revision())).body(FlowView.from(flow,codec));
}
@PutMapping(value="/flows/{id}",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<FlowView> edit(@PathVariable UUID id,@RequestHeader(value="If-Match",required=false)String match,
@AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws IOException{
long revision=revision(match);
String document=body(request);syntax(document);
try{return view(service.edit(new FlowId(id),revision,document,actors.actor(jwt),CorrelationFilter.id(request)));}
catch(IllegalArgumentException e){throw new ApiException(422,"SCHEMA_VALIDATION_ERROR");}
}
@PostMapping(value="/flows/{id}/validate",produces=MediaType.APPLICATION_JSON_VALUE)
public ValidationReportResponse validate(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){
return ValidationReportResponse.from(service.validate(new FlowId(id),actors.actor(jwt)));
}
@PostMapping(value="/flows/{id}/publish",produces=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<FlowView> publish(@PathVariable UUID id,@RequestHeader(value="If-Match",required=false)String match,
@AuthenticationPrincipal Jwt jwt,HttpServletRequest request){
return view(service.publish(new FlowId(id),revision(match),actors.actor(jwt),CorrelationFilter.id(request)));
}
@PostMapping(value="/flows/{id}/retire",produces=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<FlowView> retire(@PathVariable UUID id,@RequestHeader(value="If-Match",required=false)String match,
@AuthenticationPrincipal Jwt jwt,HttpServletRequest request){
return view(service.retire(new FlowId(id),revision(match),actors.actor(jwt),CorrelationFilter.id(request)));
}
@PutMapping(value="/request-types/{key}/active-flow",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String,Object>> activate(@PathVariable String key,@RequestHeader(value="If-Match",required=false)String match,
@AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws IOException{
long revision=revision(match);
JsonNode input;
try{input=json.readTree(body(request));}catch(JsonProcessingException e){throw new ApiException(400,"INVALID_REQUEST");}
if(input==null||!input.isObject()||input.size()!=1||!input.path("flowId").isTextual())throw new ApiException(400,"INVALID_REQUEST");
var type=service.activate(key,new FlowId(UUID.fromString(input.get("flowId").textValue())),revision,actors.actor(jwt),CorrelationFilter.id(request));
return ResponseEntity.ok().eTag(Long.toString(type.revision())).body(Map.of("typeKey",type.typeKey(),"activeFlowId",type.activeFlowId().orElseThrow().value(),
"enabled",type.enabled(),"revision",type.revision()));
}
private ResponseEntity<FlowView> view(com.organizacion.orchestrator.flow.domain.FlowVersion flow){
return ResponseEntity.ok().eTag(Long.toString(flow.revision())).body(FlowView.from(flow,codec));
}
private void syntax(String document){
try{if(json.readTree(document)==null)throw new ApiException(400,"INVALID_REQUEST");}
catch(JsonProcessingException e){throw new ApiException(400,"INVALID_REQUEST");}
}
static long revision(String match){
if(match==null)throw new ApiException(428,"PRECONDITION_REQUIRED");
if(!match.matches("\"(0|[1-9][0-9]*)\""))throw new ApiException(400,"INVALID_IF_MATCH");
try{return Long.parseLong(match.substring(1,match.length()-1));}catch(NumberFormatException e){throw new ApiException(400,"INVALID_IF_MATCH");}
}
private static String body(HttpServletRequest request)throws IOException{
if(request.getContentLengthLong()>FlowJsonCodec.MAX_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE");
byte[] bytes=request.getInputStream().readNBytes(FlowJsonCodec.MAX_BYTES+1);
if(bytes.length>FlowJsonCodec.MAX_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE");
try{return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();}
catch(CharacterCodingException e){throw new ApiException(400,"INVALID_REQUEST");}
}
}

View File

@ -0,0 +1,22 @@
package com.organizacion.orchestrator.flow.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.organizacion.orchestrator.flow.domain.FlowVersion;
import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import java.time.Instant;
import java.util.*;
/** Explicit DTO; nested definition is plain JSON-compatible data, not Jackson 2 nodes or domain entities. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FlowView(UUID id,String status,long revision,Map<String,Object> definition,String checksum,
Instant createdAt,String createdBy,Instant publishedAt){
private static final ObjectMapper JSON=new ObjectMapper().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
@SuppressWarnings("unchecked")
public static FlowView from(FlowVersion version,FlowJsonCodec codec){
Map<String,Object> definition=JSON.convertValue(codec.tree(version.definition()),Map.class);
return new FlowView(version.id().value(),version.status().name(),version.revision(),definition,
version.checksum().orElse(null),version.createdAt(),version.createdBy(),version.publishedAt().orElse(null));
}
}

View File

@ -0,0 +1,33 @@
package com.organizacion.orchestrator.flow.api;
import com.organizacion.orchestrator.flow.application.port.FlowStore;
import com.organizacion.orchestrator.flow.domain.PublicationCatalog;
import com.organizacion.orchestrator.flow.infrastructure.*;
import com.organizacion.orchestrator.request.infrastructure.RequestJson;
import com.organizacion.orchestrator.shared.api.ApiException;
import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.nio.*;
import java.nio.charset.*;
import java.util.UUID;
@RestController
@ConditionalOnProperty(name="orchestrator.admin.enabled",havingValue="true")
public class SimulationController {
private final FlowStore store;private final FlowSimulation simulation;private final RequestJson json;
public SimulationController(FlowStore store,PublicationCatalog catalog,FlowJsonCodec codec){this.store=store;simulation=new FlowSimulation(catalog,codec);json=new RequestJson(codec);}
@PostMapping(value="/api/v1/admin/flows/{id}/simulate",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> simulate(@PathVariable UUID id,HttpServletRequest request)throws java.io.IOException{
// FLOW_EDITOR and configured admin scope are enforced by the SecurityFilterChain, before parsing fixtures.
if(request.getContentLengthLong()>FlowJsonCodec.MAX_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE");
byte[] bytes=request.getInputStream().readNBytes(FlowJsonCodec.MAX_BYTES+1);if(bytes.length>FlowJsonCodec.MAX_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE");
String body;try{body=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();}
catch(CharacterCodingException e){throw new ApiException(400,"INVALID_REQUEST");}
var fixture=json.parse(body);if(fixture==null)throw new ApiException(400,"INVALID_REQUEST");
var version=store.transaction(tx->tx.lock(new FlowId(id))); // Release the read lock before pure evaluation.
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(simulation.simulate(version,fixture));
}
}

View File

@ -0,0 +1,23 @@
package com.organizacion.orchestrator.flow.api;
import com.organizacion.orchestrator.flow.domain.ValidationReport;
import java.util.List;
import java.util.Objects;
/** Transport DTO matching api.schema.json; never return domain records directly from controllers. */
public record ValidationReportResponse(boolean valid, List<Violation> errors) {
public ValidationReportResponse {
errors = List.copyOf(errors);
if (valid != errors.isEmpty()) throw new IllegalArgumentException("Inconsistent validation response");
}
public record Violation(String pointer, String code, String message) {
public Violation {
Objects.requireNonNull(pointer); Objects.requireNonNull(code); Objects.requireNonNull(message);
}
}
public static ValidationReportResponse from(ValidationReport report) {
var errors = report.issues().stream().filter(i -> i.severity() == ValidationReport.Severity.ERROR)
.map(i -> new Violation(i.pointer(), i.code(), i.message())).toList();
return new ValidationReportResponse(report.valid(), errors);
}
}

Some files were not shown because too many files have changed in this diff Show More