diff --git a/apps/api/src/services/awoooi_priority_work_order_readback.py b/apps/api/src/services/awoooi_priority_work_order_readback.py index 24e55437a..7ed57c3b3 100644 --- a/apps/api/src/services/awoooi_priority_work_order_readback.py +++ b/apps/api/src/services/awoooi_priority_work_order_readback.py @@ -52,6 +52,13 @@ def apply_stockplatform_public_api_runtime_readback( runtime_blockers = _strings(runtime_readback.get("active_blockers")) runtime_rollups = _dict(runtime_readback.get("rollups")) runtime_readback_body = _dict(runtime_readback.get("readback")) + recovery_control_path = _dict(runtime_readback.get("recovery_control_path")) + recovery_control_path_status = str( + recovery_control_path.get("status") or "unknown" + ) + recovery_control_path_blockers = _strings( + recovery_control_path.get("active_blockers") + ) state = _dict(payload.setdefault("mainline_execution_state", {})) state["stockplatform_public_api_runtime_status"] = str( @@ -64,6 +71,12 @@ def apply_stockplatform_public_api_runtime_readback( state["stockplatform_public_api_live_drift_from_committed_scorecard"] = bool( runtime_readback.get("live_drift_from_committed_scorecard") is True ) + state["stockplatform_public_api_recovery_control_path_status"] = ( + recovery_control_path_status + ) + state["stockplatform_public_api_recovery_control_path_active_blockers"] = ( + recovery_control_path_blockers + ) for item in _list(payload.get("in_progress_or_blocked_in_priority_order")): workplan = _dict(item) @@ -90,6 +103,12 @@ def apply_stockplatform_public_api_runtime_readback( evidence["stockplatform_ingestion_http_status"] = ( runtime_readback_body.get("ingestion_http_status") ) + evidence["stockplatform_recovery_control_path_status"] = ( + recovery_control_path_status + ) + evidence["stockplatform_recovery_control_path_active_blockers"] = ( + recovery_control_path_blockers + ) if runtime_ready: continue @@ -124,6 +143,7 @@ def apply_stockplatform_public_api_runtime_readback( existing_blockers + ["stockplatform_public_api_runtime_drift"] + runtime_blockers + + recovery_control_path_blockers ) state["active_p0_state"] = "blocked_stockplatform_public_api_runtime_drift" state["next_executable_mainline_workplan_id"] = ( @@ -141,6 +161,12 @@ def apply_stockplatform_public_api_runtime_readback( "freshness/ingestion are ok; fix runtime control-path evidence " "before claiming the lane is only reboot-window gated." ), + ( + "P0-006-STOCKPLATFORM-RECOVERY-CONTROL-PATH-RECEIPT: provide " + "target selector, check-mode/dry-run, rollback, post-apply " + "public API verifier, and KM/PlayBook/LOG writeback receipt before " + "any runtime apply." + ), ( "P0-006: keep reboot SLO timer live, but do not claim final SLO " "closure while StockPlatform public API readback is blocked." @@ -447,6 +473,18 @@ def _refresh_rollups_after_stockplatform_overlay( state.get("stockplatform_public_api_live_drift_from_committed_scorecard") is True ) + recovery_control_path_blockers = _strings( + state.get("stockplatform_public_api_recovery_control_path_active_blockers") + ) + rollups["stockplatform_public_api_recovery_control_path_status"] = str( + state.get("stockplatform_public_api_recovery_control_path_status") or "unknown" + ) + rollups["stockplatform_public_api_recovery_control_path_blocked"] = bool( + recovery_control_path_blockers + ) + rollups["stockplatform_public_api_recovery_control_path_blocker_count"] = len( + recovery_control_path_blockers + ) rollups["active_p0_live_active_blocker_count"] = len(active_blockers) rollups["active_p0_event_gated_by_fresh_reboot_window_only"] = ( rollups.get("active_p0_event_gated_by_fresh_reboot_window_only") is True @@ -465,6 +503,12 @@ def _refresh_rollups_after_stockplatform_overlay( state.get("stockplatform_public_api_live_drift_from_committed_scorecard") is True ) + summary["stockplatform_public_api_recovery_control_path_status"] = str( + state.get("stockplatform_public_api_recovery_control_path_status") or "unknown" + ) + summary["stockplatform_public_api_recovery_control_path_blockers"] = ( + recovery_control_path_blockers + ) summary["next_executable_mainline_workplan_id"] = str( state.get("next_executable_mainline_workplan_id") or "" ) diff --git a/apps/api/src/services/stockplatform_public_api_runtime_readback.py b/apps/api/src/services/stockplatform_public_api_runtime_readback.py index 56e08fd94..3a3f378c2 100644 --- a/apps/api/src/services/stockplatform_public_api_runtime_readback.py +++ b/apps/api/src/services/stockplatform_public_api_runtime_readback.py @@ -90,6 +90,11 @@ def _build_payload( ingestion_json=ingestion_json, ) ready = all(checks.values()) + recovery_control_path = _recovery_control_path_readback(runtime_ready=ready) + recovery_control_path_blockers = _strings( + recovery_control_path.get("active_blockers") + ) + active_blockers = _unique_strings(active_blockers + recovery_control_path_blockers) committed_freshness_ok = committed_stockplatform.get("freshness_status") == "ok" committed_ingestion_ok = committed_stockplatform.get("ingestion_status") == "ok" committed_ok = committed_freshness_ok and committed_ingestion_ok @@ -139,6 +144,8 @@ def _build_payload( "live_drift_from_committed_scorecard": ( live_drift_from_committed_scorecard ), + "recovery_control_path_status": recovery_control_path.get("status"), + "recovery_control_path_active_blockers": recovery_control_path_blockers, }, "rollups": { "runtime_ready": ready, @@ -159,7 +166,16 @@ def _build_payload( "live_drift_from_committed_scorecard": ( live_drift_from_committed_scorecard ), + "recovery_control_path_ready": ( + recovery_control_path.get("status") + == "not_required_stockplatform_runtime_ready" + ), + "recovery_control_path_blocked": bool(recovery_control_path_blockers), + "recovery_control_path_blocker_count": len( + recovery_control_path_blockers + ), }, + "recovery_control_path": recovery_control_path, "probes": probes, "operation_boundaries": { "read_only_public_https_probe": True, @@ -179,6 +195,44 @@ def _build_payload( } +def _recovery_control_path_readback(*, runtime_ready: bool) -> dict[str, Any]: + if runtime_ready: + return { + "status": "not_required_stockplatform_runtime_ready", + "active_blockers": [], + "safe_recovery_channels": [ + "keep_monitoring_public_api_runtime_readback", + ], + } + return { + "status": "blocked_recovery_control_path_receipt_missing", + "active_blockers": [ + "stockplatform_recovery_control_path_receipt_missing", + "stockplatform_controlled_deploy_or_ssh_readback_required", + ], + "safe_recovery_channels": [ + "existing_stockplatform_manual_deploy_workflow_dispatch_with_runner_pressure_guard", + "bounded_ssh_or_runner_recovery_readback_without_docker_daemon_restart", + ], + "forbidden_recovery_channels": [ + "docker_daemon_restart", + "host_reboot", + "docker_prune_or_volume_restore", + "manual_database_rows_or_fake_freshness", + "restore_push_deploy_trigger_during_110_capacity_freeze", + "read_secret_or_raw_session_values", + ], + "required_receipts": [ + "target_selector", + "source_of_truth_diff_or_runtime_state_diff", + "check_mode_or_dry_run", + "rollback_command", + "post_apply_public_api_verifier", + "km_playbook_log_writeback_receipt", + ], + } + + def _active_blockers( *, api: dict[str, Any], diff --git a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py index 249f81275..31eca832f 100644 --- a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py +++ b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py @@ -104,9 +104,28 @@ def test_awoooi_priority_work_order_readback_overlays_live_stockplatform_drift() assert evidence["stockplatform_public_api_runtime_ready"] is False assert evidence["stockplatform_public_api_http_502_count"] == 3 assert evidence["stockplatform_public_api_health_http_status"] == 502 + assert ( + evidence["stockplatform_recovery_control_path_status"] + == "blocked_recovery_control_path_receipt_missing" + ) + assert "stockplatform_recovery_control_path_receipt_missing" in evidence[ + "stockplatform_recovery_control_path_active_blockers" + ] assert in_progress["status"] == "blocked_stockplatform_public_api_runtime_drift" assert "Do not restart Docker daemon" in in_progress["professional_fix"]["action"] assert payload["rollups"]["stockplatform_public_api_runtime_blocked"] is True + assert ( + payload["rollups"][ + "stockplatform_public_api_recovery_control_path_status" + ] + == "blocked_recovery_control_path_receipt_missing" + ) + assert ( + payload["rollups"][ + "stockplatform_public_api_recovery_control_path_blocker_count" + ] + == 2 + ) assert ( payload["rollups"]["active_p0_event_gated_by_fresh_reboot_window_only"] is False @@ -205,6 +224,8 @@ def _stockplatform_runtime_blocked() -> dict: "stockplatform_public_api_healthz_http_502", "stockplatform_freshness_http_502", "stockplatform_ingestion_http_502", + "stockplatform_recovery_control_path_receipt_missing", + "stockplatform_controlled_deploy_or_ssh_readback_required", ], "runtime_ready": False, "live_drift_from_committed_scorecard": True, @@ -216,4 +237,11 @@ def _stockplatform_runtime_blocked() -> dict: "rollups": { "http_502_count": 3, }, + "recovery_control_path": { + "status": "blocked_recovery_control_path_receipt_missing", + "active_blockers": [ + "stockplatform_recovery_control_path_receipt_missing", + "stockplatform_controlled_deploy_or_ssh_readback_required", + ], + }, } diff --git a/apps/api/tests/test_stockplatform_public_api_runtime_readback.py b/apps/api/tests/test_stockplatform_public_api_runtime_readback.py index f4defee0d..6cb3d9855 100644 --- a/apps/api/tests/test_stockplatform_public_api_runtime_readback.py +++ b/apps/api/tests/test_stockplatform_public_api_runtime_readback.py @@ -32,7 +32,14 @@ def test_stockplatform_public_api_runtime_readback_blocks_live_502(): "stockplatform_public_api_healthz_http_502", "stockplatform_freshness_http_502", "stockplatform_ingestion_http_502", + "stockplatform_recovery_control_path_receipt_missing", + "stockplatform_controlled_deploy_or_ssh_readback_required", ] + assert ( + payload["recovery_control_path"]["status"] + == "blocked_recovery_control_path_receipt_missing" + ) + assert payload["rollups"]["recovery_control_path_blocker_count"] == 2 assert ( payload["operation_boundaries"]["read_only_public_https_probe"] is True ) @@ -73,7 +80,7 @@ def test_stockplatform_public_api_runtime_endpoint_returns_readback(monkeypatch) assert response.status_code == 200 data = response.json() assert data["status"] == "blocked_stockplatform_public_api_runtime_drift" - assert data["active_blocker_count"] == 3 + assert data["active_blocker_count"] == 5 def _probe_public_web_ok_api_502(url: str, timeout_seconds: float) -> dict: diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index e43a86be9..15c0f4739 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,12 @@ +## 2026-06-30 — 16:40 P0-006 StockPlatform recovery control-path readback + +**照主線修正的問題**: +- Production P0-006 已讀回 `7890778b83`,但 StockPlatform public API 仍是 edge `/healthz=200`、`/api/healthz=502`、freshness `502`、ingestion `502`;這不是資料 freshness 假綠,而是 API upstream/runtime drift。 +- SSH 到 110 TCP 可連,但 publickey auth 後等待 host 回應 timeout;StockPlatform Gitea workflow 是 manual-only,不能用 push trigger 繞過 110 capacity freeze。 +- `stockplatform_public_api_runtime_readback` 現在 fail-closed 加入 recovery control-path receipt:缺 target selector、check-mode/dry-run、rollback、post-apply public API verifier、KM/PlayBook/LOG writeback receipt 時,會把 `stockplatform_recovery_control_path_receipt_missing` 與 `stockplatform_controlled_deploy_or_ssh_readback_required` 納入 P0-006 blockers。 + +**邊界**:未觸發 StockPlatform deploy workflow,未恢復 push deploy trigger,未 restart Docker daemon / Nginx / K3s / DB / firewall,未 prune / restore / DB write,未讀 secret / token / raw sessions / SQLite / `.env`,未使用 GitHub / `gh` / GitHub API。 + ## 2026-06-30 — 10:18 production deploy readback window 擴充 **照主線修正的問題**: