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.
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.
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 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.