@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.
- package/CHANGELOG.md +74 -1
- package/bin/cli.mjs +6 -5
- package/bin/lib/check.mjs +23 -8
- package/bin/lib/copy.mjs +1 -1
- package/bin/lib/gen-manifest.mjs +2 -0
- package/bin/lib/gen-runner.mjs +7 -0
- package/bin/lib/gen-stubs.mjs +3 -1
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +1 -1
- package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +16 -12
- package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +8 -6
- package/docs/adr/0036-tabs-declares-trigger-distribution.md +11 -8
- package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
- package/docs/adr/0038-content-level-defines-primary-presentation.md +62 -0
- package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +62 -0
- package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +60 -0
- package/docs/adr/0041-actions-declare-effect-and-personal-data.md +139 -0
- package/docs/code-style.md +1 -1
- package/docs/protocol.md +15 -2
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/references/ui-patterns.md +1 -1
- package/registry/skills/create-opus-action/SKILL.md +7 -2
- package/registry/skills/create-opus-action/references/contract-and-binding.md +10 -5
- package/registry/skills/create-opus-action/references/evaluations.md +3 -0
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +6 -0
- package/registry/templates/app/src/domains/tasks/actions/list.ts +2 -0
- package/src/core/action-nature.ts +40 -0
- package/src/core/audit.ts +12 -4
- package/src/core/contracts.ts +1 -1
- package/src/core/index.ts +2 -0
- package/src/core/runtime.ts +57 -35
- package/src/core/types.ts +45 -23
- package/src/design/index.ts +14 -3
- package/src/mcp/index.ts +6 -0
- package/src/testing/index.ts +6 -26
- package/src/ui/components/patterns/content-header.tsx +5 -9
- package/src/ui/components/patterns/list.tsx +55 -1
- package/src/ui/components/patterns/presentation.tsx +1 -1
- package/src/ui/components/primitives/icon-picker.tsx +6 -0
- package/src/ui/components/primitives/tabs.tsx +55 -87
- package/src/ui/components/primitives/toggle.tsx +1 -1
- package/src/ui/docs/content/action-list.md +13 -4
- package/src/ui/docs/content/actions.md +36 -8
- package/src/ui/docs/content/content.md +5 -6
- package/src/ui/docs/content/mcp.md +6 -0
- package/src/ui/docs/content/semantic-context.md +0 -1
- package/src/ui/docs/content/tabs.md +68 -69
- package/src/ui/docs/content/testing.md +2 -3
- package/src/ui/docs/content/toggle.md +15 -10
- package/src/ui/drivers/react.tsx +4 -3
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +2 -3
- 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.
|
package/docs/code-style.md
CHANGED
|
@@ -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`, `
|
|
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
|
@@ -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 `
|
|
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.
|
|
27
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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)
|
|
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
|
-
|
|
59
|
-
|
|
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/contracts.ts
CHANGED
|
@@ -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
|
|
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'
|
package/src/core/runtime.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|