@softize/opus 22.0.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/bin/cli.mjs +18 -1
  3. package/bin/lib/check.mjs +28 -2
  4. package/bin/lib/gen-manifest.mjs +2 -0
  5. package/bin/lib/gen-runner.mjs +5 -0
  6. package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
  7. package/docs/adr/0041-actions-declare-effect-and-personal-data.md +139 -0
  8. package/docs/protocol.md +15 -2
  9. package/package.json +5 -1
  10. package/registry/skills/create-opus-action/SKILL.md +7 -2
  11. package/registry/skills/create-opus-action/references/contract-and-binding.md +11 -4
  12. package/registry/skills/create-opus-action/references/evaluations.md +3 -0
  13. package/registry/skills/create-opus-action/scripts/scaffold.mjs +6 -4
  14. package/registry/skills/test-opus-action/SKILL.md +2 -2
  15. package/registry/skills/test-opus-action/references/harness.md +2 -2
  16. package/registry/templates/app/src/App.tsx +1 -1
  17. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
  18. package/registry/templates/app/src/domains/tasks/actions/list.ts +5 -5
  19. package/registry/templates/app/vite.config.ts +3 -2
  20. package/src/core/action-nature.ts +40 -0
  21. package/src/core/audit.ts +12 -4
  22. package/src/core/domain.ts +2 -2
  23. package/src/core/index.ts +2 -0
  24. package/src/core/runtime.ts +56 -1
  25. package/src/core/types.ts +46 -20
  26. package/src/design/index.ts +179 -0
  27. package/src/mcp/index.ts +6 -0
  28. package/src/testing/fake.ts +2 -2
  29. package/src/testing/index.ts +7 -3
  30. package/src/ui/docs/content/actions.md +79 -44
  31. package/src/ui/docs/content/mcp.md +6 -0
  32. package/src/ui/docs/content/testing.md +9 -25
  33. package/src/vite/design.ts +133 -23
@@ -6,5 +6,5 @@
6
6
  - `memStorage()`: adapter de storage em memória com inspeção de chaves.
7
7
  - `fake` e `fakeMany`: dados determinísticos derivados de schema.
8
8
 
9
- Prefira o binding completo em testes de execução. Um contrato sem handler só executa via
10
- `mockHandler` quando `OPUS_MODE=design`.
9
+ Teste o binding completo. Um contrato sem handler falha com `testing.no_handler`: os dados do modo
10
+ design são gerados pelo plugin `opusDesign()` e não são comportamento da action.
@@ -9,7 +9,7 @@ type TaskItem = z.infer<typeof Task>
9
9
  export function App(): React.ReactElement {
10
10
  // O front consome o CONTRATO via /api — o plugin opusDesign (vite.config.ts)
11
11
  // serve o backend no próprio dev server: dev normal executa o handler real;
12
- // `pnpm dev:design` responde com o mockHandler, isolado de qualquer backend.
12
+ // `pnpm dev:design` responde com dado gerado a partir do contrato, isolado de qualquer backend.
13
13
  const { items, isLoading, error, refetch } = useListAction<TaskItem>(taskList)
14
14
 
15
15
  return (
@@ -1,6 +1,6 @@
1
- import { afterEach, describe, expect, it } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
2
2
  import { runAction } from '@softize/opus/testing'
3
- import { Task, taskList, taskListImpl } from './list.ts'
3
+ import { taskListImpl } from './list.ts'
4
4
 
5
5
  // O harness roda o MESMO pipeline do runtime (valida input, cobra auth, valida
6
6
  // output) — teste pelo contrato, não chamando o handler à mão.
@@ -14,21 +14,4 @@ describe('task.list', () => {
14
14
  it('sem usuário, barra: a action não é pública', async () => {
15
15
  await expect(runAction(taskListImpl, {})).rejects.toMatchObject({ code: 'auth.unauthenticated' })
16
16
  })
17
-
18
- describe('modo design (o que o `pnpm dev:design` serve)', () => {
19
- afterEach(() => {
20
- // OPUS_MODE é global do processo — limpa pra não vazar entre testes.
21
- delete process.env['OPUS_MODE']
22
- })
23
-
24
- it('o contrato puro responde pelo mockHandler, com dado válido pro schema', async () => {
25
- process.env['OPUS_MODE'] = 'design'
26
- const { output } = await runAction(taskList, {}, { user: { id: 'u1' } })
27
- expect(output.items.length).toBeGreaterThan(0)
28
- // `list` pula a validação de output no pipeline — afirme item a item.
29
- for (const item of output.items) {
30
- expect(Task.safeParse(item).success).toBe(true)
31
- }
32
- })
33
- })
34
17
  })
@@ -1,14 +1,13 @@
1
1
  import { z } from 'zod'
2
2
  import { bindAction, defineContract } from '@softize/opus/core'
3
- import { fakeMany } from '@softize/opus/testing'
4
3
 
5
- /** Item do domínio-exemplo — o schema alimenta contrato, mock e testes. */
4
+ /** Item do domínio-exemplo — o schema alimenta contrato, dados de design e testes. */
6
5
  export const Task = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
7
6
 
8
7
  // O split canônico contrato↔handler: o CONTRATO é declarativo e roda nos dois
9
8
  // lados (a SPA consome via useListAction); o BINDING é server-only. Em
10
- // `pnpm dev:design` o mockHandler responde (dado fake do próprio schema);
11
- // no dev normal e em prod, o handler real do binding assume.
9
+ // `pnpm dev:design` o plugin responde com dado gerado a partir do `output`
10
+ // (ADR 0037); no dev normal e em prod, o handler real do binding assume.
12
11
  export const taskList = defineContract({
13
12
  name: 'task.list',
14
13
  kind: 'list',
@@ -18,8 +17,9 @@ export const taskList = defineContract({
18
17
  'canônico de uma action list — contrato separado do handler, paginação por cursor.',
19
18
  input: z.object({}),
20
19
  output: Task,
20
+ // Leitura pelo GET (ADR 0041): list não declara `effect`. Tarefas não identificam pessoas.
21
+ personalData: false,
21
22
  paginate: 'cursor',
22
- mockHandler: async () => ({ items: fakeMany(Task, 5), cursor: { next: null }, total: 5 }),
23
23
  })
24
24
 
25
25
  // Fixture em memória — o domínio-exemplo não tem banco; troque pelo seu repositório.
@@ -5,11 +5,12 @@ import { fileURLToPath } from 'node:url'
5
5
 
6
6
  /**
7
7
  * Modo design (`vite --mode design`, script dev:design): o runtime Opus sobe DENTRO do
8
- * dev server e os mockHandler dos contratos respondem o `/api` — preview autossuficiente,
8
+ * dev server e as actions respondem o `/api` com dado gerado a partir do `output` do contrato
9
+ * (ADR 0037; cenários específicos entram pela opção `scenarios`) — preview autossuficiente,
9
10
  * isolado de prod (o plugin desliga o `server.proxy` sozinho; prefixo ex-proxy sem
10
11
  * cobertura responde 503, nunca vaza pra porta real). Quando o app ganhar um backend real,
11
12
  * o `dev` normal proxeia pra ele (adicione `server.proxy`); o preview de design segue nos
12
- * mocks. O entry default do plugin é `./opus.config.ts` (a fonte da spec deste app).
13
+ * dados de design. O entry default do plugin é `./opus.config.ts` (a fonte da spec deste app).
13
14
  *
14
15
  * Import DINÂMICO NO TOP-LEVEL, de propósito, e em duas condições que andam juntas: (1) o
15
16
  * @softize/opus embarca TS source e o config loader padrão (esbuild bundle — o que build e
@@ -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(
@@ -36,7 +36,7 @@ import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
36
36
  * Um registrável de action num domínio: BOUND (`ActionDef`, com handler) ou
37
37
  * contrato PURO (`ActionContract`, sem handler). Um domínio de design — a
38
38
  * fatia isomorfa (ADR 0004) — registra só contratos, e em modo design o
39
- * `mockHandler` responde. Fora de design, contrato sem handler falha no
39
+ * plugin `opusDesign()` responde por eles (ADR 0037). Fora de design, contrato sem handler falha no
40
40
  * `execute` (comportamento INTENCIONAL); o tipo só deixa de exigir o cast.
41
41
  */
42
42
  export type DomainAction = ActionDef | ActionContract
@@ -205,7 +205,7 @@ function* iterateActions(
205
205
  ): IterableIterator<ActionDef> {
206
206
  if (actions === undefined) return
207
207
  // Um contrato puro (`ActionContract`, sem handler) entra como `ActionDef` no
208
- // runtime — em design o `mockHandler` responde; fora de design, a ausência de
208
+ // runtime — em design o plugin `opusDesign()` responde; fora de design, a ausência de
209
209
  // handler falha no `execute` (intencional). O cast reconcilia o tipo neste
210
210
  // boundary de normalização; ver `DomainAction`.
211
211
  if (Array.isArray(actions)) {
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[]
@@ -821,27 +850,11 @@ export interface ActionBase<In, Out, ParsedIn = In> {
821
850
 
822
851
  /**
823
852
  * Handler alternativo executado quando `process.env.OPUS_MODE === 'design'`.
824
- * Use pra prototipar UI com dados realistas sem precisar do backend pronto.
825
- *
826
- * Em modo design: runtime tenta `mockHandler` primeiro; se ausente, cai
827
- * no `handler` normal (permitindo testar pontualmente real ao lado de
828
- * mocks).
829
853
  *
830
- * Em modo default (sem env): `mockHandler` é ignorado. Útil pra apagar
831
- * em revisão ou manter pro Designer reusar.
832
- *
833
- * Pattern recomendado pra ações que só têm mock durante dev:
834
- *
835
- * ```ts
836
- * defineAction({
837
- * name: 'ticket.list',
838
- * // ...
839
- * mockHandler: async () => MOCK_TICKETS,
840
- * handler: () => {
841
- * throw new Error('handler real ainda não implementado — use OPUS_MODE=design')
842
- * },
843
- * })
844
- * ```
854
+ * @deprecated Desde a 22.1.0 (ADR 0037); sai na próxima major. No modo design, a action sem
855
+ * mock já responde com dado gerado a partir do `output`; quando o design precisar de uma resposta
856
+ * específica, declare um cenário fora do contrato com `defineDesignScenario`, de
857
+ * `@softize/opus/design`, e aponte a opção `scenarios` do `opusDesign()` para ele.
845
858
  */
846
859
  mockHandler?: HandlerFn<ParsedIn, Out>
847
860
 
@@ -919,6 +932,8 @@ export interface ListAction<In, Out, ParsedIn = In>
919
932
  /**
920
933
  * Mesma semântica de `ActionBase.mockHandler`, mas devolve `Paginated<Out>`
921
934
  * (search retorna lista paginada, não item solto).
935
+ *
936
+ * @deprecated Desde a 22.1.0 (ADR 0037); ver `ActionBase.mockHandler`.
922
937
  */
923
938
  mockHandler?: (
924
939
  ctx: ActionContext,
@@ -1315,12 +1330,23 @@ export interface AiTool {
1315
1330
  title?: string
1316
1331
  description: string
1317
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
+ }
1318
1340
  /** Metadados de governança para o host; não fazem parte do prompt nem concedem acesso. */
1319
1341
  metadata?: {
1320
1342
  /** Identificadores técnicos dos Produtos de Dados relacionados. */
1321
1343
  dataProducts?: string[]
1322
1344
  /** Labels humanas por identificador; ausente para runtimes legados. */
1323
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
1324
1350
  }
1325
1351
  }
1326
1352
 
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Dados do modo design fora do contrato (ADR 0037).
3
+ *
4
+ * No modo design, toda action responde: sem cenário, a resposta é gerada pelo `fake` a partir do
5
+ * `output` do contrato. Quando o design precisa de coerência (um item em cada situação, algo criado
6
+ * que aparece na lista), um cenário registrado pelo nome da action vence a resposta padrão.
7
+ *
8
+ * O módulo é isomórfico e não conhece Vite: o plugin `opusDesign()` carrega os cenários e liga as
9
+ * respostas ao runtime de design.
10
+ */
11
+
12
+ import { z } from 'zod'
13
+ import type { ActionContext, InferOutput, Paginated } from '../core/types.ts'
14
+ import { fake, fakeMany } from '../testing/fake.ts'
15
+
16
+ // =============================================================================
17
+ // Cenários
18
+ // =============================================================================
19
+
20
+ // `Symbol.for`: o plugin roda no processo do Vite e carrega os cenários pelo pipeline SSR, então
21
+ // a marca precisa sobreviver a duas instâncias do módulo.
22
+ const SCENARIO = Symbol.for('@softize/opus/design-scenario')
23
+
24
+ type ContractKind = 'simple' | 'form' | 'list' | 'view'
25
+
26
+ interface ScenarioContract {
27
+ name: string
28
+ kind: ContractKind
29
+ input: unknown
30
+ output: unknown
31
+ }
32
+
33
+ type ScenarioInput<C> = C extends { input: infer S } ? InferOutput<S> : never
34
+
35
+ type ScenarioOutput<C> = C extends { output: infer S }
36
+ ? C extends { kind: 'list' }
37
+ ? Paginated<InferOutput<S>>
38
+ : InferOutput<S>
39
+ : never
40
+
41
+ /** Resposta de um cenário: o valor devolvido, ou uma função da entrada já validada. */
42
+ export type DesignScenarioResponse<C> =
43
+ | ScenarioOutput<C>
44
+ | ((input: ScenarioInput<C>, ctx: ActionContext) => ScenarioOutput<C> | Promise<ScenarioOutput<C>>)
45
+
46
+ /** Cenário de design ligado a uma action pelo nome. */
47
+ export interface DesignScenario {
48
+ readonly [SCENARIO]: true
49
+ readonly action: string
50
+ respond(input: unknown, ctx: ActionContext): Promise<unknown>
51
+ }
52
+
53
+ /**
54
+ * Declara a resposta de uma action no modo design, fora do contrato. A resposta é tipada pela
55
+ * entrada e pela saída do contrato; em `list`, é a página completa (`items`, `cursor`, `total`).
56
+ */
57
+ export function defineDesignScenario<C extends ScenarioContract>(
58
+ contract: C,
59
+ // NoInfer: o contrato é inferido só do primeiro argumento. Sem isso, a resposta entra na
60
+ // inferência e os literais do retorno de uma função alargam (`'closed'` vira `string`).
61
+ response: NoInfer<DesignScenarioResponse<C>>,
62
+ ): DesignScenario {
63
+ const respond =
64
+ typeof response === 'function'
65
+ ? async (input: unknown, ctx: ActionContext): Promise<unknown> =>
66
+ (response as (input: unknown, ctx: ActionContext) => unknown)(input, ctx)
67
+ : async (): Promise<unknown> => response
68
+ return { [SCENARIO]: true, action: contract.name, respond }
69
+ }
70
+
71
+ /** `true` quando o valor foi criado por `defineDesignScenario`. */
72
+ export function isDesignScenario(value: unknown): value is DesignScenario {
73
+ return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[SCENARIO] === true
74
+ }
75
+
76
+ // =============================================================================
77
+ // Resposta padrão
78
+ // =============================================================================
79
+
80
+ /** Itens por página da listagem padrão quando a entrada não pede `limit`. */
81
+ export const DESIGN_PAGE_SIZE = 20
82
+
83
+ // Teto do `limit` atendido: o maior tamanho de página que o `ActionList` oferece.
84
+ const DESIGN_MAX_PAGE_SIZE = 100
85
+
86
+ // Três páginas: o bastante para a paginação do preview ter para onde ir.
87
+ const DESIGN_PAGES = 3
88
+
89
+ const PAGINATION_KEYS = new Set(['page', 'cursor', 'offset', 'limit'])
90
+
91
+ interface DesignActionShape {
92
+ name: string
93
+ kind: ContractKind
94
+ output?: unknown
95
+ paginate?: 'cursor' | 'offset' | false
96
+ }
97
+
98
+ /**
99
+ * Resposta padrão do modo design: dado gerado pelo `fake` a partir do `output`, com semente
100
+ * derivada do nome da action e da entrada. A mesma requisição recebe a mesma resposta.
101
+ *
102
+ * - `list` devolve três páginas do tamanho pedido em `limit` ({@link DESIGN_PAGE_SIZE} quando a
103
+ * entrada não pede) e respeita a paginação declarada; busca e filtros não filtram o dado gerado.
104
+ * - Nas demais, a saída ecoa o `id` da entrada quando o schema da saída aceita esse valor.
105
+ * - Saída que não é schema zod não tem gerador: a resposta é `null`.
106
+ */
107
+ export function designResponse(action: DesignActionShape, input: unknown): unknown {
108
+ const output = action.output
109
+ if (!(output instanceof z.ZodType)) return null
110
+ const seed = `${action.name}:${canonicalJson(withoutPagination(input))}`
111
+
112
+ if (action.kind === 'list') {
113
+ const paginate = action.paginate ?? 'cursor'
114
+ const size = pageSize(input)
115
+ if (paginate === false) {
116
+ return { items: fakeMany(output, size, { seed }), cursor: { next: null }, total: size }
117
+ }
118
+ // O `limit` fica fora da semente: mudar o tamanho da página redistribui os mesmos itens.
119
+ const all = fakeMany(output, size * DESIGN_PAGES, { seed })
120
+ const page = requestedPage(input, size)
121
+ const start = (page - 1) * size
122
+ const next = paginate === 'cursor' && page < DESIGN_PAGES ? `design:${page + 1}` : null
123
+ const prev = paginate === 'cursor' && page > 1 ? `design:${page - 1}` : null
124
+ return {
125
+ items: all.slice(start, start + size),
126
+ cursor: { next, prev },
127
+ total: all.length,
128
+ } satisfies Paginated<unknown>
129
+ }
130
+
131
+ const value = fake(output, { seed })
132
+ const inputId = isRecord(input) ? input.id : undefined
133
+ if (inputId !== undefined && isRecord(value) && 'id' in value) {
134
+ // Só ecoa quando a saída continua válida: `id` numérico ou uuid na saída não aceita qualquer
135
+ // valor da entrada, e a validação de output do runtime recusaria a resposta.
136
+ const echoed = { ...value, id: inputId }
137
+ if (output.safeParse(echoed).success) return echoed
138
+ }
139
+ return value
140
+ }
141
+
142
+ function pageSize(input: unknown): number {
143
+ const limit = isRecord(input) && typeof input.limit === 'number' ? Math.trunc(input.limit) : DESIGN_PAGE_SIZE
144
+ return Math.min(Math.max(1, limit), DESIGN_MAX_PAGE_SIZE)
145
+ }
146
+
147
+ /** Página pedida, de 1 em diante; além da última página, a lista vem vazia. */
148
+ function requestedPage(input: unknown, size: number): number {
149
+ if (!isRecord(input)) return 1
150
+ const cursor = typeof input.cursor === 'string' ? /^design:(\d+)$/.exec(input.cursor) : null
151
+ const fromCursor = cursor === null ? undefined : Number(cursor[1])
152
+ const fromOffset = typeof input.offset === 'number' ? Math.floor(input.offset / size) + 1 : undefined
153
+ const fromPage = typeof input.page === 'number' ? input.page : undefined
154
+ return Math.max(1, Math.trunc(fromCursor ?? fromPage ?? fromOffset ?? 1))
155
+ }
156
+
157
+ function withoutPagination(input: unknown): unknown {
158
+ if (!isRecord(input)) return input
159
+ return Object.fromEntries(Object.entries(input).filter(([key]) => !PAGINATION_KEYS.has(key)))
160
+ }
161
+
162
+ /** JSON com chaves ordenadas: a mesma entrada produz a mesma semente, qualquer que seja a ordem. */
163
+ function canonicalJson(value: unknown): string {
164
+ if (typeof value === 'bigint') return `${value}n`
165
+ if (value instanceof Date) return JSON.stringify(value.toISOString())
166
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
167
+ if (isRecord(value)) {
168
+ const entries = Object.keys(value)
169
+ .filter((key) => value[key] !== undefined)
170
+ .sort()
171
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
172
+ return `{${entries.join(',')}}`
173
+ }
174
+ return JSON.stringify(value) ?? 'null'
175
+ }
176
+
177
+ function isRecord(value: unknown): value is Record<string, unknown> {
178
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
179
+ }
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
  }))
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `fake` — dado sintético DETERMINÍSTICO a partir de um schema zod. É a matéria-prima do
3
- * modo design: um `mockHandler` devolve `fake(entity)`/`fakeMany(entity, n)` e o preview mostra
4
- * a UI com dado realista, sem backend nem banco (o design-mode do runtime nem toca `ctx.db`).
3
+ * modo design: a resposta gerada pelo plugin `opusDesign()` (ADR 0037) sai de `fake`/`fakeMany`
4
+ * sobre o `output` do contrato, e o preview mostra a UI com dado realista, sem backend nem banco.
5
5
  *
6
6
  * DETERMINISMO: PRNG próprio semeado (mulberry32) — mesma `seed` → mesma saída, sempre. Nunca
7
7
  * `Math.random` (o preview tem que ser reproduzível entre execuções). O default é semeado por