DEV Community

AiGovernor
AiGovernor

Posted on • Originally published at aigovernor.app

# How we built a tamper-evident WORM audit log for AI agents using SHA-256 hash chains and PostgreSQL

How we built a tamper-evident WORM audit log for AI agents using SHA-256 hash chains and PostgreSQL

Published on dev.to | Tags: ai, security, postgres, node


When your AI agents are making real decisions — sending emails, approving contracts, deleting records — "we have logs" is not the same as "we can prove what happened." This is the story of how we built a cryptographically tamper-evident audit log for AI Governor, and why the implementation details matter more than people think.

The problem with normal audit logs

Most audit logs have a critical flaw: they can be altered after the fact. If someone with database access modifies a row, deletes it, or even changes the timestamp, there's no automatic way to detect it. For enterprise AI agents executing high-stakes actions, this is a compliance nightmare.

We needed something stronger: a WORM (Write Once Read Many) log where any tampering — however subtle — is immediately detectable.

SHA-256 hash chaining: the core idea

The approach is borrowed from blockchain design, but stripped of all the unnecessary complexity.

Every audit row stores two hash fields:

  • prev_hash — the SHA-256 hash of the previous row
  • row_hash — the SHA-256 hash of the current row's canonical fields + prev_hash
CREATE TABLE audit_log (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id      UUID NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  agent_id    UUID,
  verdict     TEXT NOT NULL,
  model       TEXT,
  cost_usd    NUMERIC(10,6),
  task        JSONB,
  stages      JSONB,
  -- WORM chain
  prev_hash   TEXT NOT NULL DEFAULT '',
  row_hash    TEXT NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

The row_hash is computed as:

row_hash = SHA256(id + org_id + created_at + verdict + model + cost_usd + ... + prev_hash)
Enter fullscreen mode Exit fullscreen mode

If anyone edits any field in any row — or deletes a row and renumbers them — the chain breaks. Every subsequent row's prev_hash will no longer match the row_hash of its predecessor.

Why a database function, not application code

Here's where most implementations go wrong: they compute the hash in application code, then insert. This creates a race condition — two concurrent requests can both read the same "last row" and write the same prev_hash.

We solved this with a PostgreSQL stored function that holds a per-org advisory lock:

CREATE OR REPLACE FUNCTION insert_audit_row(
  p_org_id      UUID,
  p_agent_id    UUID,
  p_verdict     TEXT,
  -- ... other params
) RETURNS audit_log AS $$
DECLARE
  v_prev_hash   TEXT;
  v_row_hash    TEXT;
  v_new_row     audit_log;
  v_lock_id     BIGINT;
BEGIN
  -- Per-org advisory lock: prevents concurrent inserts from racing on the hash chain
  v_lock_id := hashtext(p_org_id::text);
  PERFORM pg_advisory_xact_lock(v_lock_id);

  -- Get the hash of the last row for this org
  SELECT row_hash INTO v_prev_hash
  FROM audit_log
  WHERE org_id = p_org_id
  ORDER BY created_at DESC
  LIMIT 1;

  v_prev_hash := COALESCE(v_prev_hash, '');

  -- Compute the new row_hash
  v_row_hash := encode(
    digest(
      p_org_id::text || COALESCE(p_agent_id::text, '') ||
      p_verdict || COALESCE(p_model, '') || v_prev_hash,
      'sha256'
    ),
    'hex'
  );

  -- Insert and return the new row
  INSERT INTO audit_log (org_id, agent_id, verdict, prev_hash, row_hash, ...)
  VALUES (p_org_id, p_agent_id, p_verdict, v_prev_hash, v_row_hash, ...)
  RETURNING * INTO v_new_row;

  RETURN v_new_row;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Enter fullscreen mode Exit fullscreen mode

pg_advisory_xact_lock gives us per-org serialisation without locking the whole table. Two requests from the same org queue at the lock; requests from different orgs run fully parallel.

Verifying the chain

Chain verification walks every row for an org in order and checks:

  1. The current prev_hash matches the previous row's row_hash
  2. The current row_hash matches a fresh computation of the canonical fields
// Gateway verification endpoint: GET /audit/verify
async function verifyChain(orgId) {
  const rows = await db
    .from('audit_log')
    .select('id, created_at, org_id, agent_id, verdict, model, cost_usd, prev_hash, row_hash')
    .eq('org_id', orgId)
    .order('created_at', { ascending: true });

  let prevHash = '';
  for (const row of rows) {
    // Check linkage
    if (row.prev_hash !== prevHash) {
      return { ok: false, first_broken_id: row.id, detail: 'Chain linkage broken' };
    }

    // Recompute and check hash
    const expected = computeRowHash(row, prevHash);
    if (expected !== row.row_hash) {
      return { ok: false, first_broken_id: row.id, detail: 'Row hash mismatch — row was modified' };
    }

    prevHash = row.row_hash;
  }

  return { ok: true, rows_checked: rows.length, detail: 'Chain intact' };
}
Enter fullscreen mode Exit fullscreen mode

Making it public and auth-free

The most useful property: the chain can be verified by anyone without an account. We expose a public endpoint:

GET https://api.aigovernor.app/v1/audit/public-verify?org_id=<uuid>
Enter fullscreen mode Exit fullscreen mode

No authentication required. A regulator, auditor, or third-party compliance tool can verify an organisation's full chain independently, without trusting us. This is the governance proof layer — not just "we recorded it," but "anyone can verify we didn't alter it."

What this means in practice

Before we built this, enterprise customers asking about AI Act compliance had to trust our word that logs weren't altered. Now they can hand a verification URL to their auditor. The auditor runs it. The hash checks out. Done.

The audit log is available on every plan including free — because governance evidence isn't a premium feature, it's a basic requirement.


If you're building AI agents in production and need this kind of governance infrastructure without building it yourself, we've packaged all of this into AI Governor. One line of code to integrate — swap base_url and api_key. The full pipeline activates from your first call.

Links:


Tags to use when posting: #ai #security #typescript #devops #compliance #devtools #openai

Top comments (0)