Skip to main content

Tamper-evident chain design

Part of the Universal Audit Log Specification. The cryptographic hash chain across the audit.audit_entries table — each row's hash is H(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. 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.

11.3b Formula versions (PLT-1404)​

Each row records the formula its hash uses in audit.audit_entries.hash_version. The versions differ only in the text hashed as changes:

Versionchanges text hashedWritten
1the caller's JSON serialisation, as sentevery entry before PLT-1404
2changes::text, the canonical jsonb renderingevery entry audit.write_entry writes since

changes is stored as jsonb, which reorders object keys. A version-2 entry is therefore reproducible from its stored row, whatever key order the caller used. A version-1 entry verifies when the text its caller sent was either JSON.stringify of the stored content (a TypeScript caller) or its jsonb rendering (a SQL caller that passed jsonb_build_object(...)). A TypeScript caller whose key order jsonb rewrote matches neither. On a local database, 212 of 29,944 chained entries did not match (measured 2026-10-06). Those bytes are gone, so these entries cannot be re-verified. That is the stated residual.

  • A verifier recomputes each entry under its own version. It refuses a version it has no formula for rather than guessing one.
  • The version is not itself hashed. Changing it only changes how the stored changes is rendered, so it cannot make altered content match the stored hash.
  • Version 2 assumes PostgreSQL renders jsonb as text the way it did when the entry was written. That rendering has been stable since 9.4; re-check it on a major-version upgrade.

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 the roles that write audit entries, and since PLT-1402 it is the only way they can: no role but the tables' owner and audit_writer holds INSERT, UPDATE, DELETE or TRUNCATE on audit.audit_entries, its partitions or audit.chain_heads (platform-db migration 20261009071522). 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 published by audit.write_critical_entry (platform-db migration 016), which auditCritical() calls.

Idempotent on a caller-supplied entry id (platform-db migration 019, PLT-1421). A §5 replay re-files a buffered entry in a fresh transaction after the original throws. When the original had in fact committed (an ambiguous commit), the primary key (id, created_at) could not stop a second row, because the two writes stamp different timestamps. So a caller-supplied id is now claimed in audit.entry_write_claims, an unpartitioned ledger keyed by (tenant_id, entry_id), under the tenant's chain-head lock and before anything is written.

  • Already claimed: no row is written, the chain head does not move, and no event is published. The function returns chained = NULL.
  • Claim held by a transaction that has not committed: the replay waits for that transaction's outcome.
  • A failure to claim: propagates. It never degrades to an unchained write.
  • Database-generated ids: never claimed.

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.

Refusing the degrade (PLT-953). Best-effort is the right default for a request, and the wrong one for a long REPEATABLE READ maintenance job: any audit entry the tenant's ordinary traffic commits after the job's snapshot makes the job's next head read raise 40001, and the job would commit a NULL-hash entry while reporting success. Such a caller passes onChainFailure: 'reject' on the entry (AuditActionOptions, honoured by auditAction, writeCriticalAuditEntry and auditCritical()); the call then throws AuditChainBrokenError, the transaction rolls back, and the caller retries it with a fresh snapshot. Only a transaction's first chained write is exposed — once it has chained it holds the head lock to commit. Locking the head at the start of the transaction was rejected: it inverts the lock order against every ordinary mutation, which reaches the head only at audit time, and trades a NULL hash for a deadlock.

The fallback log (PLT-1349). Every unchained write under the default 'degrade' policy 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: start at GENESIS, follow each entry's stored previous_hash to the entry whose entry_hash it names, recompute each entry_hash under its hash_version (rendering created_at through audit.chain_timestamp_text), and compare against the stored value. On a failure, alert immediately and record the break point. @constellation-platform/audit implements the walk in two forms, and no job calls either yet.

The walk follows the links rather than created_at (PLT-982). created_at is the transaction start time, while the link is decided when the transaction locks the chain head, so concurrent writers can be chained in the opposite order to their timestamps. Every entry of one transaction also shares one timestamp.

  • verifyChainIntegrity returns the first failure: a hash that does not recompute, a broken link, or an entry with no hash.
  • summariseChainIntegrity walks the whole chain and reports every failure. It checks each entry against its own stored link, so one version-1 casualty does not hide the entries after it.
    • A broken link is a hashed entry where the stored links do not form one chain. Either an earlier entry already names the same predecessor (a fork, including a second GENESIS entry), or the walk from GENESIS cannot reach it and it is the first entry of what it cannot reach: its previous_hash is NULL or names no entry (a deleted entry leaves this), or its predecessors lead back to itself (a cycle). An entry hidden by the reading transaction's audit_access scope, or a partition dropped by the retention archival path, leaves the same missing_predecessor; a job alerting on it must know the tenant's archive state, or it alerts on every run after the first archive. One entry can carry two reasons, each reported once. The walk restarts at such an entry, so every hashed entry that names a predecessor is still recomputed. One with a NULL previous_hash names nothing to recompute from, and is reported by its link alone.
    • An entry with a NULL entry_hash, the writer's degrade path, is not in the chain. The writer leaves the head where it was, so the links run past it. It is reported as unhashed, never skipped, so a degraded chain does not read as an intact one.
    • A version-1 mismatch that no version-2 entry precedes on its link path is flagged residual. It is a key-order casualty or tampering, and the row cannot say which, because the bytes it hashed are gone.
    • Every other mismatch is not residual, including a version-1 label after version-2 history. No version-1 row is written once the writer moved to version 2.
    • The version is not hashed, so the flag is evidence rather than proof. Version 1 accepts the jsonb rendering, so a version-2 entry relabelled as version 1 still verifies: relabelling the whole version-2 prefix before a tampered entry cannot be detected, and that entry then reads as residual. Every mismatch is still reported; only its classification can be misled.
    • Neither form compares the chain's tip with audit.chain_heads.last_hash, so deleting the newest entries leaves a shorter chain that still verifies.

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.