Tamper-evident chain design
Part of the Universal Audit Log Specification. The cryptographic hash chain across the
audit.audit_entriestable — each row'shashisH(prev_hash || canonical_payload), so any tampered or out-of-order row breaks the chain at the next verification scan. This is what makes the audit log forensically reliable.
11. Tamper-Evident Chain Design
11.1 Overview
Tamper-evident chaining provides cryptographic proof that audit entries have not been modified, reordered, or deleted after creation. The chain is per-tenant to avoid global serialisation bottlenecks.
11.2 Hash Algorithm
SHA-256 SHALL be used for all chain hashes.
11.3 Hash Computation
entry_hash = SHA256(
previous_hash ||
tenant_id ||
actor_id ||
action ||
resource_type ||
resource_id ||
SHA256(changes) ||
created_at
)
The changes field is hashed separately to bound the input size. changes is the
caller's JSON serialisation, hashed as sent; created_at is rendered by
audit.chain_timestamp_text(created_at) — timestamptz::text pinned to TimeZone = 'UTC' and
DateStyle = 'ISO, MDY' — never by a client-side date formatter. Because changes is stored as
jsonb, which reorders keys, an entry whose caller key order differed from jsonb's cannot be
recomputed from its row; a versioned formula is PLT-1404.
11.3a The single writer
Exactly one implementation advances the chain: audit.write_entry(...), a SECURITY DEFINER
function in the audit schema (platform-db migration 014, PLT-1340). auditAction() — and so
auditCritical() — calls it; nothing in TypeScript locks or writes audit.chain_heads. It joins
the caller's transaction, is owned by the confined audit_writer role (NOLOGIN, not RLS-exempt,
so the tenant policies still apply inside it), and refuses an entry whose tenant is not the
transaction's app.tenant_id unless the session's active role is SUPERUSER or BYPASSRLS.
EXECUTE is held only by roles that could already write the audit tables directly. A crossing
SECURITY DEFINER binds its constitution §5 entry by calling it (ADR-048 § D3); adding a second
writer beside it is the defect ADR-048 § D4 names. The function writes the row and the chain
only — audit.entry.created is still published by auditCritical() in TypeScript.
11.4 Chain Head Management
An audit.chain_heads table tracks the latest hash per tenant:
CREATE TABLE audit.chain_heads (
tenant_id UUID PRIMARY KEY, last_hash TEXT NOT NULL,
last_entry_id UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Concurrency: SELECT ... FOR UPDATE on the tenant's chain_heads row serialises hash computation per tenant once that row exists. It cannot serialise the writes that create it — FOR UPDATE locks nothing when the row is absent — so a tenant's first-ever audit writes are serialised by the creating INSERT ... ON CONFLICT (tenant_id) DO NOTHING instead: PostgreSQL's speculative insertion waits for the conflicting transaction's outcome, so a zero affected-row count means a competitor committed, and the writer re-reads the now-visible row and chains behind it. A writer running at REPEATABLE READ or stricter is the exception — there the statement aborts with 40001 rather than act on a conflict its snapshot cannot see, and the audit entry is still written, with NULL entry_hash / previous_hash, because chaining is best-effort and never blocks the business transaction.
The fallback log (PLT-1349). Every unchained write logs [AUDIT_CHAIN_FALLBACK] so monitoring can page on it. For an ordinary caller the line carries the entry, tenant, actor and resource ids, the entry's correlation id and the database message. A run that spans tenants — a cron sweep, a queue drain, the event dispatcher — declares itself with runInCrossTenantAuditScope / withCrossTenantAuditScope from @constellation-platform/db, and inside that scope the line carries only the SQLSTATE class (errorClass) and a correlation id the scope mints for the run: a per-tenant record on an artefact spanning tenants is what ADR-040 § D2 forbids, and an entry's correlation id may itself be a tenant resource id. The declaration is never inferred from actorType. Each app's cross-tenant-audit-scope-inventory test fails for a cron or drain route that does not make it.
Alternative for high-throughput tenants: assign a per-tenant sequence number at write time, then compute hashes in a background job that processes entries in sequence order (decouples chaining from the write path).
11.5 Integrity Verification
A nightly job SHALL walk each tenant's chain: load entries in created_at order, recompute each entry_hash (rendering created_at through audit.chain_timestamp_text), and compare against the stored value. verifyChainIntegrity in @constellation-platform/audit implements the walk; its created_at ordering is PLT-982's to replace with a walk over the stored links. On mismatch, alert immediately and record the break point.
11.6 Chain Recovery After PITR
After point-in-time recovery, identify the last verified hash ("known-good" point), re-seal all subsequent entries by recomputing hashes forward, and record the re-seal event in the audit log.