Skip to content

Audit trail internals

The audit trail is our single most load-bearing piece of compliance evidence. This page explains the internals so you can debug it, extend it, and defend its integrity to auditors.

LayerMechanismProtects against
Hash chainEach event includes SHA-256 of the previous event’s hashInsertion, deletion, reordering
Event signaturesEd25519 signature on each event’s canonical hashForged events (without key)
Checkpoint anchoringSigned checkpoints written every N eventsFull chain replacement
{
"audit_id": "ae_01JC...",
"ts": "2026-04-18T14:23:00.123Z",
"prev_hash": "abc123...",
"event_kind": "chat",
"agent_id": "support-bot",
"session_id": "sess_01JC...",
"provider": "openai",
"model": "gpt-4o-mini",
"detections": [...],
"policy_result": {"verdict": "allow", "rule": "default"},
"cost": {"prompt_tokens": 123, "completion_tokens": 45, "usd": 0.0012},
"_hash": "def456...", // SHA-256 of canonical-JSON(event minus _hash, _sig)
"_sig": "base64..." // Ed25519.sign(_hash)
}

Canonical JSON = sorted keys, no whitespace, no non-ASCII escaping. Used to ensure two servers produce the same hash for the same event.

Every event carries prev_hash = <previous event's _hash>. The very first event has prev_hash = null.

evt_1 _hash=H1, prev_hash=null
evt_2 _hash=H2, prev_hash=H1
evt_3 _hash=H3, prev_hash=H2
...

Break any event and the chain after it no longer validates.

Every N events (default 1000), the server writes a checkpoint row containing:

  • The current chain head hash
  • Signed by the audit signing key
  • Timestamp and event count

If an attacker replaces the whole chain (e.g., by taking over Postgres), they’d have to re-sign every checkpoint too — which requires the signing key, which lives in Secret Manager and is not accessible from the DB.

  • Algorithm: Ed25519 (fast, compact, IETF-standard)
  • Generation: tappass keys generate --purpose audit — written to Secret Manager
  • Access: Only the core server has secretmanager.versions.access for it
  • Public half: exposed at /audit/signing-key for independent verification

Never rotate without an operational plan — see Rotate API keys.

Walks the entire chain, verifies hashes and signatures. Returns:

{
"status": "intact",
"chain_length": 15432,
"current_head": "a1b2c3...",
"events_verified": 15432,
"broken_events": []
}

Runs in O(n); on a 10M-event chain it takes ~45s. We cache the result for 5 min per env.

Every day, the server dumps all events for that day to:

gs://tappass-audit-archive/YYYY-MM-DD.jsonl.zst

Bucket is WORM (write-once, 7-year retention). Archives are the source of truth for compliance evidence older than 90 days.

See Incident response. A chain break is always SEV1 — customer-visible disclosure may be required.