@softize/opus 20.1.0 → 21.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.1.0",
3
+ "version": "21.0.1",
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