@softize/opus 24.1.0 → 25.1.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 (44) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +1 -1
  3. package/bin/cli.mjs +9 -0
  4. package/bin/lib/check.mjs +390 -1
  5. package/bin/lib/copy.mjs +39 -2
  6. package/bin/lib/gen-runner.mjs +25 -12
  7. package/bin/lib/mcp.mjs +1 -1
  8. package/docs/adr/0051-soft-delete-declares-retention-and-purge.md +224 -0
  9. package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
  10. package/docs/data-layer.md +31 -3
  11. package/docs/protocol.md +98 -2
  12. package/docs/releasing.md +8 -2
  13. package/docs/stacking-scale.md +53 -0
  14. package/package.json +6 -1
  15. package/src/ai/drivers/anthropic.ts +3 -0
  16. package/src/core/domain.ts +189 -7
  17. package/src/core/index.ts +19 -2
  18. package/src/core/retention.ts +493 -0
  19. package/src/core/runtime.ts +93 -29
  20. package/src/core/system-actor.ts +33 -0
  21. package/src/core/types.ts +12 -0
  22. package/src/data/drivers/kysely.ts +47 -5
  23. package/src/data/index.ts +24 -6
  24. package/src/mcp/index.ts +3 -2
  25. package/src/schema/entity.ts +30 -9
  26. package/src/schema/index.ts +4 -0
  27. package/src/schema/openapi.ts +4 -1
  28. package/src/ui/components/primitives/dialog.tsx +285 -26
  29. package/src/ui/components/primitives/drawer.tsx +104 -13
  30. package/src/ui/components/primitives/floating-layer.tsx +79 -0
  31. package/src/ui/components/primitives/menu.tsx +17 -2
  32. package/src/ui/components/primitives/popover.tsx +6 -1
  33. package/src/ui/components/primitives/tooltip.tsx +6 -1
  34. package/src/ui/docs/content/cli.md +5 -3
  35. package/src/ui/docs/content/data.md +42 -0
  36. package/src/ui/docs/content/dialog.md +3 -0
  37. package/src/ui/docs/content/drawer.md +3 -0
  38. package/src/ui/docs/content/floating-layer.md +178 -0
  39. package/src/ui/docs/content/scheduler.md +4 -0
  40. package/src/ui/docs/registry.tsx +6 -0
  41. package/src/ui/lib/dismiss-on-iframe.ts +4 -1
  42. package/src/ui/lib/modal-layers.ts +547 -0
  43. package/src/ui/meta.ts +6 -0
  44. package/src/ui/react.tsx +4 -0
@@ -90,7 +90,7 @@ async function main() {
90
90
  const opusCoreUrl = pathToFileURL(
91
91
  path.join(PACKAGE_ROOT, "src", "core", "index.ts"),
92
92
  ).href;
93
- const { flattenDomain } = await import(opusCoreUrl);
93
+ const { flattenDomain, domainPurges, declaredJsonSchema } = await import(opusCoreUrl);
94
94
 
95
95
  const opusSeedUrl = pathToFileURL(
96
96
  path.join(PACKAGE_ROOT, "src", "seed", "index.ts"),
@@ -137,7 +137,10 @@ async function main() {
137
137
 
138
138
  const ctx = {
139
139
  flattenDomain,
140
+ domainPurges,
141
+ // Standard Schema que declara o próprio JSON Schema (ADR 0051) dispensa o zod.
140
142
  toJsonSchema: (schema) =>
143
+ declaredJsonSchema(schema, { target: "openapi-3.0" }) ??
141
144
  zodToJsonSchema(schema, { target: "openApi3", $refStrategy: "none" }),
142
145
  sourceConfigDir: path.dirname(configAbs),
143
146
  };
@@ -150,7 +153,7 @@ async function main() {
150
153
  }
151
154
  const actionRegistry = Object.fromEntries(
152
155
  cfg.domains
153
- .flatMap((domain) => collectDomainActions(domain))
156
+ .flatMap((domain) => collectDomainActions(domain, ctx))
154
157
  .map((action) => [action.name, action]),
155
158
  );
156
159
  const presentations = validatePresentations(
@@ -185,12 +188,15 @@ async function main() {
185
188
  emit({ ok: true, payload });
186
189
  }
187
190
 
188
- function collectDomainActions(domain) {
191
+ function collectDomainActions(domain, ctx, parentPath = []) {
189
192
  const actions = Array.from(iterateActions(domain.actions));
193
+ for (const purge of ctx.domainPurges(domain, parentPath)) actions.push(purge.action);
190
194
  for (const subdomain of Array.isArray(domain.subdomains)
191
195
  ? domain.subdomains
192
196
  : []) {
193
- actions.push(...collectDomainActions(subdomain));
197
+ actions.push(
198
+ ...collectDomainActions(subdomain, ctx, [...parentPath, domain.name]),
199
+ );
194
200
  }
195
201
  return actions;
196
202
  }
@@ -206,8 +212,11 @@ function serializeSeed(seed, isSeedDefinition, publicSeedDefinition) {
206
212
  // Serialização
207
213
  // =============================================================================
208
214
 
209
- function serializeDomain(domain, ctx) {
215
+ function serializeDomain(domain, ctx, parentPath = []) {
210
216
  const entityList = serializeEntities(domain.entities);
217
+ // Expurgo derivado das entidades com retenção (ADR 0051): a action e a rotina entram no
218
+ // manifest no domínio que lista a entidade, como as declaradas.
219
+ const purges = ctx.domainPurges(domain, parentPath);
211
220
  return {
212
221
  name: domain.name,
213
222
  description: nullable(domain.description),
@@ -216,17 +225,21 @@ function serializeDomain(domain, ctx) {
216
225
  entities: entityList,
217
226
  dataProducts: serializeDataProducts(domain.dataProducts),
218
227
  dicts: serializeDicts(domain.dicts),
219
- actions: Array.from(iterateActions(domain.actions), (a) =>
220
- serializeAction(a, ctx),
221
- ),
228
+ actions: [
229
+ ...iterateActions(domain.actions),
230
+ ...purges.map((purge) => purge.action),
231
+ ].map((a) => serializeAction(a, ctx)),
222
232
  reactions: Array.isArray(domain.reactions)
223
233
  ? domain.reactions.map(serializeReaction)
224
234
  : [],
225
- schedules: Array.isArray(domain.schedules)
226
- ? domain.schedules.map(serializeSchedule)
227
- : [],
235
+ schedules: [
236
+ ...(Array.isArray(domain.schedules) ? domain.schedules : []),
237
+ ...purges.map((purge) => purge.schedule),
238
+ ].map(serializeSchedule),
228
239
  subdomains: Array.isArray(domain.subdomains)
229
- ? domain.subdomains.map((s) => serializeDomain(s, ctx))
240
+ ? domain.subdomains.map((s) =>
241
+ serializeDomain(s, ctx, [...parentPath, domain.name]),
242
+ )
230
243
  : [],
231
244
  };
232
245
  }
package/bin/lib/mcp.mjs CHANGED
@@ -42,7 +42,7 @@ export function buildServer() {
42
42
  server.registerTool(
43
43
  'opus_check',
44
44
  {
45
- description: 'Valida as convenções das actions (régua) — defineAction e o split defineContract+bindAction: naming, kind, ordem dos campos, export, requires-sem-authorize, ignored-by-kind. Retorna findings.',
45
+ description: 'Valida as convenções das actions (régua) — defineAction e o split defineContract+bindAction: naming, kind, ordem dos campos, export, requires-sem-authorize, ignored-by-kind, soft-delete-retention. Retorna findings.',
46
46
  inputSchema: { dir: z.string().optional() },
47
47
  },
48
48
  async ({ dir }) => asText(await scanDir(dirOf(dir))),
@@ -0,0 +1,224 @@
1
+ # ADR 0051 — Exclusão lógica declara retenção e expurgo
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-02.
5
+ - **Complementa:** ADR 0028, que roda actions agendadas como o sistema, e ADR 0002, cujo par
6
+ declaração e binding serve de molde.
7
+
8
+ ## Contexto
9
+
10
+ `defineEntity({ softDelete: true })` injeta a coluna `deletedAt`. Pelo `kyselyRepo`, excluir passa a
11
+ marcar a data, e `findById`, `all` e `list` deixam os registros excluídos de fora. O ciclo fica pela
12
+ metade: o Opus sabe excluir de forma lógica, mas nada diz quando o registro sai de vez.
13
+
14
+ Sem esse fechamento, cada consumidor resolve sozinho. A plataforma Grand Brasil é o caso concreto:
15
+
16
+ - os fluxos de automação fazem exclusão lógica e expurgo à mão, com coluna própria e uma action
17
+ `automation.purge-deleted` escrita no domínio. Ela é restrita ao sistema e à administração, é
18
+ interna e roda numa rotina diária;
19
+ - os relatórios ainda apagam de vez. Por isso, excluir um relatório cuja conversa de IA registrou
20
+ uma autorização de extração de dado pessoal esbarra numa escolha ruim: apagar a autorização
21
+ junto, contra a regra que manda guardá-la, ou impedir a exclusão.
22
+
23
+ A forma escolhida para os relatórios é a mesma dos fluxos: excluir de forma lógica, manter o
24
+ histórico durante um prazo e expurgar depois, numa rotina. Com dois casos, o padrão já se repete.
25
+ No terceiro, quem esquecer o expurgo deixa registros excluídos acumulando para sempre, inclusive
26
+ dado pessoal, sem que nada avise.
27
+
28
+ O que precisa ser verdadeiro:
29
+
30
+ - toda entidade com exclusão lógica tem um prazo de retenção declarado ou declara, com motivo, que
31
+ guarda para sempre;
32
+ - o expurgo existe, roda sozinho, só o sistema o executa e deixa rastro do que apagou;
33
+ - o prazo prometido é cumprido: nada expurga antes dele;
34
+ - a ordem do que se apaga e o que se preserva continuam sendo decisão do domínio, porque variam:
35
+ um fluxo leva junto as execuções, e um relatório precisa apagar conversas em outro serviço e
36
+ preservar o consumo de IA;
37
+ - a falta de declaração quebra a verificação, em vez de depender de alguém lembrar.
38
+
39
+ ## Decisão
40
+
41
+ ### 1. A retenção é declarada na entidade
42
+
43
+ `softDelete` passa a aceitar um objeto, numa de duas formas mutuamente exclusivas:
44
+
45
+ ```ts
46
+ type SoftDelete =
47
+ | true // aceito pelo tipo; reprovado pelo `opus check` (seção 6)
48
+ | {
49
+ retainDays: number // inteiro positivo; conta a partir de `deletedAt`
50
+ timezone: string // IANA, obrigatório: a rotina não depende do fuso da máquina
51
+ label: string // título da action, completo: 'Expurgar relatórios excluídos'
52
+ description: string // descrição da action, completa
53
+ schedule?: { cron?: string; enabled?: boolean } // padrão: '30 3 * * *', habilitada
54
+ batchSize?: number // padrão: 200 linhas por lote; no máximo 10_000
55
+ maxDurationMs?: number // padrão: 60_000 por execução
56
+ }
57
+ | { retain: 'forever'; reason: string }
58
+ ```
59
+
60
+ Toda forma diferente de `true` mantém a exclusão lógica. Os pontos do pacote que hoje testam
61
+ `softDelete === true` passam por uma única função, `hasSoftDelete(entity)`, que reconhece as três:
62
+ `entityColumns` (injeção de `deletedAt`), o tipo `SoftDeletePart` e o `kyselyRepo` (filtro de
63
+ leitura e exclusão lógica). Sem isso, a forma de objeto deixaria de injetar a coluna e de filtrar,
64
+ sem erro nenhum.
65
+
66
+ ### 2. Uma entidade com retenção pertence a exatamente um domínio
67
+
68
+ A action e a rotina do expurgo nascem no domínio que lista a entidade em `entities`.
69
+
70
+ - A mesma entidade com retenção em dois domínios, ou num domínio e num subdomínio dele, falha com
71
+ `domain.entity_registered_twice`: no `validateDomain`, dentro da árvore de um domínio, e no
72
+ `register` do runtime, entre domínios registrados separadamente. A comparação é pela tabela, para
73
+ pegar também duas declarações `defineEntity` da mesma tabela, que gerariam dois expurgos dela.
74
+ - Entidade com `retainDays` que não aparece em nenhum `entities` de domínio é reprovada pelo
75
+ `opus check`. O check liga a entidade ao domínio pelo identificador exportado, como já liga um
76
+ `bindAction` ao seu contrato. Assim, uma entidade usada só por repositório não fica sem expurgo.
77
+
78
+ ### 3. O que o Opus gera
79
+
80
+ `flattenDomain` deriva, de cada entidade com `retainDays`, um contrato e uma rotina, e a checagem de
81
+ duplicidade do `validateDomain` passa a incluí-los. A derivação é pura e isomorfa, como o resto do
82
+ `flattenDomain`.
83
+
84
+ **Nomes.** A action se chama `<caminho-do-domínio>.<entidade-em-kebab>.purge-deleted`. Quando a
85
+ entidade em kebab é igual ao último segmento do domínio, o segmento não se repete: a entidade
86
+ `automation` no domínio `automation` gera `automation.purge-deleted`, e a entidade `reportShare` no
87
+ domínio `report` gera `report.report-share.purge-deleted`. A rotina se chama `<action>.daily`. O
88
+ caminho do domínio entra como está, sem conversão, como nas demais actions do domínio.
89
+
90
+ **Contrato da action:**
91
+
92
+ - entrada `{ olderThanDays?: number }`: inteiro, no mínimo o `retainDays` e no máximo 3650. O
93
+ padrão é o `retainDays`. Expurgar antes do prazo é recusado, para cumprir a retenção declarada;
94
+ - saída `{ purged: number }`;
95
+ - `effect: 'write'`, `automatable: true`, `internal: true` (não fica exposta por REST),
96
+ `personalData: false` e `ai: false`. O motivo de ficar fora da IA (é operação do sistema, não
97
+ pedido de uma pessoa) fica registrado no fonte do SDK, porque a `description` é do consumidor;
98
+ - autorização: só o sistema (`isSystemActor`). Não há execução manual por pessoa nesta decisão;
99
+ - textos: o `label` e a `description` declarados na retenção, completos e literais. O Opus não
100
+ monta frase a partir do nome do recurso, porque um modelo fixo erra a concordância (um recurso
101
+ feminino viraria "conversas excluídos"). O `opus copy` passa a ler esses dois campos no
102
+ `defineEntity`, como já lê os textos de `defineContract`.
103
+
104
+ **Rotina:** `defineSchedule` com a action, `cron` (padrão `'30 3 * * *'`), o `timezone` declarado e
105
+ `enabled` (padrão verdadeiro). Ela roda como o sistema, conforme a ADR 0028. Como qualquer rotina,
106
+ exige um `SchedulerAdapter` no `start()`, mesmo com `enabled: false`.
107
+
108
+ ### 4. Quem executa: handler padrão ou `bindPurge`
109
+
110
+ O contrato é gerado no core. O handler é anexado no servidor:
111
+
112
+ - **Padrão:** quando o runtime tem um `DataAdapter` Kysely, o `register` anexa o handler padrão.
113
+ Ele apaga as linhas com `deletedAt` anterior ao corte, das mais antigas para as mais novas, em
114
+ lotes de `batchSize`. Repete os lotes até não sobrar nada ou até esgotar `maxDurationMs`.
115
+ - **Próprio:** quando apagar a linha não basta, o domínio liga `bindPurge(entity, handler)` e o
116
+ registra em `purges` no `defineDomain`. `isDomainConfig` passa a reconhecer `purges`. O handler
117
+ recebe `{ ctx, cutoff, batchSize }`, apaga um lote e devolve `{ ids: string[] }`. O Opus repete os
118
+ lotes enquanto houver ids e tempo, e continua dono do contrato, da rotina, dos lotes e do registro.
119
+ Respeitar o `cutoff` é obrigação do handler: o Opus recusa um `olderThanDays` abaixo do prazo, mas
120
+ só o handler decide o que apaga. Um lote que devolve um id já removido na mesma execução, ou mais
121
+ ids que o `batchSize`, interrompe o expurgo com `purge.no_progress`; sem essa guarda, um handler
122
+ que não avança repetiria lotes até o fim do tempo. Antes de parar, o Opus registra os ids novos
123
+ daquele lote, que o handler já apagou. Entre um lote e outro, o Opus cede a vez ao event loop.
124
+ Ele decide a ordem, o que fica e o que sai em outro serviço. É a separação da ADR 0002: a entidade,
125
+ com a retenção, fica no contrato; o handler fica no servidor.
126
+ - **Sem handler padrão aplicável e sem `bindPurge`**, por exemplo sem `DataAdapter` ou com um que não
127
+ seja Kysely: o `start()` recusa com `runtime.purge_handler_missing`, nomeando a entidade.
128
+ - **No modo design**, a action gerada se comporta como as demais: entra no catálogo, e o handler é o
129
+ do design. As rotinas não são iniciadas.
130
+
131
+ **Dependências no banco:** o schema do Opus não declara cascata; ela depende da DDL do consumidor.
132
+ Se uma chave estrangeira restritiva impedir a exclusão, a execução agendada falha com o erro do
133
+ banco no log e na auditoria da execução, e nada é apagado daquele lote. Como a linha travada está
134
+ entre as mais antigas, ela volta ao primeiro lote em toda execução: o expurgo da entidade fica
135
+ parado até a chave ser ajustada. A saída é ajustar a chave, com cascata na DDL, ou usar `bindPurge`
136
+ para apagar os dependentes primeiro.
137
+
138
+ ### 5. O registro do que saiu
139
+
140
+ Cada lote que apaga algo registra a chave `opus.purge`, com a entidade, a action, o corte, o
141
+ `olderThanDays`, o número do lote e os ids removidos, em blocos de no máximo 500 ids. Assim, um
142
+ acúmulo grande vira vários registros de tamanho limitado, nunca uma linha sem teto. No fim da
143
+ execução que apagou algo, `opus.purge.total` traz a quantidade, o número de lotes e se restou
144
+ trabalho: verdadeiro quando a execução parou por tempo com lote cheio ou foi interrompida por erro.
145
+ Os registros vão para o `ctx.log` quando existe e, senão, para o logger do runtime. Os únicos rastros
146
+ de um expurgo são esses registros e a auditoria da execução agendada.
147
+
148
+ ### 6. O `opus check` cobra a regra
149
+
150
+ A regra `soft-delete-retention` reprova:
151
+
152
+ - entidade com `softDelete: true`, sem retenção;
153
+ - `retain: 'forever'` sem `reason`;
154
+ - `retainDays` sem `timezone`, `label` ou `description`, ou com valor que não é inteiro positivo
155
+ ou passa de 3650, e `batchSize` que não é inteiro positivo ou passa de 10.000;
156
+ - entidade com `retainDays` fora de qualquer `entities` de domínio;
157
+ - `bindPurge` para uma entidade sem `retainDays`;
158
+ - `bindPurge` exportado que não aparece no `purges` de nenhum domínio, porque cairia em silêncio no
159
+ handler padrão.
160
+
161
+ ### 7. Documentação
162
+
163
+ `docs/protocol.md` e `docs/data-layer.md` passam a mostrar a forma com retenção e deixam de
164
+ apresentar `softDelete: true` como suficiente. O trecho de `protocol.md` que promete uma "opção
165
+ explícita" para incluir excluídos na leitura é corrigido: essa opção não existe hoje.
166
+
167
+ ### 8. O que fica fora
168
+
169
+ - Restaurar um registro excluído dentro do prazo. É útil, mas independente do expurgo, e entra
170
+ quando houver o caso.
171
+ - Execução manual do expurgo por uma pessoa.
172
+ - Um motor de retenção para o que não é entidade com exclusão lógica, como logs e trilhas de
173
+ auditoria. Eles têm política própria.
174
+ - A introspecção estática usada pelo MCP de desenvolvimento ainda não mostra a action e a rotina
175
+ geradas; o manifest mostra.
176
+
177
+ ## Consequências
178
+
179
+ - Quem usa `softDelete: true` precisa declarar a retenção ou o motivo para guardar. O `opus check`
180
+ passa a cobrar isso, então a versão é major, com a migração no changelog.
181
+ - O expurgo deixa de ser código repetido em cada domínio. O terceiro caso já nasce com prazo,
182
+ rotina, lotes, autorização e registro, e o esquecimento quebra a verificação.
183
+ - A action de expurgo aparece no manifest e na documentação, e a rotina na lista de agendamentos.
184
+ Fica visível o que cada projeto apaga e quando.
185
+ - O expurgo é irreversível: depois dele, restam só o registro `opus.purge` e a auditoria.
186
+
187
+ ## Alternativas consideradas
188
+
189
+ - **Manter o expurgo no consumidor, com uma convenção escrita.** É o estado de hoje. Não há
190
+ garantia: o próximo domínio pode esquecer, e nada avisa.
191
+ - **Contrato e teste de gate só no consumidor.** Resolveria a Grand Brasil, mas deixaria o
192
+ padrão fora do lugar onde a exclusão lógica já mora, e cada projeto repetiria o gate.
193
+ - **Expurgo implícito, sem declarar prazo.** Um padrão único, por exemplo 30 dias, para toda
194
+ entidade com `softDelete`. Esconderia uma decisão de retenção, que pode envolver dado pessoal,
195
+ atrás de um padrão silencioso.
196
+ - **Fuso padrão do projeto em vez de fuso por entidade.** Seria menos repetitivo, mas o pacote não
197
+ tem esse padrão hoje, e o check, que é estático, não conseguiria verificá-lo. Fica para quando
198
+ houver uma configuração de projeto para agendamentos.
199
+ - **Um motor genérico de retenção para qualquer tabela.** Cobriria logs e trilhas, mas mistura
200
+ políticas diferentes e é cedo demais com os casos que existem.
201
+
202
+ ## Verificação
203
+
204
+ - Testes do SDK:
205
+ - a forma de objeto mantém a exclusão lógica: injeta `deletedAt` e o `kyselyRepo` filtra e marca;
206
+ - a entidade com `retainDays` gera a action e a rotina com os nomes da seção 3, incluindo o caso de
207
+ entidade em kebab diferente do domínio, e com as declarações de contrato e autorização;
208
+ - entrada abaixo do `retainDays` é recusada, e chamador que não é o sistema também;
209
+ - o handler padrão apaga só os excluídos antes do corte, em lotes, do mais antigo para o mais novo,
210
+ para no tempo e registra `opus.purge` com os ids, em blocos limitados, e `opus.purge.total`;
211
+ - uma linha restaurada ou excluída de novo entre a seleção e a exclusão não é apagada;
212
+ - um handler que repete ids ou excede o `batchSize` falha com `purge.no_progress`, rápido e sem
213
+ inflar o registro, e os ids novos do lote que parou entram no registro e no total;
214
+ - o `bindPurge` substitui o padrão e mantém contrato, lotes e registro;
215
+ - entidade em dois domínios, ou duas entidades da mesma tabela, falham com
216
+ `domain.entity_registered_twice`;
217
+ - o `start()` sem handler falha com `runtime.purge_handler_missing`;
218
+ - chave estrangeira restritiva faz a execução falhar sem apagar o lote, e a seguinte também;
219
+ - o core (`src/core/index.ts`) continua sem pacote externo em tempo de execução: os schemas da
220
+ action gerada são Standard Schema com o JSON Schema anexado, sem zod.
221
+ - Testes do `opus check`: os seis casos da seção 6 reprovam, e a declaração completa passa.
222
+ - Teste do `opus copy`: o `label` e a `description` da retenção entram no inventário.
223
+ - Na Grand Brasil, a adoção migra os fluxos de automação para `softDelete` com retenção e cria a
224
+ exclusão lógica dos relatórios com expurgo próprio.