@softize/opus 20.1.0 → 21.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,9 @@
11
11
  * - Schedule = tempo dispara
12
12
  *
13
13
  * Runtime registra no SchedulerAdapter no `start()`. Quando o tempo
14
- * configurado bate, o scheduler chama `runtime.execute(action, input, ctx)`
15
- * com `ctx.provenance = { kind: 'schedule', scheduleName }`.
14
+ * configurado bate, o runtime executa a action como o sistema, com
15
+ * `ctx.provenance = { kind: 'schedule', scheduleName }`; a action precisa
16
+ * declarar `automatable: true` (ou `public: true`). Ver ADR 0028.
16
17
  *
17
18
  * Ver §16 do protocolo.
18
19
  */
package/src/core/types.ts CHANGED
@@ -255,10 +255,11 @@ export interface User {
255
255
  export type CanFn = (permission: string, resource?: unknown) => boolean | Promise<boolean>
256
256
 
257
257
  /**
258
- * Função que emite um `DomainEvent`. Em `ActionContext.emit`, publica o evento pelo
259
- * `EventBusAdapter`. Em `ReactionContext.emit`, descarta o evento sem publicá-lo, porque
260
- * reactions ainda não encadeiam outras por evento; com `config.dev` ativo, cada descarte
261
- * registra o aviso `runtime.reaction_emit_ignored`.
258
+ * Função que emite um `DomainEvent` pelo `EventBusAdapter`. Em `ActionContext.emit`, publica
259
+ * qualquer evento; um evento fora do `emits` da action gera aviso com
260
+ * `config.warnUndeclaredEmits`. Em `ReactionContext.emit`, publica somente eventos listados no
261
+ * `emits` da reaction e descarta os demais (aviso `runtime.reaction_emit_ignored`); a cadeia de
262
+ * reactions para em `config.maxReactionDepth`. Ver ADR 0027.
262
263
  */
263
264
  export type EmitFn = <T = unknown>(
264
265
  event: string,
@@ -295,7 +296,7 @@ export interface ReactionContext {
295
296
  tenantId: string | null
296
297
  db: unknown
297
298
  log: Logger
298
- /** Descarta o evento sem publicá-lo: reactions ainda não encadeiam outras. Ver `EmitFn`. */
299
+ /** Publica os eventos declarados em `emits` da reaction; descarta os demais. Ver `EmitFn`. */
299
300
  emit: EmitFn
300
301
  storage: StorageAdapter | null
301
302
  cache: CacheAdapter | null
@@ -303,6 +304,12 @@ export interface ReactionContext {
303
304
  provenance: Provenance
304
305
  trace?: TraceContext
305
306
  meta: Record<string, unknown>
307
+ /**
308
+ * Abortado quando a tentativa excede o `timeout` da reaction. O runtime sempre preenche o
309
+ * campo; ele é opcional para quem monta o contexto à mão em testes. Um handler que ignora o
310
+ * sinal continua executando depois do timeout. Ver ADR 0026.
311
+ */
312
+ signal?: AbortSignal
306
313
  }
307
314
 
308
315
  // =============================================================================
@@ -387,11 +394,26 @@ export interface DomainEvent<T = unknown> {
387
394
  actor: { id: string | null; type?: 'user' | 'system' | 'integration' }
388
395
  tenant?: string | null
389
396
  data: T
397
+ /**
398
+ * A action que iniciou a cadeia. Um evento publicado por reaction herda o `source` do evento
399
+ * que ela processava, e `provenance.sourceActionId` continua batendo com o audit da action.
400
+ */
390
401
  source: {
391
402
  action: string
392
403
  actionId: string
393
404
  correlation?: string
394
405
  }
406
+ /**
407
+ * Presente só em evento publicado por reaction: qual reaction o publicou, a partir de qual
408
+ * evento e a que profundidade da cadeia (1 = primeira reaction depois da action). Evento
409
+ * emitido por action não tem `cause` e está na profundidade 0. Ver ADR 0027.
410
+ */
411
+ cause?: {
412
+ reaction: string
413
+ eventId: string
414
+ eventType: string
415
+ depth: number
416
+ }
395
417
  trace?: TraceContext
396
418
  meta?: Record<string, unknown>
397
419
  }
@@ -434,6 +456,12 @@ export interface JobSpec {
434
456
  requestId?: string
435
457
  trace?: TraceContext
436
458
  originalProvenance?: Provenance
459
+ /**
460
+ * Presente quando o próprio runtime enfileirou o job numa execução do sistema (o disparo de um
461
+ * schedule). O worker só reconstrói a identidade de sistema com esta marca; a provenance não
462
+ * basta, porque vem do chamador. Ver ADR 0028.
463
+ */
464
+ system?: true
437
465
  }
438
466
  config: {
439
467
  queue?: string
@@ -828,6 +856,11 @@ export interface ActionBase<In, Out, ParsedIn = In> {
828
856
 
829
857
  // — Integração —
830
858
  ai?: boolean | AIConfig
859
+ /**
860
+ * Aceita execução pelo sistema, sem usuário, quando um schedule a dispara. Sem este opt-in,
861
+ * a execução pelo sistema de uma action não pública termina em `auth.system_forbidden`. Não
862
+ * abre a action a chamadores anônimos. Ver `isSystemActor` e ADR 0028.
863
+ */
831
864
  automatable?: boolean
832
865
  internal?: boolean
833
866
  successStatus?: number
@@ -950,9 +983,17 @@ export interface ReactionDef<Event = unknown> {
950
983
 
951
984
  handler: ReactionHandlerFn<Event>
952
985
 
986
+ /** Eventos que o handler pode publicar com `ctx.emit`; os demais são descartados (ADR 0027). */
987
+ emits?: string[]
988
+
989
+ // — Política de entrega (ADR 0026) —
990
+ /** Total de execuções do handler, com espera opcional entre elas. Sem `retry`, roda uma vez. */
953
991
  retry?: { attempts: number; backoff?: BackoffSpec }
992
+ /** Prazo de cada tentativa, em ms; ao vencer, aborta `ctx.signal`. */
954
993
  timeout?: number
994
+ /** Chave de idempotência: evento com chave já vista em `config.dedupCacheTtl` é descartado. */
955
995
  dedup?: (event: DomainEvent<Event>) => string
996
+ /** Máximo de tentativas simultâneas desta reaction. Padrão 1. */
956
997
  concurrency?: number
957
998
 
958
999
  authorize?: (
@@ -1419,8 +1460,8 @@ export interface SchedulerAdapter extends Adapter {
1419
1460
  * Registra um schedule e retorna função pra cancelar. Chamado pelo
1420
1461
  * runtime no `start()` pra cada schedule.
1421
1462
  *
1422
- * Quando o tempo bater, o adapter chama `fire()` que internamente
1423
- * invoca runtime.execute() com provenance schedule.
1463
+ * Quando o tempo bater, o adapter chama `fire()`, e o runtime executa a
1464
+ * action como o sistema, com provenance schedule (ADR 0028).
1424
1465
  */
1425
1466
  register(spec: ScheduleDef, fire: () => Promise<void> | void): Unsubscribe
1426
1467
  }
@@ -1453,6 +1494,8 @@ export interface RuntimeConfig {
1453
1494
 
1454
1495
  // — Resiliência —
1455
1496
  dedupCacheTtl?: number
1497
+ /** Chaves concluídas que o dedup guarda por reaction; acima disso, descarta a mais antiga. Padrão 10.000. */
1498
+ dedupCacheMaxEntries?: number
1456
1499
  defaultRetry?: { attempts: number; backoff?: BackoffSpec }
1457
1500
 
1458
1501
  // — i18n —
@@ -1464,4 +1507,6 @@ export interface RuntimeConfig {
1464
1507
  // — Limites —
1465
1508
  maxConcurrentActions?: number
1466
1509
  maxConcurrentReactions?: number
1510
+ /** Saltos de reaction que uma cadeia de eventos pode dar. Padrão 5 (ADR 0027). */
1511
+ maxReactionDepth?: number
1467
1512
  }
@@ -93,9 +93,18 @@ export function bullmqQueue(options: BullmqQueueOptions): QueueAdapter {
93
93
  },
94
94
 
95
95
  async status(jobId: string): Promise<JobHandle | null> {
96
- const job = await queue.getJob(jobId)
96
+ let job = await queue.getJob(jobId)
97
97
  if (job === undefined || job === null) return null
98
- const state = await job.getState()
98
+ let state = await job.getState()
99
+ // `getJob` e `getState` são leituras separadas: se o worker concluir entre as duas, o
100
+ // snapshot ainda é o do job ativo, sem `returnvalue`, e o estado já é terminal. O BullMQ
101
+ // grava resultado e `finishedOn` no mesmo passo atômico; estado terminal sem `finishedOn`
102
+ // denuncia snapshot anterior à conclusão, e uma releitura enxerga o registro completo.
103
+ if (isFinished(state) && job.finishedOn === undefined) {
104
+ job = await queue.getJob(jobId)
105
+ if (job === undefined || job === null) return null
106
+ state = await job.getState()
107
+ }
99
108
  const action = readAction(job)
100
109
  return jobToHandle(action, job, mapState(state))
101
110
  },
@@ -165,6 +174,10 @@ export function mapState(state: BullMQState): JobStatus {
165
174
  }
166
175
  }
167
176
 
177
+ function isFinished(state: BullMQState): boolean {
178
+ return state === 'completed' || state === 'failed'
179
+ }
180
+
168
181
  function jobToHandle(
169
182
  action: string,
170
183
  job: BullMQJob,
@@ -9,10 +9,11 @@
9
9
  * capturado em `emitted`, log capturado em `logged`, `can` configurável,
10
10
  * storage plugável (ex.: `memStorage()`).
11
11
  * • `runAction(action, input)` — o MESMO pipeline do `runtime.execute`, em unidade:
12
- * valida input → gate `public` → `authorize` (string DSL ou closure, semântica
13
- * idêntica, `loaded` incluso) → handler → valida output (pulado em `list`, como
14
- * no runtime). Falha estoura o MESMO `ActionError` tipado do runtime
15
- * (`validation.invalid_input`, `auth.unauthenticated`, `auth.forbidden`…).
12
+ * valida input → gate de autenticação (`public`, usuário ou sistema `automatable`) →
13
+ * `authorize` (string DSL ou closure, semântica idêntica, `loaded` incluso) → handler →
14
+ * valida output (pulado em `list`, como no runtime). Falha estoura o MESMO
15
+ * `ActionError` tipado do runtime (`validation.invalid_input`, `auth.unauthenticated`,
16
+ * `auth.system_forbidden`, `auth.forbidden`…). A opção `schedule` executa como o sistema.
16
17
  * Em `OPUS_MODE=design` roteia pro `mockHandler` como o runtime — contrato puro
17
18
  * (sem handler) é testável em design; fora dele, erro claro (`testing.no_handler`).
18
19
  * • `memStorage()` — StorageAdapter em memória pra testar handler que anexa
@@ -31,7 +32,7 @@ import { normalizeKey } from '../storage/index.ts'
31
32
  import { error } from '../core/errors.ts'
32
33
  // Import direto do runtime (fora do index público): a régua handler×mockHandler
33
34
  // tem UMA fonte — o harness nunca pode divergir do execute().
34
- import { isOpusDesignMode } from '../core/runtime.ts'
35
+ import { assertAuthenticated, isOpusDesignMode, markSystemActor } from '../core/runtime.ts'
35
36
  import type {
36
37
  ActionContext,
37
38
  ActionKind,
@@ -201,6 +202,7 @@ export interface TestableAction<In, Out, ParsedIn = In> {
201
202
  name: string
202
203
  kind?: ActionKind
203
204
  public?: boolean
205
+ automatable?: boolean
204
206
  authorize?: AuthorizeSpec<ParsedIn>
205
207
  input: Schema<In, ParsedIn>
206
208
  output: Schema<Out> | Schema<unknown, Out>
@@ -215,6 +217,7 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
215
217
  name: string
216
218
  kind: 'list'
217
219
  public?: boolean
220
+ automatable?: boolean
218
221
  authorize?: AuthorizeSpec<ParsedIn>
219
222
  input: Schema<In, ParsedIn>
220
223
  output: Schema<Item> | Schema<unknown, Item>
@@ -233,6 +236,12 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
233
236
  export interface RunActionOptions extends TestContextOptions {
234
237
  /** Substitui os loaders (não rodam em unidade): vai pro handler e pro evalCtx do authorize. */
235
238
  loaded?: Record<string, unknown>
239
+ /**
240
+ * Executa como o sistema, no disparo do schedule com este nome: `user` é `null`, `can` nega
241
+ * tudo, a provenance é `schedule` e `isSystemActor(ctx)` responde `true`. Como no runtime, a
242
+ * action precisa ser `automatable` (ou `public`); senão, `auth.system_forbidden`.
243
+ */
244
+ schedule?: string
236
245
  }
237
246
 
238
247
  export interface RunActionResult<Out> extends TestContext {
@@ -261,7 +270,7 @@ async function validate<T>(
261
270
 
262
271
  /**
263
272
  * Roda uma action em UNIDADE com o pipeline do `runtime.execute`: valida input →
264
- * gate `public` → authorize → handler → valida output. Sucesso devolve o output
273
+ * gate de autenticação → authorize → handler → valida output. Sucesso devolve o output
265
274
  * validado + o contexto observável (`emitted`, `logged`); falha estoura o mesmo
266
275
  * `ActionError` do runtime — afirme com `expect(...).rejects`.
267
276
  *
@@ -284,19 +293,21 @@ export async function runAction<In, Out, ParsedIn = In>(
284
293
  input: unknown,
285
294
  options: RunActionOptions = {},
286
295
  ): Promise<RunActionResult<Out>> {
287
- const t = testContext(options)
296
+ const schedule = options.schedule
297
+ const t = testContext(
298
+ schedule === undefined ? options : { ...options, user: null, can: () => false },
299
+ )
288
300
  const { ctx } = t
301
+ if (schedule !== undefined) {
302
+ ctx.provenance = { kind: 'schedule', scheduleName: schedule }
303
+ markSystemActor(ctx)
304
+ }
289
305
  const loaded = options.loaded
290
306
 
291
307
  const parsed = await validate<ParsedIn>(action.input, input, 'input')
292
308
 
293
- if (action.public !== true && ctx.user === null) {
294
- throw error({
295
- code: 'auth.unauthenticated',
296
- category: 'authentication',
297
- message: 'Autenticação necessária',
298
- })
299
- }
309
+ // O mesmo gate do runtime: pública, com usuário ou, pelo sistema, `automatable`.
310
+ assertAuthenticated(action, ctx)
300
311
 
301
312
  if (action.authorize !== undefined) {
302
313
  const spec = action.authorize
@@ -147,7 +147,8 @@ validações: `z.object({ tag: t.slug().zod(), contato: t.email().zod() })`. O `
147
147
  schema zod de cada tipo lógico (slug, email, phone, money…), importado de `@softize/opus/schema/zod`.
148
148
 
149
149
  A autorização é fechada por padrão: sem `public: true`, a action exige usuário autenticado; com
150
- `authorize`, exige também a permissão declarada.
150
+ `authorize`, exige também a permissão declarada. `automatable: true` permite que um
151
+ [schedule](/docs/scheduler) execute a action como o sistema, sem abri-la a chamadores anônimos.
151
152
 
152
153
  ## Prototipar com dados simulados
153
154
 
@@ -99,6 +99,16 @@ O que cada action captura se ajusta no contrato: `audit: false` desliga a audito
99
99
  `audit: { redact: ['senha'], output: false }` refina o registro. Por padrão, o registro inclui
100
100
  input, output e provenance.
101
101
 
102
+ O `actor` diz quem executou. Uma pessoa aparece com `type: 'user'` e o próprio `id`; uma execução
103
+ iniciada pelo sistema, como o disparo de um [schedule](/docs/scheduler), aparece com
104
+ `{ id: null, type: 'system' }` e o nome do schedule na provenance.
105
+
106
+ Reactions também geram registros, um por evento que chega ao handler. Eles têm `provenance.kind`
107
+ igual a `reaction`, o nome da reaction em `action`, `meta: { kind: 'reaction', attempts }` e nenhum
108
+ `actionKind`. O `input` traz só a identidade do evento (`id`, `type` e `source`), sem o payload, que
109
+ já está no registro da action que emitiu. Um sink que filtra leituras por `actionKind` deve tratar
110
+ esses registros à parte. Veja [Eventos](/docs/events).
111
+
102
112
  ## Limites atuais
103
113
 
104
114
  Os sinks só acrescentam registros: consulta e retenção ficam com o seu banco. Os drivers disponíveis
@@ -73,7 +73,11 @@ export const notaEmitir = bindAction(notaEmitirContract, {
73
73
  Antes do handler, o runtime verifica a chamada nesta ordem:
74
74
 
75
75
  1. Valida a entrada pelo schema do contrato.
76
- 2. Sem `public: true`, exige `ctx.user`; sem usuário, responde `auth.unauthenticated`.
76
+ 2. Sem `public: true`, exige `ctx.user`; sem usuário, responde `auth.unauthenticated`. A exceção é
77
+ o disparo de um schedule, que executa como o sistema uma action com `automatable: true` e recebe
78
+ `auth.system_forbidden` nas demais (veja [Agendador](/docs/scheduler)). Para aceitar o sistema no
79
+ `authorize`, use uma função com `isSystemActor(ctx)`: a expressão em string não o reconhece, e
80
+ `ctx.provenance` não prova que a chamada veio de um schedule.
77
81
  3. Executa os `loads`, que carregam os dados usados na autorização.
78
82
  4. Avalia `authorize`, se houver: `false` vira `auth.forbidden`, e um `ActionError` devolvido é
79
83
  lançado como está.
@@ -23,7 +23,8 @@ interface DomainEvent<T = unknown> {
23
23
  actor: { id: string | null; type?: 'user' | 'system' | 'integration' }
24
24
  tenant?: string | null
25
25
  data: T
26
- source: { action: string; actionId: string; correlation?: string }
26
+ source: { action: string; actionId: string; correlation?: string } // action que iniciou a cadeia
27
+ cause?: { reaction: string; eventId: string; eventType: string; depth: number } // só se publicado por reaction
27
28
  trace?: TraceContext // trace da execução que emitiu
28
29
  meta?: Record<string, unknown> // o terceiro argumento de ctx.emit
29
30
  }
@@ -105,17 +106,114 @@ com `runtime.eventbus_required` quando ele não existe. `defineReaction` declara
105
106
  quando não há separação entre cliente e servidor.
106
107
 
107
108
  A reaction roda com provenance `reaction`; `ctx.user` traz somente o `id` do autor do evento, e
108
- `ctx.tenantId`, o tenant do evento. O contexto não tem `can`: a decisão fica no `authorize(ctx, event)` opcional, e uma resposta `false` descarta o evento
109
- sem erro. Uma falha no handler é registrada no log e não chega à action que emitiu.
109
+ `ctx.tenantId`, o tenant do evento. O contexto não tem `can`: a decisão fica no
110
+ `authorize(ctx, event)` opcional, e uma resposta `false` descarta o evento sem erro. Uma falha na
111
+ reaction fica no log e no audit e não chega à action que emitiu.
110
112
 
111
- ## Limites atuais
113
+ ## Proteger a entrega
114
+
115
+ Uma reaction costuma falar com algo que falha ou demora: uma API externa, uma fila, outro banco. A
116
+ política de entrega, declarada no binding, diz como o runtime lida com isso:
117
+
118
+ ```ts
119
+ export const leadSincronizarImpl = bindReaction(leadSincronizar, {
120
+ retry: { attempts: 3, backoff: { kind: 'exponential', initialMs: 1_000, maxMs: 30_000 } },
121
+ timeout: 10_000,
122
+ dedup: (event) => event.data.leadId,
123
+ concurrency: 2,
124
+ handler: async (ctx, event) => {
125
+ await crm.upsertLead(event.data.leadId, { signal: ctx.signal })
126
+ },
127
+ })
128
+ ```
129
+
130
+ O runtime aplica cada campo a cada evento entregue, nesta ordem:
131
+
132
+ - **`dedup`** calcula uma chave para o evento. Outro evento com a mesma chave é descartado enquanto
133
+ o primeiro está na fila ou em andamento e durante `config.dedupCacheTtl` depois de ele concluir
134
+ (padrão de 60 segundos). Se a entrega falhar em definitivo, a chave é liberada, e o evento pode ser
135
+ entregue de novo. Cada reaction guarda no máximo `config.dedupCacheMaxEntries` chaves concluídas
136
+ (padrão 10.000); acima disso, a mais antiga sai antes do TTL, e um evento com ela volta a ser
137
+ aceito.
138
+ - **`concurrency`** limita quantos eventos da reaction rodam ao mesmo tempo. O padrão é 1: sem
139
+ declarar, a reaction processa um evento por vez, na ordem de chegada. Acima disso,
140
+ `config.maxConcurrentReactions` (padrão 10) limita a soma de todas as reactions do processo. Com o
141
+ padrão, um handler sem `timeout` que trava — uma chamada de rede sem resposta, por exemplo —
142
+ segura a única vaga, e a reaction inteira para de processar eventos até o processo reiniciar.
143
+ Declare `timeout` em toda reaction que depende de algo externo.
144
+ - **`authorize`**, quando declarado, roda uma vez, antes da primeira tentativa.
145
+ - **`timeout`** é o prazo de cada tentativa, em milissegundos. Ao vencer, o runtime aborta
146
+ `ctx.signal` e encerra a tentativa com `runtime.reaction_timeout`. Passe o sinal adiante, como no
147
+ exemplo: um handler que o ignora continua executando em segundo plano, fora do limite de
148
+ concorrência.
149
+ - **`retry`** repete o handler que falhou. `attempts` é o total de execuções, contando a primeira.
150
+ `fixed` espera `delayMs` entre elas; `exponential` começa em `initialMs`, multiplica por
151
+ `multiplier` (padrão 2) e para em `maxMs`. A espera entre tentativas não ocupa vaga de
152
+ concorrência.
153
+
154
+ Uma exceção comum, como `throw new Error(...)`, e o `runtime.reaction_timeout` são sempre repetidos.
155
+ Um `ActionError`, criado com `error()`, segue o próprio `retriable`, que por padrão vem da categoria:
156
+ só `rate_limit` e `dependency` repetem; `validation`, `authentication`, `authorization`,
157
+ `not_found`, `conflict` e `internal` encerram as tentativas. Para repetir um erro de outra categoria,
158
+ declare-o ao criá-lo:
159
+
160
+ ```ts
161
+ throw error({ code: 'crm.lead.locked', category: 'conflict', retriable: true })
162
+ ```
163
+
164
+ Sem política declarada, o handler roda uma vez e um evento por vez. `config.defaultRetry` vale só
165
+ para jobs em background, não para reactions. Com `retry`, o handler precisa tolerar a repetição: o
166
+ `dedup` evita processar o mesmo evento duas vezes, mas não desfaz o que uma tentativa anterior
167
+ já gravou. Declare também `timeout`: com a concorrência padrão, é ele que impede um handler travado
168
+ de parar a reaction inteira.
169
+
170
+ Cada tentativa que falha e será repetida gera o aviso `runtime.reaction_retry` no log, e a falha
171
+ definitiva, a linha `reaction failed` com o número de tentativas. Um evento descartado pelo `dedup`
172
+ gera uma linha `debug` com `runtime.reaction_duplicate_skipped`. Valores fora do domínio, como
173
+ `concurrency: 0`, interrompem o registro com `runtime.invalid_reaction_policy`.
112
174
 
113
- O driver `mitt` funciona no mesmo processo, sem durabilidade nem entrega entre processos.
175
+ Cada entrega que chega ao handler gera um registro de [auditoria](/docs/audit): `action` traz o
176
+ nome da reaction, `meta` traz `{ kind: 'reaction', attempts }` e `input` traz só a identidade do
177
+ evento (`id`, `type` e `source`). O payload fica no registro da action que emitiu, com a redação
178
+ dela. Um evento recusado pelo `authorize` ou descartado pelo `dedup` não gera registro.
179
+
180
+ ## Encadear reactions
181
+
182
+ Quando o efeito de uma reaction é um fato que outra parte do sistema precisa saber, ela o publica
183
+ com `ctx.emit`, como uma action. A reaction declara no binding os eventos que publica:
184
+
185
+ ```ts
186
+ export const leadSincronizarImpl = bindReaction(leadSincronizar, {
187
+ emits: ['lead.sincronizado'],
188
+ handler: async (ctx, event) => {
189
+ const externo = await crm.upsertLead(event.data.leadId)
190
+ await ctx.emit('lead.sincronizado', { leadId: event.data.leadId, externoId: externo.id })
191
+ },
192
+ })
193
+ ```
194
+
195
+ Diferente de uma action, uma reaction só publica o que está em `emits`. Um evento fora da lista,
196
+ inclusive de uma reaction sem `emits`, é descartado, e o runtime registra o aviso
197
+ `runtime.reaction_emit_ignored` com a reaction, o evento e a lista declarada. O aviso vem de
198
+ `config.warnUndeclaredEmits`, ativo por padrão fora de produção.
199
+
200
+ O evento publicado mantém `actor`, `tenant` e `source` do evento que a reaction processava: `source`
201
+ continua apontando para a action que iniciou a cadeia. O campo `cause` registra a reaction que
202
+ publicou, o evento de origem e a profundidade na cadeia, que começa em 1. Evento emitido por action
203
+ não tem `cause`.
204
+
205
+ Uma cadeia pode dar até `config.maxReactionDepth` saltos de reaction (padrão 5). O limite impede que
206
+ um laço, como uma reaction que publica um evento que volta a acioná-la, consuma o processo. O
207
+ `ctx.emit` que passaria do limite não publica e registra o erro `runtime.reaction_chain_too_deep`
208
+ com a cadeia observada. Com `emitMode: 'strict'`, o mesmo código também interrompe a reaction.
209
+
210
+ O manifest projeta o `emits` de cada reaction, e o [`opus introspect`](/docs/cli) liga esse `emits`
211
+ às reactions que escutam o evento.
212
+
213
+ ## Limites atuais
114
214
 
115
- Uma reaction não encadeia outra por evento: dentro dela, `ctx.emit` descarta o evento sem publicá-lo.
116
- Com `config.dev` ativo, cada descarte registra o aviso `runtime.reaction_emit_ignored` no log, com o
117
- nome da reaction e do evento; em produção, o descarte continua silencioso. Quando outro consumidor
118
- precisa desse fato, emita o evento no handler da action que o produz e declare-o em `emits`.
215
+ O driver `mitt` funciona no mesmo processo, sem durabilidade nem entrega entre processos. As
216
+ políticas de entrega também valem por processo, em memória: com vários processos consumindo o mesmo
217
+ barramento, cada um mantém o próprio `dedup` e os próprios limites de concorrência, e um evento que
218
+ espera vaga se perde se o processo cair.
119
219
 
120
- `retry`, `timeout`, `dedup` e `concurrency` são aceitos na declaração, mas o runtime ainda não os
121
- aplica: uma reaction que falha não é repetida.
@@ -41,8 +41,10 @@ subdomínios; um nome repetido de action, reaction, schedule ou Produto de Dados
41
41
  entre domínios registrados separadamente.
42
42
 
43
43
  O `start()` falha quando há reactions registradas sem `eventBus` (`runtime.eventbus_required`) ou
44
- schedules sem `scheduler` (`runtime.scheduler_required`). A declaração de um domínio está em
45
- [Actions & contratos](/docs/actions).
44
+ schedules sem `scheduler` (`runtime.scheduler_required`), e quando um schedule ativo aponta para uma
45
+ action não registrada (`runtime.schedule_action_not_found`) ou que não aceita execução pelo sistema
46
+ (`runtime.schedule_action_not_automatable`); veja [Agendador](/docs/scheduler). A declaração de um
47
+ domínio está em [Actions & contratos](/docs/actions).
46
48
 
47
49
  ## As capacidades
48
50
 
@@ -122,6 +124,13 @@ Duas formas de código rodam sem uma requisição: um evento de domínio dispara
122
124
  [reaction](/docs/events), e o tempo dispara um [schedule](/docs/scheduler), que executa uma action
123
125
  com provenance própria. Ambos são registrados junto com as actions, e o manifest projeta os três.
124
126
 
127
+ O runtime aplica a cada reaction a política de entrega declarada — `retry`, `timeout`, `dedup` e
128
+ `concurrency` — e limita as execuções simultâneas de todas elas com `config.maxConcurrentReactions`
129
+ (padrão 10). Uma reaction publica os eventos que declara em `emits`, e a cadeia para em
130
+ `config.maxReactionDepth` saltos (padrão 5). O schedule executa a action como o sistema, sem
131
+ usuário, quando o contrato dela declara `automatable: true`; `isSystemActor(ctx)` identifica essa
132
+ execução no `authorize` e no handler.
133
+
125
134
  `opus introspect` lista reactions, schedules e as ligações entre eles, mas só reconhece
126
135
  declarações feitas com `defineAction`, `defineReaction` e `defineSchedule`. Actions do par
127
136
  `defineContract` e `bindAction` e reactions do par `defineReactionContract` e `bindReaction` ficam de
@@ -5,8 +5,8 @@ title: Agendador
5
5
  # Agendador
6
6
 
7
7
  Use um schedule para executar uma action por cron ou em intervalos, sem uma requisição. O
8
- `SchedulerAdapter` registra o agendamento, e o runtime executa a action com provenance `schedule`,
9
- que aparece no audit e no contexto do handler.
8
+ `SchedulerAdapter` registra o agendamento, e o runtime executa a action como o sistema, com
9
+ provenance `schedule`, que aparece no audit e no contexto do handler.
10
10
 
11
11
  ## O contrato
12
12
 
@@ -47,7 +47,7 @@ Declare o schedule com `defineSchedule` e registre-o no domínio, ao lado da act
47
47
 
48
48
  ```ts
49
49
  import { createRuntime, defineDomain, defineSchedule } from '@softize/opus/core'
50
- import { caixaFechar } from './caixa-fechar.ts' // a action 'caixa.fechar', definida com bindAction
50
+ import { caixaFechar } from './caixa-fechar.ts' // a action 'caixa.fechar', preparada abaixo
51
51
 
52
52
  const fecharDia = defineSchedule({
53
53
  name: 'caixa.fechar-dia',
@@ -71,22 +71,63 @@ await runtime.start()
71
71
  ```
72
72
 
73
73
  No `start()`, o runtime confere se cada schedule aponta para uma action registrada no mesmo runtime
74
- e depois o registra no adapter. Um nome sem action registrada interrompe o `start()` com
75
- `runtime.schedule_action_not_found`, antes de inicializar os adapters; a mensagem lista cada
76
- schedule e a action que ele procurou. Com schedules registrados e sem `scheduler`, o `start()`
77
- falha com `runtime.scheduler_required`. Um schedule com `enabled: false` não é registrado nem
74
+ e que aceita execução pelo sistema, e depois o registra no adapter. As duas conferências acontecem
75
+ antes de inicializar os adapters, e cada mensagem lista os schedules e as actions afetadas. Um nome
76
+ sem action registrada interrompe o `start()` com `runtime.schedule_action_not_found`; uma action
77
+ sem `automatable` nem `public`, com `runtime.schedule_action_not_automatable` (veja a próxima
78
+ seção). Com schedules registrados e sem `scheduler`, o `start()` falha com
79
+ `runtime.scheduler_required`. Um schedule com `enabled: false` não é registrado nem
78
80
  conferido, então pode apontar para uma action que ainda não existe no ambiente.
79
81
 
80
82
  ## Preparar a action agendada
81
83
 
82
- O schedule executa a action sem usuário: `ctx.user` é `null` e `ctx.can` nega tudo. Por isso, a
83
- action alvo precisa declarar `public: true`; sem isso, cada disparo termina em
84
- `auth.unauthenticated`. Para que ela não fique acessível por HTTP, declare também `internal: true`:
85
- os servidores do Opus não expõem actions internas, e o schedule continua executando-a pelo runtime.
84
+ O schedule executa a action como o sistema, sem uma pessoa por trás. Uma action que exige usuário
85
+ recusa o sistema até que o contrato dela aceite essa execução com `automatable: true`:
86
+
87
+ ```ts
88
+ import { defineContract, isSystemActor } from '@softize/opus/core'
89
+
90
+ export const caixaFecharContract = defineContract({
91
+ name: 'caixa.fechar',
92
+ kind: 'simple',
93
+ input: z.object({}),
94
+ output: z.object({ fechados: z.number() }),
95
+ automatable: true,
96
+ })
97
+
98
+ export const caixaFechar = bindAction(caixaFecharContract, {
99
+ // O sistema não tem permissões: aceite-o de forma explícita ao lado da regra das pessoas.
100
+ authorize: (ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar'),
101
+ handler: async (ctx) => ({ fechados: await caixa.fecharDia() }),
102
+ })
103
+ ```
104
+
105
+ `automatable` só vale para o disparo de um schedule. A action continua exigindo usuário em
106
+ qualquer outra chamada: uma requisição HTTP sem usuário recebe `auth.unauthenticated`, como antes,
107
+ mesmo que informe uma provenance `schedule`.
108
+
109
+ Na execução pelo sistema, `ctx.user` é `null`, `ctx.can` nega toda permissão e `ctx.provenance` é
110
+ `{ kind: 'schedule', scheduleName }`. `isSystemActor(ctx)` responde `true` só quando o próprio
111
+ runtime criou essa execução, a partir de um schedule ou do job em background que ele enfileirou; o
112
+ job leva a identidade em uma marca que o runtime grava no envelope, e não pela provenance. Uma tool
113
+ que a action agendada chama por `ctx.ai` não herda a identidade.
114
+
115
+ Aceite o sistema no `authorize` escrito como função, com `isSystemActor(ctx)`, como no exemplo. Um
116
+ `authorize` em string, como `'user.role == "admin"'`, não tem como reconhecer o sistema: para uma
117
+ action agendada com autorização própria, use a função. Não teste `ctx.provenance.kind` para esse
118
+ fim: qualquer código do servidor que chama `runtime.execute` pode preencher a provenance com
119
+ `schedule`. Sem `authorize`, `automatable` basta. O audit registra o ator como
120
+ `{ id: null, type: 'system' }`, com o nome do schedule na provenance.
121
+
122
+ Um schedule ativo que aponta para uma action sem `automatable` e sem `public` impede o `start()` com
123
+ `runtime.schedule_action_not_automatable`. Para resolver, declare `automatable: true` no contrato da
124
+ action ou, se o agendamento não deve rodar neste ambiente, `enabled: false` no schedule. Uma action
125
+ `public: true` continua podendo ser agendada, mas fica aberta a qualquer chamador; prefira
126
+ `automatable`.
86
127
 
87
128
  Quando um disparo não conclui a action, o runtime registra um erro no log com o schedule, a action,
88
129
  o código e a mensagem do erro; o agendamento continua ativo e o próximo disparo executa a action de
89
- novo. Uma falha dentro do pipeline da action, como `auth.unauthenticated` ou um erro do handler,
130
+ novo. Uma falha dentro do pipeline da action, como um erro do `authorize` ou do handler,
90
131
  também fica registrada no audit. O callback `onError` do driver recebe somente erros lançados antes
91
132
  da execução, como uma falha na função `input`.
92
133
 
@@ -12,8 +12,8 @@ autorização. `@softize/opus/testing` executa esse fluxo em unidade, sem montar
12
12
  `runAction` segue a ordem do runtime: valida a entrada, exige usuário quando a action não é
13
13
  `public`, avalia `authorize`, executa o handler e valida a saída. Em `list`, a validação da saída é
14
14
  pulada, porque o schema descreve o item e o handler devolve a página. Falhas usam o mesmo
15
- `ActionError` tipado do runtime, como `validation.invalid_input`, `auth.unauthenticated` e
16
- `auth.forbidden`.
15
+ `ActionError` tipado do runtime, como `validation.invalid_input`, `auth.unauthenticated`,
16
+ `auth.system_forbidden` e `auth.forbidden`.
17
17
 
18
18
  Passe a action ligada ao handler, produzida por `bindAction`. O contrato puro não tem handler e só
19
19
  executa em modo design, descrito abaixo.
@@ -59,10 +59,22 @@ opcionais.
59
59
  | `ai` | Adapter de IA entregue em `ctx.ai`. Sem runtime, `run` e `runStream` não recebem tools. |
60
60
  | `meta`, `trace` | Valores de `ctx.meta` e `ctx.trace`. |
61
61
  | `loaded` | Substitui os loaders e chega ao `authorize` e ao handler. |
62
+ | `schedule` | Executa como o sistema, no disparo do schedule com esse nome: ignora `user` e `can`. |
62
63
 
63
64
  `requires` é declarativo também no harness: nada o executa. Um handler que chama `ctx.can` recebe a
64
65
  resposta da opção `can`.
65
66
 
67
+ Para uma action agendada, a opção `schedule` reproduz a execução pelo sistema com a mesma regra do
68
+ runtime: `ctx.user` é `null`, `ctx.can` nega tudo e `isSystemActor(ctx)` responde `true`. Sem
69
+ `automatable: true` no contrato, a action falha com `auth.system_forbidden`:
70
+
71
+ ```ts
72
+ it('o fechamento diário roda pelo sistema', async () => {
73
+ const { output } = await runAction(caixaFechar, {}, { schedule: 'caixa.fechar-dia' })
74
+ expect(output.fechados).toBeGreaterThan(0)
75
+ })
76
+ ```
77
+
66
78
  ## Observar efeitos
67
79
 
68
80
  `runAction` devolve, além de `output`, os efeitos capturados: `emitted` guarda cada `ctx.emit` e