@softize/opus 15.0.1 → 15.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.
@@ -0,0 +1,66 @@
1
+ ---
2
+ title: Produtos de Dados
3
+ order: 7
4
+ ---
5
+
6
+ # Produtos de Dados
7
+
8
+ Um Produto de Dados dá identidade governada ao conjunto que uma ou mais Actions disponibilizam.
9
+ Ele não é uma query, tabela ou view: essas são implementações possíveis. A fronteira de execução,
10
+ recorte e autorização continua sendo cada Action.
11
+
12
+ ```ts
13
+ import { defineDataProduct, defineDomain } from '@softize/opus'
14
+
15
+ export const salesLeads = defineDataProduct({
16
+ id: 'sales.leads',
17
+ version: 1,
18
+ label: 'Leads comerciais',
19
+ description: 'Leads e seus resultados comerciais.',
20
+ owner: 'Vendas',
21
+ grain: 'Um lead.',
22
+ classification: 'internal',
23
+ nature: 'real',
24
+ sources: [{ id: 'followize', label: 'Followize' }],
25
+ entities: ['Lead'],
26
+ access: {
27
+ contexts: ['sales'],
28
+ organizationalScopes: ['unit', 'team'],
29
+ },
30
+ interfaces: ['sale.list', 'sales.performance'],
31
+ })
32
+
33
+ export const salesDomain = defineDomain({
34
+ name: 'sales',
35
+ entities: { Lead },
36
+ actions: { saleList, salesPerformance },
37
+ dataProducts: { salesLeads },
38
+ })
39
+ ```
40
+
41
+ `interfaces` e `entities` usam os nomes declarados, não nomes de arquivo ou tabela. O registro do
42
+ domínio e `opus check` detectam relações inexistentes. `sources` identifica sistemas ou conjuntos
43
+ upstream; uma Fonte não precisa ser uma Entity Opus.
44
+
45
+ ## Acesso e segurança
46
+
47
+ `access` é documentação verificável do alcance esperado. Ele ajuda catálogo e revisão a perceber
48
+ que uma interface deveria respeitar, por exemplo, unidade e equipe. Não é RLS nem autorização
49
+ automática. Toda interface precisa aplicar seus próprios `requires`, `authorize` e recortes no
50
+ handler/repositório, inclusive quando for chamada por IA ou MCP.
51
+
52
+ ## Projeções
53
+
54
+ `opus gen` publica os produtos no `.opus/manifest.json`. Actions expostas como tools carregam os
55
+ IDs em `metadata.dataProducts`; no MCP, a chave é
56
+ `_meta['com.softize.opus/data-products']`. Consumidores devem registrar apenas produtos associados
57
+ a chamadas concluídas com sucesso.
58
+
59
+ O Opus Lens usa o manifest para mostrar a linhagem declarada entre Fontes, entities, produtos e
60
+ Actions. Nenhuma dessas projeções é fonte autoritativa: corrija a declaração e gere novamente.
61
+
62
+ ## Ciclo de vida
63
+
64
+ Incremente `version` quando a forma observável do produto mudar. Para substituí-lo, mantenha a
65
+ declaração anterior com `status: 'deprecated'` e `replacedBy` apontando para o novo ID durante a
66
+ migração. Produto ativo não declara `replacedBy`.
package/docs/protocol.md CHANGED
@@ -2084,6 +2084,18 @@ export const crm = defineDomain({
2084
2084
 
2085
2085
  ---
2086
2086
 
2087
+ ### 15.1 Produtos de Dados
2088
+
2089
+ `defineDataProduct` declara um conjunto governado consumível por pessoas, relatórios ou agentes.
2090
+ Ele liga Fontes e entities às Actions que formam sua interface, sem conter query, driver ou regra
2091
+ de autorização. O formato completo, o ciclo de vida e as projeções estão em
2092
+ [Produtos de Dados](data-products.md); a decisão arquitetural está na
2093
+ [ADR 0012](adr/0012-data-products-are-first-class-declarations.md).
2094
+
2095
+ A declaração de `access` é descritiva. O pipeline da Action permanece a única fronteira
2096
+ executável: `requires`, `authorize`, carregamentos e recortes continuam sendo aplicados em toda
2097
+ chamada, inclusive por IA e MCP.
2098
+
2087
2099
  ## 16. Glossário
2088
2100
 
2089
2101
  - **Action** — unidade declarativa que flui pelo pipeline opus. Ver §2.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "15.0.1",
3
+ "version": "15.2.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -212,6 +212,17 @@
212
212
  "bin": {
213
213
  "opus": "bin/cli.mjs"
214
214
  },
215
+ "scripts": {
216
+ "postinstall": "node ./bin/lib/postinstall.mjs",
217
+ "copy:check": "node ./bin/cli.mjs copy --check",
218
+ "typecheck": "tsc --noEmit",
219
+ "test": "vitest run",
220
+ "test:watch": "vitest",
221
+ "test:cov": "vitest run --coverage",
222
+ "registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
223
+ "release": "bash ./scripts/release.sh",
224
+ "release:local": "bash ./scripts/release.sh --local"
225
+ },
215
226
  "dependencies": {
216
227
  "@modelcontextprotocol/sdk": "^1.29.0",
217
228
  "@radix-ui/react-checkbox": "^1.1.3",
@@ -357,21 +368,11 @@
357
368
  "vitest": "^2.1.0",
358
369
  "zod": "^3.24.0"
359
370
  },
371
+ "packageManager": "pnpm@9.0.0",
360
372
  "repository": {
361
373
  "type": "git",
362
374
  "url": "git+https://github.com/softize-dev/opus.git",
363
375
  "directory": "packages/opus"
364
376
  },
365
- "homepage": "https://opus.softize.com.br",
366
- "scripts": {
367
- "postinstall": "node ./bin/lib/postinstall.mjs",
368
- "copy:check": "node ./bin/cli.mjs copy --check",
369
- "typecheck": "tsc --noEmit",
370
- "test": "vitest run",
371
- "test:watch": "vitest",
372
- "test:cov": "vitest run --coverage",
373
- "registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
374
- "release": "bash ./scripts/release.sh",
375
- "release:local": "bash ./scripts/release.sh --local"
376
- }
377
- }
377
+ "homepage": "https://opus.softize.com.br"
378
+ }
@@ -10,6 +10,9 @@
10
10
  para o caso direto.
11
11
  Não misturar as duas formas. Alterar `className` apenas quando a superfície tiver uma necessidade
12
12
  real de largura; não reconstruir esse container em cada rota.
13
+ - Fora de `PageShell`, a composição explícita também pode trocar `PageHeader` por `PageIntro` quando
14
+ o conteúdo precisar somente de título, descrição e ações, sem retorno ou breadcrumb. `PageIntro`
15
+ dá mais presença ao título e não é uma abreviação visual de `PageHeader`.
13
16
  - `PageHeader` é a única região de cabeçalho da página. O default acompanha o container;
14
17
  `variant="bar"` apresenta a mesma anatomia como faixa compacta no topo. Em uma subpágina simples,
15
18
  `PageBack` recebe o destino pai explícito: aparece acima do título no default e como icon-only com
@@ -17,10 +20,17 @@
17
20
  `PageNavigation`. Não combine retorno e breadcrumb nem crie um chrome paralelo para uma `Page`.
18
21
  Ações com texto na barra usam `Button size="sm"`; ações somente com ícone usam `icon-sm`.
19
22
  `PageActionsTarget` fica reservado a workspaces imersivos que já possuam chrome próprio.
23
+ - Quando shell e rota conhecem partes diferentes da mesma página, use `PageShell` ao redor da rota.
24
+ O shell fornece `navigation`; a `Page` descendente continua declarando `title`, `description` e
25
+ `actions`. O Opus mantém a barra de `3rem`, projeta as ações nela e apresenta título e descrição
26
+ como `PageIntro` no conteúdo. Na forma explícita dentro do shell, use
27
+ `Page > PageIntro (PageTitle, PageDescription?, PageActions?) + PageBody`. Não monte `PaneHeader`,
28
+ portal ou seletor global para reconstruir essa composição.
20
29
  - `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
21
30
  forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
22
- explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho some inteiro
23
- incluindo o `PageBack` —, o estado ocupa a área disponível e seu título assume o heading
31
+ explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho da Page isolada
32
+ some inteiro — incluindo o `PageBack`; dentro de `PageShell`, somente `PageIntro` some e a barra
33
+ permanece. O estado ocupa a área disponível e seu título assume o heading
24
34
  principal, inclusive quando um componente intermediário renderiza o estado. Uma subpágina que
25
35
  dependa do retorno ao pai oferece essa saída pelo `action` do próprio `PageState`. O erro mantém
26
36
  `role="alert"`, usa a mesma composição central e sem moldura dos demais estados e apresenta a
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Um Produto de Dados é um dataset governado e estável que uma ou mais Actions expõem.
3
+ * A declaração descreve o produto; autorização e recorte continuam pertencendo às Actions.
4
+ */
5
+
6
+ export type DataProductStatus = 'active' | 'deprecated'
7
+
8
+ export interface DataProductSource {
9
+ /** Identificador técnico estável da Fonte. */
10
+ id: string
11
+ /** Nome humano projetado por catálogos e lentes. */
12
+ label: string
13
+ description?: string
14
+ }
15
+
16
+ export interface DataProductAccess {
17
+ /** Contextos de dados normalmente exigidos pelas interfaces do produto. Descritivo. */
18
+ contexts: readonly string[]
19
+ /** Eixos organizacionais que as Actions precisam considerar, como unit e team. Descritivo. */
20
+ organizationalScopes: readonly string[]
21
+ }
22
+
23
+ export interface DataProductConfig {
24
+ /** Identidade estável e namespaced, como `sales.leads`. */
25
+ id: string
26
+ /** Versão semântica inteira da forma observável do produto. */
27
+ version: number
28
+ label: string
29
+ description: string
30
+ /** Responsável técnico ou de negócio pelo produto. */
31
+ owner: string
32
+ /** O que uma linha ou observação representa. */
33
+ grain: string
34
+ /** Classificação definida pelo produto, como `internal` ou `personal`. */
35
+ classification: string
36
+ /** Natureza definida pelo produto, como `real`, `demo` ou `synthetic`. */
37
+ nature: string
38
+ sources: readonly DataProductSource[]
39
+ /** Entidades Opus que materializam ou estruturam o produto. */
40
+ entities: readonly string[]
41
+ access: DataProductAccess
42
+ /** Nomes de Actions que disponibilizam este produto. */
43
+ interfaces: readonly string[]
44
+ status?: DataProductStatus
45
+ /** Produto sucessor quando este estiver descontinuado. */
46
+ replacedBy?: string
47
+ }
48
+
49
+ const ID_RE = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/
50
+
51
+ function nonEmpty(value: unknown, field: string): void {
52
+ if (typeof value !== 'string' || value.trim() === '') {
53
+ throw new TypeError(`DataProduct "${field}" deve ser string não-vazia`)
54
+ }
55
+ }
56
+
57
+ /** Declara e valida um Produto de Dados sem introduzir dependência de banco ou runtime. */
58
+ export function defineDataProduct<const T extends DataProductConfig>(config: T): T {
59
+ if (!ID_RE.test(config.id)) {
60
+ throw new TypeError(`DataProduct id "${config.id}" deve ser namespaced e casar com ${ID_RE.source}`)
61
+ }
62
+ if (!Number.isInteger(config.version) || config.version < 1) {
63
+ throw new TypeError('DataProduct "version" deve ser inteiro positivo')
64
+ }
65
+ for (const field of ['label', 'description', 'owner', 'grain', 'classification', 'nature'] as const) {
66
+ nonEmpty(config[field], field)
67
+ }
68
+ if (!Array.isArray(config.sources) || config.sources.length === 0) {
69
+ throw new TypeError('DataProduct "sources" deve declarar ao menos uma Fonte')
70
+ }
71
+ const sourceIds = new Set<string>()
72
+ for (const source of config.sources) {
73
+ nonEmpty(source.id, 'sources[].id')
74
+ nonEmpty(source.label, 'sources[].label')
75
+ if (source.description !== undefined) nonEmpty(source.description, 'sources[].description')
76
+ if (sourceIds.has(source.id)) throw new TypeError(`DataProduct Fonte "${source.id}" duplicada`)
77
+ sourceIds.add(source.id)
78
+ }
79
+ if (!Array.isArray(config.entities)) {
80
+ throw new TypeError('DataProduct "entities" deve ser uma lista')
81
+ }
82
+ if (new Set(config.entities).size !== config.entities.length) {
83
+ throw new TypeError(`DataProduct "${config.id}" possui Entity duplicada`)
84
+ }
85
+ for (const entity of config.entities) nonEmpty(entity, 'entities[]')
86
+ if (!Array.isArray(config.interfaces) || config.interfaces.length === 0) {
87
+ throw new TypeError('DataProduct "interfaces" deve declarar ao menos uma Action')
88
+ }
89
+ if (new Set(config.interfaces).size !== config.interfaces.length) {
90
+ throw new TypeError(`DataProduct "${config.id}" possui interface duplicada`)
91
+ }
92
+ for (const action of config.interfaces) nonEmpty(action, 'interfaces[]')
93
+ if (!Array.isArray(config.access?.contexts) || !Array.isArray(config.access?.organizationalScopes)) {
94
+ throw new TypeError('DataProduct "access" deve declarar contexts e organizationalScopes')
95
+ }
96
+ for (const context of config.access.contexts) nonEmpty(context, 'access.contexts[]')
97
+ for (const scope of config.access.organizationalScopes) nonEmpty(scope, 'access.organizationalScopes[]')
98
+ if (config.status !== undefined && config.status !== 'active' && config.status !== 'deprecated') {
99
+ throw new TypeError('DataProduct "status" deve ser "active" ou "deprecated"')
100
+ }
101
+ if (config.status === 'deprecated') {
102
+ nonEmpty(config.replacedBy, 'replacedBy')
103
+ if (!ID_RE.test(config.replacedBy!)) {
104
+ throw new TypeError(`DataProduct replacedBy "${config.replacedBy}" deve ser namespaced e casar com ${ID_RE.source}`)
105
+ }
106
+ if (config.replacedBy === config.id) throw new TypeError('DataProduct não pode substituir a si mesmo')
107
+ } else if (config.replacedBy !== undefined) {
108
+ throw new TypeError('DataProduct ativo não pode declarar "replacedBy"')
109
+ }
110
+ return config
111
+ }
112
+
113
+ export function isDataProduct(value: unknown): value is DataProductConfig {
114
+ if (typeof value !== 'object' || value === null) return false
115
+ try {
116
+ defineDataProduct(value as DataProductConfig)
117
+ return true
118
+ } catch {
119
+ return false
120
+ }
121
+ }
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { ActionContract } from './contracts.ts'
25
+ import type { DataProductConfig } from './data-product.ts'
25
26
  import { error } from './errors.ts'
26
27
  import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
27
28
 
@@ -58,6 +59,9 @@ export interface DomainConfig {
58
59
  * estrutural+negócio que vai pro manifest. */
59
60
  entities?: Record<string, unknown>
60
61
 
62
+ /** Produtos de Dados governados disponibilizados pelas Actions deste domínio. */
63
+ dataProducts?: Record<string, DataProductConfig> | DataProductConfig[]
64
+
61
65
  /** Repository class do domínio. */
62
66
  repository?: unknown
63
67
 
@@ -90,6 +94,7 @@ export interface FlattenedDomain {
90
94
  actions: ActionDef[]
91
95
  reactions: Array<ReactionDef<any>>
92
96
  schedules: ScheduleDef[]
97
+ dataProducts: DataProductConfig[]
93
98
  }
94
99
 
95
100
  // =============================================================================
@@ -150,6 +155,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
150
155
  'subdomains' in v ||
151
156
  'dicts' in v ||
152
157
  'entities' in v ||
158
+ 'dataProducts' in v ||
153
159
  'repository' in v ||
154
160
  'service' in v
155
161
  )
@@ -160,7 +166,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
160
166
  * pro `Runtime.register`.
161
167
  */
162
168
  export function flattenDomain(domain: DomainConfig): FlattenedDomain {
163
- const out: FlattenedDomain = { actions: [], reactions: [], schedules: [] }
169
+ const out: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
164
170
  collect(domain, out)
165
171
  return out
166
172
  }
@@ -179,6 +185,12 @@ function collect(domain: DomainConfig, out: FlattenedDomain): void {
179
185
  if (domain.schedules !== undefined) {
180
186
  for (const schedule of domain.schedules) out.schedules.push(schedule)
181
187
  }
188
+ if (domain.dataProducts !== undefined) {
189
+ const products = Array.isArray(domain.dataProducts)
190
+ ? domain.dataProducts
191
+ : Object.values(domain.dataProducts)
192
+ for (const product of products) out.dataProducts.push(product)
193
+ }
182
194
  if (domain.subdomains !== undefined) {
183
195
  for (const sub of domain.subdomains) collect(sub, out)
184
196
  }
@@ -257,7 +269,7 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
257
269
  // — actions: nomes únicos no domínio (e subdomínios) —
258
270
  // (Roda *depois* dos subdomains: assim erros de nome em subs aparecem
259
271
  // primeiro, antes de detectar duplicatas cruzadas.)
260
- const collected: FlattenedDomain = { actions: [], reactions: [], schedules: [] }
272
+ const collected: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
261
273
  collect(domain, collected)
262
274
 
263
275
  const actionNames = new Set<string>()
@@ -271,6 +283,48 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
271
283
  }
272
284
  actionNames.add(action.name)
273
285
  }
286
+
287
+ const entityNames = new Set<string>()
288
+ const collectEntityNames = (current: DomainConfig): void => {
289
+ for (const [key, raw] of Object.entries(current.entities ?? {})) {
290
+ const declared = typeof raw === 'object' && raw !== null && 'name' in raw
291
+ ? (raw as { name?: unknown }).name
292
+ : undefined
293
+ entityNames.add(typeof declared === 'string' && declared !== '' ? declared : key)
294
+ }
295
+ for (const subdomain of current.subdomains ?? []) collectEntityNames(subdomain)
296
+ }
297
+ collectEntityNames(domain)
298
+
299
+ const productIds = new Set<string>()
300
+ for (const product of collected.dataProducts) {
301
+ if (productIds.has(product.id)) {
302
+ throw error({
303
+ code: 'domain.duplicate_data_product',
304
+ category: 'internal',
305
+ message: `DataProduct "${product.id}" duplicado em "${fullPathStr}"`,
306
+ })
307
+ }
308
+ productIds.add(product.id)
309
+ for (const actionName of product.interfaces) {
310
+ if (!actionNames.has(actionName)) {
311
+ throw error({
312
+ code: 'domain.data_product_interface_not_found',
313
+ category: 'internal',
314
+ message: `DataProduct "${product.id}" referencia a Action inexistente "${actionName}"`,
315
+ })
316
+ }
317
+ }
318
+ for (const entityName of product.entities) {
319
+ if (!entityNames.has(entityName)) {
320
+ throw error({
321
+ code: 'domain.data_product_entity_not_found',
322
+ category: 'internal',
323
+ message: `DataProduct "${product.id}" referencia a Entity inexistente "${entityName}"`,
324
+ })
325
+ }
326
+ }
327
+ }
274
328
  }
275
329
 
276
330
  /**
package/src/core/index.ts CHANGED
@@ -200,6 +200,15 @@ export { defineSchedule, isSchedule } from './schedules.ts'
200
200
  export { defineDomain, flattenDomain, isDomainConfig } from './domain.ts'
201
201
  export type { DomainConfig, FlattenedDomain } from './domain.ts'
202
202
 
203
+ // — Produtos de Dados ————————————————————————————————————————————————————————
204
+ export { defineDataProduct, isDataProduct } from './data-product.ts'
205
+ export type {
206
+ DataProductAccess,
207
+ DataProductConfig,
208
+ DataProductSource,
209
+ DataProductStatus,
210
+ } from './data-product.ts'
211
+
203
212
  // — Audit —————————————————————————————————————————————————————————————————————
204
213
  export { AuditEmitter } from './audit.ts'
205
214
 
@@ -16,6 +16,7 @@ import { isBackgroundAction } from './actions.ts'
16
16
  import { isSchedule } from './schedules.ts'
17
17
  import { collectDomainWarnings, flattenDomain, isDomainConfig } from './domain.ts'
18
18
  import type { DomainConfig } from './domain.ts'
19
+ import type { DataProductConfig } from './data-product.ts'
19
20
  import { AuditEmitter } from './audit.ts'
20
21
  import { error, isActionError, normalizeError } from './errors.ts'
21
22
  import { normalizeTraceContext } from './trace.ts'
@@ -254,6 +255,8 @@ export class Runtime {
254
255
  private readonly actions = new Map<string, ActionDef>()
255
256
  private readonly reactions = new Map<string, ReactionDef<any>>()
256
257
  private readonly schedules = new Map<string, ScheduleDef>()
258
+ private readonly dataProducts = new Map<string, DataProductConfig>()
259
+ private readonly actionDataProducts = new Map<string, Set<string>>()
257
260
 
258
261
  /**
259
262
  * Map entidade → resolver, usado pra carregar specs DSL de loads
@@ -315,8 +318,8 @@ export class Runtime {
315
318
  for (const item of items) {
316
319
  if (isDomainConfig(item)) {
317
320
  collectDomainWarnings(item)
318
- const { actions, reactions, schedules } = flattenDomain(item)
319
- this.registerFlat(actions, reactions, schedules)
321
+ const { actions, reactions, schedules, dataProducts } = flattenDomain(item)
322
+ this.registerFlat(actions, reactions, schedules, dataProducts)
320
323
  } else if (isSchedule(item)) {
321
324
  this.registerSchedules([item])
322
325
  } else if (isReaction(item)) {
@@ -356,10 +359,31 @@ export class Runtime {
356
359
  actions: ActionDef[],
357
360
  reactions: Array<ReactionDef<any>>,
358
361
  schedules: ScheduleDef[],
362
+ dataProducts: DataProductConfig[],
359
363
  ): void {
360
364
  this.registerActions(actions)
361
365
  this.registerReactions(reactions)
362
366
  this.registerSchedules(schedules)
367
+ this.registerDataProducts(dataProducts)
368
+ }
369
+
370
+ private registerDataProducts(products: DataProductConfig[]): void {
371
+ for (const product of products) {
372
+ if (this.dataProducts.has(product.id)) {
373
+ throw error({
374
+ code: 'runtime.duplicate_data_product',
375
+ category: 'internal',
376
+ message: `DataProduct "${product.id}" registrado duas vezes`,
377
+ })
378
+ }
379
+ this.dataProducts.set(product.id, product)
380
+ for (const action of product.interfaces) {
381
+ const related = this.actionDataProducts.get(action) ?? new Set<string>()
382
+ related.add(product.id)
383
+ this.actionDataProducts.set(action, related)
384
+ }
385
+ }
386
+ this.aiToolsCache = null
363
387
  }
364
388
 
365
389
  private registerActions(actions: ActionDef[]): void {
@@ -795,6 +819,7 @@ export class Runtime {
795
819
  name: action.name,
796
820
  description: cfg.description ?? action.description ?? action.name,
797
821
  inputSchema: action.input,
822
+ metadata: { dataProducts: [...(this.actionDataProducts.get(action.name) ?? [])].sort() },
798
823
  })
799
824
  }
800
825
  this.aiToolsCache = tools
package/src/core/types.ts CHANGED
@@ -1261,6 +1261,8 @@ export interface AiTool {
1261
1261
  name: string
1262
1262
  description: string
1263
1263
  inputSchema: unknown
1264
+ /** Metadados de governança para o host; não fazem parte do prompt nem concedem acesso. */
1265
+ metadata?: { dataProducts?: string[] }
1264
1266
  }
1265
1267
 
1266
1268
  /** Turno de conversa (histórico multi-turn do chat). */
package/src/mcp/index.ts CHANGED
@@ -46,6 +46,7 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
46
46
  name: t.name,
47
47
  description: t.description,
48
48
  inputSchema: toJsonSchema(t.inputSchema) as { type: 'object' },
49
+ _meta: { 'com.softize.opus/data-products': t.metadata?.dataProducts ?? [] },
49
50
  })),
50
51
  }))
51
52