feat(governance): 顯示異地 escrow 準備度
This commit is contained in:
@@ -56,6 +56,9 @@ from src.services.backup_notification_policy import (
|
||||
from src.services.backup_restore_drill_approval_package_template import (
|
||||
load_latest_backup_restore_drill_approval_package_template,
|
||||
)
|
||||
from src.services.offsite_escrow_readiness_status import (
|
||||
load_latest_offsite_escrow_readiness_status,
|
||||
)
|
||||
from src.services.package_supply_chain_inventory import (
|
||||
load_latest_package_supply_chain_inventory,
|
||||
)
|
||||
@@ -583,6 +586,34 @@ async def get_backup_restore_drill_approval_package_template() -> dict[str, Any]
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/offsite-escrow-readiness-status",
|
||||
response_model=dict[str, Any],
|
||||
summary="取得異地 / Escrow 準備度狀態",
|
||||
description=(
|
||||
"讀取最新已提交的異地備份、credential escrow 與 K8s resource offsite readiness 狀態;"
|
||||
"此端點只回傳 read-only status,不執行 backup、restore、offsite sync、"
|
||||
"不寫 credential marker、不讀 credential、不輸出 secret 明文、不改排程、不寫 workflow、"
|
||||
"不送 Telegram 測試通知、不做破壞性 prune、不改生產路由。"
|
||||
),
|
||||
)
|
||||
async def get_offsite_escrow_readiness_status() -> dict[str, Any]:
|
||||
"""Return the latest read-only offsite / escrow readiness status."""
|
||||
try:
|
||||
return await asyncio.to_thread(load_latest_offsite_escrow_readiness_status)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.error("offsite_escrow_readiness_status_invalid", error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="異地 / Escrow 準備度狀態快照無效",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/package-supply-chain-inventory",
|
||||
response_model=dict[str, Any],
|
||||
|
||||
160
apps/api/src/services/offsite_escrow_readiness_status.py
Normal file
160
apps/api/src/services/offsite_escrow_readiness_status.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Offsite / escrow readiness status snapshot.
|
||||
|
||||
Loads the latest committed, read-only offsite / escrow readiness status. The
|
||||
status view never runs backups, restores, offsite sync, credential marker
|
||||
writes, credential reads, schedule changes, workflow writes, Telegram test
|
||||
notifications, destructive prune, or production routing changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.services.snapshot_paths import default_evaluations_dir
|
||||
|
||||
_DEFAULT_EVALUATIONS_DIR = default_evaluations_dir(Path(__file__))
|
||||
_SNAPSHOT_PATTERN = "offsite_escrow_readiness_status_*.json"
|
||||
_SCHEMA_VERSION = "offsite_escrow_readiness_status_v1"
|
||||
|
||||
|
||||
def load_latest_offsite_escrow_readiness_status(
|
||||
evaluations_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load the newest committed offsite / escrow readiness status snapshot."""
|
||||
directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR
|
||||
candidates = sorted(directory.glob(_SNAPSHOT_PATTERN))
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"no offsite / escrow readiness status snapshots found in {directory}")
|
||||
|
||||
latest = candidates[-1]
|
||||
with latest.open(encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"{latest}: expected JSON object")
|
||||
_require_schema(payload, _SCHEMA_VERSION, str(latest))
|
||||
_require_read_only_boundaries(payload, str(latest))
|
||||
_require_operation_boundaries(payload, str(latest))
|
||||
_require_rollup_consistency(payload, str(latest))
|
||||
_require_redacted_cards(payload, str(latest))
|
||||
return payload
|
||||
|
||||
|
||||
def _require_schema(payload: dict[str, Any], expected: str, label: str) -> None:
|
||||
actual = payload.get("schema_version")
|
||||
if actual != expected:
|
||||
raise ValueError(f"{label}: expected schema_version={expected}, got {actual!r}")
|
||||
|
||||
|
||||
def _require_read_only_boundaries(payload: dict[str, Any], label: str) -> None:
|
||||
program_status = payload.get("program_status") or {}
|
||||
if program_status.get("read_only_mode") is not True:
|
||||
raise ValueError(f"{label}: program_status.read_only_mode must be true")
|
||||
|
||||
boundaries = payload.get("approval_boundaries") or {}
|
||||
blocked_flags = {
|
||||
"sdk_installation_allowed",
|
||||
"paid_api_call_allowed",
|
||||
"shadow_or_canary_allowed",
|
||||
"production_routing_allowed",
|
||||
"destructive_operation_allowed",
|
||||
"restore_execution_allowed",
|
||||
"offsite_sync_execution_allowed",
|
||||
"credential_marker_write_allowed",
|
||||
}
|
||||
allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False)
|
||||
if allowed:
|
||||
raise ValueError(f"{label}: approval boundaries must remain false: {allowed}")
|
||||
|
||||
|
||||
def _require_operation_boundaries(payload: dict[str, Any], label: str) -> None:
|
||||
boundaries = payload.get("operation_boundaries") or {}
|
||||
if boundaries.get("read_only_status_allowed") is not True:
|
||||
raise ValueError(f"{label}: read_only_status_allowed must be true")
|
||||
|
||||
blocked_flags = {
|
||||
"backup_execution_allowed",
|
||||
"restore_execution_allowed",
|
||||
"offsite_sync_execution_allowed",
|
||||
"credential_marker_write_allowed",
|
||||
"credential_read_allowed",
|
||||
"secret_plaintext_allowed",
|
||||
"schedule_change_allowed",
|
||||
"workflow_write_allowed",
|
||||
"telegram_test_notification_allowed",
|
||||
"destructive_prune_allowed",
|
||||
"production_routing_allowed",
|
||||
}
|
||||
allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False)
|
||||
if allowed:
|
||||
raise ValueError(f"{label}: operation boundaries must remain false: {allowed}")
|
||||
|
||||
|
||||
def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None:
|
||||
cards = payload.get("readiness_cards") or []
|
||||
rollups = payload.get("rollups") or {}
|
||||
if rollups.get("total_cards") != len(cards):
|
||||
raise ValueError(f"{label}: rollups.total_cards must match readiness_cards")
|
||||
|
||||
verified_offsite = {
|
||||
card.get("card_id")
|
||||
for card in cards
|
||||
if card.get("kind") == "offsite_mirror" and card.get("readiness") == "verified"
|
||||
}
|
||||
if set(rollups.get("verified_offsite_card_ids") or []) != verified_offsite:
|
||||
raise ValueError(f"{label}: rollups.verified_offsite_card_ids must match verified offsite cards")
|
||||
|
||||
blocked_escrow = {
|
||||
card.get("card_id")
|
||||
for card in cards
|
||||
if card.get("kind") == "credential_escrow" and card.get("readiness") == "blocked"
|
||||
}
|
||||
if set(rollups.get("blocked_escrow_card_ids") or []) != blocked_escrow:
|
||||
raise ValueError(f"{label}: rollups.blocked_escrow_card_ids must match blocked escrow cards")
|
||||
|
||||
action_required = {
|
||||
card.get("card_id")
|
||||
for card in cards
|
||||
if card.get("readiness") == "action_required"
|
||||
}
|
||||
if set(rollups.get("action_required_card_ids") or []) != action_required:
|
||||
raise ValueError(f"{label}: rollups.action_required_card_ids must match action_required cards")
|
||||
|
||||
execution_blocked = {
|
||||
card.get("card_id")
|
||||
for card in cards
|
||||
if any(operation.endswith("_execution") or operation == "credential_marker_write" for operation in card.get("blocked_operations", []))
|
||||
}
|
||||
if set(rollups.get("execution_blocked_card_ids") or []) != execution_blocked:
|
||||
raise ValueError(f"{label}: rollups.execution_blocked_card_ids must match cards with blocked execution operations")
|
||||
|
||||
|
||||
def _require_redacted_cards(payload: dict[str, Any], label: str) -> None:
|
||||
cards = payload.get("readiness_cards") or []
|
||||
forbidden_exposure = {
|
||||
"plaintext",
|
||||
"secret_plaintext",
|
||||
"credential_plaintext",
|
||||
"token_visible",
|
||||
}
|
||||
exposed = sorted(
|
||||
card.get("card_id")
|
||||
for card in cards
|
||||
if card.get("credential_exposure_status") in forbidden_exposure
|
||||
)
|
||||
if exposed:
|
||||
raise ValueError(f"{label}: credential exposure must stay redacted: {exposed}")
|
||||
|
||||
contract = payload.get("operator_contract") or {}
|
||||
must_not_interpret_as = set(contract.get("must_not_interpret_as") or [])
|
||||
required_denials = {
|
||||
"復原批准",
|
||||
"異地同步批准",
|
||||
"credential marker 寫入批准",
|
||||
"完整 DR 綠燈",
|
||||
}
|
||||
if not required_denials.issubset(must_not_interpret_as):
|
||||
raise ValueError(f"{label}: operator_contract.must_not_interpret_as is missing required denials")
|
||||
@@ -18,12 +18,12 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho
|
||||
assert data["schema_version"] == "ai_agent_automation_backlog_v1"
|
||||
assert data["program_status"]["overall_completion_percent"] == 100
|
||||
assert data["program_status"]["read_only_mode"] is True
|
||||
assert data["program_status"]["current_task_id"] == "P1-105"
|
||||
assert data["program_status"]["next_task_id"] == "P1-106"
|
||||
assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 20
|
||||
assert data["rollups"]["by_priority"]["P1"] == 18
|
||||
assert data["rollups"]["by_status"]["done"] == 13
|
||||
assert data["rollups"]["by_gate_status"]["read_only_allowed"] == 17
|
||||
assert data["program_status"]["current_task_id"] == "P1-106"
|
||||
assert data["program_status"]["next_task_id"] == "P1-305"
|
||||
assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 21
|
||||
assert data["rollups"]["by_priority"]["P1"] == 19
|
||||
assert data["rollups"]["by_status"]["done"] == 14
|
||||
assert data["rollups"]["by_gate_status"]["read_only_allowed"] == 18
|
||||
assert data["approval_boundaries"]["sdk_installation_allowed"] is False
|
||||
assert data["approval_boundaries"]["paid_api_call_allowed"] is False
|
||||
assert data["approval_boundaries"]["production_routing_allowed"] is False
|
||||
@@ -33,4 +33,5 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho
|
||||
assert any(item["item_id"] == "AUTO-P1-103" for item in data["backlog_items"])
|
||||
assert any(item["item_id"] == "AUTO-P1-104" for item in data["backlog_items"])
|
||||
assert any(item["item_id"] == "AUTO-P1-105" for item in data["backlog_items"])
|
||||
assert any(item["item_id"] == "AUTO-P1-106" for item in data["backlog_items"])
|
||||
assert any(item["item_id"] == "AUTO-P3-001" for item in data["backlog_items"])
|
||||
|
||||
@@ -18,8 +18,8 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps
|
||||
assert data["schema_version"] == "ai_agent_automation_inventory_snapshot_v1"
|
||||
assert data["program_status"]["overall_completion_percent"] == 100
|
||||
assert data["program_status"]["read_only_mode"] is True
|
||||
assert data["program_status"]["current_task_id"] == "P1-105"
|
||||
assert data["program_status"]["next_task_id"] == "P1-106"
|
||||
assert data["program_status"]["current_task_id"] == "P1-106"
|
||||
assert data["program_status"]["next_task_id"] == "P1-305"
|
||||
assert data["approval_boundaries"]["sdk_installation_allowed"] is False
|
||||
assert data["approval_boundaries"]["paid_api_call_allowed"] is False
|
||||
assert data["approval_boundaries"]["production_routing_allowed"] is False
|
||||
@@ -30,6 +30,7 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps
|
||||
assert any(task["task_id"] == "P1-103" for task in data["tasks"])
|
||||
assert any(task["task_id"] == "P1-104" for task in data["tasks"])
|
||||
assert any(task["task_id"] == "P1-105" for task in data["tasks"])
|
||||
assert any(task["task_id"] == "P1-106" for task in data["tasks"])
|
||||
assert any(evidence["evidence_id"] == "dependency_risk_policy_api" for evidence in data["evidence"])
|
||||
assert any(evidence["evidence_id"] == "dependency_drift_check_plan_api" for evidence in data["evidence"])
|
||||
assert any(
|
||||
@@ -42,3 +43,4 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps
|
||||
evidence["evidence_id"] == "backup_restore_drill_approval_package_template_api"
|
||||
for evidence in data["evidence"]
|
||||
)
|
||||
assert any(evidence["evidence_id"] == "offsite_escrow_readiness_status_api" for evidence in data["evidence"])
|
||||
|
||||
213
apps/api/tests/test_offsite_escrow_readiness_status.py
Normal file
213
apps/api/tests/test_offsite_escrow_readiness_status.py
Normal file
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.offsite_escrow_readiness_status import (
|
||||
load_latest_offsite_escrow_readiness_status,
|
||||
)
|
||||
|
||||
|
||||
def test_load_latest_offsite_escrow_readiness_status_reads_newest_file(tmp_path):
|
||||
older = _snapshot(generated_at="2026-06-04T00:00:00+08:00", completion=40)
|
||||
newer = _snapshot(generated_at="2026-06-05T00:00:00+08:00", completion=100)
|
||||
(tmp_path / "offsite_escrow_readiness_status_2026-06-04.json").write_text(
|
||||
json.dumps(older),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text(
|
||||
json.dumps(newer),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loaded = load_latest_offsite_escrow_readiness_status(tmp_path)
|
||||
|
||||
assert loaded["generated_at"] == "2026-06-05T00:00:00+08:00"
|
||||
assert loaded["program_status"]["overall_completion_percent"] == 100
|
||||
assert loaded["rollups"]["total_cards"] == 3
|
||||
|
||||
|
||||
def test_offsite_escrow_readiness_status_requires_read_only_mode(tmp_path):
|
||||
snapshot = _snapshot()
|
||||
snapshot["program_status"]["read_only_mode"] = False
|
||||
(tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text(
|
||||
json.dumps(snapshot),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="read_only_mode"):
|
||||
load_latest_offsite_escrow_readiness_status(tmp_path)
|
||||
|
||||
|
||||
def test_offsite_escrow_readiness_status_requires_blocked_approval_boundaries(tmp_path):
|
||||
snapshot = _snapshot()
|
||||
snapshot["approval_boundaries"]["credential_marker_write_allowed"] = True
|
||||
(tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text(
|
||||
json.dumps(snapshot),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="approval boundaries"):
|
||||
load_latest_offsite_escrow_readiness_status(tmp_path)
|
||||
|
||||
|
||||
def test_offsite_escrow_readiness_status_requires_blocked_operation_boundaries(tmp_path):
|
||||
snapshot = _snapshot()
|
||||
snapshot["operation_boundaries"]["offsite_sync_execution_allowed"] = True
|
||||
(tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text(
|
||||
json.dumps(snapshot),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="operation boundaries"):
|
||||
load_latest_offsite_escrow_readiness_status(tmp_path)
|
||||
|
||||
|
||||
def test_offsite_escrow_readiness_status_requires_total_consistency(tmp_path):
|
||||
snapshot = _snapshot()
|
||||
snapshot["rollups"]["total_cards"] = 999
|
||||
(tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text(
|
||||
json.dumps(snapshot),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="total_cards"):
|
||||
load_latest_offsite_escrow_readiness_status(tmp_path)
|
||||
|
||||
|
||||
def test_offsite_escrow_readiness_status_requires_escrow_blocked_consistency(tmp_path):
|
||||
snapshot = _snapshot()
|
||||
snapshot["rollups"]["blocked_escrow_card_ids"] = []
|
||||
(tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text(
|
||||
json.dumps(snapshot),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="blocked_escrow_card_ids"):
|
||||
load_latest_offsite_escrow_readiness_status(tmp_path)
|
||||
|
||||
|
||||
def test_offsite_escrow_readiness_status_rejects_credential_plaintext(tmp_path):
|
||||
snapshot = _snapshot()
|
||||
snapshot["readiness_cards"][1]["credential_exposure_status"] = "credential_plaintext"
|
||||
(tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text(
|
||||
json.dumps(snapshot),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="credential exposure"):
|
||||
load_latest_offsite_escrow_readiness_status(tmp_path)
|
||||
|
||||
|
||||
def test_offsite_escrow_readiness_status_requires_operator_denials(tmp_path):
|
||||
snapshot = _snapshot()
|
||||
snapshot["operator_contract"]["must_not_interpret_as"] = ["復原批准"]
|
||||
(tmp_path / "offsite_escrow_readiness_status_2026-06-05.json").write_text(
|
||||
json.dumps(snapshot),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must_not_interpret_as"):
|
||||
load_latest_offsite_escrow_readiness_status(tmp_path)
|
||||
|
||||
|
||||
def test_offsite_escrow_readiness_status_fails_when_missing(tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_latest_offsite_escrow_readiness_status(tmp_path)
|
||||
|
||||
|
||||
def _snapshot(
|
||||
*,
|
||||
generated_at: str = "2026-06-05T00:00:00+08:00",
|
||||
completion: int = 100,
|
||||
) -> dict:
|
||||
return {
|
||||
"schema_version": "offsite_escrow_readiness_status_v1",
|
||||
"generated_at": generated_at,
|
||||
"source_refs": ["docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json"],
|
||||
"program_status": {
|
||||
"overall_completion_percent": completion,
|
||||
"current_priority": "P1",
|
||||
"current_task_id": "P1-106",
|
||||
"next_task_id": "P1-305",
|
||||
"read_only_mode": True,
|
||||
},
|
||||
"rollups": {
|
||||
"total_cards": 3,
|
||||
"by_readiness": {"verified": 1, "action_required": 1, "blocked": 1},
|
||||
"by_kind": {
|
||||
"offsite_mirror": 1,
|
||||
"credential_escrow": 1,
|
||||
"k8s_resource_offsite": 1,
|
||||
},
|
||||
"verified_offsite_card_ids": ["offsite_rclone_full_sync"],
|
||||
"blocked_escrow_card_ids": ["credential_escrow_markers"],
|
||||
"action_required_card_ids": ["velero_k8s_resources"],
|
||||
"execution_blocked_card_ids": [
|
||||
"offsite_rclone_full_sync",
|
||||
"credential_escrow_markers",
|
||||
"velero_k8s_resources",
|
||||
],
|
||||
},
|
||||
"readiness_cards": [
|
||||
_card("offsite_rclone_full_sync", "offsite_mirror", "verified"),
|
||||
_card("credential_escrow_markers", "credential_escrow", "blocked"),
|
||||
_card("velero_k8s_resources", "k8s_resource_offsite", "action_required"),
|
||||
],
|
||||
"operator_contract": {
|
||||
"display_mode": "read_only_status",
|
||||
"success_notification_policy": "成功狀態不得即時通知洗版。",
|
||||
"failure_notification_policy": "失敗或阻擋維持 action-required。",
|
||||
"credential_display_policy": "只顯示 redacted metadata。",
|
||||
"must_not_interpret_as": [
|
||||
"復原批准",
|
||||
"異地同步批准",
|
||||
"credential marker 寫入批准",
|
||||
"完整 DR 綠燈",
|
||||
],
|
||||
},
|
||||
"operation_boundaries": {
|
||||
"read_only_status_allowed": True,
|
||||
"backup_execution_allowed": False,
|
||||
"restore_execution_allowed": False,
|
||||
"offsite_sync_execution_allowed": False,
|
||||
"credential_marker_write_allowed": False,
|
||||
"credential_read_allowed": False,
|
||||
"secret_plaintext_allowed": False,
|
||||
"schedule_change_allowed": False,
|
||||
"workflow_write_allowed": False,
|
||||
"telegram_test_notification_allowed": False,
|
||||
"destructive_prune_allowed": False,
|
||||
"production_routing_allowed": False,
|
||||
},
|
||||
"approval_boundaries": {
|
||||
"sdk_installation_allowed": False,
|
||||
"paid_api_call_allowed": False,
|
||||
"shadow_or_canary_allowed": False,
|
||||
"production_routing_allowed": False,
|
||||
"destructive_operation_allowed": False,
|
||||
"restore_execution_allowed": False,
|
||||
"offsite_sync_execution_allowed": False,
|
||||
"credential_marker_write_allowed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _card(card_id: str, kind: str, readiness: str) -> dict:
|
||||
return {
|
||||
"card_id": card_id,
|
||||
"target_id": card_id,
|
||||
"display_name": card_id,
|
||||
"kind": kind,
|
||||
"readiness": readiness,
|
||||
"offsite_status": "verified" if readiness == "verified" else "not_applicable",
|
||||
"escrow_status": "missing_markers" if kind == "credential_escrow" else "not_applicable",
|
||||
"restore_drill_status": "approval_required",
|
||||
"credential_exposure_status": "redacted_only",
|
||||
"automation_gate_status": "read_only_allowed",
|
||||
"operator_summary": "只讀狀態。",
|
||||
"next_action": "維持只讀。",
|
||||
"evidence_refs": ["docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json"],
|
||||
"blocked_operations": ["offsite_sync_execution", "credential_marker_write"],
|
||||
}
|
||||
43
apps/api/tests/test_offsite_escrow_readiness_status_api.py
Normal file
43
apps/api/tests/test_offsite_escrow_readiness_status_api.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.v1.agents import router
|
||||
|
||||
|
||||
def test_offsite_escrow_readiness_status_endpoint_returns_committed_snapshot():
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/agents/offsite-escrow-readiness-status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["schema_version"] == "offsite_escrow_readiness_status_v1"
|
||||
assert data["program_status"]["overall_completion_percent"] == 100
|
||||
assert data["program_status"]["read_only_mode"] is True
|
||||
assert data["program_status"]["current_task_id"] == "P1-106"
|
||||
assert data["program_status"]["next_task_id"] == "P1-305"
|
||||
assert data["rollups"]["total_cards"] == len(data["readiness_cards"]) == 3
|
||||
assert data["rollups"]["verified_offsite_card_ids"] == ["offsite_rclone_full_sync"]
|
||||
assert data["rollups"]["blocked_escrow_card_ids"] == ["credential_escrow_markers"]
|
||||
assert data["rollups"]["action_required_card_ids"] == ["velero_k8s_resources"]
|
||||
assert data["operation_boundaries"]["read_only_status_allowed"] is True
|
||||
assert data["operation_boundaries"]["restore_execution_allowed"] is False
|
||||
assert data["operation_boundaries"]["offsite_sync_execution_allowed"] is False
|
||||
assert data["operation_boundaries"]["credential_marker_write_allowed"] is False
|
||||
assert data["operation_boundaries"]["credential_read_allowed"] is False
|
||||
assert data["operation_boundaries"]["secret_plaintext_allowed"] is False
|
||||
assert data["operation_boundaries"]["telegram_test_notification_allowed"] is False
|
||||
assert data["approval_boundaries"]["credential_marker_write_allowed"] is False
|
||||
assert any(
|
||||
card["card_id"] == "credential_escrow_markers" and card["readiness"] == "blocked"
|
||||
for card in data["readiness_cards"]
|
||||
)
|
||||
assert any(
|
||||
card["card_id"] == "velero_k8s_resources" and card["readiness"] == "action_required"
|
||||
for card in data["readiness_cards"]
|
||||
)
|
||||
assert "完整 DR 綠燈" in data["operator_contract"]["must_not_interpret_as"]
|
||||
@@ -3022,7 +3022,31 @@
|
||||
"deferred_until_service_active": "等服務啟用",
|
||||
"suppress_immediate_success": "成功不即時通知",
|
||||
"escalate_immediate": "立即升級",
|
||||
"create_action_required": "建立待處置"
|
||||
"create_action_required": "建立待處置",
|
||||
"missing_markers": "缺 marker",
|
||||
"redacted_only": "僅脫敏",
|
||||
"read_only_allowed": "只讀允許"
|
||||
}
|
||||
},
|
||||
"offsiteEscrow": {
|
||||
"title": "異地 / Escrow 準備度",
|
||||
"source": "{generated} · {current} → {next}",
|
||||
"contractTitle": "顯示契約",
|
||||
"metrics": {
|
||||
"total": "狀態卡",
|
||||
"verified": "異地已驗證",
|
||||
"actionRequired": "需處置",
|
||||
"blocked": "Escrow 阻擋",
|
||||
"executionBlocked": "執行阻擋"
|
||||
},
|
||||
"labels": {
|
||||
"escrow": "Escrow",
|
||||
"credential": "Credential"
|
||||
},
|
||||
"kinds": {
|
||||
"offsite_mirror": "異地鏡像",
|
||||
"credential_escrow": "Credential escrow",
|
||||
"k8s_resource_offsite": "K8s offsite"
|
||||
}
|
||||
},
|
||||
"boundaries": {
|
||||
|
||||
@@ -3022,7 +3022,31 @@
|
||||
"deferred_until_service_active": "等服務啟用",
|
||||
"suppress_immediate_success": "成功不即時通知",
|
||||
"escalate_immediate": "立即升級",
|
||||
"create_action_required": "建立待處置"
|
||||
"create_action_required": "建立待處置",
|
||||
"missing_markers": "缺 marker",
|
||||
"redacted_only": "僅脫敏",
|
||||
"read_only_allowed": "只讀允許"
|
||||
}
|
||||
},
|
||||
"offsiteEscrow": {
|
||||
"title": "異地 / Escrow 準備度",
|
||||
"source": "{generated} · {current} → {next}",
|
||||
"contractTitle": "顯示契約",
|
||||
"metrics": {
|
||||
"total": "狀態卡",
|
||||
"verified": "異地已驗證",
|
||||
"actionRequired": "需處置",
|
||||
"blocked": "Escrow 阻擋",
|
||||
"executionBlocked": "執行阻擋"
|
||||
},
|
||||
"labels": {
|
||||
"escrow": "Escrow",
|
||||
"credential": "Credential"
|
||||
},
|
||||
"kinds": {
|
||||
"offsite_mirror": "異地鏡像",
|
||||
"credential_escrow": "Credential escrow",
|
||||
"k8s_resource_offsite": "K8s offsite"
|
||||
}
|
||||
},
|
||||
"boundaries": {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type BackupDrReadinessMatrixSnapshot,
|
||||
type BackupDrTargetInventorySnapshot,
|
||||
type BackupNotificationPolicySnapshot,
|
||||
type OffsiteEscrowReadinessStatusSnapshot,
|
||||
} from '@/lib/api-client'
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
@@ -203,6 +204,7 @@ export function AutomationInventoryTab() {
|
||||
const [backupTargets, setBackupTargets] = useState<BackupDrTargetInventorySnapshot | null>(null)
|
||||
const [backupReadiness, setBackupReadiness] = useState<BackupDrReadinessMatrixSnapshot | null>(null)
|
||||
const [backupPolicy, setBackupPolicy] = useState<BackupNotificationPolicySnapshot | null>(null)
|
||||
const [offsiteEscrow, setOffsiteEscrow] = useState<OffsiteEscrowReadinessStatusSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
@@ -214,13 +216,15 @@ export function AutomationInventoryTab() {
|
||||
apiClient.getBackupDrTargetInventory(),
|
||||
apiClient.getBackupDrReadinessMatrix(),
|
||||
apiClient.getBackupNotificationPolicy(),
|
||||
apiClient.getOffsiteEscrowReadinessStatus(),
|
||||
])
|
||||
.then(([inventoryData, backlogData, targetData, readinessData, policyData]) => {
|
||||
.then(([inventoryData, backlogData, targetData, readinessData, policyData, offsiteEscrowData]) => {
|
||||
setSnapshot(inventoryData)
|
||||
setBacklog(backlogData)
|
||||
setBackupTargets(targetData)
|
||||
setBackupReadiness(readinessData)
|
||||
setBackupPolicy(policyData)
|
||||
setOffsiteEscrow(offsiteEscrowData)
|
||||
setError(false)
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
@@ -282,6 +286,17 @@ export function AutomationInventoryTab() {
|
||||
.slice(0, 6)
|
||||
}, [backupTargets])
|
||||
|
||||
const visibleOffsiteEscrowCards = useMemo(() => {
|
||||
if (!offsiteEscrow) return []
|
||||
const priority = { blocked: 0, action_required: 1, verified: 2 } as Record<string, number>
|
||||
return [...offsiteEscrow.readiness_cards].sort((a, b) => {
|
||||
const left = priority[a.readiness] ?? 3
|
||||
const right = priority[b.readiness] ?? 3
|
||||
if (left !== right) return left - right
|
||||
return a.card_id.localeCompare(b.card_id)
|
||||
})
|
||||
}, [offsiteEscrow])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 20, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-kpi-grid">
|
||||
@@ -295,7 +310,7 @@ export function AutomationInventoryTab() {
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy) {
|
||||
if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy || !offsiteEscrow) {
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
<GlassCard variant="subtle" padding="lg">
|
||||
@@ -338,6 +353,10 @@ export function AutomationInventoryTab() {
|
||||
const blockedBackupRows = backupReadiness.rollups.by_overall_readiness.blocked ?? 0
|
||||
const suppressedSuccessRules = backupPolicy.rollups.by_decision.suppress_immediate_success ?? 0
|
||||
const immediateEscalationRules = backupPolicy.rollups.by_decision.escalate_immediate ?? 0
|
||||
const verifiedOffsiteCards = offsiteEscrow.rollups.by_readiness.verified ?? 0
|
||||
const actionRequiredOffsiteCards = offsiteEscrow.rollups.by_readiness.action_required ?? 0
|
||||
const blockedEscrowCards = offsiteEscrow.rollups.by_readiness.blocked ?? 0
|
||||
const executionBlockedCards = offsiteEscrow.rollups.execution_blocked_card_ids.length
|
||||
const blockedApprovals = Object.entries(snapshot.approval_boundaries)
|
||||
.filter(([, allowed]) => allowed === false)
|
||||
.map(([key]) => key)
|
||||
@@ -350,6 +369,14 @@ export function AutomationInventoryTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const kindLabel = (value: string) => {
|
||||
try {
|
||||
return t(`offsiteEscrow.kinds.${value}` as never)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
@@ -640,6 +667,94 @@ export function AutomationInventoryTab() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||
<Archive size={14} style={{ color: '#d97757' }} />
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('offsiteEscrow.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
|
||||
{t('offsiteEscrow.source', {
|
||||
generated: formatDateTime(offsiteEscrow.generated_at),
|
||||
current: offsiteEscrow.program_status.current_task_id,
|
||||
next: offsiteEscrow.program_status.next_task_id,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-offsite-kpi-grid">
|
||||
<MetricCard label={t('offsiteEscrow.metrics.total')} value={offsiteEscrow.rollups.total_cards} icon={<HardDrive size={16} />} />
|
||||
<MetricCard label={t('offsiteEscrow.metrics.verified')} value={verifiedOffsiteCards} tone="ok" icon={<ShieldCheck size={16} />} />
|
||||
<MetricCard label={t('offsiteEscrow.metrics.actionRequired')} value={actionRequiredOffsiteCards} tone="warn" icon={<AlertTriangle size={16} />} />
|
||||
<MetricCard label={t('offsiteEscrow.metrics.blocked')} value={blockedEscrowCards} tone="danger" icon={<Lock size={16} />} />
|
||||
<MetricCard label={t('offsiteEscrow.metrics.executionBlocked')} value={executionBlockedCards} tone="danger" icon={<Lock size={16} />} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.35fr) minmax(0, 0.65fr)', gap: 12 }} className="automation-inventory-offsite-grid">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 10 }} className="automation-inventory-offsite-card-grid">
|
||||
{visibleOffsiteEscrowCards.map(card => (
|
||||
<div key={card.card_id} style={{ padding: 11, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontFamily: 'Syne, sans-serif',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: '#141413',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{card.display_name}
|
||||
</span>
|
||||
<Chip value={statusLabel(card.readiness)} muted={card.readiness === 'verified'} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
<Chip value={kindLabel(card.kind)} />
|
||||
<Chip value={`${t('backupEvidence.labels.offsite')}: ${statusLabel(card.offsite_status)}`} muted={evidenceTone(card.offsite_status) === 'ok'} />
|
||||
<Chip value={`${t('offsiteEscrow.labels.escrow')}: ${statusLabel(card.escrow_status)}`} muted={card.escrow_status === 'not_applicable'} />
|
||||
<Chip value={`${t('backupEvidence.labels.restore')}: ${statusLabel(card.restore_drill_status)}`} muted={card.restore_drill_status === 'not_applicable'} />
|
||||
<Chip value={statusLabel(card.automation_gate_status)} muted />
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
|
||||
{card.operator_summary}
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#141413', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
|
||||
{card.next_action}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
<Chip value={`${t('offsiteEscrow.labels.credential')}: ${statusLabel(card.credential_exposure_status)}`} muted />
|
||||
<Chip value={card.evidence_refs[0] ?? t('backupEvidence.noEvidence')} muted />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('offsiteEscrow.contractTitle')}
|
||||
</span>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.5, overflowWrap: 'anywhere' }}>
|
||||
{offsiteEscrow.operator_contract.credential_display_policy}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7, minWidth: 0 }}>
|
||||
<Chip value={offsiteEscrow.operator_contract.success_notification_policy} muted />
|
||||
<Chip value={offsiteEscrow.operator_contract.failure_notification_policy} muted />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
|
||||
{offsiteEscrow.operator_contract.must_not_interpret_as.slice(0, 6).map(item => (
|
||||
<Chip key={item} value={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.2fr) minmax(0, 0.8fr)', gap: 12 }} className="automation-inventory-bottom-grid">
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
|
||||
@@ -704,6 +819,9 @@ export function AutomationInventoryTab() {
|
||||
.automation-inventory-backup-kpi-grid,
|
||||
.automation-inventory-backup-evidence-grid,
|
||||
.automation-inventory-backup-readiness-grid,
|
||||
.automation-inventory-offsite-kpi-grid,
|
||||
.automation-inventory-offsite-grid,
|
||||
.automation-inventory-offsite-card-grid,
|
||||
.automation-inventory-bottom-grid,
|
||||
.automation-inventory-task-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
|
||||
@@ -276,6 +276,11 @@ export const apiClient = {
|
||||
const res = await fetch(`${API_BASE_URL}/agents/backup-notification-policy`)
|
||||
return handleResponse<BackupNotificationPolicySnapshot>(res)
|
||||
},
|
||||
|
||||
async getOffsiteEscrowReadinessStatus() {
|
||||
const res = await fetch(`${API_BASE_URL}/agents/offsite-escrow-readiness-status`)
|
||||
return handleResponse<OffsiteEscrowReadinessStatusSnapshot>(res)
|
||||
},
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -859,3 +864,50 @@ export interface BackupNotificationPolicySnapshot {
|
||||
approval_boundaries: Record<string, false>
|
||||
operation_boundaries: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface OffsiteEscrowReadinessStatusSnapshot {
|
||||
schema_version: 'offsite_escrow_readiness_status_v1'
|
||||
generated_at: string
|
||||
source_refs: string[]
|
||||
program_status: {
|
||||
overall_completion_percent: number
|
||||
current_priority: 'P0' | 'P1' | 'P2' | 'P3'
|
||||
current_task_id: string
|
||||
next_task_id: string
|
||||
read_only_mode: true
|
||||
}
|
||||
rollups: {
|
||||
total_cards: number
|
||||
by_readiness: Record<string, number>
|
||||
by_kind: Record<string, number>
|
||||
verified_offsite_card_ids: string[]
|
||||
blocked_escrow_card_ids: string[]
|
||||
action_required_card_ids: string[]
|
||||
execution_blocked_card_ids: string[]
|
||||
}
|
||||
readiness_cards: Array<{
|
||||
card_id: string
|
||||
target_id: string
|
||||
display_name: string
|
||||
kind: 'offsite_mirror' | 'credential_escrow' | 'k8s_resource_offsite'
|
||||
readiness: 'verified' | 'action_required' | 'blocked'
|
||||
offsite_status: string
|
||||
escrow_status: string
|
||||
restore_drill_status: string
|
||||
credential_exposure_status: string
|
||||
automation_gate_status: string
|
||||
operator_summary: string
|
||||
next_action: string
|
||||
evidence_refs: string[]
|
||||
blocked_operations: string[]
|
||||
}>
|
||||
operator_contract: {
|
||||
display_mode: 'read_only_status'
|
||||
success_notification_policy: string
|
||||
failure_notification_policy: string
|
||||
credential_display_policy: string
|
||||
must_not_interpret_as: string[]
|
||||
}
|
||||
approval_boundaries: Record<string, false>
|
||||
operation_boundaries: Record<string, boolean>
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|---|---:|---|---|
|
||||
| Agent 市場治理 | 72% | 進行中 | `agent_market_governance_snapshot_v1`、API、UI 分頁、每週觀察流程 |
|
||||
| Nemotron 實際整合應用 | 30% | 完整回放前仍被關卡擋下 | `blocked_needs_evidence`,下一關是 `refresh_source_evidence_then_5_record_smoke_only` |
|
||||
| 工具 / 服務 / 套件 AI 自動化 | 100% | P0 已完成,P1 套件 / 供應鏈主線已完成;備份 / DR 主線已完成到復原演練批准包,下一主線是異地 / escrow 準備度顯示 | 狀態分類、盤點 schema、權限矩陣、靜態盤點種子、只讀 API、UI 骨架、驗證、自動化待辦 schema / 快照 / API / 分組 UI、Backup / DR 目標盤點、準備度矩陣、備份通知政策、Backup / DR 證據 UI、復原演練批准包模板、Python 套件 / 供應鏈只讀基線、JS pnpm/npm 只讀基線、Docker build surface 只讀基線、CVE / license / drift 嚴重度政策、定期依賴漂移與外部資料來源檢查設計、依賴升級批准包模板已完成 |
|
||||
| 工具 / 服務 / 套件 AI 自動化 | 100% | P0 已完成,P1 套件 / 供應鏈主線已完成;備份 / DR 主線已完成到異地 / escrow 準備度顯示,下一主線是 P1-305 / P1-306 任務批准邊界與進度彙總細節 | 狀態分類、盤點 schema、權限矩陣、靜態盤點種子、只讀 API、UI 骨架、驗證、自動化待辦 schema / 快照 / API / 分組 UI、Backup / DR 目標盤點、準備度矩陣、備份通知政策、Backup / DR 證據 UI、復原演練批准包模板、異地 / escrow 準備度狀態、Python 套件 / 供應鏈只讀基線、JS pnpm/npm 只讀基線、Docker build surface 只讀基線、CVE / license / drift 嚴重度政策、定期依賴漂移與外部資料來源檢查設計、依賴升級批准包模板已完成 |
|
||||
| 本工作清單與分析報告 | 100% | 已完成 | 本 MD 文件 |
|
||||
|
||||
整體計畫完成度:**100%**。
|
||||
@@ -713,7 +713,7 @@ API:
|
||||
核心裁決:
|
||||
|
||||
- UI 只顯示備份目標、readiness matrix、通知政策、關鍵 blocker 與 evidence ref。
|
||||
- `configs_capture` 與 `credential_escrow_markers` 仍為 blocked,不得宣稱 full DR green。
|
||||
- `configs_capture` 與 `credential_escrow_markers` 仍為 blocked,不得宣稱完整 DR 綠燈。
|
||||
- `signoz` 顯示 disruptive backup guard;Agent 不得任意觸發會短暫停止 collector 的備份。
|
||||
- 成功備份仍不即時送 Telegram / AwoooP;成功狀態由每日摘要與查詢承載。
|
||||
- warning、failed、action-required、core blocker 才能進即時升級。
|
||||
@@ -786,6 +786,69 @@ API:
|
||||
- `PYTHONDONTWRITEBYTECODE=1 apps/api/.venv/bin/python -m pytest apps/api/tests/test_backup_restore_drill_approval_package_template.py apps/api/tests/test_backup_restore_drill_approval_package_template_api.py -q`:`11 passed`。
|
||||
- `python3 -m py_compile apps/api/src/services/backup_restore_drill_approval_package_template.py apps/api/src/api/v1/agents.py apps/api/tests/test_backup_restore_drill_approval_package_template.py apps/api/tests/test_backup_restore_drill_approval_package_template_api.py` 通過。
|
||||
|
||||
### P1-106 異地 / Escrow 準備度狀態摘要
|
||||
|
||||
正式 JSON Schema:
|
||||
|
||||
- `docs/schemas/offsite_escrow_readiness_status_v1.schema.json`
|
||||
|
||||
正式 JSON Snapshot:
|
||||
|
||||
- `docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json`
|
||||
|
||||
API:
|
||||
|
||||
- `GET /api/v1/agents/offsite-escrow-readiness-status`
|
||||
|
||||
UI:
|
||||
|
||||
- `/zh-TW/governance?tab=automation-inventory`
|
||||
|
||||
狀態內容:
|
||||
|
||||
- 狀態卡:`3`
|
||||
- 異地已驗證:`1`,`offsite_rclone_full_sync`
|
||||
- 需處置:`1`,`velero_k8s_resources`
|
||||
- Escrow 阻擋:`1`,`credential_escrow_markers`
|
||||
- 執行阻擋:`3`
|
||||
|
||||
核心裁決:
|
||||
|
||||
- P1-106 只顯示異地 / escrow readiness,不批准 offsite sync、restore、credential marker write 或 secret read。
|
||||
- `offsite_rclone_full_sync` 可顯示 verified,但 sync execution 仍 blocked;成功不即時送 Telegram / AwoooP 洗版。
|
||||
- `credential_escrow_markers` 必須維持 blocked;UI 只能顯示 redacted marker metadata 與 evidence refs,不得顯示 token、password、private key、cookie、authorization header、runner token、webhook secret、rclone credential 或 secret payload value。
|
||||
- `velero_k8s_resources` 必須維持 action_required;restore drill 仍需 OpenClaw 仲裁與 HITL。
|
||||
- P1-106 UI 可見不得被 Agent 或 operator 解讀為完整 DR 綠燈。
|
||||
|
||||
實作邊界:
|
||||
|
||||
- 不執行 backup。
|
||||
- 不執行 restore。
|
||||
- 不執行 offsite sync。
|
||||
- 不寫 credential marker。
|
||||
- 不讀 credential。
|
||||
- 不輸出 secret 明文。
|
||||
- 不改排程、不寫 workflow。
|
||||
- 不發 Telegram 測試訊息。
|
||||
- 不做 destructive prune。
|
||||
- 不改生產路由。
|
||||
|
||||
驗證:
|
||||
|
||||
- `python3 -m json.tool docs/schemas/offsite_escrow_readiness_status_v1.schema.json` 通過。
|
||||
- `python3 -m json.tool docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json` 通過。
|
||||
- `python3 -m json.tool docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json` 通過。
|
||||
- `python3 -m json.tool docs/evaluations/ai_agent_automation_backlog_2026-06-04.json` 通過。
|
||||
- `python3 -m json.tool apps/web/messages/zh-TW.json apps/web/messages/en.json` 通過。
|
||||
- `PYTHONDONTWRITEBYTECODE=1 /Users/ogt/awoooi/apps/api/.venv/bin/python -m pytest apps/api/tests/test_offsite_escrow_readiness_status.py apps/api/tests/test_offsite_escrow_readiness_status_api.py apps/api/tests/test_ai_agent_automation_inventory_snapshot.py apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py apps/api/tests/test_ai_agent_automation_backlog_snapshot.py apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py -q`:`21 passed`。
|
||||
- `python3 -m py_compile apps/api/src/services/offsite_escrow_readiness_status.py apps/api/src/api/v1/agents.py apps/api/tests/test_offsite_escrow_readiness_status.py apps/api/tests/test_offsite_escrow_readiness_status_api.py apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py` 通過。
|
||||
- `/Users/ogt/awoooi/apps/web/node_modules/.bin/tsc --noEmit --tsBuildInfoFile /tmp/awoooi-p1-106-offsite-escrow-readiness-after-ff2.tsbuildinfo -p apps/web/tsconfig.json` 在 clean worktree 透過既有 node_modules toolchain 通過,不執行 `pnpm install`、不改 lockfile。
|
||||
- `NEXT_PUBLIC_API_URL=https://awoooi.wooo.work NEXT_PRIVATE_BUILD_WORKER_COUNT=1 SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 pnpm --filter @awoooi/web build` 通過;`/zh-TW/governance` First Load JS `381 kB`。
|
||||
- `python3 scripts/security/source-control-owner-response-guard.py --root .`:`SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK`。
|
||||
- `python3 scripts/security/security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。
|
||||
- `git diff --check` 通過。
|
||||
- Production desktop / 390px mobile browser checks 需等 Gitea CD 正式部署後回寫 `docs/LOGBOOK.md`。
|
||||
|
||||
### P0 - 治理與 Inventory 基礎
|
||||
|
||||
| ID | 狀態 | % | 負責 Agent | 任務 | 產出 | 關卡 |
|
||||
@@ -820,7 +883,7 @@ API:
|
||||
| P1-103 | 完成 | 100 | Hermes | 對齊備份通知政策 | `docs/evaluations/backup_notification_policy_2026-06-04.json` | 不發成功洗版 |
|
||||
| P1-104 | 完成 | 100 | OpenClaw | 在 AwoooP / governance UI 加備份證據 | `/zh-TW/governance?tab=automation-inventory` | 只讀 + 瀏覽器驗證 |
|
||||
| P1-105 | 完成 | 100 | OpenClaw | 定義復原演練批准包 | `docs/evaluations/backup_restore_drill_approval_package_template_2026-06-05.json` | 只讀模板 + 人工批准 |
|
||||
| P1-106 | 待辦 | 0 | Hermes | 顯示異地 / escrow 準備度狀態 | DR 準備度區塊 | 不暴露 credential |
|
||||
| P1-106 | 完成 | 100 | Hermes | 顯示異地 / escrow 準備度狀態 | `docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json` + `/zh-TW/governance?tab=automation-inventory` | 不暴露 credential |
|
||||
|
||||
### P1 - 套件與供應鏈自動化
|
||||
|
||||
@@ -965,17 +1028,17 @@ API:
|
||||
```text
|
||||
進度:100%。
|
||||
目前優先級:P1。
|
||||
目前任務:P1-105 定義復原演練批准包。
|
||||
目前任務:P1-106 顯示異地 / escrow 準備度狀態。
|
||||
狀態變更:待辦 -> 完成。
|
||||
證據:backup_restore_drill_approval_package_template schema / snapshot JSON parse 通過;service + API tests 11 passed;py_compile 通過;API 端點為 GET /api/v1/agents/backup-restore-drill-approval-package-template。
|
||||
阻擋:無;backup、restore、offsite sync、credential marker、排程、workflow、Telegram 測試通知、secret 明文、生產路由仍未批准。
|
||||
下一步:P1-106 顯示異地 / escrow 準備度狀態。
|
||||
證據:offsite_escrow_readiness_status schema / snapshot JSON parse 通過;service + API + inventory + backlog 目標測試 21 passed;web typecheck / build 通過;API 端點為 GET /api/v1/agents/offsite-escrow-readiness-status;UI 已接入 governance automation inventory tab。
|
||||
阻擋:無;backup、restore、offsite sync、credential marker、credential read、排程、workflow、Telegram 測試通知、secret 明文、生產路由仍未批准。
|
||||
下一步:P1-305 / P1-306 補每個任務的批准邊界與進度彙總細節。
|
||||
```
|
||||
|
||||
## 13. 立即執行順序
|
||||
|
||||
1. P1-106:顯示異地 / escrow 準備度狀態。
|
||||
2. P1-305 / P1-306:補每個任務的批准邊界與進度彙總細節。
|
||||
1. P1-305 / P1-306:補每個任務的批准邊界與進度彙總細節。
|
||||
2. P1-001:盤點 API / Web / Worker / K8s runtime surface。
|
||||
3. P2 / P3 必須等 P1 可見且關卡穩定後再做。
|
||||
|
||||
## 14. 目前風險
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"schema_version": "ai_agent_automation_backlog_v1",
|
||||
"generated_at": "2026-06-05T05:36:00+08:00",
|
||||
"generated_at": "2026-06-05T08:40:00+08:00",
|
||||
"source_inventory_snapshot_ref": "docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json",
|
||||
"program_status": {
|
||||
"overall_completion_percent": 100,
|
||||
"current_priority": "P1",
|
||||
"current_task_id": "P1-105",
|
||||
"next_task_id": "P1-106",
|
||||
"current_task_id": "P1-106",
|
||||
"next_task_id": "P1-305",
|
||||
"read_only_mode": true
|
||||
},
|
||||
"rollups": {
|
||||
"total_items": 20,
|
||||
"total_items": 21,
|
||||
"by_priority": {
|
||||
"P1": 18,
|
||||
"P1": 19,
|
||||
"P2": 1,
|
||||
"P3": 1
|
||||
},
|
||||
"by_status": {
|
||||
"planned": 7,
|
||||
"done": 13
|
||||
"done": 14
|
||||
},
|
||||
"by_gate_status": {
|
||||
"read_only_allowed": 17,
|
||||
"read_only_allowed": 18,
|
||||
"production_change_blocked": 1,
|
||||
"cost_approval_required": 1,
|
||||
"blocked_by_evidence": 1
|
||||
},
|
||||
"by_owner_agent": {
|
||||
"hermes": 10,
|
||||
"hermes": 11,
|
||||
"openclaw": 9,
|
||||
"nemotron": 1
|
||||
}
|
||||
@@ -334,6 +334,34 @@
|
||||
],
|
||||
"next_review": "P1-105"
|
||||
},
|
||||
{
|
||||
"item_id": "AUTO-P1-106",
|
||||
"priority": "P1",
|
||||
"status": "done",
|
||||
"workstream_id": "WS4",
|
||||
"source_asset_id": "offsite_escrow_readiness_status",
|
||||
"source_signal_kind": "ui_visibility_gap",
|
||||
"title": "顯示異地 / escrow 準備度狀態",
|
||||
"owner_agent": "hermes",
|
||||
"recommended_action": "建立 read-only offsite / escrow readiness status 與治理頁狀態區塊,顯示 offsite verified、credential escrow blocked、Velero action-required 與 credential redaction policy。",
|
||||
"action_class": "execute_read_only",
|
||||
"gate_status": "read_only_allowed",
|
||||
"risk_level": "critical",
|
||||
"evidence_refs": [
|
||||
"docs/schemas/offsite_escrow_readiness_status_v1.schema.json",
|
||||
"docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json",
|
||||
"GET /api/v1/agents/offsite-escrow-readiness-status",
|
||||
"apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx"
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
"不執行 offsite sync、backup、restore 或 Velero restore",
|
||||
"不寫 credential marker、不讀 credential、不輸出 secret 明文",
|
||||
"UI 必須把 credential_escrow_markers 維持 blocked,不能解讀成 full DR green",
|
||||
"成功 offsite evidence 不即時送 Telegram / AwoooP 洗版",
|
||||
"desktop 與 390px mobile 無橫向溢出"
|
||||
],
|
||||
"next_review": "P1-106"
|
||||
},
|
||||
{
|
||||
"item_id": "AUTO-P1-201",
|
||||
"priority": "P1",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schema_version": "ai_agent_automation_inventory_snapshot_v1",
|
||||
"generated_at": "2026-06-05T05:36:00+08:00",
|
||||
"generated_at": "2026-06-05T08:40:00+08:00",
|
||||
"program_status": {
|
||||
"overall_completion_percent": 100,
|
||||
"current_priority": "P1",
|
||||
"current_task_id": "P1-105",
|
||||
"next_task_id": "P1-106",
|
||||
"current_task_id": "P1-106",
|
||||
"next_task_id": "P1-305",
|
||||
"read_only_mode": true
|
||||
},
|
||||
"status_taxonomy": {
|
||||
@@ -423,9 +423,9 @@
|
||||
{
|
||||
"workstream_id": "WS4",
|
||||
"display_name": "備份與 DR 自動化",
|
||||
"completion_percent": 83,
|
||||
"status": "in_progress",
|
||||
"next_task_id": "P1-106"
|
||||
"completion_percent": 100,
|
||||
"status": "done",
|
||||
"next_task_id": "P1-305"
|
||||
},
|
||||
{
|
||||
"workstream_id": "WS5",
|
||||
@@ -451,7 +451,7 @@
|
||||
{
|
||||
"workstream_id": "WS8",
|
||||
"display_name": "產品 UI",
|
||||
"completion_percent": 82,
|
||||
"completion_percent": 86,
|
||||
"status": "in_progress",
|
||||
"next_task_id": "P1-305"
|
||||
}
|
||||
@@ -644,6 +644,17 @@
|
||||
"gate_status": "read_only_allowed",
|
||||
"next_action": "完成,P1-106 顯示異地 / escrow 準備度狀態。"
|
||||
},
|
||||
{
|
||||
"task_id": "P1-106",
|
||||
"priority": "P1",
|
||||
"status": "done",
|
||||
"completion_percent": 100,
|
||||
"owner_agent": "hermes",
|
||||
"title": "顯示異地 / escrow 準備度狀態",
|
||||
"output": "docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json + /zh-TW/governance?tab=automation-inventory",
|
||||
"gate_status": "read_only_allowed",
|
||||
"next_action": "完成,P1-305 / P1-306 補任務批准邊界與進度彙總細節。"
|
||||
},
|
||||
{
|
||||
"task_id": "P1-201",
|
||||
"priority": "P1",
|
||||
@@ -856,6 +867,24 @@
|
||||
"ref": "GET /api/v1/agents/backup-restore-drill-approval-package-template",
|
||||
"result": "復原演練批准包模板只讀 API 已新增,只回傳 committed template,不執行 backup、restore、offsite sync、不寫 credential marker、不送 Telegram 測試通知、不改生產路由。"
|
||||
},
|
||||
{
|
||||
"evidence_id": "offsite_escrow_readiness_status_schema",
|
||||
"kind": "schema",
|
||||
"ref": "docs/schemas/offsite_escrow_readiness_status_v1.schema.json",
|
||||
"result": "異地 / Escrow 準備度狀態 schema 已建立,明確禁止 offsite sync、credential marker 寫入、credential read、secret 明文、restore、workflow 寫入與 Telegram 測試通知。"
|
||||
},
|
||||
{
|
||||
"evidence_id": "offsite_escrow_readiness_status_snapshot",
|
||||
"kind": "doc",
|
||||
"ref": "docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json",
|
||||
"result": "異地 / Escrow 準備度快照已建立;offsite_rclone_full_sync verified、credential_escrow_markers blocked、velero_k8s_resources action_required,全部執行型操作仍 blocked。"
|
||||
},
|
||||
{
|
||||
"evidence_id": "offsite_escrow_readiness_status_api",
|
||||
"kind": "api",
|
||||
"ref": "GET /api/v1/agents/offsite-escrow-readiness-status",
|
||||
"result": "異地 / Escrow 準備度只讀 API 已新增,只回傳 committed status,不執行 backup、restore、offsite sync、不寫 credential marker、不讀 credential、不輸出 secret 明文。"
|
||||
},
|
||||
{
|
||||
"evidence_id": "package_supply_chain_inventory_schema",
|
||||
"kind": "schema",
|
||||
|
||||
163
docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json
Normal file
163
docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json
Normal file
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"schema_version": "offsite_escrow_readiness_status_v1",
|
||||
"generated_at": "2026-06-05T08:40:00+08:00",
|
||||
"source_refs": [
|
||||
"docs/evaluations/backup_dr_target_inventory_2026-06-04.json",
|
||||
"docs/evaluations/backup_dr_readiness_matrix_2026-06-04.json",
|
||||
"docs/evaluations/backup_restore_drill_approval_package_template_2026-06-05.json",
|
||||
"docs/runbooks/OFFSITE-BACKUP-ESCROW-RUNBOOK.md",
|
||||
"docs/HARD_RULES.md"
|
||||
],
|
||||
"program_status": {
|
||||
"overall_completion_percent": 100,
|
||||
"current_priority": "P1",
|
||||
"current_task_id": "P1-106",
|
||||
"next_task_id": "P1-305",
|
||||
"read_only_mode": true
|
||||
},
|
||||
"rollups": {
|
||||
"total_cards": 3,
|
||||
"by_readiness": {
|
||||
"verified": 1,
|
||||
"action_required": 1,
|
||||
"blocked": 1
|
||||
},
|
||||
"by_kind": {
|
||||
"offsite_mirror": 1,
|
||||
"credential_escrow": 1,
|
||||
"k8s_resource_offsite": 1
|
||||
},
|
||||
"verified_offsite_card_ids": [
|
||||
"offsite_rclone_full_sync"
|
||||
],
|
||||
"blocked_escrow_card_ids": [
|
||||
"credential_escrow_markers"
|
||||
],
|
||||
"action_required_card_ids": [
|
||||
"velero_k8s_resources"
|
||||
],
|
||||
"execution_blocked_card_ids": [
|
||||
"offsite_rclone_full_sync",
|
||||
"credential_escrow_markers",
|
||||
"velero_k8s_resources"
|
||||
]
|
||||
},
|
||||
"readiness_cards": [
|
||||
{
|
||||
"card_id": "offsite_rclone_full_sync",
|
||||
"target_id": "offsite_rclone_full_sync",
|
||||
"display_name": "Google Drive / rclone offsite mirror",
|
||||
"kind": "offsite_mirror",
|
||||
"readiness": "verified",
|
||||
"offsite_status": "verified",
|
||||
"escrow_status": "not_applicable",
|
||||
"restore_drill_status": "not_applicable",
|
||||
"credential_exposure_status": "not_applicable",
|
||||
"automation_gate_status": "read_only_allowed",
|
||||
"operator_summary": "latest-only remote mirror 證據已可見且已驗證,但 Agent 觸發異地同步仍維持阻擋。",
|
||||
"next_action": "持續顯示 verify freshness;任何新的 sync 執行都需要獨立人工批准。",
|
||||
"evidence_refs": [
|
||||
"scripts/backup/sync-offsite-backups.sh",
|
||||
"scripts/backup/verify-offsite-full-sync.sh",
|
||||
"docs/runbooks/BACKUP-STATUS.md"
|
||||
],
|
||||
"blocked_operations": [
|
||||
"offsite_sync_execution",
|
||||
"schedule_change",
|
||||
"workflow_write",
|
||||
"telegram_test_notification"
|
||||
]
|
||||
},
|
||||
{
|
||||
"card_id": "credential_escrow_markers",
|
||||
"target_id": "credential_escrow_markers",
|
||||
"display_name": "Credential escrow evidence markers",
|
||||
"kind": "credential_escrow",
|
||||
"readiness": "blocked",
|
||||
"offsite_status": "not_applicable",
|
||||
"escrow_status": "missing_markers",
|
||||
"restore_drill_status": "blocked",
|
||||
"credential_exposure_status": "redacted_only",
|
||||
"automation_gate_status": "credential_approval_required",
|
||||
"operator_summary": "5 個 escrow evidence marker 仍缺失;UI 必須維持 blocked,且不得暴露任何 credential value。",
|
||||
"next_action": "顯示 blocked 狀態;任何 marker 更新都必須走 P1-105 credential escrow review package 與 HITL。",
|
||||
"evidence_refs": [
|
||||
"scripts/backup/mark-credential-escrow-verified.sh",
|
||||
"scripts/backup/offsite-escrow-evidence-report.sh",
|
||||
"docs/runbooks/BACKUP-STATUS.md",
|
||||
"docs/evaluations/backup_restore_drill_approval_package_template_2026-06-05.json"
|
||||
],
|
||||
"blocked_operations": [
|
||||
"credential_marker_write",
|
||||
"credential_read",
|
||||
"secret_plaintext_export",
|
||||
"restore_execution",
|
||||
"telegram_test_notification"
|
||||
]
|
||||
},
|
||||
{
|
||||
"card_id": "velero_k8s_resources",
|
||||
"target_id": "velero_k8s_resources",
|
||||
"display_name": "Velero K8s resource snapshots",
|
||||
"kind": "k8s_resource_offsite",
|
||||
"readiness": "action_required",
|
||||
"offsite_status": "needs_metric_binding",
|
||||
"escrow_status": "not_applicable",
|
||||
"restore_drill_status": "approval_required",
|
||||
"credential_exposure_status": "redacted_only",
|
||||
"automation_gate_status": "restore_approval_required",
|
||||
"operator_summary": "Velero / MinIO freshness 與 independent offsite evidence 仍需 metric binding,才能進入 restore drill 升級判定。",
|
||||
"next_action": "顯示 action-required 狀態;restore drill 仍由 OpenClaw 仲裁與 HITL 批准阻擋。",
|
||||
"evidence_refs": [
|
||||
"docs/runbooks/BACKUP-STATUS.md",
|
||||
"k8s/awoooi-prod/16-cronjob-backup-restore-test.yaml",
|
||||
"docs/evaluations/backup_restore_drill_approval_package_template_2026-06-05.json"
|
||||
],
|
||||
"blocked_operations": [
|
||||
"velero_restore",
|
||||
"kubectl_apply",
|
||||
"secret_restore",
|
||||
"offsite_sync_execution",
|
||||
"production_routing_change"
|
||||
]
|
||||
}
|
||||
],
|
||||
"operator_contract": {
|
||||
"display_mode": "read_only_status",
|
||||
"success_notification_policy": "已驗證的異地證據可進每日摘要;成功狀態不得觸發即時 Telegram / AwoooP 洗版。",
|
||||
"failure_notification_policy": "escrow marker blocked、metric binding gap、verify failure 或 approval-required restore attempt 必須維持 action-required。",
|
||||
"credential_display_policy": "只能顯示 redacted marker metadata 與 evidence refs;禁止顯示 token、password、private key、cookie、authorization header、runner token、webhook secret、rclone credential 與 secret payload value。",
|
||||
"must_not_interpret_as": [
|
||||
"復原批准",
|
||||
"異地同步批准",
|
||||
"credential marker 寫入批准",
|
||||
"secret 讀取批准",
|
||||
"完整 DR 綠燈",
|
||||
"生產路由批准"
|
||||
]
|
||||
},
|
||||
"operation_boundaries": {
|
||||
"read_only_status_allowed": true,
|
||||
"backup_execution_allowed": false,
|
||||
"restore_execution_allowed": false,
|
||||
"offsite_sync_execution_allowed": false,
|
||||
"credential_marker_write_allowed": false,
|
||||
"credential_read_allowed": false,
|
||||
"secret_plaintext_allowed": false,
|
||||
"schedule_change_allowed": false,
|
||||
"workflow_write_allowed": false,
|
||||
"telegram_test_notification_allowed": false,
|
||||
"destructive_prune_allowed": false,
|
||||
"production_routing_allowed": false
|
||||
},
|
||||
"approval_boundaries": {
|
||||
"sdk_installation_allowed": false,
|
||||
"paid_api_call_allowed": false,
|
||||
"shadow_or_canary_allowed": false,
|
||||
"production_routing_allowed": false,
|
||||
"destructive_operation_allowed": false,
|
||||
"restore_execution_allowed": false,
|
||||
"offsite_sync_execution_allowed": false,
|
||||
"credential_marker_write_allowed": false
|
||||
}
|
||||
}
|
||||
324
docs/schemas/offsite_escrow_readiness_status_v1.schema.json
Normal file
324
docs/schemas/offsite_escrow_readiness_status_v1.schema.json
Normal file
@@ -0,0 +1,324 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:awoooi:offsite-escrow-readiness-status-v1",
|
||||
"title": "AWOOOI 異地 / Escrow 準備度狀態 v1",
|
||||
"description": "異地備份、credential escrow 與 K8s resource offsite readiness 的只讀狀態。此 schema 不授權 offsite sync、credential marker 寫入、secret 讀取、restore、workflow 寫入、Telegram 測試通知或任何生產操作。",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"generated_at",
|
||||
"source_refs",
|
||||
"program_status",
|
||||
"rollups",
|
||||
"readiness_cards",
|
||||
"operator_contract",
|
||||
"operation_boundaries",
|
||||
"approval_boundaries"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "offsite_escrow_readiness_status_v1"
|
||||
},
|
||||
"generated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"program_status": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"overall_completion_percent",
|
||||
"current_priority",
|
||||
"current_task_id",
|
||||
"next_task_id",
|
||||
"read_only_mode"
|
||||
],
|
||||
"properties": {
|
||||
"overall_completion_percent": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"current_priority": {
|
||||
"enum": ["P0", "P1", "P2", "P3"]
|
||||
},
|
||||
"current_task_id": {
|
||||
"const": "P1-106"
|
||||
},
|
||||
"next_task_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"read_only_mode": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"rollups": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"total_cards",
|
||||
"by_readiness",
|
||||
"by_kind",
|
||||
"verified_offsite_card_ids",
|
||||
"blocked_escrow_card_ids",
|
||||
"action_required_card_ids",
|
||||
"execution_blocked_card_ids"
|
||||
],
|
||||
"properties": {
|
||||
"total_cards": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"by_readiness": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"by_kind": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"verified_offsite_card_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"blocked_escrow_card_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"action_required_card_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"execution_blocked_card_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"readiness_cards": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"card_id",
|
||||
"target_id",
|
||||
"display_name",
|
||||
"kind",
|
||||
"readiness",
|
||||
"offsite_status",
|
||||
"escrow_status",
|
||||
"restore_drill_status",
|
||||
"credential_exposure_status",
|
||||
"automation_gate_status",
|
||||
"operator_summary",
|
||||
"next_action",
|
||||
"evidence_refs",
|
||||
"blocked_operations"
|
||||
],
|
||||
"properties": {
|
||||
"card_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"enum": ["offsite_mirror", "credential_escrow", "k8s_resource_offsite"]
|
||||
},
|
||||
"readiness": {
|
||||
"enum": ["verified", "action_required", "blocked"]
|
||||
},
|
||||
"offsite_status": {
|
||||
"enum": ["verified", "needs_metric_binding", "blocked", "not_applicable"]
|
||||
},
|
||||
"escrow_status": {
|
||||
"enum": ["verified", "missing_markers", "blocked", "not_applicable"]
|
||||
},
|
||||
"restore_drill_status": {
|
||||
"enum": ["approval_required", "blocked", "not_applicable"]
|
||||
},
|
||||
"credential_exposure_status": {
|
||||
"enum": ["redacted_only", "not_applicable"]
|
||||
},
|
||||
"automation_gate_status": {
|
||||
"type": "string"
|
||||
},
|
||||
"operator_summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"next_action": {
|
||||
"type": "string"
|
||||
},
|
||||
"evidence_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"blocked_operations": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operator_contract": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"display_mode",
|
||||
"success_notification_policy",
|
||||
"failure_notification_policy",
|
||||
"credential_display_policy",
|
||||
"must_not_interpret_as"
|
||||
],
|
||||
"properties": {
|
||||
"display_mode": {
|
||||
"const": "read_only_status"
|
||||
},
|
||||
"success_notification_policy": {
|
||||
"type": "string"
|
||||
},
|
||||
"failure_notification_policy": {
|
||||
"type": "string"
|
||||
},
|
||||
"credential_display_policy": {
|
||||
"type": "string"
|
||||
},
|
||||
"must_not_interpret_as": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"operation_boundaries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"read_only_status_allowed",
|
||||
"backup_execution_allowed",
|
||||
"restore_execution_allowed",
|
||||
"offsite_sync_execution_allowed",
|
||||
"credential_marker_write_allowed",
|
||||
"credential_read_allowed",
|
||||
"secret_plaintext_allowed",
|
||||
"schedule_change_allowed",
|
||||
"workflow_write_allowed",
|
||||
"telegram_test_notification_allowed",
|
||||
"destructive_prune_allowed",
|
||||
"production_routing_allowed"
|
||||
],
|
||||
"properties": {
|
||||
"read_only_status_allowed": {
|
||||
"const": true
|
||||
},
|
||||
"backup_execution_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"restore_execution_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"offsite_sync_execution_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"credential_marker_write_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"credential_read_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"secret_plaintext_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"schedule_change_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"workflow_write_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"telegram_test_notification_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"destructive_prune_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"production_routing_allowed": {
|
||||
"const": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"approval_boundaries": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"sdk_installation_allowed",
|
||||
"paid_api_call_allowed",
|
||||
"shadow_or_canary_allowed",
|
||||
"production_routing_allowed",
|
||||
"destructive_operation_allowed",
|
||||
"restore_execution_allowed",
|
||||
"offsite_sync_execution_allowed",
|
||||
"credential_marker_write_allowed"
|
||||
],
|
||||
"properties": {
|
||||
"sdk_installation_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"paid_api_call_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"shadow_or_canary_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"production_routing_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"destructive_operation_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"restore_execution_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"offsite_sync_execution_allowed": {
|
||||
"const": false
|
||||
},
|
||||
"credential_marker_write_allowed": {
|
||||
"const": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3392,7 +3392,7 @@ Phase 6 完成後
|
||||
2. P1-106:顯示異地 / escrow 準備度狀態。
|
||||
3. P1-305 / P1-306:補任務批准邊界與進度彙總細節。
|
||||
|
||||
**裁決:** P1-104 已完成,但仍只屬於 read-only evidence surface。不得執行 backup、restore、offsite sync、credential marker 寫入、排程變更、workflow 寫入或 Telegram 測試通知;不得把 Backup / DR UI 可見解讀成 full DR green。下一步只能產生復原演練與 escrow review 的批准包,必須保留 OpenClaw 仲裁與人工批准邊界。
|
||||
**裁決:** P1-104 已完成,但仍只屬於 read-only evidence surface。不得執行 backup、restore、offsite sync、credential marker 寫入、排程變更、workflow 寫入或 Telegram 測試通知;不得把 Backup / DR UI 可見解讀成完整 DR 綠燈。下一步只能產生復原演練與 escrow review 的批准包,必須保留 OpenClaw 仲裁與人工批准邊界。
|
||||
|
||||
### 2026-06-05 凌晨 (台北) — P1-105 復原演練批准包模板完成
|
||||
|
||||
@@ -3418,3 +3418,28 @@ Phase 6 完成後
|
||||
2. P1-305 / P1-306:補任務批准邊界與進度彙總細節。
|
||||
|
||||
**裁決:** P1-105 只完成批准包模板與只讀 API,不代表已批准任何 restore drill。不得執行 backup、restore、offsite sync、credential marker 寫入、排程變更、workflow 寫入、Telegram 測試通知、secret 明文輸出、destructive prune 或 production routing;blocked / action_required 目標不得被 UI 或 Agent 解讀成 ready。
|
||||
|
||||
### 2026-06-05 上午 (台北) — P1-106 異地 / Escrow 準備度狀態完成
|
||||
|
||||
**觸發**:統帥批准繼續,要求依 `docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md` 的優先順序推進,並同步工作完成度與狀態。
|
||||
|
||||
**已推進:**
|
||||
- P1-106:建立異地 / Escrow 準備度只讀狀態,將 `offsite_rclone_full_sync`、`credential_escrow_markers`、`velero_k8s_resources` 從 Backup / DR readiness matrix 中抽成 operator 可掃描狀態卡。
|
||||
- 新增 `docs/schemas/offsite_escrow_readiness_status_v1.schema.json`,明確禁止 offsite sync、credential marker 寫入、credential read、secret 明文、restore、workflow 寫入、Telegram 測試通知、destructive prune 與 production routing。
|
||||
- 新增 `docs/evaluations/offsite_escrow_readiness_status_2026-06-05.json`;3 張狀態卡中 `offsite_rclone_full_sync=verified`、`credential_escrow_markers=blocked`、`velero_k8s_resources=action_required`,三者執行型操作全部 blocked。
|
||||
- 新增 `GET /api/v1/agents/offsite-escrow-readiness-status`,只讀取 committed snapshot,不呼叫外部來源、不碰 DB/Redis、不執行 backup / restore / offsite sync。
|
||||
- `/zh-TW/governance?tab=automation-inventory` 已接入異地 / Escrow 準備度區塊,顯示狀態卡、blocked execution、credential redaction policy 與不可解讀為完整 DR 綠燈的契約。
|
||||
- `docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json` 已將 `current_task_id` 推進到 `P1-106`、`next_task_id` 推進到 `P1-305`;WS4 備份與 DR 自動化推進到 `100%`。
|
||||
- `docs/evaluations/ai_agent_automation_backlog_2026-06-04.json` 新增 `AUTO-P1-106` done item,rollup 更新為 total `21`、P1 `19`、done `14`、read_only_allowed `18`、Hermes owner `11`。
|
||||
|
||||
**驗證:**
|
||||
- P1-106 schema / snapshot JSON parse 通過。
|
||||
- P1-106 service / API / inventory / backlog 目標測試 `21 passed`。
|
||||
- `py_compile`、web typecheck、Next build、security guards 與 `git diff --check` 通過。
|
||||
- Production browser smoke 需等 Gitea CD 正式部署後回寫 `docs/LOGBOOK.md`。
|
||||
|
||||
**下一步:**
|
||||
1. P1-305 / P1-306:補任務批准邊界與進度彙總細節。
|
||||
2. P1-001:盤點 API / Web / Worker / K8s runtime surface。
|
||||
|
||||
**裁決:** P1-106 只完成異地 / Escrow readiness 的 read-only status 與 UI 顯示,不代表 offsite sync、credential marker、restore drill 或完整 DR 綠燈已批准。成功 offsite evidence 不即時通知洗版;blocked / action-required 仍只能進 action-required 與人工批准流程。
|
||||
|
||||
Reference in New Issue
Block a user