The core logic is direct:
const allocRes = await tx.execute(
sql`SELECT fn_allocate_chain_index(${entityId}::uuid)::text AS idx`,
);
const chainIndex = BigInt(allocRes.rows[0]!.idx);
let previousHash: string | null = null;
if (chainIndex > 1n) {
const priorRes = await tx.execute(
sql`SELECT payload_hash FROM document_hash_chain
WHERE entity_id = ${entityId}::uuid
AND chain_index = ${(chainIndex - 1n).toString()}::bigint`,
);
previousHash = priorRes.rows[0]!.payload_hash;
}
const chainLinkHash = computeChainLinkHash({
content_hash: contentHash,
previous_hash: previousHash,
chain_index: chainIndex,
entity_id: entityId,
});
There are two design choices hidden in that snippet.
The index comes from fn_allocate_chain_index(entity_id), not a PostgreSQL sequence. The same rollback problem that makes sequences wrong for legal document numbers also applies to chain indices. A verifier expects the chain to be contiguous. If index 19 is missing because a transaction rolled back after a sequence increment, the verifier cannot know whether that is harmless or tampering.
The link hash includes entity_id. That prevents a row from one entity being copied into another entity's chain without detection. Klevar has one group boundary, but the legal proof is per entity. FZE, LLC, and Ltd cannot share a chain just because the service is single-tenant.
The verifier is a walker, not a database query. It receives rows sorted by chain_index, checks ordering, checks the genesis row, checks each previous_hash, recomputes each link hash, and returns the first mismatch. It also reports intentionally broken indices. That last category is important because some retention or force-purge action may be documented rather than hidden. A broken chain can be honest if the break is recorded and visible.
What surprised me was the dependency on canonical JSON. Hashing JavaScript objects directly is a trap because key order and serialization details can drift. The service pins canonicalize@2.0.0 and runs an RFC 8785 boot assertion before Fastify binds a port. If canonicalization changes, the server refuses to start. That is not paranoia. It is the cost of using hashes as legal proof.
The hash chain also changed how I think about events. events_emitted is the integration outbox for Hub and Webhook Engine fanout. It is operational. document_hash_chain is proof. Those two surfaces overlap, but they are not the same thing. A notification can be retried, delayed, or dropped without changing the legal document. A chain append cannot be treated that way.
The biggest tradeoff is operational weight. A chain gives you another invariant to maintain, another verifier to run, another repair story to document, and another failure mode to alert on. The alternative is worse: a document archive that can produce files but cannot prove nobody altered the history.
For this system, the archive without the chain would be storage. The chain turns it into evidence.