Owner: Eng. Review cadence: revisit if the two-log split or the chain design needs to change. Last revisited: 2026-06-17 — the data-plane log gained the WORM hash-chain (see History below).
MeterBox ships two audit logs, by design:
| Log | Where it runs | What it captures | Tamper-evidence | Read API |
|---|---|---|---|---|
/cp/audit (control-plane) |
central cluster Postgres cp_audit_events |
tenant lifecycle: signup, login, MFA, invite, member role, org-delete, password reset, verify-email | WORM hash-chain (per-org SHA-256, verifyChainForOrg) |
GET /cp/audit, GET /cp/audit/verify |
/v1/audit (data-plane) |
per-(org, env) pod's env-scoped storage | billing-ops: plan / customer / credit mutations with before-after diff | WORM hash-chain (env-scoped SHA-256, verifyChain) |
GET /v1/audit, GET /v1/audit/verify, GET /v1/audit/export |
The two logs deliberately use the same audit_events table name in
different storage planes. They never collide (the data-plane store is
env-scoped via envArrayStore); the shared name signals they're the same
kind of record at different planes.
The two planes are physically separate processes in production:
cp_* tables) in a
central cluster Postgres shared across orgs.A single shared audit log would force one of two architecturally lossy choices:
cp_audit_events —
couples planes and gives every per-org pod the central Postgres creds.
Breaks the "each pod sees only its own data" boundary the on-prem /
sovereign tier is built on.Both options trade architecture cleanliness for the optics of "one log."
Splitting is the cheaper choice: each log naturally lives where its data
lives, and the dashboard can render both — /cp/audit for tenancy events,
/v1/audit for billing-ops, no cross-plane writes in the critical path.
cp_audit_events is the compliance anchor the on-prem ladder leans on
(SOC2 CC2.1 — see SOC 2 evidence map, and a
hard requirement for HIPAA + FedRAMP). The per-org SHA-256 chain
(prev_hash + hash over canonical content) makes any after-the-fact edit
detectable by GET /cp/audit/verify, which walks the chain and reports the
first broken_at row. Implementation:
src/control-plane/services/audit.js
(computeHash, verifyChainForOrg). Retention pruning is tolerated — the
first surviving hashed row after a prune is a "post-prune genesis".
The billing-ops log (plan price changes, credit grants/revocations, customer
status transitions) is chained the same way, with one simplification: a
data-plane pod is a single (org, env), so the entire audit_events store is
one chain — no per-org partition. verifyChain walks it and reports the
first broken_at; GET /v1/audit/verify exposes it (admin) and the SPA
Audit Log page shows an integrity badge. Implementation:
src/billing-audit.js (computeHash,
verifyChain). Same tolerances as the control plane (pre-chain legacy rows
counted-not-chained; post-prune genesis).
Concurrent appends are safe. Both chains serialize the read-tail-through-
insert under a per-chain in-process lock (src/lib/async-mutex.js) — keyed by
env for the data-plane log, by org for the control-plane log — and generate the
row's id + timestamp inside the lock, so insert order matches the
chronological order verify walks. Two genuinely-parallel admin writes
therefore can't fork the chain. The lock is process-local, which is sufficient:
the data plane is one process per (org, env) pod and the control plane is a
single writer per org. A multi-replica-per-env deployment would need a
distributed lock (see async-mutex.js).
One known limit, shared by both chains:
legacy_count); the chain protects everything written since.The data-plane chain was initially deferred — v1 shipped append-only with
no chain, on the rationale that the control-plane chain satisfied the anchor
cert and billing-ops investigations didn't strictly need tamper-evidence. It
was brought forward to close the SOC 1 ICFR "ledger/audit immutability" +
SOC 2 CC7 evidence gap: billing-ops mutations directly affect customers'
financial statements (metering → revenue, credits → revenue adjustments,
plan price → invoiced totals), so an auditor asking for tamper-evidence on
that surface is expected. The implementation ports the control-plane's proven
computeHash / chain-verify rather than inventing a new scheme. Off-host
NDJSON export (GET /v1/audit/export, oldest-first, optional since/until)
shipped alongside, reaching parity with GET /cp/audit/export so the billing
log can be shipped to a SIEM for retention + off-host tamper-resistance.
src/billing-audit.jssrc/routes/audit.jssrc/control-plane/services/audit.jssrc/routes/catalog.js
(plan create/update/publish/delete), src/routes/customers.js
(customer create/plan-change/cancel/archive/unarchive/erase, credit
grant/revoke).