@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
@@ -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`.
package/docs/protocol.md CHANGED
@@ -98,7 +98,7 @@ type ActionBase<In, Out> = {
98
98
 
99
99
  // — Integração —
100
100
  ai?: boolean | AIConfig // default true (opt-out via false)
101
- automatable?: boolean // sistema/cron/workflow pode disparar
101
+ automatable?: boolean // schedule pode executar como o sistema (§4, ADR 0028)
102
102
  internal?: boolean // só service-to-service; não expõe REST
103
103
  successStatus?: number // HTTP code de sucesso (default 200)
104
104
  }
@@ -606,6 +606,20 @@ O Opus **não autentica** — não valida token, não emite sessão. O adapter (
606
606
 
607
607
  Se `ctx.user` é `null` e `public !== true`, runtime rejeita com `{ code: 'auth.unauthenticated', category: 'authentication' }` **antes** dos loaders e de `authorize`. Assim um chamador anônimo não dispara consultas nem descobre, pelo erro de um loader, se um registro existe.
608
608
 
609
+ ### Identidade de sistema
610
+
611
+ O disparo de um schedule executa a action **como o sistema**: `ctx.user` é `null`, `ctx.can` nega toda permissão e `ctx.provenance` é `{ kind: 'schedule', scheduleName }` (ou `background` com essa provenance original, no job que o schedule enfileirou). Só o runtime cria essa execução; `runtime.execute` não a aceita de fora, mesmo com uma provenance `schedule`. O job carrega a identidade pela marca `JobSpec.ctx.system`, gravada no enfileiramento só quando a execução é do sistema; o worker não a deduz da provenance.
612
+
613
+ O acesso do sistema é **opt-in por action** e negado por padrão. Sem usuário, o gate de autenticação aceita o sistema apenas quando a action declara `automatable: true`; nas demais, responde `{ code: 'auth.system_forbidden', category: 'authorization' }`. Uma action `public: true` continua aceitando qualquer chamador, inclusive o sistema.
614
+
615
+ `isSystemActor(ctx)` responde se o ator é o sistema. Ele consulta um registro interno dos contextos que o runtime criou, e não a provenance. Como o sistema não detém permissões, um `authorize` que delega a `ctx.can` o aceita de forma explícita, numa closure — a DSL em string não expressa isso, e `ctx.provenance` não serve como teste de sistema, porque o chamador de `runtime.execute` pode preenchê-la:
616
+
617
+ ```ts
618
+ authorize: (ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar')
619
+ ```
620
+
621
+ `requires` continua declarativo; o manifest projeta `automatable` ao lado de `permission`. O audit registra `actor: { id: null, type: 'system' }`. Ver ADR 0028.
622
+
609
623
  ---
610
624
 
611
625
  ## 5. Contrato de audit
@@ -719,6 +733,10 @@ registerAuditSink({
719
733
 
720
734
  Múltiplos sinks são permitidos. Action sem `audit.sink` emite para todos os sinks registrados; com `audit.sink: 'compliance'` emite só pro sink nomeado.
721
735
 
736
+ ### Registros de reaction
737
+
738
+ Reactions também auditam: um registro por entrega que chega ao handler, com `action` igual ao nome da reaction, `provenance.kind: 'reaction'`, `meta: { kind: 'reaction', attempts }`, sem `actionKind` e com `input: { event: { id, type, source } }` — sem o payload, que já está no audit da action emissora. Registros de reaction vão para todos os sinks. Ver §12.
739
+
722
740
  ### Falha de sink
723
741
 
724
742
  Falha ao emitir audit **não** falha a action. Sink lançou → opus loga `{ code: 'audit.sink.failed', cause: <error> }` no logger configurado e continua. Persistência de audit é responsabilidade do sink (retry, fila, etc).
@@ -1517,10 +1535,16 @@ type DomainEvent<T = unknown> = {
1517
1535
  tenant?: string | null
1518
1536
  data: T // payload (livre, handler decide)
1519
1537
  source: {
1520
- action: string // 'deal.archive'
1521
- actionId: string // bate com AuditRecord.id
1538
+ action: string // action que iniciou a cadeia ('deal.archive')
1539
+ actionId: string // bate com AuditRecord.id dessa action
1522
1540
  correlation?: string // request id / trace
1523
1541
  }
1542
+ cause?: { // só em evento publicado por reaction (§12)
1543
+ reaction: string // reaction que publicou
1544
+ eventId: string // evento que ela processava
1545
+ eventType: string
1546
+ depth: number // 1 = primeira reaction depois da action
1547
+ }
1524
1548
  trace?: TraceContext // propagado para reactions/consumidores
1525
1549
  meta?: Record<string, unknown>
1526
1550
  }
@@ -1577,10 +1601,11 @@ type ReactionDef<Event = unknown> = {
1577
1601
  // — Execução —
1578
1602
  handler: (ctx: ReactionContext, event: DomainEvent<Event>) =>
1579
1603
  Promise<void> | void
1604
+ emits?: string[] // eventos que o handler pode publicar
1580
1605
 
1581
1606
  // — Resiliência —
1582
- retry?: { attempts: number, backoff?: BackoffSpec }
1583
- timeout?: number // ms
1607
+ retry?: { attempts: number, backoff?: BackoffSpec } // attempts = total de execuções
1608
+ timeout?: number // ms por tentativa; aborta ctx.signal
1584
1609
  dedup?: (event: DomainEvent<Event>) => string // chave de idempotência
1585
1610
  concurrency?: number // máx execuções paralelas (default 1)
1586
1611
 
@@ -1628,16 +1653,21 @@ type ReactionContext = {
1628
1653
  user: User | null // ator que disparou o evento (pode ser system)
1629
1654
  tenantId: string | null
1630
1655
  db: unknown // mesmo data adapter
1631
- emit: EmitFn // descarta o evento sem publicar (sem encadeamento)
1656
+ emit: EmitFn // publica só os eventos de `emits` (encadeamento limitado)
1632
1657
  log: LoggerAdapter // logger pre-bound com contexto da reaction
1633
1658
  storage: StorageAdapter | null // storage de arquivos (experimental)
1634
1659
  provenance: Provenance // { kind: 'reaction', … }
1635
1660
  trace?: TraceContext // span filho do evento recebido
1636
1661
  meta: Record<string, unknown>
1662
+ signal?: AbortSignal // abortado quando a tentativa excede `timeout`
1637
1663
  }
1638
1664
  ```
1639
1665
 
1640
- Uma reaction não encadeia outra por evento: dentro dela, `ctx.emit` descarta o evento sem publicá-lo. Com `config.dev` ativo, cada descarte registra o aviso `runtime.reaction_emit_ignored` no log; em produção, o descarte é silencioso. Quando outro consumidor precisa desse fato, emita o evento no handler da action que o produz e declare-o em `emits`.
1666
+ ### Encadeamento
1667
+
1668
+ Uma reaction publica com `ctx.emit` somente os eventos listados no seu `emits`. Qualquer outro evento, inclusive de reaction sem `emits`, é descartado com o aviso `runtime.reaction_emit_ignored`, controlado por `config.warnUndeclaredEmits`. O evento publicado herda `actor`, `tenant` e `source` do evento recebido e ganha `cause: { reaction, eventId, eventType, depth }`.
1669
+
1670
+ Uma cadeia dá no máximo `config.maxReactionDepth` saltos (default 5). O `ctx.emit` que passaria do limite não publica e registra o erro `runtime.reaction_chain_too_deep`; com `emitMode: 'strict'`, o mesmo código interrompe a reaction. Falha de `publish` segue `emitMode`, como nas actions. Ver ADR 0027.
1641
1671
 
1642
1672
  ### Setup unificado
1643
1673
 
@@ -1663,20 +1693,20 @@ await runtime.start()
1663
1693
 
1664
1694
  ### Resiliência: quem implementa o quê
1665
1695
 
1666
- | Campo | Implementação | Sem suporte do adapter |
1667
- |---------------|------------------------------------------|------------------------------|
1668
- | `dedup` | Core — cache LRU de `event.id` por reaction (TTL configurável) | Sempre funciona |
1669
- | `retry` | Adapter preferencial (Inngest, BullMQ); core fallback simples (in-memory backoff) | Warn em setup; core fallback ativo |
1670
- | `timeout` | Core — AbortController em volta do handler | Sempre funciona |
1671
- | `concurrency` | Adapter (consumer groups); core fallback (semáforo) | Default `concurrency: 1` |
1696
+ | Campo | Implementação no core (ADR 0026) |
1697
+ |---------------|----------------------------------|
1698
+ | `dedup` | Chave por reaction, em memória: ocupada da chegada até o fim da entrega e, após sucesso, por `config.dedupCacheTtl` (default 60 s), com no máximo `config.dedupCacheMaxEntries` chaves concluídas por reaction (default 10.000; a mais antiga sai primeiro). Falha definitiva libera a chave. Duplicata vira `debug` `runtime.reaction_duplicate_skipped`. |
1699
+ | `retry` | Em memória. Sem `retry`, uma execução (`config.defaultRetry` vale só para background). Exceções comuns e timeout repetem. `ActionError` segue `retriable`, cujo padrão vem da categoria: só `rate_limit` e `dependency` repetem (`internal` não); `error({ ..., retriable: true })` força a repetição. Cada repetição avisa `runtime.reaction_retry`. |
1700
+ | `timeout` | `AbortController` por tentativa: aborta `ctx.signal` e falha com `runtime.reaction_timeout` (retriable). A vaga é liberada no timeout. |
1701
+ | `concurrency` | Semáforo por reaction (default 1) e global (`config.maxConcurrentReactions`, default 10), em ordem de chegada. A espera entre tentativas não ocupa vaga. |
1672
1702
 
1673
- Reaction declara intenção; runtime aplica o que core garante + delega o que adapter suporta. Se adapter não cobre algo declarado, **warning visível no boot** (não silencia falha de capability).
1703
+ As políticas valem por processo. Delegar retry e concorrência a um adapter durável (consumer groups, filas) fica para quando existir driver distribuído. Valores inválidos falham no registro com `runtime.invalid_reaction_policy` ou na criação do runtime com `runtime.invalid_config`.
1674
1704
 
1675
1705
  ### Falha de reaction não afeta o emissor
1676
1706
 
1677
1707
  Reaction roda **assíncrono** ao handler que emitiu o evento. Se reaction falha, é problema dela (retry / dead-letter via adapter); a action que emitiu o evento já completou.
1678
1708
 
1679
- Falhas registradas no log do runtime + audit (entrada do tipo `'reaction.failed'`). Adapter de eventos pode rotear pra dead-letter (v1).
1709
+ Falhas definitivas são registradas no log do runtime (`reaction failed`, com o número de tentativas) e no audit (registro com `outcome: 'error'`). Adapter de eventos pode rotear pra dead-letter (v1).
1680
1710
 
1681
1711
  ### Pipeline de uma reaction
1682
1712
 
@@ -1684,14 +1714,14 @@ Falhas registradas no log do runtime + audit (entrada do tipo `'reaction.failed'
1684
1714
  EventBusAdapter recebe evento (publish externo ou ctx.emit interno)
1685
1715
  ↓
1686
1716
  matching: encontra reactions registradas pra esse padrão
1687
- ↓ (paralelo, com concurrency limit)
1688
1717
  para cada reaction:
1689
- ↓ (a) dedup check (se declarado)
1690
- ↓ (b) authorize (se declarado)
1718
+ ↓ (a) dedup: reserva a chave na chegada (se declarado)
1719
+ ↓ espera vaga da reaction e vaga global (concurrency)
1720
+ ↓ (b) authorize, uma vez (se declarado)
1691
1721
  ↓ (c) handler executa (com timeout)
1692
- ↓ (d) ctx.emit dentro da reaction não publica (descartado, com aviso em dev)
1693
- ↓ (e) audit (reaction completed/failed)
1694
- ↓ (f) retry se falhou e config permite
1722
+ ↓ (d) ctx.emit publica os eventos declarados em `emits`, até a profundidade máxima
1723
+ ↓ (e) retry se falhou e a política permite (libera as vagas durante a espera)
1724
+ ↓ (f) audit, um registro por entrega (completed/failed, com as tentativas)
1695
1725
  ```
1696
1726
 
1697
1727
  ### O que **não** entra em v0
@@ -1758,6 +1788,7 @@ type RuntimeConfig = {
1758
1788
 
1759
1789
  // — Resiliência / dedup —
1760
1790
  dedupCacheTtl?: number // ms, default 60_000
1791
+ dedupCacheMaxEntries?: number // chaves concluídas por reaction, default 10_000
1761
1792
  defaultRetry?: { attempts: number, backoff?: BackoffSpec }
1762
1793
 
1763
1794
  // — i18n —
@@ -1768,7 +1799,8 @@ type RuntimeConfig = {
1768
1799
 
1769
1800
  // — Limites —
1770
1801
  maxConcurrentActions?: number // default Infinity
1771
- maxConcurrentReactions?: number // default 10
1802
+ maxConcurrentReactions?: number // default 10, somando todas as reactions
1803
+ maxReactionDepth?: number // default 5 saltos de reaction por cadeia
1772
1804
  }
1773
1805
  ```
1774
1806
 
@@ -1831,9 +1863,11 @@ Se você não passa `config`, o runtime usa:
1831
1863
  validateOutputInDev: true,
1832
1864
  warnUndeclaredEmits: true,
1833
1865
  dedupCacheTtl: 60_000,
1866
+ dedupCacheMaxEntries: 10_000,
1834
1867
  defaultRetry: { attempts: 3, backoff: { kind: 'exponential', initialMs: 1000 } },
1835
1868
  maxConcurrentActions: Number.POSITIVE_INFINITY,
1836
1869
  maxConcurrentReactions: 10,
1870
+ maxReactionDepth: 5,
1837
1871
  }
1838
1872
  ```
1839
1873
 
@@ -2134,12 +2168,14 @@ chamada, inclusive por IA e MCP.
2134
2168
  - **`QueueAdapter`** — adapter que executa background jobs. Ver §8/§10.
2135
2169
  - **`ctx.emit`** — função disponível no contexto do handler para emitir eventos declarados em `emits`. Ver §11.
2136
2170
  - **`requires`** — permissão declarativa (string ou array) usada por adapters de docs/OpenAPI. Distinta de `authorize` (lógica runtime). Ver §4.
2137
- - **`automatable`** — flag que indica se a action pode ser disparada por sistema (cron, workflow, trigger). Distinta de `ai.enabled`. Ver §2.
2171
+ - **`automatable`** — opt-in que permite a um schedule executar a action como o sistema, sem usuário. Negado por padrão; não abre a action a anônimos. Distinta de `ai.enabled`. Ver §4.
2172
+ - **`isSystemActor(ctx)`** — responde se a execução foi criada pelo runtime para o sistema (schedule ou job que ele enfileirou). Usado no `authorize` de actions `automatable`. Ver §4.
2138
2173
  - **`internal`** — flag que indica action service-to-service apenas; server adapter não expõe como REST público. Ver §2.
2139
2174
  - **Reaction** — declaração que escuta evento(s) e executa handler. Auto-registrada pelo runtime via EventBusAdapter. Pareada com Action (ator inicia × sistema reage). Ver §12.
2140
2175
  - **`defineReaction`** — função que constrói uma reaction declarativa.
2141
2176
  - **`ReactionContext`** — contexto entregue ao handler de reaction (user, tenant, db, emit, log, meta).
2142
- - **`dedup`** — função em reaction que produz chave de idempotência; runtime descarta evento já processado. Ver §12.
2177
+ - **`dedup`** — função em reaction que produz chave de idempotência; runtime descarta evento com chave em processamento ou processada dentro de `dedupCacheTtl`. Ver §12.
2178
+ - **`cause`** — campo de `DomainEvent` publicado por reaction: reaction, evento de origem e profundidade na cadeia. Ver §11/§12.
2143
2179
  - **`LoggerAdapter`** — interface de logger plugável (levels, structured, child binding). Default: `@softize/opus/log-console`. Ver §8.
2144
2180
  - **`RuntimeConfig`** — behavior knobs do runtime (auditMode, emitMode, env, i18n, limits). Recebe valores literais; core nunca lê env direto. Ver §14.
2145
2181
  - **`healthCheck()`** — método opcional em qualquer adapter, retorna `{ ok, details? }`. Agregado por `runtime.healthCheck()` e exposto em `/ready`. Ver §8.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "20.0.0",
3
+ "version": "21.0.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/core/audit.ts CHANGED
@@ -61,7 +61,8 @@ export class AuditEmitter {
61
61
  if (targetSinks.length === 0) return
62
62
 
63
63
  const results = await Promise.allSettled(
64
- targetSinks.map((sink) => Promise.resolve(sink.emit(processed))),
64
+ // `then` também captura o sink que lança de forma síncrona: toda falha vira rejeição tratada.
65
+ targetSinks.map((sink) => Promise.resolve().then(() => sink.emit(processed))),
65
66
  )
66
67
 
67
68
  const failures = results.filter(
package/src/core/index.ts CHANGED
@@ -216,7 +216,7 @@ export type {
216
216
  export { AuditEmitter } from './audit.ts'
217
217
 
218
218
  // — Runtime ———————————————————————————————————————————————————————————————————
219
- export { Runtime, createRuntime } from './runtime.ts'
219
+ export { Runtime, createRuntime, isSystemActor } from './runtime.ts'
220
220
  export type {
221
221
  RuntimeSetup,
222
222
  RuntimeSetupConfig,
@@ -27,7 +27,7 @@ export function defineReaction<Event = unknown>(
27
27
  // =============================================================================
28
28
 
29
29
  /** Campos server-only que o `bindReaction` fornece — o resto é a DECLARAÇÃO (isomorfa). */
30
- type ReactionBindingKeys = 'handler' | 'retry' | 'timeout' | 'dedup' | 'concurrency'
30
+ type ReactionBindingKeys = 'handler' | 'emits' | 'retry' | 'timeout' | 'dedup' | 'concurrency'
31
31
 
32
32
  /**
33
33
  * A DECLARAÇÃO de uma reação, autorável na sessão de design (isomorfa, SEM efeito): qual
@@ -36,9 +36,13 @@ type ReactionBindingKeys = 'handler' | 'retry' | 'timeout' | 'dedup' | 'concurre
36
36
  */
37
37
  export type ReactionContract<Event = unknown> = Omit<ReactionDef<Event>, ReactionBindingKeys>
38
38
 
39
- /** Parte server-only amarrada por `bindReaction`: o handler + a config de entrega. */
39
+ /**
40
+ * Parte server-only amarrada por `bindReaction`: o handler, os eventos que ele publica e a
41
+ * política de entrega. Como no `bindAction`, `emits` fica no binding, ao lado do handler.
42
+ */
40
43
  export interface ReactionBinding<Event = unknown> {
41
44
  handler: ReactionHandlerFn<Event>
45
+ emits?: string[]
42
46
  retry?: { attempts: number; backoff?: BackoffSpec }
43
47
  timeout?: number
44
48
  dedup?: (event: DomainEvent<Event>) => string