Accept now extends a single-schema chain. Verify walks it; a rewritten payload_hash is a break. Tamper evidence is that detector plus an external chain-head attestation, not WORM.
3 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'schain_hash, or genesischain_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:
verifyexists and fails on a rewritten row- 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
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.