"""Candidate scheduling/owner handoff for ACTIVITY-WP-0042. Pure envelope validation, not operation admission or authorization. Nothing in this module executes a restore. The platform enforcement point must validate per-run authority and produce its own recovery/cleanup evidence. """ from __future__ import annotations from datetime import datetime, timedelta from typing import Literal from uuid import UUID from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, ValidationError, model_validator class RecoveryRequest(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) schema_id: Literal["activity.platform-recovery-request.v1"] = "activity.platform-recovery-request.v1" run_id: UUID operation: Literal["apps_pg_restore_evidence"] = "apps_pg_restore_evidence" target: Literal["apps-pg"] = "apps-pg" cluster_uid: UUID implementation_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") mode: Literal["dry_run", "apply"] # Opaque reference only. Presence never establishes authority (C1/C2/C9). authority_ref: UUID | None = None @model_validator(mode="after") def require_apply_reference(self) -> RecoveryRequest: if self.mode == "apply" and self.authority_ref is None: raise ValueError("apply requires a per-run authority reference") return self class RecoveryReceipt(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) schema_id: Literal["activity.platform-recovery-receipt.v1"] run_id: UUID operation: Literal["apps_pg_restore_evidence"] target: Literal["apps-pg"] cluster_uid: UUID mode: Literal["dry_run", "apply"] authority_ref: UUID | None outcome: Literal["planned", "succeeded", "failed", "rejected", "unknown"] started_at: AwareDatetime finished_at: AwareDatetime validation_succeeded: bool = Field(strict=True) cleanup: Literal["completed", "not_created", "failed", "unknown"] # Digests bind owner evidence; payloads, paths and logs do not cross here. evidence_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") implementation_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") def parse_recovery_request(payload: object) -> RecoveryRequest: """Reject malformed requests without echoing potentially sensitive input.""" try: return RecoveryRequest.model_validate(payload) except ValidationError: raise ValueError("invalid recovery request envelope") from None def validate_recovery_receipt( request: RecoveryRequest, payload: object, *, observed_at: datetime, max_age: timedelta, ) -> RecoveryReceipt: """Check correlation and receipt shape; the owner validates the recovery. The caller must supply an owner-accepted freshness window and authenticated transport. A structurally valid receipt is not authority to run anything. Ambiguous/failed results remain visible, never retried by this module. """ if observed_at.tzinfo is None or observed_at.utcoffset() is None or max_age <= timedelta(0): raise ValueError("aware observation time and positive freshness window required") try: receipt = RecoveryReceipt.model_validate(payload) except ValidationError: raise ValueError("invalid recovery receipt envelope") from None for field in ("run_id", "operation", "target", "cluster_uid", "implementation_sha256", "mode", "authority_ref"): if getattr(receipt, field) != getattr(request, field): raise ValueError("recovery receipt does not match request") if not receipt.started_at <= receipt.finished_at <= observed_at: raise ValueError("invalid recovery receipt timestamps") if observed_at - receipt.finished_at > max_age: raise ValueError("stale recovery receipt") if receipt.outcome == "succeeded" and ( receipt.mode != "apply" or not receipt.validation_succeeded or receipt.cleanup != "completed" ): raise ValueError("successful recovery requires validation and completed cleanup") if receipt.mode == "dry_run" and ( receipt.validation_succeeded or receipt.cleanup != "not_created" ): raise ValueError("dry-run receipt cannot claim recovery or cleanup effects") if receipt.outcome == "planned" and receipt.mode != "dry_run": raise ValueError("planned outcome is reserved for dry-run requests") return receipt