ADR-005 Jobs como operaciones, política afuera y mecanismo adentro
- Estado: accepted (2026-11-07)
- Fecha: 2026-11-07
- Issue: OCT-47. Habilita: OCT-18 (
intent-expiration) y el primer batch (requiere OCT-48). Depende de ADR-001 (accepted).
Contexto
Cada afirmación sobre código está verificada en octopus-api (rutas relativas a ese repo).
No hay trabajo programado en el kernel
grep -rn @Scheduledsobre todos los.kty.javano devuelve nada. Tampoco hay@EnableScheduling.ControlCorruptedBalancees manual: corrige los contadores de una intenciónAPPROVEDtrabada, a pedido, condryRunopcional (modules/transactions/.../operations/ControlCorruptedBalance.kt). Su federation lo declarapublicApi = false(.../federation/ControlCorruptedBalanceFederation.kt).- El único proceso periódico es
OutboxPoller(platform/outbox/core/.../polling/OutboxPoller.kt): un bucle de corrutinas condelay(options.delayMs)que arrancaOutboxDispatcherBootstrapperalApplicationReadyEventsioctopus.outbox.dispatcher.enabledestrue(apps/polling/src/main/kotlin/OutboxDispatcherBootstrapper.kt). Es un bucle fijo en código, no un job: no tiene schedule declarado, niexecution, ni lock de instancia única (la exclusión entre pollers la daFOR UPDATE SKIP LOCKEDenOutboxPollingRepository, ver ADR-001). - El
CLAUDE.mddel workspace registra la consecuencia como brecha conocida: no hay expiración automática de intenciones, y los límites diarios se inflan si el outbox falla.
Cómo es hoy una intención que vence
- La entidad es
TransactionsIntentEntity(modules/shared/.../transactions/entities/TransactionsIntentEntity.kt): tieneexpires_at,intent_statusymodifier. - No existe un estado
HOLD. Los estados de intención sonAPPROVED,REJECTED,PENDING_AUTHORIZATION,FAILEDyEXPIRED(opciones deintentStatusenListTransactionsFederation.kt); «HOLD» es un valor demodifier(TransactionModifierTypes). Una intención de retención es entoncesmodifier = HOLD. - El vencimiento se evalúa hoy solo de forma perezosa, cuando alguien ejecuta:
ExecuteTransactionHandlercomparaintent.expiresAt < Instant.now(), marca la intenciónEXPIREDy llamacompositeTransactionService.revertPending(modules/transactions/.../handlers/ExecuteTransactionHandler.kt). Si nadie ejecuta, la intención nunca vence.ControlCorruptedBalanceHandlertambién puede marcarlaEXPIRED, a mano.
Qué lecturas sobre intenciones existen
| Operación | publicApi | Qué permite | Qué no permite |
|---|---|---|---|
ListTransactions | sí (READONLY) | filtrar por holder, cuenta, intentStatus, transactionStatus, perfil, rango de createdAt; paginado | filtrar por modifier; filtrar por vencimiento |
RetrieveTransaction | sí (READONLY) | leer una transacción por id | listar |
ListTransactions lee TransactionsView, que no expone expires_at (modules/shared/.../transactions/views/TransactionsView.kt). Para intent-expiration faltan, y hay que crear, una lectura equivalente a ListIntents(modifier = HOLD, intentStatus = APPROVED, expiresBefore = now) con paginación estable por expiresAt, y la columna de vencimiento en la vista. Hoy el único camino para listar vencidos sería un repositorio del módulo transactions, justo lo que esta ADR prohíbe a un job.
Lo que el kernel ya ofrece para el otro extremo
- Un evento puede disparar una operación: la federation declara
effect<Evento, Operación>yFederationEffectRegistryadmite un solo efecto por tipo de evento (platform/operations/effects/.../FederationEffectRegistry.kt).TransactionIntentApproved → ExecuteTransactiones el caso canónico (ExecuteTransactionFederation.kt). - El efecto invoca la operación con un
InvocationContextcuyaidempotencyKeyes eleventId(platform/operations/effects/.../EffectEngineImpl.kt,buildContextFromMetadata). Las operaciones conIdempotencyMode.REQUIRED, comoInitiateTransaction, devuelven el resultado anterior ante la misma clave. - La verificación de partida doble existe como operación pública de lectura,
RunDoubleEntryCheck(businessDate?, unitCode?)(modules/audit/.../operations/RunDoubleEntryCheck.kt,AuditFederation.kt). Se parametriza por fecha y unidad, no por lote.
Principios que esta ADR respeta
- El kernel es la única puerta a la base de datos (garantías).
- El kernel modela garantías, no productos: la política de negocio (qué retener, a quién, cuándo) vive fuera del núcleo (
transactions,balances,ledger), en capas operadoras. - Las brechas identificadas se cierran, no se difieren (heurística del proyecto «2 · Corrección del kernel»).
Decisión
Job = política, kernel = mecanismo. El job decide qué hay que hacer; el núcleo ejecuta y garantiza que cierra.
Un job es una operación
- Se registra en una federation como cualquier otra (manifest, inputs
tenantywindow,publicApi). Invocarla pasa por elExecutionEnginecompleto:executionpersistida, outbox, correlación y estado final. «Ejecutar ahora» es invocar la operación. - El recurso
kind: Jobdeclara solo qué operación correr y cuándo (schedule,tenantScope,enabled), visible en la consola y en el CLI (octopus job list,octopus job run-now). - La lógica vive en el job: en la capa
services/del repo (p. ej.intent-expiration) o en un servicio operador aparte sobreoctopus-sdk(p. ej. retenciones enoctopus-accounting). El framework no distingue.
Un runtime de jobs los programa
apps/jobsen modo distribuido, corrutinas en el monolito. Hospeda jobs como plugins y entrega a cada uno: cliente del kernel, emisor de eventos, lock de instancia única y métricas.- En el monolito la invocación es en proceso; en distribuido,
apps/jobscorre el mismo código con cliente gRPC. - «API pública» en esta ADR significa operaciones y puertos de
platform/, no red.
Regla dura: el job lee, decide y emite
- Lee solo por operaciones o puertos de
platform/, nunca por repositorios de otro módulo (misma regla que OCT-33 impondrá con ArchUnit; hoy no hay ArchUnit en el repo). Si la lectura no existe, se crea: enriquece la API para consola y MCP. - No llama
InitiateTransactionni ninguna operación de escritura del núcleo. Emite eventos de tarea con el objeto ya resuelto:ReleaseIntentRequested(intentId),TaxWithholdingRequested(account, amount, batchId),BatchCloseRequested(batchId). Salen por el outbox de laexecutiondel job (EmitPrimaryEventStep), atómicos con ella, y viajan por el puerto de eventos de ADR-001, con lanes ordenadas por cuenta. - Quien ejecuta es un effect handler del kernel: consume el evento, revalida, aplica idempotencia e inicia la transacción por
InitiateTransactioncon su propiaexecution. Son dos ejecuciones encadenadas por un evento. - El kernel revalida al ejecutar. Si el estado cambió entre la lectura del job y la ejecución (el hold ya se capturó), la operación falla limpio y queda registrado en la
execution. El job propone, el kernel dispone.
Idempotencia por evento
La clave lógica es (job, item, window). El efecto usa el eventId como idempotencyKey (ver Contexto), así que el emisor deriva el eventId del evento de tarea de forma determinista a partir de esa clave: una reemisión tras un reinicio produce la misma clave y el kernel devuelve el resultado anterior en lugar de repetir el efecto. Esto exige que el emisor fije el id del EventEnvelope (ver respuesta 6; ADR-001 lo recoge).
Dos formas de job
- Periódico: lee, decide, emite un evento por ítem. Ejemplo:
intent-expiration. - Batch sobre una población: el job crea un
batch_id, enumera la población por la API pública, emite un evento por ítem con(batch_id, item)como clave y, al final,BatchCloseRequested(batch_id). Al cerrar, el kernel verifica que la suma de los ítems sea igual al total y que la partida doble dé cero, en unaexecutionauditable. Escribe en elpurposedel batch (p. ej.PROFIT), no enPRIMARY, para no competir con el tráfico en vivo. Es reanudable: el job reemite lo pendiente y el kernel descarta lo ya hecho.
Por qué eventos y no gRPC síncrono desde el job
El job no se bloquea en N llamadas; el kernel procesa a su ritmo y con orden por cuenta; y si el kernel está caído, los eventos esperan en el outbox. Para un batch de miles de ítems es la diferencia entre un proceso que se cae a mitad de camino y uno que se reanuda.
Anti-objetivo
No es un orquestador: sin DAGs ni dependencias entre jobs. Si un caso las necesita, se resuelve con eventos entre jobs o se reabre esta ADR.
Alternativas consideradas
@Scheduleddentro del kernel. Es lo más corto, pero mete reloj y política en el núcleo de garantías, no dejaexecutionni schedule declarado, y duplica la acción en cada instancia sin lock. Descartada.- Un job que lee la base y escribe balances directo. Rompe la única puerta a la base de datos y saltea la partida doble, la idempotencia y el audit trail. Descartada sin discusión.
- Un daemon que solo emite un tick, con la lógica en handlers del kernel (
JobTickRequested, el handler busca los vencidos y libera). Es el diseño que describe hoy OCT-18. Se descarta porque pone política de negocio —qué vence, a quién retener— dentro del núcleo, contra el principio de capas operadoras; el kernel pasaría a conocer cada producto que tenga un job. - Un scheduler externo (EventBridge, CronJob) que llama gRPC de forma síncrona por ítem. Resuelve el reloj pero no la contención: N llamadas bloqueantes, sin orden por cuenta, sin tolerancia a un kernel caído y con la idempotencia repartida entre clientes. El scheduler sí es reemplazable: ver respuesta 7.
- Un orquestador de workflows genérico (estilo Temporal). Aporta DAGs, reintentos y estado durable, que están fuera del alcance (anti-objetivo), y suma un sistema más a operar para dos jobs.
Consecuencias
Positivas: el kernel sigue siendo agnóstico de producto; un job se prueba como política pura contra un cliente de kernel falso; apps/polling se reduce al job outbox-sweeper; toda acción periódica es una execution auditable; el mismo runtime hospeda los futuros jobs de contabilidad y cumplimiento.
Negativas:
- La API pública de lectura tiene que crecer. Lista mínima para los dos primeros jobs:
ListIntents(oListTransactionscon filtrosmodifieryexpiresBeforey la columnaexpires_aten la vista) paraintent-expiration; paraoutbox-sweeper, un puerto de administración de particiones del outbox (hoy solo existenclaimBatch,markProcessed,scheduleRetry,markFailedy los inserts enOutboxRecordStore). - Un runtime más que operar en modo distribuido.
- N eventos por batch son carga de outbox: 50 000 ítems son 50 000 filas más las de sus efectos. La partición por día y la limpieza son las de ADR-001 (respuesta 5, a cargo del propio
outbox-sweeper). - El cierre de batch necesita una operación de verificación en el kernel que no existe:
VerifyBatch(batchId)(respuesta 4).RunDoubleEntryCheckse parametriza por fecha y unidad, no porbatch_id. - Un batch que calcula importes con porcentajes hereda el truncamiento a
LongdeExpressionEvaluatorhasta que se resuelva OCT-48.
Plan
| Paso | Contenido | Prueba de aceptación |
|---|---|---|
ADR-001 accepted | Ya cumplido (2026-11-07) | — |
| Jobs runtime (OCT-53) | Hospedaje de plugins, schedule, lock de instancia única, cliente del kernel, emisor de eventos, métricas | Un job de prueba corre en el monolito y en apps/jobs; una segunda instancia no lo duplica |
| Lecturas públicas | ListIntents o filtros nuevos en ListTransactions; expires_at en la vista | Un hold vencido aparece; uno capturado no |
| OCT-18 | intent-expiration como primer job y effect handler de ReleaseIntentRequested | Hold vencido liberado; un hold capturado en carrera falla limpio; sum(ledger) intacto. El texto de OCT-18 ya está reescrito con este diseño |
| OCT-48 | Redondeo explícito en el profile | Prerrequisito de cualquier batch |
| Batch | Evento de cierre y operación VerifyBatch(batchId) del kernel | Suma de ítems igual al total y partida doble en cero |
Preguntas resueltas
Respuestas de Delmo del 2026-11-07; con ellas el ADR pasa a accepted.
- Lock de instancia única.
pg_try_advisory_lock(hash(jobName))en una conexión dedicada del runtime: si la conexión cae, el lock se libera. Leases solo si algún día hay más de dos instancias. outbox-sweeper. Es un job del runtime, con una excepción documentada: opera sobre el outbox por un puerto deplatform/outbox(infraestructura del framework, no módulo de dominio), porque no puede depender del transporte que limpia. Emite un evento solo para auditoría.- Retención de los eventos de tarea. La misma política que todos los eventos (ADR-001, respuesta 5).
- Cierre de batch. Operación del kernel
VerifyBatch(batchId): la garantía es del kernel. Un job de auditoría puede invocarla. - Nombre de la familia de eventos de tarea.
*Requested. EventEnvelope.id. Lo fija el emisor. Por defecto, un UUIDv7 que genera el kernel. En un evento de tarea,UUIDv5(namespace, "job:item:window"), determinista. La reemisión se rechaza al insertar en el outbox por el índice único sobreevent_id, yidempotencyKey = eventIden el efecto cubre el resto. Se agregó la línea correspondiente a ADR-001, sección «Eventos y puertos».- El índice único de
event_ides por partición (idx_outbox_<n>_event_id,octopus-local/db/changelog/structure/003-events.sql), no una restricción de tabla; con la partición por día de ADR-001 (respuesta 5) solo rechaza la reemisión dentro de la misma partición. Una reemisión en otro día la ataja elidempotencyKeydel efecto. - Los ids de evento de hoy son TypeID con prefijo
ev(EventId,platform/api/.../TypedIds.kt) y la columna esVARCHAR(32): un UUID con guiones (36 caracteres) no entra. El UUIDv5 se codifica como el resto de los ids; falta confirmar al implementar queEventId.ofacepta un UUID que no sea v7.
- El índice único de
- Scheduler externo. Soportado como configuración
schedule: externalen el recursoJob: el runtime no dispara y solo expone «ejecutar ahora».
Decisiones para OCT-18
Solo el caso (b), la liberación de un HOLD ya ejecutado. El caso (a), una intención APPROVED sin ejecución, es recuperación de ejecuciones trabadas y va a OCT-54, dominio del outbox-sweeper.
Siguen abiertas
Ninguna.