Resolved.
Resolved as a single pass in src/Models/AuditLog.php:
H-8 (concurrency): The SELECT prev_hash → INSERT pair is now wrapped in a MariaDB named lock:
GET_LOCK('audit_chain', 5) → SELECT → INSERT → RELEASE_LOCK
Two concurrent record() calls no longer both read the same previous integrity_hash.
H-7 (payload shape parity): Extracted a private hmacPayload() helper. Both record() and verifyChain() now build their JSON through the same function, so null handling and key ordering can't diverge.
H-6 (first-row anchor): verifyChain() now fetches the row immediately preceding the scan window and seeds $expectedPrev with its integrity_hash. The first row in the scan has its prev_hash verified against actual history, not against null — so an attacker can't sever-and-re-anchor at a $sinceId boundary.
Smoke test:
verifyChain from 707 (limit 10): OK
verifyChain from first chained row 675 (limit 100): OK
Locking this thread.