Blog

Como projetar um sistema de rastreabilidade com hash único.

Toda empresa que trabalha com documento, transação ou entrega chega no mesmo pedido: “preciso de um código único pra rastrear isso”. Parece uma tarde de trabalho. Você gera um SHA-256, guarda numa coluna, devolve pro cliente.

Seis meses depois vem a pergunta que quebra tudo: esse hash prova o quê, exatamente?

Se você não conseguir responder essa pergunta na hora do desenho, não construa. Rastreabilidade mal desenhada é pior que rastreabilidade nenhuma, porque cria confiança onde não existe garantia.

Antes do código: três perguntas

Quem é o adversário? Se você só quer detectar corrupção acidental de dado (disco ruim, transferência truncada), um checksum resolve e você pode ir pra casa. Se você quer detectar alguém que mexeu de propósito, e essa pessoa tem acesso ao seu banco, hash sozinho não faz nada. Ela recalcula e regrava.

O que exatamente está sendo carimbado? Um documento? Um estado do sistema num instante? Uma decisão? A resposta muda a estrutura do dado inteira.

Quem vai verificar depois? Você, num script interno? O cliente? Um auditor externo que não confia em nenhum dos dois? Cada resposta exige um nível diferente de rigor. Verificação interna é barata. Verificação por terceiro hostil é outro projeto.

Eu já vi mais de um time construir a versão cara pra um problema que era a versão barata, e o contrário, que é bem pior.

A escolha que define o resto: o que entra no hash

Aqui mora quase todo o erro que eu vejo.

A tentação é serializar o objeto e passar pro algoritmo. O problema é que serialização não é determinística. Ordem de chave em JSON muda. Float ganha ou perde um dígito dependendo da linguagem. Timezone entra e sai. Campo novo aparece no meio. Aí o mesmo dado, hasheado em dois lugares, dá resultado diferente, e a verificação passa a acusar fraude onde só houve um deploy.

O que funciona é definir uma representação canônica explícita. Escrita, versionada, documentada.

// Canônico v1: campos fixos, ordem fixa, tipos normalizados.
// Qualquer mudança aqui vira v2 e passa a conviver com a v1.
function canonicalV1(evt: TraceEvent): string {
  return [
    "v1",
    evt.tenantId,
    evt.entityType,
    evt.entityId,
    evt.action,
    // ISO 8601 em UTC, sempre com milissegundo
    evt.occurredAt.toISOString(),
    // dinheiro em inteiro, na menor unidade. Nunca float.
    String(evt.amountMinor ?? ""),
    evt.payloadDigest,
  ].join("");
}

function traceHash(evt: TraceEvent): string {
  return createHash("sha256").update(canonicalV1(evt), "utf8").digest("hex");
}

Três detalhes que parecem preciosismo e não são.

O separador é um byte nulo, não um hífen. Com hífen, os campos ["ab", "c"] e ["a", "bc"] viram a mesma string e colidem de graça. Isso tem nome, ambiguidade de concatenação, e é uma classe de bug que só aparece em produção com dado real.

O prefixo de versão está dentro do hash. Sem ele, você não consegue mudar o formato sem invalidar o passado inteiro.

E o payload grande entra como digest, não inteiro. Você hasheia o documento uma vez, guarda o digest e usa ele como campo. Assim o cálculo do hash do evento não depende de carregar 40MB de PDF.

A estrutura de dados: append-only, e sério

Rastreabilidade e UPDATE não convivem. Se a linha pode ser alterada, o histórico é uma opinião.

create table trace_event (
  id            bigserial primary key,
  tenant_id     uuid not null,
  entity_type   text not null,
  entity_id     uuid not null,
  action        text not null,
  occurred_at   timestamptz not null,
  recorded_at   timestamptz not null default now(),
  payload       jsonb not null,
  payload_digest char(64) not null,
  prev_hash     char(64),
  hash          char(64) not null,
  unique (tenant_id, hash)
);

-- sem update, sem delete. Garantido no banco, não no code review.
create rule trace_event_no_update as on update to trace_event do instead nothing;
create rule trace_event_no_delete as on delete to trace_event do instead nothing;

Repare em occurred_at e recorded_at separados. Um é quando o fato aconteceu no mundo, o outro é quando o sistema soube. Eles divergem sempre que existe fila, integração ou aplicativo offline, e juntar os dois numa coluna só é uma decisão que você vai lamentar na primeira investigação séria.

O prev_hash é o que transforma uma lista de registros numa cadeia. Cada evento carrega o hash do anterior daquela entidade, e o hash do atual é calculado incluindo esse encadeamento. Remover um evento do meio quebra a cadeia inteira a partir dali. Não impede que alguém com acesso total reescreva tudo, mas eleva o custo de “editei uma linha” para “reescrevi e recalculei todo o histórico posterior sem ninguém notar”.

Onde para o hash e começa a assinatura

Hash responde “esse dado mudou?”. Ele não responde “quem escreveu isso?” nem “isso existia ontem?”.

Se o requisito envolve alguém de fora que não confia em você, a cadeia sozinha não basta. Quem controla o banco controla a cadeia. As saídas, em ordem de custo:

Publicar periodicamente a raiz da cadeia em algum lugar que você não controla. Uma Merkle tree diária cuja raiz vai pro Twitter, pro log de transparência de certificados, pra qualquer meio público e datado. Barato e surpreendentemente eficaz.

Assinar cada evento com uma chave guardada em HSM ou KMS. Aí o registro carrega origem, não só integridade.

Timestamping por autoridade externa (RFC 3161). Necessário quando o requisito é jurídico, não técnico.

A pergunta que decide é sempre a terceira lá de cima: quem verifica. Escolher isso depois de construir custa uma migração.

Idempotência não é opcional

Todo sistema de rastreabilidade recebe o mesmo evento duas vezes. Retry de fila, cliente que reenviou, integração com timeout. Se o segundo registro entrar, sua contagem mentiu e sua cadeia bifurcou.

A chave única em (tenant_id, hash) já resolve boa parte, porque um evento idêntico produz hash idêntico e o insert falha. Mas cuidado: se occurred_at for preenchido com now() na hora da gravação, dois envios do mesmo fato geram hashes diferentes e os dois entram. O timestamp tem que vir do fato, não do processamento.

O que eu faria diferente

Já vi esse tipo de sistema ser construído sem endpoint de verificação. Alguém gerava o hash, guardava, e a única forma de conferir era abrir o banco. Um endpoint público que recebe o identificador e devolve a cadeia validada muda o produto de “confia em mim” para “confere você mesmo”, e custa umas duas tardes.

O outro erro caro é não pensar em volume desde o começo. Tabela append-only cresce e nunca encolhe. Particionamento por tempo, política de retenção e arquivamento pra armazenamento barato precisam estar no desenho, não no incidente.

O resumo

Antes de escolher algoritmo, decida contra quem você está se defendendo. Depois escreva a forma canônica antes de escrever o hash. Grave em estrutura que não aceita update. Encadeie. E se a verificação for feita por alguém que não confia em você, ancore a cadeia fora do seu domínio.

O algoritmo é a parte fácil, e é a única parte que a maioria dos times discute.

Se esse tipo de decisão é o que está travando teu time, é sobre isso que eu falo em palestra e é o tipo de projeto que faço no Singularity.

← Voltar pro blog