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
This commit is contained in:
parent
5d499622e0
commit
7b38f05eb2
4 changed files with 316 additions and 19 deletions
102
src/activity_core/recovery_contract.py
Normal file
102
src/activity_core/recovery_contract.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue