From c682551bca1b3f3062aa8ca7d4fc1461805adc2c Mon Sep 17 00:00:00 2001 From: AWOOOI CD Date: Fri, 10 Jul 2026 19:38:03 +0800 Subject: [PATCH 1/2] chore(cd): deploy 5487b12 [skip ci] --- k8s/awoooi-prod/06-deployment-api.yaml | 4 ++-- k8s/awoooi-prod/kustomization.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index e2e6b0977..6a0eeccac 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -84,12 +84,12 @@ spec: - name: AWOOOI_BUILD_COMMIT_SHA # 2026-06-29 Codex: CD rewrites this to the deployed image tag so # production deploy readback does not rely on a stale static snapshot. - value: "65de12b5e88f0d769bf758c1e9ece096bddcb7fa" + value: "5487b128e33922eddd9a9f4cd96561b3c4e8eb69" - name: AWOOOI_DESIRED_API_IMAGE_TAG # 2026-06-30 Codex: CD rewrites this alongside AWOOOI_BUILD_COMMIT_SHA. # Production readback compares runtime image truth against this # GitOps desired tag instead of doing a slow Gitea raw fetch. - value: "65de12b5e88f0d769bf758c1e9ece096bddcb7fa" + value: "5487b128e33922eddd9a9f4cd96561b3c4e8eb69" - name: DATABASE_POOL_SIZE # 2026-07-01 Codex: production role `awoooi` currently has a low # connection limit. Keep API pool conservative until DB role diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml index 11c5c1324..02ce1f0f6 100644 --- a/k8s/awoooi-prod/kustomization.yaml +++ b/k8s/awoooi-prod/kustomization.yaml @@ -41,7 +41,7 @@ resources: images: - name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/api - newTag: 65de12b5e88f0d769bf758c1e9ece096bddcb7fa + newTag: 5487b128e33922eddd9a9f4cd96561b3c4e8eb69 - name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/web - newTag: 65de12b5e88f0d769bf758c1e9ece096bddcb7fa + newTag: 5487b128e33922eddd9a9f4cd96561b3c4e8eb69 From 542fbda10998ad3c9ef22b5aea6104d27e048cf8 Mon Sep 17 00:00:00 2001 From: ogt Date: Fri, 10 Jul 2026 19:41:13 +0800 Subject: [PATCH 2/2] feat(agent): persist same-run governance receipts --- .../ai_agent_autonomous_runtime_control.py | 103 ++++++++- .../ai_automation_runtime_contract.py | 1 + .../awooop_ansible_check_mode_service.py | 210 ++++++++++++++++++ ...est_ai_agent_autonomous_runtime_control.py | 36 ++- .../tests/test_awooop_truth_chain_service.py | 54 +++++ 5 files changed, 385 insertions(+), 19 deletions(-) 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 09d43b6ce..7ea1ae0ff 100644 --- a/apps/api/src/services/ai_agent_autonomous_runtime_control.py +++ b/apps/api/src/services/ai_agent_autonomous_runtime_control.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio import copy +import json import time from collections.abc import Iterable, Mapping from datetime import UTC, datetime @@ -34,6 +35,7 @@ from src.services.ai_automation_runtime_contract import ( AI_AUTOMATION_EXECUTION_CORRELATED_STAGES, AI_AUTOMATION_REQUIRED_LOOP_STAGES, AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION, + AI_AUTOMATION_STAGE_RECEIPT_SCHEMA_VERSION, ) from src.services.report_generation_service import ( DAILY_REPORT_HOUR_TAIPEI, @@ -3347,6 +3349,45 @@ def _merge_runtime_operation_rows( return merged +def _runtime_stage_receipt_map( + rows: Iterable[Mapping[str, Any] | Any], + *, + expected_run_id: str, +) -> dict[str, dict[str, Any]]: + receipts: dict[str, dict[str, Any]] = {} + if not expected_run_id: + return receipts + for raw_row in rows: + row = _row_mapping(raw_row) + if str(row.get("automation_run_id") or "") != expected_run_id: + continue + raw_receipts = row.get("runtime_stage_receipts") + if isinstance(raw_receipts, str): + try: + raw_receipts = json.loads(raw_receipts) + except json.JSONDecodeError: + continue + if not isinstance(raw_receipts, list): + continue + for raw_receipt in raw_receipts: + if not isinstance(raw_receipt, Mapping): + continue + receipt = dict(raw_receipt) + stage_id = str(receipt.get("stage_id") or "") + if ( + stage_id not in AI_AUTOMATION_REQUIRED_LOOP_STAGES + or receipt.get("schema_version") + != AI_AUTOMATION_STAGE_RECEIPT_SCHEMA_VERSION + or str(receipt.get("automation_run_id") or "") + != expected_run_id + or receipt.get("durable_receipt") is not True + or not str(receipt.get("evidence_ref") or "") + ): + continue + receipts[stage_id] = receipt + return receipts + + def _stage_status(row: Mapping[str, Any] | None, *, fallback_status: str | None = None) -> str: if row is None: return fallback_status or "missing" @@ -3499,6 +3540,10 @@ def _autonomous_execution_loop_ledger( or (latest_apply or {}).get("automation_run_id") or "" ) + same_run_stage_receipt_by_id = _runtime_stage_receipt_map( + operation_rows, + expected_run_id=automation_run_id, + ) candidate_present = latest_candidate is not None check_present = latest_check is not None apply_present = latest_apply is not None @@ -3653,6 +3698,11 @@ def _autonomous_execution_loop_ledger( "run_id_mismatch_stage_ids": run_id_mismatch_stage_ids, "missing_stage_ids": missing_stage_ids, "next_executor_action": next_executor_action, + "same_run_stage_receipts": [ + same_run_stage_receipt_by_id[stage_id] + for stage_id in AI_AUTOMATION_REQUIRED_LOOP_STAGES + if stage_id in same_run_stage_receipt_by_id + ], "stages": stages, "safety_contract": { "writes_on_read": False, @@ -4920,6 +4970,14 @@ def _attach_runtime_receipt_readback( for stage in trace_stages if isinstance(stage, Mapping) and stage.get("stage_id") } + same_run_stage_receipts = loop_ledger.get("same_run_stage_receipts") + if not isinstance(same_run_stage_receipts, list): + same_run_stage_receipts = [] + same_run_stage_receipt_by_id = { + str(receipt.get("stage_id") or ""): receipt + for receipt in same_run_stage_receipts + if isinstance(receipt, Mapping) and receipt.get("stage_id") + } latest_flow_closed = latest_flow.get("closed") is True latest_loop_closed = loop_ledger.get("closed") is True automation_run_id = str(loop_ledger.get("automation_run_id") or "") @@ -4932,18 +4990,30 @@ def _attach_runtime_receipt_readback( same_run_proven = stage.get("run_id_matches_expected") is True evidence_source = "same_run_execution_loop_ledger" else: - stage = trace_stage_by_id.get(stage_id, {}) - evidence_present = stage.get("recorded") is True - # Aggregate trace counts prove that a source exists, not that it - # belongs to the latest AutomationRun. Incident closure is the - # only auxiliary stage currently carrying the same run identity. - same_run_proven = bool( - stage_id == "incident_closure" - and evidence_present - and automation_run_id - and latest_flow_run_id == automation_run_id - ) - evidence_source = "aggregate_trace_ledger" + same_run_receipt = same_run_stage_receipt_by_id.get(stage_id, {}) + if same_run_receipt: + stage = same_run_receipt + evidence_present = True + same_run_proven = bool( + automation_run_id + and str(same_run_receipt.get("automation_run_id") or "") + == automation_run_id + and same_run_receipt.get("durable_receipt") is True + ) + evidence_source = "same_run_runtime_stage_receipt" + else: + stage = trace_stage_by_id.get(stage_id, {}) + evidence_present = stage.get("recorded") is True + # Aggregate trace counts prove that a source exists, not that it + # belongs to the latest AutomationRun. Incident closure is the + # only auxiliary stage currently carrying the same run identity. + same_run_proven = bool( + stage_id == "incident_closure" + and evidence_present + and automation_run_id + and latest_flow_run_id == automation_run_id + ) + evidence_source = "aggregate_trace_ledger" completion_eligible = evidence_present and same_run_proven strict_stage_contracts.append( { @@ -4952,6 +5022,11 @@ def _attach_runtime_receipt_readback( "same_run_correlation_proven": same_run_proven, "completion_eligible": completion_eligible, "evidence_source": evidence_source, + "evidence_ref": ( + stage.get("evidence_ref") + if evidence_source == "same_run_runtime_stage_receipt" + else None + ), } ) required_stage_count = len(strict_stage_contracts) @@ -5948,6 +6023,7 @@ _RUNTIME_OPERATION_LATEST_SQL = """ input ->> 'check_mode_op_id' AS check_mode_op_id, input ->> 'risk_level' AS risk_level, input ->> 'controlled_apply_allowed' AS controlled_apply_allowed, + input -> 'runtime_stage_receipts' AS runtime_stage_receipts, coalesce(output ->> 'returncode', dry_run_result ->> 'returncode') AS returncode, duration_ms, created_at @@ -6023,6 +6099,7 @@ _RUNTIME_OPERATION_LATEST_DIRECT_SQL = """ input ->> 'check_mode_op_id' AS check_mode_op_id, input ->> 'risk_level' AS risk_level, input ->> 'controlled_apply_allowed' AS controlled_apply_allowed, + input -> 'runtime_stage_receipts' AS runtime_stage_receipts, coalesce(output ->> 'returncode', dry_run_result ->> 'returncode') AS returncode, duration_ms, created_at @@ -6088,6 +6165,7 @@ _RUNTIME_OPERATION_CHAIN_SQL = """ input ->> 'check_mode_op_id' AS check_mode_op_id, input ->> 'risk_level' AS risk_level, input ->> 'controlled_apply_allowed' AS controlled_apply_allowed, + input -> 'runtime_stage_receipts' AS runtime_stage_receipts, coalesce(output ->> 'returncode', dry_run_result ->> 'returncode') AS returncode, duration_ms, created_at @@ -6126,6 +6204,7 @@ _RUNTIME_OPERATION_CHAIN_DIRECT_SQL = """ input ->> 'check_mode_op_id' AS check_mode_op_id, input ->> 'risk_level' AS risk_level, input ->> 'controlled_apply_allowed' AS controlled_apply_allowed, + input -> 'runtime_stage_receipts' AS runtime_stage_receipts, coalesce(output ->> 'returncode', dry_run_result ->> 'returncode') AS returncode, duration_ms, created_at diff --git a/apps/api/src/services/ai_automation_runtime_contract.py b/apps/api/src/services/ai_automation_runtime_contract.py index 969adfe9b..63ad05938 100644 --- a/apps/api/src/services/ai_automation_runtime_contract.py +++ b/apps/api/src/services/ai_automation_runtime_contract.py @@ -5,6 +5,7 @@ from __future__ import annotations AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION = ( "ai_automation_runtime_completion_contract_v2" ) +AI_AUTOMATION_STAGE_RECEIPT_SCHEMA_VERSION = "ai_automation_stage_receipt_v1" # Every stage is required before the product may claim an autonomous security # or operations loop is complete. A source contract or historical aggregate is 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 77f06f0d3..303e98c59 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -22,6 +22,9 @@ from sqlalchemy import text from src.core.config import settings from src.db.base import get_db_context +from src.services.ai_automation_runtime_contract import ( + AI_AUTOMATION_STAGE_RECEIPT_SCHEMA_VERSION, +) from src.services.awooop_ansible_audit_service import get_ansible_catalog_item logger = structlog.get_logger(__name__) @@ -735,6 +738,179 @@ def _post_apply_action_label(claim: AnsibleCheckModeClaim, *, apply_op_id: str) ) +def _automation_run_id_for_claim(claim: AnsibleCheckModeClaim) -> str: + return str( + claim.input_payload.get("automation_run_id") + or claim.source_candidate_op_id + ) + + +def _runtime_stage_receipt( + claim: AnsibleCheckModeClaim, + *, + stage_id: str, + evidence_ref: str, + detail: dict[str, Any], + derived_from_durable_chain: bool = False, +) -> dict[str, Any]: + return { + "schema_version": AI_AUTOMATION_STAGE_RECEIPT_SCHEMA_VERSION, + "stage_id": stage_id, + "automation_run_id": _automation_run_id_for_claim(claim), + "incident_id": claim.incident_id, + "evidence_ref": evidence_ref, + "detail": detail, + "durable_receipt": True, + "derived_from_durable_chain": derived_from_durable_chain, + "raw_log_payload_stored": False, + "secret_value_stored": False, + } + + +def build_ansible_pre_apply_runtime_stage_receipts( + claim: AnsibleCheckModeClaim, + *, + derived_from_durable_chain: bool = False, +) -> list[dict[str, Any]]: + """Build receipts proven before controlled apply from the claimed dry-run.""" + + return [ + _runtime_stage_receipt( + claim, + stage_id="normalized_asset_identity", + evidence_ref=f"ansible_catalog:{claim.catalog_id}", + detail={ + "asset_kind": "managed_host", + "canonical_asset_ids": list(claim.inventory_hosts), + "catalog_id": claim.catalog_id, + "playbook_path": claim.apply_playbook_path, + }, + derived_from_durable_chain=derived_from_durable_chain, + ), + _runtime_stage_receipt( + claim, + stage_id="source_truth_diff", + evidence_ref=( + "automation_operation_log:" + f"{claim.op_id}:dry_run_result" + ), + detail={ + "check_mode_op_id": claim.op_id, + "check_mode": True, + "diff": True, + "check_mode_passed_before_apply": True, + }, + derived_from_durable_chain=derived_from_durable_chain, + ), + _runtime_stage_receipt( + claim, + stage_id="risk_policy_decision", + evidence_ref=f"ansible_policy:{claim.catalog_id}", + detail={ + "risk_level": str(claim.risk_level or "").lower(), + "controlled_apply_allowed": True, + "break_glass_required": False, + "policy": "global_product_governance_v2", + }, + derived_from_durable_chain=derived_from_durable_chain, + ), + ] + + +def build_ansible_post_apply_runtime_stage_receipts( + claim: AnsibleCheckModeClaim, + result: AnsibleRunResult, + *, + apply_op_id: str, + verifier_ready: bool, + derived_from_durable_chain: bool = False, +) -> list[dict[str, Any]]: + receipts = [ + _runtime_stage_receipt( + claim, + stage_id="executor_log_projection", + evidence_ref=f"automation_operation_log:{apply_op_id}:output", + detail={ + "apply_op_id": apply_op_id, + "returncode": result.returncode, + "timed_out": result.timed_out, + "duration_ms": result.duration_ms, + "projection_sanitized": True, + }, + derived_from_durable_chain=derived_from_durable_chain, + ) + ] + if result.returncode == 0 and verifier_ready: + receipts.append( + _runtime_stage_receipt( + claim, + stage_id="retry_or_rollback", + evidence_ref=f"incident_evidence:{apply_op_id}:post_execution_state", + detail={ + "apply_op_id": apply_op_id, + "terminal_type": "verified_success_no_retry_or_rollback_required", + "bounded_retry_performed": False, + "rollback_performed": False, + "post_apply_verifier_passed": True, + }, + derived_from_durable_chain=derived_from_durable_chain, + ) + ) + return receipts + + +async def _record_runtime_stage_receipts( + claim: AnsibleCheckModeClaim, + result: AnsibleRunResult, + *, + apply_op_id: str, + verifier_ready: bool, + project_id: str, + derived_from_durable_chain: bool = False, +) -> bool: + receipts = [ + *build_ansible_pre_apply_runtime_stage_receipts( + claim, + derived_from_durable_chain=derived_from_durable_chain, + ), + *build_ansible_post_apply_runtime_stage_receipts( + claim, + result, + apply_op_id=apply_op_id, + verifier_ready=verifier_ready, + derived_from_durable_chain=derived_from_durable_chain, + ), + ] + try: + async with get_db_context(project_id) as db: + updated = await db.execute( + text(""" + UPDATE automation_operation_log + SET input = jsonb_set( + coalesce(input, '{}'::jsonb), + '{runtime_stage_receipts}', + CAST(:receipts AS jsonb), + true + ) + WHERE op_id = CAST(:apply_op_id AS uuid) + AND operation_type = 'ansible_apply_executed' + """), + { + "receipts": json.dumps(receipts, ensure_ascii=False), + "apply_op_id": apply_op_id, + }, + ) + return bool(updated.rowcount) + except Exception as exc: + logger.warning( + "ansible_runtime_stage_receipt_write_failed", + incident_id=claim.incident_id, + apply_op_id=apply_op_id, + error=str(exc), + ) + return False + + async def _record_learning_writeback_receipt( claim: AnsibleCheckModeClaim, result: AnsibleRunResult, @@ -1080,6 +1256,7 @@ async def backfill_missing_auto_repair_execution_receipts_once( "verification_written": 0, "learning_written": 0, "trust_learning_written": 0, + "runtime_stage_receipts_written": 0, "skipped": 0, "error": None, } @@ -1098,6 +1275,16 @@ async def backfill_missing_auto_repair_execution_receipts_once( apply.error, apply.duration_ms, apply.status, + EXISTS ( + SELECT 1 + FROM incident_evidence verifier + WHERE verifier.incident_id = coalesce( + apply.incident_id::text, + apply.input ->> 'incident_id' + ) + AND verifier.post_execution_state ->> 'apply_op_id' = apply.op_id::text + AND verifier.verification_result = 'success' + ) AS verifier_ready, apply.created_at FROM automation_operation_log apply WHERE apply.operation_type = 'ansible_apply_executed' @@ -1134,6 +1321,7 @@ async def backfill_missing_auto_repair_execution_receipts_once( WHERE learning.operation_type = 'ansible_learning_writeback_recorded' AND learning.parent_op_id = apply.op_id ) + OR NOT (coalesce(apply.input, '{}'::jsonb) ? 'runtime_stage_receipts') ) ORDER BY apply.created_at DESC LIMIT :limit @@ -1169,6 +1357,17 @@ async def backfill_missing_auto_repair_execution_receipts_once( stats["learning_written"] += 1 if writeback.get("trust_learning"): stats["trust_learning_written"] += 1 + if await _record_runtime_stage_receipts( + claim, + result, + apply_op_id=str(row.get("op_id") or ""), + verifier_ready=bool( + writeback.get("verification") or row.get("verifier_ready") + ), + project_id=project_id, + derived_from_durable_chain=True, + ): + stats["runtime_stage_receipts_written"] += 1 except Exception as exc: stats["error"] = f"{type(exc).__name__}: {exc}"[:500] logger.warning("ansible_auto_repair_execution_receipt_backfill_failed", **stats) @@ -1628,6 +1827,9 @@ async def run_controlled_apply_for_claim( "apply_enabled": True, "approval_required_before_apply": False, "controlled_apply_allowed": True, + "runtime_stage_receipts": ( + build_ansible_pre_apply_runtime_stage_receipts(claim) + ), }, ensure_ascii=False), "dry_run_result": json.dumps({ "check_mode_executed_before_apply": True, @@ -1695,6 +1897,13 @@ async def run_controlled_apply_for_claim( apply_op_id=apply_op_id, project_id=project_id, ) + runtime_stage_receipts_written = await _record_runtime_stage_receipts( + claim, + result, + apply_op_id=apply_op_id, + verifier_ready=bool(writeback.get("verification")), + project_id=project_id, + ) telegram_receipt_sent = await _send_controlled_apply_telegram_receipt( claim, result, @@ -1715,6 +1924,7 @@ async def run_controlled_apply_for_claim( auto_repair_receipt_written=receipt_written, post_apply_verification_written=writeback.get("verification"), post_apply_learning_written=writeback.get("learning"), + runtime_stage_receipts_written=runtime_stage_receipts_written, telegram_receipt_sent=telegram_receipt_sent, ) return result 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 5d9778752..9a23427ac 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py @@ -1163,6 +1163,22 @@ def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): "check_mode_op_id": "check-mode-op", "returncode": "0", "duration_ms": 7727, + "runtime_stage_receipts": [ + { + "schema_version": "ai_automation_stage_receipt_v1", + "stage_id": stage_id, + "automation_run_id": "candidate-op", + "evidence_ref": f"receipt:{stage_id}", + "durable_receipt": True, + } + for stage_id in ( + "normalized_asset_identity", + "source_truth_diff", + "risk_policy_decision", + "executor_log_projection", + "retry_or_rollback", + ) + ], }, { "op_id": "learning-op", @@ -1338,25 +1354,31 @@ def test_runtime_receipt_readback_summarizes_live_executor_closure_rows(): assert ledger["safety_contract"]["live_apply_may_send_telegram_gateway_receipt"] is True control = build_ai_agent_autonomous_runtime_control() runtime_control_module._attach_runtime_receipt_readback(control, readback) - assert control["program_status"]["implementation_completion_percent"] == 44 + assert control["program_status"]["implementation_completion_percent"] == 72 assert control["program_status"]["latest_flow_closed"] is True assert control["program_status"]["latest_loop_closed"] is True assert control["program_status"]["can_claim_execution_loop_complete"] is True assert control["program_status"]["can_claim_autonomous_loop_complete"] is False - assert control["strict_runtime_completion"]["present_stage_count"] == 8 + assert control["strict_runtime_completion"]["present_stage_count"] == 13 assert control["strict_runtime_completion"]["same_run_correlation"] is True assert set(control["strict_runtime_completion"]["missing_stage_ids"]) == { "mcp_context", "service_log_evidence", - "normalized_asset_identity", - "source_truth_diff", - "risk_policy_decision", - "executor_log_projection", - "retry_or_rollback", "rag_writeback", "playbook_trust", "timeline_projection", } + stage_contracts = { + stage["stage_id"]: stage + for stage in control["strict_runtime_completion"]["stage_contracts"] + } + assert stage_contracts["normalized_asset_identity"]["evidence_source"] == ( + "same_run_runtime_stage_receipt" + ) + assert stage_contracts["source_truth_diff"]["completion_eligible"] is True + assert stage_contracts["risk_policy_decision"]["completion_eligible"] is True + assert stage_contracts["executor_log_projection"]["completion_eligible"] is True + assert stage_contracts["retry_or_rollback"]["completion_eligible"] is True assert control["strict_runtime_completion"]["closed"] is False trace = readback["trace_ledger"] assert trace["schema_version"] == "ai_agent_autonomous_trace_ledger_v1" diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index 17616f4ab..0b87e7ba7 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -23,12 +23,15 @@ from src.services.awooop_ansible_check_mode_service import ( _post_apply_verification_result, _record_auto_repair_execution_receipt, _record_learning_writeback_receipt, + _record_runtime_stage_receipts, _run_ansible_command, _send_controlled_apply_telegram_receipt, backfill_missing_auto_repair_execution_receipts_once, build_ansible_apply_command, build_ansible_check_mode_claim_input, build_ansible_check_mode_command, + build_ansible_post_apply_runtime_stage_receipts, + build_ansible_pre_apply_runtime_stage_receipts, claim_pending_check_modes, claim_stale_pending_check_modes, detect_ansible_transport_blockers, @@ -1713,6 +1716,8 @@ def test_ansible_apply_receipt_backfill_includes_verifier_and_km_gaps() -> None: assert "km.path_type = 'ansible_apply_receipt:' || left(apply.op_id::text, 8)" in source assert "ansible_learning_writeback_recorded" in source assert "_record_post_apply_verifier_and_learning" in source + assert "runtime_stage_receipts" in source + assert "_record_runtime_stage_receipts" in source def test_ansible_learning_writeback_receipt_records_learning_service_call() -> None: @@ -1809,6 +1814,55 @@ def test_ansible_post_apply_verifier_helpers_are_deterministic() -> None: ) == "timeout" +def test_ansible_runtime_stage_receipts_are_same_run_and_public_safe() -> None: + claim = AnsibleCheckModeClaim( + op_id="00000000-0000-0000-0000-000000000102", + source_candidate_op_id="00000000-0000-0000-0000-000000000101", + incident_id="INC-RUNTIME-STAGE", + catalog_id="ansible:188-momo-backup-user", + playbook_path="infra/ansible/playbooks/188-momo-backup-user.yml", + apply_playbook_path="infra/ansible/playbooks/188-momo-backup-user.yml", + inventory_hosts=("host_188",), + risk_level="low", + input_payload={ + "automation_run_id": "00000000-0000-0000-0000-000000000101", + }, + ) + result = AnsibleRunResult( + returncode=0, + stdout="changed=0", + stderr="", + duration_ms=42, + ) + + pre = build_ansible_pre_apply_runtime_stage_receipts(claim) + post = build_ansible_post_apply_runtime_stage_receipts( + claim, + result, + apply_op_id="00000000-0000-0000-0000-000000000103", + verifier_ready=True, + ) + + assert {receipt["stage_id"] for receipt in pre} == { + "normalized_asset_identity", + "source_truth_diff", + "risk_policy_decision", + } + assert {receipt["stage_id"] for receipt in post} == { + "executor_log_projection", + "retry_or_rollback", + } + assert all(receipt["durable_receipt"] is True for receipt in [*pre, *post]) + assert all(receipt["raw_log_payload_stored"] is False for receipt in [*pre, *post]) + assert all(receipt["secret_value_stored"] is False for receipt in [*pre, *post]) + assert all( + receipt["automation_run_id"] + == "00000000-0000-0000-0000-000000000101" + for receipt in [*pre, *post] + ) + assert inspect.iscoroutinefunction(_record_runtime_stage_receipts) + + def test_ansible_claim_query_limits_recent_candidate_backlog() -> None: source = inspect.getsource(claim_pending_check_modes)