@softize/opus 22.1.0 → 22.2.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 22.2.0 — 2026-09-29
11
+
12
+ As actions passam a declarar o que fazem com os dados (ADR 0041). Dois campos novos no contrato,
13
+ ambos opcionais nesta versão:
14
+
15
+ - **`effect: 'read' | 'write'`** diz se a action só lê ou altera estado. `list` e `view` já são
16
+ leitura, porque são servidas por `GET`, e não declaram; declarar `effect: 'write'` nelas é recusado
17
+ ao registrar a action (`runtime.invalid_action_effect`) e apontado pelo `opus check`. `simple` e
18
+ `form` declaram.
19
+ - **`personalData: boolean`** diz se a resposta contém dados que identificam pessoas.
20
+
21
+ Com os dois declarados, o runtime:
22
+
23
+ - marca no `AuditRecord` toda execução bem-sucedida de action com `personalData: true`
24
+ (`personalData: { records }`, com a quantidade devolvida) e registra `effect`, qualquer que seja o
25
+ consumidor. Com `audit: false`, a exposição ainda gera um registro mínimo, sem entrada nem saída
26
+ (`meta.exposureOnly`). Execução com erro, enfileiramento em segundo plano e resposta simulada do
27
+ modo design não são marcados;
28
+ - acrescenta `opus.action.effect` e `opus.action.personal_data` aos atributos do span;
29
+ - projeta `effect` e `personalData` no manifest;
30
+ - publica nas ferramentas de IA (`runtime.aiTools()`) e no `ListTools` do MCP as anotações
31
+ `readOnlyHint` e `destructiveHint` (numa escrita, sempre explícito, porque o MCP trata a ausência
32
+ como destrutiva), e em `_meta` os campos `com.softize.opus/effect` e
33
+ `com.softize.opus/personal-data`.
34
+
35
+ O `opus check` conta, numa linha de aviso que não reprova, quantas actions `simple` ou `form` não
36
+ declaram `effect` e quantas actions não declaram `personalData`.
37
+
38
+ **O que fazer:** rode `opus gen` depois do bump. O manifest passa a ter `effect` e `personalData` em
39
+ toda action (`null` quando não declarado), e sem regenerar o `manifest:check` falha mesmo sem mudança
40
+ de código. O resto é gradual: declare `effect` nas actions `simple` e `form` e `personalData` em
41
+ todas; uma major futura passará a exigir as duas declarações. Um sink de audit que descarta leituras
42
+ (`actionKind` `list` ou `view`) deve preservar as que trazem `personalData`, se a aplicação precisar
43
+ registrar a exposição.
44
+
10
45
  ## 22.1.0 — 2026-09-28
11
46
 
12
47
  Os dados do modo design saem do contrato (ADR 0037). No modo design, toda action passa a responder
package/bin/cli.mjs CHANGED
@@ -40,7 +40,8 @@ async function cmdCheck(dir) {
40
40
  for (const error of materialization.errors) log('error', `✗ opus check: ${error}`)
41
41
  process.exit(1)
42
42
  }
43
- const { files, actions, contracts, deprecatedMockHandlers, findings, hasMarker } = await scanDir(root)
43
+ const { files, actions, contracts, deprecatedMockHandlers, missingEffect, missingPersonalData, findings, hasMarker } =
44
+ await scanDir(root)
44
45
  // Aviso de migração, não violação: uma linha só, para não poluir o gate a cada execução.
45
46
  if (deprecatedMockHandlers > 0) {
46
47
  log(
@@ -50,6 +51,14 @@ async function cmdCheck(dir) {
50
51
  )
51
52
  }
52
53
 
54
+ if (missingEffect > 0 || missingPersonalData > 0) {
55
+ log(
56
+ 'warn',
57
+ `⚠ opus check: ${missingEffect} action(s) simple/form sem \`effect\` e ${missingPersonalData} sem \`personalData\`. ` +
58
+ 'Declare se a action lê ou escreve e se devolve dado pessoal; uma major futura passará a exigir as duas declarações (ADR 0041).',
59
+ )
60
+ }
61
+
53
62
  if (actions === 0 && findings.length === 0) {
54
63
  const verdict = emptyScanVerdict({ hasMarker, contracts })
55
64
  // Projeto MARCADO (opus.json) sem actions passa vacuamente — server/lib/app sem
package/bin/lib/check.mjs CHANGED
@@ -51,6 +51,8 @@ const FIELD_GROUP = {
51
51
  errors: 1,
52
52
  input: 2,
53
53
  output: 2,
54
+ effect: 2,
55
+ personalData: 2,
54
56
  public: 3,
55
57
  requires: 3,
56
58
  authorize: 3,
@@ -515,6 +517,7 @@ export function parseActions(fileName, sourceText) {
515
517
  form: callee,
516
518
  name: strProp(obj, "name"),
517
519
  kind: strProp(obj, "kind"),
520
+ effect: strProp(obj, "effect"),
518
521
  keys: objKeys(obj),
519
522
  ident: constIdent(node),
520
523
  exported: isExported(node),
@@ -575,6 +578,12 @@ export function lintAction(a) {
575
578
  if (!a.kind) at("kind", `${a.form} sem \`kind\``);
576
579
  else if (!KINDS.has(a.kind))
577
580
  at("kind", `kind "${a.kind}" inválido (use simple|form|list|view)`);
581
+ // ADR 0041: list e view são servidas por GET — leitura pelo protocolo.
582
+ if ((a.kind === "list" || a.kind === "view") && a.effect === "write")
583
+ at(
584
+ "effect",
585
+ `${a.kind} é servida por GET e não pode declarar effect: 'write'; use simple ou form para escrever`,
586
+ );
578
587
 
579
588
  // field-order: os campos conhecidos devem ter group-index não-decrescente
580
589
  let lastGroup = -1;
@@ -585,7 +594,7 @@ export function lintAction(a) {
585
594
  if (g < lastGroup) {
586
595
  at(
587
596
  "field-order",
588
- `campo "${k}" fora de ordem (depois de "${lastKey}") — siga identidade→docs→input/output→authorize→handler→comportamento`,
597
+ `campo "${k}" fora de ordem (depois de "${lastKey}") — siga identidade→docs→input/output e natureza (effect, personalData)→authorize→handler→comportamento`,
589
598
  );
590
599
  break;
591
600
  }
@@ -644,6 +653,9 @@ export function checkProject(sources) {
644
653
  const actionNames = new Set();
645
654
  // `mockHandler` obsoleto (ADR 0037): contado, não reprovado — a minor ainda o aceita.
646
655
  let deprecatedMockHandlers = 0;
656
+ // Natureza da action (ADR 0041): declarações ausentes são contadas, não reprovadas, na minor.
657
+ let missingEffect = 0;
658
+ let missingPersonalData = 0;
647
659
 
648
660
  for (const { file, text } of sources) {
649
661
  const declarations = parseDataProducts(file, text);
@@ -669,6 +681,11 @@ export function checkProject(sources) {
669
681
  }
670
682
  for (const f of lintAction(item)) findings.push({ ...f, file });
671
683
  if (item.keys.includes("mockHandler")) deprecatedMockHandlers += 1;
684
+ if (item.form !== "bindAction") {
685
+ const writesByGet = item.kind === "list" || item.kind === "view";
686
+ if (!writesByGet && !item.keys.includes("effect")) missingEffect += 1;
687
+ if (!item.keys.includes("personalData")) missingPersonalData += 1;
688
+ }
672
689
  }
673
690
  }
674
691
 
@@ -775,6 +792,8 @@ export function checkProject(sources) {
775
792
  contracts,
776
793
  dataProducts: productDeclarations.length,
777
794
  deprecatedMockHandlers,
795
+ missingEffect,
796
+ missingPersonalData,
778
797
  findings,
779
798
  };
780
799
  }
@@ -1956,6 +1975,8 @@ export async function scanDir(rootDir) {
1956
1975
  contracts: checked.contracts,
1957
1976
  dataProducts: checked.dataProducts,
1958
1977
  deprecatedMockHandlers: checked.deprecatedMockHandlers,
1978
+ missingEffect: checked.missingEffect,
1979
+ missingPersonalData: checked.missingPersonalData,
1959
1980
  findings: [...checked.findings, ...uiFindings],
1960
1981
  hasMarker,
1961
1982
  };
@@ -53,6 +53,8 @@ function shapeAction(a) {
53
53
  authorize: a.authorize,
54
54
  public: a.public,
55
55
  automatable: a.automatable,
56
+ effect: a.effect ?? null,
57
+ personalData: a.personalData ?? null,
56
58
  internal: a.internal,
57
59
  successStatus: a.successStatus,
58
60
  input: a.input,
@@ -51,6 +51,8 @@
51
51
  * successStatus?: number,
52
52
  * public?: boolean,
53
53
  * automatable?: boolean, // aceita execução pelo sistema (ADR 0028)
54
+ * effect: 'read'|'write'|null, // efeito resolvido; list/view = read (ADR 0041)
55
+ * personalData: boolean|null, // devolve dado pessoal; null = não declarado
54
56
  * internal?: boolean,
55
57
  * rateLimit?: { window, max },
56
58
  * confirm?: ConfirmSpec,
@@ -375,6 +377,9 @@ function serializeAction(action, ctx) {
375
377
  authorize: serializeAuthorize(action.authorize),
376
378
  public: action.public === true,
377
379
  automatable: action.automatable === true,
380
+ // ADR 0041: `list` e `view` são leitura pelo protocolo; null = não declarado.
381
+ effect: action.kind === "list" || action.kind === "view" ? "read" : (action.effect ?? null),
382
+ personalData: typeof action.personalData === "boolean" ? action.personalData : null,
378
383
  internal: action.internal === true,
379
384
  successStatus: action.successStatus,
380
385
  input: safeToJsonSchema(action.input, ctx),
@@ -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.
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": "22.2.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",
@@ -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
 
@@ -9,6 +9,11 @@ O binding contém `handler`, `loads`, autorização dependente de dados carregad
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(
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,
@@ -852,6 +868,7 @@ export class Runtime {
852
868
  actionId,
853
869
  action: action.name,
854
870
  actionKind: action.kind,
871
+ nature: action,
855
872
  outcome: 'success',
856
873
  durationMs,
857
874
  ctx,
@@ -884,6 +901,7 @@ export class Runtime {
884
901
  actionId,
885
902
  action: action.name,
886
903
  actionKind: action.kind,
904
+ nature: action,
887
905
  outcome: 'error',
888
906
  durationMs,
889
907
  ctx,
@@ -932,6 +950,7 @@ export class Runtime {
932
950
  const cfg = normalizeAiConfig(action.ai)
933
951
  if (cfg === null) continue
934
952
  const dataProducts = [...(this.actionDataProducts.get(action.name) ?? [])].sort()
953
+ const effect = resolveActionEffect(action)
935
954
  tools.push({
936
955
  name: action.name,
937
956
  ...(action.label !== undefined
@@ -939,7 +958,23 @@ export class Runtime {
939
958
  : {}),
940
959
  description: cfg.description ?? action.description ?? action.name,
941
960
  inputSchema: action.input,
961
+ // No MCP, `destructiveHint` ausente numa escrita vale `true`. Numa escrita declarada, o valor
962
+ // vai explícito, para o cliente não tratar como destrutiva o que o Opus não trata.
963
+ ...(effect !== undefined || cfg.destructive === true
964
+ ? {
965
+ annotations: {
966
+ ...(effect !== undefined ? { readOnlyHint: effect === 'read' } : {}),
967
+ ...(effect === 'write'
968
+ ? { destructiveHint: cfg.destructive === true }
969
+ : cfg.destructive === true && effect === undefined
970
+ ? { destructiveHint: true }
971
+ : {}),
972
+ },
973
+ }
974
+ : {}),
942
975
  metadata: {
976
+ ...(effect !== undefined ? { effect } : {}),
977
+ ...(action.personalData !== undefined ? { personalData: action.personalData } : {}),
943
978
  dataProducts,
944
979
  dataProductLabels: Object.fromEntries(
945
980
  dataProducts.map((id) => [id, this.dataProducts.get(id)?.label ?? id]),
@@ -1228,6 +1263,8 @@ export class Runtime {
1228
1263
  actionId: string
1229
1264
  action: string
1230
1265
  actionKind?: ActionDef['kind']
1266
+ /** A natureza declarada (ADR 0041): efeito e dado pessoal viajam para o registro. */
1267
+ nature?: Pick<ActionDef, 'kind' | 'effect' | 'personalData'>
1231
1268
  outcome: 'success' | 'error'
1232
1269
  durationMs: number
1233
1270
  ctx: ActionContext
@@ -1253,6 +1290,15 @@ export class Runtime {
1253
1290
  }
1254
1291
  if (args.output !== undefined) record.output = args.output
1255
1292
  if (args.error !== undefined) record.error = args.error
1293
+ if (args.nature !== undefined) {
1294
+ const effect = resolveActionEffect(args.nature)
1295
+ if (effect !== undefined) record.effect = effect
1296
+ // A exposição só existe quando a action devolveu algo: erro não expõe o dado declarado, e a
1297
+ // resposta simulada do modo design (mock, cenário ou gerada) não é de ninguém.
1298
+ if (args.nature.personalData === true && args.outcome === 'success' && args.mode !== 'design') {
1299
+ record.personalData = { records: countReturnedRecords(args.nature.kind, args.output) }
1300
+ }
1301
+ }
1256
1302
  if (args.mode !== undefined) record.meta = { mode: args.mode }
1257
1303
  return record
1258
1304
  }
@@ -1750,6 +1796,15 @@ export class Runtime {
1750
1796
  // Helpers
1751
1797
  // =============================================================================
1752
1798
 
1799
+ /** Atributos do span com a natureza declarada da action (ADR 0041). */
1800
+ function natureAttributes(action: ActionDef): Record<string, string | boolean> {
1801
+ const effect = resolveActionEffect(action)
1802
+ return {
1803
+ ...(effect !== undefined ? { 'opus.action.effect': effect } : {}),
1804
+ ...(action.personalData !== undefined ? { 'opus.action.personal_data': action.personalData } : {}),
1805
+ }
1806
+ }
1807
+
1753
1808
  async function settledSuccessfully<T>(promise: Promise<T>): Promise<boolean> {
1754
1809
  try {
1755
1810
  await promise
package/src/core/types.ts CHANGED
@@ -356,6 +356,15 @@ export interface AuditRecord {
356
356
  output?: unknown
357
357
  error?: ActionError
358
358
 
359
+ /** Efeito resolvido da action (ADR 0041): `list` e `view` são sempre leitura. */
360
+ effect?: ActionEffect
361
+ /**
362
+ * Presente quando a action declara `personalData: true` e executou com sucesso (ADR 0041):
363
+ * a requisição expôs dado pessoal a quem a executou. `records` conta os registros devolvidos
364
+ * (itens da página de uma `list`, 1 para um objeto) e é `null` quando a saída não permite contar.
365
+ */
366
+ personalData?: { records: number | null }
367
+
359
368
  severity: 'info' | 'warning' | 'error'
360
369
  /** Campo legado preservado para compatibilidade com sinks/consumers existentes. */
361
370
  trace?: {
@@ -709,6 +718,12 @@ export interface ExpandSpec {
709
718
 
710
719
  export type ActionKind = 'simple' | 'form' | 'list' | 'view'
711
720
 
721
+ /**
722
+ * O que a action faz com o estado (ADR 0041). `list` e `view` são leitura pelo protocolo (`GET`);
723
+ * `simple` e `form` declaram, porque cobrem as duas coisas.
724
+ */
725
+ export type ActionEffect = 'read' | 'write'
726
+
712
727
  export type AuthorizeFn<In, Loaded = Record<string, unknown>> = (
713
728
  ctx: ActionContext,
714
729
  input: In,
@@ -810,6 +825,20 @@ export interface ActionBase<In, Out, ParsedIn = In> {
810
825
  input: Schema<In, ParsedIn>
811
826
  output: Schema<Out>
812
827
 
828
+ // — Natureza (ADR 0041) —
829
+ /**
830
+ * `read` não altera estado; `write` altera. `list` e `view` já são leitura pelo protocolo e não
831
+ * declaram (declarar `write` nelas é recusado no registro). `simple` e `form` declaram: o `kind`
832
+ * sozinho não diz se a action lê ou escreve. Declarativo — o runtime não impede o handler.
833
+ */
834
+ effect?: ActionEffect
835
+ /**
836
+ * A resposta contém dados que identificam pessoas: nome, documento, contato, placa ou texto
837
+ * livre sobre alguém. Com `true`, toda execução bem-sucedida é marcada no `AuditRecord` e no
838
+ * span, qualquer que seja o consumidor. Nunca inferido; ausente significa "não declarado".
839
+ */
840
+ personalData?: boolean
841
+
813
842
  // — Autorização (fail-closed default) —
814
843
  public?: boolean
815
844
  requires?: string | string[]
@@ -1301,12 +1330,23 @@ export interface AiTool {
1301
1330
  title?: string
1302
1331
  description: string
1303
1332
  inputSchema: unknown
1333
+ /** Anotações padrão de ferramenta do MCP, derivadas do contrato (ADR 0041). */
1334
+ annotations?: {
1335
+ /** A ferramenta não altera estado (efeito `read`). */
1336
+ readOnlyHint?: boolean
1337
+ /** A ferramenta pode destruir dados (`ai.destructive`); só faz sentido quando não é leitura. */
1338
+ destructiveHint?: boolean
1339
+ }
1304
1340
  /** Metadados de governança para o host; não fazem parte do prompt nem concedem acesso. */
1305
1341
  metadata?: {
1306
1342
  /** Identificadores técnicos dos Produtos de Dados relacionados. */
1307
1343
  dataProducts?: string[]
1308
1344
  /** Labels humanas por identificador; ausente para runtimes legados. */
1309
1345
  dataProductLabels?: Record<string, string>
1346
+ /** Efeito resolvido; ausente quando `simple`/`form` não declaram. */
1347
+ effect?: ActionEffect
1348
+ /** A action declara devolver dado pessoal; ausente quando não declarou. */
1349
+ personalData?: boolean
1310
1350
  }
1311
1351
  }
1312
1352
 
package/src/mcp/index.ts CHANGED
@@ -48,9 +48,15 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
48
48
  ...(t.title !== undefined ? { title: t.title } : {}),
49
49
  description: t.description,
50
50
  inputSchema: toJsonSchema(t.inputSchema) as { type: 'object' },
51
+ // Anotações padrão do MCP (ADR 0041): o cliente sabe se a ferramenta só lê.
52
+ ...(t.annotations !== undefined ? { annotations: t.annotations } : {}),
51
53
  _meta: {
52
54
  'com.softize.opus/data-products': t.metadata?.dataProducts ?? [],
53
55
  'com.softize.opus/data-product-labels': t.metadata?.dataProductLabels ?? {},
56
+ ...(t.metadata?.effect !== undefined ? { 'com.softize.opus/effect': t.metadata.effect } : {}),
57
+ ...(t.metadata?.personalData !== undefined
58
+ ? { 'com.softize.opus/personal-data': t.metadata.personalData }
59
+ : {}),
54
60
  },
55
61
  })),
56
62
  }))
@@ -132,7 +132,7 @@ nessa estrutura. Os grupos seguem esta sequência; dentro de cada grupo, a ordem
132
132
  ```text
133
133
  name, kind
134
134
  → label, title, summary, description, icon, color, messages, tags, examples, errors
135
- → input, output
135
+ → input, output, effect, personalData
136
136
  → public, requires, authorize, loads
137
137
  → handler, mockHandler
138
138
  → audit, confirm, invalidates, rateLimit, idempotency
@@ -146,6 +146,35 @@ Declare `input` e `output` com `z.object` e use os tipos lógicos do catálogo e
146
146
  validações: `z.object({ tag: t.slug().zod(), contato: t.email().zod() })`. O `.zod()` devolve o
147
147
  schema zod de cada tipo lógico (slug, email, phone, money…), importado de `@softize/opus/schema/zod`.
148
148
 
149
+ ## Declarar o que a action faz com os dados
150
+
151
+ Dois fatos sobre a action valem para qualquer consumidor, da interface à IA, e ficam declarados no
152
+ contrato:
153
+
154
+ - **`effect`** diz se a action só lê (`'read'`) ou altera estado (`'write'`). `list` e `view` já são
155
+ leitura, porque são servidas por `GET`, e não precisam declarar. `simple` e `form` declaram,
156
+ porque o `kind` não diz isso: uma análise sob demanda costuma ser `simple` e só ler.
157
+ - **`personalData`** diz se a resposta contém dados que identificam pessoas, como nome, documento,
158
+ contato, placa ou texto livre sobre alguém. `false` também é uma declaração.
159
+
160
+ ```ts
161
+ export const salesPerformanceContract = defineContract({
162
+ name: 'sales.performance',
163
+ kind: 'simple',
164
+ input: z.object({ period: z.string() }),
165
+ output: z.object({ total: z.number() }),
166
+ effect: 'read',
167
+ personalData: false,
168
+ })
169
+ ```
170
+
171
+ Com `personalData: true`, toda execução bem-sucedida fica marcada no registro de auditoria com a
172
+ quantidade de registros devolvidos, inclusive quando a action declara `audit: false`; nesse caso o
173
+ registro é mínimo, sem entrada nem saída. No modo design, a resposta simulada não é marcada. Um
174
+ host de IA pode usar `effect` para decidir o que oferecer ao modelo sem procedimento, e o MCP publica
175
+ a anotação `readOnlyHint`. O `opus check` avisa quantas actions ainda não declaram os dois fatos; uma
176
+ major futura passará a exigi-los.
177
+
149
178
  A autorização é fechada por padrão: sem `public: true`, a action exige usuário autenticado; com
150
179
  `authorize`, exige também a permissão declarada. `automatable: true` permite que um
151
180
  [schedule](/docs/scheduler) execute a action como o sistema, sem abri-la a chamadores anônimos.
@@ -56,6 +56,12 @@ entrada. Em `_meta`, cada tool traz os
56
56
  [Produtos de Dados](/docs/data-products) relacionados: `com.softize.opus/data-products` lista os
57
57
  identificadores e `com.softize.opus/data-product-labels`, o nome legível de cada um.
58
58
 
59
+ Quando a action declara o que faz com os dados, a tool traz também as anotações padrão do MCP:
60
+ `readOnlyHint` vem de `effect`. Numa action que escreve, `destructiveHint` vai sempre explícito, a
61
+ partir de `ai.destructive`, porque o MCP trata a ausência numa escrita como destrutiva.
62
+ Em `_meta`, `com.softize.opus/effect` repete o efeito e `com.softize.opus/personal-data` diz se a
63
+ resposta contém dado pessoal. Veja [Actions](/docs/actions).
64
+
59
65
  `CallTool` recusa com erro qualquer nome fora dessa lista; os demais seguem por `runtime.execute`.
60
66
  A exposição declarada em `ai` decide o que o cliente MCP pode executar, e a autorização do contexto
61
67
  continua valendo sobre ela. Para um acesso somente de leitura, exponha apenas actions de leitura.