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.