@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 +35 -0
- package/bin/cli.mjs +10 -1
- package/bin/lib/check.mjs +22 -1
- package/bin/lib/gen-manifest.mjs +2 -0
- package/bin/lib/gen-runner.mjs +5 -0
- package/docs/adr/0041-actions-declare-effect-and-personal-data.md +139 -0
- package/docs/protocol.md +15 -2
- package/package.json +1 -1
- package/registry/skills/create-opus-action/SKILL.md +7 -2
- package/registry/skills/create-opus-action/references/contract-and-binding.md +7 -2
- 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/index.ts +2 -0
- package/src/core/runtime.ts +56 -1
- package/src/core/types.ts +40 -0
- package/src/mcp/index.ts +6 -0
- package/src/ui/docs/content/actions.md +30 -1
- package/src/ui/docs/content/mcp.md +6 -0
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 } =
|
|
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
|
};
|
package/bin/lib/gen-manifest.mjs
CHANGED
package/bin/lib/gen-runner.mjs
CHANGED
|
@@ -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
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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/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,
|
|
@@ -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.
|