audit-core/docs/integrity.md
tegwick 2f7f475e85 AUDIT-WP-0009-T01 — derive tamper_evidence from live attestation state
The Postgres backend returned tamper_evidence=True as a constant while
docs/integrity.md permits the claim only when a live external chain-head
attestation exists. The one attestation on record is 2026-08-16 and no job
renews it, so audit-core was telling every sender it had a property whose
precondition was unverified — the §9.6 defect it twice corrected in
gate-house's doctrine, turned inward.

evaluate_tamper_evidence() derives the flag from the chain report and the
mounted attestation, distinguishing seven states. Absence, staleness,
mismatch, an undated or unreadable attestation, a chain break, and an
unwalkable chain all degrade the claim rather than leave it standing.
Unreadable is treated as absent on purpose: a malformed file must not hold
up a claim a missing file would drop.

The freshness window is 168h against an intended daily cadence — seven
cadences, so a handful of missed runs degrade the claim rather than a single
one flapping it. Window and cadence are one contract in docs/integrity.md.

Production /readyz will now report tamper_evidence: false until
AUDIT-WP-0009-T02 schedules attestation. The claim was already false; it now
says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185wifnLzCxjEY2MT1XbK7L

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713962@bnt-lap001
Assistant-Session: 2718d99d-d3ff-478f-83a2-3a30f01a02fc
2026-09-06 20:34:22 +02:00

4.2 KiB

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_hashSHA-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

python -m audit_core verify-chain
python -m audit_core verify-chain --against docs/evidence/chain-head-<ts>.json
python -m audit_core attest-chain --output docs/evidence/chain-head-<ts>.json

GET /v1/integrity (may_read) returns {intact, events, head, head_event_id, first_break, attestation_match} and never event payloads.