diff --git a/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py index a8c90ffd0..c1176ccc8 100644 --- a/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py +++ b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py @@ -503,6 +503,8 @@ async def _ensure_mcp_evidence_writeback( "rag_writeback_ack_id", "playbook_trust_writeback_ack_id", } + if "backup_health" in identity.route_id: + required.add("dr_scorecard_writeback_ack_id") safe_refs = sanitize_agent99_public_receipt_refs( receipt_refs, allowed_keys=required, @@ -794,13 +796,6 @@ async def _finalize_learning( "telegram_lifecycle_receipt_id", lambda: _ensure_telegram_receipt(identity, mode=mode), ), - ( - "mcp_evidence_writeback_ack_id", - lambda: _ensure_mcp_evidence_writeback( - identity, - receipt_refs=receipt_refs, - ), - ), ] if "backup_health" in identity.route_id: assets.append(( @@ -810,6 +805,18 @@ async def _finalize_learning( evidence_refs=evidence_refs, ), )) + # MCP is the final evidence binding: for backup routes it must include the + # already-persisted DR scorecard acknowledgement as well as Telegram, + # KM/RAG and PlayBook. Writing MCP first would create a durable but + # incomplete evidence graph that could never prove the DR asset belonged + # to this same run. + assets.append(( + "mcp_evidence_writeback_ack_id", + lambda: _ensure_mcp_evidence_writeback( + identity, + receipt_refs=receipt_refs, + ), + )) for key, writer in assets: if not await ensure_checkpointed(key, writer): return { diff --git a/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py index f92f2f8d4..1e608bce5 100644 --- a/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py +++ b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py @@ -147,6 +147,28 @@ async def test_agent99_mcp_ack_is_same_run_durable_and_idempotent( assert second == expected +@pytest.mark.asyncio +async def test_backup_mcp_ack_requires_dr_scorecard_binding() -> None: + identity = build_agent99_dispatch_identity( + project_id="awoooi", + incident_id="INC-20260719-BACKUP-MCP", + source_fingerprint="backup-mcp-fingerprint", + route_id="agent99:backup_health:BackupCheck", + ) + + receipt = await job._ensure_mcp_evidence_writeback( + identity, + receipt_refs={ + "telegram_lifecycle_receipt_id": "telegram:1", + "km_writeback_ack_id": "km:1", + "rag_writeback_ack_id": "rag:1", + "playbook_trust_writeback_ack_id": "playbook:1", + }, + ) + + assert receipt is None + + @pytest.mark.asyncio async def test_backup_telegram_ack_is_no_write_lifecycle_not_apply( monkeypatch, @@ -445,30 +467,40 @@ async def test_learning_checkpoint_resumes_without_replaying_assets( "mcp": 0, "dr": 0, } + effect_order: list[str] = [] terminal_calls = 0 async def write_playbook(*_args, **_kwargs): # type: ignore[no-untyped-def] effects["playbook"] += 1 + effect_order.append("playbook") return "playbooks:PB-A99:version:1" async def write_km(*_args, **_kwargs): # type: ignore[no-untyped-def] effects["km"] += 1 + effect_order.append("km") return "knowledge_entries:km-1:embedding_verified" async def write_telegram(*_args, **_kwargs): # type: ignore[no-untyped-def] effects["telegram"] += 1 + effect_order.append("telegram") return "telegram_outbound:1:durable_ack" async def write_rag(*_args, **_kwargs): # type: ignore[no-untyped-def] effects["rag"] += 1 + effect_order.append("rag") return "knowledge_entries:km-1:rag_embedding_verified" async def write_mcp(*_args, **_kwargs): # type: ignore[no-untyped-def] effects["mcp"] += 1 + effect_order.append("mcp") + assert _kwargs["receipt_refs"]["dr_scorecard_writeback_ack_id"] == ( + "knowledge_entries:dr-1:dr_scorecard" + ) return "awooop_mcp_gateway_audit:call-1:verified" async def write_dr(*_args, **_kwargs): # type: ignore[no-untyped-def] effects["dr"] += 1 + effect_order.append("dr") return "knowledge_entries:dr-1:dr_scorecard" async def terminal(**_kwargs): # type: ignore[no-untyped-def] @@ -514,6 +546,7 @@ async def test_learning_checkpoint_resumes_without_replaying_assets( "mcp": 1, "dr": 1, } + assert effect_order == ["playbook", "km", "rag", "telegram", "dr", "mcp"] assert terminal_calls == 2