@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
@@ -117,7 +117,7 @@ function DrawerContent({
117
117
  data-slot="drawer-content"
118
118
  data-layout={layout}
119
119
  className={cn(
120
- "fixed z-50 flex flex-col bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
120
+ "fixed z-50 flex flex-col bg-background shadow-lg transition ease-in-out [--surface-background:var(--background)] data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
121
121
  side === "right" &&
122
122
  "inset-y-0 right-0 h-full w-3/4 max-w-sm border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
123
123
  side === "left" &&
@@ -96,10 +96,11 @@ apresentação para o consumidor, como cards de status acima da lista. No modal,
96
96
  `section` formam um grupo, e `advancedFilters.columns` distribui os grupos em até três colunas.
97
97
  Use `advancedFilters.sectionIcons` quando os grupos representarem dimensões recorrentes; a chave é
98
98
  o mesmo rótulo de `section`, e cada grupo usa `Content as="fieldset"` para manter uma legenda
99
- nativa. Quando os grupos precisarem de limites mais explícitos,
100
- `advancedFilters.sectionVariant: 'framed'` envolve cada seção e pede ao `Content` o acabamento
101
- `frameVariant="legend"`, integrando visualmente a legenda à borda sem atribuir esse design à
102
- semântica de `fieldset`.
99
+ nativa. O ícone ocupa a mídia do grupo, antes do título, e recebe a moldura padrão: passe só o
100
+ glifo. Um `IconTile` é adotado pela mídia com o que declara, e um ícone `null` não desenha nada.
101
+ Quando os grupos precisarem de limites mais explícitos, `advancedFilters.sectionVariant: 'framed'`
102
+ envolve cada seção e pede ao `Content` o acabamento `frameVariant="legend"`, integrando visualmente
103
+ a legenda à borda sem atribuir esse design à semântica de `fieldset`.
103
104
  Um filtro `select` pode declarar `searchable: true` quando a lista fechada for longa; a opção geral
104
105
  continua disponível e participa da busca. Campos de data avançados usam calendário. A antiga `advanced: true` continua aceita durante a
105
106
  migração e equivale a `placement: 'advanced'`.
@@ -138,8 +139,8 @@ filters: {
138
139
  columns: 2,
139
140
  sectionVariant: 'framed',
140
141
  sectionIcons: {
141
- Relacionamento: <IconTile size="xs"><UserRound /></IconTile>,
142
- Datas: <IconTile size="xs"><CalendarDays /></IconTile>,
142
+ Relacionamento: <UserRound />,
143
+ Datas: <CalendarDays />,
143
144
  },
144
145
  }}
145
146
  />
@@ -147,7 +147,8 @@ validações: `z.object({ tag: t.slug().zod(), contato: t.email().zod() })`. O `
147
147
  schema zod de cada tipo lógico (slug, email, phone, money…), importado de `@softize/opus/schema/zod`.
148
148
 
149
149
  A autorização é fechada por padrão: sem `public: true`, a action exige usuário autenticado; com
150
- `authorize`, exige também a permissão declarada.
150
+ `authorize`, exige também a permissão declarada. `automatable: true` permite que um
151
+ [schedule](/docs/scheduler) execute a action como o sistema, sem abri-la a chamadores anônimos.
151
152
 
152
153
  ## Prototipar com dados simulados
153
154
 
@@ -99,6 +99,16 @@ O que cada action captura se ajusta no contrato: `audit: false` desliga a audito
99
99
  `audit: { redact: ['senha'], output: false }` refina o registro. Por padrão, o registro inclui
100
100
  input, output e provenance.
101
101
 
102
+ O `actor` diz quem executou. Uma pessoa aparece com `type: 'user'` e o próprio `id`; uma execução
103
+ iniciada pelo sistema, como o disparo de um [schedule](/docs/scheduler), aparece com
104
+ `{ id: null, type: 'system' }` e o nome do schedule na provenance.
105
+
106
+ Reactions também geram registros, um por evento que chega ao handler. Eles têm `provenance.kind`
107
+ igual a `reaction`, o nome da reaction em `action`, `meta: { kind: 'reaction', attempts }` e nenhum
108
+ `actionKind`. O `input` traz só a identidade do evento (`id`, `type` e `source`), sem o payload, que
109
+ já está no registro da action que emitiu. Um sink que filtra leituras por `actionKind` deve tratar
110
+ esses registros à parte. Veja [Eventos](/docs/events).
111
+
102
112
  ## Limites atuais
103
113
 
104
114
  Os sinks só acrescentam registros: consulta e retenção ficam com o seu banco. Os drivers disponíveis
@@ -73,7 +73,11 @@ export const notaEmitir = bindAction(notaEmitirContract, {
73
73
  Antes do handler, o runtime verifica a chamada nesta ordem:
74
74
 
75
75
  1. Valida a entrada pelo schema do contrato.
76
- 2. Sem `public: true`, exige `ctx.user`; sem usuário, responde `auth.unauthenticated`.
76
+ 2. Sem `public: true`, exige `ctx.user`; sem usuário, responde `auth.unauthenticated`. A exceção é
77
+ o disparo de um schedule, que executa como o sistema uma action com `automatable: true` e recebe
78
+ `auth.system_forbidden` nas demais (veja [Agendador](/docs/scheduler)). Para aceitar o sistema no
79
+ `authorize`, use uma função com `isSystemActor(ctx)`: a expressão em string não o reconhece, e
80
+ `ctx.provenance` não prova que a chamada veio de um schedule.
77
81
  3. Executa os `loads`, que carregam os dados usados na autorização.
78
82
  4. Avalia `authorize`, se houver: `false` vira `auth.forbidden`, e um `ActionError` devolvido é
79
83
  lançado como está.
@@ -31,21 +31,15 @@ render(
31
31
 
32
32
  Use `framed` quando título e corpo precisarem formar uma única região delimitada. A moldura pertence
33
33
  ao `Content`; componentes internos, como `DetailGroup`, continuam responsáveis somente pelo layout
34
- e pela semântica dos próprios dados.
34
+ e pela semântica dos próprios dados. Por padrão, a borda permanece contínua e o header fica dentro
35
+ do recuo da região.
35
36
 
36
37
  ```tsx preview col
37
38
  render(
38
39
  <Content
39
40
  framed
40
- title={
41
- <span className="flex items-center gap-2">
42
- <UserRound
43
- className="size-4 text-muted-foreground"
44
- aria-hidden="true"
45
- />
46
- Cliente
47
- </span>
48
- }
41
+ title="Cliente"
42
+ media={<UserRound aria-hidden="true" />}
49
43
  >
50
44
  <DetailGroup columns={2}>
51
45
  <DetailField label="Nome" value="Carla Souza" />
@@ -55,16 +49,66 @@ render(
55
49
  );
56
50
  ```
57
51
 
52
+ ## Header integrado à moldura
53
+
54
+ Quando uma região compacta precisar integrar visualmente o header à borda, use
55
+ `frameVariant="legend"`. O acabamento não muda a semântica: uma região de leitura continua sendo
56
+ uma `section`, o título continua sendo um heading e descrição, contador e ações permanecem
57
+ disponíveis.
58
+
59
+ ```tsx preview col
60
+ render(
61
+ <Content
62
+ framed
63
+ frameVariant="legend"
64
+ title="Veículo"
65
+ media={<CarFront aria-hidden="true" />}
66
+ >
67
+ <DetailGroup columns={2}>
68
+ <DetailField label="Placa" value="ABC1D23" />
69
+ <DetailField label="Modelo" value="Corolla XEi" />
70
+ </DetailGroup>
71
+ </Content>,
72
+ );
73
+ ```
74
+
75
+ Para interromper a borda, o header pinta o título e as ações com o fundo da superfície que hospeda o
76
+ `Content`, lido da variável CSS `--surface-background`. `Card`, `Dialog` e `Drawer` já a declaram
77
+ com o fundo que pintam; fora deles, vale `--background`. Uma superfície com fundo próprio, como um
78
+ `Card` com `bg-*`, um `Popover` ou um contêiner da aplicação, precisa declarar a variável com o
79
+ mesmo fundo. Sem isso, a faixa atrás do título aparece com a cor da página.
80
+
81
+ ```tsx preview col
82
+ render(
83
+ <Card className="bg-muted [--surface-background:var(--muted)]">
84
+ <CardBody>
85
+ <Content framed frameVariant="legend" title="Veículo">
86
+ <DetailGroup columns={2}>
87
+ <DetailField label="Placa" value="ABC1D23" />
88
+ <DetailField label="Modelo" value="Corolla XEi" />
89
+ </DetailGroup>
90
+ </Content>
91
+ </CardBody>
92
+ </Card>,
93
+ );
94
+ ```
95
+
58
96
  ## Grupo de campos
59
97
 
60
98
  Quando a região nomear um conjunto de campos relacionados, use `as="fieldset"`. O `Content`
61
99
  renderiza o título como `legend`, preserva as props nativas do `fieldset` e mantém o mesmo contrato
62
100
  de título e corpo. Essa escolha altera somente a semântica e o comportamento nativo; com `framed`,
63
- a moldura permanece contínua. Nesse modo, contador, descrição e ações não entram na legenda.
101
+ a moldura permanece contínua. A mídia usa a mesma apresentação da `section`; contador, descrição e
102
+ ações não entram na legenda.
64
103
 
65
104
  ```tsx preview col
66
105
  render(
67
- <Content as="fieldset" framed title="Proprietário">
106
+ <Content
107
+ as="fieldset"
108
+ framed
109
+ title="Proprietário"
110
+ media={<UserRound aria-hidden="true" />}
111
+ >
68
112
  <FieldGroup>
69
113
  <Field>
70
114
  <FieldLabel htmlFor="owner-name">Nome</FieldLabel>
@@ -79,8 +123,9 @@ render(
79
123
  );
80
124
  ```
81
125
 
82
- Quando a legenda precisar integrar visualmente a borda, declare essa escolha separadamente. Use
83
- `frameVariant="legend"` em agrupamentos compactos, como as seções de filtros avançados.
126
+ O mesmo acabamento pode integrar a legenda nativa à borda. Declare `frameVariant="legend"` em
127
+ agrupamentos compactos, como as seções de filtros avançados; `as="fieldset"` continua sendo a
128
+ decisão semântica, e não uma forma de obter esse desenho.
84
129
 
85
130
  ```tsx preview col
86
131
  render(
@@ -104,13 +149,16 @@ render(
104
149
 
105
150
  Use os slots quando o header precisar de composição própria. A árvore aceita exatamente um
106
151
  `ContentHeader` e um `ContentBody` como filhos diretos. O header exige um `ContentTitle` e aceita
107
- uma descrição, um contador (`ContentMeta`) e uma região de ações. O contador é próprio de
108
- `Content`; `PageHeader` reconhece somente navegação, título e ações.
152
+ uma mídia (`ContentMedia`), uma descrição, um contador (`ContentMeta`) e uma região de ações. O
153
+ contador é próprio de `Content`; `PageHeader` reconhece somente navegação, título e ações.
109
154
 
110
155
  ```tsx preview col
111
156
  render(
112
157
  <Content level={2}>
113
158
  <ContentHeader>
159
+ <ContentMedia>
160
+ <Monitor aria-hidden="true" />
161
+ </ContentMedia>
114
162
  <ContentTitle>Dispositivos conectados</ContentTitle>
115
163
  <ContentMeta>3</ContentMeta>
116
164
  <ContentDescription>Sessões com acesso à sua conta.</ContentDescription>
@@ -166,6 +214,7 @@ classes adicionais; um `Content` irmão, sem lista, mantém a altura do próprio
166
214
  | Propriedade | Tipo | Padrão | Descrição |
167
215
  | -------------- | ---------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- |
168
216
  | `title` | `ReactNode` | | Forma curta: título da região (vira o heading ligado à `section`). |
217
+ | `media` | `ReactNode` | | Forma curta: ícone ou imagem que identifica a região antes do título. |
169
218
  | `count` | `number` | | Forma curta: total de itens ao lado do título da região. |
170
219
  | `description` | `ReactNode` | | Forma curta: frase de apoio sob o título. |
171
220
  | `actions` | `ReactNode` | | Forma curta: ações sobre a região inteira, no extremo oposto ao título. |
@@ -173,11 +222,30 @@ classes adicionais; um `Content` irmão, sem lista, mantém a altura do próprio
173
222
  | `variant` | `'page' \| 'section'` | `'section'` | Hierarquia visual: `page` reproduz o cabeçalho de `Page`; `section` é a região dentro de uma superfície. |
174
223
  | `as` | `'section' \| 'fieldset'` | `'section'` | Usa uma região com heading ou um grupo de campos cujo título é a legenda nativa. |
175
224
  | `framed` | `boolean` | `false` | Envolve o header e o body com a moldura estrutural da região. |
176
- | `frameVariant` | `'default' \| 'legend'` | `'default'` | Em `fieldset` com moldura, mantém a borda contínua ou integra visualmente a legenda à borda. Não altera a semântica. |
225
+ | `frameVariant` | `'default' \| 'legend'` | `'default'` | Mantém a borda contínua ou integra visualmente o header à moldura. Não altera o elemento nem sua semântica. |
177
226
  | `children` | `ReactNode` | | Corpo da região na forma curta, ou `ContentHeader` e `ContentBody` na composição explícita. |
178
227
 
179
228
  As demais propriedades são as nativas de `section` ou, com `as="fieldset"`, de `fieldset`.
180
229
 
181
- Na composição explícita, `ContentHeader` recebe `ContentTitle`, `ContentMeta`, `ContentDescription` e
182
- `ContentActions`, e `ContentBody` recebe o conteúdo; nenhum desses slots aceita `title` ou `level`
183
- próprios — a hierarquia é declarada em `Content`. `ContentHeader` não existe fora de `Content`.
230
+ Na composição explícita de uma `section`, `ContentHeader` recebe `ContentMedia`, `ContentTitle`,
231
+ `ContentMeta`, `ContentDescription` e `ContentActions`. Em `fieldset`, recebe `ContentMedia` e
232
+ `ContentTitle`, preservando a apresentação enquanto a legenda continua nomeando somente o grupo de
233
+ campos. `ContentBody` recebe o conteúdo; nenhum desses slots aceita `title` ou `level` próprios — a
234
+ hierarquia é declarada em `Content`. `ContentHeader` não existe fora de `Content`.
235
+
236
+ ## Propriedades de ContentMedia
237
+
238
+ | Propriedade | Tipo | Padrão | Descrição |
239
+ | ----------- | ------------------------------------------------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------- |
240
+ | `size` | `'xs' \| 'sm' \| 'default' \| 'lg'` | `'xs'` | Tamanho da moldura e do glifo na escala compartilhada dos controles. |
241
+ | `context` | `'neutral' \| 'primary' \| 'info' \| 'success' \| 'warning' \| 'danger'` | `'neutral'` | Declara o significado da cor. |
242
+ | `variant` | `'subtle' \| 'outline'` | `'subtle'` | Usa preenchimento tonal ou acrescenta borda. |
243
+ | `className` | `string` | | Ajusta a moldura sem substituir a escala. |
244
+ | `asChild` | `boolean` | `false` | Cede o elemento para uma marca com visual próprio — um orbe, um logotipo — que ocupa o slot sem ganhar a moldura por cima. |
245
+
246
+ `ContentMedia` já é a moldura. Passe só o glifo e escolha `size`, `context` e `variant` no slot. Um
247
+ `IconTile` passado em `media` ou dentro de `ContentMedia` não vira moldura dentro de moldura: o slot
248
+ assume o lugar dele e herda o que ele declara. `media` com `null` ou `false` não desenha nada.
249
+
250
+ Em `fieldset`, a mídia é sempre decorativa: o `Content` a esconde da árvore de acessibilidade para
251
+ que o grupo seja anunciado apenas pelo título.
@@ -23,7 +23,8 @@ interface DomainEvent<T = unknown> {
23
23
  actor: { id: string | null; type?: 'user' | 'system' | 'integration' }
24
24
  tenant?: string | null
25
25
  data: T
26
- source: { action: string; actionId: string; correlation?: string }
26
+ source: { action: string; actionId: string; correlation?: string } // action que iniciou a cadeia
27
+ cause?: { reaction: string; eventId: string; eventType: string; depth: number } // só se publicado por reaction
27
28
  trace?: TraceContext // trace da execução que emitiu
28
29
  meta?: Record<string, unknown> // o terceiro argumento de ctx.emit
29
30
  }
@@ -105,17 +106,114 @@ com `runtime.eventbus_required` quando ele não existe. `defineReaction` declara
105
106
  quando não há separação entre cliente e servidor.
106
107
 
107
108
  A reaction roda com provenance `reaction`; `ctx.user` traz somente o `id` do autor do evento, e
108
- `ctx.tenantId`, o tenant do evento. O contexto não tem `can`: a decisão fica no `authorize(ctx, event)` opcional, e uma resposta `false` descarta o evento
109
- sem erro. Uma falha no handler é registrada no log e não chega à action que emitiu.
109
+ `ctx.tenantId`, o tenant do evento. O contexto não tem `can`: a decisão fica no
110
+ `authorize(ctx, event)` opcional, e uma resposta `false` descarta o evento sem erro. Uma falha na
111
+ reaction fica no log e no audit e não chega à action que emitiu.
110
112
 
111
- ## Limites atuais
113
+ ## Proteger a entrega
114
+
115
+ Uma reaction costuma falar com algo que falha ou demora: uma API externa, uma fila, outro banco. A
116
+ política de entrega, declarada no binding, diz como o runtime lida com isso:
117
+
118
+ ```ts
119
+ export const leadSincronizarImpl = bindReaction(leadSincronizar, {
120
+ retry: { attempts: 3, backoff: { kind: 'exponential', initialMs: 1_000, maxMs: 30_000 } },
121
+ timeout: 10_000,
122
+ dedup: (event) => event.data.leadId,
123
+ concurrency: 2,
124
+ handler: async (ctx, event) => {
125
+ await crm.upsertLead(event.data.leadId, { signal: ctx.signal })
126
+ },
127
+ })
128
+ ```
129
+
130
+ O runtime aplica cada campo a cada evento entregue, nesta ordem:
131
+
132
+ - **`dedup`** calcula uma chave para o evento. Outro evento com a mesma chave é descartado enquanto
133
+ o primeiro está na fila ou em andamento e durante `config.dedupCacheTtl` depois de ele concluir
134
+ (padrão de 60 segundos). Se a entrega falhar em definitivo, a chave é liberada, e o evento pode ser
135
+ entregue de novo. Cada reaction guarda no máximo `config.dedupCacheMaxEntries` chaves concluídas
136
+ (padrão 10.000); acima disso, a mais antiga sai antes do TTL, e um evento com ela volta a ser
137
+ aceito.
138
+ - **`concurrency`** limita quantos eventos da reaction rodam ao mesmo tempo. O padrão é 1: sem
139
+ declarar, a reaction processa um evento por vez, na ordem de chegada. Acima disso,
140
+ `config.maxConcurrentReactions` (padrão 10) limita a soma de todas as reactions do processo. Com o
141
+ padrão, um handler sem `timeout` que trava — uma chamada de rede sem resposta, por exemplo —
142
+ segura a única vaga, e a reaction inteira para de processar eventos até o processo reiniciar.
143
+ Declare `timeout` em toda reaction que depende de algo externo.
144
+ - **`authorize`**, quando declarado, roda uma vez, antes da primeira tentativa.
145
+ - **`timeout`** é o prazo de cada tentativa, em milissegundos. Ao vencer, o runtime aborta
146
+ `ctx.signal` e encerra a tentativa com `runtime.reaction_timeout`. Passe o sinal adiante, como no
147
+ exemplo: um handler que o ignora continua executando em segundo plano, fora do limite de
148
+ concorrência.
149
+ - **`retry`** repete o handler que falhou. `attempts` é o total de execuções, contando a primeira.
150
+ `fixed` espera `delayMs` entre elas; `exponential` começa em `initialMs`, multiplica por
151
+ `multiplier` (padrão 2) e para em `maxMs`. A espera entre tentativas não ocupa vaga de
152
+ concorrência.
153
+
154
+ Uma exceção comum, como `throw new Error(...)`, e o `runtime.reaction_timeout` são sempre repetidos.
155
+ Um `ActionError`, criado com `error()`, segue o próprio `retriable`, que por padrão vem da categoria:
156
+ só `rate_limit` e `dependency` repetem; `validation`, `authentication`, `authorization`,
157
+ `not_found`, `conflict` e `internal` encerram as tentativas. Para repetir um erro de outra categoria,
158
+ declare-o ao criá-lo:
159
+
160
+ ```ts
161
+ throw error({ code: 'crm.lead.locked', category: 'conflict', retriable: true })
162
+ ```
163
+
164
+ Sem política declarada, o handler roda uma vez e um evento por vez. `config.defaultRetry` vale só
165
+ para jobs em background, não para reactions. Com `retry`, o handler precisa tolerar a repetição: o
166
+ `dedup` evita processar o mesmo evento duas vezes, mas não desfaz o que uma tentativa anterior
167
+ já gravou. Declare também `timeout`: com a concorrência padrão, é ele que impede um handler travado
168
+ de parar a reaction inteira.
169
+
170
+ Cada tentativa que falha e será repetida gera o aviso `runtime.reaction_retry` no log, e a falha
171
+ definitiva, a linha `reaction failed` com o número de tentativas. Um evento descartado pelo `dedup`
172
+ gera uma linha `debug` com `runtime.reaction_duplicate_skipped`. Valores fora do domínio, como
173
+ `concurrency: 0`, interrompem o registro com `runtime.invalid_reaction_policy`.
112
174
 
113
- O driver `mitt` funciona no mesmo processo, sem durabilidade nem entrega entre processos.
175
+ Cada entrega que chega ao handler gera um registro de [auditoria](/docs/audit): `action` traz o
176
+ nome da reaction, `meta` traz `{ kind: 'reaction', attempts }` e `input` traz só a identidade do
177
+ evento (`id`, `type` e `source`). O payload fica no registro da action que emitiu, com a redação
178
+ dela. Um evento recusado pelo `authorize` ou descartado pelo `dedup` não gera registro.
179
+
180
+ ## Encadear reactions
181
+
182
+ Quando o efeito de uma reaction é um fato que outra parte do sistema precisa saber, ela o publica
183
+ com `ctx.emit`, como uma action. A reaction declara no binding os eventos que publica:
184
+
185
+ ```ts
186
+ export const leadSincronizarImpl = bindReaction(leadSincronizar, {
187
+ emits: ['lead.sincronizado'],
188
+ handler: async (ctx, event) => {
189
+ const externo = await crm.upsertLead(event.data.leadId)
190
+ await ctx.emit('lead.sincronizado', { leadId: event.data.leadId, externoId: externo.id })
191
+ },
192
+ })
193
+ ```
194
+
195
+ Diferente de uma action, uma reaction só publica o que está em `emits`. Um evento fora da lista,
196
+ inclusive de uma reaction sem `emits`, é descartado, e o runtime registra o aviso
197
+ `runtime.reaction_emit_ignored` com a reaction, o evento e a lista declarada. O aviso vem de
198
+ `config.warnUndeclaredEmits`, ativo por padrão fora de produção.
199
+
200
+ O evento publicado mantém `actor`, `tenant` e `source` do evento que a reaction processava: `source`
201
+ continua apontando para a action que iniciou a cadeia. O campo `cause` registra a reaction que
202
+ publicou, o evento de origem e a profundidade na cadeia, que começa em 1. Evento emitido por action
203
+ não tem `cause`.
204
+
205
+ Uma cadeia pode dar até `config.maxReactionDepth` saltos de reaction (padrão 5). O limite impede que
206
+ um laço, como uma reaction que publica um evento que volta a acioná-la, consuma o processo. O
207
+ `ctx.emit` que passaria do limite não publica e registra o erro `runtime.reaction_chain_too_deep`
208
+ com a cadeia observada. Com `emitMode: 'strict'`, o mesmo código também interrompe a reaction.
209
+
210
+ O manifest projeta o `emits` de cada reaction, e o [`opus introspect`](/docs/cli) liga esse `emits`
211
+ às reactions que escutam o evento.
212
+
213
+ ## Limites atuais
114
214
 
115
- Uma reaction não encadeia outra por evento: dentro dela, `ctx.emit` descarta o evento sem publicá-lo.
116
- Com `config.dev` ativo, cada descarte registra o aviso `runtime.reaction_emit_ignored` no log, com o
117
- nome da reaction e do evento; em produção, o descarte continua silencioso. Quando outro consumidor
118
- precisa desse fato, emita o evento no handler da action que o produz e declare-o em `emits`.
215
+ O driver `mitt` funciona no mesmo processo, sem durabilidade nem entrega entre processos. As
216
+ políticas de entrega também valem por processo, em memória: com vários processos consumindo o mesmo
217
+ barramento, cada um mantém o próprio `dedup` e os próprios limites de concorrência, e um evento que
218
+ espera vaga se perde se o processo cair.
119
219
 
120
- `retry`, `timeout`, `dedup` e `concurrency` são aceitos na declaração, mas o runtime ainda não os
121
- aplica: uma reaction que falha não é repetida.
@@ -13,24 +13,24 @@ com tamanho de ícone e um nome acessível.
13
13
  O tamanho controla a caixa e o glifo pela escala compartilhada dos controles. Um ícone que declara
14
14
  uma classe `size-*` preserva a própria medida.
15
15
 
16
- ## Em uma legenda
16
+ ## Em slots de ícone e de mídia
17
17
 
18
- Slots de ícone não criam a moldura automaticamente. Componha `IconTile` somente quando ela fizer
19
- sentido para a superfície, como na legenda de um [`Content`](/ui/content) emoldurado.
18
+ Slots genéricos de ícone, como `FieldLegend.icon`, não criam a moldura: passe o glifo quando quiser
19
+ só o ícone e componha `IconTile` quando a moldura reforçar a hierarquia.
20
+
21
+ Os slots de mídia das superfícies já são um `IconTile`: `CardMedia`, `ContentMedia`, `DialogMedia`,
22
+ `DrawerMedia` e `PaneMedia` desenham a moldura sozinhos. Passe só o glifo e escolha `size`,
23
+ `context` e `variant` no próprio slot. Em [`Content`](/ui/content), um `IconTile` passado em
24
+ `media` é adotado pelo slot, que herda o que ele declara, em vez de desenhar uma moldura dentro da
25
+ outra.
20
26
 
21
27
  ```tsx preview col md
22
28
  <Content
23
29
  as="fieldset"
24
30
  framed
25
31
  frameVariant="legend"
26
- title={
27
- <span className="flex items-center gap-2">
28
- <IconTile size="xs">
29
- <CarFront />
30
- </IconTile>
31
- Veículo
32
- </span>
33
- }
32
+ title="Veículo"
33
+ media={<CarFront aria-hidden="true" />}
34
34
  >
35
35
  <FieldGroup>
36
36
  <Field>
@@ -41,8 +41,10 @@ subdomínios; um nome repetido de action, reaction, schedule ou Produto de Dados
41
41
  entre domínios registrados separadamente.
42
42
 
43
43
  O `start()` falha quando há reactions registradas sem `eventBus` (`runtime.eventbus_required`) ou
44
- schedules sem `scheduler` (`runtime.scheduler_required`). A declaração de um domínio está em
45
- [Actions & contratos](/docs/actions).
44
+ schedules sem `scheduler` (`runtime.scheduler_required`), e quando um schedule ativo aponta para uma
45
+ action não registrada (`runtime.schedule_action_not_found`) ou que não aceita execução pelo sistema
46
+ (`runtime.schedule_action_not_automatable`); veja [Agendador](/docs/scheduler). A declaração de um
47
+ domínio está em [Actions & contratos](/docs/actions).
46
48
 
47
49
  ## As capacidades
48
50
 
@@ -122,6 +124,13 @@ Duas formas de código rodam sem uma requisição: um evento de domínio dispara
122
124
  [reaction](/docs/events), e o tempo dispara um [schedule](/docs/scheduler), que executa uma action
123
125
  com provenance própria. Ambos são registrados junto com as actions, e o manifest projeta os três.
124
126
 
127
+ O runtime aplica a cada reaction a política de entrega declarada — `retry`, `timeout`, `dedup` e
128
+ `concurrency` — e limita as execuções simultâneas de todas elas com `config.maxConcurrentReactions`
129
+ (padrão 10). Uma reaction publica os eventos que declara em `emits`, e a cadeia para em
130
+ `config.maxReactionDepth` saltos (padrão 5). O schedule executa a action como o sistema, sem
131
+ usuário, quando o contrato dela declara `automatable: true`; `isSystemActor(ctx)` identifica essa
132
+ execução no `authorize` e no handler.
133
+
125
134
  `opus introspect` lista reactions, schedules e as ligações entre eles, mas só reconhece
126
135
  declarações feitas com `defineAction`, `defineReaction` e `defineSchedule`. Actions do par
127
136
  `defineContract` e `bindAction` e reactions do par `defineReactionContract` e `bindReaction` ficam de
@@ -5,8 +5,8 @@ title: Agendador
5
5
  # Agendador
6
6
 
7
7
  Use um schedule para executar uma action por cron ou em intervalos, sem uma requisição. O
8
- `SchedulerAdapter` registra o agendamento, e o runtime executa a action com provenance `schedule`,
9
- que aparece no audit e no contexto do handler.
8
+ `SchedulerAdapter` registra o agendamento, e o runtime executa a action como o sistema, com
9
+ provenance `schedule`, que aparece no audit e no contexto do handler.
10
10
 
11
11
  ## O contrato
12
12
 
@@ -47,7 +47,7 @@ Declare o schedule com `defineSchedule` e registre-o no domínio, ao lado da act
47
47
 
48
48
  ```ts
49
49
  import { createRuntime, defineDomain, defineSchedule } from '@softize/opus/core'
50
- import { caixaFechar } from './caixa-fechar.ts' // a action 'caixa.fechar', definida com bindAction
50
+ import { caixaFechar } from './caixa-fechar.ts' // a action 'caixa.fechar', preparada abaixo
51
51
 
52
52
  const fecharDia = defineSchedule({
53
53
  name: 'caixa.fechar-dia',
@@ -71,22 +71,63 @@ await runtime.start()
71
71
  ```
72
72
 
73
73
  No `start()`, o runtime confere se cada schedule aponta para uma action registrada no mesmo runtime
74
- e depois o registra no adapter. Um nome sem action registrada interrompe o `start()` com
75
- `runtime.schedule_action_not_found`, antes de inicializar os adapters; a mensagem lista cada
76
- schedule e a action que ele procurou. Com schedules registrados e sem `scheduler`, o `start()`
77
- falha com `runtime.scheduler_required`. Um schedule com `enabled: false` não é registrado nem
74
+ e que aceita execução pelo sistema, e depois o registra no adapter. As duas conferências acontecem
75
+ antes de inicializar os adapters, e cada mensagem lista os schedules e as actions afetadas. Um nome
76
+ sem action registrada interrompe o `start()` com `runtime.schedule_action_not_found`; uma action
77
+ sem `automatable` nem `public`, com `runtime.schedule_action_not_automatable` (veja a próxima
78
+ seção). Com schedules registrados e sem `scheduler`, o `start()` falha com
79
+ `runtime.scheduler_required`. Um schedule com `enabled: false` não é registrado nem
78
80
  conferido, então pode apontar para uma action que ainda não existe no ambiente.
79
81
 
80
82
  ## Preparar a action agendada
81
83
 
82
- O schedule executa a action sem usuário: `ctx.user` é `null` e `ctx.can` nega tudo. Por isso, a
83
- action alvo precisa declarar `public: true`; sem isso, cada disparo termina em
84
- `auth.unauthenticated`. Para que ela não fique acessível por HTTP, declare também `internal: true`:
85
- os servidores do Opus não expõem actions internas, e o schedule continua executando-a pelo runtime.
84
+ O schedule executa a action como o sistema, sem uma pessoa por trás. Uma action que exige usuário
85
+ recusa o sistema até que o contrato dela aceite essa execução com `automatable: true`:
86
+
87
+ ```ts
88
+ import { defineContract, isSystemActor } from '@softize/opus/core'
89
+
90
+ export const caixaFecharContract = defineContract({
91
+ name: 'caixa.fechar',
92
+ kind: 'simple',
93
+ input: z.object({}),
94
+ output: z.object({ fechados: z.number() }),
95
+ automatable: true,
96
+ })
97
+
98
+ export const caixaFechar = bindAction(caixaFecharContract, {
99
+ // O sistema não tem permissões: aceite-o de forma explícita ao lado da regra das pessoas.
100
+ authorize: (ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar'),
101
+ handler: async (ctx) => ({ fechados: await caixa.fecharDia() }),
102
+ })
103
+ ```
104
+
105
+ `automatable` só vale para o disparo de um schedule. A action continua exigindo usuário em
106
+ qualquer outra chamada: uma requisição HTTP sem usuário recebe `auth.unauthenticated`, como antes,
107
+ mesmo que informe uma provenance `schedule`.
108
+
109
+ Na execução pelo sistema, `ctx.user` é `null`, `ctx.can` nega toda permissão e `ctx.provenance` é
110
+ `{ kind: 'schedule', scheduleName }`. `isSystemActor(ctx)` responde `true` só quando o próprio
111
+ runtime criou essa execução, a partir de um schedule ou do job em background que ele enfileirou; o
112
+ job leva a identidade em uma marca que o runtime grava no envelope, e não pela provenance. Uma tool
113
+ que a action agendada chama por `ctx.ai` não herda a identidade.
114
+
115
+ Aceite o sistema no `authorize` escrito como função, com `isSystemActor(ctx)`, como no exemplo. Um
116
+ `authorize` em string, como `'user.role == "admin"'`, não tem como reconhecer o sistema: para uma
117
+ action agendada com autorização própria, use a função. Não teste `ctx.provenance.kind` para esse
118
+ fim: qualquer código do servidor que chama `runtime.execute` pode preencher a provenance com
119
+ `schedule`. Sem `authorize`, `automatable` basta. O audit registra o ator como
120
+ `{ id: null, type: 'system' }`, com o nome do schedule na provenance.
121
+
122
+ Um schedule ativo que aponta para uma action sem `automatable` e sem `public` impede o `start()` com
123
+ `runtime.schedule_action_not_automatable`. Para resolver, declare `automatable: true` no contrato da
124
+ action ou, se o agendamento não deve rodar neste ambiente, `enabled: false` no schedule. Uma action
125
+ `public: true` continua podendo ser agendada, mas fica aberta a qualquer chamador; prefira
126
+ `automatable`.
86
127
 
87
128
  Quando um disparo não conclui a action, o runtime registra um erro no log com o schedule, a action,
88
129
  o código e a mensagem do erro; o agendamento continua ativo e o próximo disparo executa a action de
89
- novo. Uma falha dentro do pipeline da action, como `auth.unauthenticated` ou um erro do handler,
130
+ novo. Uma falha dentro do pipeline da action, como um erro do `authorize` ou do handler,
90
131
  também fica registrada no audit. O callback `onError` do driver recebe somente erros lançados antes
91
132
  da execução, como uma falha na função `input`.
92
133
 
@@ -12,8 +12,8 @@ autorização. `@softize/opus/testing` executa esse fluxo em unidade, sem montar
12
12
  `runAction` segue a ordem do runtime: valida a entrada, exige usuário quando a action não é
13
13
  `public`, avalia `authorize`, executa o handler e valida a saída. Em `list`, a validação da saída é
14
14
  pulada, porque o schema descreve o item e o handler devolve a página. Falhas usam o mesmo
15
- `ActionError` tipado do runtime, como `validation.invalid_input`, `auth.unauthenticated` e
16
- `auth.forbidden`.
15
+ `ActionError` tipado do runtime, como `validation.invalid_input`, `auth.unauthenticated`,
16
+ `auth.system_forbidden` e `auth.forbidden`.
17
17
 
18
18
  Passe a action ligada ao handler, produzida por `bindAction`. O contrato puro não tem handler e só
19
19
  executa em modo design, descrito abaixo.
@@ -59,10 +59,22 @@ opcionais.
59
59
  | `ai` | Adapter de IA entregue em `ctx.ai`. Sem runtime, `run` e `runStream` não recebem tools. |
60
60
  | `meta`, `trace` | Valores de `ctx.meta` e `ctx.trace`. |
61
61
  | `loaded` | Substitui os loaders e chega ao `authorize` e ao handler. |
62
+ | `schedule` | Executa como o sistema, no disparo do schedule com esse nome: ignora `user` e `can`. |
62
63
 
63
64
  `requires` é declarativo também no harness: nada o executa. Um handler que chama `ctx.can` recebe a
64
65
  resposta da opção `can`.
65
66
 
67
+ Para uma action agendada, a opção `schedule` reproduz a execução pelo sistema com a mesma regra do
68
+ runtime: `ctx.user` é `null`, `ctx.can` nega tudo e `isSystemActor(ctx)` responde `true`. Sem
69
+ `automatable: true` no contrato, a action falha com `auth.system_forbidden`:
70
+
71
+ ```ts
72
+ it('o fechamento diário roda pelo sistema', async () => {
73
+ const { output } = await runAction(caixaFechar, {}, { schedule: 'caixa.fechar-dia' })
74
+ expect(output.fechados).toBeGreaterThan(0)
75
+ })
76
+ ```
77
+
66
78
  ## Observar efeitos
67
79
 
68
80
  `runAction` devolve, além de `output`, os efeitos capturados: `emitted` guarda cada `ctx.emit` e
package/src/ui/meta.ts CHANGED
@@ -123,7 +123,7 @@ export const componentMeta = {
123
123
  name: "content-header",
124
124
  ancestry: "opus",
125
125
  whenToUse:
126
- 'Estruture uma região com título e corpo. No caso comum, declare título, descrição, contador e ações diretamente em Content; use `framed` quando a região precisar de limite próprio, `as="fieldset"` para agrupar campos sob uma legenda nativa e `frameVariant="legend"` somente quando essa legenda precisar integrar visualmente a borda. Componha ContentHeader apenas quando precisar controlar a anatomia.',
126
+ 'Estruture uma região com título e corpo. No caso comum, declare título, mídia, descrição, contador e ações diretamente em Content; use `framed` quando a região precisar de limite próprio, `frameVariant="legend"` para integrar visualmente o header à borda e `as="fieldset"` somente para agrupar campos sob uma legenda nativa. Componha ContentHeader apenas quando precisar controlar a anatomia.',
127
127
  },
128
128
  copyable: {
129
129
  name: "copyable",
package/src/ui/react.tsx CHANGED
@@ -487,6 +487,7 @@ export type {
487
487
  export {
488
488
  Content,
489
489
  ContentHeader,
490
+ ContentMedia,
490
491
  ContentTitle,
491
492
  ContentDescription,
492
493
  ContentMeta,