# 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 3. that attestation is **fresh** — its `observed_at` is within the freshness window below It still does not mean WORM, object lock, or ITC-CAP `data.archive`. It does not raise provision maturity to D5. ## Attestation cadence and freshness window An attestation dated once is not a live precondition. The claim is derived from the mounted attestation on every read, not declared: | Setting | Value | | --- | --- | | Intended attestation cadence | daily | | Freshness window | **168 hours (7 days)** | | Mount path | `AUDIT_CORE_ATTESTATION_PATH` | The window is seven times the cadence so that a handful of missed runs degrade the claim rather than a single one flapping it. Widening the window without shortening the cadence weakens the claim and is a change to this contract, not a tuning knob. `tamper_evidence` degrades to `False`, with the reason recorded, when the attestation is absent, unreadable, undated, older than the window, or cites a head the live chain does not carry — and when the chain itself is broken or cannot be walked. Unreadable is treated as absent deliberately: a malformed file must not hold up a claim that a missing file would drop. Until `AUDIT-WP-0009-T02` schedules the attestation job, no attestation is mounted in production and `/readyz` reports `tamper_evidence: false`. That is the honest reading of the current state, not a regression. 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.