@softize/opus 22.1.0 → 23.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 (52) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/bin/cli.mjs +6 -5
  3. package/bin/lib/check.mjs +23 -8
  4. package/bin/lib/copy.mjs +1 -1
  5. package/bin/lib/gen-manifest.mjs +2 -0
  6. package/bin/lib/gen-runner.mjs +7 -0
  7. package/bin/lib/gen-stubs.mjs +3 -1
  8. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +1 -1
  9. package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +16 -12
  10. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +8 -6
  11. package/docs/adr/0036-tabs-declares-trigger-distribution.md +11 -8
  12. package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
  13. package/docs/adr/0038-content-level-defines-primary-presentation.md +62 -0
  14. package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +62 -0
  15. package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +60 -0
  16. package/docs/adr/0041-actions-declare-effect-and-personal-data.md +139 -0
  17. package/docs/code-style.md +1 -1
  18. package/docs/protocol.md +15 -2
  19. package/package.json +1 -1
  20. package/registry/skills/build-opus-ui/references/ui-patterns.md +1 -1
  21. package/registry/skills/create-opus-action/SKILL.md +7 -2
  22. package/registry/skills/create-opus-action/references/contract-and-binding.md +10 -5
  23. package/registry/skills/create-opus-action/references/evaluations.md +3 -0
  24. package/registry/skills/create-opus-action/scripts/scaffold.mjs +6 -0
  25. package/registry/templates/app/src/domains/tasks/actions/list.ts +2 -0
  26. package/src/core/action-nature.ts +40 -0
  27. package/src/core/audit.ts +12 -4
  28. package/src/core/contracts.ts +1 -1
  29. package/src/core/index.ts +2 -0
  30. package/src/core/runtime.ts +57 -35
  31. package/src/core/types.ts +45 -23
  32. package/src/design/index.ts +14 -3
  33. package/src/mcp/index.ts +6 -0
  34. package/src/testing/index.ts +6 -26
  35. package/src/ui/components/patterns/content-header.tsx +5 -9
  36. package/src/ui/components/patterns/list.tsx +55 -1
  37. package/src/ui/components/patterns/presentation.tsx +1 -1
  38. package/src/ui/components/primitives/icon-picker.tsx +6 -0
  39. package/src/ui/components/primitives/tabs.tsx +55 -87
  40. package/src/ui/components/primitives/toggle.tsx +1 -1
  41. package/src/ui/docs/content/action-list.md +13 -4
  42. package/src/ui/docs/content/actions.md +36 -8
  43. package/src/ui/docs/content/content.md +5 -6
  44. package/src/ui/docs/content/mcp.md +6 -0
  45. package/src/ui/docs/content/semantic-context.md +0 -1
  46. package/src/ui/docs/content/tabs.md +68 -69
  47. package/src/ui/docs/content/testing.md +2 -3
  48. package/src/ui/docs/content/toggle.md +15 -10
  49. package/src/ui/drivers/react.tsx +4 -3
  50. package/src/ui/meta.ts +2 -2
  51. package/src/ui/react.tsx +2 -3
  52. package/src/vite/design.ts +23 -21
@@ -0,0 +1,139 @@
1
+ # ADR 0041 — Actions declaram efeito e dado pessoal
2
+
3
+ - **Status:** aceita; saiu na 22.2.0.
4
+ - **Data:** 2026-09-29.
5
+ - **Relacionadas:** ADR 0028 (execução pelo sistema); ADR 0104 da Grand Brasil (actions são a
6
+ interface da IA).
7
+
8
+ ## Contexto
9
+
10
+ Uma action é consumida pela interface, pela IA, por automações e por outros sistemas via MCP. Dois
11
+ fatos sobre ela importam a todos esses consumidores e hoje não estão declarados em lugar nenhum.
12
+
13
+ **Efeito.** Quem oferece uma action a um modelo precisa saber se ela só lê ou se altera estado. O
14
+ `kind` responde isso só em parte:
15
+
16
+ - `list` e `view` são servidas por `GET`, que o protocolo HTTP define como seguro, sem efeito
17
+ colateral;
18
+ - `simple` e `form` são servidas por `POST` e cobrem as duas coisas. Na Grand Brasil, em setembro de
19
+ 2026, `sales.performance`, `doc.search`, `facts.query` e `sale.view` eram leituras declaradas como
20
+ `simple`.
21
+
22
+ O MCP tem uma anotação padrão para isso, `readOnlyHint`, que o Opus não preenche.
23
+
24
+ **Dado pessoal.** Uma action que devolve nome, documento, contato ou texto livre sobre pessoas expõe
25
+ dado pessoal a quem a executa. A aplicação precisa registrar essa exposição em toda requisição,
26
+ qualquer que seja o consumidor, para auditoria e LGPD. Hoje o registro da requisição (`AuditRecord`)
27
+ não distingue uma execução que expôs dado pessoal de outra que não expôs, e cada consumidor teria
28
+ de lembrar de marcar por conta própria.
29
+
30
+ Os dois fatos pertencem à action, não à IA: valem para qualquer consumidor. Por isso não cabem na
31
+ configuração `ai`.
32
+
33
+ ## Decisão
34
+
35
+ ### 1. Dois campos no contrato
36
+
37
+ ```ts
38
+ defineContract({
39
+ name: 'sales.performance',
40
+ kind: 'simple',
41
+ description: 'Soma as vendas por período e dimensão.',
42
+ input: salesPerformanceInput,
43
+ output: salesPerformanceOutput,
44
+ effect: 'read',
45
+ personalData: false,
46
+ })
47
+ ```
48
+
49
+ Os dois campos ficam no grupo de `input` e `output` da ordem canônica, cobrada pelo `opus check`.
50
+
51
+ - **`effect: 'read' | 'write'`.** `list` e `view` são leitura pelo protocolo e não precisam
52
+ declarar; declarar `effect: 'write'` nelas é erro de configuração, recusado ao registrar a action.
53
+ `simple` e `form` declaram.
54
+ - **`personalData: boolean`.** Declara se a resposta contém dados que identificam pessoas. Não é
55
+ inferido de nada. `false` é uma declaração tão explícita quanto `true`; ausente significa "não
56
+ declarado".
57
+
58
+ ### 2. O runtime marca a exposição em toda requisição
59
+
60
+ Quando uma action com `personalData: true` executa com sucesso, o `AuditRecord` passa a carregar:
61
+
62
+ ```ts
63
+ personalData: { records: number | null }
64
+ ```
65
+
66
+ `records` conta os registros devolvidos: os itens da página de uma `list`, os elementos de um array,
67
+ 1 para um objeto e `null` quando a forma da saída não permite contar. Numa `view`, `simple` ou `form`,
68
+ um campo `items` faz parte do registro, e o objeto continua contando como um.
69
+
70
+ A marca vale mesmo quando a action declara `audit: false`: nesse caso, o runtime emite um registro
71
+ mínimo, sem entrada nem saída e com `meta.exposureOnly`, para todos os sinks. Desligar a auditoria
72
+ serve para reduzir ruído, não para esconder exposição. Com `auditMode: 'strict'`, a falha de um sink
73
+ nesse registro mínimo derruba a requisição, como em qualquer outro registro. Com `AuditConfig.sink`, o registro vai só ao
74
+ sink indicado, com a marca. Não são marcados: a execução com erro, o enfileiramento em segundo plano
75
+ (que devolve o handle do job; a marca sai na execução do job) e a resposta simulada do modo design
76
+ (mock, cenário ou resposta gerada); um handler real executado no modo design continua marcado. O registro também carrega `effect`. O span da execução
77
+ recebe os atributos `opus.action.effect` e `opus.action.personal_data`, que são declarações
78
+ conhecidas antes da execução; a contagem fica no registro, porque o span é aberto antes de haver
79
+ saída.
80
+
81
+ A marca é do runtime, não do consumidor: tela, IA, automação e MCP passam pelo mesmo `execute`.
82
+
83
+ ### 3. Projeções
84
+
85
+ - **Manifest:** cada action projeta `effect`, já resolvido para `list` e `view`, e `personalData`.
86
+ - **`runtime.aiTools()`:** cada ferramenta carrega `annotations.readOnlyHint` e
87
+ `annotations.destructiveHint`, e `metadata.personalData`. No MCP, `destructiveHint` ausente numa
88
+ escrita vale `true`; por isso, numa action com `effect: 'write'`, ele vai sempre explícito, a partir
89
+ de `ai.destructive`.
90
+ - **MCP:** o `ListTools` publica as mesmas `annotations` e acrescenta
91
+ `com.softize.opus/effect` e `com.softize.opus/personal-data` em `_meta`.
92
+
93
+ ### 4. Adoção gradual
94
+
95
+ Nesta minor, os dois campos são opcionais. O `opus check` conta, numa linha de aviso que não
96
+ reprova, quantas actions `simple` ou `form` não declaram `effect` e quantas actions não declaram
97
+ `personalData`. A obrigatoriedade fica para uma major, depois que o consumidor declarar tudo.
98
+
99
+ ## Consequências
100
+
101
+ - O host de IA decide pela declaração, e não por `kind`, nome ou descrição, o que entra no turno
102
+ sem procedimento.
103
+ - A auditoria de exposição de dado pessoal passa a ser propriedade do runtime.
104
+ - Consumidores precisam classificar retroativamente as actions que já existem. O aviso do
105
+ `opus check` mede o que falta.
106
+ - `effect` é declarativo: o runtime não impede uma action `read` de escrever. A garantia vem do
107
+ handler e da revisão.
108
+
109
+ ## Alternativas descartadas
110
+
111
+ ### Declarar na configuração `ai`
112
+
113
+ Seria menor, mas os dois fatos valem para todo consumidor. A marca de dado pessoal precisa existir
114
+ também em requisições da interface e de automações.
115
+
116
+ ### Inferir leitura de `kind` para todos os kinds
117
+
118
+ Funciona para `list` e `view`, por causa do `GET`. Para `simple` e `form`, a inferência erraria as
119
+ leituras declaradas como `simple` e daria à convenção um papel de segurança.
120
+
121
+ ### Classificação rica de sensibilidade
122
+
123
+ Um enum com dado financeiro, de saúde ou sigiloso seria mais expressivo, mas nenhum consumidor tem
124
+ política para essas categorias hoje. O campo booleano cobre a necessidade atual e pode evoluir para
125
+ uma classificação sem mudar o ponto de declaração.
126
+
127
+ ## Verificação
128
+
129
+ - Registrar `effect: 'write'` em `list` ou `view` falha na subida do runtime.
130
+ - O manifest projeta `effect: 'read'` para `list` e `view` sem declaração.
131
+ - `aiTools()` e o `ListTools` do MCP publicam `readOnlyHint` a partir do efeito resolvido.
132
+ - Execução bem-sucedida de action com `personalData: true` produz `AuditRecord.personalData` com a
133
+ contagem, e o span recebe os atributos de efeito e de dado pessoal.
134
+ - Execução com erro, enfileiramento em segundo plano, modo design e action com `personalData: false`
135
+ não produzem a marca; a execução do job produz.
136
+ - Com `audit: false`, a exposição gera registro mínimo, sem entrada nem saída.
137
+ - `records` conta a página numa `list` e 1 numa `view` com campo `items`.
138
+ - Escrita sem `ai.destructive` publica `destructiveHint: false`.
139
+ - O `opus check` conta as declarações ausentes sem reprovar.
@@ -115,7 +115,7 @@ O extrator cobre três superfícies, sem heurística de nome:
115
115
  reprovam em vez de serem executadas e o dicionário mantém um snapshot imutável das entradas;
116
116
  3. texto estático em filhos e props de uma allowlist de componentes importados diretamente
117
117
  de `@softize/opus/ui*` — por exemplo, `Button`, `DialogTitle`, `DialogBody`,
118
- `FieldLabel`, `TabsTrigger`, `Page`, `Input` e `DataState`. Alias e namespace de import
118
+ `FieldLabel`, `TabsListItem`, `Page`, `Input` e `DataState`. Alias e namespace de import
119
119
  continuam rastreáveis. `uppercase`, variantes como `sm:hover:uppercase`, modificadores
120
120
  `!uppercase`/`uppercase!` e classes em descendentes são projetados na superfície que
121
121
  realmente herda o CSS. Props específicas também respeitam a implementação: por exemplo,
package/docs/protocol.md CHANGED
@@ -79,6 +79,10 @@ type ActionBase<In, Out> = {
79
79
  input: Schema<In> // StandardSchemaV1-compatible
80
80
  output: Schema<Out>
81
81
 
82
+ // — Natureza (ADR 0041) —
83
+ effect?: 'read' | 'write' // list/view = read pelo GET; simple/form declaram
84
+ personalData?: boolean // devolve dado que identifica pessoas; marca o audit
85
+
82
86
  // — Autorização (fail-closed default) —
83
87
  public?: boolean // default false
84
88
  requires?: string | string[] // permissão declarativa (drive OpenAPI/docs)
@@ -628,6 +632,8 @@ Toda action **audita por default**. Para desabilitar (raramente justificável),
628
632
 
629
633
  > Audit é o **log de negócio** de mudanças de estado. Não confundir com logging de aplicação (debug, info) — esses ficam por conta do logger do projeto.
630
634
 
635
+ **Exposição de dado pessoal (ADR 0041).** Quando a action declara `personalData: true` e executa com sucesso, o registro carrega `personalData: { records }`, com a quantidade de registros devolvidos (os itens da página de uma `list`, 1 para um objeto, `null` quando a saída não permite contar). A marca é do runtime: vale para a interface, a IA, automações e o MCP, sem que o consumidor precise lembrar. O registro também carrega `effect`. Não recebem a marca: a execução com erro; o enfileiramento de uma action em segundo plano, que devolve o handle do job (a marca sai na execução do job); e a resposta simulada do modo design (mock, cenário ou resposta gerada); um handler real executado no modo design continua marcado. Com `audit: false`, a execução que expôs dado pessoal ainda gera um registro mínimo, sem entrada nem saída e com `meta.exposureOnly`, entregue a todos os sinks. Com `auditMode: 'strict'`, a falha de um sink nesse registro derruba a requisição, como em qualquer outro registro. Com `AuditConfig.sink`, o registro, com a marca, vai só ao sink indicado. `records` conta os itens da página numa `list`; em `view`, `simple` e `form`, um objeto conta como um registro, mesmo que tenha um campo `items`.
636
+
631
637
  ### Shape do registro
632
638
 
633
639
  ```ts
@@ -649,6 +655,11 @@ type AuditRecord = {
649
655
  output?: unknown; // já redacted, presente apenas em success
650
656
  error?: ActionError; // presente apenas em outcome=error
651
657
 
658
+ effect?: 'read' | 'write'; // efeito resolvido (ADR 0041); list/view = read
659
+ personalData?: { // exposição de dado pessoal (ADR 0041)
660
+ records: number | null; // registros devolvidos; null = não contável
661
+ };
662
+
652
663
  severity: 'info' | 'warning' | 'error';
653
664
  trace?: { // campo legado — compatibilidade
654
665
  requestId?: string;
@@ -667,8 +678,8 @@ defineAction({
667
678
  })
668
679
 
669
680
  defineAction({
670
- audit: false, // desabilita (precisa justificar em PR)
671
- })
681
+ audit: false, // desabilita (precisa justificar em PR); exposição de
682
+ }) // dado pessoal ainda gera registro mínimo (ADR 0041)
672
683
 
673
684
  defineAction({
674
685
  audit: {
@@ -2171,6 +2182,8 @@ chamada, inclusive por IA e MCP.
2171
2182
  - **`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
2183
  - **`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.
2173
2184
  - **`internal`** — flag que indica action service-to-service apenas; server adapter não expõe como REST público. Ver §2.
2185
+ - **`effect`** — o que a action faz com o estado: `read` ou `write`. `list` e `view` são leitura pelo `GET` e não declaram; `simple` e `form` declaram. Projetado no manifest, nas ferramentas de IA e no MCP (`readOnlyHint`). Ver ADR 0041.
2186
+ - **`personalData`** — a resposta contém dados que identificam pessoas. Com `true`, toda execução bem-sucedida é marcada no `AuditRecord` (`personalData.records`) e no span, qualquer que seja o consumidor. Ver §5 e ADR 0041.
2174
2187
  - **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.
2175
2188
  - **`defineReaction`** — função que constrói uma reaction declarativa.
2176
2189
  - **`ReactionContext`** — contexto entregue ao handler de reaction (user, tenant, db, emit, log, meta).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "22.1.0",
3
+ "version": "23.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",
@@ -52,7 +52,7 @@ ContentDescription, ContentMeta, ContentActions) + ContentBody`. `ContentHeader`
52
52
  `toolbarActions` fica reservado a operações que dependem do recorte atual.
53
53
  - Card, Drawer e Pane nomeiam a região principal como `CardBody`, `DrawerBody` e `PaneBody`.
54
54
  `*Content` permanece reservado a raízes técnicas ou painéis cujo papel não é o corpo de uma
55
- estrutura, como `DialogContent`, `PopoverContent` e `TabsContent`.
55
+ estrutura, como `DialogContent`, `PopoverContent` e `TabsPanel`.
56
56
  - Componentes compartilhados não impõem margem externa; páginas e shells compõem layout.
57
57
  - A UI assume layout desktop fixo e largura mínima suportada de `64rem`. Não introduzir
58
58
  breakpoints de viewport ou container queries isoladas; uma futura responsividade precisa começar
@@ -15,6 +15,7 @@ registrados no runtime do projeto.
15
15
 
16
16
  - nome `<resource>.<verb>` e `kind` (`simple`, `form`, `list` ou `view`);
17
17
  - comportamento, autorização, input, output, erros e efeitos esperados;
18
+ - se a action só lê ou altera estado e se a resposta contém dados que identificam pessoas;
18
19
  - caminhos reais de `shared` e `api` adotados pelo repo.
19
20
 
20
21
  ## Procedimento
@@ -23,8 +24,12 @@ registrados no runtime do projeto.
23
24
  2. Colocar o contrato em módulo importável pelos consumidores e o binding no lado servidor.
24
25
  3. Substituir os exemplos do scaffold por schemas, descrições e comportamento do domínio.
25
26
  4. Declarar `requires` somente com `authorize` efetivo; `requires` sozinho não protege a action.
26
- 5. Exportar e registrar o binding no domínio/runtime existente.
27
- 6. Carregar e seguir `$test-opus-action` para cobrir contrato, sucesso, erros e efeitos relevantes.
27
+ 5. Declarar `effect` em `simple` e `form` e `personalData` em todos os kinds. O scaffold parte de
28
+ `effect: 'write'` e deixa `personalData` comentado de propósito: a ausência aparece no aviso do
29
+ `opus check` até alguém decidir, porque um `false` preenchido por padrão esconderia exposição de
30
+ dado pessoal.
31
+ 6. Exportar e registrar o binding no domínio/runtime existente.
32
+ 7. Carregar e seguir `$test-opus-action` para cobrir contrato, sucesso, erros e efeitos relevantes.
28
33
 
29
34
  ## Verificação
30
35
 
@@ -1,14 +1,19 @@
1
1
  # Contrato e binding
2
2
 
3
3
  O contrato contém `name`, `kind`, documentação, schemas, metadata de UI e autorização
4
- declarativa compartilhável. Ele não declara dados de design: no modo design, a action sem mock já
5
- responde com dado gerado a partir do `output`, e um cenário específico, quando necessário, fica
6
- fora do contrato em `defineDesignScenario` (ADR 0037). `mockHandler` está obsoleto.
4
+ declarativa compartilhável. Ele não declara dados de design: no modo design, a action responde
5
+ com dado gerado a partir do `output`, e um cenário específico, quando necessário, fica fora do
6
+ contrato em `defineDesignScenario` (ADR 0037).
7
7
 
8
8
  O binding contém `handler`, `loads`, autorização dependente de dados carregados, execução
9
9
  em background, eventos e idempotência. `bindAction(contract, binding)` produz o `ActionDef`
10
10
  registrado no runtime.
11
11
 
12
- Ordem cobrada pelo check: identidade → documentação → input/output → autorização →
13
- handler → comportamento. Campos específicos do kind permanecem junto do grupo ao qual
12
+ O contrato também declara o que a action faz com os dados (ADR 0041): `effect` (`read` ou
13
+ `write`) em `simple` e `form`, porque `list` e `view` já são leitura pelo `GET`, e `personalData`
14
+ em todos os kinds. Os dois fatos alimentam a auditoria de exposição de dado pessoal, as ferramentas
15
+ de IA e as anotações do MCP.
16
+
17
+ Ordem cobrada pelo check: identidade → documentação → input/output e natureza (`effect`,
18
+ `personalData`) → autorização → handler → comportamento. Campos específicos do kind permanecem junto do grupo ao qual
14
19
  servem e o scaffold acompanha a forma atual do SDK.
@@ -3,3 +3,6 @@
3
3
  - Dispara: “Crie a action `invoice.approve` no Opus.”
4
4
  - Não dispara: “Crie uma função TypeScript que soma duas parcelas.”
5
5
  - Execução: gerar contrato e binding para cada kind e obter zero findings no `opus check`.
6
+ - Execução: “Crie `customer.list`, que lista clientes com nome e telefone” resulta em
7
+ `personalData: true` declarado; “Crie `sales.performance`, que soma vendas por mês” resulta em
8
+ `effect: 'read'` e `personalData: false`.
@@ -17,6 +17,7 @@ ${common}
17
17
  description: 'Descreva o comportamento de negócio e seus limites.',
18
18
  input: z.object({}),
19
19
  output: z.object({ id: z.string() }),
20
+ // personalData: decida — true se a resposta identifica pessoas (nome, documento, contato).
20
21
  paginate: 'cursor',
21
22
  })`
22
23
  }
@@ -28,6 +29,8 @@ ${common}
28
29
  messages: { success: 'Operação concluída.', error: 'Não foi possível concluir.' },
29
30
  input: z.object({ id: z.string() }),
30
31
  output: z.object({ id: z.string() }),
32
+ effect: 'write',
33
+ // personalData: decida — true se a resposta identifica pessoas (nome, documento, contato).
31
34
  fields: { id: { label: 'Identificador' } },
32
35
  })`
33
36
  }
@@ -38,6 +41,7 @@ ${common}
38
41
  description: 'Descreva o comportamento de negócio e seus limites.',
39
42
  input: z.object({ id: z.string() }),
40
43
  output: z.object({ id: z.string() }),
44
+ // personalData: decida — true se a resposta identifica pessoas (nome, documento, contato).
41
45
  })`
42
46
  }
43
47
  return `export const ${identifier} = defineContract({
@@ -47,6 +51,8 @@ ${common}
47
51
  messages: { success: 'Operação concluída.', error: 'Não foi possível concluir.' },
48
52
  input: z.object({ id: z.string() }),
49
53
  output: z.object({ ok: z.boolean() }),
54
+ effect: 'write',
55
+ // personalData: decida — true se a resposta identifica pessoas (nome, documento, contato).
50
56
  })`
51
57
  }
52
58
 
@@ -17,6 +17,8 @@ export const taskList = defineContract({
17
17
  'canônico de uma action list — contrato separado do handler, paginação por cursor.',
18
18
  input: z.object({}),
19
19
  output: Task,
20
+ // Leitura pelo GET (ADR 0041): list não declara `effect`. Tarefas não identificam pessoas.
21
+ personalData: false,
20
22
  paginate: 'cursor',
21
23
  })
22
24
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A natureza declarada de uma action (ADR 0041): o efeito sobre o estado e a exposição de dado
3
+ * pessoal. Funções puras, compartilhadas pelo runtime (registro, auditoria e ferramentas de IA) e
4
+ * por quem projeta o contrato.
5
+ */
6
+
7
+ import type { ActionEffect, ActionKind } from './types.ts'
8
+
9
+ /** `list` e `view` são servidas por `GET`: leitura pelo protocolo, sem declaração. */
10
+ const READ_BY_PROTOCOL: ReadonlySet<ActionKind> = new Set(['list', 'view'])
11
+
12
+ /**
13
+ * O efeito da action, com `list` e `view` resolvidos como leitura. `undefined` quando uma
14
+ * `simple` ou `form` não declarou: quem consome decide o que fazer com a ausência.
15
+ */
16
+ export function resolveActionEffect(action: { kind: ActionKind; effect?: ActionEffect }): ActionEffect | undefined {
17
+ return READ_BY_PROTOCOL.has(action.kind) ? 'read' : action.effect
18
+ }
19
+
20
+ /** A declaração contraditória que o registro recusa: `write` numa action servida por `GET`. */
21
+ export function hasInvalidEffect(action: { kind: ActionKind; effect?: ActionEffect }): boolean {
22
+ return READ_BY_PROTOCOL.has(action.kind) && action.effect === 'write'
23
+ }
24
+
25
+ /**
26
+ * Quantos registros a saída devolveu: os itens da página de uma `list`, os elementos de um array,
27
+ * 1 para um objeto e 0 para ausência. `null` quando a forma não permite contar. Só a `list` lê
28
+ * `items`: numa `view`, `items` é parte do registro (as linhas de um pedido, os contatos de um
29
+ * cliente), e o registro devolvido continua sendo um.
30
+ */
31
+ export function countReturnedRecords(kind: ActionKind, output: unknown): number | null {
32
+ if (output === null || output === undefined) return 0
33
+ if (Array.isArray(output)) return output.length
34
+ if (typeof output === 'object') {
35
+ const items = (output as { items?: unknown }).items
36
+ if (kind === 'list' && Array.isArray(items)) return items.length
37
+ return 1
38
+ }
39
+ return null
40
+ }
package/src/core/audit.ts CHANGED
@@ -41,7 +41,8 @@ export class AuditEmitter {
41
41
 
42
42
  /**
43
43
  * Emite um record pros sinks apropriados.
44
- * - `config === false` → no-op (audit desabilitado pra esta action).
44
+ * - `config === false` → no-op (audit desabilitado pra esta action), exceto quando o record
45
+ * marca exposição de dado pessoal: aí sai um registro mínimo, sem input nem output (ADR 0041).
45
46
  * - `config === true | undefined` → record bruto, todos os sinks.
46
47
  * - `config: AuditConfig` → aplica redact/fields/severity/sink filter.
47
48
  *
@@ -53,11 +54,18 @@ export class AuditEmitter {
53
54
  record: AuditRecord,
54
55
  config?: boolean | AuditConfig,
55
56
  ): Promise<void> {
56
- if (config === false) return
57
+ if (config === false) {
58
+ // ADR 0041: a exposição de dado pessoal é registrada mesmo com o audit desligado. O registro
59
+ // mínimo não leva entrada nem saída, só quem executou, a action e a contagem.
60
+ if (record.personalData === undefined) return
61
+ const { output: _output, ...rest } = record
62
+ return this.dispatch({ ...rest, input: null, meta: { ...record.meta, exposureOnly: true } }, this.sinks)
63
+ }
57
64
 
58
- const processed = this.applyConfig(record, config)
59
- const targetSinks = this.filterSinks(config)
65
+ return this.dispatch(this.applyConfig(record, config), this.filterSinks(config))
66
+ }
60
67
 
68
+ private async dispatch(processed: AuditRecord, targetSinks: AuditSink[]): Promise<void> {
61
69
  if (targetSinks.length === 0) return
62
70
 
63
71
  const results = await Promise.allSettled(
@@ -42,7 +42,7 @@ type BindingKeys = 'handler' | 'loads' | 'background' | 'emits' | 'idempotency'
42
42
  * `ParsedIn` (o lado PARSEADO do schema de input — defaults aplicados) defaulta
43
43
  * a `any` nos contratos, de propósito: quem anota contrato à mão é o CLIENTE
44
44
  * (`FormContract<MeuInput, MeuOutput>` em hooks/props), que só conhece o fio —
45
- * e `authorize`/`mockHandler` são contravariantes em `ParsedIn`, então um
45
+ * e `authorize` é contravariante em `ParsedIn`, então um
46
46
  * default estreito rejeitaria contrato real cujo schema tem `.default()`.
47
47
  * Quem precisa do parseado com precisão (`bindAction`) o infere do schema.
48
48
  */
package/src/core/index.ts CHANGED
@@ -67,6 +67,7 @@ export type {
67
67
  ExpandSpec,
68
68
  // action union + variants
69
69
  ActionKind,
70
+ ActionEffect,
70
71
  ActionBase,
71
72
  SimpleAction,
72
73
  FormAction,
@@ -222,3 +223,4 @@ export type {
222
223
  RuntimeSetupConfig,
223
224
  ResolvedRuntimeConfig,
224
225
  } from './runtime.ts'
226
+ export { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
@@ -18,6 +18,7 @@ import { collectDomainWarnings, flattenDomain, isDomainConfig } from './domain.t
18
18
  import type { DomainConfig } from './domain.ts'
19
19
  import type { DataProductConfig } from './data-product.ts'
20
20
  import { AuditEmitter } from './audit.ts'
21
+ import { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
21
22
  import { error, isActionError, normalizeError } from './errors.ts'
22
23
  import { normalizeTraceContext } from './trace.ts'
23
24
  import { evalExpression } from '../dsl/eval.ts'
@@ -463,6 +464,15 @@ export class Runtime {
463
464
  message: `Action "${action.name}" registrada duas vezes`,
464
465
  })
465
466
  }
467
+ if (hasInvalidEffect(action)) {
468
+ throw error({
469
+ code: 'runtime.invalid_action_effect',
470
+ category: 'internal',
471
+ message:
472
+ `A action "${action.name}" é ${action.kind}, servida por GET, e não pode declarar effect: 'write'. ` +
473
+ 'Use simple ou form para escrever (ADR 0041).',
474
+ })
475
+ }
466
476
  this.actions.set(action.name, action)
467
477
  if (this.config.dev) this.warnIgnoredExecutionFields(action)
468
478
  }
@@ -727,7 +737,11 @@ export class Runtime {
727
737
  kind: 'action',
728
738
  resultKind: 'action-result',
729
739
  ...(ctxBase.trace !== undefined ? { parent: ctxBase.trace } : {}),
730
- attributes: { 'opus.action.id': actionId, 'opus.action.kind': action.kind },
740
+ attributes: {
741
+ 'opus.action.id': actionId,
742
+ 'opus.action.kind': action.kind,
743
+ ...natureAttributes(action),
744
+ },
731
745
  },
732
746
  ctxBase.trace,
733
747
  async (trace) => {
@@ -803,6 +817,8 @@ export class Runtime {
803
817
  actionId,
804
818
  action: action.name,
805
819
  actionKind: action.kind,
820
+ // Enfileirar devolve o handle do job, não os dados: a exposição é marcada na execução.
821
+ nature: { kind: action.kind, ...(action.effect !== undefined ? { effect: action.effect } : {}) },
806
822
  outcome: 'success',
807
823
  durationMs,
808
824
  ctx,
@@ -817,16 +833,7 @@ export class Runtime {
817
833
  }
818
834
 
819
835
  // — 5. Handler ————————————————————————————————————————————————————————
820
- // Modo design (`OPUS_MODE=design`): tenta mockHandler primeiro, cai
821
- // no handler real se não houver. Em qualquer outro modo, handler real
822
- // sempre manda. Leitura de env mora aqui, no boundary do execute — o
823
- // resto do core continua sem tocar `process.env`.
824
- const isDesignMode = isOpusDesignMode()
825
- const handlerToRun =
826
- isDesignMode && action.mockHandler !== undefined
827
- ? action.mockHandler
828
- : action.handler
829
- const output = await handlerToRun(
836
+ const output = await action.handler(
830
837
  ctx,
831
838
  validatedInput,
832
839
  loaded,
@@ -834,8 +841,6 @@ export class Runtime {
834
841
  ? progress
835
842
  : undefined,
836
843
  )
837
- const usedMock = handlerToRun === action.mockHandler
838
-
839
844
  // — 6. Validate output (dev only) —————————————————————————————————————
840
845
  // Search retorna Paginated<Item> mas `output` schema descreve só Item;
841
846
  // skipamos pra evitar falso negativo. v1 pode validar items[].
@@ -852,12 +857,12 @@ export class Runtime {
852
857
  actionId,
853
858
  action: action.name,
854
859
  actionKind: action.kind,
860
+ nature: action,
855
861
  outcome: 'success',
856
862
  durationMs,
857
863
  ctx,
858
864
  input: validatedInput,
859
865
  output: validatedOutput,
860
- mode: usedMock ? 'design' : undefined,
861
866
  }),
862
867
  action.audit,
863
868
  )
@@ -884,6 +889,7 @@ export class Runtime {
884
889
  actionId,
885
890
  action: action.name,
886
891
  actionKind: action.kind,
892
+ nature: action,
887
893
  outcome: 'error',
888
894
  durationMs,
889
895
  ctx,
@@ -932,6 +938,7 @@ export class Runtime {
932
938
  const cfg = normalizeAiConfig(action.ai)
933
939
  if (cfg === null) continue
934
940
  const dataProducts = [...(this.actionDataProducts.get(action.name) ?? [])].sort()
941
+ const effect = resolveActionEffect(action)
935
942
  tools.push({
936
943
  name: action.name,
937
944
  ...(action.label !== undefined
@@ -939,7 +946,23 @@ export class Runtime {
939
946
  : {}),
940
947
  description: cfg.description ?? action.description ?? action.name,
941
948
  inputSchema: action.input,
949
+ // No MCP, `destructiveHint` ausente numa escrita vale `true`. Numa escrita declarada, o valor
950
+ // vai explícito, para o cliente não tratar como destrutiva o que o Opus não trata.
951
+ ...(effect !== undefined || cfg.destructive === true
952
+ ? {
953
+ annotations: {
954
+ ...(effect !== undefined ? { readOnlyHint: effect === 'read' } : {}),
955
+ ...(effect === 'write'
956
+ ? { destructiveHint: cfg.destructive === true }
957
+ : cfg.destructive === true && effect === undefined
958
+ ? { destructiveHint: true }
959
+ : {}),
960
+ },
961
+ }
962
+ : {}),
942
963
  metadata: {
964
+ ...(effect !== undefined ? { effect } : {}),
965
+ ...(action.personalData !== undefined ? { personalData: action.personalData } : {}),
943
966
  dataProducts,
944
967
  dataProductLabels: Object.fromEntries(
945
968
  dataProducts.map((id) => [id, this.dataProducts.get(id)?.label ?? id]),
@@ -1228,14 +1251,14 @@ export class Runtime {
1228
1251
  actionId: string
1229
1252
  action: string
1230
1253
  actionKind?: ActionDef['kind']
1254
+ /** A natureza declarada (ADR 0041): efeito e dado pessoal viajam para o registro. */
1255
+ nature?: Pick<ActionDef, 'kind' | 'effect' | 'personalData'>
1231
1256
  outcome: 'success' | 'error'
1232
1257
  durationMs: number
1233
1258
  ctx: ActionContext
1234
1259
  input: unknown
1235
1260
  output?: unknown
1236
1261
  error?: ReturnType<typeof error>
1237
- /** Quando `'design'`, sinaliza que o mockHandler foi executado. */
1238
- mode?: 'design' | undefined
1239
1262
  }): AuditRecord {
1240
1263
  const record: AuditRecord = {
1241
1264
  id: args.actionId,
@@ -1253,7 +1276,15 @@ export class Runtime {
1253
1276
  }
1254
1277
  if (args.output !== undefined) record.output = args.output
1255
1278
  if (args.error !== undefined) record.error = args.error
1256
- if (args.mode !== undefined) record.meta = { mode: args.mode }
1279
+ if (args.nature !== undefined) {
1280
+ const effect = resolveActionEffect(args.nature)
1281
+ if (effect !== undefined) record.effect = effect
1282
+ // A exposição só existe quando a action devolveu algo. O plugin de design remove o marcador
1283
+ // das respostas simuladas antes de registrar a action no runtime.
1284
+ if (args.nature.personalData === true && args.outcome === 'success') {
1285
+ record.personalData = { records: countReturnedRecords(args.nature.kind, args.output) }
1286
+ }
1287
+ }
1257
1288
  return record
1258
1289
  }
1259
1290
 
@@ -1750,6 +1781,15 @@ export class Runtime {
1750
1781
  // Helpers
1751
1782
  // =============================================================================
1752
1783
 
1784
+ /** Atributos do span com a natureza declarada da action (ADR 0041). */
1785
+ function natureAttributes(action: ActionDef): Record<string, string | boolean> {
1786
+ const effect = resolveActionEffect(action)
1787
+ return {
1788
+ ...(effect !== undefined ? { 'opus.action.effect': effect } : {}),
1789
+ ...(action.personalData !== undefined ? { 'opus.action.personal_data': action.personalData } : {}),
1790
+ }
1791
+ }
1792
+
1753
1793
  async function settledSuccessfully<T>(promise: Promise<T>): Promise<boolean> {
1754
1794
  try {
1755
1795
  await promise
@@ -2040,21 +2080,3 @@ function generateId(): string {
2040
2080
  // fallback determinístico-suficiente quando crypto não disponível
2041
2081
  return `id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
2042
2082
  }
2043
-
2044
- /**
2045
- * Único ponto do core que lê `process.env`. Usado pelo `execute()` pra
2046
- * decidir entre `handler` real e `mockHandler`. Isolado em função pra
2047
- * testabilidade — testes podem `vi.stubEnv('OPUS_MODE', ...)`.
2048
- *
2049
- * Mantém a invariante "core nunca lê env" intacta no resto do código:
2050
- * adapters/config seguem recebendo valores literais.
2051
- *
2052
- * Exportado (fora do index público) pro harness de `testing` aplicar a MESMA
2053
- * régua no `runAction` — a semântica de design mora num lugar só.
2054
- */
2055
- export function isOpusDesignMode(): boolean {
2056
- // Acesso via globalThis: defensivo pra runtimes sem `process` (browser, edge) E
2057
- // não exige @types/node no consumidor que compila este source (apps web).
2058
- const g = globalThis as { process?: { env?: Record<string, string | undefined> } }
2059
- return g.process?.env?.['OPUS_MODE'] === 'design'
2060
- }