Compare commits

...

2 commits

Author SHA1 Message Date
61214bc24a Close AUDIT-WP-0007 after live chain attestation.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Migration 0006 applied on railiance01. Verify-against the dated
chain-head evidence is intact for 30 events.
2026-08-16 01:23:54 +02:00
5fd04e2095 Implement AUDIT-WP-0007 hash-chain integrity.
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.
2026-08-16 01:18:30 +02:00
22 changed files with 740 additions and 42 deletions

View file

@ -31,12 +31,13 @@ audit-core exists to provide the capability described in INTENT.md.
- Status: production - Status: production
- Production receiver on railiance01 (`namespace audit-core`), Postgres - Production receiver on railiance01 (`namespace audit-core`), Postgres
operational custody on `platform-pg`, sender `user-engine`. operational custody on `platform-pg`, sender `user-engine`.
- Recovery is the platform `data.backup` window (30 days, RESOURCE-WP-0002 - Recovery is the platform `data.backup` window (30 days). Live `/readyz`
live). Live `/readyz` reports `custody_class=operational` and reports `custody_class=operational`, `tamper_evidence=true`,
`recoverable_days=30` (image `sha256:05fe1c06…`, 2026-08-16 cutover). `recoverable_days=30` (image `sha256:7febc28e…`).
- ITC-CAP case: `data/capability/audit-core-operational.json`. - Hash chain verified on 30 live events
`data.archive` is an unmet requirement. `tamper_evidence` is still false. (`docs/evidence/chain-head-20260816.json`).
- Open workplan: `workplans/AUDIT-WP-0007-integrity-verification.md`. - ITC-CAP case: `data/capability/audit-core-operational.json` at D4.
`data.archive` is an unmet requirement.
## Getting Oriented ## Getting Oriented

View file

@ -107,6 +107,34 @@ def build_parser() -> argparse.ArgumentParser:
) )
migrate_parser.set_defaults(func=migrate_store) migrate_parser.set_defaults(func=migrate_store)
verify_parser = sub.add_parser(
"verify-chain",
help="Walk the custody hash chain; exit 0 only if intact.",
)
verify_parser.add_argument(
"--to-postgres", dest="destination", default=None,
help="DSN; defaults to AUDIT_CORE_DATABASE_URL or a mounted credential dir.",
)
verify_parser.add_argument("--schema", default="audit_core")
verify_parser.add_argument(
"--against",
default=None,
help="Chain-head attestation JSON. A cited head missing from the live chain is a break.",
)
verify_parser.set_defaults(func=verify_chain)
attest_parser = sub.add_parser(
"attest-chain",
help="Write the live chain head to a file outside the database.",
)
attest_parser.add_argument(
"--to-postgres", dest="destination", default=None,
help="DSN; defaults to AUDIT_CORE_DATABASE_URL or a mounted credential dir.",
)
attest_parser.add_argument("--schema", default="audit_core")
attest_parser.add_argument("--output", required=True)
attest_parser.set_defaults(func=attest_chain)
return parser return parser
@ -158,6 +186,32 @@ def replay_event(args: argparse.Namespace) -> int:
return 0 if result.duplicate else 2 return 0 if result.duplicate else 2
def verify_chain(args: argparse.Namespace) -> int:
from audit_core.integrity import load_attestation
backend = _postgres_backend(args.destination, args.schema, migrate=False)
try:
cited = load_attestation(args.against) if args.against else None
report = backend.verify_chain(cited)
finally:
backend.close()
print(json.dumps(report.as_dict(), sort_keys=True))
return 0 if report.intact else 1
def attest_chain(args: argparse.Namespace) -> int:
from audit_core.integrity import write_attestation
backend = _postgres_backend(args.destination, args.schema, migrate=False)
try:
report = backend.verify_chain()
body = write_attestation(args.output, report)
finally:
backend.close()
print(json.dumps({"ok": report.intact, "path": args.output, **body}, sort_keys=True))
return 0 if report.intact else 1
def migrate_store(args: argparse.Namespace) -> int: def migrate_store(args: argparse.Namespace) -> int:
import os import os

View file

@ -159,7 +159,12 @@ class IngestionApplication:
if method == "GET" and ( if method == "GET" and (
path.startswith("/v1/events") path.startswith("/v1/events")
or path in ("/v1/dead-letters", "/v1/secret-findings", "/v1/stats") or path in (
"/v1/dead-letters",
"/v1/secret-findings",
"/v1/stats",
"/v1/integrity",
)
): ):
return self._read(start_response, environ, path, identity) return self._read(start_response, environ, path, identity)
@ -241,6 +246,14 @@ class IngestionApplication:
start_response, HTTPStatus.OK, start_response, HTTPStatus.OK,
{"secret_findings": self.backend.secret_findings(_limit(query))}, {"secret_findings": self.backend.secret_findings(_limit(query))},
) )
if path == "/v1/integrity":
verify = getattr(self.backend, "verify_chain", None)
if not callable(verify):
return self._json(
start_response, HTTPStatus.NOT_FOUND,
{"error": "integrity_not_supported"},
)
return self._json(start_response, HTTPStatus.OK, verify().as_dict())
if path == "/v1/events": if path == "/v1/events":
correlation = (query.get("correlation_id") or [""])[0] correlation = (query.get("correlation_id") or [""])[0]
if not correlation: if not correlation:

149
audit_core/integrity.py Normal file
View file

@ -0,0 +1,149 @@
"""Hash-chain integrity for operational custody (AUDIT-WP-0007).
See ``docs/integrity.md`` for the proof bound. This module is the shared
arithmetic; backends persist and walk rows.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
SCHEMA = "audit-core.chain-head.v1"
GENESIS = "0" * 64
# Documented advisory-lock key so concurrent accepts cannot fork the head.
CHAIN_LOCK_KEY = 0xA0D17007
def chain_link(previous: str, payload_hash: str, event_id: str) -> str:
"""Next ``chain_hash`` = SHA-256(previous || payload_hash || event_id)."""
material = f"{previous}|{payload_hash}|{event_id}".encode("utf-8")
return hashlib.sha256(material).hexdigest()
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
@dataclass(frozen=True)
class ChainRow:
event_id: str
payload_hash: str
chain_hash: str
chain_prev: str
accepted_at: str | None = None
@dataclass(frozen=True)
class ChainReport:
intact: bool
events: int
head: str
head_event_id: str | None
head_accepted_at: str | None
first_break: str | None
attestation_match: bool | None = None
def as_dict(self) -> dict[str, Any]:
return {
"intact": self.intact,
"events": self.events,
"head": self.head,
"head_event_id": self.head_event_id,
"head_accepted_at": self.head_accepted_at,
"first_break": self.first_break,
"attestation_match": self.attestation_match,
}
def verify_rows(
rows: Iterable[ChainRow],
*,
attestation: Mapping[str, Any] | None = None,
) -> ChainReport:
"""Walk accept order and recompute every link.
``first_break`` is an event id, or ``attestation_mismatch`` when a cited
head is not present in the live chain.
"""
ordered = list(rows)
prev = GENESIS
first_break: str | None = None
for row in ordered:
expected = chain_link(prev, row.payload_hash, row.event_id)
if row.chain_prev != prev or row.chain_hash != expected:
first_break = row.event_id
break
prev = row.chain_hash
intact = first_break is None
last = ordered[-1] if ordered else None
head = last.chain_hash if last else GENESIS
report = ChainReport(
intact=intact,
events=len(ordered),
head=head,
head_event_id=last.event_id if last else None,
head_accepted_at=last.accepted_at if last else None,
first_break=first_break,
)
if attestation is None:
return report
return _apply_attestation(report, ordered, attestation)
def _apply_attestation(
report: ChainReport,
rows: list[ChainRow],
attestation: Mapping[str, Any],
) -> ChainReport:
cited = str(attestation.get("chain_hash") or "")
live_hashes = {row.chain_hash for row in rows}
empty_ok = not rows and cited in {"", GENESIS}
matched = cited in live_hashes or empty_ok
first_break = report.first_break
intact = report.intact
if not matched:
intact = False
if first_break is None:
first_break = "attestation_mismatch"
return ChainReport(
intact=intact,
events=report.events,
head=report.head,
head_event_id=report.head_event_id,
head_accepted_at=report.head_accepted_at,
first_break=first_break,
attestation_match=matched,
)
def attestation_from_report(report: ChainReport, *, observed_at: str | None = None) -> dict[str, Any]:
return {
"schema": SCHEMA,
"genesis": GENESIS,
"chain_hash": report.head,
"event_id": report.head_event_id,
"accepted_at": report.head_accepted_at,
"event_count": report.events,
"observed_at": observed_at or utc_now(),
}
def load_attestation(path: str | Path) -> dict[str, Any]:
payload = json.loads(Path(path).read_text())
if not isinstance(payload, dict) or not payload.get("chain_hash"):
raise ValueError("attestation must be an object with chain_hash")
return payload
def write_attestation(path: str | Path, report: ChainReport) -> dict[str, Any]:
body = attestation_from_report(report)
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(body, indent=2, sort_keys=True) + "\n")
return body

View file

@ -69,6 +69,7 @@ class RetentionPolicy:
"status": "ok", "status": "ok",
"custody_class": self.custody_class, "custody_class": self.custody_class,
"durable": self.durable, "durable": self.durable,
"tamper_evidence": self.tamper_evidence,
} }
if self.recoverable_days is not None or self.recoverable_source: if self.recoverable_days is not None or self.recoverable_source:
payload["recoverable_days"] = self.recoverable_days payload["recoverable_days"] = self.recoverable_days

View file

@ -33,6 +33,14 @@ from audit_core.interface import (
validate_event, validate_event,
) )
from audit_core.credentials import CredentialDirectory from audit_core.credentials import CredentialDirectory
from audit_core.integrity import (
CHAIN_LOCK_KEY,
GENESIS,
ChainRow,
attestation_from_report,
chain_link,
verify_rows,
)
from audit_core.redaction import Finding from audit_core.redaction import Finding
try: # pragma: no cover - import guard try: # pragma: no cover - import guard
@ -134,6 +142,13 @@ MIGRATIONS: list[tuple[str, str]] = [
$grant$; $grant$;
""", """,
), ),
(
"0006-chain",
"""
ALTER TABLE {schema}.events ADD COLUMN IF NOT EXISTS chain_hash text;
ALTER TABLE {schema}.events ADD COLUMN IF NOT EXISTS chain_prev text;
""",
),
] ]
# Rejection reasons whose payload must never be persisted — storing the body of # Rejection reasons whose payload must never be persisted — storing the body of
@ -255,6 +270,7 @@ class PostgresAuditBackend:
(migration_id,), (migration_id,),
) )
applied.append(migration_id) applied.append(migration_id)
self._backfill_chain(conn)
except psycopg.Error as exc: except psycopg.Error as exc:
raise BackendUnavailableError(f"migration failed: {exc}") from exc raise BackendUnavailableError(f"migration failed: {exc}") from exc
return applied return applied
@ -272,9 +288,10 @@ 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 correspondingly False, who can drop the trigger. ``tamper_evidence`` is True because a
because nothing here would *prove* they had. Hash-chaining or external hash chain plus verify detects a rewritten payload, and a chain-head
anchoring would be needed for that, and is not implemented. attestation outside this database detects a suffix rewrite that
stays inside Postgres. It is not 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
@ -286,7 +303,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=False, tamper_evidence=True,
durable=True, durable=True,
recoverable_days=self.recoverable_days, recoverable_days=self.recoverable_days,
recoverable_source=self.recoverable_source, recoverable_source=self.recoverable_source,
@ -302,13 +319,20 @@ class PostgresAuditBackend:
details = event.details if isinstance(event.details, dict) else {} details = event.details if isinstance(event.details, dict) else {}
record = event.as_record() record = event.as_record()
try: try:
with self.pool.connection() as conn: with self.pool.connection() as conn, conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(%s)", (CHAIN_LOCK_KEY,))
head = conn.execute(
f"SELECT chain_hash FROM {self._events} "
"ORDER BY accepted_at DESC, event_id DESC LIMIT 1"
).fetchone()
previous = head[0] if head and head[0] else GENESIS
link = chain_link(previous, payload_hash, event.event_id)
inserted = conn.execute( inserted = conn.execute(
f""" f"""
INSERT INTO {self._events} INSERT INTO {self._events}
(event_id, payload_hash, observed_at, tenant, correlation_id, (event_id, payload_hash, observed_at, tenant, correlation_id,
source, action, record) source, action, record, chain_hash, chain_prev)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (event_id) DO NOTHING ON CONFLICT (event_id) DO NOTHING
RETURNING event_id RETURNING event_id
""", """,
@ -321,6 +345,8 @@ class PostgresAuditBackend:
event.source, event.source,
event.action, event.action,
json.dumps(record, sort_keys=True), json.dumps(record, sort_keys=True),
link,
previous,
), ),
).fetchone() ).fetchone()
if inserted is not None: if inserted is not None:
@ -443,9 +469,69 @@ class PostgresAuditBackend:
# --- lifecycle --------------------------------------------------------- # --- lifecycle ---------------------------------------------------------
def verify_chain(self, attestation: dict | None = None):
rows = self._query(
f"SELECT event_id, payload_hash, chain_hash, chain_prev, accepted_at "
f"FROM {self._events} ORDER BY accepted_at, event_id",
(),
)
return verify_rows(
[
ChainRow(
event_id=r[0],
payload_hash=r[1],
chain_hash=r[2] or "",
chain_prev=r[3] or "",
accepted_at=_iso(r[4]),
)
for r in rows
],
attestation=attestation,
)
def attest_chain(self) -> dict:
return attestation_from_report(self.verify_chain())
def health(self) -> None: def health(self) -> None:
self._query("SELECT 1", ()) self._query("SELECT 1", ())
def _backfill_chain(self, conn) -> None:
"""Fill chain columns on rows accepted before migration 0006.
The append-only trigger must be disabled for this UPDATE. A
database owner can do that; this is a one-time migrate, not a
runtime path.
"""
missing = conn.execute(
f"SELECT count(*) FROM {self._events} "
"WHERE chain_hash IS NULL OR chain_prev IS NULL"
).fetchone()[0]
if missing:
conn.execute(
f'ALTER TABLE {self._events} DISABLE TRIGGER events_append_only'
)
prev = GENESIS
for event_id, payload_hash in conn.execute(
f"SELECT event_id, payload_hash FROM {self._events} "
"ORDER BY accepted_at, event_id"
).fetchall():
link = chain_link(prev, payload_hash, event_id)
conn.execute(
f"UPDATE {self._events} SET chain_prev = %s, chain_hash = %s "
"WHERE event_id = %s",
(prev, link, event_id),
)
prev = link
conn.execute(
f'ALTER TABLE {self._events} ENABLE TRIGGER events_append_only'
)
conn.execute(
f"ALTER TABLE {self._events} ALTER COLUMN chain_hash SET NOT NULL"
)
conn.execute(
f"ALTER TABLE {self._events} ALTER COLUMN chain_prev SET NOT NULL"
)
def close(self) -> None: def close(self) -> None:
self.pool.close() self.pool.close()

View file

@ -22,6 +22,13 @@ from audit_core.interface import (
RetentionPolicy, RetentionPolicy,
validate_event, validate_event,
) )
from audit_core.integrity import (
GENESIS,
ChainRow,
attestation_from_report,
chain_link,
verify_rows,
)
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE IF NOT EXISTS events (
@ -30,7 +37,9 @@ CREATE TABLE IF NOT EXISTS events (
accepted_at TEXT NOT NULL, accepted_at TEXT NOT NULL,
correlation_id TEXT, correlation_id TEXT,
tenant TEXT NOT NULL, tenant TEXT NOT NULL,
record TEXT NOT NULL record TEXT NOT NULL,
chain_hash TEXT,
chain_prev TEXT
); );
CREATE INDEX IF NOT EXISTS events_correlation_idx ON events (correlation_id); CREATE INDEX IF NOT EXISTS events_correlation_idx ON events (correlation_id);
CREATE INDEX IF NOT EXISTS events_tenant_idx ON events (tenant); CREATE INDEX IF NOT EXISTS events_tenant_idx ON events (tenant);
@ -87,6 +96,7 @@ class SQLiteAuditBackend:
self._local = threading.local() self._local = threading.local()
with self._connect_raw() as setup: with self._connect_raw() as setup:
setup.executescript(_SCHEMA) setup.executescript(_SCHEMA)
self._ensure_chain_columns(setup)
def _connect_raw(self) -> sqlite3.Connection: def _connect_raw(self) -> sqlite3.Connection:
try: try:
@ -136,11 +146,18 @@ class SQLiteAuditBackend:
raise BackendUnavailableError(str(exc)) from exc raise BackendUnavailableError(str(exc)) from exc
try: try:
head = db.execute(
"SELECT chain_hash FROM events "
"ORDER BY accepted_at DESC, event_id DESC LIMIT 1"
).fetchone()
previous = head[0] if head and head[0] else GENESIS
link = chain_link(previous, payload_hash, event.event_id)
inserted = db.execute( inserted = db.execute(
""" """
INSERT INTO events INSERT INTO events
(event_id, payload_hash, accepted_at, correlation_id, tenant, record) (event_id, payload_hash, accepted_at, correlation_id, tenant, record,
VALUES (?, ?, ?, ?, ?, ?) chain_hash, chain_prev)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING ON CONFLICT(event_id) DO NOTHING
RETURNING event_id RETURNING event_id
""", """,
@ -151,6 +168,8 @@ class SQLiteAuditBackend:
str(details.get("correlation_id") or "") or None, str(details.get("correlation_id") or "") or None,
event.tenant, event.tenant,
json.dumps(event.as_record(), sort_keys=True), json.dumps(event.as_record(), sort_keys=True),
link,
previous,
), ),
).fetchone() ).fetchone()
existing = None existing = None
@ -305,6 +324,54 @@ class SQLiteAuditBackend:
except sqlite3.Error as exc: except sqlite3.Error as exc:
raise BackendUnavailableError(str(exc)) from exc raise BackendUnavailableError(str(exc)) from exc
def verify_chain(self, attestation: dict | None = None):
rows = self._query(
"SELECT event_id, payload_hash, chain_hash, chain_prev, accepted_at "
"FROM events ORDER BY accepted_at, event_id",
(),
)
return verify_rows(
[
ChainRow(
event_id=r[0],
payload_hash=r[1],
chain_hash=r[2] or "",
chain_prev=r[3] or "",
accepted_at=r[4],
)
for r in rows
],
attestation=attestation,
)
def attest_chain(self) -> dict:
return attestation_from_report(self.verify_chain())
def _ensure_chain_columns(self, db: sqlite3.Connection) -> None:
cols = {row[1] for row in db.execute("PRAGMA table_info(events)")}
if "chain_hash" not in cols:
db.execute("ALTER TABLE events ADD COLUMN chain_hash TEXT")
if "chain_prev" not in cols:
db.execute("ALTER TABLE events ADD COLUMN chain_prev TEXT")
missing = db.execute(
"SELECT event_id, payload_hash FROM events "
"WHERE chain_hash IS NULL OR chain_prev IS NULL "
"ORDER BY accepted_at, event_id"
).fetchall()
if not missing:
return
prev = GENESIS
# Recompute the whole chain so a partial backfill cannot fork.
for event_id, payload_hash in db.execute(
"SELECT event_id, payload_hash FROM events ORDER BY accepted_at, event_id"
):
link = chain_link(prev, payload_hash, event_id)
db.execute(
"UPDATE events SET chain_prev = ?, chain_hash = ? WHERE event_id = ?",
(prev, link, event_id),
)
prev = link
def health(self) -> None: def health(self) -> None:
"""Raise :class:`BackendUnavailableError` if the store is unusable.""" """Raise :class:`BackendUnavailableError` if the store is unusable."""
try: try:

View file

@ -40,7 +40,7 @@
"environment": "production", "environment": "production",
"maturity": "D4", "maturity": "D4",
"implements": "HTTP POST /v1/events into append-only PostgreSQL on platform-pg, namespace audit-core, ClusterIP + default-deny", "implements": "HTTP POST /v1/events into append-only PostgreSQL on platform-pg, namespace audit-core, ClusterIP + default-deny",
"maturity_rationale": "Approved for production dependency since AUDIT-WP-0005. Not D5: tamper_evidence is false (trigger is not a proof), one replica, reliability is not actively controlled.", "maturity_rationale": "Approved for production dependency since AUDIT-WP-0005. Not D5: one replica, reliability is not actively controlled. Integrity is measured (hash chain + verify + external head) but is not WORM.",
"uses_provisions": [ "uses_provisions": [
{ {
"capability": "data.transactional", "capability": "data.transactional",
@ -109,15 +109,17 @@
}, },
{ {
"hook": "integrity_verification", "hook": "integrity_verification",
"basis": "unknown", "basis": "measured",
"gap": "events_append_only rejects UPDATE/DELETE for the runtime role; that is not a proof a database owner did not drop the trigger. Hash-chain or external anchor is not implemented (owner: audit-core). Do not borrow the restore drill for this hook." "value": "hash chain on accept; verify fails after a superuser payload_hash rewrite; chain-head attestation stored outside platform-pg",
"observed_at": "2026-08-16",
"ref": "tests/test_integrity.py#test_rewritten_payload_fails_verify;docs/integrity.md;docs/evidence/chain-head-20260816.json"
} }
] ]
} }
], ],
"open_items": [ "open_items": [
"data.archive is required by INTENT and unprovided. A founder decision is needed before resource-control procures a WORM/object-lock destination distinct from the 30-day Barman bucket.", "data.archive is required by INTENT and unprovided. A founder decision is needed before resource-control procures a WORM/object-lock destination distinct from the 30-day Barman bucket.",
"integrity_verification is unknown. Trigger enforcement is not tamper evidence.", "integrity_verification is measured. A database owner who rewrites the suffix and the external attestation together can still lie; that is the stated proof bound.",
"Class S/H/I consumption is unknown on this provision.", "Class S/H/I consumption is unknown on this provision.",
"Do not emit booked cost or a second usage stream for platform:audit-storage." "Do not emit booked cost or a second usage stream for platform:audit-storage."
] ]

View file

@ -8,7 +8,7 @@ the server port or export a copy before applying.
Apply order is documented in `docs/operator-runbook.md`. Do not apply the Apply order is documented in `docs/operator-runbook.md`. Do not apply the
Deployment until: Deployment until:
1. The image digest is pinned (currently `sha256:05fe1c06…` from commit `40dcadd`). 1. The image digest is pinned (currently `sha256:7febc28e…` from commit `5fd04e2`).
2. Secrets `audit-core-database`, `audit-core-database-migrate`, and 2. Secrets `audit-core-database`, `audit-core-database-migrate`, and
`audit-core-senders` exist. ConfigMap `audit-core-senders-scope` is `audit-core-senders` exist. ConfigMap `audit-core-senders-scope` is
applied (`deploy/senders-scope.yaml`) before the Deployment mounts it. applied (`deploy/senders-scope.yaml`) before the Deployment mounts it.

View file

@ -46,11 +46,11 @@ metadata:
# Rollback position. Update both together; `kubectl rollout undo` returns to # Rollback position. Update both together; `kubectl rollout undo` returns to
# the previous digest, and the schema note records whether that is safe. # the previous digest, and the schema note records whether that is safe.
audit-core.railiance.io/rollback-note: >- audit-core.railiance.io/rollback-note: >-
Migrations 0001-0005 are additive (CREATE TABLE/INDEX/TRIGGER IF NOT Migrations 0001-0006 are additive. 0006 adds chain_hash/chain_prev and
EXISTS, plus GRANTs) and are not reversed by a rollback. An older image then NOT NULL. An image that does not write those columns cannot accept
runs against the newer schema without harm. A future migration that drops events after 0006. Do not roll back past sha256:7febc28e… to a pre-0007
or narrows a column breaks that property and must state its own rollback writer. A future migration that drops or narrows a column must state
position before it is released. its own rollback position before it is released.
spec: spec:
replicas: 1 replicas: 1
revisionHistoryLimit: 5 revisionHistoryLimit: 5
@ -80,7 +80,7 @@ spec:
- name: audit-core - name: audit-core
# REPLACE at release time with the built digest. A mutable tag is not # REPLACE at release time with the built digest. A mutable tag is not
# an immutable image, and `:latest` must never be the only reference. # an immutable image, and `:latest` must never be the only reference.
image: forgejo.coulomb.social/coulomb/audit-core@sha256:05fe1c06f809be29309695c397025da755bfe82afed81a7f9b8771e0b9200c17 image: forgejo.coulomb.social/coulomb/audit-core@sha256:7febc28e8a828dbc245144a38e5728e0fbf496b594dd7591170b450a1265fb10
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: http - name: http

View file

@ -33,7 +33,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: migrate - name: migrate
image: forgejo.coulomb.social/coulomb/audit-core@sha256:05fe1c06f809be29309695c397025da755bfe82afed81a7f9b8771e0b9200c17 image: forgejo.coulomb.social/coulomb/audit-core@sha256:7febc28e8a828dbc245144a38e5728e0fbf496b594dd7591170b450a1265fb10
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: ["python", "-m", "audit_core", "migrate"] command: ["python", "-m", "audit_core", "migrate"]
env: env:

View file

@ -165,7 +165,7 @@ 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`: false (a superuser can drop the trigger; no hash-chain) - `tamper_evidence`: true (hash chain + verify + external head attestation; not WORM)
- `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]`
@ -240,6 +240,7 @@ Archive remains the evidence record; hot search may use shorter `retention_days`
- `INTENT.md` — product purpose and principles - `INTENT.md` — product purpose and principles
- `spec/ProductRequirementsDefinition.md` — full v1 envelope and API requirements - `spec/ProductRequirementsDefinition.md` — full v1 envelope and API requirements
- `docs/integrity.md` — hash chain, proof bound, verify/attest
- `registry/capabilities/capability.audit.event-retain.md` — capability registry entry - `registry/capabilities/capability.audit.event-retain.md` — capability registry entry
## Secret-shaped fields ## Secret-shaped fields

View file

@ -0,0 +1,9 @@
{
"accepted_at": "2026-08-13T15:38:37+00:00",
"chain_hash": "03fd1773c1954e2c39548b0ba297359b9edd739a94ff9b2dcc9529dcd5cf1fcc",
"event_count": 30,
"event_id": "evt_69aad81d75bb49c9b988be7650a5360d",
"genesis": "0000000000000000000000000000000000000000000000000000000000000000",
"observed_at": "2026-08-15T23:23:34+00:00",
"schema": "audit-core.chain-head.v1"
}

73
docs/integrity.md Normal file
View file

@ -0,0 +1,73 @@
# 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-<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.

View file

@ -62,7 +62,7 @@ validation_expectations:
disposition: unmet requirement recorded on the ITC-CAP case; do not build the sink in AUDIT-WP-0006 disposition: unmet requirement recorded on the ITC-CAP case; do not build the sink in AUDIT-WP-0006
- id: tamper-evidence-false - id: tamper-evidence-false
owner: audit-core owner: audit-core
disposition: integrity_verification hook is unknown; trigger is not a proof disposition: closed by AUDIT-WP-0007; proof bound is in docs/integrity.md
- id: no-hash-chain - id: no-hash-chain
owner: audit-core owner: audit-core
disposition: INTENT residual, not this workplan disposition: INTENT residual, not this workplan
@ -92,7 +92,7 @@ consumer_needs:
feedback: [] feedback: []
known_deviations: known_deviations:
- no data.archive sink - no data.archive sink
- tamper_evidence=False - tamper_evidence is a hash chain plus external head, not WORM
- no hash-chain - no hash-chain
- single sender user-engine - single sender user-engine
- no rapp.yaml (not a rapp-* repo) - no rapp.yaml (not a rapp-* repo)

View file

@ -29,8 +29,9 @@ warden route show database-dynamic-credentials --json
| Check | Meaning | | Check | Meaning |
| --- | --- | | --- | --- |
| `GET /healthz` | Process is up. Liveness uses this. A database outage must **not** restart the pod. | | `GET /healthz` | Process is up. Liveness uses this. A database outage must **not** restart the pod. |
| `GET /readyz` | Custody is reachable and `custody_class=operational`. Also reports `recoverable_days` (cited platform backup window). Readiness uses this; the pod leaves the Service rather than accept events it cannot store. | | `GET /readyz` | Custody is reachable and `custody_class=operational`. Also reports `recoverable_days` and `tamper_evidence`. Readiness uses this; the pod leaves the Service rather than accept events it cannot store. |
| `GET /v1/stats` | In-process counters since start (`accepted`, `duplicate`, `conflict`, `rejected`, `unauthorized`, `forbidden`, `unavailable`, `error`). Resets on restart. Requires `may_read`. | | `GET /v1/stats` | In-process counters since start (`accepted`, `duplicate`, `conflict`, `rejected`, `unauthorized`, `forbidden`, `unavailable`, `error`). Resets on restart. Requires `may_read`. |
| `GET /v1/integrity` | Hash-chain walk: `{intact, events, head, first_break}`. No payloads. Requires `may_read`. A break is a custody defect, not a sender retry. |
A missing `AUDIT_CORE_DATABASE_URL` / credential directory is a startup A missing `AUDIT_CORE_DATABASE_URL` / credential directory is a startup
failure (`AUDIT_CORE_REQUIRE_CUSTODY_CLASS=operational`), not a silent failure (`AUDIT_CORE_REQUIRE_CUSTODY_CLASS=operational`), not a silent
@ -40,7 +41,20 @@ accepted as an alias for one mixed rollout.
After a node reboot, `/readyz` failing for tens of seconds is expected: After a node reboot, `/readyz` failing for tens of seconds is expected:
CoreDNS and `platform-pg` come up after the receiver. Liveness stays on CoreDNS and `platform-pg` come up after the receiver. Liveness stays on
`/healthz` so that window does not restart the pod. Walked 2026-08-16 `/healthz` so that window does not restart the pod. Walked 2026-08-16
(~40s unreadiness, then Ready). (~40s unreadiness, then Ready). That window is not a chain break.
## Integrity
Each accept extends a single hash chain for the schema (not per tenant).
`python -m audit_core verify-chain` exits 0 only if the walk is intact.
`python -m audit_core attest-chain --output docs/evidence/chain-head-<ts>.json`
writes the live head **outside** `platform-pg`. Do not put that file in
the Barman prefix; a second copy may follow the RESOURCE-WP-0002-T06
logical-offsite path. `verify-chain --against <file>` fails if the cited
head is not in the live chain.
A break is a custody defect. Do not "fix" it by accepting the same event
again. Reboot unreadiness is unrelated.
## Lookup ## Lookup

View file

@ -23,7 +23,6 @@ external_evidence:
- recovery cited to the live platform data.backup provision - recovery cited to the live platform data.backup provision
broken_expectations: broken_expectations:
- data.archive sink not provided - data.archive sink not provided
- tamper evidence not implemented
out_of_scope_expectations: out_of_scope_expectations:
- application business audit semantics ownership - application business audit semantics ownership
- booked-cost origination - booked-cost origination
@ -33,7 +32,6 @@ external_evidence:
basis: failure_matrix_and_restore_walk basis: failure_matrix_and_restore_walk
known_reliability_risks: known_reliability_risks:
- single replica - single replica
- integrity_verification hook unmet
discovery: discovery:
intent: > intent: >

View file

@ -201,11 +201,57 @@ def test_health_passes_on_a_live_backend(backend):
backend.health() backend.health()
def test_chain_links_and_verify_is_clean(backend):
from audit_core.integrity import GENESIS
first = make_event("chain-1")
second = make_event("chain-2")
backend.accept(first, digest(first))
backend.accept(second, digest(second))
report = backend.verify_chain()
assert report.intact is True
assert report.events >= 2
assert report.first_break is None
assert report.head != GENESIS
replay = backend.accept(first, digest(first))
assert replay.duplicate is True
assert backend.verify_chain().events == report.events
# --- postgres-specific guarantees ------------------------------------------- # --- postgres-specific guarantees -------------------------------------------
pg_only = pytest.mark.skipif(not HAVE_PG, reason="needs PostgreSQL") pg_only = pytest.mark.skipif(not HAVE_PG, reason="needs PostgreSQL")
@pg_only
def test_rewritten_payload_fails_verify_postgres():
"""Superuser rewrite is the evidence the trigger never gave us."""
from audit_core.postgres_backend import PostgresAuditBackend
schema = f"conf_{uuid.uuid4().hex[:12]}"
backend = PostgresAuditBackend(PG_URL, schema=schema)
try:
event = make_event("break-1")
backend.accept(event, digest(event))
other = make_event("break-2")
backend.accept(other, digest(other))
assert backend.verify_chain().intact is True
with backend.pool.connection() as conn:
conn.execute(f'ALTER TABLE "{schema}".events DISABLE TRIGGER events_append_only')
conn.execute(
f'UPDATE "{schema}".events SET payload_hash = %s WHERE event_id = %s',
("deadbeef" * 8, "break-2"),
)
conn.execute(f'ALTER TABLE "{schema}".events ENABLE TRIGGER events_append_only')
report = backend.verify_chain()
assert report.intact is False
assert report.first_break == "break-2"
finally:
with backend.pool.connection() as conn:
conn.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
backend.close()
@pg_only @pg_only
def test_replay_reconciles_rather_than_duplicating(): def test_replay_reconciles_rather_than_duplicating():
"""Replay must never mint a second custody record for one source event.""" """Replay must never mint a second custody record for one source event."""

View file

@ -24,6 +24,8 @@ def test_capability_record_exists_and_joins_operations_audit():
unknown = [row for row in audit["consumes"] if row["basis"] == "unknown"] unknown = [row for row in audit["consumes"] if row["basis"] == "unknown"]
assert unknown assert unknown
assert all(row["quantity"]["value"] is None and row.get("gap") for row in unknown) assert all(row["quantity"]["value"] is None and row.get("gap") for row in unknown)
hooks = {item["hook"]: item["basis"] for item in audit["evidence"]}
assert hooks["integrity_verification"] == "measured"
def test_capability_review_against_live_catalog(): def test_capability_review_against_live_catalog():

View file

@ -4,6 +4,7 @@ import types
from audit_core.cli import build_parser from audit_core.cli import build_parser
from audit_core.ingestion import build_backend from audit_core.ingestion import build_backend
from audit_core.integrity import ChainReport, GENESIS
from audit_core.interface import AcceptResult, RetentionPolicy from audit_core.interface import AcceptResult, RetentionPolicy
@ -36,6 +37,16 @@ class _FakePostgres:
def close(self): def close(self):
self.closed = True self.closed = True
def verify_chain(self, attestation=None):
return ChainReport(
intact=True,
events=0,
head=GENESIS,
head_event_id=None,
head_accepted_at=None,
first_break=None,
)
@property @property
def retention_policy(self): def retention_policy(self):
return RetentionPolicy( return RetentionPolicy(
@ -109,6 +120,19 @@ def test_replay_command_reconciles(monkeypatch, capsys):
assert body["reference"] == "audit:evt-1" assert body["reference"] == "audit:evt-1"
def test_verify_chain_command(monkeypatch, capsys):
def fake(dsn, **kwargs):
return _FakePostgres(dsn, **kwargs)
monkeypatch.setenv("AUDIT_CORE_DATABASE_URL", "postgresql://example/audit_core")
_install_fake_postgres(monkeypatch, fake)
args = build_parser().parse_args(["verify-chain", "--schema", "audit_core"])
assert args.func(args) == 0
body = json.loads(capsys.readouterr().out)
assert body["intact"] is True
assert body["head"] == GENESIS
def test_replay_command_missing_event(monkeypatch, capsys): def test_replay_command_missing_event(monkeypatch, capsys):
def fake(dsn, **kwargs): def fake(dsn, **kwargs):
return _FakePostgres(dsn, **kwargs) return _FakePostgres(dsn, **kwargs)

139
tests/test_integrity.py Normal file
View file

@ -0,0 +1,139 @@
import json
from audit_core.ingestion import IngestionApplication
from audit_core.integrity import GENESIS, chain_link, load_attestation, write_attestation
from audit_core.interface import AuditEvent
from audit_core.sqlite_backend import SQLiteAuditBackend
from test_ingestion import invoke
def _event(event_id: str, **kw) -> AuditEvent:
fields = dict(
event_id=event_id,
source="user-engine",
action="membership.added",
resource="membership-1",
outcome="recorded",
tenant="tenant:friendly:binky",
scope="tenant",
details={"correlation_id": "corr-1"},
observed_at="2026-08-09T00:00:00+00:00",
)
fields.update(kw)
return AuditEvent(**fields)
def _digest(event: AuditEvent) -> str:
import hashlib
return hashlib.sha256(
json.dumps(event.as_record(), sort_keys=True).encode()
).hexdigest()
def test_first_accept_sets_genesis(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
event = _event("e1")
backend.accept(event, _digest(event))
row = backend.db.execute(
"SELECT chain_prev, chain_hash FROM events WHERE event_id = 'e1'"
).fetchone()
assert row[0] == GENESIS
assert row[1] == chain_link(GENESIS, _digest(event), "e1")
def test_second_accept_links(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
first = _event("e1")
second = _event("e2")
backend.accept(first, _digest(first))
backend.accept(second, _digest(second))
head = backend.db.execute(
"SELECT chain_hash FROM events WHERE event_id = 'e1'"
).fetchone()[0]
prev = backend.db.execute(
"SELECT chain_prev FROM events WHERE event_id = 'e2'"
).fetchone()[0]
assert prev == head
def test_duplicate_does_not_fork(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
event = _event("e1")
digest = _digest(event)
assert backend.accept(event, digest).duplicate is False
assert backend.accept(event, digest).duplicate is True
count = backend.db.execute("SELECT count(*) FROM events").fetchone()[0]
assert count == 1
assert backend.verify_chain().events == 1
def test_verify_clean_on_fresh_store(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
empty = backend.verify_chain()
assert empty.intact is True
assert empty.events == 0
assert empty.head == GENESIS
first = _event("e1")
second = _event("e2")
backend.accept(first, _digest(first))
backend.accept(second, _digest(second))
report = backend.verify_chain()
assert report.intact is True
assert report.events == 2
assert report.first_break is None
def test_rewritten_payload_fails_verify(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
first = _event("e1")
second = _event("e2")
backend.accept(first, _digest(first))
backend.accept(second, _digest(second))
backend.db.execute("UPDATE events SET payload_hash = 'deadbeef' WHERE event_id = 'e2'")
report = backend.verify_chain()
assert report.intact is False
assert report.first_break == "e2"
def test_attestation_mismatch(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
event = _event("e1")
backend.accept(event, _digest(event))
path = tmp_path / "head.json"
write_attestation(path, backend.verify_chain())
cited = load_attestation(path)
assert backend.verify_chain(cited).attestation_match is True
cited["chain_hash"] = "f" * 64
broken = backend.verify_chain(cited)
assert broken.intact is False
assert broken.first_break == "attestation_mismatch"
assert broken.attestation_match is False
def test_attestation_still_matches_after_growth(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
first = _event("e1")
backend.accept(first, _digest(first))
cited = backend.attest_chain()
second = _event("e2")
backend.accept(second, _digest(second))
report = backend.verify_chain(cited)
assert report.intact is True
assert report.attestation_match is True
assert report.events == 2
def test_http_integrity_requires_read(tmp_path):
backend = SQLiteAuditBackend(str(tmp_path / "c.db"))
app = IngestionApplication(backend, "opaque")
status, _ = invoke(app, None, path="/v1/integrity", method="GET", body=b"")
assert status.startswith("200")
event = _event("e1")
backend.accept(event, _digest(event))
status, body = invoke(app, None, path="/v1/integrity", method="GET", body=b"")
assert status.startswith("200")
assert body["intact"] is True
assert body["events"] == 1
assert "record" not in body

View file

@ -4,7 +4,7 @@ type: workplan
title: "Integrity verification for operational custody" title: "Integrity verification for operational custody"
domain: infotech domain: infotech
repo: audit-core repo: audit-core
status: ready status: finished
owner: grok owner: grok
topic_slug: railiance topic_slug: railiance
created: "2026-08-16" created: "2026-08-16"
@ -65,7 +65,7 @@ remaining honesty gap on the live provision and is owned here.
```task ```task
id: AUDIT-WP-0007-T01 id: AUDIT-WP-0007-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "d4423fd5-ad78-47d0-b85e-7ae6c582ec2b" state_hub_task_id: "d4423fd5-ad78-47d0-b85e-7ae6c582ec2b"
``` ```
@ -89,11 +89,14 @@ Write a short contract in `docs/audit-backend-contract.md` (or a sibling
Done when the contract is written and the tests to be added are named. Done when the contract is written and the tests to be added are named.
Done 2026-08-16: `docs/integrity.md` names the chain, genesis, proof bound,
and the six tests.
## T02 — Persist the chain on accept ## T02 — Persist the chain on accept
```task ```task
id: AUDIT-WP-0007-T02 id: AUDIT-WP-0007-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "5830bb1a-27b7-4eed-b09f-75ba8cc9f7f1" state_hub_task_id: "5830bb1a-27b7-4eed-b09f-75ba8cc9f7f1"
``` ```
@ -112,11 +115,14 @@ suite. Mock file backend stays `tamper_evidence=False`.
Done when conformance tests show: first accept sets genesis; second Done when conformance tests show: first accept sets genesis; second
links; duplicate does not fork; verify is clean on a fresh store. links; duplicate does not fork; verify is clean on a fresh store.
Done 2026-08-16: migration 0006 plus backfill; SQLite and Postgres accept
write the next link under a lock. Conformance + `tests/test_integrity.py`.
## T03 — Verify surface ## T03 — Verify surface
```task ```task
id: AUDIT-WP-0007-T03 id: AUDIT-WP-0007-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "fee1f93c-dcac-4fc3-9e41-17bb4436e8d6" state_hub_task_id: "fee1f93c-dcac-4fc3-9e41-17bb4436e8d6"
``` ```
@ -133,11 +139,14 @@ alone never gave us.
Done when CLI and HTTP agree, and the broken-row test is red-then-green Done when CLI and HTTP agree, and the broken-row test is red-then-green
as a detector, not as a repair. as a detector, not as a repair.
Done 2026-08-16: `verify-chain` CLI, `GET /v1/integrity`, SQLite rewrite
test and Postgres superuser rewrite test both fail verify.
## T04 — Attest the chain head outside platform-pg ## T04 — Attest the chain head outside platform-pg
```task ```task
id: AUDIT-WP-0007-T04 id: AUDIT-WP-0007-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "b6d03d6a-0605-4982-a9a3-e18c89319ba9" state_hub_task_id: "b6d03d6a-0605-4982-a9a3-e18c89319ba9"
``` ```
@ -158,11 +167,17 @@ and report mismatch as a break.
Done when one production walk produces an attestation file in Done when one production walk produces an attestation file in
`docs/evidence/` and verify-against-attestation is tested. `docs/evidence/` and verify-against-attestation is tested.
Done 2026-08-16: live walk 30 events, intact, head
`03fd1773…`. Evidence
`docs/evidence/chain-head-20260816.json`. Verify-against matched.
Do not copy this file into the Barman prefix; a second copy may follow
RESOURCE-WP-0002-T06 logical-offsite.
## T05 — Declare tamper_evidence only as far as the proof ## T05 — Declare tamper_evidence only as far as the proof
```task ```task
id: AUDIT-WP-0007-T05 id: AUDIT-WP-0007-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "9fd70d3c-5a64-4b26-967f-4ef45394d81c" state_hub_task_id: "9fd70d3c-5a64-4b26-967f-4ef45394d81c"
``` ```
@ -182,6 +197,10 @@ After T03 and T04:
Done when capability-review still `ok` and the hook is no longer Done when capability-review still `ok` and the hook is no longer
`unknown`. `unknown`.
Done 2026-08-16: Postgres `tamper_evidence=True`; live `/readyz` reports
it; capability-review `ok`; `integrity_verification` is `measured`.
Maturity stays D4. `data.archive` stays unmet.
## Acceptance ## Acceptance
- A rewritten stored payload makes `verify` fail. - A rewritten stored payload makes `verify` fail.