Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
"""Move audit records from a SQLite store into PostgreSQL (AUDIT-WP-0005-T04).
|
|
|
|
|
|
|
|
|
|
As of 2026-08-10 there are no pre-production records to move: audit-core was
|
|
|
|
|
never deployed, so the only SQLite stores that ever existed were test
|
|
|
|
|
fixtures. See the workplan for that finding.
|
|
|
|
|
|
|
|
|
|
The tool exists anyway because the SQLite path is still reachable — the
|
|
|
|
|
entrypoint falls back to it when ``AUDIT_CORE_DATABASE_URL`` is unset. If that
|
|
|
|
|
fallback is ever used in anger, the records it accumulates are audit records
|
|
|
|
|
and cannot simply be dropped.
|
|
|
|
|
|
|
|
|
|
Fidelity is the whole point. Records are transferred with their original
|
|
|
|
|
``event_id``, ``payload_hash``, and ``accepted_at`` intact; a migration that
|
|
|
|
|
rewrote acceptance times would destroy the evidence it was meant to preserve.
|
|
|
|
|
That is why this bypasses ``accept()``, which stamps ``accepted_at`` with the
|
|
|
|
|
current time.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-10-04 11:36:06 +02:00
|
|
|
import hashlib
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
import json
|
|
|
|
|
import sqlite3
|
|
|
|
|
from dataclasses import dataclass, field
|
2026-10-04 11:36:06 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from audit_core.integrity import CHAIN_LOCK_KEY, chain_link
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
|
|
|
|
|
from audit_core.interface import BackendUnavailableError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class MigrationReport:
|
|
|
|
|
"""What happened, in enough detail to be evidence."""
|
|
|
|
|
|
|
|
|
|
events_read: int = 0
|
|
|
|
|
events_written: int = 0
|
|
|
|
|
events_already_present: int = 0
|
|
|
|
|
dead_letters_written: int = 0
|
|
|
|
|
findings_written: int = 0
|
|
|
|
|
conflicts: list[str] = field(default_factory=list)
|
|
|
|
|
verified: bool = False
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def ok(self) -> bool:
|
|
|
|
|
return not self.conflicts and self.verified
|
|
|
|
|
|
|
|
|
|
def summary(self) -> str:
|
|
|
|
|
lines = [
|
|
|
|
|
f"events read: {self.events_read}",
|
|
|
|
|
f"events written: {self.events_written}",
|
|
|
|
|
f"already present: {self.events_already_present}",
|
|
|
|
|
f"dead letters written: {self.dead_letters_written}",
|
|
|
|
|
f"secret findings written:{self.findings_written}",
|
|
|
|
|
f"verified: {self.verified}",
|
|
|
|
|
]
|
|
|
|
|
if self.conflicts:
|
|
|
|
|
lines.append(f"CONFLICTS ({len(self.conflicts)}):")
|
|
|
|
|
lines += [f" - {c}" for c in self.conflicts]
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 11:36:06 +02:00
|
|
|
def migrate(
|
|
|
|
|
sqlite_path: str, backend, *, verify: bool = True, source_id: str | None = None
|
|
|
|
|
) -> MigrationReport:
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
"""Copy every record from ``sqlite_path`` into ``backend``.
|
|
|
|
|
|
2026-10-04 11:36:06 +02:00
|
|
|
Idempotent: re-running skips events with matching hash, time and record.
|
|
|
|
|
An event present with different historical evidence is reported as a conflict
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
and never overwritten — the destination record wins, and a human decides.
|
|
|
|
|
"""
|
2026-10-04 11:36:06 +02:00
|
|
|
import psycopg
|
|
|
|
|
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
report = MigrationReport()
|
2026-10-04 11:36:06 +02:00
|
|
|
# Callers moving a source to a new path must retain its explicit source_id.
|
|
|
|
|
source_id = source_id or str(Path(sqlite_path).resolve())
|
|
|
|
|
source_id = hashlib.sha256(source_id.encode()).hexdigest()
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
source = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
|
|
|
|
try:
|
2026-10-04 11:36:06 +02:00
|
|
|
source.execute("BEGIN") # One consistent source snapshot, including counters.
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
rows = source.execute(
|
|
|
|
|
"SELECT event_id, payload_hash, accepted_at, correlation_id, tenant, record "
|
|
|
|
|
"FROM events ORDER BY accepted_at, event_id"
|
|
|
|
|
).fetchall()
|
|
|
|
|
report.events_read = len(rows)
|
|
|
|
|
|
2026-10-04 11:36:06 +02:00
|
|
|
# Same serialization boundary as native accept(), including side-table
|
|
|
|
|
# receipts. A retry cannot double-count after an interrupted import.
|
|
|
|
|
with backend.pool.connection() as conn, conn.transaction():
|
|
|
|
|
conn.execute("SELECT pg_advisory_xact_lock(%s)", (CHAIN_LOCK_KEY,))
|
|
|
|
|
for event_id, payload_hash, accepted_at, correlation_id, tenant, record in rows:
|
|
|
|
|
outcome = _import_event(
|
|
|
|
|
backend, conn, event_id, payload_hash, accepted_at, correlation_id,
|
|
|
|
|
tenant, json.loads(record),
|
|
|
|
|
)
|
|
|
|
|
if outcome == "written":
|
|
|
|
|
report.events_written += 1
|
|
|
|
|
elif outcome == "present":
|
|
|
|
|
report.events_already_present += 1
|
|
|
|
|
else:
|
|
|
|
|
report.conflicts.append(outcome)
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
|
2026-10-04 11:36:06 +02:00
|
|
|
report.dead_letters_written = _copy_dead_letters(source, backend, conn, source_id, report)
|
|
|
|
|
report.findings_written = _copy_findings(source, backend, conn, source_id, report)
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
|
|
|
|
|
if verify:
|
|
|
|
|
report.verified = _verify(source, backend, report)
|
2026-10-04 11:36:06 +02:00
|
|
|
if not backend.verify_chain().intact:
|
|
|
|
|
report.conflicts.append("destination hash chain is not intact")
|
|
|
|
|
report.verified = False
|
|
|
|
|
except psycopg.Error as exc:
|
|
|
|
|
raise BackendUnavailableError("SQLite import transaction failed") from exc
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
finally:
|
|
|
|
|
source.close()
|
|
|
|
|
return report
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _import_event(
|
2026-10-04 11:36:06 +02:00
|
|
|
backend, conn, event_id, payload_hash, accepted_at, correlation_id, tenant, record
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
) -> str:
|
2026-10-04 11:36:06 +02:00
|
|
|
existing = conn.execute(
|
|
|
|
|
f"SELECT payload_hash, accepted_at, record FROM {backend._events} WHERE event_id = %s",
|
|
|
|
|
(event_id,),
|
|
|
|
|
).fetchone()
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
if existing:
|
2026-10-04 11:36:06 +02:00
|
|
|
if existing[0] != payload_hash:
|
|
|
|
|
return f"{event_id}: destination holds a different payload — left untouched"
|
|
|
|
|
if not _same_instant(existing[1], accepted_at) or existing[2] != record:
|
|
|
|
|
return f"{event_id}: destination holds different historical evidence — left untouched"
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
return "present"
|
|
|
|
|
|
2026-10-04 11:36:06 +02:00
|
|
|
previous = backend._chain_head(conn)
|
|
|
|
|
conn.execute(
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
f"INSERT INTO {backend._events} "
|
|
|
|
|
"(event_id, payload_hash, accepted_at, observed_at, tenant, correlation_id, "
|
2026-10-04 11:36:06 +02:00
|
|
|
" source, action, record, chain_prev, chain_hash) "
|
|
|
|
|
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
(
|
|
|
|
|
event_id, payload_hash, accepted_at, record.get("observed_at"),
|
|
|
|
|
tenant, correlation_id, record.get("source", ""), record.get("action", ""),
|
2026-10-04 11:36:06 +02:00
|
|
|
json.dumps(record, sort_keys=True), previous,
|
|
|
|
|
chain_link(previous, payload_hash, event_id),
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
return "written"
|
|
|
|
|
|
|
|
|
|
|
2026-10-04 11:36:06 +02:00
|
|
|
def _fingerprint(value) -> str:
|
|
|
|
|
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _receipt(backend, conn, source_id, kind, record_id):
|
|
|
|
|
return conn.execute(
|
|
|
|
|
f'SELECT content_hash, occurrences FROM "{backend.schema}".import_receipts '
|
|
|
|
|
"WHERE source_id = %s AND kind = %s AND record_id = %s",
|
|
|
|
|
(source_id, kind, record_id),
|
|
|
|
|
).fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _save_receipt(backend, conn, source_id, kind, record_id, content_hash, occurrences=0):
|
|
|
|
|
conn.execute(
|
|
|
|
|
f'INSERT INTO "{backend.schema}".import_receipts '
|
|
|
|
|
"(source_id, kind, record_id, content_hash, occurrences) VALUES (%s,%s,%s,%s,%s) "
|
|
|
|
|
"ON CONFLICT (source_id, kind, record_id) DO UPDATE "
|
|
|
|
|
"SET content_hash = excluded.content_hash, occurrences = excluded.occurrences",
|
|
|
|
|
(source_id, kind, record_id, content_hash, occurrences),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _copy_dead_letters(source, backend, conn, source_id, report) -> int:
|
|
|
|
|
if not source.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dead_letters'").fetchone():
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
return 0
|
2026-10-04 11:36:06 +02:00
|
|
|
rows = source.execute(
|
|
|
|
|
"SELECT id, event_id, received_at, sender, reason, payload_hash, payload, "
|
|
|
|
|
"payload_withheld FROM dead_letters ORDER BY id"
|
|
|
|
|
).fetchall()
|
|
|
|
|
written = 0
|
|
|
|
|
for row_id, *data in rows:
|
|
|
|
|
record_id = str(row_id)
|
|
|
|
|
fingerprint = _fingerprint(data)
|
|
|
|
|
receipt = _receipt(backend, conn, source_id, "dead-letter", record_id)
|
|
|
|
|
if receipt:
|
|
|
|
|
if receipt[0] != fingerprint:
|
|
|
|
|
report.conflicts.append(f"dead-letter {record_id}: source changed after import")
|
|
|
|
|
continue
|
|
|
|
|
conn.execute(
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
f'INSERT INTO "{backend.schema}".dead_letters '
|
|
|
|
|
"(event_id, received_at, sender, reason, payload_hash, payload, payload_withheld) "
|
|
|
|
|
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
2026-10-04 11:36:06 +02:00
|
|
|
(*data[:-1], bool(data[-1])),
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
)
|
2026-10-04 11:36:06 +02:00
|
|
|
_save_receipt(backend, conn, source_id, "dead-letter", record_id, fingerprint)
|
|
|
|
|
written += 1
|
|
|
|
|
return written
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
|
|
|
|
|
|
2026-10-04 11:36:06 +02:00
|
|
|
def _copy_findings(source, backend, conn, source_id, report) -> int:
|
|
|
|
|
if not source.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='secret_findings'").fetchone():
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
return 0
|
2026-10-04 11:36:06 +02:00
|
|
|
rows = source.execute(
|
|
|
|
|
"SELECT sender, source, action, field_path, outcome, persisted, "
|
|
|
|
|
"occurrences, first_seen, last_seen FROM secret_findings"
|
|
|
|
|
).fetchall()
|
|
|
|
|
written = 0
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
for r in rows:
|
2026-10-04 11:36:06 +02:00
|
|
|
record_id = _fingerprint(r[:5])
|
|
|
|
|
fingerprint = _fingerprint(r)
|
|
|
|
|
receipt = _receipt(backend, conn, source_id, "finding", record_id)
|
|
|
|
|
if receipt and receipt[0] == fingerprint:
|
|
|
|
|
continue
|
|
|
|
|
delta = r[6] - (receipt[1] if receipt else 0)
|
|
|
|
|
if delta < 0:
|
|
|
|
|
report.conflicts.append(f"finding {record_id}: source occurrence count decreased")
|
|
|
|
|
continue
|
|
|
|
|
conn.execute(
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
f'INSERT INTO "{backend.schema}".secret_findings '
|
|
|
|
|
"(sender, source, action, field_path, outcome, persisted, occurrences, "
|
|
|
|
|
" first_seen, last_seen) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) "
|
|
|
|
|
"ON CONFLICT (sender, source, action, field_path, outcome) DO UPDATE "
|
2026-10-04 11:36:06 +02:00
|
|
|
"SET occurrences = secret_findings.occurrences + excluded.occurrences, "
|
|
|
|
|
"persisted = secret_findings.persisted OR excluded.persisted, "
|
|
|
|
|
"first_seen = LEAST(secret_findings.first_seen, excluded.first_seen), "
|
|
|
|
|
"last_seen = GREATEST(secret_findings.last_seen, excluded.last_seen)",
|
|
|
|
|
(r[0], r[1], r[2], r[3], r[4], bool(r[5]), delta, r[7], r[8]),
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
)
|
2026-10-04 11:36:06 +02:00
|
|
|
_save_receipt(backend, conn, source_id, "finding", record_id, fingerprint, r[6])
|
|
|
|
|
written += 1
|
|
|
|
|
return written
|
Establish pre-production record disposition and add the migration tool
AUDIT-WP-0005-T04.
Disposition: there are no pre-production records. No audit-core SQLite store
on this host, no mock-file-backend output, and no audit-core pod, deployment
or PVC on railiance01 - the only audit-* PVC there is OpenBao's own audit
device. Consistent with the history: WP-0003-T03 was cancelled before the
receiver was ever deployed, so every SQLite store that has existed was a test
fixture. Nothing is being discarded because nothing was ever accepted outside
tests.
The tool is built anyway because the SQLite path stays reachable - the
entrypoint falls back to it when AUDIT_CORE_DATABASE_URL is unset. If that
fallback is ever used in anger the records are audit records, and writing the
migration afterwards under pressure is the wrong time.
audit_core.migrate_store and `python -m audit_core migrate-store` transfer
events, dead letters and secret-finding counters. Records keep their original
event_id, payload_hash and accepted_at, which is why this bypasses accept():
that stamps acceptance with the current time, and a migration that rewrote
acceptance times would destroy the evidence it exists to preserve.
Idempotent, and verification reads back from the destination rather than
trusting the write path. A destination record with a differing payload hash is
reported as a conflict and left untouched - silently overwriting a stored audit
record is the same class of failure as losing it. Conflicts and failed
verification exit non-zero; a partial migration is not a success.
Tests 71 -> 77.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:31:18 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify(source: sqlite3.Connection, backend, report: MigrationReport) -> bool:
|
|
|
|
|
"""Confirm every source event is present with its hash and time intact.
|
|
|
|
|
|
|
|
|
|
Verification reads back rather than trusting the write path: a migration
|
|
|
|
|
that reports success without checking is the same as no migration at all.
|
|
|
|
|
"""
|
|
|
|
|
rows = source.execute("SELECT event_id, payload_hash, accepted_at FROM events").fetchall()
|
|
|
|
|
for event_id, payload_hash, accepted_at in rows:
|
|
|
|
|
found = backend._query(
|
|
|
|
|
f"SELECT payload_hash, accepted_at FROM {backend._events} WHERE event_id = %s",
|
|
|
|
|
(event_id,),
|
|
|
|
|
)
|
|
|
|
|
if not found:
|
|
|
|
|
report.conflicts.append(f"{event_id}: missing from destination after migration")
|
|
|
|
|
return False
|
|
|
|
|
if found[0][0] != payload_hash:
|
|
|
|
|
report.conflicts.append(f"{event_id}: payload hash differs after migration")
|
|
|
|
|
return False
|
|
|
|
|
if not _same_instant(found[0][1], accepted_at):
|
|
|
|
|
report.conflicts.append(
|
|
|
|
|
f"{event_id}: accepted_at not preserved "
|
|
|
|
|
f"(source {accepted_at}, destination {found[0][1]})"
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _same_instant(destination, source_iso: str) -> bool:
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
want = datetime.fromisoformat(str(source_iso).replace("Z", "+00:00"))
|
|
|
|
|
except ValueError:
|
|
|
|
|
return False
|
|
|
|
|
if not hasattr(destination, "timestamp"):
|
|
|
|
|
return False
|
|
|
|
|
if want.tzinfo is None or destination.tzinfo is None:
|
|
|
|
|
return False
|
2026-10-04 11:36:06 +02:00
|
|
|
return destination == want
|