activity-core/src/activity_core/recovery_contract.py
tegwick 7b38f05eb2
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 1m19s
Implement offline recovery handoff within existing scheduling scope
Apply the user-directed Emergent Security guidance to close the redundant role gate. Add closed request/receipt validation bound to run, cluster, implementation, mode and authority reference; preserve platform enforcement and operation admission gates.

Decision-Class: task-scoped-code-and-tests

Delegation: explicit-user-task; Emergent-Security-A6

Evidence: 653-tests-passed; 1-skipped; focused-43-passed; ruff-clean

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a100de-2e1c-7e23-9ae9-2da0e0499411
2026-10-03 14:32:50 +02:00

102 lines
4.3 KiB
Python

"""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