Máquina de estados, matriz de permissões e contrato de eventos para o DeskPilot, antes de qualquer sugestão de IA virar comando

Um ticket do DeskPilot foi resolvido numa sexta-feira, perto do fim do expediente. Na segunda seguinte, o cliente reabre: o problema voltou. O sistema recalcula o prazo de SLA a partir do momento da reabertura, como se o ticket fosse novo. O ticket é de prioridade normal, com orçamento de 16 horas úteis (960 minutos). Ele já tinha consumido cerca de 13 horas úteis entre a criação e a primeira resolução. O recálculo "do zero" devolve as 16 horas inteiras, de novo — um prazo mais generoso do que o ticket deveria ter, escondendo o fato de que a maior parte do orçamento original já foi gasta.
Isso parece um erro inofensivo, até generoso. Não é. Um SLA existe para que gestor, agente e cliente saibam quanto tempo resta antes de uma escalada. Se o cálculo trata toda reabertura como um ticket novo, a métrica de SLA deixa de significar o que o contrato com o cliente diz que significa. Pior: se ninguém formalizou essa regra como código testável, o comportamento — correto ou errado — depende de quem escreveu aquele trecho específico do backend, ou pior ainda, de qual modelo de IA foi consultado para "resumir a regra" numa conversa lateral.
Este é o assunto deste capítulo: transformar linguagem, exceção e responsabilidade em contratos que um teste automatizado rejeita quando violados — antes de decidir o que uma IA pode ou não sugerir dentro desse contrato. A tese é direta: conhecer produto exige converter regra em algo verificável antes de delegar qualquer decisão a um modelo. A decisão que este capítulo pede ao leitor é dupla: o que é regra determinística, o que é sugestão, e quem tem autoridade para mudar cada uma.
Antes de qualquer discussão de arquitetura, o núcleo deste capítulo cabe em uma função. Ela recebe um comando, um ator autenticado e o estado atual de um ticket, e devolve se a transição é permitida — sem ler banco de dados, sem chamar IA, sem depender do relógio do sistema:
export function applyCommand(cmd: TicketCommand, now: { iso: string }): TransitionResult {
const { actor, ticket } = cmd;
if (actor.tenantId !== ticket.tenantId) {
return { ok: false, error: "cross_tenant_denied" };
}
if (actor.status === "suspended") {
return { ok: false, error: "actor_suspended" };
}
if (actor.role === "ai") {
return { ok: false, error: "ai_role_cannot_command" };
}
if (cmd.expectedVersion !== ticket.version) {
return { ok: false, error: "version_conflict" };
}
const rule = TICKET_TRANSITIONS[ticket.state]?.[cmd.type];
if (!rule) {
return { ok: false, error: `invalid_transition:${ticket.state}->${cmd.type}` };
}
if (!rule.allowedRoles.includes(actor.role)) {
return { ok: false, error: `actor_not_permitted:${actor.role}` };
}
// ...aplica a transição e devolve o novo ticket
}
Repare na ordem das checagens: isolamento por tenant primeiro, status do ator em seguida, e só então — antes mesmo de consultar a tabela de transições — a checagem que bloqueia qualquer ator com papel "ai". Essa ordem não é estética. Um ator de outro tenant, ou um papel de IA, não deveria nem descobrir quais transições existem para aquele ticket; a função encerra antes de vazar essa informação. O código completo está em examples/domain.ts, com 17 testes automatizados em examples/domain.test.ts e examples/events.test.ts, todos executados nesta sessão via node --test examples/ (ver verification.md).
O ponto central não é a sintaxe de TypeScript. É que a pergunta "quem pode fazer o quê, quando" tem uma resposta que cabe numa tabela de decisão, testável, independente de qualquer modelo de linguagem. Se essa pergunta só existe espalhada em condicionais de controller, comentário de código ou — pior — na expectativa de que um prompt "converse" o modelo para nunca sugerir a ação errada, ela não é uma regra. É uma esperança.
A intuição comum é: "a IA só sugere, o humano decide, então está tudo bem". Essa frase é verdadeira só se o sistema que recebe a decisão do humano também verifica quem é esse humano e o que ele tem autoridade para fazer. Uma sugestão aceita por um agente sem que o backend confirme o papel desse agente é, na prática, a IA decidindo — só que com um clique de verniz humano no meio.
O capítulo 02 desta série chegou até a Oportunidade A: ambiguidade entre cobrança e produto na triagem de tickets, com seis entrevistas sintéticas indicando que agentes perdem tempo cruzando abas para descobrir o tipo real de um problema antes de classificá-lo. A tentação natural, ao construir a primeira versão, é pular direto para "a IA sugere a categoria e o agente clica em aceitar". Isso é uma escolha de produto legítima. O que não é legítimo é deixar essa sugestão tocar o estado do ticket sem que exista, em algum lugar testável, uma regra que diga: aceitar sugestão muda o registro da sugestão; mudar o ticket exige um comando separado, emitido pelo agente, verificado contra o papel dele. A diferença entre as duas frases é a diferença entre um produto com contrato e um produto com sorte.
O DeskPilot v1 modela sete conceitos. Nenhum deles é novo em relação ao que o capítulo 02 descreveu; a mudança é que agora cada um vira um tipo com campos explícitos, sem espaço para texto livre decidir nada:
tenant_id.role (agent, manager, system, ai) e status (active/suspended).ownerId do ticket nesta versão.O ponto que mais gera confusão em produtos assistidos por IA é tratar ticket e sugestão como se fossem o mesmo relógio. Não são. O estado do ticket (new, triaged, assigned, resolved, reopened) descreve o trabalho real sendo feito por humanos e pelo backend. O estado da sugestão (pending, accepted, edited, rejected, expired) descreve só o ciclo de vida de uma proposta de IA — se ela foi aceita, editada, rejeitada ou expirou sem resposta. Aceitar uma sugestão é evidência de que um humano concordou com ela. Não é, por si só, uma transição de ticket:
export function applySuggestionCommand(
actor: User,
suggestion: Suggestion,
action: "ACCEPT" | "EDIT" | "REJECT" | "EXPIRE"
): { ok: boolean; suggestion?: Suggestion; error?: string } {
if (actor.tenantId !== suggestion.tenantId) {
return { ok: false, error: "cross_tenant_denied" };
}
if (actor.role === "ai") {
return { ok: false, error: "ai_role_cannot_command" };
}
const rule = SUGGESTION_TRANSITIONS[suggestion.state]?.[action];
if (!rule) {
return { ok: false, error: `invalid_suggestion_transition:${suggestion.state}->${action}` };
}
if (!rule.allowedRoles.includes(actor.role)) {
return { ok: false, error: `actor_not_permitted:${actor.role}` };
}
return { ok: true, suggestion: { ...suggestion, state: rule.to, reviewedBy: actor.id } };
}
Note o que essa função devolve: um Suggestion atualizado, nunca um Ticket. Transformar "sugestão aceita" em "ticket triado" exige uma segunda chamada, independente, a applyCommand, com o ator autenticado do próprio agente. É verboso de propósito. O teste suggestion acceptance does not touch ticket state existe exatamente para impedir que alguém, num refactor futuro apressado, funda as duas funções numa só "por conveniência".
A tabela abaixo resume examples/permissions.md. Ela cobre as transições de ticket; sugestão segue o mesmo princípio numa tabela menor no mesmo arquivo.
| De → Para | Comando | agent | manager | system | ai |
|---|---|---|---|---|---|
| new → triaged | TRIAGE | ✅ | ✅ | ✅ | ❌ |
| triaged → assigned | ASSIGN | ✅ | ✅ | ✅ | ❌ |
| assigned → resolved | RESOLVE | ✅ | ✅ | ✅ | ❌ |
| assigned → assigned (troca de dono) | REASSIGN | ❌ | ✅ | ✅ | ❌ |
| resolved → reopened | REOPEN | ✅ | ✅ | ✅ | ❌ |
| reopened → assigned | ASSIGN | ✅ | ✅ | ✅ | ❌ |
Duas colunas merecem atenção. Primeiro, REASSIGN (trocar o dono de um ticket já atribuído) exige manager ou system — um agente não pode se tirar de um ticket sozinho, porque isso afeta a carga de trabalho da equipe, uma decisão que o capítulo 02 atribuiu ao gestor como comprador e responsável por esse tipo de trade-off. Segundo, e mais importante: a coluna ai é vazia em toda a tabela. Essa ausência não é uma checagem if (role !== "ai") throw espalhada pelo código — ela está embutida na própria estrutura da tabela de decisão. Não existe uma linha de configuração para "desligar" a permissão da IA por engano, porque a permissão nunca existiu. É a diferença entre uma regra de segurança que alguém pode esquecer de aplicar e uma regra que é impossível de não aplicar, porque faz parte da forma dos dados.
Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares

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.

Uma demo de triagem impecável, gravada numa sexta-feira, evapora na segunda: nada tinha sido persistido. A partir desse incidente, uma fatia vertical completa do DeskPilot — importar, revisar,…

Como transformar um pedido de chatbot em hipótese, contrato de autonomia e primeiro experimento de produto, usando o DeskPilot como exemplo sintético.
Checklist prático para validar segurança, custos, avaliações e observabilidade antes de colocar seu app com IA em produção.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.
Voltando ao ticket reaberto na segunda-feira. O Policy de cada tenant declara um Calendar explícito — não uma suposição sobre fuso ou feriado:
export interface Calendar {
businessStartMinuteUtc: number;
businessMinutesPerDay: Record<number, number>;
holidaysIso: string[];
}
A versão errada — a que abriu este capítulo — está em domain.ts, exportada de propósito para que o teste possa comparar as duas e provar a diferença, nunca para ser chamada em código de produção:
export function naiveReopenDueAt(reopenedAtIso: string, priority: Priority, policy: Policy): string {
return addBusinessMinutes(reopenedAtIso, policy.slaMinutesByPriority[priority], policy.calendar);
}
A versão correta calcula quanto do orçamento de SLA já foi consumido em minutos úteis entre a criação e a primeira resolução, e concede só o restante, com um piso mínimo configurável (reopenFloorMinutes) para o caso em que o orçamento já esteja praticamente esgotado:
export function computeReopenDueAt(params: {
createdAtIso: string;
resolvedAtIso: string;
reopenedAtIso: string;
priority: Priority;
policy: Policy;
}): string {
const totalBudget = params.policy.slaMinutesByPriority[params.priority];
const consumed = businessMinutesElapsed(params.createdAtIso, params.resolvedAtIso, params.policy.calendar);
const remaining = Math.max(totalBudget - consumed, params.policy.reopenFloorMinutes);
return addBusinessMinutes(params.reopenedAtIso, remaining, params.policy.calendar);
}
O teste naive reopen grants more time than the calendar-aware reopen after heavy SLA consumption fixa um cenário sintético — ticket criado numa segunda, resolvido na segunda seguinte, reaberto no mesmo instante — em que o orçamento normal (960 minutos) já foi majoritariamente consumido. Ele confirma que naiveReopenDueAt devolve uma data mais tardia (mais generosa) do que computeReopenDueAt, exatamente o padrão do bug de abertura: o recálculo do zero esconde quanto tempo já foi gasto. Um segundo teste, computeDueAt skips the weekend for a Friday-afternoon urgent ticket, fixa um ticket urgente criado numa sexta às 19h30 UTC, trinta minutos antes do fim do expediente: como o orçamento de duas horas não cabe no que resta do dia, o prazo precisa cair na segunda seguinte — nunca dentro do fim de semana, mesmo que a soma ingênua de "duas horas corridas" caísse tranquilamente ainda na sexta à noite.
Vale registrar o limite honesto deste modelo: businessMinutesElapsed soma dias inteiros de orçamento entre criação e resolução, uma aproximação suficiente para provar o contraste entre cálculo ingênuo e correto, não uma medição de precisão de minuto para produção — isso está documentado em examples/rules.md, Limites explícitos.
O prompt deste capítulo cita Martin Fowler sobre bounded context (2014-01-15, acesso 2026-09-28): um bounded context é separação lógica de modelo, e o próprio artigo dá como exemplo válido "múltiplos contextos dentro da mesma aplicação". O DeskPilot v1 usa exatamente essa leitura. Dentro do mesmo processo e do mesmo banco, três vocabulários vivem separados:
Ticket, Suggestion, Assignment. Vocabulário: estado, prioridade, SLA, dono.Tenant, User, papel, status. Vocabulário: autenticação, autorização, isolamento.A confusão entre "cobrança" como assunto de ticket e "cobrança" como sistema de billing é, na prática, a origem da Oportunidade A. Modelar bounded contexts aqui não significa três bancos de dados ou três deploys — significa que o tipo Ticket não importa conceitos de faturamento, e que uma Suggestion de categoria "cobrança" é uma etiqueta de triagem, não uma consulta ao sistema de billing. Errar esse limite é como misturar dois idiomas na mesma frase e esperar que o leitor entenda qual palavra pertence a qual gramática.
Todo AuditEvent carrega event_id, tenant_id, ticket_id, actor_id, occurred_at, schema_version e correlation_id. O schema em examples/events.schema.json fecha a porta com "additionalProperties": false — adicionar um campo de texto livre exige uma mudança de schema revisada, nunca um vazamento silencioso:
{
"required": [
"event_id", "tenant_id", "ticket_id", "actor_id",
"occurred_at", "schema_version", "correlation_id", "type"
],
"additionalProperties": false
}
Os testes em events.test.ts confirmam isso nos dois sentidos: um evento bem formado passa, e um evento com um campo ticket_text ou ai_summary é rejeitado por unexpected_property. Essa é a defesa contra a quarta falha obrigatória deste capítulo, discutida adiante: telemetria de produto não é o lugar para reconstruir o que o cliente escreveu.
Sintoma: um comando chega ao backend acompanhado de texto como "o modelo confirmou que este ticket deve ser resolvido"; alguém decide que essa frase, vinda de uma resposta de IA, é suficiente para executar RESOLVE.
Causa: confundir a saída de um modelo de linguagem — que é texto, sujeito a injeção e a erro de interpretação — com uma autorização, que precisa vir de um ator autenticado com papel verificável.
Resposta: applyCommand nunca lê o conteúdo de um prompt ou de um ticket para decidir permissão. A checagem de papel usa actor.role, um campo estruturado que vem da sessão autenticada, nunca do texto. O teste ai role can never carry a state-changing command, even claiming manager-shaped text simula exatamente esse ataque: um ator com role: "ai" tenta RESOLVE, e a função rejeita com ai_role_cannot_command antes mesmo de olhar a tabela de transições. Isso mapeia diretamente para LLM01:2025 Prompt Injection no OWASP Top 10 for LLM Applications (edição 2025, acesso 2026-09-28): a taxonomia nomeia a classe de ataque, mas a defesa real é este teste, não uma lista de verificação.
Sintoma: o ticket reaberto na segunda-feira que abriu este capítulo. Um prazo recalculado "do zero" parece mais seguro (mais tempo!), mas quebra a promessa original do SLA e some com a informação de quanto tempo já foi gasto.
Causa: somar horas corridas, ou reiniciar o orçamento de SLA a cada evento, em vez de consultar um calendário de negócio declarado e subtrair o que já foi consumido.
Resposta: computeReopenDueAt e businessMinutesElapsed, testados contra naiveReopenDueAt no mesmo cenário sintético. Nenhuma legislação ou obrigação contratual real foi deduzida aqui — o calendário é um dado de configuração por tenant (Policy.calendar), a confirmar com o cliente real antes de qualquer piloto, exatamente como pede a restrição deste pacote.
Sintoma: o botão "aceitar sugestão" da interface, sozinho, já move o ticket para assigned ou resolved, sem uma segunda confirmação de comando vinda do agente.
Causa: tratar a conveniência de "um clique" como equivalente a "uma decisão autorizada", fundindo o ciclo de vida da sugestão com o ciclo de vida do ticket.
Resposta: applySuggestionCommand devolve só um Suggestion atualizado; nunca um Ticket. O teste suggestion acceptance does not touch ticket state verifica isso explicitamente, checando que a chave "ticket" nem existe no retorno. Essa separação também é a resposta prática a LLM06:2025 Excessive Agency: a IA nunca tem, estruturalmente, agência para fechar seu próprio ciclo de sugestão em uma ação real.
Sintoma: um evento de telemetria carrega "resumo: cliente pediu reembolso do cartão final 1234" para "facilitar dashboards depois".
Causa: tratar o log de auditoria como um lugar conveniente para guardar contexto de negócio, sem perceber que esse mesmo log alimenta análises, exportações e, eventualmente, terceiros.
Resposta: o schema fechado em events.schema.json, testado por an event carrying ticket text is rejected e an event carrying an AI-generated summary is rejected. A defesa não é uma política de redação escrita depois — é a ausência estrutural de qualquer campo de texto livre no tipo AuditEvent.
Os testes provam que, dado um ator e um ticket com os campos descritos, a função de transição obedece isolamento de tenant, papel, status, concorrência otimista e a tabela de estados — e que o contrato de evento rejeita conteúdo fora da lista fechada de campos. Eles não provam que a UI do DeskPilot impede um agente mal-intencionado de forjar uma sessão, que o provedor de IA real nunca vai gerar uma sugestão tendenciosa, ou que o calendário de negócio configurado está correto para um cliente real. A citação da documentação do PostgreSQL sobre row security policies (acesso 2026-09-28) é relevante aqui por um motivo específico: "table owners normally bypass row security as well" — ou seja, se este isolamento fosse implementado só como uma política de RLS no banco, rodando com o papel dono da tabela, ele não protegeria nada. O teste de isolamento deste capítulo roda inteiramente em código de aplicação; uma política de RLS complementar, se adotada depois, precisaria ser testada com um papel de aplicação sem BYPASSRLS e sem ownership — não como proteção única.
Custo: formalizar sete tipos, duas máquinas de estado e um schema de evento antes de qualquer tela é trabalho que não aparece em uma demonstração rápida. O retorno vem depois: cada regra nova (uma quinta prioridade, um novo papel) é uma linha na tabela de decisão e um teste, não uma investigação arqueológica em condicionais espalhadas.
Segurança: o risco central deste capítulo não é "a IA vai inventar uma resposta errada" — é "alguém vai tratar a resposta da IA como comando". A defesa é estrutural (papel ai fora de toda tabela de permissão), não comportamental (pedir educadamente ao modelo para não fazer isso).
Reversibilidade: reabrir um ticket não apaga o evento de resolução anterior; ele soma um novo evento, com reopenCount incrementado. O teste reopen preserves ticket identity and increments reopenCount without deleting history verifica isso na projeção do ticket; num backend real, o AuditEvent de resolução original continuaria como linha própria e imutável no log — a reversibilidade vem de nunca sobrescrever histórico, só adicionar a ele.
Básico. Adicione a prioridade "critical" ao tipo Priority e a Policy.slaMinutesByPriority, com um orçamento menor que "urgent". Critério de aceite: um novo teste cria um ticket "critical" e confirma que computeDueAt devolve um prazo mais curto que o de um ticket "urgent" criado no mesmo instante. Solução comentada: basta estender o Record<Priority, number> no objeto POLICY de teste e o tipo Priority em domain.ts; nenhuma outra função muda, porque toda a lógica de cálculo já é paramétrica em priority.
Intermediário. Adicione uma transição triaged → new chamada RETRIAGE, permitida só para manager, usada quando uma triagem inicial se mostra errada antes de qualquer atribuição. Critério de aceite: um teste feliz confirma a transição para manager; um teste adversarial confirma que agent recebe actor_not_permitted:agent. Solução comentada: adicione uma entrada em TICKET_TRANSITIONS.triaged.RETRIAGE com allowedRoles: ["manager"]; nenhuma mudança é necessária em applyCommand, porque a função já lê a tabela genericamente — essa é a vantagem de centralizar as regras numa estrutura de dados em vez de num switch crescente.
Avançado. Implemente businessMinutesElapsed com granularidade de minuto (em vez de dia inteiro) para o dia de resolução, e escreva um teste que mostre um caso em que a versão atual (dia inteiro) e a versão nova discordam em mais de 60 minutos de orçamento de SLA restante. Critério de aceite: o teste falha contra a implementação atual e passa contra a nova, documentando a diferença numericamente, não só qualitativamente. Fora do escopo deste capítulo, fica registrado como pendência honesta em examples/rules.md.
version) para detectar se dois comandos tentaram mudar o mesmo registro ao mesmo tempo, rejeitando o que chegou com uma versão desatualizada.O capítulo 04 (../product-designer-serie-04/) recebe este domínio como entrada e constrói a primeira fatia vertical completa: importar ticket, revisar, atribuir e auditar, com dois tenants fixture, login de desenvolvimento isolado de produção e uma UI web simples sobre TypeScript, Node.js e PostgreSQL — stack didática, com versões e lockfile a fixar na sessão de implementação, não um endosso deste repositório. Nenhum benefício real do DeskPilot foi comprovado até aqui; o que este capítulo entrega é a garantia de que, quando a IA entrar em cena no capítulo seguinte, ela vai encontrar um contrato que já sabe dizer não.