Skip to content
75 changes: 75 additions & 0 deletions docs/adr/0019-postgres-physical-pitr-profile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ADR 0019: Bind a caller-owned physical/WAL/PITR recovery profile

- **Status:** Accepted for the bounded physical/PITR profile seam
- **Date:** 2026-08-16

## Context

Protected main already records content-free logical-recovery receipts and
artifact/schema integrity evidence. Issue #204 still requires a separately
reviewed physical/WAL/PITR profile. A buyer who can dump and restore SQL still
cannot state, in machine-readable form, whether recovery is crash-consistent
only or can follow time.

PostgreSQL continuous archiving uses a base backup plus a WAL archive and a
recovery target (`immediate`, time, transaction identifier, named restore
point, or LSN). Those choices are deployer-owned. The package must not execute
`pg_basebackup`, invent an RPO/RTO, or treat a physical base backup as a
point-in-time restore.

## Decision

`bind_postgres_physical_recovery_profile()` records one caller-owned profile:

- `backup_method` is exactly `physical` or `pitr`.
- `recovery_target_kind` is exactly `immediate`, `time`, `xid`, `name`, or `lsn`.
- Point-in-time kinds require `backup_method="pitr"`.
- `pitr` requires `wal_archive_required=True`.
- `wal_archive_required=False` on a physical+immediate profile means no
continuous WAL archive. It does not waive backup-internal WAL required to
reach crash consistency (`pg_basebackup -X stream` or `-X fetch`).
- `immediate` is end-of-backup consistency. Replay-to-end-of-archive is the
absence of a `recovery_target*` setting and is not a recorded kind.
- `isolated_target_prepared` must be the exact boolean `True`.
- Optional `rpo_seconds` and `rto_seconds` are deployer-selected objectives.
- Emitted evidence always sets `package_capability_claim` to `False`.

The seam does not receive archive paths, DSNs, WAL locations, or restore
commands, and it does not start PostgreSQL tools.

```mermaid
flowchart TD
A[Caller asserts isolated target] --> B{Method}
B -->|physical| C[immediate target only]
B -->|pitr| D[WAL archive required]
D --> E[time / xid / name / lsn / immediate]
C --> F[Profile JSON with capability_claim false]
E --> F
F --> G[Operator runs pg_basebackup / restore_command outside this package]
```

## Consequences

Hosts can persist a reviewed physical/PITR contract next to #205 receipts
without claiming that this package completed backup, restore, CSAP, or SOC 2.
Logical restore (#208/#212) remains a separate executor. Live WAL replay and
base-backup execution remain later #204 slices.

## Rollback

Delete the profile module, tests, and this decision record. No schema
migration is required.

## References

Swanson, M., Bowen, P., Phillips, A., Gallup, D., & Lynes, D. (2010).
*Contingency planning guide for federal information systems* (NIST Special
Publication 800-34 Rev. 1). National Institute of Standards and Technology.
https://doi.org/10.6028/NIST.SP.800-34r1

The PostgreSQL Global Development Group. (2026). *Continuous archiving and
point-in-time recovery (PITR)*. PostgreSQL 18 documentation.
https://www.postgresql.org/docs/18/continuous-archiving.html

The PostgreSQL Global Development Group. (2026). *pg_basebackup*. PostgreSQL 18
documentation. https://www.postgresql.org/docs/18/app-pgbasebackup.html
85 changes: 85 additions & 0 deletions docs/doctoring/postgres-physical-pitr-profile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# PostgreSQL physical and PITR recovery profile

This record is the operator contract for
`bind_postgres_physical_recovery_profile()`. Use it before you claim that a
deployment can recover through WAL replay. The function binds evidence. It does
not run `pg_basebackup`, `restore_command`, or `pg_restore`.

## What to do next

1. Create an isolated recovery target first. Pass
`isolated_target_prepared=True` only after that target exists. The boolean
is your assertion, not package proof of isolation.
2. Choose `backup_method="physical"` only for a crash-consistent base backup
whose recovery target kind is `immediate`. `wal_archive_required=False`
means you are not claiming a continuous WAL archive. It does not mean the
base backup can omit backup-internal WAL. Keep the WAL generated during
the backup (`pg_basebackup -X stream` or `-X fetch`) with the backup.
3. Choose `backup_method="pitr"` only when a continuous WAL archive is already
being written and you can restore to a time, transaction identifier, named
restore point, LSN, or immediate consistent state. `immediate` on a `pitr`
profile is end-of-backup consistency, not replay to the end of the archive.
Replay-to-end-of-archive is the absence of a `recovery_target*` setting, not
a kind this binder records.
4. Record deployer-selected `rpo_seconds` and `rto_seconds` when your
organization has those objectives. Read `package_capability_claim=false` as
a hard rule: this package did not meet those numbers for you.
5. Keep Fernet keys, TLS private material, host configuration, and provider
files outside database and WAL custody unless a separately reviewed adapter
owns them.
6. After you bind the profile, run the actual base backup and WAL restore
outside this seam, then prove schema, tenant/RLS, checkpoint, and lifecycle
usability on the isolated target.

## Time-flow boundary

PostgreSQL point-in-time recovery is a time-flow control. Kinds `time`,
`xid`, `name`, and `lsn` are accepted only on a `pitr` profile with
`wal_archive_required=True`. A physical profile cannot borrow those kinds even
when WAL is being archived. That prevents labeling a crash-consistent restore
as a point-in-time restore.

## What this slice does not prove

This binder does not execute physical backup, WAL archive, or replay. It does
not prove schema/RLS/constraint/extension parity, target isolation, universal
RPO/RTO, high availability, disaster recovery, CSAP, or SOC 2 readiness. Those
remain issue #204 and deployment-specific evidence. Canonical CHANGELOG and
README ownership for the broader recovery program stays with the documentation
and restore-executor writers.

## Verification

Confirm on the exact current head that:

- a PostgreSQL 18 `pitr` + `time` profile round-trips without a capability claim;
- `pitr` without a WAL archive fails closed;
- point-in-time kinds on a `physical` method fail closed;
- `isolated_target_prepared=False` fails closed;
- exact-type and hostile-subclass metadata fail closed;
- parse rejects lone UTF-8 surrogates with the typed profile JSON error;
- parse rejects duplicate keys, unknown keys, and a true capability claim;
- production statement and branch coverage and public docstrings remain 100%.

## References

International Organization for Standardization. (2022). *Information security,
cybersecurity and privacy protection — Information security management systems
— Requirements* (ISO/IEC 27001:2022). Control A.8.13 requires information
backup; this package records a bounded physical/PITR profile, not a certified
ISMS.

Swanson, M., Bowen, P., Phillips, A., Gallup, D., & Lynes, D. (2010).
*Contingency planning guide for federal information systems* (NIST Special
Publication 800-34 Rev. 1). National Institute of Standards and Technology.
https://doi.org/10.6028/NIST.SP.800-34r1

The PostgreSQL Global Development Group. (2026). *Backup and restore*.
PostgreSQL 18 documentation. https://www.postgresql.org/docs/18/backup.html

The PostgreSQL Global Development Group. (2026). *Continuous archiving and
point-in-time recovery (PITR)*. PostgreSQL 18 documentation.
https://www.postgresql.org/docs/18/continuous-archiving.html

The PostgreSQL Global Development Group. (2026). *pg_basebackup*. PostgreSQL 18
documentation. https://www.postgresql.org/docs/18/app-pgbasebackup.html
226 changes: 226 additions & 0 deletions pg_llm_batch/postgres_physical_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# SPDX-License-Identifier: Apache-2.0
"""Bind a caller-owned physical/WAL/PITR recovery profile without executing backup."""

from __future__ import annotations

import json
from dataclasses import dataclass


_BACKUP_METHODS = frozenset({"physical", "pitr"})
_POINT_IN_TIME_KINDS = frozenset({"time", "xid", "name", "lsn"})
_RECOVERY_TARGET_KINDS = frozenset({"immediate"}) | _POINT_IN_TIME_KINDS
_PROFILE_KEYS = frozenset(
{
"schema_version",
"postgres_major",
"backup_method",
"recovery_target_kind",
"wal_archive_required",
"isolated_target_prepared",
"rpo_seconds",
"rto_seconds",
"package_capability_claim",
}
)
_MAX_SIGNED_BIGINT = (1 << 63) - 1
_MAX_PROFILE_JSON_BYTES = 2048


class PostgresPhysicalRecoveryError(ValueError):
"""Report a fail-closed physical/WAL/PITR recovery-profile violation."""


def _exact_major(value: object) -> bool:
"""Return whether a value is an exact PostgreSQL major-version integer."""
return type(value) is int and 1 <= value <= 99


def _exact_method(value: object) -> bool:
"""Return whether a value is one reviewed physical or PITR method string."""
return type(value) is str and value in _BACKUP_METHODS


def _exact_target_kind(value: object) -> bool:
"""Return whether a value is one reviewed recovery-target kind string."""
return type(value) is str and value in _RECOVERY_TARGET_KINDS


def _exact_bool(value: object) -> bool:
"""Return whether a value is an exact built-in boolean."""
return type(value) is bool


def _optional_positive_seconds(value: object) -> bool:
"""Return whether a value is omitted or an exact positive second budget."""
return value is None or (
type(value) is int and 1 <= value <= _MAX_SIGNED_BIGINT
)


def _reject_duplicate_object_pairs(
pairs: list[tuple[str, object]],
) -> dict[str, object]:
"""Build one JSON object while rejecting ambiguous duplicate member names."""
decoded: dict[str, object] = {}
for key, value in pairs:
if key in decoded:
raise PostgresPhysicalRecoveryError(
"invalid PostgreSQL physical recovery profile schema"
)
decoded[key] = value
return decoded


@dataclass(frozen=True, slots=True)
class PostgresPhysicalRecoveryProfile:
"""Represent one caller-owned physical or PITR recovery profile.

The profile records deployer-selected method, time-flow target kind, WAL
archive necessity, isolated-target readiness, and optional RPO/RTO
objectives. It does not execute ``pg_basebackup``, archive WAL, or restore
a cluster, and it never claims those objectives as package capability.
"""

postgres_major: int
backup_method: str
recovery_target_kind: str
wal_archive_required: bool
isolated_target_prepared: bool
rpo_seconds: int | None
rto_seconds: int | None

def __post_init__(self) -> None:
"""Fail closed when the profile violates the bounded recovery contract."""
if not (
_exact_major(self.postgres_major)
and _exact_method(self.backup_method)
and _exact_target_kind(self.recovery_target_kind)
and _exact_bool(self.wal_archive_required)
and _exact_bool(self.isolated_target_prepared)
and _optional_positive_seconds(self.rpo_seconds)
and _optional_positive_seconds(self.rto_seconds)
):
raise PostgresPhysicalRecoveryError(
"invalid PostgreSQL physical recovery profile"
)
if self.isolated_target_prepared is False:
raise PostgresPhysicalRecoveryError(
"PostgreSQL physical recovery requires an isolated target"
)
if self.backup_method == "pitr" and self.wal_archive_required is False:
raise PostgresPhysicalRecoveryError(
"PostgreSQL PITR profile requires a WAL archive"
)
if (
self.recovery_target_kind in _POINT_IN_TIME_KINDS
and self.backup_method != "pitr"
):
raise PostgresPhysicalRecoveryError(
"PostgreSQL point-in-time target requires a PITR profile"
)

def as_dict(self) -> dict[str, object]:
"""Return the stable machine-readable physical recovery profile schema."""
return {
"schema_version": 1,
"postgres_major": self.postgres_major,
"backup_method": self.backup_method,
"recovery_target_kind": self.recovery_target_kind,
"wal_archive_required": self.wal_archive_required,
"isolated_target_prepared": self.isolated_target_prepared,
"rpo_seconds": self.rpo_seconds,
"rto_seconds": self.rto_seconds,
"package_capability_claim": False,
}

def to_json(self) -> str:
"""Return deterministic compact JSON without deployment or business content."""
return json.dumps(
self.as_dict(),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)


def bind_postgres_physical_recovery_profile(
*,
postgres_major: int,
backup_method: str,
recovery_target_kind: str,
wal_archive_required: bool,
isolated_target_prepared: bool,
rpo_seconds: int | None = None,
rto_seconds: int | None = None,
) -> PostgresPhysicalRecoveryProfile:
"""Bind one caller-owned physical or PITR profile without executing recovery.

Callers must already have an isolated recovery target. The service name,
archive path, WAL location, and restore command stay outside this seam.
``backup_method="pitr"`` requires ``wal_archive_required=True``. Point-in-time
kinds ``time``, ``xid``, ``name``, and ``lsn`` require ``backup_method="pitr"``
so a crash-consistent physical base backup cannot be labeled as a time-flow
restore. Optional ``rpo_seconds`` and ``rto_seconds`` are deployer-selected
objectives only; the emitted profile always sets
``package_capability_claim`` to ``False``.
"""
return PostgresPhysicalRecoveryProfile(
postgres_major=postgres_major,
backup_method=backup_method,
recovery_target_kind=recovery_target_kind,
wal_archive_required=wal_archive_required,
isolated_target_prepared=isolated_target_prepared,
rpo_seconds=rpo_seconds,
rto_seconds=rto_seconds,
)


def parse_postgres_physical_recovery_profile(
raw_profile: str,
) -> PostgresPhysicalRecoveryProfile:
"""Parse one bounded profile and reject extensions or capability claims."""
if type(raw_profile) is not str:
raise PostgresPhysicalRecoveryError(
"invalid PostgreSQL physical recovery profile JSON"
)
try:
encoded_size = len(raw_profile.encode("utf-8"))
except UnicodeError:
raise PostgresPhysicalRecoveryError(
"invalid PostgreSQL physical recovery profile JSON"
) from None
if encoded_size == 0 or encoded_size > _MAX_PROFILE_JSON_BYTES:
raise PostgresPhysicalRecoveryError(
"invalid PostgreSQL physical recovery profile JSON"
)
try:
decoded = json.loads(
raw_profile,
object_pairs_hook=_reject_duplicate_object_pairs,
)
except (json.JSONDecodeError, RecursionError):
raise PostgresPhysicalRecoveryError(
"invalid PostgreSQL physical recovery profile JSON"
) from None
if type(decoded) is not dict or frozenset(decoded) != _PROFILE_KEYS:
raise PostgresPhysicalRecoveryError(
"invalid PostgreSQL physical recovery profile schema"
)
if decoded.get("schema_version") != 1 or type(decoded.get("schema_version")) is not int:
raise PostgresPhysicalRecoveryError(
"invalid PostgreSQL physical recovery profile schema"
)
if decoded.get("package_capability_claim") is not False:
raise PostgresPhysicalRecoveryError(
"PostgreSQL physical recovery profile cannot claim package capability"
)
return PostgresPhysicalRecoveryProfile(
postgres_major=decoded["postgres_major"],
backup_method=decoded["backup_method"],
recovery_target_kind=decoded["recovery_target_kind"],
wal_archive_required=decoded["wal_archive_required"],
isolated_target_prepared=decoded["isolated_target_prepared"],
rpo_seconds=decoded["rpo_seconds"],
rto_seconds=decoded["rto_seconds"],
)
Loading
Loading