Uma spec, várias saídas
A feature fica em .plintrix/features/**/*.md. O texto C4 explica o negócio; o front-matter builder: contém o contrato executável. A fonte é a spec, e não uma cópia independente em YAML.
entity:Domain + schema
Define Dock, a tabela dock.doca, tenant, auditoria e soft delete. O gerador usa esse contrato na entidade, no mapeamento EF e na migration.
fields:DTOs + validação
Name vira nome no JSON e doc_nome no banco. required, max e unique alimentam validação, constraints e testes.
api:Controllers + consultas
publics e resource formam /client/dock/v1/docas. operations seleciona os endpoints; list define filtros, ordenação e busca.
permissions:Autorização
Em produtos, declare os códigos aprovados. A referência usa exempt de forma explícita; isso não é uma política padrão para produção.
acceptance:Testes executáveis
CA-01 valida nome vazio; CA-02 e CA-03 verificam o comportamento de leitura e escrita com outra organização.
Ver a spec Dock real, completa
---
id: features/dock/2026-10-03-dock-docas
layer: feature
version: 0.1.0
status: draft
owners:
- "@silsistemas/builder"
last-updated: 2026-10-03
template: v2
superficie: [api, persistencia]
extends:
- constitution@0.x
uses:
platform:
- platform/api-rules@0.x
- platform/api-endpoints@0.x
- platform/multitenancy@^0.2
builder:
schema: 1
module: Dock
entity:
name: Dock
plural: Docks
table: dock.doca
prefix: doc
tenant: organization
softDelete: true
audit: log
label: { singular: doca, plural: docas, gender: f }
fields:
- { name: Name, json: nome, type: string, max: 100, required: true, unique: organization, description: Nome da doca. }
- { name: Active, json: ativo, type: bool, default: true, description: Indica se a doca está ativa. }
api:
publics: [client]
resource: dock/v1/docas
operations: [insert, update, delete, get, list]
list:
filters:
- { field: Name, ops: [eq, like] }
sort: [Name]
fullSearch: [Name]
status: Active
permissions:
exempt: Backend de referência sem matriz de permissões; quem gera o backend define os códigos.
acceptance:
- { id: CA-01, request: { method: POST, body: { nome: "" } }, expect: { status: 400, error: NameRequired } }
- { id: CA-02, given: other-organization, request: { method: GET, path: "{guid}" }, expect: { status: 204 } }
- { id: CA-03, given: other-organization, request: { method: PUT, path: "{guid}", body: { nome: Outra } }, expect: { status: 400, error: NotFound } }
---
# Docas
## 1. Summary
Cadastro de docas do módulo Dock: cada organização mantém as próprias docas, com nome único e indicador de ativa, por uma API de inclusão, alteração, exclusão lógica, consulta por id e listagem paginada. É a feature de referência do template `sil-satellite`; o código do módulo é saída do gerador a partir deste arquivo.
## 2. Context & motivation
O template precisa de uma fatia completa e pequena que exercite o contrato do builder de ponta a ponta: isolamento por organização, auditoria, exclusão lógica, erros por local e listagem com filtro, ordenação e busca. A doca, ponto de carga e descarga de um armazém ou centro de distribuição, é simples o bastante para caber numa tela e real o bastante para servir de modelo às features dos backends gerados.
## 3. Scope
**Entra:**
- cadastro de docas com nome e indicador de ativa;
- API `client` com inclusão, alteração, exclusão lógica, consulta por id e listagem;
- listagem com filtro por nome (`eq`, `like`), ordenação por nome, busca textual e filtro de situação;
- persistência em `dock.doca`, com auditoria `log` na mesma transação.
**Não entra:**
- agendamento de docas, janelas de atendimento e vínculo com veículos, que seriam features próprias;
- códigos de permissão: o backend de referência declara `permissions.exempt` e quem gera o backend define os códigos;
- coleções filhas e PATCH, fora do schema 1 do builder.
## 4. Domain model
### 4.1 Conceito
Doca é um ponto de carga e descarga mantido por uma organização. Ela pode ser desativada sem perder o histórico.
### 4.2 Identidade
A chave interna é o `id` (UUID v7, D0.9), gerado na inclusão e exposto como `id` e `object_id`. A chave natural é o `nome`, único entre as docas não excluídas da mesma organização.
### 4.3 Ciclo de vida
A doca nasce ativa (`ativo = true`), pode ser desativada e reativada por alteração e sai do cadastro por exclusão lógica: `doc_deldat` recebe o instante da exclusão e o registro deixa de aparecer em consultas e listas. A exclusão não é desfeita pela API.
### 4.4 Entidades e relacionamentos
| Entidade | Relação | Cardinalidade |
|---|---|---|
| Organização | Dona das docas | 1 organização : N docas |
### 4.5 Multitenancy
Isolada por organização (`tenant: organization`): toda leitura e escrita filtra `doc_orgid` na tabela principal (D0.14). Doca de outra organização se comporta como inexistente (RN-03). Não há lacuna conhecida de isolamento.
## 5. Business rules
| # | Regra | Descrição |
|---|---|---|
| RN-01 | Nome obrigatório | O campo `nome` é obrigatório, tem no máximo 100 caracteres e é gravado sem espaços nas pontas. A violação devolve `NameRequired` ou `NameTooLong`. |
| RN-02 | Nome único na organização | Não existem duas docas não excluídas com o mesmo `nome` na mesma organização. A violação devolve `NameDuplicated`, também quando detectada pelo índice único parcial. |
| RN-03 | Isolamento por organização | Doca de outra organização se comporta como inexistente: GET responde 204 e PUT ou DELETE respondem 400 com `NotFound` (D0.8). |
## 6. Data model
Tabela `dock.doca`, prefixo `doc`.
| Coluna | Tipo | Nulo | Descrição |
|---|---|---|---|
| `doc_id` | `uuid` | não | Identificador (PK, UUID v7). |
| `doc_orgid` | `uuid` | não | Organização dona da doca. |
| `doc_nome` | `varchar(100)` | não | Nome da doca. |
| `doc_ativo` | `boolean` | não | Indica se a doca está ativa; padrão `true`. |
| `doc_incdat` | `timestamptz` | não | Instante da inclusão. |
| `doc_incusu` | `uuid` | sim | Usuário da inclusão. |
| `doc_altdat` | `timestamptz` | sim | Instante da última alteração. |
| `doc_altusu` | `uuid` | sim | Usuário da última alteração. |
| `doc_deldat` | `timestamptz` | sim | Instante da exclusão lógica; nulo enquanto a doca existe. |
Constraints e índices: `pk_doca`; `uk_doca_org_doc_nome` sobre `doc_orgid` e `doc_nome`, parcial `WHERE doc_deldat IS NULL`; `ix_doca_orgid`. Os `COMMENT ON` vêm das descrições dos campos. A auditoria `log` grava em `dock.audit_log` na mesma transação: na inclusão, todos os campos de negócio; na alteração, só os alterados; na exclusão, nenhum.
## 7. API surface
Público `client`, recurso `dock/v1/docas`, rota `/client/dock/v1/docas`. O corpo usa os nomes JSON `nome` e `ativo`; `doc=true` descreve os campos a partir das descrições (D0.22).
| Operação | Método e caminho | Sucesso | Falha |
|---|---|---|---|
| insert | `POST /client/dock/v1/docas` | 201 com `object_id` | 400 `NameRequired`, `NameTooLong` ou `NameDuplicated` |
| update | `PUT /client/dock/v1/docas/{id}` | 204 | 400 `NameRequired`, `NameTooLong`, `NameDuplicated` ou `NotFound` |
| delete | `DELETE /client/dock/v1/docas/{id}` | 204 | 400 `NotFound` |
| get | `GET /client/dock/v1/docas/{id}` | 200 com a doca | 204 quando não existe ou é de outra organização |
| list | `GET /client/dock/v1/docas` | 200 paginado por `page_num` e `page_rows` (padrão 10) | 204 quando vazia; 400 com campo ou operador fora da definição |
A listagem aceita `query=nome__eq__…` e `query=nome__like__…`, `sort=nome` ou `sort=nome__desc` (padrão `id`, a ordem de criação), `full_search` sobre `nome` e `status=A|I|T` sobre `ativo`. Os erros saem no envelope `error_code`, `error_message` e `display_message`, com um código por local, como `DOCK_DOCK_INSERT_NAME_REQUIRED`.
## 8. Acceptance criteria
| # | Dado | Quando | Então |
|---|---|---|---|
| CA-01 | Nenhuma doca | `POST` com `nome` vazio | 400 com `NameRequired` |
| CA-02 | Doca de outra organização | `GET` pelo id dela | 204, sem corpo |
| CA-03 | Doca de outra organização | `PUT` pelo id dela com `nome` "Outra" | 400 com `NotFound` |
## 9. Tests
- Validadores: caso válido e inválido para `nome` obrigatório e para o limite de 100 caracteres, conferindo o código e as duas mensagens.
- Handlers com repositórios fake: inclusão, alteração e exclusão; alteração e exclusão de doca de outra organização devolvem `NotFound`.
- HTTP em processo: CA-01 a CA-03, isolamento com duas organizações em cada operação, `doc=true` e lista vazia com 204.
- Banco, contra PostgreSQL descartável (`SIL_BUILDER_TEST_POSTGRES`): índice único parcial do nome, auditoria `log` e filtro de organização; sem a variável, o teste fica pulado.
## 10. Open questions
Nenhuma.
## 11. Notes & caveats
- Os códigos de erro são simbólicos (`DOCK_DOCK_<OP>_<CHAVE>`) até o registro Shipyard (PAR-07) trocá-los pelo código definitivo, sem mudar a chave.
- O texto Plintrix da Sil usa 404 para registro inexistente e totais como texto; aqui valem D0.8 (GET 204, PUT e DELETE 400) e D0.24 (totais numéricos), como registra a ADR-BLD-0003.
- `permissions.exempt` vale só para o backend de referência; um produto real declara os códigos `PRD.MOD.funcio.objeto.AC` com `dotnet builder add permission`.
Abrir fonte ↗Altere pelo contrato
Seção intitulada “Altere pelo contrato”dotnet builder add field features/dock/2026-10-03-dock-docas Code:string --json codigo --max 20 --description "Código da doca."dotnet builder spec lintdotnet builder spec plan features/dock/2026-10-03-dock-docas --task BLD-42 --author "Seu nome" --link https://example.com/BLD-42 --date 2026-10-05Substitua task, autor, link e data pelos dados reais. Revise o plano, depois execute spec apply com os mesmos argumentos. Os comandos add preservam o texto C4 e reescrevem somente o bloco builder:.
Campos, relações e operações precisam respeitar o schema oficial. Coleções filhas e PATCH estão fora do schema 1; não deduza suporte pela existência de uma pasta reservada.
Amostras válidas de teste
Seção intitulada “Amostras válidas de teste”Cada campo pode declarar samples, uma lista de literais do próprio tipo. Use valores válidos de CNPJ, URL, escopo ou evento para que regras manuais também aceitem os dados gerados.
- { name: BankCode, json: banco, type: enum, values: ["001", "041", "748", "341"], unique: organization }- { name: Callback, json: callback, type: string, max: 200, samples: ["https://example.com/eventos"] }Num campo opcional (não obrigatório, sem default, não único e não referência), samples aceita null; a lista é percorrida pelo índice, então dois campos com listas do mesmo tamanho andam juntos. Sem samples, um campo int/long não único limitado por um check numérico em constraints (hno_nivel >= 1 AND hno_nivel <= 7) recebe amostras dentro da faixa. Quando a linha de amostra padrão viola um check declarado, o gerador troca o mínimo de campos citados por ele (outro valor do enum, null no campo opcional, um literal do check ou um texto com o tamanho mínimo) até nenhum check recusar a linha; sem check, ou com a linha válida, nada muda. Para um campo único, uma referência ou uma condição que o gerador não decide, declare samples. Enums únicos percorrem os valores declarados. A paginação cria até 12 registros, limitada pelo menor conjunto de valores únicos, e calcula as expectativas a partir desse total. spec lint recusa amostras incompatíveis, repetidas após trim e conjuntos únicos com menos de dois valores, necessários aos cenários de alteração e unicidade. Referências usam registros semeados, não GUIDs arbitrários em samples.
Campos, relações e operações precisam respeitar o schema oficial. Coleções filhas e referências entre módulos continuam fora do schema 1.
Índice único parcial
Seção intitulada “Índice único parcial”field.unique cobre a unicidade de um campo. Para uma regra como “no máximo uma versão ativa por organização”, declare o índice na entidade:
entity: # ... indexes: - { name: uk_hierarquia_versao_ativa_org, columns: [hve_orgid], unique: true, where: "hve_situacao = 'ATI' AND hve_deldat IS NULL", error: ActiveVersionExists }unique omitido vale false (índice ix_). columns e where usam nomes físicos. O predicado aceita só coluna IS [NOT] NULL, coluna = 'literal', coluna <> 'literal' (com true/false sem aspas contra coluna boolean) e coluna IN ('a', 'b') (literais de texto, lista não vazia) ligados por AND; o resto é recusado (BLD5012), inclusive NOT IN, números e subconsultas no IN. A exclusão lógica não entra sozinha: inclua <prefixo>_deldat IS NULL. error é opcional e aponta para o erro de uma regra em insert, update ou patch; o catálogo de constraints traduz o 23505 para ele. A migration cria o índice com IF NOT EXISTS, e db verify o compara. Veja Índices declarados.
Restrições CHECK e EXCLUDE
Seção intitulada “Restrições CHECK e EXCLUDE”Para regras do banco que um índice não expressa, como “o fim não antecede o início” ou “um titular não tem dois períodos sobrepostos”, declare constraints na entidade (exemplo das férias do Força de Vendas):
entity: # ... constraints: - { name: ck_ferias_periodo, type: check, expression: fri_data_fim >= fri_data_inicio } - { name: ck_ferias_titular_substituto, type: check, expression: fri_vendedor_id <> fri_substituto_id } - { name: ex_ferias_titular_periodo, type: exclude, equalityColumns: [fri_orgid, fri_vendedor_id], start: fri_data_inicio, end: fri_data_fim, endOverride: fri_data_termino_real, where: "fri_situacao_registro <> 'CAN' AND fri_deldat IS NULL" }check combina com AND, OR, NOT e parênteses as condições valor IS [NOT] NULL, valor op valor (=, <>, <, <=, >, >=) e valor IN ('a', 'b'), em que valor é coluna, 'literal', número, true/false, char_length, btrim, lower ou upper de texto, ou COALESCE(coluna, literal). AND e OR só se misturam com parênteses, e as palavras-chave são maiúsculas. Casts, outras funções, subconsultas, aritmética, NOT IN, $, ;, colunas inexistentes e tipos incompatíveis são recusados (BLD5012). Exemplos: trc_origem <> 'TRANSFERENCIA' OR char_length(btrim(COALESCE(trc_motivo, ''))) >= 20 e o “se e só se” (fct_situacao = 'ENCERRADO' AND fct_encerrado_em IS NOT NULL) OR (fct_situacao <> 'ENCERRADO' AND fct_encerrado_em IS NULL). exclude gera EXCLUDE USING gist com daterange(start, COALESCE(endOverride, end), '[]'): os dois extremos contam, e endOverride preenchido encerra o período antes. start é um campo date obrigatório; end opcional é uma vigência aberta (o fim nulo não limita o período, como na vigência de um preço); em tabela de tenant, equalityColumns inclui a coluna da organização. where segue a gramática do índice parcial. error é opcional e aponta para o erro de uma regra; o catálogo traduz 23514 ou 23P01 para ele. A migration cria tudo de forma idempotente, com btree_gist. Veja Restrições declaradas.
Exemplo da solicitação de aprovação do Força de Vendas: no máximo uma solicitação em andamento por documento.
indexes: - { name: uk_aprovacao_solicitacao_andamento, columns: [apr_orgid, apr_documento_tipo, apr_documento_id], unique: true, where: "apr_situacao IN ('PEN', 'INF') AND apr_deldat IS NULL" }Repositório de escrita sem rotas
Seção intitulada “Repositório de escrita sem rotas”Uma entidade que só publica get/list, mas que ações manuais do host criam e alteram, declara repository: write:
entity: # ... repository: writeapi: operations: [get, list]O builder gera I<Entidade>Repository (GetByIdAsync, InsertAsync, Delete na exclusão física e <Campo>ExistsAsync dos campos únicos), a implementação EF, o registro no DI e o fake de teste, sem rotas, comandos nem handlers de escrita. O código manual grava por ele e confirma pela unidade de trabalho do módulo. Sem a opção, a geração não muda.
Uma reference para uma entidade sem repositório de escrita é conferida pela porta de leitura dela (I<Alvo>QueryRepository), com a mesma visibilidade por organização e exclusão lógica.
Regras com serviços
Seção intitulada “Regras com serviços”Um gancho de rules[] é estático: ele só olha a entidade. Quando a regra precisa consultar o banco ou alterar outra linha na mesma transação, declare services: true:
rules: - { id: RN-02, hook: CloseCurrentCost, on: [insert], error: CostOverlap, services: true }ProductCostRules passa a ser uma classe registrada no DI. O handler de cada operação em on a recebe e chama o gancho antes de gravar. Escreva os serviços na região ProductCost.rule-services e a regra na região dela:
// <sil-business id="ProductCost.rule-services">private readonly IProductCostHistory history;private readonly TimeProvider clock;
public ProductCostRules(IProductCostHistory history, TimeProvider clock){ this.history = history; this.clock = clock;}// </sil-business>
// <sil-business id="RN-02.CloseCurrentCost">public async ValueTask<bool> CloseCurrentCostAsync(ProductCostEntity entity, string operation, CancellationToken cancellationToken){ var costs = await history.ListByProductAsync(entity.ProductId, cancellationToken); if (costs.FirstOrDefault(cost => cost.ValidTo is null) is { } open) { if (open.ValidFrom >= entity.ValidFrom) return true; open.ValidTo = entity.ValidFrom.AddDays(-1); open.MarkUpdated(clock); } var end = entity.ValidTo ?? DateOnly.MaxValue; return costs.Any(cost => cost.ValidTo is { } to && to >= entity.ValidFrom && cost.ValidFrom <= end);}// </sil-business>IProductCostHistory é uma porta escrita à mão em Application. Implemente-a em Infrastructure sobre o DbContext do módulo, por exemplo herdando SilRepository<T> e usando Visible(), e registre-a na região Dock.services.infrastructure. Para os testes com fakes, registre um fake sobre FakeStore<T> na região Dock.fake-services de DockFakes.cs, que só existe quando o módulo tem regra com serviços.
O gancho roda depois da validação, da unicidade, das referências e do mapeamento, e antes da gravação. As linhas que ele altera pelo EF entram no mesmo SaveChanges da operação. Se ele devolver true (ou um Result com falha), o handler devolve o erro e nada é gravado, nem o que o gancho já tinha alterado. Na inclusão, OrganizationId ainda não está preenchido: filtre a organização pela consulta. Não há gancho depois da gravação, e uma alteração feita por SQL direto não é desfeita. Sem services, a saída gerada não muda. Veja o contrato.
Tabela de outro sistema
Seção intitulada “Tabela de outro sistema”builder.entity.external: { id: <coluna bigint>, ideapi: <coluna do GUID> } declara uma entidade somente leitura, como a organização da plataforma em base.organizacao. Ela serve de organization a um tenant bigint-identity e gera só o mapeamento EF de leitura. Veja Organização externa para as restrições, os testes e o baseline do db verify.
builder.entity.ideapiStorage: varchar (padrão uuid) guarda o GUID público de uma entidade bigint-identity em varchar(50), o padrão da plataforma; na entidade externa, descreve a coluna existente (ger_org_ideapi varchar(50)). O C# continua com Guid. Veja GUID público em varchar(50).
Colunas JSON
Seção intitulada “Colunas JSON”Declare type: json para uma coluna PostgreSQL json, ou type: jsonb para jsonb:
- name: Metadata json: metadados type: json default: {origem: portal, tags: []}O gerador usa JsonElement nos contratos e entidades. POST, PUT, GET e lista preservam objetos, arrays e escalares JSON. Envie {"metadados":{"tags":["urgente"]}}; colocar aspas em volta do documento grava uma string JSON. Defaults também aceitam objetos e arrays.
No PATCH, inclua Metadata em api.patchFields: o valor enviado substitui o documento inteiro. Omissão preserva o documento; null limpa uma coluna anulável. Obrigatórios e campos com default recusam null. A lista permite filtro null nos campos anuláveis; ordenação, unicidade e busca textual em JSON não são suportadas.
new feature --from-db preserva json e jsonb. Para converter uma coluna existente de text, revise os dados e a migration de conversão; alterar a spec não converte automaticamente os registros existentes.
PATCH com allowlist
Seção intitulada “PATCH com allowlist”Publique patch declarando os nomes C# dos campos que podem mudar:
dotnet builder add endpoint features/dock/2026-10-03-dock-docas patch --patch-fields Name,Activedotnet builder spec lintdotnet builder spec apply features/dock/2026-10-03-dock-docasO comando grava api.patchFields: [Name, Active]. A lista é obrigatória e aceita apenas campos de negócio existentes; ID, organização e auditoria não entram nela. O PATCH é optativo, inclusive em features sem PUT.
Envie um objeto JSON parcial para PATCH /client/dock/v1/docas/{guid}, por exemplo {"ativo": false}. Campo ausente preserva o valor; null limpa apenas campos anuláveis e é recusado nos obrigatórios ou com default. false e 0 são alterações explícitas. Campo fora da allowlist, tipo inválido ou corpo malformado retorna 400; sucesso retorna 204 sem corpo. {} preserva os campos de negócio.
Permissões ficam em api.permissions.patch (ações 30–39), ou na isenção explícita da feature. Regras de negócio que também se aplicam ao PATCH devem incluir patch em rules[].on. OpenAPI e doc=true mostram apenas os campos da allowlist, todos opcionais na presença. Não há operações JSON Patch (op/path/value) nem atualização de coleções.