Blog

Trilha de auditoria em agente de IA: provar o que foi decidido e por quê

Um cliente reclama que o bot negou o reembolso dele, e a pergunta que chega ao time não é técnica: por que negou. Você abre o log, encontra a linha da chamada ao modelo, o tempo de resposta, a contagem de tokens, o status duzentos, e nada disso responde. O log de aplicação registra que a decisão aconteceu, não o que a sustentou. Trilha de auditoria é a disciplina de gravar, no momento em que a decisão é tomada, tudo que seria necessário para reconstituí-la meses depois diante de alguém que não confia na sua palavra: qual regra aplicou, qual dado o agente leu, qual versão de prompt e de modelo estava em vigor, qual ferramenta executou e com qual resultado. Este artigo mostra como montar essa trilha sem transformar o sistema em um depósito de dado pessoal: qual é a unidade certa de registro, o que precisa entrar em cada evento, como encadear os eventos por hash para que uma edição posterior seja detectável, como reduzir o dado sensível antes da gravação e como sair da reclamação até o evento concreto em minutos.

2026-07-31 / IA Aplicada / 14 min

01

Log de aplicação não é trilha de auditoria

Os dois escrevem linhas em disco e é por isso que a confusão sobrevive tanto tempo. A diferença aparece na primeira pergunta séria. O log de aplicação existe para o engenheiro entender por que o sistema quebrou, é otimizado para volume e custo, tem retenção curta, é reescrito com liberdade em cada refactor e ninguém se importa se um campo mudou de nome entre duas versões. A trilha de auditoria existe para provar a terceiros o que o sistema decidiu, precisa sobreviver a anos, não pode ser reescrita depois do fato e o formato dela é um contrato tão sério quanto o de uma API pública.

A consequência prática de tratar um como outro é sempre a mesma: no dia em que a pergunta chega, o log existe, tem gigabytes, e não responde. Ele tem a duração da chamada mas não o texto da política aplicada, tem o id da requisição mas não o id da versão do prompt, tem a mensagem de erro mas não os documentos que o agente leu antes de responder. E, mais grave, ele pode ter sido alterado: se qualquer pessoa com acesso ao banco pode fazer um update numa linha de log, aquela linha não prova nada em uma discussão em que a boa fé está sendo questionada.

DimensãoLog de aplicaçãoTrilha de auditoria
Pergunta que respondePor que o sistema quebrouPor que o sistema decidiu assim
LeitorQuem escreveu o códigoJurídico, cliente, auditor, regulador
Retenção típicaDe sete a trinta diasDe um a cinco anos, conforme o contrato
MutabilidadeLivre, ninguém acompanha mudança de campoAppend-only, alteração precisa ser detectável
EsquemaInformal, evolui a cada deployVersionado, mudança quebrando exige migração
AmostragemAceitável e comum sob cargaProibida, decisão amostrada é decisão não registrada

A linha de amostragem é a que mais surpreende times que vêm de observabilidade. Descartar noventa por cento dos traces sob carga é uma prática correta e barata quando o objetivo é achar gargalo, porque a estatística sobrevive à amostra. Ela é indefensável em auditoria, porque a decisão que vai ser questionada é justamente uma, e a chance de ela estar entre as descartadas é de noventa por cento. Trilha de auditoria grava cem por cento das decisões ou não é trilha, e é por isso que ela precisa ser desenhada para custar pouco por evento em vez de ser desligada quando o volume sobe.

02

A unidade de registro é a decisão, não a chamada

A escolha da unidade define se a trilha vai responder ou não. Registrar por chamada ao modelo parece natural porque é onde o dinheiro é gasto, mas produz uma trilha em que ninguém consegue navegar: um único atendimento gera doze chamadas, das quais oito são de classificação e reformulação, e a pergunta do cliente não é sobre nenhuma delas. Registrar por turno de conversa é melhor e ainda insuficiente, porque um turno pode conter várias decisões independentes e porque muitas decisões relevantes acontecem sem nenhuma mensagem do cliente por perto, como uma reprocessamento noturno.

A unidade que funciona é a decisão: um ponto do fluxo em que o sistema escolheu entre alternativas e essa escolha produziu efeito visível para alguém de fora. Aprovar ou negar reembolso é decisão. Transbordar para humano é decisão. Escolher não executar uma ferramenta por falta de permissão é decisão. Chamar o modelo para reescrever uma frase não é decisão, é passo. Passos entram na trilha como evidência anexada à decisão que eles sustentaram, não como registros de primeira classe com vida própria.

  • Toda decisão recebe um identificador próprio, estável e derivado do conteúdo, para poder ser citada em um ticket sem ambiguidade.
  • Toda decisão carrega o identificador da conversa e o do cliente, para que a busca a partir da reclamação chegue nela sem varredura.
  • Os passos que a sustentaram apontam para o identificador da decisão, e não o contrário, porque a decisão é o que alguém vai procurar.
  • Uma decisão revertida depois não sobrescreve a original: ela é um novo evento que referencia o anterior, preservando a sequência real dos fatos.
  • Decisões automáticas e decisões humanas usam o mesmo esquema, mudando só o campo que identifica quem decidiu, porque a reclamação não distingue as duas.

A última regra é a que evita a armadilha mais cara. Times costumam construir a trilha só para o que a IA decide, e quando o caso escala descobrem que o pedaço humano do fluxo, justamente o que reverteu a decisão do bot, não deixou rastro nenhum. Se o supervisor aprovou manualmente um reembolso que o agente havia negado, esse é o evento mais importante da história inteira, e ele precisa estar na mesma sequência, com o mesmo formato, com a mesma garantia de integridade.

03

O que precisa entrar para a decisão ser reconstituível

O critério para escolher os campos é direto e implacável: um leitor que não tem acesso ao seu banco de produção, lendo o evento daqui a dois anos, consegue entender o que foi decidido e por quê. Se qualquer resposta exige abrir outra fonte que talvez não exista mais, o campo está faltando. Isso não significa gravar tudo, significa gravar o suficiente e apontar de forma estável para o resto.

CampoPor que é obrigatórioO que quebra sem ele
decision_idReferência estável para citar em ticket e em processoA discussão vira "aquele atendimento de terça"
outcome e reason_codeO que foi decidido e sob qual regra codificadaSobra texto livre que ninguém consegue agregar nem comparar
policy_versionA regra de hoje não é a regra que valia na dataVocê julga a decisão passada pelo critério atual
prompt_version e model_idIdentifica o comportamento exato que produziu a saídaNão dá para reproduzir nem para delimitar o alcance do bug
inputs_digestProva quais dados o agente tinha na hora, sem copiá-losNão se distingue erro de decisão de dado desatualizado
evidence_refsPonteiro imutável para os trechos e registros consultadosA justificativa vira alegação sem lastro verificável
actorDiz se decidiu o agente, uma regra fixa ou uma pessoaResponsabilidade fica difusa exatamente quando importa
occurred_at e recorded_atSepara quando o fato ocorreu de quando foi gravadoReprocessamento tardio parece adulteração de data

O par inputs_digest e evidence_refs é o que dá densidade à trilha sem inchá-la. Em vez de copiar os cinco trechos de base de conhecimento dentro do evento, você grava o hash do conjunto de entradas e a referência imutável de cada trecho, com o identificador do documento e o da revisão. Isso responde as duas perguntas que aparecem em toda apuração séria: o agente tinha esse dado na hora, e o texto que ele leu era este. E responde mesmo que o documento tenha sido editado depois, contanto que suas revisões sejam preservadas, o que é uma exigência que a trilha impõe ao resto do sistema e que vale a pena assumir explicitamente.

O reason_code merece disciplina de esquema fechado. Texto livre gerado pelo modelo como justificativa é útil para o cliente ler e inútil para a operação, porque não agrega: mil justificativas ligeiramente diferentes para o mesmo motivo impedem qualquer contagem. Um código enumerado, definido pelo produto e não pelo modelo, permite responder quantas negativas de reembolso foram por prazo excedido no trimestre, que é a pergunta que efetivamente muda o produto. Guarde os dois, o código para agregar e o texto para explicar, e trate divergência entre eles como defeito.

04

Encadeamento por hash: tornar a edição posterior detectável

Append-only por convenção não é append-only. Enquanto a tabela aceitar update, a trilha vale exatamente a confiança que se tem em quem tem acesso ao banco, e o argumento "ninguém alteraria" perde a força na hora exata em que a trilha é necessária, que é a hora em que alguém está sendo acusado de algo. A solução não exige blockchain nem serviço externo: basta encadear cada evento ao anterior por hash, de forma que alterar um registro passado invalide todos os posteriores.

A ideia é a de uma lista ligada criptográfica. Cada evento carrega o hash do evento anterior, e o hash de um evento cobre tanto o seu conteúdo canônico quanto esse elo. Editar o evento número quarenta muda o hash dele, o que quebra o elo do quarenta e um, que quebra o do quarenta e dois, e assim por diante até o fim da sequência. Quem quiser adulterar precisa reescrever toda a cauda, e é aí que entra a segunda peça: selar periodicamente a ponta da cadeia em um lugar fora do alcance de quem escreve, como um objeto com bloqueio de retenção ou um repositório separado com credencial distinta. Selar de hora em hora reduz a janela de adulteração indetectável a uma hora, o que costuma ser suficiente.

// audit/chain.js
// Trilha append-only encadeada por hash.
// Cada evento carrega o hash do anterior; editar um evento passado
// invalida a cadeia inteira a partir dele.

import { createHash } from 'node:crypto';

const GENESIS = '0'.repeat(64);

/**
 * Serializacao canonica: chaves ordenadas em qualquer profundidade.
 * Sem isso, dois processos gravam o mesmo evento com hashes diferentes
 * e a verificacao acusa adulteracao onde so houve ordem de chave.
 */
function canonical(value) {
  if (value === null || typeof value !== 'object') return JSON.stringify(value);
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
  const entries = Object.keys(value)
    .sort()
    .filter((key) => value[key] !== undefined)
    .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`);
  return `{${entries.join(',')}}`;
}

export function hashEntry(entry) {
  return createHash('sha256').update(canonical(entry), 'utf8').digest('hex');
}

export function createAuditChain({ store, redact = (x) => x, now }) {
  if (typeof now !== 'function') throw new Error('now precisa ser uma funcao');

  /**
   * Grava uma decisao. O payload passa por redacao ANTES do hash,
   * para que o hash cubra exatamente o que foi persistido.
   */
  async function append(decision) {
    const previous = await store.last();
    const prevHash = previous ? previous.hash : GENESIS;
    const seq = previous ? previous.seq + 1 : 0;

    const body = {
      seq,
      schema: 'decision.v1',
      decision_id: decision.decisionId,
      conversation_id: decision.conversationId,
      subject_id: decision.subjectId,
      actor: decision.actor,              // 'agent' | 'rule' | 'human:<id>'
      outcome: decision.outcome,
      reason_code: decision.reasonCode,   // enumerado, nao texto livre
      policy_version: decision.policyVersion,
      prompt_version: decision.promptVersion,
      model_id: decision.modelId,
      inputs_digest: hashEntry(decision.inputs),
      evidence_refs: decision.evidenceRefs,
      payload: redact(decision.payload),
      occurred_at: decision.occurredAt,
      recorded_at: new Date(now()).toISOString(),
      prev_hash: prevHash,
    };

    const record = { ...body, hash: hashEntry(body) };
    await store.append(record);         // INSERT apenas: sem UPDATE, sem DELETE
    return record;
  }

  /** Recalcula a cadeia e aponta a primeira posicao rompida. */
  async function verify({ from = 0, expectedHead } = {}) {
    let prevHash = from === 0 ? GENESIS : (await store.at(from - 1))?.hash;
    if (!prevHash) return { ok: false, brokenAt: from, reason: 'ancora ausente' };

    let last = null;
    for await (const record of store.stream(from)) {
      const { hash, ...body } = record;
      if (body.prev_hash !== prevHash) {
        return { ok: false, brokenAt: body.seq, reason: 'elo rompido' };
      }
      if (hashEntry(body) !== hash) {
        return { ok: false, brokenAt: body.seq, reason: 'conteudo alterado' };
      }
      prevHash = hash;
      last = record;
    }

    // O selo externo prova que a cauda nao foi reescrita inteira.
    if (expectedHead && last?.hash !== expectedHead) {
      return { ok: false, brokenAt: last?.seq ?? from, reason: 'divergencia com o selo' };
    }
    return { ok: true, head: last?.hash ?? prevHash, count: (last?.seq ?? -1) + 1 };
  }

  return { append, verify };
}

Três decisões nesse código são as que separam uma implementação que funciona de uma que dá falso alarme. A serialização canônica com chaves ordenadas evita que dois processos gravem o mesmo evento com hashes diferentes só porque a ordem das chaves do objeto variou, algo que transforma a verificação em uma fonte constante de alerta falso. A redação acontece antes do cálculo do hash, para que ele cubra exatamente o que ficou persistido, e não uma versão que nunca existiu em disco. E a verificação retorna a posição exata do rompimento em vez de um booleano, porque saber que a cadeia quebrou sem saber onde não permite nenhuma investigação útil.

evento 40            evento 41            evento 42
+-------------+      +-------------+      +-------------+
| prev: H39   |      | prev: H40   |      | prev: H41   |
| body ...    |      | body ...    |      | body ...    |
| hash: H40   |----->| hash: H41   |----->| hash: H42   |
+-------------+      +-------------+      +-------------+
       |                                         |
       | edita o body do 40                      | selo horario
       v                                         v
   H40 muda  =>  o elo do 41 nao bate       objeto com retencao
   verify() retorna brokenAt: 41            (credencial separada)

05

Registrar sem virar depósito de dado pessoal

Existe uma tensão real entre auditoria e minimização de dados, e ignorá-la produz um dos dois desastres. De um lado, a trilha que guarda a conversa inteira por cinco anos vira o maior repositório de dado pessoal da empresa, com retenção longa por desenho e apagamento difícil porque a imutabilidade é justamente o ponto. Do outro, a trilha que não guarda nada de identificável não consegue nem localizar a decisão do cliente que reclamou.

A saída é separar o que precisa ser provado do que precisa ser lido. O que precisa ser provado entra como hash: o digest das entradas prova que o agente tinha aquele conjunto de dados sem copiá-los para dentro do evento. O que precisa ser lido entra por referência: o identificador do documento e o da revisão apontam para o texto que vive no seu lugar de origem, com o ciclo de vida dele. E o que precisa apenas identificar a pessoa entra como pseudônimo estável por sujeito, que permite achar todas as decisões daquele cliente sem espalhar documento e telefone por milhões de linhas.

  1. Defina o esquema campo a campo declarando a classificação de cada um: identificador, dado pessoal, dado sensível ou metadado técnico.
  2. Aplique a redação antes da gravação, no mesmo ponto em que o hash é calculado, para que nenhum caminho de código consiga escrever o valor cru.
  3. Use um pseudônimo estável por sujeito no lugar do documento e do contato, mantendo o mapa reversível em um cofre separado com acesso auditado.
  4. Guarde o texto da justificativa com prazo mais curto que o do restante do evento, porque ele é o campo com maior chance de conter dado pessoal livre.
  5. Trate o pedido de exclusão pela via da cripto-exclusão: apague a chave do sujeito no cofre em vez de editar eventos, preservando a cadeia intacta.
  6. Escreva um teste que falha se um valor sensível conhecido aparecer em qualquer campo do evento serializado, e rode-o com os dados de exemplo do domínio.

A cripto-exclusão é o ponto que costuma travar a conversa com o jurídico e o que a destrava de vez quando explicado. Como a cadeia não permite apagar um evento sem quebrar a integridade de tudo que veio depois, o campo sensível é guardado cifrado com uma chave por sujeito, e o pedido de exclusão apaga essa chave. O evento continua lá, a cadeia continua íntegra, o hash continua conferindo, e o conteúdo cifrado se torna permanentemente ilegível. Você preserva a capacidade de provar que a decisão existiu e o encadeamento que a torna confiável, sem preservar o dado que não pode mais ser retido.

06

Da reclamação ao evento: a trilha precisa responder rápido

Uma trilha completa que leva dois dias para produzir uma resposta falha no objetivo. O caminho da pergunta até a evidência é parte do desenho, não um detalhe operacional deixado para depois, e é a diferença entre um sistema auditável e um sistema que teoricamente registra tudo. Na prática, a pergunta chega sempre pela mesma porta: um identificador de cliente, uma data aproximada e uma descrição em português do que incomodou.

Isso define os índices antes de qualquer outra otimização: por sujeito com data, por conversa e por código de motivo com data. Com esses três, a busca sai da descrição vaga para a decisão concreta em segundos. Sem eles, você tem uma trilha íntegra dentro de um armazenamento que só permite varredura, e a resposta chega quando o cliente já foi embora. Vale também expor a leitura como uma consulta pronta para o time de atendimento em vez de exigir acesso ao banco, porque trilha que só o engenheiro consegue ler acaba não sendo consultada.

// audit/query.js
// Caminho da reclamacao ate a evidencia, no formato em que a pergunta chega.

export function createAuditQuery({ store, chain, vault }) {
  /**
   * Reconstitui uma decisao: o evento, a verificacao de integridade do
   * trecho da cadeia que o contem e as evidencias resolvidas.
   */
  async function explain(decisionId, { reveal = false, requestedBy } = {}) {
    const record = await store.findByDecisionId(decisionId);
    if (!record) return { found: false };

    // Verifica so a janela relevante: verificar a cadeia inteira a cada
    // consulta e caro e desnecessario para responder uma reclamacao.
    const integrity = await chain.verify({ from: Math.max(0, record.seq - 500) });

    const evidence = await Promise.all(
      record.evidence_refs.map((ref) => store.resolveEvidence(ref)),
    );

    // O dado cifrado so e aberto sob pedido explicito e o acesso
    // entra na propria trilha como um novo evento.
    const sensitive = reveal
      ? await vault.decrypt(record.subject_id, record.payload.sealed)
      : null;
    if (reveal) {
      await store.appendAccessLog({
        decision_id: decisionId,
        requested_by: requestedBy,
        at: record.recorded_at,
      });
    }

    return {
      found: true,
      decision: {
        outcome: record.outcome,
        reasonCode: record.reason_code,
        actor: record.actor,
        occurredAt: record.occurred_at,
        policyVersion: record.policy_version,
        promptVersion: record.prompt_version,
        modelId: record.model_id,
      },
      evidence,
      sensitive,
      integrity,
    };
  }

  /** Busca pela porta real: cliente e janela de tempo. */
  function findBySubject(subjectId, { from, to, reasonCode } = {}) {
    return store.query({ subjectId, from, to, reasonCode });
  }

  return { explain, findBySubject };
}

Repare que o próprio acesso ao dado sensível gera um evento. Essa é uma exigência recorrente em auditoria e ela é barata quando a trilha já existe: quem abriu o conteúdo cifrado de qual cliente e quando é exatamente o tipo de pergunta que aparece depois, e responder com "não registramos" é pior do que não ter a funcionalidade. Repare também que a verificação de integridade cobre uma janela em torno do evento e não a cadeia inteira, porque verificar milhões de registros a cada consulta é um custo que na prática leva o time a desligar a verificação, e verificação desligada é o mesmo que não ter.

  • Meça o tempo entre a chegada da pergunta e a apresentação da evidência, e trate esse número como métrica de produto da trilha.
  • Rode a verificação completa da cadeia em lote fora do horário de pico e alerte com a posição exata do rompimento, não com um booleano.
  • Exercite a trilha com uma pergunta real por semana, escolhida entre casos encerrados, para descobrir campo faltante antes que ele faça falta.
  • Compare a contagem de decisões da trilha com a contagem de resultados no banco operacional, porque divergência silenciosa significa decisão não registrada.

FAQ

Perguntas frequentes

Preciso de blockchain para a trilha ser confiável?

Não, e na maioria dos casos ela atrapalha mais do que ajuda. O que a auditoria exige é que uma alteração posterior seja detectável, e isso é resolvido por encadeamento de hash mais um selo periódico fora do alcance de quem escreve, como um objeto com bloqueio de retenção ou um repositório com credencial separada. Blockchain resolve um problema diferente: consenso entre partes que não confiam umas nas outras e não têm autoridade comum. Na relação entre uma empresa e seus clientes, existe autoridade comum, existe contrato e existe auditor, então o custo operacional, a latência e a exposição de dados de uma cadeia distribuída não compram garantia adicional relevante. A pergunta que separa os dois casos é simples: quem precisa ser convencido de que o registro não foi alterado. Se a resposta é o auditor, o cliente ou o regulador, o encadeamento com selo externo já basta e é ordens de grandeza mais barato de operar.

Como registrar tudo sem que o custo de armazenamento fique inviável?

Separando o evento pequeno e imutável do anexo grande e com ciclo de vida próprio. O evento de decisão bem desenhado tem entre um e dois kilobytes, porque ele guarda códigos, versões, hashes e referências, não textos. Um milhão de decisões por mês nesse formato ocupa poucos gigabytes por ano, um custo irrelevante mesmo com retenção de cinco anos. O que cresce sem controle é o instinto de anexar o prompt completo, a resposta completa e os trechos recuperados dentro do evento, e é justamente esse conteúdo que deve ficar fora, referenciado por hash e por identificador de revisão. Se o time realmente precisar guardar o corpo das chamadas para reproduzir um bug, guarde em armazenamento frio com retenção mais curta, tratando esse anexo como evidência opcional e não como parte do registro de auditoria, que continua íntegro mesmo depois que o anexo expira.

A trilha de auditoria substitui o log e o tracing que já existem?

Não, os três respondem perguntas diferentes e tentar unificá-los produz um artefato ruim nas três funções. O tracing responde onde o tempo foi gasto e é amostrado por desenho, o que é correto para performance e proibido em auditoria. O log responde por que quebrou, tem retenção curta e formato que muda a cada refactor, o que é aceitável para depuração e inaceitável para um registro que precisa ser lido daqui a três anos. A trilha responde por que decidiu assim, grava cem por cento das decisões, tem esquema versionado e integridade verificável. O que compensa compartilhar entre eles é o identificador de correlação: quando o evento de auditoria carrega o trace_id, o engenheiro que investiga uma decisão específica pula direto para o trace correspondente enquanto ele ainda existe, e ganha o detalhe técnico sem que a trilha precise carregá-lo.

Sem trilha, a explicação da decisão é só a sua palavra

O dia em que alguém questiona uma decisão do agente é o dia em que a trilha vale, e ela não pode ser construída naquele dia. O que separa uma resposta em minutos, com o motivo codificado, a versão da política em vigor e os documentos consultados, de uma reconstrução por aproximação a partir de logs incompletos é ter escolhido a decisão como unidade de registro, ter gravado o suficiente para reconstituí-la sem o banco de produção, ter encadeado os eventos para que a edição posterior apareça e ter reduzido o dado pessoal antes da gravação. Posso desenhar e implementar essa trilha no seu agente, do esquema de eventos à consulta que o time de atendimento usa.