Módulos, estados e schemas versionados antes de deixar um modelo propor campos

Um analista de operações resolve um ticket antigo: "reprocessar a fatura INV-2001 com o extrator novo". Ele roda o comando. A função aceita o document_id, encontra um registro, aplica a extração e devolve um resultado plausível. Só que o tenant que chamou o comando é globex; o INV-2001 que ele deveria tocar pertence a acme. Duas organizações diferentes, por coincidência ou por adversário, usaram o mesmo identificador de documento. Se o sistema decide "existe um documento com esse ID, deixa eu processar", ele acabou de processar dado de outro tenant. Este incidente é sintético. Não descreve cliente, chamado de suporte ou vazamento real.
O capítulo 1 desta série tratou de decidir o que medir antes de escolher um extrator. Este capítulo trata de uma pergunta anterior a qualquer modelo: quando duas organizações usam o mesmo produto, quem decide que documento pertence a quem, e essa decisão sobrevive a um ID que colide? A tese é operacional: tenancy, estados de revisão e contratos de schema são a parte do sistema que não pode ser delegada a inferência de linguagem. Confiar no modelo, no texto do documento ou em um argumento de conveniência para resolver isso transforma um erro comum de extração - um campo errado, um tipo de documento não suportado - em um incidente de integridade e privacidade entre organizações.
O artefato deste capítulo evolui o RelayOps do brief e da CLI do capítulo 1 para um monólito modular local com quatro módulos (ingest, extraction, review, audit), dois contratos JSON Schema versionados e 16 testes de domínio em examples/. Continua em ponto zero de produção: sem cliente, piloto ou dado real. O que muda é que agora existe uma fronteira testável entre "o modelo propôs isso" e "o sistema aceitou isso". A decisão de tenancy pooled versus silo está registrada em ADR-004.
null não distingue "não existe" de "não é seu"Antes de desenhar os quatro módulos, fixamos uma regra sobre o armazenamento: uma leitura por tenant_id e document_id que não pertence ao tenant chamador retorna exatamente o mesmo resultado de uma leitura de um document_id que não existe em lugar nenhum. Em examples/src/store.mjs, getOwned(tenantId, documentId) particiona por tenant e devolve null nos dois casos. Isso não é um detalhe de implementação; é a diferença entre negar acesso e revelar existência. Um sistema que responde "403: documento pertence a outro tenant" já vazou um bit de informação sobre outra organização. Um sistema que responde "não encontrado" para os dois casos não vaza nada além de "essa credencial não tem nada aqui com esse nome".
O teste que prova isso é direto:
const asOwner = store.getOwned('acme', 'INV-2001');
const asAttacker = store.getOwned('globex', 'INV-2001');
const asAttackerUnknownId = store.getOwned('globex', 'INV-9999');
assert.ok(asOwner);
assert.equal(asAttacker, null);
assert.equal(asAttackerUnknownId, null);
assert.equal(asAttacker, asAttackerUnknownId); // indistinguível: sem vazamento de existência
A última linha é a que importa: o resultado de tentar ler o documento correto de outro tenant é igual, bit a bit, ao resultado de tentar ler um ID que nunca existiu. Não há diferença de latência, mensagem ou shape de resposta que um chamador possa usar para inferir "esse ID existe em algum lugar". Essa propriedade guia todo o resto do capítulo: ingest.mjs, extraction.mjs e review.mjs chamam store.getOwned primeiro e tratam null como o único sinal de "não posso continuar", nunca distinguindo "não existe" de "não é seu" em uma mensagem de erro que o chamador veja.
O incidente sintético do início mistura três conceitos que o RelayOps agora separa explicitamente:
actorContext.tenantId e actorContext.actorId, passados por quem chama a função - o equivalente didático de uma sessão de servidor autenticada. Nenhum módulo aceita tenant vindo de um parâmetro de texto livre.review.approve, review.reject e review.reprocess verificam não só o tenant, mas também a revisão que o revisor viu (revision no payload deve bater com doc.revision atual) antes de aceitar uma decisão - um controle de concorrência otimista que impede aprovar uma versão que já mudou.store.mjs garante particionando por tenant_id e nunca cruzando partições, mesmo quando o document_id colide.Um sistema pode ter autenticação forte (login funciona bem) e ainda falhar em autorização (qualquer usuário autenticado acessa qualquer documento) ou em isolamento (uma consulta mal escrita cruza tenants mesmo com autorização correta no código de aplicação). O incidente do início do capítulo é uma falha de autorização: o comando de reprocessamento tinha uma identidade autenticada válida (globex), mas faltou verificar se aquele tenant tinha autoridade sobre aquele document_id específico antes de agir.
O capítulo 1 definiu um brief de arquitetura, seis cenários de qualidade e um parser de fatura que aceitava uma linha Tenant: acme dentro do próprio texto do documento, comparando com um argumento de tenant esperado. Isso resolvia um problema didático - mostrar que uma declaração dentro do documento não deveria bastar - mas ainda dava ao texto um papel na conversa sobre tenant. Este capítulo remove esse papel por completo: parseInvoiceText em examples/src/extraction.mjs não procura mais nenhuma linha de tenant. O tenant_id de um documento é decidido no momento do ingest, a partir de actorContext, e nunca mais revisitado a partir do conteúdo.
O modelo de dados agora tem seis campos centrais, definidos em examples/contracts/document.schema.json:
tenant_id - identidade de propriedade, nunca inferida do documento.document_id - identificador escolhido no ingest; não é globalmente único, só único dentro do tenant (é exatamente essa escolha que faz o incidente do início ser interessante).revision - número que avança apenas quando uma decisão humana (aprovar, rejeitar, reprocessar) cria uma nova versão correta.source_checksum - SHA-256 do texto original, calculado uma vez no ingest e nunca recalculado ou sobrescrito.extraction_version - avança a cada vez que um extrator (regra ou, futuramente, modelo) produz uma proposta nova para aquele documento.review_status - um dos cinco estados: uploaded, extracted, needs_review, approved, rejected.A máquina de estados, em examples/src/state.mjs, é deliberadamente pequena:
uploaded -> extracted
extracted -> needs_review
needs_review -> approved | rejected
approved -> extracted (somente reprocessamento com nova revision/extraction_version)
rejected -> extracted (idem)
Qualquer transição fora dessa tabela lança TransitionError. E há uma segunda checagem, assertReprocessVersioning, que só entra em jogo quando a transição é approved -> extracted ou rejected -> extracted: ela exige que a nova revision seja estritamente maior que a anterior e que a nova extraction_version também seja. Isso é o que torna o exercício básico deste capítulo verificável em código, não só em prosa.
O capítulo pede para separar ingest, extraction, review e audit "por interfaces e dependências". Na prática isso significa decidir quem pode importar quem:
store.mjs, state.mjs, schema-lite.mjs, migrations.mjs e audit.mjs são folhas: nenhum deles importa outro módulo do sistema.ingest.mjs importa store.mjs e schema-lite.mjs. Ele cria o documento, calcula o checksum e valida contra o contrato antes de gravar.extraction.mjs importa store.mjs, state.mjs e schema-lite.mjs. Ele lê um documento que já possui, propõe campos com um parser baseado em regra, valida a proposta contra extraction.schema.json e pede duas transições (uploaded -> extracted -> needs_review) usando a tabela de state.mjs.review.mjs importa store.mjs e state.mjs. Ele é o único módulo autorizado a aprovar, rejeitar ou reprocessar.Note o que não existe: extraction.mjs não importa review.mjs, e review.mjs não importa extraction.mjs. Os dois dependem da mesma tabela de transições compartilhada (state.mjs), mas não um do outro. Isso não é estética - é o que evita a quarta falha obrigatória deste capítulo, descrita abaixo. Uma inspeção estática simples confirma isso:
$ grep -n "^import" examples/src/*.mjs
audit.mjs: (nenhum import)
extraction.mjs: store.mjs, state.mjs, schema-lite.mjs
ingest.mjs: store.mjs, schema-lite.mjs
migrations.mjs: (nenhum import)
review.mjs: store.mjs, state.mjs
schema-lite.mjs: (nenhum import)
state.mjs: (nenhum import)
store.mjs: (nenhum import)
Essa é uma verificação real, executada nesta sessão (ver verification.md), não uma alegação sobre a intenção do design.
O prompt deste capítulo pede para comparar um módulo bem delimitado com um microserviço prematuro, considerando custo de operação. A resposta aqui é concreta: ingest, extraction, review e audit são arquivos separados, com fronteiras de import explícitas e nenhuma dependência circular - mas continuam rodando no mesmo processo Node, sem fila, sem rede, sem deploy independente. Transformar cada um em um serviço HTTP separado hoje adicionaria: um protocolo de rede a versionar, retry e timeout para toda chamada entre eles, um jeito de propagar actorContext de forma que não vire outra superfície de spoofing de tenant, e observabilidade distribuída para um sistema com zero tenants reais. Nada disso é gratuito, e nada disso resolve um problema que o RelayOps tenha hoje. A ADR de modularidade do capítulo 1 (ADR-002) já apontava isso; este capítulo mostra a versão testável: separação de módulo por import, não por processo, até que uma medida real - não uma preferência estética por microserviços - mostre que um módulo específico precisa escalar, ser implantado ou ter uptime independente dos outros.
examples/contracts/document.schema.json é a versão 2 do contrato de documento. A versão 1, congelada em examples/contracts/document.schema.v1.json, não tem o campo idempotency_key; a versão 2 adiciona esse campo como opcional. Essa é a metade "expand" de uma migração expand/contract: qualquer registro v1 se torna um registro v2 válido apenas preenchendo idempotency_key: null, sem que nenhum produtor de dados v1 precise mudar primeiro. A função migrateDocumentV1toV2 em examples/src/migrations.mjs faz exatamente isso, e examples/compatibility.test.mjs prova três coisas com uma fixture v1 real (examples/fixtures/document-v1.json):
const de schema_version diverge) - não existe compatibilidade automática só por coincidência de campos.migrateDocumentV1toV2, o mesmo documento valida contra v2, mantém tenant_id e source_checksum idênticos, e ganha idempotency_key: null sem perder nenhuma informação anterior.A metade "contract" - remover a tolerância à v1, tornar idempotency_key obrigatório - deliberadamente não está implementada. Contrair antes que todo produtor emita v2 quebraria qualquer chamador que ainda não migrou. Isso fica registrado como trabalho futuro em examples/src/migrations.mjs e no ADR-004, não como pendência escondida.
O ponto pedagógico mais importante aqui é o que compatibility.test.mjs testa como falha esperada: um documento v2 com review_status: "closed" - um valor de enum novo, introduzido sem versionar o schema - é rejeitado pelo validador. Isso é o cenário descrito no prompt como "schema mutável sem migração": alguém ajusta um prompt de extração, ou um handler, para começar a emitir um status novo, sem tocar no contrato. Se o validador aceitasse silenciosamente, o sistema teria uma bifurcação de comportamento não documentada. Rejeitar é o comportamento correto; documentar por que é o trabalho deste capítulo.
Sintoma: um extrator (regra ou modelo) processa um documento e a proposta de campos inclui, entre outras coisas, um tenant_id - seja porque o texto mencionava outra organização, seja porque um tool call decidiu "esse documento parece ser da Globex". Se o sistema usa esse campo para decidir onde gravar ou quem pode ver o resultado, a autorização virou uma opinião do modelo.
Causa: confundir "o modelo pode descrever algo sobre tenant" com "o modelo pode decidir tenant". Um LLM que tem permissão de propor uma ação com efeito de autorização é exatamente o risco que a OWASP GenAI Security Project descreve como LLM06:2025, Excessive Agency - um sistema baseado em LLM recebendo grau de autonomia maior do que a tarefa exige (OWASP, LLM Top 10, edição 2025).
Resposta: extraction.mjs aceita um options.modelProposal opcional (pensado para quando um extrator baseado em modelo existir) e, se esse objeto contiver tenant_id ou tenant, o campo é ignorado e registrado em auditoria com o motivo proposal_declared_tenant - nunca mesclado no registro. O teste correspondente injeta uma proposta adversarial com tenant_id: 'globex' enquanto o actorContext real é acme, e confirma que o documento resultante continua com tenant_id: 'acme'.
Sintoma: um novo campo, um novo valor de enum ou uma mudança de tipo aparece nos dados sem que o schema_version mude. Consumidores antigos e novos discordam silenciosamente sobre o formato.
Causa: tratar o schema como documentação e não como contrato executável - especialmente fácil quando a mudança nasce de uma alteração de prompt ("agora o extrator também retorna confidence") em vez de uma decisão de schema deliberada.
Resposta: todo escritor de documento (ingest.mjs, extraction.mjs) valida contra schema-lite.mjs antes de gravar, e qualquer propriedade fora da lista declarada é rejeitada (additionalProperties: false). compatibility.test.mjs prova isso injetando model_suggested_tenant como uma propriedade extra e confirmando que o validador rejeita - a mesma defesa que impede a falha 1 de entrar pela porta do schema em vez de pela porta do parâmetro.
Sintoma: um revisor corrige um campo errado, e a correção substitui o texto original ou a revisão anterior. Depois de um incidente, não há como saber o que o documento dizia antes da correção, nem quem decidiu o quê.
Causa: tratar "revisão" como edição de um único registro mutável, em vez de como criação de uma nova revisão auditável sobre uma anterior imutável.
Resposta: store.putDocument sempre adiciona ao histórico (docs.get(documentId).push(...)); nunca substitui uma entrada anterior. review.reprocess cria uma nova revisão com revision e extraction_version maiores, mas herda source_checksum do documento original - o texto de origem é gravado uma vez, no ingest, e nunca recalculado. O teste "reprocessamento preserva checksum e revisão anterior" confirma as três propriedades ao mesmo tempo: reprocessed.source_checksum === uploaded.source_checksum, e store.history('acme', 'INV-2001') ainda contém a entrada com review_status: 'rejected' da revisão anterior.
Sintoma: o time descreve o sistema como "arquitetura modular" porque o código está em arquivos separados com nomes de domínio (ingest.js, review.js...), mas extraction importa uma função de review para "avisar que terminou", e review importa algo de extraction para "reprocessar automaticamente". Nenhum dos dois pode ser entendido, testado ou substituído isoladamente, porque carregar um carrega o outro.
Causa: adicionar uma chamada direta entre módulos de domínio na primeira vez que "seria conveniente" um chamar o outro, sem checar se isso fecha um ciclo no grafo de dependências.
Resposta: este capítulo resolveu o mesmo problema de coordenação - extração precisa avançar o estado, revisão precisa reprocessar de volta para extração - sem um ciclo, extraindo a tabela de transições para state.mjs, um módulo-folha que ambos importam. Nem extraction.mjs nem review.mjs precisam saber que o outro existe. A verificação de que isso é verdade não é uma alegação de design; é o grep estático reproduzido acima, que qualquer leitor pode rodar de novo.
Os 16 testes locais (node --test examples/*.test.mjs, ver verification.md para saída completa) cobrem: caminho positivo completo (ingest → extract → approve); o incidente sintético do início, com dois tenants usando o mesmo document_id; leitura, extração e aprovação/rejeição cross-tenant negadas e auditadas; injeção de tenant_id forjado em uma proposta de extração; rejeição de approved -> extracted sem novo versionamento (o exercício básico); preservação de checksum e revisão anterior no reprocessamento; conflito de checksum ao reingerir o mesmo document_id com conteúdo diferente; validação da fixture v1 contra o próprio schema v1; falha esperada ao validar v1 direto contra v2; migração v1→v2 sem perda; rejeição de um schema_version v2 já migrado sendo migrado de novo; aceitação de idempotency_key explícito; e as duas falhas de schema descritas acima (enum não versionado, propriedade desconhecida).
O que isso não prova: nenhuma linha de PostgreSQL foi executada nesta sessão. store.mjs particiona corretamente dentro de um único processo Node com uma única instância de Map - isso não é o mesmo que provar que uma política de row-level security segura uma tabela compartilhada sob conexões concorrentes, com um pool de conexões que pode, por engano, rodar como dono da tabela. A seção seguinte e o exercício avançado detalham exatamente o que faltaria. Também não medimos qualidade de extração: parseInvoiceText é a mesma classe de parser por regra do capítulo 1, testado aqui só por comportamento de contrato (produz um registro válido? registra proveniência?), não por acurácia contra uma distribuição real de faturas.
A AWS Well-Architected Framework enquadra isso como perguntas, não como checklist de certificação: que decisão de segurança este desenho assume, e o que ela custa para operar? Neste capítulo, a resposta é registrada em ADR-004: manter tenancy pooled (uma partição lógica por tenant_id, não um schema ou banco por tenant) e aplicar isolamento no código da aplicação, adiando a prova em infraestrutura compartilhada.
Isso tem um custo de reversibilidade explícito. Migrar um tenant específico para um schema ou banco dedicado, mais tarde, é um problema de extração e recarga de dados - não um redesenho, porque tenant_id já é uma chave de primeira classe desde o início. O caminho inverso - juntar tenants que já divergiram em silos separados - é mais difícil. Isso favorece adiar a decisão de silo até haver um gatilho real: um piloto consentido com exigência contratual de residência de dados, ou uma medição de carga (não feita ainda) mostrando que um tenant degrada a latência de outro em uma tabela compartilhada.
Sobre segurança especificamente de banco de dados, a documentação de Row Security Policies do PostgreSQL - reaberta nesta sessão, refletindo a versão "current" (PostgreSQL 18 no momento do acesso) - registra três detalhes que mudam o que "prova de isolamento" significa na prática: ENABLE ROW LEVEL SECURITY precisa ser ligado por tabela, ou a política simplesmente não roda; superusuários e o dono da tabela ignoram RLS por padrão, então um pool de conexões que rodasse como dono passaria qualquer teste cross-tenant sem provar nada; e checagens de integridade referencial (chave única, chave estrangeira) ignoram RLS por design, o que a própria documentação chama de canal secundário potencial de vazamento de informação. Nenhuma dessas condições foi testada aqui - elas estão documentadas em ADR-004 como o que falta antes de qualquer afirmação de isolamento em produção.
tenant_id de toda operação vem de um contexto autenticado pelo chamador, nunca de um campo dentro do documento ou de uma proposta de modelo.Básico. Critério verificável: chamar review.reprocess(store, auditLog, reviewer, 'INV-2001', { nextRevision: revisãoAtual, reason: '...' }) a partir do estado approved, usando a mesma revision atual (sem incrementar), deve lançar TransitionError com a mensagem contendo "new revision", e o log de auditoria deve conter uma entrada com result: 'denied' e reason: 'missing_versioning'. Solução comentada: isso já está implementado e testado em examples/tenancy.test.mjs, no teste "basic exercise: approved -> extracted is rejected without a new revision/extraction_version" - a checagem vive em assertReprocessVersioning (examples/src/state.mjs), chamada por review.reprocess antes de qualquer escrita no store.
Intermediário. Critério verificável: adicionar um schema v2 do contrato de documento sem invalidar a fixture v1 existente. Solução comentada: já resolvido por examples/contracts/document.schema.v1.json (congelado) + examples/contracts/document.schema.json (v2, com idempotency_key opcional) + migrateDocumentV1toV2 em examples/src/migrations.mjs. O teste "intermediate exercise" em examples/compatibility.test.mjs roda exatamente essa migração sobre examples/fixtures/document-v1.json e confirma que nada se perde. Trecho demonstrativo (não é um comando novo, é o já executado nesta sessão): node --test examples/compatibility.test.mjs.
Avançado. Critério verificável: escrever um teste que tenta ler, por document_id conhecido, um documento de outro tenant, e descrever - sem implementar - os requisitos adicionais para provar a mesma propriedade em PostgreSQL real. Solução comentada: o teste já existe ('cross-tenant read of a known document_id returns nothing, same as a nonexistent id', em examples/tenancy.test.mjs) e prova a propriedade dentro de um processo Node com uma única instância de Map. Os requisitos adicionais, descritos em examples/adrs/004-tenancy.md e não executados nesta sessão, são: (1) ALTER TABLE ... ENABLE ROW LEVEL SECURITY explicitamente ativado na tabela; (2) uma política USING (tenant_id = current_setting('app.tenant_id')::text) - trecho demonstrativo, não executado:
-- Demonstrativo. Não executado nesta sessão; requer Postgres real e role dedicada.
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.tenant_id')::text);
(3) uma role de teste que não seja dona da tabela e não tenha BYPASSRLS, porque o dono e o superusuário ignoram RLS por padrão; (4) duas transações separadas com SET LOCAL app.tenant_id = '...' diferentes, testando SELECT/UPDATE/DELETE cross-tenant e esperando zero linhas ou erro de permissão; (5) atenção ao canal secundário documentado pelo próprio Postgres: violações de chave única/estrangeira ignoram RLS e podem, em tese, revelar que uma linha existe em outra partição.
Este capítulo prova isolamento e contrato dentro de um único processo Node. O que fica pendente, explicitamente, para a continuação da série: uma integração real com um banco transacional (a ADR-004 já lista os requisitos de RLS que precisam ser satisfeitos, não apenas declarados); uma política de retenção e redação de PII mais completa do que o redactFields atual, que hoje só mascara o comprimento de strings; e o primeiro ponto em que um extrator baseado em modelo real - não a regra de parseInvoiceText - entra no sistema sem herdar nenhuma autoridade sobre tenant ou transição de estado. Nenhum desses três itens está implementado aqui; estão registrados como o próximo experimento, não como resultado.
Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares

A valid document_id can cross an organization boundary if authorization depends on document text or a model. Chapter 2 builds tenancy, review states, and versioned contracts for RelayOps.

Start AI-native architecture with testable constraints. Build an offline baseline, measurable quality scenarios, and reversible decisions for RelayOps.

A microservices diagram appears before any operational measurement. The final AI-native Architect chapter uses virtual-time simulation to test per-tenant serving order, records the trade-off, and…
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.