Skip to main content

ADR-0005 — Tamper-chain audit log

  • Status: Accepted
  • Date: 2026-04-18
  • Decision-makers: Tom Anderson

Context

Helm holds financial records, customer data, and inventory state. Forensic reconstruction (who did what when) is a hard requirement — both for compliance and for trust. Three approaches considered:

  • Plain audit tableINSERT INTO audit (actor, action, before, after, at) ... on every mutation. Simple. Vulnerable to any DBA-style direct edit of the audit table itself.
  • Append-only event sourcing — the audit table is the source of truth; primary tables are projections. Heavyweight; reshapes the whole architecture; not aligned with boring tech.
  • Hash-chained audit log — like plain audit but with each row's hash incorporating the previous row's hash. Tampering with any past row breaks the chain forward; daily verification detects breaks. Lightweight; doesn't require event-sourcing the rest of the schema.

The chain-hash pattern is well-known (blockchain-style ledgers, git's commit graph, certificate-transparency logs). It's not novel; it is somewhat unusual for a SaaS POS.

Decision

Implement audit as two tables:

  • audit_events (id, prev_chain_hash, chain_hash, staff_id, action, entity_type, entity_id, at, ip, ua, request_id)
  • audit_mutations (id, event_id, table_name, row_id, before_json, after_json, summary)

chain_hash = sha256(prev_chain_hash || canonical_json(event_row_minus_chain_hash)). The daily cron verifies the chain end-to-end and alerts on any break.

External anchoring (post the latest hash to an external timestamping service daily) is on the roadmap but not in v0.1 scope.

Consequences

Positive:

  • Tampering with past rows is detectable, not silent
  • Recovery from a tampering event has a known process (the cron alert)
  • The pattern is simple to implement and audit (~50 lines of JS)
  • Aligns with audit-everything principle

Negative:

  • Audit writes are on the critical path of every mutation; can't decouple
  • Chain verification is O(N) — for a year of mutations (~1.5M rows), the daily check is a few seconds, acceptable
  • The chain is per-database; cross-shop forensics requires per-shop verification
  • A tampering event is detectable but not undoable from the audit alone — you'd restore from backups

Mitigations:

  • Audit writes are tiny (one INSERT to each of two tables, ~5ms additional latency) — negligible on the request budget
  • Monthly cron archives audit older than 90 days to R2; D1 keeps only the live working set
  • Backup procedures ensure point-in-time recovery for the rare tampering case

Notes

The chain-hash detection only works if the verification job runs reliably. The daily cron will be monitored (observability). If the cron fails to run for two consecutive days, that's a high-priority alert.

The schema is migrated and the per-endpoint wiring (recordMutation) is live across all 19+ mutation endpoints. The remaining piece is the daily chain-hash verification cron — see current state.

See also