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 62a999941..2d68a67e2 100644 --- a/apps/api/src/services/windows99_vmx_source_locator_readback.py +++ b/apps/api/src/services/windows99_vmx_source_locator_readback.py @@ -77,6 +77,7 @@ _CANDIDATE_CONFIRMATION_ALLOWED_KINDS = { "target_alias_hint", "authorized_console_identity", "verified_backup_path_fingerprint", + "bounded_identity_metadata_single_unassigned_candidate", } _CANDIDATE_CONFIRMATION_FORBIDDEN_ACTIONS = [ "raw_vmx_path_output", @@ -495,6 +496,14 @@ def validate_windows99_vmx_source_candidate_confirmation_packet( _CANDIDATE_CONFIRMATION_ALLOWED_KINDS ): rejected_fields.append("candidate_identity_kind") + if ( + str(packet.get("candidate_identity_kind") or "") + == "bounded_identity_metadata_single_unassigned_candidate" + and _int(locator.get("identity_unassigned_ubuntu_candidate_count"), 0) != 1 + ): + rejected_fields.append( + "candidate_identity_kind.requires_single_unassigned_identity_candidate" + ) if str(packet.get("candidate_identity_evidence_ref") or "").strip() == "": rejected_fields.append("candidate_identity_evidence_ref") if _int(packet.get("candidate_match_count"), 0) != 1: 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 72f4e46a7..df951e37c 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 @@ -828,7 +828,7 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a is False ) assert state["windows99_vmx_source_locator_status"] == ( - "collector_readback_verified_source_required_before_relink_dry_run" + "collector_readback_scoped_relink_dry_run_ready" ) assert state["windows99_vmx_source_locator_required"] is True assert state["windows99_vmx_source_locator_check_mode_ready"] is True @@ -842,7 +842,7 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a ) assert state["windows99_vmx_source_locator_target_aliases"] == ["111"] assert state["windows99_vmx_source_locator_next_executable_step"] == ( - "provide_verified_vmx_source_or_identity_evidence_then_scoped_relink_dry_run" + "rerun_no_secret_collector_and_scorecard" ) assert state["windows99_vmx_source_locator_expected_vmx_path_count"] == 1 assert state["windows99_vmx_source_locator_candidate_source_count"] == 8 @@ -865,15 +865,15 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a state[ "windows99_vmx_source_locator_verified_source_or_identity_evidence_required" ] - is True + is False ) assert ( state["windows99_vmx_source_locator_controlled_relink_dry_run_ready"] - is False + is True ) assert state[ "windows99_vmx_source_locator_controlled_relink_dry_run_blocker" - ] == "verified_vmx_source_or_identity_evidence_missing" + ] == "" assert state["windows99_vmx_source_locator_relink_dry_run_plan_id"] == ( "windows99-vmx-source-relink-dry-run-111" ) @@ -887,9 +887,9 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a state[ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient" ] - is False + is True ) - assert state["windows99_vmx_source_locator_candidate_relink_dry_run_ready"] is False + assert state["windows99_vmx_source_locator_candidate_relink_dry_run_ready"] is True assert state[ "windows99_vmx_source_locator_candidate_confirmation_missing_evidence" ] == [ @@ -1034,7 +1034,7 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a "111" ] assert payload["summary"]["windows99_vmx_source_locator_status"] == ( - "collector_readback_verified_source_required_before_relink_dry_run" + "collector_readback_scoped_relink_dry_run_ready" ) assert payload["summary"]["windows99_vmx_source_locator_check_mode_ready"] is True assert payload["summary"]["windows99_vmx_source_locator_target_aliases"] == [ @@ -1063,13 +1063,13 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a payload["summary"][ "windows99_vmx_source_locator_candidate_identity_evidence_sufficient" ] - is False + is True ) assert ( payload["summary"][ "windows99_vmx_source_locator_candidate_relink_dry_run_ready" ] - is False + is True ) assert ( payload["rollups"][ @@ -1087,17 +1087,17 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a payload["summary"][ "windows99_vmx_source_locator_verified_source_or_identity_evidence_required" ] - is True + is False ) assert ( payload["rollups"][ "windows99_vmx_source_locator_controlled_relink_dry_run_ready" ] - is False + is True ) assert payload["rollups"][ "windows99_vmx_source_locator_controlled_relink_dry_run_blocker" - ] == "verified_vmx_source_or_identity_evidence_missing" + ] == "" assert ( payload["rollups"]["windows99_vmx_source_locator_remote_write_performed"] is False @@ -1161,7 +1161,7 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a "windows99_vmx_source_repair_playbook_trust_writeback_ref" ].startswith("playbook://awoooi/windows99-vmx-source-repair/") assert in_progress["evidence"]["windows99_vmx_source_locator_status"] == ( - "collector_readback_verified_source_required_before_relink_dry_run" + "collector_readback_scoped_relink_dry_run_ready" ) assert ( in_progress["evidence"]["windows99_vmx_source_locator_check_mode_ready"] @@ -1188,7 +1188,7 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a in_progress["evidence"][ "windows99_vmx_source_locator_candidate_relink_dry_run_ready" ] - is False + is True ) assert ( in_progress["evidence"][ @@ -1206,13 +1206,13 @@ def test_awoooi_priority_work_order_readback_routes_closed_backup_to_host_boot_a in_progress["evidence"][ "windows99_vmx_source_locator_verified_source_or_identity_evidence_required" ] - is True + is False ) assert ( in_progress["evidence"][ "windows99_vmx_source_locator_controlled_relink_dry_run_ready" ] - is False + is True ) assert ( in_progress["evidence"]["windows99_vmx_source_locator_remote_write_performed"] 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 f0d42960e..548882e51 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 @@ -545,9 +545,11 @@ def test_windows99_candidate_confirmation_accepts_scoped_missing_alias_subset() "schema_version": "windows99_vmx_source_candidate_confirmation_packet_v1", "target_host_alias": "99", "target_vm_aliases": ["111"], - "candidate_identity_kind": "verified_backup_path_fingerprint", + "candidate_identity_kind": ( + "bounded_identity_metadata_single_unassigned_candidate" + ), "candidate_identity_evidence_ref": ( - "backup://awoooi/windows99/redacted-fingerprint-111" + "log://awoooi/windows99/redacted-identity-metadata-single-candidate-111" ), "candidate_match_count": 1, "redacted_metadata_only": True, @@ -582,6 +584,68 @@ def test_windows99_candidate_confirmation_accepts_scoped_missing_alias_subset() assert plan["vm_power_change_performed"] is False +def test_windows99_candidate_confirmation_rejects_unassigned_identity_without_single_candidate() -> None: + repair_package = build_windows99_vmx_source_repair_package( + _scorecard(), + generated_at="2026-07-03T11:21:00+08:00", + ) + locator = build_windows99_vmx_source_locator_readback( + _scorecard(), + repair_package=repair_package, + collector_receipt={ + "schema_version": "windows99_vmx_source_locator_receipt_v1", + "collector_readback_present": True, + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "expected_vmx_path_present_count": 0, + "candidate_source_count": 8, + "candidate_source_fingerprint_count": 0, + "alias_hint_candidate_count": 0, + "identity_metadata_read": True, + "bounded_identity_metadata_only": True, + "identity_target_hint_count": 0, + "identity_unassigned_ubuntu_candidate_count": 2, + "locate_status": ( + "multiple_identity_unassigned_ubuntu_candidates_requires_confirmation" + ), + }, + ) + + payload = validate_windows99_vmx_source_candidate_confirmation_packet( + { + "schema_version": "windows99_vmx_source_candidate_confirmation_packet_v1", + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "candidate_identity_kind": ( + "bounded_identity_metadata_single_unassigned_candidate" + ), + "candidate_identity_evidence_ref": ( + "log://awoooi/windows99/redacted-identity-metadata-single-candidate-111" + ), + "candidate_match_count": 1, + "redacted_metadata_only": True, + "bounded_identity_metadata_only": True, + "path_fingerprint_only": True, + "raw_path_output": False, + "secret_value_read": False, + "remote_write_performed": False, + "host_reboot_performed": False, + "vm_power_change_performed": False, + "windows_registry_apply_performed": False, + "scheduled_task_write_performed": False, + "apply_requested": False, + "vm_power_change_requested": False, + }, + locator_readback=locator, + ) + + assert payload["accepted"] is False + assert ( + "candidate_identity_kind.requires_single_unassigned_identity_candidate" + in payload["rejected_fields"] + ) + + def test_windows99_candidate_confirmation_validator_rejects_sensitive_payload() -> None: locator = build_windows99_vmx_source_locator_readback( _scorecard(),