@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,111 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 21.0.1 — 2026-09-26
11
+
12
+ **Correção.** O `status()` do driver BullMQ podia devolver `status: 'done'` com `data: null` para um
13
+ job que concluiu com resultado. O driver lia o job e depois o estado em duas consultas separadas;
14
+ quando o worker terminava entre as duas, o estado já era terminal e o job lido ainda era o do
15
+ processamento, sem resultado. Quem acompanhava o job por polling encerrava a espera sem o
16
+ resultado e não voltava a consultar. O mesmo valia para `failed` sem o motivo. Agora, quando o
17
+ estado é terminal e o job lido ainda não traz a conclusão, o driver relê o job antes de montar o
18
+ handle. Nenhuma mudança é necessária no seu código.
19
+
20
+ ## 21.0.0 — 2026-09-26
21
+
22
+ As reactions passam a fazer o que a declaração promete. `retry`, `timeout`, `dedup` e
23
+ `concurrency`, aceitos desde as primeiras versões e ignorados até aqui, agora valem em cada evento
24
+ entregue, e cada entrega que chega ao handler entra no audit (ADR 0026). Uma reaction pode publicar
25
+ os eventos que declara em `emits`, numa cadeia com profundidade limitada (ADR 0027). E um schedule
26
+ executa como o sistema uma action que declara `automatable: true`, sem precisar abri-la com
27
+ `public: true` (ADR 0028). É major porque algumas dessas mudanças podem interromper o boot ou mudar
28
+ o ritmo de uma aplicação que funcionava na 20.1.0: a seção **Breaking** diz o que fazer em cada
29
+ uma. Outros pontos podem pedir ajuste sem interromper a aplicação:
30
+
31
+ - os sinks de audit passam a receber registros de reactions, com `provenance.kind: 'reaction'`,
32
+ `meta.kind: 'reaction'` e sem `actionKind`. Um sink que conta ou filtra registros por action ou
33
+ por `actionKind` deve tratá-los à parte;
34
+ - o manifest ganha `automatable` em cada action e `emits` em cada reaction: rode `opus gen` para
35
+ que o `manifest:check` volte a passar;
36
+ - o aviso `runtime.reaction_emit_ignored` passa a seguir `config.warnUndeclaredEmits`, e não
37
+ `config.dev`. Com os padrões, nada muda.
38
+
39
+ ### Breaking
40
+
41
+ **Política de reaction ou config do runtime fora do domínio derruba o boot.** Valores que o runtime
42
+ ignorava passam a ser conferidos. `retry.attempts`, `concurrency` ou `timeout` de uma reaction com
43
+ zero, número negativo ou fracionário, como `retry: { attempts: 0 }` ou `concurrency: 0`, interrompem
44
+ o registro com `runtime.invalid_reaction_policy`. `maxConcurrentReactions`, `dedupCacheTtl` e os
45
+ novos `maxReactionDepth` e `dedupCacheMaxEntries` inválidos, como `dedupCacheTtl: Infinity` ou
46
+ `maxConcurrentReactions: 0`, interrompem o `createRuntime` com `runtime.invalid_config`. A mensagem
47
+ nomeia a reaction e cada campo. Migração: corrija o valor ou remova o campo para usar o padrão. Rode
48
+ o `start()` num teste para descobrir antes do deploy.
49
+
50
+ **Reactions passam a ser serializadas.** Sem `concurrency` declarado, cada reaction processa um
51
+ evento por vez, na ordem de chegada, e todas as reactions do processo somam no máximo 10 execuções
52
+ simultâneas. Antes, cada evento rodava assim que chegava, sem limite. Uma reaction que dependia de
53
+ rodar eventos em paralelo fica mais lenta, e um handler sem `timeout` que trava segura a única vaga
54
+ e para a reaction inteira. Migração: declare `timeout` em toda reaction que depende de algo externo;
55
+ declare `concurrency` onde o paralelismo é necessário; aumente `config.maxConcurrentReactions` se o
56
+ teto global limitar a vazão.
57
+
58
+ **Schedule sem opt-in derruba o boot.** Um schedule ativo que aponta para uma action sem `public` nem
59
+ `automatable` interrompe o `start()` com `runtime.schedule_action_not_automatable`, antes de
60
+ inicializar os adapters. Antes, o processo subia e cada disparo falhava com `auth.unauthenticated`.
61
+ Migração: declare `automatable: true` no contrato da action para que o schedule a execute como o
62
+ sistema, ou `enabled: false` no schedule para mantê-lo sem executar.
63
+
64
+ **`auth.system_forbidden` substitui `auth.unauthenticated` na execução pelo sistema.** Uma execução
65
+ iniciada por schedule, inclusive o job em background que ele enfileira, que chega a uma action sem
66
+ opt-in termina em `auth.system_forbidden` (categoria `authorization`). Com a falha no boot, isso só
67
+ acontece com um job enfileirado antes de a action perder `automatable`. Migração: alertas e
68
+ consultas de audit que filtravam `auth.unauthenticated` para schedules passam a filtrar o código
69
+ novo.
70
+
71
+ ### Reactions
72
+
73
+ - **Entrega:** `dedup` reserva a chave na chegada, descarta a mesma chave enquanto a entrega está em
74
+ andamento e por `config.dedupCacheTtl` depois do sucesso, e a libera se a entrega falhar em
75
+ definitivo; cada reaction guarda no máximo `config.dedupCacheMaxEntries` chaves concluídas
76
+ (padrão 10.000). `concurrency` (padrão 1) e `config.maxConcurrentReactions` (padrão 10) limitam as
77
+ execuções simultâneas. `timeout` aborta o novo `ctx.signal` e encerra a tentativa com
78
+ `runtime.reaction_timeout`. `retry.attempts` conta o total de execuções, com backoff `fixed` ou
79
+ `exponential`. Exceções comuns e timeouts repetem; um `ActionError` segue o `retriable` da
80
+ categoria (só `rate_limit` e `dependency` repetem por padrão) ou o declarado. Sem `retry`, o handler
81
+ roda uma vez: `config.defaultRetry` continua valendo só para jobs em background.
82
+ - **Observação:** cada tentativa repetida gera o aviso `runtime.reaction_retry`; a falha definitiva
83
+ gera `reaction failed` com o número de tentativas; um evento descartado pelo `dedup` gera a linha
84
+ `debug` `runtime.reaction_duplicate_skipped`. O audit traz um registro por entrega, com
85
+ `meta: { kind: 'reaction', attempts }` e a identidade do evento, sem o payload.
86
+ - **Encadeamento:** `emits` entra no `bindReaction`, como no `bindAction`. Dentro da reaction,
87
+ `ctx.emit` publica só os eventos listados; os demais continuam descartados com o aviso
88
+ `runtime.reaction_emit_ignored`, como antes. O evento publicado herda `actor`, `tenant` e `source`
89
+ e ganha `cause: { reaction, eventId, eventType, depth }`. A cadeia para em
90
+ `config.maxReactionDepth` saltos (padrão 5) com o erro `runtime.reaction_chain_too_deep`, que em
91
+ `emitMode: 'strict'` também interrompe a reaction. `opus introspect` liga o `emits` de uma
92
+ reaction às reactions que escutam o evento.
93
+
94
+ ### Auditoria
95
+
96
+ Um sink que lança de forma síncrona, em vez de rejeitar, passa a seguir o `auditMode` como os demais:
97
+ aviso em `lenient` e `audit.sink.failed` em `strict`. Antes, o erro cru escapava do emissor.
98
+
99
+ ### Schedules
100
+
101
+ `automatable: true`, que existia no contrato sem efeito, passa a ser o opt-in para que um schedule
102
+ execute a action como o sistema. A execução continua sem usuário (`ctx.user` é `null` e `ctx.can`
103
+ nega tudo), e o novo `isSystemActor(ctx)`, de `@softize/opus/core`, permite aceitar o sistema no
104
+ `authorize`: `(ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar')`. Só o runtime cria essa
105
+ identidade: uma chamada HTTP ou a `runtime.execute` sem usuário continua recebendo
106
+ `auth.unauthenticated`, mesmo com provenance `schedule`. O job em background enfileirado pelo
107
+ schedule leva a identidade no worker pela marca `JobSpec.ctx.system`, que o runtime grava ao
108
+ enfileirar; uma provenance `schedule` informada por quem chama não a concede. O audit registra
109
+ `actor: { id: null, type: 'system' }`.
110
+
111
+ Migração recomendada, não obrigatória: troque `public: true` e `internal: true` da action agendada
112
+ por `automatable: true`. `public: true` continua funcionando. O harness `runAction` ganha a opção
113
+ `schedule` para testar a action como o sistema.
114
+
10
115
  ## 20.1.0 — 2026-09-26
11
116
 
12
117
  `Content` ganha um slot de mídia e passa a aceitar a moldura integrada ao header também em regiões
@@ -52,6 +52,7 @@ function shapeAction(a) {
52
52
  permission: a.permission,
53
53
  authorize: a.authorize,
54
54
  public: a.public,
55
+ automatable: a.automatable,
55
56
  internal: a.internal,
56
57
  successStatus: a.successStatus,
57
58
  input: a.input,
@@ -50,6 +50,7 @@
50
50
  * invalidates?: string[] | '<function>',
51
51
  * successStatus?: number,
52
52
  * public?: boolean,
53
+ * automatable?: boolean, // aceita execução pelo sistema (ADR 0028)
53
54
  * internal?: boolean,
54
55
  * rateLimit?: { window, max },
55
56
  * confirm?: ConfirmSpec,
@@ -373,6 +374,7 @@ function serializeAction(action, ctx) {
373
374
  permission: derivePermission(action),
374
375
  authorize: serializeAuthorize(action.authorize),
375
376
  public: action.public === true,
377
+ automatable: action.automatable === true,
376
378
  internal: action.internal === true,
377
379
  successStatus: action.successStatus,
378
380
  input: safeToJsonSchema(action.input, ctx),
@@ -478,6 +480,7 @@ function serializeReaction(reaction) {
478
480
  description: nullable(reaction.description),
479
481
  tags: Array.isArray(reaction.tags) ? reaction.tags : [],
480
482
  handler: "<function>",
483
+ emits: Array.isArray(reaction.emits) ? reaction.emits : [],
481
484
  retry: reaction.retry ?? null,
482
485
  timeout: reaction.timeout ?? null,
483
486
  concurrency: reaction.concurrency ?? null,
@@ -54,6 +54,7 @@ export function parseStructure(fileName, sourceText) {
54
54
  out.reactions.push({
55
55
  name: asString(prop(obj, 'name', sf)),
56
56
  on: asStringArray(prop(obj, 'on', sf)),
57
+ emits: asStringArray(prop(obj, 'emits', sf)),
57
58
  line: line(node),
58
59
  })
59
60
  } else if (fn === 'defineSchedule') {
@@ -90,11 +91,11 @@ export function deriveWiring(model) {
90
91
  for (const s of model.schedules) {
91
92
  if (s.action) edges.push({ kind: 'schedule', from: s.name, to: s.action, via: s.cron ?? s.every ?? 'tempo' })
92
93
  }
93
- // evento: action.emits → reaction.on
94
- for (const a of model.actions) {
95
- for (const ev of a.emits ?? []) {
94
+ // evento: action.emits → reaction.on, e reaction.emits → reaction.on (cadeia, ADR 0027)
95
+ for (const emitter of [...model.actions, ...model.reactions]) {
96
+ for (const ev of emitter.emits ?? []) {
96
97
  for (const r of model.reactions) {
97
- if ((r.on ?? []).includes(ev)) edges.push({ kind: 'event', from: a.name, to: r.name, via: ev })
98
+ if ((r.on ?? []).includes(ev)) edges.push({ kind: 'event', from: emitter.name, to: r.name, via: ev })
98
99
  }
99
100
  }
100
101
  }
@@ -0,0 +1,123 @@
1
+ # ADR 0026 — Reactions aplicam a política de entrega declarada
2
+
3
+ - **Status:** aceita (21.0.0).
4
+ - **Data:** 2026-09-26.
5
+ - **Relaciona-se com:** ADR 0027 (encadeamento por evento).
6
+
7
+ ## Contexto
8
+
9
+ `ReactionDef` e `ReactionBinding` aceitam `retry`, `timeout`, `dedup` e `concurrency`, e a
10
+ `RuntimeConfig` aceita `dedupCacheTtl` e `maxConcurrentReactions`. Até a 20.0.0 o runtime ignorava
11
+ todos eles: cada evento disparava o handler imediatamente, uma falha virava uma linha de log e nada
12
+ era repetido, limitado ou auditado. As páginas `events.md` e `protocol.md` descreviam o pipeline
13
+ pretendido — dedup, authorize, handler com timeout, audit e retry sob um limite de concorrência —
14
+ e, ao lado, avisavam que nada disso era aplicado.
15
+
16
+ As forças em tensão:
17
+
18
+ - **Declaração × efeito.** Um campo aceito pelo tipo e ignorado pelo runtime parece proteger o
19
+ sistema sem proteger.
20
+ - **Repetição × efeito colateral.** Repetir um handler repete o que ele já fez antes de falhar. Só
21
+ quem escreve a reaction sabe se o handler é idempotente.
22
+ - **Vazão × ordem e carga.** Rodar todos os eventos ao mesmo tempo maximiza vazão, mas sobrecarrega
23
+ integrações e banco em rajadas e não preserva ordem nenhuma.
24
+ - **Registro × ruído e vazamento.** Uma reaction que muda estado precisa aparecer na trilha de
25
+ auditoria, mas o payload do evento já foi auditado — com a redação da action — por quem o emitiu.
26
+
27
+ ## Decisão
28
+
29
+ O runtime aplica a política declarada em cada entrega de evento a uma reaction, nesta ordem:
30
+
31
+ 1. **Dedup.** Com `dedup(event)` declarado, a chave calculada ocupa uma posição por reaction desde a
32
+ chegada do evento. Outro evento com a mesma chave é descartado enquanto a entrega anterior está
33
+ na fila ou em andamento, e durante `config.dedupCacheTtl` milissegundos depois de ela concluir com
34
+ sucesso. Se a entrega falhar em definitivo, a chave é liberada, para que uma nova entrega do mesmo
35
+ evento possa tentar de novo. O descarte gera uma linha `debug` com o código
36
+ `runtime.reaction_duplicate_skipped` e não gera audit. Para limitar a memória, cada reaction
37
+ guarda no máximo `config.dedupCacheMaxEntries` chaves concluídas (padrão 10.000); acima disso, a
38
+ mais antiga sai antes do TTL. As chaves em andamento não entram no teto.
39
+ 2. **Concorrência.** Cada tentativa espera uma vaga da reaction (`concurrency`, padrão 1, como o
40
+ protocolo já declarava) e uma vaga global (`config.maxConcurrentReactions`, padrão 10), em ordem
41
+ de chegada. As vagas são liberadas ao fim de cada tentativa e não ficam presas durante a espera
42
+ entre tentativas.
43
+ 3. **Autorização.** `authorize(ctx, event)` roda uma vez, antes da primeira tentativa. `false`
44
+ descarta o evento sem erro e sem audit, como antes.
45
+ 4. **Handler com timeout.** Com `timeout` declarado, o runtime aborta `ctx.signal` quando o prazo
46
+ vence e encerra a tentativa com `runtime.reaction_timeout`. A vaga é liberada no timeout; um
47
+ handler que ignora o sinal pode continuar executando em segundo plano.
48
+ 5. **Retry.** Sem `retry` declarado, o handler roda uma vez. Com `retry`, `attempts` é o total de
49
+ execuções, como em `background.retry`. O backoff `fixed` espera `delayMs`; o `exponential` espera
50
+ `initialMs × multiplier^(n−1)` (multiplier padrão 2), limitado por `maxMs`; sem `backoff`, a nova
51
+ tentativa começa em seguida. Exceções comuns e `runtime.reaction_timeout` são repetidas. Um
52
+ `ActionError` segue o próprio `retriable`, cujo padrão vem da categoria (`errors.ts`): só
53
+ `rate_limit` e `dependency` repetem; `internal` e as demais categorias encerram as tentativas.
54
+ Quem quer repetir outro erro declara `retriable: true` no `error()`. Cada tentativa que falha e
55
+ será repetida gera um aviso `runtime.reaction_retry`.
56
+ 6. **Audit.** Cada entrega que chegou ao handler, ou que falhou antes dele, gera um único
57
+ `AuditRecord`: `action` recebe o nome da reaction, `provenance` é a da reaction, `meta` traz
58
+ `{ kind: 'reaction', attempts }` e `input` traz só a identidade do evento
59
+ (`{ event: { id, type, source } }`), nunca o payload. A falha definitiva continua gerando a linha
60
+ `reaction failed` no log, agora com o número de tentativas.
61
+
62
+ `config.defaultRetry` continua valendo apenas para jobs em background. As políticas valem por
63
+ processo, em memória: com vários processos consumindo o mesmo barramento, cada um mantém o próprio
64
+ dedup e os próprios limites. Valores inválidos (`attempts`, `concurrency` ou `maxConcurrentReactions`
65
+ ou `dedupCacheMaxEntries` fora dos inteiros positivos, `timeout` não positivo, `dedupCacheTtl`
66
+ negativo) interrompem o
67
+ registro ou a criação do runtime com `runtime.invalid_reaction_policy` ou `runtime.invalid_config`.
68
+
69
+ O `ReactionContext` ganha o campo opcional `signal?: AbortSignal`. O runtime sempre o preenche; o
70
+ campo é opcional para não quebrar quem monta o contexto à mão em testes.
71
+
72
+ ## Consequências
73
+
74
+ - Uma reaction sem `concurrency` passa a processar um evento por vez. Rajadas ficam em fila na
75
+ memória do processo, e a ordem de chegada é preservada para a mesma reaction. Um handler que
76
+ trava sem `timeout` segura a única vaga e para a reaction inteira: os eventos seguintes esperam
77
+ na fila até o processo reiniciar. Por isso a documentação recomenda declarar `timeout` em toda
78
+ reaction que depende de algo externo.
79
+ - O limite global de 10 execuções simultâneas passa a valer. Quem precisa de mais vazão aumenta
80
+ `maxConcurrentReactions` ou `concurrency` da reaction.
81
+ - Os sinks de audit passam a receber registros de reactions, reconhecíveis por
82
+ `provenance.kind === 'reaction'` com `meta.kind === 'reaction'`. `actionKind` fica ausente.
83
+ - Uma reaction com `retry` precisa de handler idempotente: a documentação diz isso ao lado do
84
+ exemplo, e `dedup` protege contra a entrega duplicada do mesmo evento, não contra a repetição do
85
+ próprio handler.
86
+ - Nada muda para quem não declara política, exceto concorrência, limite global e audit.
87
+
88
+ ## Alternativas descartadas
89
+
90
+ ### Manter as políticas ignoradas e documentadas como limite
91
+
92
+ Custo zero. Foi descartada porque a declaração continua enganando quem lê o contrato e porque o
93
+ protocolo aprovado descreve o pipeline como garantia do core.
94
+
95
+ ### Aplicar `config.defaultRetry` a toda reaction
96
+
97
+ Daria resiliência sem declaração. Foi descartada porque repetiria três vezes, por padrão, handlers
98
+ escritos sem idempotência, que até a 20.0.0 rodavam uma vez.
99
+
100
+ ### Concorrência ilimitada por reaction, limitada só pelo teto global
101
+
102
+ Preservaria a vazão anterior. Foi descartada porque o protocolo já declarava padrão 1 e porque uma
103
+ integração externa raramente aceita rajadas sem limite. Quem precisa de paralelismo o declara.
104
+
105
+ ### Auditar cada tentativa ou incluir o payload do evento
106
+
107
+ Um registro por tentativa multiplicaria o ruído; a sequência de tentativas já aparece no log. O
108
+ payload já está no audit da action que emitiu, com a redação dela; copiá-lo para a reaction
109
+ contornaria essa redação.
110
+
111
+ ### Delegar retry e concorrência ao adapter de eventos
112
+
113
+ Seria a implementação preferencial com barramentos duráveis. Fica para quando existir um driver
114
+ distribuído: o único driver atual, `mitt`, não tem essas capacidades, e o fallback em memória é o
115
+ que o protocolo prometia.
116
+
117
+ ## Verificação
118
+
119
+ `tests/core/reaction-policies.test.ts` exercita, pela fronteira do runtime, com relógio falso:
120
+ backoff fixo e exponencial com limite, parada em erro não repetível, timeout com sinal abortado,
121
+ dedup dentro e fora do TTL, teto de chaves, liberação da chave após falha, rejeição tratada quando
122
+ dedup e audit falham juntos, concorrência por reaction e global, validação dos valores e o registro
123
+ de audit de sucesso e de falha.
@@ -0,0 +1,96 @@
1
+ # ADR 0027 — Reactions publicam os eventos que declaram, em cadeia limitada
2
+
3
+ - **Status:** aceita (21.0.0).
4
+ - **Data:** 2026-09-26.
5
+ - **Relaciona-se com:** ADR 0026 (política de entrega das reactions).
6
+
7
+ ## Contexto
8
+
9
+ O `protocol.md` descreve o pipeline de uma reaction a partir de um evento “publicado externamente
10
+ ou por `ctx.emit` interno”, o que pressupõe encadeamento. Até a 20.0.0, porém, `ctx.emit` dentro de
11
+ uma reaction descartava o evento; fora de produção, o runtime registrava o aviso
12
+ `runtime.reaction_emit_ignored` e recomendava emitir o fato na action de origem. Isso obriga a
13
+ action a conhecer consequências que pertencem a outra reaction: por exemplo, a reaction que
14
+ sincroniza um lead com o CRM externo não consegue anunciar `crm.lead.synced` para quem depende da
15
+ sincronização.
16
+
17
+ As forças em tensão:
18
+
19
+ - **Composição × laço.** Permitir que reactions publiquem cria cadeias úteis, mas também ciclos: uma
20
+ reaction em `*` que emite recebe o próprio evento, e duas reactions podem se alimentar
21
+ mutuamente. Em memória, um ciclo consome CPU e fila até derrubar o processo.
22
+ - **Declaração × conveniência.** Nas actions, `emits` é informativo: um evento fora da lista é
23
+ publicado com aviso. Reactions nunca publicaram; qualquer publicação é comportamento novo.
24
+ - **Rastreabilidade.** Quem lê um evento encadeado precisa saber qual reaction o publicou, a partir
25
+ de qual evento e a que distância da action de origem.
26
+
27
+ ## Decisão
28
+
29
+ 1. **Publicação exige declaração.** `emits?: string[]` passa a existir no binding da reaction, como
30
+ no binding da action. `ctx.emit` dentro de uma reaction publica somente eventos listados em
31
+ `emits`. Qualquer outro evento, inclusive de reactions sem `emits`, continua descartado, e o
32
+ aviso `runtime.reaction_emit_ignored` permanece, agora controlado por
33
+ `config.warnUndeclaredEmits` (ativo por padrão fora de produção), o mesmo interruptor do aviso de
34
+ evento não declarado das actions. Reactions existentes mantêm exatamente o comportamento anterior.
35
+ 2. **O evento encadeado herda a origem e registra a causa.** O runtime preenche `id`, `timestamp`,
36
+ `trace` (o span da reaction) e `meta`. `actor`, `tenant` e `source` vêm do evento recebido:
37
+ `source` continua apontando para a action que iniciou a cadeia, e `provenance.sourceActionId` das
38
+ reactions seguintes continua igual ao `id` do audit dessa action. O campo opcional novo
39
+ `DomainEvent.cause` diz `{ reaction, eventId, eventType, depth }`: a reaction que publicou, o
40
+ evento que ela processava e a profundidade na cadeia. Evento emitido por action não tem `cause`
41
+ e está na profundidade 0.
42
+ 3. **A cadeia tem profundidade máxima.** `config.maxReactionDepth` (padrão 5) limita quantos saltos
43
+ de reaction uma cadeia pode dar. Um `ctx.emit` que produziria um evento acima do limite não
44
+ publica e registra o erro `runtime.reaction_chain_too_deep` no log, em qualquer ambiente, com a
45
+ cadeia observada. Com `emitMode: 'strict'`, o mesmo código interrompe a reaction, como
46
+ `emit.failed` faz com uma falha de publicação.
47
+ 4. **O resto do `ctx.emit` segue as actions.** Uma falha de `publish` vira aviso em `lenient` e
48
+ interrompe a reaction com `emit.failed` em `strict`. Um evento encadeado é entregue às reactions
49
+ inscritas com a mesma política de entrega (ADR 0026), inclusive à própria reaction que o publicou.
50
+
51
+ ## Consequências
52
+
53
+ - Uma reaction pode anunciar um fato novo sem que a action de origem o conheça, e o manifest
54
+ projeta o `emits` da reaction; `opus introspect` passa a ligar esse `emits` às reactions que o
55
+ escutam.
56
+ - Um ciclo não derruba o processo: ele para na profundidade 5, com um erro no log que nomeia a
57
+ reaction e o evento. Com fan-out *f*, o pior caso é da ordem de *f*⁵ eventos por evento de origem.
58
+ - Recursão legítima e limitada (por exemplo, percorrer uma árvore rasa) continua possível.
59
+ - Reactions ficam mais estritas que actions: um evento fora de `emits` é descartado, não publicado.
60
+ A diferença existe porque reactions nunca publicaram e porque a lista declarada é o que torna a
61
+ cadeia visível no manifest.
62
+ - `DomainEvent` ganha um campo opcional. Barramentos que serializam o evento inteiro o transportam
63
+ sem mudança; um driver que monta o evento campo a campo precisa copiá-lo para o limite valer entre
64
+ processos.
65
+
66
+ ## Alternativas descartadas
67
+
68
+ ### Manter o descarte
69
+
70
+ Não cria risco de laço. Foi descartada porque obriga a action a emitir eventos que descrevem
71
+ consequências de outras reactions, e porque o protocolo já descrevia o encadeamento.
72
+
73
+ ### Publicar qualquer evento, com aviso quando não declarado
74
+
75
+ Espelharia as actions. Foi descartada porque mudaria em silêncio o comportamento de reactions
76
+ existentes que hoje chamam `ctx.emit` sem efeito, e porque um evento não declarado não aparece no
77
+ manifest, que é onde uma cadeia pode ser revisada.
78
+
79
+ ### Detectar ciclos pela cadeia de causa
80
+
81
+ Recusaria o evento quando a mesma reaction já aparece na cadeia. Foi descartada porque bloqueia
82
+ recursão legítima, exige transportar a cadeia inteira em cada evento e não limita cadeias longas
83
+ sem ciclo. A profundidade resolve os dois casos com um número.
84
+
85
+ ### Detectar ciclos no grafo declarado durante o `start()`
86
+
87
+ Útil como diagnóstico, mas insuficiente: padrões como `*`, barramentos externos e reactions de
88
+ outros processos ficam fora do grafo. Pode ser acrescentada depois como aviso do `opus check`.
89
+
90
+ ## Verificação
91
+
92
+ `tests/core/reaction-policies.test.ts` confere, pela fronteira do runtime: publicação de evento
93
+ declarado com `cause`, `source`, `actor` e `tenant` corretos; descarte e aviso de evento não
94
+ declarado e de reaction sem `emits`; silêncio do aviso com `warnUndeclaredEmits: false`; parada de
95
+ um laço na profundidade configurada com o erro `runtime.reaction_chain_too_deep`; e interrupção da
96
+ reaction no modo `strict`.
@@ -0,0 +1,127 @@
1
+ # ADR 0028 — Schedules executam actions como o sistema, por opt-in da action
2
+
3
+ - **Status:** aceita (21.0.0).
4
+ - **Data:** 2026-09-26.
5
+
6
+ ## Contexto
7
+
8
+ Um schedule executa a action com `user: null` e um `can` que nega tudo. Para o runtime, isso é
9
+ indistinguível de um chamador anônimo: a action alvo só roda se declarar `public: true`. A
10
+ documentação ensinava a combinar `public: true` com `internal: true`, para que os servidores do Opus
11
+ não a expusessem por HTTP. O contorno tem três defeitos:
12
+
13
+ - `public: true` declara que a action não exige identidade, o contrário do que se quer dizer;
14
+ - `internal` só protege nos servidores do Opus; outro transporte que chame `runtime.execute` expõe
15
+ uma action pública sem autenticação;
16
+ - o `authorize` da action não distingue o sistema de um anônimo, porque os dois chegam com
17
+ `user: null`.
18
+
19
+ O contrato já tem, sem efeito até aqui, o campo `automatable`, documentado no protocolo como
20
+ “sistema/cron/workflow pode disparar”. A provenance `schedule` e o `actor.type: 'system'` do audit
21
+ também já existem.
22
+
23
+ Restrições dadas pelo dono, inegociáveis: nada que hoje exige usuário passa a ser executável por
24
+ HTTP sem usuário; o acesso do sistema é opt-in por action e negado por padrão; `authorize` e
25
+ `requires` enxergam que o ator é o sistema; o audit registra o ator.
26
+
27
+ ## Decisão
28
+
29
+ 1. **O opt-in é `automatable: true` no contrato da action.** O campo existente passa a ter efeito:
30
+ uma action automatizável aceita execução pelo sistema sem usuário. Sem ele, a execução pelo
31
+ sistema de uma action não pública é recusada com `auth.system_forbidden` (categoria
32
+ `authorization`). O opt-in fica no contrato, e não no schedule, para que quem é dono da action
33
+ decida e para que o manifest, que passa a projetar `automatable`, mostre a decisão a quem revisa.
34
+ 2. **Só o runtime cria a identidade de sistema.** O disparo de um schedule executa a action por um
35
+ caminho interno do runtime, marcado como execução do sistema; `runtime.execute` não aceita essa
36
+ marca. Um `ContextBase` com `provenance: { kind: 'schedule' }` vindo de fora continua sendo, sem
37
+ usuário, um chamador anônimo. Um job em background preserva a identidade por uma marca
38
+ explícita no envelope, `ctx.system: true`, que o runtime grava ao enfileirar somente quando a
39
+ execução que enfileira é do sistema (`isSystemActor`). O worker exige essa marca e `userId: null`;
40
+ a provenance original não conta, porque vem do chamador de `runtime.execute` e pode dizer
41
+ `schedule` sem que um schedule tenha disparado. Pelo mesmo motivo, uma tool chamada por
42
+ `ctx.ai` dentro de uma action agendada não herda a identidade, nem no job que ela enfileira.
43
+ 3. **A identidade reaproveita o contexto existente.** Na execução pelo sistema, `ctx.user` é `null`
44
+ (não há pessoa), `ctx.can` nega toda permissão e `ctx.provenance` é
45
+ `{ kind: 'schedule', scheduleName }` — ou `background` com essa provenance original. O helper
46
+ `isSystemActor(ctx)`, exportado pelo core, responde se o ator é o sistema, para que um
47
+ `authorize` possa aceitá-lo de forma explícita: `(ctx) => isSystemActor(ctx) ||
48
+ ctx.can('caixa:fechar')`. O helper não lê a provenance, que qualquer código servidor pode
49
+ preencher: ele consulta um registro interno dos contextos que o próprio runtime criou para o
50
+ sistema. `requires` continua declarativo: o sistema não detém permissões, e o manifest mostra
51
+ `automatable` ao lado de `permission`.
52
+ 4. **O audit registra o ator como sistema.** O registro de uma execução pelo sistema traz
53
+ `actor: { id: null, type: 'system' }` e a provenance com o nome do schedule, como já acontecia
54
+ com actions públicas agendadas.
55
+ 5. **O `start()` recusa schedules que não vão rodar.** Um schedule ativo cuja action não é `public`
56
+ nem `automatable` interrompe o boot com `runtime.schedule_action_not_automatable`, antes de
57
+ inicializar os adapters, como `runtime.schedule_action_not_found`. A mensagem lista cada schedule
58
+ e a action, e indica as duas saídas: `automatable: true` no contrato ou `enabled: false` no
59
+ schedule. Um aviso deixaria o processo subir com um agendamento que falha em todo disparo; o
60
+ custo de quebrar o boot de quem tem essa configuração foi aceito junto com a versão major
61
+ (21.0.0), que já reúne as outras mudanças incompatíveis desta decisão e da ADR 0026.
62
+
63
+ `public: true` continua funcionando para actions agendadas e mantém o comportamento de hoje; a
64
+ documentação passa a recomendar `automatable: true`.
65
+
66
+ ## Consequências
67
+
68
+ - Uma action pode ser agendada sem ficar aberta a anônimos. `internal: true` deixa de ser
69
+ necessário para esse fim e volta a significar só “não expor por REST”.
70
+ - Um `authorize` que delega a `ctx.can` recusa o sistema até aceitá-lo com `isSystemActor(ctx)`.
71
+ São duas decisões explícitas — `automatable` e a regra no `authorize` — quando a action tem
72
+ autorização própria; sem `authorize`, `automatable` basta.
73
+ - Uma chamada de IA feita pelo handler de uma action agendada (`ctx.ai.run`) não herda a identidade
74
+ de sistema: as tools passam por `runtime.execute`, como qualquer chamador.
75
+ - `actions` que já declaravam `automatable: true` passam a aceitar execução por schedule. Como só um
76
+ schedule registrado no próprio runtime cria a identidade, nenhuma rota HTTP ganha acesso.
77
+ - O harness `runAction` ganha a opção `schedule`, que executa a action como o sistema com a mesma
78
+ regra do runtime.
79
+ - No worker, a identidade vem do envelope do job, como o `userId` já vinha. Escrever diretamente na
80
+ fila fica **fora do modelo de ameaça** desta decisão: quem tem esse acesso já forja o `userId` de
81
+ qualquer pessoa, e a marca `system` não amplia o que ele alcança. A fronteira de confiança da fila
82
+ não muda; protegê-la é papel da infraestrutura (credenciais do Redis, rede).
83
+ - Um schedule sem opt-in, que antes subia e falhava em cada disparo com `auth.unauthenticated`,
84
+ agora impede o boot. `auth.system_forbidden` continua existindo para o caso que o boot não
85
+ detecta: um job do sistema cuja action perdeu `automatable` entre o enfileiramento e o worker.
86
+
87
+ ## Alternativas descartadas
88
+
89
+ ### Um usuário sintético de sistema em `ctx.user`
90
+
91
+ Deixaria `authorize` existente funcionar sem mudança. Foi descartada porque um `authorize` que
92
+ testa apenas `ctx.user !== null` passaria a aceitar o sistema sem que ninguém decidisse isso, e
93
+ porque um id sintético se misturaria aos ids de pessoas no audit.
94
+
95
+ ### Opt-in no schedule
96
+
97
+ Colocaria a decisão ao lado do agendamento. Foi descartada porque o schedule concederia acesso a uma
98
+ action que ele não possui, e porque o contrato da action — e o manifest — deixariam de mostrar quem
99
+ pode executá-la.
100
+
101
+ ### Um campo novo `ctx.actor` ou um tipo de identidade próprio
102
+
103
+ Tornaria a identidade mais evidente. Foi adiado: provenance e `user: null` já descrevem o ator sem
104
+ ambiguidade quando só o runtime cria a execução pelo sistema, e um campo novo exigiria migração em
105
+ todos os consumidores do contexto.
106
+
107
+ ### Um `can` de sistema que concede permissões configuráveis
108
+
109
+ Resolveria `authorize` baseado em `ctx.can`. Foi descartada por ora porque criaria uma política de
110
+ permissões do sistema paralela à do app; a regra explícita no `authorize` é mais fácil de revisar.
111
+
112
+ ### Só avisar no boot quando o schedule aponta para action não automatizável
113
+
114
+ Foi a proposta inicial: não quebraria projetos que sobem hoje. Foi descartada na decisão do dono,
115
+ porque o processo subiria com um schedule que falha em todo disparo e o aviso se perde no log; como
116
+ a versão já é major, a falha no boot fica coerente com `runtime.schedule_action_not_found`.
117
+
118
+ ## Verificação
119
+
120
+ `tests/core/system-actor.test.ts` confere, pela fronteira do runtime: schedule com action
121
+ `automatable` executa sem `public`; `runtime.execute` sem usuário, mesmo com provenance `schedule`,
122
+ continua recusado; `isSystemActor` é verdadeiro só na execução pelo sistema, inclusive no job em
123
+ background criado por schedule, e falso para uma provenance `schedule` montada fora do runtime —
124
+ também no job que essa chamada enfileira e na tool em background chamada por `ctx.ai` dentro de uma
125
+ action agendada; a falha de boot antes da inicialização dos adapters, inclusive com schedule
126
+ desabilitado ignorado; a recusa `auth.system_forbidden` de um job do sistema para action sem
127
+ opt-in; e o harness `runAction` com a opção `schedule`.
@@ -16,6 +16,11 @@
16
16
  tem opinião.
17
17
  4. Com o tempo, a biblioteca inteira converge pra "ours" naturalmente, pelo uso — sem
18
18
  big-bang, sem cerimônia de virada.
19
+ 5. **O ledger acompanha o ejetado.** Editar um componente `ejected` continua livre, mas a
20
+ edição atualiza no mesmo commit o `localHash` e, quando muda o comportamento, o `delta`
21
+ do `registry.lock.json`. O `lock.test` confere o hash dos ejetados com esse propósito: não
22
+ impede a edição, impede que o registro de proveniência derive em silêncio (set/2026, depois
23
+ de doze ejetados ficarem com hash desatualizado sem nenhum gate perceber).
19
24
 
20
25
  ## Princípio irmão: sobrescrita sancionada
21
26