Uma fatia vertical do DeskPilot com persistência, isolamento por tenant e idempotência, antes de qualquer sugestão de IA existir
Sexta-feira à tarde, um engenheiro termina de montar as telas de triagem do DeskPilot: fila, detalhe do ticket, formulário de atribuição, histórico. Ele grava um vídeo de quatro minutos passando pelo fluxo inteiro — importar um ticket, abrir, atribuir a um agente, ver o evento aparecer no histórico — e manda para o time. Todo mundo gosta. Na segunda-feira, ele abre o laptop para continuar e a fila está vazia. Não existe ticket nenhum, nem o que tinha sido "atribuído" na sexta. O estado inteiro vivia em variáveis React no navegador; o restart do processo de desenvolvimento apagou tudo, e ninguém tinha notado porque o vídeo gravado escondia esse detalhe tão bem quanto qualquer produto em produção esconderia.
Isso não é um bug cosmético. É a diferença entre um MVP e uma demonstração. Um MVP entrega uma fatia real de valor que sobrevive ao mundo real — reinício de processo, segundo usuário, clique duplicado, tentativa de acessar o que não é seu. Uma demonstração entrega a aparência de valor no caminho exato que o apresentador decidiu percorrer. A tese deste capítulo é direta: uma fatia vertical com feedback, persistência e regras verificáveis produz evidência melhor do que uma coleção de telas ou prompts. A decisão que se pede ao leitor: qual é o menor fluxo completo que permite a um agente concluir uma triagem com confiança — e o que, especificamente, prova que ele concluiu?
Este capítulo parte do domínio construído no capítulo 03 (applyCommand, matriz de permissões, contrato de evento) e o leva à primeira execução ponta a ponta: DeskPilot v0.1, local, com IA completamente desligada. Nada aqui depende de um modelo para funcionar — essa é a baseline contra a qual o capítulo 05 vai medir se uma sugestão de IA realmente ajuda.
commandId, uma linha no bancoAntes de qualquer tela, o núcleo executável deste capítulo é uma rota HTTP que aplica um comando de forma idempotente. O handler de POST /tickets/:id/commands em examples/deskpilot/src/api/server.ts faz exatamente uma coisa antes de qualquer lógica de domínio: rejeita a requisição se ela não trouxer um identificador de comando gerado pelo cliente.
const commandId = body.commandId ? String(body.commandId) : "";
if (!commandId) {
// A missing client-generated id is a caller bug, not something the
// server can make idempotent on the caller's behalf.
return send(res, 400, { error: "missing_command_id" });
}
const result = await repo.applyIdempotentCommand({
tenantId: actor.tenantId,
commandId,
ticketId: parts[1],
type: body.type as never,
actor,
expectedVersion: Number(body.expectedVersion),
assigneeId: body.assigneeId ? String(body.assigneeId) : undefined,
nowIso: new Date().toISOString(),
});
Do outro lado, PgRepository.applyIdempotentCommand (em src/db/pg-repository.ts) verifica se aquele commandId já foi aplicado para aquele tenant antes de tocar em qualquer linha de tickets. Se já foi, devolve o resultado gravado sem chamar applyCommand de novo. Se não foi, aplica a transição, grava o evento de auditoria e só então insere uma linha em applied_commands com uma chave primária composta (tenant_id, command_id) — é essa restrição de unicidade, não uma verificação em memória, que impede duas requisições concorrentes de vencerem a mesma corrida:
try {
await client.query(
"insert into applied_commands (tenant_id, command_id, ticket_id, result_version, applied_at) values ($1,$2,$3,$4,$5)",
[input.tenantId, input.commandId, input.ticketId, next.version, input.nowIso]
);
} catch (err) {
const pgErr = err as { code?: string };
if (pgErr.code === "23505") {
return { ok: true, replayed: true, ticket: current };
}
throw err;
}
Isso é o oposto de "adicionar mais uma tela". É uma linha de defesa contra um comportamento real e comum: um agente clica duas vezes em "Atribuir" porque a interface não deu feedback rápido o bastante, ou o navegador reenvia a requisição depois de uma falha de rede. O teste retrying the same commandId (double-click) does not duplicate the assignment or its audit event, em test/api/api.test.ts, reproduz exatamente esse cenário e passa tanto contra o repositório em memória quanto contra PostgreSQL real (test/flow/rls.test.ts).
A tentação de um MVP é otimizar para a impressão de completude: quatro telas bonitas, cada uma parecendo pronta isoladamente. O problema é que "parecer pronto" e "completar uma tarefa" são propriedades diferentes, e só a segunda é testável. Uma fatia vertical força a pergunta certa em cada camada: o dado sobrevive a um restart? O backend, não a UI, decide se este ator pode ver este ticket? Um clique duplicado produz um efeito duplicado?
Essas três perguntas mapeiam diretamente às quatro falhas obrigatórias que este capítulo precisa desenvolver, detalhadas mais abaixo. A intuição por trás da tese é que cada uma dessas perguntas só tem resposta honesta quando existe um fluxo completo e executável — não quando existe um protótipo de interface com dados mockados em memória, nem quando existe um documento descrevendo "como o sistema deveria se comportar".
O capítulo 03 deixou um domínio puro (applyCommand, applySuggestionCommand, matemática de SLA por calendário) sem nenhuma dependência de I/O. Este capítulo copia esse arquivo sem alterar uma linha de regra — a nota de migração no topo de examples/deskpilot/src/domain/domain.ts é explícita sobre isso — e constrói três camadas em volta dele:
src/db/pg-repository.ts, src/db/memory-repository.ts): duas implementações da mesma interface DeskPilotRepository, uma real (PostgreSQL) e uma offline (em memória), garantindo que os testes de contrato HTTP rodem sem Docker.src/api/server.ts): um servidor HTTP sem framework — a única dependência de produção deste pacote é pg, versão 8.23.0 fixada em package.json/package-lock.json. Rotas: POST /dev/login, GET /tickets (fila), GET /tickets/:id (detalhe), GET /tickets/:id/audit (histórico), POST /tickets/:id/commands (comando idempotente).src/web/index.html, src/web/app.js): quatro telas — fila, detalhe, formulário de atribuição, histórico — em HTML semântico e JavaScript puro, sem build step.Dois tenants fixture (tenant-a, tenant-b) e quatro usuários sintéticos são criados por seed/seed.ts, rodando na conexão de dono do banco (nunca na conexão da aplicação). Nenhum nome, e-mail ou dado que se pareceria com informação real de cliente aparece em qualquer fixture — voltamos a isso na Falha #3.
O login de desenvolvimento é o ponto mais sensível desta camada, então ele recebe um guarda-corpo explícito em vez de um comentário: a rota /dev/login só responde quando a variável de ambiente DESKPILOT_ALLOW_DEV_LOGIN é exatamente a string "true", checada a cada requisição (não uma constante carregada uma vez no boot, para não existir nenhum caminho de código que "esqueça" de reler a configuração). Fora desse caso, ela devolve 503.
A tarefa completa deste capítulo — "importar ticket → revisar → atribuir → auditar" — precisa de quatro telas, nem uma a mais. Cada uma delas, em src/web/, cobre explicitamente os três estados que costumam faltar em protótipo: vazio, carregando e erro.
A tela de fila (GET /tickets) começa em estado de carregamento (renderStatus(queueStatus, "loading", "Carregando fila…")), anuncia a mudança numa região aria-live="polite" para quem usa leitor de tela, e distingue explicitamente "fila vazia" (mensagem própria, sem erro) de "falha ao carregar" (mensagem de erro, com sugestão de tentar de novo). Essa distinção importa: um agente que vê "fila vazia" sabe que não há trabalho pendente; um agente que vê "erro ao carregar" sabe que precisa tentar de novo ou avisar alguém — confundir os dois estados é um jeito comum de esconder um bug atrás de uma mensagem genérica de "nada aqui".
A tela de detalhe carrega o ticket e o histórico de auditoria em paralelo (Promise.all) e move o foco para o cabeçalho da seção ao terminar, satisfazendo o Success Criterion 2.1.1 Keyboard da WCAG 2.2 (toda função operável por teclado, incluindo navegação entre telas) sem exigir mouse em nenhum passo.
O formulário de atribuição desabilita o botão de envio no clique (a defesa perceptível ao usuário contra o duplo clique) e só o reabilita depois que a requisição volta — mas, como qualquer defesa só no cliente pode falhar (a aba pode travar, o clique pode acontecer antes do React re-renderizar, o usuário pode usar duas abas), o commandId gerado no envio é a defesa que não depende do estado do botão. O Success Criterion 2.1.2 No Keyboard Trap garante que o foco nunca fica preso dentro desse formulário — testável simplesmente tabulando até o campo e para fora dele sem usar o mouse.
A tela de histórico lista os eventos de auditoria em ordem cronológica, sem nenhum campo de texto livre do ticket — o mesmo contrato fechado (assertClosedAuditEvent) que o capítulo 03 definiu para AuditEvent continua valendo aqui, e a UI simplesmente não tem onde renderizar um campo que o backend nunca envia.
A interface DeskPilotRepository (em src/domain/repository.ts) é o contrato que separa domínio de armazenamento. Ela declara applyIdempotentCommand como um método que deve produzir o mesmo resultado gravado quando chamado duas vezes com o mesmo commandId — essa garantia não é opcional nem específica do Postgres; a implementação em memória precisa satisfazê-la também, e o mesmo arquivo de teste (test/api/api.test.ts) roda contra ela.
A escolha de isolamento de tenant tem duas camadas deliberadamente redundantes:
PgRepository abre uma transação, executa SELECT set_config('app.tenant_id', $1, true) (o equivalente parametrizado de SET LOCAL app.tenant_id = $1 — que não aceita bind parameters) e só então roda a query real, escopada WHERE tenant_id = $1.migrations/0001_init.sql habilita row-level security em todas as tabelas com escopo de tenant e cria uma política tenant_isolation que compara tenant_id ao mesmo current_setting('app.tenant_id', true).alter table tickets enable row level security;
-- (users, suggestions, audit_events e applied_commands recebem o mesmo alter)
drop policy if exists tenant_isolation on tickets;
create policy tenant_isolation on tickets
using (tenant_id = current_setting('app.tenant_id', true))
with check (tenant_id = current_setting('app.tenant_id', true));
Por que duas camadas para a mesma garantia? Porque a documentação do PostgreSQL é explícita sobre um detalhe fácil de esquecer: "the table's owner is typically not subject to row security policies" (PostgreSQL, Row Security Policies, acesso 2026-09-28). Se o time de migração testar RLS conectado como dono — o papel mais comum durante desenvolvimento — a política nunca vai falhar, mesmo quebrada. Por isso migrations/0001_init.sql cria um papel separado, deskpilot_app, sem BYPASSRLS e sem posse de nenhuma tabela, e é só com esse papel que test/flow/rls.test.ts roda. Um dos cinco testes ali existe só para provar o caso oposto: sem app.tenant_id definido, uma sessão autenticada como deskpilot_app não vê nenhuma linha de nenhum tenant — a mesma documentação explica por quê ("If no policy exists for the table, a default-deny policy is used"), e a ausência de valor na sessão produz o mesmo efeito prático.
O trade-off do lado da idempotência é mais estreito do que parece à primeira vista. A documentação da Stripe sobre requisições idempotentes (acesso 2026-09-28) descreve um contrato mais rico do que este exemplo implementa: a chave de idempotência da Stripe salva o corpo e o código de status da primeira requisição — inclusive erros 500 — e pode ser descartada automaticamente depois de, no mínimo, 24 horas. A tabela applied_commands deste capítulo não expira, não devolve o corpo original byte a byte, e não tem escopo por chave de API. É suficiente para provar a propriedade que este capítulo pede — retry não duplica atribuição nem evento —, mas não é uma implementação de idempotência de propósito geral, e um leitor que for construir isso para uma API externa real deveria ler o contrato específico dessa API, não copiar esta tabela.
Falha 1 — Demo sem persistência. Sintoma: o fluxo funciona perfeitamente ao vivo, mas qualquer restart (do processo, da aba, do laptop) apaga o progresso. Causa: o estado inteiro vive em variáveis de UI ou num array em memória do processo Node, nunca chega a um banco durável. Resposta: PostgreSQL real, com migração versionada (migrations/0001_init.sql) e seed reprodutível (seed/seed.ts); verification.md documenta um teste específico — triagem e atribuição via HTTP, depois docker restart series-ai-pe04-pg, depois releitura direta pelo repositório — confirmando que o ticket e os dois eventos de auditoria sobrevivem ao restart do container.
Falha 2 — Autorização só na UI. Sintoma: trocar o id na URL, ou chamar a API diretamente pulando a tela, deixa um tenant ler ou mudar o ticket de outro. Causa: a checagem de "isso é seu tenant?" existe só no componente React que decide o que renderizar, nunca no handler que de fato executa a query. Resposta: duas camadas independentes, descritas acima — filtro tenant_id = $1 em toda query do PgRepository, mais política RLS no banco testada contra um papel que não é dono da tabela. O teste tenant B cannot read tenant A's ticket by id devolve 404, não 403: a existência do recurso para o tenant errado também não é revelada.
Falha 3 — Seeds sensíveis. Sintoma: o script de seed usado "só para testar localmente" contém nomes, e-mails ou telefones reais copiados de uma planilha de cliente, ou uma senha fixa commitada no repositório que depois aparece num ambiente que não deveria ter acesso a ela. Causa: conveniência de curto prazo — é mais rápido copiar um dado real do que inventar um sintético plausível, e senha fixa evita configurar variável de ambiente. Resposta: seed/seed.ts só contém identificadores sintéticos (tenant-a, agent-b1) sem nenhum campo de nome, e-mail ou telefone; a senha do papel deskpilot_app é lida de DESKPILOT_APP_PASSWORD, nunca escrita no .sql (o placeholder :'deskpilot_app_password' em migrations/0001_init.sql é substituído em tempo de execução por migrate.ts); .env.example documenta as variáveis sem nenhum segredo real, e .env está no .gitignore deste pacote.
Falha 4 — Muitos componentes sem tarefa concluída. Sintoma: o board de design tem doze telas, cada uma revisada e aprovada isoladamente, mas nenhum caminho de ponta a ponta jamais foi testado inteiro. Causa: revisão por componente recompensa polimento visual local, não conclusão de tarefa; é possível "terminar" todas as telas sem nunca ter completado a tarefa de triagem uma única vez. Resposta: a fatia deste capítulo tem exatamente quatro telas (fila, detalhe, atribuição, histórico), cada uma mapeada a um passo da mesma tarefa, e o critério de aceite documentado em editorial-spec.md é "a tarefa completa passa", não "as telas existem" — o teste full happy path: triage, assign, then read history é o critério, não uma checklist de componente.
examples/deskpilot/ roda 25 testes automatizados no total: 14 em test/domain/ (13 herdados do capítulo 03 sem alteração, mais um novo sobre retry no nível de domínio), 6 em test/api/ (contrato HTTP contra o repositório em memória, sem Docker) e 5 em test/flow/ (o mesmo contrato contra PostgreSQL real, com o papel deskpilot_app). verification.md registra o comando exato e a saída de cada bloco, nesta sessão, em 2026-09-28.
O que isso prova: que a máquina de estados de tickets e sugestões continua correta depois de ganhar uma camada HTTP e um banco; que o isolamento de tenant é redundante em duas camadas independentes, e a camada de banco falha fechado quando mal configurada, não aberto; que retry e duplo clique não duplicam efeito, tanto em memória quanto em Postgres real; que o histórico sobrevive a um restart de container.
O que isso não prova: que um agente de suporte real consegue usar a interface sem ajuda — examples/usability-protocol.md define o roteiro think-aloud e o instrumento de tempo, mas nenhuma sessão com participante real aconteceu para este pacote, e isso está marcado como pendente, não como resultado. Não prova conformidade completa com WCAG 2.2 — a UI aplica três critérios pontuais (2.1.1 Keyboard, 2.1.2 No Keyboard Trap, 4.1.3 Status Messages, este último via a região aria-live em src/web/index.html), o que é diferente de uma reivindicação de conformidade formal, que exige data, nível e lista de páginas por definição do próprio W3C. E não prova que existe demanda real pelo DeskPilot — testar a aplicação e validar demanda são perguntas diferentes, e só a primeira tem evidência neste capítulo.
O custo operacional desta fatia, hoje, é essencialmente zero fora do tempo de engenharia: um container Docker efêmero (--rm, sem volume nomeado, porta de host dinâmica), sem serviço gerenciado, sem chamada de API paga — porque não há chamada de IA em nenhum caminho deste MVP. Isso é deliberado: a baseline determinística precisa ser barata de rodar repetidamente, porque ela vai ser recriada a cada sessão de teste e comparada contra a versão com IA no capítulo 05.
Do lado de segurança, os limites explícitos deste pacote são: PII minimizada (nenhuma fixture contém dado que se pareceria com informação real de uma pessoa), dev login isolado por flag lida a cada requisição, segredo de banco fora do controle de versão, e duas camadas de isolamento por tenant testadas de forma adversarial. O que não está aqui: consentimento de piloto real, decisão de retenção de dado além de um campo retentionDays no Policy herdado do capítulo 03 (ainda não aplicado por um job de expiração), e qualquer afirmação de conformidade regulatória.
Reversibilidade é alta por construção: o container não tem volume nomeado, então pará-lo apaga todo o estado sem deixar rastro em disco; a migração pode ser reaplicada do zero a qualquer momento contra um container novo; e a dependência de produção única (pg) está fixada em lockfile, então reinstalar em uma máquina limpa reproduz exatamente o mesmo grafo de dependências.
npm install a partir do lockfile, sem copiar node_modules.series-ai-pe04-pg iniciado com --rm, sem volume nomeado, porta dinâmica.npm run migrate e npm run seed rodados contra a conexão de dono (DATABASE_URL), nunca contra DATABASE_APP_URL.npm test (domínio + API, offline) e npm run test:rls (Postgres real) verdes.commandId não duplica atribuição nem evento.docker ps -a --filter name=series-ai-pe04-pg vazio).Básico. Rode o setup completo em examples/deskpilot/README.md numa máquina limpa: npm install, subir o container, migrar, semear, rodar npm test e npm run test:rls. Critério de aceite: os três comandos de teste terminam com fail 0, e você consegue explicar, sem olhar o código, por que npm test não precisa do container. Solução comentada: se npm run test:rls falhar com erro de conexão, o problema mais comum é .env apontando para uma porta antiga — docker port series-ai-pe04-pg 5432/tcp muda a cada docker run (e, como este capítulo documentou em verification.md, pode mudar até num docker restart); releia a porta antes de rodar os testes.
Intermediário. Adicione um teste em test/api/api.test.ts que confirma que a tela de fila mostra corretamente o estado vazio quando um tenant novo, sem tickets seedados, faz login. Critério de aceite: o teste falha antes de qualquer mudança de código (confirmando que hoje não há tenant fixture vazio para testar isso) e passa depois de você seedar — só para o teste, sem alterar seed/seed.ts — um terceiro tenant sem tickets. Solução comentada: a forma mais simples é instanciar um MemoryRepository direto no teste e nunca chamar nada que insira ticket para esse tenant; listQueue já devolve array vazio para um tenant sem linhas, então o teste está verificando o contrato da API ({tickets: []}, não um erro), não uma lacuna de implementação.
Avançado. O capítulo 05 vai introduzir um papel que só pode escrever Suggestion, nunca ler ou escrever Ticket. Modifique migrations/0001_init.sql para criar esse papel (deskpilot_suggester, por exemplo) com GRANT INSERT apenas em suggestions, e escreva um teste em test/flow/ que confirma que uma tentativa desse papel de ler tickets falha por falta de privilégio SQL (não por RLS) — a chamada deve devolver um erro de permissão do Postgres, não uma lista vazia. Critério de aceite: o teste distingue explicitamente "sem privilégio para a tabela" (GRANT ausente) de "com privilégio mas sem linha visível por RLS" (o caso já coberto pelos testes existentes) — são duas defesas diferentes, e confundi-las nesta preparação para o capítulo 05 seria repetir, em miniatura, a Falha #2 deste capítulo.
commandId) mais de uma vez produz o mesmo efeito de aplicá-lo uma vez.O capítulo seguinte entra exatamente onde este para: a tabela suggestions e o método getSuggestion já existem no contrato DeskPilotRepository, mas nada neste pacote escreve uma sugestão. O capítulo 05 introduz uma sugestão tipada de categoria e resumo em modo shadow, com adaptador offline e provedor real opcional, dataset rotulado com holdout separado, e a decisão explícita de que prioridade final, atribuição e comunicação externa continuam sendo comandos humanos validados pelo mesmo applyCommand que este capítulo já testou. Nenhuma regra de domínio deste capítulo muda; o que muda é que, pela primeira vez na série, existirá uma saída de modelo para comparar contra esta baseline determinística — e a baseline só é uma baseline honesta porque este capítulo mediu o que ela de fato faz, não o que pareceu fazer num vídeo de quatro minutos.
Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares
A flawless triage demo, recorded on a Friday, evaporates by Monday: nothing had been persisted. Starting from that incident, a complete vertical slice of DeskPilot — import, review, assign, audit —…
Um roteiro de entrevistas, uma árvore de oportunidades e um teste de disposição a pagar para decidir se o DeskPilot merece ser construído, usando apenas evidência sintética rotulada.
DeskPilot's tests were green. A synthetic provider outage, an actual local restore, and a release review exposed what those tests did not establish: whether a support agent can safely use the pilot.
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.