diff --git a/apps/api/src/services/awooop_ansible_check_mode_service.py b/apps/api/src/services/awooop_ansible_check_mode_service.py index 5a3d1d21b..5c27a9e0d 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -252,7 +252,7 @@ def _apply_ansible_playbook_trust_gate( candidate_input: dict[str, Any], trust_rows: list[Mapping[str, Any]], ) -> tuple[dict[str, Any], dict[str, Any]]: - """Filter failed PlayBooks before they can claim another runtime apply.""" + """Filter failed PlayBooks while allowing one repaired revision canary.""" candidates = candidate_input.get("executor_candidates") if not isinstance(candidates, list): @@ -274,16 +274,44 @@ def _apply_ansible_playbook_trust_gate( trust_score = float((row or {}).get("trust_score") or 0.0) success_count = int((row or {}).get("success_count") or 0) failure_count = int((row or {}).get("failure_count") or 0) + catalog_item = get_ansible_catalog_item(catalog_id) or {} + candidate_catalog_revision = str( + candidate.get("catalog_revision") or "" + ).strip() + allowlisted_catalog_revision = str( + catalog_item.get("catalog_revision") or "" + ).strip() + current_catalog_revision = str( + allowlisted_catalog_revision + or candidate_catalog_revision + or "" + ).strip() + observed_pattern = _json_loads( + (row or {}).get("symptom_pattern") + ) + observed_catalog_revision = str( + observed_pattern.get("catalog_revision") or "" + ).strip() retired = status in {"deprecated", "archived"} learned_failure = bool( row and failure_count > success_count and trust_score < TRUST_ARCHIVE_THRESHOLD ) - circuit_open = retired or learned_failure + bounded_repair_canary = bool( + not retired + and learned_failure + and current_catalog_revision + and current_catalog_revision != observed_catalog_revision + ) + circuit_open = retired or ( + learned_failure and not bounded_repair_canary + ) reason = ( "playbook_status_retired" if retired + else "repaired_catalog_revision_canary_allowed" + if bounded_repair_canary else "playbook_trust_below_archive_threshold" if learned_failure else "playbook_trust_allows_controlled_canary" @@ -300,6 +328,22 @@ def _apply_ansible_playbook_trust_gate( "success_count": success_count, "failure_count": failure_count, "review_required": bool((row or {}).get("review_required")), + "current_catalog_revision": current_catalog_revision or None, + "catalog_revision_source": ( + "allowlisted_catalog" + if allowlisted_catalog_revision + else "candidate_fallback" + if candidate_catalog_revision + else None + ), + "candidate_catalog_revision_mismatch": bool( + allowlisted_catalog_revision + and candidate_catalog_revision + and candidate_catalog_revision + != allowlisted_catalog_revision + ), + "observed_catalog_revision": observed_catalog_revision or None, + "bounded_repair_canary": bounded_repair_canary, "circuit_open": circuit_open, "reason": reason, } @@ -316,7 +360,7 @@ def _apply_ansible_playbook_trust_gate( else "closed" ) gate = { - "schema_version": "ansible_playbook_trust_claim_gate_v1", + "schema_version": "ansible_playbook_trust_claim_gate_v2", "policy_source": "playbook_evolver.TRUST_ARCHIVE_THRESHOLD", "trust_archive_threshold": TRUST_ARCHIVE_THRESHOLD, "status": status, @@ -363,6 +407,7 @@ async def _guard_candidate_input_by_playbook_trust( PlaybookRecord.success_count, PlaybookRecord.failure_count, PlaybookRecord.review_required, + PlaybookRecord.symptom_pattern, ).where(PlaybookRecord.playbook_id.in_(canonical_ids)) ) trust_rows = list(result.mappings().all()) @@ -382,6 +427,9 @@ async def _revalidate_claim_playbook_trust( "check_mode_playbook_path": claim.playbook_path, "inventory_hosts": list(claim.inventory_hosts), "risk_level": claim.risk_level, + "catalog_revision": str( + claim.input_payload.get("catalog_revision") or "" + ), } ], } diff --git a/apps/api/src/services/awooop_ansible_learning_writeback.py b/apps/api/src/services/awooop_ansible_learning_writeback.py index 2e529d048..bd512467b 100644 --- a/apps/api/src/services/awooop_ansible_learning_writeback.py +++ b/apps/api/src/services/awooop_ansible_learning_writeback.py @@ -49,6 +49,28 @@ def _json_dict(value: Any) -> dict[str, Any]: return {} +def _playbook_catalog_revision(playbook: PlaybookRecord) -> str | None: + revision = str( + _json_dict(playbook.symptom_pattern).get("catalog_revision") or "" + ).strip() + return revision or None + + +def _record_catalog_revision_observation( + playbook: PlaybookRecord, + catalog: dict[str, Any], +) -> str | None: + """Bind a trust mutation to the catalog revision it observed.""" + + revision = str(catalog.get("catalog_revision") or "").strip() + if not revision: + return None + symptom_pattern = dict(_json_dict(playbook.symptom_pattern)) + symptom_pattern["catalog_revision"] = revision + playbook.symptom_pattern = symptom_pattern + return revision + + def resolve_ansible_playbook_identity( catalog_id: str, ) -> dict[str, Any] | None: @@ -117,6 +139,7 @@ def _playbook_trust_readback( "identity_fingerprint": identity["identity_fingerprint"], "canonical_playbook_id": playbook.playbook_id, "catalog_id": identity["catalog_id"], + "catalog_revision": _playbook_catalog_revision(playbook), "playbook_path": identity["playbook_path"], "playbook_row_version": int(playbook.version or 0), "playbook_row_updated_at": ( @@ -301,6 +324,10 @@ async def record_ansible_playbook_trust_writeback( "severity_range": ["P0", "P1", "P2", "P3"], "label_patterns": {}, "keywords": list(catalog.get("keywords") or [])[:50], + "catalog_revision": str( + catalog.get("catalog_revision") or "" + ) + or None, }, repair_steps=[ { @@ -345,6 +372,7 @@ async def record_ansible_playbook_trust_writeback( await db.flush() if trust_mutation_performed: + _record_catalog_revision_observation(playbook, catalog) source_incident_ids = list(playbook.source_incident_ids or []) if incident_id and incident_id not in source_incident_ids: source_incident_ids.append(incident_id) @@ -373,6 +401,7 @@ async def record_ansible_playbook_trust_writeback( "automation_run_id": automation_run_id, "incident_id": incident_id, "catalog_id": catalog_id, + "catalog_revision": trust_writeback["catalog_revision"], "canonical_playbook_id": canonical_id, "identity_schema_version": identity["schema_version"], "identity_fingerprint": identity["identity_fingerprint"], diff --git a/apps/api/tests/test_awooop_ansible_trust_claim_gate.py b/apps/api/tests/test_awooop_ansible_trust_claim_gate.py index 591a22d2a..3bdf87d2d 100644 --- a/apps/api/tests/test_awooop_ansible_trust_claim_gate.py +++ b/apps/api/tests/test_awooop_ansible_trust_claim_gate.py @@ -2,6 +2,7 @@ from __future__ import annotations import inspect +from src.services.awooop_ansible_audit_service import get_ansible_catalog_item from src.services.awooop_ansible_check_mode_service import ( _apply_ansible_playbook_trust_gate, _revalidate_claim_playbook_trust, @@ -9,18 +10,27 @@ from src.services.awooop_ansible_check_mode_service import ( ) -def _candidate(catalog_id: str) -> dict[str, object]: +def _candidate( + catalog_id: str, + *, + catalog_revision: str = "test-revision-v1", +) -> dict[str, object]: + catalog_item = get_ansible_catalog_item(catalog_id) or {} return { "catalog_id": catalog_id, "playbook_path": f"infra/ansible/playbooks/{catalog_id.split(':', 1)[1]}.yml", "inventory_hosts": ["host_188"], "risk_level": "medium", + "catalog_revision": ( + catalog_item.get("catalog_revision") or catalog_revision + ), } def test_low_trust_failed_playbook_opens_claim_circuit() -> None: + candidate = _candidate("ansible:nginx-sync") guarded, gate = _apply_ansible_playbook_trust_gate( - {"executor_candidates": [_candidate("ansible:nginx-sync")]}, + {"executor_candidates": [candidate]}, [ { "playbook_id": "PB-ANSIBLE-NGINX-SYNC", @@ -29,6 +39,9 @@ def test_low_trust_failed_playbook_opens_claim_circuit() -> None: "success_count": 0, "failure_count": 24, "review_required": False, + "symptom_pattern": { + "catalog_revision": candidate["catalog_revision"], + }, } ], ) @@ -45,11 +58,12 @@ def test_low_trust_failed_playbook_opens_claim_circuit() -> None: def test_low_trust_candidate_is_filtered_without_blocking_healthy_candidate() -> None: + failed = _candidate("ansible:nginx-sync") healthy = _candidate("ansible:188-momo-backup-user") guarded, gate = _apply_ansible_playbook_trust_gate( { "executor_candidates": [ - _candidate("ansible:nginx-sync"), + failed, healthy, ] }, @@ -61,6 +75,9 @@ def test_low_trust_candidate_is_filtered_without_blocking_healthy_candidate() -> "success_count": 0, "failure_count": 15, "review_required": True, + "symptom_pattern": { + "catalog_revision": failed["catalog_revision"], + }, }, { "playbook_id": "PB-ANSIBLE-188-MOMO-BACKUP-USER", @@ -69,6 +86,9 @@ def test_low_trust_candidate_is_filtered_without_blocking_healthy_candidate() -> "success_count": 7, "failure_count": 1, "review_required": False, + "symptom_pattern": { + "catalog_revision": healthy["catalog_revision"], + }, }, ], ) @@ -79,6 +99,132 @@ def test_low_trust_candidate_is_filtered_without_blocking_healthy_candidate() -> assert gate["blocked_candidate_count"] == 1 +def test_repaired_catalog_revision_gets_one_bounded_canary() -> None: + candidate = _candidate( + "ansible:110-devops", + catalog_revision="host110-route-v3", + ) + current_revision = str(candidate["catalog_revision"]) + guarded, gate = _apply_ansible_playbook_trust_gate( + {"executor_candidates": [candidate]}, + [ + { + "playbook_id": "PB-ANSIBLE-110-DEVOPS", + "status": "approved", + "trust_score": 0.024, + "success_count": 0, + "failure_count": 1, + "review_required": False, + "symptom_pattern": { + "catalog_revision": f"{current_revision}-previous", + }, + } + ], + ) + + assert guarded["executor_candidates"] == [candidate] + assert gate["status"] == "closed" + decision = gate["decisions"][0] + assert decision["circuit_open"] is False + assert decision["bounded_repair_canary"] is True + assert decision["reason"] == "repaired_catalog_revision_canary_allowed" + assert decision["current_catalog_revision"] == current_revision + assert decision["catalog_revision_source"] == "allowlisted_catalog" + assert decision["observed_catalog_revision"] == ( + f"{current_revision}-previous" + ) + + +def test_failed_repaired_revision_reopens_circuit_for_same_revision() -> None: + candidate = _candidate( + "ansible:110-devops", + catalog_revision="host110-route-v3", + ) + current_revision = str(candidate["catalog_revision"]) + guarded, gate = _apply_ansible_playbook_trust_gate( + {"executor_candidates": [candidate]}, + [ + { + "playbook_id": "PB-ANSIBLE-110-DEVOPS", + "status": "approved", + "trust_score": 0.0192, + "success_count": 0, + "failure_count": 2, + "review_required": False, + "symptom_pattern": { + "catalog_revision": current_revision, + }, + } + ], + ) + + assert guarded["executor_candidates"] == [] + assert gate["status"] == "circuit_open" + decision = gate["decisions"][0] + assert decision["circuit_open"] is True + assert decision["bounded_repair_canary"] is False + assert decision["reason"] == "playbook_trust_below_archive_threshold" + + +def test_legacy_failed_record_without_revision_gets_migration_canary() -> None: + candidate = _candidate( + "ansible:110-devops", + catalog_revision="host110-route-v3", + ) + guarded, gate = _apply_ansible_playbook_trust_gate( + {"executor_candidates": [candidate]}, + [ + { + "playbook_id": "PB-ANSIBLE-110-DEVOPS", + "status": "approved", + "trust_score": 0.02, + "success_count": 0, + "failure_count": 3, + "review_required": False, + "symptom_pattern": {}, + } + ], + ) + + assert guarded["executor_candidates"] == [candidate] + decision = gate["decisions"][0] + assert decision["bounded_repair_canary"] is True + assert decision["observed_catalog_revision"] is None + + +def test_candidate_cannot_forge_revision_to_bypass_same_revision_circuit() -> None: + catalog_id = "ansible:110-devops" + current_revision = str( + (get_ansible_catalog_item(catalog_id) or {}).get("catalog_revision") + or "" + ) + candidate = _candidate(catalog_id) + candidate["catalog_revision"] = "forged-revision-v999" + + guarded, gate = _apply_ansible_playbook_trust_gate( + {"executor_candidates": [candidate]}, + [ + { + "playbook_id": "PB-ANSIBLE-110-DEVOPS", + "status": "approved", + "trust_score": 0.02, + "success_count": 0, + "failure_count": 3, + "review_required": False, + "symptom_pattern": { + "catalog_revision": current_revision, + }, + } + ], + ) + + assert guarded["executor_candidates"] == [] + decision = gate["decisions"][0] + assert decision["bounded_repair_canary"] is False + assert decision["candidate_catalog_revision_mismatch"] is True + assert decision["current_catalog_revision"] == current_revision + + def test_owner_review_flag_does_not_become_low_medium_high_terminal_gate() -> None: candidate = _candidate("ansible:188-momo-backup-user") guarded, gate = _apply_ansible_playbook_trust_gate( diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index e45841272..44a6081f7 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -77,6 +77,7 @@ from src.services.awooop_ansible_check_mode_service import ( from src.services.awooop_ansible_learning_writeback import ( _ensure_rag_chunk_fallback, _playbook_trust_readback, + _record_catalog_revision_observation, canonical_ansible_playbook_id, ensure_ansible_rag_writeback, record_ansible_playbook_trust_writeback, @@ -2743,6 +2744,7 @@ def test_no_write_playbook_observation_does_not_claim_trust_mutation() -> None: success_count=7, failure_count=2, trust_score=0.81, + symptom_pattern={"catalog_revision": "backup-user-v2"}, updated_at=datetime(2026, 7, 14, tzinfo=UTC), ) identity = { @@ -2772,6 +2774,24 @@ def test_no_write_playbook_observation_does_not_claim_trust_mutation() -> None: assert receipt["success_count"] == 7 assert receipt["failure_count"] == 2 assert receipt["trust_score"] == 0.81 + assert receipt["catalog_revision"] == "backup-user-v2" + + +def test_playbook_trust_mutation_records_observed_catalog_revision() -> None: + playbook = SimpleNamespace( + symptom_pattern={"alert_names": ["Host110"]}, + ) + + revision = _record_catalog_revision_observation( + playbook, + {"catalog_revision": "host110-route-v3"}, + ) + + assert revision == "host110-route-v3" + assert playbook.symptom_pattern == { + "alert_names": ["Host110"], + "catalog_revision": "host110-route-v3", + } def test_ansible_catalog_ids_map_to_stable_playbook_primary_keys() -> None: