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)