# Integrity contract (AUDIT-WP-0007) Operational custody already stores `payload_hash` and rejects UPDATE/DELETE for the runtime role. That is not tamper evidence: a database owner can drop the trigger and rewrite rows. This contract says what the hash chain proves, and what it does not. ## Chain Each accepted event stores: - `payload_hash` — SHA-256 of the canonical event record (already stored) - `chain_prev` — the previous event's `chain_hash`, or genesis - `chain_hash` — `SHA-256(chain_prev | payload_hash | event_id)` Genesis is 64 ASCII zeros (`audit_core.integrity.GENESIS`). The delimiter is `|`. The chain is **one stream per schema**, not per tenant. A per-tenant chain would hide a cross-tenant rewrite. The first accept of an empty store uses genesis as `chain_prev`. A duplicate accept must not mint a second link. A conflict must not advance the head. Concurrent first-accepts of different events take an advisory lock (`CHAIN_LOCK_KEY`) so they cannot fork. Verify walks `ORDER BY accepted_at, event_id`, recomputes every link, and reports the first event id whose stored hashes do not match. A break is a **custody defect**, not a sender error. Retrying the event will not repair it. ## Proof bound A chain *inside* the same database detects a rewritten `payload_hash` **if the attacker does not also recompute the suffix**. A database owner can. Tamper evidence against that class of attacker requires a **chain-head attestation** stored outside `platform-pg`. `tamper_evidence=True` is allowed only when: 1. `verify` exists and fails on a rewritten row 2. an external head attestation exists and verify-against-attestation reports a missing cited head as a break It still does not mean WORM, object lock, or ITC-CAP `data.archive`. It does not raise provision maturity to D5. Do not write the attestation into the Barman prefix (`platform-pg/` on `resource:platform:audit-storage`). That copy is restored with the table. A second copy may follow the logical-offsite path already used by RESOURCE-WP-0002-T06 (`rapp-postgres` / Nextcloud + age); cite it, do not invent a new bucket. ## Tests this contract names | Test | What it asserts | | --- | --- | | `test_first_accept_sets_genesis` | First `chain_prev` is genesis | | `test_second_accept_links` | Second `chain_prev` is the first `chain_hash` | | `test_duplicate_does_not_fork` | Replay does not add a link | | `test_verify_clean_on_fresh_store` | Empty and two-event stores verify | | `test_rewritten_payload_fails_verify` | Superuser rewrite of `payload_hash` is a break | | `test_attestation_mismatch` | Cited head absent from the live chain is a break | ## Operator surface ```bash python -m audit_core verify-chain python -m audit_core verify-chain --against docs/evidence/chain-head-.json python -m audit_core attest-chain --output docs/evidence/chain-head-.json ``` `GET /v1/integrity` (`may_read`) returns `{intact, events, head, head_event_id, first_break, attestation_match}` and never event payloads.