# 0005 — Hash-chained, append-only audit trail

**Status:** Accepted · 2026-08-01

## Context

The software runs on the clinic's own server, with the clinic's own database
credentials. Anyone with those credentials can edit any row, including audit
rows. Tamper *prevention* is therefore not available to us, and claiming it
would be dishonest.

Tamper *evidence* is available, and in a dispute it is what actually matters.

## Decision

`audit_logs` is append-only: no `updated_at`, no soft delete, and both `update()`
and `delete()` throw at the model layer. Every row stores the SHA-256 hash of its
predecessor's contents, forming a chain. `audit:verify` walks it weekly from the
scheduler and reports where it breaks.

`AuditLogger` is the only writer. It redacts passwords, tokens and blind indexes
before they can reach the trail, and it fails soft: an audit write must never
block the clinical action that triggered it. A receptionist cannot be prevented
from registering a patient because a log insert failed — the failure is logged
and surfaced on the diagnostics page instead.

Read access is recorded separately in `record_access_logs`. A change log cannot
capture who *looked* at a record, and that is the access most health-data
regimes actually ask about — and the question a clinic will eventually have to
answer about a celebrity's, a neighbour's or an ex-partner's file.

`AuditHasher` is a single shared class used by both the writer and the verifier.

## Consequences

**Good.** An altered or deleted row is detectable. Because the verifier continues
from each row's *stored* hash rather than its recomputed one, a single edited row
is reported once rather than invalidating everything after it.

**Bad.** Each write takes a brief row lock on the chain head to prevent two
concurrent writers forking it. At clinic-scale write volumes this is not a
bottleneck; at hospital scale it would need revisiting.

**Learned the hard way.** The first implementation hashed the raw JSON column
text. MySQL's JSON type re-serialises what it stores — key order and whitespace
are not preserved — so the verifier hashed different bytes than the writer had,
and every healthy row carrying values was reported as tampered. The early tests
all logged entries *without* values, so the chain looked intact and the defect
only surfaced against real data. Hence: `AuditHasher` canonicalises JSON and
timestamps, one implementation serves both sides, and
`tests/Feature/AuditTrailTest.php` carries an explicit regression guard for
entries that carry values.
