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 6f0617539..902ace2e5 100644
--- a/apps/api/src/services/awoooi_priority_work_order_readback.py
+++ b/apps/api/src/services/awoooi_priority_work_order_readback.py
@@ -3509,6 +3509,45 @@ def _enrich_from_current_readbacks(
state["windows99_vmx_source_locator_relink_dry_run_plan_id"] = str(
locator_relink_plan.get("plan_id") or ""
)
+ state["windows99_vmx_source_locator_candidate_confirmation_status"] = str(
+ windows99_vmx_source_locator_readback.get("candidate_confirmation_status")
+ or "unknown"
+ )
+ state["windows99_vmx_source_locator_candidate_identity_decision"] = str(
+ windows99_vmx_source_locator_readback.get("candidate_identity_decision")
+ or "unknown"
+ )
+ state["windows99_vmx_source_locator_candidate_identity_evidence_sufficient"] = bool(
+ windows99_vmx_source_locator_readback.get(
+ "candidate_identity_evidence_sufficient"
+ )
+ is True
+ )
+ state["windows99_vmx_source_locator_candidate_relink_dry_run_ready"] = bool(
+ windows99_vmx_source_locator_readback.get("candidate_relink_dry_run_ready")
+ is True
+ )
+ state["windows99_vmx_source_locator_candidate_confirmation_missing_evidence"] = (
+ _strings(
+ windows99_vmx_source_locator_readback.get(
+ "candidate_confirmation_missing_evidence"
+ )
+ )
+ )
+ state["windows99_vmx_source_locator_candidate_confirmation_safe_next_step"] = str(
+ windows99_vmx_source_locator_readback.get(
+ "candidate_confirmation_safe_next_step"
+ )
+ or ""
+ )
+ state[
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate"
+ ] = bool(
+ windows99_vmx_source_locator_readback.get(
+ "apply_allowed_by_candidate_confirmation_gate"
+ )
+ is True
+ )
state["windows99_vmx_source_locator_candidate_source_count_known"] = bool(
windows99_vmx_source_locator_readback.get("candidate_source_count_known")
is True
@@ -3948,6 +3987,35 @@ def _enrich_from_current_readbacks(
evidence["windows99_vmx_source_locator_relink_dry_run_plan_id"] = state[
"windows99_vmx_source_locator_relink_dry_run_plan_id"
]
+ evidence["windows99_vmx_source_locator_candidate_confirmation_status"] = state[
+ "windows99_vmx_source_locator_candidate_confirmation_status"
+ ]
+ evidence["windows99_vmx_source_locator_candidate_identity_decision"] = state[
+ "windows99_vmx_source_locator_candidate_identity_decision"
+ ]
+ evidence[
+ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient"
+ ] = state[
+ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient"
+ ]
+ evidence["windows99_vmx_source_locator_candidate_relink_dry_run_ready"] = state[
+ "windows99_vmx_source_locator_candidate_relink_dry_run_ready"
+ ]
+ evidence[
+ "windows99_vmx_source_locator_candidate_confirmation_missing_evidence"
+ ] = state[
+ "windows99_vmx_source_locator_candidate_confirmation_missing_evidence"
+ ]
+ evidence[
+ "windows99_vmx_source_locator_candidate_confirmation_safe_next_step"
+ ] = state[
+ "windows99_vmx_source_locator_candidate_confirmation_safe_next_step"
+ ]
+ evidence[
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate"
+ ] = state[
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate"
+ ]
evidence["windows99_vmx_source_locator_candidate_source_count_known"] = state[
"windows99_vmx_source_locator_candidate_source_count_known"
]
@@ -4765,6 +4833,43 @@ def _set_rollups_and_summary(
"windows99_vmx_source_locator_relink_dry_run_plan_id": str(
state.get("windows99_vmx_source_locator_relink_dry_run_plan_id") or ""
),
+ "windows99_vmx_source_locator_candidate_confirmation_status": str(
+ state.get(
+ "windows99_vmx_source_locator_candidate_confirmation_status"
+ )
+ or "unknown"
+ ),
+ "windows99_vmx_source_locator_candidate_identity_decision": str(
+ state.get("windows99_vmx_source_locator_candidate_identity_decision")
+ or "unknown"
+ ),
+ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient": (
+ state.get(
+ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient"
+ )
+ is True
+ ),
+ "windows99_vmx_source_locator_candidate_relink_dry_run_ready": (
+ state.get("windows99_vmx_source_locator_candidate_relink_dry_run_ready")
+ is True
+ ),
+ "windows99_vmx_source_locator_candidate_confirmation_missing_evidence": _strings(
+ state.get(
+ "windows99_vmx_source_locator_candidate_confirmation_missing_evidence"
+ )
+ ),
+ "windows99_vmx_source_locator_candidate_confirmation_safe_next_step": str(
+ state.get(
+ "windows99_vmx_source_locator_candidate_confirmation_safe_next_step"
+ )
+ or ""
+ ),
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate": (
+ state.get(
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate"
+ )
+ is True
+ ),
"windows99_vmx_source_locator_candidate_source_count_known": (
state.get("windows99_vmx_source_locator_candidate_source_count_known")
is True
@@ -5112,6 +5217,43 @@ def _set_rollups_and_summary(
"windows99_vmx_source_locator_relink_dry_run_plan_id": str(
state.get("windows99_vmx_source_locator_relink_dry_run_plan_id") or ""
),
+ "windows99_vmx_source_locator_candidate_confirmation_status": str(
+ state.get(
+ "windows99_vmx_source_locator_candidate_confirmation_status"
+ )
+ or "unknown"
+ ),
+ "windows99_vmx_source_locator_candidate_identity_decision": str(
+ state.get("windows99_vmx_source_locator_candidate_identity_decision")
+ or "unknown"
+ ),
+ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient": (
+ state.get(
+ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient"
+ )
+ is True
+ ),
+ "windows99_vmx_source_locator_candidate_relink_dry_run_ready": (
+ state.get("windows99_vmx_source_locator_candidate_relink_dry_run_ready")
+ is True
+ ),
+ "windows99_vmx_source_locator_candidate_confirmation_missing_evidence": _strings(
+ state.get(
+ "windows99_vmx_source_locator_candidate_confirmation_missing_evidence"
+ )
+ ),
+ "windows99_vmx_source_locator_candidate_confirmation_safe_next_step": str(
+ state.get(
+ "windows99_vmx_source_locator_candidate_confirmation_safe_next_step"
+ )
+ or ""
+ ),
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate": (
+ state.get(
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate"
+ )
+ is True
+ ),
"windows99_vmx_source_locator_candidate_source_count_known": (
state.get("windows99_vmx_source_locator_candidate_source_count_known")
is True
diff --git a/apps/api/src/services/windows99_vmx_source_locator_readback.py b/apps/api/src/services/windows99_vmx_source_locator_readback.py
index 55c949e03..60773dca3 100644
--- a/apps/api/src/services/windows99_vmx_source_locator_readback.py
+++ b/apps/api/src/services/windows99_vmx_source_locator_readback.py
@@ -26,6 +26,9 @@ _SCHEMA_VERSION = "windows99_vmx_source_locator_readback_v1"
_COLLECTOR_SCHEMA_VERSION = "windows99_vmx_source_locator_collector_v1"
_LOCATOR_SCHEMA_VERSION = "windows99_vmx_source_locator_v1"
_RECEIPT_SCHEMA_VERSION = "windows99_vmx_source_locator_receipt_v1"
+_CANDIDATE_CONFIRMATION_SCHEMA_VERSION = (
+ "windows99_vmx_source_candidate_confirmation_gate_v1"
+)
_RECEIPT_FILE = "windows99-vmx-source-locator-receipt.snapshot.json"
_COLLECTOR_SCRIPT_PATH = "scripts/reboot-recovery/locate-windows99-vmx-source.sh"
_LOCATOR_SCRIPT_PATH = "scripts/reboot-recovery/windows99-vmx-source-locator.ps1"
@@ -89,6 +92,120 @@ def _vm_rows_for_aliases(
return rows
+def _build_candidate_confirmation_gate(
+ *,
+ receipt_applies: bool,
+ receipt: dict[str, Any],
+ target_aliases: list[str],
+ receipt_locate_status: str,
+) -> dict[str, Any]:
+ alias_hint_count = _int(receipt.get("alias_hint_candidate_count"), 0) or 0
+ identity_target_hint_count = (
+ _int(receipt.get("identity_target_hint_count"), 0) or 0
+ )
+ identity_unassigned_count = (
+ _int(receipt.get("identity_unassigned_ubuntu_candidate_count"), 0) or 0
+ )
+ candidate_source_count = _int(receipt.get("candidate_source_count"), 0) or 0
+ candidate_confirmation_required = bool(
+ receipt_applies and "requires_confirmation" in receipt_locate_status
+ )
+ confirmed_candidate_count = 0
+ identity_evidence_sufficient = False
+ status = "not_required_no_candidate_confirmation"
+ decision = "not_required"
+ reason = "no active candidate confirmation gate"
+ safe_next_step = "run_locator_collect_or_keep_no_source_locator_required"
+ missing_evidence: list[str] = []
+
+ if not receipt_applies:
+ status = "blocked_locator_receipt_missing"
+ decision = "blocked"
+ reason = "locator receipt is not attached to the current target aliases"
+ safe_next_step = (
+ "run_no_secret_windows99_vmx_source_locator_collect_then_rerun_"
+ "candidate_confirmation_gate"
+ )
+ missing_evidence = ["collector_receipt"]
+ elif alias_hint_count == 1 or identity_target_hint_count == 1:
+ confirmed_candidate_count = 1
+ identity_evidence_sufficient = True
+ status = "candidate_identity_confirmed_ready_for_scoped_relink_dry_run"
+ decision = "confirmed_for_dry_run"
+ reason = "exactly one candidate carries target alias or target identity evidence"
+ safe_next_step = (
+ "build_scoped_relink_dry_run_with_rollback_and_post_verifier_"
+ "no_vm_power_change"
+ )
+ elif alias_hint_count > 1 or identity_target_hint_count > 1:
+ confirmed_candidate_count = alias_hint_count + identity_target_hint_count
+ status = "blocked_multiple_target_hint_candidates"
+ decision = "blocked"
+ reason = "multiple candidates carry target-like evidence"
+ safe_next_step = "collect_authorized_console_candidate_identity_evidence_then_rerun_gate"
+ missing_evidence = ["single_target_candidate"]
+ elif candidate_confirmation_required:
+ status = "blocked_single_unassigned_ubuntu_candidate_identity_not_confirmed"
+ decision = "blocked"
+ reason = (
+ "candidate is still generic Ubuntu evidence; no target alias, MAC, UUID, "
+ "or display-name proof ties it to 111"
+ )
+ safe_next_step = (
+ "collect_authorized_console_candidate_identity_evidence_or_verified_"
+ "backup_path_then_rerun_confirmation_gate"
+ )
+ missing_evidence = [
+ "target_alias_identity_hint",
+ "verified_backup_or_authorized_console_candidate_identity",
+ "pre_apply_backup_plan",
+ "scoped_relink_dry_run",
+ ]
+ elif candidate_source_count <= 0:
+ status = "blocked_no_candidate_source_found"
+ decision = "blocked"
+ reason = "locator found no candidate source for the missing VMX alias"
+ safe_next_step = (
+ "provide_existing_vmx_source_or_verified_backup_path_then_rerun_"
+ "locator_check_mode"
+ )
+ missing_evidence = ["existing_vmx_source_or_verified_backup_path"]
+
+ can_prepare_scoped_relink_dry_run = bool(
+ identity_evidence_sufficient and confirmed_candidate_count == 1
+ )
+
+ return {
+ "schema_version": _CANDIDATE_CONFIRMATION_SCHEMA_VERSION,
+ "status": status,
+ "decision": decision,
+ "reason": reason,
+ "target_aliases": target_aliases,
+ "candidate_confirmation_required": candidate_confirmation_required,
+ "candidate_source_count": candidate_source_count,
+ "alias_hint_candidate_count": alias_hint_count,
+ "identity_target_hint_count": identity_target_hint_count,
+ "identity_unassigned_ubuntu_candidate_count": identity_unassigned_count,
+ "confirmed_candidate_count": confirmed_candidate_count,
+ "identity_evidence_sufficient": identity_evidence_sufficient,
+ "can_prepare_scoped_relink_dry_run": can_prepare_scoped_relink_dry_run,
+ "apply_allowed_by_confirmation_gate": False,
+ "vm_power_change_allowed": False,
+ "remote_write_performed": False,
+ "vm_power_change_performed": False,
+ "secret_value_read": False,
+ "safe_next_step": safe_next_step,
+ "missing_evidence": missing_evidence,
+ "required_evidence": [
+ "single_target_candidate_identity",
+ "source_path_fingerprint_or_authorized_console_artifact",
+ "pre_apply_backup_plan",
+ "scoped_relink_dry_run",
+ "post_apply_no_secret_verifier",
+ ],
+ }
+
+
def build_windows99_vmx_source_locator_readback(
scorecard: dict[str, Any],
*,
@@ -282,6 +399,13 @@ def build_windows99_vmx_source_locator_readback(
"vm_power_change_performed": False,
}
+ candidate_confirmation_gate = _build_candidate_confirmation_gate(
+ receipt_applies=receipt_applies,
+ receipt=receipt,
+ target_aliases=target_aliases,
+ receipt_locate_status=receipt_locate_status,
+ )
+
return {
"schema_version": _SCHEMA_VERSION,
"generated_at": generated_at
@@ -342,6 +466,30 @@ def build_windows99_vmx_source_locator_readback(
"controlled_relink_dry_run_ready": controlled_relink_dry_run_ready,
"controlled_relink_dry_run_blocker": controlled_relink_dry_run_blocker,
"controlled_relink_dry_run_plan": relink_dry_run_plan,
+ "candidate_confirmation_gate": candidate_confirmation_gate,
+ "candidate_confirmation_status": str(
+ candidate_confirmation_gate.get("status") or "unknown"
+ ),
+ "candidate_identity_decision": str(
+ candidate_confirmation_gate.get("decision") or "unknown"
+ ),
+ "candidate_identity_evidence_sufficient": bool(
+ candidate_confirmation_gate.get("identity_evidence_sufficient") is True
+ ),
+ "candidate_relink_dry_run_ready": bool(
+ candidate_confirmation_gate.get("can_prepare_scoped_relink_dry_run")
+ is True
+ ),
+ "candidate_confirmation_missing_evidence": _strings(
+ candidate_confirmation_gate.get("missing_evidence")
+ ),
+ "candidate_confirmation_safe_next_step": str(
+ candidate_confirmation_gate.get("safe_next_step") or ""
+ ),
+ "apply_allowed_by_candidate_confirmation_gate": bool(
+ candidate_confirmation_gate.get("apply_allowed_by_confirmation_gate")
+ is True
+ ),
"public_lan_topology_redacted": True,
"redacted_public_display_only": True,
"raw_path_output": False,
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 3ca434c29..398dc6804 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
@@ -787,6 +787,33 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a
assert state["windows99_vmx_source_locator_relink_dry_run_plan_id"] == (
"windows99-vmx-source-relink-dry-run-111"
)
+ assert state["windows99_vmx_source_locator_candidate_confirmation_status"] == (
+ "blocked_single_unassigned_ubuntu_candidate_identity_not_confirmed"
+ )
+ assert state["windows99_vmx_source_locator_candidate_identity_decision"] == (
+ "blocked"
+ )
+ assert (
+ state[
+ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient"
+ ]
+ is False
+ )
+ assert state["windows99_vmx_source_locator_candidate_relink_dry_run_ready"] is False
+ assert state[
+ "windows99_vmx_source_locator_candidate_confirmation_missing_evidence"
+ ] == [
+ "target_alias_identity_hint",
+ "verified_backup_or_authorized_console_candidate_identity",
+ "pre_apply_backup_plan",
+ "scoped_relink_dry_run",
+ ]
+ assert (
+ state[
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate"
+ ]
+ is False
+ )
assert state["windows99_vmx_source_locator_candidate_source_count_known"] is True
assert state["windows99_vmx_source_locator_collector_readback_present"] is True
assert state["windows99_vmx_source_locator_file_content_read"] is True
@@ -889,6 +916,30 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a
]
is True
)
+ assert payload["summary"][
+ "windows99_vmx_source_locator_candidate_confirmation_status"
+ ] == "blocked_single_unassigned_ubuntu_candidate_identity_not_confirmed"
+ assert payload["summary"][
+ "windows99_vmx_source_locator_candidate_identity_decision"
+ ] == "blocked"
+ assert (
+ payload["summary"][
+ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient"
+ ]
+ is False
+ )
+ assert (
+ payload["summary"][
+ "windows99_vmx_source_locator_candidate_relink_dry_run_ready"
+ ]
+ is False
+ )
+ assert (
+ payload["rollups"][
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate"
+ ]
+ is False
+ )
assert (
payload["summary"][
"windows99_vmx_source_locator_human_confirmation_required_for_dry_run"
@@ -988,6 +1039,21 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a
]
is True
)
+ assert in_progress["evidence"][
+ "windows99_vmx_source_locator_candidate_confirmation_status"
+ ] == "blocked_single_unassigned_ubuntu_candidate_identity_not_confirmed"
+ assert (
+ in_progress["evidence"][
+ "windows99_vmx_source_locator_candidate_relink_dry_run_ready"
+ ]
+ is False
+ )
+ assert (
+ in_progress["evidence"][
+ "windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate"
+ ]
+ is False
+ )
assert (
in_progress["evidence"][
"windows99_vmx_source_locator_human_confirmation_required_for_dry_run"
diff --git a/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py b/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py
index 250bd7480..998f8f660 100644
--- a/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py
+++ b/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py
@@ -172,6 +172,23 @@ def test_windows99_vmx_source_locator_builder_projects_redacted_receipt() -> Non
assert payload["controlled_relink_dry_run_plan"]["source_diff"][
"raw_path_output"
] is False
+ assert payload["candidate_confirmation_status"] == (
+ "blocked_single_unassigned_ubuntu_candidate_identity_not_confirmed"
+ )
+ assert payload["candidate_identity_decision"] == "blocked"
+ assert payload["candidate_identity_evidence_sufficient"] is False
+ assert payload["candidate_relink_dry_run_ready"] is False
+ assert payload["apply_allowed_by_candidate_confirmation_gate"] is False
+ assert payload["candidate_confirmation_missing_evidence"] == [
+ "target_alias_identity_hint",
+ "verified_backup_or_authorized_console_candidate_identity",
+ "pre_apply_backup_plan",
+ "scoped_relink_dry_run",
+ ]
+ assert payload["candidate_confirmation_gate"]["schema_version"] == (
+ "windows99_vmx_source_candidate_confirmation_gate_v1"
+ )
+ assert payload["candidate_confirmation_gate"]["vm_power_change_allowed"] is False
assert payload["next_executable_step"] == (
"provide_verified_vmx_source_or_identity_evidence_then_scoped_relink_dry_run"
)
@@ -221,6 +238,9 @@ def test_windows99_vmx_source_locator_endpoint_returns_public_safe_readback(
"windows99-vmx-source-relink-dry-run-111"
)
assert payload["controlled_relink_dry_run_plan"]["remote_write_performed"] is False
+ assert payload["candidate_identity_evidence_sufficient"] is False
+ assert payload["candidate_relink_dry_run_ready"] is False
+ assert payload["apply_allowed_by_candidate_confirmation_gate"] is False
assert payload["public_lan_topology_redacted"] is True
assert payload["raw_path_output"] is False
assert payload["file_content_read"] is True
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 166348b97..af46e1610 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -9390,6 +9390,13 @@
"locatorIdentityUnassigned": "unassigned {count}",
"locatorNeedsConfirm": "confirm",
"locatorConfirmClosed": "confirm closed",
+ "locatorIdentityGateReady": "identity gate ok",
+ "locatorIdentityGateBlocked": "identity gate blocked",
+ "locatorDryRunReady": "dry-run ready",
+ "locatorDryRunClosed": "dry-run 0",
+ "locatorMissingEvidence": "missing evidence {count}",
+ "locatorApplyGateOpen": "apply gate open",
+ "locatorApplyGateClosed": "apply gate 0",
"locatorCollected": "collected",
"locatorPending": "collect wait",
"noRawPath": "no raw path",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index f060fac49..e56cf15f2 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -9390,6 +9390,13 @@
"locatorIdentityUnassigned": "未指派 {count}",
"locatorNeedsConfirm": "需確認",
"locatorConfirmClosed": "確認關閉",
+ "locatorIdentityGateReady": "identity gate ok",
+ "locatorIdentityGateBlocked": "identity gate blocked",
+ "locatorDryRunReady": "dry-run ready",
+ "locatorDryRunClosed": "dry-run 0",
+ "locatorMissingEvidence": "缺證據 {count}",
+ "locatorApplyGateOpen": "apply gate open",
+ "locatorApplyGateClosed": "apply gate 0",
"locatorCollected": "已收件",
"locatorPending": "等待收件",
"noRawPath": "不吐原路徑",
diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
index 834e9fac0..c0b4d1e7e 100644
--- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
@@ -1192,6 +1192,13 @@ type PriorityWorkOrderResponse = {
windows99_vmx_source_locator_candidate_source_count?: number | null;
windows99_vmx_source_locator_identity_unassigned_ubuntu_candidate_count?: number | null;
windows99_vmx_source_locator_candidate_confirmation_required?: boolean | null;
+ windows99_vmx_source_locator_candidate_confirmation_status?: string | null;
+ windows99_vmx_source_locator_candidate_identity_decision?: string | null;
+ windows99_vmx_source_locator_candidate_identity_evidence_sufficient?: boolean | null;
+ windows99_vmx_source_locator_candidate_relink_dry_run_ready?: boolean | null;
+ windows99_vmx_source_locator_candidate_confirmation_missing_evidence?: string[] | null;
+ windows99_vmx_source_locator_candidate_confirmation_safe_next_step?: string | null;
+ windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate?: boolean | null;
windows99_vmx_source_locator_candidate_source_count_known?: boolean | null;
windows99_vmx_source_locator_collector_readback_present?: boolean | null;
windows99_vmx_source_locator_collector_collection_status?: string | null;
@@ -1275,6 +1282,13 @@ type PriorityWorkOrderResponse = {
windows99_vmx_source_locator_candidate_source_count?: number | null;
windows99_vmx_source_locator_identity_unassigned_ubuntu_candidate_count?: number | null;
windows99_vmx_source_locator_candidate_confirmation_required?: boolean | null;
+ windows99_vmx_source_locator_candidate_confirmation_status?: string | null;
+ windows99_vmx_source_locator_candidate_identity_decision?: string | null;
+ windows99_vmx_source_locator_candidate_identity_evidence_sufficient?: boolean | null;
+ windows99_vmx_source_locator_candidate_relink_dry_run_ready?: boolean | null;
+ windows99_vmx_source_locator_candidate_confirmation_missing_evidence?: string[] | null;
+ windows99_vmx_source_locator_candidate_confirmation_safe_next_step?: string | null;
+ windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate?: boolean | null;
windows99_vmx_source_locator_candidate_source_count_known?: boolean | null;
windows99_vmx_source_locator_collector_readback_present?: boolean | null;
windows99_vmx_source_locator_collector_collection_status?: string | null;
@@ -9381,6 +9395,23 @@ function ManagerSituationBoard({
summary?.windows99_vmx_source_locator_candidate_confirmation_required ??
evidence?.windows99_vmx_source_locator_candidate_confirmation_required ??
false;
+ const vmxSourceLocatorIdentityEvidenceSufficient =
+ summary?.windows99_vmx_source_locator_candidate_identity_evidence_sufficient ??
+ evidence?.windows99_vmx_source_locator_candidate_identity_evidence_sufficient ??
+ false;
+ const vmxSourceLocatorRelinkDryRunReady =
+ summary?.windows99_vmx_source_locator_candidate_relink_dry_run_ready ??
+ evidence?.windows99_vmx_source_locator_candidate_relink_dry_run_ready ??
+ false;
+ const vmxSourceLocatorConfirmationMissingEvidence = (
+ summary?.windows99_vmx_source_locator_candidate_confirmation_missing_evidence ??
+ evidence?.windows99_vmx_source_locator_candidate_confirmation_missing_evidence ??
+ []
+ ).filter(Boolean);
+ const vmxSourceLocatorApplyGateOpen =
+ summary?.windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate ??
+ evidence?.windows99_vmx_source_locator_apply_allowed_by_candidate_confirmation_gate ??
+ false;
const vmxSourceLocatorCandidateKnown =
summary?.windows99_vmx_source_locator_candidate_source_count_known ??
evidence?.windows99_vmx_source_locator_candidate_source_count_known ??
@@ -9782,6 +9813,47 @@ function ManagerSituationBoard({
? t("vmxRepair.locatorNeedsConfirm")
: t("vmxRepair.locatorConfirmClosed")}
+
+ {vmxSourceLocatorIdentityEvidenceSufficient
+ ? t("vmxRepair.locatorIdentityGateReady")
+ : t("vmxRepair.locatorIdentityGateBlocked")}
+
+
+ {vmxSourceLocatorRelinkDryRunReady
+ ? t("vmxRepair.locatorDryRunReady")
+ : t("vmxRepair.locatorDryRunClosed")}
+
+
+ {t("vmxRepair.locatorMissingEvidence", {
+ count: vmxSourceLocatorConfirmationMissingEvidence.length,
+ })}
+
+
+ {vmxSourceLocatorApplyGateOpen
+ ? t("vmxRepair.locatorApplyGateOpen")
+ : t("vmxRepair.locatorApplyGateClosed")}
+
{vmxSourceLocatorCollectorPresent
? t("vmxRepair.locatorCollected")
diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md
index eb7d933bd..c450f6528 100644
--- a/docs/LOGBOOK.md
+++ b/docs/LOGBOOK.md
@@ -1,11 +1,12 @@
-## 2026-07-03 — 13:12 Windows99 VMX relink dry-run evidence gate 上卷
+## 2026-07-03 — 13:12 Windows99 VMX relink dry-run / identity gate 上卷
**完成內容**:
- Gitea CD `#4582` 已完成最新 main `ba0849f70` 部署,deploy marker 為 `558b9e916 chore(cd): deploy ba0849f [skip ci]`;job3 讀回 `ArgoCD: sync=Synced health=Healthy`、API/Web/Worker rollout completed、`Production deploy readback matches this build`、`Job succeeded`。
- Production `/api/v1/agents/windows99-vmx-source-locator-readback` 已讀回 collector receipt:`collector_readback_present=true`、`candidate_source_count=6`、`identity_unassigned_ubuntu_candidate_count=1`、`raw_path_output=false`、`path_fingerprint_only=true`、`remote_write_performed=false`、`vm_power_change_performed=false`。
- 本輪 verifier-only artifact:`/tmp/awoooi-p0-006-reboot-slo-20260703-130633`。host probe 顯示 99 reachable-unknown-boot、111 unreachable、112/120/121 reachable、188 reachable 但 `awoooi-startup.service failed/degraded`;本工作機對 99 的 no-secret SSH collector 固定 `ssh_batchmode_auth_ready=0`、`verify_collection_status=blocked_ssh_publickey_auth_missing`。
- 將 VMX locator 的「confirmation」字眼改成 machine-readable evidence gate:`verified_source_or_identity_evidence_required=true`、`human_confirmation_required_for_dry_run=false`、`controlled_relink_dry_run_ready=false`、`controlled_relink_dry_run_blocker=verified_vmx_source_or_identity_evidence_missing`,並新增 `controlled_relink_dry_run_plan`。此 plan 只允許 dry-run evidence,不允許 apply / remote write / VM power change。
-- `awoooi-priority-work-order-readback` 同步投影上述 dry-run blocker 到 `mainline_execution_state`、in-progress evidence、`summary` 與 `rollups`,避免再把 P0-006 卡成模糊的人工確認。
+- 同步新增 `candidate_confirmation_gate`,schema 固定為 `windows99_vmx_source_candidate_confirmation_gate_v1`,把「唯一未指派 Ubuntu candidate」拆成 machine-readable identity gate;目前 `candidate_identity_evidence_sufficient=false`、`candidate_relink_dry_run_ready=false`、`apply_allowed_by_candidate_confirmation_gate=false`。
+- `awoooi-priority-work-order-readback` 同步投影上述 dry-run blocker 與 identity gate 到 `mainline_execution_state`、in-progress evidence、`summary` 與 `rollups`;AwoooP Work Items 補上 identity gate、dry-run gate、missing evidence 與 apply gate chips,避免再把 P0-006 卡成模糊人工確認。
**已跑驗證**:
- `python3.11 -m py_compile apps/api/src/services/windows99_vmx_source_locator_readback.py apps/api/src/services/awoooi_priority_work_order_readback.py apps/api/tests/test_windows99_vmx_source_locator_readback_api.py apps/api/tests/test_awoooi_priority_work_order_readback_api.py`:通過。
diff --git a/docs/runbooks/FULL-STACK-COLD-START-SOP.md b/docs/runbooks/FULL-STACK-COLD-START-SOP.md
index a0380e333..fb5aa6ab4 100644
--- a/docs/runbooks/FULL-STACK-COLD-START-SOP.md
+++ b/docs/runbooks/FULL-STACK-COLD-START-SOP.md
@@ -64,6 +64,8 @@ v1.115 Windows99 VMX source locate check-mode rule:2026-07-03 12:34 deploy 後
v1.116 Windows99 VMX locator via-host execution rule:若目前工作站直連 99 缺 public-key auth,但 110 到 99 的 no-secret SSH path 已驗證,官方 locator 必須支援 `--via-host`,固定可執行命令為 `bash scripts/reboot-recovery/locate-windows99-vmx-source.sh --collect --via-host wooo@192.168.0.110 --users wooo --max-auth-users 1`。2026-07-03 live readback:`locator_collection_status=collected_windows99_vmx_source_locator_stdout`、`expected_vmx_path_present_count=0`、`candidate_source_count=0`、`source_locator_status=blocked_missing_expected_vmx_source_no_candidate_found`、`safe_next_step=provide_existing_vmx_source_or_backup_path_then_rerun_locator_check_mode_no_write`,且 `secret_value_read=false`、`remote_write_performed=false`、`vm_power_change_performed=false`、`raw_path_output=false`、`path_fingerprint_only=true`。此結果把下一步收斂為「提供既有 VMX source 或 verified backup path」;仍不得 relink generic VM、啟動 VM、restore 到 production 或讀密碼。
+v1.117 Windows99 VMX candidate confirmation gate rule:locator receipt 顯示 `single_identity_unassigned_ubuntu_candidate_requires_confirmation` 時,不能把「唯一 generic Ubuntu candidate」直接升級成 111 VMX source。`/api/v1/agents/windows99-vmx-source-locator-readback` 必須輸出 `candidate_confirmation_gate`,並同步到 priority readback / Work Items;目前 gate 固定 `candidate_identity_evidence_sufficient=false`、`candidate_relink_dry_run_ready=false`、`apply_allowed_by_candidate_confirmation_gate=false`,missing evidence 至少包含 `target_alias_identity_hint`、`verified_backup_or_authorized_console_candidate_identity`、`pre_apply_backup_plan`、`scoped_relink_dry_run`。只有取得 target alias / MAC / UUID / display-name 或 verified backup path 證據,且產生 rollback + post-verifier 的 scoped relink dry-run 後,才可進下一階段;scorecard / locator / work item 均不得授權 VM power change、Windows write、service restart、host reboot、secret/password 讀取或 production restore。
+
2026-07-02 110 control-path / Harbor recovery receipt rule:若 Gitea Harbor repair queue 仍保留 `harbor_110_remote_ssh_publickey_auth_stalled`、remote-control unavailable、jobs stale 或 historical failure,但同一輪本地證據同時證明 `wooo` command path ready、110 local Harbor `/v2/` ready、public/internal registry `/v2/` 回 `401`,則該 Gitea Harbor repair 失敗只能列為 historical queue metadata,不得再當成 current SSH blocker。必須用 `/api/v1/agents/harbor-registry-controlled-recovery-receipt` 或同等 validator 合併 `diagnose-110-ssh-publickey-auth.sh`、`recover-110-control-path-and-harbor-local.sh --check`、public Gitea queue readback 與 registry `/v2/` verifier,並把機器可讀結果寫入 `docs/operations/harbor-110-control-path-recovery-readback-2026-07-02.snapshot.json` 類型的 snapshot。2026-07-02 live receipt 顯示:public/internal registry `/v2/` 均為 `401`、latest visible CD `#4335` 為 `Success`、Gitea Harbor repair failure 已是 `historical_after_latest_cd_success=true`;active blockers 收斂為 110 controlled CD lane config / binary / registration / service guardrail、active action container pressure,以及 Gitea CD jobs head-SHA / stale readback mismatch。若 local-console output 只有 `AWOOOI_110_CONTROLLED_CD_LANE_READY` marker,non110 runner parser 不得從 110 `BLOCKER` 行推導 non110 blocker;non110 只有看到 `AWOOOI_NON110_RUNNER_READY` marker 才能列入 active blocker。
2026-07-02 110 controlled CD lane fail-closed enforcer staging rule:110 runner 壓力事故後,legacy / generic runner 仍必須 fail-closed;但 `awoooi-cd-lane-drain.service` 的非 secret staging artifact 不得再被 enforcer 無差別封回 stub。`scripts/reboot-recovery/enforce-110-runner-failclosed.sh` 只有在 `config.yaml` 符合 `capacity <= 1`、只含 `awoooi-host:host` 與 `awoooi-ubuntu:docker://192.168.0.110:5000/awoooi/ci-runner:act-22.04`、binary 是 executable ELF、systemd unit 具備 `ConditionPathExists=/home/wooo/awoooi-cd-lane-drain/data/.runner`、`CPUAccounting` / `MemoryAccounting` / `TasksAccounting` / `NoNewPrivileges` 等 guardrail,且 service `inactive`、`MainPID=0`、未 enabled / 未 masked 時,才可保留 drain config / binary / unit,並輸出 `CONTROLLED_DRAIN_STAGING_ALLOWED=1` 與 textfile metric。此 staging 規則不得讀 token、不得讀 `.runner` 內容、不得註冊 runner、不得啟動 service;若 registration 缺失,readiness verifier 仍必須只留下 `controlled_cd_lane_registration_missing` / `controlled_cd_lane_service_not_active` 類 blocker。若 `CONTROLLED_DRAIN_STAGING_ALLOWED=0` 且 config / binary 又被搬走,優先修 source enforcer / unit guardrail,不要手工反覆補同一組 artifact。
diff --git a/docs/runbooks/REBOOT-RECOVERY-SOP.md b/docs/runbooks/REBOOT-RECOVERY-SOP.md
index 206d9eac0..a5d29d1c5 100644
--- a/docs/runbooks/REBOOT-RECOVERY-SOP.md
+++ b/docs/runbooks/REBOOT-RECOVERY-SOP.md
@@ -1,9 +1,9 @@
# AWOOOI 重開機恢復 SOP
-> **版本**: v5.8
+> **版本**: v5.9
> **最後更新**: 2026-07-03 (台北時間)
> **更新者**: Codex
-> **觸發事件**: 2026-07-03 12:34 deploy 後 production SLO scorecard + Windows99 VMX source locator readback:111 VMX source 尚未找到,10 分鐘 SLO 仍 blocked
+> **觸發事件**: 2026-07-03 13:10 deploy 後 production SLO scorecard + Windows99 VMX source locator confirmation gate:111 VMX candidate 尚未取得足夠 identity evidence,10 分鐘 SLO 仍 blocked
---
@@ -48,6 +48,8 @@
固定下一步:`locate_or_relink_missing_vmx_source_check_mode`。production `/api/v1/agents/windows99-vmx-source-locator-readback` 已回 `status=check_mode_locator_ready_windows99_vmx_source_required`、`missing_vmx_aliases=["111"]`、`safe_next_step=bash scripts/reboot-recovery/locate-windows99-vmx-source.sh --collect`、`secret_value_read=false`、`remote_write_performed=false`、`vm_power_change_allowed=false`。目前工作站必須加 `--via-host wooo@192.168.0.110 --users wooo --max-auth-users 1` 走已驗證的 110 no-secret path;live 結果為 `locator_collection_status=collected_windows99_vmx_source_locator_stdout`、`expected_vmx_path_present_count=0`、`candidate_source_count=0`、`source_locator_status=blocked_missing_expected_vmx_source_no_candidate_found`、`raw_path_output=false`、`path_fingerprint_only=true`。下一步只能取得既有 VMX source 或 external verified backup path,再產生 scoped relink / restore dry-run 與 post-verifier。若 public API response 顯示 `public_lan_topology_redacted=true` 或 `check_mode_probe_commands_executable=false`,其中 `host:...` VMX path 只能當 public display evidence,不得直接貼到 Windows console 執行;要執行 probe 時必須使用授權內部 console / no-secret package 保留的原始 Windows path。禁止讀 Windows 密碼、啟動 / 關閉 VM、host reboot、service restart、Docker / Nginx / K3s / DB / firewall restart、restore、prune、delete。
+2026-07-03 13:10 最新 overlay:Gitea CD `#4582` 已推 deploy marker `558b9e9`,source `ba0849f70`,production deploy readback matched=true 且 job_succeeded=true;Gitea UI 若仍顯示 Running,只能視為 run-level lag。production `/api/v1/agents/windows99-vmx-source-locator-readback` 已上卷 redacted locator receipt:`candidate_source_count=6`、`alias_hint_candidate_count=0`、`unassigned_ubuntu_candidate_count=5`、`identity_unassigned_ubuntu_candidate_count=1`、`candidate_confirmation_required=true`,狀態為 `collector_readback_single_identity_candidate_confirmation_required`。新的 `candidate_confirmation_gate` 固定輸出 `candidate_identity_evidence_sufficient=false`、`candidate_relink_dry_run_ready=false`、`apply_allowed_by_candidate_confirmation_gate=false`;缺少 evidence 為 `target_alias_identity_hint`、`verified_backup_or_authorized_console_candidate_identity`、`pre_apply_backup_plan`、`scoped_relink_dry_run`。因此下一步已從「重跑 locator」收斂為:取得授權 console candidate identity evidence 或 verified backup path,重跑 confirmation gate,再產生 scoped relink dry-run + rollback + post-verifier;仍不得猜測 generic Ubuntu candidate、不得 relink、不得 restore、不得 power on VM。
+
### 五主機全貌
```