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 7da5d5ce6..ec740cb2e 100644
--- a/apps/api/src/services/awooop_ansible_check_mode_service.py
+++ b/apps/api/src/services/awooop_ansible_check_mode_service.py
@@ -1094,12 +1094,9 @@ def _claim_is_no_write_observation(claim: AnsibleCheckModeClaim) -> bool:
def _telegram_shadow_completion_allowed(claim: AnsibleCheckModeClaim) -> bool:
- """Keep denoise receipts separate from Wazuh recipient-visible closure."""
+ """Accept a policy-complete no-send receipt for no-write observations."""
- return bool(
- _claim_is_no_write_observation(claim)
- and claim.catalog_id != _WAZUH_MANAGER_POSTURE_CATALOG_ID
- )
+ return _claim_is_no_write_observation(claim)
def _controlled_apply_allowed(candidate: dict[str, Any]) -> tuple[bool, str | None]:
@@ -3913,6 +3910,29 @@ async def _read_verified_apply_closure_prerequisites(
(
outbound.send_status = 'sent'
AND outbound.provider_message_id IS NOT NULL
+ AND (
+ :telegram_shadow_allowed IS FALSE
+ OR (
+ coalesce(
+ outbound.source_envelope #>>
+ '{callback_reply,execution_kind}',
+ outbound.source_envelope ->>
+ 'execution_kind'
+ ) = 'no_write_posture_readback'
+ AND outbound.source_envelope #>>
+ '{notification_policy,policy_version}'
+ = 'telegram_posture_lifecycle_transition_v1'
+ AND outbound.source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'state_transition'
+ AND outbound.source_envelope #>>
+ '{notification_policy,lifecycle_state}'
+ = 'recovered'
+ AND outbound.source_envelope #>>
+ '{notification_policy,disposition}'
+ = 'sent'
+ )
+ )
)
OR (
:telegram_shadow_allowed
@@ -3921,21 +3941,36 @@ async def _read_verified_apply_closure_prerequisites(
AND outbound.source_envelope #>>
'{notification_policy,disposition}'
= 'suppressed'
- AND nullif(
- outbound.source_envelope #>>
- '{notification_policy,policy_version}',
- ''
- ) IS NOT NULL
AND outbound.source_envelope #>>
- '{notification_policy,provider_delivery}'
- IN (
- 'shadow_only',
- 'state_transition',
- 'digest'
- )
+ '{notification_policy,lifecycle_state}'
+ = 'recovered'
AND outbound.source_envelope #>>
'{notification_policy,provider_send_performed}'
= 'false'
+ AND (
+ (
+ outbound.source_envelope #>>
+ '{notification_policy,policy_version}'
+ = 'telegram_posture_lifecycle_transition_v1'
+ AND outbound.source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'state_transition'
+ AND outbound.source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'duplicate_lifecycle_state'
+ )
+ OR (
+ outbound.source_envelope #>>
+ '{notification_policy,policy_version}'
+ = 'telegram_posture_state_digest_v1'
+ AND outbound.source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'shadow_only'
+ AND outbound.source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'historical_projection_backfill'
+ )
+ )
AND coalesce(
outbound.source_envelope #>>
'{callback_reply,execution_kind}',
@@ -4981,25 +5016,76 @@ async def _retry_telegram_receipt_acknowledged(
AND outbound.source_envelope #>>
'{notification_policy,disposition}'
= 'suppressed'
- AND nullif(
- outbound.source_envelope #>>
- '{notification_policy,policy_version}',
- ''
- ) IS NOT NULL
- AND outbound.source_envelope #>>
- '{notification_policy,provider_delivery}'
- IN ('shadow_only', 'digest')
AND outbound.source_envelope #>>
'{notification_policy,provider_send_performed}'
= 'false'
- AND coalesce(
- outbound.source_envelope #>>
- '{callback_reply,execution_kind}',
- outbound.source_envelope ->>
- 'execution_kind'
- ) IN (
- 'no_write_posture_readback',
- 'no_write_replay'
+ AND outbound.source_envelope #>>
+ '{notification_policy,lifecycle_state}'
+ IN ('recovered', 'failed')
+ AND (
+ (
+ coalesce(
+ outbound.source_envelope #>>
+ '{callback_reply,execution_kind}',
+ outbound.source_envelope ->>
+ 'execution_kind'
+ ) = 'no_write_posture_readback'
+ AND outbound.source_envelope #>>
+ '{notification_policy,policy_version}'
+ = 'telegram_posture_lifecycle_transition_v1'
+ AND outbound.source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'state_transition'
+ AND outbound.source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'duplicate_lifecycle_state'
+ )
+ OR (
+ coalesce(
+ outbound.source_envelope #>>
+ '{callback_reply,execution_kind}',
+ outbound.source_envelope ->>
+ 'execution_kind'
+ ) = 'no_write_posture_readback'
+ AND outbound.source_envelope #>>
+ '{notification_policy,policy_version}'
+ = 'telegram_posture_state_digest_v1'
+ AND outbound.source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'shadow_only'
+ AND outbound.source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'historical_projection_backfill'
+ )
+ OR (
+ coalesce(
+ outbound.source_envelope #>>
+ '{callback_reply,execution_kind}',
+ outbound.source_envelope ->>
+ 'execution_kind'
+ ) = 'no_write_replay'
+ AND outbound.source_envelope #>>
+ '{notification_policy,policy_version}'
+ = 'telegram_failure_digest_v2'
+ AND (
+ (
+ outbound.source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'digest'
+ AND outbound.source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'duplicate_failure_fingerprint_window'
+ )
+ OR (
+ outbound.source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'shadow_only'
+ AND outbound.source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'historical_projection_backfill'
+ )
+ )
+ )
)
)
)
@@ -5114,24 +5200,76 @@ async def _record_retry_runtime_stage_receipt(
AND source_envelope #>>
'{notification_policy,disposition}'
= 'suppressed'
- AND nullif(
- source_envelope #>>
- '{notification_policy,policy_version}',
- ''
- ) IS NOT NULL
- AND source_envelope #>>
- '{notification_policy,provider_delivery}'
- IN ('shadow_only', 'digest')
AND source_envelope #>>
'{notification_policy,provider_send_performed}'
= 'false'
- AND coalesce(
- source_envelope #>>
- '{callback_reply,execution_kind}',
- source_envelope ->> 'execution_kind'
- ) IN (
- 'no_write_posture_readback',
- 'no_write_replay'
+ AND source_envelope #>>
+ '{notification_policy,lifecycle_state}'
+ IN ('recovered', 'failed')
+ AND (
+ (
+ coalesce(
+ source_envelope #>>
+ '{callback_reply,execution_kind}',
+ source_envelope ->>
+ 'execution_kind'
+ ) = 'no_write_posture_readback'
+ AND source_envelope #>>
+ '{notification_policy,policy_version}'
+ = 'telegram_posture_lifecycle_transition_v1'
+ AND source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'state_transition'
+ AND source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'duplicate_lifecycle_state'
+ )
+ OR (
+ coalesce(
+ source_envelope #>>
+ '{callback_reply,execution_kind}',
+ source_envelope ->>
+ 'execution_kind'
+ ) = 'no_write_posture_readback'
+ AND source_envelope #>>
+ '{notification_policy,policy_version}'
+ = 'telegram_posture_state_digest_v1'
+ AND source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'shadow_only'
+ AND source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'historical_projection_backfill'
+ )
+ OR (
+ coalesce(
+ source_envelope #>>
+ '{callback_reply,execution_kind}',
+ source_envelope ->>
+ 'execution_kind'
+ ) = 'no_write_replay'
+ AND source_envelope #>>
+ '{notification_policy,policy_version}'
+ = 'telegram_failure_digest_v2'
+ AND (
+ (
+ source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'digest'
+ AND source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'duplicate_failure_fingerprint_window'
+ )
+ OR (
+ source_envelope #>>
+ '{notification_policy,provider_delivery}'
+ = 'shadow_only'
+ AND source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ = 'historical_projection_backfill'
+ )
+ )
+ )
)
)
)
@@ -7448,16 +7586,21 @@ async def _send_controlled_apply_telegram_receipt(
if no_write_observation and execution_kind == "controlled_apply"
else execution_kind
)
- effective_provider_delivery = str(
- claim.input_payload.get("telegram_provider_delivery")
- or provider_delivery
- )
- if no_write_observation and verified_success:
- effective_provider_delivery = (
- "state_transition"
- if claim.catalog_id == _WAZUH_MANAGER_POSTURE_CATALOG_ID
- else "shadow_only"
+ requested_provider_delivery = str(provider_delivery or "").strip()
+ effective_provider_delivery = (
+ "shadow_only"
+ if requested_provider_delivery == "shadow_only"
+ else str(
+ claim.input_payload.get("telegram_provider_delivery")
+ or requested_provider_delivery
+ or "digest"
)
+ )
+ if no_write_observation and effective_provider_delivery != "shadow_only":
+ if claim.catalog_id == _WAZUH_MANAGER_POSTURE_CATALOG_ID:
+ effective_provider_delivery = "state_transition"
+ elif verified_success:
+ effective_provider_delivery = "shadow_only"
receipt_readback = dict(closure_receipts or {})
response = await get_telegram_gateway().send_controlled_apply_result_receipt(
automation_run_id=str(
@@ -7520,6 +7663,7 @@ async def _reconcile_verified_apply_closure_projections(
apply_op_id: str,
writeback: dict[str, Any],
project_id: str,
+ provider_delivery: str = "digest",
) -> dict[str, Any]:
"""Repair terminal projections without ever executing the apply again."""
@@ -7541,6 +7685,7 @@ async def _reconcile_verified_apply_closure_projections(
apply_op_id=apply_op_id,
writeback=writeback,
project_id=project_id,
+ provider_delivery=provider_delivery,
)
if receipt_finalized:
telegram_receipt = await _retry_telegram_receipt_acknowledged(
@@ -7561,7 +7706,13 @@ async def _reconcile_verified_apply_closure_projections(
apply_op_id=apply_op_id,
success=False,
action_detail=(
- "no_write_posture_failure_digest_recorded"
+ "no_write_posture_failure_transition_recorded"
+ if (
+ notification_classified
+ and claim.catalog_id
+ == _WAZUH_MANAGER_POSTURE_CATALOG_ID
+ )
+ else "no_write_posture_failure_digest_recorded"
if notification_classified
else "controlled_apply_result_receipt_sent"
),
@@ -7615,6 +7766,7 @@ async def _reconcile_verified_apply_closure_projections(
apply_op_id=apply_op_id,
writeback=writeback,
project_id=project_id,
+ provider_delivery=provider_delivery,
closure_receipts=receipt_readback,
)
readback = await _read_verified_apply_closure_prerequisites(
@@ -8009,6 +8161,7 @@ async def backfill_missing_auto_repair_execution_receipts_once(
apply_op_id=str(row.get("op_id") or ""),
writeback=writeback,
project_id=project_id,
+ provider_delivery="shadow_only",
)
if closure.get("telegram_receipt_acknowledged") is True:
stats["telegram_receipt_acknowledged"] += 1
@@ -8699,6 +8852,7 @@ async def backfill_missing_retry_terminal_projections_once(
},
project_id=project_id,
execution_kind="no_write_replay",
+ provider_delivery="shadow_only",
)
if receipt_sent:
telegram_acknowledged = (
diff --git a/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py b/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py
index ac614bf7d..15005384d 100644
--- a/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py
+++ b/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py
@@ -565,6 +565,12 @@ _LIVE_RUNTIME_SQL = """
outbound.source_envelope #>>
'{notification_policy,policy_version}'
AS telegram_notification_policy_version,
+ outbound.source_envelope #>>
+ '{notification_policy,lifecycle_state}'
+ AS telegram_notification_lifecycle_state,
+ outbound.source_envelope #>>
+ '{notification_policy,suppression_reason}'
+ AS telegram_notification_suppression_reason,
outbound.source_envelope #>>
'{notification_policy,provider_send_performed}'
= 'false' AS telegram_provider_send_suppressed,
@@ -1230,6 +1236,20 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback(
telegram_provider_sent = bool(
data.get("telegram_send_status") == "sent"
and data.get("telegram_provider_message_id")
+ and (
+ ingress_mode
+ or (
+ data.get("telegram_execution_kind")
+ == "no_write_posture_readback"
+ and data.get("telegram_notification_policy_version")
+ == "telegram_posture_lifecycle_transition_v1"
+ and data.get("telegram_provider_delivery")
+ == "state_transition"
+ and data.get("telegram_notification_lifecycle_state")
+ == "recovered"
+ and data.get("telegram_notification_disposition") == "sent"
+ )
+ )
)
telegram_notification_suppressed = bool(
not ingress_mode
@@ -1238,15 +1258,29 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback(
and data.get("telegram_execution_kind")
== "no_write_posture_readback"
and data.get("telegram_notification_disposition") == "suppressed"
- and str(data.get("telegram_notification_policy_version") or "")
- and data.get("telegram_provider_delivery")
- in {"shadow_only", "state_transition", "digest"}
and data.get("telegram_provider_send_suppressed") is True
+ and data.get("telegram_notification_lifecycle_state") == "recovered"
+ and (
+ (
+ data.get("telegram_notification_policy_version")
+ == "telegram_posture_lifecycle_transition_v1"
+ and data.get("telegram_provider_delivery")
+ == "state_transition"
+ and data.get("telegram_notification_suppression_reason")
+ == "duplicate_lifecycle_state"
+ )
+ or (
+ data.get("telegram_notification_policy_version")
+ == "telegram_posture_state_digest_v1"
+ and data.get("telegram_provider_delivery") == "shadow_only"
+ and data.get("telegram_notification_suppression_reason")
+ == "historical_projection_backfill"
+ )
+ )
)
- # This P0 slice requires a same-run, recipient-visible provider receipt.
- # A durable suppression row remains useful denoise evidence, but it cannot
- # substitute for Telegram delivery or close the incident.
- telegram_ready = telegram_provider_sent
+ # A policy-complete shadow is the terminal receipt for unchanged healthy
+ # posture. Only actual lifecycle transitions are provider-visible.
+ telegram_ready = telegram_provider_sent or telegram_notification_suppressed
incident_ledger_closure_verified = bool(
str(data.get("incident_status") or "").upper()
in {"RESOLVED", "CLOSED"}
diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py
index bd1b6c3a0..7847f41cd 100644
--- a/apps/api/src/services/telegram_gateway.py
+++ b/apps/api/src/services/telegram_gateway.py
@@ -418,6 +418,9 @@ def _telegram_provider_destination_verification_method(
_CONTROLLED_APPLY_OUTBOUND_FINALIZE_ATTEMPTS = 3
_NO_WRITE_REPLAY_DIGEST_WINDOW_MINUTES = 30
+_WAZUH_MANAGER_POSTURE_CATALOG_ID = (
+ "ansible:wazuh-manager-posture-readback"
+)
_CONTROLLED_APPLY_LIFECYCLE_PENDING_LEASE_MINUTES = 10
_CONTROLLED_APPLY_LIFECYCLE_DELIVERY_DEADLINE_SECONDS = 90.0
_CONTROLLED_APPLY_LIFECYCLE_DELIVERY_LOCK_TTL_SECONDS = 120
@@ -6943,6 +6946,17 @@ class TelegramGateway:
WHERE project_id = :project_id
AND run_id = CAST(:run_id AS uuid)
AND channel_type = 'telegram'
+ AND (
+ :lifecycle_scope = ''
+ OR (
+ source_envelope #>>
+ '{notification_policy,lifecycle_scope}'
+ = :lifecycle_scope
+ AND source_envelope #>>
+ '{notification_policy,lifecycle_state}'
+ = :lifecycle_state
+ )
+ )
ORDER BY
(
send_status = 'sent'
@@ -6955,6 +6969,8 @@ class TelegramGateway:
{
"project_id": project_id,
"run_id": str(delivery_run_id),
+ "lifecycle_scope": lifecycle_scope,
+ "lifecycle_state": lifecycle_state,
},
)
row = existing.mappings().one_or_none()
@@ -6986,7 +7002,14 @@ class TelegramGateway:
AS lifecycle_state
FROM awooop_outbound_message
WHERE project_id = :project_id
- AND run_id <> CAST(:run_id AS uuid)
+ AND (
+ run_id <> CAST(:run_id AS uuid)
+ OR coalesce(
+ source_envelope #>>
+ '{notification_policy,lifecycle_state}',
+ ''
+ ) <> :lifecycle_state
+ )
AND channel_type = 'telegram'
AND (
send_status = 'sent'
@@ -7011,6 +7034,7 @@ class TelegramGateway:
"project_id": project_id,
"run_id": str(delivery_run_id),
"lifecycle_scope": lifecycle_scope,
+ "lifecycle_state": lifecycle_state,
"pending_lease_minutes": (
_CONTROLLED_APPLY_LIFECYCLE_PENDING_LEASE_MINUTES
),
@@ -13854,6 +13878,12 @@ class TelegramGateway:
no_write_replay = execution_kind == "no_write_replay"
no_write_posture = execution_kind == "no_write_posture_readback"
+ if (
+ no_write_posture
+ and catalog_id == _WAZUH_MANAGER_POSTURE_CATALOG_ID
+ and provider_delivery != "shadow_only"
+ ):
+ provider_delivery = "state_transition"
success = (
not no_write_replay
and verification_result == "success"
@@ -13882,20 +13912,11 @@ class TelegramGateway:
if str(catalog_id).startswith("ansible:")
else "controlled_automation"
)
- same_run_provider_receipt = bool(
- no_write_posture
- and success
- and provider_delivery == "state_transition"
- and catalog_id == "ansible:wazuh-manager-posture-readback"
- )
lifecycle_scope = _controlled_apply_lifecycle_scope(
alert_category=normalized_category,
canonical_asset_id=canonical_asset_id,
catalog_id=catalog_id,
playbook_path=playbook_path,
- scope_discriminator=(
- automation_run_id if same_run_provider_receipt else ""
- ),
)
lifecycle_state = (
"recovered"
@@ -14020,31 +14041,19 @@ class TelegramGateway:
if provider_delivery == "state_transition":
source_extra["notification_policy"] = {
"policy_version": (
- "telegram_wazuh_posture_same_run_receipt_v1"
- if same_run_provider_receipt
- else "telegram_posture_lifecycle_transition_v1"
+ "telegram_posture_lifecycle_transition_v1"
),
"canonical_category": normalized_category,
"lifecycle_scope": lifecycle_scope,
"lifecycle_state": lifecycle_state,
- "group_scope": (
- "controlled_asset_same_run"
- if same_run_provider_receipt
- else "controlled_asset_lifecycle"
- ),
+ "group_scope": "controlled_asset_lifecycle",
"provider_delivery": "state_transition",
- "disposition": (
- "send_once_per_run"
- if same_run_provider_receipt
- else "send_on_transition"
- ),
+ "disposition": "send_on_transition",
"pending_lease_minutes": (
_CONTROLLED_APPLY_LIFECYCLE_PENDING_LEASE_MINUTES
),
"delivery_semantics": (
- "at_least_once_per_run_after_unknown_ack_lease"
- if same_run_provider_receipt
- else "at_least_once_after_unknown_ack_lease"
+ "at_least_once_after_unknown_ack_lease"
),
"provider_send_performed": None,
"runtime_write_performed": False,
@@ -14063,6 +14072,7 @@ class TelegramGateway:
source_extra["notification_policy"] = {
"policy_version": "telegram_posture_state_digest_v1",
"failure_fingerprint": failure_fingerprint,
+ "lifecycle_state": lifecycle_state,
"group_scope": (
"scheduled_posture_healthy"
if success
@@ -14094,6 +14104,7 @@ class TelegramGateway:
source_extra["notification_policy"] = {
"policy_version": "telegram_failure_digest_v2",
"failure_fingerprint": failure_fingerprint,
+ "lifecycle_state": lifecycle_state,
"group_scope": (
"historical_projection_backfill"
if effective_provider_delivery == "shadow_only"
@@ -14176,9 +14187,7 @@ class TelegramGateway:
f"{html.escape(incident_runs_url(incident_id, project_id=project_id or 'awoooi'))}"
),
(
- "通知: 本次 run 僅外送一次,並保存 recipient/provider durable receipt。"
- if same_run_provider_receipt
- else "通知: 狀態轉換僅外送一次;相同狀態由 durable receipt 去重。"
+ "通知: 狀態轉換僅外送一次;相同狀態由 durable receipt 去重。"
if provider_delivery == "state_transition"
else "通知: 健康週期讀回不外送;異常相同原因 30 分鐘只通知一次。"
),
diff --git a/apps/api/tests/test_ansible_incident_ledger_churn_guard.py b/apps/api/tests/test_ansible_incident_ledger_churn_guard.py
index 2d8eae810..c8aa67d3e 100644
--- a/apps/api/tests/test_ansible_incident_ledger_churn_guard.py
+++ b/apps/api/tests/test_ansible_incident_ledger_churn_guard.py
@@ -484,10 +484,91 @@ async def test_wazuh_posture_success_receipt_is_recipient_visible_transition(
assert call["provider_delivery"] == "state_transition"
-def test_wazuh_posture_shadow_receipt_remains_denoise_not_terminal() -> None:
+@pytest.mark.asyncio
+async def test_wazuh_posture_failure_receipt_is_lifecycle_transition(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ gateway = AsyncMock()
+ gateway.send_controlled_apply_result_receipt.return_value = {
+ "ok": True,
+ "_awooop_outbound_mirror_acknowledged": True,
+ "_awooop_delivery_status": "sent",
+ }
+ monkeypatch.setattr(
+ "src.services.telegram_gateway.get_telegram_gateway",
+ lambda: gateway,
+ )
+
+ acknowledged = await service._send_controlled_apply_telegram_receipt(
+ _wazuh_claim(),
+ service.AnsibleRunResult(
+ returncode=2,
+ stdout="",
+ stderr="failed posture",
+ duration_ms=5,
+ post_verifier_passed=False,
+ ),
+ apply_op_id="00000000-0000-0000-0000-000000000203",
+ writeback={
+ "verification_result": "failed",
+ "verification": True,
+ "learning": True,
+ },
+ project_id="awoooi",
+ )
+
+ assert acknowledged is True
+ call = gateway.send_controlled_apply_result_receipt.await_args.kwargs
+ assert call["execution_kind"] == "no_write_posture_readback"
+ assert call["provider_delivery"] == "state_transition"
+
+
+@pytest.mark.asyncio
+async def test_wazuh_posture_backfill_preserves_explicit_shadow_delivery(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ gateway = AsyncMock()
+ gateway.send_controlled_apply_result_receipt.return_value = {
+ "ok": True,
+ "_awooop_outbound_mirror_acknowledged": True,
+ "_awooop_delivery_status": "suppressed",
+ }
+ monkeypatch.setattr(
+ "src.services.telegram_gateway.get_telegram_gateway",
+ lambda: gateway,
+ )
+
+ claim = _wazuh_claim()
+ claim.input_payload["telegram_provider_delivery"] = "state_transition"
+ acknowledged = await service._send_controlled_apply_telegram_receipt(
+ claim,
+ service.AnsibleRunResult(
+ returncode=0,
+ stdout="",
+ stderr="",
+ duration_ms=5,
+ post_verifier_passed=True,
+ ),
+ apply_op_id="00000000-0000-0000-0000-000000000203",
+ writeback={
+ "verification_result": "success",
+ "verification": True,
+ "learning": True,
+ },
+ project_id="awoooi",
+ provider_delivery="shadow_only",
+ )
+
+ assert acknowledged is True
+ call = gateway.send_controlled_apply_result_receipt.await_args.kwargs
+ assert call["execution_kind"] == "no_write_posture_readback"
+ assert call["provider_delivery"] == "shadow_only"
+
+
+def test_wazuh_posture_shadow_receipt_is_no_write_terminal() -> None:
closure_source = service._read_verified_apply_closure_prerequisites.__doc__ or ""
assert service._claim_is_no_write_observation(_wazuh_claim()) is True
- assert service._telegram_shadow_completion_allowed(_wazuh_claim()) is False
+ assert service._telegram_shadow_completion_allowed(_wazuh_claim()) is True
assert "durable prerequisite" in closure_source
import inspect
@@ -506,13 +587,23 @@ def test_wazuh_posture_shadow_receipt_remains_denoise_not_terminal() -> None:
assert "no_write_posture_readback" in source
assert "send_status = 'shadow'" in source
assert "_telegram_shadow_completion_allowed" in source
+ assert "telegram_posture_lifecycle_transition_v1" in source
+ assert "duplicate_lifecycle_state" in source
+ assert "telegram_posture_state_digest_v1" in source
+ assert "historical_projection_backfill" in source
+ assert "'{notification_policy,lifecycle_state}'" in readback_source
+ assert "= 'recovered'" in readback_source
+ assert ":telegram_shadow_allowed IS FALSE" in readback_source
+ assert "'{notification_policy,disposition}'" in readback_source
+ assert "= 'sent'" in readback_source
assert "no_write_posture_result_receipt_suppressed" in reconcile_source
assert "no_write_posture_failure_digest_recorded" in reconcile_source
+ assert "no_write_posture_failure_transition_recorded" in reconcile_source
assert '"NOTIFICATION_CLASSIFIED"' in reconcile_source
@pytest.mark.asyncio
-async def test_wazuh_posture_retry_rejects_shadow_receipt_allowance(
+async def test_wazuh_posture_retry_accepts_shadow_receipt_allowance(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = _SequenceDB(_ScalarResult(False))
@@ -530,4 +621,4 @@ async def test_wazuh_posture_retry_rejects_shadow_receipt_allowance(
)
assert acknowledged is False
- assert db.parameters[0]["telegram_shadow_allowed"] is False
+ assert db.parameters[0]["telegram_shadow_allowed"] is True
diff --git a/apps/api/tests/test_ansible_verified_closure.py b/apps/api/tests/test_ansible_verified_closure.py
index 9eb09e6c7..1d89eefd1 100644
--- a/apps/api/tests/test_ansible_verified_closure.py
+++ b/apps/api/tests/test_ansible_verified_closure.py
@@ -54,8 +54,14 @@ class _SequenceDB:
self.parameters: list[dict] = []
async def execute(self, statement, parameters=None):
+ provided = dict(parameters or {})
+ required = set(getattr(statement, "_bindparams", {}))
+ missing = required - set(provided)
+ assert not missing, (
+ f"missing SQL bind parameters: {sorted(missing)}"
+ )
self.statements.append(str(statement))
- self.parameters.append(dict(parameters or {}))
+ self.parameters.append(provided)
return self._results.pop(0) if self._results else _MappingResult()
@@ -1844,7 +1850,7 @@ async def test_no_write_group_reservation_persists_shadow_suppression(
@pytest.mark.asyncio
-async def test_same_controlled_apply_lifecycle_state_persists_shadow_receipt(
+async def test_same_wazuh_lifecycle_state_across_runs_persists_shadow_receipt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = _SequenceDB(
@@ -1875,17 +1881,28 @@ async def test_same_controlled_apply_lifecycle_state_persists_shadow_receipt(
identity = {
"project_id": "awoooi",
"automation_run_id": "00000000-0000-0000-0000-000000000402",
- "incident_id": "INC-20260722-LIFECYCLE",
+ "incident_id": "IWZ-POSTURE-2026072206",
"apply_op_id": "00000000-0000-0000-0000-000000000404",
}
+ lifecycle_scope = (
+ telegram_gateway_module._controlled_apply_lifecycle_scope(
+ alert_category="security",
+ canonical_asset_id="service:wazuh-manager",
+ catalog_id="ansible:wazuh-manager-posture-readback",
+ playbook_path=(
+ "infra/ansible/playbooks/wazuh-manager-posture-readback.yml"
+ ),
+ )
+ )
source_extra = {
"callback_reply": {
"action": "controlled_apply_result",
+ "execution_kind": "no_write_posture_readback",
**identity,
},
"notification_policy": {
- "policy_version": "telegram_controlled_apply_lifecycle_v1",
- "lifecycle_scope": "assetlifecycle00000000001",
+ "policy_version": "telegram_posture_lifecycle_transition_v1",
+ "lifecycle_scope": lifecycle_scope,
"lifecycle_state": "recovered",
"provider_delivery": "state_transition",
"disposition": "send_on_transition",
@@ -1899,9 +1916,7 @@ async def test_same_controlled_apply_lifecycle_state_persists_shadow_receipt(
)
assert reservation["status"] == "suppressed"
- assert db.parameters[0]["lock_key"].endswith(
- ":assetlifecycle00000000001"
- )
+ assert db.parameters[0]["lock_key"].endswith(f":{lifecycle_scope}")
assert "lifecycle_scope" in db.statements[2]
assert "(send_status = 'pending') DESC" in db.statements[2]
assert "queued_at DESC" in db.statements[2]
@@ -1918,10 +1933,13 @@ async def test_same_controlled_apply_lifecycle_state_persists_shadow_receipt(
assert policy["lifecycle_state"] == "recovered"
assert policy["suppression_reason"] == "duplicate_lifecycle_state"
assert policy["provider_send_performed"] is False
+ assert record_kwargs["source_envelope"]["callback_reply"][
+ "execution_kind"
+ ] == "no_write_posture_readback"
@pytest.mark.asyncio
-async def test_controlled_apply_lifecycle_transition_reserves_provider_send(
+async def test_same_apply_wazuh_lifecycle_transition_reserves_new_provider_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = _SequenceDB(
@@ -1952,17 +1970,28 @@ async def test_controlled_apply_lifecycle_transition_reserves_provider_send(
identity = {
"project_id": "awoooi",
"automation_run_id": "00000000-0000-0000-0000-000000000502",
- "incident_id": "INC-20260722-RECOVERY",
+ "incident_id": "IWZ-POSTURE-2026072206",
"apply_op_id": "00000000-0000-0000-0000-000000000504",
}
+ lifecycle_scope = (
+ telegram_gateway_module._controlled_apply_lifecycle_scope(
+ alert_category="security",
+ canonical_asset_id="service:wazuh-manager",
+ catalog_id="ansible:wazuh-manager-posture-readback",
+ playbook_path=(
+ "infra/ansible/playbooks/wazuh-manager-posture-readback.yml"
+ ),
+ )
+ )
source_extra = {
"callback_reply": {
"action": "controlled_apply_result",
+ "execution_kind": "no_write_posture_readback",
**identity,
},
"notification_policy": {
- "policy_version": "telegram_controlled_apply_lifecycle_v1",
- "lifecycle_scope": "assetlifecycle00000000001",
+ "policy_version": "telegram_posture_lifecycle_transition_v1",
+ "lifecycle_scope": lifecycle_scope,
"lifecycle_state": "recovered",
"provider_delivery": "state_transition",
"disposition": "send_on_transition",
@@ -1976,6 +2005,13 @@ async def test_controlled_apply_lifecycle_transition_reserves_provider_send(
)
assert reservation["status"] == "reserved"
+ assert "'{notification_policy,lifecycle_scope}'" in db.statements[1]
+ assert "'{notification_policy,lifecycle_state}'" in db.statements[1]
+ assert db.parameters[1]["lifecycle_scope"] == lifecycle_scope
+ assert db.parameters[1]["lifecycle_state"] == "recovered"
+ assert "run_id <> CAST(:run_id AS uuid)" in db.statements[2]
+ assert "<> :lifecycle_state" in db.statements[2]
+ assert db.parameters[2]["lifecycle_state"] == "recovered"
record_kwargs = record_outbound.await_args.kwargs
assert record_kwargs["send_status"] == "pending"
assert record_kwargs["is_shadow"] is False
@@ -2522,7 +2558,7 @@ async def test_retry_telegram_readback_binds_exact_apply_operation(
[
("ansible:generic-posture-readback", False, False),
("ansible:generic-posture-readback", True, True),
- ("ansible:wazuh-manager-posture-readback", True, False),
+ ("ansible:wazuh-manager-posture-readback", True, True),
],
)
async def test_retry_stage_receipt_binds_shadow_allowance(
@@ -2596,7 +2632,7 @@ async def test_retry_stage_receipt_binds_shadow_allowance(
@pytest.mark.asyncio
-async def test_wazuh_retry_telegram_readback_rejects_shadow_completion(
+async def test_wazuh_retry_telegram_readback_binds_shadow_completion(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = _SequenceDB(_MappingResult(scalar=False))
@@ -2624,7 +2660,7 @@ async def test_wazuh_retry_telegram_readback_rejects_shadow_completion(
)
assert acknowledged is False
- assert db.parameters[0]["telegram_shadow_allowed"] is False
+ assert db.parameters[0]["telegram_shadow_allowed"] is True
def test_all_terminal_telegram_readbacks_require_exact_apply_operation() -> None:
@@ -2937,6 +2973,9 @@ async def test_retry_terminal_projection_backfill_writes_and_verifies_no_apply(
assert telegram_sender.await_args.kwargs["execution_kind"] == (
"no_write_replay"
)
+ assert telegram_sender.await_args.kwargs["provider_delivery"] == (
+ "shadow_only"
+ )
telegram_readback.assert_awaited_once()
@@ -3039,6 +3078,7 @@ async def test_retry_projection_does_not_starve_verified_success_closure(
assert result["incident_closure_written"] == 1
assert result["telegram_receipt_acknowledged"] == 1
closure.assert_awaited_once()
+ assert closure.await_args.kwargs["provider_delivery"] == "shadow_only"
@pytest.mark.asyncio
diff --git a/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py b/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py
index 66a3eea25..129e45fdd 100644
--- a/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py
+++ b/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py
@@ -63,6 +63,8 @@ def test_wazuh_runtime_sql_reads_schema_safe_packet_fallback() -> None:
assert "critical_secret_reference_provisioning_receipt_verified" not in (
_LIVE_RUNTIME_SQL
)
+ assert "telegram_notification_lifecycle_state" in _LIVE_RUNTIME_SQL
+ assert "telegram_notification_suppression_reason" in _LIVE_RUNTIME_SQL
def _durable_scheduled_wazuh_source_recurrence(
@@ -136,6 +138,15 @@ def _complete_row() -> dict[str, object]:
"telegram_receipt_id": "telegram-303",
"telegram_send_status": "sent",
"telegram_provider_message_id": "303",
+ "telegram_execution_kind": "no_write_posture_readback",
+ "telegram_notification_disposition": "sent",
+ "telegram_provider_delivery": "state_transition",
+ "telegram_notification_policy_version": (
+ "telegram_posture_lifecycle_transition_v1"
+ ),
+ "telegram_notification_lifecycle_state": "recovered",
+ "telegram_notification_suppression_reason": None,
+ "telegram_provider_send_suppressed": False,
"incident_status": "resolved",
"incident_resolved_at": "2026-07-11T03:36:30+00:00",
"incident_terminal_automation_run_id": run_id,
@@ -280,7 +291,7 @@ async def test_wazuh_runtime_loader_keeps_ingress_and_posture_lanes_visible(
assert lanes["posture"]["summary"]["runtime_closed_count"] == 1
-def test_wazuh_posture_shadow_receipt_cannot_replace_provider_send() -> None:
+def test_wazuh_posture_shadow_receipt_closes_unchanged_healthy_state() -> None:
row = _complete_row()
row.update(
{
@@ -292,6 +303,10 @@ def test_wazuh_posture_shadow_receipt_cannot_replace_provider_send() -> None:
"telegram_notification_policy_version": (
"telegram_posture_state_digest_v1"
),
+ "telegram_notification_lifecycle_state": "recovered",
+ "telegram_notification_suppression_reason": (
+ "historical_projection_backfill"
+ ),
"telegram_provider_send_suppressed": True,
}
)
@@ -301,14 +316,14 @@ def test_wazuh_posture_shadow_receipt_cannot_replace_provider_send() -> None:
executor_enabled=True,
)
- assert payload["summary"]["runtime_closed_count"] == 0
- assert payload["summary"]["telegram_receipt_count"] == 0
+ assert payload["summary"]["runtime_closed_count"] == 1
+ assert payload["summary"]["telegram_receipt_count"] == 1
assert payload["summary"]["telegram_provider_send_count"] == 0
assert payload["summary"]["telegram_notification_suppressed_count"] == 1
assert payload["recipient_receipt"]["status"] == "suppressed_unchanged"
assert payload["recipient_receipt"]["provider_message_ref"] is None
- assert "telegram_receipt" in payload["missing_stage_ids"]
- assert "incident_closure" in payload["missing_stage_ids"]
+ assert "telegram_receipt" not in payload["missing_stage_ids"]
+ assert "incident_closure" not in payload["missing_stage_ids"]
assert (
payload["boundaries"][
"healthy_posture_telegram_provider_send_performed"
@@ -318,7 +333,7 @@ def test_wazuh_posture_shadow_receipt_cannot_replace_provider_send() -> None:
assert payload["boundaries"]["healthy_posture_notification_suppressed"] is True
-def test_wazuh_posture_duplicate_lifecycle_shadow_is_not_recipient_receipt() -> None:
+def test_wazuh_posture_duplicate_lifecycle_shadow_is_terminal_receipt() -> None:
row = _complete_row()
row.update(
{
@@ -330,6 +345,42 @@ def test_wazuh_posture_duplicate_lifecycle_shadow_is_not_recipient_receipt() ->
"telegram_notification_policy_version": (
"telegram_posture_lifecycle_transition_v1"
),
+ "telegram_notification_lifecycle_state": "recovered",
+ "telegram_notification_suppression_reason": (
+ "duplicate_lifecycle_state"
+ ),
+ "telegram_provider_send_suppressed": True,
+ }
+ )
+
+ payload = build_iwooos_wazuh_controlled_executor_runtime_readback(
+ row,
+ executor_enabled=True,
+ )
+
+ assert payload["summary"]["runtime_closed_count"] == 1
+ assert payload["summary"]["telegram_receipt_count"] == 1
+ assert payload["summary"]["telegram_provider_send_count"] == 0
+ assert payload["summary"]["telegram_notification_suppressed_count"] == 1
+ assert payload["recipient_receipt"]["status"] == "suppressed_unchanged"
+
+
+def test_wazuh_failure_digest_shadow_cannot_close_recovered_posture() -> None:
+ row = _complete_row()
+ row.update(
+ {
+ "telegram_send_status": "shadow",
+ "telegram_provider_message_id": None,
+ "telegram_execution_kind": "no_write_posture_readback",
+ "telegram_notification_disposition": "suppressed",
+ "telegram_provider_delivery": "digest",
+ "telegram_notification_policy_version": (
+ "telegram_posture_state_digest_v1"
+ ),
+ "telegram_notification_lifecycle_state": "failed",
+ "telegram_notification_suppression_reason": (
+ "duplicate_failure_fingerprint_window"
+ ),
"telegram_provider_send_suppressed": True,
}
)
@@ -342,8 +393,24 @@ def test_wazuh_posture_duplicate_lifecycle_shadow_is_not_recipient_receipt() ->
assert payload["summary"]["runtime_closed_count"] == 0
assert payload["summary"]["telegram_receipt_count"] == 0
assert payload["summary"]["telegram_provider_send_count"] == 0
- assert payload["summary"]["telegram_notification_suppressed_count"] == 1
- assert payload["recipient_receipt"]["status"] == "suppressed_unchanged"
+ assert "telegram_receipt" in payload["missing_stage_ids"]
+ assert "incident_closure" in payload["missing_stage_ids"]
+
+
+def test_wazuh_sent_failed_transition_cannot_close_recovered_posture() -> None:
+ row = _complete_row()
+ row["telegram_notification_lifecycle_state"] = "failed"
+
+ payload = build_iwooos_wazuh_controlled_executor_runtime_readback(
+ row,
+ executor_enabled=True,
+ )
+
+ assert payload["summary"]["runtime_closed_count"] == 0
+ assert payload["summary"]["telegram_receipt_count"] == 0
+ assert payload["summary"]["telegram_provider_send_count"] == 0
+ assert "telegram_receipt" in payload["missing_stage_ids"]
+ assert "incident_closure" in payload["missing_stage_ids"]
def test_wazuh_runtime_readback_prefers_ingress_convergence_semantics() -> None:
diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py
index 96b077bd7..cfa31e657 100644
--- a/apps/api/tests/test_telegram_message_templates.py
+++ b/apps/api/tests/test_telegram_message_templates.py
@@ -1575,7 +1575,7 @@ async def test_no_write_replay_receipt_never_claims_runtime_apply(monkeypatch) -
@pytest.mark.asyncio
-async def test_healthy_posture_readback_is_suppressed_and_never_claims_apply(
+async def test_historical_healthy_posture_is_shadowed_and_never_claims_apply(
monkeypatch,
) -> None:
gateway = TelegramGateway()
@@ -1621,7 +1621,11 @@ async def test_healthy_posture_readback_is_suppressed_and_never_claims_apply(
duration_ms=8649,
verifier_written=True,
learning_written=True,
+ rag_written=True,
+ mcp_context_ready=True,
+ playbook_trust_written=True,
execution_kind="no_write_posture_readback",
+ provider_delivery="shadow_only",
project_id="awoooi",
)
@@ -1642,11 +1646,12 @@ async def test_healthy_posture_readback_is_suppressed_and_never_claims_apply(
assert policy["provider_delivery"] == "shadow_only"
assert policy["disposition"] == "suppressed"
assert policy["provider_send_performed"] is False
+ assert policy["lifecycle_state"] == "recovered"
assert policy["runtime_write_performed"] is False
@pytest.mark.asyncio
-async def test_healthy_wazuh_posture_is_recipient_visible_once_per_run(
+async def test_healthy_wazuh_posture_uses_stable_lifecycle_transition(
monkeypatch,
) -> None:
gateway = TelegramGateway()
@@ -1702,16 +1707,16 @@ async def test_healthy_wazuh_posture_is_recipient_visible_once_per_run(
method, payload = sent_requests[0]
assert method == "sendMessage"
assert "Runtime write: 0" in payload["text"]
- assert "本次 run 僅外送一次" in payload["text"]
+ assert "狀態轉換僅外送一次" in payload["text"]
policy = payload["_awooop_source_envelope_extra"]["notification_policy"]
assert policy["policy_version"] == (
- "telegram_wazuh_posture_same_run_receipt_v1"
+ "telegram_posture_lifecycle_transition_v1"
)
assert policy["provider_delivery"] == "state_transition"
- assert policy["disposition"] == "send_once_per_run"
- assert policy["group_scope"] == "controlled_asset_same_run"
+ assert policy["disposition"] == "send_on_transition"
+ assert policy["group_scope"] == "controlled_asset_lifecycle"
assert policy["delivery_semantics"] == (
- "at_least_once_per_run_after_unknown_ack_lease"
+ "at_least_once_after_unknown_ack_lease"
)
assert policy["lifecycle_state"] == "recovered"
assert policy["runtime_write_performed"] is False
@@ -1723,7 +1728,6 @@ async def test_healthy_wazuh_posture_is_recipient_visible_once_per_run(
playbook_path=(
"infra/ansible/playbooks/wazuh-manager-posture-readback.yml"
),
- scope_discriminator="00000000-0000-0000-0000-000000000013",
)
)
@@ -1762,7 +1766,7 @@ def test_controlled_apply_lifecycle_scope_preserves_legacy_and_separates_runs(
@pytest.mark.asyncio
-async def test_failed_posture_readback_is_digest_deduplicated_and_queues_repair(
+async def test_failed_wazuh_posture_uses_lifecycle_transition_and_queues_repair(
monkeypatch,
) -> None:
gateway = TelegramGateway()
@@ -1808,10 +1812,16 @@ async def test_failed_posture_readback_is_digest_deduplicated_and_queues_repair(
assert "AI 自動修復摘要|姿態檢查待修復" in payload["text"]
assert "transport_or_playbook_repair_queued" in payload["text"]
assert "Runtime write: 0" in payload["text"]
+ assert "狀態轉換僅外送一次" in payload["text"]
policy = payload["_awooop_source_envelope_extra"]["notification_policy"]
- assert policy["provider_delivery"] == "digest"
- assert policy["digest_window_minutes"] == 30
- assert len(policy["failure_fingerprint"]) == 24
+ assert policy["policy_version"] == (
+ "telegram_posture_lifecycle_transition_v1"
+ )
+ assert policy["provider_delivery"] == "state_transition"
+ assert policy["disposition"] == "send_on_transition"
+ assert policy["lifecycle_state"] == "failed"
+ assert "digest_window_minutes" not in policy
+ assert "failure_fingerprint" not in policy
def test_callback_reply_awooop_status_chain_snapshot_marks_manual_gate() -> None: