diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 2f1bd873d..dea3e63d9 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -2400,6 +2400,9 @@ jobs: "AWOOOI_BUILD_COMMIT_SHA", "AWOOOI_DESIRED_API_IMAGE_TAG", ), + Path("k8s/awoooi-prod/08-deployment-worker.yaml"): ( + "AWOOOI_BUILD_COMMIT_SHA", + ), Path( "k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml" ): ("AWOOOI_BUILD_COMMIT_SHA",), @@ -2417,6 +2420,7 @@ jobs: git add \ k8s/awoooi-prod/kustomization.yaml \ k8s/awoooi-prod/06-deployment-api.yaml \ + k8s/awoooi-prod/08-deployment-worker.yaml \ k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml DEPLOY_REVISION="" git diff --cached --quiet && echo "⚡ kustomization.yaml 無變化,跳過 push" || { @@ -3167,6 +3171,13 @@ jobs: --from-literal=verifier_failure_marks_apply_failed=true \ --from-literal=verifier_failure_retry_first=true \ --from-literal=post_verifier_raw_output_not_persisted=true \ + --from-literal=canonical_learning_source_verifier=cd_tests_and_production_source_sha_v1 \ + --from-literal=canonical_playbook_resolver_complete=true \ + --from-literal=km_row_readback_required=true \ + --from-literal=playbook_row_readback_required=true \ + --from-literal=learning_failure_fail_closed=true \ + --from-literal=trust_failure_fail_closed=true \ + --from-literal=learning_receipts_public_safe=true \ --from-literal=db_identity_verifier=runtime_role_fingerprint_secret_projection_and_representative_preflight_v2 \ --from-literal=api_database_secret_ref="$API_DATABASE_SECRET_REF" \ --from-literal=worker_database_secret_ref="$WORKER_DATABASE_SECRET_REF" \ @@ -3281,6 +3292,10 @@ jobs: ) or {} ) + canonical_learning_source_boundary = ( + boundary.get("canonical_learning_source_boundary") + or {} + ) last_status = str(boundary.get("status") or "missing") if ( boundary.get("production_boundary_verified") is True @@ -3293,6 +3308,9 @@ jobs: and independent_post_verifier_source_boundary.get( "source_boundary_verified" ) is True + and canonical_learning_source_boundary.get( + "source_boundary_verified" + ) is True and str(boundary.get("verified_source_sha") or "").lower() == expected ): diff --git a/apps/api/src/services/ai_agent_autonomous_runtime_control.py b/apps/api/src/services/ai_agent_autonomous_runtime_control.py index 3bd07ac67..c43e5d043 100644 --- a/apps/api/src/services/ai_agent_autonomous_runtime_control.py +++ b/apps/api/src/services/ai_agent_autonomous_runtime_control.py @@ -3476,13 +3476,29 @@ def _runtime_stage_receipt_has_required_proof(receipt: Mapping[str, Any]) -> boo """Reject learning receipts that do not prove the durable write.""" stage_id = str(receipt.get("stage_id") or "") - if stage_id not in {"rag_writeback", "playbook_trust"}: + if stage_id not in { + "km_playbook_writeback", + "rag_writeback", + "playbook_trust", + }: return True detail = receipt.get("detail") if not isinstance(detail, Mapping): return False if detail.get("durable_write_acknowledged") is not True: return False + if stage_id == "km_playbook_writeback": + return bool( + detail.get("schema_version") == "ansible_km_writeback_v1" + and str(detail.get("knowledge_entry_id") or "") + and str(detail.get("canonical_playbook_id") or "") + and str(detail.get("km_row_version") or "") + and detail.get("repository_write_acknowledged") is True + and detail.get("repository_readback_verified") is True + and detail.get("raw_log_payload_stored") is False + and detail.get("secret_value_stored") is False + and str(detail.get("writer_source_sha") or "") + ) if stage_id == "rag_writeback": primary_embedding = bool( detail.get("embedding_persisted") is True @@ -3495,11 +3511,25 @@ def _runtime_stage_receipt_has_required_proof(receipt: Mapping[str, Any]) -> boo ) return primary_embedding or chunk_fallback return bool( - str(detail.get("canonical_playbook_id") or "") + detail.get("schema_version") + == "ansible_playbook_trust_writeback_v2" + and detail.get("identity_schema_version") + == "ansible_playbook_identity_v1" + and str(detail.get("identity_fingerprint") or "") + and str(detail.get("canonical_playbook_id") or "") + and _int_value(detail.get("playbook_row_version")) > 0 + and str(detail.get("playbook_row_updated_at") or "") + and str(detail.get("playbook_row_fingerprint") or "") and detail.get("trust_score") is not None - and _int_value(detail.get("success_count")) - + _int_value(detail.get("failure_count")) - > 0 + and _int_value(detail.get("trust_observation_count")) > 0 + and detail.get("learning_recorded") is True + and detail.get("trust_updated") is True + and detail.get("repository_write_acknowledged") is True + and detail.get("repository_readback_verified") is True + and detail.get("operation_receipt_readback_verified") is True + and detail.get("raw_log_payload_stored") is False + and detail.get("secret_value_stored") is False + and str(detail.get("writer_source_sha") or "") ) @@ -4635,6 +4665,150 @@ def _build_independent_post_verifier_runtime_readback( } +def _build_canonical_learning_runtime_readback( + *, + operation_latest_rows: Iterable[Mapping[str, Any] | Any], + loop_ledger: Mapping[str, Any], +) -> dict[str, Any]: + rows = [_row_mapping(row) for row in operation_latest_rows] + automation_run_id = str(loop_ledger.get("automation_run_id") or "") + apply_op_id = str(loop_ledger.get("apply_op_id") or "") + catalog_id = str(loop_ledger.get("catalog_id") or "") + playbook_path = str(loop_ledger.get("playbook_path") or "") + learning_operation = next( + ( + row + for row in rows + if str(row.get("operation_type") or "") + == "ansible_learning_writeback_recorded" + and str(row.get("parent_op_id") or "") == apply_op_id + and str(row.get("automation_run_id") or "") + == automation_run_id + ), + {}, + ) + stage_receipts = { + str(receipt.get("stage_id") or ""): receipt + for receipt in loop_ledger.get("same_run_stage_receipts") or [] + if isinstance(receipt, Mapping) + } + km_receipt = stage_receipts.get("km_playbook_writeback") or {} + trust_receipt = stage_receipts.get("playbook_trust") or {} + km_detail = km_receipt.get("detail") + if not isinstance(km_detail, Mapping): + km_detail = {} + trust_detail = trust_receipt.get("detail") + if not isinstance(trust_detail, Mapping): + trust_detail = {} + canonical_playbook_id = str( + trust_detail.get("canonical_playbook_id") or "" + ) + km_playbook_id = str(km_detail.get("canonical_playbook_id") or "") + writer_source_sha = str( + trust_detail.get("writer_source_sha") or "" + ).lower() + km_writer_source_sha = str( + km_detail.get("writer_source_sha") or "" + ).lower() + controls = { + "same_run_learning_operation": bool(learning_operation), + "learning_operation_success": ( + learning_operation.get("status") == "success" + ), + "learning_parent_apply_matches": bool( + apply_op_id + and str(learning_operation.get("parent_op_id") or "") + == apply_op_id + ), + "km_writeback_receipt_v1": ( + km_detail.get("schema_version") == "ansible_km_writeback_v1" + ), + "km_repository_readback_verified": bool( + km_detail.get("repository_write_acknowledged") is True + and km_detail.get("repository_readback_verified") is True + and km_detail.get("durable_write_acknowledged") is True + ), + "km_row_version_present": bool( + str(km_detail.get("knowledge_entry_id") or "") + and str(km_detail.get("km_row_version") or "") + ), + "playbook_trust_receipt_v2": ( + trust_detail.get("schema_version") + == "ansible_playbook_trust_writeback_v2" + ), + "canonical_playbook_identity_resolved": bool( + trust_detail.get("identity_schema_version") + == "ansible_playbook_identity_v1" + and str(trust_detail.get("identity_fingerprint") or "") + and canonical_playbook_id + and trust_detail.get("catalog_id") == catalog_id + and trust_detail.get("playbook_path") == playbook_path + ), + "km_playbook_identity_matches": bool( + canonical_playbook_id + and canonical_playbook_id == km_playbook_id + and km_detail.get("catalog_id") == catalog_id + ), + "playbook_repository_readback_verified": bool( + trust_detail.get("repository_write_acknowledged") is True + and trust_detail.get("repository_readback_verified") is True + and trust_detail.get("operation_receipt_readback_verified") is True + and trust_detail.get("durable_write_acknowledged") is True + ), + "playbook_row_version_present": bool( + _int_value(trust_detail.get("playbook_row_version")) > 0 + and str(trust_detail.get("playbook_row_updated_at") or "") + and str(trust_detail.get("playbook_row_fingerprint") or "") + ), + "learning_and_trust_acknowledged": bool( + trust_detail.get("learning_recorded") is True + and trust_detail.get("trust_updated") is True + and _int_value(trust_detail.get("trust_observation_count")) > 0 + ), + "learning_receipts_public_safe": bool( + km_detail.get("raw_log_payload_stored") is False + and km_detail.get("secret_value_stored") is False + and trust_detail.get("raw_log_payload_stored") is False + and trust_detail.get("secret_value_stored") is False + ), + "writer_source_sha_same_run": bool( + writer_source_sha + and writer_source_sha == km_writer_source_sha + ), + } + blockers = [ + f"{control_id}_not_verified" + for control_id, verified in controls.items() + if verified is not True + ] + return { + "schema_version": "awoooi_canonical_learning_runtime_v1", + "status": "runtime_evidence_ready" if not blockers else "in_progress", + "automation_run_id": automation_run_id or None, + "apply_op_id": apply_op_id or None, + "learning_operation_id": str( + learning_operation.get("op_id") or "" + ) + or None, + "knowledge_entry_id": str( + km_detail.get("knowledge_entry_id") or "" + ) + or None, + "canonical_playbook_id": canonical_playbook_id or None, + "catalog_id": catalog_id or None, + "km_row_version": km_detail.get("km_row_version"), + "playbook_row_version": trust_detail.get("playbook_row_version"), + "playbook_row_fingerprint": trust_detail.get( + "playbook_row_fingerprint" + ), + "writer_source_sha": writer_source_sha or None, + "runtime_evidence_ready": not blockers, + "controls": controls, + "active_blockers": blockers, + "writes_on_read": False, + } + + def build_runtime_receipt_readback_from_rows( *, project_id: str = _DEFAULT_PROJECT_ID, @@ -4783,6 +4957,10 @@ def build_runtime_receipt_readback_from_rows( loop_ledger=loop_ledger, ) ) + canonical_learning_runtime = _build_canonical_learning_runtime_readback( + operation_latest_rows=operation_latest, + loop_ledger=loop_ledger, + ) trace_ledger = _build_trace_ledger( operation_summary=operation_summary, auto_repair_summary=auto_repair_summary, @@ -4919,6 +5097,7 @@ def build_runtime_receipt_readback_from_rows( }, "autonomous_single_writer_runtime": single_writer_runtime, "independent_post_verifier_runtime": independent_post_verifier_runtime, + "canonical_learning_runtime": canonical_learning_runtime, "auto_repair_execution_receipt": { **auto_repair_summary, "latest": _sanitize_latest_rows( @@ -6475,6 +6654,11 @@ async def build_ai_agent_autonomous_runtime_control_with_live_readback( readback=readback, boundary_readback=boundary_readback, ) + _attach_canonical_learning_readback( + payload, + readback=readback, + boundary_readback=boundary_readback, + ) payload.setdefault("rollups", {})[ "executor_trust_boundary_verified_count" ] = 1 if boundary_readback.get("production_boundary_verified") is True else 0 @@ -6505,7 +6689,6 @@ def _attach_autonomous_single_writer_readback( source_controls = source_boundary.get("controls") if not isinstance(source_controls, Mapping): source_controls = {} - runtime_run_id = str(runtime.get("automation_run_id") or "") strict_run_id = str(strict_runtime.get("automation_run_id") or "") deployed_source_sha = str( @@ -6658,6 +6841,105 @@ def _attach_independent_post_verifier_readback( ] = 1 if closed else 0 +def _attach_canonical_learning_readback( + payload: dict[str, Any], + *, + readback: Mapping[str, Any], + boundary_readback: Mapping[str, Any], +) -> None: + runtime = readback.get("canonical_learning_runtime") + if not isinstance(runtime, Mapping): + runtime = {} + source_boundary = boundary_readback.get( + "canonical_learning_source_boundary" + ) + if not isinstance(source_boundary, Mapping): + source_boundary = {} + strict_runtime = payload.get("strict_runtime_completion") + if not isinstance(strict_runtime, Mapping): + strict_runtime = {} + runtime_controls = runtime.get("controls") + if not isinstance(runtime_controls, Mapping): + runtime_controls = {} + source_controls = source_boundary.get("controls") + if not isinstance(source_controls, Mapping): + source_controls = {} + normalized_source_controls = { + ( + f"source_{control_id}" + if str(control_id) in runtime_controls + else str(control_id) + ): verified is True + for control_id, verified in source_controls.items() + } + + runtime_run_id = str(runtime.get("automation_run_id") or "") + strict_run_id = str(strict_runtime.get("automation_run_id") or "") + deployed_source_sha = str( + boundary_readback.get("deployed_source_sha") or "" + ).lower() + writer_source_sha = str( + runtime.get("writer_source_sha") or "" + ).lower() + controls = { + "strict_runtime_same_run_closed": bool( + strict_runtime.get("closed") is True + and runtime_run_id + and runtime_run_id == strict_run_id + ), + "writer_source_sha_matches_deployment": bool( + deployed_source_sha + and writer_source_sha == deployed_source_sha + ), + **{ + str(control_id): verified is True + for control_id, verified in runtime_controls.items() + }, + **normalized_source_controls, + } + blockers = [ + *list(runtime.get("active_blockers") or []), + *list(source_boundary.get("active_blockers") or []), + *[ + f"{control_id}_not_verified" + for control_id, verified in controls.items() + if verified is not True + ], + ] + blockers = list(dict.fromkeys(str(blocker) for blocker in blockers if blocker)) + closed = bool(controls and not blockers) + payload["autonomous_canonical_learning_writeback"] = { + "schema_version": ( + "awoooi_autonomous_canonical_learning_writeback_v1" + ), + "work_item_id": "AIA-P0-004", + "status": "verified_ready" if closed else "in_progress", + "closed": closed, + "automation_run_id": runtime_run_id or None, + "deployed_source_sha": deployed_source_sha or None, + "writer_source_sha": writer_source_sha or None, + "apply_op_id": runtime.get("apply_op_id"), + "learning_operation_id": runtime.get("learning_operation_id"), + "knowledge_entry_id": runtime.get("knowledge_entry_id"), + "canonical_playbook_id": runtime.get("canonical_playbook_id"), + "catalog_id": runtime.get("catalog_id"), + "km_row_version": runtime.get("km_row_version"), + "playbook_row_version": runtime.get("playbook_row_version"), + "playbook_row_fingerprint": runtime.get( + "playbook_row_fingerprint" + ), + "source_receipt_ref": source_boundary.get("receipt_ref"), + "source_verifier": source_boundary.get("verifier"), + "controls": controls, + "active_blockers": blockers, + "writes_on_read": False, + "source_only_counts_as_completion": False, + } + payload.setdefault("rollups", {})[ + "autonomous_canonical_learning_writeback_closed_count" + ] = 1 if closed else 0 + + _RUNTIME_OPERATION_COUNTS_SQL = """ SELECT coalesce(input ->> 'semantic_operation_type', operation_type) 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 1a92de007..09f5fc6c5 100644 --- a/apps/api/src/services/awoooi_priority_work_order_readback.py +++ b/apps/api/src/services/awoooi_priority_work_order_readback.py @@ -263,7 +263,7 @@ _AI_AUTOMATION_PROGRAM_DEPENDENCIES = { "AIA-P0-011": ["AIA-P0-001"], "AIA-P0-002": ["AIA-P0-001", "AIA-P0-011"], "AIA-P0-003": ["AIA-P0-002"], - "AIA-P0-004": ["AIA-P0-002"], + "AIA-P0-004": ["AIA-P0-002", "AIA-P0-003"], "AIA-P0-005": ["AIA-P0-002", "AIA-P0-003", "AIA-P0-004"], "AIA-P0-006": ["AIA-P0-001"], "AIA-P0-008": ["AIA-P0-002", "AIA-P0-003", "AIA-P0-004", "AIA-P0-005", "AIA-P0-006"], @@ -312,6 +312,30 @@ _AI_INDEPENDENT_POST_VERIFIER_REQUIRED_CONTROL_IDS = ( "verifier_failure_retry_first", "post_verifier_raw_output_not_persisted", ) +_AI_CANONICAL_LEARNING_REQUIRED_CONTROL_IDS = ( + "strict_runtime_same_run_closed", + "writer_source_sha_matches_deployment", + "same_run_learning_operation", + "learning_operation_success", + "learning_parent_apply_matches", + "km_writeback_receipt_v1", + "km_repository_readback_verified", + "km_row_version_present", + "playbook_trust_receipt_v2", + "canonical_playbook_identity_resolved", + "km_playbook_identity_matches", + "playbook_repository_readback_verified", + "playbook_row_version_present", + "learning_and_trust_acknowledged", + "learning_receipts_public_safe", + "writer_source_sha_same_run", + "canonical_playbook_resolver_complete", + "km_row_readback_required", + "playbook_row_readback_required", + "learning_failure_fail_closed", + "trust_failure_fail_closed", + "source_learning_receipts_public_safe", +) _AI_AUTOMATION_PROGRAM_SCOPE_CLASSES: list[dict[str, Any]] = [ { "id": "hosts", @@ -7631,6 +7655,131 @@ def _apply_ai_automation_program_ledger(payload: dict[str, Any]) -> None: or single_writer_work_item["status"] == "done" ): post_verifier_work_item["status"] = "in_progress" + canonical_learning_work_item = next( + item for item in work_items if item["id"] == "AIA-P0-004" + ) + canonical_learning_closed = bool( + summary.get("ai_agent_canonical_learning_runtime_closed") is True + ) + observed_canonical_learning_controls = _dict( + summary.get("ai_agent_canonical_learning_controls") + ) + canonical_learning_controls = { + control_id: observed_canonical_learning_controls.get(control_id) is True + for control_id in _AI_CANONICAL_LEARNING_REQUIRED_CONTROL_IDS + } + canonical_learning_present_count = sum( + canonical_learning_controls.values() + ) + canonical_learning_blockers = list( + summary.get("ai_agent_canonical_learning_active_blockers") or [] + ) + canonical_learning_blockers.extend( + f"{control_id}_not_verified" + for control_id, verified in canonical_learning_controls.items() + if verified is not True + ) + if post_verifier_work_item["status"] != "done": + canonical_learning_blockers.append("dependency:AIA-P0-003_not_done") + canonical_learning_work_item["runtime_progress"] = { + "completion_percent": round( + canonical_learning_present_count + / len(_AI_CANONICAL_LEARNING_REQUIRED_CONTROL_IDS) + * 100 + ), + "present_control_count": canonical_learning_present_count, + "required_control_count": len( + _AI_CANONICAL_LEARNING_REQUIRED_CONTROL_IDS + ), + "controls": canonical_learning_controls, + "missing": list(dict.fromkeys(canonical_learning_blockers)), + "runtime_closed": canonical_learning_closed, + } + if ( + post_verifier_work_item["status"] == "done" + and canonical_learning_closed + and all(canonical_learning_controls.values()) + ): + canonical_learning_work_item["status"] = "done" + canonical_learning_work_item["completion_evidence"] = { + "source": "agent-autonomous-runtime-control", + "automation_run_id": str( + summary.get("ai_agent_canonical_learning_automation_run_id") + or "" + ), + "writer_source_sha": str( + summary.get("ai_agent_canonical_learning_writer_source_sha") + or "" + ), + "source_receipt_ref": str( + summary.get("ai_agent_canonical_learning_source_receipt_ref") + or "" + ), + "source_verifier": str( + summary.get("ai_agent_canonical_learning_source_verifier") + or "" + ), + "apply_op_id": str( + summary.get("ai_agent_canonical_learning_apply_op_id") or "" + ), + "learning_operation_id": str( + summary.get( + "ai_agent_canonical_learning_learning_operation_id" + ) + or "" + ), + "knowledge_entry_id": str( + summary.get("ai_agent_canonical_learning_knowledge_entry_id") + or "" + ), + "canonical_playbook_id": str( + summary.get( + "ai_agent_canonical_learning_canonical_playbook_id" + ) + or "" + ), + "catalog_id": str( + summary.get("ai_agent_canonical_learning_catalog_id") or "" + ), + "km_row_version": str( + summary.get("ai_agent_canonical_learning_km_row_version") or "" + ), + "playbook_row_version": _int( + summary.get("ai_agent_canonical_learning_playbook_row_version") + ), + "playbook_row_fingerprint": str( + summary.get( + "ai_agent_canonical_learning_playbook_row_fingerprint" + ) + or "" + ), + } + canonical_learning_work_item["runtime_evidence_refs"] = [ + "automation-run:" + + canonical_learning_work_item["completion_evidence"][ + "automation_run_id" + ], + "learning-operation:" + + canonical_learning_work_item["completion_evidence"][ + "learning_operation_id" + ], + "knowledge-entry:" + + canonical_learning_work_item["completion_evidence"][ + "knowledge_entry_id" + ], + "playbook:" + + canonical_learning_work_item["completion_evidence"][ + "canonical_playbook_id" + ], + canonical_learning_work_item["completion_evidence"][ + "source_receipt_ref" + ], + ] + elif ( + observed_canonical_learning_controls + or post_verifier_work_item["status"] == "done" + ): + canonical_learning_work_item["status"] = "in_progress" priority_rank = {"P0": 0, "P1": 1, "P2": 2, "P3": 3} for item in work_items: item_id = str(item["id"]) @@ -7997,6 +8146,9 @@ def apply_ai_automation_live_closure_readbacks( autonomous_independent_post_verifier = _dict( runtime_control.get("autonomous_independent_post_verifier") ) + autonomous_canonical_learning = _dict( + runtime_control.get("autonomous_canonical_learning_writeback") + ) strict_runtime_closed = bool( strict_runtime.get("schema_version") == "ai_agent_strict_runtime_completion_v2" @@ -8225,6 +8377,48 @@ def apply_ai_automation_live_closure_readbacks( rollups[ "ai_agent_independent_post_verifier_runtime_closed_count" ] = 1 if independent_post_verifier_closed else 0 + canonical_learning_controls = _dict( + autonomous_canonical_learning.get("controls") + ) + canonical_learning_closed = bool( + autonomous_canonical_learning.get("schema_version") + == "awoooi_autonomous_canonical_learning_writeback_v1" + and autonomous_canonical_learning.get("closed") is True + and str( + autonomous_canonical_learning.get("automation_run_id") or "" + ) + == str(strict_runtime.get("automation_run_id") or "") + and strict_runtime_closed + ) + summary["ai_agent_canonical_learning_runtime_closed"] = ( + canonical_learning_closed + ) + summary["ai_agent_canonical_learning_controls"] = ( + canonical_learning_controls + ) + summary["ai_agent_canonical_learning_active_blockers"] = list( + autonomous_canonical_learning.get("active_blockers") or [] + ) + for field in ( + "automation_run_id", + "writer_source_sha", + "source_receipt_ref", + "source_verifier", + "apply_op_id", + "learning_operation_id", + "knowledge_entry_id", + "canonical_playbook_id", + "catalog_id", + "km_row_version", + "playbook_row_version", + "playbook_row_fingerprint", + ): + summary[f"ai_agent_canonical_learning_{field}"] = ( + autonomous_canonical_learning.get(field) + ) + rollups["ai_agent_canonical_learning_runtime_closed_count"] = ( + 1 if canonical_learning_closed else 0 + ) executor = _dict(executor_readback) executor_rollups = _dict(executor.get("rollups")) 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 47ae83b8b..6fe07eeaf 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -1876,6 +1876,7 @@ async def _record_post_apply_verifier_and_learning( "verification_result": verification_result, "independent_verifier": verifier_receipt, "learning": False, + "km_writeback": None, "trust_learning": False, "rag_writeback": False, "runtime_stage_receipts": [], @@ -2019,9 +2020,7 @@ async def _record_post_apply_verifier_and_learning( "path_type": path_type, }, ) - if existing.scalar() is not None: - status["learning"] = True - else: + if existing.scalar() is None: repo = KnowledgeDBRepository(db) await repo.create( KnowledgeEntryCreate( @@ -2058,7 +2057,53 @@ async def _record_post_apply_verifier_and_learning( created_by="ai_agent_ansible_worker", ) ) + verified_km = await db.execute( + text(""" + SELECT + id, + related_playbook_id, + path_type, + status, + updated_at + FROM knowledge_entries + WHERE related_incident_id = :incident_id + AND path_type = :path_type + LIMIT 1 + """), + { + "incident_id": claim.incident_id, + "path_type": path_type, + }, + ) + km_row = verified_km.mappings().first() + if ( + km_row + and str(km_row.get("id") or "") + and str(km_row.get("related_playbook_id") or "") + == canonical_playbook_id + and str(km_row.get("path_type") or "") == path_type + and km_row.get("updated_at") is not None + ): status["learning"] = True + status["km_writeback"] = { + "schema_version": "ansible_km_writeback_v1", + "knowledge_entry_id": str(km_row["id"]), + "canonical_playbook_id": canonical_playbook_id, + "catalog_id": claim.catalog_id, + "path_type": path_type, + "status": str(km_row.get("status") or ""), + "km_row_version": km_row["updated_at"].isoformat(), + "repository_write_acknowledged": True, + "repository_readback_verified": True, + "durable_write_acknowledged": True, + "writer_source_sha": os.getenv( + "AWOOOI_BUILD_COMMIT_SHA", + "", + ).strip().lower() + or None, + "raw_log_payload_stored": False, + "secret_value_stored": False, + } except Exception as exc: logger.warning( "ansible_post_apply_learning_writeback_failed", @@ -2067,6 +2112,19 @@ async def _record_post_apply_verifier_and_learning( apply_op_id=apply_op_id, error=str(exc), ) + km_writeback = status.get("km_writeback") + if isinstance(km_writeback, Mapping): + status["runtime_stage_receipts"].append( + _runtime_stage_receipt( + claim, + stage_id="km_playbook_writeback", + evidence_ref=( + "knowledge_entries:" + f"{km_writeback['knowledge_entry_id']}:row_version" + ), + detail=dict(km_writeback), + ) + ) trust_writeback = await _record_learning_writeback_receipt( claim, result, diff --git a/apps/api/src/services/awooop_ansible_learning_writeback.py b/apps/api/src/services/awooop_ansible_learning_writeback.py index 512f20388..c712f54d4 100644 --- a/apps/api/src/services/awooop_ansible_learning_writeback.py +++ b/apps/api/src/services/awooop_ansible_learning_writeback.py @@ -4,6 +4,7 @@ from __future__ import annotations import hashlib import json +import os import re from typing import Any @@ -48,6 +49,122 @@ def _json_dict(value: Any) -> dict[str, Any]: return {} +def resolve_ansible_playbook_identity( + catalog_id: str, +) -> dict[str, Any] | None: + """Resolve one allowlisted catalog action to its canonical PlayBook row.""" + + catalog = get_ansible_catalog_item(catalog_id) + if not catalog: + return None + playbook_path = str(catalog.get("playbook_path") or "") + canonical_id = canonical_ansible_playbook_id(catalog_id) + identity_payload = { + "canonical_playbook_id": canonical_id, + "catalog_id": catalog_id, + "playbook_path": playbook_path, + } + return { + "schema_version": "ansible_playbook_identity_v1", + **identity_payload, + "identity_fingerprint": hashlib.sha256( + json.dumps( + identity_payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest(), + } + + +def _playbook_row_fingerprint(playbook: PlaybookRecord) -> str: + updated_at = playbook.updated_at.isoformat() if playbook.updated_at else None + payload = { + "playbook_id": playbook.playbook_id, + "project_id": playbook.project_id, + "version": int(playbook.version or 0), + "success_count": int(playbook.success_count or 0), + "failure_count": int(playbook.failure_count or 0), + "trust_score": round(float(playbook.trust_score or 0.0), 6), + "updated_at": updated_at, + } + return hashlib.sha256( + json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + + +def _playbook_trust_readback( + *, + playbook: PlaybookRecord, + identity: dict[str, Any], + verification_result: str, +) -> dict[str, Any]: + return { + "schema_version": "ansible_playbook_trust_writeback_v2", + "identity_schema_version": identity["schema_version"], + "identity_fingerprint": identity["identity_fingerprint"], + "canonical_playbook_id": playbook.playbook_id, + "catalog_id": identity["catalog_id"], + "playbook_path": identity["playbook_path"], + "playbook_row_version": int(playbook.version or 0), + "playbook_row_updated_at": ( + playbook.updated_at.isoformat() if playbook.updated_at else None + ), + "playbook_row_fingerprint": _playbook_row_fingerprint(playbook), + "success_count": int(playbook.success_count or 0), + "failure_count": int(playbook.failure_count or 0), + "trust_observation_count": ( + int(playbook.success_count or 0) + + int(playbook.failure_count or 0) + ), + "trust_score": round(float(playbook.trust_score or 0.0), 6), + "verification_result": verification_result, + "learning_recorded": True, + "trust_updated": True, + "repository_write_acknowledged": True, + "repository_readback_verified": True, + "operation_receipt_readback_verified": True, + "durable_write_acknowledged": True, + "source_table": "playbooks", + "writer_source_sha": os.getenv("AWOOOI_BUILD_COMMIT_SHA", "") + .strip() + .lower() + or None, + "raw_log_payload_stored": False, + "secret_value_stored": False, + } + + +def _existing_trust_readback_matches( + existing_trust: dict[str, Any], + *, + playbook: PlaybookRecord, + identity: dict[str, Any], +) -> bool: + return bool( + existing_trust.get("schema_version") + == "ansible_playbook_trust_writeback_v2" + and existing_trust.get("canonical_playbook_id") + == identity["canonical_playbook_id"] + and existing_trust.get("identity_fingerprint") + == identity["identity_fingerprint"] + and existing_trust.get("playbook_row_version") + == int(playbook.version or 0) + and existing_trust.get("playbook_row_fingerprint") + == _playbook_row_fingerprint(playbook) + and existing_trust.get("repository_readback_verified") is True + and existing_trust.get("operation_receipt_readback_verified") is True + and existing_trust.get("learning_recorded") is True + and existing_trust.get("trust_updated") is True + ) + + async def record_ansible_playbook_trust_writeback( *, project_id: str, @@ -62,9 +179,20 @@ async def record_ansible_playbook_trust_writeback( """Atomically update canonical PlayBook trust and its learning receipt.""" catalog = get_ansible_catalog_item(catalog_id) - if not catalog: + identity = resolve_ansible_playbook_identity(catalog_id) + if not catalog or identity is None: return None - canonical_id = canonical_ansible_playbook_id(catalog_id) + canonical_id = str(identity["canonical_playbook_id"]) + canonical_playbook_path = str(identity["playbook_path"]) + if str(playbook_path) != canonical_playbook_path: + logger.warning( + "ansible_learning_playbook_identity_mismatch", + incident_id=incident_id, + catalog_id=catalog_id, + apply_op_id=apply_op_id, + ) + return None + playbook_path = canonical_playbook_path success = verification_result == "success" now = now_taipei() @@ -89,11 +217,6 @@ async def record_ansible_playbook_trust_writeback( existing_trust = _json_dict( existing_output.get("playbook_trust_writeback") ) - if ( - existing_trust.get("durable_write_acknowledged") is True - and existing_trust.get("canonical_playbook_id") == canonical_id - ): - return existing_trust selected = await db.execute( select(PlaybookRecord) @@ -101,6 +224,31 @@ async def record_ansible_playbook_trust_writeback( .with_for_update() ) playbook = selected.scalar_one_or_none() + if existing_row: + if playbook is None: + logger.warning( + "ansible_learning_receipt_playbook_row_missing", + incident_id=incident_id, + catalog_id=catalog_id, + apply_op_id=apply_op_id, + ) + return None + if _existing_trust_readback_matches( + existing_trust, + playbook=playbook, + identity=identity, + ): + return existing_trust + logger.warning( + "ansible_learning_receipt_readback_not_verified", + incident_id=incident_id, + catalog_id=catalog_id, + apply_op_id=apply_op_id, + receipt_schema_version=existing_trust.get( + "schema_version" + ), + ) + return None if playbook is None: playbook = PlaybookRecord( playbook_id=canonical_id, @@ -161,6 +309,11 @@ async def record_ansible_playbook_trust_writeback( db.add(playbook) await db.flush() + 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) + playbook.source_incident_ids = source_incident_ids + if success: playbook.success_count = int(playbook.success_count or 0) + 1 playbook.trust_score = 0.9 * float(playbook.trust_score) + 0.1 @@ -170,84 +323,100 @@ async def record_ansible_playbook_trust_writeback( playbook.trust_score = max(0.0, min(1.0, playbook.trust_score)) playbook.last_used_at = now playbook.updated_at = now - - trust_writeback = { - "schema_version": "ansible_playbook_trust_writeback_v1", - "canonical_playbook_id": canonical_id, - "catalog_id": catalog_id, - "success_count": playbook.success_count, - "failure_count": playbook.failure_count, - "trust_score": round(float(playbook.trust_score), 6), - "verification_result": verification_result, - "durable_write_acknowledged": True, - "source_table": "playbooks", - } + await db.flush() + await db.refresh(playbook) + trust_writeback = _playbook_trust_readback( + playbook=playbook, + identity=identity, + verification_result=verification_result, + ) input_payload = { - "schema_version": "ansible_learning_writeback_receipt_v2", + "schema_version": "ansible_learning_writeback_receipt_v3", "automation_run_id": automation_run_id, "incident_id": incident_id, "catalog_id": catalog_id, "canonical_playbook_id": canonical_id, + "identity_schema_version": identity["schema_version"], + "identity_fingerprint": identity["identity_fingerprint"], "playbook_path": playbook_path, "apply_op_id": apply_op_id, "verification_result": verification_result, "learning_repository": "playbooks", + "writer_source_sha": trust_writeback["writer_source_sha"], "stores_raw_logs": False, "stores_secret_values": False, } output_payload = { "learning_recorded": True, + "trust_updated": True, "success": success, + "repository_readback_verified": True, "playbook_trust_writeback": trust_writeback, } - if existing_row: - await db.execute( - text(""" - UPDATE automation_operation_log - SET status = 'success', - input = coalesce(input, '{}'::jsonb) || CAST(:input AS jsonb), - output = coalesce(output, '{}'::jsonb) || CAST(:output AS jsonb), - error = NULL - WHERE op_id = CAST(:op_id AS uuid) - """), - { - "input": json.dumps(input_payload, ensure_ascii=False), - "output": json.dumps(output_payload, ensure_ascii=False), - "op_id": str(existing_row["op_id"]), - }, - ) - else: - await db.execute( - text(""" - INSERT INTO automation_operation_log ( - operation_type, actor, status, incident_id, - input, output, dry_run_result, parent_op_id, tags - ) VALUES ( - 'ansible_learning_writeback_recorded', - 'ansible_controlled_apply_worker', - 'success', - :incident_db_id, - CAST(:input AS jsonb), - CAST(:output AS jsonb), - '{}'::jsonb, - CAST(:parent_op_id AS uuid), - :tags - ) - """), - { - "incident_db_id": incident_db_id, - "input": json.dumps(input_payload, ensure_ascii=False), - "output": json.dumps(output_payload, ensure_ascii=False), - "parent_op_id": apply_op_id, - "tags": [ - "ansible", - "controlled_apply", - "learning_writeback", - "playbook_trust", - "ai_agent_auto_execution", - f"automation_run_id:{automation_run_id}", - ], - }, + await db.execute( + text(""" + INSERT INTO automation_operation_log ( + operation_type, actor, status, incident_id, + input, output, dry_run_result, parent_op_id, tags + ) VALUES ( + 'ansible_learning_writeback_recorded', + 'ansible_controlled_apply_worker', + 'success', + :incident_db_id, + CAST(:input AS jsonb), + CAST(:output AS jsonb), + '{}'::jsonb, + CAST(:parent_op_id AS uuid), + :tags + ) + """), + { + "incident_db_id": incident_db_id, + "input": json.dumps(input_payload, ensure_ascii=False), + "output": json.dumps(output_payload, ensure_ascii=False), + "parent_op_id": apply_op_id, + "tags": [ + "ansible", + "controlled_apply", + "learning_writeback", + "playbook_trust", + "ai_agent_auto_execution", + f"automation_run_id:{automation_run_id}", + ], + }, + ) + await db.flush() + operation_readback = await db.execute( + text(""" + SELECT status, input, output + FROM automation_operation_log + WHERE operation_type + = 'ansible_learning_writeback_recorded' + AND parent_op_id = CAST(:apply_op_id AS uuid) + ORDER BY created_at DESC + LIMIT 1 + """), + {"apply_op_id": apply_op_id}, + ) + operation_row = operation_readback.mappings().first() + operation_output = _json_dict( + operation_row.get("output") if operation_row else None + ) + operation_trust = _json_dict( + operation_output.get("playbook_trust_writeback") + ) + if not ( + operation_row + and operation_row.get("status") == "success" + and operation_output.get("learning_recorded") is True + and operation_output.get("trust_updated") is True + and operation_output.get("repository_readback_verified") + is True + and operation_trust.get("playbook_row_fingerprint") + == trust_writeback["playbook_row_fingerprint"] + ): + raise RuntimeError( + "ansible_learning_operation_receipt_readback_failed" ) return trust_writeback except Exception as exc: diff --git a/apps/api/src/services/executor_trust_boundary_readback.py b/apps/api/src/services/executor_trust_boundary_readback.py index d1b6af19d..723c3dc2d 100644 --- a/apps/api/src/services/executor_trust_boundary_readback.py +++ b/apps/api/src/services/executor_trust_boundary_readback.py @@ -50,6 +50,14 @@ _INDEPENDENT_POST_VERIFIER_REQUIRED_TRUE_FIELDS = ( "verifier_failure_retry_first", "post_verifier_raw_output_not_persisted", ) +_CANONICAL_LEARNING_REQUIRED_TRUE_FIELDS = ( + "canonical_playbook_resolver_complete", + "km_row_readback_required", + "playbook_row_readback_required", + "learning_failure_fail_closed", + "trust_failure_fail_closed", + "learning_receipts_public_safe", +) _DB_WORKLOAD_IDS = ("api", "worker", "broker") _ROLE_FINGERPRINT_PATTERN = re.compile(r"^[0-9a-f]{64}$") _cache: tuple[float, dict[str, Any]] | None = None @@ -357,6 +365,41 @@ def _build_independent_post_verifier_source_boundary( } +def _build_canonical_learning_source_boundary( + receipt: Mapping[str, str], + *, + receipt_ref: str, + source_sha_matches_deployment: bool, +) -> dict[str, Any]: + controls = { + field: _truthy(receipt.get(field)) + for field in _CANONICAL_LEARNING_REQUIRED_TRUE_FIELDS + } + blockers = [ + f"{field}_not_verified" + for field, verified in controls.items() + if verified is not True + ] + verifier = receipt.get("canonical_learning_source_verifier") or None + if verifier != "cd_tests_and_production_source_sha_v1": + blockers.append("canonical_learning_source_not_verified") + if not source_sha_matches_deployment: + blockers.append("canonical_learning_source_sha_not_deployed") + verified = not blockers + return { + "schema_version": "awoooi_canonical_learning_source_boundary_v1", + "status": "verified_ready" if verified else "in_progress", + "receipt_ref": receipt_ref, + "verifier": verifier, + "source_boundary_verified": verified, + "source_sha_matches_deployment": source_sha_matches_deployment, + "controls": controls, + "active_blockers": blockers, + "writes_on_read": False, + "secret_values_read": False, + } + + def build_executor_trust_boundary_readback( data: Mapping[str, Any] | None, *, @@ -405,6 +448,13 @@ def build_executor_trust_boundary_readback( source_sha_matches_deployment=source_sha_matches_deployment, ) ) + canonical_learning_source_boundary = ( + _build_canonical_learning_source_boundary( + receipt, + receipt_ref=receipt_ref, + source_sha_matches_deployment=source_sha_matches_deployment, + ) + ) full_workload_boundary_verified = bool( ready and database_identity_boundary["db_identity_isolated"] is True @@ -448,6 +498,9 @@ def build_executor_trust_boundary_readback( "independent_post_verifier_source_boundary": ( independent_post_verifier_source_boundary ), + "canonical_learning_source_boundary": ( + canonical_learning_source_boundary + ), "active_blockers": blockers, "full_boundary_active_blockers": [ *blockers, diff --git a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py index f5b84da1f..11a61f01f 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py @@ -1316,6 +1316,129 @@ def test_independent_post_verifier_requires_same_run_and_deployed_source() -> No ] +def test_canonical_learning_requires_row_readback_and_deployed_source() -> None: + run_id = "11111111-1111-4111-8111-111111111111" + apply_op_id = "33333333-3333-4333-8333-333333333333" + catalog_id = "ansible:188-momo-backup-user" + canonical_playbook_id = "PB-ANSIBLE-188-MOMO-BACKUP-USER" + playbook_path = "infra/ansible/playbooks/188-momo-backup-user.yml" + km_detail = { + "schema_version": "ansible_km_writeback_v1", + "knowledge_entry_id": "km-p0-004", + "canonical_playbook_id": canonical_playbook_id, + "catalog_id": catalog_id, + "km_row_version": "2026-07-11T02:00:00+00:00", + "repository_write_acknowledged": True, + "repository_readback_verified": True, + "durable_write_acknowledged": True, + "writer_source_sha": "source123", + "raw_log_payload_stored": False, + "secret_value_stored": False, + } + trust_detail = { + "schema_version": "ansible_playbook_trust_writeback_v2", + "identity_schema_version": "ansible_playbook_identity_v1", + "identity_fingerprint": "a" * 64, + "canonical_playbook_id": canonical_playbook_id, + "catalog_id": catalog_id, + "playbook_path": playbook_path, + "playbook_row_version": 2, + "playbook_row_updated_at": "2026-07-11T02:00:00+00:00", + "playbook_row_fingerprint": "b" * 64, + "trust_observation_count": 13, + "learning_recorded": True, + "trust_updated": True, + "repository_write_acknowledged": True, + "repository_readback_verified": True, + "operation_receipt_readback_verified": True, + "durable_write_acknowledged": True, + "writer_source_sha": "source123", + "raw_log_payload_stored": False, + "secret_value_stored": False, + } + runtime = runtime_control_module._build_canonical_learning_runtime_readback( + operation_latest_rows=[ + { + "op_id": "learning-p0-004", + "operation_type": "ansible_learning_writeback_recorded", + "status": "success", + "parent_op_id": apply_op_id, + "automation_run_id": run_id, + } + ], + loop_ledger={ + "automation_run_id": run_id, + "apply_op_id": apply_op_id, + "catalog_id": catalog_id, + "playbook_path": playbook_path, + "same_run_stage_receipts": [ + { + "stage_id": "km_playbook_writeback", + "detail": km_detail, + }, + {"stage_id": "playbook_trust", "detail": trust_detail}, + ], + }, + ) + assert runtime["runtime_evidence_ready"] is True + assert all(runtime["controls"].values()) + + payload = { + "strict_runtime_completion": { + "automation_run_id": run_id, + "closed": True, + }, + "rollups": {}, + } + source_controls = { + "canonical_playbook_resolver_complete": True, + "km_row_readback_required": True, + "playbook_row_readback_required": True, + "learning_failure_fail_closed": True, + "trust_failure_fail_closed": True, + "learning_receipts_public_safe": True, + } + runtime_control_module._attach_canonical_learning_readback( + payload, + readback={"canonical_learning_runtime": runtime}, + boundary_readback={ + "deployed_source_sha": "source123", + "canonical_learning_source_boundary": { + "receipt_ref": "configmap:awoooi-prod/boundary", + "verifier": "cd_tests_and_production_source_sha_v1", + "controls": source_controls, + "active_blockers": [], + }, + }, + ) + + learning = payload["autonomous_canonical_learning_writeback"] + assert learning["closed"] is True + assert learning["active_blockers"] == [] + assert learning["controls"]["learning_receipts_public_safe"] is True + assert learning["controls"]["source_learning_receipts_public_safe"] is True + assert payload["rollups"][ + "autonomous_canonical_learning_writeback_closed_count" + ] == 1 + + runtime_control_module._attach_canonical_learning_readback( + payload, + readback={"canonical_learning_runtime": runtime}, + boundary_readback={ + "deployed_source_sha": "different-source", + "canonical_learning_source_boundary": { + "controls": source_controls, + "active_blockers": [], + }, + }, + ) + learning = payload["autonomous_canonical_learning_writeback"] + assert learning["closed"] is False + assert "writer_source_sha_matches_deployment_not_verified" in learning[ + "active_blockers" + ] + + def test_ai_agent_autonomous_runtime_control_uses_current_owner_directive(): data = build_ai_agent_autonomous_runtime_control() @@ -2103,16 +2226,53 @@ def test_strict_learning_receipts_require_terminal_proof(): "chunk_index_verified": True, }, }) is False + assert _runtime_stage_receipt_has_required_proof({ + "stage_id": "km_playbook_writeback", + "detail": { + "schema_version": "ansible_km_writeback_v1", + "knowledge_entry_id": "km-entry-1", + "canonical_playbook_id": "PB-ANSIBLE-188-AI-WEB", + "km_row_version": "2026-07-11T02:00:00+00:00", + "repository_write_acknowledged": True, + "repository_readback_verified": True, + "durable_write_acknowledged": True, + "writer_source_sha": "source123", + "raw_log_payload_stored": False, + "secret_value_stored": False, + }, + }) is True assert _runtime_stage_receipt_has_required_proof({ "stage_id": "playbook_trust", "detail": { + "schema_version": "ansible_playbook_trust_writeback_v2", + "identity_schema_version": "ansible_playbook_identity_v1", + "identity_fingerprint": "a" * 64, + "durable_write_acknowledged": True, + "canonical_playbook_id": "PB-ANSIBLE-188-AI-WEB", + "playbook_row_version": 2, + "playbook_row_updated_at": "2026-07-11T02:00:00+00:00", + "playbook_row_fingerprint": "b" * 64, + "trust_score": 0.37, + "trust_observation_count": 1, + "learning_recorded": True, + "trust_updated": True, + "repository_write_acknowledged": True, + "repository_readback_verified": True, + "operation_receipt_readback_verified": True, + "writer_source_sha": "source123", + "raw_log_payload_stored": False, + "secret_value_stored": False, + }, + }) is True + assert _runtime_stage_receipt_has_required_proof({ + "stage_id": "playbook_trust", + "detail": { + "schema_version": "ansible_playbook_trust_writeback_v1", "durable_write_acknowledged": True, "canonical_playbook_id": "PB-ANSIBLE-188-AI-WEB", "trust_score": 0.37, - "success_count": 1, - "failure_count": 0, }, - }) is True + }) is False def test_strict_runtime_completion_rejects_cross_run_receipt_mix(): 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 1f0044d08..3f1615bb1 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 @@ -205,6 +205,39 @@ def _autonomous_runtime_control_independent_post_verifier_closed() -> dict: return payload +def _autonomous_runtime_control_canonical_learning_closed() -> dict: + payload = _autonomous_runtime_control_independent_post_verifier_closed() + run_id = payload["strict_runtime_completion"]["automation_run_id"] + payload["autonomous_canonical_learning_writeback"] = { + "schema_version": ( + "awoooi_autonomous_canonical_learning_writeback_v1" + ), + "closed": True, + "automation_run_id": run_id, + "writer_source_sha": "source-p0-004", + "apply_op_id": "apply-p0-004", + "learning_operation_id": "learning-p0-004", + "knowledge_entry_id": "knowledge-p0-004", + "canonical_playbook_id": "PB-ANSIBLE-188-MOMO-BACKUP-USER", + "catalog_id": "ansible:188-momo-backup-user", + "km_row_version": "2026-07-11T02:00:00+00:00", + "playbook_row_version": 2, + "playbook_row_fingerprint": "a" * 64, + "source_receipt_ref": ( + "configmap:awoooi-prod/awoooi-executor-boundary-verification" + ), + "source_verifier": "cd_tests_and_production_source_sha_v1", + "controls": { + control_id: True + for control_id in ( + priority_service._AI_CANONICAL_LEARNING_REQUIRED_CONTROL_IDS + ) + }, + "active_blockers": [], + } + return payload + + async def _autonomous_runtime_control_closed_async() -> dict: return _autonomous_runtime_control_closed() @@ -2178,6 +2211,63 @@ def test_ai_automation_independent_post_verifier_fails_closed() -> None: assert program["summary"]["done_count"] == 3 +def test_ai_automation_canonical_learning_receipt_advances_order() -> None: + payload = load_latest_awoooi_priority_work_order_readback() + + apply_ai_automation_live_closure_readbacks( + payload, + autonomous_runtime_control=( + _autonomous_runtime_control_canonical_learning_closed() + ), + ) + + program = payload["ai_automation_program_ledger"] + items = {item["id"]: item for item in program["work_items"]} + learning = items["AIA-P0-004"] + assert items["AIA-P0-003"]["status"] == "done" + assert learning["status"] == "done" + assert learning["runtime_progress"]["runtime_closed"] is True + assert learning["runtime_progress"]["completion_percent"] == 100 + assert learning["runtime_progress"]["required_control_count"] == len( + priority_service._AI_CANONICAL_LEARNING_REQUIRED_CONTROL_IDS + ) + assert learning["completion_evidence"]["canonical_playbook_id"] == ( + "PB-ANSIBLE-188-MOMO-BACKUP-USER" + ) + assert learning["completion_evidence"]["playbook_row_version"] == 2 + assert program["summary"]["next_work_item_id"] == "AIA-P0-005" + assert program["summary"]["done_count"] == 5 + assert program["summary"]["completion_percent"] == 25 + + +def test_ai_automation_canonical_learning_receipt_fails_closed() -> None: + payload = load_latest_awoooi_priority_work_order_readback() + runtime_control = _autonomous_runtime_control_canonical_learning_closed() + learning = runtime_control["autonomous_canonical_learning_writeback"] + learning["closed"] = False + learning["controls"]["playbook_repository_readback_verified"] = False + learning["active_blockers"] = [ + "playbook_repository_readback_verified_not_verified" + ] + + apply_ai_automation_live_closure_readbacks( + payload, + autonomous_runtime_control=runtime_control, + ) + + program = payload["ai_automation_program_ledger"] + items = {item["id"]: item for item in program["work_items"]} + learning_item = items["AIA-P0-004"] + assert learning_item["status"] == "in_progress" + assert learning_item["runtime_progress"]["runtime_closed"] is False + assert learning_item["runtime_progress"]["completion_percent"] < 100 + assert "playbook_repository_readback_verified_not_verified" in ( + learning_item["runtime_progress"]["missing"] + ) + assert program["summary"]["next_work_item_id"] == "AIA-P0-004" + assert program["summary"]["done_count"] == 4 + + def test_awoooi_priority_work_order_readback_endpoint_returns_snapshot( monkeypatch: pytest.MonkeyPatch, ): diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index 79798d005..440e4e18d 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -61,9 +61,11 @@ from src.services.awooop_ansible_check_mode_service import ( ) from src.services.awooop_ansible_learning_writeback import ( _ensure_rag_chunk_fallback, + _playbook_trust_readback, canonical_ansible_playbook_id, ensure_ansible_rag_writeback, record_ansible_playbook_trust_writeback, + resolve_ansible_playbook_identity, ) from src.services.awooop_truth_chain_service import ( _ansible_playbook_roots, @@ -2099,13 +2101,18 @@ def test_ansible_apply_receipt_backfill_includes_verifier_and_km_gaps() -> None: def test_ansible_learning_writeback_uses_canonical_durable_service() -> None: source = inspect.getsource(_record_learning_writeback_receipt) + durable_source = inspect.getsource(record_ansible_playbook_trust_writeback) + receipt_source = inspect.getsource(_playbook_trust_readback) assert "record_ansible_playbook_trust_writeback" in source - assert "canonical" in inspect.getsource(record_ansible_playbook_trust_writeback) - assert "FOR UPDATE" in inspect.getsource(record_ansible_playbook_trust_writeback) - assert "durable_write_acknowledged" in inspect.getsource( - record_ansible_playbook_trust_writeback - ) + assert "resolve_ansible_playbook_identity" in durable_source + assert "FOR UPDATE" in durable_source + assert "await db.refresh(playbook)" in durable_source + assert '"operation_receipt_readback_verified": True' in receipt_source + assert "operation_readback" in durable_source + assert 'operation_output.get("learning_recorded") is True' in durable_source + assert 'operation_output.get("trust_updated") is True' in durable_source + assert '"durable_write_acknowledged": True' in receipt_source def test_ansible_catalog_ids_map_to_stable_playbook_primary_keys() -> None: @@ -2113,6 +2120,10 @@ def test_ansible_catalog_ids_map_to_stable_playbook_primary_keys() -> None: "ansible:110-devops": "PB-ANSIBLE-110-DEVOPS", "ansible:188-momo-backup-user": "PB-ANSIBLE-188-MOMO-BACKUP-USER", "ansible:188-ai-web": "PB-ANSIBLE-188-AI-WEB", + "ansible:nginx-sync": "PB-ANSIBLE-NGINX-SYNC", + "ansible:restore-password-auth": ( + "PB-ANSIBLE-RESTORE-PASSWORD-AUTH" + ), } assert { @@ -2120,6 +2131,29 @@ def test_ansible_catalog_ids_map_to_stable_playbook_primary_keys() -> None: for catalog_id in expected } == expected assert all(len(playbook_id) <= 32 for playbook_id in expected.values()) + for catalog_id, playbook_id in expected.items(): + identity = resolve_ansible_playbook_identity(catalog_id) + assert identity is not None + assert identity["schema_version"] == "ansible_playbook_identity_v1" + assert identity["canonical_playbook_id"] == playbook_id + assert len(identity["identity_fingerprint"]) == 64 + assert resolve_ansible_playbook_identity("ansible:not-allowlisted") is None + + +@pytest.mark.asyncio +async def test_ansible_learning_writeback_rejects_catalog_path_mismatch() -> None: + receipt = await record_ansible_playbook_trust_writeback( + project_id="awoooi", + automation_run_id="run-path-mismatch", + incident_id="INC-PATH-MISMATCH", + incident_db_id=None, + catalog_id="ansible:188-momo-backup-user", + playbook_path="infra/ansible/playbooks/not-allowlisted.yml", + apply_op_id="11111111-1111-4111-8111-111111111111", + verification_result="success", + ) + + assert receipt is None def test_ansible_rag_writeback_requires_embedding_readback() -> None: @@ -2208,6 +2242,9 @@ def test_ansible_post_apply_km_writeback_is_idempotent_for_learning_backfill() - assert "KnowledgeDBRepository" in source assert "stage_id=\"rag_writeback\"" in source assert "stage_id=\"playbook_trust\"" in source + assert "stage_id=\"km_playbook_writeback\"" in source + assert "repository_readback_verified" in source + assert "km_row_version" in source assert '"knowledge_entries:"' in source assert '"rag_chunks:"' in source diff --git a/apps/api/tests/test_executor_trust_boundary_readback.py b/apps/api/tests/test_executor_trust_boundary_readback.py index 7aa703151..869b33bcd 100644 --- a/apps/api/tests/test_executor_trust_boundary_readback.py +++ b/apps/api/tests/test_executor_trust_boundary_readback.py @@ -44,6 +44,15 @@ def _verified_receipt(source_sha: str = "abc123") -> dict[str, str]: "verifier_failure_marks_apply_failed": "true", "verifier_failure_retry_first": "true", "post_verifier_raw_output_not_persisted": "true", + "canonical_learning_source_verifier": ( + "cd_tests_and_production_source_sha_v1" + ), + "canonical_playbook_resolver_complete": "true", + "km_row_readback_required": "true", + "playbook_row_readback_required": "true", + "learning_failure_fail_closed": "true", + "trust_failure_fail_closed": "true", + "learning_receipts_public_safe": "true", "db_identity_verifier": ( "runtime_role_fingerprint_secret_projection_and_" "representative_preflight_v2" @@ -119,6 +128,10 @@ def test_executor_trust_boundary_requires_live_controls_and_matching_source() -> assert verifier_boundary["source_boundary_verified"] is True assert verifier_boundary["active_blockers"] == [] assert all(verifier_boundary["controls"].values()) + learning_boundary = readback["canonical_learning_source_boundary"] + assert learning_boundary["source_boundary_verified"] is True + assert learning_boundary["active_blockers"] == [] + assert all(learning_boundary["controls"].values()) def test_executor_trust_boundary_single_writer_source_controls_fail_closed() -> None: @@ -155,6 +168,23 @@ def test_executor_trust_boundary_post_verifier_source_controls_fail_closed() -> ) +def test_executor_trust_boundary_canonical_learning_controls_fail_closed() -> None: + receipt = _verified_receipt() + receipt["playbook_row_readback_required"] = "false" + + readback = build_executor_trust_boundary_readback( + receipt, + deployed_source_sha="abc123", + ) + + learning_boundary = readback["canonical_learning_source_boundary"] + assert readback["production_boundary_verified"] is True + assert learning_boundary["source_boundary_verified"] is False + assert "playbook_row_readback_required_not_verified" in ( + learning_boundary["active_blockers"] + ) + + def test_executor_trust_boundary_rejects_stale_or_incomplete_receipt() -> None: stale = build_executor_trust_boundary_readback( _verified_receipt("old-source"), diff --git a/apps/api/tests/test_signal_worker_ansible_executor_binding.py b/apps/api/tests/test_signal_worker_ansible_executor_binding.py index 35323c0fa..ec19542f3 100644 --- a/apps/api/tests/test_signal_worker_ansible_executor_binding.py +++ b/apps/api/tests/test_signal_worker_ansible_executor_binding.py @@ -144,6 +144,13 @@ def test_cd_prunes_and_verifies_api_executor_boundary_in_production() -> None: ) if document and document.get("kind") == "Deployment" ) + worker_deployment = next( + document + for document in yaml.safe_load_all( + (repo_root / "k8s/awoooi-prod/08-deployment-worker.yaml").read_text() + ) + if document and document.get("kind") == "Deployment" + ) workflow = (repo_root / ".gitea/workflows/cd.yaml").read_text() assert "argocd.argoproj.io/sync-options" not in deployment["metadata"].get( @@ -172,6 +179,13 @@ def test_cd_prunes_and_verifies_api_executor_boundary_in_production() -> None: assert "verifier_failure_marks_apply_failed=true" in workflow assert "verifier_failure_retry_first=true" in workflow assert "post_verifier_raw_output_not_persisted=true" in workflow + assert "canonical_learning_source_verifier=cd_tests_and_production_source_sha_v1" in workflow + assert "canonical_playbook_resolver_complete=true" in workflow + assert "km_row_readback_required=true" in workflow + assert "playbook_row_readback_required=true" in workflow + assert "learning_failure_fail_closed=true" in workflow + assert "trust_failure_fail_closed=true" in workflow + assert "learning_receipts_public_safe=true" in workflow assert ( "k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml" in workflow @@ -181,6 +195,12 @@ def test_cd_prunes_and_verifies_api_executor_boundary_in_production() -> None: assert 'database_boundary.get("db_identity_isolated") is True' in workflow assert "autonomous_single_writer_source_boundary" in workflow assert "independent_post_verifier_source_boundary" in workflow + assert "canonical_learning_source_boundary" in workflow + worker_env = worker_deployment["spec"]["template"]["spec"]["containers"][0][ + "env" + ] + assert any(item.get("name") == "AWOOOI_BUILD_COMMIT_SHA" for item in worker_env) + assert "k8s/awoooi-prod/08-deployment-worker.yaml" in workflow assert '"source_boundary_verified"' in workflow assert 'database_boundary.get("connection_budget_verified") is True' in workflow assert "verified_source_sha" in workflow diff --git a/k8s/awoooi-prod/08-deployment-worker.yaml b/k8s/awoooi-prod/08-deployment-worker.yaml index 8a46970dc..962cf2956 100644 --- a/k8s/awoooi-prod/08-deployment-worker.yaml +++ b/k8s/awoooi-prod/08-deployment-worker.yaml @@ -167,6 +167,8 @@ spec: value: "0" - name: DATABASE_NULL_POOL value: "true" + - name: AWOOOI_BUILD_COMMIT_SHA + value: "653e58179ea2ad538d4bfaa26aebca1dce6bfb1c" - name: ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER value: "true" - name: AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_INTERVAL_SECONDS