@softize/opus 20.0.0 → 21.0.0
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 +141 -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/0020-icon-tiles-are-explicitly-composed.md +8 -0
- package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +14 -1
- package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
- 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/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/testing/index.ts +25 -14
- package/src/ui/components/patterns/content-header.tsx +94 -15
- package/src/ui/components/patterns/list.tsx +2 -14
- package/src/ui/components/patterns/surface-header.tsx +58 -25
- package/src/ui/components/primitives/card.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +1 -1
- package/src/ui/components/primitives/drawer.tsx +1 -1
- package/src/ui/docs/content/action-list.md +7 -6
- 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/content.md +88 -20
- package/src/ui/docs/content/events.md +109 -11
- package/src/ui/docs/content/icon-tile.md +11 -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/src/ui/meta.ts +1 -1
- package/src/ui/react.tsx +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,147 @@ 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.0 — 2026-09-26
|
|
11
|
+
|
|
12
|
+
As reactions passam a fazer o que a declaração promete. `retry`, `timeout`, `dedup` e
|
|
13
|
+
`concurrency`, aceitos desde as primeiras versões e ignorados até aqui, agora valem em cada evento
|
|
14
|
+
entregue, e cada entrega que chega ao handler entra no audit (ADR 0026). Uma reaction pode publicar
|
|
15
|
+
os eventos que declara em `emits`, numa cadeia com profundidade limitada (ADR 0027). E um schedule
|
|
16
|
+
executa como o sistema uma action que declara `automatable: true`, sem precisar abri-la com
|
|
17
|
+
`public: true` (ADR 0028). É major porque algumas dessas mudanças podem interromper o boot ou mudar
|
|
18
|
+
o ritmo de uma aplicação que funcionava na 20.1.0: a seção **Breaking** diz o que fazer em cada
|
|
19
|
+
uma. Outros pontos podem pedir ajuste sem interromper a aplicação:
|
|
20
|
+
|
|
21
|
+
- os sinks de audit passam a receber registros de reactions, com `provenance.kind: 'reaction'`,
|
|
22
|
+
`meta.kind: 'reaction'` e sem `actionKind`. Um sink que conta ou filtra registros por action ou
|
|
23
|
+
por `actionKind` deve tratá-los à parte;
|
|
24
|
+
- o manifest ganha `automatable` em cada action e `emits` em cada reaction: rode `opus gen` para
|
|
25
|
+
que o `manifest:check` volte a passar;
|
|
26
|
+
- o aviso `runtime.reaction_emit_ignored` passa a seguir `config.warnUndeclaredEmits`, e não
|
|
27
|
+
`config.dev`. Com os padrões, nada muda.
|
|
28
|
+
|
|
29
|
+
### Breaking
|
|
30
|
+
|
|
31
|
+
**Política de reaction ou config do runtime fora do domínio derruba o boot.** Valores que o runtime
|
|
32
|
+
ignorava passam a ser conferidos. `retry.attempts`, `concurrency` ou `timeout` de uma reaction com
|
|
33
|
+
zero, número negativo ou fracionário, como `retry: { attempts: 0 }` ou `concurrency: 0`, interrompem
|
|
34
|
+
o registro com `runtime.invalid_reaction_policy`. `maxConcurrentReactions`, `dedupCacheTtl` e os
|
|
35
|
+
novos `maxReactionDepth` e `dedupCacheMaxEntries` inválidos, como `dedupCacheTtl: Infinity` ou
|
|
36
|
+
`maxConcurrentReactions: 0`, interrompem o `createRuntime` com `runtime.invalid_config`. A mensagem
|
|
37
|
+
nomeia a reaction e cada campo. Migração: corrija o valor ou remova o campo para usar o padrão. Rode
|
|
38
|
+
o `start()` num teste para descobrir antes do deploy.
|
|
39
|
+
|
|
40
|
+
**Reactions passam a ser serializadas.** Sem `concurrency` declarado, cada reaction processa um
|
|
41
|
+
evento por vez, na ordem de chegada, e todas as reactions do processo somam no máximo 10 execuções
|
|
42
|
+
simultâneas. Antes, cada evento rodava assim que chegava, sem limite. Uma reaction que dependia de
|
|
43
|
+
rodar eventos em paralelo fica mais lenta, e um handler sem `timeout` que trava segura a única vaga
|
|
44
|
+
e para a reaction inteira. Migração: declare `timeout` em toda reaction que depende de algo externo;
|
|
45
|
+
declare `concurrency` onde o paralelismo é necessário; aumente `config.maxConcurrentReactions` se o
|
|
46
|
+
teto global limitar a vazão.
|
|
47
|
+
|
|
48
|
+
**Schedule sem opt-in derruba o boot.** Um schedule ativo que aponta para uma action sem `public` nem
|
|
49
|
+
`automatable` interrompe o `start()` com `runtime.schedule_action_not_automatable`, antes de
|
|
50
|
+
inicializar os adapters. Antes, o processo subia e cada disparo falhava com `auth.unauthenticated`.
|
|
51
|
+
Migração: declare `automatable: true` no contrato da action para que o schedule a execute como o
|
|
52
|
+
sistema, ou `enabled: false` no schedule para mantê-lo sem executar.
|
|
53
|
+
|
|
54
|
+
**`auth.system_forbidden` substitui `auth.unauthenticated` na execução pelo sistema.** Uma execução
|
|
55
|
+
iniciada por schedule, inclusive o job em background que ele enfileira, que chega a uma action sem
|
|
56
|
+
opt-in termina em `auth.system_forbidden` (categoria `authorization`). Com a falha no boot, isso só
|
|
57
|
+
acontece com um job enfileirado antes de a action perder `automatable`. Migração: alertas e
|
|
58
|
+
consultas de audit que filtravam `auth.unauthenticated` para schedules passam a filtrar o código
|
|
59
|
+
novo.
|
|
60
|
+
|
|
61
|
+
### Reactions
|
|
62
|
+
|
|
63
|
+
- **Entrega:** `dedup` reserva a chave na chegada, descarta a mesma chave enquanto a entrega está em
|
|
64
|
+
andamento e por `config.dedupCacheTtl` depois do sucesso, e a libera se a entrega falhar em
|
|
65
|
+
definitivo; cada reaction guarda no máximo `config.dedupCacheMaxEntries` chaves concluídas
|
|
66
|
+
(padrão 10.000). `concurrency` (padrão 1) e `config.maxConcurrentReactions` (padrão 10) limitam as
|
|
67
|
+
execuções simultâneas. `timeout` aborta o novo `ctx.signal` e encerra a tentativa com
|
|
68
|
+
`runtime.reaction_timeout`. `retry.attempts` conta o total de execuções, com backoff `fixed` ou
|
|
69
|
+
`exponential`. Exceções comuns e timeouts repetem; um `ActionError` segue o `retriable` da
|
|
70
|
+
categoria (só `rate_limit` e `dependency` repetem por padrão) ou o declarado. Sem `retry`, o handler
|
|
71
|
+
roda uma vez: `config.defaultRetry` continua valendo só para jobs em background.
|
|
72
|
+
- **Observação:** cada tentativa repetida gera o aviso `runtime.reaction_retry`; a falha definitiva
|
|
73
|
+
gera `reaction failed` com o número de tentativas; um evento descartado pelo `dedup` gera a linha
|
|
74
|
+
`debug` `runtime.reaction_duplicate_skipped`. O audit traz um registro por entrega, com
|
|
75
|
+
`meta: { kind: 'reaction', attempts }` e a identidade do evento, sem o payload.
|
|
76
|
+
- **Encadeamento:** `emits` entra no `bindReaction`, como no `bindAction`. Dentro da reaction,
|
|
77
|
+
`ctx.emit` publica só os eventos listados; os demais continuam descartados com o aviso
|
|
78
|
+
`runtime.reaction_emit_ignored`, como antes. O evento publicado herda `actor`, `tenant` e `source`
|
|
79
|
+
e ganha `cause: { reaction, eventId, eventType, depth }`. A cadeia para em
|
|
80
|
+
`config.maxReactionDepth` saltos (padrão 5) com o erro `runtime.reaction_chain_too_deep`, que em
|
|
81
|
+
`emitMode: 'strict'` também interrompe a reaction. `opus introspect` liga o `emits` de uma
|
|
82
|
+
reaction às reactions que escutam o evento.
|
|
83
|
+
|
|
84
|
+
### Auditoria
|
|
85
|
+
|
|
86
|
+
Um sink que lança de forma síncrona, em vez de rejeitar, passa a seguir o `auditMode` como os demais:
|
|
87
|
+
aviso em `lenient` e `audit.sink.failed` em `strict`. Antes, o erro cru escapava do emissor.
|
|
88
|
+
|
|
89
|
+
### Schedules
|
|
90
|
+
|
|
91
|
+
`automatable: true`, que existia no contrato sem efeito, passa a ser o opt-in para que um schedule
|
|
92
|
+
execute a action como o sistema. A execução continua sem usuário (`ctx.user` é `null` e `ctx.can`
|
|
93
|
+
nega tudo), e o novo `isSystemActor(ctx)`, de `@softize/opus/core`, permite aceitar o sistema no
|
|
94
|
+
`authorize`: `(ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar')`. Só o runtime cria essa
|
|
95
|
+
identidade: uma chamada HTTP ou a `runtime.execute` sem usuário continua recebendo
|
|
96
|
+
`auth.unauthenticated`, mesmo com provenance `schedule`. O job em background enfileirado pelo
|
|
97
|
+
schedule leva a identidade no worker pela marca `JobSpec.ctx.system`, que o runtime grava ao
|
|
98
|
+
enfileirar; uma provenance `schedule` informada por quem chama não a concede. O audit registra
|
|
99
|
+
`actor: { id: null, type: 'system' }`.
|
|
100
|
+
|
|
101
|
+
Migração recomendada, não obrigatória: troque `public: true` e `internal: true` da action agendada
|
|
102
|
+
por `automatable: true`. `public: true` continua funcionando. O harness `runAction` ganha a opção
|
|
103
|
+
`schedule` para testar a action como o sistema.
|
|
104
|
+
|
|
105
|
+
## 20.1.0 — 2026-09-26
|
|
106
|
+
|
|
107
|
+
`Content` ganha um slot de mídia e passa a aceitar a moldura integrada ao header também em regiões
|
|
108
|
+
de leitura. Quem não usa as novas props mantém a mesma aparência: `frameVariant="default"` continua
|
|
109
|
+
sendo o padrão, com a borda contínua em `section` e em `fieldset`. Alguns pontos podem pedir ajuste
|
|
110
|
+
sem interromper a aplicação:
|
|
111
|
+
|
|
112
|
+
- a `legend` de todo `Content as="fieldset"` passa a envolver o título nos wrappers do header
|
|
113
|
+
(`legend > span[data-slot=content-heading] > …`): um seletor como
|
|
114
|
+
`legend > [data-slot=content-title]` deixa de casar; use `legend [data-slot=content-title]`;
|
|
115
|
+
- um `ContentHeader` de fieldset com slot inválido lança outra mensagem: “ContentHeader de fieldset
|
|
116
|
+
aceita somente ContentMedia e ContentTitle; contador, descrição e ações ficam fora da legenda.”
|
|
117
|
+
Atualize testes que conferem o texto;
|
|
118
|
+
- com `frameVariant="legend"` dentro de uma superfície com fundo próprio, como um `Card` com `bg-*`
|
|
119
|
+
ou um `Popover`, declare `--surface-background` com esse fundo na superfície (veja **Cor da
|
|
120
|
+
superfície**);
|
|
121
|
+
- em `advancedFilters.sectionIcons`, um glifo solto ganha a moldura padrão, e testes que buscavam
|
|
122
|
+
`[data-slot="content-title-icon"]` devem buscar `[data-slot="content-media"]`.
|
|
123
|
+
|
|
124
|
+
Em detalhe:
|
|
125
|
+
|
|
126
|
+
- **Mídia no header.** `ContentHeader` aceita `ContentMedia`, e a forma curta de `Content` ganha
|
|
127
|
+
`media`. O slot já é a moldura de `IconTile`, com a mesma escala e posição em `section` e em
|
|
128
|
+
`fieldset`: passe só o glifo e escolha `size`, `context` e `variant` no slot. Se você colocava um
|
|
129
|
+
`IconTile` dentro de `title`, mova-o para `media`; o slot o adota e herda o que ele declara, sem
|
|
130
|
+
desenhar moldura dentro de moldura. `media` com `null` ou `false` não desenha nada. Em `fieldset`,
|
|
131
|
+
a mídia é decorativa e não entra no nome do grupo.
|
|
132
|
+
- **Moldura integrada em `section`.** `frameVariant="legend"` deixa de exigir `as="fieldset"`. Em
|
|
133
|
+
uma `section`, o título continua sendo um heading no nível declarado, e contador, descrição e
|
|
134
|
+
ações continuam disponíveis; nenhuma `legend` é emitida. Use `as="fieldset"` só para grupos de
|
|
135
|
+
campos: a `legend` aceita a mídia e o título, e contador, descrição e ações continuam fora dela
|
|
136
|
+
(ADR 0025).
|
|
137
|
+
- **Cor da superfície.** Para interromper a borda, o header pinta o título e as ações com a
|
|
138
|
+
variável `--surface-background`. `Card`, `Dialog` e `Drawer` a declaram com o fundo que pintam,
|
|
139
|
+
então a linha não reaparece atrás do título quando há um `CardBody`, um `DrawerBody` ou outro
|
|
140
|
+
wrapper transparente no caminho. Fora delas, vale `--background`.
|
|
141
|
+
- **Filtros avançados.** Os ícones de `advancedFilters.sectionIcons` passam a ocupar o
|
|
142
|
+
`ContentMedia` de cada seção: um glifo solto ganha a moldura padrão, em vez do ícone esmaecido sem
|
|
143
|
+
moldura, e um `IconTile` é adotado pelo slot com o que declara. Um ícone `null` não desenha
|
|
144
|
+
moldura.
|
|
145
|
+
|
|
146
|
+
A página de `Content` mostra a mídia, os dois acabamentos da moldura e a declaração de
|
|
147
|
+
`--surface-background`. A ADR 0025 registra que o acabamento da moldura não altera a semântica; a
|
|
148
|
+
ADR 0023 ganha a emenda do slot de mídia de `Content`, e as ADRs 0020 e 0021, emendas que apontam
|
|
149
|
+
para a 0025.
|
|
150
|
+
|
|
10
151
|
## 20.0.0 — 2026-09-26
|
|
11
152
|
|
|
12
153
|
Esta versão corrige componentes que falhavam em acessibilidade ou em layout, leva o `fill` da
|
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
|
}
|
|
@@ -24,6 +24,14 @@ a mesma base visual porque essas variantes já prometem a moldura.
|
|
|
24
24
|
`IconTile` é somente apresentação. Quando o ícone aciona algo, deve-se usar `Button` com tamanho
|
|
25
25
|
de ícone e nome acessível.
|
|
26
26
|
|
|
27
|
+
### Emenda de 2026-09-26 — slots de mídia das superfícies
|
|
28
|
+
|
|
29
|
+
Os slots de mídia da anatomia de superfícies (ADR 0023), como `CardMedia` e `ContentMedia`, não são
|
|
30
|
+
slots genéricos: assim como `ItemMedia variant="icon"`, eles já prometem a moldura, e o consumidor
|
|
31
|
+
passa só o glifo. `ContentMedia` adota um `IconTile` recebido como filho em vez de desenhar uma
|
|
32
|
+
moldura dentro da outra (ADR 0025). Slots genéricos, como `FieldLegend.icon`, continuam como esta
|
|
33
|
+
ADR descreve.
|
|
34
|
+
|
|
27
35
|
## Consequências
|
|
28
36
|
|
|
29
37
|
- A presença da moldura deixa de ser uma regra implícita da legenda.
|
|
@@ -30,6 +30,13 @@ uma `section`. A escolha de `fieldset` não define o acabamento visual: quando `
|
|
|
30
30
|
presente, a moldura é contínua por padrão. Os filtros avançados preservam a legenda integrada à
|
|
31
31
|
borda declarando `frameVariant="legend"`.
|
|
32
32
|
|
|
33
|
+
### Emenda de 2026-09-26 — o acabamento `legend` também vale para `section`
|
|
34
|
+
|
|
35
|
+
A ADR 0025 estende `frameVariant="legend"` a `Content as="section"`, para que regiões de leitura
|
|
36
|
+
integrem o header à moldura sem declarar semântica de formulário, e permite `ContentMedia` na
|
|
37
|
+
legenda de um `fieldset`. A regra desta ADR se mantém: `as` escolhe o elemento e a semântica, e
|
|
38
|
+
`frameVariant` escolhe só o acabamento. Na legenda, contador, descrição e ações continuam inválidos.
|
|
39
|
+
|
|
33
40
|
## Consequências
|
|
34
41
|
|
|
35
42
|
- A mesma API nomeia e delimita regiões de leitura e grupos de formulário.
|
|
@@ -143,7 +143,7 @@ anteriores já os delimitaram:
|
|
|
143
143
|
|
|
144
144
|
| Slot | Card | Content | Page | Dialog | Drawer | Pane |
|
|
145
145
|
| --- | --- | --- | --- | --- | --- | --- |
|
|
146
|
-
| Media | sim |
|
|
146
|
+
| Media | sim | sim (`ContentMedia`) | não | sim (`DialogMedia`) | sim | sim |
|
|
147
147
|
| Title | sim | sim | sim | sim | sim | sim |
|
|
148
148
|
| Description | sim | sim | não (ADR 0014) | não (ADR 0018) | não (ADR 0018) | não |
|
|
149
149
|
| Meta | não | sim (contador) | não (ADR 0009) | não | não | não |
|
|
@@ -163,6 +163,19 @@ de um cabeçalho remontam a linha com `span`, incluindo o ícone dentro do texto
|
|
|
163
163
|
`Content` permanece sem footer: é uma região nomeada dentro de outra superfície, e um rodapé
|
|
164
164
|
pertence à superfície que a hospeda.
|
|
165
165
|
|
|
166
|
+
#### Emenda de 2026-09-25 — Content também expõe mídia
|
|
167
|
+
|
|
168
|
+
A aplicação posterior da anatomia à tela do Evento mostrou que regiões de leitura podem precisar
|
|
169
|
+
da mesma identificação visual que um Card sem deixarem de ser `Content`. Sem um slot próprio, a
|
|
170
|
+
documentação e os consumidores voltavam a inserir o ícone dentro de `ContentTitle`, repetindo o
|
|
171
|
+
desvio que motivou esta ADR.
|
|
172
|
+
|
|
173
|
+
`ContentHeader` passa a aceitar `ContentMedia`, e o shorthand expõe `media`. A mídia usa a mesma
|
|
174
|
+
moldura de `IconTile` das demais famílias e integra o grupo visual do título. Esse contrato vale
|
|
175
|
+
tanto para `section` quanto para `fieldset`: `as` troca o elemento e a semântica, não a apresentação
|
|
176
|
+
de mídia e título. Na `legend`, contador, descrição e ações continuam indisponíveis porque não
|
|
177
|
+
nomeiam o grupo de campos.
|
|
178
|
+
|
|
166
179
|
### 7. Título: hierarquia e tamanho são independentes
|
|
167
180
|
|
|
168
181
|
O nível semântico vem de `level` (`1`–`6`) ou de `asChild`, e nunca do tamanho visual. O tamanho vem
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# ADR 0025 — O acabamento da moldura de Content não altera sua semântica
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita.
|
|
4
|
+
- **Data:** 2026-09-25.
|
|
5
|
+
- **Complementa:** ADRs 0021 e 0023.
|
|
6
|
+
|
|
7
|
+
## Contexto
|
|
8
|
+
|
|
9
|
+
`Content` separa o elemento semântico (`section` ou `fieldset`) da presença da moldura. Porém, o
|
|
10
|
+
acabamento `frameVariant="legend"`, que integra visualmente o título à borda, só está disponível
|
|
11
|
+
quando a raiz é um `fieldset`.
|
|
12
|
+
|
|
13
|
+
Essa restrição leva regiões somente de leitura a escolherem `fieldset` apenas para obter o
|
|
14
|
+
acabamento visual. É o caso de grupos de detalhes como Cliente e Veículo: eles formam seções
|
|
15
|
+
nomeadas com `DetailGroup`, não conjuntos de controles de formulário. A aparência desejada se
|
|
16
|
+
parece com uma legenda, mas não transforma o título em uma `legend` semântica.
|
|
17
|
+
|
|
18
|
+
As forças em tensão são:
|
|
19
|
+
|
|
20
|
+
- preservar `fieldset` e `legend` exclusivamente para grupos de campos relacionados;
|
|
21
|
+
- oferecer a mesma linguagem visual a regiões de leitura;
|
|
22
|
+
- manter título, mídia, descrição, contador e ações compatíveis com a anatomia normal de `Content`;
|
|
23
|
+
- não criar outra família pública apenas para desenhar uma borda interrompida.
|
|
24
|
+
|
|
25
|
+
## Decisão
|
|
26
|
+
|
|
27
|
+
`frameVariant="legend"` passa a ser aceito tanto por `Content as="section"` quanto por
|
|
28
|
+
`Content as="fieldset"`. A propriedade escolhe somente o acabamento da moldura; `as` continua
|
|
29
|
+
sendo a única propriedade que determina o elemento e sua semântica.
|
|
30
|
+
|
|
31
|
+
Em uma `section`, o título permanece um heading no nível declarado, o header continua aceitando
|
|
32
|
+
`ContentMedia`, `ContentMeta`, `ContentDescription` e `ContentActions`, e a borda é visualmente
|
|
33
|
+
interrompida ao redor das regiões do header que a ocupam. Nenhum elemento `legend` é emitido.
|
|
34
|
+
|
|
35
|
+
Em um `fieldset`, o título continua sendo a `legend` nativa e pode ser precedido pelo mesmo
|
|
36
|
+
`ContentMedia` usado em uma `section`. A mídia é decorativa e acompanha o nome do grupo; contador,
|
|
37
|
+
descrição e ações continuam fora da legenda. `as` altera a semântica, não a apresentação do par
|
|
38
|
+
mídia + título nem o comportamento nativo de formulários.
|
|
39
|
+
|
|
40
|
+
O nome `legend` descreve o acabamento visual existente e não cria um terceiro valor incompatível
|
|
41
|
+
para o mesmo desenho. A documentação deve apresentar primeiro a escolha semântica e depois o
|
|
42
|
+
acabamento, deixando explícito que uma região de leitura permanece `section`.
|
|
43
|
+
|
|
44
|
+
Para interromper a linha sem depender da transparência dos wrappers intermediários, Card, Dialog e
|
|
45
|
+
Drawer declaram `--surface-background` com o token que já usam (`--background` ou `--card`). As
|
|
46
|
+
regiões do header que cruzam a borda usam essa cor; quando não houver uma superfície declarada,
|
|
47
|
+
`Content` usa `--background` como fallback. O nome segue o papel da variável, como os demais
|
|
48
|
+
contratos CSS locais, sem prefixo de pacote. Uma superfície com fundo próprio que não seja uma
|
|
49
|
+
dessas três, como um `Card` com `bg-*`, um `Popover` ou um contêiner da aplicação, declara a mesma
|
|
50
|
+
variável com o fundo que pinta.
|
|
51
|
+
|
|
52
|
+
### Relação com a ADR 0020
|
|
53
|
+
|
|
54
|
+
A ADR 0020 mantém genéricos os slots de ícone, como `FieldLegend.icon`: o consumidor passa o glifo
|
|
55
|
+
quando quer só o ícone e compõe `IconTile` quando quer a moldura. `ContentMedia` não é um slot
|
|
56
|
+
genérico; é o slot de mídia da anatomia de superfícies (ADR 0023), que já promete a moldura, como
|
|
57
|
+
`CardMedia` e `ItemMedia variant="icon"`. Por isso a mídia de `Content` sempre desenha a moldura, e
|
|
58
|
+
`asChild` continua sendo a saída para uma marca com visual próprio.
|
|
59
|
+
|
|
60
|
+
Consumidores que já compunham `IconTile` antes deste slot existir não devem ganhar moldura dentro de
|
|
61
|
+
moldura. `ContentMedia` adota um `IconTile` recebido como filho: o slot assume o lugar dele e herda
|
|
62
|
+
`size`, `context`, `variant` e as demais props declaradas nele. `media` com `null` ou `false` é
|
|
63
|
+
tratado como ausência, e nenhuma moldura vazia é desenhada. Os ícones de
|
|
64
|
+
`advancedFilters.sectionIcons` da `ActionList` passam pelo mesmo slot, sem normalização própria.
|
|
65
|
+
|
|
66
|
+
## Consequências
|
|
67
|
+
|
|
68
|
+
- Regiões de leitura podem integrar o header à moldura sem declarar semântica de formulário.
|
|
69
|
+
- A composição `Content + DetailGroup` preserva `section`, heading, mídia, descrição, contador e
|
|
70
|
+
ações.
|
|
71
|
+
- Grupos de campos continuam usando `fieldset` e `legend` quando essa relação existe no conteúdo.
|
|
72
|
+
- Filtros e regiões de leitura usam o mesmo slot, escala e alinhamento para mídia e título.
|
|
73
|
+
- O default permanece `frameVariant="default"`; quem não usa as novas props não muda visualmente.
|
|
74
|
+
- `advancedFilters.sectionIcons` muda: um glifo solto, antes exibido sem moldura, passa a ocupar o
|
|
75
|
+
`ContentMedia` e ganha a moldura padrão. Um `IconTile` continua com a aparência que declara.
|
|
76
|
+
- A `legend` de todo `Content as="fieldset"` passa a envolver título e mídia nos wrappers do
|
|
77
|
+
`SurfaceHeader` (`legend > span[data-slot=content-heading] > …`). Seletores que dependiam do
|
|
78
|
+
título como filho direto da `legend` precisam ser ajustados.
|
|
79
|
+
- O acabamento precisa funcionar sobre as superfícies canônicas sem o consumidor reconstruir a
|
|
80
|
+
borda ou posicionar o header por classes próprias.
|
|
81
|
+
- Wrappers transparentes entre a superfície e `Content` não deixam a borda aparecer atrás do grupo
|
|
82
|
+
de heading ou das ações, porque a cor vem da variável declarada pela superfície e não do pai.
|
|
83
|
+
- Superfícies com fundo próprio fora de Card, Dialog e Drawer ficam responsáveis por declarar
|
|
84
|
+
`--surface-background`; sem isso, a faixa atrás do header usa `--background`.
|
|
85
|
+
|
|
86
|
+
## Alternativas consideradas
|
|
87
|
+
|
|
88
|
+
### Usar `fieldset` também para detalhes somente de leitura
|
|
89
|
+
|
|
90
|
+
Reaproveitaria a implementação atual, mas atribuiria semântica de grupo de formulário a uma lista
|
|
91
|
+
de definições. Foi descartada porque a escolha do elemento passaria a ser governada pela aparência.
|
|
92
|
+
|
|
93
|
+
### Criar outro componente de painel
|
|
94
|
+
|
|
95
|
+
Separaria esse desenho de `Content`, mas duplicaria a anatomia de região nomeada e voltaria a exigir
|
|
96
|
+
uma decisão entre estruturas equivalentes. Foi descartada porque `Content` já é responsável pelo
|
|
97
|
+
header, corpo e moldura da região.
|
|
98
|
+
|
|
99
|
+
### Criar um novo valor visual com o mesmo desenho
|
|
100
|
+
|
|
101
|
+
Um nome como `notched` evitaria a associação com a `legend`, mas criaria dois valores para o mesmo
|
|
102
|
+
acabamento e uma migração sem ganho observável. Foi descartado porque o contrato já afirma que
|
|
103
|
+
`frameVariant` não altera a semântica.
|
|
104
|
+
|
|
105
|
+
### Herdar o background do pai imediato
|
|
106
|
+
|
|
107
|
+
Manteria a implementação restrita a `Content`, mas `background-color: inherit` recebe
|
|
108
|
+
`transparent` quando `DrawerBody`, `CardBody` ou outro wrapper não pinta o próprio fundo. Foi
|
|
109
|
+
descartado porque o acabamento depende da superfície que efetivamente pinta, não do pai estrutural
|
|
110
|
+
mais próximo.
|
|
111
|
+
|
|
112
|
+
### Redesenhar a borda em segmentos
|
|
113
|
+
|
|
114
|
+
Evitaria cobrir a linha com uma cor, mas exigiria derivar segmentos antes, entre e depois de título,
|
|
115
|
+
descrição e ações com larguras dinâmicas. Foi descartado porque duplicaria no CSS a anatomia que o
|
|
116
|
+
header já organiza.
|
|
117
|
+
|
|
118
|
+
## Verificação
|
|
119
|
+
|
|
120
|
+
- Testes de UI confirmam que `Content as="section"` com `frameVariant="legend"` mantém `section`,
|
|
121
|
+
heading e todos os slots do header.
|
|
122
|
+
- Testes confirmam que o mesmo acabamento em `fieldset` continua emitindo `legend` e restringindo
|
|
123
|
+
seus slots.
|
|
124
|
+
- Testes confirmam que o default continua usando a moldura contínua nos dois elementos.
|
|
125
|
+
- Testes confirmam que Card, Dialog e Drawer declaram `--surface-background` e que o grupo de
|
|
126
|
+
heading e as ações do header usam essa variável, com `--background` como fallback. A herança da
|
|
127
|
+
variável através de wrappers transparentes é comportamento do CSS, não é provada pela happy-dom e
|
|
128
|
+
depende de conferência visual no catálogo, onde a página de `Content` a demonstra sobre um `Card`.
|
|
129
|
+
- Testes confirmam que um `IconTile` passado como mídia é adotado pelo slot, que `null` e `false`
|
|
130
|
+
não desenham mídia e que um glifo solto em `sectionIcons` ganha a moldura.
|
|
131
|
+
- Um teste confirma que `Page layout="fill"` leva a altura até a `ActionList` através de um
|
|
132
|
+
`Content` com `frameVariant="legend"` (ADR 0024).
|
|
133
|
+
- A documentação demonstra `Content + DetailGroup` no acabamento `legend` sem usar `fieldset` e
|
|
134
|
+
mantém `Content as="fieldset" + FieldGroup` para grupos de controles.
|
|
@@ -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`.
|