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
|
|
@ -1,17 +1,19 @@
|
|||
# Platform recovery scheduling — reviewed proposal
|
||||
# Platform recovery scheduling — scoped delivery plan
|
||||
|
||||
Reviewed 2026-10-03 for ACTIVITY-WP-0042. This is a proposed delivery contract,
|
||||
not admission of a new operation or permission to run it.
|
||||
Reviewed 2026-10-03 for ACTIVITY-WP-0042. Offline implementation is authorized
|
||||
within the existing scheduling role by the user's task and explicit Emergent
|
||||
Security guidance. The runner contract remains a candidate for platform review;
|
||||
no new operation or live execution is admitted.
|
||||
|
||||
## Recommended decision
|
||||
## Scheduling boundary
|
||||
|
||||
Accept **scheduling ownership only** for activity-core: Temporal triggers,
|
||||
Continue within activity-core's existing **scheduling-only ownership**: Temporal triggers,
|
||||
bounded dispatch, run correlation, missed-run visibility, and normalized result
|
||||
evidence. Railiance Platform retains recovery implementations, acceptance
|
||||
validators, cadence/freshness policy, destinations, retention, and credential
|
||||
custody. Existing ACT-ADR-007 operations remain unchanged.
|
||||
|
||||
Approve offline design and tests of the invocation contract now. Admit each
|
||||
Proceed with offline design and tests under C4/A2/A6. Admit each
|
||||
new mutating operation separately after its owner supplies the contract and
|
||||
ACT-ADR-007 is amended or superseded. No new generic shell executor, restore
|
||||
schedule, retention action, or production rollout follows from role acceptance.
|
||||
|
|
@ -87,8 +89,9 @@ successful restore.
|
|||
|
||||
## Implementation and verification sequence
|
||||
|
||||
1. Founder accepts the scheduling-only boundary (T01). This resolves only
|
||||
ACTIVITY-WP-0042's role choice, not the other decisions bundled in D2.
|
||||
1. T01 is complete: the explicit user task and Emergent Security guidance
|
||||
authorize development within the existing scheduling boundary. The other
|
||||
decisions bundled in D2 remain unchanged.
|
||||
2. Agree the fixed runner contract above with railiance-platform (T05).
|
||||
3. Build offline adapter/admission tests within T02. Cover unknown operation,
|
||||
target drift, absent/expired/wrong-scope authority, duplicate dispatch,
|
||||
|
|
@ -108,3 +111,25 @@ issuance/deployment record identifying the actual execution unit, or a founder
|
|||
ruling that it is an unused example to retire. Scheduling development does not
|
||||
need to claim this historical actor. No owner message has been sent by this
|
||||
review; the contract and identity questions are recorded as workplan tasks.
|
||||
|
||||
|
||||
## Candidate contract implementation
|
||||
|
||||
`src/activity_core/recovery_contract.py` provides a pure request parser and
|
||||
receipt validator; `tests/test_recovery_contract.py` exercises it offline.
|
||||
It deliberately has no executor, transport, credential access, or registration
|
||||
in the bounded-operation allowlist. It is not imported by the runtime workflow.
|
||||
|
||||
An apply request must name a per-run authority UUID, but UUID presence never
|
||||
proves permission. The platform enforcement point must verify scope, expiry and
|
||||
consumption before an effect. The receipt envelope checks only the owner's
|
||||
assertions and correlation; platform validators remain authoritative for backup
|
||||
contents and recovery correctness. Digests require verification against the
|
||||
owner evidence store by the future authenticated transport, not trust by format.
|
||||
The supplied freshness window must come from accepted owner policy.
|
||||
|
||||
A successful apply receipt requires validated recovery and completed cleanup;
|
||||
a dry-run may only describe a plan without recovery effects. Failed/rejected/
|
||||
unknown results remain visible for reconciliation and do not trigger retries.
|
||||
Malformed input errors suppress raw validation values. This contract is an
|
||||
implementation candidate, not an assertion of platform-owner acceptance.
|
||||
|
|
|
|||
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
|
||||
129
tests/test_recovery_contract.py
Normal file
129
tests/test_recovery_contract.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Offline owner-handoff contract checks; no platform calls or credentials."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from activity_core.recovery_contract import parse_recovery_request, validate_recovery_receipt
|
||||
|
||||
NOW = datetime(2026, 10, 3, 10, tzinfo=UTC)
|
||||
|
||||
|
||||
def request(mode="apply"):
|
||||
return parse_recovery_request({
|
||||
"run_id": str(uuid4()), "mode": mode,
|
||||
"cluster_uid": str(uuid4()), "implementation_sha256": "b" * 64,
|
||||
"authority_ref": str(uuid4()) if mode == "apply" else None,
|
||||
})
|
||||
|
||||
|
||||
def receipt(req):
|
||||
return {
|
||||
"schema_id": "activity.platform-recovery-receipt.v1",
|
||||
"run_id": str(req.run_id), "operation": req.operation, "target": req.target,
|
||||
"cluster_uid": str(req.cluster_uid),
|
||||
"mode": req.mode, "authority_ref": str(req.authority_ref) if req.authority_ref else None,
|
||||
"outcome": "succeeded" if req.mode == "apply" else "planned",
|
||||
"started_at": (NOW - timedelta(minutes=5)).isoformat(),
|
||||
"finished_at": (NOW - timedelta(minutes=1)).isoformat(),
|
||||
"validation_succeeded": req.mode == "apply",
|
||||
"cleanup": "completed" if req.mode == "apply" else "not_created",
|
||||
"evidence_sha256": "a" * 64, "implementation_sha256": "b" * 64,
|
||||
}
|
||||
|
||||
|
||||
def check(req, value):
|
||||
return validate_recovery_receipt(req, value, observed_at=NOW, max_age=timedelta(hours=1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["apply", "dry_run"])
|
||||
def test_exact_owner_receipt_preserves_outcome(mode):
|
||||
req = request(mode)
|
||||
result = check(req, receipt(req))
|
||||
assert result.outcome == ("succeeded" if mode == "apply" else "planned")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [
|
||||
{"operation": "platform_root"}, {"target": "forgejo-db"},
|
||||
{"command": "arbitrary"}, {"credential": "SECRET_CANARY"},
|
||||
{"authority_ref": None}, {"mode": "unknown"},
|
||||
])
|
||||
def test_request_rejects_widening_and_missing_apply_reference_without_echo(change):
|
||||
value = request().model_dump(mode="json")
|
||||
value.update(change)
|
||||
with pytest.raises(ValueError) as exc:
|
||||
parse_recovery_request(value)
|
||||
assert str(exc.value) == "invalid recovery request envelope"
|
||||
assert exc.value.__suppress_context__
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [
|
||||
{"run_id": str(uuid4())}, {"authority_ref": str(uuid4())},
|
||||
{"cluster_uid": str(uuid4())}, {"implementation_sha256": "c" * 64},
|
||||
{"target": "production"}, {"mode": "dry_run"},
|
||||
{"validation_succeeded": False}, {"validation_succeeded": "true"},
|
||||
{"cleanup": "failed"}, {"cleanup": "unknown"},
|
||||
{"finished_at": (NOW + timedelta(seconds=1)).isoformat()},
|
||||
{"started_at": NOW.isoformat()},
|
||||
{"finished_at": "2026-10-03T09:59:00"},
|
||||
{"evidence_sha256": "invalid"},
|
||||
{"stderr": "SECRET_CANARY"}, {"evidence_sha256": "SECRET_CANARY"},
|
||||
])
|
||||
def test_receipt_rejects_mismatch_invalid_proof_and_sensitive_fields(change):
|
||||
req = request()
|
||||
value = receipt(req)
|
||||
value.update(change)
|
||||
with pytest.raises(ValueError) as exc:
|
||||
check(req, value)
|
||||
assert "SECRET_CANARY" not in str(exc.value)
|
||||
|
||||
|
||||
def test_stale_receipt_is_not_freshened_by_scheduler():
|
||||
req = request()
|
||||
value = receipt(req)
|
||||
value.update(started_at=(NOW - timedelta(hours=3)).isoformat(),
|
||||
finished_at=(NOW - timedelta(hours=2)).isoformat())
|
||||
with pytest.raises(ValueError, match="stale"):
|
||||
check(req, value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["failed", "rejected", "unknown"])
|
||||
def test_failure_and_ambiguity_remain_visible(outcome):
|
||||
req = request()
|
||||
value = receipt(req)
|
||||
value.update(outcome=outcome, validation_succeeded=False, cleanup="unknown")
|
||||
assert check(req, value).outcome == outcome
|
||||
|
||||
|
||||
def test_dry_run_cannot_claim_a_restore():
|
||||
req = request("dry_run")
|
||||
value = receipt(req)
|
||||
value.update(outcome="succeeded", validation_succeeded=True, cleanup="completed")
|
||||
with pytest.raises(ValueError):
|
||||
check(req, value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("observed_at,max_age", [
|
||||
(NOW.replace(tzinfo=None), timedelta(hours=1)),
|
||||
(NOW, timedelta(0)), (NOW, timedelta(seconds=-1)),
|
||||
])
|
||||
def test_freshness_policy_must_be_explicit_and_valid(observed_at, max_age):
|
||||
req = request()
|
||||
with pytest.raises(ValueError):
|
||||
validate_recovery_receipt(req, receipt(req), observed_at=observed_at, max_age=max_age)
|
||||
|
||||
|
||||
def test_receipt_requires_an_explicit_supported_schema():
|
||||
req = request()
|
||||
for schema in (None, "activity.platform-recovery-receipt.v2"):
|
||||
value = receipt(req)
|
||||
if schema is None:
|
||||
del value["schema_id"]
|
||||
else:
|
||||
value["schema_id"] = schema
|
||||
with pytest.raises(ValueError, match="invalid recovery receipt envelope"):
|
||||
check(req, value)
|
||||
|
||||
|
||||
def test_offline_contract_does_not_register_an_operation():
|
||||
from activity_core.bounded_operations import BOUNDED_OPERATION_REGISTRY
|
||||
assert "apps_pg_restore_evidence" not in BOUNDED_OPERATION_REGISTRY
|
||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Scheduling role for recurring platform backup/restore evidence runs"
|
||||
domain: infotech
|
||||
repo: activity-core
|
||||
status: proposed
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: activity-core
|
||||
created: "2026-10-03"
|
||||
|
|
@ -14,8 +14,10 @@ state_hub_workstream_id: "e1bfb5a4-e647-592d-883f-fe8728ad2f5f"
|
|||
|
||||
Origin: State Hub message e3668cd9 from railiance-platform (RPF-WP-0036-T03,
|
||||
RPF-WP-0038-T04) proposing activity-core as execution owner for recurring
|
||||
backup/restore evidence runs. This plan is a proposal. Acceptance of the role
|
||||
is a founder/owner decision and is NOT recorded here.
|
||||
backup/restore evidence runs. Scheduling/dispatch remains within the existing
|
||||
activity-core intent and the platform owner's scheduling-only consent. The user
|
||||
directed continuation under Emergent Security on 2026-10-03; offline contract
|
||||
development is active. New recovery execution and live activation remain gated.
|
||||
|
||||
Facts from the repo (2026-10-03):
|
||||
|
||||
|
|
@ -37,10 +39,10 @@ Facts from the repo (2026-10-03):
|
|||
|
||||
```task
|
||||
id: ACTIVITY-WP-0042-T01
|
||||
status: wait
|
||||
needs_human: true
|
||||
decision_id: 89f1058f-33bc-47a8-8a49-0b44d426c67e
|
||||
blocking_reason: "Founder decision: accept scheduling only, scheduling plus a bounded operation, or decline the role."
|
||||
status: done
|
||||
needs_human: false
|
||||
decision_id: 5ec92a02-cb0b-466d-9aaf-9da5ffc6c92d
|
||||
blocking_reason: null
|
||||
priority: high
|
||||
state_hub_task_id: "2a368988-2611-596a-a847-82c048bd179e"
|
||||
```
|
||||
|
|
@ -60,23 +62,39 @@ founder confirmation. activity-core replied with the same position.
|
|||
|
||||
2026-10-03: human gate routed to hub decision 89f1058f-33bc-47a8-8a49-0b44d426c67e (D2: ArgoCD owns production: go-ahead sequence for railiance-apps-root, issue-core, policy-nexus and activity-core automation) by CUST-WP-0075; resolving that decision raises C-40 here.
|
||||
|
||||
**2026-10-03 superseding scope clarification:** the user explicitly directed
|
||||
continuation under `emergent-security/emergent-security-agent.md`. Apply C4,
|
||||
A2 and A6 to the already-owned scheduling/dispatch role in INTENT.md, with
|
||||
platform owner consent in message 0638c13b. No new responsibility or runtime
|
||||
capability is being claimed (C3/A5). The redundant blanket role gate is closed;
|
||||
proceed with offline contract code/tests. Per-run authority remains an executing
|
||||
platform enforcement point's responsibility (C2/C9). This is an autonomous
|
||||
scope clarification, not a human answer to D2; D2's other gates and T03 remain.
|
||||
|
||||
## Admit a bounded operation and credential lane
|
||||
|
||||
```task
|
||||
id: ACTIVITY-WP-0042-T02
|
||||
status: wait
|
||||
status: progress
|
||||
depends_on: [ACTIVITY-WP-0042-T01, ACTIVITY-WP-0042-T05]
|
||||
blocking_reason: "Requires role acceptance (T01) and the agreed platform runner contract (T05) before operation or credential admission."
|
||||
priority: medium
|
||||
state_hub_task_id: "250c6e8e-7281-5dec-9a92-06081b8c96bb"
|
||||
```
|
||||
|
||||
Blocked on T01. Successor to ACT-ADR-007, registry entry (target bounds,
|
||||
Offline contract development is authorized by the explicit task and existing
|
||||
scheduling scope. Actual operation admission waits on T05. Any new local
|
||||
bounded operation still requires a successor to ACT-ADR-007, registry entry (target bounds,
|
||||
timeout, idempotency, dry_run explicit), tests, credential route via OpenBao
|
||||
(owner railiance-platform; existing store health verified), and
|
||||
non-secret evidence mode. Scheduled caller satisfies RPF-WP-0038-T04 only
|
||||
once recurring tiered scheduling and retention are accepted by their owners.
|
||||
|
||||
Offline candidate implemented in `src/activity_core/recovery_contract.py`:
|
||||
closed request/receipt envelopes, explicit mode, run/authority-reference
|
||||
correlation, owner-supplied freshness window, cleanup proof and safe errors.
|
||||
No runner call, credential acquisition or allowlist change. Owner contract
|
||||
acceptance and live proof remain incomplete; T02 is not done.
|
||||
|
||||
## Answer ops-warden on atm-backup-daily
|
||||
|
||||
```task
|
||||
|
|
@ -127,7 +145,7 @@ id: ACTIVITY-WP-0042-T05
|
|||
status: wait
|
||||
priority: high
|
||||
depends_on: [ACTIVITY-WP-0042-T01]
|
||||
blocking_reason: "Scheduling role acceptance and platform agreement on the fixed runner contract are needed before adapter admission."
|
||||
blocking_reason: "Platform owner must accept the candidate request/receipt contract, execution authority checks, limits and credential scope before runtime wiring."
|
||||
state_hub_task_id: "a2d95c80-bfc5-54b7-9987-ad3571d9c75f"
|
||||
```
|
||||
|
||||
|
|
@ -137,3 +155,26 @@ durable run receipts, overlap/retry behavior, cleanup, resource ceilings and
|
|||
credential scope. Existing restore CLIs are not an admitted unattended API.
|
||||
This task records the dependency; no external acceptance or message is claimed.
|
||||
T02 implements/adopts the agreed contract; T03 does not gate offline design.
|
||||
|
||||
|
||||
## Decision record — Emergent Security application
|
||||
|
||||
- Decision class: task-scoped code/tests and scheduling-boundary clarification.
|
||||
- Delegator: the user who requested ACTIVITY-WP-0042 progress and explicitly
|
||||
directed use of Emergent Security agent guidance in this conversation.
|
||||
- Scope: activity-core repository development in the build estate; no tenant
|
||||
effect, production mutation, identity assignment, or recovery credential use.
|
||||
- Rules: C4 lowest sufficient scope; A2 proceed within the task; A6 code/tests;
|
||||
C3/A5 no new capability or unowned responsibility; C2/C9 authority enforcement
|
||||
stays with the platform executor; A8 this record and commit evidence.
|
||||
- Evidence: INTENT.md scheduling boundary, ACT-ADR-007 existing allowlist,
|
||||
platform consent 0638c13b, offline adversarial contract tests.
|
||||
- Outcome: active offline implementation, T01 done; T05 and T03 retain their
|
||||
specific unresolved requirements. The broader D2 decision is unchanged.
|
||||
|
||||
|
||||
Validation (2026-10-03): full activity-core suite **653 passed, 1 skipped**;
|
||||
focused recovery-contract/bounded-operation suite **43 passed**; Ruff and
|
||||
`git diff --check` clean. No runtime wiring, new operation registration,
|
||||
credential fetch, or live restore was performed. Decision receipt:
|
||||
`5ec92a02-cb0b-466d-9aaf-9da5ffc6c92d` (codex scope clarification; D2 unchanged).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue