@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/bin/lib/gen-manifest.mjs +1 -0
  3. package/bin/lib/gen-runner.mjs +3 -0
  4. package/bin/lib/introspect.mjs +5 -4
  5. package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +8 -0
  6. package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
  7. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +14 -1
  8. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
  9. package/docs/adr/0026-reactions-apply-declared-delivery-policy.md +123 -0
  10. package/docs/adr/0027-reactions-publish-declared-events-with-bounded-chains.md +96 -0
  11. package/docs/adr/0028-schedules-run-actions-as-the-system-by-action-opt-in.md +127 -0
  12. package/docs/protocol.md +60 -24
  13. package/package.json +1 -1
  14. package/src/core/audit.ts +2 -1
  15. package/src/core/index.ts +1 -1
  16. package/src/core/reactions.ts +6 -2
  17. package/src/core/runtime.ts +598 -70
  18. package/src/core/schedules.ts +3 -2
  19. package/src/core/types.ts +52 -7
  20. package/src/testing/index.ts +25 -14
  21. package/src/ui/components/patterns/content-header.tsx +94 -15
  22. package/src/ui/components/patterns/list.tsx +2 -14
  23. package/src/ui/components/patterns/surface-header.tsx +58 -25
  24. package/src/ui/components/primitives/card.tsx +1 -1
  25. package/src/ui/components/primitives/dialog.tsx +1 -1
  26. package/src/ui/components/primitives/drawer.tsx +1 -1
  27. package/src/ui/docs/content/action-list.md +7 -6
  28. package/src/ui/docs/content/actions.md +2 -1
  29. package/src/ui/docs/content/audit.md +10 -0
  30. package/src/ui/docs/content/auth.md +5 -1
  31. package/src/ui/docs/content/content.md +88 -20
  32. package/src/ui/docs/content/events.md +109 -11
  33. package/src/ui/docs/content/icon-tile.md +11 -11
  34. package/src/ui/docs/content/runtime.md +11 -2
  35. package/src/ui/docs/content/scheduler.md +53 -12
  36. package/src/ui/docs/content/testing.md +14 -2
  37. package/src/ui/meta.ts +1 -1
  38. 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
@@ -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
  }
@@ -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 | não | não | sim (`DialogMedia`) | sim | 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`.