@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 +105 -0
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +3 -0
- package/bin/lib/introspect.mjs +5 -4
- package/docs/adr/0026-reactions-apply-declared-delivery-policy.md +123 -0
- package/docs/adr/0027-reactions-publish-declared-events-with-bounded-chains.md +96 -0
- package/docs/adr/0028-schedules-run-actions-as-the-system-by-action-opt-in.md +127 -0
- package/docs/ownership-vs-shadcn-lock.md +5 -0
- package/docs/protocol.md +60 -24
- package/package.json +1 -1
- package/src/core/audit.ts +2 -1
- package/src/core/index.ts +1 -1
- package/src/core/reactions.ts +6 -2
- package/src/core/runtime.ts +598 -70
- package/src/core/schedules.ts +3 -2
- package/src/core/types.ts +52 -7
- package/src/queue/drivers/bullmq.ts +15 -2
- package/src/testing/index.ts +25 -14
- package/src/ui/docs/content/actions.md +2 -1
- package/src/ui/docs/content/audit.md +10 -0
- package/src/ui/docs/content/auth.md +5 -1
- package/src/ui/docs/content/events.md +109 -11
- package/src/ui/docs/content/runtime.md +11 -2
- package/src/ui/docs/content/scheduler.md +53 -12
- package/src/ui/docs/content/testing.md +14 -2
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
|
package/bin/lib/gen-manifest.mjs
CHANGED
package/bin/lib/gen-runner.mjs
CHANGED
|
@@ -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,
|
package/bin/lib/introspect.mjs
CHANGED
|
@@ -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
|
|
95
|
-
for (const ev of
|
|
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:
|
|
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
|
|