Examples

Audit log

Append-only, hash-chained, and verifiable. A row that has been altered no longer links to its successor.

Why hash chaining

An append-only table is a promise. Hash chaining turns it into a verifiable fact: each row commits to the previous one, so deleting or editing a row breaks every link after it.

yatta/func/db.tsts
import { col, createDatabase } from "yatta/db"; export const schema = {  auditLog: {    id: col.uuid(),    // Monotonic per tenant — an out-of-order insert is visible, not silent.    seq: col.integer(),    tenantId: col.text().nullable(),    actorId: col.text().nullable(),    action: col.text(),    target: col.text(),    before: col.json<Record<string, unknown>>().nullable(),    after: col.json<Record<string, unknown>>().nullable(),    /** sha256(prevHash + canonical payload). */    hash: col.text(),    prevHash: col.text(),    at: col.createdAt(),  },}; export const db = createDatabase({ url: process.env.DATABASE_URL, schema });

Append a chained entry

yatta/func/audit.tsts
import { createHash } from "node:crypto"; const GENESIS = "0".repeat(64); export interface AuditEntry {  seq: number;  tenantId: string | null;  actorId: string | null;  action: string;  target: string;  before: Record<string, unknown> | null;  after: Record<string, unknown> | null;  hash: string;  prevHash: string;  at: string;} /** * Stable serialisation. * * JSON.stringify key order follows insertion order, so two structurally equal * objects can serialise differently and break the chain. Sorting the keys is * what makes the hash reproducible. */function canonical(value: unknown): string {  if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";  if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;   const entries = Object.entries(value as Record<string, unknown>)    .sort(([a], [b]) => a.localeCompare(b))    .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`);   return `{${entries.join(",")}}`;} export function hashEntry(entry: Omit<AuditEntry, "hash">): string {  return createHash("sha256").update(entry.prevHash + canonical(entry)).digest("hex");} export async function record(  input: Omit<AuditEntry, "seq" | "hash" | "prevHash" | "at">,): Promise<AuditEntry> {  const previous = await db.auditLog    .where((f) => (input.tenantId ? f.tenantId.isEqualTo(input.tenantId) : f.tenantId.isNull()))    .orderBy({ seq: "desc" })    .first();   const partial: Omit<AuditEntry, "hash"> = {    ...input,    seq: (previous?.seq ?? 0) + 1,    prevHash: previous?.hash ?? GENESIS,    at: new Date().toISOString(),  };   const entry: AuditEntry = { ...partial, hash: hashEntry(partial) };  await db.auditLog.insert(entry);   return entry;}
Warning
Never update or delete a row in this table. Not to redact a mistake, not for GDPR. Redact by writing a compensating entry — that is the only honest way to correct an append-only log.

Verify the chain

A verifier is worthless if you never run it. Sweep it on a schedule and record the result.

yatta/func/audit-verify.tsts
import { hashEntry } from "./audit"; export interface ChainReport {  tenantId: string | null;  entries: number;  valid: boolean;  /** Index of the first row whose hash does not match. */  brokenAt: number | null;} export async function verifyChain(tenantId: string | null): Promise<ChainReport> {  const rows = await db.auditLog    .where((f) => (tenantId ? f.tenantId.isEqualTo(tenantId) : f.tenantId.isNull()))    .orderBy({ seq: "asc" })    .all();   let expectedPrev = "0".repeat(64);  let previousSeq = 0;   for (const row of rows) {    const { hash, ...rest } = row;     // A gap in the sequence is as much a tamper signal as a bad hash.    if (row.seq !== previousSeq + 1 || row.prevHash !== expectedPrev) {      return { tenantId, entries: rows.length, valid: false, brokenAt: row.seq };    }     if (hashEntry(rest as Omit<AuditEntry, "hash">) !== hash) {      return { tenantId, entries: rows.length, valid: false, brokenAt: row.seq };    }     expectedPrev = hash;    previousSeq = row.seq;  }   return { tenantId, entries: rows.length, valid: true, brokenAt: null };}

Diffing a record

yatta/func/audit.tsts
/** Minimal field-level diff, so a viewer shows what actually changed. */export function diff(  before: Record<string, unknown> | null,  after: Record<string, unknown> | null,): Array<{ field: string; from: unknown; to: unknown }> {  const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]);  const changes: Array<{ field: string; from: unknown; to: unknown }> = [];   for (const key of keys) {    const from = before?.[key];    const to = after?.[key];    if (JSON.stringify(from) !== JSON.stringify(to)) {      changes.push({ field: key, from, to });    }  }   return changes;}
Tip
Record the diff, not the whole row. Storing before and after in full duplicates every secret that ever passed through the object.