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
This commit is contained in:
tegwick 2026-09-06 20:34:22 +02:00
parent 95dcb78e17
commit 2f7f475e85
7 changed files with 408 additions and 12 deletions

View file

@ -97,10 +97,12 @@ effect of its own.
**Known conformance gaps** (assessed 2026-08-29, **Known conformance gaps** (assessed 2026-08-29,
`history/2026-08-29-v0.7-alignment-and-scope-assessment.md`): `history/2026-08-29-v0.7-alignment-and-scope-assessment.md`):
- `tamper_evidence=True` is returned unconditionally by the Postgres backend - ~~`tamper_evidence=True` returned unconditionally by the Postgres backend~~
while `docs/integrity.md` permits it only when a live external attestation **Closed 2026-09-06 (`AUDIT-WP-0009-T01`).** The flag is now derived per
exists. The one attestation on record is 2026-08-16 and no job renews it. read from live chain and attestation state against a declared 168h
This is Audit Core overclaiming its own bound — the §9.6 defect turned inward. freshness window. Until `AUDIT-WP-0009-T02` schedules attestation, the
honest answer in production is `false` — the overclaim is gone, the
precondition is not yet met.
- No cadence, heartbeat, reconciliation, or load-bearing classification exists. - No cadence, heartbeat, reconciliation, or load-bearing classification exists.
The §9.6 obligations Audit Core argued for are not yet supportable by Audit The §9.6 obligations Audit Core argued for are not yet supportable by Audit
Core. Core.

View file

@ -14,6 +14,10 @@ from pathlib import Path
from typing import Any, Iterable, Mapping from typing import Any, Iterable, Mapping
SCHEMA = "audit-core.chain-head.v1" SCHEMA = "audit-core.chain-head.v1"
# How old a chain-head attestation may be before the tamper-evidence claim
# degrades. Declared in ``docs/integrity.md`` alongside the attestation
# cadence; the two are one contract and must move together.
DEFAULT_ATTESTATION_MAX_AGE_HOURS = 168.0
GENESIS = "0" * 64 GENESIS = "0" * 64
# Documented advisory-lock key so concurrent accepts cannot fork the head. # Documented advisory-lock key so concurrent accepts cannot fork the head.
CHAIN_LOCK_KEY = 0xA0D17007 CHAIN_LOCK_KEY = 0xA0D17007
@ -122,6 +126,106 @@ def _apply_attestation(
) )
@dataclass(frozen=True)
class TamperEvidenceState:
"""Whether the ``tamper_evidence`` claim is currently earned.
``docs/integrity.md`` permits the claim only while both preconditions are
live. Absence or staleness degrades it it does not leave it standing.
"""
claimed: bool
reason: str
observed_at: str | None = None
age_seconds: float | None = None
max_age_seconds: float | None = None
def as_dict(self) -> dict[str, Any]:
return {
"tamper_evidence": self.claimed,
"reason": self.reason,
"attestation_observed_at": self.observed_at,
"attestation_age_seconds": self.age_seconds,
"attestation_max_age_seconds": self.max_age_seconds,
}
def _parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def evaluate_tamper_evidence(
report: ChainReport | None,
attestation: Mapping[str, Any] | None,
*,
max_age_hours: float = DEFAULT_ATTESTATION_MAX_AGE_HOURS,
now: datetime | None = None,
) -> TamperEvidenceState:
"""Derive the tamper-evidence claim from live state.
``report`` must be the chain report produced *against* ``attestation``, so
that ``attestation_match`` reflects the cited head. ``report is None``
means the chain could not be walked at all, which is not a licence to keep
claiming.
"""
max_age_seconds = float(max_age_hours) * 3600.0
if report is None:
return TamperEvidenceState(
False, "chain_unreadable", max_age_seconds=max_age_seconds
)
if not report.intact:
return TamperEvidenceState(
False, "chain_break", max_age_seconds=max_age_seconds
)
if attestation is None:
return TamperEvidenceState(
False, "no_attestation", max_age_seconds=max_age_seconds
)
observed_at = str(attestation.get("observed_at") or "") or None
if report.attestation_match is not True:
return TamperEvidenceState(
False,
"attestation_mismatch",
observed_at=observed_at,
max_age_seconds=max_age_seconds,
)
stamped = _parse_iso(observed_at)
if stamped is None:
return TamperEvidenceState(
False,
"attestation_undated",
observed_at=observed_at,
max_age_seconds=max_age_seconds,
)
reference = now or datetime.now(timezone.utc)
if reference.tzinfo is None:
reference = reference.replace(tzinfo=timezone.utc)
age_seconds = (reference - stamped).total_seconds()
if age_seconds > max_age_seconds:
return TamperEvidenceState(
False,
"attestation_stale",
observed_at=observed_at,
age_seconds=age_seconds,
max_age_seconds=max_age_seconds,
)
return TamperEvidenceState(
True,
"attested",
observed_at=observed_at,
age_seconds=age_seconds,
max_age_seconds=max_age_seconds,
)
def attestation_from_report(report: ChainReport, *, observed_at: str | None = None) -> dict[str, Any]: def attestation_from_report(report: ChainReport, *, observed_at: str | None = None) -> dict[str, Any]:
return { return {
"schema": SCHEMA, "schema": SCHEMA,

View file

@ -20,6 +20,7 @@ from __future__ import annotations
import json import json
import os import os
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@ -35,10 +36,14 @@ from audit_core.interface import (
from audit_core.credentials import CredentialDirectory from audit_core.credentials import CredentialDirectory
from audit_core.integrity import ( from audit_core.integrity import (
CHAIN_LOCK_KEY, CHAIN_LOCK_KEY,
DEFAULT_ATTESTATION_MAX_AGE_HOURS,
GENESIS, GENESIS,
ChainRow, ChainRow,
TamperEvidenceState,
attestation_from_report, attestation_from_report,
chain_link, chain_link,
evaluate_tamper_evidence,
load_attestation,
verify_rows, verify_rows,
) )
from audit_core.redaction import Finding from audit_core.redaction import Finding
@ -177,6 +182,9 @@ class PostgresAuditBackend:
statement_timeout_ms: int = 30_000, statement_timeout_ms: int = 30_000,
migrate: bool = True, migrate: bool = True,
credential_dir: str | None = None, credential_dir: str | None = None,
attestation_path: str | None = None,
attestation_max_age_hours: float = DEFAULT_ATTESTATION_MAX_AGE_HOURS,
attestation_cache_seconds: float = 60.0,
) -> None: ) -> None:
# A mounted credential directory takes precedence: it is the only # A mounted credential directory takes precedence: it is the only
# source that can change while the process runs, which is what dynamic # source that can change while the process runs, which is what dynamic
@ -202,6 +210,19 @@ class PostgresAuditBackend:
self.recoverable_days = recoverable_days self.recoverable_days = recoverable_days
self.recoverable_source = recoverable_source self.recoverable_source = recoverable_source
self.recoverable_basis = recoverable_basis self.recoverable_basis = recoverable_basis
# The chain-head attestation lives outside this database by design
# (docs/integrity.md): a copy restored with the table proves nothing.
# Its path is mounted, not configured here, so a rotated attestation
# takes effect without a restart.
self.attestation_path = (
attestation_path
if attestation_path is not None
else os.environ.get("AUDIT_CORE_ATTESTATION_PATH") or None
)
self.attestation_max_age_hours = float(attestation_max_age_hours)
self._attestation_cache_seconds = float(attestation_cache_seconds)
self._tamper_state: TamperEvidenceState | None = None
self._tamper_state_at = 0.0
base_kwargs = { base_kwargs = {
"autocommit": True, "autocommit": True,
# A stalled write must surface as unavailable rather than hold a # A stalled write must surface as unavailable rather than hold a
@ -288,10 +309,12 @@ class PostgresAuditBackend:
``immutable`` is True because migration 0002 installs a trigger that ``immutable`` is True because migration 0002 installs a trigger that
rejects UPDATE and DELETE, so no consumer credential can alter a stored rejects UPDATE and DELETE, so no consumer credential can alter a stored
record. It is not a claim against the database owner or a superuser, record. It is not a claim against the database owner or a superuser,
who can drop the trigger. ``tamper_evidence`` is True because a who can drop the trigger. ``tamper_evidence`` is *derived*, not
hash chain plus verify detects a rewritten payload, and a chain-head declared: a hash chain plus verify detects a rewritten payload only
attestation outside this database detects a suffix rewrite that while a live chain-head attestation outside this database also
stays inside Postgres. It is not WORM or ``data.archive``. detects a suffix rewrite that stays inside Postgres. Absent, stale,
or mismatched attestation degrades the claim to False see
:meth:`tamper_evidence_state`. It is never WORM or ``data.archive``.
``custody_class`` is ``operational``, not ``archive``. This store is ``custody_class`` is ``operational``, not ``archive``. This store is
durable append-only Postgres recovered through the platform durable append-only Postgres recovered through the platform
@ -303,7 +326,7 @@ class PostgresAuditBackend:
custody_class="operational", custody_class="operational",
retention_days=self.retention_days, retention_days=self.retention_days,
immutable=True, immutable=True,
tamper_evidence=True, tamper_evidence=self.tamper_evidence_state().claimed,
durable=True, durable=True,
recoverable_days=self.recoverable_days, recoverable_days=self.recoverable_days,
recoverable_source=self.recoverable_source, recoverable_source=self.recoverable_source,
@ -492,6 +515,44 @@ class PostgresAuditBackend:
def attest_chain(self) -> dict: def attest_chain(self) -> dict:
return attestation_from_report(self.verify_chain()) return attestation_from_report(self.verify_chain())
def load_current_attestation(self) -> dict | None:
"""The mounted chain-head attestation, or None if absent/unreadable.
Unreadable is treated as absent on purpose: a malformed file must not
be able to keep a claim standing that a missing file would drop.
"""
if not self.attestation_path:
return None
try:
return load_attestation(self.attestation_path)
except (OSError, ValueError):
return None
def tamper_evidence_state(self) -> TamperEvidenceState:
"""Evaluate the tamper-evidence preconditions against live state.
Cached briefly because ``/readyz`` reads it on every probe and the
evaluation walks the chain. The cache only ever delays a *change* of
state; it cannot manufacture one.
"""
now = time.monotonic()
cached = self._tamper_state
if cached is not None and now - self._tamper_state_at < self._attestation_cache_seconds:
return cached
attestation = self.load_current_attestation()
try:
report = self.verify_chain(attestation)
except Exception: # backend unavailable, and so is the claim
report = None
state = evaluate_tamper_evidence(
report,
attestation,
max_age_hours=self.attestation_max_age_hours,
)
self._tamper_state = state
self._tamper_state_at = now
return state
def health(self) -> None: def health(self) -> None:
self._query("SELECT 1", ()) self._query("SELECT 1", ())

View file

@ -120,7 +120,7 @@ Compatibility rules (not yet implemented):
| `custody_class` | `development`, `operational`, `archive`, or `hot_search` | | `custody_class` | `development`, `operational`, `archive`, or `hot_search` |
| `retention_days` | Maximum age before eligible deletion; `None` is a lifecycle statement (no expiry), not a recovery guarantee | | `retention_days` | Maximum age before eligible deletion; `None` is a lifecycle statement (no expiry), not a recovery guarantee |
| `immutable` | Whether stored records are protected from in-place alteration | | `immutable` | Whether stored records are protected from in-place alteration |
| `tamper_evidence` | Whether manifests, hash chains, or signatures exist | | `tamper_evidence` | Whether manifests, hash chains, or signatures exist **and their preconditions are currently live** — backends derive this, they do not declare it |
| `durable` | Whether survival is expected across process restarts and host reboots | | `durable` | Whether survival is expected across process restarts and host reboots |
| `recoverable_days` | Cited platform backup window; `None` if not declared | | `recoverable_days` | Cited platform backup window; `None` if not declared |
| `recoverable_source` | Where the recoverable window is cited from | | `recoverable_source` | Where the recoverable window is cited from |
@ -165,7 +165,10 @@ integrity proofs, or survival of `/tmp` across reboots.
- `custody_class`: `operational` - `custody_class`: `operational`
- `retention_days`: unset in production (the service does not expire rows) - `retention_days`: unset in production (the service does not expire rows)
- `immutable`: true (trigger `events_append_only`; not a claim against the database owner) - `immutable`: true (trigger `events_append_only`; not a claim against the database owner)
- `tamper_evidence`: true (hash chain + verify + external head attestation; not WORM) - `tamper_evidence`: **derived per read**, not constant — true only while the
chain verifies and a fresh, matching external head attestation is mounted
(`docs/integrity.md`, freshness window 168h; not WORM). Absent, stale,
unreadable, or mismatched attestation reports false.
- `durable`: true - `durable`: true
- `recoverable_days`: 30, cited from the platform `data.backup` provision - `recoverable_days`: 30, cited from the platform `data.backup` provision
- `recoverable_source`: `resource-control/data/capability/platform-audit-storage.json#provisions[capability=data.backup]` - `recoverable_source`: `resource-control/data/capability/platform-audit-storage.json#provisions[capability=data.backup]`

View file

@ -39,10 +39,39 @@ owner can. Tamper evidence against that class of attacker requires a
1. `verify` exists and fails on a rewritten row 1. `verify` exists and fails on a rewritten row
2. an external head attestation exists and verify-against-attestation 2. an external head attestation exists and verify-against-attestation
reports a missing cited head as a break 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 still does not mean WORM, object lock, or ITC-CAP `data.archive`.
It does not raise provision maturity to D5. 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 Do not write the attestation into the Barman prefix
(`platform-pg/` on `resource:platform:audit-storage`). That copy is (`platform-pg/` on `resource:platform:audit-storage`). That copy is
restored with the table. A second copy may follow the logical-offsite restored with the table. A second copy may follow the logical-offsite

View file

@ -137,3 +137,182 @@ def test_http_integrity_requires_read(tmp_path):
assert body["intact"] is True assert body["intact"] is True
assert body["events"] == 1 assert body["events"] == 1
assert "record" not in body assert "record" not in body
# --- AUDIT-WP-0009-T01: tamper_evidence is derived, not declared -----------
def _report(**kw):
from audit_core.integrity import ChainReport
fields = dict(
intact=True,
events=2,
head="a" * 64,
head_event_id="e2",
head_accepted_at="2026-09-01T00:00:00+00:00",
first_break=None,
attestation_match=True,
)
fields.update(kw)
return ChainReport(**fields)
def _at(hours_ago: float):
from datetime import datetime, timedelta, timezone
stamp = datetime(2026, 9, 6, tzinfo=timezone.utc) - timedelta(hours=hours_ago)
return stamp.isoformat()
def _now():
from datetime import datetime, timezone
return datetime(2026, 9, 6, tzinfo=timezone.utc)
def test_fresh_matching_attestation_earns_the_claim():
from audit_core.integrity import evaluate_tamper_evidence
state = evaluate_tamper_evidence(
_report(), {"chain_hash": "a" * 64, "observed_at": _at(6)}, now=_now()
)
assert state.claimed is True
assert state.reason == "attested"
def test_absent_attestation_degrades_the_claim():
from audit_core.integrity import evaluate_tamper_evidence
state = evaluate_tamper_evidence(_report(attestation_match=None), None, now=_now())
assert state.claimed is False
assert state.reason == "no_attestation"
def test_stale_attestation_degrades_the_claim():
from audit_core.integrity import evaluate_tamper_evidence
# The one attestation on record when this task was written was 21 days old.
state = evaluate_tamper_evidence(
_report(), {"chain_hash": "a" * 64, "observed_at": _at(21 * 24)}, now=_now()
)
assert state.claimed is False
assert state.reason == "attestation_stale"
assert state.age_seconds > state.max_age_seconds
def test_attestation_just_inside_the_window_still_counts():
from audit_core.integrity import evaluate_tamper_evidence
state = evaluate_tamper_evidence(
_report(), {"chain_hash": "a" * 64, "observed_at": _at(167)}, now=_now()
)
assert state.claimed is True
def test_mismatched_attestation_degrades_the_claim():
from audit_core.integrity import evaluate_tamper_evidence
state = evaluate_tamper_evidence(
_report(attestation_match=False),
{"chain_hash": "b" * 64, "observed_at": _at(1)},
now=_now(),
)
assert state.claimed is False
assert state.reason == "attestation_mismatch"
def test_undated_attestation_degrades_the_claim():
from audit_core.integrity import evaluate_tamper_evidence
state = evaluate_tamper_evidence(_report(), {"chain_hash": "a" * 64}, now=_now())
assert state.claimed is False
assert state.reason == "attestation_undated"
def test_broken_chain_degrades_the_claim_even_with_fresh_attestation():
from audit_core.integrity import evaluate_tamper_evidence
state = evaluate_tamper_evidence(
_report(intact=False, first_break="e2"),
{"chain_hash": "a" * 64, "observed_at": _at(1)},
now=_now(),
)
assert state.claimed is False
assert state.reason == "chain_break"
def test_unreadable_chain_degrades_the_claim():
from audit_core.integrity import evaluate_tamper_evidence
state = evaluate_tamper_evidence(
None, {"chain_hash": "a" * 64, "observed_at": _at(1)}, now=_now()
)
assert state.claimed is False
assert state.reason == "chain_unreadable"
def _bare_postgres_backend(tmp_path, attestation_path=None):
"""A PostgresAuditBackend with no pool — retention_policy needs no I/O."""
import pytest
pytest.importorskip("psycopg", reason="needs psycopg to import the backend")
from audit_core.integrity import DEFAULT_ATTESTATION_MAX_AGE_HOURS
from audit_core.postgres_backend import PostgresAuditBackend
backend = object.__new__(PostgresAuditBackend)
backend.retention_days = None
backend.recoverable_days = 30
backend.recoverable_source = "test"
backend.recoverable_basis = "measured"
backend.attestation_path = str(attestation_path) if attestation_path else None
backend.attestation_max_age_hours = DEFAULT_ATTESTATION_MAX_AGE_HOURS
backend._attestation_cache_seconds = 0.0
backend._tamper_state = None
backend._tamper_state_at = 0.0
return backend
def test_postgres_policy_drops_the_claim_without_an_attestation(tmp_path):
backend = _bare_postgres_backend(tmp_path)
backend.verify_chain = lambda attestation=None: _report(attestation_match=None)
policy = backend.retention_policy
assert policy.tamper_evidence is False
assert policy.immutable is True
assert backend.tamper_evidence_state().reason == "no_attestation"
def test_postgres_policy_earns_the_claim_from_a_fresh_attestation(tmp_path):
from audit_core.integrity import utc_now
path = tmp_path / "chain-head.json"
path.write_text(json.dumps({"chain_hash": "a" * 64, "observed_at": utc_now()}))
backend = _bare_postgres_backend(tmp_path, attestation_path=path)
backend.verify_chain = lambda attestation=None: _report(
attestation_match=attestation is not None
)
assert backend.retention_policy.tamper_evidence is True
def test_postgres_policy_treats_an_unreadable_attestation_as_absent(tmp_path):
path = tmp_path / "chain-head.json"
path.write_text("{not json")
backend = _bare_postgres_backend(tmp_path, attestation_path=path)
backend.verify_chain = lambda attestation=None: _report(attestation_match=None)
assert backend.retention_policy.tamper_evidence is False
assert backend.tamper_evidence_state().reason == "no_attestation"
def test_postgres_policy_drops_the_claim_when_the_chain_cannot_be_walked(tmp_path):
from audit_core.integrity import utc_now
path = tmp_path / "chain-head.json"
path.write_text(json.dumps({"chain_hash": "a" * 64, "observed_at": utc_now()}))
backend = _bare_postgres_backend(tmp_path, attestation_path=path)
def _unavailable(attestation=None):
raise RuntimeError("backend unavailable")
backend.verify_chain = _unavailable
assert backend.retention_policy.tamper_evidence is False
assert backend.tamper_evidence_state().reason == "chain_unreadable"

View file

@ -57,7 +57,7 @@ Fixed by the statute; not deferred, not ours:
```task ```task
id: AUDIT-WP-0009-T01 id: AUDIT-WP-0009-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "f545b0e4-8c99-5186-affd-ce9a41209ed7" state_hub_task_id: "f545b0e4-8c99-5186-affd-ce9a41209ed7"
``` ```
@ -69,6 +69,24 @@ leave it standing. Assert the degradation with a test, and state the freshness
window in `docs/integrity.md` alongside the two existing preconditions, which window in `docs/integrity.md` alongside the two existing preconditions, which
today are documented but unenforced. today are documented but unenforced.
Done 2026-09-06. `evaluate_tamper_evidence` in `audit_core/integrity.py` derives
the claim from a `ChainReport` and the mounted attestation; `PostgresAuditBackend`
reads it per `retention_policy` call through `tamper_evidence_state()`, briefly
cached because `/readyz` probes it. Seven degradation reasons are distinguished
and asserted — `no_attestation`, `attestation_stale`, `attestation_mismatch`,
`attestation_undated`, `chain_break`, `chain_unreadable`, against `attested`.
Unreadable is treated as absent deliberately: a malformed file must not hold up
a claim a missing file would drop. The freshness window is **168h** against an
intended daily cadence, declared in `docs/integrity.md` with the reasoning that
the window is seven cadences so missed runs degrade rather than flap. Twelve
tests in `tests/test_integrity.py`; `docs/audit-backend-contract.md` now states
the field is derived rather than declared.
Consequence to state plainly: production `/readyz` will report
`tamper_evidence: false` until T02 mounts a renewed attestation. The 2026-08-16
attestation is 21 days old against a 7-day window. That is the overclaim being
removed, not a regression — the claim was false before and now says so.
```task ```task
id: AUDIT-WP-0009-T02 id: AUDIT-WP-0009-T02
status: todo status: todo