@softize/opus 15.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ 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
+ ## 15.2.0 — 2026-09-11
11
+
12
+ Produtos de Dados passam a ser declarações de primeira classe com `defineDataProduct` e registro
13
+ em `defineDomain({ dataProducts })`. Identidade, versão, responsável, grão, classificação,
14
+ natureza, Fontes, entities, acesso descritivo, interfaces e ciclo de vida seguem para o manifest.
15
+ O domínio e `opus check` detectam IDs duplicados e referências inexistentes. Actions oferecidas à
16
+ IA carregam os produtos que expõem; MCP projeta a relação em
17
+ `_meta['com.softize.opus/data-products']`. Autorização e recorte continuam obrigatoriamente nas
18
+ Actions — metadata de produto não abre acesso.
19
+
20
+ O `<Chat>` aceita `id` e `createdAt` nos itens, gera esses campos para mensagens que administra e
21
+ oferece `renderMessageActions`. O slot aparece em hover ou foco e permite que o app carregue ações
22
+ e detalhes contextuais sem acoplar o componente a uma implementação de observabilidade.
23
+
24
+ **Migração:** nenhuma para declarações existentes. Para catalogar um produto, declare-o, registre-o
25
+ no domínio e mantenha todas as Actions citadas em `interfaces` protegidas. Históricos antigos sem
26
+ `id` ou `createdAt` continuam renderizando.
27
+
10
28
  ## 15.1.0 — 2026-09-10
11
29
 
12
30
  `PageShell` passa a coordenar o chrome persistente quando shell e rota conhecem partes diferentes da
package/README.md CHANGED
@@ -15,7 +15,7 @@ Protocolo de actions de ponta a ponta para TypeScript. Declare uma vez — input
15
15
  - **Contrato, não feature.** Tudo que entra no core padroniza forma; tudo que faz trabalho usa lib externa.
16
16
  - **Fail-closed por default.** Action sem `authorize` é negada. Audit default-on.
17
17
  - **Adapters plugáveis.** Cada categoria é uma superfície (subpath) com interface fechada e drivers trocáveis.
18
- - **Declaração é a fonte; doc é projeção.** As declarações (`defineContract`, `bindAction`, `defineEntity`) são a fonte dos contratos; manifest, OpenAPI e a doc gerada são projeções. `docs/protocol.md` descreve o protocolo em 16 seções (15 + glossário).
18
+ - **Declaração é a fonte; doc é projeção.** As declarações (`defineContract`, `bindAction`, `defineEntity`, `defineDataProduct`) são a fonte dos contratos; manifest, OpenAPI e a doc gerada são projeções. `docs/protocol.md` descreve o protocolo em 16 seções (15 + glossário).
19
19
 
20
20
  ## Superfícies
21
21
 
@@ -23,7 +23,7 @@ Um pacote (`@softize/opus`), uma versão. Cada categoria abaixo é um **subpath
23
23
 
24
24
  | Superfície | Drivers | Propósito |
25
25
  |---|---|---|
26
- | `@softize/opus` (core) | — | Protocolo, `defineContract`, `bindAction`, `defineAction`, `defineEntity`, runtime |
26
+ | `@softize/opus` (core) | — | Protocolo, Actions, entities, Produtos de Dados e runtime |
27
27
  | `@softize/opus/schema` | `/zod`, `/openapi` | Tipos lógicos (`t.*`) e gerador de OpenAPI |
28
28
  | `@softize/opus/server` | `/fastify`, `/node` | Transporte HTTP e montagem de endpoints |
29
29
  | `@softize/opus/client` | `/fetch` | Adapter de cliente para invocar actions |
@@ -95,7 +95,7 @@ registry/ # scaffolds + skills Opus — geração e conhecimento do SDK, não
95
95
  bin/ # CLI (opus create/setup/gen/copy/check/db/seed/pre-push/introspect/mcp) + libs
96
96
  docs/
97
97
  protocol.md # contrato completo (16 seções)
98
- data-layer.md · seeds.md · releasing.md · code-style.md
98
+ data-layer.md · data-products.md · seeds.md · releasing.md · code-style.md
99
99
  ```
100
100
 
101
101
  ## Seeds de projeto
package/bin/lib/check.mjs CHANGED
@@ -323,6 +323,54 @@ const UI_LEGACY_VARIANTS = new Map([
323
323
 
324
324
  /** Os três nomes que denotam action/contrato — o arquivo sem nenhum deles é pulado. */
325
325
  export const ACTION_MARKERS = ["defineAction", "defineContract", "bindAction"];
326
+ export const DATA_PRODUCT_MARKERS = ["defineDataProduct", "defineEntity"];
327
+
328
+ /** Extrai Produtos de Dados e entities literais para validar a linhagem sem executar o app. */
329
+ export function parseDataProducts(fileName, sourceText) {
330
+ const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
331
+ const products = [];
332
+ const entities = [];
333
+ const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
334
+ const prop = (obj, key) => obj.properties.find((item) =>
335
+ ts.isPropertyAssignment(item) && item.name?.getText(sf) === key)?.initializer;
336
+ const stringProp = (obj, key) => {
337
+ const value = prop(obj, key);
338
+ return value && ts.isStringLiteral(value) ? value.text : null;
339
+ };
340
+ const stringList = (obj, key) => {
341
+ const value = prop(obj, key);
342
+ if (!value || !ts.isArrayLiteralExpression(value)) return null;
343
+ if (!value.elements.every(ts.isStringLiteral)) return null;
344
+ return value.elements.map((item) => item.text);
345
+ };
346
+ const isExported = (call) => {
347
+ const declaration = call.parent;
348
+ const statement = ts.isVariableDeclaration(declaration) ? declaration.parent?.parent : undefined;
349
+ return statement !== undefined && ts.isVariableStatement(statement) && (statement.modifiers ?? []).some((item) => item.kind === ts.SyntaxKind.ExportKeyword);
350
+ };
351
+ const visit = (node) => {
352
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.arguments[0] && ts.isObjectLiteralExpression(node.arguments[0])) {
353
+ const obj = node.arguments[0];
354
+ if (node.expression.text === "defineDataProduct") {
355
+ const versionNode = prop(obj, "version");
356
+ products.push({
357
+ id: stringProp(obj, "id"),
358
+ version: versionNode && ts.isNumericLiteral(versionNode) ? Number(versionNode.text) : null,
359
+ interfaces: stringList(obj, "interfaces"),
360
+ entities: stringList(obj, "entities"),
361
+ exported: isExported(node),
362
+ line: lineOf(node),
363
+ });
364
+ } else if (node.expression.text === "defineEntity") {
365
+ const name = stringProp(obj, "name");
366
+ if (name !== null) entities.push(name);
367
+ }
368
+ }
369
+ ts.forEachChild(node, visit);
370
+ };
371
+ visit(sf);
372
+ return { products, entities };
373
+ }
326
374
 
327
375
  /**
328
376
  * Extrai `defineAction`/`defineContract`/`bindAction` de um source. Puro/sintático —
@@ -516,14 +564,21 @@ export function checkProject(sources) {
516
564
  const findings = [];
517
565
  const contractsByIdent = new Map(); // ident → item | 'ambiguous'
518
566
  const binds = []; // { item, file }
567
+ const productDeclarations = [];
568
+ const entityNames = new Set();
569
+ const actionNames = new Set();
519
570
 
520
571
  for (const { file, text } of sources) {
572
+ const declarations = parseDataProducts(file, text);
573
+ for (const entity of declarations.entities) entityNames.add(entity);
574
+ for (const product of declarations.products) productDeclarations.push({ product, file });
521
575
  for (const item of parseActions(file, text)) {
522
576
  if (item.form === "bindAction") {
523
577
  actions += 1;
524
578
  binds.push({ item, file });
525
579
  } else if (item.form === "defineContract") {
526
580
  contracts += 1;
581
+ if (item.name !== null) actionNames.add(item.name);
527
582
  if (item.ident !== null) {
528
583
  contractsByIdent.set(
529
584
  item.ident,
@@ -532,11 +587,45 @@ export function checkProject(sources) {
532
587
  }
533
588
  } else {
534
589
  actions += 1;
590
+ if (item.name !== null) actionNames.add(item.name);
535
591
  }
536
592
  for (const f of lintAction(item)) findings.push({ ...f, file });
537
593
  }
538
594
  }
539
595
 
596
+ const productIds = new Set();
597
+ for (const { product, file } of productDeclarations) {
598
+ const finding = (rule, message) => findings.push({
599
+ rule, level: "error", action: product.id ?? "(Produto de Dados sem id)",
600
+ line: product.line, file, message,
601
+ });
602
+ if (!product.exported) finding("data-product-export", "defineDataProduct precisa ser `export const` para compor o domínio e o catálogo.");
603
+ if (product.id === null || !ACTION_NAME_RE.test(product.id)) {
604
+ finding("data-product-id", "Produto de Dados precisa de `id` namespaced literal, como `sales.leads`.");
605
+ } else if (productIds.has(product.id)) {
606
+ finding("data-product-duplicate", `Produto de Dados "${product.id}" está declarado mais de uma vez.`);
607
+ } else {
608
+ productIds.add(product.id);
609
+ }
610
+ if (product.version === null || !Number.isInteger(product.version) || product.version < 1) {
611
+ finding("data-product-version", "Produto de Dados precisa de `version` literal inteira e positiva.");
612
+ }
613
+ if (product.interfaces === null || product.interfaces.length === 0) {
614
+ finding("data-product-interfaces", "Produto de Dados precisa declarar ao menos uma Action literal em `interfaces`.");
615
+ } else {
616
+ for (const action of product.interfaces) {
617
+ if (!actionNames.has(action)) finding("data-product-interface", `A interface "${action}" não corresponde a uma Action ou contrato declarado no projeto.`);
618
+ }
619
+ }
620
+ if (product.entities === null) {
621
+ finding("data-product-entities", "Produto de Dados precisa declarar `entities` como lista literal.");
622
+ } else {
623
+ for (const entity of product.entities) {
624
+ if (!entityNames.has(entity)) finding("data-product-entity", `A Entity "${entity}" não foi declarada no projeto.`);
625
+ }
626
+ }
627
+ }
628
+
540
629
  for (const { item, file } of binds) {
541
630
  const contract = contractsByIdent.get(item.contractRef);
542
631
  if (contract === undefined || contract === "ambiguous") continue;
@@ -555,7 +644,7 @@ export function checkProject(sources) {
555
644
  }
556
645
  }
557
646
 
558
- return { actions, contracts, findings };
647
+ return { actions, contracts, dataProducts: productDeclarations.length, findings };
559
648
  }
560
649
 
561
650
  /** Migrações de UI que falhariam apenas visualmente. Linhas que são só comentário não
@@ -1408,7 +1497,7 @@ export async function scanDir(rootDir) {
1408
1497
  const sources = [];
1409
1498
  for (const file of files) {
1410
1499
  const text = readProjectFile(rootDir, path.relative(rootDir, file)).content;
1411
- if (!ACTION_MARKERS.some((m) => text.includes(m))) continue;
1500
+ if (![...ACTION_MARKERS, ...DATA_PRODUCT_MARKERS].some((m) => text.includes(m))) continue;
1412
1501
  sources.push({ file: path.relative(rootDir, file), text });
1413
1502
  }
1414
1503
  const checked = checkProject(sources);
@@ -1424,6 +1513,7 @@ export async function scanDir(rootDir) {
1424
1513
  files: files.length,
1425
1514
  actions: checked.actions,
1426
1515
  contracts: checked.contracts,
1516
+ dataProducts: checked.dataProducts,
1427
1517
  findings: [...checked.findings, ...uiFindings],
1428
1518
  hasMarker,
1429
1519
  };
@@ -27,6 +27,7 @@ function shapeDomain(d) {
27
27
  repository: d.hasRepository,
28
28
  service: d.hasService,
29
29
  entities: d.entities ?? [],
30
+ dataProducts: d.dataProducts ?? [],
30
31
  actions: d.actions.map(shapeAction),
31
32
  reactions: d.reactions,
32
33
  schedules: d.schedules,
@@ -178,6 +178,7 @@ function serializeDomain(domain, ctx) {
178
178
  hasRepository: domain.repository !== undefined,
179
179
  hasService: domain.service !== undefined,
180
180
  entities: entityList,
181
+ dataProducts: serializeDataProducts(domain.dataProducts),
181
182
  dicts: serializeDicts(domain.dicts),
182
183
  actions: Array.from(iterateActions(domain.actions), (a) =>
183
184
  serializeAction(a, ctx),
@@ -194,6 +195,42 @@ function serializeDomain(domain, ctx) {
194
195
  }
195
196
  }
196
197
 
198
+ function serializeDataProducts(source) {
199
+ if (source === undefined || source === null) return []
200
+ const products = Array.isArray(source)
201
+ ? source
202
+ : typeof source === 'object'
203
+ ? Object.values(source)
204
+ : []
205
+ return products.map((product) => ({
206
+ id: product.id,
207
+ version: product.version,
208
+ label: product.label,
209
+ description: product.description,
210
+ owner: product.owner,
211
+ grain: product.grain,
212
+ classification: product.classification,
213
+ nature: product.nature,
214
+ sources: Array.isArray(product.sources)
215
+ ? product.sources.map((source) => ({
216
+ id: source.id,
217
+ label: source.label,
218
+ description: nullable(source.description),
219
+ }))
220
+ : [],
221
+ entities: Array.isArray(product.entities) ? product.entities : [],
222
+ access: {
223
+ contexts: Array.isArray(product.access?.contexts) ? product.access.contexts : [],
224
+ organizationalScopes: Array.isArray(product.access?.organizationalScopes)
225
+ ? product.access.organizationalScopes
226
+ : [],
227
+ },
228
+ interfaces: Array.isArray(product.interfaces) ? product.interfaces : [],
229
+ status: product.status ?? 'active',
230
+ replacedBy: nullable(product.replacedBy),
231
+ }))
232
+ }
233
+
197
234
  /** Serializa as entidades (`defineEntity`) com campos + docs — a fonte que vai pro
198
235
  * manifest/lente. Ignora valores que não parecem EntityConfig (name+fields). */
199
236
  function serializeEntities(src) {
@@ -28,10 +28,10 @@ function asStringArray(init) {
28
28
  return []
29
29
  }
30
30
 
31
- /** Extrai actions/reactions/schedules de um source. Puro/sintático — testável. */
31
+ /** Extrai actions/reactions/schedules/Produtos de Dados de um source. Puro/sintático — testável. */
32
32
  export function parseStructure(fileName, sourceText) {
33
33
  const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
34
- const out = { actions: [], reactions: [], schedules: [] }
34
+ const out = { actions: [], reactions: [], schedules: [], dataProducts: [] }
35
35
  const line = (n) => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1
36
36
 
37
37
  function visit(node) {
@@ -64,6 +64,17 @@ export function parseStructure(fileName, sourceText) {
64
64
  every: asString(prop(obj, 'every', sf)),
65
65
  line: line(node),
66
66
  })
67
+ } else if (fn === 'defineDataProduct') {
68
+ out.dataProducts.push({
69
+ id: asString(prop(obj, 'id', sf)),
70
+ version: (() => {
71
+ const value = prop(obj, 'version', sf)
72
+ return value && ts.isNumericLiteral(value) ? Number(value.text) : null
73
+ })(),
74
+ entities: asStringArray(prop(obj, 'entities', sf)),
75
+ interfaces: asStringArray(prop(obj, 'interfaces', sf)),
76
+ line: line(node),
77
+ })
67
78
  }
68
79
  }
69
80
  ts.forEachChild(node, visit)
@@ -94,15 +105,16 @@ export function deriveWiring(model) {
94
105
  export async function introspect(rootDir) {
95
106
  rootDir = canonicalProjectDirectory(rootDir)
96
107
  const files = await walkTsFiles(rootDir)
97
- const model = { actions: [], reactions: [], schedules: [] }
108
+ const model = { actions: [], reactions: [], schedules: [], dataProducts: [] }
98
109
  for (const file of files) {
99
110
  const text = readProjectFile(rootDir, path.relative(rootDir, file)).content
100
- if (!/define(Action|Reaction|Schedule)/.test(text)) continue
111
+ if (!/define(Action|Reaction|Schedule|DataProduct)/.test(text)) continue
101
112
  const s = parseStructure(file, text)
102
113
  const rel = path.relative(rootDir, file)
103
114
  for (const a of s.actions) model.actions.push({ ...a, file: rel })
104
115
  for (const r of s.reactions) model.reactions.push({ ...r, file: rel })
105
116
  for (const sc of s.schedules) model.schedules.push({ ...sc, file: rel })
117
+ for (const product of s.dataProducts) model.dataProducts.push({ ...product, file: rel })
106
118
  }
107
119
  return { ...model, wiring: deriveWiring(model) }
108
120
  }
@@ -0,0 +1,72 @@
1
+ # ADR 0012 — Produtos de Dados são declarações de primeira classe
2
+
3
+ - Status: aceita
4
+ - Data: 2026-09-11
5
+
6
+ ## Contexto e forças
7
+
8
+ Entities descrevem estrutura persistida e Actions descrevem interfaces autorizadas. Nenhuma das
9
+ duas declara, porém, qual conjunto governado existe para responder uma pergunta de negócio. Sem
10
+ esse artefato, relatórios, agentes e catálogos reconstroem a relação entre Fonte, Entity e Action
11
+ por nomes ou tabelas paralelas. A mesma análise pode então receber uma definição diferente a cada
12
+ novo consumidor.
13
+
14
+ Uma view de banco não resolve o problema: ela pode ser uma implementação eficiente, mas não
15
+ carrega por si só identidade pública, responsável, grão, classificação, linhagem, interfaces ou
16
+ ciclo de vida. Também não queremos que metadata descritiva contorne o pipeline de autorização da
17
+ Action.
18
+
19
+ ## Decisão
20
+
21
+ O core expõe `defineDataProduct`. O produto tem identidade namespaced e estável, versão inteira,
22
+ nome e descrição humanas, responsável, grão, classificação, natureza, Fontes, entities, acesso
23
+ descritivo e Actions de interface. Produtos ativos podem ser descontinuados com `status:
24
+ 'deprecated'` e `replacedBy`.
25
+
26
+ `defineDomain({ dataProducts })` registra os produtos. O domínio e `opus check` recusam IDs
27
+ duplicados e relações literais que apontam para Action ou Entity inexistente. A declaração não
28
+ executa consulta, não contém driver e não substitui uma Action.
29
+
30
+ A autorização continua sendo responsabilidade da Action. `access.contexts` e
31
+ `access.organizationalScopes` documentam o alcance esperado para catálogo, Lens e revisão, mas o
32
+ runtime não os converte em autorização implícita. Essa separação impede que uma descrição
33
+ incompleta abra dados.
34
+
35
+ O manifest projeta a declaração integral. Tools de IA recebem a lista de produtos que expõem em
36
+ metadata, e o servidor MCP publica essa lista em `_meta['com.softize.opus/data-products']`. A
37
+ camada que compõe MCPs pode persistir a linhagem das chamadas bem-sucedidas sem conhecer tabelas
38
+ ou inferir produtos pelo nome da tool.
39
+
40
+ ## Alternativas consideradas
41
+
42
+ ### Tratar Entity como Produto de Dados
43
+
44
+ Rejeitada. Uma Entity descreve estrutura e invariantes; um produto pode combinar várias entities,
45
+ ter outro grão e oferecer mais de uma interface.
46
+
47
+ ### Usar view ou tabela consolidada como identidade
48
+
49
+ Rejeitada como contrato. Views continuam válidas como implementação de performance, mas trocar a
50
+ materialização não deve trocar a identidade consumida por relatório, agente ou interface.
51
+
52
+ ### Manter um catálogo manual fora do domínio
53
+
54
+ Rejeitada. O catálogo voltaria a divergir das Actions e entities que realmente existem.
55
+
56
+ ## Consequências
57
+
58
+ Um produto novo exige decisão explícita sobre semântica, acesso e linhagem antes de ser publicado.
59
+ O custo adicional é intencional. Mudança de forma observável incrementa `version`; substituição
60
+ preserva o ID antigo como descontinuado durante a migração dos consumidores.
61
+
62
+ Relatórios e agentes passam a depender da identidade do produto, enquanto cada Action permanece
63
+ a fronteira executável e autorizada. O Opus Lens pode apresentar a cadeia `Fonte/Entity → Produto
64
+ de Dados → Action` a partir do manifest.
65
+
66
+ ## Verificação
67
+
68
+ - testes de construção e registro cobrem formato, duplicidade, ciclo de vida e referências;
69
+ - `opus check` cobre declarações literais, exportação e drift de Action/Entity;
70
+ - o manifest preserva todos os campos;
71
+ - runtime de IA e MCP projetam a identidade do produto por interface;
72
+ - Opus Lens lê a declaração e deriva a linhagem sem heurística.
@@ -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.1.0",
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
+ }
@@ -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
 
@@ -5,6 +5,10 @@ import { Composer } from './composer.tsx'
5
5
  import { Markdown } from './markdown.tsx'
6
6
 
7
7
  export interface ChatMessage {
8
+ /** Identidade persistida pelo app. Opcional para históricos legados. */
9
+ id?: string
10
+ /** Instante ISO-8601 persistido pelo app. Opcional para históricos legados. */
11
+ createdAt?: string
8
12
  role: 'user' | 'assistant'
9
13
  content: string
10
14
  }
@@ -19,8 +23,10 @@ export interface ChatArtifact {
19
23
  /** Item do transcript no modo CONTROLADO (o app é o dono do estado — ex.: sala
20
24
  * server-autoritativa com replay, como o ChatRail do Maestro). */
21
25
  export type ChatTranscriptItem =
22
- | { role: 'user' | 'assistant' | 'error'; content: string }
23
- | { role: 'artifact'; artifact: ChatArtifact }
26
+ | { id?: string; createdAt?: string; role: 'user' | 'assistant' | 'error'; content: string }
27
+ | { id?: string; createdAt?: string; role: 'artifact'; artifact: ChatArtifact }
28
+
29
+ export type ChatTranscriptMessage = Extract<ChatTranscriptItem, { content: string }>
24
30
 
25
31
  /** Feedback humano do tool em uso (default pt-BR; override por prop). */
26
32
  function defaultHumanizeTool(name: string): string {
@@ -85,6 +91,9 @@ export interface ChatProps {
85
91
  kickoff?: () => Promise<string> | AsyncIterable<ChatEvent>
86
92
  /** Render do evento `artifact` (card, iframe, preview…). Default: link com o título. */
87
93
  renderArtifact?: (artifact: ChatArtifact) => React.ReactNode
94
+ /** Ações contextuais de uma mensagem. O Chat fornece apenas a anatomia; buscar dados
95
+ * detalhados, pedir reautorização e decidir o conteúdo pertencem ao app. */
96
+ renderMessageActions?: (message: ChatTranscriptMessage) => React.ReactNode
88
97
  /** Rótulo humano do tool em uso no indicador vivo (modo autogerenciado). */
89
98
  humanizeTool?: (name: string, detail?: string) => string
90
99
  placeholder?: string
@@ -95,6 +104,14 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<ChatEvent> {
95
104
  return typeof value === 'object' && value !== null && Symbol.asyncIterator in value
96
105
  }
97
106
 
107
+ function transcriptMeta(): { id: string; createdAt: string } {
108
+ const cryptoApi = globalThis.crypto
109
+ const id = typeof cryptoApi?.randomUUID === 'function'
110
+ ? cryptoApi.randomUUID()
111
+ : `message_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
112
+ return { id, createdAt: new Date().toISOString() }
113
+ }
114
+
98
115
  /** Agrupa o transcript em turnos (mensagem do usuário + o que veio em resposta). */
99
116
  function groupTurns(items: ChatTranscriptItem[]): Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> {
100
117
  const turns: Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> = []
@@ -114,6 +131,7 @@ interface ChatTranscriptProps {
114
131
  greeting: string | undefined
115
132
  empty: React.ReactNode | undefined
116
133
  renderArtifact: ChatProps['renderArtifact']
134
+ renderMessageActions: ChatProps['renderMessageActions']
117
135
  }
118
136
 
119
137
  /**
@@ -128,6 +146,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
128
146
  greeting,
129
147
  empty,
130
148
  renderArtifact,
149
+ renderMessageActions,
131
150
  }: ChatTranscriptProps): React.ReactElement {
132
151
  const scrollRef = React.useRef<HTMLDivElement>(null)
133
152
  const turns = React.useMemo(() => groupTurns(items), [items])
@@ -164,16 +183,21 @@ const ChatTranscript = React.memo(function ChatTranscript({
164
183
  // (o agente narra o que vai fazendo), então andam juntas; quem separa é o gap
165
184
  // MAIOR entre turnos. Com o mesmo gap nos dois níveis, um turno de oito falas
166
185
  // curtas virava uma parede uniforme, sem começo nem fim visíveis.
167
- <div key={ti} className="flex flex-col gap-1">
186
+ <div key={turn.user?.id ?? turn.rest[0]?.id ?? ti} className="flex flex-col gap-1">
168
187
  {turn.user !== null && turn.user.role === 'user' && (
169
- <div data-slot="chat-turn-user" className="flex justify-end py-1.5">
188
+ <div
189
+ data-slot="chat-turn-user"
190
+ data-message-id={turn.user.id}
191
+ data-message-created-at={turn.user.createdAt}
192
+ className="flex justify-end py-1.5"
193
+ >
170
194
  <UserTurn content={turn.user.content} />
171
195
  </div>
172
196
  )}
173
197
  {turn.rest.map((item, i) => {
174
198
  if (item.role === 'artifact') {
175
199
  return (
176
- <div key={i} data-slot="chat-artifact" className="flex justify-start">
200
+ <div key={item.id ?? i} data-slot="chat-artifact" data-message-id={item.id} className="flex justify-start">
177
201
  {renderArtifact !== undefined ? (
178
202
  renderArtifact(item.artifact)
179
203
  ) : (
@@ -192,7 +216,9 @@ const ChatTranscript = React.memo(function ChatTranscript({
192
216
  if (item.role === 'error') {
193
217
  return (
194
218
  <div
195
- key={i}
219
+ key={item.id ?? i}
220
+ data-slot="chat-error"
221
+ data-message-id={item.id}
196
222
  className="max-w-[88%] self-start rounded-xl border border-context-danger-border bg-context-danger-subtle px-3.5 py-2.5 text-sm text-context-danger-emphasis"
197
223
  >
198
224
  {item.content}
@@ -201,8 +227,22 @@ const ChatTranscript = React.memo(function ChatTranscript({
201
227
  }
202
228
  // O assistente responde direto no corpo, em Markdown (convenção da casa).
203
229
  return (
204
- <div key={i} className="break-words px-1 text-sm leading-snug">
230
+ <div
231
+ key={item.id ?? i}
232
+ data-slot="chat-message"
233
+ data-message-id={item.id}
234
+ data-message-created-at={item.createdAt}
235
+ className="group/chat-message break-words px-1 text-sm leading-snug"
236
+ >
205
237
  <Markdown content={item.content} />
238
+ {renderMessageActions !== undefined && (
239
+ <div
240
+ data-slot="chat-message-actions"
241
+ className="mt-1 flex min-h-5 items-center opacity-0 transition-opacity group-hover/chat-message:opacity-100 focus-within:opacity-100"
242
+ >
243
+ {renderMessageActions(item)}
244
+ </div>
245
+ )}
206
246
  </div>
207
247
  )
208
248
  })}
@@ -257,6 +297,7 @@ export function Chat({
257
297
  initialMessages,
258
298
  kickoff,
259
299
  renderArtifact,
300
+ renderMessageActions,
260
301
  humanizeTool = defaultHumanizeTool,
261
302
  placeholder = 'Escreva uma mensagem…',
262
303
  className,
@@ -335,7 +376,7 @@ export function Chat({
335
376
  return
336
377
  }
337
378
 
338
- const next: ChatTranscriptItem[] = [...ownItems, { role: 'user', content: text }]
379
+ const next: ChatTranscriptItem[] = [...ownItems, { ...transcriptMeta(), role: 'user', content: text }]
339
380
  setOwnItems(next)
340
381
  const transcript = next.filter((i): i is ChatMessage => i.role === 'user' || i.role === 'assistant')
341
382
  await consume(() => send!(transcript))
@@ -349,16 +390,18 @@ export function Chat({
349
390
  const result = run()
350
391
  if (isAsyncIterable(result)) {
351
392
  let streaming = false
393
+ let streamingMeta: { id: string; createdAt: string } | undefined
352
394
  for await (const event of result) {
353
395
  if (event.type === 'text') {
354
396
  setOwnActivity(null)
355
397
  setOwnItems((list) => {
356
398
  const last = list[list.length - 1]
357
399
  if (streaming && last !== undefined && last.role === 'assistant') {
358
- return [...list.slice(0, -1), { role: 'assistant', content: last.content + event.delta }]
400
+ return [...list.slice(0, -1), { ...last, content: last.content + event.delta }]
359
401
  }
360
402
  streaming = true
361
- return [...list, { role: 'assistant', content: event.delta }]
403
+ streamingMeta = transcriptMeta()
404
+ return [...list, { ...streamingMeta, role: 'assistant', content: event.delta }]
362
405
  })
363
406
  } else if (event.type === 'tool') {
364
407
  streaming = false
@@ -367,20 +410,20 @@ export function Chat({
367
410
  streaming = false
368
411
  setOwnItems((list) => [
369
412
  ...list,
370
- { role: 'artifact', artifact: { kind: event.kind, ref: event.ref, title: event.title } },
413
+ { ...transcriptMeta(), role: 'artifact', artifact: { kind: event.kind, ref: event.ref, title: event.title } },
371
414
  ])
372
415
  } else if (event.type === 'done') {
373
416
  if (!event.ok) {
374
- setOwnItems((list) => [...list, { role: 'error', content: event.error ?? 'Desculpe, algo falhou.' }])
417
+ setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'error', content: event.error ?? 'Desculpe, algo falhou.' }])
375
418
  }
376
419
  }
377
420
  }
378
421
  } else {
379
422
  const reply = await result
380
- setOwnItems((list) => [...list, { role: 'assistant', content: reply }])
423
+ setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'assistant', content: reply }])
381
424
  }
382
425
  } catch {
383
- setOwnItems((list) => [...list, { role: 'error', content: 'Desculpe, algo falhou.' }])
426
+ setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'error', content: 'Desculpe, algo falhou.' }])
384
427
  } finally {
385
428
  setOwnBusy(false)
386
429
  setOwnActivity(null)
@@ -395,6 +438,7 @@ export function Chat({
395
438
  greeting={greeting}
396
439
  empty={empty}
397
440
  renderArtifact={renderArtifact}
441
+ renderMessageActions={renderMessageActions}
398
442
  />
399
443
  {/* Composer da casa (pílula elevada, enviar dentro). Extraído no <Composer> — o Chat
400
444
  liga texto/envio e repassa `composerActions` pros seletores da conversa (agente,
@@ -92,6 +92,11 @@ Reidratação: passe `initialMessages` com o histórico persistido e troque a `k
92
92
  componente ao trocar de conversa. O rótulo do indicador é customizável por
93
93
  `humanizeTool={(name) => '…'}`.
94
94
 
95
+ Itens controlados podem carregar `id` e `createdAt`. O Chat os projeta no elemento da
96
+ mensagem e oferece `renderMessageActions` para ações contextuais discretas. O slot não
97
+ busca nem conhece detalhes: o app deve carregar informação complementar somente após a
98
+ interação da pessoa e aplicar novamente sua autorização.
99
+
95
100
  ## Propriedades de Chat
96
101
 
97
102
  | Propriedade | Tipo | Padrão | Descrição |
@@ -109,5 +114,6 @@ componente ao trocar de conversa. O rótulo do indicador é customizável por
109
114
  | `initialMessages` | `ChatMessage[]` | | Histórico inicial do modo autogerenciado; troque a `key` ao trocar de conversa. |
110
115
  | `kickoff` | `() => Promise<string> \| AsyncIterable<ChatEvent>` | | Conversa que começa pelo assistente, uma vez, quando o transcript nasce vazio. |
111
116
  | `renderArtifact` | `(artifact: ChatArtifact) => ReactNode` | link com o título | Render do evento `artifact`. |
117
+ | `renderMessageActions` | `(message: ChatTranscriptMessage) => ReactNode` | | Ações contextuais da mensagem; ficam visíveis em hover ou foco. |
112
118
  | `humanizeTool` | `(name: string, detail?: string) => string` | pt-BR embutido | Rótulo humano do tool em uso no indicador vivo. |
113
119
  | `placeholder` | `string` | `'Escreva uma mensagem…'` | Placeholder do composer. |
package/src/ui/react.tsx CHANGED
@@ -33,6 +33,7 @@ export type {
33
33
  ChatMessage,
34
34
  ChatArtifact,
35
35
  ChatTranscriptItem,
36
+ ChatTranscriptMessage,
36
37
  } from "./components/primitives/chat.tsx";
37
38
  export { Ask } from "./components/primitives/ask.tsx";
38
39
  export type { AskProps } from "./components/primitives/ask.tsx";