diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index d0b9dfb52..8e644c009 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -2877,17 +2877,36 @@ jobs: import socket import sys - reachable = [] + connected = [] + refused = [] + permitted = [] for host in sys.argv[1:]: try: connection = socket.create_connection((host, 22), timeout=1.0) + except ConnectionRefusedError: + # A TCP refusal proves that the packet crossed the egress + # NetworkPolicy boundary and the target replied. Keep host + # service readiness separate from this security-boundary + # verifier; a stopped sshd must not make a healthy rollout + # look like a policy failure. + refused.append(host) + permitted.append(host) except OSError: continue else: connection.close() - reachable.append(host) - print("broker_ssh_reachable=" + ",".join(reachable)) - raise SystemExit(0 if len(reachable) == len(sys.argv) - 1 else 1) + connected.append(host) + permitted.append(host) + print("broker_ssh_connected=" + ",".join(connected)) + print( + "broker_ssh_refused_but_egress_permitted=" + + ",".join(refused) + ) + raise SystemExit( + 0 + if connected and len(permitted) == len(sys.argv) - 1 + else 1 + ) PY } verify_ssh_denied awoooi-api api || { diff --git a/agent99-control-plane.ps1 b/agent99-control-plane.ps1 index fedc1d480..22edfae5e 100644 --- a/agent99-control-plane.ps1 +++ b/agent99-control-plane.ps1 @@ -6,6 +6,7 @@ param( [switch]$SelfTestTelegramDelivery, [switch]$SelfTestSensorGate, [switch]$SelfTestOutcomeContract, + [switch]$SelfTestBackupReadbackContract, [switch]$SuppressAlerts, [string]$SuppressReason = "", [string]$ConfigPath = "C:\Wooo\Agent99\config\agent99.config.json", @@ -53,6 +54,71 @@ function Convert-AgentBool { return [bool]($text -match "^(?i:true|1|yes|y|on)$") } +function Convert-AgentPublicReceiptLeaf { + param([object]$Value) + $text = ([string]$Value).Trim() + if (-not $text) { return "missing" } + $leaf = [regex]::Replace($text, "^.*[\\/]", "") + $safe = [regex]::Replace($leaf, "[^A-Za-z0-9._:@+%-]", "-").Trim(".-") + if (-not $safe) { return "missing" } + if ($safe.Length -gt 180) { return $safe.Substring(0, 180) } + return $safe +} + +function Send-AgentOutcomeWriteback { + param( + [object]$Result, + [string]$PostVerifierEvidenceRef, + [string]$SourceEventEvidenceRef + ) + $configured = if ($Config.PSObject.Properties["outcomeWriteback"]) { $Config.outcomeWriteback } else { $null } + $enabled = if ($configured -and $configured.PSObject.Properties["enabled"]) { Convert-AgentBool $configured.enabled } else { $true } + if (-not $enabled) { + return [pscustomobject]@{ ok = $false; status = "outcome_writeback_disabled"; attempts = 0 } + } + $url = if ($configured -and $configured.PSObject.Properties["url"] -and $configured.url) { [string]$configured.url } else { "https://awoooi.wooo.work/api/v1/webhooks/agent99/outcomes" } + $tokenEnv = if ($configured -and $configured.PSObject.Properties["tokenEnv"] -and $configured.tokenEnv) { [string]$configured.tokenEnv } else { "AGENT99_SRE_RELAY_TOKEN" } + $token = [Environment]::GetEnvironmentVariable($tokenEnv) + if (-not $token) { + return [pscustomobject]@{ ok = $false; status = "outcome_writeback_token_missing"; attempts = 0 } + } + if (-not $Result.identity -or -not $Result.outcome) { + return [pscustomobject]@{ ok = $false; status = "outcome_writeback_identity_or_outcome_missing"; attempts = 0 } + } + $body = [pscustomobject]@{ + identity = $Result.identity + outcome_receipt = [pscustomobject]@{ + identity = $Result.identity + mode = $Result.mode + controlledApply = [bool]$Result.controlledApply + transportOk = [bool]$Result.transportOk + outcome = $Result.outcome + } + evidence_refs = [pscustomobject]@{ + agent99_outcome_receipt_id = "agent99-outcome:$($Result.id)" + post_verifier_evidence_ref = $PostVerifierEvidenceRef + source_event_evidence_ref = $SourceEventEvidenceRef + } + learning_receipt_refs = [pscustomobject]@{} + } | ConvertTo-Json -Depth 12 -Compress + $lastStatus = "outcome_writeback_failed" + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + $response = Invoke-RestMethod -Method Post -Uri $url -Headers @{ "X-Agent99-Relay-Token" = $token } -ContentType "application/json" -Body $body -TimeoutSec 20 + return [pscustomobject]@{ + ok = [bool]($response -and $response.accepted -eq $true) + status = if ($response -and $response.status) { [string]$response.status } else { "outcome_writeback_acknowledged" } + attempts = $attempt + runtimeClosureVerified = [bool]($response -and $response.runtime_closure_verified -eq $true) + } + } catch { + $lastStatus = "outcome_writeback_http_error" + if ($attempt -lt 3) { Start-Sleep -Seconds 2 } + } + } + [pscustomobject]@{ ok = $false; status = $lastStatus; attempts = 3; runtimeClosureVerified = $false } +} + function Get-HostSshUser { param([string]$TargetHost) if ($Config.sshUsers -and $Config.sshUsers.PSObject.Properties[$TargetHost]) { @@ -3292,6 +3358,151 @@ function Get-AgentBackupHealthConfig { targets = if ($backupHealth -and $backupHealth.targets) { @($backupHealth.targets) } else { $targets } fileChecks = if ($backupHealth -and $backupHealth.fileChecks) { @($backupHealth.fileChecks) } else { $fileChecks } cronPatterns = if ($backupHealth -and $backupHealth.cronPatterns) { @($backupHealth.cronPatterns) } else { $cronPatterns } + protectionMetricsPath = if ($backupHealth -and $backupHealth.protectionMetricsPath) { [string]$backupHealth.protectionMetricsPath } else { "/home/wooo/node_exporter_textfiles/backup_health.prom" } + protectionReadTimeoutSeconds = if ($backupHealth -and $backupHealth.protectionReadTimeoutSeconds) { [int]$backupHealth.protectionReadTimeoutSeconds } else { 45 } + protectionMaxAgeMinutes = if ($backupHealth -and $backupHealth.protectionMaxAgeMinutes) { [double]$backupHealth.protectionMaxAgeMinutes } else { 60 } + } +} + +function Get-AgentBackupMetricValues { + param([string]$Output, [string]$MetricName) + $values = @() + $pattern = "(?m)^" + [regex]::Escape($MetricName) + "(?:\{[^}]*\})?\s+(-?[0-9]+(?:\.[0-9]+)?)\s*$" + foreach ($match in [regex]::Matches([string]$Output, $pattern)) { + $parsed = 0.0 + if ([double]::TryParse($match.Groups[1].Value, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)) { + $values += $parsed + } + } + @($values) +} + +function Convert-AgentBackupProtectionReadback { + param( + [object]$Readback, + [double]$MaxAgeMinutes = 60, + [double]$NowUnix = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + ) + + $readback = $Readback + $mtimeMatch = [regex]::Match([string]$readback.output, "__PROTECTION_MTIME__=([0-9]+)") + $mtime = if ($mtimeMatch.Success) { $mtimeMatch.Groups[1].Value } else { "" } + $evidenceAgeMinutes = if ($mtime) { ($NowUnix - [double]$mtime) / 60 } else { $null } + $evidenceFresh = [bool]($null -ne $evidenceAgeMinutes -and $evidenceAgeMinutes -ge -5 -and $evidenceAgeMinutes -le [math]::Max(1, $MaxAgeMinutes)) + $configured = @(Get-AgentBackupMetricValues $readback.output "awoooi_backup_offsite_configured") + $offsiteFresh = @(Get-AgentBackupMetricValues $readback.output "awoooi_backup_offsite_fresh") + $remoteVerify = @(Get-AgentBackupMetricValues $readback.output "awoooi_backup_offsite_remote_verify_ok") + $escrowFresh = @(Get-AgentBackupMetricValues $readback.output "awoooi_backup_credential_escrow_fresh") + $escrowMissingValues = @(Get-AgentBackupMetricValues $readback.output "awoooi_backup_dr_credential_escrow_missing_count") + $escrowMissing = if ($escrowMissingValues.Count -gt 0 -and @($escrowMissingValues | Where-Object { $_ -lt 0 }).Count -eq 0) { [int](($escrowMissingValues | Measure-Object -Sum).Sum) } else { -1 } + $offsiteOk = [bool]( + $readback.ok -and $mtime -and $evidenceFresh -and + $configured.Count -gt 0 -and @($configured | Where-Object { $_ -ge 1 }).Count -eq $configured.Count -and + $offsiteFresh.Count -gt 0 -and @($offsiteFresh | Where-Object { $_ -ge 1 }).Count -eq $offsiteFresh.Count -and + $remoteVerify.Count -gt 0 -and @($remoteVerify | Where-Object { $_ -ge 1 }).Count -eq $remoteVerify.Count + ) + $escrowOk = [bool]( + $readback.ok -and $mtime -and $evidenceFresh -and $escrowMissing -eq 0 -and + $escrowFresh.Count -gt 0 -and @($escrowFresh | Where-Object { $_ -ge 1 }).Count -eq $escrowFresh.Count + ) + [pscustomobject]@{ + offsiteVerify = [pscustomobject]@{ + ok = $offsiteOk + receiptId = if ($offsiteOk) { "offsite-verify:$mtime" } else { $null } + reason = if ($offsiteOk) { "read_only_metrics_verified" } elseif (-not $readback.ok) { "protection_metrics_readback_failed" } else { "offsite_verify_not_green" } + configuredMetricCount = $configured.Count + freshMetricCount = $offsiteFresh.Count + remoteVerifyMetricCount = $remoteVerify.Count + evidenceFresh = $evidenceFresh + evidenceAgeMinutes = $evidenceAgeMinutes + maxAgeMinutes = $MaxAgeMinutes + } + escrow = [pscustomobject]@{ + ok = $escrowOk + missingCount = $escrowMissing + receiptId = if ($escrowOk) { "escrow-status:$mtime:missing-0" } else { $null } + reason = if ($escrowOk) { "non_secret_metrics_verified" } elseif ($escrowMissing -lt 0) { "escrow_missing_metric_absent" } else { "escrow_evidence_incomplete" } + freshMetricCount = $escrowFresh.Count + evidenceFresh = $evidenceFresh + evidenceAgeMinutes = $evidenceAgeMinutes + maxAgeMinutes = $MaxAgeMinutes + } + evidenceMtime = $mtime + route = $readback.route + exitCode = $readback.exitCode + readOnly = $true + backupRunPerformed = $false + restoreRunPerformed = $false + markerWritePerformed = $false + } +} + +function Test-AgentBackupProtectionReceipts { + param([object]$BackupConfig) + + $hostIp = [string]$BackupConfig.host + $path = [string]$BackupConfig.protectionMetricsPath + $timeoutSeconds = [math]::Min(120, [math]::Max(5, [int]$BackupConfig.protectionReadTimeoutSeconds)) + $maxAgeMinutes = [math]::Min(1440, [math]::Max(1, [double]$BackupConfig.protectionMaxAgeMinutes)) + if (-not $path -or $path -notmatch "^/[A-Za-z0-9_.\/-]+$" -or $path -match "\.\.") { + return [pscustomobject]@{ + offsiteVerify = [pscustomobject]@{ ok = $false; receiptId = $null; reason = "invalid_protection_metrics_path" } + escrow = [pscustomobject]@{ ok = $false; missingCount = -1; receiptId = $null; reason = "invalid_protection_metrics_path" } + readOnly = $true + backupRunPerformed = $false + restoreRunPerformed = $false + markerWritePerformed = $false + } + } + + $quotedPath = Quote-ShSingle $path + $metricPattern = "awoooi_backup_(offsite_configured|offsite_fresh|offsite_remote_verify_ok|credential_escrow_fresh|dr_credential_escrow_missing_count)" + $command = "if [ -r $quotedPath ]; then stat -c '__PROTECTION_MTIME__=%Y' $quotedPath; grep -E '^$metricPattern([ {])' $quotedPath || true; else echo __PROTECTION_MISSING__; fi" + $readback = Invoke-HostSshText $hostIp $command $timeoutSeconds 1 + Convert-AgentBackupProtectionReadback $readback $maxAgeMinutes +} + +function Invoke-AgentBackupReadbackContractSelfTest { + $fixture = [pscustomobject]@{ + ok = $true + route = "selftest-read-only" + exitCode = 0 + output = @" +__PROTECTION_MTIME__=1783785600 +awoooi_backup_offsite_configured{host="110",provider="rclone"} 1 +awoooi_backup_offsite_fresh{host="110",provider="rclone"} 1 +awoooi_backup_offsite_remote_verify_ok{host="110",provider="rclone"} 1 +awoooi_backup_credential_escrow_fresh{host="110",item="redacted-id"} 1 +awoooi_backup_dr_credential_escrow_missing_count{host="110"} 0 +"@ + } + $result = Convert-AgentBackupProtectionReadback $fixture 60 1783785660 + $staleResult = Convert-AgentBackupProtectionReadback $fixture 60 1783792800 + $partialFixture = [pscustomobject]@{ + ok = $true + route = "selftest-read-only" + exitCode = 0 + output = $fixture.output -replace 'awoooi_backup_offsite_remote_verify_ok\{host="110",provider="rclone"\} 1', 'awoooi_backup_offsite_remote_verify_ok{host="110",provider="rclone"} 0' + } + $partialResult = Convert-AgentBackupProtectionReadback $partialFixture 60 1783785660 + $ok = [bool]( + $result.offsiteVerify.ok -and + $result.offsiteVerify.receiptId -eq "offsite-verify:1783785600" -and + $result.escrow.ok -and + $result.escrow.missingCount -eq 0 -and + $result.escrow.receiptId -eq "escrow-status:1783785600:missing-0" -and + $result.readOnly -and + -not $staleResult.offsiteVerify.ok -and + -not $staleResult.escrow.ok -and + -not $partialResult.offsiteVerify.ok -and + -not $result.backupRunPerformed -and + -not $result.restoreRunPerformed -and + -not $result.markerWritePerformed + ) + [pscustomobject]@{ + schemaVersion = "agent99_backup_readback_contract_selftest_v1" + ok = $ok + result = $result } } @@ -3549,6 +3760,7 @@ function Test-AgentBackupHealth { } $cron = Test-AgentBackupCronPatterns @($backupConfig.cronPatterns) $backupConfig.host + $protection = Test-AgentBackupProtectionReceipts $backupConfig $targetCritical = @($targets | Where-Object { $_.severity -eq "critical" }).Count $targetWarning = @($targets | Where-Object { $_.severity -eq "warning" }).Count @@ -3556,7 +3768,8 @@ function Test-AgentBackupHealth { $fileWarning = @($files | Where-Object { $_.severity -eq "warning" }).Count $cronCritical = @($cron | Where-Object { $_.severity -eq "critical" }).Count $cronWarning = @($cron | Where-Object { $_.severity -eq "warning" }).Count - $critical = $targetCritical + $fileCritical + $cronCritical + $protectionCritical = @(@($protection.offsiteVerify, $protection.escrow) | Where-Object { -not $_.ok }).Count + $critical = $targetCritical + $fileCritical + $cronCritical + $protectionCritical $warning = $targetWarning + $fileWarning + $cronWarning $severity = if ($critical -gt 0) { "critical" } elseif ($warning -gt 0) { "warning" } else { "ok" } @@ -3567,6 +3780,7 @@ function Test-AgentBackupHealth { fileWarning = $fileWarning cronCritical = $cronCritical cronWarning = $cronWarning + protectionCritical = $protectionCritical targetCount = @($targets).Count fileCheckCount = @($files).Count cronPatternCount = @($cron).Count @@ -3584,6 +3798,9 @@ function Test-AgentBackupHealth { targets = $targets files = $files cron = $cron + offsiteVerify = $protection.offsiteVerify + escrow = $protection.escrow + protectionReadback = $protection summary = $summary } } @@ -3672,6 +3889,7 @@ function Get-AgentOutcomeContract { $modeVerifierPassed = $false $successState = "resolved" $failureState = "degraded" + $modeEvidenceRefs = @{} if ($data) { switch ($ModeName) { @@ -3770,9 +3988,27 @@ function Get-AgentOutcomeContract { $modeVerifierPassed = $selfOk } "BackupCheck" { - $backupOk = [bool]($data.backupHealth -and $data.backupHealth.severity -eq "ok") - $checks += New-AgentOutcomeCheck "backup_health_ok" $backupOk - $modeVerifierPassed = $backupOk + $backup = $data.backupHealth + $backupStatusRow = @($backup.files | Where-Object { $_.name -eq "backup_status_log" -and $_.ok } | Select-Object -First 1) + $requiredTargets = @($backup.targets | Where-Object { $_.required }) + $requiredFiles = @($backup.files | Where-Object { $_.required }) + $backupStatusOk = [bool]($backup -and $backup.severity -eq "ok" -and $backupStatusRow.Count -gt 0) + $freshnessOk = [bool]($requiredTargets.Count -gt 0 -and @($requiredTargets | Where-Object { -not $_.ok -or $null -eq $_.ageMinutes -or $_.ageMinutes -gt $_.maxAgeMinutes }).Count -eq 0 -and @($requiredFiles | Where-Object { -not $_.ok -or $null -eq $_.ageMinutes -or $_.ageMinutes -gt $_.maxAgeMinutes }).Count -eq 0) + $offsiteOk = [bool]($backup -and $backup.PSObject.Properties["offsiteVerify"] -and $backup.offsiteVerify -and $backup.offsiteVerify.ok -eq $true -and $backup.offsiteVerify.receiptId) + $escrowOk = [bool]($backup -and $backup.PSObject.Properties["escrow"] -and $backup.escrow -and $backup.escrow.ok -eq $true -and [int]$backup.escrow.missingCount -eq 0 -and $backup.escrow.receiptId) + $restoreRow = @($backup.files | Where-Object { $_.name -eq "restore_drill_status" -and $_.ok } | Select-Object -First 1) + $restoreOk = [bool]($restoreRow.Count -gt 0 -and $restoreRow[0].lastWriteTime) + $checks += New-AgentOutcomeCheck "backup_status_evidence" $backupStatusOk + $checks += New-AgentOutcomeCheck "backup_freshness_evidence" $freshnessOk + $checks += New-AgentOutcomeCheck "backup_offsite_verify_evidence" $offsiteOk + $checks += New-AgentOutcomeCheck "backup_escrow_evidence" $escrowOk + $checks += New-AgentOutcomeCheck "backup_restore_drill_evidence" $restoreOk + if ($backupStatusOk) { $modeEvidenceRefs["backup_status_evidence_ref"] = "backup-status:$([string]$backupStatusRow[0].lastWriteTime)" } + if ($freshnessOk) { $modeEvidenceRefs["freshness_evidence_ref"] = "backup-freshness:targets-$($requiredTargets.Count):files-$($requiredFiles.Count)" } + if ($offsiteOk) { $modeEvidenceRefs["offsite_verify_evidence_ref"] = [string]$backup.offsiteVerify.receiptId } + if ($escrowOk) { $modeEvidenceRefs["escrow_evidence_ref"] = [string]$backup.escrow.receiptId } + if ($restoreOk) { $modeEvidenceRefs["restore_drill_evidence_ref"] = "restore-drill:$([string]$restoreRow[0].lastWriteTime)" } + $modeVerifierPassed = [bool]($backupStatusOk -and $freshnessOk -and $offsiteOk -and $escrowOk -and $restoreOk) } "ProviderFreshness" { $provider = $data.providerFreshness @@ -3819,6 +4055,10 @@ function Get-AgentOutcomeContract { if ($SourceEventResolutionRequired) { $checks += New-AgentOutcomeCheck "source_event_resolved" $sourceEventResolvedBool $true "policy=$SourceEventResolutionPolicy evidence=$effectiveSourceEventEvidence" } + if ($ModeName -eq "BackupCheck" -and $sourceEventResolvedBool -and $effectiveSourceEventEvidence) { + $sourceEvidenceLeaf = Split-Path -Leaf ([string]$effectiveSourceEventEvidence) + $modeEvidenceRefs["source_resolution_receipt_ref"] = "source-resolution:$sourceEvidenceLeaf" + } $state = if (-not $transportOk) { "failed" } elseif (-not $basePassed) { @@ -3850,6 +4090,7 @@ function Get-AgentOutcomeContract { sourceEventEvidence = if ($effectiveSourceEventEvidence) { $effectiveSourceEventEvidence } else { $null } failedChecks = $failedChecks checks = $checks + evidenceRefs = $modeEvidenceRefs verifiedAt = (Get-Date -Format o) } } @@ -4672,6 +4913,20 @@ function Invoke-AgentQueuedCommands { $sourceEventResolutionPolicy = if ($command.PSObject.Properties["sourceEventResolutionPolicy"]) { [string]$command.sourceEventResolutionPolicy } else { "external_source_receipt_required" } $sourceEventResolved = if ($command.PSObject.Properties["sourceEventResolved"]) { [object](Convert-AgentBool $command.sourceEventResolved) } else { $null } $sourceEventEvidence = if ($command.PSObject.Properties["sourceEventEvidence"]) { [string]$command.sourceEventEvidence } else { "" } + $automationRunId = if ($command.PSObject.Properties["automationRunId"]) { [string]$command.automationRunId } else { "" } + $traceId = if ($command.PSObject.Properties["traceId"]) { [string]$command.traceId } else { "" } + $workItemId = if ($command.PSObject.Properties["workItemId"]) { [string]$command.workItemId } else { "" } + $idempotencyKey = if ($command.PSObject.Properties["idempotencyKey"]) { [string]$command.idempotencyKey } else { "" } + $executionGeneration = if ($command.PSObject.Properties["executionGeneration"]) { [string]$command.executionGeneration } else { "1" } + $incidentId = if ($command.PSObject.Properties["incidentId"]) { [string]$command.incidentId } else { "" } + $approvalId = if ($command.PSObject.Properties["approvalId"]) { [string]$command.approvalId } else { "" } + $approvalIdentityPresent = [bool]$command.PSObject.Properties["approvalId"] + $projectId = if ($command.PSObject.Properties["projectId"]) { [string]$command.projectId } else { "" } + $sourceFingerprint = if ($command.PSObject.Properties["sourceFingerprint"]) { [string]$command.sourceFingerprint } else { "" } + $dispatchRouteId = if ($command.PSObject.Properties["dispatchRouteId"]) { [string]$command.dispatchRouteId } else { "" } + $singleFlightKey = if ($command.PSObject.Properties["singleFlightKey"]) { [string]$command.singleFlightKey } else { "" } + $lockOwner = if ($command.PSObject.Properties["lockOwner"]) { [string]$command.lockOwner } else { "" } + $canonicalDigest = if ($command.PSObject.Properties["canonicalDigest"]) { [string]$command.canonicalDigest } else { "" } $replyChatId = if ($command.PSObject.Properties["replyChatId"]) { [string]$command.replyChatId } else { "" } $replyMessageId = if ($command.PSObject.Properties["replyMessageId"]) { [string]$command.replyMessageId } else { "" } $suppressTelegram = if ($command.PSObject.Properties["suppressTelegram"]) { Convert-AgentBool $command.suppressTelegram } else { $false } @@ -4683,6 +4938,24 @@ function Invoke-AgentQueuedCommands { $results += $result continue } + $hasDispatchIdentity = [bool]($automationRunId -or $traceId -or $workItemId -or $idempotencyKey -or $incidentId -or $projectId -or $sourceFingerprint -or $dispatchRouteId -or $singleFlightKey -or $lockOwner -or $canonicalDigest) + $dispatchIdentityIncomplete = [bool](-not $automationRunId -or -not $traceId -or -not $workItemId -or -not $idempotencyKey -or -not $incidentId -or -not $projectId -or -not $sourceFingerprint -or -not $dispatchRouteId -or -not $singleFlightKey -or -not $lockOwner -or -not $canonicalDigest -or -not $approvalIdentityPresent -or -not $executionGeneration) + if (($controlled -and $modeName -eq "Recover" -and -not $hasDispatchIdentity) -or ($hasDispatchIdentity -and $dispatchIdentityIncomplete)) { + $target = Join-Path $failedDir ("failed-" + $file.Name) + Move-Item -Force $file.FullName $target + $result = [pscustomobject]@{ + id = $id + mode = $modeName + source = $commandSource + ok = $false + reason = "controlled_dispatch_identity_missing" + runtimeWritePerformed = $false + file = $target + } + Record-AgentEvent "operator_command_rejected" "warning" "id=$id mode=$modeName reason=controlled_dispatch_identity_missing" $result -Alert + $results += $result + continue + } $runningPath = Join-Path $queueDir ("running-" + $file.Name) Move-Item -Force $file.FullName $runningPath @@ -4712,6 +4985,31 @@ function Invoke-AgentQueuedCommands { -3 } $outcome = Get-AgentOutcomeContract -ModeName $modeName -TransportExitCode $exitCode -LatestEvidence $latest -StartedAt $startTime -SourceEventResolutionRequired $sourceEventResolutionRequired -SourceEventResolved $sourceEventResolved -SourceEventEvidence $sourceEventEvidence -SourceEventResolutionPolicy $sourceEventResolutionPolicy + $outcome | Add-Member -MemberType NoteProperty -Name automationRunId -Value $automationRunId -Force + $outcome | Add-Member -MemberType NoteProperty -Name traceId -Value $traceId -Force + $outcome | Add-Member -MemberType NoteProperty -Name workItemId -Value $workItemId -Force + $outcome | Add-Member -MemberType NoteProperty -Name executionGeneration -Value $executionGeneration -Force + $outcome | Add-Member -MemberType NoteProperty -Name incidentId -Value $incidentId -Force + $dispatchIdentity = $null + if ($hasDispatchIdentity) { + $dispatchIdentity = [pscustomobject]@{ + schema_version = "agent99_controlled_dispatch_identity_v1" + project_id = $projectId + incident_id = $incidentId + source_fingerprint = $sourceFingerprint + route_id = $dispatchRouteId + execution_generation = $executionGeneration + approval_id = $approvalId + run_id = $automationRunId + trace_id = $traceId + work_item_id = $workItemId + idempotency_key = $idempotencyKey + single_flight_key = $singleFlightKey + lock_owner = $lockOwner + canonical_digest = $canonicalDigest + } + $outcome | Add-Member -MemberType NoteProperty -Name identity -Value $dispatchIdentity -Force + } $result = [pscustomobject]@{ id = $id mode = $modeName @@ -4733,6 +5031,14 @@ function Invoke-AgentQueuedCommands { verifierPassed = [bool]$outcome.verifierPassed sourceEventResolved = [bool]$outcome.sourceEventResolved sourceEventResolutionPolicy = $sourceEventResolutionPolicy + automationRunId = $automationRunId + traceId = $traceId + workItemId = $workItemId + idempotencyKey = $idempotencyKey + executionGeneration = $executionGeneration + incidentId = $incidentId + approvalId = $approvalId + identity = $dispatchIdentity outcome = $outcome exitCode = $exitCode durationSeconds = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 1) @@ -4742,6 +5048,19 @@ function Invoke-AgentQueuedCommands { } $problem = Update-AgentProblemKnowledge $result $result | Add-Member -MemberType NoteProperty -Name problem -Value $problem -Force + $donePath = Join-Path $processedDir ("processed-$id.json") + $result | ConvertTo-Json -Depth 8 | Set-Content -Path $donePath -Encoding UTF8 + $sourceEvidenceValue = if ($sourceEventEvidence) { $sourceEventEvidence } elseif ($latest.path) { [string]$latest.path } else { "agent99-outcome-$id" } + $sourceEvidenceRef = "agent99-source:$(Convert-AgentPublicReceiptLeaf $sourceEvidenceValue)" + $postVerifierEvidenceValue = if ($latest.path) { [string]$latest.path } else { $donePath } + $postVerifierEvidenceRef = "agent99-verifier:$(Convert-AgentPublicReceiptLeaf $postVerifierEvidenceValue)" + $outcomeWriteback = if ($hasDispatchIdentity) { + Send-AgentOutcomeWriteback $result $postVerifierEvidenceRef $sourceEvidenceRef + } else { + [pscustomobject]@{ ok = $false; status = "no_durable_dispatch_identity"; attempts = 0; runtimeClosureVerified = $false } + } + $result | Add-Member -MemberType NoteProperty -Name outcomeWriteback -Value $outcomeWriteback -Force + $result | ConvertTo-Json -Depth 10 | Set-Content -Path $donePath -Encoding UTF8 Remove-Item $runningPath -Force -ErrorAction SilentlyContinue | Out-Null $recordData = $result | Select-Object * if ($replyChatId) { @@ -5406,6 +5725,13 @@ if ($SelfTestOutcomeContract) { Invoke-AgentOutcomeContractSelfTest } +if ($SelfTestBackupReadbackContract) { + $backupReadbackSelfTest = Invoke-AgentBackupReadbackContractSelfTest + $backupReadbackSelfTest | ConvertTo-Json -Depth 10 + if (-not $backupReadbackSelfTest.ok) { exit 1 } + exit 0 +} + Write-AgentLog "agent99_start mode=$Mode controlledApply=$ControlledApply config=$ConfigPath" Record-AgentEvent "agent_start" "info" "mode=$Mode controlledApply=$ControlledApply host=$env:COMPUTERNAME" $null -Alert diff --git a/agent99-sre-alert-inbox.ps1 b/agent99-sre-alert-inbox.ps1 index 584ed271a..5b2c05c3b 100644 --- a/agent99-sre-alert-inbox.ps1 +++ b/agent99-sre-alert-inbox.ps1 @@ -84,7 +84,7 @@ function Test-AgentAlertActionable { if ($severity -in @("critical", "warning", "error", "degraded", "down", "failed")) { return $true } - if ($text -match "502|provider_freshness_signal|source_provider_freshness_triage|raw_signal_normalized|controlled_runtime_candidate|freshness|last_seen|last seen|ingestion|imagepullbackoff|errimagepull|crashloopbackoff|host_down|vm_not_running|vm_stuck|host112_guest|backup_failed|backup_missing|disk_used|load_per_core|cpu|memory|harbor|registry|k3s|wazuh|siem|security|intrusion|malware|auth_failed|bruteforce|cve|ioc|gitea|repo_missing|repository_missing|missing_key|e_ai_missing_key|application_ai_config_error|locale guard|ai support") { + if ($text -match "502|provider_freshness_signal|source_provider_freshness_triage|raw_signal_normalized|controlled_runtime_candidate|freshness|last_seen|last seen|ingestion|imagepullbackoff|errimagepull|crashloopbackoff|host_down|vm_not_running|vm_stuck|host112_guest|cold[-_ ]start|reboot[-_ ]auto[-_ ]recovery|backup_failed|backup_missing|disk_used|load_per_core|cpu|memory|harbor|registry|k3s|wazuh|siem|security|intrusion|malware|auth_failed|bruteforce|cve|ioc|gitea|repo_missing|repository_missing|missing_key|e_ai_missing_key|application_ai_config_error|locale guard|ai support") { return $true } return $false @@ -180,11 +180,15 @@ function Resolve-AgentAlertMode { function Get-AgentAlertCorrelationKey { param([object]$Alert) + $awoooi = Get-AgentField $Alert "awoooi" $null + $dispatchIdentity = Get-AgentField $awoooi "agent99DispatchIdentity" $null + $stableSingleFlight = [string](Get-AgentField $dispatchIdentity "single_flight_key" "") + if ($stableSingleFlight) { return "controlled:$($stableSingleFlight.ToLowerInvariant())" } + $routing = Get-AgentField $Alert "routing" $null $correlationKey = [string](Get-AgentField $routing "correlationKey" "") if ($correlationKey) { return "route:$($correlationKey.ToLowerInvariant())" } - $awoooi = Get-AgentField $Alert "awoooi" $null $fingerprint = [string](Get-AgentField $awoooi "fingerprint" "") if (-not $fingerprint) { $fingerprint = [string](Get-AgentField $Alert "fingerprint" "") } if ($fingerprint) { return "fingerprint:$($fingerprint.ToLowerInvariant())" } @@ -747,6 +751,37 @@ foreach ($file in $files) { $resolution = Get-AgentField $alert "resolution" $null $sourceEventResolutionPolicy = [string](Get-AgentField $resolution "policy" "external_source_receipt_required") $sourceEventResolutionRequired = Convert-AgentBool (Get-AgentField $resolution "required" $true) + $routing = Get-AgentField $alert "routing" $null + $awoooiMetadata = Get-AgentField $alert "awoooi" $null + $dispatchIdentity = Get-AgentField $awoooiMetadata "agent99DispatchIdentity" $null + $automationRunId = [string](Get-AgentField $dispatchIdentity "run_id" (Get-AgentField $routing "runId" "")) + $traceId = [string](Get-AgentField $dispatchIdentity "trace_id" (Get-AgentField $routing "traceId" "")) + $workItemId = [string](Get-AgentField $dispatchIdentity "work_item_id" (Get-AgentField $routing "workItemId" "")) + $idempotencyKey = [string](Get-AgentField $dispatchIdentity "idempotency_key" (Get-AgentField $routing "idempotencyKey" "")) + $executionGeneration = [string](Get-AgentField $dispatchIdentity "execution_generation" (Get-AgentField $routing "executionGeneration" "1")) + $incidentId = [string](Get-AgentField $dispatchIdentity "incident_id" (Get-AgentField $awoooiMetadata "incidentId" "")) + $approvalId = [string](Get-AgentField $dispatchIdentity "approval_id" (Get-AgentField $awoooiMetadata "approvalId" "")) + $projectId = [string](Get-AgentField $dispatchIdentity "project_id" "") + $sourceFingerprint = [string](Get-AgentField $dispatchIdentity "source_fingerprint" "") + $dispatchRouteId = [string](Get-AgentField $dispatchIdentity "route_id" "") + $singleFlightKey = [string](Get-AgentField $dispatchIdentity "single_flight_key" "") + $lockOwner = [string](Get-AgentField $dispatchIdentity "lock_owner" "") + $canonicalDigest = [string](Get-AgentField $dispatchIdentity "canonical_digest" "") + $hasDispatchIdentity = [bool]($automationRunId -or $traceId -or $workItemId -or $idempotencyKey -or $incidentId -or $projectId -or $sourceFingerprint -or $dispatchRouteId -or $singleFlightKey) + $dispatchIdentityIncomplete = [bool](-not $automationRunId -or -not $traceId -or -not $workItemId -or -not $idempotencyKey -or -not $incidentId -or -not $projectId -or -not $sourceFingerprint -or -not $dispatchRouteId -or -not $singleFlightKey -or -not $lockOwner -or -not $canonicalDigest -or -not $executionGeneration) + if (($controlled -and $modeName -eq "Recover" -and -not $hasDispatchIdentity) -or ($hasDispatchIdentity -and $dispatchIdentityIncomplete)) { + $failedPath = Join-Path $FailedDir ("failed-" + (Convert-AgentSafeFileToken $alertId) + "-" + $file.Name) + Move-Item -Force $runningPath $failedPath + $failed += [pscustomobject]@{ + alertId = $alertId + file = $failedPath + ok = $false + reason = "controlled_dispatch_identity_missing" + mode = $modeName + runtimeWritePerformed = $false + } + continue + } $requestPath = Join-Path $RequestDir "$queueId.json" $queuePath = Join-Path $QueueDir "$queueId.json" @@ -774,6 +809,19 @@ foreach ($file in $files) { suppressTelegram = $suppressTelegram sourceEventResolutionPolicy = $sourceEventResolutionPolicy sourceEventResolutionRequired = $sourceEventResolutionRequired + automationRunId = $automationRunId + traceId = $traceId + workItemId = $workItemId + idempotencyKey = $idempotencyKey + executionGeneration = $executionGeneration + incidentId = $incidentId + approvalId = $approvalId + projectId = $projectId + sourceFingerprint = $sourceFingerprint + dispatchRouteId = $dispatchRouteId + singleFlightKey = $singleFlightKey + lockOwner = $lockOwner + canonicalDigest = $canonicalDigest createdAt = (Get-Date -Format o) } | ConvertTo-Json -Depth 8 | Set-Content -Path $requestPath -Encoding UTF8 @@ -802,6 +850,19 @@ foreach ($file in $files) { suppressTelegram = $suppressTelegram sourceEventResolutionPolicy = $sourceEventResolutionPolicy sourceEventResolutionRequired = $sourceEventResolutionRequired + automationRunId = $automationRunId + traceId = $traceId + workItemId = $workItemId + idempotencyKey = $idempotencyKey + executionGeneration = $executionGeneration + incidentId = $incidentId + approvalId = $approvalId + projectId = $projectId + sourceFingerprint = $sourceFingerprint + dispatchRouteId = $dispatchRouteId + singleFlightKey = $singleFlightKey + lockOwner = $lockOwner + canonicalDigest = $canonicalDigest createdAt = (Get-Date -Format o) } | ConvertTo-Json -Depth 8 | Set-Content -Path $queuePath -Encoding UTF8 @@ -843,6 +904,13 @@ foreach ($file in $files) { suppressTelegram = $suppressTelegram sourceEventResolutionPolicy = $sourceEventResolutionPolicy sourceEventResolutionRequired = $sourceEventResolutionRequired + automationRunId = $automationRunId + traceId = $traceId + workItemId = $workItemId + idempotencyKey = $idempotencyKey + executionGeneration = $executionGeneration + incidentId = $incidentId + approvalId = $approvalId requestPath = $requestPath queuePath = $queuePath alertPath = $processedPath diff --git a/agent99-sre-alert-relay.ps1 b/agent99-sre-alert-relay.ps1 index 1e5d4cc40..ac2180376 100644 --- a/agent99-sre-alert-relay.ps1 +++ b/agent99-sre-alert-relay.ps1 @@ -14,9 +14,10 @@ $AlertRoot = Join-Path $AgentRoot "alerts" $IncomingDir = Join-Path $AlertRoot "incoming" $LogDir = Join-Path $AgentRoot "logs" $BinDir = Join-Path $AgentRoot "bin" +$ProcessedDir = Join-Path (Join-Path $AgentRoot "queue") "processed" $SreAlertInboxScript = Join-Path $BinDir "agent99-sre-alert-inbox.ps1" -New-Item -ItemType Directory -Force -Path $EvidenceDir, $IncomingDir, $LogDir | Out-Null +New-Item -ItemType Directory -Force -Path $EvidenceDir, $IncomingDir, $LogDir, $ProcessedDir | Out-Null function Convert-AgentSafeFileToken { param([string]$Value) @@ -47,6 +48,115 @@ function Get-AgentEnvValue { return "" } +function Test-AgentPublicReceiptRef { + param([string]$Value) + if (-not $Value -or $Value.Length -gt 256) { return $false } + if ($Value -match "(?i)(authorization|bearer|password|passwd|token|secret|api[_-]?key|private[_-]?key|cookie|session)(\s|:|=)|-----BEGIN.*PRIVATE KEY-----|(^|[/\.])\.env($|[/\.])") { return $false } + if ($Value -match "\.\.|://|[\\?=\s]") { return $false } + return [bool]($Value -match "^[A-Za-z0-9][A-Za-z0-9._:/@+%-]{0,255}$") +} + +function Get-AgentCanonicalOutcomeIdentity { + param([object]$Result, [object]$Outcome) + $outer = Get-AgentField $Result "identity" $null + $nested = Get-AgentField $Outcome "identity" $null + $required = @( + "schema_version", "project_id", "incident_id", "source_fingerprint", + "route_id", "execution_generation", "approval_id", "run_id", "trace_id", + "work_item_id", "idempotency_key", "single_flight_key", "lock_owner", + "canonical_digest" + ) + if (-not $outer -or -not $nested) { return $null } + foreach ($name in $required) { + $outerValue = [string](Get-AgentField $outer $name "") + $nestedValue = [string](Get-AgentField $nested $name "") + if ($name -ne "approval_id" -and (-not $outerValue -or -not $nestedValue)) { + return $null + } + if ($outerValue -ne $nestedValue) { return $null } + } + return $outer +} + +function Get-AgentOutcomeReadback { + param([string]$RunId) + + $parsedRunId = [guid]::Empty + if (-not [guid]::TryParse($RunId, [ref]$parsedRunId)) { + return @{ ok = $false; found = $false; error = "invalid_run_id" } + } + + foreach ($file in @(Get-ChildItem -Path $ProcessedDir -Filter "processed-*.json" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 256)) { + try { + $result = Get-Content $file.FullName -Raw | ConvertFrom-Json + if ([string](Get-AgentField $result "automationRunId" "") -ne $RunId) { continue } + $outcome = Get-AgentField $result "outcome" $null + $canonicalIdentity = Get-AgentCanonicalOutcomeIdentity $result $outcome + if (-not $canonicalIdentity) { continue } + $safeChecks = @() + foreach ($check in @((Get-AgentField $outcome "checks" @()) | Select-Object -First 32)) { + $safeChecks += @{ + name = [string](Get-AgentField $check "name" "") + required = [bool](Get-AgentField $check "required" $false) + passed = [bool](Get-AgentField $check "passed" $false) + } + } + $safeEvidenceRefs = @{} + $outcomeEvidenceRefs = Get-AgentField $outcome "evidenceRefs" $null + foreach ($name in @( + "backup_status_evidence_ref", + "freshness_evidence_ref", + "offsite_verify_evidence_ref", + "escrow_evidence_ref", + "restore_drill_evidence_ref", + "source_resolution_receipt_ref" + )) { + $value = [string](Get-AgentField $outcomeEvidenceRefs $name "") + if (Test-AgentPublicReceiptRef $value) { $safeEvidenceRefs[$name] = $value } + } + return @{ + ok = $true + found = $true + schemaVersion = "agent99_outcome_readback_v1" + automationRunId = $RunId + traceId = [string](Get-AgentField $result "traceId" "") + workItemId = [string](Get-AgentField $result "workItemId" "") + idempotencyKey = [string](Get-AgentField $result "idempotencyKey" "") + executionGeneration = [string](Get-AgentField $result "executionGeneration" "1") + incidentId = [string](Get-AgentField $result "incidentId" "") + approvalId = [string](Get-AgentField $result "approvalId" "") + identity = $canonicalIdentity + controlledApply = [bool](Get-AgentField $result "controlledApply" $false) + mode = [string](Get-AgentField $result "mode" "") + outcome = @{ + schemaVersion = [string](Get-AgentField $outcome "schemaVersion" "") + state = [string](Get-AgentField $outcome "state" "unknown") + resolved = [bool](Get-AgentField $outcome "resolved" $false) + transportOk = [bool](Get-AgentField $outcome "transportOk" $false) + verifierName = [string](Get-AgentField $outcome "verifierName" "") + verifierPassed = [bool](Get-AgentField $outcome "verifierPassed" $false) + sourceEventResolved = [bool](Get-AgentField $outcome "sourceEventResolved" $false) + identity = $canonicalIdentity + failedChecks = @((Get-AgentField $outcome "failedChecks" @()) | Select-Object -First 32) + checks = $safeChecks + evidenceRefs = $safeEvidenceRefs + verifiedAt = [string](Get-AgentField $outcome "verifiedAt" "") + } + storesRawEvidence = $false + } + } catch { + continue + } + } + return @{ + ok = $true + found = $false + schemaVersion = "agent99_outcome_readback_v1" + automationRunId = $RunId + storesRawEvidence = $false + } +} + function Write-AgentRelayEvent { param([hashtable]$Event) $path = Join-Path $LogDir "agent99-sre-alert-relay.jsonl" @@ -136,12 +246,6 @@ try { $context = $listener.GetContext() $remote = [string]$context.Request.RemoteEndPoint.Address try { - if ($context.Request.HttpMethod -ne "POST") { - Send-AgentRelayResponse $context 405 @{ ok = $false; error = "method_not_allowed" } - Write-AgentRelayEvent @{ event = "relay_rejected"; ok = $false; remote = $remote; reason = "method_not_allowed" } - continue - } - if (-not (Test-AgentRelayRemoteAllowed $context)) { Send-AgentRelayResponse $context 403 @{ ok = $false; error = "remote_not_allowed" } Write-AgentRelayEvent @{ event = "relay_rejected"; ok = $false; remote = $remote; reason = "remote_not_allowed" } @@ -158,6 +262,34 @@ try { } } + if ($context.Request.HttpMethod -eq "GET" -and -not $expectedToken) { + Send-AgentRelayResponse $context 503 @{ ok = $false; found = $false; error = "outcome_readback_auth_not_configured" } + Write-AgentRelayEvent @{ event = "outcome_readback_rejected"; ok = $false; remote = $remote; reason = "auth_not_configured" } + continue + } + + if ($context.Request.HttpMethod -eq "GET") { + $runId = [string]$context.Request.QueryString["run_id"] + $readback = Get-AgentOutcomeReadback $runId + $statusCode = if ($readback.ok) { 200 } else { 400 } + Send-AgentRelayResponse $context $statusCode $readback + Write-AgentRelayEvent @{ + event = "outcome_readback" + ok = [bool]$readback.ok + found = [bool]$readback.found + remote = $remote + runId = if ($readback.ok) { $runId } else { "invalid" } + storesRawEvidence = $false + } + continue + } + + if ($context.Request.HttpMethod -ne "POST") { + Send-AgentRelayResponse $context 405 @{ ok = $false; error = "method_not_allowed" } + Write-AgentRelayEvent @{ event = "relay_rejected"; ok = $false; remote = $remote; reason = "method_not_allowed" } + continue + } + if ($context.Request.ContentLength64 -gt $MaxBodyBytes) { Send-AgentRelayResponse $context 413 @{ ok = $false; error = "payload_too_large" } Write-AgentRelayEvent @{ event = "relay_rejected"; ok = $false; remote = $remote; reason = "payload_too_large" } diff --git a/agent99.config.99.example.json b/agent99.config.99.example.json index 29240f9b0..5099fb06b 100644 --- a/agent99.config.99.example.json +++ b/agent99.config.99.example.json @@ -144,6 +144,11 @@ "batchLimit": 5, "retryAfterMinutes": 5 }, + "outcomeWriteback": { + "enabled": true, + "url": "https://awoooi.wooo.work/api/v1/webhooks/agent99/outcomes", + "tokenEnv": "AGENT99_SRE_RELAY_TOKEN" + }, "telegram": { "enabled": true, "botTokenEnv": "AGENT99_TELEGRAM_BOT_TOKEN", @@ -325,6 +330,9 @@ "backupHealth": { "host": "192.168.0.110", "snapshotReadTimeoutSeconds": 90, + "protectionMetricsPath": "/home/wooo/node_exporter_textfiles/backup_health.prom", + "protectionReadTimeoutSeconds": 45, + "protectionMaxAgeMinutes": 60, "targets": [], "fileChecks": [], "cronPatterns": [] diff --git a/agent99.config.example.json b/agent99.config.example.json index 505e545f1..083f9eed2 100644 --- a/agent99.config.example.json +++ b/agent99.config.example.json @@ -136,6 +136,11 @@ "batchLimit": 5, "retryAfterMinutes": 5 }, + "outcomeWriteback": { + "enabled": true, + "url": "https://awoooi.wooo.work/api/v1/webhooks/agent99/outcomes", + "tokenEnv": "AGENT99_SRE_RELAY_TOKEN" + }, "telegram": { "enabled": true, "botTokenEnv": "AGENT99_TELEGRAM_BOT_TOKEN", @@ -260,6 +265,9 @@ }, "backupHealth": { "host": "192.168.0.110", + "protectionMetricsPath": "/home/wooo/node_exporter_textfiles/backup_health.prom", + "protectionReadTimeoutSeconds": 45, + "protectionMaxAgeMinutes": 60, "targets": [], "fileChecks": [], "cronPatterns": [] diff --git a/apps/api/migrations/agent99_dispatch_next_attempt_at_2026-07-11.sql b/apps/api/migrations/agent99_dispatch_next_attempt_at_2026-07-11.sql new file mode 100644 index 000000000..1928874c5 --- /dev/null +++ b/apps/api/migrations/agent99_dispatch_next_attempt_at_2026-07-11.sql @@ -0,0 +1,14 @@ +-- Durable retry not-before for Agent99 controlled dispatch claims. +-- Prevents the 15-second reconciler tick from consuming every attempt and +-- generation while a Redis flight lease or transport cooldown is active. + +BEGIN; + +ALTER TABLE awooop_run_state + ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMP NULL; + +CREATE INDEX IF NOT EXISTS idx_run_state_retry_due + ON awooop_run_state (project_id, next_attempt_at) + WHERE state = 'pending' AND next_attempt_at IS NOT NULL; + +COMMIT; diff --git a/apps/api/migrations/agent99_dispatch_next_attempt_at_2026-07-11_down.sql b/apps/api/migrations/agent99_dispatch_next_attempt_at_2026-07-11_down.sql new file mode 100644 index 000000000..7079fb15a --- /dev/null +++ b/apps/api/migrations/agent99_dispatch_next_attempt_at_2026-07-11_down.sql @@ -0,0 +1,6 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_run_state_retry_due; +ALTER TABLE awooop_run_state DROP COLUMN IF EXISTS next_attempt_at; + +COMMIT; diff --git a/apps/api/src/api/v1/auto_repair.py b/apps/api/src/api/v1/auto_repair.py index a75b016a4..cb808aa05 100644 --- a/apps/api/src/api/v1/auto_repair.py +++ b/apps/api/src/api/v1/auto_repair.py @@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field from src.core.csrf import CSRFToken # Phase 20: CSRF Protection +from src.models.incident import IncidentReadDegradation from src.services.auto_repair_service import ( get_auto_repair_service, ) @@ -248,6 +249,11 @@ class RepairHistoryResponse(BaseModel): count: int items: list[RepairHistoryItem] + degraded: bool = False + degraded_count: int = 0 + degraded_records: list[IncidentReadDegradation] = Field(default_factory=list) + source_of_truth: str = "postgresql" + redis_fallback_used: bool = False @router.get("/history", response_model=RepairHistoryResponse) @@ -267,9 +273,20 @@ async def get_repair_history( """ try: incident_service = get_incident_service() - all_incidents = await incident_service.get_active_incidents( - project_id=project_id, + detailed_reader = getattr( + incident_service, + "get_active_incidents_readback", + None, ) + if callable(detailed_reader): + readback = await detailed_reader(project_id=project_id) + all_incidents = readback.incidents + degraded_records = readback.degraded_records + else: + all_incidents = await incident_service.get_active_incidents( + project_id=project_id, + ) + degraded_records = [] items: list[RepairHistoryItem] = [] for incident in all_incidents: @@ -315,7 +332,13 @@ async def get_repair_history( # 最多回傳 limit 筆,newest first (updated_at 已是活躍事件,先按 ID 截斷) items = items[:limit] - return RepairHistoryResponse(count=len(items), items=items) + return RepairHistoryResponse( + count=len(items), + items=items, + degraded=bool(degraded_records), + degraded_count=len(degraded_records), + degraded_records=degraded_records, + ) except IncidentDurableReadError as exc: raise HTTPException( diff --git a/apps/api/src/api/v1/incidents.py b/apps/api/src/api/v1/incidents.py index e1c9c8252..4323242ec 100644 --- a/apps/api/src/api/v1/incidents.py +++ b/apps/api/src/api/v1/incidents.py @@ -25,7 +25,12 @@ from pydantic import BaseModel, Field from src.core.logging import get_logger from src.models.approval import ApprovalRequestResponse -from src.models.incident import Incident, IncidentStatus, Severity +from src.models.incident import ( + Incident, + IncidentReadDegradation, + IncidentStatus, + Severity, +) # Phase 22 P1: 移除 Repository 直接存取 (2026-03-31) # Phase 16 R3.3b (2026-03-25 台北時區): Repository 層整合 - 已移至 Service 層 @@ -86,6 +91,11 @@ class IncidentListResponse(BaseModel): """事件清單回應""" count: int incidents: list[IncidentResponse] + degraded: bool = False + degraded_count: int = 0 + degraded_records: list[IncidentReadDegradation] = Field(default_factory=list) + source_of_truth: str = "postgresql" + redis_fallback_used: bool = False class ProposalGenerateResponse(BaseModel): @@ -189,9 +199,20 @@ async def list_incidents( try: # 透過 Service 取得活躍事件 - incidents = await incident_service.get_active_incidents( - project_id=project_id, + detailed_reader = getattr( + incident_service, + "get_active_incidents_readback", + None, ) + if callable(detailed_reader): + readback = await detailed_reader(project_id=project_id) + incidents = readback.incidents + degraded_records = readback.degraded_records + else: + incidents = await incident_service.get_active_incidents( + project_id=project_id, + ) + degraded_records = [] # 按時間排序 (最新優先) # 2026-03-26 修復: 處理 timezone-aware 與 naive datetime 混合問題 @@ -277,6 +298,9 @@ async def list_incidents( return IncidentListResponse( count=len(incidents), incidents=responses, + degraded=bool(degraded_records), + degraded_count=len(degraded_records), + degraded_records=degraded_records, ) except IncidentDurableReadError as e: diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 606f4c010..764d1f1e9 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -133,6 +133,14 @@ class AiAlertCardDeliveryItem(BaseModel): lane: str target: str gates: list[str] + declared_gates: list[str] = Field(default_factory=list) + declared_runtime_write_gate_count: int = 0 + declared_runtime_write_allowed: bool = False + declared_runtime_write_gate_state: str = "unknown" + declared_candidate_only: bool = False + declared_controlled_playbook_queue: bool = False + declared_learning_writeback_status: str = "not_evaluated" + effective_policy_projection: dict[str, Any] | None = None runtime_write_gate_count: int runtime_write_allowed: bool candidate_only: bool @@ -143,6 +151,7 @@ class AiAlertCardDeliveryItem(BaseModel): learning_writeback_status: str = "not_evaluated" learning_writeback_refs: dict[str, Any] = Field(default_factory=dict) learning_registry_bindings: list[dict[str, Any]] = Field(default_factory=list) + backup_restore_automation: dict[str, Any] | None = None source_refs: dict[str, Any] run_state: str | None = None agent_id: str | None = None @@ -167,8 +176,13 @@ class AiAlertCardDeliverySummary(BaseModel): pending_total: int shadow_total: int delivery_receipt_required_total: int + backup_restore_total: int = 0 + declared_runtime_write_gate_open_count: int = 0 + backup_runtime_write_gate_excluded_total: int = 0 runtime_write_gate_open_count: int runtime_write_allowed: bool + declared_learning_writeback_ready_total: int = 0 + backup_learning_writeback_excluded_total: int = 0 learning_writeback_ready_total: int = 0 learning_writeback_missing_total: int = 0 km_writeback_ref_ready_total: int = 0 @@ -181,6 +195,10 @@ class AiAlertCardDeliverySummary(BaseModel): learning_registry_ready_target_count: int = 0 learning_registry_missing_target_count: int = 0 learning_registry_next_action: str = "none" + backup_restore_readback_required: bool = False + no_false_green_enforced: bool = False + backup_restore_next_action: str = "none" + effective_policy_projection: dict[str, Any] = Field(default_factory=dict) latest_sent_at: datetime | None = None latest_queued_at: datetime | None = None production_write_count: int = 0 diff --git a/apps/api/src/api/v1/webhooks.py b/apps/api/src/api/v1/webhooks.py index bc5268995..8d266141e 100644 --- a/apps/api/src/api/v1/webhooks.py +++ b/apps/api/src/api/v1/webhooks.py @@ -43,7 +43,13 @@ from src.models.approval import ( DryRunCheck, RiskLevel, ) +from src.models.incident import SignalSource from src.models.webhook import AlertPayload, AlertResponse +from src.services.agent99_sre_bridge import ( + bridge_alertmanager_to_agent99, + resolve_agent99_durable_route, +) +from src.services.agent99_outcome_ingestion import ingest_agent99_outcome_receipt from src.services.alert_analyzer_service import AlertAnalyzer from src.services.alert_approval_guard import guard_alert_approval_action from src.services.alert_grouping_service import get_alert_grouping_service @@ -55,11 +61,16 @@ from src.services.alertmanager_llm_guard import ( from src.services.approval_db import get_approval_service from src.services.auto_approve import get_auto_approve_policy from src.services.auto_repair_service import AutoRepairService -from src.services.agent99_sre_bridge import bridge_alertmanager_to_agent99 +from src.services.backup_restore_alertmanager_ingress import ( + process_backup_restore_alertmanager_signal, +) from src.services.channel_hub import ( record_alertmanager_event, record_grouped_alert_event, ) +from src.services.controlled_alert_target_router import ( + build_controlled_recovery_handoff, +) from src.services.converged_alert_recurrence_notifier import ( notify_converged_alert_recurrence, ) @@ -96,6 +107,59 @@ router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) logger = get_logger("awoooi.webhooks") +class Agent99OutcomeWebhookPayload(BaseModel): + """Public-safe Agent99 outcome; learning refs are compatibility-only.""" + + identity: dict[str, str] + outcome_receipt: dict[str, Any] + evidence_refs: dict[str, str] + learning_receipt_refs: dict[str, str] = Field( + default_factory=dict, + description=( + "Untrusted compatibility input. Ignored for terminal closure; " + "the reconciler reads durable server-side step-3 checkpoints." + ), + ) + + +@router.post("/agent99/outcomes", summary="Ingest a fenced Agent99 outcome receipt") +async def ingest_agent99_outcome_webhook( + payload: Agent99OutcomeWebhookPayload, + x_agent99_relay_token: str | None = Header( + default=None, + alias="X-Agent99-Relay-Token", + ), +) -> dict[str, Any]: + """Persist a fenced outcome/verifier; the reconciler owns closure.""" + + configured_token = str(settings.AGENT99_SRE_ALERT_RELAY_TOKEN or "") + if ( + not configured_token + or not x_agent99_relay_token + or not hmac.compare_digest(configured_token, x_agent99_relay_token) + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="agent99_outcome_authentication_failed", + ) + try: + receipt = await ingest_agent99_outcome_receipt(payload.model_dump()) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + if receipt.get("accepted") is not True: + failure_status = str(receipt.get("status") or "outcome_rejected") + response_status = ( + status.HTTP_503_SERVICE_UNAVAILABLE + if "persistence" in failure_status + else status.HTTP_409_CONFLICT + ) + raise HTTPException(status_code=response_status, detail=failure_status) + return receipt + + # ============================================================================= # Incident-Approval 同步 (feedback_incident_approval_sync.md 鐵律) # ============================================================================= @@ -238,6 +302,38 @@ def _controlled_alert_execution_priority(risk_level: RiskLevel | str) -> int: ) +def _resolve_controlled_source_fingerprint( + *, + source_fingerprint: str, + source_alertname: str, + alert_type: str, + target_resource: str, + namespace: str, + source_message: str, + source_labels: dict[str, Any] | None, +) -> str: + """Preserve the canonical webhook identity for normalized alert routes.""" + + fingerprint = str(source_fingerprint or "").strip() + if fingerprint: + return fingerprint + + labels = dict(source_labels or {}) + labels["alertname"] = str( + source_alertname or labels.get("alertname") or alert_type + ).strip() + normalized_alert = AlertPayload( + alert_type="custom", + severity="warning", + source="alert_webhook_controlled_router", + target_resource=target_resource, + namespace=namespace, + message=source_message or target_resource, + labels=labels, + ) + return AlertAnalyzer.generate_fingerprint(normalized_alert) + + async def _try_auto_repair_background( incident_id: str, approval_id: str, @@ -245,41 +341,195 @@ async def _try_auto_repair_background( target_resource: str, namespace: str, risk_level: str, + source_alert_id: str = "", + source_alertname: str = "", + source_severity: str = "", + source_message: str = "", + source_labels: dict[str, Any] | None = None, + source_annotations: dict[str, Any] | None = None, + source_fingerprint: str = "", + alert_category: str = "", + notification_type: str = "", + source_url: str | None = None, ) -> dict: """Queue an alert decision for the single-writer executor.""" - from src.repositories.alert_operation_log_repository import ( - get_alert_operation_log_repository, - ) from src.jobs.awooop_ansible_candidate_backfill_job import ( enqueue_ai_decision_ansible_candidate, ) + from src.repositories.alert_operation_log_repository import ( + get_alert_operation_log_repository, + ) op_log = get_alert_operation_log_repository() execution_priority = _controlled_alert_execution_priority(risk_level) - incident = await get_incident_service().get_from_working_memory(incident_id) - if incident is None: + resolved_source_fingerprint = _resolve_controlled_source_fingerprint( + source_fingerprint=source_fingerprint, + source_alertname=source_alertname, + alert_type=alert_type, + target_resource=target_resource, + namespace=namespace, + source_message=source_message, + source_labels=source_labels, + ) + handoff = build_controlled_recovery_handoff( + alertname=alert_type, + target_resource=target_resource, + namespace=namespace, + incident_id=incident_id, + ) + durable_agent99_route = resolve_agent99_durable_route( + alertname=source_alertname or alert_type, + severity=source_severity or risk_level, + target_resource=target_resource, + namespace=namespace, + message=source_message or target_resource, + labels=source_labels or {}, + annotations=source_annotations or {}, + ) + if handoff is None and durable_agent99_route is not None: + # Domain-specific Agent99 routes (including read-only BackupCheck) are + # durable single-owner lanes too; they must never fall through to the + # generic Ansible keyword catalog. handoff = { "schema_version": "ai_decision_controlled_executor_handoff_v1", - "status": "incident_not_found", + "status": "agent99_dispatch_reservation_pending", "queued": False, "side_effect_performed": False, - "single_writer_executor": "awoooi-ansible-executor-broker", - "active_blockers": ["incident_not_found"], + "single_writer_executor": "Agent99", + "route_id": durable_agent99_route["route_id"], + "agent99_kind": durable_agent99_route["kind"], + "agent99_mode": durable_agent99_route["suggested_mode"], + "active_blockers": ["agent99_dispatch_receipt_pending"], + "safe_next_action": "reserve_dispatch_verify_and_writeback_same_run", + "runtime_execution_authorized": False, + "runtime_closure_verified": False, + "needs_human": False, } + if handoff is None: + incident = await get_incident_service().get_from_working_memory(incident_id) + if incident is None: + handoff = { + "schema_version": "ai_decision_controlled_executor_handoff_v1", + "status": "incident_not_found", + "queued": False, + "side_effect_performed": False, + "single_writer_executor": "awoooi-ansible-executor-broker", + "active_blockers": ["incident_not_found"], + } + else: + handoff = await enqueue_ai_decision_ansible_candidate( + incident=incident, + proposal_data={ + "source": "alert_webhook_controlled_router", + "risk_level": risk_level, + "action": target_resource, + "alert_type": alert_type, + "namespace": namespace, + "approval_id": approval_id, + "execution_priority": execution_priority, + }, + project_id=str(getattr(incident, "project_id", None) or "awoooi"), + ) else: - handoff = await enqueue_ai_decision_ansible_candidate( - incident=incident, - proposal_data={ - "source": "alert_webhook_controlled_router", - "risk_level": risk_level, - "action": target_resource, - "alert_type": alert_type, - "namespace": namespace, - "approval_id": approval_id, - "execution_priority": execution_priority, - }, - project_id=str(getattr(incident, "project_id", None) or "awoooi"), + # Agent99 is the only dispatch owner for host control-plane recovery. + # The generic Ansible catalog must not manufacture candidates from + # incidental words such as 110, backup, Docker, or container. + route_id = str(handoff.get("route_id") or "") + dispatch_result = await bridge_alertmanager_to_agent99( + alert_id=source_alert_id, + alertname=source_alertname or alert_type, + severity=source_severity or risk_level, + namespace=namespace, + target_resource=target_resource, + message=source_message or target_resource, + labels=source_labels or {}, + annotations=source_annotations or {}, + fingerprint=resolved_source_fingerprint, + alert_category=alert_category, + notification_type=notification_type, + source_url=source_url, + project_id="awoooi", + incident_id=incident_id, + approval_id=approval_id, + route_id=route_id, + work_item_id=f"agent99-dispatch:awoooi:{incident_id}:{route_id}", + ) + identity = ( + dispatch_result.get("identity") + if isinstance(dispatch_result.get("identity"), dict) + else {} + ) + correlated_receipt = ( + dispatch_result.get("correlatedReceipt") + if isinstance(dispatch_result.get("correlatedReceipt"), dict) + else {} + ) + dispatch_receipt = ( + dispatch_result.get("dispatchReceipt") + if isinstance(dispatch_result.get("dispatchReceipt"), dict) + else {} + ) + accepted = bool(dispatch_receipt.get("accepted") is True) + inbox_triggered = bool(dispatch_receipt.get("inbox_triggered") is True) + dispatch_promoted = bool(accepted and inbox_triggered) + receipt_persisted = bool( + correlated_receipt.get("receipt_persisted") is True + or ( + dispatch_result.get("status") == "deduplicated" + and correlated_receipt + ) + ) + handoff.update({ + "status": ( + "agent99_dispatch_accepted_verifier_pending" + if dispatch_promoted and receipt_persisted + else "agent99_dispatch_failed_or_receipt_missing" + ), + "queued": dispatch_promoted and receipt_persisted, + "side_effect_performed": bool( + dispatch_result.get("dispatchPerformed") is True + ), + "automation_run_id": identity.get("run_id"), + "trace_id": identity.get("trace_id"), + "work_item_id": identity.get("work_item_id"), + "idempotency_key": identity.get("idempotency_key"), + "dispatch_receipt": dispatch_receipt or None, + "correlated_receipt": correlated_receipt or None, + "receipt_persisted": receipt_persisted, + "dispatch_accepted": accepted and receipt_persisted, + "inbox_triggered": inbox_triggered and receipt_persisted, + "dispatch_promoted": dispatch_promoted and receipt_persisted, + "runtime_execution_authorized": False, + "runtime_closure_verified": False, + "active_blockers": ( + [ + "post_apply_verifier_missing", + "km_playbook_writeback_missing", + ] + if dispatch_promoted and receipt_persisted + else [ + str( + dispatch_result.get("reason") + or "agent99_dispatch_or_receipt_failed" + ) + ] + ), + "safe_next_action": ( + "run_independent_cold_start_verifier_then_km_playbook_writeback" + if dispatch_promoted and receipt_persisted + else "retry_new_generation_after_dispatch_transport_repair" + ), + }) + logger.info( + "controlled_alert_domain_route_selected", + incident_id=incident_id, + target_resource=target_resource, + executor=handoff.get("single_writer_executor"), + route_id=handoff.get("route_id"), + automation_run_id=handoff.get("automation_run_id"), + receipt_persisted=handoff.get("receipt_persisted"), + runtime_closure_verified=False, ) handoff["execution_priority"] = execution_priority @@ -292,6 +542,8 @@ async def _try_auto_repair_background( action_detail=( "controlled_check_mode_queued" if queued + else "controlled_domain_route_pending_receipt" + if handoff.get("single_writer_executor") == "Agent99" else "controlled_queue_blocked" ), success=queued, @@ -304,13 +556,22 @@ async def _try_auto_repair_background( "schema_version": handoff.get("schema_version"), "status": handoff.get("status"), "automation_run_id": handoff.get("automation_run_id"), + "trace_id": handoff.get("trace_id"), + "work_item_id": handoff.get("work_item_id"), + "idempotency_key": handoff.get("idempotency_key"), "single_writer_executor": handoff.get("single_writer_executor"), "execution_priority": execution_priority, - "side_effect_performed": False, + "side_effect_performed": bool(handoff.get("side_effect_performed")), + "dispatch_receipt": handoff.get("dispatch_receipt"), + "correlated_receipt": handoff.get("correlated_receipt"), + "runtime_closure_verified": False, "safe_next_action": ( - "awooop_ansible_check_mode_worker_claims_candidate" - if queued - else "repair_candidate_backfill_or_playbook_coverage_gap" + handoff.get("safe_next_action") + or ( + "awooop_ansible_check_mode_worker_claims_candidate" + if queued + else "repair_candidate_backfill_or_playbook_coverage_gap" + ) ), }, ) @@ -321,7 +582,7 @@ async def _try_auto_repair_background( queued=queued, status=handoff.get("status"), automation_run_id=handoff.get("automation_run_id"), - side_effect_performed=False, + side_effect_performed=bool(handoff.get("side_effect_performed")), ) return handoff @@ -1064,9 +1325,12 @@ class SignalPayload(BaseModel): - AlertPayload: 同步處理,含 LLM 分析 """ - source: str = Field( + source: SignalSource = Field( ..., - description="訊號來源 (prometheus, grafana, k8s-events, signoz)", + description=( + "受控訊號來源(例如 prometheus, signoz, journal, " + "node-exporter, sensor-probe)" + ), ) alert_name: str = Field( @@ -1313,6 +1577,15 @@ async def receive_alert( target_resource=alert.target_resource, namespace=alert.namespace, risk_level=updated_approval.risk_level.value, + source_alert_id=alert_id, + source_alertname=str( + (alert.labels or {}).get("alertname") + or alert.alert_type + ), + source_severity=alert.severity, + source_message=alert.message, + source_labels=alert.labels or {}, + source_fingerprint=fingerprint, ) # ================================================================= # 2026-03-27 ogt: 收斂告警不重複發送 Telegram,只更新 hit_count @@ -1448,6 +1721,7 @@ async def receive_alert( alert_namespace=alert.namespace, alertname=_alertname_cs1, alert_category=get_incident_type(_alertname_cs1), + target_resource=alert.target_resource, ) _matched_playbook_id_cs1 = await resolve_playbook_id_for_alert( alertname=_alertname_cs1, @@ -1486,7 +1760,7 @@ async def receive_alert( metadata=_approval_metadata_cs1, matched_playbook_id=_matched_playbook_id_cs1, ) - suggested_action = analysis_result.kubectl_command + suggested_action = _guarded_action_cs1.action else: # LLM 失敗,降級使用靜態分析 logger.warning( @@ -1565,6 +1839,13 @@ async def receive_alert( target_resource=alert.target_resource, namespace=alert.namespace, risk_level=_cs1_risk_level.value, + source_alert_id=alert_id, + source_alertname=_alertname_cs1, + source_severity=alert.severity, + source_message=alert.message, + source_labels=alert.labels or {}, + source_fingerprint=fingerprint, + alert_category=_cs1_alert_category, ) logger.info( @@ -1830,6 +2111,7 @@ async def _process_new_alert_background( alert_namespace=namespace, alertname=alertname, alert_category=alert_category, + target_resource=target_resource, ) if _guarded_action_cs2.blocked: rule_action = _guarded_action_cs2.action @@ -1968,6 +2250,16 @@ async def _process_new_alert_background( target_resource=target_resource, namespace=namespace, risk_level=rule_risk.value, + source_alert_id=alert_id, + source_alertname=alertname, + source_severity=severity, + source_message=message, + source_labels=traced_alert_labels, + source_annotations=alert_context.get("annotations", {}), + source_fingerprint=fingerprint, + alert_category=alert_category, + notification_type=notification_type, + source_url=str(alert_context.get("source_url") or "") or None, ) elif not _is_heartbeat: from src.repositories.alert_operation_log_repository import get_alert_operation_log_repository @@ -2059,6 +2351,7 @@ async def _process_new_alert_background( alert_namespace=namespace, alertname=alertname, alert_category=alert_category, + target_resource=target_resource, ) _matched_playbook_id_cs3 = await resolve_playbook_id_for_alert( rule_id=str(rule_response.get("rule_id", "")), @@ -2200,6 +2493,16 @@ async def _process_new_alert_background( target_resource=target_resource, namespace=namespace, risk_level=risk_level.value, + source_alert_id=alert_id, + source_alertname=alertname, + source_severity=severity, + source_message=message, + source_labels=traced_alert_labels, + source_annotations=alert_context.get("annotations", {}), + source_fingerprint=fingerprint, + alert_category=alert_category, + notification_type=notification_type, + source_url=str(alert_context.get("source_url") or "") or None, ) elif not _is_heartbeat: from src.repositories.alert_operation_log_repository import get_alert_operation_log_repository @@ -2464,6 +2767,16 @@ async def _process_new_alert_background( target_resource=target_resource, namespace=namespace, risk_level=fallback_create.risk_level.value, + source_alert_id=alert_id, + source_alertname=alertname, + source_severity=severity, + source_message=message, + source_labels=traced_alert_labels, + source_annotations=alert_context.get("annotations", {}), + source_fingerprint=fingerprint, + alert_category=alert_category, + notification_type=notification_type, + source_url=str(alert_context.get("source_url") or "") or None, ) if _controlled_handoff.get("queued") is True: telegram_root_cause = ( @@ -2959,24 +3272,60 @@ async def alertmanager_webhook( converged=True, ) - # Agent99 only receives the parent route after grouping. The bridge adds a - # Redis NX fingerprint lock so concurrent duplicate deliveries cannot race - # past this point and launch duplicate remediation. - background_tasks.add_task( - bridge_alertmanager_to_agent99, - alert_id=alert_id, + # Backup/restore signals have one production dispatch owner: the raw + # Alertmanager ingress. Run it after storm grouping, but before generic + # approval convergence and the legacy TYPE-1 "notify then resolve" branch. + # The worker first persists/binds a canonical Incident and then sends one + # read-only BackupCheck through the PostgreSQL idempotency ledger. Telegram + # may only project that receipt and cannot dispatch a second copy. + backup_restore_route = resolve_agent99_durable_route( alertname=alertname, severity=severity, - namespace=namespace, target_resource=target_resource, + namespace=namespace, message=message, labels=dict(alert.labels) if alert.labels else {}, annotations=dict(alert.annotations) if alert.annotations else {}, - fingerprint=fingerprint, - alert_category=alert_category, - notification_type=notification_type, - source_url=alert.generatorURL, ) + if ( + isinstance(backup_restore_route, dict) + and backup_restore_route.get("kind") == "backup_health" + and backup_restore_route.get("suggested_mode") == "BackupCheck" + ): + background_tasks.add_task( + process_backup_restore_alertmanager_signal, + project_id="awoooi", + alert_id=alert_id, + alertname=alertname, + severity=severity, + namespace=namespace, + target_resource=target_resource, + message=message, + labels=dict(alert.labels) if alert.labels else {}, + annotations=dict(alert.annotations) if alert.annotations else {}, + source_fingerprint=fingerprint, + source_event_fingerprint=str(alert.fingerprint or fingerprint), + source_started_at=str(alert.startsAt or ""), + alert_category=alert_category, + notification_type=notification_type, + source_url=alert.generatorURL, + ) + record_alert_chain_success("alertmanager") + _record_webhook_metric("success") + return AlertResponse( + success=True, + message=( + "Backup/restore signal queued for durable read-only BackupCheck " + "dispatch and verifier readback" + ), + alert_id=alert_id, + approval_created=False, + ) + + # Agent99 dispatch is deliberately deferred until an incident exists. + # _try_auto_repair_background is the sole owner and first reserves the + # deterministic PostgreSQL run/idempotency identity. Telegram only reads + # that durable receipt and never launches a second Recover. try: service = get_approval_service() @@ -3019,6 +3368,18 @@ async def alertmanager_webhook( target_resource=target_resource, namespace=namespace, risk_level=updated_approval.risk_level.value, + source_alert_id=alert_id, + source_alertname=alertname, + source_severity=severity, + source_message=message, + source_labels=dict(alert.labels) if alert.labels else {}, + source_annotations=( + dict(alert.annotations) if alert.annotations else {} + ), + source_fingerprint=fingerprint, + alert_category=alert_category, + notification_type=notification_type, + source_url=alert.generatorURL, ) # 2026-03-27 ogt: 收斂告警不重複發送 Telegram,只更新 hit_count # 用戶可在 UI 查看聚合次數,避免 Telegram 洗版 @@ -3200,6 +3561,7 @@ async def alertmanager_webhook( "fingerprint": fingerprint, "message": message, "annotations": dict(alert.annotations) if alert.annotations else {}, + "source_url": alert.generatorURL or "", "metrics": {}, "labels": alert.labels, } diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index 71926624a..aced8db6a 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -976,6 +976,39 @@ class Settings(BaseSettings): default=3.0, description="Timeout for fail-open Agent99 SRE alert relay POSTs.", ) + ENABLE_BACKUP_RESTORE_LEGACY_BACKFILL: bool = Field( + default=True, + description=( + "Enable bounded durable BackupCheck replay for legacy backup/restore " + "Telegram receipt cards when a trusted Agent99 relay is configured." + ), + ) + BACKUP_RESTORE_LEGACY_BACKFILL_STARTUP_SLEEP_SECONDS: float = Field( + default=5.0, + ge=0.0, + le=300.0, + description="Startup delay for the read-only legacy backup-card replay loop.", + ) + BACKUP_RESTORE_LEGACY_BACKFILL_INTERVAL_SECONDS: float = Field( + default=15.0, + ge=5.0, + le=3600.0, + description="Interval between bounded legacy backup-card replay batches.", + ) + BACKUP_RESTORE_LEGACY_BACKFILL_BATCH_LIMIT: int = Field( + default=20, + ge=1, + le=20, + description="Maximum legacy backup cards reserved and replayed per tick.", + ) + BACKUP_RESTORE_LEGACY_BACKFILL_MAX_ROWS: int = Field( + default=100, + ge=1, + le=100, + description=( + "Maximum rows retained in one durable legacy replay snapshot cohort." + ), + ) # 2026-04-24 Claude Sonnet 4.6 (ADR-095 WS4): Hermes NL 自然語言閘道 # false=不啟用(預設),true=啟用 @mention 問答(需 ANTHROPIC_API_KEY) HERMES_NL_ENABLED: bool = Field( diff --git a/apps/api/src/db/awooop_models.py b/apps/api/src/db/awooop_models.py index 3c132c9b8..6a60efe29 100644 --- a/apps/api/src/db/awooop_models.py +++ b/apps/api/src/db/awooop_models.py @@ -329,6 +329,8 @@ class AwoooPRunState(Base): ), Index("idx_run_state_pending", "project_id", "created_at", postgresql_where=text("state = 'pending' AND lease_until IS NULL")), + Index("idx_run_state_retry_due", "project_id", "next_attempt_at", + postgresql_where=text("state = 'pending' AND next_attempt_at IS NOT NULL")), Index("idx_run_state_stale", "lease_until", postgresql_where=text("state = 'running' AND lease_until IS NOT NULL")), Index("idx_run_state_project_timeline", "project_id", "created_at"), @@ -343,6 +345,7 @@ class AwoooPRunState(Base): agent_id: Mapped[str] = mapped_column(String(128), nullable=False) state: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") lease_until: Mapped[datetime | None] = mapped_column(nullable=True) + next_attempt_at: Mapped[datetime | None] = mapped_column(nullable=True) heartbeat_at: Mapped[datetime | None] = mapped_column(nullable=True) worker_id: Mapped[str | None] = mapped_column(String(128), nullable=True) attempt_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) diff --git a/apps/api/src/db/base.py b/apps/api/src/db/base.py index b7df07e39..652f310b5 100644 --- a/apps/api/src/db/base.py +++ b/apps/api/src/db/base.py @@ -368,6 +368,22 @@ async def _run_init_db_ddl(conn: AsyncConnection) -> None: else: raise + # Agent99 dispatch retry scheduling is read by the worker immediately + # after ``init_db`` returns. Normal CD does not apply arbitrary migration + # files, so this nullable compatibility column and bounded partial index + # must commit before any serving/background query can reference them. + # Keep the audited up/down migration as the explicit schema record. + async with conn.begin(): + await conn.execute(text(""" + ALTER TABLE awooop_run_state + ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMP NULL; + """)) + await conn.execute(text( + "CREATE INDEX IF NOT EXISTS idx_run_state_retry_due " + "ON awooop_run_state(project_id, next_attempt_at) " + "WHERE state = 'pending' AND next_attempt_at IS NOT NULL;" + )) + async with conn.begin(): # 2026-04-02 Claude Code: 確保 risklevel enum 包含 'high' 值 # Phase 23 新增,避免舊 DB 缺少此值導致 InvalidTextRepresentation diff --git a/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py new file mode 100644 index 000000000..456ea2cbd --- /dev/null +++ b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py @@ -0,0 +1,626 @@ +"""Reconcile durable Agent99 dispatches into verifier and learning closure.""" + +from __future__ import annotations + +import asyncio +import hashlib +from collections.abc import Awaitable, Callable +from typing import Any + +import structlog +from sqlalchemy import select, text + +from src.db.base import get_db_context +from src.db.models import PlaybookRecord +from src.models.knowledge import ( + EntrySource, + EntryStatus, + EntryType, + KnowledgeEntryCreate, +) +from src.services.agent99_controlled_dispatch_ledger import ( + Agent99DispatchIdentity, + get_agent99_dispatch_ledger, + record_agent99_learning_writeback, +) +from src.services.agent99_public_receipts import ( + extract_agent99_outcome_evidence_refs, + sanitize_agent99_public_receipt_refs, +) +from src.services.agent99_sre_bridge import ( + bridge_alertmanager_to_agent99, + read_agent99_sre_outcome, +) +from src.services.knowledge_service import get_knowledge_service +from src.services.telegram_gateway import get_telegram_gateway +from src.utils.timezone import now_taipei + +logger = structlog.get_logger(__name__) + +INTERVAL_SECONDS = 15 +RECONCILE_LIMIT = 20 +LEGACY_BACKFILL_LIMIT = 5 +LEGACY_COLD_START_INCIDENT_ID = "INC-20260711-11C751" + +LegacyIncidentFetcher = Callable[..., Awaitable[list[dict[str, Any]]]] +Agent99Dispatcher = Callable[..., Awaitable[dict[str, Any]]] + + +async def _fetch_legacy_cold_start_failures( + *, + project_id: str, + limit: int, +) -> list[dict[str, Any]]: + """Read unresolved legacy Ansible failures that belong to Agent99. + + This includes the production incident that exposed the invalid Kubernetes + namespace route. The query is bounded and read-only; PG/Redis dispatch + idempotency remains the sole authority for whether transport may run. + """ + + async with get_db_context(project_id) as db: + await db.execute(text("SET LOCAL statement_timeout = '5000ms'")) + result = await db.execute( + text(""" + SELECT + incident.incident_id, + coalesce(incident.project_id, :project_id) AS project_id, + coalesce(incident.alertname, 'ColdStartGateBlocked') AS alertname, + coalesce(incident.severity::text, 'low') AS severity, + coalesce(incident.alert_category, 'general') AS alert_category, + coalesce(incident.notification_type, 'legacy_backfill') + AS notification_type, + approval_identity.id::text AS approval_id, + coalesce(approval_identity.fingerprint, '') + AS approval_fingerprint, + lower(approval_state.status::text) AS approval_status + FROM incidents incident + JOIN LATERAL ( + SELECT + candidate.id, + candidate.fingerprint + FROM approval_records candidate + WHERE candidate.incident_id = incident.incident_id + ORDER BY candidate.created_at ASC, candidate.id ASC + LIMIT 1 + ) approval_identity ON TRUE + JOIN LATERAL ( + SELECT candidate.status + FROM approval_records candidate + WHERE candidate.incident_id = incident.incident_id + ORDER BY candidate.updated_at DESC, candidate.created_at DESC + LIMIT 1 + ) approval_state ON TRUE + WHERE (incident.project_id = :project_id OR incident.project_id IS NULL) + AND incident.resolved_at IS NULL + AND upper(coalesce(incident.status::text, '')) + NOT IN ('RESOLVED', 'CLOSED') + AND ( + incident.incident_id = :exact_incident_id + OR lower(coalesce(incident.alertname, '')) ~ + 'cold[-_ ]?start|reboot[-_ ]?auto[-_ ]?recovery' + OR lower(CAST(incident.signals AS text)) ~ + 'cold[-_ ]?start|reboot[-_ ]?auto[-_ ]?recovery' + ) + AND ( + lower(approval_state.status::text) = 'execution_failed' + OR EXISTS ( + SELECT 1 + FROM automation_operation_log legacy + WHERE coalesce( + legacy.incident_id::text, + legacy.input ->> 'incident_id' + ) = incident.incident_id + AND legacy.input ->> 'executor' = 'ansible' + AND lower(coalesce(legacy.status, '')) + IN ('failed', 'error') + ) + ) + ORDER BY + (incident.incident_id = :exact_incident_id) DESC, + incident.updated_at DESC + LIMIT :limit + """), + { + "project_id": project_id, + "exact_incident_id": LEGACY_COLD_START_INCIDENT_ID, + "limit": max(1, min(int(limit), 20)), + }, + ) + return [dict(row) for row in result.mappings().all()] + + +def _legacy_source_fingerprint(row: dict[str, Any]) -> str: + """Use immutable incident identity; approval rows are metadata only.""" + + canonical = ":".join([ + "legacy-agent99-cold-start-v1", + str(row.get("project_id") or "awoooi"), + str(row.get("incident_id") or ""), + "agent99:host_recovery:Recover", + "cold-start-gate", + ]) + return f"legacy-cold-start:{hashlib.sha256(canonical.encode()).hexdigest()}" + + +async def backfill_legacy_agent99_dispatches_once( + *, + project_id: str = "awoooi", + limit: int = LEGACY_BACKFILL_LIMIT, + fetcher: LegacyIncidentFetcher | None = None, + dispatcher: Agent99Dispatcher | None = None, +) -> dict[str, int]: + """Route old generic-Ansible failures into the fenced Agent99 chain.""" + + fetch = fetcher or _fetch_legacy_cold_start_failures + dispatch = dispatcher or bridge_alertmanager_to_agent99 + rows = await fetch(project_id=project_id, limit=limit) + counters = { + "scanned": len(rows), + "dispatch_performed": 0, + "deduplicated": 0, + "retryable": 0, + "blocked": 0, + } + for row in rows: + incident_id = str(row.get("incident_id") or "").strip() + if not incident_id: + counters["blocked"] += 1 + continue + result = await dispatch( + alert_id=f"legacy-agent99-backfill-{incident_id}", + alertname=str(row.get("alertname") or "ColdStartGateBlocked"), + severity=str(row.get("severity") or "low"), + namespace="host_recovery", + target_resource="cold-start-gate", + message=( + "Legacy generic Ansible execution_failed rerouted to the " + "bounded Agent99 cold-start verifier chain." + ), + labels={ + "event_type": "legacy_cold_start_agent99_backfill", + "legacy_executor": "ansible", + "legacy_status": str(row.get("approval_status") or "execution_failed"), + "legacy_approval_fingerprint": str( + row.get("approval_fingerprint") or "" + )[:64], + }, + annotations={ + "summary": "Bounded Agent99 recovery backfill for legacy failure", + }, + fingerprint=_legacy_source_fingerprint(row), + alert_category=str(row.get("alert_category") or "general"), + notification_type=str( + row.get("notification_type") or "legacy_backfill" + ), + project_id=str(row.get("project_id") or project_id), + incident_id=incident_id, + approval_id=str(row.get("approval_id") or ""), + route_id="agent99:host_recovery:Recover", + work_item_id=( + f"agent99-dispatch:{project_id}:{incident_id}:legacy-cold-start" + ), + ) + if result.get("dispatchPerformed") is True: + counters["dispatch_performed"] += 1 + elif result.get("status") == "deduplicated": + counters["deduplicated"] += 1 + elif result.get("status") == "retryable": + counters["retryable"] += 1 + else: + counters["blocked"] += 1 + return counters + + +def _playbook_id(identity: Agent99DispatchIdentity) -> str: + digest = hashlib.sha256( + f"{identity.project_id}:{identity.route_id}".encode() + ).hexdigest()[:20] + return f"PB-A99-{digest}" + + +async def _ensure_playbook_trust( + identity: Agent99DispatchIdentity, + *, + mode: str, +) -> str | None: + playbook_id = _playbook_id(identity) + run_tag = f"agent99_run:{identity.run_id}" + now = now_taipei() + try: + async with get_db_context(identity.project_id) as db: + selected = await db.execute( + select(PlaybookRecord) + .where( + PlaybookRecord.playbook_id == playbook_id, + PlaybookRecord.project_id == identity.project_id, + ) + .with_for_update() + ) + playbook = selected.scalar_one_or_none() + if playbook is None: + playbook = PlaybookRecord( + playbook_id=playbook_id, + project_id=identity.project_id, + name=f"Agent99 {mode or 'Status'}: {identity.route_id}", + description=( + "Verified Agent99 controlled route with independent " + "outcome readback and same-run learning receipts." + ), + status="approved", + source="ai_extracted", + symptom_pattern={ + "alert_names": [], + "affected_services": ["cold-start-gate"], + "severity_range": ["P0", "P1", "P2"], + "label_patterns": {}, + "keywords": ["agent99", "cold-start", mode], + }, + repair_steps=[{ + "step_number": 1, + "action_type": "agent99_mode", + "command": mode, + "expected_result": "independent verifier passes", + "rollback_command": "Agent99 Status (no-write)", + "requires_approval": False, + "risk_level": "LOW", + }], + estimated_duration_minutes=10, + source_incident_ids=[identity.incident_id], + version=1, + ai_confidence=1.0, + success_count=1, + failure_count=0, + last_used_at=now, + trust_score=0.37, + approved_by="agent99_independent_verifier", + approved_at=now, + tags=["agent99", "controlled_dispatch", run_tag], + notes="No raw logs or secret values are persisted.", + requires_approval_level="auto", + stateful_targets=[], + requires_pre_backup=False, + review_required=False, + created_at=now, + updated_at=now, + ) + db.add(playbook) + else: + tags = list(playbook.tags or []) + incidents = list(playbook.source_incident_ids or []) + if run_tag not in tags: + tags.append(run_tag) + playbook.success_count = int(playbook.success_count or 0) + 1 + playbook.trust_score = min( + 1.0, + 0.9 * float(playbook.trust_score or 0.3) + 0.1, + ) + playbook.version = int(playbook.version or 0) + 1 + if identity.incident_id not in incidents: + incidents.append(identity.incident_id) + playbook.tags = tags + playbook.source_incident_ids = incidents + playbook.last_used_at = now + playbook.updated_at = now + await db.flush() + await db.refresh(playbook) + return f"playbooks:{playbook.playbook_id}:version:{playbook.version}" + except Exception as exc: + logger.warning( + "agent99_playbook_trust_writeback_failed", + run_id=str(identity.run_id), + error=str(exc), + ) + return None + + +async def _ensure_km_writeback( + identity: Agent99DispatchIdentity, + *, + mode: str, +) -> str | None: + knowledge = get_knowledge_service() + try: + entry = await knowledge.create_entry( + KnowledgeEntryCreate( + title=f"Agent99 verified recovery {identity.incident_id}", + content=( + f"Run {identity.run_id} on route {identity.route_id} " + f"completed Agent99 mode {mode or 'Status'} with independent " + "verifier success. Raw logs and secret values are not stored." + ), + entry_type=EntryType.INCIDENT_CASE, + category="AI自動化/Agent99受控修復", + tags=[ + "agent99", + "controlled_dispatch", + f"automation_run_id:{identity.run_id}", + ], + source=EntrySource.AI_EXTRACTED, + status=EntryStatus.REVIEW, + related_incident_id=identity.incident_id, + related_playbook_id=_playbook_id(identity), + related_approval_id=identity.approval_id or None, + path_type=f"agent99:{identity.run_id}", + created_by="agent99_controlled_dispatch_reconciler", + ), + project_id=identity.project_id, + schedule_embedding=False, + ) + embedded = await knowledge.ensure_entry_embedding( + entry.id, + entry.title, + entry.content, + project_id=identity.project_id, + ) + if not embedded: + return None + return f"knowledge_entries:{entry.id}:embedding_verified" + except Exception as exc: + logger.warning( + "agent99_km_writeback_failed", + run_id=str(identity.run_id), + error=str(exc), + ) + return None + + +async def _ensure_telegram_receipt( + identity: Agent99DispatchIdentity, + *, + mode: str, +) -> str | None: + try: + response = await get_telegram_gateway().send_controlled_apply_result_receipt( + automation_run_id=str(identity.run_id), + incident_id=identity.incident_id, + catalog_id=f"agent99:{identity.route_id}", + apply_op_id=str(identity.run_id), + playbook_path=f"agent99://{mode or 'Status'}", + verification_result="success", + returncode=0, + duration_ms=0, + verifier_written=True, + # This receipt is one prerequisite for learning writeback; it must + # not claim the still-pending terminal ledger step is complete. + learning_written=False, + project_id=identity.project_id, + ) + result = response.get("result") if isinstance(response, dict) else None + message_id = ( + str(result.get("message_id") or "") + if isinstance(result, dict) + else "" + ) + if ( + not isinstance(response, dict) + or response.get("_awooop_outbound_mirror_acknowledged") is not True + or not message_id + ): + return None + return f"telegram_outbound:{message_id}:durable_ack" + except Exception as exc: + logger.warning( + "agent99_telegram_writeback_failed", + run_id=str(identity.run_id), + error=str(exc), + ) + return None + + +async def _ensure_dr_scorecard( + identity: Agent99DispatchIdentity, + *, + evidence_refs: dict[str, str], +) -> str | None: + if "backup_health" not in identity.route_id: + return "not_required" + try: + entry = await get_knowledge_service().create_entry( + KnowledgeEntryCreate( + title=f"DR scorecard {identity.incident_id}", + content=( + "Agent99 BackupCheck verified freshness, offsite, escrow, " + "restore-drill, and source-resolution receipts. " + + "; ".join( + f"{key}={value}" + for key, value in sorted(evidence_refs.items()) + ) + ), + entry_type=EntryType.BEST_PRACTICE, + category="資料保護/DR Scorecard", + tags=[ + "backup_restore", + "dr_scorecard", + f"automation_run_id:{identity.run_id}", + ], + source=EntrySource.AI_EXTRACTED, + status=EntryStatus.REVIEW, + related_incident_id=identity.incident_id, + related_playbook_id=_playbook_id(identity), + related_approval_id=identity.approval_id or None, + path_type=f"agent99-dr:{identity.run_id}", + created_by="agent99_controlled_dispatch_reconciler", + ), + project_id=identity.project_id, + ) + return f"knowledge_entries:{entry.id}:dr_scorecard" + except Exception as exc: + logger.warning( + "agent99_dr_scorecard_writeback_failed", + run_id=str(identity.run_id), + error=str(exc), + ) + return None + + +async def _finalize_learning( + identity: Agent99DispatchIdentity, + *, + mode: str, + evidence_refs: dict[str, str], +) -> dict[str, Any]: + ledger = get_agent99_dispatch_ledger() + checkpoint = await ledger.record_learning_asset_acks( + identity=identity, + receipt_refs={}, + ) + if checkpoint.get("receipt_persisted") is not True: + return { + "status": "learning_asset_checkpoint_unavailable", + "closed": False, + } + if checkpoint.get("runtime_closure_verified") is True: + return checkpoint + receipt_refs = dict(checkpoint.get("learning_receipt_refs") or {}) + + async def ensure_checkpointed( + key: str, + writer: Callable[[], Awaitable[str | None]], + ) -> bool: + nonlocal receipt_refs + if key in receipt_refs: + return True + receipt = await writer() + if not receipt: + return False + persisted = await ledger.record_learning_asset_acks( + identity=identity, + receipt_refs={key: receipt}, + ) + if persisted.get("receipt_persisted") is not True: + return False + receipt_refs = dict(persisted.get("learning_receipt_refs") or {}) + return key in receipt_refs + + assets = [ + ( + "playbook_trust_writeback_ack_id", + lambda: _ensure_playbook_trust(identity, mode=mode), + ), + ( + "km_writeback_ack_id", + lambda: _ensure_km_writeback(identity, mode=mode), + ), + ( + "telegram_lifecycle_receipt_id", + lambda: _ensure_telegram_receipt(identity, mode=mode), + ), + ] + if "backup_health" in identity.route_id: + assets.append(( + "dr_scorecard_writeback_ack_id", + lambda: _ensure_dr_scorecard( + identity, + evidence_refs=evidence_refs, + ), + )) + for key, writer in assets: + if not await ensure_checkpointed(key, writer): + return { + "status": "learning_assets_pending", + "missing_asset": key, + "closed": False, + } + return await record_agent99_learning_writeback( + identity=identity, + receipt_refs=receipt_refs, + ) + + +async def reconcile_agent99_controlled_dispatches_once( + *, + project_id: str = "awoooi", + limit: int = RECONCILE_LIMIT, +) -> dict[str, int]: + ledger = get_agent99_dispatch_ledger() + items = await ledger.list_reconcilable(project_id=project_id, limit=limit) + counters = {"scanned": len(items), "verifier_written": 0, "closed": 0} + for item in items: + identity = item["identity"] + receipt = item.get("receipt") if isinstance(item.get("receipt"), dict) else {} + mode = identity.route_id.rsplit(":", 1)[-1] + verifier_receipt = ( + receipt.get("verifier") + if isinstance(receipt.get("verifier"), dict) + else {} + ) + evidence_refs = { + **sanitize_agent99_public_receipt_refs( + verifier_receipt.get("evidence_refs") + if isinstance(verifier_receipt.get("evidence_refs"), dict) + else {} + ) + } + if receipt.get("post_verifier_passed") is not True: + outcome = await asyncio.to_thread( + read_agent99_sre_outcome, + str(identity.run_id), + ) + if not isinstance(outcome, dict): + continue + mode = str(outcome.get("mode") or mode) + outcome_contract = ( + outcome.get("outcome") + if isinstance(outcome.get("outcome"), dict) + else {} + ) + verified_at = str(outcome_contract.get("verifiedAt") or "unknown") + generic_refs = { + "agent99_outcome_receipt_id": ( + f"agent99-relay:outcome:{identity.run_id}" + ), + "post_verifier_evidence_ref": ( + f"agent99:{mode}:verified:{verified_at}" + ), + "source_event_evidence_ref": ( + f"incident:{identity.incident_id}:source-resolved:{verified_at}" + ), + } + evidence_refs = extract_agent99_outcome_evidence_refs( + outcome, + generic_refs, + ) + verifier = await ledger.record_verifier( + identity=identity, + outcome_receipt=outcome, + evidence_refs=evidence_refs, + ) + if verifier.get("receipt_persisted") is not True: + continue + counters["verifier_written"] += 1 + receipt = verifier + if receipt.get("post_verifier_passed") is True: + closure = await _finalize_learning( + identity, + mode=mode, + evidence_refs=evidence_refs, + ) + if closure.get("runtime_closure_verified") is True: + counters["closed"] += 1 + return counters + + +async def run_agent99_controlled_dispatch_reconciler_loop() -> None: + while True: + try: + backfill = await backfill_legacy_agent99_dispatches_once() + if backfill["scanned"]: + logger.info("agent99_legacy_failure_backfill_tick", **backfill) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "agent99_legacy_failure_backfill_tick_failed", + error=str(exc), + ) + try: + await reconcile_agent99_controlled_dispatches_once() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "agent99_controlled_dispatch_reconciler_tick_failed", + error=str(exc), + ) + await asyncio.sleep(INTERVAL_SECONDS) diff --git a/apps/api/src/jobs/backup_restore_legacy_backfill_job.py b/apps/api/src/jobs/backup_restore_legacy_backfill_job.py new file mode 100644 index 000000000..df09f2067 --- /dev/null +++ b/apps/api/src/jobs/backup_restore_legacy_backfill_job.py @@ -0,0 +1,2004 @@ +"""Bounded durable replay for legacy backup/restore Telegram cards. + +The historical card is only a source receipt. This worker reserves each row +in the RLS-protected AwoooP run/idempotency tables, binds a deterministic +Incident/work item from ``message_id`` plus the persisted source fingerprint, +and dispatches the existing read-only ``BackupCheck`` producer. Telegram is +never a dispatch owner in this path. + +Safety boundary: this worker cannot run backup/restore, delete or prune data, +change retention, write escrow markers, or read secrets. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import urllib.parse +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any +from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 + +import structlog +from sqlalchemy import text + +from src.core.config import settings +from src.core.context import clear_project_context, set_project_context +from src.db.base import get_db_context +from src.services.backup_restore_alertmanager_ingress import ( + BACKUP_RESTORE_AGENT99_ROUTE_ID, + BACKUP_RESTORE_PROHIBITED_ACTIONS, + build_backup_restore_ingress_owner, + process_backup_restore_alertmanager_signal, +) + +logger = structlog.get_logger(__name__) + +BACKFILL_SCHEMA_VERSION = "backup_restore_legacy_backfill_v1" +BACKFILL_CURSOR_SCHEMA_VERSION = "backup_restore_legacy_backfill_cursor_v1" +BACKFILL_RESULT_SCHEMA_VERSION = "backup_restore_legacy_backfill_result_v1" +BACKFILL_CURSOR_AGENT_ID = "backup_restore_legacy_backfill_cursor" +BACKFILL_ITEM_AGENT_ID = "backup_restore_legacy_backfill" +BACKFILL_IDEMPOTENCY_CHANNEL = "backup_restore_backfill" +BACKUP_RESTORE_EVENT_TYPE = "backup_restore_escrow_signal" +BACKUP_RESTORE_LANE = "backup_restore_escrow_triage" +PROJECTION_SCHEMA_VERSION = "telegram_agent99_dispatch_receipt_projection_v1" +CLAIM_LEASE_SECONDS = 120 +MAX_BATCH_LIMIT = 20 +MAX_SCOPE_ROWS = 100 +REPO_KNOWN_LAN_RELAY_HOST = "192.168.0.99" +REPO_KNOWN_LAN_RELAY_PORT = 8787 +REPO_KNOWN_LAN_RELAY_PATH = "/agent99/sre-alert" + +Processor = Callable[..., Awaitable[dict[str, Any]]] + + +def build_backup_restore_backfill_relay_preflight( + *, + relay_url: str | None = None, + relay_token: str | None = None, +) -> dict[str, Any]: + """Classify the public-safe relay trust mode without exposing credentials.""" + + target_url = str( + settings.AGENT99_SRE_ALERT_RELAY_URL if relay_url is None else relay_url + ).strip() + token_present = bool( + str( + settings.AGENT99_SRE_ALERT_RELAY_TOKEN + if relay_token is None + else relay_token + ).strip() + ) + try: + parsed = urllib.parse.urlsplit(target_url) + port = parsed.port + except ValueError: + parsed = urllib.parse.SplitResult("", "", "", "", "") + port = None + structurally_valid = bool( + parsed.scheme in {"http", "https"} + and parsed.hostname + and parsed.username is None + and parsed.password is None + and not parsed.query + and not parsed.fragment + ) + lan_allowlisted = bool( + structurally_valid + and parsed.scheme == "http" + and parsed.hostname == REPO_KNOWN_LAN_RELAY_HOST + and port == REPO_KNOWN_LAN_RELAY_PORT + and parsed.path.rstrip("/") == REPO_KNOWN_LAN_RELAY_PATH + ) + https_token_endpoint = bool( + structurally_valid and parsed.scheme == "https" and token_present + ) + auth_mode = ( + "token" + if https_token_endpoint + else "lan_allowlist" + if lan_allowlisted + else "blocked" + ) + ready = auth_mode in {"token", "lan_allowlist"} + return { + "schema_version": "backup_restore_relay_preflight_v1", + "status": "ready" if ready else "blocked_fail_closed", + "ready": ready, + "auth_mode": auth_mode, + "endpoint_class": ( + "repo_known_private_lan" + if lan_allowlisted + else "configured_token_endpoint" + if https_token_endpoint + else "untrusted_or_missing" + ), + "http_method": "POST", + "token_present": token_present, + "stores_secret": False, + } + + +_CURSOR_INSERT_SQL = text( + """ + INSERT INTO awooop_run_state ( + run_id, project_id, agent_id, state, + attempt_count, max_attempts, trigger_type, trigger_ref, + is_shadow, input_sha256, step_count, error_detail, timeout_at + ) VALUES ( + :run_id, :project_id, :agent_id, 'waiting_tool', + 0, 1, 'backfill_cursor', 'backup_restore_legacy_cards', + FALSE, :input_sha256, 0, :error_detail, NOW() + INTERVAL '30 days' + ) + ON CONFLICT (run_id) DO NOTHING +""" +) + +_CURSOR_SELECT_SQL = text( + """ + SELECT run_id, state, error_detail + FROM awooop_run_state + WHERE project_id = :project_id + AND run_id = :run_id + AND agent_id = :agent_id + FOR UPDATE +""" +) + +_SOURCE_SNAPSHOT_SQL = text( + """ + SELECT + m.message_id, + m.queued_at, + COUNT(*) OVER () AS source_total_in_scope, + FIRST_VALUE(m.message_id) OVER ( + ORDER BY m.queued_at DESC, m.message_id DESC + ) AS source_upper_message_id, + FIRST_VALUE(m.queued_at) OVER ( + ORDER BY m.queued_at DESC, m.message_id DESC + ) AS source_upper_queued_at, + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{source_refs,fingerprints,0}' AS source_fingerprint, + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,event_type}' AS event_type, + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,lane}' AS lane, + COALESCE( + NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,target}', + '' + ), + 'backup_restore' + ) AS target + FROM awooop_outbound_message m + WHERE m.project_id = :project_id + AND m.channel_type = 'telegram' + AND COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,event_type}' = :event_type + AND COALESCE( + NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,target}', + '' + ), + 'backup_restore' + ) = 'backup_restore' + AND ( + CAST(:source_upper_queued_at AS TIMESTAMPTZ) IS NULL + OR ( + m.queued_at, + m.message_id + ) <= ( + CAST(:source_upper_queued_at AS TIMESTAMPTZ), + CAST(:source_upper_message_id AS UUID) + ) + ) + AND ( + COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,schema_version}', + '' + ) <> :projection_schema + OR COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,receipt_persisted}', + '' + ) <> 'true' + OR COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,accepted}', + '' + ) <> 'true' + OR COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,inbox_triggered}', + '' + ) <> 'true' + OR NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,run_id}', + '' + ) IS NULL + OR NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,trace_id}', + '' + ) IS NULL + OR NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,work_item_id}', + '' + ) IS NULL + OR COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,kind}', + '' + ) <> 'backup_health' + OR COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,suggested_mode}', + '' + ) <> 'BackupCheck' + ) + ORDER BY m.queued_at ASC, m.message_id ASC + LIMIT :limit +""" +) + +_ITEM_RUN_INSERT_SQL = text( + """ + INSERT INTO awooop_run_state ( + run_id, project_id, agent_id, state, + attempt_count, max_attempts, trigger_type, trigger_ref, + is_shadow, input_sha256, step_count, error_detail, timeout_at + ) VALUES ( + :run_id, :project_id, :agent_id, 'waiting_tool', + 0, 5, 'backfill_replay', :trigger_ref, + FALSE, :input_sha256, 1, :error_detail, NOW() + INTERVAL '7 days' + ) + ON CONFLICT (run_id) DO NOTHING +""" +) + +_ITEM_IDEMPOTENCY_INSERT_SQL = text( + """ + INSERT INTO awooop_run_idempotency ( + project_id, channel_type, provider_event_id, run_id + ) VALUES ( + :project_id, :channel_type, :provider_event_id, :run_id + ) + ON CONFLICT (project_id, channel_type, provider_event_id) DO NOTHING + RETURNING run_id +""" +) + +_CURSOR_UPDATE_SQL = text( + """ + UPDATE awooop_run_state + SET state = :state, + heartbeat_at = NOW(), + error_detail = :error_detail, + completed_at = CASE WHEN :state = 'completed' THEN NOW() ELSE NULL END + WHERE project_id = :project_id + AND run_id = :run_id + AND agent_id = :agent_id +""" +) + +_CLAIM_SQL = text( + """ + WITH claimable AS ( + SELECT run_id + FROM awooop_run_state + WHERE project_id = :project_id + AND agent_id = :agent_id + AND state = 'waiting_tool' + AND (lease_until IS NULL OR lease_until < NOW()) + AND attempt_count < max_attempts + ORDER BY created_at ASC, run_id ASC + FOR UPDATE SKIP LOCKED + LIMIT :limit + ) + UPDATE awooop_run_state item + SET state = 'waiting_tool', + worker_id = :claim_token, + lease_until = NOW() + (:lease_seconds * INTERVAL '1 second'), + heartbeat_at = NOW(), + started_at = COALESCE(started_at, NOW()), + attempt_count = attempt_count + 1, + error_code = NULL + FROM claimable + WHERE item.run_id = claimable.run_id + AND item.project_id = :project_id + AND item.agent_id = :agent_id + RETURNING + item.run_id, + item.attempt_count, + item.max_attempts, + item.error_detail +""" +) + +_EXHAUST_EXPIRED_CLAIMS_SQL = text( + """ + UPDATE awooop_run_state + SET state = 'failed', + worker_id = NULL, + lease_until = NULL, + heartbeat_at = NOW(), + error_code = 'E-BACKFILL-LEASE-EXHAUSTED', + completed_at = NOW() + WHERE project_id = :project_id + AND agent_id = :agent_id + AND state = 'waiting_tool' + AND worker_id IS NOT NULL + AND lease_until < NOW() + AND attempt_count >= max_attempts + AND error_code IS NULL + RETURNING run_id +""" +) + +_SOURCE_LOAD_SQL = text( + """ + SELECT + m.message_id, + m.queued_at, + COALESCE(m.source_envelope, '{}'::jsonb) AS source_envelope + FROM awooop_outbound_message m + WHERE m.project_id = :project_id + AND m.message_id = CAST(:message_id AS UUID) + AND m.channel_type = 'telegram' + AND COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,event_type}' = :event_type + LIMIT 1 +""" +) + +_SOURCE_PROJECTION_UPDATE_SQL = text( + """ + WITH active_claim AS ( + SELECT item.run_id + FROM awooop_run_state item + WHERE item.project_id = :project_id + AND item.run_id = :claim_run_id + AND item.agent_id = :agent_id + AND item.state = 'waiting_tool' + AND item.worker_id = :claim_token + AND item.lease_until > clock_timestamp() + FOR UPDATE + ) + UPDATE awooop_outbound_message m + SET source_envelope = + JSONB_SET( + JSONB_SET( + COALESCE(m.source_envelope, '{}'::jsonb), + '{agent99_dispatch_receipt}', + CAST(:projection AS jsonb), + TRUE + ), + '{backup_restore_backfill}', + CAST(:backfill_receipt AS jsonb), + TRUE + ) + FROM active_claim + WHERE m.project_id = :project_id + AND m.message_id = CAST(:message_id AS UUID) + AND m.queued_at = CAST(:source_queued_at AS TIMESTAMPTZ) + AND COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{source_refs,fingerprints,0}' = :source_fingerprint + AND COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,event_type}' = :event_type + AND COALESCE( + NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,lane}', + '' + ), + :default_lane + ) = :source_lane + AND COALESCE( + NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,target}', + '' + ), + 'backup_restore' + ) = :source_target + AND ( + NOT ( + COALESCE(m.source_envelope, '{}'::jsonb) + ? 'agent99_dispatch_receipt' + ) + OR COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,schema_version}' = + 'telegram_agent99_dispatch_receipt_v1' + OR ( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,schema_version}' = + :projection_schema + AND ( + COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,receipt_persisted}', + '' + ) <> 'true' + OR COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,accepted}', + '' + ) <> 'true' + OR COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,inbox_triggered}', + '' + ) <> 'true' + OR NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,run_id}', + '' + ) IS NULL + OR NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,trace_id}', + '' + ) IS NULL + OR NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,work_item_id}', + '' + ) IS NULL + OR COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,kind}', + '' + ) <> 'backup_health' + OR COALESCE( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,suggested_mode}', + '' + ) <> 'BackupCheck' + ) + ) + OR COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,run_id}' = :run_id + OR ( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{backup_restore_backfill,occurrence_id}' = :occurrence_id + AND COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{backup_restore_backfill,source_message_id}' = + CAST(:message_id AS TEXT) + AND ( + NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,run_id}', + '' + ) IS NULL + OR COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{agent99_dispatch_receipt,run_id}' = :run_id + ) + ) + ) + RETURNING m.message_id +""" +) + +_PROJECTION_CONFLICT_READBACK_SQL = text( + """ + SELECT + EXISTS ( + SELECT 1 + FROM awooop_run_state item + WHERE item.project_id = :project_id + AND item.run_id = :claim_run_id + AND item.agent_id = :agent_id + AND item.state = 'waiting_tool' + AND item.worker_id = :claim_token + AND item.lease_until > clock_timestamp() + ) AS claim_active, + EXISTS ( + SELECT 1 + FROM awooop_outbound_message m + WHERE m.project_id = :project_id + AND m.message_id = CAST(:message_id AS UUID) + ) AS source_exists, + EXISTS ( + SELECT 1 + FROM awooop_outbound_message m + WHERE m.project_id = :project_id + AND m.message_id = CAST(:message_id AS UUID) + AND m.queued_at = CAST(:source_queued_at AS TIMESTAMPTZ) + AND COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{source_refs,fingerprints,0}' = :source_fingerprint + AND COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,event_type}' = :event_type + AND COALESCE( + NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,lane}', + '' + ), + :default_lane + ) = :source_lane + AND COALESCE( + NULLIF( + COALESCE(m.source_envelope, '{}'::jsonb) #>> + '{ai_automation_alert_card,target}', + '' + ), + 'backup_restore' + ) = :source_target + ) AS source_identity_matches +""" +) + +_ITEM_FINISH_SQL = text( + """ + UPDATE awooop_run_state + SET state = :state, + worker_id = NULL, + lease_until = NULL, + heartbeat_at = NOW(), + output_sha256 = :output_sha256, + error_code = :error_code, + error_detail = :error_detail, + completed_at = CASE + WHEN :state IN ('completed', 'failed') THEN NOW() + ELSE NULL + END + WHERE project_id = :project_id + AND run_id = :run_id + AND agent_id = :agent_id + AND state = 'waiting_tool' + AND worker_id = :claim_token + RETURNING run_id +""" +) + +_ITEM_STATE_COUNTS_SQL = text( + """ + SELECT + COUNT(*) AS item_total, + COUNT(*) FILTER ( + WHERE state NOT IN ('completed', 'failed', 'cancelled', 'timeout') + ) AS remaining_total, + COUNT(*) FILTER (WHERE state = 'completed') AS completed_total, + COUNT(*) FILTER ( + WHERE state IN ('failed', 'cancelled', 'timeout') + ) AS failed_total + FROM awooop_run_state + WHERE project_id = :project_id + AND agent_id = :agent_id +""" +) + + +@dataclass(frozen=True) +class LegacyBackupClaim: + run_id: UUID + claim_token: str + attempt_count: int + max_attempts: int + item: dict[str, Any] + + +@dataclass(frozen=True) +class LegacyBackupCard: + message_id: str + queued_at: str + source_fingerprint: str + event_type: str + lane: str + target: str + source_envelope: dict[str, Any] + + +def _stable_json(value: Mapping[str, Any]) -> str: + return json.dumps( + dict(value), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + +def _sha256(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _json_object(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return dict(value) + if not value: + return {} + try: + parsed = json.loads(str(value)) + except (TypeError, ValueError, json.JSONDecodeError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _row_mapping(row: Any) -> dict[str, Any]: + if isinstance(row, Mapping): + return dict(row) + mapping = getattr(row, "_mapping", None) + return dict(mapping) if isinstance(mapping, Mapping) else {} + + +def _timestamp(value: Any) -> str: + if isinstance(value, datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.isoformat() + return str(value or "").strip() + + +def _database_timestamp(value: Any) -> datetime | None: + if value is None or str(value).strip() == "": + return None + if isinstance(value, datetime): + parsed = value + else: + raw = str(value).strip() + if raw.endswith("Z"): + raw = f"{raw[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(raw) + except ValueError as exc: + raise ValueError("backup_backfill_source_timestamp_invalid") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed + + +def _source_refs(envelope: Mapping[str, Any]) -> dict[str, Any]: + refs = envelope.get("source_refs") + return dict(refs) if isinstance(refs, Mapping) else {} + + +def _alert_card(envelope: Mapping[str, Any]) -> dict[str, Any]: + card = envelope.get("ai_automation_alert_card") + return dict(card) if isinstance(card, Mapping) else {} + + +def _first_string(value: Any) -> str: + if isinstance(value, list): + for item in value: + text_value = str(item or "").strip() + if text_value: + return text_value + return "" + + +def _cursor_run_id(project_id: str) -> UUID: + return uuid5( + NAMESPACE_URL, + f"{BACKFILL_CURSOR_SCHEMA_VERSION}:{project_id}", + ) + + +def build_legacy_backup_occurrence( + *, + project_id: str, + message_id: str, + source_fingerprint: str, + queued_at: str, +) -> dict[str, Any]: + """Build the stable source occurrence, Incident, and work-item identity.""" + + project = str(project_id or "").strip() + source_message = str(message_id or "").strip() + fingerprint = str(source_fingerprint or "").strip() + queued = str(queued_at or "").strip() + if not project: + raise ValueError("backup_backfill_project_id_required") + if not source_message: + raise ValueError("backup_backfill_message_id_required") + if not fingerprint: + raise ValueError("backup_backfill_source_fingerprint_required") + fingerprint_hash = _sha256(fingerprint)[:16] + occurrence_id = f"legacy-backup:{source_message}:{fingerprint_hash}" + dispatch_source_fingerprint = "legacy-backup-occurrence:" + _sha256( + f"{project}\x1f{source_message}\x1f{fingerprint}" + ) + owner = build_backup_restore_ingress_owner( + project_id=project, + source_fingerprint=dispatch_source_fingerprint, + source_event_fingerprint=occurrence_id, + source_started_at=queued, + ) + item_run_id = uuid5( + NAMESPACE_URL, + f"{BACKFILL_SCHEMA_VERSION}:{project}:{occurrence_id}", + ) + return { + "schema_version": "backup_restore_legacy_occurrence_v1", + "project_id": project, + "source_message_id": source_message, + "source_fingerprint": fingerprint, + "source_fingerprint_hash": fingerprint_hash, + "dispatch_source_fingerprint": dispatch_source_fingerprint, + "source_queued_at": queued, + "occurrence_id": occurrence_id, + "backfill_run_id": str(item_run_id), + "incident_id": owner["incident_id"], + "approval_id": owner["approval_id"], + "work_item_id": owner["work_item_id"], + "route_id": BACKUP_RESTORE_AGENT99_ROUTE_ID, + "controlled_apply": False, + "telegram_dispatch_performed": False, + } + + +def _initial_cursor(project_id: str) -> dict[str, Any]: + return { + "schema_version": BACKFILL_CURSOR_SCHEMA_VERSION, + "project_id": project_id, + "cursor": {"queued_at": None, "message_id": None}, + "scanned_total": 0, + "reserved_total": 0, + "deduplicated_total": 0, + "completed_total": 0, + "failed_total": 0, + "retry_total": 0, + "source_total_at_start": None, + "source_upper_bound": None, + "source_snapshot": None, + "source_snapshot_sha256": None, + "source_snapshot_truncated": False, + "cohort_index": 0, + "cohort_source_total": 0, + "cohort_scanned_total": 0, + "cohort_exhausted": False, + "cohort_counted": False, + "completed_cohort_count": 0, + "source_exhausted": False, + "source_drift_detected": False, + "scope_cap_reached": False, + "runtime_execution_authorized": False, + "telegram_dispatch_performed": False, + "prohibited_actions": list(BACKUP_RESTORE_PROHIBITED_ACTIONS), + } + + +def _cursor_terminal_state( + detail: Mapping[str, Any], + *, + remaining: int, +) -> tuple[str, str]: + if detail.get("source_drift_detected") is True: + return "blocked_source_drift_fail_closed", "failed" + if detail.get("scope_cap_reached") is True: + return "blocked_scope_cap", "waiting_tool" + if ( + detail.get("cohort_exhausted") is True + and remaining == 0 + and int(detail.get("failed_total") or 0) > 0 + ): + return "blocked_failed_items_fail_closed", "failed" + if ( + detail.get("source_exhausted") is True + and remaining == 0 + and int(detail.get("item_total") or 0) + != int(detail.get("source_total_at_start") or 0) + ): + return "blocked_accounting_drift_fail_closed", "failed" + if ( + detail.get("source_exhausted") is True + and remaining == 0 + and int(detail.get("failed_total") or 0) > 0 + ): + return "blocked_failed_items_fail_closed", "failed" + if detail.get("source_exhausted") is True and remaining == 0: + return "completed", "completed" + return "in_progress", "waiting_tool" + + +def _item_payload_from_row( + *, + project_id: str, + row: Mapping[str, Any], +) -> dict[str, Any]: + message_id = str(row.get("message_id") or "").strip() + source_fingerprint = str(row.get("source_fingerprint") or "").strip() + queued_at = _timestamp(row.get("queued_at")) + if source_fingerprint: + occurrence = build_legacy_backup_occurrence( + project_id=project_id, + message_id=message_id, + source_fingerprint=source_fingerprint, + queued_at=queued_at, + ) + else: + missing_hash = _sha256(f"missing:{project_id}:{message_id}")[:16] + occurrence = { + "schema_version": "backup_restore_legacy_occurrence_v1", + "project_id": project_id, + "source_message_id": message_id, + "source_fingerprint": "", + "source_fingerprint_hash": "", + "dispatch_source_fingerprint": "", + "source_queued_at": queued_at, + "occurrence_id": f"legacy-backup-blocked:{message_id}:{missing_hash}", + "backfill_run_id": str( + uuid5( + NAMESPACE_URL, + f"{BACKFILL_SCHEMA_VERSION}:{project_id}:{message_id}:missing", + ) + ), + "incident_id": None, + "approval_id": None, + "work_item_id": None, + "route_id": BACKUP_RESTORE_AGENT99_ROUTE_ID, + "controlled_apply": False, + "telegram_dispatch_performed": False, + } + return { + "schema_version": "backup_restore_legacy_backfill_item_v1", + **occurrence, + "event_type": str(row.get("event_type") or ""), + "lane": str(row.get("lane") or BACKUP_RESTORE_LANE), + "target": str(row.get("target") or "backup_restore"), + "identity_ready": bool(source_fingerprint), + "runtime_execution_authorized": False, + "production_write_performed": False, + "telegram_dispatch_performed": False, + "prohibited_actions": list(BACKUP_RESTORE_PROHIBITED_ACTIONS), + } + + +async def _reserve_legacy_backup_page( + *, + project_id: str, + scan_limit: int, + max_rows: int, +) -> dict[str, Any]: + """Reserve one source page before advancing the durable cursor.""" + + bounded_limit = max(1, min(int(scan_limit), MAX_BATCH_LIMIT)) + bounded_max = max(1, min(int(max_rows), MAX_SCOPE_ROWS)) + cursor_run_id = _cursor_run_id(project_id) + initial = _initial_cursor(project_id) + async with get_db_context(project_id) as db: + await db.execute( + _CURSOR_INSERT_SQL, + { + "run_id": cursor_run_id, + "project_id": project_id, + "agent_id": BACKFILL_CURSOR_AGENT_ID, + "input_sha256": _sha256(f"{BACKFILL_SCHEMA_VERSION}:{project_id}"), + "error_detail": _stable_json(initial), + }, + ) + selected = await db.execute( + _CURSOR_SELECT_SQL, + { + "project_id": project_id, + "run_id": cursor_run_id, + "agent_id": BACKFILL_CURSOR_AGENT_ID, + }, + ) + selected_row = selected.mappings().one_or_none() + if selected_row is None: + raise RuntimeError("backup_backfill_cursor_row_missing") + detail = _json_object(selected_row.get("error_detail")) + if detail.get("schema_version") != BACKFILL_CURSOR_SCHEMA_VERSION: + raise RuntimeError("backup_backfill_cursor_schema_invalid") + if ( + detail.get("scope_cap_reached") is True + and int(detail.get("scanned_total") or 0) == 0 + and int(detail.get("reserved_total") or 0) == 0 + ): + # The pre-cohort implementation captured max+1 rows and blocked + # before reserving any work. It is safe to replace only that + # zero-progress cursor with the bounded cohort shape. + detail = _initial_cursor(project_id) + if detail.get("source_snapshot") is None: + upper_bound = detail.get("source_upper_bound") + upper = dict(upper_bound) if isinstance(upper_bound, Mapping) else {} + snapshot_result = await db.execute( + _SOURCE_SNAPSHOT_SQL, + { + "project_id": project_id, + "event_type": BACKUP_RESTORE_EVENT_TYPE, + "projection_schema": PROJECTION_SCHEMA_VERSION, + "source_upper_queued_at": _database_timestamp( + upper.get("queued_at") + ), + "source_upper_message_id": upper.get("message_id"), + "limit": bounded_max, + }, + ) + raw_snapshot = [ + _row_mapping(row) for row in snapshot_result.mappings().all() + ] + snapshot = [ + { + "message_id": str(mapping.get("message_id") or ""), + "queued_at": _timestamp(mapping.get("queued_at")), + "source_fingerprint": str(mapping.get("source_fingerprint") or ""), + "event_type": str(mapping.get("event_type") or ""), + "lane": str(mapping.get("lane") or BACKUP_RESTORE_LANE), + "target": str(mapping.get("target") or "backup_restore"), + } + for mapping in raw_snapshot + ] + remaining_in_scope = ( + int(raw_snapshot[0].get("source_total_in_scope") or len(snapshot)) + if raw_snapshot + else 0 + ) + scanned_before_cohort = int(detail.get("scanned_total") or 0) + if detail.get("source_total_at_start") is None: + detail["source_total_at_start"] = remaining_in_scope + if raw_snapshot: + upper_queued_at = _timestamp( + raw_snapshot[0].get("source_upper_queued_at") + ) + upper_message_id = str( + raw_snapshot[0].get("source_upper_message_id") or "" + ).strip() + if ( + not upper_queued_at or not upper_message_id + ) and remaining_in_scope == len(snapshot): + upper_queued_at = snapshot[-1]["queued_at"] + upper_message_id = snapshot[-1]["message_id"] + if not upper_queued_at or not upper_message_id: + detail["source_drift_detected"] = True + else: + detail["source_upper_bound"] = { + "queued_at": upper_queued_at, + "message_id": upper_message_id, + } + expected_remaining = max( + 0, + int(detail.get("source_total_at_start") or 0) - scanned_before_cohort, + ) + if remaining_in_scope != expected_remaining: + detail["source_drift_detected"] = True + detail["source_snapshot"] = snapshot + detail["source_snapshot_sha256"] = _sha256(_stable_json({"rows": snapshot})) + detail["source_snapshot_truncated"] = remaining_in_scope > len(snapshot) + detail["cohort_index"] = int(detail.get("cohort_index") or 0) + 1 + detail["cohort_source_total"] = len(snapshot) + detail["cohort_scanned_total"] = 0 + detail["cohort_exhausted"] = not snapshot + detail["cohort_counted"] = False + detail["source_exhausted"] = bool( + not snapshot + and scanned_before_cohort + == int(detail.get("source_total_at_start") or 0) + ) + detail["scope_cap_reached"] = False + snapshot_value = detail.get("source_snapshot") + if not isinstance(snapshot_value, list) or any( + not isinstance(row, Mapping) for row in snapshot_value + ): + raise RuntimeError("backup_backfill_source_snapshot_invalid") + snapshot_rows = [dict(row) for row in snapshot_value] + snapshot_message_ids = [ + str(row.get("message_id") or "").strip() for row in snapshot_rows + ] + if ( + any(not message_id for message_id in snapshot_message_ids) + or len(snapshot_message_ids) != len(set(snapshot_message_ids)) + or any( + str(row.get("event_type") or "") != BACKUP_RESTORE_EVENT_TYPE + for row in snapshot_rows + ) + ): + raise RuntimeError("backup_backfill_source_snapshot_identity_invalid") + snapshot_sha256 = _sha256(_stable_json({"rows": snapshot_rows})) + if snapshot_sha256 != str(detail.get("source_snapshot_sha256") or ""): + raise RuntimeError("backup_backfill_source_snapshot_hash_mismatch") + if int(detail.get("cohort_source_total") or 0) != len(snapshot_rows): + raise RuntimeError("backup_backfill_source_cohort_count_mismatch") + source_total = int(detail.get("source_total_at_start") or 0) + scanned_total = int(detail.get("scanned_total") or 0) + cohort_scanned_total = int(detail.get("cohort_scanned_total") or 0) + cohort_source_total = int(detail.get("cohort_source_total") or 0) + if detail.get("source_drift_detected") is True: + await db.execute( + _CURSOR_UPDATE_SQL, + { + "state": "waiting_tool", + "error_detail": _stable_json(detail), + "project_id": project_id, + "run_id": cursor_run_id, + "agent_id": BACKFILL_CURSOR_AGENT_ID, + }, + ) + return { + "scanned": 0, + "reserved": 0, + "deduplicated": 0, + "source_exhausted": bool(detail.get("source_exhausted")), + "source_drift_detected": True, + "scope_cap_reached": False, + "source_total_at_start": source_total, + "cohort_index": int(detail.get("cohort_index") or 0), + "cohort_source_total": cohort_source_total, + "cursor": dict(detail.get("cursor") or {}), + } + + if ( + scanned_total > source_total + or cohort_scanned_total > cohort_source_total + or len(snapshot_rows) > bounded_max + ): + detail["source_drift_detected"] = True + raise RuntimeError("backup_backfill_cursor_beyond_source_snapshot") + page_end = min(cohort_scanned_total + bounded_limit, cohort_source_total) + rows = snapshot_rows[cohort_scanned_total:page_end] + reserved = 0 + deduplicated = 0 + for row in rows: + item = _item_payload_from_row(project_id=project_id, row=row) + item_run_id = UUID(str(item["backfill_run_id"])) + await db.execute( + _ITEM_RUN_INSERT_SQL, + { + "run_id": item_run_id, + "project_id": project_id, + "agent_id": BACKFILL_ITEM_AGENT_ID, + "trigger_ref": (f"legacy-backup:{item['source_message_id']}")[:256], + "input_sha256": _sha256(_stable_json(item)), + "error_detail": _stable_json({"item": item}), + }, + ) + idempotency = await db.execute( + _ITEM_IDEMPOTENCY_INSERT_SQL, + { + "project_id": project_id, + "channel_type": BACKFILL_IDEMPOTENCY_CHANNEL, + "provider_event_id": item["occurrence_id"], + "run_id": item_run_id, + }, + ) + if idempotency.scalar_one_or_none() is not None: + reserved += 1 + else: + deduplicated += 1 + + new_scanned_total = scanned_total + len(rows) + new_cohort_scanned_total = cohort_scanned_total + len(rows) + cohort_exhausted = new_cohort_scanned_total == cohort_source_total + source_exhausted = new_scanned_total == source_total + source_drift_detected = False + if rows: + last = rows[-1] + detail["cursor"] = { + "queued_at": _timestamp(last.get("queued_at")), + "message_id": str(last.get("message_id") or ""), + } + detail["scanned_total"] = new_scanned_total + detail["cohort_scanned_total"] = new_cohort_scanned_total + detail["cohort_exhausted"] = cohort_exhausted + detail["reserved_total"] = int(detail.get("reserved_total") or 0) + reserved + detail["deduplicated_total"] = ( + int(detail.get("deduplicated_total") or 0) + deduplicated + ) + detail["source_exhausted"] = source_exhausted + detail["source_drift_detected"] = source_drift_detected + detail["scope_cap_reached"] = False + await db.execute( + _CURSOR_UPDATE_SQL, + { + "state": "waiting_tool", + "error_detail": _stable_json(detail), + "project_id": project_id, + "run_id": cursor_run_id, + "agent_id": BACKFILL_CURSOR_AGENT_ID, + }, + ) + return { + "scanned": len(rows), + "reserved": reserved, + "deduplicated": deduplicated, + "source_exhausted": source_exhausted, + "source_drift_detected": source_drift_detected, + "scope_cap_reached": bool(detail.get("scope_cap_reached")), + "source_total_at_start": source_total, + "cohort_index": int(detail.get("cohort_index") or 0), + "cohort_source_total": cohort_source_total, + "cohort_exhausted": cohort_exhausted, + "cursor": dict(detail.get("cursor") or {}), + } + + +async def _claim_legacy_backup_items( + *, + project_id: str, + limit: int, +) -> list[LegacyBackupClaim]: + bounded_limit = max(1, min(int(limit), MAX_BATCH_LIMIT)) + claim_token = str(uuid4()) + async with get_db_context(project_id) as db: + exhausted_result = await db.execute( + _EXHAUST_EXPIRED_CLAIMS_SQL, + { + "project_id": project_id, + "agent_id": BACKFILL_ITEM_AGENT_ID, + }, + ) + exhausted_run_ids = exhausted_result.mappings().all() + if exhausted_run_ids: + logger.warning( + "backup_restore_legacy_backfill_expired_claims_exhausted", + project_id=project_id, + exhausted_count=len(exhausted_run_ids), + telegram_dispatch_performed=False, + ) + result = await db.execute( + _CLAIM_SQL, + { + "project_id": project_id, + "agent_id": BACKFILL_ITEM_AGENT_ID, + "limit": bounded_limit, + "claim_token": claim_token, + "lease_seconds": CLAIM_LEASE_SECONDS, + }, + ) + rows = result.mappings().all() + claims: list[LegacyBackupClaim] = [] + for row in rows: + mapping = _row_mapping(row) + envelope = _json_object(mapping.get("error_detail")) + item = envelope.get("item") + if not isinstance(item, Mapping): + item = {} + claims.append( + LegacyBackupClaim( + run_id=UUID(str(mapping.get("run_id"))), + claim_token=claim_token, + attempt_count=int(mapping.get("attempt_count") or 0), + max_attempts=int(mapping.get("max_attempts") or 0), + item=dict(item), + ) + ) + return claims + + +async def _load_legacy_backup_card( + *, + project_id: str, + claim: LegacyBackupClaim, +) -> LegacyBackupCard | None: + message_id = str(claim.item.get("source_message_id") or "").strip() + if not message_id: + return None + async with get_db_context(project_id) as db: + result = await db.execute( + _SOURCE_LOAD_SQL, + { + "project_id": project_id, + "message_id": message_id, + "event_type": BACKUP_RESTORE_EVENT_TYPE, + }, + ) + row = result.mappings().one_or_none() + if row is None: + return None + mapping = _row_mapping(row) + envelope = _json_object(mapping.get("source_envelope")) + refs = _source_refs(envelope) + card = _alert_card(envelope) + return LegacyBackupCard( + message_id=str(mapping.get("message_id") or ""), + queued_at=_timestamp(mapping.get("queued_at")), + source_fingerprint=_first_string(refs.get("fingerprints")), + event_type=str(card.get("event_type") or ""), + lane=str(card.get("lane") or BACKUP_RESTORE_LANE), + target=str(card.get("target") or "backup_restore"), + source_envelope=envelope, + ) + + +def _existing_projection_matches( + card: LegacyBackupCard, + *, + occurrence: Mapping[str, Any], +) -> bool: + projection = card.source_envelope.get("agent99_dispatch_receipt") + if not isinstance(projection, Mapping): + return False + schema_version = str(projection.get("schema_version") or "") + if schema_version == "telegram_agent99_dispatch_receipt_v1": + return False + if schema_version != PROJECTION_SCHEMA_VERSION: + raise ValueError("backup_backfill_existing_projection_schema_conflict") + durable_shape = bool( + projection.get("receipt_persisted") is True + and projection.get("accepted") is True + and projection.get("inbox_triggered") is True + and str(projection.get("run_id") or "").strip() + and str(projection.get("trace_id") or "").strip() + and str(projection.get("work_item_id") or "").strip() + and projection.get("kind") == "backup_health" + and projection.get("suggested_mode") == "BackupCheck" + ) + if not durable_shape: + return False + backfill = card.source_envelope.get("backup_restore_backfill") + if isinstance(backfill, Mapping): + if str(backfill.get("occurrence_id") or "") != str( + occurrence.get("occurrence_id") or "" + ): + raise ValueError("backup_backfill_existing_occurrence_conflict") + required = { + "incident_id": occurrence.get("incident_id"), + "work_item_id": occurrence.get("work_item_id"), + "kind": "backup_health", + "suggested_mode": "BackupCheck", + } + if any( + str(projection.get(key) or "") != str(expected or "") + for key, expected in required.items() + ): + raise ValueError("backup_backfill_existing_projection_identity_conflict") + return True + + +def _validated_projection( + *, + result: Mapping[str, Any], + occurrence: Mapping[str, Any], + relay_auth_mode: str, +) -> dict[str, Any]: + owner = result.get("owner") + identity = result.get("identity") + if not isinstance(owner, Mapping) or not isinstance(identity, Mapping): + raise ValueError("backup_backfill_dispatch_identity_missing") + expected_owner = { + "incident_id": occurrence.get("incident_id"), + "approval_id": occurrence.get("approval_id"), + "work_item_id": occurrence.get("work_item_id"), + } + if any( + str(owner.get(key) or "") != str(expected or "") + for key, expected in expected_owner.items() + ): + raise ValueError("backup_backfill_dispatch_owner_mismatch") + if ( + owner.get("controlled_apply") is not False + or owner.get("read_only") is not True + or str(owner.get("route_id") or "") != BACKUP_RESTORE_AGENT99_ROUTE_ID + or str(owner.get("kind") or "") != "backup_health" + or str(owner.get("mode") or "") != "BackupCheck" + ): + raise ValueError("backup_backfill_dispatch_owner_policy_mismatch") + if any( + str(identity.get(key) or "") != str(expected or "") + for key, expected in { + "incident_id": occurrence.get("incident_id"), + "work_item_id": occurrence.get("work_item_id"), + }.items() + ): + raise ValueError("backup_backfill_dispatch_stage_identity_mismatch") + if ( + result.get("accepted") is not True + or result.get("receipt_persisted") is not True + or result.get("runtime_execution_authorized") is not False + or result.get("production_write_performed") is not False + or str(result.get("status") or "") + not in { + "backupcheck_dispatched_verifier_pending", + "backupcheck_deduplicated_verifier_pending", + } + ): + raise ValueError("backup_backfill_dispatch_not_durable_fail_closed") + run_id = str(identity.get("run_id") or "").strip() + trace_id = str(identity.get("trace_id") or "").strip() + work_item_id = str(identity.get("work_item_id") or "").strip() + if not run_id or not trace_id or not work_item_id: + raise ValueError("backup_backfill_dispatch_stage_identity_incomplete") + if relay_auth_mode not in {"token", "lan_allowlist", "injected_processor"}: + raise ValueError("backup_backfill_relay_auth_mode_invalid") + verifier = result.get("verifier") + verifier_receipt = ( + dict(verifier.get("receipt")) + if isinstance(verifier, Mapping) + and isinstance(verifier.get("receipt"), Mapping) + else {} + ) + return { + "schema_version": PROJECTION_SCHEMA_VERSION, + "status": str(result.get("status") or ""), + "transport": "durable_agent99_ledger", + "auth_mode": relay_auth_mode, + "alert_id": f"legacy-backup-card-{occurrence['source_message_id']}", + "kind": "backup_health", + "suggested_mode": "BackupCheck", + # Ingress accepted is intentionally the conjunction of relay accepted + # and inboxTriggered; projecting both values is therefore not an + # inference from transport text. + "accepted": True, + "inbox_triggered": True, + "incident_id": str(occurrence["incident_id"]), + "run_id": run_id, + "trace_id": trace_id, + "work_item_id": work_item_id, + "idempotency_key": str(identity.get("idempotency_key") or ""), + "receipt_persisted": True, + "dispatch_performed": bool(result.get("dispatch_performed") is True), + "controlled_apply": False, + "read_only": True, + "runtime_execution_authorized": False, + "runtime_closure_verified": bool( + result.get("runtime_closure_verified") is True + ), + "current_verifier": verifier_receipt, + "source": BACKFILL_SCHEMA_VERSION, + "telegram_dispatch_performed": False, + "stores_raw_response": False, + } + + +async def _persist_legacy_dispatch_projection( + *, + project_id: str, + claim: LegacyBackupClaim, + card: LegacyBackupCard, + occurrence: Mapping[str, Any], + projection: Mapping[str, Any], +) -> str: + backfill_receipt = { + "schema_version": BACKFILL_SCHEMA_VERSION, + "status": "dispatch_receipt_projected", + "source_message_id": card.message_id, + "source_fingerprint_hash": occurrence["source_fingerprint_hash"], + "occurrence_id": occurrence["occurrence_id"], + "backfill_run_id": occurrence["backfill_run_id"], + "incident_id": occurrence["incident_id"], + "work_item_id": occurrence["work_item_id"], + "agent99_run_id": str(projection.get("run_id") or ""), + "agent99_trace_id": str(projection.get("trace_id") or ""), + "auth_mode": str(projection.get("auth_mode") or ""), + "controlled_apply": False, + "telegram_dispatch_performed": False, + "runtime_closure_verified": False, + "prohibited_actions": list(BACKUP_RESTORE_PROHIBITED_ACTIONS), + } + source_identity_params = { + "project_id": project_id, + "message_id": card.message_id, + "source_queued_at": _database_timestamp( + claim.item.get("source_queued_at") + ), + "source_fingerprint": str(claim.item.get("source_fingerprint") or ""), + "event_type": str(claim.item.get("event_type") or ""), + "default_lane": BACKUP_RESTORE_LANE, + "source_lane": str(claim.item.get("lane") or BACKUP_RESTORE_LANE), + "source_target": str(claim.item.get("target") or "backup_restore"), + "claim_run_id": claim.run_id, + "agent_id": BACKFILL_ITEM_AGENT_ID, + "claim_token": claim.claim_token, + } + async with get_db_context(project_id) as db: + result = await db.execute( + _SOURCE_PROJECTION_UPDATE_SQL, + { + **source_identity_params, + "projection_schema": PROJECTION_SCHEMA_VERSION, + "projection": _stable_json(projection), + "backfill_receipt": _stable_json(backfill_receipt), + "run_id": str(projection.get("run_id") or ""), + "occurrence_id": occurrence["occurrence_id"], + }, + ) + if result.scalar_one_or_none() is not None: + return "projected" + readback_result = await db.execute( + _PROJECTION_CONFLICT_READBACK_SQL, + source_identity_params, + ) + readback = readback_result.mappings().one_or_none() + readback_mapping = _row_mapping(readback) if readback is not None else {} + if readback_mapping.get("claim_active") is not True: + return "lease_lost" + if ( + readback_mapping.get("source_exists") is not True + or readback_mapping.get("source_identity_matches") is not True + ): + return "source_identity_drift" + return "projection_conflict" + + +def _public_result( + *, + status: str, + occurrence: Mapping[str, Any], + projection: Mapping[str, Any] | None = None, + reason: str = "", + auth_mode: str = "injected_processor", +) -> dict[str, Any]: + return { + "schema_version": BACKFILL_RESULT_SCHEMA_VERSION, + "status": status, + "reason": reason, + "source_message_id": occurrence.get("source_message_id"), + "source_fingerprint_hash": occurrence.get("source_fingerprint_hash"), + "occurrence_id": occurrence.get("occurrence_id"), + "backfill_run_id": occurrence.get("backfill_run_id"), + "incident_id": occurrence.get("incident_id"), + "work_item_id": occurrence.get("work_item_id"), + "agent99_run_id": ( + projection.get("run_id") if isinstance(projection, Mapping) else None + ), + "controlled_apply": False, + "auth_mode": auth_mode, + "runtime_execution_authorized": False, + "telegram_dispatch_performed": False, + "runtime_closure_verified": False, + "stores_raw_response": False, + } + + +async def _finish_backfill_claim( + *, + project_id: str, + claim: LegacyBackupClaim, + state: str, + result: Mapping[str, Any], + error_code: str | None, +) -> bool: + if state not in {"waiting_tool", "completed", "failed"}: + raise ValueError("backup_backfill_finish_state_invalid") + envelope = {"item": claim.item, "last_result": dict(result)} + output = _stable_json(dict(result)) + async with get_db_context(project_id) as db: + updated = await db.execute( + _ITEM_FINISH_SQL, + { + "state": state, + "output_sha256": _sha256(output), + "error_code": error_code, + "error_detail": _stable_json(envelope), + "project_id": project_id, + "run_id": claim.run_id, + "agent_id": BACKFILL_ITEM_AGENT_ID, + "claim_token": claim.claim_token, + }, + ) + return updated.scalar_one_or_none() is not None + + +async def _replay_backfill_claim( + *, + project_id: str, + claim: LegacyBackupClaim, + processor: Processor, + relay_auth_mode: str = "injected_processor", +) -> str: + card = await _load_legacy_backup_card(project_id=project_id, claim=claim) + if card is None: + result = _public_result( + status="source_row_missing_fail_closed", + occurrence=claim.item, + reason="source_row_missing_or_not_backup_card", + auth_mode=relay_auth_mode, + ) + finished = await _finish_backfill_claim( + project_id=project_id, + claim=claim, + state="failed", + result=result, + error_code="E-BACKFILL-SOURCE", + ) + return "failed" if finished else "lease_lost" + if not card.source_fingerprint: + result = _public_result( + status="source_fingerprint_missing_fail_closed", + occurrence=claim.item, + reason="message_id_and_source_fingerprint_required", + auth_mode=relay_auth_mode, + ) + finished = await _finish_backfill_claim( + project_id=project_id, + claim=claim, + state="failed", + result=result, + error_code="E-BACKFILL-FINGERPRINT", + ) + return "failed" if finished else "lease_lost" + occurrence = build_legacy_backup_occurrence( + project_id=project_id, + message_id=card.message_id, + source_fingerprint=card.source_fingerprint, + queued_at=card.queued_at, + ) + if any( + str(claim.item.get(key) or "") != str(occurrence.get(key) or "") + for key in ( + "source_message_id", + "source_fingerprint", + "dispatch_source_fingerprint", + "occurrence_id", + "incident_id", + "work_item_id", + ) + ) or any( + str(claim.item.get(key) or "") != str(current_value or "") + for key, current_value in { + "event_type": card.event_type, + "lane": card.lane, + "target": card.target, + }.items() + ): + result = _public_result( + status="source_identity_drift_fail_closed", + occurrence=occurrence, + reason="reserved_identity_does_not_match_current_source_row", + auth_mode=relay_auth_mode, + ) + finished = await _finish_backfill_claim( + project_id=project_id, + claim=claim, + state="failed", + result=result, + error_code="E-BACKFILL-IDENTITY", + ) + return "failed" if finished else "lease_lost" + + try: + if _existing_projection_matches(card, occurrence=occurrence): + result = _public_result( + status="already_projected_deduplicated", + occurrence=occurrence, + projection=card.source_envelope.get("agent99_dispatch_receipt"), + auth_mode=relay_auth_mode, + ) + finished = await _finish_backfill_claim( + project_id=project_id, + claim=claim, + state="completed", + result=result, + error_code=None, + ) + return "deduplicated" if finished else "lease_lost" + except ValueError as exc: + result = _public_result( + status="existing_projection_conflict_fail_closed", + occurrence=occurrence, + reason=str(exc), + auth_mode=relay_auth_mode, + ) + finished = await _finish_backfill_claim( + project_id=project_id, + claim=claim, + state="failed", + result=result, + error_code="E-BACKFILL-PROJECTION-CONFLICT", + ) + return "failed" if finished else "lease_lost" + + context_tokens = set_project_context( + project_id, + source="backup_restore_legacy_backfill", + request_id=str(claim.run_id), + ) + try: + dispatch_result = await processor( + project_id=project_id, + alert_id=f"legacy-backup-card-{card.message_id}", + alertname="BackupRestoreEscrowSignalLegacyReplay", + severity="warning", + namespace="awoooi", + target_resource=card.target or "backup_restore", + message=( + "Legacy backup/restore card durable readback replay; collect " + "status, freshness, offsite, escrow and restore-drill evidence only." + ), + labels={ + "event_type": BACKUP_RESTORE_EVENT_TYPE, + "lane": card.lane or BACKUP_RESTORE_LANE, + "legacy_source_message_id": card.message_id, + "legacy_source_fingerprint_hash": occurrence["source_fingerprint_hash"], + }, + annotations={"summary": "Read-only BackupCheck historical evidence replay"}, + source_fingerprint=occurrence["dispatch_source_fingerprint"], + source_event_fingerprint=occurrence["occurrence_id"], + source_started_at=card.queued_at, + alert_category="backup", + notification_type="TYPE-1", + source_url=None, + ) + if ( + str(dispatch_result.get("status") or "") + not in { + "backupcheck_dispatched_verifier_pending", + "backupcheck_deduplicated_verifier_pending", + } + or dispatch_result.get("accepted") is not True + or dispatch_result.get("receipt_persisted") is not True + ): + raise RuntimeError("durable_backupcheck_dispatch_not_ready") + projection = _validated_projection( + result=dispatch_result, + occurrence=occurrence, + relay_auth_mode=relay_auth_mode, + ) + projection_status = await _persist_legacy_dispatch_projection( + project_id=project_id, + claim=claim, + card=card, + occurrence=occurrence, + projection=projection, + ) + if projection_status == "lease_lost": + return "lease_lost" + if projection_status == "source_identity_drift": + result = _public_result( + status="source_identity_drift_after_dispatch_fail_closed", + occurrence=occurrence, + reason="reserved_identity_changed_before_projection_write", + auth_mode=relay_auth_mode, + ) + finished = await _finish_backfill_claim( + project_id=project_id, + claim=claim, + state="failed", + result=result, + error_code="E-BACKFILL-IDENTITY", + ) + return "failed" if finished else "lease_lost" + if projection_status != "projected": + raise ValueError("backup_backfill_projection_write_conflict") + except ValueError as exc: + result = _public_result( + status="dispatch_validation_failed_fail_closed", + occurrence=occurrence, + reason=str(exc), + auth_mode=relay_auth_mode, + ) + finished = await _finish_backfill_claim( + project_id=project_id, + claim=claim, + state="failed", + result=result, + error_code="E-BACKFILL-VALIDATION", + ) + return "failed" if finished else "lease_lost" + except Exception as exc: + retryable = claim.attempt_count < claim.max_attempts + result = _public_result( + status=( + "dispatch_retry_pending" + if retryable + else "dispatch_retry_exhausted_fail_closed" + ), + occurrence=occurrence, + reason=f"processor_error:{type(exc).__name__}", + auth_mode=relay_auth_mode, + ) + finished = await _finish_backfill_claim( + project_id=project_id, + claim=claim, + state="waiting_tool" if retryable else "failed", + result=result, + error_code="E-BACKFILL-RETRY" if retryable else "E-BACKFILL-EXHAUSTED", + ) + logger.warning( + "backup_restore_legacy_backfill_processor_failed", + project_id=project_id, + source_message_id=card.message_id, + error_type=type(exc).__name__, + retryable=retryable, + ) + if not finished: + return "lease_lost" + return "retried" if retryable else "failed" + finally: + clear_project_context(context_tokens) + + result = _public_result( + status="dispatch_receipt_projected", + occurrence=occurrence, + projection=projection, + auth_mode=relay_auth_mode, + ) + finished = await _finish_backfill_claim( + project_id=project_id, + claim=claim, + state="completed", + result=result, + error_code=None, + ) + return "completed" if finished else "lease_lost" + + +async def _update_cursor_counters( + *, + project_id: str, + completed: int, + deduplicated: int, + failed: int, + retried: int, +) -> dict[str, Any]: + cursor_run_id = _cursor_run_id(project_id) + async with get_db_context(project_id) as db: + selected = await db.execute( + _CURSOR_SELECT_SQL, + { + "project_id": project_id, + "run_id": cursor_run_id, + "agent_id": BACKFILL_CURSOR_AGENT_ID, + }, + ) + row = selected.mappings().one_or_none() + if row is None: + raise RuntimeError("backup_backfill_cursor_row_missing") + detail = _json_object(row.get("error_detail")) + detail["last_tick"] = { + "completed": int(completed), + "deduplicated": int(deduplicated), + "failed": int(failed), + "retried": int(retried), + } + detail["deduplicated_total"] = int(detail.get("deduplicated_total") or 0) + int( + deduplicated + ) + detail["retry_total"] = int(detail.get("retry_total") or 0) + int(retried) + state_counts_result = await db.execute( + _ITEM_STATE_COUNTS_SQL, + { + "project_id": project_id, + "agent_id": BACKFILL_ITEM_AGENT_ID, + }, + ) + state_counts_row = state_counts_result.mappings().one_or_none() + if state_counts_row is None: + raise RuntimeError("backup_backfill_item_state_counts_missing") + state_counts = _row_mapping(state_counts_row) + detail["item_total"] = int(state_counts.get("item_total") or 0) + detail["completed_total"] = int(state_counts.get("completed_total") or 0) + detail["failed_total"] = int(state_counts.get("failed_total") or 0) + remaining = int(state_counts.get("remaining_total") or 0) + detail["remaining_item_total"] = remaining + cohort_ready = bool(detail.get("cohort_exhausted") is True and remaining == 0) + if cohort_ready and int(detail.get("item_total") or 0) != int( + detail.get("scanned_total") or 0 + ): + detail["source_drift_detected"] = True + status, cursor_state = _cursor_terminal_state(detail, remaining=remaining) + if ( + status == "in_progress" + and cohort_ready + and detail.get("source_exhausted") is not True + ): + if detail.get("cohort_counted") is not True: + detail["completed_cohort_count"] = ( + int(detail.get("completed_cohort_count") or 0) + 1 + ) + detail["cohort_counted"] = True + detail["source_snapshot"] = None + detail["source_snapshot_sha256"] = None + detail["source_snapshot_truncated"] = False + detail["cohort_source_total"] = 0 + detail["cohort_scanned_total"] = 0 + detail["cohort_exhausted"] = False + status = "cohort_completed_next_pending" + cursor_state = "waiting_tool" + elif status == "completed" and detail.get("cohort_counted") is not True: + if int(detail.get("cohort_source_total") or 0) > 0: + detail["completed_cohort_count"] = ( + int(detail.get("completed_cohort_count") or 0) + 1 + ) + detail["cohort_counted"] = True + detail["status"] = status + await db.execute( + _CURSOR_UPDATE_SQL, + { + "state": cursor_state, + "error_detail": _stable_json(detail), + "project_id": project_id, + "run_id": cursor_run_id, + "agent_id": BACKFILL_CURSOR_AGENT_ID, + }, + ) + return detail + + +async def run_backup_restore_legacy_backfill_once( + *, + project_id: str = "awoooi", + scan_limit: int = 20, + process_limit: int = 20, + max_rows: int = MAX_SCOPE_ROWS, + processor: Processor = process_backup_restore_alertmanager_signal, +) -> dict[str, Any]: + """Reserve and process one bounded historical replay batch.""" + + normalized_project = str(project_id or "").strip() + if not normalized_project: + return { + "schema_version": BACKFILL_RESULT_SCHEMA_VERSION, + "status": "project_context_missing_fail_closed", + "dispatch_total": 0, + "runtime_execution_authorized": False, + "telegram_dispatch_performed": False, + } + relay_auth_mode = "injected_processor" + relay_preflight: dict[str, Any] | None = None + if processor is process_backup_restore_alertmanager_signal: + relay_preflight = build_backup_restore_backfill_relay_preflight() + if relay_preflight["ready"] is not True: + return { + "schema_version": BACKFILL_RESULT_SCHEMA_VERSION, + "status": "relay_preflight_failed_fail_closed", + "dispatch_total": 0, + "auth_mode": relay_preflight["auth_mode"], + "relay_preflight": relay_preflight, + "runtime_execution_authorized": False, + "telegram_dispatch_performed": False, + "error": "trusted_relay_not_configured", + } + relay_auth_mode = str(relay_preflight["auth_mode"]) + stats: dict[str, Any] = { + "schema_version": BACKFILL_RESULT_SCHEMA_VERSION, + "project_id": normalized_project, + "status": "in_progress", + "auth_mode": relay_auth_mode, + "relay_preflight": relay_preflight, + "scanned": 0, + "reserved": 0, + "claimed": 0, + "completed": 0, + "deduplicated": 0, + "failed": 0, + "retried": 0, + "lease_lost": 0, + "dispatch_total": 0, + "source_total_at_start": None, + "source_exhausted": False, + "source_drift_detected": False, + "scope_cap_reached": False, + "cohort_index": 0, + "cohort_source_total": 0, + "cohort_exhausted": False, + "cursor": {}, + "runtime_execution_authorized": False, + "telegram_dispatch_performed": False, + "production_write_performed": False, + "prohibited_actions": list(BACKUP_RESTORE_PROHIBITED_ACTIONS), + "error": None, + } + try: + reserved = await _reserve_legacy_backup_page( + project_id=normalized_project, + scan_limit=scan_limit, + max_rows=max_rows, + ) + stats.update( + { + "scanned": int(reserved.get("scanned") or 0), + "reserved": int(reserved.get("reserved") or 0), + "deduplicated": int(reserved.get("deduplicated") or 0), + "source_total_at_start": reserved.get("source_total_at_start"), + "source_exhausted": bool(reserved.get("source_exhausted")), + "source_drift_detected": bool(reserved.get("source_drift_detected")), + "scope_cap_reached": bool(reserved.get("scope_cap_reached")), + "cohort_index": int(reserved.get("cohort_index") or 0), + "cohort_source_total": int(reserved.get("cohort_source_total") or 0), + "cohort_exhausted": bool(reserved.get("cohort_exhausted")), + "cursor": dict(reserved.get("cursor") or {}), + } + ) + claims = await _claim_legacy_backup_items( + project_id=normalized_project, + limit=process_limit, + ) + stats["claimed"] = len(claims) + for claim in claims: + outcome = await _replay_backfill_claim( + project_id=normalized_project, + claim=claim, + processor=processor, + relay_auth_mode=relay_auth_mode, + ) + if outcome == "completed": + stats["completed"] += 1 + stats["dispatch_total"] += 1 + elif outcome == "deduplicated": + stats["deduplicated"] += 1 + elif outcome == "retried": + stats["retried"] += 1 + elif outcome == "lease_lost": + stats["lease_lost"] += 1 + else: + stats["failed"] += 1 + cursor_detail = await _update_cursor_counters( + project_id=normalized_project, + completed=stats["completed"], + deduplicated=( + stats["deduplicated"] - int(reserved.get("deduplicated") or 0) + ), + failed=stats["failed"], + retried=stats["retried"], + ) + stats["status"] = str(cursor_detail.get("status") or "in_progress") + stats["remaining_item_total"] = int( + cursor_detail.get("remaining_item_total") or 0 + ) + except Exception as exc: + stats["status"] = "database_or_worker_failure_fail_closed" + stats["error"] = f"{type(exc).__name__}" + logger.warning( + "backup_restore_legacy_backfill_tick_failed", + project_id=normalized_project, + error_type=type(exc).__name__, + runtime_execution_authorized=False, + telegram_dispatch_performed=False, + ) + logger.info("backup_restore_legacy_backfill_tick", **stats) + return stats + + +async def run_backup_restore_legacy_backfill_loop() -> None: + """Drain the historical backlog in bounded, restart-safe batches.""" + + if not settings.ENABLE_BACKUP_RESTORE_LEGACY_BACKFILL: + logger.info("backup_restore_legacy_backfill_disabled") + return + relay_preflight = build_backup_restore_backfill_relay_preflight() + if relay_preflight["ready"] is not True: + logger.warning( + "backup_restore_legacy_backfill_relay_preflight_failed", + status=relay_preflight["status"], + auth_mode=relay_preflight["auth_mode"], + endpoint_class=relay_preflight["endpoint_class"], + telegram_dispatch_performed=False, + ) + return + logger.info( + "backup_restore_legacy_backfill_loop_started", + auth_mode=relay_preflight["auth_mode"], + batch_limit=settings.BACKUP_RESTORE_LEGACY_BACKFILL_BATCH_LIMIT, + max_rows=settings.BACKUP_RESTORE_LEGACY_BACKFILL_MAX_ROWS, + telegram_dispatch_performed=False, + ) + await asyncio.sleep(settings.BACKUP_RESTORE_LEGACY_BACKFILL_STARTUP_SLEEP_SECONDS) + while True: + sleep_seconds = float(settings.BACKUP_RESTORE_LEGACY_BACKFILL_INTERVAL_SECONDS) + try: + result = await run_backup_restore_legacy_backfill_once( + project_id="awoooi", + scan_limit=settings.BACKUP_RESTORE_LEGACY_BACKFILL_BATCH_LIMIT, + process_limit=settings.BACKUP_RESTORE_LEGACY_BACKFILL_BATCH_LIMIT, + max_rows=settings.BACKUP_RESTORE_LEGACY_BACKFILL_MAX_ROWS, + ) + if result.get("status") == "completed": + logger.info( + "backup_restore_legacy_backfill_loop_completed", + source_total_at_start=result.get("source_total_at_start"), + telegram_dispatch_performed=False, + ) + return + if result.get("status") in { + "blocked_scope_cap", + "blocked_failed_items_fail_closed", + "blocked_source_drift_fail_closed", + "blocked_accounting_drift_fail_closed", + }: + logger.warning( + "backup_restore_legacy_backfill_loop_terminal_blocked", + status=result.get("status"), + telegram_dispatch_performed=False, + ) + return + if result.get("error"): + sleep_seconds = max(sleep_seconds, 60.0) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "backup_restore_legacy_backfill_loop_failed", + error_type=type(exc).__name__, + ) + sleep_seconds = max(sleep_seconds, 60.0) + await asyncio.sleep(sleep_seconds) diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 9e203f95e..7e43b3e49 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -465,6 +465,37 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: handoff="awoooi-worker deployment owns Redis stream consumption", ) + # Agent99 durable dispatch outcome reconciler. The worker reads only the + # authenticated public-safe relay outcome and closes the same PG run after + # verifier, KM/PlayBook, Telegram, and incident receipts are durable. + if ( + settings.AGENT99_SRE_ALERT_RELAY_URL + and settings.AGENT99_SRE_ALERT_RELAY_TOKEN + ): + try: + from src.jobs.agent99_controlled_dispatch_reconciler_job import ( + run_agent99_controlled_dispatch_reconciler_loop, + ) + + scheduled_task = schedule_api_background_task( + run_agent99_controlled_dispatch_reconciler_loop() + ) + logger.info( + "agent99_controlled_dispatch_reconciler_schedule_evaluated", + scheduled_in_process=scheduled_task is not None, + interval_seconds=15, + ) + except Exception as exc: + logger.warning( + "agent99_controlled_dispatch_reconciler_schedule_failed", + error=str(exc), + ) + else: + logger.info( + "agent99_controlled_dispatch_reconciler_skipped", + reason="authenticated_relay_not_configured", + ) + # BUG-005 修復 2026-04-11: 啟動時掃描 Redis 中所有 state=ready 但未送 Telegram 的 token # dedup TTL 10 分鐘過期後,ready decisions 就沒有補送機制 → 長期卡在 ready 無人審核 try: diff --git a/apps/api/src/models/incident.py b/apps/api/src/models/incident.py index 98296bf48..b648a8446 100644 --- a/apps/api/src/models/incident.py +++ b/apps/api/src/models/incident.py @@ -22,7 +22,7 @@ C-Suite 戰略會議決議 (2026-03-22): from datetime import datetime, timezone from enum import Enum -from typing import Literal +from typing import Literal, cast, get_args from uuid import UUID, uuid4 from pydantic import BaseModel, Field, field_validator @@ -70,6 +70,33 @@ class IncidentStatus(str, Enum): ESCALATED = "escalated" # 已升級 - 需要人工介入 +# Signal sources are a durable contract: values written by the host sensor and +# the public signal ingress can remain in PostgreSQL long after a producer is +# upgraded. Keep this list bounded, but include every repo-owned producer +# vocabulary instead of silently rewriting provenance during readback. +SignalSource = Literal[ + "prometheus", + "signoz", + "alertmanager", + "manual", + "telegram", + "journal", + "node-exporter", + "sensor-probe", + "sensor-agent", +] +SIGNAL_SOURCE_VALUES: frozenset[str] = frozenset(get_args(SignalSource)) + + +def parse_signal_source(value: object) -> SignalSource: + """Return a canonical known source or reject an unowned durable value.""" + + normalized = str(value or "").strip().lower() + if normalized not in SIGNAL_SOURCE_VALUES: + raise ValueError("unsupported_signal_source") + return cast(SignalSource, normalized) + + # ============================================================================= # Signal (原始告警) # ============================================================================= @@ -89,9 +116,7 @@ class Signal(BaseModel): ) alert_name: str = Field(..., description="告警名稱 (如 HighCPUUsage)") severity: Severity = Field(..., description="告警嚴重度") - source: Literal["prometheus", "signoz", "alertmanager", "manual", "telegram"] = ( - Field(..., description="告警來源") - ) + source: SignalSource = Field(..., description="告警來源") fired_at: datetime = Field(..., description="告警觸發時間") resolved_at: datetime | None = Field(None, description="告警解除時間") labels: dict[str, str] = Field( @@ -107,9 +132,26 @@ class Signal(BaseModel): description="告警指紋 Hash,用於去重與聚合", ) + @field_validator("source", mode="before") + @classmethod + def validate_source(cls, value: object) -> SignalSource: + return parse_signal_source(value) + # [首席架構師] 移除 json_encoders (Pydantic v2 已 deprecated),原生序列化輸出格式與 .isoformat() 一致 v1.1 2026-04-01 Asia/Taipei +class IncidentReadDegradation(BaseModel): + """Public-safe metadata for one durable row excluded from a response.""" + + incident_id: str + reason_code: Literal[ + "incident_signal_source_unsupported", + "incident_signal_schema_invalid", + "incident_record_schema_invalid", + ] + quarantined_from_response: bool = True + + # ============================================================================= # AI Decision Chain (CISO 要求:可稽核性) # ============================================================================= diff --git a/apps/api/src/repositories/incident_repository.py b/apps/api/src/repositories/incident_repository.py index 67cb5047d..bd06937f2 100644 --- a/apps/api/src/repositories/incident_repository.py +++ b/apps/api/src/repositories/incident_repository.py @@ -11,10 +11,12 @@ Phase 16 R3.3: Repository 層實作 建立者: Claude Code (Phase 16 架構重構) """ +from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Any import structlog +from pydantic import ValidationError from sqlalchemy import select from src.db.base import get_db_context @@ -22,6 +24,7 @@ from src.db.models import IncidentRecord from src.models.incident import ( Incident, IncidentFrequencyStats, + IncidentReadDegradation, IncidentStatus, Severity, ) @@ -29,6 +32,37 @@ from src.repositories.interfaces import IIncidentRepository logger = structlog.get_logger(__name__) +_ACTIVE_INCIDENT_STATUS_VALUES = frozenset({ + IncidentStatus.INVESTIGATING.value, + IncidentStatus.MITIGATING.value, +}) + + +@dataclass +class ActiveIncidentReadResult: + """Valid active incidents plus public-safe per-row quarantine metadata.""" + + incidents: list[Incident] = field(default_factory=list) + degraded_records: list[IncidentReadDegradation] = field(default_factory=list) + + @property + def degraded(self) -> bool: + return bool(self.degraded_records) + + +def _record_validation_reason(exc: ValidationError) -> str: + errors = exc.errors(include_input=False) + source_errors = [ + error + for error in errors + if tuple(error.get("loc") or ())[-1:] == ("source",) + ] + if source_errors and all(error.get("type") != "missing" for error in source_errors): + return "incident_signal_source_unsupported" + if any(tuple(error.get("loc") or ())[:1] == ("signals",) for error in errors): + return "incident_signal_schema_invalid" + return "incident_record_schema_invalid" + # ============================================================================= # Conversion Helpers @@ -168,8 +202,13 @@ class IncidentDBRepository(IIncidentRepository): record = result.scalar_one_or_none() return _record_to_incident(record) if record else None - async def get_active(self, *, project_id: str | None = None) -> list[Incident]: - """取得所有活躍的 Incident""" + async def get_active_readback( + self, + *, + project_id: str | None = None, + ) -> ActiveIncidentReadResult: + """Read valid active rows and quarantine only schema-invalid rows.""" + active_statuses = [ IncidentStatus.INVESTIGATING, IncidentStatus.MITIGATING, @@ -181,7 +220,48 @@ class IncidentDBRepository(IIncidentRepository): .order_by(IncidentRecord.created_at.desc()) ) records = result.scalars().all() - return [_record_to_incident(r) for r in records] + # SQL is authoritative, while the defensive filter prevents a + # terminal row from leaking into the active surface if an enum or + # test adapter returns a wider result set. In particular, verified + # terminal incident INC-20260711-D037E5 must never reappear here. + active_records = [ + record + for record in records + if _normalize_status(record.status) + in _ACTIVE_INCIDENT_STATUS_VALUES + ] + incidents: list[Incident] = [] + degraded_records: list[IncidentReadDegradation] = [] + for record in active_records: + try: + incidents.append(_record_to_incident(record)) + except ValidationError as exc: + reason_code = _record_validation_reason(exc) + logger.warning( + "active_incident_row_quarantined", + incident_id=record.incident_id, + project_id=project_id, + reason_code=reason_code, + validation_error_count=len(exc.errors()), + redis_fallback_used=False, + ) + degraded_records.append( + IncidentReadDegradation( + incident_id=record.incident_id, + reason_code=reason_code, + ) + ) + return ActiveIncidentReadResult( + incidents=incidents, + degraded_records=degraded_records, + ) + + async def get_active(self, *, project_id: str | None = None) -> list[Incident]: + """取得所有可安全讀取的活躍 Incident。""" + + return ( + await self.get_active_readback(project_id=project_id) + ).incidents async def update(self, incident: Incident) -> Incident | None: """更新 Incident""" diff --git a/apps/api/src/services/agent99_controlled_dispatch_ledger.py b/apps/api/src/services/agent99_controlled_dispatch_ledger.py new file mode 100644 index 000000000..fab3e692f --- /dev/null +++ b/apps/api/src/services/agent99_controlled_dispatch_ledger.py @@ -0,0 +1,1962 @@ +"""Durable single-owner ledger for Agent99 controlled dispatch. + +One logical recovery action must have one identity across Alertmanager, +Agent99, Telegram projection, verifier, and learning writeback. This module +uses the existing AwoooP run/idempotency/step tables as a PostgreSQL-backed +reservation and receipt store. It never performs the transport dispatch. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 + +import structlog +from sqlalchemy import and_, or_, select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from src.db.awooop_models import ( + AwoooPRunIdempotency, + AwoooPRunState, + AwoooPRunStepJournal, +) +from src.db.base import get_db_context +from src.db.models import AlertOperationLog, IncidentRecord +from src.models.incident import IncidentStatus +from src.services.agent99_public_receipts import ( + AGENT99_BACKUP_REQUIRED_VERIFIER_EVIDENCE_REFS, + AGENT99_LEARNING_RECEIPT_REFS, + AGENT99_REQUIRED_VERIFIER_EVIDENCE_REFS, + sanitize_agent99_public_receipt_refs, +) + +logger = structlog.get_logger(__name__) + +AGENT99_DISPATCH_AGENT_ID = "agent99_controlled_dispatch" +AGENT99_DISPATCH_CHANNEL = "agent99_controlled" +AGENT99_DISPATCH_TRIGGER_TYPE = "agent99_dispatch" +AGENT99_DISPATCH_LEASE_SECONDS = 120 +AGENT99_DISPATCH_RETRY_SECONDS = 600 +AGENT99_DISPATCH_TIMEOUT_MINUTES = 30 +AGENT99_MAX_EXECUTION_GENERATION = 3 + + +def _utc_now_naive() -> datetime: + """Return PostgreSQL-compatible UTC for the existing naive columns.""" + + return datetime.now(UTC).replace(tzinfo=None) + + +def _stable_json(value: dict[str, Any]) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _sha256(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class Agent99DispatchIdentity: + project_id: str + incident_id: str + source_fingerprint: str + route_id: str + execution_generation: str + approval_id: str + run_id: UUID + trace_id: str + work_item_id: str + idempotency_key: str + single_flight_key: str + lock_owner: str + trigger_ref: str + + def public_dict(self) -> dict[str, str]: + public = { + "schema_version": "agent99_controlled_dispatch_identity_v1", + "project_id": self.project_id, + "incident_id": self.incident_id, + "source_fingerprint": self.source_fingerprint, + "route_id": self.route_id, + "execution_generation": self.execution_generation, + "approval_id": self.approval_id, + "run_id": str(self.run_id), + "trace_id": self.trace_id, + "work_item_id": self.work_item_id, + "idempotency_key": self.idempotency_key, + "single_flight_key": self.single_flight_key, + "lock_owner": self.lock_owner, + } + public["canonical_digest"] = _sha256(_stable_json(public)) + return public + + +def build_agent99_dispatch_identity( + *, + project_id: str, + incident_id: str, + source_fingerprint: str, + route_id: str, + execution_generation: str = "1", + approval_id: str = "", + work_item_id: str = "", +) -> Agent99DispatchIdentity: + """Build one deterministic identity for every projection of a dispatch.""" + + resolved_project_id = str(project_id or "awoooi").strip() or "awoooi" + resolved_incident_id = str(incident_id or "").strip() + resolved_route_id = str(route_id or "").strip() + resolved_work_item_id = ( + str(work_item_id or "").strip() + or ( + f"agent99-dispatch:{resolved_project_id}:" + f"{resolved_incident_id}:{resolved_route_id}" + ) + )[:160] + normalized = { + "project_id": resolved_project_id, + "incident_id": resolved_incident_id, + "source_fingerprint": str(source_fingerprint or "").strip(), + "route_id": resolved_route_id, + "execution_generation": str(execution_generation or "1").strip() or "1", + "approval_id": str(approval_id or "").strip()[:128], + "work_item_id": resolved_work_item_id, + } + if not normalized["incident_id"]: + raise ValueError("agent99_dispatch_incident_id_required") + if not normalized["source_fingerprint"]: + raise ValueError("agent99_dispatch_source_fingerprint_required") + if not normalized["route_id"]: + raise ValueError("agent99_dispatch_route_id_required") + + canonical = _stable_json(normalized) + digest = _sha256(canonical) + # The transport lock protects one logical incident/source/route across + # approval refreshes and bounded generations. Run/idempotency identity is + # stricter and includes both approval and work-item identity above. + single_flight_digest = _sha256(_stable_json({ + "project_id": normalized["project_id"], + "incident_id": normalized["incident_id"], + "source_fingerprint": normalized["source_fingerprint"], + "route_id": normalized["route_id"], + })) + run_id = uuid5(NAMESPACE_URL, f"agent99-controlled-dispatch:{canonical}") + trace_id = f"00-{digest[:32]}-{digest[32:48]}-01" + idempotency_key = f"agent99-controlled:{digest}" + trigger_ref = ( + f"agent99:{normalized['incident_id']}:{normalized['route_id']}:" + f"{normalized['execution_generation']}" + )[:256] + return Agent99DispatchIdentity( + project_id=normalized["project_id"], + incident_id=normalized["incident_id"], + source_fingerprint=normalized["source_fingerprint"], + route_id=normalized["route_id"], + execution_generation=normalized["execution_generation"], + approval_id=normalized["approval_id"], + run_id=run_id, + trace_id=trace_id, + work_item_id=normalized["work_item_id"], + idempotency_key=idempotency_key, + single_flight_key=f"agent99-controlled-flight:{single_flight_digest}", + lock_owner=str(run_id), + trigger_ref=trigger_ref, + ) + + +def parse_agent99_dispatch_identity( + value: dict[str, Any], +) -> Agent99DispatchIdentity: + """Rebuild and validate a complete public identity from an outcome caller.""" + + required_nonempty = { + "schema_version", + "project_id", + "incident_id", + "source_fingerprint", + "route_id", + "execution_generation", + "run_id", + "trace_id", + "work_item_id", + "idempotency_key", + "single_flight_key", + "lock_owner", + "canonical_digest", + } + normalized = { + str(key): str(item or "").strip() + for key, item in (value or {}).items() + } + missing = sorted( + key for key in required_nonempty if not normalized.get(key) + ) + if "approval_id" not in normalized: + missing.append("approval_id") + if missing: + raise ValueError( + "agent99_dispatch_identity_fields_required:" + ",".join(missing) + ) + if normalized["schema_version"] != "agent99_controlled_dispatch_identity_v1": + raise ValueError("agent99_dispatch_identity_schema_invalid") + identity = build_agent99_dispatch_identity( + project_id=normalized["project_id"], + incident_id=normalized["incident_id"], + source_fingerprint=normalized["source_fingerprint"], + route_id=normalized["route_id"], + execution_generation=normalized["execution_generation"], + approval_id=normalized.get("approval_id", ""), + work_item_id=normalized["work_item_id"], + ) + expected = identity.public_dict() + compared = required_nonempty | {"approval_id"} + mismatched = sorted( + key for key in (compared - {"canonical_digest"}) + if normalized.get(key, "") != str(expected.get(key) or "") + ) + if mismatched: + raise ValueError( + "agent99_dispatch_identity_mismatch:" + ",".join(mismatched) + ) + if normalized["canonical_digest"] != expected["canonical_digest"]: + raise ValueError("agent99_dispatch_identity_mismatch:canonical_digest") + return identity + + +def validate_agent99_outcome_identity( + identity: Agent99DispatchIdentity, + receipt: dict[str, Any], +) -> bool: + """Require the complete canonical identity at both receipt layers.""" + + outer = receipt.get("identity") + outcome = receipt.get("outcome") + nested = outcome.get("identity") if isinstance(outcome, dict) else None + if not isinstance(outer, dict) or not isinstance(nested, dict): + return False + expected = identity.public_dict() + for candidate in (outer, nested): + try: + parsed = parse_agent99_dispatch_identity(candidate) + except ValueError: + return False + if parsed != identity or parsed.public_dict() != expected: + return False + return True + + +def agent99_dispatch_identity_from_public( + value: dict[str, Any], +) -> Agent99DispatchIdentity: + """Rebuild and cryptographically validate a public dispatch identity.""" + + return parse_agent99_dispatch_identity(value) + + +def attach_agent99_dispatch_identity( + payload: dict[str, Any], + identity: Agent99DispatchIdentity, +) -> dict[str, Any]: + """Attach the shared identity without exposing secrets or raw responses.""" + + enriched = dict(payload) + routing = dict(enriched.get("routing") or {}) + awoooi = dict(enriched.get("awoooi") or {}) + public_identity = identity.public_dict() + routing.update({ + "idempotencyKey": identity.idempotency_key, + "runId": str(identity.run_id), + "traceId": identity.trace_id, + "workItemId": identity.work_item_id, + "executionGeneration": identity.execution_generation, + "correlationKey": identity.idempotency_key, + }) + awoooi.update({ + "incidentId": identity.incident_id, + "approvalId": identity.approval_id, + "agent99DispatchIdentity": public_identity, + }) + enriched.update({ + "id": f"awoooi-agent99-{identity.run_id}", + "routing": routing, + "awoooi": awoooi, + }) + return enriched + + +def build_agent99_dispatch_receipt_envelope( + *, + identity: Agent99DispatchIdentity, + dispatch_receipt: dict[str, Any], + controlled_apply_authorized: bool | None = None, +) -> dict[str, Any]: + accepted = bool(dispatch_receipt.get("accepted") is True) + inbox_triggered = bool(dispatch_receipt.get("inbox_triggered") is True) + dispatch_promoted = bool(accepted and inbox_triggered) + delivery_certainty = str( + dispatch_receipt.get("delivery_certainty") + or ( + "delivered" + if dispatch_promoted + else "unknown" + ) + ) + if delivery_certainty not in {"delivered", "not_delivered", "unknown"}: + delivery_certainty = "unknown" + retry_safe = bool( + not dispatch_promoted + and not accepted + and delivery_certainty == "not_delivered" + ) + public_identity = identity.public_dict() + stage_identity = { + "run_id": str(identity.run_id), + "trace_id": identity.trace_id, + "work_item_id": identity.work_item_id, + } + return { + "schema_version": "agent99_controlled_dispatch_receipt_v1", + "status": ( + "dispatch_accepted_verifier_pending" + if dispatch_promoted + else "dispatch_not_delivered_retry_safe" + if retry_safe + else "dispatch_delivery_unknown_reconcile_only" + ), + "identity": public_identity, + "dispatch_receipt": { + "schema_version": str( + dispatch_receipt.get("schema_version") + or "agent99_sre_dispatch_receipt_v1" + ), + "status": str(dispatch_receipt.get("status") or "unknown"), + "transport": str(dispatch_receipt.get("transport") or "unknown"), + "alert_id": str(dispatch_receipt.get("alert_id") or ""), + "kind": str(dispatch_receipt.get("kind") or ""), + "http_status": int(dispatch_receipt.get("http_status") or 0), + "accepted": accepted, + "inbox_triggered": inbox_triggered, + "delivery_certainty": delivery_certainty, + "stores_raw_response": False, + }, + # Transport acceptance proves only that Agent99 accepted the inbox + # item. Runtime execution stays unproven until the independent verifier + # and durable learning acknowledgements write into this same run. + "dispatch_accepted": accepted, + "inbox_triggered": inbox_triggered, + "dispatch_promoted": dispatch_promoted, + "controlled_apply_authorized": bool( + dispatch_promoted + and ( + dispatch_receipt.get("controlled_apply_authorized") is True + if controlled_apply_authorized is None + else controlled_apply_authorized + ) + ), + "runtime_execution_authorized": False, + "runtime_execution_attempted": False, + "post_verifier_passed": False, + "runtime_closure_verified": False, + "closure_complete": False, + "needs_human": False, + "retry_policy": { + "safe_to_retry": retry_safe, + "requires_generation_advance": retry_safe, + "reconcile_only": bool(not dispatch_promoted and not retry_safe), + "max_generation": AGENT99_MAX_EXECUTION_GENERATION, + }, + "outbox": { + "status": ( + "delivered" + if dispatch_promoted + else "not_delivered" + if retry_safe + else "delivery_unknown" + ), + "durable_reservation": True, + "step_seq": 1, + **stage_identity, + }, + "verifier": { + "status": ( + "pending" + if dispatch_promoted + else "blocked_dispatch_not_promoted" + ), + "step_seq": 2, + **stage_identity, + "required_receipts": [ + "agent99_outcome_terminal_readback", + "full_stack_cold_start_scorecard_rerun", + "source_fingerprint_resolution", + ], + }, + "learning_writeback": { + "status": ( + "pending_verifier" + if dispatch_promoted + else "blocked_dispatch_not_promoted" + ), + "step_seq": 3, + **stage_identity, + "required_receipts": [ + "incident_closure_receipt", + "telegram_lifecycle_receipt", + "km_writeback_ack", + "playbook_trust_writeback_ack", + ], + }, + } + + +def _stage_identity(identity: Agent99DispatchIdentity) -> dict[str, str]: + return { + "run_id": str(identity.run_id), + "trace_id": identity.trace_id, + "work_item_id": identity.work_item_id, + } + + +def _stage_identity_matches( + identity: Agent99DispatchIdentity, + receipt: dict[str, Any], +) -> bool: + """Validate one complete canonical identity projection.""" + + supplied_identity = ( + receipt.get("identity") + if isinstance(receipt.get("identity"), dict) + else receipt + ) + if not isinstance(supplied_identity, dict): + return False + try: + parsed = parse_agent99_dispatch_identity(supplied_identity) + except ValueError: + return False + return parsed == identity and parsed.public_dict() == identity.public_dict() + + +def _safe_receipt_refs(receipt_refs: dict[str, Any] | None) -> dict[str, str]: + """Keep only bounded durable identifiers, never raw evidence bodies.""" + + return sanitize_agent99_public_receipt_refs(receipt_refs) + + +class PostgresAgent99DispatchLedger: + """PostgreSQL reservation and public-safe receipt store.""" + + async def reserve( + self, + *, + identity: Agent99DispatchIdentity, + payload: dict[str, Any], + ) -> dict[str, Any]: + identity_payload = identity.public_dict() + input_payload = { + "schema_version": "agent99_controlled_dispatch_input_v1", + "identity": identity_payload, + "kind": str(payload.get("kind") or ""), + "suggested_mode": str(payload.get("suggestedMode") or "Status"), + "controlled_apply": bool(payload.get("controlledApply") is True), + "target_resource": str((payload.get("awoooi") or {}).get("targetResource") or ""), + "stores_secrets": False, + } + input_json = _stable_json(input_payload) + now = _utc_now_naive() + claim_token = str(uuid4()) + stage_identity = { + "run_id": str(identity.run_id), + "trace_id": identity.trace_id, + "work_item_id": identity.work_item_id, + } + reservation_envelope = { + "schema_version": "agent99_controlled_dispatch_receipt_v1", + "status": "dispatch_reserved", + "identity": identity_payload, + "dispatch_accepted": False, + "controlled_apply_requested": bool(input_payload["controlled_apply"]), + "controlled_apply_authorized": False, + "runtime_execution_authorized": False, + "runtime_execution_attempted": False, + "post_verifier_passed": False, + "runtime_closure_verified": False, + "closure_complete": False, + "outbox": { + "status": "pending_delivery", + "durable_reservation": True, + "step_seq": 1, + **stage_identity, + }, + "verifier": { + "status": "pending_dispatch", + "step_seq": 2, + **stage_identity, + }, + "learning_writeback": { + "status": "pending_verifier", + "step_seq": 3, + **stage_identity, + }, + } + + try: + async with get_db_context(identity.project_id) as db: + await db.execute( + pg_insert(AwoooPRunState) + .values( + run_id=identity.run_id, + project_id=identity.project_id, + agent_id=AGENT99_DISPATCH_AGENT_ID, + state="pending", + attempt_count=0, + max_attempts=3, + trace_id=identity.trace_id, + trigger_type=AGENT99_DISPATCH_TRIGGER_TYPE, + trigger_ref=identity.trigger_ref, + is_shadow=False, + input_sha256=_sha256(input_json), + step_count=3, + error_detail=_stable_json(reservation_envelope), + timeout_at=( + now + timedelta(minutes=AGENT99_DISPATCH_TIMEOUT_MINUTES) + ), + next_attempt_at=None, + ) + .on_conflict_do_nothing(index_elements=[AwoooPRunState.run_id]) + ) + await db.execute( + pg_insert(AwoooPRunIdempotency) + .values( + project_id=identity.project_id, + channel_type=AGENT99_DISPATCH_CHANNEL, + provider_event_id=identity.idempotency_key, + run_id=identity.run_id, + ) + .on_conflict_do_nothing(constraint="uix_run_idempotency_key") + ) + claim = await db.execute( + update(AwoooPRunState) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + AwoooPRunState.attempt_count < AwoooPRunState.max_attempts, + # A running claim is delivery-unknown after a worker + # crash and is therefore reconciliation-only. Only a + # durable pending row whose not-before elapsed may be + # claimed for transport. + and_( + AwoooPRunState.state == "pending", + or_( + AwoooPRunState.next_attempt_at.is_(None), + AwoooPRunState.next_attempt_at <= now, + ), + ), + ) + .values( + state="running", + # A fresh token fences every claim/reclaim. The stable + # run identity must never be used as a worker token. + worker_id=claim_token, + started_at=now, + heartbeat_at=now, + lease_until=( + now + timedelta(seconds=AGENT99_DISPATCH_LEASE_SECONDS) + ), + next_attempt_at=None, + attempt_count=AwoooPRunState.attempt_count + 1, + ) + .returning(AwoooPRunState.run_id) + ) + claimed = claim.scalar_one_or_none() is not None + await db.execute( + pg_insert(AwoooPRunStepJournal) + .values([ + { + "run_id": identity.run_id, + "project_id": identity.project_id, + "step_seq": 1, + "tool_name": "agent99.controlled_dispatch.outbox", + "input_hash": _sha256(input_json), + "compensation_json": reservation_envelope, + "result_status": "pending", + "was_blocked": False, + }, + { + "run_id": identity.run_id, + "project_id": identity.project_id, + "step_seq": 2, + "tool_name": "agent99.independent_post_verifier", + "input_hash": _sha256(identity.trace_id), + "compensation_json": reservation_envelope["verifier"], + "result_status": "pending", + "was_blocked": False, + }, + { + "run_id": identity.run_id, + "project_id": identity.project_id, + "step_seq": 3, + "tool_name": "agent99.learning_writeback", + "input_hash": _sha256(identity.work_item_id), + "compensation_json": reservation_envelope[ + "learning_writeback" + ], + "result_status": "pending", + "was_blocked": False, + }, + ]) + .on_conflict_do_nothing(constraint="uix_run_step_seq") + ) + state_result = await db.execute( + select( + AwoooPRunState.state, + AwoooPRunState.error_detail, + AwoooPRunState.next_attempt_at, + ).where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + ) + ) + state_row = state_result.one_or_none() + except Exception as exc: + logger.warning( + "agent99_dispatch_pg_reservation_failed_fail_closed", + incident_id=identity.incident_id, + run_id=str(identity.run_id), + error=str(exc), + ) + return { + "status": "ledger_unavailable_fail_closed", + "claimed": False, + "identity": identity_payload, + "receipt": None, + } + + existing = _parse_envelope(state_row.error_detail if state_row else None) + if claimed: + return { + "status": "reserved", + "claimed": True, + "identity": identity_payload, + "receipt": reservation_envelope, + "claim_token": claim_token, + } + next_attempt_at = getattr(state_row, "next_attempt_at", None) + retry_not_before = bool(next_attempt_at and next_attempt_at > now) + return { + "status": ( + "retry_not_before" + if retry_not_before + else f"idempotent_{str(state_row.state if state_row else 'unknown')}" + ), + "claimed": False, + "identity": identity_payload, + "receipt": existing, + "next_attempt_at": ( + next_attempt_at.isoformat() if next_attempt_at else None + ), + } + + async def defer_claim_retryable( + self, + *, + identity: Agent99DispatchIdentity, + claim_token: str, + reason: str, + retry_after_seconds: int, + ) -> dict[str, Any]: + """Return an undispatched claim to pending without losing fencing. + + A Redis lock collision or Redis outage occurs before transport. The run + therefore remains retryable; it must not be marked as a failed repair. + The claim token prevents an expired worker from changing a newer claim. + """ + + now = _utc_now_naive() + safe_reason = str(reason or "dispatch_lock_unavailable")[:128] + retry_after = max(1, min(int(retry_after_seconds or 1), 3600)) + next_attempt_at = now + timedelta(seconds=retry_after) + try: + async with get_db_context(identity.project_id) as db: + current_result = await db.execute( + select( + AwoooPRunState.state, + AwoooPRunState.worker_id, + AwoooPRunState.attempt_count, + AwoooPRunState.max_attempts, + AwoooPRunState.error_detail, + ) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + ) + .with_for_update() + ) + current = current_result.one_or_none() + if ( + current is None + or current.state != "running" + or str(current.worker_id or "") != str(claim_token or "") + ): + return { + "status": "claim_fenced_no_write", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + envelope = _parse_envelope(current.error_detail) or { + "schema_version": "agent99_controlled_dispatch_receipt_v1", + "identity": identity.public_dict(), + } + attempts_exhausted = bool( + int(current.attempt_count or 0) >= int(current.max_attempts or 0) + ) + envelope.update({ + "status": "dispatch_lock_retryable", + "dispatch_accepted": False, + "inbox_triggered": False, + "dispatch_promoted": False, + "controlled_apply_authorized": False, + "runtime_execution_authorized": False, + "runtime_closure_verified": False, + "closure_complete": False, + "retry_policy": { + "safe_to_retry": True, + "retry_same_generation": not attempts_exhausted, + "requires_generation_advance": attempts_exhausted, + "reconcile_only": False, + "max_generation": AGENT99_MAX_EXECUTION_GENERATION, + "retry_after_seconds": retry_after, + "next_attempt_at": next_attempt_at.isoformat(), + "reason": safe_reason, + }, + "outbox": { + "status": "lock_wait_retryable", + "durable_reservation": True, + "step_seq": 1, + **_stage_identity(identity), + }, + }) + envelope_json = _stable_json(envelope) + update_result = await db.execute( + update(AwoooPRunState) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + AwoooPRunState.state == "running", + AwoooPRunState.worker_id == claim_token, + ) + .values( + state="pending", + worker_id=None, + lease_until=None, + next_attempt_at=next_attempt_at, + heartbeat_at=now, + error_detail=envelope_json, + ) + .returning(AwoooPRunState.run_id) + ) + persisted = update_result.scalar_one_or_none() is not None + if persisted: + await db.execute( + update(AwoooPRunStepJournal) + .where( + AwoooPRunStepJournal.run_id == identity.run_id, + AwoooPRunStepJournal.step_seq == 1, + ) + .values( + compensation_json=envelope, + result_status="pending", + error_code=None, + was_blocked=True, + block_reason=safe_reason, + completed_at=None, + ) + ) + except Exception as exc: + logger.warning( + "agent99_dispatch_retry_defer_failed_fail_closed", + incident_id=identity.incident_id, + run_id=str(identity.run_id), + error=str(exc), + ) + return { + "status": "retry_defer_persistence_failed", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + return { + **envelope, + "receipt_persisted": persisted, + "next_attempt_at": next_attempt_at.isoformat(), + "runtime_closure_verified": False, + } + + async def complete( + self, + *, + identity: Agent99DispatchIdentity, + claim_token: str, + dispatch_receipt: dict[str, Any], + controlled_apply_authorized: bool | None = None, + ) -> dict[str, Any]: + envelope = build_agent99_dispatch_receipt_envelope( + identity=identity, + dispatch_receipt=dispatch_receipt, + controlled_apply_authorized=controlled_apply_authorized, + ) + accepted = bool(dispatch_receipt.get("accepted") is True) + inbox_triggered = bool(dispatch_receipt.get("inbox_triggered") is True) + dispatch_promoted = bool(accepted and inbox_triggered) + retry_safe = bool( + isinstance(envelope.get("retry_policy"), dict) + and envelope["retry_policy"].get("safe_to_retry") is True + ) + now = _utc_now_naive() + next_attempt_at = ( + now + timedelta(seconds=AGENT99_DISPATCH_RETRY_SECONDS) + if retry_safe + else None + ) + if retry_safe: + envelope["retry_policy"].update({ + "retry_after_seconds": AGENT99_DISPATCH_RETRY_SECONDS, + "next_attempt_at": next_attempt_at.isoformat(), + }) + envelope_json = _stable_json(envelope) + try: + async with get_db_context(identity.project_id) as db: + result = await db.execute( + update(AwoooPRunState) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + AwoooPRunState.state.in_(["running", "waiting_tool"]), + AwoooPRunState.worker_id == claim_token, + ) + .values( + # Delivery is not the terminal run state. A successful + # dispatch waits for the independent verifier and KM / + # PlayBook acknowledgements in steps 2 and 3. + state=( + "waiting_tool" + if dispatch_promoted or not retry_safe + else "failed" + ), + output_sha256=_sha256(envelope_json), + error_code=( + None + if dispatch_promoted + else "E-AGENT99-DISPATCH-NOT-DELIVERED" + if retry_safe + else "E-AGENT99-DISPATCH-UNKNOWN" + ), + error_detail=envelope_json, + completed_at=now if retry_safe else None, + heartbeat_at=now, + lease_until=None, + next_attempt_at=next_attempt_at, + worker_id=None, + ) + .returning(AwoooPRunState.run_id) + ) + finalized = result.scalar_one_or_none() is not None + if finalized: + await db.execute( + update(AwoooPRunStepJournal) + .where( + AwoooPRunStepJournal.run_id == identity.run_id, + AwoooPRunStepJournal.step_seq == 1, + ) + .values( + output_hash=_sha256(envelope_json), + compensation_json=envelope, + result_status=( + "success" + if dispatch_promoted + else "failed" + if retry_safe + else "pending" + ), + error_code=( + None + if dispatch_promoted + else "E-AGENT99-DISPATCH-NOT-DELIVERED" + if retry_safe + else None + ), + was_blocked=not dispatch_promoted, + block_reason=( + None + if dispatch_promoted + else "dispatch_not_delivered" + if retry_safe + else "dispatch_delivery_reconcile_pending" + ), + completed_at=now if dispatch_promoted or retry_safe else None, + ) + ) + if retry_safe: + await db.execute( + update(AwoooPRunStepJournal) + .where( + AwoooPRunStepJournal.run_id == identity.run_id, + AwoooPRunStepJournal.step_seq.in_([2, 3]), + AwoooPRunStepJournal.result_status == "pending", + ) + .values( + result_status="failed", + error_code="E-AGENT99-DISPATCH-NOT-DELIVERED", + was_blocked=True, + block_reason="dispatch_not_accepted", + completed_at=now, + ) + ) + except Exception as exc: + logger.warning( + "agent99_dispatch_pg_receipt_write_failed_fail_closed", + incident_id=identity.incident_id, + run_id=str(identity.run_id), + error=str(exc), + ) + return { + **envelope, + "status": "receipt_persistence_failed_no_runtime_closure", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + return { + **envelope, + "receipt_persisted": finalized, + "claim_fenced": not finalized, + "status": ( + str(envelope.get("status") or "unknown") + if finalized + else "claim_fenced_no_write" + ), + "runtime_closure_verified": False, + } + + async def mark_delivery_attempt_started( + self, + *, + identity: Agent99DispatchIdentity, + claim_token: str, + ) -> dict[str, Any]: + """Fence transport ambiguity before the first network/file write. + + After this durable transition a worker crash is delivery-unknown. The + run stays in ``waiting_tool`` and only authenticated outcome readback + may advance it; an expired lease can never redispatch the mutation. + """ + + now = _utc_now_naive() + try: + async with get_db_context(identity.project_id) as db: + current_result = await db.execute( + select( + AwoooPRunState.state, + AwoooPRunState.worker_id, + AwoooPRunState.error_detail, + ) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + ) + .with_for_update() + ) + current = current_result.one_or_none() + if ( + current is None + or current.state != "running" + or str(current.worker_id or "") != str(claim_token or "") + ): + return { + "status": "claim_fenced_no_write", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + envelope = _parse_envelope(current.error_detail) or { + "schema_version": "agent99_controlled_dispatch_receipt_v1", + "identity": identity.public_dict(), + } + envelope.update({ + "status": "dispatch_delivery_unknown_reconcile_only", + "dispatch_accepted": False, + "inbox_triggered": False, + "dispatch_promoted": False, + "controlled_apply_authorized": False, + "runtime_execution_authorized": False, + "runtime_closure_verified": False, + "closure_complete": False, + "retry_policy": { + "safe_to_retry": False, + "requires_generation_advance": False, + "reconcile_only": True, + "max_generation": AGENT99_MAX_EXECUTION_GENERATION, + }, + "outbox": { + "status": "delivery_attempt_started_unknown", + "durable_reservation": True, + "step_seq": 1, + **_stage_identity(identity), + }, + }) + envelope_json = _stable_json(envelope) + updated = await db.execute( + update(AwoooPRunState) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + AwoooPRunState.state == "running", + AwoooPRunState.worker_id == claim_token, + ) + .values( + state="waiting_tool", + error_code="E-AGENT99-DISPATCH-UNKNOWN", + error_detail=envelope_json, + heartbeat_at=now, + next_attempt_at=None, + ) + .returning(AwoooPRunState.run_id) + ) + persisted = updated.scalar_one_or_none() is not None + if persisted: + await db.execute( + update(AwoooPRunStepJournal) + .where( + AwoooPRunStepJournal.run_id == identity.run_id, + AwoooPRunStepJournal.step_seq == 1, + ) + .values( + compensation_json=envelope["outbox"], + result_status="pending", + error_code=None, + was_blocked=True, + block_reason="dispatch_delivery_reconcile_pending", + completed_at=None, + ) + ) + except Exception as exc: + logger.warning( + "agent99_delivery_attempt_fence_failed_fail_closed", + incident_id=identity.incident_id, + run_id=str(identity.run_id), + error=str(exc), + ) + return { + "status": "delivery_attempt_fence_persistence_failed", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + return { + **envelope, + "receipt_persisted": persisted, + "runtime_closure_verified": False, + } + + async def record_verifier( + self, + *, + identity: Agent99DispatchIdentity, + outcome_receipt: dict[str, Any], + evidence_refs: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Persist Agent99 outcome + independent verifier on the same run.""" + + if not validate_agent99_outcome_identity(identity, outcome_receipt): + return { + "status": "verifier_identity_mismatch_fail_closed", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + outcome = ( + outcome_receipt.get("outcome") + if isinstance(outcome_receipt.get("outcome"), dict) + else outcome_receipt + ) + schema_version = str( + outcome.get("schemaVersion") or outcome.get("schema_version") or "" + ) + if schema_version != "agent99_outcome_contract_v1": + return { + "status": "verifier_schema_invalid_fail_closed", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + + outcome_state = str(outcome.get("state") or "unknown") + verifier_passed = bool(outcome.get("verifierPassed") is True) + source_resolved = bool(outcome.get("sourceEventResolved") is True) + transport_ok = bool(outcome.get("transportOk") is True) + safe_refs = _safe_receipt_refs(evidence_refs) + required_evidence_refs = set(AGENT99_REQUIRED_VERIFIER_EVIDENCE_REFS) + mode = str(outcome_receipt.get("mode") or "") + if mode == "BackupCheck" or "backup_health" in identity.route_id: + required_evidence_refs.update( + AGENT99_BACKUP_REQUIRED_VERIFIER_EVIDENCE_REFS + ) + missing_evidence_refs = sorted(required_evidence_refs - set(safe_refs)) + if missing_evidence_refs: + return { + "status": "verifier_evidence_missing_fail_closed", + "missing_evidence_refs": missing_evidence_refs, + "receipt_persisted": False, + "runtime_closure_verified": False, + } + passed = bool( + outcome_state == "resolved" + and transport_ok + and verifier_passed + and source_resolved + ) + execution_attempted = transport_ok + now = _utc_now_naive() + + try: + async with get_db_context(identity.project_id) as db: + current_result = await db.execute( + select(AwoooPRunState.state, AwoooPRunState.error_detail) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + ) + .with_for_update() + ) + current = current_result.one_or_none() + envelope = _parse_envelope( + current.error_detail if current is not None else None + ) + if ( + current is None + or current.state not in {"pending", "running", "waiting_tool"} + or not isinstance(envelope, dict) + or not _stage_identity_matches( + identity, + envelope.get("identity") + if isinstance(envelope.get("identity"), dict) + else {}, + ) + ): + return { + "status": "verifier_run_not_ready_fail_closed", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + + if ( + current.state in {"pending", "running"} + or envelope.get("dispatch_promoted") is not True + ): + # A signed/allowlisted Agent99 outcome is the authoritative + # reconciliation receipt when transport acceptance was + # observed but the API could not persist its response. + envelope.update({ + "dispatch_accepted": True, + "inbox_triggered": True, + "dispatch_promoted": True, + "controlled_apply_authorized": bool( + outcome_receipt.get("controlledApply") is True + ), + "dispatch_receipt": { + "schema_version": "agent99_sre_dispatch_receipt_v1", + "status": "outcome_readback_proves_delivery", + "transport": "agent99_relay_readback", + "alert_id": str(identity.run_id), + "kind": str(outcome_receipt.get("mode") or ""), + "accepted": True, + "inbox_triggered": True, + "delivery_certainty": "delivered", + "stores_raw_response": False, + }, + "outbox": { + "status": "delivered_reconciled_from_outcome", + "durable_reservation": True, + "step_seq": 1, + **_stage_identity(identity), + }, + "retry_policy": { + "safe_to_retry": False, + "requires_generation_advance": False, + "reconcile_only": False, + "max_generation": AGENT99_MAX_EXECUTION_GENERATION, + }, + }) + + verifier_receipt = { + "schema_version": "agent99_independent_verifier_receipt_v1", + "status": "success" if passed else "failed", + **_stage_identity(identity), + "outcome_schema_version": schema_version, + "outcome_state": outcome_state, + "transport_ok": transport_ok, + "verifier_passed": verifier_passed, + "source_event_resolved": source_resolved, + "verified_at": str( + outcome.get("verifiedAt") + or outcome.get("verified_at") + or "" + )[:64], + "evidence_refs": safe_refs, + "stores_raw_evidence": False, + } + envelope.update({ + "status": ( + "verifier_passed_learning_writeback_pending" + if passed + else "verifier_failed_no_runtime_closure" + ), + "runtime_execution_authorized": bool( + envelope.get("controlled_apply_authorized") is True + ), + "runtime_execution_attempted": execution_attempted, + "post_verifier_passed": passed, + "runtime_closure_verified": False, + "closure_complete": False, + "verifier": verifier_receipt, + "learning_writeback": { + **_stage_identity(identity), + "step_seq": 3, + "status": ( + "pending_writeback" + if passed + else "blocked_verifier_failed" + ), + "required_receipts": [ + "incident_closure_receipt", + "telegram_lifecycle_receipt", + "km_writeback_ack", + "playbook_trust_writeback_ack", + ], + }, + }) + envelope_json = _stable_json(envelope) + update_result = await db.execute( + update(AwoooPRunState) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + AwoooPRunState.state.in_([ + "pending", + "running", + "waiting_tool", + ]), + ) + .values( + state="waiting_tool" if passed else "failed", + output_sha256=_sha256(envelope_json), + error_code=None if passed else "E-AGENT99-VERIFY", + error_detail=envelope_json, + heartbeat_at=now, + completed_at=None if passed else now, + lease_until=None, + next_attempt_at=None, + worker_id=None, + ) + .returning(AwoooPRunState.run_id) + ) + persisted = update_result.scalar_one_or_none() is not None + if persisted: + if current.state == "running": + await db.execute( + update(AwoooPRunStepJournal) + .where( + AwoooPRunStepJournal.run_id == identity.run_id, + AwoooPRunStepJournal.step_seq == 1, + ) + .values( + output_hash=_sha256(envelope_json), + compensation_json=envelope["outbox"], + result_status="success", + error_code=None, + was_blocked=False, + block_reason=None, + completed_at=now, + ) + ) + await db.execute( + update(AwoooPRunStepJournal) + .where( + AwoooPRunStepJournal.run_id == identity.run_id, + AwoooPRunStepJournal.step_seq == 2, + ) + .values( + output_hash=_sha256(_stable_json(verifier_receipt)), + compensation_json=verifier_receipt, + result_status="success" if passed else "failed", + error_code=None if passed else "E-AGENT99-VERIFY", + was_blocked=not passed, + block_reason=( + None if passed else "independent_verifier_failed" + ), + completed_at=now, + ) + ) + if not passed: + await db.execute( + update(AwoooPRunStepJournal) + .where( + AwoooPRunStepJournal.run_id == identity.run_id, + AwoooPRunStepJournal.step_seq == 3, + AwoooPRunStepJournal.result_status == "pending", + ) + .values( + result_status="failed", + error_code="E-AGENT99-VERIFY", + was_blocked=True, + block_reason="independent_verifier_failed", + completed_at=now, + ) + ) + except Exception as exc: + logger.warning( + "agent99_verifier_receipt_write_failed_fail_closed", + incident_id=identity.incident_id, + run_id=str(identity.run_id), + error=str(exc), + ) + return { + "status": "verifier_receipt_persistence_failed", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + return { + **envelope, + "receipt_persisted": persisted, + "runtime_closure_verified": False, + } + + async def record_learning_writeback( + self, + *, + identity: Agent99DispatchIdentity, + receipt_refs: dict[str, Any], + ) -> dict[str, Any]: + """Atomically close the incident, operation log, and same-run ledger. + + KM, PlayBook, DR, and Telegram each provide a durable idempotent + acknowledgement first. The incident terminal state, immutable closure + event, run state, and step-3 receipt then commit in one PostgreSQL + transaction. Redis is only a post-commit projection. + """ + + required_refs = { + "telegram_lifecycle_receipt_id", + "km_writeback_ack_id", + "playbook_trust_writeback_ack_id", + } + if "backup_health" in identity.route_id: + required_refs.add("dr_scorecard_writeback_ack_id") + supplied_refs = sanitize_agent99_public_receipt_refs( + receipt_refs, + allowed_keys=( + AGENT99_LEARNING_RECEIPT_REFS + - {"incident_closure_receipt_id"} + ), + ) + supplied_missing = sorted(required_refs - set(supplied_refs)) + if supplied_refs and supplied_missing: + return { + "status": "learning_writeback_incomplete_fail_closed", + "missing_receipts": supplied_missing, + "receipt_persisted": False, + "runtime_closure_verified": False, + } + now = _utc_now_naive() + incident_now = datetime.now(UTC) + closure_event_id = str(uuid5( + NAMESPACE_URL, + f"agent99-terminal-bundle:{identity.project_id}:{identity.run_id}", + )) + try: + async with get_db_context(identity.project_id) as db: + current_result = await db.execute( + select(AwoooPRunState.state, AwoooPRunState.error_detail) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + ) + .with_for_update() + ) + current = current_result.one_or_none() + envelope = _parse_envelope( + current.error_detail if current is not None else None + ) + if ( + current is not None + and current.state == "completed" + and isinstance(envelope, dict) + and envelope.get("runtime_closure_verified") is True + and envelope.get("closure_complete") is True + and _stage_identity_matches( + identity, + envelope.get("identity") + if isinstance(envelope.get("identity"), dict) + else {}, + ) + ): + return { + **envelope, + "status": "closed_verified_learning_written_idempotent", + "receipt_persisted": True, + "postgres_terminal_bundle_committed": True, + "redis_projection_pending": True, + "runtime_closure_verified": True, + } + if ( + current is None + or current.state != "waiting_tool" + or not isinstance(envelope, dict) + or envelope.get("post_verifier_passed") is not True + or not _stage_identity_matches( + identity, + envelope.get("identity") + if isinstance(envelope.get("identity"), dict) + else {}, + ) + ): + return { + "status": "learning_writeback_run_not_ready_fail_closed", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + + learning_checkpoint = ( + envelope.get("learning_writeback") + if isinstance(envelope.get("learning_writeback"), dict) + else {} + ) + checkpoint_refs = sanitize_agent99_public_receipt_refs( + learning_checkpoint.get("receipt_refs") + if isinstance(learning_checkpoint.get("receipt_refs"), dict) + else {}, + allowed_keys=( + AGENT99_LEARNING_RECEIPT_REFS + - {"incident_closure_receipt_id"} + ), + ) + missing = sorted(required_refs - set(checkpoint_refs)) + if missing: + return { + "status": ( + "learning_writeback_checkpoint_incomplete_fail_closed" + ), + "missing_receipts": missing, + "receipt_persisted": False, + "runtime_closure_verified": False, + } + if supplied_refs and supplied_refs != checkpoint_refs: + return { + "status": ( + "learning_writeback_checkpoint_mismatch_fail_closed" + ), + "receipt_persisted": False, + "runtime_closure_verified": False, + } + # Only the refs already committed by record_learning_asset_acks + # are allowed into the terminal bundle. Caller values are a + # consistency check, never the source of truth. + safe_refs = dict(checkpoint_refs) + safe_refs["incident_closure_receipt_id"] = ( + f"alert_operation_log:{closure_event_id}" + ) + + incident_result = await db.execute( + select(IncidentRecord) + .where( + IncidentRecord.incident_id == identity.incident_id, + IncidentRecord.project_id == identity.project_id, + ) + .with_for_update() + ) + incident = incident_result.scalar_one_or_none() + if incident is None: + return { + "status": "incident_terminal_bundle_target_missing", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + incident_outcome = dict(incident.outcome or {}) + incident_outcome.update({ + "execution_success": True, + "effectiveness_score": max( + 4, + int(incident_outcome.get("effectiveness_score") or 0), + ), + "learning_notes": ( + f"Agent99 same-run verifier passed: {identity.run_id}" + ), + "should_remember": True, + "automation_run_id": str(identity.run_id), + "trace_id": identity.trace_id, + "work_item_id": identity.work_item_id, + }) + + learning_receipt = { + "schema_version": "agent99_learning_writeback_receipt_v1", + "status": "success", + "step_seq": 3, + **_stage_identity(identity), + "receipt_refs": safe_refs, + "stores_raw_evidence": False, + } + envelope.update({ + "status": "closed_verified_learning_written", + "runtime_closure_verified": True, + "closure_complete": True, + "learning_writeback": learning_receipt, + }) + envelope_json = _stable_json(envelope) + await db.execute( + pg_insert(AlertOperationLog) + .values( + id=closure_event_id, + incident_id=identity.incident_id, + approval_id=identity.approval_id or None, + event_type="RESOLVED", + actor="agent99_controlled_dispatch_reconciler", + action_detail=( + "Agent99 verifier and durable learning receipts closed" + ), + success=True, + context={ + "automation_run_id": str(identity.run_id), + "trace_id": identity.trace_id, + "work_item_id": identity.work_item_id, + "runtime_closure_verified": True, + "receipt_refs": safe_refs, + }, + created_at=incident_now, + ) + .on_conflict_do_nothing( + index_elements=[AlertOperationLog.id] + ) + ) + incident_update = await db.execute( + update(IncidentRecord) + .where( + IncidentRecord.incident_id == identity.incident_id, + IncidentRecord.project_id == identity.project_id, + ) + .values( + status=IncidentStatus.RESOLVED, + outcome=incident_outcome, + resolved_at=incident_now, + updated_at=incident_now, + ) + .returning(IncidentRecord.incident_id) + ) + if incident_update.scalar_one_or_none() is None: + raise RuntimeError("agent99_incident_terminal_update_missing") + update_result = await db.execute( + update(AwoooPRunState) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + AwoooPRunState.state == "waiting_tool", + ) + .values( + state="completed", + output_sha256=_sha256(envelope_json), + error_code=None, + error_detail=envelope_json, + heartbeat_at=now, + completed_at=now, + lease_until=None, + next_attempt_at=None, + worker_id=None, + ) + .returning(AwoooPRunState.run_id) + ) + persisted = update_result.scalar_one_or_none() is not None + if not persisted: + raise RuntimeError("agent99_terminal_run_update_fenced") + terminal_step = await db.execute( + update(AwoooPRunStepJournal) + .where( + AwoooPRunStepJournal.run_id == identity.run_id, + AwoooPRunStepJournal.step_seq == 3, + ) + .values( + output_hash=_sha256(_stable_json(learning_receipt)), + compensation_json=learning_receipt, + result_status="success", + error_code=None, + was_blocked=False, + block_reason=None, + completed_at=now, + ) + .returning(AwoooPRunStepJournal.step_id) + ) + if terminal_step.scalar_one_or_none() is None: + raise RuntimeError("agent99_terminal_step_update_missing") + except Exception as exc: + logger.warning( + "agent99_learning_writeback_failed_fail_closed", + incident_id=identity.incident_id, + run_id=str(identity.run_id), + error=str(exc), + ) + return { + "status": "learning_writeback_persistence_failed", + "receipt_persisted": False, + "runtime_closure_verified": False, + } + return { + **envelope, + "receipt_persisted": persisted, + "postgres_terminal_bundle_committed": persisted, + "redis_projection_pending": persisted, + "runtime_closure_verified": bool( + persisted and envelope.get("runtime_closure_verified") is True + ), + } + + async def record_learning_asset_acks( + self, + *, + identity: Agent99DispatchIdentity, + receipt_refs: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Checkpoint per-run learning side effects before terminal closure. + + Reconciliation may crash after KM, PlayBook, Telegram, or DR succeeds. + Persisting each public acknowledgement on the same run lets the next + tick resume at the first missing asset instead of replaying completed + side effects. The asset writers retain their own deterministic keys as + the final crash fence between side effect and this checkpoint. + """ + + supplied = sanitize_agent99_public_receipt_refs( + receipt_refs, + allowed_keys=( + AGENT99_LEARNING_RECEIPT_REFS + - {"incident_closure_receipt_id"} + ), + ) + now = _utc_now_naive() + try: + async with get_db_context(identity.project_id) as db: + current_result = await db.execute( + select(AwoooPRunState.state, AwoooPRunState.error_detail) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + ) + .with_for_update() + ) + current = current_result.one_or_none() + envelope = _parse_envelope( + current.error_detail if current is not None else None + ) + if ( + current is None + or current.state not in {"waiting_tool", "completed"} + or not isinstance(envelope, dict) + or envelope.get("post_verifier_passed") is not True + or not _stage_identity_matches( + identity, + envelope.get("identity") + if isinstance(envelope.get("identity"), dict) + else {}, + ) + ): + return { + "status": "learning_asset_ack_run_not_ready_fail_closed", + "receipt_persisted": False, + "learning_receipt_refs": {}, + "runtime_closure_verified": False, + } + + learning = ( + dict(envelope.get("learning_writeback")) + if isinstance(envelope.get("learning_writeback"), dict) + else {} + ) + existing = sanitize_agent99_public_receipt_refs( + learning.get("receipt_refs") + if isinstance(learning.get("receipt_refs"), dict) + else {}, + allowed_keys=AGENT99_LEARNING_RECEIPT_REFS, + ) + merged = {**existing, **supplied} + if current.state == "completed": + return { + **envelope, + "status": "learning_asset_acks_terminal_readback", + "receipt_persisted": True, + "learning_receipt_refs": merged, + "runtime_closure_verified": bool( + envelope.get("runtime_closure_verified") is True + ), + } + + learning.update({ + "status": "pending_writeback", + "step_seq": 3, + **_stage_identity(identity), + "receipt_refs": merged, + "acknowledged_receipts": sorted(merged), + "stores_raw_evidence": False, + }) + envelope["learning_writeback"] = learning + envelope_json = _stable_json(envelope) + updated = await db.execute( + update(AwoooPRunState) + .where( + AwoooPRunState.run_id == identity.run_id, + AwoooPRunState.project_id == identity.project_id, + AwoooPRunState.state == "waiting_tool", + ) + .values( + error_detail=envelope_json, + heartbeat_at=now, + ) + .returning(AwoooPRunState.run_id) + ) + persisted = updated.scalar_one_or_none() is not None + if persisted: + await db.execute( + update(AwoooPRunStepJournal) + .where( + AwoooPRunStepJournal.run_id == identity.run_id, + AwoooPRunStepJournal.step_seq == 3, + ) + .values( + compensation_json=learning, + result_status="pending", + error_code=None, + was_blocked=False, + block_reason=None, + completed_at=None, + ) + ) + except Exception as exc: + logger.warning( + "agent99_learning_asset_ack_failed_fail_closed", + incident_id=identity.incident_id, + run_id=str(identity.run_id), + error=str(exc), + ) + return { + "status": "learning_asset_ack_persistence_failed", + "receipt_persisted": False, + "learning_receipt_refs": {}, + "runtime_closure_verified": False, + } + return { + **envelope, + "status": "learning_asset_acks_recorded", + "receipt_persisted": persisted, + "learning_receipt_refs": merged, + "runtime_closure_verified": False, + } + + async def read_latest_for_incident( + self, + *, + project_id: str, + incident_id: str, + ) -> dict[str, Any] | None: + if not incident_id: + return None + try: + async with get_db_context(project_id or "awoooi") as db: + result = await db.execute( + select( + AwoooPRunState.run_id, + AwoooPRunState.state, + AwoooPRunState.trace_id, + AwoooPRunState.error_detail, + ) + .where( + AwoooPRunState.project_id == (project_id or "awoooi"), + AwoooPRunState.agent_id == AGENT99_DISPATCH_AGENT_ID, + AwoooPRunState.trigger_ref.like(f"agent99:{incident_id}:%"), + ) + .order_by(AwoooPRunState.created_at.desc()) + .limit(1) + ) + row = result.one_or_none() + envelope = _parse_envelope(row.error_detail if row else None) + if not isinstance(envelope, dict) or row is None: + return None + return { + **envelope, + "run_state": str(row.state), + "run_id": str(row.run_id), + "trace_id": str(row.trace_id or ""), + } + except Exception as exc: + logger.warning( + "agent99_dispatch_receipt_read_failed", + incident_id=incident_id, + error=str(exc), + ) + return None + + async def list_reconcilable( + self, + *, + project_id: str = "awoooi", + limit: int = 20, + ) -> list[dict[str, Any]]: + """List durable deliveries that need outcome or learning reconciliation.""" + + try: + async with get_db_context(project_id or "awoooi") as db: + result = await db.execute( + select( + AwoooPRunState.run_id, + AwoooPRunState.state, + AwoooPRunState.error_detail, + ) + .where( + AwoooPRunState.project_id == (project_id or "awoooi"), + AwoooPRunState.agent_id == AGENT99_DISPATCH_AGENT_ID, + AwoooPRunState.state.in_([ + "pending", + "running", + "waiting_tool", + ]), + ) + # Prioritize observed delivery/outcome lanes; pending crash + # recovery candidates are polled after them. + .order_by( + (AwoooPRunState.state == "pending").asc(), + AwoooPRunState.created_at.asc(), + ) + .limit(max(1, min(int(limit), 100))) + ) + rows = result.all() + except Exception as exc: + logger.warning( + "agent99_dispatch_reconciliation_list_failed", + project_id=project_id, + error=str(exc), + ) + return [] + + items: list[dict[str, Any]] = [] + for row in rows: + envelope = _parse_envelope(row.error_detail) + public_identity = ( + envelope.get("identity") + if isinstance(envelope, dict) + and isinstance(envelope.get("identity"), dict) + else None + ) + if not isinstance(public_identity, dict): + continue + try: + identity = agent99_dispatch_identity_from_public(public_identity) + except ValueError: + logger.warning( + "agent99_dispatch_reconciliation_identity_invalid", + run_id=str(row.run_id), + ) + continue + items.append({ + "identity": identity, + "run_state": str(row.state), + "receipt": envelope, + }) + return items + + +def _parse_envelope(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + return value + if not value: + return None + try: + parsed = json.loads(str(value)) + except (TypeError, ValueError, json.JSONDecodeError): + return None + return parsed if isinstance(parsed, dict) else None + + +_ledger = PostgresAgent99DispatchLedger() + + +def get_agent99_dispatch_ledger() -> PostgresAgent99DispatchLedger: + return _ledger + + +async def read_agent99_dispatch_receipt( + *, + project_id: str, + incident_id: str, +) -> dict[str, Any] | None: + return await _ledger.read_latest_for_incident( + project_id=project_id, + incident_id=incident_id, + ) + + +async def list_agent99_reconcilable_dispatches( + *, + project_id: str = "awoooi", + limit: int = 20, +) -> list[dict[str, Any]]: + return await _ledger.list_reconcilable( + project_id=project_id, + limit=limit, + ) + + +async def record_agent99_verifier_receipt( + *, + identity: Agent99DispatchIdentity, + outcome_receipt: dict[str, Any], + evidence_refs: dict[str, Any] | None = None, +) -> dict[str, Any]: + return await _ledger.record_verifier( + identity=identity, + outcome_receipt=outcome_receipt, + evidence_refs=evidence_refs, + ) + + +async def record_agent99_learning_writeback( + *, + identity: Agent99DispatchIdentity, + receipt_refs: dict[str, Any], +) -> dict[str, Any]: + result = await _ledger.record_learning_writeback( + identity=identity, + receipt_refs=receipt_refs, + ) + if result.get("postgres_terminal_bundle_committed") is not True: + return result + + projection_acknowledged = False + try: + # PostgreSQL is source-of-truth. Working memory is refreshed only + # after the terminal transaction above has committed successfully. + from src.services.incident_service import get_incident_service + + incident_service = get_incident_service() + incident = await incident_service.get_from_episodic_memory( + identity.incident_id, + project_id=identity.project_id, + ) + projection_acknowledged = bool( + incident is not None + and await incident_service.save_to_working_memory(incident) + ) + except Exception as exc: + logger.warning( + "agent99_incident_redis_projection_pending", + incident_id=identity.incident_id, + run_id=str(identity.run_id), + error=str(exc), + ) + return { + **result, + "redis_projection_pending": not projection_acknowledged, + "redis_projection_acknowledged": projection_acknowledged, + } diff --git a/apps/api/src/services/agent99_outcome_ingestion.py b/apps/api/src/services/agent99_outcome_ingestion.py new file mode 100644 index 000000000..cab4a1d61 --- /dev/null +++ b/apps/api/src/services/agent99_outcome_ingestion.py @@ -0,0 +1,119 @@ +"""Production ingestion for Agent99 outcome, verifier, and learning receipts.""" + +from __future__ import annotations + +from typing import Any + +from src.services.agent99_controlled_dispatch_ledger import ( + parse_agent99_dispatch_identity, + record_agent99_verifier_receipt, + validate_agent99_outcome_identity, +) +from src.services.agent99_public_receipts import ( + extract_agent99_outcome_evidence_refs, +) + + +def _public_evidence_refs( + payload_refs: dict[str, Any], + outcome_receipt: dict[str, Any], +) -> dict[str, str]: + """Merge only bounded, public-safe identifiers from authenticated input.""" + + return extract_agent99_outcome_evidence_refs( + outcome_receipt, + payload_refs, + ) + + +async def ingest_agent99_outcome_receipt( + payload: dict[str, Any], +) -> dict[str, Any]: + """Write the Agent99 outcome and optional learning acks to one run. + + The caller must carry the complete deterministic identity both in the + request identity and in the Agent99 outcome receipt. This prevents a valid + outcome from being rebound to another incident or execution generation. + """ + + identity_payload = ( + payload.get("identity") + if isinstance(payload.get("identity"), dict) + else {} + ) + identity = parse_agent99_dispatch_identity(identity_payload) + outcome_receipt = ( + payload.get("outcome_receipt") + if isinstance(payload.get("outcome_receipt"), dict) + else {} + ) + if not outcome_receipt: + raise ValueError("agent99_outcome_receipt_required") + if not validate_agent99_outcome_identity(identity, outcome_receipt): + raise ValueError("agent99_outcome_identity_mismatch") + payload_evidence_refs = ( + payload.get("evidence_refs") + if isinstance(payload.get("evidence_refs"), dict) + else {} + ) + evidence_refs = _public_evidence_refs( + payload_evidence_refs, + outcome_receipt, + ) + verifier = await record_agent99_verifier_receipt( + identity=identity, + outcome_receipt=outcome_receipt, + evidence_refs=evidence_refs, + ) + if verifier.get("receipt_persisted") is not True: + return { + "schema_version": "agent99_outcome_ingestion_receipt_v1", + "status": str(verifier.get("status") or "verifier_rejected"), + "accepted": False, + "identity": identity.public_dict(), + "verifier": verifier, + "learning_writeback": { + "status": "blocked_verifier_not_persisted", + "receipt_persisted": False, + }, + "runtime_closure_verified": False, + } + + # The relay is authoritative for the Agent99 outcome, but it is not the + # authority for Telegram/KM/PlayBook/DR delivery acknowledgements. Those + # side effects are created and checkpointed by the server-side reconciler. + # Keep the compatibility field in the webhook schema, but never let its + # caller-provided values enter the terminal transaction. + untrusted_learning_refs = ( + payload.get("learning_receipt_refs") + if isinstance(payload.get("learning_receipt_refs"), dict) + else {} + ) + learning = { + "status": ( + "untrusted_external_receipts_ignored_reconciler_pending" + if verifier.get("post_verifier_passed") is True + and untrusted_learning_refs + else "pending_reconciler_durable_receipts" + if verifier.get("post_verifier_passed") is True + else "blocked_verifier_failed" + ), + "receipt_persisted": False, + "runtime_closure_verified": False, + } + return { + "schema_version": "agent99_outcome_ingestion_receipt_v1", + "status": ( + "verifier_recorded_learning_pending" + if verifier.get("post_verifier_passed") is True + else "verifier_failed_no_runtime_closure" + ), + "accepted": True, + "identity": identity.public_dict(), + "verifier": verifier, + "learning_writeback": learning, + "untrusted_learning_receipt_refs_ignored": bool( + untrusted_learning_refs + ), + "runtime_closure_verified": False, + } diff --git a/apps/api/src/services/agent99_public_receipts.py b/apps/api/src/services/agent99_public_receipts.py new file mode 100644 index 000000000..5851358fd --- /dev/null +++ b/apps/api/src/services/agent99_public_receipts.py @@ -0,0 +1,108 @@ +"""Strict public receipt identifiers shared by every Agent99 readback path. + +Receipt references are durable identifiers, never log bodies, filesystem +paths, URLs with query strings, credentials, or arbitrary caller metadata. +Keeping the grammar in one module prevents the webhook and relay reconciler +from accepting different evidence for the same run. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Mapping +from typing import Any + +AGENT99_REQUIRED_VERIFIER_EVIDENCE_REFS = frozenset({ + "agent99_outcome_receipt_id", + "post_verifier_evidence_ref", + "source_event_evidence_ref", +}) +AGENT99_BACKUP_REQUIRED_VERIFIER_EVIDENCE_REFS = frozenset({ + "backup_status_evidence_ref", + "freshness_evidence_ref", + "offsite_verify_evidence_ref", + "escrow_evidence_ref", + "restore_drill_evidence_ref", + "source_resolution_receipt_ref", +}) +AGENT99_LEARNING_RECEIPT_REFS = frozenset({ + "incident_closure_receipt_id", + "telegram_lifecycle_receipt_id", + "km_writeback_ack_id", + "playbook_trust_writeback_ack_id", + "dr_scorecard_writeback_ack_id", +}) +AGENT99_PUBLIC_RECEIPT_REF_KEYS = ( + AGENT99_REQUIRED_VERIFIER_EVIDENCE_REFS + | AGENT99_BACKUP_REQUIRED_VERIFIER_EVIDENCE_REFS + | AGENT99_LEARNING_RECEIPT_REFS +) + +# URN-like public identifiers only. Deliberately excludes whitespace, +# backslashes, query delimiters, fragments, and assignment (``=``). +_PUBLIC_RECEIPT_REF_RE = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._:/@+%-]{0,255}$" +) +_SECRET_SHAPED_RE = re.compile( + r"(?:authorization|bearer|password|passwd|token|secret|api[_-]?key|" + r"private[_-]?key|cookie|session)(?:\s|:|=)|" + r"-----BEGIN[^-]*PRIVATE KEY-----|(?:^|[/.])\.env(?:$|[/.])", + re.IGNORECASE, +) + + +def normalize_agent99_public_receipt_ref(value: Any) -> str | None: + """Return one bounded public receipt id, or ``None`` when unsafe.""" + + ref = str(value or "").strip() + if not ref or len(ref) > 256 or not ref.isascii(): + return None + if _SECRET_SHAPED_RE.search(ref) or not _PUBLIC_RECEIPT_REF_RE.fullmatch(ref): + return None + if ".." in ref or "://" in ref: + return None + return ref + + +def sanitize_agent99_public_receipt_refs( + *sources: Mapping[str, Any] | None, + allowed_keys: Iterable[str] = AGENT99_PUBLIC_RECEIPT_REF_KEYS, +) -> dict[str, str]: + """Merge allowlisted refs using the same strict grammar everywhere.""" + + allowed = frozenset(str(key) for key in allowed_keys) + safe: dict[str, str] = {} + for source in sources: + for raw_key, raw_value in (source or {}).items(): + key = str(raw_key or "").strip() + if key not in allowed: + continue + ref = normalize_agent99_public_receipt_ref(raw_value) + if ref is not None: + safe[key] = ref + return safe + + +def extract_agent99_outcome_evidence_refs( + outcome_receipt: Mapping[str, Any] | None, + *additional_sources: Mapping[str, Any] | None, +) -> dict[str, str]: + """Extract generic and BackupCheck refs from one outcome envelope.""" + + receipt = outcome_receipt or {} + outcome = receipt.get("outcome") + if not isinstance(outcome, Mapping): + outcome = receipt + nested_refs = outcome.get("evidenceRefs") + if not isinstance(nested_refs, Mapping): + nested_refs = outcome.get("evidence_refs") + if not isinstance(nested_refs, Mapping): + nested_refs = {} + return sanitize_agent99_public_receipt_refs( + *additional_sources, + nested_refs, + allowed_keys=( + AGENT99_REQUIRED_VERIFIER_EVIDENCE_REFS + | AGENT99_BACKUP_REQUIRED_VERIFIER_EVIDENCE_REFS + ), + ) diff --git a/apps/api/src/services/agent99_sre_bridge.py b/apps/api/src/services/agent99_sre_bridge.py index 1c733dd79..8dabf1df3 100644 --- a/apps/api/src/services/agent99_sre_bridge.py +++ b/apps/api/src/services/agent99_sre_bridge.py @@ -13,11 +13,30 @@ from typing import Any from src.core.config import settings from src.core.logging import get_logger +from src.services.agent99_controlled_dispatch_ledger import ( + attach_agent99_dispatch_identity, + build_agent99_dispatch_identity, + get_agent99_dispatch_ledger, + parse_agent99_dispatch_identity, +) +from src.services.agent99_public_receipts import ( + extract_agent99_outcome_evidence_refs, +) from src.utils.timezone import now_taipei logger = get_logger("awoooi.agent99_sre_bridge") AGENT99_SRE_SINGLE_FLIGHT_TTL_SECONDS = 600 +AGENT99_DURABLE_ROUTE_KINDS = { + "provider_freshness_signal", + "public_route_502", + "performance_pressure", + "backup_health", + "security_health", + "harbor_registry", + "awoooi_k3s", + "host_recovery", +} _SENSITIVE_KEY_RE = re.compile( r"(token|secret|password|passwd|authorization|cookie|session|private[_-]?key)", @@ -95,6 +114,25 @@ def resolve_agent99_alert_kind( annotations=annotations or {}, ) + # Explicit backup identities win over generic freshness words. Backup + # failure cards intentionally mention freshness, but must stay in the + # read-only BackupCheck lane with its stricter no-delete/no-restore policy. + backup_identity = " ".join( + [ + alertname, + target_resource, + str((labels or {}).get("event_type") or ""), + str((labels or {}).get("lane") or ""), + ] + ).lower() + if re.search( + r"backup_restore_escrow|backup[-_ ]restore|backupcredentialescrow|" + r"backupaggregaterun|backupconfigcapture|escrow_missing|" + r"restore[_ -]drill|restic|rclone|offsite", + backup_identity, + ): + return "backup_health" + if re.search( r"host112guest(?:readbackmissing|notready)|host112_guest|" r"awoooi_host112_|host 112.*(?:guest|console|lightdm|vmware tools)", @@ -181,6 +219,37 @@ def _suggested_mode_for_kind(kind: str) -> str | None: }.get(kind) +def resolve_agent99_durable_route( + *, + alertname: str, + severity: str, + target_resource: str, + namespace: str, + message: str, + labels: dict[str, Any] | None = None, + annotations: dict[str, Any] | None = None, +) -> dict[str, str] | None: + """Return the single-owner route for Agent99-actionable domains.""" + + kind = resolve_agent99_alert_kind( + alertname=alertname, + severity=severity, + target_resource=target_resource, + namespace=namespace, + message=message, + labels=labels, + annotations=annotations, + ) + if kind not in AGENT99_DURABLE_ROUTE_KINDS: + return None + suggested_mode = _suggested_mode_for_kind(kind) or "Status" + return { + "kind": kind, + "suggested_mode": suggested_mode, + "route_id": f"agent99:{kind}:{suggested_mode}", + } + + def _resolution_policy_for_kind(kind: str) -> str: if kind in { "provider_freshness_signal", @@ -221,10 +290,39 @@ async def try_acquire_agent99_sre_single_flight( *, ttl_seconds: int = AGENT99_SRE_SINGLE_FLIGHT_TTL_SECONDS, ) -> bool: - """Grant one Agent99 dispatch per fingerprint inside the controlled window.""" + """Compatibility wrapper for read-only/non-mutating callers.""" + + decision = await acquire_agent99_sre_single_flight( + fingerprint, + alert_id, + ttl_seconds=ttl_seconds, + fail_closed=False, + ) + return bool(decision["acquired"]) + + +async def acquire_agent99_sre_single_flight( + fingerprint: str, + alert_id: str, + *, + ttl_seconds: int = AGENT99_SRE_SINGLE_FLIGHT_TTL_SECONDS, + fail_closed: bool, +) -> dict[str, Any]: + """Acquire the shared Redis lock with explicit failure semantics. + + Mutating modes pass ``fail_closed=True``. Redis unavailability therefore + produces zero dispatches instead of silently allowing two Recover actions. + """ if not fingerprint: - return True + return { + "acquired": not fail_closed, + "reason": ( + "missing_idempotency_identity_fail_closed" + if fail_closed + else "missing_identity_readonly_allowed" + ), + } try: from src.core.redis_client import get_redis @@ -235,15 +333,29 @@ async def try_acquire_agent99_sre_single_flight( ex=ttl_seconds, nx=True, ) - return bool(acquired) + return { + "acquired": bool(acquired), + "reason": "acquired" if acquired else "single_flight_duplicate", + } except Exception as exc: logger.warning( - "agent99_sre_single_flight_failed_fail_open", + ( + "agent99_sre_single_flight_failed_fail_closed" + if fail_closed + else "agent99_sre_single_flight_failed_readonly_fail_open" + ), fingerprint=fingerprint, alert_id=alert_id, error=str(exc), ) - return True + return { + "acquired": not fail_closed, + "reason": ( + "redis_unavailable_fail_closed" + if fail_closed + else "redis_unavailable_readonly_fail_open" + ), + } async def release_agent99_sre_single_flight(fingerprint: str, alert_id: str) -> None: @@ -335,6 +447,8 @@ def build_agent99_sre_alert( ) if kind == "provider_freshness_signal": safe_labels.extend(["provider-freshness", "no-false-green"]) + elif kind == "backup_health": + safe_labels.extend(["backup-readback", "no-false-green", "read-only"]) instruction = None if kind == "provider_freshness_signal": @@ -343,6 +457,13 @@ def build_agent99_sre_alert( "directReference,verifierReadback; mark missing,expired,timeout," "no_false_green; do not switch provider, call paid model, edit env, or reload." ) + elif kind == "backup_health": + instruction = ( + "BackupCheck readback candidate only; collect backup status, freshness, " + "offsite verification, escrow missing count, restore drill and source " + "resolution receipts; no_false_green; do not run backup/restore, prune, " + "remote delete, change retention, write escrow markers, or read secrets." + ) elif kind == "host112_guest_recovery": instruction = ( "Run the allowlisted host 112 guest recovery; verify graphical.target, " @@ -495,6 +616,130 @@ def post_agent99_sre_alert( return {"status": status, "body": body} +def read_agent99_sre_outcome( + run_id: str, + *, + relay_url: str | None = None, + relay_token: str | None = None, + timeout_seconds: float | None = None, +) -> dict[str, Any] | None: + """Read one public-safe Agent99 outcome from the authenticated relay.""" + + target_url = ( + relay_url if relay_url is not None else settings.AGENT99_SRE_ALERT_RELAY_URL + ) + token = ( + relay_token + if relay_token is not None + else settings.AGENT99_SRE_ALERT_RELAY_TOKEN + ) + if not target_url or not token: + return None + timeout = ( + timeout_seconds + if timeout_seconds is not None + else settings.AGENT99_SRE_ALERT_RELAY_TIMEOUT_SECONDS + ) + parsed = urllib.parse.urlsplit(target_url) + query = urllib.parse.urlencode({"run_id": str(run_id)}) + readback_url = urllib.parse.urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, query, "") + ) + request = urllib.request.Request( + readback_url, + headers={ + "Accept": "application/json", + "User-Agent": "awoooi-agent99-outcome-reconciler/1", + "X-Agent99-Relay-Token": token, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + if int(response.getcode()) != 200: + return None + body = response.read(65536).decode("utf-8", errors="replace") + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc: + logger.info( + "agent99_outcome_readback_pending", + run_id=str(run_id), + error_type=type(exc).__name__, + ) + return None + try: + payload = json.loads(body) + except json.JSONDecodeError: + return None + if not isinstance(payload, dict) or payload.get("found") is not True: + return None + if str(payload.get("automationRunId") or "") != str(run_id): + return None + raw_identity = ( + payload.get("identity") + if isinstance(payload.get("identity"), dict) + else {} + ) + outcome = payload.get("outcome") if isinstance(payload.get("outcome"), dict) else {} + nested_identity = ( + outcome.get("identity") + if isinstance(outcome.get("identity"), dict) + else {} + ) + try: + identity = parse_agent99_dispatch_identity(raw_identity) + nested = parse_agent99_dispatch_identity(nested_identity) + except ValueError: + return None + if ( + identity != nested + or str(identity.run_id) != str(run_id) + or identity.public_dict() != nested.public_dict() + ): + return None + safe_evidence_refs = extract_agent99_outcome_evidence_refs(payload) + return { + "schemaVersion": "agent99_outcome_readback_v1", + "automationRunId": str(payload.get("automationRunId") or ""), + "traceId": str(payload.get("traceId") or ""), + "workItemId": str(payload.get("workItemId") or ""), + "idempotencyKey": str(payload.get("idempotencyKey") or ""), + "executionGeneration": str(payload.get("executionGeneration") or "1"), + "incidentId": str(payload.get("incidentId") or ""), + "approvalId": str(payload.get("approvalId") or ""), + "identity": identity.public_dict(), + "controlledApply": bool(payload.get("controlledApply") is True), + "mode": str(payload.get("mode") or ""), + "outcome": { + "schemaVersion": str(outcome.get("schemaVersion") or ""), + "state": str(outcome.get("state") or "unknown"), + "resolved": bool(outcome.get("resolved") is True), + "transportOk": bool(outcome.get("transportOk") is True), + "verifierName": str(outcome.get("verifierName") or ""), + "verifierPassed": bool(outcome.get("verifierPassed") is True), + "sourceEventResolved": bool( + outcome.get("sourceEventResolved") is True + ), + "failedChecks": [ + str(value)[:120] + for value in (outcome.get("failedChecks") or [])[:32] + ], + "checks": [ + { + "name": str(check.get("name") or "")[:120], + "required": bool(check.get("required") is True), + "passed": bool(check.get("passed") is True), + } + for check in (outcome.get("checks") or [])[:32] + if isinstance(check, dict) + ], + "identity": identity.public_dict(), + "evidenceRefs": safe_evidence_refs, + "verifiedAt": str(outcome.get("verifiedAt") or "")[:64], + }, + "storesRawEvidence": False, + } + + def dispatch_agent99_sre_alert(payload: dict[str, Any]) -> str: receipt = dispatch_agent99_sre_alert_with_receipt(payload) return str(receipt["transport"]) @@ -534,6 +779,13 @@ def dispatch_agent99_sre_alert_with_receipt( "http_status": http_status, "accepted": accepted, "inbox_triggered": inbox_triggered, + "delivery_certainty": ( + "delivered" + if accepted and inbox_triggered + else "unknown" + if accepted + else "not_delivered" + ), "stores_raw_response": False, } @@ -548,6 +800,8 @@ def dispatch_agent99_sre_alert_with_receipt( "kind": kind, "accepted": True, "inbox_triggered": False, + # A file write is not proof that the Agent99 inbox consumed it. + "delivery_certainty": "unknown", "stores_raw_response": False, } @@ -565,6 +819,7 @@ def dispatch_agent99_sre_alert_with_receipt( "kind": kind, "accepted": False, "inbox_triggered": False, + "delivery_certainty": "not_delivered", "stores_raw_response": False, } @@ -583,9 +838,20 @@ async def bridge_alertmanager_to_agent99( alert_category: str = "", notification_type: str = "", source_url: str | None = None, + project_id: str = "awoooi", + incident_id: str = "", + approval_id: str = "", + route_id: str = "", + work_item_id: str = "", + execution_generation: str = "1", ) -> dict[str, Any]: - single_flight_fingerprint = "" + single_flight_identity = "" + single_flight_owner = alert_id single_flight_acquired = False + delivery_attempt_started = False + dispatch_identity = None + claim_token = "" + ledger = get_agent99_dispatch_ledger() try: payload = build_agent99_sre_alert( alert_id=alert_id, @@ -604,56 +870,348 @@ async def bridge_alertmanager_to_agent99( routing = ( payload.get("routing") if isinstance(payload.get("routing"), dict) else {} ) - single_flight_fingerprint = fingerprint or str( - routing.get("correlationKey") or "" + suggested_mode = str(payload.get("suggestedMode") or "Status") + mutating = bool(payload.get("controlledApply") is True) + resolved_route_id = ( + str(route_id or "").strip() + or f"agent99:{str(payload.get('kind') or 'monitoring_alert')}:{suggested_mode}" ) - single_flight_acquired = await try_acquire_agent99_sre_single_flight( - single_flight_fingerprint, - alert_id, + durable_route = bool( + str(incident_id or "").strip() + or str(route_id or "").strip() + or str(work_item_id or "").strip() ) + if mutating and not durable_route: + logger.warning( + "agent99_mutating_dispatch_identity_missing_fail_closed", + alert_id=alert_id, + incident_id=incident_id, + reason="agent99_dispatch_incident_id_required", + ) + return { + "status": "failed", + "reason": "agent99_dispatch_incident_id_required", + "dispatchPerformed": False, + "runtimeClosureVerified": False, + } + if durable_route: + try: + generation = max(1, int(str(execution_generation or "1"))) + except ValueError: + generation = 1 + generation = min(generation, 3) + base_payload = payload + while True: + try: + dispatch_identity = build_agent99_dispatch_identity( + project_id=project_id, + incident_id=incident_id, + source_fingerprint=fingerprint, + route_id=resolved_route_id, + execution_generation=str(generation), + approval_id=approval_id, + work_item_id=work_item_id, + ) + except ValueError as exc: + logger.warning( + "agent99_durable_dispatch_identity_missing_fail_closed", + alert_id=alert_id, + incident_id=incident_id, + reason=str(exc), + ) + return { + "status": "failed", + "reason": str(exc), + "dispatchPerformed": False, + "runtimeClosureVerified": False, + } + payload = attach_agent99_dispatch_identity( + base_payload, + dispatch_identity, + ) + # Durable structured identity makes the inbox actionable. + # Mutating routes must not bypass routing checks; read-only + # BackupCheck retains controlledApply=false/no-write policy. + if mutating: + payload["force"] = False + reservation = await ledger.reserve( + identity=dispatch_identity, + payload=payload, + ) + if reservation.get("claimed") is True: + claim_token = str(reservation.get("claim_token") or "") + if not claim_token: + return { + "status": "failed", + "reason": "agent99_dispatch_claim_token_missing", + "dispatchPerformed": False, + "identity": dispatch_identity.public_dict(), + "runtimeClosureVerified": False, + } + break + + existing_receipt = reservation.get("receipt") + retry_policy = ( + existing_receipt.get("retry_policy") + if isinstance(existing_receipt, dict) + and isinstance(existing_receipt.get("retry_policy"), dict) + else {} + ) + max_generation = min( + 3, + int(retry_policy.get("max_generation") or 3), + ) + if reservation.get("status") == "retry_not_before": + return { + "status": "retryable", + "reason": "durable_retry_not_before", + "dispatchPerformed": False, + "nextAttemptAt": reservation.get("next_attempt_at"), + "correlatedReceipt": existing_receipt, + "identity": dispatch_identity.public_dict(), + "runtimeClosureVerified": False, + } + if ( + retry_policy.get("safe_to_retry") is True + and generation < max_generation + ): + generation += 1 + continue + + dispatch_receipt = ( + existing_receipt.get("dispatch_receipt") + if isinstance(existing_receipt, dict) + and isinstance(existing_receipt.get("dispatch_receipt"), dict) + else {} + ) + accepted = bool(dispatch_receipt.get("accepted") is True) + inbox_triggered = bool( + dispatch_receipt.get("inbox_triggered") is True + ) + dispatch_promoted = bool(accepted and inbox_triggered) + reconcile_only = bool( + retry_policy.get("reconcile_only") is True + or reservation.get("status") == "idempotent_running" + or ( + isinstance(existing_receipt, dict) + and existing_receipt.get("status") + == "dispatch_delivery_unknown_reconcile_only" + ) + ) + return { + "status": ( + "deduplicated" + if dispatch_promoted + else "delivery_unknown" + if reconcile_only + else "suppressed" + ), + "reason": str( + reservation.get("status") or "pg_idempotency_hit" + ), + "dispatchPerformed": False, + "dispatchReceipt": dispatch_receipt or None, + "correlatedReceipt": existing_receipt, + "identity": dispatch_identity.public_dict(), + "runtimeClosureVerified": False, + } + # Generation retries share one Redis flight key; otherwise a new + # generation could race an older still-live transport lock. + single_flight_identity = dispatch_identity.single_flight_key + single_flight_owner = claim_token + else: + single_flight_identity = fingerprint or str( + routing.get("correlationKey") or "" + ) + + lock_decision = await acquire_agent99_sre_single_flight( + single_flight_identity, + single_flight_owner, + fail_closed=durable_route, + ) + single_flight_acquired = bool(lock_decision.get("acquired") is True) if not single_flight_acquired: logger.info( "agent99_sre_dispatch_single_flight_suppressed", alert_id=alert_id, alertname=alertname, - fingerprint=single_flight_fingerprint, + fingerprint=single_flight_identity, group_key=routing.get("groupKey"), + reason=lock_decision.get("reason"), ) + correlated_receipt = None + if dispatch_identity is not None: + correlated_receipt = await ledger.defer_claim_retryable( + identity=dispatch_identity, + claim_token=claim_token, + reason=str( + lock_decision.get("reason") or "single_flight_duplicate" + ), + retry_after_seconds=AGENT99_SRE_SINGLE_FLIGHT_TTL_SECONDS, + ) return { - "status": "suppressed", - "reason": "single_flight_duplicate", - "fingerprint": single_flight_fingerprint, + "status": "retryable" if dispatch_identity is not None else "suppressed", + "reason": str(lock_decision.get("reason") or "single_flight_duplicate"), + "fingerprint": single_flight_identity, "groupKey": routing.get("groupKey"), + "dispatchPerformed": False, + "correlatedReceipt": correlated_receipt, + "identity": ( + dispatch_identity.public_dict() + if dispatch_identity is not None + else None + ), + "runtimeClosureVerified": False, } + if dispatch_identity is not None: + delivery_fence = await ledger.mark_delivery_attempt_started( + identity=dispatch_identity, + claim_token=claim_token, + ) + if delivery_fence.get("receipt_persisted") is not True: + await release_agent99_sre_single_flight( + single_flight_identity, + single_flight_owner, + ) + single_flight_acquired = False + return { + "status": "failed", + "reason": "delivery_attempt_fence_persistence_failed", + "dispatchPerformed": False, + "correlatedReceipt": delivery_fence, + "identity": dispatch_identity.public_dict(), + "runtimeClosureVerified": False, + } + delivery_attempt_started = True dispatch_receipt = await asyncio.to_thread( dispatch_agent99_sre_alert_with_receipt, payload ) - if not dispatch_receipt.get("accepted"): - await release_agent99_sre_single_flight(single_flight_fingerprint, alert_id) + correlated_receipt = None + if dispatch_identity is not None: + correlated_receipt = await ledger.complete( + identity=dispatch_identity, + claim_token=claim_token, + dispatch_receipt=dispatch_receipt, + controlled_apply_authorized=mutating, + ) + accepted = bool(dispatch_receipt.get("accepted") is True) + inbox_triggered = bool(dispatch_receipt.get("inbox_triggered") is True) + dispatch_promoted = bool(accepted and inbox_triggered) + delivery_certainty = str( + dispatch_receipt.get("delivery_certainty") or "unknown" + ) + if not accepted and delivery_certainty == "not_delivered": + await release_agent99_sre_single_flight( + single_flight_identity, + single_flight_owner, + ) single_flight_acquired = False return { "status": "failed", "reason": dispatch_receipt.get("status") or "dispatch_not_accepted", "dispatchReceipt": dispatch_receipt, - "fingerprint": single_flight_fingerprint, + "correlatedReceipt": correlated_receipt, + "fingerprint": single_flight_identity, "groupKey": routing.get("groupKey"), + "dispatchPerformed": True, + "identity": ( + dispatch_identity.public_dict() + if dispatch_identity is not None + else None + ), + "runtimeClosureVerified": False, + } + if ( + dispatch_identity is not None + and not isinstance(correlated_receipt, dict) + ) or ( + isinstance(correlated_receipt, dict) + and correlated_receipt.get("receipt_persisted") is not True + ): + return { + "status": "failed", + "reason": "dispatch_receipt_persistence_failed", + "dispatchReceipt": dispatch_receipt, + "correlatedReceipt": correlated_receipt, + "fingerprint": single_flight_identity, + "groupKey": routing.get("groupKey"), + "dispatchPerformed": True, + "identity": dispatch_identity.public_dict(), + "runtimeClosureVerified": False, + } + if not dispatch_promoted: + return { + "status": "delivery_pending", + "reason": "accepted_without_inbox_triggered", + "dispatch": dispatch_receipt.get("transport"), + "dispatchReceipt": dispatch_receipt, + "correlatedReceipt": correlated_receipt, + "fingerprint": single_flight_identity, + "groupKey": routing.get("groupKey"), + "dispatchPerformed": True, + "identity": ( + dispatch_identity.public_dict() + if dispatch_identity is not None + else None + ), + "runtimeClosureVerified": False, } return { "status": "dispatched", "dispatch": dispatch_receipt.get("transport"), "dispatchReceipt": dispatch_receipt, - "fingerprint": single_flight_fingerprint, + "correlatedReceipt": correlated_receipt, + "fingerprint": single_flight_identity, "groupKey": routing.get("groupKey"), "kind": payload.get("kind"), "suggestedMode": payload.get("suggestedMode"), + "dispatchPerformed": True, + "identity": ( + dispatch_identity.public_dict() + if dispatch_identity is not None + else None + ), + "runtimeClosureVerified": False, } except Exception as exc: - if single_flight_acquired: - await release_agent99_sre_single_flight(single_flight_fingerprint, alert_id) + if single_flight_acquired and not delivery_attempt_started: + await release_agent99_sre_single_flight( + single_flight_identity, + single_flight_owner, + ) + correlated_receipt = None + if dispatch_identity is not None: + correlated_receipt = await ledger.complete( + identity=dispatch_identity, + claim_token=claim_token, + dispatch_receipt={ + "schema_version": "agent99_sre_dispatch_receipt_v1", + "status": "transport_exception", + "transport": "unknown", + "alert_id": str(dispatch_identity.run_id), + "kind": "host_recovery", + "accepted": False, + "inbox_triggered": False, + "delivery_certainty": "unknown", + "stores_raw_response": False, + }, + controlled_apply_authorized=mutating, + ) logger.warning( - "agent99_sre_bridge_failed_fail_open", + "agent99_sre_bridge_failed_fail_closed", alert_id=alert_id, alertname=alertname, error=str(exc), ) - return {"status": "failed", "reason": type(exc).__name__} + return { + "status": "failed", + "reason": type(exc).__name__, + "dispatchPerformed": False, + "correlatedReceipt": correlated_receipt, + "identity": ( + dispatch_identity.public_dict() + if dispatch_identity is not None + else None + ), + "runtimeClosureVerified": False, + } diff --git a/apps/api/src/services/alert_approval_guard.py b/apps/api/src/services/alert_approval_guard.py index e96d8a967..c21ee226c 100644 --- a/apps/api/src/services/alert_approval_guard.py +++ b/apps/api/src/services/alert_approval_guard.py @@ -13,6 +13,9 @@ from dataclasses import dataclass, field import structlog from src.services.action_parser import ActionKind, parse_kubectl_action +from src.services.controlled_alert_target_router import ( + build_controlled_recovery_promotion_contract, +) logger = structlog.get_logger(__name__) @@ -35,6 +38,7 @@ async def guard_alert_approval_action( alert_namespace: str | None, alertname: str, alert_category: str, + target_resource: str | None = None, ) -> ApprovalActionGuardResult: """Validate an AI/rule action before it is persisted as an approval. @@ -55,11 +59,18 @@ async def guard_alert_approval_action( requested_namespace = parsed.namespace expected_namespace = (alert_namespace or "awoooi-prod").strip() or "awoooi-prod" if requested_namespace and requested_namespace not in _ALLOWED_K8S_NAMESPACES: + controlled_reroute = _controlled_recovery_reroute( + alertname=alertname, + target_resource=target_resource or "", + namespace=expected_namespace, + ) return _blocked( raw_action, f"namespace_not_allowed:{requested_namespace}", alertname, expected_namespace=expected_namespace, + replacement_action=(controlled_reroute or {}).get("replacement_action"), + extra_metadata=(controlled_reroute or {}).get("metadata"), ) if ( @@ -68,11 +79,33 @@ async def guard_alert_approval_action( and requested_namespace != expected_namespace and requested_namespace != "observability" ): + controlled_reroute = _controlled_recovery_reroute( + alertname=alertname, + target_resource=target_resource or "", + namespace=expected_namespace, + ) return _blocked( raw_action, f"namespace_mismatch:{requested_namespace}!={expected_namespace}", alertname, expected_namespace=expected_namespace, + replacement_action=(controlled_reroute or {}).get("replacement_action"), + extra_metadata=(controlled_reroute or {}).get("metadata"), + ) + + controlled_reroute = _controlled_recovery_reroute( + alertname=alertname, + target_resource=target_resource or "", + namespace=expected_namespace, + ) + if controlled_reroute is not None: + return _blocked( + raw_action, + "kubernetes_action_not_applicable:control_plane_recovery", + alertname, + expected_namespace=expected_namespace, + replacement_action=controlled_reroute.get("replacement_action"), + extra_metadata=controlled_reroute.get("metadata"), ) # Read-only commands are safe enough to display once the namespace is sane. @@ -121,6 +154,47 @@ async def guard_alert_approval_action( return ApprovalActionGuardResult(action=action) +def _controlled_recovery_reroute( + *, + alertname: str, + target_resource: str, + namespace: str, +) -> dict[str, object] | None: + """Replace a blocked fake K8s action with a fail-closed recovery candidate.""" + + contract = build_controlled_recovery_promotion_contract( + alertname=alertname, + target_resource=target_resource, + namespace=namespace, + ) + if contract is None: + return None + route_id = str(contract.get("route_id") or "agent99_recover_after_owner_review") + ready = int(contract.get("ready_count") or 0) + total = int(contract.get("total_count") or 0) + blocked = int(contract.get("blocked_count") or 0) + summary = ( + f"route={route_id}; promotion={ready}/{total}; blocked={blocked}; " + f"status={contract.get('status')}; runtime=false" + ) + return { + "replacement_action": ( + "DRAFT_READY - CONTROLLED_AGENT99_RECOVERY: " + "agent99-mode Recover controlledApply=true" + ), + "metadata": { + "controlled_target_reroute": True, + "controlled_target_reroute_reason": "kubernetes_action_invalid_for_control_plane", + "controlled_target_executor": "Agent99", + "controlled_target_route_id": route_id, + "repair_candidate_promotion_contract": contract, + "repair_candidate_promotion_summary": summary, + "runtime_execution_authorized": False, + "runtime_write_allowed": False, + }, + } + + def _blocked( raw_action: str, reason: str, @@ -128,6 +202,8 @@ def _blocked( *, expected_namespace: str | None = None, candidates: list[str] | None = None, + replacement_action: object | None = None, + extra_metadata: object | None = None, ) -> ApprovalActionGuardResult: logger.warning( "approval_action_blocked_before_persist", @@ -137,15 +213,22 @@ def _blocked( expected_namespace=expected_namespace, candidates=candidates or [], ) + metadata = { + "action_guard": "blocked_before_persist", + "blocked_action": raw_action[:300], + "blocked_reason": reason, + "expected_namespace": expected_namespace, + "candidates": candidates or [], + } + if isinstance(extra_metadata, dict): + metadata.update(extra_metadata) return ApprovalActionGuardResult( - action=f"NO_ACTION - INVALID_TARGET: {reason}; original={raw_action[:180]}", + action=( + str(replacement_action) + if replacement_action + else f"NO_ACTION - INVALID_TARGET: {reason}; original={raw_action[:180]}" + ), blocked=True, reason=reason, - metadata={ - "action_guard": "blocked_before_persist", - "blocked_action": raw_action[:300], - "blocked_reason": reason, - "expected_namespace": expected_namespace, - "candidates": candidates or [], - }, + metadata=metadata, ) diff --git a/apps/api/src/services/awooop_ansible_audit_service.py b/apps/api/src/services/awooop_ansible_audit_service.py index 4c68a9aa4..308dda8b4 100644 --- a/apps/api/src/services/awooop_ansible_audit_service.py +++ b/apps/api/src/services/awooop_ansible_audit_service.py @@ -17,6 +17,9 @@ import structlog from sqlalchemy import text from src.db.base import get_db_context +from src.services.controlled_alert_target_router import ( + resolve_controlled_alert_target, +) logger = structlog.get_logger(__name__) @@ -633,7 +636,63 @@ def _source_haystack(incident: dict[str, Any] | None, drift: dict[str, Any] | No return " ".join(pieces) +def _domain_controlled_executor_route( + incident: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Keep host control-plane alerts out of the generic Ansible catalog.""" + + if not isinstance(incident, dict): + return None + signals = [ + signal + for signal in incident.get("signals") or [] + if isinstance(signal, dict) + ] + first_signal = signals[0] if signals else {} + labels = ( + first_signal.get("labels") + if isinstance(first_signal.get("labels"), dict) + else {} + ) + alertname = str( + incident.get("alertname") + or first_signal.get("alert_name") + or labels.get("alertname") + or "" + ) + affected_services = [ + str(value) + for value in incident.get("affected_services") or [] + if str(value).strip() + ] + target_resource = str( + affected_services[0] + if affected_services + else labels.get("component") + or labels.get("target_resource") + or "" + ) + namespace = str(labels.get("namespace") or "") + return resolve_controlled_alert_target( + alertname=alertname, + target_resource=target_resource, + namespace=namespace, + ) + + def _catalog_hints(incident: dict[str, Any] | None, drift: dict[str, Any] | None) -> dict[str, Any]: + controlled_route = _domain_controlled_executor_route(incident) + if controlled_route is not None: + return { + "match_mode": "domain_controlled_executor_route_v1", + "decision_effect": "delegate_to_domain_executor", + "available_count": len(_CATALOG), + "candidates": [], + "unmatched_catalog_ids": [str(item["catalog_id"]) for item in _CATALOG], + "controlled_executor": controlled_route, + "not_used_reason": "non_kubernetes_control_plane_route", + } + haystack = _source_haystack(incident, drift) candidates: list[dict[str, Any]] = [] unmatched: list[str] = [] @@ -691,6 +750,7 @@ def build_ansible_truth( records = [_ansible_record(row) for row in automation_ops if _is_ansible_operation(row)] summary = summarize_ansible_execution(records) + candidate_catalog = _catalog_hints(incident, drift) return { "considered": bool(records), "records": records, @@ -715,11 +775,12 @@ def build_ansible_truth( ], "default_execution_mode": "catalog/dry-run audit only until approval execution is explicitly wired", }, - "candidate_catalog": _catalog_hints(incident, drift), + "candidate_catalog": candidate_catalog, "not_used_reason": ( None if records - else "no automation_operation_log row with Ansible operation type, tag, or executor backend for this source" + else candidate_catalog.get("not_used_reason") + or "no automation_operation_log row with Ansible operation type, tag, or executor backend for this source" ), } diff --git a/apps/api/src/services/backup_restore_alertmanager_ingress.py b/apps/api/src/services/backup_restore_alertmanager_ingress.py new file mode 100644 index 000000000..9a657838d --- /dev/null +++ b/apps/api/src/services/backup_restore_alertmanager_ingress.py @@ -0,0 +1,498 @@ +"""Single-owner Alertmanager ingress for read-only BackupCheck automation. + +The raw Alertmanager signal owns dispatch. Telegram is only a projection of +the durable Agent99 receipt. This lane may collect backup/restore evidence, +but it must never run backup/restore, delete remote data, change retention, +write escrow markers, or read secrets. +""" + +from __future__ import annotations + +import hashlib +from typing import Any +from uuid import NAMESPACE_URL, UUID, uuid5 + +from src.core.logging import get_logger +from src.models.approval import ( + ApprovalRequestCreate, + BlastRadius, + DataImpact, + DryRunCheck, + RiskLevel, +) +from src.services.agent99_controlled_dispatch_ledger import ( + read_agent99_dispatch_receipt, +) +from src.services.agent99_sre_bridge import ( + bridge_alertmanager_to_agent99, + resolve_agent99_durable_route, +) +from src.services.approval_db import get_approval_service +from src.services.channel_hub import record_alertmanager_event +from src.services.incident_service import ( + create_incident_for_approval, + get_incident_service, +) + +logger = get_logger("awoooi.backup_restore_alertmanager_ingress") + +BACKUP_RESTORE_AGENT99_ROUTE_ID = "agent99:backup_health:BackupCheck" +BACKUP_RESTORE_DISPATCH_OWNER = "alertmanager_raw_signal" +BACKUP_RESTORE_OWNER_SCHEMA_VERSION = "backup_restore_ingress_owner_v1" +BACKUP_RESTORE_PROHIBITED_ACTIONS = ( + "run_backup", + "run_restore", + "remote_delete", + "prune", + "change_retention", + "write_escrow_marker", + "read_secret", +) + + +def _stable_digest(*parts: object) -> str: + value = "\x1f".join(str(part or "").strip() for part in parts) + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def build_backup_restore_ingress_owner( + *, + project_id: str, + source_fingerprint: str, + source_event_fingerprint: str = "", + source_started_at: str = "", +) -> dict[str, Any]: + """Return the deterministic durable owner identity for one firing episode.""" + + project = str(project_id or "awoooi").strip() or "awoooi" + fingerprint = str(source_fingerprint or "").strip() + if not fingerprint: + raise ValueError("backup_restore_source_fingerprint_required") + source_occurrence = "|".join( + value + for value in ( + str(source_event_fingerprint or "").strip(), + str(source_started_at or "").strip(), + ) + if value + ) or fingerprint + owner_key = ( + f"{BACKUP_RESTORE_OWNER_SCHEMA_VERSION}:{project}:" + f"{fingerprint}:{source_occurrence}" + ) + digest = _stable_digest(owner_key) + approval_id = uuid5(NAMESPACE_URL, owner_key) + return { + "schema_version": BACKUP_RESTORE_OWNER_SCHEMA_VERSION, + "project_id": project, + "source_fingerprint": fingerprint, + "source_occurrence": source_occurrence, + "source_occurrence_hash": digest, + "approval_id": str(approval_id), + "incident_id": f"INC-BRR-{digest[:16].upper()}", + "work_item_id": f"backupcheck:{project}:{digest[:32]}", + "route_id": BACKUP_RESTORE_AGENT99_ROUTE_ID, + "kind": "backup_health", + "mode": "BackupCheck", + "single_dispatch_owner": BACKUP_RESTORE_DISPATCH_OWNER, + "telegram_role": "receipt_projection_only", + "controlled_apply": False, + "read_only": True, + "prohibited_actions": list(BACKUP_RESTORE_PROHIBITED_ACTIONS), + } + + +def _backup_restore_approval_request( + *, + owner: dict[str, Any], + target_resource: str, + message: str, + incident_id: str = "", +) -> ApprovalRequestCreate: + return ApprovalRequestCreate( + action=( + "READ_ONLY_BACKUPCHECK - collect backup status, freshness, offsite, " + "escrow count, restore-drill and source-resolution receipts" + ), + description=( + "Alertmanager raw backup/restore signal selected the non-mutating " + f"BackupCheck evidence lane. {str(message or '')[:500]}" + ), + risk_level=RiskLevel.LOW, + blast_radius=BlastRadius( + affected_pods=0, + estimated_downtime="0", + related_services=[target_resource] if target_resource else [], + data_impact=DataImpact.READ_ONLY, + ), + dry_run_checks=[ + DryRunCheck( + name="single dispatch owner", + passed=True, + message=BACKUP_RESTORE_DISPATCH_OWNER, + ), + DryRunCheck( + name="non-mutating BackupCheck boundary", + passed=True, + message="backup/restore/delete/retention/escrow writes prohibited", + ), + ], + requested_by="Alertmanager BackupCheck durable owner", + incident_id=incident_id or None, + metadata={ + **owner, + "preallocated_approval_id": owner["approval_id"], + "owner_policy": "global_product_governance_v2", + "runtime_execution_authorized": False, + "runtime_closure_verified": False, + }, + ) + + +def _value(value: Any) -> str: + return str(getattr(value, "value", value) or "").strip() + + +def _incident_risk_from_source_severity(severity: str) -> str: + normalized = str(severity or "warning").strip().lower() + if normalized in {"critical", "error"}: + return "high" + if normalized == "warning": + return "medium" + return "low" + + +def _dispatch_receipt_from_readback( + readback: dict[str, Any] | None, +) -> dict[str, Any]: + if not isinstance(readback, dict): + return {} + receipt = readback.get("dispatch_receipt") + return dict(receipt) if isinstance(receipt, dict) else {} + + +def _identity_from(value: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + identity = value.get("identity") + return dict(identity) if isinstance(identity, dict) else {} + + +async def process_backup_restore_alertmanager_signal( + *, + project_id: str, + alert_id: str, + alertname: str, + severity: str, + namespace: str, + target_resource: str, + message: str, + labels: dict[str, Any] | None, + annotations: dict[str, Any] | None, + source_fingerprint: str, + source_event_fingerprint: str = "", + source_started_at: str = "", + alert_category: str = "backup", + notification_type: str = "TYPE-1", + source_url: str | None = None, +) -> dict[str, Any]: + """Create/bind a canonical Incident, dispatch once, and read the receipt. + + The function fails closed before transport whenever owner persistence or + canonical Incident readback is missing. A repeated invocation reuses the + same approval, Incident, work item, and PostgreSQL dispatch identity. + """ + + safe_labels = dict(labels or {}) + safe_annotations = dict(annotations or {}) + route = resolve_agent99_durable_route( + alertname=alertname, + severity=severity, + target_resource=target_resource, + namespace=namespace, + message=message, + labels=safe_labels, + annotations=safe_annotations, + ) + if not isinstance(route, dict) or ( + route.get("kind") != "backup_health" + or route.get("suggested_mode") != "BackupCheck" + or route.get("route_id") != BACKUP_RESTORE_AGENT99_ROUTE_ID + ): + return { + "schema_version": "backup_restore_ingress_result_v1", + "status": "not_backup_restore_route_no_dispatch", + "dispatch_performed": False, + "runtime_execution_authorized": False, + "runtime_closure_verified": False, + } + + owner = build_backup_restore_ingress_owner( + project_id=project_id, + source_fingerprint=source_fingerprint, + source_event_fingerprint=source_event_fingerprint, + source_started_at=source_started_at, + ) + approval_service = get_approval_service() + owner_approval_id = UUID(str(owner["approval_id"])) + approval = await approval_service.get_approval(owner_approval_id) + created_owner = approval is None + legacy_incident_id = "" + if approval is None: + recent = await approval_service.find_by_fingerprint( + fingerprint=source_fingerprint, + debounce_minutes=30, + ) + legacy_incident_id = _value(getattr(recent, "incident_id", "")) + request = _backup_restore_approval_request( + owner=owner, + target_resource=target_resource, + message=message, + incident_id=legacy_incident_id, + ) + try: + approval = await approval_service.create_approval_with_fingerprint( + request=request, + fingerprint=source_fingerprint, + ) + except Exception: + # A concurrent producer may have inserted the deterministic owner. + # Read it back; never create another random owner or dispatch twice. + approval = await approval_service.get_approval(owner_approval_id) + if approval is None: + raise + else: + incremented = await approval_service.increment_hit_count(owner_approval_id) + if incremented is not None: + approval = incremented + + if _value(getattr(approval, "id", "")) != str(owner_approval_id): + return { + "schema_version": "backup_restore_ingress_result_v1", + "status": "durable_owner_identity_mismatch_no_dispatch", + "owner": owner, + "dispatch_performed": False, + "runtime_execution_authorized": False, + "runtime_closure_verified": False, + } + + incident_id = _value(getattr(approval, "incident_id", "")) + if not incident_id: + incident_id = str(owner["incident_id"]) + incident_id = await create_incident_for_approval( + approval_id=str(owner_approval_id), + risk_level=_incident_risk_from_source_severity(severity), + target_resource=target_resource, + namespace=namespace, + alert_type="custom", + message=message, + source="alertmanager", + alertname=alertname, + alert_labels={ + **safe_labels, + "fingerprint": source_fingerprint, + "alert_id": alert_id, + "backup_restore_dispatch_owner": BACKUP_RESTORE_DISPATCH_OWNER, + }, + notification_type=notification_type, + alert_category=alert_category, + canonical_incident_id=incident_id, + ) + await approval_service.update_incident_id(owner_approval_id, incident_id) + + bound_approval = await approval_service.get_approval(owner_approval_id) + if ( + bound_approval is None + or _value(getattr(bound_approval, "incident_id", "")) != incident_id + ): + return { + "schema_version": "backup_restore_ingress_result_v1", + "status": "canonical_incident_binding_missing_no_dispatch", + "owner": owner, + "incident_id": incident_id or None, + "dispatch_performed": False, + "runtime_execution_authorized": False, + "runtime_closure_verified": False, + "safe_next_action": ( + "persist_owner_to_canonical_incident_binding_then_retry" + ), + } + approval = bound_approval + + canonical_incident = await get_incident_service().get_for_readback( + incident_id, + project_id=project_id, + ) + canonical_status = _value(getattr(canonical_incident, "status", "")) + canonical_durable = bool( + canonical_incident is not None + and getattr(canonical_incident, "persisted_to_pg", False) is True + ) + if not canonical_durable or canonical_status not in { + "investigating", + "mitigating", + }: + return { + "schema_version": "backup_restore_ingress_result_v1", + "status": "canonical_active_incident_missing_no_dispatch", + "owner": owner, + "incident_id": incident_id or None, + "canonical_incident_status": canonical_status or "missing", + "canonical_incident_durable": canonical_durable, + "dispatch_performed": False, + "runtime_execution_authorized": False, + "runtime_closure_verified": False, + "safe_next_action": ( + "repair_or_create_active_canonical_incident_then_retry_same_source_owner" + ), + } + + await record_alertmanager_event( + project_id=project_id, + alert_id=alert_id, + alertname=alertname, + severity=severity, + namespace=namespace, + target_resource=target_resource, + fingerprint=source_fingerprint, + stage="backup_restore_incident_owner_bound", + notification_type=notification_type, + alert_category=alert_category, + incident_id=incident_id, + approval_id=str(owner_approval_id), + repeat_count=int(getattr(approval, "hit_count", 1) or 1), + source_url=source_url, + labels={ + **safe_labels, + "backup_restore_dispatch_owner": BACKUP_RESTORE_DISPATCH_OWNER, + }, + annotations=safe_annotations, + ) + + dispatch = await bridge_alertmanager_to_agent99( + alert_id=alert_id, + alertname=alertname, + severity=severity, + namespace=namespace, + target_resource=target_resource, + message=message, + labels=safe_labels, + annotations=safe_annotations, + fingerprint=source_fingerprint, + alert_category=alert_category, + notification_type=notification_type, + source_url=source_url, + project_id=project_id, + incident_id=incident_id, + approval_id=str(owner_approval_id), + route_id=BACKUP_RESTORE_AGENT99_ROUTE_ID, + work_item_id=str(owner["work_item_id"]), + ) + readback = await read_agent99_dispatch_receipt( + project_id=project_id, + incident_id=incident_id, + ) + dispatch_identity = _identity_from(dispatch) + readback_identity = _identity_from(readback) + readback_receipt = _dispatch_receipt_from_readback(readback) + identity_matches = bool( + dispatch_identity + and readback_identity + and all( + _value(dispatch_identity.get(field)) + == _value(readback_identity.get(field)) + for field in ("incident_id", "run_id", "trace_id", "work_item_id") + ) + ) + accepted = bool( + readback_receipt.get("accepted") is True + and readback_receipt.get("inbox_triggered") is True + ) + receipt_persisted = bool(readback and identity_matches) + verifier = ( + dict(readback.get("verifier")) + if isinstance(readback, dict) + and isinstance(readback.get("verifier"), dict) + else {} + ) + verifier_status = _value(verifier.get("status")) or "not_started" + durable = bool(accepted and receipt_persisted) + result_status = ( + "backupcheck_dispatched_verifier_pending" + if durable and dispatch.get("dispatchPerformed") is True + else "backupcheck_deduplicated_verifier_pending" + if durable and dispatch.get("status") == "deduplicated" + else "backupcheck_dispatch_or_readback_failed" + ) + + await record_alertmanager_event( + project_id=project_id, + alert_id=alert_id, + alertname=alertname, + severity=severity, + namespace=namespace, + target_resource=target_resource, + fingerprint=source_fingerprint, + stage=( + "backup_restore_dispatch_receipt_persisted" + if durable + else "backup_restore_dispatch_receipt_missing" + ), + notification_type=notification_type, + alert_category=alert_category, + incident_id=incident_id, + approval_id=str(owner_approval_id), + repeat_count=int(getattr(approval, "hit_count", 1) or 1), + source_url=source_url, + labels={ + **safe_labels, + "backup_restore_dispatch_owner": BACKUP_RESTORE_DISPATCH_OWNER, + }, + annotations=safe_annotations, + ) + logger.info( + "backup_restore_alertmanager_dispatch_result", + status=result_status, + incident_id=incident_id, + approval_id=str(owner_approval_id), + automation_run_id=readback_identity.get("run_id"), + dispatch_performed=bool(dispatch.get("dispatchPerformed") is True), + receipt_persisted=receipt_persisted, + accepted=accepted, + verifier_status=verifier_status, + runtime_execution_authorized=False, + runtime_closure_verified=False, + ) + return { + "schema_version": "backup_restore_ingress_result_v1", + "status": result_status, + "owner": owner, + "owner_created": created_owner, + "approval_id": str(owner_approval_id), + "incident_id": incident_id, + "canonical_incident_status": canonical_status, + "canonical_incident_durable": canonical_durable, + "dispatch_performed": bool(dispatch.get("dispatchPerformed") is True), + "dispatch_status": _value(dispatch.get("status")), + "receipt_persisted": receipt_persisted, + "accepted": accepted, + "identity": readback_identity or dispatch_identity, + "verifier": { + "status": verifier_status, + "terminal": verifier_status in {"success", "failed"}, + "receipt": verifier, + "outcome_source": "agent99_outcome_contract_v1", + "safe_next_action": ( + "ingest_same_identity_agent99_outcome_then_run_independent_dr_verifier" + if verifier_status in {"pending", "pending_dispatch", "not_started"} + else "complete_learning_writeback_only_after_verifier_success" + ), + }, + "runtime_execution_authorized": False, + "runtime_closure_verified": bool( + isinstance(readback, dict) + and readback.get("runtime_closure_verified") is True + ), + "production_write_performed": False, + "prohibited_actions": list(BACKUP_RESTORE_PROHIBITED_ACTIONS), + } diff --git a/apps/api/src/services/backup_restore_signal_automation.py b/apps/api/src/services/backup_restore_signal_automation.py new file mode 100644 index 000000000..bcd0523f9 --- /dev/null +++ b/apps/api/src/services/backup_restore_signal_automation.py @@ -0,0 +1,1020 @@ +"""Fail-closed automation contract for backup / restore / escrow signals. + +The Telegram card is only a sensor receipt. This module turns that receipt +into deterministic work-item, candidate, durable-receipt and verifier +identities without running backup, restore, prune, remote delete, retention +changes, or escrow writes. +""" + +from __future__ import annotations + +import hashlib +import html +import re +from collections.abc import Mapping, Sequence +from typing import Any + +BACKUP_RESTORE_EVENT_TYPE = "backup_restore_escrow_signal" +BACKUP_RESTORE_LANE = "backup_restore_escrow_triage" +BACKUP_RESTORE_POLICY_VERSION = "backup_restore_readback_policy_v2" +BACKUP_RESTORE_POLICY_EFFECTIVE_AT = "2026-07-11T19:30:58+08:00" +BACKUP_RESTORE_POLICY_SOURCE_REF = ( + "apps/api/src/services/backup_restore_signal_automation.py:" + "backup_restore_readback_policy_v2" +) +_BACKUP_RESTORE_POLICY_CANONICAL = "|".join( + [ + BACKUP_RESTORE_POLICY_VERSION, + BACKUP_RESTORE_POLICY_EFFECTIVE_AT, + BACKUP_RESTORE_EVENT_TYPE, + BACKUP_RESTORE_LANE, + "controlled_playbook_queue=readback_only", + "runtime_write_gate=0_until_verifier", + "backup_status,freshness,offsite_verify,escrow,restore_drill,source_resolution_receipt", + "no_backup_restore_delete_retention_escrow_write_or_secret_read", + ] +) +BACKUP_RESTORE_POLICY_SOURCE_HASH = ( + "sha256:" + + hashlib.sha256(_BACKUP_RESTORE_POLICY_CANONICAL.encode("utf-8")).hexdigest() +) + +_TOP_EVIDENCE_RE = re.compile( + r"\s*Top evidence\s*(?P
.*?)(?:\s*建議下一步\s*|$)", + re.IGNORECASE | re.DOTALL, +) +_TAG_RE = re.compile(r"<[^>]+>") +_SPACE_RE = re.compile(r"\s+") +_SAFE_ID_RE = re.compile(r"[^a-zA-Z0-9_.:-]+") + +_REQUIRED_EVIDENCE = ( + "backup_status", + "freshness", + "offsite_verify", + "escrow", + "restore_drill", + "source_resolution_receipt", +) +_BACKUP_VERIFIER_EVIDENCE_REF_KEYS = { + "agent99_outcome_receipt_id", + "post_verifier_evidence_ref", + "source_event_evidence_ref", + "backup_status_evidence_ref", + "freshness_evidence_ref", + "offsite_verify_evidence_ref", + "escrow_evidence_ref", + "restore_drill_evidence_ref", + "source_resolution_receipt_ref", +} + +_FIELD_ALIASES = { + "backup_status": ( + "backup_status", + "backup-status", + "backup status", + "backupaggregaterun", + "backup aggregate", + "last_backup_success", + "last backup success", + ), + "freshness": ( + "freshness", + "backup_age", + "backup age", + "last_success_age", + "last success age", + ), + "offsite_verify": ( + "offsite_verify", + "offsite verify", + "offsite_status", + "offsite status", + "rclone_check", + ), + "escrow": ( + "escrow_missing", + "escrow missing", + "escrow_status", + "escrow status", + "credential_escrow", + ), + "restore_drill": ( + "restore_drill", + "restore drill", + "restore_test", + "restore test", + "restore_status", + "restore status", + ), + "source_resolution_receipt": ( + "source_resolution_receipt", + "source resolution receipt", + "alert_resolved_receipt", + "alert resolved receipt", + ), +} + +_BAD_TOKENS = ( + "failed", + "failure", + "missing", + "blocked", + "stale", + "expired", + "timeout", + "degraded", + "unhealthy", + "false", + "error", +) +_NEGATIVE_PHRASES = ( + "not ok", + "not verified", + "not healthy", + "not fresh", + "not current", + "not complete", + "not resolved", + "not passed", + "never verified", + "unverified", +) +_GOOD_TOKENS = ( + "success", + "succeeded", + "healthy", + "verified", + "passed", + "fresh", + "current", + "complete", + "resolved", + "true", + "ok", +) + +_PROHIBITED_ACTIONS = ( + "run_backup", + "run_restore", + "restore_over_production", + "remote_delete", + "retention_change", + "restic_prune", + "escrow_marker_write", + "read_backup_contents", + "read_secret_values", +) + + +def backup_restore_readback_policy() -> dict[str, Any]: + """Return the immutable source policy used for effective projections.""" + + return { + "schema_version": "backup_restore_readback_policy_v2", + "policy_version": BACKUP_RESTORE_POLICY_VERSION, + "effective_at": BACKUP_RESTORE_POLICY_EFFECTIVE_AT, + "source_ref": BACKUP_RESTORE_POLICY_SOURCE_REF, + "source_hash": BACKUP_RESTORE_POLICY_SOURCE_HASH, + "historical_projection_supported": True, + "send_time_contract_supported": True, + "runtime_write_requires_terminal_verifier": True, + "metadata_refs_are_learning_writeback": False, + "agent99_dispatch_is_runtime_closure": False, + } + + +def _normalized_agent99_dispatch_receipt( + receipt: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Validate the persisted dispatch receipt without treating it as outcome.""" + + if not isinstance(receipt, Mapping) or not receipt: + return { + "schema_version": "backup_restore_agent99_dispatch_readback_v1", + "status": "dispatch_receipt_missing", + "receipt_present": False, + "dispatched": False, + "accepted": False, + "inbox_triggered": False, + "backupcheck_route": False, + "receipt_persisted": False, + "identity_complete": False, + "identity_binding_valid": False, + "agent99_run_id": None, + "trace_id": None, + "agent99_work_item_id": None, + "alert_id": None, + "runtime_closure_verified": False, + "post_verifier_passed": False, + "post_verifier_failed": False, + "backup_evidence_refs_complete": False, + "missing_backup_evidence_refs": sorted( + _BACKUP_VERIFIER_EVIDENCE_REF_KEYS + ), + "learning_writeback_complete": False, + "current_run_state": None, + "current_verifier_status": None, + "current_learning_writeback_status": None, + "next_action": "persist_agent99_dispatch_receipt_before_verifier", + } + + source_schema = str(receipt.get("schema_version") or "") + accepted = receipt.get("accepted") is True + inbox_triggered = ( + receipt.get("inbox_triggered") is True + or receipt.get("inboxTriggered") is True + ) + kind = str(receipt.get("kind") or "") + suggested_mode = str( + receipt.get("suggested_mode") or receipt.get("suggestedMode") or "" + ) + identity = receipt.get("identity") + if not isinstance(identity, Mapping): + identity = receipt.get("agent99_dispatch_identity") + if not isinstance(identity, Mapping): + identity = {} + agent99_run_id = str( + receipt.get("run_id") or identity.get("run_id") or "" + ).strip() + trace_id = str(receipt.get("trace_id") or identity.get("trace_id") or "").strip() + agent99_work_item_id = str( + receipt.get("work_item_id") or identity.get("work_item_id") or "" + ).strip() + legacy_schema = source_schema == "telegram_agent99_dispatch_receipt_v1" + projection_schema = ( + source_schema == "telegram_agent99_dispatch_receipt_projection_v1" + ) + valid_schema = legacy_schema or projection_schema + receipt_persisted = receipt.get("receipt_persisted") is True + identity_binding_valid = receipt.get("identity_binding_valid") is not False + identity_complete = bool( + agent99_run_id and trace_id and agent99_work_item_id + ) + backupcheck_route = kind == "backup_health" and suggested_mode == "BackupCheck" + current_verifier = receipt.get("current_verifier") + if not isinstance(current_verifier, Mapping): + current_verifier = {} + current_learning = receipt.get("current_learning_writeback") + if not isinstance(current_learning, Mapping): + current_learning = {} + + def same_stage_identity(stage: Mapping[str, Any]) -> bool: + return bool( + identity_complete + and str(stage.get("run_id") or "").strip() == agent99_run_id + and str(stage.get("trace_id") or "").strip() == trace_id + and str(stage.get("work_item_id") or "").strip() + == agent99_work_item_id + ) + + verifier_evidence_refs = current_verifier.get("evidence_refs") + if not isinstance(verifier_evidence_refs, Mapping): + verifier_evidence_refs = {} + missing_backup_evidence_refs = sorted( + key + for key in _BACKUP_VERIFIER_EVIDENCE_REF_KEYS + if not str(verifier_evidence_refs.get(key) or "").strip() + ) + backup_evidence_refs_complete = not missing_backup_evidence_refs + post_verifier_passed = bool( + receipt.get("post_verifier_passed") is True + and current_verifier.get("schema_version") + == "agent99_independent_verifier_receipt_v1" + and current_verifier.get("status") == "success" + and current_verifier.get("verifier_passed") is True + and current_verifier.get("source_event_resolved") is True + and current_verifier.get("transport_ok") is True + and current_verifier.get("outcome_state") == "resolved" + and bool(str(current_verifier.get("verified_at") or "").strip()) + and backup_evidence_refs_complete + and same_stage_identity(current_verifier) + ) + post_verifier_failed = bool( + current_verifier.get("schema_version") + == "agent99_independent_verifier_receipt_v1" + and current_verifier.get("status") == "failed" + and same_stage_identity(current_verifier) + ) + learning_refs = current_learning.get("receipt_refs") + if not isinstance(learning_refs, Mapping): + learning_refs = {} + learning_writeback_complete = bool( + current_learning.get("schema_version") + == "agent99_learning_writeback_receipt_v1" + and current_learning.get("status") == "success" + and same_stage_identity(current_learning) + and { + "incident_closure_receipt_id", + "telegram_lifecycle_receipt_id", + "km_writeback_ack_id", + "playbook_trust_writeback_ack_id", + "dr_scorecard_writeback_ack_id", + }.issubset(learning_refs) + ) + runtime_closure_verified = bool( + receipt.get("runtime_closure_verified") is True + and receipt.get("closure_complete") is True + and str(receipt.get("current_run_state") or "") == "completed" + and post_verifier_passed + and learning_writeback_complete + ) + dispatched = bool( + projection_schema + and receipt_persisted + and identity_complete + and identity_binding_valid + and accepted + and inbox_triggered + and backupcheck_route + ) + if not valid_schema: + status = "dispatch_receipt_schema_invalid" + next_action = "repair_agent99_dispatch_receipt_schema" + elif legacy_schema: + # The old Telegram mirror dispatched independently and lacked the + # durable run/trace/work-item binding. Keep it as declared history, + # never as evidence that this candidate was dispatched. + status = "legacy_dispatch_receipt_unbound" + next_action = "project_durable_agent99_dispatch_receipt_with_identity" + elif not receipt_persisted: + status = "dispatch_receipt_not_persisted_fail_closed" + next_action = "persist_agent99_dispatch_receipt_before_verifier" + elif not identity_complete: + status = "dispatch_identity_incomplete_fail_closed" + next_action = "repair_agent99_run_trace_work_item_binding" + elif not identity_binding_valid: + status = "current_ledger_identity_mismatch_fail_closed" + next_action = "repair_agent99_run_trace_work_item_binding" + elif not accepted or not inbox_triggered: + status = "dispatch_not_accepted_or_inbox_not_triggered" + next_action = "retry_idempotent_agent99_backupcheck_dispatch" + elif not backupcheck_route: + status = "dispatch_route_mismatch_fail_closed" + next_action = "repair_backupcheck_route_before_dispatch_replay" + elif runtime_closure_verified: + status = "closed_verified_learning_written" + next_action = "monitor_backup_restore_recurrence" + elif post_verifier_passed: + status = "verifier_passed_learning_writeback_pending" + next_action = "complete_km_playbook_and_lifecycle_writeback" + elif post_verifier_failed: + status = "verifier_failed_no_runtime_closure" + next_action = "collect_failed_verifier_evidence_then_retry_bounded_readback" + else: + status = "accepted_inbox_triggered" + next_action = "ingest_agent99_backupcheck_outcome_and_independent_dr_verifier" + + return { + "schema_version": "backup_restore_agent99_dispatch_readback_v1", + "source_schema_version": source_schema, + "status": status, + "receipt_present": True, + "dispatched": dispatched, + "accepted": accepted, + "inbox_triggered": inbox_triggered, + "backupcheck_route": backupcheck_route, + "receipt_persisted": receipt_persisted, + "identity_complete": identity_complete, + "identity_binding_valid": identity_binding_valid, + "kind": kind, + "suggested_mode": suggested_mode, + "transport": str(receipt.get("transport") or ""), + "alert_id": str(receipt.get("alert_id") or "") or None, + "agent99_run_id": agent99_run_id or None, + "trace_id": trace_id or None, + "agent99_work_item_id": agent99_work_item_id or None, + "stores_raw_response": False, + "runtime_closure_verified": bool( + runtime_closure_verified + ), + "post_verifier_passed": post_verifier_passed, + "post_verifier_failed": post_verifier_failed, + "backup_evidence_refs_complete": backup_evidence_refs_complete, + "missing_backup_evidence_refs": missing_backup_evidence_refs, + "learning_writeback_complete": learning_writeback_complete, + "current_run_state": str(receipt.get("current_run_state") or "") or None, + "current_verifier_status": str(current_verifier.get("status") or "") or None, + "current_learning_writeback_status": ( + str(current_learning.get("status") or "") or None + ), + "next_action": next_action, + } + + +def _safe_id(value: object, *, fallback: str) -> str: + cleaned = _SAFE_ID_RE.sub("-", str(value or "")).strip("-._:") + return (cleaned or fallback)[:96] + + +def _stable_digest(*values: object) -> str: + material = "|".join(str(value or "").strip().lower() for value in values) + return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16] + + +def _evidence_lines(text: str) -> list[str]: + """Return only the normalized card's public-safe Top evidence section.""" + + match = _TOP_EVIDENCE_RE.search(text or "") + body = match.group("body") if match else text or "" + lines: list[str] = [] + for raw_line in body.splitlines(): + plain = html.unescape(_TAG_RE.sub(" ", raw_line)) + plain = _SPACE_RE.sub(" ", plain).strip(" ├└\t") + if plain: + lines.append(plain[:240]) + if len(lines) >= 8: + break + return lines + + +def _numeric_assignment(text: str, aliases: Sequence[str]) -> float | None: + for alias in aliases: + match = re.search( + rf"{re.escape(alias)}\s*[:=]\s*(?Pcontrolled_playbook_queue=readback_only / "
+ "runtime_write_gate=0_until_verifier"
+ if spec.event_type == BACKUP_RESTORE_EVENT_TYPE
+ else "├ Gate:controlled_playbook_queue / "
+ "runtime_write_gate=controlled"
+ )
return "\n".join(
[
@@ -500,7 +514,7 @@ def format_aiops_signal_alert_card(text: str) -> str:
"",
"AI 自動化判讀",
f"├ Lane:{html.escape(spec.lane)}",
- "├ Gate:controlled_playbook_queue / runtime_write_gate=controlled",
+ gate_line,
f"└ 下一步:{html.escape(spec.next_step)}",
"",
"Top evidence",
@@ -2332,6 +2346,55 @@ def _ai_automation_alert_card_metadata(text: str) -> dict[str, object] | None:
"delivery_receipt_readback_required": True,
"mirror_source": "legacy_telegram_gateway_outbound_message",
}
+ if event_type == BACKUP_RESTORE_EVENT_TYPE:
+ declared_gates = list(gates)
+ declared_runtime_write_gate_count = (
+ 1 if runtime_write_gate_state == "controlled" else 0
+ )
+ declared_candidate_only = "candidate_only" in declared_gates
+ declared_controlled_playbook_queue = (
+ "controlled_playbook_queue" in declared_gates
+ )
+ contract = build_backup_restore_signal_automation_contract(
+ project_id="awoooi",
+ event_type=event_type,
+ lane=lane,
+ target=values.get("Target", "") or "backup_restore",
+ evidence_text=text,
+ # This envelope is assembled before the outbound row is committed.
+ # The operator readback upgrades the origin to ``persisted_at_send``
+ # only after it observes this contract in the durable source row.
+ contract_origin="generated_for_send",
+ contract_persisted_at_send=False,
+ declared_gates=declared_gates,
+ declared_runtime_write_gate_count=(
+ declared_runtime_write_gate_count
+ ),
+ declared_runtime_write_gate_state=runtime_write_gate_state,
+ declared_controlled_playbook_queue=(
+ declared_controlled_playbook_queue
+ ),
+ declared_candidate_only=declared_candidate_only,
+ )
+ metadata.update({
+ "gates": list(contract["effective_gates"]),
+ "declared_gates": declared_gates,
+ "declared_runtime_write_gate_count": (
+ declared_runtime_write_gate_count
+ ),
+ "declared_runtime_write_gate_state": runtime_write_gate_state,
+ "declared_candidate_only": declared_candidate_only,
+ "declared_controlled_playbook_queue": (
+ declared_controlled_playbook_queue
+ ),
+ "candidate_only": True,
+ "controlled_playbook_queue": False,
+ "backup_readback_candidate": True,
+ "runtime_write_gate_count": 0,
+ "runtime_write_gate_state": "evidence_only",
+ "no_false_green": True,
+ "backup_restore_automation": contract,
+ })
return metadata
@@ -2484,6 +2547,9 @@ def _agent99_alert_from_actionable_outbound(
payload["awoooi"]["incidentId"] = incident_id
payload["awoooi"]["commitSha"] = commit_sha[:40]
payload["awoooi"]["cardSchema"] = card_schema
+ backup_automation = metadata.get("backup_restore_automation")
+ if isinstance(backup_automation, dict):
+ payload["awoooi"]["backupRestoreAutomation"] = backup_automation
return payload
@@ -2504,53 +2570,91 @@ async def _mirror_ai_automation_alert_card_to_agent99(
*,
source: str = "telegram_gateway",
) -> dict[str, object] | None:
+ """Project the durable Agent99 dispatch receipt into Telegram metadata.
+
+ Telegram is deliberately not a dispatch owner. The webhook/incident lane
+ reserves and dispatches exactly once; outbound cards can only read and
+ project the receipt linked to that incident.
+ """
+
payload = _agent99_alert_from_actionable_outbound(text, source=source)
if not payload:
return None
alert_id = str(payload.get("id") or "")
- try:
- dispatch_receipt = await asyncio.to_thread(
- dispatch_agent99_sre_alert_with_receipt,
- payload,
- )
- except Exception as exc:
+ awoooi = payload.get("awoooi") if isinstance(payload.get("awoooi"), dict) else {}
+ incident_id = str(awoooi.get("incidentId") or "").strip()
+ if not incident_id:
logger.warning(
- "telegram_agent99_alert_card_mirror_failed_fail_open",
+ "telegram_agent99_receipt_projection_unbound_fail_closed",
alert_id=alert_id,
kind=payload.get("kind"),
- error=str(exc),
)
return {
- "schema_version": "telegram_agent99_dispatch_receipt_v1",
- "status": "dispatch_failed",
+ "schema_version": "telegram_agent99_dispatch_receipt_projection_v1",
+ "status": "receipt_unbound_fail_closed",
"alert_id": alert_id,
"kind": str(payload.get("kind") or "monitoring_alert"),
"suggested_mode": str(payload.get("suggestedMode") or "Status"),
"accepted": False,
"inbox_triggered": False,
- "error_type": type(exc).__name__,
+ "dispatch_performed": False,
+ "runtime_closure_verified": False,
}
- logger.info(
- "telegram_agent99_alert_card_mirrored",
- alert_id=alert_id,
- kind=payload.get("kind"),
- suggested_mode=payload.get("suggestedMode"),
- dispatch_result=dispatch_receipt.get("status"),
+ correlated_receipt = await read_agent99_dispatch_receipt(
+ project_id="awoooi",
+ incident_id=incident_id,
+ )
+ dispatch_receipt = (
+ correlated_receipt.get("dispatch_receipt")
+ if isinstance(correlated_receipt, dict)
+ and isinstance(correlated_receipt.get("dispatch_receipt"), dict)
+ else {}
+ )
+ identity = (
+ correlated_receipt.get("identity")
+ if isinstance(correlated_receipt, dict)
+ and isinstance(correlated_receipt.get("identity"), dict)
+ else {}
+ )
+ receipt_found = bool(correlated_receipt)
+ logger.info(
+ "telegram_agent99_dispatch_receipt_projected",
+ alert_id=alert_id,
+ incident_id=incident_id,
+ kind=payload.get("kind"),
+ receipt_found=receipt_found,
+ run_id=identity.get("run_id"),
)
- awoooi = payload.get("awoooi") if isinstance(payload.get("awoooi"), dict) else {}
return {
- "schema_version": "telegram_agent99_dispatch_receipt_v1",
- "status": str(dispatch_receipt.get("status") or "unknown"),
+ "schema_version": "telegram_agent99_dispatch_receipt_projection_v1",
+ "status": (
+ str(correlated_receipt.get("status") or "receipt_available")
+ if isinstance(correlated_receipt, dict)
+ else "receipt_pending_fail_closed"
+ ),
"transport": str(dispatch_receipt.get("transport") or "unknown"),
"alert_id": alert_id,
"kind": str(payload.get("kind") or "monitoring_alert"),
"suggested_mode": str(payload.get("suggestedMode") or "Status"),
"accepted": bool(dispatch_receipt.get("accepted") is True),
"inbox_triggered": bool(dispatch_receipt.get("inbox_triggered") is True),
- "incident_id": str(awoooi.get("incidentId") or ""),
+ "incident_id": incident_id,
"commit_sha": str(awoooi.get("commitSha") or ""),
+ "run_id": str(identity.get("run_id") or ""),
+ "trace_id": str(identity.get("trace_id") or ""),
+ "work_item_id": str(identity.get("work_item_id") or ""),
+ "idempotency_key": str(identity.get("idempotency_key") or ""),
+ "dispatch_performed": False,
+ "receipt_persisted": bool(
+ isinstance(correlated_receipt, dict)
+ and correlated_receipt.get("receipt_persisted") is True
+ ),
+ "runtime_closure_verified": bool(
+ isinstance(correlated_receipt, dict)
+ and correlated_receipt.get("runtime_closure_verified") is True
+ ),
"stores_raw_response": False,
}
diff --git a/apps/api/src/utils/incident_converter.py b/apps/api/src/utils/incident_converter.py
index fed1aa383..b71fa9e6b 100644
--- a/apps/api/src/utils/incident_converter.py
+++ b/apps/api/src/utils/incident_converter.py
@@ -27,6 +27,7 @@ from src.models.incident import (
IncidentStatus,
Severity,
Signal,
+ parse_signal_source,
)
logger = structlog.get_logger(__name__)
@@ -83,12 +84,10 @@ def brain_to_local(brain_incident: Any) -> Incident:
except ValueError:
sig_severity = Severity.P2
- # BrainSignal.source 是 str,LocalSignal.source 是 Literal
- # 使用 getattr 確保不因 BrainSignal 欄位增減而崩潰
- source = getattr(brain_signal, "source", "alertmanager")
- valid_sources = {"prometheus", "signoz", "alertmanager", "manual", "telegram"}
- if source not in valid_sources:
- source = "alertmanager"
+ # Preserve repo-owned producer provenance across the package
+ # boundary. Unknown values fail closed instead of being mislabeled
+ # as Alertmanager and later poisoning the durable read model.
+ source = parse_signal_source(getattr(brain_signal, "source", ""))
local_signals.append(
Signal(
diff --git a/apps/api/src/workers/signal_worker.py b/apps/api/src/workers/signal_worker.py
index f6846e7bf..bee848313 100644
--- a/apps/api/src/workers/signal_worker.py
+++ b/apps/api/src/workers/signal_worker.py
@@ -620,6 +620,7 @@ async def _main() -> None:
# Initialize and start worker
worker = await init_signal_worker()
ansible_candidate_backfill_task: asyncio.Task[Any] | None = None
+ backup_restore_legacy_backfill_task: asyncio.Task[Any] | None = None
if settings.ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER:
from src.jobs.awooop_ansible_candidate_backfill_job import (
run_awooop_ansible_candidate_backfill_loop,
@@ -634,6 +635,33 @@ async def _main() -> None:
interval_seconds=settings.AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_INTERVAL_SECONDS,
batch_limit=settings.AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_BATCH_LIMIT,
)
+ if settings.ENABLE_BACKUP_RESTORE_LEGACY_BACKFILL:
+ from src.jobs.backup_restore_legacy_backfill_job import (
+ build_backup_restore_backfill_relay_preflight,
+ run_backup_restore_legacy_backfill_loop,
+ )
+
+ relay_preflight = build_backup_restore_backfill_relay_preflight()
+ if relay_preflight["ready"] is True:
+ backup_restore_legacy_backfill_task = asyncio.create_task(
+ run_backup_restore_legacy_backfill_loop(),
+ name="run_backup_restore_legacy_backfill_loop",
+ )
+ logger.info(
+ "signal_worker_backup_restore_legacy_backfill_loop_started",
+ batch_limit=settings.BACKUP_RESTORE_LEGACY_BACKFILL_BATCH_LIMIT,
+ max_rows=settings.BACKUP_RESTORE_LEGACY_BACKFILL_MAX_ROWS,
+ auth_mode=relay_preflight["auth_mode"],
+ telegram_dispatch_performed=False,
+ )
+ else:
+ logger.warning(
+ "signal_worker_backup_restore_legacy_backfill_loop_skipped_fail_closed",
+ status=relay_preflight["status"],
+ auth_mode=relay_preflight["auth_mode"],
+ endpoint_class=relay_preflight["endpoint_class"],
+ telegram_dispatch_performed=False,
+ )
# Setup graceful shutdown
shutdown_event = asyncio.Event()
@@ -665,6 +693,12 @@ async def _main() -> None:
await ansible_candidate_backfill_task
except asyncio.CancelledError:
pass
+ if backup_restore_legacy_backfill_task is not None:
+ backup_restore_legacy_backfill_task.cancel()
+ try:
+ await backup_restore_legacy_backfill_task
+ except asyncio.CancelledError:
+ pass
await worker.stop()
await close_worker_redis_pool() # 關閉 Worker 專屬連線
await close_redis_pool()
diff --git a/apps/api/tests/test_agent99_alert_dispatch_order_contract.py b/apps/api/tests/test_agent99_alert_dispatch_order_contract.py
index 59881bfe8..f55704fa0 100644
--- a/apps/api/tests/test_agent99_alert_dispatch_order_contract.py
+++ b/apps/api/tests/test_agent99_alert_dispatch_order_contract.py
@@ -3,6 +3,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SRE_INBOX = ROOT / "agent99-sre-alert-inbox.ps1"
TELEGRAM_INBOX = ROOT / "agent99-telegram-inbox.ps1"
+CONTROL_PLANE = ROOT / "agent99-control-plane.ps1"
+SRE_RELAY = ROOT / "agent99-sre-alert-relay.ps1"
def test_alertmanager_groups_before_agent99_dispatch() -> None:
@@ -12,22 +14,40 @@ def test_alertmanager_groups_before_agent99_dispatch() -> None:
grouped_return_at = source.index(
"if grouping_result and grouping_result.is_grouped", grouping_at
)
- dispatch_at = source.index("bridge_alertmanager_to_agent99", grouped_return_at)
+ dispatch_at = source.index("_try_auto_repair_background,", grouped_return_at)
assert grouping_at < grouped_return_at < dispatch_at
-def test_agent99_bridge_has_fingerprint_single_flight_before_transport() -> None:
+def test_agent99_bridge_reserves_and_locks_before_transport() -> None:
source = (ROOT / "apps/api/src/services/agent99_sre_bridge.py").read_text(
encoding="utf-8"
)
bridge_at = source.index("async def bridge_alertmanager_to_agent99")
- lock_at = source.index("try_acquire_agent99_sre_single_flight", bridge_at)
+ reserve_at = source.index("ledger.reserve", bridge_at)
+ lock_at = source.index("acquire_agent99_sre_single_flight", reserve_at)
dispatch_at = source.index(
"dispatch_agent99_sre_alert_with_receipt, payload", lock_at
)
- assert lock_at < dispatch_at
+ assert reserve_at < lock_at < dispatch_at
+
+
+def test_telegram_is_receipt_projection_not_agent99_dispatch_owner() -> None:
+ source = (ROOT / "apps/api/src/services/telegram_gateway.py").read_text(
+ encoding="utf-8"
+ )
+ projection_at = source.index(
+ "async def _mirror_ai_automation_alert_card_to_agent99"
+ )
+ projection_end = source.index(
+ "def _schedule_agent99_ai_alert_card_mirror", projection_at
+ )
+ projection = source[projection_at:projection_end]
+
+ assert "read_agent99_dispatch_receipt" in projection
+ assert "dispatch_agent99_sre_alert_with_receipt" not in projection
+ assert '"dispatch_performed": False' in projection
def test_agent99_runtime_prioritizes_structured_route_over_text() -> None:
@@ -61,6 +81,62 @@ def test_agent99_runtime_has_local_correlation_dedupe_and_lock() -> None:
assert 'reason = "duplicate_single_flight"' in source
assert "severity_escalation_dispatch" in source
assert "correlationKey = $correlationKey" in source
+ assert "cold[-_ ]start|reboot[-_ ]auto[-_ ]recovery" in source
+ for field in (
+ "automationRunId",
+ "traceId",
+ "workItemId",
+ "idempotencyKey",
+ "executionGeneration",
+ "incidentId",
+ ):
+ assert field in source
+ assert "controlled_dispatch_identity_missing" in source
+
+ control_plane = CONTROL_PLANE.read_text(encoding="utf-8")
+ assert "controlled_dispatch_identity_missing" in control_plane
+ assert "-Name automationRunId" in control_plane
+ assert "-Name traceId" in control_plane
+ assert "-Name workItemId" in control_plane
+ for evidence_ref in (
+ "backup_status_evidence_ref",
+ "freshness_evidence_ref",
+ "offsite_verify_evidence_ref",
+ "escrow_evidence_ref",
+ "restore_drill_evidence_ref",
+ "source_resolution_receipt_ref",
+ ):
+ assert evidence_ref in control_plane
+
+
+def test_running_claim_is_reconcile_only_and_pending_retry_is_not_before() -> None:
+ source = (
+ ROOT
+ / "apps/api/src/services/agent99_controlled_dispatch_ledger.py"
+ ).read_text(encoding="utf-8")
+ reserve = source[
+ source.index("async def reserve"):source.index(
+ "async def defer_claim_retryable"
+ )
+ ]
+
+ assert 'AwoooPRunState.state == "pending"' in reserve
+ assert "AwoooPRunState.next_attempt_at.is_(None)" in reserve
+ assert "AwoooPRunState.next_attempt_at <= now" in reserve
+ assert "AwoooPRunState.lease_until < now" not in reserve
+ assert "worker_id=claim_token" in reserve
+ assert "AwoooPRunState.attempt_count < AwoooPRunState.max_attempts" in reserve
+ assert "reconciliation-only" in reserve
+
+
+def test_agent99_relay_exposes_authenticated_public_safe_outcome_readback() -> None:
+ source = SRE_RELAY.read_text(encoding="utf-8")
+
+ assert "function Get-AgentOutcomeReadback" in source
+ assert '$context.Request.HttpMethod -eq "GET"' in source
+ assert "outcome_readback_auth_not_configured" in source
+ assert 'Headers["X-Agent99-Relay-Token"]' in source
+ assert "storesRawEvidence = $false" in source
def test_telegram_ingress_writes_same_structured_route_contract() -> None:
diff --git a/apps/api/tests/test_agent99_control_plane_outcome_contract.py b/apps/api/tests/test_agent99_control_plane_outcome_contract.py
index 01d07e138..fe9994589 100644
--- a/apps/api/tests/test_agent99_control_plane_outcome_contract.py
+++ b/apps/api/tests/test_agent99_control_plane_outcome_contract.py
@@ -5,6 +5,7 @@ from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[3]
_CONTROL_PLANE = _REPO_ROOT / "agent99-control-plane.ps1"
_BOOTSTRAP = _REPO_ROOT / "agent99-bootstrap.ps1"
+_SRE_INBOX = _REPO_ROOT / "agent99-sre-alert-inbox.ps1"
def test_agent99_queue_uses_verified_outcome_not_exit_code() -> None:
@@ -64,3 +65,68 @@ def test_agent99_outcome_contract_has_runtime_self_test() -> None:
assert '$failedPostCondition.state -eq "degraded"' in source
assert '$transportFailed.state -eq "failed"' in source
assert '$securityCandidate.state -eq "candidate_ready"' in source
+
+
+def test_agent99_posts_fenced_outcome_to_production_ingestion() -> None:
+ source = _CONTROL_PLANE.read_text(encoding="utf-8")
+ inbox = _SRE_INBOX.read_text(encoding="utf-8")
+
+ assert "function Send-AgentOutcomeWriteback" in source
+ assert "/api/v1/webhooks/agent99/outcomes" in source
+ assert '"X-Agent99-Relay-Token" = $token' in source
+ assert "$attempt -le 3" in source
+ assert "agent99_outcome_receipt_id" in source
+ assert "post_verifier_evidence_ref" in source
+ assert "source_event_evidence_ref" in source
+ for field in (
+ "projectId",
+ "sourceFingerprint",
+ "dispatchRouteId",
+ "singleFlightKey",
+ "lockOwner",
+ "canonicalDigest",
+ "automationRunId",
+ "traceId",
+ "workItemId",
+ "idempotencyKey",
+ "executionGeneration",
+ "incidentId",
+ ):
+ assert field in inbox
+ assert field in source
+
+
+def test_agent99_backupcheck_reads_offsite_and_escrow_without_mutation() -> None:
+ source = _CONTROL_PLANE.read_text(encoding="utf-8")
+ readback = source[
+ source.index("function Test-AgentBackupProtectionReceipts") :
+ source.index("function Invoke-AgentBackupReadbackContractSelfTest")
+ ]
+
+ assert "[switch]$SelfTestBackupReadbackContract" in source
+ assert "function Convert-AgentBackupProtectionReadback" in source
+ for metric in (
+ "awoooi_backup_offsite_configured",
+ "awoooi_backup_offsite_fresh",
+ "awoooi_backup_offsite_remote_verify_ok",
+ "awoooi_backup_credential_escrow_fresh",
+ "awoooi_backup_dr_credential_escrow_missing_count",
+ ):
+ assert metric in source
+ assert "stat -c '__PROTECTION_MTIME__=%Y'" in readback
+ assert "grep -E" in readback
+ assert "backupRunPerformed = $false" in source
+ assert "restoreRunPerformed = $false" in source
+ assert "markerWritePerformed = $false" in source
+ assert '"offsite_verify_evidence_ref"' in source
+ assert '"escrow_evidence_ref"' in source
+ assert "protectionMaxAgeMinutes" in source
+ assert "$evidenceAgeMinutes -ge -5" in source
+ assert "$evidenceAgeMinutes -le" in source
+ assert "-eq $configured.Count" in source
+ assert "-eq $offsiteFresh.Count" in source
+ assert "-eq $remoteVerify.Count" in source
+ assert "-not $staleResult.offsiteVerify.ok" in source
+ assert "-not $partialResult.offsiteVerify.ok" in source
+ for mutation in ("rclone ", "rsync ", " rm ", "restore ", "Set-Content"):
+ assert mutation not in readback
diff --git a/apps/api/tests/test_agent99_controlled_dispatch_ledger_contract.py b/apps/api/tests/test_agent99_controlled_dispatch_ledger_contract.py
new file mode 100644
index 000000000..bbca57c5e
--- /dev/null
+++ b/apps/api/tests/test_agent99_controlled_dispatch_ledger_contract.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+import pytest
+
+from src.services.agent99_controlled_dispatch_ledger import (
+ PostgresAgent99DispatchLedger,
+ build_agent99_dispatch_identity,
+ build_agent99_dispatch_receipt_envelope,
+ parse_agent99_dispatch_identity,
+)
+
+
+def test_dispatch_identity_parser_rejects_cross_run_projection() -> None:
+ identity = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-20260711-11C751",
+ source_fingerprint="fp-cold-start",
+ route_id="agent99:host_recovery:Recover",
+ )
+ public = identity.public_dict()
+
+ assert parse_agent99_dispatch_identity(public) == identity
+ public["trace_id"] = "00-00000000000000000000000000000000-0000000000000000-01"
+ with pytest.raises(ValueError, match="identity_mismatch:trace_id"):
+ parse_agent99_dispatch_identity(public)
+
+
+def test_accepted_without_inbox_trigger_is_delivery_unknown_not_authorized() -> None:
+ identity = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-20260711-11C751",
+ source_fingerprint="fp-cold-start",
+ route_id="agent99:host_recovery:Recover",
+ )
+
+ envelope = build_agent99_dispatch_receipt_envelope(
+ identity=identity,
+ dispatch_receipt={
+ "status": "accepted_inbox_pending",
+ "accepted": True,
+ "inbox_triggered": False,
+ "delivery_certainty": "unknown",
+ },
+ controlled_apply_authorized=True,
+ )
+
+ assert envelope["status"] == "dispatch_delivery_unknown_reconcile_only"
+ assert envelope["dispatch_promoted"] is False
+ assert envelope["controlled_apply_authorized"] is False
+ assert envelope["retry_policy"]["reconcile_only"] is True
+
+
+@pytest.mark.asyncio
+async def test_backup_verifier_requires_all_six_dr_evidence_refs() -> None:
+ identity = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-20260711-BACKUP",
+ source_fingerprint="fp-backup",
+ route_id="agent99:backup_health:BackupCheck",
+ )
+ ledger = PostgresAgent99DispatchLedger()
+
+ result = await ledger.record_verifier(
+ identity=identity,
+ outcome_receipt={
+ "identity": identity.public_dict(),
+ "automationRunId": str(identity.run_id),
+ "traceId": identity.trace_id,
+ "workItemId": identity.work_item_id,
+ "mode": "BackupCheck",
+ "outcome": {
+ "identity": identity.public_dict(),
+ "schemaVersion": "agent99_outcome_contract_v1",
+ "state": "resolved",
+ "transportOk": True,
+ "verifierPassed": True,
+ "sourceEventResolved": True,
+ },
+ },
+ evidence_refs={
+ "agent99_outcome_receipt_id": "agent99:outcome:1",
+ "post_verifier_evidence_ref": "agent99:BackupCheck:verified",
+ "source_event_evidence_ref": "incident:source-resolved",
+ },
+ )
+
+ assert result["status"] == "verifier_evidence_missing_fail_closed"
+ assert set(result["missing_evidence_refs"]) == {
+ "backup_status_evidence_ref",
+ "freshness_evidence_ref",
+ "offsite_verify_evidence_ref",
+ "escrow_evidence_ref",
+ "restore_drill_evidence_ref",
+ "source_resolution_receipt_ref",
+ }
+
+
+@pytest.mark.asyncio
+async def test_backup_learning_requires_dr_scorecard_ack() -> None:
+ identity = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-20260711-BACKUP",
+ source_fingerprint="fp-backup",
+ route_id="agent99:backup_health:BackupCheck",
+ )
+ ledger = PostgresAgent99DispatchLedger()
+
+ result = await ledger.record_learning_writeback(
+ identity=identity,
+ receipt_refs={
+ "incident_closure_receipt_id": "incident:1",
+ "telegram_lifecycle_receipt_id": "telegram:1",
+ "km_writeback_ack_id": "km:1",
+ "playbook_trust_writeback_ack_id": "playbook:1",
+ },
+ )
+
+ assert result["status"] == "learning_writeback_incomplete_fail_closed"
+ assert result["missing_receipts"] == ["dr_scorecard_writeback_ack_id"]
diff --git a/apps/api/tests/test_agent99_controlled_dispatch_p1.py b/apps/api/tests/test_agent99_controlled_dispatch_p1.py
new file mode 100644
index 000000000..f7bfc4b73
--- /dev/null
+++ b/apps/api/tests/test_agent99_controlled_dispatch_p1.py
@@ -0,0 +1,899 @@
+from __future__ import annotations
+
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi import HTTPException
+
+from src.api.v1 import webhooks
+from src.db.models import IncidentRecord
+from src.models.incident import IncidentStatus
+from src.services import agent99_controlled_dispatch_ledger as ledger_module
+from src.services import agent99_outcome_ingestion as ingestion_module
+from src.services.agent99_controlled_dispatch_ledger import (
+ PostgresAgent99DispatchLedger,
+ build_agent99_dispatch_identity,
+ build_agent99_dispatch_receipt_envelope,
+ parse_agent99_dispatch_identity,
+ record_agent99_learning_writeback,
+)
+from src.services.agent99_sre_bridge import bridge_alertmanager_to_agent99
+
+
+def _identity():
+ return build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-20260711-11C751",
+ source_fingerprint="cold-start-source-fingerprint",
+ route_id="agent99_cold_start_recovery",
+ execution_generation="1",
+ work_item_id="agent99-dispatch:awoooi:INC-20260711-11C751:recovery",
+ )
+
+
+def _evidence_refs() -> dict[str, str]:
+ return {
+ "agent99_outcome_receipt_id": "agent99-outcome:queue-1",
+ "post_verifier_evidence_ref": "evidence:cold-start-scorecard:1",
+ "source_event_evidence_ref": "alert:fingerprint:resolved:1",
+ }
+
+
+def _outcome(identity, *, transport_ok: bool = True) -> dict:
+ return {
+ "identity": identity.public_dict(),
+ "controlledApply": True,
+ "outcome": {
+ "identity": identity.public_dict(),
+ "schemaVersion": "agent99_outcome_contract_v1",
+ "state": "resolved",
+ "transportOk": transport_ok,
+ "verifierPassed": True,
+ "sourceEventResolved": True,
+ "verifiedAt": "2026-07-11T20:00:00+08:00",
+ },
+ }
+
+
+def test_pending_inbox_never_promotes_or_authorizes() -> None:
+ receipt = build_agent99_dispatch_receipt_envelope(
+ identity=_identity(),
+ dispatch_receipt={
+ "accepted": True,
+ "inbox_triggered": False,
+ "delivery_certainty": "unknown",
+ },
+ controlled_apply_authorized=True,
+ )
+
+ assert receipt["dispatch_accepted"] is True
+ assert receipt["inbox_triggered"] is False
+ assert receipt["dispatch_promoted"] is False
+ assert receipt["controlled_apply_authorized"] is False
+ assert receipt["runtime_execution_authorized"] is False
+ assert receipt["verifier"]["status"] == "blocked_dispatch_not_promoted"
+ assert receipt["runtime_closure_verified"] is False
+
+
+def test_complete_identity_is_required_and_recomputed() -> None:
+ identity = _identity()
+
+ assert parse_agent99_dispatch_identity(identity.public_dict()) == identity
+ missing = identity.public_dict()
+ missing["trace_id"] = ""
+ with pytest.raises(ValueError, match="identity_fields_required:trace_id"):
+ parse_agent99_dispatch_identity(missing)
+ mismatched = identity.public_dict()
+ mismatched["run_id"] = "00000000-0000-0000-0000-000000000000"
+ with pytest.raises(ValueError, match="identity_mismatch:run_id"):
+ parse_agent99_dispatch_identity(mismatched)
+
+
+def test_approval_identity_cannot_alias_run_or_idempotency() -> None:
+ common = {
+ "project_id": "awoooi",
+ "incident_id": "INC-20260711-11C751",
+ "source_fingerprint": "cold-start-source-fingerprint",
+ "route_id": "agent99_cold_start_recovery",
+ "execution_generation": "1",
+ "work_item_id": "agent99-dispatch:awoooi:11c751",
+ }
+ first = build_agent99_dispatch_identity(
+ **common,
+ approval_id="00000000-0000-0000-0000-000000000751",
+ )
+ second = build_agent99_dispatch_identity(
+ **common,
+ approval_id="00000000-0000-0000-0000-000000000752",
+ )
+
+ assert first.run_id != second.run_id
+ assert first.idempotency_key != second.idempotency_key
+ assert first.public_dict()["canonical_digest"] != (
+ second.public_dict()["canonical_digest"]
+ )
+ assert first.single_flight_key == second.single_flight_key
+
+
+def test_terminal_incident_status_uses_database_enum_member() -> None:
+ status_type = IncidentRecord.__table__.c.status.type
+
+ assert status_type.enum_class is IncidentStatus
+ assert IncidentStatus.RESOLVED.name in status_type.enums
+ assert IncidentStatus.RESOLVED.value == "resolved"
+
+
+class _ScalarResult:
+ def __init__(self, value=None, row=None) -> None:
+ self.value = value
+ self.row = row
+
+ def scalar_one_or_none(self):
+ return self.value
+
+ def one_or_none(self):
+ return self.row
+
+
+class _Context:
+ def __init__(self, db) -> None:
+ self.db = db
+
+ async def __aenter__(self):
+ return self.db
+
+ async def __aexit__(self, *_args):
+ return False
+
+
+@pytest.mark.asyncio
+async def test_reservation_claim_tokens_are_unique_and_running_is_reconcile_only(
+ monkeypatch,
+) -> None:
+ identity = _identity()
+ claim_statements = []
+
+ class ReserveDB:
+ def __init__(self) -> None:
+ self.call = 0
+
+ async def execute(self, statement):
+ self.call += 1
+ if self.call == 3:
+ claim_statements.append(statement)
+ return _ScalarResult(identity.run_id)
+ if self.call == 5:
+ return _ScalarResult(
+ row=SimpleNamespace(
+ state="running",
+ error_detail=json.dumps({"identity": identity.public_dict()}),
+ )
+ )
+ return _ScalarResult()
+
+ databases = [ReserveDB(), ReserveDB()]
+ monkeypatch.setattr(
+ ledger_module,
+ "get_db_context",
+ lambda _project_id: _Context(databases.pop(0)),
+ )
+ ledger = PostgresAgent99DispatchLedger()
+ payload = {
+ "kind": "host_recovery",
+ "suggestedMode": "Recover",
+ "controlledApply": True,
+ "awoooi": {"targetResource": "cold-start-gate"},
+ }
+
+ first = await ledger.reserve(identity=identity, payload=payload)
+ second = await ledger.reserve(identity=identity, payload=payload)
+
+ assert first["claimed"] is True
+ assert second["claimed"] is True
+ assert first["claim_token"] != second["claim_token"]
+ claim_sql = str(claim_statements[0])
+ assert "awooop_run_state.state = :state_1" in claim_sql
+ assert "awooop_run_state.next_attempt_at IS NULL" in claim_sql
+ assert "awooop_run_state.next_attempt_at <=" in claim_sql
+ assert "awooop_run_state.lease_until <" not in claim_sql
+
+
+@pytest.mark.asyncio
+async def test_stale_worker_cannot_complete_newer_claim(monkeypatch) -> None:
+ identity = _identity()
+
+ class FencedDB:
+ async def execute(self, _statement):
+ return _ScalarResult(None)
+
+ monkeypatch.setattr(
+ ledger_module,
+ "get_db_context",
+ lambda _project_id: _Context(FencedDB()),
+ )
+ result = await PostgresAgent99DispatchLedger().complete(
+ identity=identity,
+ claim_token="stale-claim-token",
+ dispatch_receipt={
+ "accepted": True,
+ "inbox_triggered": True,
+ "delivery_certainty": "delivered",
+ },
+ controlled_apply_authorized=True,
+ )
+
+ assert result["status"] == "claim_fenced_no_write"
+ assert result["receipt_persisted"] is False
+ assert result["runtime_closure_verified"] is False
+
+
+@pytest.mark.asyncio
+async def test_redis_lock_collision_returns_run_to_retryable_pending(
+ monkeypatch,
+) -> None:
+ identity = _identity()
+ initial = {
+ "schema_version": "agent99_controlled_dispatch_receipt_v1",
+ "identity": identity.public_dict(),
+ "runtime_closure_verified": False,
+ }
+
+ class RetryDB:
+ def __init__(self) -> None:
+ self.call = 0
+
+ async def execute(self, _statement):
+ self.call += 1
+ if self.call == 1:
+ return _ScalarResult(
+ row=SimpleNamespace(
+ state="running",
+ worker_id="claim-2",
+ attempt_count=1,
+ max_attempts=3,
+ error_detail=json.dumps(initial),
+ )
+ )
+ if self.call == 2:
+ return _ScalarResult(identity.run_id)
+ return _ScalarResult()
+
+ monkeypatch.setattr(
+ ledger_module,
+ "get_db_context",
+ lambda _project_id: _Context(RetryDB()),
+ )
+ result = await PostgresAgent99DispatchLedger().defer_claim_retryable(
+ identity=identity,
+ claim_token="claim-2",
+ reason="single_flight_duplicate",
+ retry_after_seconds=600,
+ )
+
+ assert result["status"] == "dispatch_lock_retryable"
+ assert result["receipt_persisted"] is True
+ assert result["retry_policy"]["retry_same_generation"] is True
+ assert result["controlled_apply_authorized"] is False
+ assert result["runtime_closure_verified"] is False
+
+
+@pytest.mark.asyncio
+async def test_verifier_requires_transport_and_all_evidence(monkeypatch) -> None:
+ identity = _identity()
+ ledger = PostgresAgent99DispatchLedger()
+
+ missing = await ledger.record_verifier(
+ identity=identity,
+ outcome_receipt=_outcome(identity),
+ evidence_refs={"agent99_outcome_receipt_id": "one-ref-only"},
+ )
+ assert missing["status"] == "verifier_evidence_missing_fail_closed"
+ assert missing["receipt_persisted"] is False
+
+ promoted = build_agent99_dispatch_receipt_envelope(
+ identity=identity,
+ dispatch_receipt={
+ "accepted": True,
+ "inbox_triggered": True,
+ "delivery_certainty": "delivered",
+ },
+ controlled_apply_authorized=True,
+ )
+
+ class VerifierDB:
+ def __init__(self) -> None:
+ self.call = 0
+
+ async def execute(self, _statement):
+ self.call += 1
+ if self.call == 1:
+ return _ScalarResult(
+ row=SimpleNamespace(
+ state="waiting_tool",
+ error_detail=json.dumps(promoted),
+ )
+ )
+ if self.call == 2:
+ return _ScalarResult(identity.run_id)
+ if self.call == 4:
+ return _ScalarResult(identity.incident_id)
+ if self.call in {5, 6}:
+ return _ScalarResult(identity.run_id)
+ return _ScalarResult()
+
+ monkeypatch.setattr(
+ ledger_module,
+ "get_db_context",
+ lambda _project_id: _Context(VerifierDB()),
+ )
+ failed = await ledger.record_verifier(
+ identity=identity,
+ outcome_receipt=_outcome(identity, transport_ok=False),
+ evidence_refs=_evidence_refs(),
+ )
+
+ assert failed["post_verifier_passed"] is False
+ assert failed["verifier"]["transport_ok"] is False
+ assert failed["runtime_closure_verified"] is False
+
+ monkeypatch.setattr(
+ ledger_module,
+ "get_db_context",
+ lambda _project_id: _Context(VerifierDB()),
+ )
+ passed = await ledger.record_verifier(
+ identity=identity,
+ outcome_receipt=_outcome(identity, transport_ok=True),
+ evidence_refs=_evidence_refs(),
+ )
+ assert passed["post_verifier_passed"] is True
+ assert passed["verifier"]["transport_ok"] is True
+ assert passed["status"] == "verifier_passed_learning_writeback_pending"
+ assert passed["runtime_closure_verified"] is False
+
+
+@pytest.mark.asyncio
+async def test_learning_writeback_is_the_only_same_run_terminal_step(
+ monkeypatch,
+) -> None:
+ identity = _identity()
+ ledger = PostgresAgent99DispatchLedger()
+ verifier_passed = build_agent99_dispatch_receipt_envelope(
+ identity=identity,
+ dispatch_receipt={
+ "accepted": True,
+ "inbox_triggered": True,
+ "delivery_certainty": "delivered",
+ },
+ controlled_apply_authorized=True,
+ )
+ verifier_passed.update({
+ "status": "verifier_passed_learning_writeback_pending",
+ "post_verifier_passed": True,
+ "runtime_closure_verified": False,
+ "closure_complete": False,
+ "learning_writeback": {
+ "status": "pending_writeback",
+ "receipt_refs": {"km_writeback_ack_id": "km-only"},
+ },
+ })
+
+ class IncompleteCheckpointDB:
+ def __init__(self) -> None:
+ self.call = 0
+
+ async def execute(self, _statement):
+ self.call += 1
+ if self.call == 1:
+ return _ScalarResult(
+ row=SimpleNamespace(
+ state="waiting_tool",
+ error_detail=json.dumps(verifier_passed),
+ )
+ )
+ raise AssertionError("terminal tables must not be touched")
+
+ incomplete_db = IncompleteCheckpointDB()
+ monkeypatch.setattr(
+ ledger_module,
+ "get_db_context",
+ lambda _project_id: _Context(incomplete_db),
+ )
+ incomplete = await ledger.record_learning_writeback(
+ identity=identity,
+ receipt_refs={
+ "telegram_lifecycle_receipt_id": "telegram:fake",
+ "km_writeback_ack_id": "knowledge:fake",
+ "playbook_trust_writeback_ack_id": "playbook:fake",
+ },
+ )
+ assert incomplete["status"] == (
+ "learning_writeback_checkpoint_incomplete_fail_closed"
+ )
+ assert incomplete["runtime_closure_verified"] is False
+ assert incomplete_db.call == 1
+
+ checkpoint_refs = {
+ "telegram_lifecycle_receipt_id": "telegram-1",
+ "km_writeback_ack_id": "km-1",
+ "playbook_trust_writeback_ack_id": "playbook-1",
+ }
+ verifier_passed["learning_writeback"]["receipt_refs"] = checkpoint_refs
+
+ class LearningDB:
+ def __init__(self) -> None:
+ self.call = 0
+
+ async def execute(self, _statement):
+ self.call += 1
+ if self.call == 1:
+ return _ScalarResult(
+ row=SimpleNamespace(
+ state="waiting_tool",
+ error_detail=json.dumps(verifier_passed),
+ )
+ )
+ if self.call == 2:
+ return _ScalarResult(SimpleNamespace(outcome={}))
+ if self.call == 4:
+ return _ScalarResult(identity.incident_id)
+ if self.call in {5, 6}:
+ return _ScalarResult(identity.run_id)
+ return _ScalarResult()
+
+ monkeypatch.setattr(
+ ledger_module,
+ "get_db_context",
+ lambda _project_id: _Context(LearningDB()),
+ )
+ closed = await ledger.record_learning_writeback(
+ identity=identity,
+ receipt_refs=checkpoint_refs,
+ )
+
+ assert closed["status"] == "closed_verified_learning_written"
+ assert closed["runtime_closure_verified"] is True
+ assert closed["closure_complete"] is True
+
+
+@pytest.mark.asyncio
+async def test_terminal_post_commit_projection_is_project_scoped(
+ monkeypatch,
+) -> None:
+ identity = _identity()
+ incident = SimpleNamespace(incident_id=identity.incident_id)
+ episodic = AsyncMock(return_value=incident)
+ working = AsyncMock(return_value=True)
+
+ monkeypatch.setattr(
+ ledger_module._ledger,
+ "record_learning_writeback",
+ AsyncMock(
+ return_value={
+ "status": "closed_verified_learning_written",
+ "postgres_terminal_bundle_committed": True,
+ "redis_projection_pending": True,
+ "runtime_closure_verified": True,
+ }
+ ),
+ )
+ monkeypatch.setattr(
+ "src.services.incident_service.get_incident_service",
+ lambda: SimpleNamespace(
+ get_from_episodic_memory=episodic,
+ save_to_working_memory=working,
+ ),
+ )
+
+ result = await record_agent99_learning_writeback(
+ identity=identity,
+ receipt_refs={
+ "telegram_lifecycle_receipt_id": "telegram:1",
+ "km_writeback_ack_id": "knowledge:1",
+ "playbook_trust_writeback_ack_id": "playbook:1",
+ },
+ )
+
+ episodic.assert_awaited_once_with(
+ identity.incident_id,
+ project_id=identity.project_id,
+ )
+ working.assert_awaited_once_with(incident)
+ assert result["redis_projection_acknowledged"] is True
+ assert result["redis_projection_pending"] is False
+
+
+@pytest.mark.asyncio
+async def test_outcome_ingestion_ignores_untrusted_refs_and_cannot_terminalize(
+ monkeypatch,
+) -> None:
+ identity = _identity()
+ calls: list[tuple[str, str]] = []
+
+ async def verifier(**kwargs):
+ calls.append(("verifier", str(kwargs["identity"].run_id)))
+ return {
+ "status": "verifier_passed_learning_writeback_pending",
+ "receipt_persisted": True,
+ "post_verifier_passed": True,
+ "runtime_closure_verified": False,
+ }
+
+ terminal = AsyncMock(side_effect=AssertionError("terminal bypass invoked"))
+ latest = AsyncMock(side_effect=AssertionError("terminal readback invoked"))
+ monkeypatch.setattr(ingestion_module, "record_agent99_verifier_receipt", verifier)
+ monkeypatch.setattr(
+ ingestion_module,
+ "record_agent99_learning_writeback",
+ terminal,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ ingestion_module,
+ "read_agent99_dispatch_receipt",
+ latest,
+ raising=False,
+ )
+ result = await ingestion_module.ingest_agent99_outcome_receipt({
+ "identity": identity.public_dict(),
+ "outcome_receipt": _outcome(identity),
+ "evidence_refs": _evidence_refs(),
+ "learning_receipt_refs": {
+ "telegram_lifecycle_receipt_id": "telegram:fake",
+ "km_writeback_ack_id": "knowledge:fake",
+ "playbook_trust_writeback_ack_id": "playbook:fake",
+ },
+ })
+
+ assert calls == [("verifier", str(identity.run_id))]
+ terminal.assert_not_awaited()
+ latest.assert_not_awaited()
+ assert result["status"] == "verifier_recorded_learning_pending"
+ assert result["learning_writeback"]["status"] == (
+ "untrusted_external_receipts_ignored_reconciler_pending"
+ )
+ assert result["untrusted_learning_receipt_refs_ignored"] is True
+ assert result["runtime_closure_verified"] is False
+
+
+@pytest.mark.asyncio
+async def test_authenticated_backup_outcome_merges_all_nine_safe_refs(
+ monkeypatch,
+) -> None:
+ identity = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-BRR-20260711-001",
+ source_fingerprint="backup-source-fingerprint",
+ route_id="agent99:backup_health:BackupCheck",
+ work_item_id="agent99-dispatch:awoooi:backup-readback",
+ )
+ captured: dict[str, object] = {}
+
+ async def verifier(**kwargs):
+ captured.update(kwargs)
+ return {
+ "status": "verifier_passed_learning_writeback_pending",
+ "receipt_persisted": True,
+ "post_verifier_passed": True,
+ "runtime_closure_verified": False,
+ }
+
+ monkeypatch.setattr(ingestion_module, "record_agent99_verifier_receipt", verifier)
+ result = await ingestion_module.ingest_agent99_outcome_receipt({
+ "identity": identity.public_dict(),
+ "evidence_refs": {
+ "agent99_outcome_receipt_id": "agent99-outcome:backup-1",
+ "post_verifier_evidence_ref": "backupcheck:verified:1",
+ "source_event_evidence_ref": "backup-alert:resolved:1",
+ "untrusted_extra": "must-not-persist",
+ },
+ "outcome_receipt": {
+ "identity": identity.public_dict(),
+ "mode": "BackupCheck",
+ "outcome": {
+ "identity": identity.public_dict(),
+ "schemaVersion": "agent99_outcome_contract_v1",
+ "state": "resolved",
+ "transportOk": True,
+ "verifierPassed": True,
+ "sourceEventResolved": True,
+ "evidenceRefs": {
+ "backup_status_evidence_ref": "backup-status:run:1",
+ "freshness_evidence_ref": "backup-freshness:slo:1",
+ "offsite_verify_evidence_ref": "offsite-verify:run:1",
+ "escrow_evidence_ref": "escrow-metadata:run:1",
+ "restore_drill_evidence_ref": "restore-drill:run:1",
+ "source_resolution_receipt_ref": "source-resolution:run:1",
+ "token": "must-not-persist",
+ },
+ },
+ },
+ })
+
+ refs = captured["evidence_refs"]
+ assert set(refs) == {
+ "agent99_outcome_receipt_id",
+ "post_verifier_evidence_ref",
+ "source_event_evidence_ref",
+ "backup_status_evidence_ref",
+ "freshness_evidence_ref",
+ "offsite_verify_evidence_ref",
+ "escrow_evidence_ref",
+ "restore_drill_evidence_ref",
+ "source_resolution_receipt_ref",
+ }
+ assert "must-not-persist" not in str(refs)
+ assert captured["identity"].run_id == identity.run_id
+ assert result["status"] == "verifier_recorded_learning_pending"
+ assert result["runtime_closure_verified"] is False
+
+
+def test_outcome_evidence_ref_filter_rejects_secret_shaped_values() -> None:
+ refs = ingestion_module._public_evidence_refs(
+ {
+ "agent99_outcome_receipt_id": "https://unsafe.invalid/outcome",
+ "post_verifier_evidence_ref": "verifier:public-ref",
+ "source_event_evidence_ref": "source:public-ref",
+ },
+ {
+ "outcome": {
+ "evidenceRefs": {
+ "backup_status_evidence_ref": "token=must-not-persist",
+ "freshness_evidence_ref": "freshness:public-ref",
+ "offsite_verify_evidence_ref": "receipt?token=unsafe",
+ "escrow_evidence_ref": "../runtime-secret",
+ "restore_drill_evidence_ref": "Bearer must-not-persist",
+ }
+ }
+ },
+ )
+
+ assert refs == {
+ "post_verifier_evidence_ref": "verifier:public-ref",
+ "source_event_evidence_ref": "source:public-ref",
+ "freshness_evidence_ref": "freshness:public-ref",
+ }
+
+
+@pytest.mark.asyncio
+async def test_outcome_webhook_requires_shared_token(monkeypatch) -> None:
+ identity = _identity()
+ request = webhooks.Agent99OutcomeWebhookPayload(
+ identity=identity.public_dict(),
+ outcome_receipt=_outcome(identity),
+ evidence_refs=_evidence_refs(),
+ )
+ monkeypatch.setattr(
+ webhooks.settings,
+ "AGENT99_SRE_ALERT_RELAY_TOKEN",
+ "relay-token",
+ )
+ with pytest.raises(HTTPException) as exc_info:
+ await webhooks.ingest_agent99_outcome_webhook(request, None)
+ assert exc_info.value.status_code == 401
+
+ async def accepted(_payload):
+ return {
+ "status": "verifier_recorded_learning_pending",
+ "accepted": True,
+ "runtime_closure_verified": False,
+ }
+
+ monkeypatch.setattr(webhooks, "ingest_agent99_outcome_receipt", accepted)
+ response = await webhooks.ingest_agent99_outcome_webhook(
+ request,
+ "relay-token",
+ )
+ assert response["accepted"] is True
+ assert response["runtime_closure_verified"] is False
+
+
+@pytest.mark.asyncio
+async def test_safe_failed_generation_advances_bounded_and_keeps_one_flight_key(
+ monkeypatch,
+) -> None:
+ identities = []
+ flight_calls: list[tuple[str, str]] = []
+
+ class GenerationLedger:
+ async def reserve(self, *, identity, payload):
+ identities.append(identity)
+ if identity.execution_generation == "1":
+ return {
+ "status": "idempotent_failed",
+ "claimed": False,
+ "receipt": {
+ "status": "dispatch_not_delivered_retry_safe",
+ "retry_policy": {
+ "safe_to_retry": True,
+ "requires_generation_advance": True,
+ "max_generation": 3,
+ },
+ },
+ }
+ return {
+ "status": "reserved",
+ "claimed": True,
+ "claim_token": "generation-2-claim",
+ "receipt": None,
+ }
+
+ async def complete(
+ self,
+ *,
+ identity,
+ claim_token,
+ dispatch_receipt,
+ **_kwargs,
+ ):
+ return {
+ **build_agent99_dispatch_receipt_envelope(
+ identity=identity,
+ dispatch_receipt=dispatch_receipt,
+ controlled_apply_authorized=True,
+ ),
+ "receipt_persisted": True,
+ }
+
+ async def mark_delivery_attempt_started(self, *, identity, claim_token):
+ return {
+ "status": "dispatch_delivery_unknown_reconcile_only",
+ "identity": identity.public_dict(),
+ "receipt_persisted": True,
+ }
+
+ async def acquire(key, owner, **_kwargs):
+ flight_calls.append((key, owner))
+ return {"acquired": True, "reason": "acquired"}
+
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.get_agent99_dispatch_ledger",
+ lambda: GenerationLedger(),
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
+ acquire,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.dispatch_agent99_sre_alert_with_receipt",
+ lambda _payload: {
+ "status": "accepted_inbox_triggered",
+ "transport": "relay",
+ "accepted": True,
+ "inbox_triggered": True,
+ "delivery_certainty": "delivered",
+ },
+ )
+
+ result = await bridge_alertmanager_to_agent99(
+ alert_id="recurrence-2",
+ alertname="ColdStartGateBlocked",
+ severity="critical",
+ namespace="host_recovery",
+ target_resource="cold-start-gate",
+ message="cold-start gate blocked",
+ fingerprint="same-source-fingerprint",
+ incident_id="INC-20260711-11C751",
+ route_id="agent99_cold_start_recovery",
+ )
+
+ assert result["status"] == "dispatched"
+ assert [item.execution_generation for item in identities] == ["1", "2"]
+ assert identities[0].idempotency_key != identities[1].idempotency_key
+ assert identities[0].single_flight_key == identities[1].single_flight_key
+ assert flight_calls == [
+ (identities[1].single_flight_key, "generation-2-claim")
+ ]
+
+
+@pytest.mark.asyncio
+async def test_retry_not_before_does_not_burn_generation_or_transport(
+ monkeypatch,
+) -> None:
+ identities = []
+
+ class CooldownLedger:
+ async def reserve(self, *, identity, payload): # type: ignore[no-untyped-def]
+ identities.append(identity)
+ return {
+ "status": "retry_not_before",
+ "claimed": False,
+ "next_attempt_at": "2026-07-11T20:10:00",
+ "receipt": {
+ "status": "dispatch_not_delivered_retry_safe",
+ "retry_policy": {
+ "safe_to_retry": True,
+ "requires_generation_advance": True,
+ "max_generation": 3,
+ },
+ },
+ }
+
+ async def unexpected_flight(*_args, **_kwargs): # type: ignore[no-untyped-def]
+ raise AssertionError("cooldown must return before Redis or transport")
+
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.get_agent99_dispatch_ledger",
+ lambda: CooldownLedger(),
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
+ unexpected_flight,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.dispatch_agent99_sre_alert_with_receipt",
+ lambda _payload: (_ for _ in ()).throw(
+ AssertionError("cooldown must not dispatch")
+ ),
+ )
+
+ result = await bridge_alertmanager_to_agent99(
+ alert_id="cooldown-recurrence",
+ alertname="ColdStartGateBlocked",
+ severity="critical",
+ namespace="host_recovery",
+ target_resource="cold-start-gate",
+ message="cold-start gate blocked",
+ fingerprint="same-source-fingerprint",
+ incident_id="INC-20260711-11C751",
+ route_id="agent99_cold_start_recovery",
+ )
+
+ assert result["status"] == "retryable"
+ assert result["reason"] == "durable_retry_not_before"
+ assert result["nextAttemptAt"] == "2026-07-11T20:10:00"
+ assert [item.execution_generation for item in identities] == ["1"]
+
+
+@pytest.mark.asyncio
+async def test_webhook_pending_inbox_is_not_queued_or_authorized(monkeypatch) -> None:
+ append = AsyncMock()
+ monkeypatch.setattr(
+ "src.repositories.alert_operation_log_repository.get_alert_operation_log_repository",
+ lambda: SimpleNamespace(append=append),
+ )
+ monkeypatch.setattr(
+ webhooks,
+ "bridge_alertmanager_to_agent99",
+ AsyncMock(
+ return_value={
+ "status": "delivery_pending",
+ "reason": "accepted_without_inbox_triggered",
+ "dispatchPerformed": True,
+ "identity": _identity().public_dict(),
+ "dispatchReceipt": {
+ "accepted": True,
+ "inbox_triggered": False,
+ "status": "accepted_inbox_pending",
+ },
+ "correlatedReceipt": {
+ "receipt_persisted": True,
+ "dispatch_promoted": False,
+ "controlled_apply_authorized": False,
+ "runtime_closure_verified": False,
+ },
+ }
+ ),
+ )
+
+ handoff = await webhooks._try_auto_repair_background(
+ incident_id="INC-20260711-11C751",
+ approval_id="00000000-0000-0000-0000-000000000751",
+ alert_type="ColdStartGateBlocked",
+ target_resource="cold-start-gate",
+ namespace="host_recovery",
+ risk_level="low",
+ source_alert_id="pending-inbox",
+ source_alertname="ColdStartGateBlocked",
+ source_fingerprint="same-source-fingerprint",
+ )
+
+ assert handoff["queued"] is False
+ assert handoff["dispatch_accepted"] is True
+ assert handoff["inbox_triggered"] is False
+ assert handoff["dispatch_promoted"] is False
+ assert handoff["runtime_execution_authorized"] is False
+ assert handoff["runtime_closure_verified"] is False
+ assert append.await_args.kwargs["success"] is False
diff --git a/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py
new file mode 100644
index 000000000..4b6a4ab95
--- /dev/null
+++ b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py
@@ -0,0 +1,313 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from src.jobs import agent99_controlled_dispatch_reconciler_job as job
+from src.services.agent99_controlled_dispatch_ledger import (
+ build_agent99_dispatch_identity,
+)
+
+
+class _Ledger:
+ def __init__(self, receipt: dict[str, object]) -> None:
+ self.receipt = receipt
+ self.verifier_calls: list[dict[str, object]] = []
+
+ async def list_reconcilable(self, **_kwargs): # type: ignore[no-untyped-def]
+ return [{"identity": IDENTITY, "receipt": self.receipt}]
+
+ async def record_verifier(self, **kwargs): # type: ignore[no-untyped-def]
+ self.verifier_calls.append(kwargs)
+ return {
+ **self.receipt,
+ "post_verifier_passed": True,
+ "receipt_persisted": True,
+ "verifier": {
+ "status": "success",
+ "evidence_refs": kwargs["evidence_refs"],
+ },
+ }
+
+
+IDENTITY = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-20260711-11C751",
+ source_fingerprint="fp-cold-start",
+ route_id="agent99:host_recovery:Recover",
+)
+
+
+def test_agent99_playbook_identity_is_project_scoped() -> None:
+ other = build_agent99_dispatch_identity(
+ project_id="other-project",
+ incident_id=IDENTITY.incident_id,
+ source_fingerprint=IDENTITY.source_fingerprint,
+ route_id=IDENTITY.route_id,
+ )
+
+ assert job._playbook_id(IDENTITY) != job._playbook_id(other)
+
+
+@pytest.mark.asyncio
+async def test_reconciler_consumes_authenticated_outcome_and_closes_same_run(
+ monkeypatch,
+) -> None:
+ ledger = _Ledger({
+ "status": "dispatch_accepted_verifier_pending",
+ "post_verifier_passed": False,
+ "dispatch_receipt": {"kind": "host_recovery"},
+ })
+ finalized: list[dict[str, object]] = []
+
+ monkeypatch.setattr(job, "get_agent99_dispatch_ledger", lambda: ledger)
+ monkeypatch.setattr(
+ job,
+ "read_agent99_sre_outcome",
+ lambda _run_id: {
+ "automationRunId": str(IDENTITY.run_id),
+ "traceId": IDENTITY.trace_id,
+ "workItemId": IDENTITY.work_item_id,
+ "mode": "Recover",
+ "controlledApply": True,
+ "outcome": {
+ "schemaVersion": "agent99_outcome_contract_v1",
+ "state": "resolved",
+ "transportOk": True,
+ "verifierName": "agent99-recover-post-condition-v1",
+ "verifierPassed": True,
+ "sourceEventResolved": True,
+ "verifiedAt": "2026-07-11T20:00:00+08:00",
+ },
+ },
+ )
+
+ async def finalize(identity, *, mode, evidence_refs): # type: ignore[no-untyped-def]
+ finalized.append({
+ "identity": identity,
+ "mode": mode,
+ "evidence_refs": evidence_refs,
+ })
+ return {"runtime_closure_verified": True}
+
+ monkeypatch.setattr(job, "_finalize_learning", finalize)
+
+ result = await job.reconcile_agent99_controlled_dispatches_once()
+
+ assert result == {"scanned": 1, "verifier_written": 1, "closed": 1}
+ assert len(ledger.verifier_calls) == 1
+ refs = ledger.verifier_calls[0]["evidence_refs"]
+ assert refs["agent99_outcome_receipt_id"].endswith(str(IDENTITY.run_id))
+ assert "post_verifier_evidence_ref" in refs
+ assert "source_event_evidence_ref" in refs
+ assert finalized[0]["identity"].run_id == IDENTITY.run_id
+ assert finalized[0]["mode"] == "Recover"
+
+
+@pytest.mark.asyncio
+async def test_reconciler_does_not_finalize_without_outcome(monkeypatch) -> None:
+ ledger = _Ledger({
+ "status": "dispatch_delivery_unknown_reconcile_only",
+ "post_verifier_passed": False,
+ })
+ monkeypatch.setattr(job, "get_agent99_dispatch_ledger", lambda: ledger)
+ monkeypatch.setattr(job, "read_agent99_sre_outcome", lambda _run_id: None)
+
+ result = await job.reconcile_agent99_controlled_dispatches_once()
+
+ assert result == {"scanned": 1, "verifier_written": 0, "closed": 0}
+ assert ledger.verifier_calls == []
+
+
+def test_windows_relay_outcome_readback_is_authenticated_and_public_safe() -> None:
+ source = Path(__file__).resolve().parents[3] / "agent99-sre-alert-relay.ps1"
+ text = source.read_text(encoding="utf-8")
+
+ assert '$context.Request.HttpMethod -eq "GET"' in text
+ assert "outcome_readback_auth_not_configured" in text
+ assert 'Headers["X-Agent99-Relay-Token"]' in text
+ assert "storesRawEvidence = $false" in text
+ assert "stdout" not in text[text.index("function Get-AgentOutcomeReadback"):text.index("function Write-AgentRelayEvent")]
+
+
+@pytest.mark.asyncio
+async def test_exact_11c751_legacy_failure_backfills_once_without_recurrence() -> None:
+ row = {
+ "incident_id": "INC-20260711-11C751",
+ "project_id": "awoooi",
+ "alertname": "ColdStartGateBlocked",
+ "severity": "low",
+ "alert_category": "general",
+ "notification_type": "legacy_backfill",
+ "approval_id": "00000000-0000-0000-0000-000000000751",
+ "approval_fingerprint": "legacy-source-fingerprint-11c751",
+ "approval_status": "execution_failed",
+ }
+ calls: list[dict[str, object]] = []
+ dispatched_keys: set[tuple[str, str, str]] = set()
+ fetch_count = 0
+
+ async def fetcher(**_kwargs): # type: ignore[no-untyped-def]
+ nonlocal fetch_count
+ fetch_count += 1
+ if fetch_count == 1:
+ return [row]
+ return [{
+ **row,
+ "latest_approval_id": "00000000-0000-0000-0000-000000000752",
+ "latest_approval_fingerprint": "newer-approval-fingerprint-11c751",
+ }]
+
+ async def dispatcher(**kwargs): # type: ignore[no-untyped-def]
+ calls.append(kwargs)
+ key = (
+ str(kwargs["incident_id"]),
+ str(kwargs["fingerprint"]),
+ str(kwargs["route_id"]),
+ )
+ if key in dispatched_keys:
+ return {"status": "deduplicated", "dispatchPerformed": False}
+ dispatched_keys.add(key)
+ return {"status": "dispatched", "dispatchPerformed": True}
+
+ first = await job.backfill_legacy_agent99_dispatches_once(
+ fetcher=fetcher,
+ dispatcher=dispatcher,
+ )
+ second = await job.backfill_legacy_agent99_dispatches_once(
+ fetcher=fetcher,
+ dispatcher=dispatcher,
+ )
+
+ assert first == {
+ "scanned": 1,
+ "dispatch_performed": 1,
+ "deduplicated": 0,
+ "retryable": 0,
+ "blocked": 0,
+ }
+ assert second == {
+ "scanned": 1,
+ "dispatch_performed": 0,
+ "deduplicated": 1,
+ "retryable": 0,
+ "blocked": 0,
+ }
+ assert len(dispatched_keys) == 1
+ assert calls[0]["incident_id"] == "INC-20260711-11C751"
+ assert calls[0]["namespace"] == "host_recovery"
+ assert calls[0]["target_resource"] == "cold-start-gate"
+ assert calls[0]["route_id"] == "agent99:host_recovery:Recover"
+ assert calls[0]["fingerprint"].startswith("legacy-cold-start:")
+ assert calls[0]["fingerprint"] == calls[1]["fingerprint"]
+ assert calls[0]["approval_id"] == calls[1]["approval_id"]
+ assert calls[0]["labels"]["legacy_approval_fingerprint"] == (
+ calls[1]["labels"]["legacy_approval_fingerprint"]
+ )
+ assert calls[0]["labels"]["legacy_executor"] == "ansible"
+ assert calls[0]["labels"]["legacy_status"] == "execution_failed"
+
+
+@pytest.mark.asyncio
+async def test_learning_checkpoint_resumes_without_replaying_assets(
+ monkeypatch,
+) -> None:
+ identity = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-20260711-BACKUP",
+ source_fingerprint="backup-source-1",
+ route_id="agent99:backup_health:BackupCheck",
+ approval_id="00000000-0000-0000-0000-000000000751",
+ )
+
+ class CheckpointLedger:
+ def __init__(self) -> None:
+ self.refs: dict[str, str] = {}
+
+ async def record_learning_asset_acks(
+ self,
+ *,
+ identity, # type: ignore[no-untyped-def]
+ receipt_refs,
+ ) -> dict[str, object]:
+ self.refs.update(receipt_refs)
+ return {
+ "status": "learning_asset_acks_recorded",
+ "receipt_persisted": True,
+ "learning_receipt_refs": dict(self.refs),
+ "runtime_closure_verified": False,
+ }
+
+ ledger = CheckpointLedger()
+ effects = {"playbook": 0, "km": 0, "telegram": 0, "dr": 0}
+ terminal_calls = 0
+
+ async def write_playbook(*_args, **_kwargs): # type: ignore[no-untyped-def]
+ effects["playbook"] += 1
+ return "playbooks:PB-A99:version:1"
+
+ async def write_km(*_args, **_kwargs): # type: ignore[no-untyped-def]
+ effects["km"] += 1
+ return "knowledge_entries:km-1:embedding_verified"
+
+ async def write_telegram(*_args, **_kwargs): # type: ignore[no-untyped-def]
+ effects["telegram"] += 1
+ return "telegram_outbound:1:durable_ack"
+
+ async def write_dr(*_args, **_kwargs): # type: ignore[no-untyped-def]
+ effects["dr"] += 1
+ return "knowledge_entries:dr-1:dr_scorecard"
+
+ async def terminal(**_kwargs): # type: ignore[no-untyped-def]
+ nonlocal terminal_calls
+ terminal_calls += 1
+ if terminal_calls == 1:
+ return {
+ "status": "learning_writeback_persistence_failed",
+ "runtime_closure_verified": False,
+ }
+ return {
+ "status": "closed_verified_learning_written",
+ "runtime_closure_verified": True,
+ }
+
+ monkeypatch.setattr(job, "get_agent99_dispatch_ledger", lambda: ledger)
+ monkeypatch.setattr(job, "_ensure_playbook_trust", write_playbook)
+ monkeypatch.setattr(job, "_ensure_km_writeback", write_km)
+ monkeypatch.setattr(job, "_ensure_telegram_receipt", write_telegram)
+ monkeypatch.setattr(job, "_ensure_dr_scorecard", write_dr)
+ monkeypatch.setattr(job, "record_agent99_learning_writeback", terminal)
+
+ first = await job._finalize_learning(
+ identity,
+ mode="BackupCheck",
+ evidence_refs={},
+ )
+ second = await job._finalize_learning(
+ identity,
+ mode="BackupCheck",
+ evidence_refs={},
+ )
+
+ assert first["runtime_closure_verified"] is False
+ assert second["runtime_closure_verified"] is True
+ assert effects == {"playbook": 1, "km": 1, "telegram": 1, "dr": 1}
+ assert terminal_calls == 2
+
+
+def test_legacy_backfill_query_is_bounded_and_requires_failure_evidence() -> None:
+ source = Path(job.__file__).read_text(encoding="utf-8")
+ fetch = source[
+ source.index("async def _fetch_legacy_cold_start_failures"):source.index(
+ "def _legacy_source_fingerprint"
+ )
+ ]
+
+ assert 'LEGACY_COLD_START_INCIDENT_ID = "INC-20260711-11C751"' in source
+ assert "lower(approval_state.status::text) = 'execution_failed'" in fetch
+ assert "ORDER BY candidate.created_at ASC, candidate.id ASC" in fetch
+ assert "legacy.input ->> 'executor' = 'ansible'" in fetch
+ assert "LIMIT :limit" in fetch
+ assert "statement_timeout = '5000ms'" in fetch
+ assert "backfill_legacy_agent99_dispatches_once()" in source
diff --git a/apps/api/tests/test_agent99_dispatch_receipt_projection.py b/apps/api/tests/test_agent99_dispatch_receipt_projection.py
new file mode 100644
index 000000000..d7b292828
--- /dev/null
+++ b/apps/api/tests/test_agent99_dispatch_receipt_projection.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import pytest
+
+from src.services import telegram_gateway as telegram_gateway_module
+
+
+@pytest.mark.asyncio
+async def test_telegram_projects_correlated_receipt_without_dispatch(
+ monkeypatch,
+) -> None:
+ calls: list[tuple[str, str]] = []
+ correlated_receipt = {
+ "schema_version": "agent99_controlled_dispatch_receipt_v1",
+ "status": "dispatch_accepted_verifier_pending",
+ "identity": {
+ "incident_id": "INC-20260711-11C751",
+ "run_id": "7cf8fdf7-0966-5ac6-95b2-09e32808b248",
+ "trace_id": "00-11111111111111111111111111111111-2222222222222222-01",
+ "work_item_id": "agent99-dispatch:awoooi:INC-20260711-11C751:recovery",
+ "idempotency_key": "agent99-controlled:stable-key",
+ },
+ "dispatch_receipt": {
+ "status": "accepted_inbox_triggered",
+ "transport": "relay",
+ "accepted": True,
+ "inbox_triggered": True,
+ },
+ "receipt_persisted": True,
+ "runtime_execution_authorized": False,
+ "runtime_closure_verified": False,
+ "verifier": {"status": "pending"},
+ "learning_writeback": {"status": "pending_verifier"},
+ }
+
+ async def fake_read(*, project_id: str, incident_id: str):
+ calls.append((project_id, incident_id))
+ return correlated_receipt
+
+ monkeypatch.setattr(
+ telegram_gateway_module,
+ "read_agent99_dispatch_receipt",
+ fake_read,
+ )
+
+ projection = (
+ await telegram_gateway_module._mirror_ai_automation_alert_card_to_agent99(
+ "\n".join(
+ [
+ "ℹ️ ACTION REQUIRED | 低風險",
+ "📋 INC-20260711-11C751",
+ "🎯 資源:cold-start-gate",
+ "🏷️ 分類:general",
+ "🤖 AI 自動化鏈路",
+ ]
+ ),
+ source="unit-test",
+ )
+ )
+
+ assert calls == [("awoooi", "INC-20260711-11C751")]
+ assert projection is not None
+ assert projection["schema_version"] == (
+ "telegram_agent99_dispatch_receipt_projection_v1"
+ )
+ assert projection["dispatch_performed"] is False
+ assert projection["accepted"] is True
+ assert projection["incident_id"] == "INC-20260711-11C751"
+ assert projection["run_id"] == correlated_receipt["identity"]["run_id"]
+ assert projection["trace_id"] == correlated_receipt["identity"]["trace_id"]
+ assert projection["idempotency_key"] == (
+ correlated_receipt["identity"]["idempotency_key"]
+ )
+ assert projection["runtime_closure_verified"] is False
+
+
+@pytest.mark.asyncio
+async def test_telegram_unbound_card_fails_closed_without_receipt_read(
+ monkeypatch,
+) -> None:
+ reads: list[str] = []
+
+ async def unexpected_read(*, project_id: str, incident_id: str):
+ reads.append(f"{project_id}:{incident_id}")
+ return None
+
+ monkeypatch.setattr(
+ telegram_gateway_module,
+ "read_agent99_dispatch_receipt",
+ unexpected_read,
+ )
+
+ card = telegram_gateway_module.format_aiops_signal_alert_card(
+ "SourceProviderIngestionStale provider_freshness_signal freshness "
+ "window timeout Sentry SigNoz provider freshness last seen missing "
+ "direct ref verifier readback"
+ )
+ projection = await telegram_gateway_module._mirror_ai_automation_alert_card_to_agent99(
+ card,
+ source="unit-test",
+ )
+
+ assert projection is not None
+ assert projection["dispatch_performed"] is False
+ assert projection["status"] == "receipt_unbound_fail_closed"
+ assert reads == []
diff --git a/apps/api/tests/test_agent99_sre_bridge.py b/apps/api/tests/test_agent99_sre_bridge.py
index 8cce78a4d..3a69cbe9c 100644
--- a/apps/api/tests/test_agent99_sre_bridge.py
+++ b/apps/api/tests/test_agent99_sre_bridge.py
@@ -4,7 +4,12 @@ import json
import pytest
+from src.services.agent99_controlled_dispatch_ledger import (
+ build_agent99_dispatch_identity,
+ build_agent99_dispatch_receipt_envelope,
+)
from src.services.agent99_sre_bridge import (
+ acquire_agent99_sre_single_flight,
agent99_sre_single_flight_key,
bridge_alertmanager_to_agent99,
build_agent99_sre_alert,
@@ -54,6 +59,35 @@ def test_provider_freshness_alert_builds_agent99_contract() -> None:
assert payload["routing"]["correlationKey"] == payload["routing"]["groupKey"]
+def test_explicit_backup_identity_wins_over_generic_freshness_words() -> None:
+ payload = build_agent99_sre_alert(
+ alert_id="backup-escrow-missing",
+ alertname="BackupCredentialEscrowEvidenceMissing",
+ severity="critical",
+ namespace="awoooi-prod",
+ target_resource="backup_restore",
+ message=(
+ "backup freshness missing; escrow evidence missing; "
+ "readback verifier required"
+ ),
+ labels={
+ "event_type": "backup_restore_escrow_signal",
+ "lane": "backup_restore_escrow_triage",
+ },
+ fingerprint="fp-backup-freshness-collision",
+ )
+
+ assert payload["kind"] == "backup_health"
+ assert payload["suggestedMode"] == "BackupCheck"
+ assert payload["controlledApply"] is False
+ assert "backup-readback" in payload["labels"]
+ assert "no-false-green" in payload["labels"]
+ assert "read-only" in payload["labels"]
+ assert "do not run backup/restore" in payload["instruction"]
+ assert "remote delete" in payload["instruction"]
+ assert payload["resolution"]["policy"] == "mode_verifier_can_resolve"
+
+
@pytest.mark.parametrize(
("alertname", "target", "message", "labels", "expected_kind", "expected_mode"),
[
@@ -331,6 +365,7 @@ def test_dispatch_receipt_keeps_relay_acceptance_without_raw_body(monkeypatch) -
"http_status": 202,
"accepted": True,
"inbox_triggered": True,
+ "delivery_certainty": "delivered",
"stores_raw_response": False,
}
assert "private-path" not in str(receipt)
@@ -345,12 +380,12 @@ def test_agent99_single_flight_key_does_not_expose_fingerprint() -> None:
@pytest.mark.asyncio
async def test_agent99_bridge_suppresses_duplicate_single_flight(monkeypatch) -> None:
- async def duplicate(*_args, **_kwargs) -> bool:
- return False
+ async def duplicate(*_args, **_kwargs) -> dict[str, object]:
+ return {"acquired": False, "reason": "single_flight_duplicate"}
dispatched: list[dict[str, object]] = []
monkeypatch.setattr(
- "src.services.agent99_sre_bridge.try_acquire_agent99_sre_single_flight",
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
duplicate,
)
monkeypatch.setattr(
@@ -366,11 +401,11 @@ async def test_agent99_bridge_suppresses_duplicate_single_flight(monkeypatch) ->
result = await bridge_alertmanager_to_agent99(
alert_id="duplicate-alert",
- alertname="HostDown",
+ alertname="UnknownObservation",
severity="critical",
namespace="node",
target_resource="192.168.0.120",
- message="host_down",
+ message="observation only",
fingerprint="same-fingerprint",
)
@@ -381,12 +416,12 @@ async def test_agent99_bridge_suppresses_duplicate_single_flight(monkeypatch) ->
@pytest.mark.asyncio
async def test_agent99_bridge_dispatches_single_flight_winner(monkeypatch) -> None:
- async def winner(*_args, **_kwargs) -> bool:
- return True
+ async def winner(*_args, **_kwargs) -> dict[str, object]:
+ return {"acquired": True, "reason": "acquired"}
dispatched: list[dict[str, object]] = []
monkeypatch.setattr(
- "src.services.agent99_sre_bridge.try_acquire_agent99_sre_single_flight",
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
winner,
)
monkeypatch.setattr(
@@ -402,11 +437,11 @@ async def test_agent99_bridge_dispatches_single_flight_winner(monkeypatch) -> No
result = await bridge_alertmanager_to_agent99(
alert_id="winner-alert",
- alertname="HostDown",
+ alertname="UnknownObservation",
severity="critical",
namespace="node",
target_resource="192.168.0.120",
- message="host_down",
+ message="observation only",
fingerprint="winner-fingerprint",
)
@@ -414,7 +449,7 @@ async def test_agent99_bridge_dispatches_single_flight_winner(monkeypatch) -> No
assert result["dispatch"] == "relay"
assert result["dispatchReceipt"]["accepted"] is True
assert result["dispatchReceipt"]["inbox_triggered"] is True
- assert result["suggestedMode"] == "Recover"
+ assert result["suggestedMode"] == "Status"
assert len(dispatched) == 1
@@ -422,8 +457,8 @@ async def test_agent99_bridge_dispatches_single_flight_winner(monkeypatch) -> No
async def test_agent99_bridge_releases_lock_when_receipt_is_not_accepted(
monkeypatch,
) -> None:
- async def winner(*_args, **_kwargs) -> bool:
- return True
+ async def winner(*_args, **_kwargs) -> dict[str, object]:
+ return {"acquired": True, "reason": "acquired"}
released: list[tuple[str, str]] = []
@@ -431,7 +466,7 @@ async def test_agent99_bridge_releases_lock_when_receipt_is_not_accepted(
released.append((fingerprint, alert_id))
monkeypatch.setattr(
- "src.services.agent99_sre_bridge.try_acquire_agent99_sre_single_flight",
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
winner,
)
monkeypatch.setattr(
@@ -442,19 +477,20 @@ async def test_agent99_bridge_releases_lock_when_receipt_is_not_accepted(
"src.services.agent99_sre_bridge.dispatch_agent99_sre_alert_with_receipt",
lambda _payload: {
"status": "relay_response_not_accepted",
- "transport": "relay",
- "accepted": False,
- "inbox_triggered": False,
- },
+ "transport": "relay",
+ "accepted": False,
+ "inbox_triggered": False,
+ "delivery_certainty": "not_delivered",
+ },
)
result = await bridge_alertmanager_to_agent99(
alert_id="receipt-rejected",
- alertname="HostDown",
+ alertname="UnknownObservation",
severity="critical",
namespace="node",
target_resource="192.168.0.120",
- message="host_down",
+ message="observation only",
fingerprint="receipt-rejected-fingerprint",
)
@@ -466,8 +502,8 @@ async def test_agent99_bridge_releases_lock_when_receipt_is_not_accepted(
@pytest.mark.asyncio
async def test_agent99_bridge_releases_lock_when_transport_fails(monkeypatch) -> None:
- async def winner(*_args, **_kwargs) -> bool:
- return True
+ async def winner(*_args, **_kwargs) -> dict[str, object]:
+ return {"acquired": True, "reason": "acquired"}
released: list[tuple[str, str]] = []
@@ -478,7 +514,7 @@ async def test_agent99_bridge_releases_lock_when_transport_fails(monkeypatch) ->
raise RuntimeError("relay unavailable")
monkeypatch.setattr(
- "src.services.agent99_sre_bridge.try_acquire_agent99_sre_single_flight",
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
winner,
)
monkeypatch.setattr(
@@ -492,15 +528,18 @@ async def test_agent99_bridge_releases_lock_when_transport_fails(monkeypatch) ->
result = await bridge_alertmanager_to_agent99(
alert_id="transport-failure",
- alertname="HostDown",
+ alertname="UnknownObservation",
severity="critical",
namespace="node",
target_resource="192.168.0.120",
- message="host_down",
+ message="observation only",
fingerprint="transport-fingerprint",
)
- assert result == {"status": "failed", "reason": "RuntimeError"}
+ assert result["status"] == "failed"
+ assert result["reason"] == "RuntimeError"
+ assert result["dispatchPerformed"] is False
+ assert result["runtimeClosureVerified"] is False
assert released == [("transport-fingerprint", "transport-failure")]
@@ -546,3 +585,447 @@ async def test_agent99_single_flight_release_is_owner_checked(monkeypatch) -> No
assert "redis.call('get'" in str(seen["script"])
assert seen["key_count"] == 1
assert seen["owner"] == "alert-1"
+
+
+class _FakeControlledDispatchLedger:
+ def __init__(self) -> None:
+ self.reserved = False
+ self.receipt: dict[str, object] | None = None
+ self.complete_calls = 0
+ self.defer_calls = 0
+
+ async def reserve(self, *, identity, payload): # type: ignore[no-untyped-def]
+ if self.reserved:
+ return {
+ "status": "idempotent_completed",
+ "claimed": False,
+ "identity": identity.public_dict(),
+ "receipt": self.receipt,
+ }
+ self.reserved = True
+ return {
+ "status": "reserved",
+ "claimed": True,
+ "identity": identity.public_dict(),
+ "receipt": None,
+ "claim_token": "claim-attempt-1",
+ }
+
+ async def complete( # type: ignore[no-untyped-def]
+ self, *, identity, claim_token, dispatch_receipt, **kwargs
+ ) -> dict[str, object]:
+ self.complete_calls += 1
+ self.receipt = {
+ **build_agent99_dispatch_receipt_envelope(
+ identity=identity,
+ dispatch_receipt=dispatch_receipt,
+ ),
+ "receipt_persisted": True,
+ }
+ return self.receipt
+
+ async def mark_delivery_attempt_started( # type: ignore[no-untyped-def]
+ self, *, identity, claim_token
+ ) -> dict[str, object]:
+ self.receipt = {
+ "status": "dispatch_delivery_unknown_reconcile_only",
+ "identity": identity.public_dict(),
+ "retry_policy": {"reconcile_only": True, "safe_to_retry": False},
+ "receipt_persisted": True,
+ "runtime_closure_verified": False,
+ }
+ return self.receipt
+
+ async def defer_claim_retryable( # type: ignore[no-untyped-def]
+ self, *, identity, claim_token, reason, retry_after_seconds
+ ) -> dict[str, object]:
+ self.defer_calls += 1
+ self.receipt = {
+ "schema_version": "agent99_controlled_dispatch_receipt_v1",
+ "status": "dispatch_lock_retryable",
+ "identity": identity.public_dict(),
+ "dispatch_accepted": False,
+ "inbox_triggered": False,
+ "dispatch_promoted": False,
+ "controlled_apply_authorized": False,
+ "retry_policy": {
+ "safe_to_retry": True,
+ "retry_same_generation": True,
+ "requires_generation_advance": False,
+ "retry_after_seconds": retry_after_seconds,
+ "reason": reason,
+ },
+ "receipt_persisted": True,
+ "runtime_closure_verified": False,
+ }
+ return self.receipt
+
+
+def test_agent99_dispatch_identity_is_stable_across_projections() -> None:
+ alertmanager = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-20260711-11C751",
+ source_fingerprint="source-fingerprint",
+ route_id="agent99_cold_start_recovery",
+ execution_generation="1",
+ )
+ telegram = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-20260711-11C751",
+ source_fingerprint="source-fingerprint",
+ route_id="agent99_cold_start_recovery",
+ execution_generation="1",
+ )
+
+ assert telegram.run_id == alertmanager.run_id
+ assert telegram.trace_id == alertmanager.trace_id
+ assert telegram.idempotency_key == alertmanager.idempotency_key
+ assert telegram.work_item_id == alertmanager.work_item_id
+
+
+@pytest.mark.asyncio
+async def test_mutating_dispatch_is_deduplicated_by_postgres_identity(
+ monkeypatch,
+) -> None:
+ ledger = _FakeControlledDispatchLedger()
+ dispatched: list[dict[str, object]] = []
+
+ async def acquired(*_args, **_kwargs) -> dict[str, object]:
+ return {"acquired": True, "reason": "acquired"}
+
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.get_agent99_dispatch_ledger",
+ lambda: ledger,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
+ acquired,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.dispatch_agent99_sre_alert_with_receipt",
+ lambda payload: dispatched.append(payload)
+ or {
+ "status": "accepted_inbox_triggered",
+ "transport": "relay",
+ "alert_id": str(payload["id"]),
+ "kind": str(payload["kind"]),
+ "accepted": True,
+ "inbox_triggered": True,
+ },
+ )
+ common = {
+ "alertname": "ColdStartGateBlocked",
+ "severity": "critical",
+ "namespace": "host_recovery",
+ "target_resource": "cold-start-gate",
+ "message": "cold-start gate blocked",
+ "fingerprint": "source-fingerprint",
+ "project_id": "awoooi",
+ "incident_id": "INC-20260711-11C751",
+ "route_id": "agent99_cold_start_recovery",
+ }
+
+ first = await bridge_alertmanager_to_agent99(alert_id="alertmanager-1", **common)
+ second = await bridge_alertmanager_to_agent99(alert_id="telegram-card-2", **common)
+
+ assert first["status"] == "dispatched"
+ assert second["status"] == "deduplicated"
+ assert len(dispatched) == 1
+ assert dispatched[0]["force"] is False
+ assert first["identity"] == second["identity"]
+ assert first["correlatedReceipt"]["runtime_execution_authorized"] is False
+ assert first["correlatedReceipt"]["runtime_closure_verified"] is False
+ assert first["correlatedReceipt"]["verifier"]["status"] == "pending"
+ assert first["correlatedReceipt"]["learning_writeback"]["status"] == (
+ "pending_verifier"
+ )
+
+
+@pytest.mark.asyncio
+async def test_mutating_dispatch_redis_failure_performs_zero_transport(
+ monkeypatch,
+) -> None:
+ ledger = _FakeControlledDispatchLedger()
+ dispatched: list[dict[str, object]] = []
+
+ async def redis_unavailable(*_args, **_kwargs) -> dict[str, object]:
+ return {"acquired": False, "reason": "redis_unavailable_fail_closed"}
+
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.get_agent99_dispatch_ledger",
+ lambda: ledger,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
+ redis_unavailable,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.dispatch_agent99_sre_alert_with_receipt",
+ lambda payload: dispatched.append(payload),
+ )
+
+ result = await bridge_alertmanager_to_agent99(
+ alert_id="cold-start-redis-failure",
+ alertname="ColdStartGateBlocked",
+ severity="critical",
+ namespace="host_recovery",
+ target_resource="cold-start-gate",
+ message="cold-start gate blocked",
+ fingerprint="redis-failure-fingerprint",
+ incident_id="INC-20260711-11C751",
+ route_id="agent99_cold_start_recovery",
+ )
+
+ assert result["status"] == "retryable"
+ assert result["reason"] == "redis_unavailable_fail_closed"
+ assert result["dispatchPerformed"] is False
+ assert dispatched == []
+ assert ledger.complete_calls == 0
+ assert ledger.defer_calls == 1
+ assert result["correlatedReceipt"]["receipt_persisted"] is True
+ assert result["correlatedReceipt"]["runtime_closure_verified"] is False
+
+
+@pytest.mark.asyncio
+async def test_mutating_single_flight_redis_exception_is_fail_closed(
+ monkeypatch,
+) -> None:
+ monkeypatch.setattr(
+ "src.core.redis_client.get_redis",
+ lambda: (_ for _ in ()).throw(RuntimeError("redis down")),
+ )
+
+ decision = await acquire_agent99_sre_single_flight(
+ "same-controlled-idempotency-key",
+ "same-run-owner",
+ fail_closed=True,
+ )
+
+ assert decision == {
+ "acquired": False,
+ "reason": "redis_unavailable_fail_closed",
+ }
+
+
+@pytest.mark.asyncio
+async def test_backupcheck_read_only_dispatch_has_durable_same_run_receipt(
+ monkeypatch,
+) -> None:
+ ledger = _FakeControlledDispatchLedger()
+ dispatched: list[dict[str, object]] = []
+
+ async def acquired(*_args, **_kwargs) -> dict[str, object]:
+ return {"acquired": True, "reason": "acquired"}
+
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.get_agent99_dispatch_ledger",
+ lambda: ledger,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
+ acquired,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.dispatch_agent99_sre_alert_with_receipt",
+ lambda payload: dispatched.append(payload)
+ or {
+ "schema_version": "agent99_sre_dispatch_receipt_v1",
+ "status": "accepted_inbox_triggered",
+ "transport": "relay",
+ "alert_id": str(payload["id"]),
+ "kind": str(payload["kind"]),
+ "accepted": True,
+ "inbox_triggered": True,
+ "delivery_certainty": "delivered",
+ },
+ )
+
+ result = await bridge_alertmanager_to_agent99(
+ alert_id="backup-readback",
+ alertname="BackupCredentialEscrowEvidenceMissing",
+ severity="warning",
+ namespace="awoooi-prod",
+ target_resource="backup_restore",
+ message="freshness escrow restore evidence required",
+ labels={"event_type": "backup_restore_escrow_signal"},
+ fingerprint="backup-fingerprint",
+ incident_id="INC-20260711-BACKUP",
+ route_id="agent99:backup_health:BackupCheck",
+ )
+
+ assert result["status"] == "dispatched"
+ assert result["identity"]["run_id"]
+ assert result["identity"]["trace_id"]
+ assert result["identity"]["work_item_id"]
+ assert result["correlatedReceipt"]["receipt_persisted"] is True
+ assert dispatched[0]["suggestedMode"] == "BackupCheck"
+ assert dispatched[0]["controlledApply"] is False
+ assert result["correlatedReceipt"]["controlled_apply_authorized"] is False
+
+
+class _DeliveryUnknownLedger:
+ def __init__(self) -> None:
+ self.reserved = False
+ self.receipt: dict[str, object] | None = None
+
+ async def reserve(self, *, identity, payload): # type: ignore[no-untyped-def]
+ if self.reserved:
+ return {
+ "status": "idempotent_running",
+ "claimed": False,
+ "receipt": self.receipt,
+ }
+ self.reserved = True
+ self.receipt = {
+ "schema_version": "agent99_controlled_dispatch_receipt_v1",
+ "status": "dispatch_reserved",
+ "identity": identity.public_dict(),
+ "outbox": {"status": "pending_delivery"},
+ }
+ return {
+ "status": "reserved",
+ "claimed": True,
+ "claim_token": "unknown-delivery-claim",
+ "receipt": self.receipt,
+ }
+
+ async def complete(self, **_kwargs): # type: ignore[no-untyped-def]
+ return {
+ **(self.receipt or {}),
+ "status": "receipt_persistence_failed_no_runtime_closure",
+ "receipt_persisted": False,
+ }
+
+ async def mark_delivery_attempt_started( # type: ignore[no-untyped-def]
+ self, *, identity, claim_token
+ ) -> dict[str, object]:
+ self.receipt = {
+ "schema_version": "agent99_controlled_dispatch_receipt_v1",
+ "status": "dispatch_delivery_unknown_reconcile_only",
+ "identity": identity.public_dict(),
+ "retry_policy": {"safe_to_retry": False, "reconcile_only": True},
+ "receipt_persisted": True,
+ "runtime_closure_verified": False,
+ }
+ return self.receipt
+
+ async def defer_claim_retryable(self, **_kwargs): # type: ignore[no-untyped-def]
+ raise AssertionError("lock is acquired in this test")
+
+
+@pytest.mark.asyncio
+async def test_accepted_transport_with_receipt_write_failure_is_not_replayed(
+ monkeypatch,
+) -> None:
+ ledger = _DeliveryUnknownLedger()
+ dispatched: list[dict[str, object]] = []
+
+ async def acquired(*_args, **_kwargs) -> dict[str, object]:
+ return {"acquired": True, "reason": "acquired"}
+
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.get_agent99_dispatch_ledger",
+ lambda: ledger,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
+ acquired,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.dispatch_agent99_sre_alert_with_receipt",
+ lambda payload: dispatched.append(payload)
+ or {
+ "status": "accepted_inbox_triggered",
+ "transport": "relay",
+ "alert_id": str(payload["id"]),
+ "kind": str(payload["kind"]),
+ "accepted": True,
+ "inbox_triggered": True,
+ "delivery_certainty": "delivered",
+ },
+ )
+ common = {
+ "alertname": "ColdStartGateBlocked",
+ "severity": "critical",
+ "namespace": "host_recovery",
+ "target_resource": "cold-start-gate",
+ "message": "cold-start gate blocked",
+ "fingerprint": "unknown-delivery-fingerprint",
+ "incident_id": "INC-20260711-11C751",
+ "route_id": "agent99:host_recovery:Recover",
+ }
+
+ first = await bridge_alertmanager_to_agent99(alert_id="first", **common)
+ second = await bridge_alertmanager_to_agent99(alert_id="second", **common)
+
+ assert first["reason"] == "dispatch_receipt_persistence_failed"
+ assert second["dispatchPerformed"] is False
+ assert len(dispatched) == 1
+
+
+class _GenerationAdvanceLedger(_FakeControlledDispatchLedger):
+ async def reserve(self, *, identity, payload): # type: ignore[no-untyped-def]
+ if identity.execution_generation == "1":
+ return {
+ "status": "idempotent_failed",
+ "claimed": False,
+ "receipt": {
+ "status": "dispatch_not_delivered_retry_safe",
+ "identity": identity.public_dict(),
+ "retry_policy": {
+ "safe_to_retry": True,
+ "requires_generation_advance": True,
+ "max_generation": 3,
+ },
+ },
+ }
+ return await super().reserve(identity=identity, payload=payload)
+
+
+@pytest.mark.asyncio
+async def test_safe_not_delivered_failure_advances_generation_before_dispatch(
+ monkeypatch,
+) -> None:
+ ledger = _GenerationAdvanceLedger()
+ dispatched: list[dict[str, object]] = []
+
+ async def acquired(*_args, **_kwargs) -> dict[str, object]:
+ return {"acquired": True, "reason": "acquired"}
+
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.get_agent99_dispatch_ledger",
+ lambda: ledger,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
+ acquired,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.dispatch_agent99_sre_alert_with_receipt",
+ lambda payload: dispatched.append(payload)
+ or {
+ "status": "accepted_inbox_triggered",
+ "transport": "relay",
+ "alert_id": str(payload["id"]),
+ "kind": str(payload["kind"]),
+ "accepted": True,
+ "inbox_triggered": True,
+ "delivery_certainty": "delivered",
+ },
+ )
+
+ result = await bridge_alertmanager_to_agent99(
+ alert_id="retry-generation",
+ alertname="ColdStartGateBlocked",
+ severity="critical",
+ namespace="host_recovery",
+ target_resource="cold-start-gate",
+ message="cold-start gate blocked",
+ fingerprint="retry-generation-fingerprint",
+ incident_id="INC-20260711-11C751",
+ route_id="agent99:host_recovery:Recover",
+ )
+
+ assert result["status"] == "dispatched"
+ assert result["identity"]["execution_generation"] == "2"
+ assert len(dispatched) == 1
diff --git a/apps/api/tests/test_alert_approval_guard.py b/apps/api/tests/test_alert_approval_guard.py
index 827791cfe..1c0becbba 100644
--- a/apps/api/tests/test_alert_approval_guard.py
+++ b/apps/api/tests/test_alert_approval_guard.py
@@ -53,6 +53,77 @@ async def test_blocks_llm_kubectl_default_namespace_for_prod_alert() -> None:
assert result.reason == "namespace_not_allowed:default"
+@pytest.mark.asyncio
+async def test_cold_start_invalid_k8s_target_becomes_fail_closed_agent99_candidate() -> None:
+ result = await guard_alert_approval_action(
+ action="kubectl scale deployment/cold-start-gate --replicas=2 -n default",
+ alert_namespace="default",
+ alertname="ColdStartGateBlocked",
+ alert_category="general",
+ target_resource="cold-start-gate",
+ )
+
+ assert result.blocked is True
+ assert result.reason == "namespace_not_allowed:default"
+ assert result.action == (
+ "DRAFT_READY - CONTROLLED_AGENT99_RECOVERY: "
+ "agent99-mode Recover controlledApply=true"
+ )
+ assert result.metadata["blocked_action"].startswith("kubectl scale")
+ assert result.metadata["controlled_target_reroute"] is True
+ assert result.metadata["controlled_target_executor"] == "Agent99"
+
+ contract = result.metadata["repair_candidate_promotion_contract"]
+ assert contract["route_id"] == "agent99_recover_after_owner_review"
+ assert contract["status"] == "controlled_route_candidate_pending_receipts"
+ assert contract["runtime_execution_authorized"] is False
+ assert contract["runtime_write_allowed"] is False
+ assert contract["completion_status"] == "partial"
+ assert contract["blocked_count"] > 0
+ assert "dispatch_receipt" in contract["blocked_fields"]
+ assert "post_apply_verifier" in contract["blocked_fields"]
+ assert "km_writeback" in contract["blocked_fields"]
+ assert "playbook_trust_writeback" in contract["blocked_fields"]
+ assert "kubectl_for_host_control_plane_target" in contract["forbidden_operations"]
+ assert "generic_ansible_keyword_fallback" in contract["forbidden_operations"]
+
+ closure = contract["controlled_automation_closure"]
+ stages = {row["stage"]: row["status"] for row in closure["stages"]}
+ assert stages == {
+ "sensor": "pending",
+ "normalize": "passed",
+ "check": "pending",
+ "controlled_apply": "pending",
+ "verify": "pending",
+ "learn_writeback": "pending",
+ }
+ assert {row["asset_type"] for row in closure["automation_asset_ledger"]} == {
+ "Sensor",
+ "PlayBook",
+ "Verifier",
+ "KM",
+ }
+
+
+@pytest.mark.asyncio
+async def test_cold_start_valid_namespace_still_cannot_become_fake_k8s_action() -> None:
+ result = await guard_alert_approval_action(
+ action=(
+ "kubectl rollout restart deployment/cold-start-gate "
+ "-n awoooi-prod"
+ ),
+ alert_namespace="awoooi-prod",
+ alertname="ColdStartGateBlocked",
+ alert_category="general",
+ target_resource="cold-start-gate",
+ )
+
+ assert result.blocked is True
+ assert result.reason == "kubernetes_action_not_applicable:control_plane_recovery"
+ assert result.metadata["controlled_target_executor"] == "Agent99"
+ assert result.action.startswith("DRAFT_READY - CONTROLLED_AGENT99_RECOVERY")
+
+
@pytest.mark.asyncio
async def test_blocks_hallucinated_mutating_k8s_resource() -> None:
resolver = StubResolver(success=False, candidates=["awoooi-api"])
diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py
index 811b8dfba..9485c1dc4 100644
--- a/apps/api/tests/test_awooop_operator_timeline_labels.py
+++ b/apps/api/tests/test_awooop_operator_timeline_labels.py
@@ -17,6 +17,11 @@ from src.api.v1.platform.operator_runs import (
ListCicdEventsResponse,
ListRunsResponse,
)
+from src.services.agent99_controlled_dispatch_ledger import (
+ build_agent99_dispatch_identity,
+ build_agent99_dispatch_receipt_envelope,
+)
+from src.services.agent99_sre_bridge import build_agent99_sre_alert
from src.services.ollama_failover_manager import OllamaEndpoint, OllamaRoutingResult
from src.services.ollama_health_monitor import HealthReport, HealthStatus
from src.services.platform_operator_service import (
@@ -1056,6 +1061,645 @@ def test_ai_alert_card_delivery_summary_keeps_no_false_green_status() -> None:
assert summary["production_write_count"] == 0
+def test_backup_restore_delivery_readback_is_durable_but_never_false_green() -> None:
+ run_id = UUID("0b25cafa-00a4-520b-8946-a3ea467b13fc")
+ message_id = UUID("97ff9f41-7cdc-4893-8aab-634286fb128c")
+ item = _ai_alert_card_delivery_item({
+ "message_id": message_id,
+ "run_id": run_id,
+ "project_id": "awoooi",
+ "channel_type": "telegram",
+ "message_type": "final",
+ "provider_message_id": "37451",
+ "send_status": "sent",
+ "send_error": None,
+ "queued_at": datetime(2026, 7, 11, 10, 57, 30),
+ "sent_at": datetime(2026, 7, 11, 10, 57, 35),
+ "triggered_by_state": "legacy_gateway",
+ "alert_card": {
+ "schema_version": "ai_automation_alert_card_mirror_v1",
+ "card_schema": "ai_automation_alert_card_v1",
+ "event_type": "backup_restore_escrow_signal",
+ "lane": "backup_restore_escrow_triage",
+ "target": "backup_restore",
+ "gates": [
+ "controlled_playbook_queue",
+ "runtime_write_gate=controlled",
+ ],
+ "candidate_only": False,
+ "controlled_playbook_queue": True,
+ "runtime_write_gate_count": 1,
+ "runtime_write_gate_state": "controlled",
+ "delivery_receipt_readback_required": True,
+ },
+ "source_refs": {
+ "alert_ids": ["backup_restore_escrow_signal"],
+ "fingerprints": [
+ "ai_automation_alert_card:backup_restore_escrow_signal:"
+ "backup_restore_escrow_triage"
+ ],
+ },
+ "run_state": "completed",
+ "agent_id": "legacy-telegram-gateway",
+ "run_created_at": datetime(2026, 7, 11, 10, 57, 30),
+ })
+
+ contract = item["backup_restore_automation"]
+ assert contract is not None
+ assert contract["contract_origin"] == "synthesized_at_read"
+ assert contract["contract_persisted_at_send"] is False
+ assert contract["contract_provenance"][
+ "source_contract_present_at_send"
+ ] is False
+ assert contract["contract_provenance"]["recorded_policy_present"] is False
+ assert contract["policy"]["policy_version"] == (
+ "backup_restore_readback_policy_v2"
+ )
+ assert contract["receipt"]["status"] == (
+ "projected_from_historical_source_row"
+ )
+ assert contract["receipt"]["source_row_exists"] is True
+ assert contract["receipt"]["is_agent99_execution_receipt"] is False
+ assert contract["work_item"]["work_item_id"].startswith("BRR-WI-")
+ assert contract["candidate"]["candidate_id"].startswith("BRR-CAND-")
+ assert contract["verifier"]["verifier_id"].startswith("BRR-VER-")
+ assert contract["candidate"]["execution_state"] == "not_started"
+ assert contract["candidate"]["agent99_dispatch"]["status"] == (
+ "dispatch_receipt_missing"
+ )
+ assert contract["verifier"]["status"] == (
+ "projected_dispatch_receipt_missing_verifier_not_started"
+ )
+ assert contract["verifier"]["agent99_outcome"]["status"] == (
+ "outcome_source_unavailable"
+ )
+ assert item["declared_runtime_write_gate_count"] == 1
+ assert item["declared_runtime_write_allowed"] is True
+ assert item["declared_controlled_playbook_queue"] is True
+ assert item["effective_policy_projection"]["runtime_write_gate_count"] == 0
+ assert item["runtime_write_gate_count"] == 0
+ assert item["runtime_write_allowed"] is False
+ assert item["controlled_playbook_queue"] is False
+ assert item["runtime_write_gate_state"] == "evidence_only"
+ assert item["learning_writeback_ready"] is False
+ assert item["learning_writeback_status"] == (
+ "blocked_backup_restore_evidence_or_verifier"
+ )
+ assert item["learning_writeback_refs"]["work_item_id"] == (
+ contract["work_item"]["work_item_id"]
+ )
+ assert all(
+ binding["status"] != "ready_for_consumer_context"
+ for binding in item["learning_registry_bindings"]
+ )
+
+ summary = _ai_alert_card_delivery_summary_from_row(
+ {
+ "total": 98,
+ "sent_total": 98,
+ "failed_total": 0,
+ "pending_total": 0,
+ "shadow_total": 0,
+ "delivery_receipt_required_total": 98,
+ "declared_runtime_write_gate_open_count": 96,
+ "runtime_write_gate_open_count": 0,
+ "declared_learning_writeback_ready_total": 98,
+ "learning_writeback_ready_total": 0,
+ },
+ project_id="awoooi",
+ event_type="backup_restore_escrow_signal",
+ lane="backup_restore_escrow_triage",
+ )
+ assert summary["status"] == "blocked_backup_restore_evidence_or_verifier"
+ assert summary["declared_runtime_write_gate_open_count"] == 96
+ assert summary["runtime_write_gate_open_count"] == 0
+ assert summary["runtime_write_allowed"] is False
+ assert summary["learning_writeback_ready_total"] == 0
+ assert summary["declared_learning_writeback_ready_total"] == 98
+ assert summary["backup_runtime_write_gate_excluded_total"] == 96
+ assert summary["backup_learning_writeback_excluded_total"] == 98
+ assert summary["learning_writeback_missing_total"] == 98
+ assert summary["backup_restore_readback_required"] is True
+ assert summary["no_false_green_enforced"] is True
+ assert summary["production_write_count"] == 0
+
+
+def test_unfiltered_mixed_summary_uses_effective_per_row_counts() -> None:
+ summary = _ai_alert_card_delivery_summary_from_row(
+ {
+ "total": 100,
+ "sent_total": 100,
+ "failed_total": 0,
+ "pending_total": 0,
+ "shadow_total": 0,
+ "delivery_receipt_required_total": 100,
+ "backup_restore_total": 98,
+ "declared_runtime_write_gate_open_count": 97,
+ "runtime_write_gate_open_count": 1,
+ "declared_learning_writeback_ready_total": 100,
+ "learning_writeback_ready_total": 2,
+ },
+ project_id="awoooi",
+ event_type=None,
+ lane=None,
+ )
+
+ assert summary["status"] == (
+ "observed_effective_backup_policy_projection_applied"
+ )
+ assert summary["declared_runtime_write_gate_open_count"] == 97
+ assert summary["backup_restore_total"] == 98
+ assert summary["runtime_write_gate_open_count"] == 1
+ assert summary["runtime_write_allowed"] is True
+ assert summary["backup_runtime_write_gate_excluded_total"] == 96
+ assert summary["declared_learning_writeback_ready_total"] == 100
+ assert summary["learning_writeback_ready_total"] == 2
+ assert summary["backup_learning_writeback_excluded_total"] == 98
+ assert summary["learning_writeback_missing_total"] == 98
+ assert summary["no_false_green_enforced"] is True
+ projection = summary["effective_policy_projection"]
+ assert projection["scope"] == "mixed_or_unfiltered"
+ assert projection["applied"] is True
+ assert projection["policy"]["source_hash"].startswith("sha256:")
+
+
+def test_unfiltered_readback_only_backup_still_enforces_no_false_green() -> None:
+ summary = _ai_alert_card_delivery_summary_from_row(
+ {
+ "total": 1,
+ "sent_total": 1,
+ "failed_total": 0,
+ "pending_total": 0,
+ "shadow_total": 0,
+ "delivery_receipt_required_total": 1,
+ "backup_restore_total": 1,
+ "declared_runtime_write_gate_open_count": 0,
+ "runtime_write_gate_open_count": 0,
+ "declared_learning_writeback_ready_total": 0,
+ "learning_writeback_ready_total": 0,
+ },
+ project_id="awoooi",
+ event_type=None,
+ lane=None,
+ )
+
+ assert summary["backup_restore_total"] == 1
+ assert summary["runtime_write_allowed"] is False
+ assert summary["learning_writeback_ready_total"] == 0
+ assert summary["no_false_green_enforced"] is True
+ assert summary["backup_restore_readback_required"] is True
+ assert summary["status"] == (
+ "observed_effective_backup_policy_projection_applied"
+ )
+
+
+def test_backup_item_consumes_agent99_dispatch_receipt_without_faking_outcome() -> None:
+ item = _ai_alert_card_delivery_item({
+ "message_id": "source-message-1",
+ "run_id": "source-run-1",
+ "project_id": "awoooi",
+ "channel_type": "telegram",
+ "message_type": "final",
+ "send_status": "sent",
+ "alert_card": {
+ "event_type": "backup_restore_escrow_signal",
+ "lane": "backup_restore_escrow_triage",
+ "target": "backup_restore",
+ "gates": [
+ "controlled_playbook_queue=readback_only",
+ "runtime_write_gate=0_until_verifier",
+ ],
+ "candidate_only": True,
+ "controlled_playbook_queue": False,
+ "runtime_write_gate_count": 0,
+ "runtime_write_gate_state": "evidence_only",
+ "delivery_receipt_readback_required": True,
+ "backup_restore_automation": {
+ "schema_version": "backup_restore_readback_automation_v1",
+ "verifier": {"checks": []},
+ },
+ },
+ "source_refs": {
+ "fingerprints": [
+ "ai_automation_alert_card:backup_restore_escrow_signal:"
+ "backup_restore_escrow_triage"
+ ],
+ },
+ "agent99_dispatch_receipt": {
+ "schema_version": "telegram_agent99_dispatch_receipt_projection_v1",
+ "status": "accepted_inbox_triggered",
+ "transport": "relay",
+ "alert_id": "backup-alert-1",
+ "kind": "backup_health",
+ "suggested_mode": "BackupCheck",
+ "accepted": True,
+ "inbox_triggered": True,
+ "receipt_persisted": True,
+ "runtime_closure_verified": False,
+ "run_id": "agent99-run-1",
+ "trace_id": (
+ "00-12345678901234567890123456789012-1234567890123456-01"
+ ),
+ "work_item_id": (
+ "agent99-dispatch:awoooi:INC-BACKUP:backupcheck"
+ ),
+ },
+ })
+
+ contract = item["backup_restore_automation"]
+ assert contract["contract_origin"] == "persisted_at_send"
+ assert contract["contract_persisted_at_send"] is True
+ assert contract["contract_provenance"][
+ "source_contract_present_at_send"
+ ] is True
+ assert contract["contract_provenance"]["recorded_contract_origin"] == (
+ "legacy_unspecified"
+ )
+ assert contract["contract_provenance"]["recorded_policy_present"] is False
+ assert contract["contract_provenance"][
+ "recorded_immutable_declared_present"
+ ] is False
+ assert contract["candidate"]["execution_state"] == "dispatched"
+ assert contract["candidate"]["status"] == "dispatched_verifier_pending"
+ assert contract["candidate"]["agent99_dispatch"]["agent99_run_id"] == (
+ "agent99-run-1"
+ )
+ assert contract["candidate"]["agent99_dispatch"]["identity_complete"] is True
+ assert contract["candidate"]["identity_binding"]["brr_work_item_id"] == (
+ contract["work_item"]["work_item_id"]
+ )
+ assert contract["candidate"]["identity_binding"]["brr_verifier_id"] == (
+ contract["verifier"]["verifier_id"]
+ )
+ assert contract["receipt"]["source_receipt_id"] == "source-message-1"
+ assert contract["receipt"]["is_agent99_execution_receipt"] is False
+ assert contract["verifier"]["status"] == "dispatched_verifier_pending"
+ assert contract["verifier"]["terminal"] is False
+ assert contract["verifier"]["agent99_outcome"]["status"] == (
+ "dispatch_accepted_outcome_pending"
+ )
+ assert contract["next_action"] == (
+ "ingest_agent99_backupcheck_outcome_then_run_independent_dr_verifier"
+ )
+
+
+def test_backup_item_consumes_real_bridge_and_ledger_identity() -> None:
+ alert = build_agent99_sre_alert(
+ alert_id="backup-alert-real-builder",
+ alertname="BackupRestoreEscrowSignal",
+ severity="warning",
+ namespace="awoooi",
+ target_resource="backup_restore",
+ message="backup freshness degraded; restore drill evidence missing",
+ fingerprint="backup-real-builder-fingerprint",
+ labels={"event_type": "backup_restore_escrow_signal"},
+ )
+ assert alert["kind"] == "backup_health"
+ assert alert["suggestedMode"] == "BackupCheck"
+ assert alert["controlledApply"] is False
+
+ identity = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-BACKUP-REAL",
+ source_fingerprint="backup-real-builder-fingerprint",
+ route_id="agent99:backup_health:BackupCheck",
+ )
+ current_receipt = build_agent99_dispatch_receipt_envelope(
+ identity=identity,
+ dispatch_receipt={
+ "schema_version": "agent99_sre_dispatch_receipt_v1",
+ "status": "accepted",
+ "transport": "relay",
+ "alert_id": str(alert["id"]),
+ "kind": "backup_health",
+ "accepted": True,
+ "inbox_triggered": True,
+ },
+ )
+ public_identity = identity.public_dict()
+ item = _ai_alert_card_delivery_item({
+ "message_id": "source-message-real-builder",
+ "run_id": "source-run-real-builder",
+ "project_id": "awoooi",
+ "alert_card": {
+ "event_type": "backup_restore_escrow_signal",
+ "lane": "backup_restore_escrow_triage",
+ "target": "backup_restore",
+ "delivery_receipt_readback_required": True,
+ "backup_restore_automation": {
+ "schema_version": "backup_restore_readback_automation_v1",
+ },
+ },
+ "agent99_dispatch_receipt": {
+ "schema_version": "telegram_agent99_dispatch_receipt_projection_v1",
+ "status": "dispatch_accepted_verifier_pending",
+ "kind": "backup_health",
+ "suggested_mode": "BackupCheck",
+ "accepted": True,
+ "inbox_triggered": True,
+ "incident_id": "INC-BACKUP-REAL",
+ "run_id": public_identity["run_id"],
+ "trace_id": public_identity["trace_id"],
+ "work_item_id": public_identity["work_item_id"],
+ "receipt_persisted": True,
+ },
+ "agent99_current_receipt": current_receipt,
+ "agent99_current_run_state": "waiting_tool",
+ "agent99_current_trace_id": public_identity["trace_id"],
+ })
+
+ contract = item["backup_restore_automation"]
+ dispatch = contract["candidate"]["agent99_dispatch"]
+ assert dispatch["dispatched"] is True
+ assert dispatch["agent99_run_id"] == public_identity["run_id"]
+ assert dispatch["trace_id"] == public_identity["trace_id"]
+ assert dispatch["agent99_work_item_id"] == public_identity["work_item_id"]
+ assert contract["candidate"]["identity_binding"]["complete"] is True
+ assert contract["verifier"]["terminal"] is False
+
+
+def test_backup_item_advances_from_current_durable_ledger_terminal() -> None:
+ run_id = "c977681c-7753-5d02-9b36-aa42b5d74df0"
+ trace_id = "00-12345678901234567890123456789012-1234567890123456-01"
+ work_item_id = "agent99-dispatch:awoooi:INC-BACKUP:backupcheck"
+ stage_identity = {
+ "run_id": run_id,
+ "trace_id": trace_id,
+ "work_item_id": work_item_id,
+ }
+ item = _ai_alert_card_delivery_item({
+ "message_id": "source-message-terminal",
+ "run_id": "source-run-terminal",
+ "project_id": "awoooi",
+ "channel_type": "telegram",
+ "message_type": "final",
+ "send_status": "sent",
+ "alert_card": {
+ "event_type": "backup_restore_escrow_signal",
+ "lane": "backup_restore_escrow_triage",
+ "target": "backup_restore",
+ "gates": [
+ "controlled_playbook_queue=readback_only",
+ "runtime_write_gate=0_until_verifier",
+ ],
+ "declared_gates": [
+ "controlled_playbook_queue",
+ "runtime_write_gate=controlled",
+ ],
+ "declared_runtime_write_gate_count": 1,
+ "declared_runtime_write_gate_state": "controlled",
+ "declared_controlled_playbook_queue": True,
+ "candidate_only": True,
+ "controlled_playbook_queue": False,
+ "runtime_write_gate_count": 0,
+ "runtime_write_gate_state": "evidence_only",
+ "delivery_receipt_readback_required": True,
+ "backup_restore_automation": {
+ "schema_version": "backup_restore_readback_automation_v1",
+ "contract_origin": "persisted_at_send",
+ "verifier": {"checks": []},
+ },
+ },
+ "source_refs": {
+ "fingerprints": [
+ "ai_automation_alert_card:backup_restore_escrow_signal:"
+ "backup_restore_escrow_triage"
+ ],
+ },
+ "agent99_dispatch_receipt": {
+ "schema_version": "telegram_agent99_dispatch_receipt_projection_v1",
+ "status": "dispatch_accepted_verifier_pending",
+ "transport": "relay",
+ "alert_id": "backup-alert-terminal",
+ "kind": "backup_health",
+ "suggested_mode": "BackupCheck",
+ "accepted": True,
+ "inbox_triggered": True,
+ "incident_id": "INC-BACKUP",
+ **stage_identity,
+ "receipt_persisted": True,
+ "runtime_closure_verified": False,
+ },
+ "agent99_current_receipt": {
+ "schema_version": "agent99_controlled_dispatch_receipt_v1",
+ "status": "closed_verified_learning_written",
+ "identity": {
+ "incident_id": "INC-BACKUP",
+ **stage_identity,
+ "idempotency_key": "agent99-controlled:test",
+ },
+ "dispatch_receipt": {
+ "transport": "relay",
+ "alert_id": "backup-alert-terminal",
+ "kind": "backup_health",
+ "accepted": True,
+ "inbox_triggered": True,
+ },
+ "dispatch_accepted": True,
+ "post_verifier_passed": True,
+ "runtime_closure_verified": True,
+ "closure_complete": True,
+ "verifier": {
+ "schema_version": "agent99_independent_verifier_receipt_v1",
+ "status": "success",
+ **stage_identity,
+ "outcome_state": "resolved",
+ "transport_ok": True,
+ "verifier_passed": True,
+ "source_event_resolved": True,
+ "verified_at": "2026-07-11T20:20:00+08:00",
+ "evidence_refs": {
+ "agent99_outcome_receipt_id": "agent99-outcome-1",
+ "post_verifier_evidence_ref": "post-verifier-1",
+ "source_event_evidence_ref": "source-event-1",
+ "backup_status_evidence_ref": "backup-status-1",
+ "freshness_evidence_ref": "freshness-1",
+ "offsite_verify_evidence_ref": "offsite-1",
+ "escrow_evidence_ref": "escrow-1",
+ "restore_drill_evidence_ref": "restore-drill-1",
+ "source_resolution_receipt_ref": "source-resolved-1",
+ },
+ },
+ "learning_writeback": {
+ "schema_version": "agent99_learning_writeback_receipt_v1",
+ "status": "success",
+ **stage_identity,
+ "receipt_refs": {
+ "incident_closure_receipt_id": "inc-close-1",
+ "telegram_lifecycle_receipt_id": "tg-life-1",
+ "km_writeback_ack_id": "km-ack-1",
+ "playbook_trust_writeback_ack_id": "pb-ack-1",
+ "dr_scorecard_writeback_ack_id": "dr-scorecard-ack-1",
+ },
+ },
+ },
+ "agent99_current_run_state": "completed",
+ "agent99_current_trace_id": trace_id,
+ })
+
+ contract = item["backup_restore_automation"]
+ assert contract["candidate"]["execution_state"] == "completed_verified"
+ assert contract["candidate"]["identity_binding"]["complete"] is True
+ assert contract["verifier"]["status"] == "success_terminal"
+ assert contract["verifier"]["result"] == "success"
+ assert contract["verifier"]["backup_evidence_refs_complete"] is True
+ assert contract["verifier"]["terminal"] is True
+ assert contract["verifier"]["agent99_outcome"]["status"] == (
+ "terminal_verified_learning_written"
+ )
+ assert contract["effective_policy_projection"][
+ "learning_writeback_ready"
+ ] is True
+ assert contract["closure_state"] == "closed_verified_learning_written"
+ assert contract["next_action"] == "monitor_backup_restore_recurrence"
+ assert item["declared_runtime_write_gate_count"] == 1
+ assert item["runtime_write_allowed"] is False
+
+
+def test_backup_current_ledger_identity_mismatch_fails_closed() -> None:
+ item = _ai_alert_card_delivery_item({
+ "message_id": "source-message-mismatch",
+ "run_id": "source-run-mismatch",
+ "project_id": "awoooi",
+ "alert_card": {
+ "event_type": "backup_restore_escrow_signal",
+ "lane": "backup_restore_escrow_triage",
+ "target": "backup_restore",
+ "delivery_receipt_readback_required": True,
+ "backup_restore_automation": {
+ "schema_version": "backup_restore_readback_automation_v1",
+ },
+ },
+ "agent99_dispatch_receipt": {
+ "schema_version": "telegram_agent99_dispatch_receipt_projection_v1",
+ "kind": "backup_health",
+ "suggested_mode": "BackupCheck",
+ "incident_id": "INC-BACKUP",
+ "run_id": "expected-run",
+ "trace_id": "expected-trace",
+ "work_item_id": "expected-work-item",
+ },
+ "agent99_current_receipt": {
+ "schema_version": "agent99_controlled_dispatch_receipt_v1",
+ "identity": {
+ "incident_id": "INC-BACKUP",
+ "run_id": "different-run",
+ "trace_id": "different-trace",
+ "work_item_id": "different-work-item",
+ },
+ "dispatch_receipt": {
+ "kind": "backup_health",
+ "accepted": True,
+ "inbox_triggered": True,
+ },
+ "dispatch_accepted": True,
+ "runtime_closure_verified": True,
+ "closure_complete": True,
+ },
+ "agent99_current_run_state": "completed",
+ "agent99_current_trace_id": "different-trace",
+ })
+
+ contract = item["backup_restore_automation"]
+ dispatch = contract["candidate"]["agent99_dispatch"]
+ assert dispatch["dispatched"] is False
+ assert dispatch["status"] == "current_ledger_identity_mismatch_fail_closed"
+ assert dispatch["runtime_closure_verified"] is False
+ assert contract["candidate"]["execution_state"] == "not_started"
+ assert contract["verifier"]["terminal"] is False
+ assert contract["closure_state"] == "partial_degraded_safe_next_action"
+
+
+@pytest.mark.asyncio
+async def test_unfiltered_query_selects_declared_effective_and_dispatch_receipts(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ queries: list[str] = []
+
+ class FakeResult:
+ def __init__(self, *, first=None, rows=None):
+ self._first = first
+ self._rows = rows or []
+
+ def mappings(self):
+ return self
+
+ def first(self):
+ return self._first
+
+ def all(self):
+ return self._rows
+
+ class FakeDb:
+ async def execute(self, statement, _params=None):
+ query = str(statement)
+ queries.append(query)
+ if "COUNT(*) AS total" in query:
+ return FakeResult(first={
+ "total": 100,
+ "sent_total": 100,
+ "failed_total": 0,
+ "pending_total": 0,
+ "shadow_total": 0,
+ "delivery_receipt_required_total": 100,
+ "backup_restore_total": 98,
+ "declared_runtime_write_gate_open_count": 97,
+ "runtime_write_gate_open_count": 1,
+ "declared_learning_writeback_ready_total": 100,
+ "learning_writeback_ready_total": 2,
+ "latest_sent_at": None,
+ "latest_queued_at": None,
+ })
+ return FakeResult(rows=[])
+
+ class FakeDbContext:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def passthrough_store(_kind, _key, value, **_kwargs):
+ return value
+
+ monkeypatch.setattr(
+ platform_operator_service,
+ "get_db_context",
+ lambda _project_id: FakeDbContext(),
+ )
+ monkeypatch.setattr(
+ platform_operator_service,
+ "store_operator_summary_async",
+ passthrough_store,
+ )
+
+ payload = await platform_operator_service.list_ai_alert_card_delivery_readback(
+ project_id="awoooi",
+ event_type=None,
+ lane=None,
+ page=1,
+ per_page=5,
+ refresh=True,
+ )
+
+ summary_query = next(query for query in queries if "COUNT(*) AS total" in query)
+ list_query = next(query for query in queries if "m.message_id" in query)
+ assert "AS declared_runtime_write_gate_open_count" in summary_query
+ assert "AS backup_restore_total" in summary_query
+ assert "declared_runtime_write_gate_count" in summary_query
+ assert "AS declared_learning_writeback_ready_total" in summary_query
+ assert summary_query.count("<> 'backup_restore_escrow_signal'") >= 2
+ assert "-> 'agent99_dispatch_receipt' AS agent99_dispatch_receipt" in list_query
+ assert "LEFT JOIN LATERAL" in list_query
+ assert "agent99_current_receipt" in list_query
+ assert payload["summary"]["declared_runtime_write_gate_open_count"] == 97
+ assert payload["summary"]["backup_restore_total"] == 98
+ assert payload["summary"]["runtime_write_gate_open_count"] == 1
+ assert payload["summary"]["declared_learning_writeback_ready_total"] == 100
+ assert payload["summary"]["learning_writeback_ready_total"] == 2
+ assert payload["summary"]["no_false_green_enforced"] is True
+
+
def test_list_ai_alert_cards_response_preserves_delivery_metadata() -> None:
run_id = UUID("5c0306e0-591a-5445-9a33-80f499426b38")
message_id = UUID("66cdb6ad-46a4-48f5-9d3b-b1ac9c0b2e92")
@@ -1356,6 +2000,9 @@ async def test_list_ai_alert_card_delivery_readback_failsofts_slow_db(
async def __aexit__(self, exc_type, exc, tb):
return False
+ async def no_cached_readback(**_kwargs):
+ return None
+
monkeypatch.setattr(
platform_operator_service,
"_AI_ALERT_CARD_QUERY_TIMEOUT_SECONDS",
@@ -1376,6 +2023,11 @@ async def test_list_ai_alert_card_delivery_readback_failsofts_slow_db(
"_load_ai_alert_card_delivery_readback_direct",
_no_ai_alert_card_direct_readback,
)
+ monkeypatch.setattr(
+ platform_operator_service,
+ "_get_cached_ai_alert_card_delivery_readback",
+ no_cached_readback,
+ )
payload = await platform_operator_service.list_ai_alert_card_delivery_readback(
project_id="awoooi",
diff --git a/apps/api/tests/test_backup_restore_alertmanager_ingress.py b/apps/api/tests/test_backup_restore_alertmanager_ingress.py
new file mode 100644
index 000000000..f63f29c59
--- /dev/null
+++ b/apps/api/tests/test_backup_restore_alertmanager_ingress.py
@@ -0,0 +1,397 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+from uuid import UUID
+
+import pytest
+from fastapi import BackgroundTasks
+from starlette.requests import Request
+
+from src.api.v1 import webhooks
+from src.services import backup_restore_alertmanager_ingress as ingress
+from src.services.agent99_controlled_dispatch_ledger import (
+ build_agent99_dispatch_receipt_envelope,
+)
+from src.services.agent99_sre_bridge import bridge_alertmanager_to_agent99
+
+
+def _request() -> Request:
+ return Request(
+ {
+ "type": "http",
+ "method": "POST",
+ "path": "/api/v1/webhooks/alertmanager",
+ "scheme": "http",
+ "server": ("testserver", 80),
+ "client": ("127.0.0.1", 50000),
+ "query_string": b"",
+ "headers": [],
+ }
+ )
+
+
+def _signal_kwargs() -> dict[str, object]:
+ return {
+ "project_id": "awoooi",
+ "alert_id": "backup-alert-1",
+ "alertname": "BackupCredentialEscrowEvidenceMissing",
+ "severity": "critical",
+ "namespace": "awoooi-prod",
+ "target_resource": "backup_restore",
+ "message": "backup freshness and escrow evidence missing",
+ "labels": {
+ "event_type": "backup_restore_escrow_signal",
+ "lane": "backup_restore_escrow_triage",
+ },
+ "annotations": {"summary": "readback verifier required"},
+ "source_fingerprint": "source-fingerprint",
+ "source_event_fingerprint": "alertmanager-native-fingerprint",
+ "source_started_at": "2026-07-11T10:00:00Z",
+ "alert_category": "backup",
+ "notification_type": "TYPE-1",
+ "source_url": "http://alertmanager/graph",
+ }
+
+
+def test_backup_ingress_owner_is_stable_per_source_occurrence() -> None:
+ first = ingress.build_backup_restore_ingress_owner(
+ project_id="awoooi",
+ source_fingerprint="source-fingerprint",
+ source_event_fingerprint="native-fingerprint",
+ source_started_at="2026-07-11T10:00:00Z",
+ )
+ replay = ingress.build_backup_restore_ingress_owner(
+ project_id="awoooi",
+ source_fingerprint="source-fingerprint",
+ source_event_fingerprint="native-fingerprint",
+ source_started_at="2026-07-11T10:00:00Z",
+ )
+ new_firing = ingress.build_backup_restore_ingress_owner(
+ project_id="awoooi",
+ source_fingerprint="source-fingerprint",
+ source_event_fingerprint="native-fingerprint",
+ source_started_at="2026-07-12T10:00:00Z",
+ )
+
+ assert first == replay
+ assert new_firing["approval_id"] != first["approval_id"]
+ assert first["route_id"] == "agent99:backup_health:BackupCheck"
+ assert first["single_dispatch_owner"] == "alertmanager_raw_signal"
+ assert first["telegram_role"] == "receipt_projection_only"
+ assert first["controlled_apply"] is False
+ assert first["read_only"] is True
+
+
+@pytest.mark.asyncio
+async def test_alertmanager_type1_backup_queues_durable_owner_before_info_shortcut(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ grouped = SimpleNamespace(is_grouped=False)
+ grouping = SimpleNamespace(evaluate=AsyncMock(return_value=grouped))
+ process = AsyncMock(return_value={"status": "queued"})
+ record = AsyncMock()
+ append = AsyncMock()
+
+ monkeypatch.setattr(webhooks, "get_alert_grouping_service", lambda: grouping)
+ monkeypatch.setattr(
+ "src.repositories.alert_operation_log_repository.get_alert_operation_log_repository",
+ lambda: SimpleNamespace(append=append),
+ )
+ monkeypatch.setattr(webhooks, "record_alertmanager_event", record)
+ monkeypatch.setattr(
+ webhooks,
+ "process_backup_restore_alertmanager_signal",
+ process,
+ )
+ monkeypatch.setattr(
+ webhooks,
+ "get_approval_service",
+ lambda: (_ for _ in ()).throw(
+ AssertionError("generic approval/TYPE-1 branch must not run")
+ ),
+ )
+
+ tasks = BackgroundTasks()
+ payload = webhooks.AlertmanagerPayload(
+ status="firing",
+ alerts=[
+ webhooks.AlertmanagerAlert(
+ status="firing",
+ labels={
+ "alertname": "HostBackupFailed",
+ "severity": "warning",
+ "component": "backup_restore",
+ "namespace": "awoooi-prod",
+ },
+ annotations={
+ "summary": "backup freshness missing; readback required"
+ },
+ startsAt=datetime.now(UTC).isoformat(),
+ fingerprint="native-backup-fingerprint",
+ generatorURL="http://alertmanager/graph",
+ )
+ ],
+ )
+
+ response = await webhooks.alertmanager_webhook(_request(), payload, tasks)
+
+ assert response.success is True
+ assert "durable read-only BackupCheck" in response.message
+ assert response.approval_created is False
+ assert process.await_count == 0
+
+ await tasks()
+
+ process.assert_awaited_once()
+ call = process.await_args.kwargs
+ assert call["alertname"] == "HostBackupFailed"
+ assert call["source_event_fingerprint"] == "native-backup-fingerprint"
+ assert call["notification_type"] == "TYPE-1"
+ assert call["alert_category"] == "backup"
+ assert call["target_resource"] == "backup_restore"
+
+
+class _FakeApprovalService:
+ def __init__(self, calls: list[str]) -> None:
+ self.calls = calls
+ self.approval: SimpleNamespace | None = None
+ self.created_request = None
+
+ async def get_approval(self, approval_id: UUID):
+ self.calls.append("owner_read")
+ if self.approval is not None:
+ assert self.approval.id == approval_id
+ return self.approval
+
+ async def find_by_fingerprint(self, **_kwargs):
+ self.calls.append("legacy_owner_read")
+ return None
+
+ async def create_approval_with_fingerprint(self, *, request, fingerprint):
+ self.calls.append("owner_create")
+ self.created_request = request
+ assert fingerprint == "source-fingerprint"
+ self.approval = SimpleNamespace(
+ id=UUID(request.metadata["preallocated_approval_id"]),
+ incident_id=request.incident_id,
+ hit_count=1,
+ risk_level=request.risk_level,
+ )
+ return self.approval
+
+ async def increment_hit_count(self, approval_id: UUID):
+ self.calls.append("owner_recurrence")
+ assert self.approval is not None
+ assert self.approval.id == approval_id
+ self.approval.hit_count += 1
+ return self.approval
+
+ async def update_incident_id(self, approval_id: UUID, incident_id: str):
+ self.calls.append("incident_bind")
+ assert self.approval is not None
+ assert self.approval.id == approval_id
+ self.approval.incident_id = incident_id
+
+
+@pytest.mark.asyncio
+async def test_backup_ingress_binds_canonical_incident_then_dispatches_and_reads_back(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls: list[str] = []
+ approval_service = _FakeApprovalService(calls)
+ incident_id = "INC-20260711-BACKUP"
+ identity: dict[str, str] = {}
+
+ async def create_incident(**_kwargs) -> str:
+ calls.append("incident_create")
+ return incident_id
+
+ async def canonical_readback(value: str, *, project_id: str):
+ calls.append("incident_canonical_readback")
+ assert value == incident_id
+ assert project_id == "awoooi"
+ return SimpleNamespace(status="investigating", persisted_to_pg=True)
+
+ async def dispatch(**kwargs):
+ calls.append("agent99_dispatch")
+ assert kwargs["incident_id"] == incident_id
+ assert kwargs["route_id"] == "agent99:backup_health:BackupCheck"
+ identity.update({
+ "incident_id": incident_id,
+ "run_id": "12345678-1234-5678-9234-567812345678",
+ "trace_id": "00-11111111111111111111111111111111-2222222222222222-01",
+ "work_item_id": kwargs["work_item_id"],
+ })
+ return {
+ "status": "dispatched",
+ "dispatchPerformed": True,
+ "identity": dict(identity),
+ }
+
+ async def readback(**kwargs):
+ calls.append("dispatch_receipt_readback")
+ assert kwargs == {"project_id": "awoooi", "incident_id": incident_id}
+ return {
+ "status": "dispatch_accepted_verifier_pending",
+ "identity": dict(identity),
+ "dispatch_receipt": {
+ "kind": "backup_health",
+ "accepted": True,
+ "inbox_triggered": True,
+ },
+ "verifier": {"status": "pending"},
+ "runtime_execution_authorized": False,
+ "runtime_closure_verified": False,
+ }
+
+ monkeypatch.setattr(ingress, "get_approval_service", lambda: approval_service)
+ monkeypatch.setattr(ingress, "create_incident_for_approval", create_incident)
+ monkeypatch.setattr(
+ ingress,
+ "get_incident_service",
+ lambda: SimpleNamespace(get_for_readback=canonical_readback),
+ )
+ monkeypatch.setattr(ingress, "bridge_alertmanager_to_agent99", dispatch)
+ monkeypatch.setattr(ingress, "read_agent99_dispatch_receipt", readback)
+ monkeypatch.setattr(ingress, "record_alertmanager_event", AsyncMock())
+
+ result = await ingress.process_backup_restore_alertmanager_signal(
+ **_signal_kwargs()
+ )
+
+ assert calls.index("owner_create") < calls.index("incident_create")
+ assert calls.index("incident_create") < calls.index("incident_bind")
+ assert calls.index("incident_bind") < calls.index("incident_canonical_readback")
+ assert calls.index("incident_canonical_readback") < calls.index(
+ "agent99_dispatch"
+ )
+ assert calls.index("agent99_dispatch") < calls.index(
+ "dispatch_receipt_readback"
+ )
+ assert result["status"] == "backupcheck_dispatched_verifier_pending"
+ assert result["receipt_persisted"] is True
+ assert result["accepted"] is True
+ assert result["verifier"]["status"] == "pending"
+ assert result["verifier"]["terminal"] is False
+ assert result["runtime_execution_authorized"] is False
+ assert result["runtime_closure_verified"] is False
+ assert result["production_write_performed"] is False
+ assert approval_service.created_request.risk_level.value == "low"
+ assert approval_service.created_request.metadata["telegram_role"] == (
+ "receipt_projection_only"
+ )
+ assert "run_restore" in result["prohibited_actions"]
+
+
+class _FakeDispatchLedger:
+ def __init__(self) -> None:
+ self.receipt = None
+ self.complete_calls = 0
+
+ async def reserve(self, *, identity, payload):
+ if self.receipt is not None:
+ return {
+ "status": "idempotent_waiting_tool",
+ "claimed": False,
+ "identity": identity.public_dict(),
+ "receipt": self.receipt,
+ }
+ return {
+ "status": "reserved",
+ "claimed": True,
+ "claim_token": "claim-token-1",
+ "identity": identity.public_dict(),
+ "receipt": None,
+ }
+
+ async def complete(self, *, identity, dispatch_receipt, **_kwargs):
+ self.complete_calls += 1
+ self.receipt = {
+ **build_agent99_dispatch_receipt_envelope(
+ identity=identity,
+ dispatch_receipt=dispatch_receipt,
+ ),
+ "receipt_persisted": True,
+ }
+ return self.receipt
+
+ async def mark_delivery_attempt_started(self, *, identity, **_kwargs):
+ self.receipt = {
+ "status": "dispatch_delivery_unknown_reconcile_only",
+ "identity": identity.public_dict(),
+ "retry_policy": {"safe_to_retry": False, "reconcile_only": True},
+ "receipt_persisted": True,
+ "runtime_closure_verified": False,
+ }
+ return self.receipt
+
+
+@pytest.mark.asyncio
+async def test_backup_bridge_postgres_identity_deduplicates_transport(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ ledger = _FakeDispatchLedger()
+ transported: list[dict[str, object]] = []
+
+ async def lock(*_args, **_kwargs):
+ return {"acquired": True, "reason": "acquired"}
+
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.get_agent99_dispatch_ledger",
+ lambda: ledger,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.acquire_agent99_sre_single_flight",
+ lock,
+ )
+ monkeypatch.setattr(
+ "src.services.agent99_sre_bridge.dispatch_agent99_sre_alert_with_receipt",
+ lambda payload: transported.append(payload)
+ or {
+ "schema_version": "agent99_sre_dispatch_receipt_v1",
+ "status": "accepted_inbox_triggered",
+ "transport": "relay",
+ "alert_id": str(payload["id"]),
+ "kind": str(payload["kind"]),
+ "accepted": True,
+ "inbox_triggered": True,
+ "delivery_certainty": "delivered",
+ },
+ )
+ common = {
+ "alertname": "BackupCredentialEscrowEvidenceMissing",
+ "severity": "critical",
+ "namespace": "awoooi-prod",
+ "target_resource": "backup_restore",
+ "message": "backup freshness and escrow evidence missing",
+ "labels": {"event_type": "backup_restore_escrow_signal"},
+ "fingerprint": "stable-backup-source-fingerprint",
+ "project_id": "awoooi",
+ "incident_id": "INC-20260711-BACKUP",
+ "approval_id": "12345678-1234-5678-9234-567812345678",
+ "route_id": "agent99:backup_health:BackupCheck",
+ "work_item_id": "backupcheck:awoooi:stable",
+ }
+
+ first = await bridge_alertmanager_to_agent99(
+ alert_id="backup-alert-1",
+ **common,
+ )
+ replay = await bridge_alertmanager_to_agent99(
+ alert_id="backup-alert-2",
+ **common,
+ )
+
+ assert first["status"] == "dispatched"
+ assert replay["status"] == "deduplicated"
+ assert first["identity"] == replay["identity"]
+ assert first["dispatchPerformed"] is True
+ assert replay["dispatchPerformed"] is False
+ assert ledger.complete_calls == 1
+ assert len(transported) == 1
+ assert transported[0]["kind"] == "backup_health"
+ assert transported[0]["suggestedMode"] == "BackupCheck"
+ assert transported[0]["controlledApply"] is False
+ assert "do not run backup/restore" in str(transported[0]["instruction"])
diff --git a/apps/api/tests/test_backup_restore_legacy_backfill_job.py b/apps/api/tests/test_backup_restore_legacy_backfill_job.py
new file mode 100644
index 000000000..c92529f8d
--- /dev/null
+++ b/apps/api/tests/test_backup_restore_legacy_backfill_job.py
@@ -0,0 +1,1383 @@
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import Any
+from unittest.mock import AsyncMock
+from uuid import NAMESPACE_URL, UUID, uuid5
+
+import pytest
+
+from src.core.context import (
+ clear_project_context,
+ get_current_project_id,
+ set_project_context,
+)
+from src.jobs import backup_restore_legacy_backfill_job as job
+
+
+class _Result:
+ def __init__(
+ self,
+ *,
+ row: dict[str, Any] | None = None,
+ rows: list[dict[str, Any]] | None = None,
+ value: Any = None,
+ ) -> None:
+ self.row = row
+ self.rows = rows or []
+ self.value = value
+
+ def mappings(self) -> _Result:
+ return self
+
+ def one_or_none(self) -> dict[str, Any] | None:
+ return self.row
+
+ def all(self) -> list[dict[str, Any]]:
+ return self.rows
+
+ def scalar_one_or_none(self) -> Any:
+ return self.value
+
+
+class _DbContext:
+ def __init__(self, db: Any) -> None:
+ self.db = db
+
+ async def __aenter__(self) -> Any:
+ return self.db
+
+ async def __aexit__(self, *_args: Any) -> bool:
+ return False
+
+
+def _source_row(index: int) -> dict[str, Any]:
+ return {
+ "message_id": uuid5(NAMESPACE_URL, f"legacy-backup-message:{index}"),
+ "queued_at": datetime(2026, 7, 11, tzinfo=UTC) + timedelta(seconds=index),
+ "source_fingerprint": (
+ "ai_automation_alert_card:backup_restore_escrow_signal:"
+ "backup_restore_escrow_triage"
+ ),
+ "event_type": job.BACKUP_RESTORE_EVENT_TYPE,
+ "lane": job.BACKUP_RESTORE_LANE,
+ "target": "backup_restore",
+ }
+
+
+def _claim_from_row(index: int) -> job.LegacyBackupClaim:
+ item = job._item_payload_from_row(
+ project_id="awoooi",
+ row=_source_row(index),
+ )
+ return job.LegacyBackupClaim(
+ run_id=UUID(item["backfill_run_id"]),
+ claim_token=f"claim-{index}",
+ attempt_count=1,
+ max_attempts=5,
+ item=item,
+ )
+
+
+def _card_from_claim(claim: job.LegacyBackupClaim) -> job.LegacyBackupCard:
+ item = claim.item
+ return job.LegacyBackupCard(
+ message_id=str(item["source_message_id"]),
+ queued_at=str(item["source_queued_at"]),
+ source_fingerprint=str(item["source_fingerprint"]),
+ event_type=job.BACKUP_RESTORE_EVENT_TYPE,
+ lane=job.BACKUP_RESTORE_LANE,
+ target="backup_restore",
+ source_envelope={
+ "ai_automation_alert_card": {
+ "event_type": job.BACKUP_RESTORE_EVENT_TYPE,
+ "lane": job.BACKUP_RESTORE_LANE,
+ "target": "backup_restore",
+ },
+ "source_refs": {"fingerprints": [item["source_fingerprint"]]},
+ },
+ )
+
+
+def _durable_dispatch_result(kwargs: dict[str, Any]) -> dict[str, Any]:
+ owner = job.build_backup_restore_ingress_owner(
+ project_id=kwargs["project_id"],
+ source_fingerprint=kwargs["source_fingerprint"],
+ source_event_fingerprint=kwargs["source_event_fingerprint"],
+ source_started_at=kwargs["source_started_at"],
+ )
+ run_id = uuid5(
+ NAMESPACE_URL,
+ f"agent99-backupcheck:{kwargs['source_event_fingerprint']}",
+ )
+ return {
+ "status": "backupcheck_dispatched_verifier_pending",
+ "owner": {
+ "incident_id": owner["incident_id"],
+ "approval_id": owner["approval_id"],
+ "work_item_id": owner["work_item_id"],
+ "route_id": job.BACKUP_RESTORE_AGENT99_ROUTE_ID,
+ "kind": "backup_health",
+ "mode": "BackupCheck",
+ "controlled_apply": False,
+ "read_only": True,
+ },
+ "identity": {
+ "incident_id": owner["incident_id"],
+ "run_id": str(run_id),
+ "trace_id": f"trace-{run_id}",
+ "work_item_id": owner["work_item_id"],
+ "idempotency_key": kwargs["source_event_fingerprint"],
+ },
+ "accepted": True,
+ "receipt_persisted": True,
+ "dispatch_performed": True,
+ "runtime_execution_authorized": False,
+ "runtime_closure_verified": False,
+ "production_write_performed": False,
+ "verifier": {"status": "pending", "receipt": {"status": "pending"}},
+ }
+
+
+def test_legacy_occurrence_identity_is_stable_and_source_specific() -> None:
+ kwargs = {
+ "project_id": "awoooi",
+ "message_id": "2f0eb0e7-a5b2-55ab-8d48-397c19998725",
+ "source_fingerprint": "legacy-fingerprint-1",
+ "queued_at": "2026-07-11T10:00:00+00:00",
+ }
+ first = job.build_legacy_backup_occurrence(**kwargs)
+ replay = job.build_legacy_backup_occurrence(**kwargs)
+ next_message = job.build_legacy_backup_occurrence(
+ **{**kwargs, "message_id": "22c0b48d-9674-543d-bf0b-ec25f1386406"}
+ )
+ next_fingerprint = job.build_legacy_backup_occurrence(
+ **{**kwargs, "source_fingerprint": "legacy-fingerprint-2"}
+ )
+
+ assert first == replay
+ for other in (next_message, next_fingerprint):
+ assert other["occurrence_id"] != first["occurrence_id"]
+ assert other["incident_id"] != first["incident_id"]
+ assert other["work_item_id"] != first["work_item_id"]
+ assert other["backfill_run_id"] != first["backfill_run_id"]
+ assert first["source_message_id"] in first["occurrence_id"]
+ assert first["dispatch_source_fingerprint"].startswith("legacy-backup-occurrence:")
+ assert first["controlled_apply"] is False
+ assert first["telegram_dispatch_performed"] is False
+
+
+def test_cursor_never_reports_completed_when_any_item_failed() -> None:
+ detail = {
+ "source_exhausted": True,
+ "scope_cap_reached": False,
+ "failed_total": 1,
+ }
+
+ assert job._cursor_terminal_state(detail, remaining=0) == (
+ "blocked_failed_items_fail_closed",
+ "failed",
+ )
+ assert job._cursor_terminal_state(
+ {**detail, "failed_total": 0},
+ remaining=0,
+ ) == ("completed", "completed")
+ assert job._cursor_terminal_state(
+ {**detail, "scope_cap_reached": True},
+ remaining=0,
+ ) == ("blocked_scope_cap", "waiting_tool")
+ assert job._cursor_terminal_state(
+ {**detail, "failed_total": 0, "source_drift_detected": True},
+ remaining=0,
+ ) == ("blocked_source_drift_fail_closed", "failed")
+ assert job._cursor_terminal_state(
+ {
+ **detail,
+ "failed_total": 0,
+ "source_total_at_start": 98,
+ "item_total": 97,
+ },
+ remaining=0,
+ ) == ("blocked_accounting_drift_fail_closed", "failed")
+
+
+@pytest.mark.asyncio
+async def test_116_rows_progress_through_two_bounded_durable_cohorts(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ rows = [_source_row(index) for index in range(116)]
+
+ class FakeDb:
+ def __init__(self) -> None:
+ self.cursor_detail: dict[str, Any] | None = None
+ self.snapshot_reads = 0
+ self.snapshot_sizes: list[int] = []
+ self.idempotency: set[str] = set()
+ self.item_total = 0
+ self.completed_total = 0
+
+ async def execute(
+ self,
+ statement: Any,
+ params: dict[str, Any],
+ ) -> _Result:
+ assert params["project_id"] == "awoooi"
+ if statement is job._CURSOR_INSERT_SQL:
+ if self.cursor_detail is None:
+ self.cursor_detail = json.loads(params["error_detail"])
+ return _Result()
+ if statement is job._CURSOR_SELECT_SQL:
+ assert self.cursor_detail is not None
+ return _Result(
+ row={
+ "run_id": job._cursor_run_id("awoooi"),
+ "state": "waiting_tool",
+ "error_detail": json.dumps(self.cursor_detail),
+ }
+ )
+ if statement is job._SOURCE_SNAPSHOT_SQL:
+ assert params["limit"] == 100
+ upper = rows[-1]
+ if self.snapshot_reads == 0:
+ source_rows = rows[:100]
+ remaining = 116
+ assert params["source_upper_queued_at"] is None
+ else:
+ source_rows = rows[100:]
+ remaining = 16
+ assert params["source_upper_message_id"] == str(upper["message_id"])
+ assert isinstance(params["source_upper_queued_at"], datetime)
+ assert params["source_upper_queued_at"] == upper["queued_at"]
+ self.snapshot_reads += 1
+ self.snapshot_sizes.append(len(source_rows))
+ return _Result(
+ rows=[
+ {
+ **row,
+ "source_total_in_scope": remaining,
+ "source_upper_message_id": upper["message_id"],
+ "source_upper_queued_at": upper["queued_at"],
+ }
+ for row in source_rows
+ ]
+ )
+ if statement is job._ITEM_RUN_INSERT_SQL:
+ return _Result()
+ if statement is job._ITEM_IDEMPOTENCY_INSERT_SQL:
+ key = str(params["provider_event_id"])
+ if key in self.idempotency:
+ return _Result(value=None)
+ self.idempotency.add(key)
+ return _Result(value=params["run_id"])
+ if statement is job._ITEM_STATE_COUNTS_SQL:
+ return _Result(
+ row={
+ "item_total": self.item_total,
+ "remaining_total": 0,
+ "completed_total": self.completed_total,
+ "failed_total": 0,
+ }
+ )
+ if statement is job._CURSOR_UPDATE_SQL:
+ self.cursor_detail = json.loads(params["error_detail"])
+ return _Result()
+ raise AssertionError(f"unexpected statement: {statement}")
+
+ db = FakeDb()
+ monkeypatch.setattr(job, "get_db_context", lambda _project_id: _DbContext(db))
+
+ first_cohort_pages = []
+ for _ in range(5):
+ result = await job._reserve_legacy_backup_page(
+ project_id="awoooi",
+ scan_limit=20,
+ max_rows=100,
+ )
+ first_cohort_pages.append(result["scanned"])
+
+ assert first_cohort_pages == [20, 20, 20, 20, 20]
+ assert result["source_total_at_start"] == 116
+ assert result["source_exhausted"] is False
+ assert result["cohort_exhausted"] is True
+ assert result["scope_cap_reached"] is False
+ assert len(db.idempotency) == 100
+
+ db.item_total = db.completed_total = 100
+ first_terminal = await job._update_cursor_counters(
+ project_id="awoooi",
+ completed=100,
+ deduplicated=0,
+ failed=0,
+ retried=0,
+ )
+ assert first_terminal["status"] == "cohort_completed_next_pending"
+ assert first_terminal["completed_cohort_count"] == 1
+ assert first_terminal["source_snapshot"] is None
+
+ second = await job._reserve_legacy_backup_page(
+ project_id="awoooi",
+ scan_limit=20,
+ max_rows=100,
+ )
+ assert second["scanned"] == second["reserved"] == 16
+ assert second["source_exhausted"] is True
+ assert second["cohort_index"] == 2
+ assert second["cohort_source_total"] == 16
+ assert len(db.idempotency) == 116
+
+ db.item_total = db.completed_total = 116
+ final = await job._update_cursor_counters(
+ project_id="awoooi",
+ completed=16,
+ deduplicated=0,
+ failed=0,
+ retried=0,
+ )
+
+ assert final["status"] == "completed"
+ assert final["completed_cohort_count"] == 2
+ assert final["source_total_at_start"] == 116
+ assert final["scanned_total"] == 116
+ assert db.snapshot_reads == 2
+ assert db.snapshot_sizes == [100, 16]
+ assert max(db.snapshot_sizes) <= 100
+
+
+@pytest.mark.asyncio
+async def test_reserver_snapshots_exact_98_once_and_advances_durable_cursor(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ rows = [_source_row(index) for index in range(98)]
+
+ class FakeDb:
+ def __init__(self) -> None:
+ self.cursor_detail: dict[str, Any] | None = None
+ self.snapshot_reads = 0
+ self.item_runs: set[str] = set()
+ self.idempotency: set[str] = set()
+
+ async def execute(
+ self,
+ statement: Any,
+ params: dict[str, Any],
+ ) -> _Result:
+ assert params["project_id"] == "awoooi"
+ if statement is job._CURSOR_INSERT_SQL:
+ if self.cursor_detail is None:
+ self.cursor_detail = json.loads(params["error_detail"])
+ return _Result()
+ if statement is job._CURSOR_SELECT_SQL:
+ assert self.cursor_detail is not None
+ return _Result(
+ row={
+ "run_id": params["run_id"],
+ "state": "waiting_tool",
+ "error_detail": json.dumps(self.cursor_detail),
+ }
+ )
+ if statement is job._SOURCE_SNAPSHOT_SQL:
+ self.snapshot_reads += 1
+ assert params["limit"] == 100
+ return _Result(rows=rows)
+ if statement is job._ITEM_RUN_INSERT_SQL:
+ self.item_runs.add(str(params["run_id"]))
+ return _Result()
+ if statement is job._ITEM_IDEMPOTENCY_INSERT_SQL:
+ key = str(params["provider_event_id"])
+ if key in self.idempotency:
+ return _Result(value=None)
+ self.idempotency.add(key)
+ return _Result(value=params["run_id"])
+ if statement is job._CURSOR_UPDATE_SQL:
+ self.cursor_detail = json.loads(params["error_detail"])
+ return _Result()
+ raise AssertionError(f"unexpected statement: {statement}")
+
+ db = FakeDb()
+ monkeypatch.setattr(job, "get_db_context", lambda _project_id: _DbContext(db))
+
+ batches = []
+ for _ in range(10):
+ result = await job._reserve_legacy_backup_page(
+ project_id="awoooi",
+ scan_limit=17,
+ max_rows=100,
+ )
+ batches.append(result["scanned"])
+ if result["source_exhausted"]:
+ break
+
+ assert batches == [17, 17, 17, 17, 17, 13]
+ assert db.snapshot_reads == 1
+ assert len(db.item_runs) == len(db.idempotency) == 98
+ assert db.cursor_detail is not None
+ assert db.cursor_detail["source_total_at_start"] == 98
+ assert db.cursor_detail["scanned_total"] == 98
+ assert db.cursor_detail["source_exhausted"] is True
+ assert db.cursor_detail["source_snapshot_sha256"]
+
+ replay = await job._reserve_legacy_backup_page(
+ project_id="awoooi",
+ scan_limit=17,
+ max_rows=100,
+ )
+ assert replay["scanned"] == replay["reserved"] == 0
+ assert db.snapshot_reads == 1
+
+
+@pytest.mark.asyncio
+async def test_claim_sweeps_expired_max_attempt_before_selecting_retryable_work(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ statements: list[Any] = []
+
+ class FakeDb:
+ async def execute(
+ self,
+ statement: Any,
+ params: dict[str, Any],
+ ) -> _Result:
+ statements.append(statement)
+ assert params["project_id"] == "awoooi"
+ assert params["agent_id"] == job.BACKFILL_ITEM_AGENT_ID
+ if statement is job._EXHAUST_EXPIRED_CLAIMS_SQL:
+ return _Result(rows=[{"run_id": _claim_from_row(90).run_id}])
+ if statement is job._CLAIM_SQL:
+ assert params["limit"] == 20
+ return _Result(rows=[])
+ raise AssertionError(f"unexpected statement: {statement}")
+
+ monkeypatch.setattr(
+ job,
+ "get_db_context",
+ lambda _project_id: _DbContext(FakeDb()),
+ )
+
+ claims = await job._claim_legacy_backup_items(project_id="awoooi", limit=25)
+
+ assert claims == []
+ assert statements == [job._EXHAUST_EXPIRED_CLAIMS_SQL, job._CLAIM_SQL]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("item_total", "completed_total", "expected_status", "expected_cursor_state"),
+ [
+ (98, 98, "completed", "completed"),
+ (97, 97, "blocked_accounting_drift_fail_closed", "failed"),
+ ],
+)
+async def test_terminal_status_uses_authoritative_item_state_counts(
+ monkeypatch: pytest.MonkeyPatch,
+ item_total: int,
+ completed_total: int,
+ expected_status: str,
+ expected_cursor_state: str,
+) -> None:
+ updated_state = ""
+ detail = job._initial_cursor("awoooi")
+ detail.update(
+ {
+ "source_total_at_start": 98,
+ "source_exhausted": True,
+ "scanned_total": 98,
+ }
+ )
+
+ class FakeDb:
+ async def execute(
+ self,
+ statement: Any,
+ params: dict[str, Any],
+ ) -> _Result:
+ nonlocal updated_state
+ if statement is job._CURSOR_SELECT_SQL:
+ return _Result(row={"error_detail": json.dumps(detail)})
+ if statement is job._ITEM_STATE_COUNTS_SQL:
+ return _Result(
+ row={
+ "item_total": item_total,
+ "remaining_total": 0,
+ "completed_total": completed_total,
+ "failed_total": 0,
+ }
+ )
+ if statement is job._CURSOR_UPDATE_SQL:
+ updated_state = str(params["state"])
+ return _Result()
+ raise AssertionError(f"unexpected statement: {statement}")
+
+ monkeypatch.setattr(
+ job,
+ "get_db_context",
+ lambda _project_id: _DbContext(FakeDb()),
+ )
+
+ result = await job._update_cursor_counters(
+ project_id="awoooi",
+ completed=0,
+ deduplicated=0,
+ failed=0,
+ retried=0,
+ )
+
+ assert result["status"] == expected_status
+ assert result["completed_total"] == completed_total
+ assert updated_state == expected_cursor_state
+
+
+@pytest.mark.asyncio
+async def test_exact_98_legacy_cards_replay_in_bounded_restart_safe_batches(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ rows = [_source_row(index) for index in range(98)]
+ pending: list[job.LegacyBackupClaim] = []
+ projected: dict[str, dict[str, Any]] = {}
+ finished: dict[str, str] = {}
+ processor_calls: list[dict[str, Any]] = []
+ reserve_batch_sizes: list[int] = []
+ scan_index = 0
+ source_exhausted = False
+ cumulative = {
+ "completed_total": 0,
+ "deduplicated_total": 0,
+ "failed_total": 0,
+ "retry_total": 0,
+ }
+
+ async def reserve(
+ *, project_id: str, scan_limit: int, max_rows: int
+ ) -> dict[str, Any]:
+ nonlocal scan_index, source_exhausted
+ assert project_id == "awoooi"
+ assert max_rows == 100
+ limit = min(scan_limit, job.MAX_BATCH_LIMIT, max_rows - scan_index)
+ page = rows[scan_index : scan_index + limit]
+ for row in page:
+ item = job._item_payload_from_row(project_id=project_id, row=row)
+ pending.append(
+ job.LegacyBackupClaim(
+ run_id=UUID(item["backfill_run_id"]),
+ claim_token=f"claim-{item['source_message_id']}",
+ attempt_count=1,
+ max_attempts=5,
+ item=item,
+ )
+ )
+ scan_index += len(page)
+ source_exhausted = scan_index == len(rows)
+ reserve_batch_sizes.append(len(page))
+ return {
+ "scanned": len(page),
+ "reserved": len(page),
+ "deduplicated": 0,
+ "source_total_at_start": 98,
+ "source_exhausted": source_exhausted,
+ "source_drift_detected": False,
+ "scope_cap_reached": False,
+ "cursor": {
+ "queued_at": (
+ str(page[-1]["queued_at"]) if page else str(rows[-1]["queued_at"])
+ ),
+ "message_id": (
+ str(page[-1]["message_id"]) if page else str(rows[-1]["message_id"])
+ ),
+ },
+ }
+
+ async def claim(*, project_id: str, limit: int) -> list[job.LegacyBackupClaim]:
+ assert project_id == "awoooi"
+ page = pending[:limit]
+ del pending[:limit]
+ return page
+
+ async def load(
+ *, project_id: str, claim: job.LegacyBackupClaim
+ ) -> job.LegacyBackupCard:
+ assert project_id == "awoooi"
+ return _card_from_claim(claim)
+
+ async def processor(**kwargs: Any) -> dict[str, Any]:
+ assert get_current_project_id() == "awoooi"
+ assert kwargs["project_id"] == "awoooi"
+ assert kwargs["labels"]["event_type"] == job.BACKUP_RESTORE_EVENT_TYPE
+ assert kwargs["labels"]["lane"] == job.BACKUP_RESTORE_LANE
+ assert kwargs["source_event_fingerprint"].startswith("legacy-backup:")
+ processor_calls.append(kwargs)
+ return _durable_dispatch_result(kwargs)
+
+ async def persist(
+ *,
+ project_id: str,
+ claim: job.LegacyBackupClaim,
+ card: job.LegacyBackupCard,
+ occurrence: dict[str, Any],
+ projection: dict[str, Any],
+ ) -> str:
+ assert project_id == "awoooi"
+ assert claim.run_id == UUID(occurrence["backfill_run_id"])
+ assert occurrence["source_message_id"] == card.message_id
+ assert projection["telegram_dispatch_performed"] is False
+ assert projection["runtime_execution_authorized"] is False
+ assert card.message_id not in projected
+ projected[card.message_id] = dict(projection)
+ return "projected"
+
+ async def finish(
+ *,
+ project_id: str,
+ claim: job.LegacyBackupClaim,
+ state: str,
+ result: dict[str, Any],
+ error_code: str | None,
+ ) -> bool:
+ assert project_id == "awoooi"
+ assert state == "completed"
+ assert error_code is None
+ assert result["controlled_apply"] is False
+ assert result["telegram_dispatch_performed"] is False
+ assert result["backfill_run_id"] == str(claim.run_id)
+ finished[str(claim.run_id)] = state
+ return True
+
+ async def update_cursor(
+ *,
+ project_id: str,
+ completed: int,
+ deduplicated: int,
+ failed: int,
+ retried: int,
+ ) -> dict[str, Any]:
+ assert project_id == "awoooi"
+ cumulative["completed_total"] += completed
+ cumulative["deduplicated_total"] += deduplicated
+ cumulative["failed_total"] += failed
+ cumulative["retry_total"] += retried
+ return {
+ **cumulative,
+ "remaining_item_total": len(pending),
+ "status": (
+ "completed" if source_exhausted and not pending else "in_progress"
+ ),
+ }
+
+ monkeypatch.setattr(job, "_reserve_legacy_backup_page", reserve)
+ monkeypatch.setattr(job, "_claim_legacy_backup_items", claim)
+ monkeypatch.setattr(job, "_load_legacy_backup_card", load)
+ monkeypatch.setattr(job, "_persist_legacy_dispatch_projection", persist)
+ monkeypatch.setattr(job, "_finish_backfill_claim", finish)
+ monkeypatch.setattr(job, "_update_cursor_counters", update_cursor)
+
+ results: list[dict[str, Any]] = []
+ for _ in range(10):
+ result = await job.run_backup_restore_legacy_backfill_once(
+ project_id="awoooi",
+ scan_limit=17,
+ process_limit=17,
+ max_rows=100,
+ processor=processor,
+ )
+ results.append(result)
+ if result["status"] == "completed":
+ break
+
+ assert results[-1]["status"] == "completed"
+ assert sum(result["scanned"] for result in results) == 98
+ assert sum(result["dispatch_total"] for result in results) == 98
+ assert cumulative == {
+ "completed_total": 98,
+ "deduplicated_total": 0,
+ "failed_total": 0,
+ "retry_total": 0,
+ }
+ assert len(processor_calls) == len(projected) == len(finished) == 98
+ assert max(reserve_batch_sizes) == 17
+ assert reserve_batch_sizes == [17, 17, 17, 17, 17, 13]
+ assert (
+ len({call["labels"]["legacy_source_message_id"] for call in processor_calls})
+ == 98
+ )
+ assert len({call["source_fingerprint"] for call in processor_calls}) == 98
+ assert len({value["incident_id"] for value in projected.values()}) == 98
+ assert len({value["work_item_id"] for value in projected.values()}) == 98
+ assert len({value["run_id"] for value in projected.values()}) == 98
+ assert {value["auth_mode"] for value in projected.values()} == {
+ "injected_processor"
+ }
+
+ replay = await job.run_backup_restore_legacy_backfill_once(
+ project_id="awoooi",
+ scan_limit=17,
+ process_limit=17,
+ max_rows=100,
+ processor=processor,
+ )
+ assert replay["status"] == "completed"
+ assert replay["scanned"] == replay["claimed"] == replay["dispatch_total"] == 0
+ assert len(processor_calls) == 98
+
+
+@pytest.mark.asyncio
+async def test_missing_source_fingerprint_fails_closed_without_processor(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ row = _source_row(1)
+ row["source_fingerprint"] = ""
+ item = job._item_payload_from_row(project_id="awoooi", row=row)
+ claim = job.LegacyBackupClaim(
+ run_id=UUID(item["backfill_run_id"]),
+ claim_token="claim-missing-fingerprint",
+ attempt_count=1,
+ max_attempts=5,
+ item=item,
+ )
+ card = job.LegacyBackupCard(
+ message_id=str(row["message_id"]),
+ queued_at=str(row["queued_at"]),
+ source_fingerprint="",
+ event_type=job.BACKUP_RESTORE_EVENT_TYPE,
+ lane=job.BACKUP_RESTORE_LANE,
+ target="backup_restore",
+ source_envelope={},
+ )
+ processor = AsyncMock()
+ finish = AsyncMock(return_value=True)
+ monkeypatch.setattr(job, "_load_legacy_backup_card", AsyncMock(return_value=card))
+ monkeypatch.setattr(job, "_finish_backfill_claim", finish)
+
+ outcome = await job._replay_backfill_claim(
+ project_id="awoooi",
+ claim=claim,
+ processor=processor,
+ )
+
+ assert outcome == "failed"
+ processor.assert_not_awaited()
+ assert finish.await_args.kwargs["state"] == "failed"
+ assert finish.await_args.kwargs["error_code"] == "E-BACKFILL-FINGERPRINT"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "processor_result",
+ [
+ RuntimeError("relay unavailable"),
+ {
+ "status": "backupcheck_dispatch_or_readback_failed",
+ "accepted": False,
+ "receipt_persisted": False,
+ },
+ ],
+)
+async def test_transport_or_readback_failure_is_retryable_and_restores_context(
+ monkeypatch: pytest.MonkeyPatch,
+ processor_result: Exception | dict[str, Any],
+) -> None:
+ claim = _claim_from_row(7)
+ card = _card_from_claim(claim)
+ finish = AsyncMock(return_value=True)
+ persist = AsyncMock(return_value="projected")
+
+ async def processor(**_kwargs: Any) -> dict[str, Any]:
+ assert get_current_project_id() == "awoooi"
+ if isinstance(processor_result, Exception):
+ raise processor_result
+ return processor_result
+
+ monkeypatch.setattr(job, "_load_legacy_backup_card", AsyncMock(return_value=card))
+ monkeypatch.setattr(job, "_finish_backfill_claim", finish)
+ monkeypatch.setattr(job, "_persist_legacy_dispatch_projection", persist)
+ prior_tokens = set_project_context("parent-project", source="test")
+ try:
+ outcome = await job._replay_backfill_claim(
+ project_id="awoooi",
+ claim=claim,
+ processor=processor,
+ )
+ assert get_current_project_id() == "parent-project"
+ finally:
+ clear_project_context(prior_tokens)
+
+ assert outcome == "retried"
+ persist.assert_not_awaited()
+ assert finish.await_args.kwargs["state"] == "waiting_tool"
+ assert finish.await_args.kwargs["error_code"] == "E-BACKFILL-RETRY"
+
+
+@pytest.mark.asyncio
+async def test_transport_retry_budget_exhaustion_is_terminal_fail_closed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ original = _claim_from_row(8)
+ claim = job.LegacyBackupClaim(
+ run_id=original.run_id,
+ claim_token=original.claim_token,
+ attempt_count=original.max_attempts,
+ max_attempts=original.max_attempts,
+ item=original.item,
+ )
+ finish = AsyncMock(return_value=True)
+
+ async def processor(**_kwargs: Any) -> dict[str, Any]:
+ raise RuntimeError("relay unavailable")
+
+ monkeypatch.setattr(
+ job,
+ "_load_legacy_backup_card",
+ AsyncMock(return_value=_card_from_claim(claim)),
+ )
+ monkeypatch.setattr(job, "_finish_backfill_claim", finish)
+
+ outcome = await job._replay_backfill_claim(
+ project_id="awoooi",
+ claim=claim,
+ processor=processor,
+ )
+
+ assert outcome == "failed"
+ assert finish.await_args.kwargs["state"] == "failed"
+ assert finish.await_args.kwargs["error_code"] == "E-BACKFILL-EXHAUSTED"
+
+
+def test_existing_projection_deduplicates_only_exact_durable_identity() -> None:
+ claim = _claim_from_row(4)
+ card = _card_from_claim(claim)
+ occurrence = job.build_legacy_backup_occurrence(
+ project_id="awoooi",
+ message_id=card.message_id,
+ source_fingerprint=card.source_fingerprint,
+ queued_at=card.queued_at,
+ )
+ card.source_envelope["agent99_dispatch_receipt"] = {
+ "schema_version": job.PROJECTION_SCHEMA_VERSION,
+ "incident_id": occurrence["incident_id"],
+ "work_item_id": occurrence["work_item_id"],
+ "kind": "backup_health",
+ "suggested_mode": "BackupCheck",
+ "accepted": True,
+ "inbox_triggered": True,
+ "receipt_persisted": True,
+ "run_id": "agent99-run-4",
+ "trace_id": "trace-agent99-run-4",
+ }
+ card.source_envelope["backup_restore_backfill"] = {
+ "occurrence_id": occurrence["occurrence_id"]
+ }
+
+ assert job._existing_projection_matches(card, occurrence=occurrence) is True
+
+ partial = dict(card.source_envelope["agent99_dispatch_receipt"])
+ partial["receipt_persisted"] = False
+ card.source_envelope["agent99_dispatch_receipt"] = partial
+ assert job._existing_projection_matches(card, occurrence=occurrence) is False
+
+ card.source_envelope["agent99_dispatch_receipt"]["receipt_persisted"] = True
+ card.source_envelope["agent99_dispatch_receipt"]["incident_id"] = "INC-WRONG"
+ with pytest.raises(ValueError, match="identity_conflict"):
+ job._existing_projection_matches(card, occurrence=occurrence)
+
+
+@pytest.mark.asyncio
+async def test_claim_completion_compare_and_set_failure_reports_lease_lost(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ claim = _claim_from_row(9)
+ card = _card_from_claim(claim)
+ occurrence = job.build_legacy_backup_occurrence(
+ project_id="awoooi",
+ message_id=card.message_id,
+ source_fingerprint=card.source_fingerprint,
+ queued_at=card.queued_at,
+ )
+ card.source_envelope["agent99_dispatch_receipt"] = {
+ "schema_version": job.PROJECTION_SCHEMA_VERSION,
+ "incident_id": occurrence["incident_id"],
+ "work_item_id": occurrence["work_item_id"],
+ "kind": "backup_health",
+ "suggested_mode": "BackupCheck",
+ "accepted": True,
+ "inbox_triggered": True,
+ "receipt_persisted": True,
+ "run_id": "agent99-run-lease-lost",
+ "trace_id": "trace-agent99-run-lease-lost",
+ }
+ processor = AsyncMock()
+ monkeypatch.setattr(job, "_load_legacy_backup_card", AsyncMock(return_value=card))
+ monkeypatch.setattr(
+ job,
+ "_finish_backfill_claim",
+ AsyncMock(return_value=False),
+ )
+
+ outcome = await job._replay_backfill_claim(
+ project_id="awoooi",
+ claim=claim,
+ processor=processor,
+ )
+
+ assert outcome == "lease_lost"
+ processor.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("readback", "expected_status"),
+ [
+ (
+ {
+ "claim_active": False,
+ "source_exists": True,
+ "source_identity_matches": True,
+ },
+ "lease_lost",
+ ),
+ (
+ {
+ "claim_active": True,
+ "source_exists": True,
+ "source_identity_matches": False,
+ },
+ "source_identity_drift",
+ ),
+ (
+ {
+ "claim_active": True,
+ "source_exists": True,
+ "source_identity_matches": True,
+ },
+ "projection_conflict",
+ ),
+ ],
+)
+async def test_projection_write_classifies_lease_and_source_identity_conflicts(
+ monkeypatch: pytest.MonkeyPatch,
+ readback: dict[str, Any],
+ expected_status: str,
+) -> None:
+ claim = _claim_from_row(10)
+ card = _card_from_claim(claim)
+ occurrence = job.build_legacy_backup_occurrence(
+ project_id="awoooi",
+ message_id=card.message_id,
+ source_fingerprint=card.source_fingerprint,
+ queued_at=card.queued_at,
+ )
+
+ class FakeDb:
+ async def execute(
+ self,
+ statement: Any,
+ params: dict[str, Any],
+ ) -> _Result:
+ assert params["project_id"] == "awoooi"
+ assert params["claim_run_id"] == claim.run_id
+ assert params["claim_token"] == claim.claim_token
+ assert isinstance(params["source_queued_at"], datetime)
+ assert params["source_queued_at"].isoformat() == claim.item["source_queued_at"]
+ assert params["source_fingerprint"] == claim.item["source_fingerprint"]
+ assert params["source_lane"] == claim.item["lane"]
+ assert params["source_target"] == claim.item["target"]
+ if statement is job._SOURCE_PROJECTION_UPDATE_SQL:
+ return _Result(value=None)
+ if statement is job._PROJECTION_CONFLICT_READBACK_SQL:
+ return _Result(row=readback)
+ raise AssertionError(f"unexpected statement: {statement}")
+
+ monkeypatch.setattr(
+ job,
+ "get_db_context",
+ lambda _project_id: _DbContext(FakeDb()),
+ )
+
+ status = await job._persist_legacy_dispatch_projection(
+ project_id="awoooi",
+ claim=claim,
+ card=card,
+ occurrence=occurrence,
+ projection={
+ "run_id": "agent99-run-10",
+ "trace_id": "agent99-trace-10",
+ "auth_mode": "lan_allowlist",
+ },
+ )
+
+ assert status == expected_status
+
+
+@pytest.mark.asyncio
+async def test_lost_lease_cannot_project_after_dispatch(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ claim = _claim_from_row(11)
+ card = _card_from_claim(claim)
+ finish = AsyncMock()
+
+ async def processor(**kwargs: Any) -> dict[str, Any]:
+ return _durable_dispatch_result(kwargs)
+
+ monkeypatch.setattr(job, "_load_legacy_backup_card", AsyncMock(return_value=card))
+ monkeypatch.setattr(
+ job,
+ "_persist_legacy_dispatch_projection",
+ AsyncMock(return_value="lease_lost"),
+ )
+ monkeypatch.setattr(job, "_finish_backfill_claim", finish)
+
+ outcome = await job._replay_backfill_claim(
+ project_id="awoooi",
+ claim=claim,
+ processor=processor,
+ )
+
+ assert outcome == "lease_lost"
+ finish.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_source_drift_after_dispatch_is_terminal_and_fail_visible(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ claim = _claim_from_row(12)
+ card = _card_from_claim(claim)
+ finish = AsyncMock(return_value=True)
+
+ async def processor(**kwargs: Any) -> dict[str, Any]:
+ return _durable_dispatch_result(kwargs)
+
+ monkeypatch.setattr(job, "_load_legacy_backup_card", AsyncMock(return_value=card))
+ monkeypatch.setattr(
+ job,
+ "_persist_legacy_dispatch_projection",
+ AsyncMock(return_value="source_identity_drift"),
+ )
+ monkeypatch.setattr(job, "_finish_backfill_claim", finish)
+
+ outcome = await job._replay_backfill_claim(
+ project_id="awoooi",
+ claim=claim,
+ processor=processor,
+ )
+
+ assert outcome == "failed"
+ assert finish.await_args.kwargs["state"] == "failed"
+ assert finish.await_args.kwargs["error_code"] == "E-BACKFILL-IDENTITY"
+ assert finish.await_args.kwargs["result"]["status"] == (
+ "source_identity_drift_after_dispatch_fail_closed"
+ )
+
+
+@pytest.mark.asyncio
+async def test_lan_allowlist_auth_mode_is_persisted_in_dispatch_projection(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ claim = _claim_from_row(10)
+ card = _card_from_claim(claim)
+ persist = AsyncMock(return_value="projected")
+ finish = AsyncMock(return_value=True)
+
+ async def processor(**kwargs: Any) -> dict[str, Any]:
+ return _durable_dispatch_result(kwargs)
+
+ monkeypatch.setattr(job, "_load_legacy_backup_card", AsyncMock(return_value=card))
+ monkeypatch.setattr(job, "_persist_legacy_dispatch_projection", persist)
+ monkeypatch.setattr(job, "_finish_backfill_claim", finish)
+
+ outcome = await job._replay_backfill_claim(
+ project_id="awoooi",
+ claim=claim,
+ processor=processor,
+ relay_auth_mode="lan_allowlist",
+ )
+
+ assert outcome == "completed"
+ projection = persist.await_args.kwargs["projection"]
+ assert projection["auth_mode"] == "lan_allowlist"
+ assert projection["controlled_apply"] is False
+ assert projection["telegram_dispatch_performed"] is False
+
+
+def test_sql_and_startup_contract_are_bounded_rls_scoped_and_no_telegram_send() -> None:
+ snapshot_sql = str(job._SOURCE_SNAPSHOT_SQL)
+ claim_sql = str(job._CLAIM_SQL)
+ exhaust_sql = str(job._EXHAUST_EXPIRED_CLAIMS_SQL)
+ idempotency_sql = str(job._ITEM_IDEMPOTENCY_INSERT_SQL)
+ projection_sql = str(job._SOURCE_PROJECTION_UPDATE_SQL)
+ conflict_readback_sql = str(job._PROJECTION_CONFLICT_READBACK_SQL)
+ source = Path(job.__file__).read_text(encoding="utf-8")
+ src_dir = Path(job.__file__).resolve().parent.parent
+ config_source = (src_dir / "core" / "config.py").read_text(encoding="utf-8")
+ main_source = (src_dir / "main.py").read_text(encoding="utf-8")
+ worker_source = (src_dir / "workers" / "signal_worker.py").read_text(
+ encoding="utf-8"
+ )
+
+ assert "m.project_id = :project_id" in snapshot_sql
+ assert "ORDER BY m.queued_at ASC, m.message_id ASC" in snapshot_sql
+ assert "{ai_automation_alert_card,target}" in snapshot_sql
+ assert "{agent99_dispatch_receipt,receipt_persisted}" in snapshot_sql
+ assert "{agent99_dispatch_receipt,run_id}" in snapshot_sql
+ assert "LIMIT :limit" in snapshot_sql
+ assert "COUNT(*) OVER () AS source_total_in_scope" in snapshot_sql
+ assert "source_upper_queued_at" in snapshot_sql
+ assert "FOR UPDATE SKIP LOCKED" in claim_sql
+ assert "attempt_count < max_attempts" in claim_sql
+ assert "attempt_count >= max_attempts" not in claim_sql
+ assert "state = 'waiting_tool'" in claim_sql
+ assert "lease_until < NOW()" in claim_sql
+ assert "attempt_count >= max_attempts" in exhaust_sql
+ assert "E-BACKFILL-LEASE-EXHAUSTED" in exhaust_sql
+ assert "error_code IS NULL" in exhaust_sql
+ assert "awooop_run_idempotency" in idempotency_sql
+ assert "project_id, channel_type, provider_event_id" in idempotency_sql
+ assert "m.project_id = :project_id" in projection_sql
+ assert "m.message_id = CAST(:message_id AS UUID)" in projection_sql
+ assert "item.worker_id = :claim_token" in projection_sql
+ assert "item.lease_until > clock_timestamp()" in projection_sql
+ assert "FOR UPDATE" in projection_sql
+ assert "m.queued_at = CAST(:source_queued_at AS TIMESTAMPTZ)" in projection_sql
+ assert "{source_refs,fingerprints,0}" in projection_sql
+ assert "{ai_automation_alert_card,lane}" in projection_sql
+ assert "{ai_automation_alert_card,target}" in projection_sql
+ assert "{agent99_dispatch_receipt,run_id}' = :run_id" in projection_sql
+ assert "CAST(:message_id AS TEXT)" in projection_sql
+ assert "source_identity_matches" in conflict_readback_sql
+ assert "item.worker_id = :claim_token" in conflict_readback_sql
+ assert job.MAX_BATCH_LIMIT == 20
+ assert job.MAX_SCOPE_ROWS == 100
+ assert "telegram_gateway" not in source
+ assert "subprocess" not in source
+ for mutation_call in (
+ "run_backup(",
+ "run_restore(",
+ "remote_delete(",
+ "change_retention(",
+ "write_escrow_marker(",
+ ):
+ assert mutation_call not in source
+ assert "ENABLE_BACKUP_RESTORE_LEGACY_BACKFILL" in config_source
+ assert "BACKUP_RESTORE_LEGACY_BACKFILL_MAX_ROWS" in config_source
+ assert "run_backup_restore_legacy_backfill_loop" not in main_source
+ assert "signal_worker_backup_restore_legacy_backfill_loop_started" in worker_source
+ assert "build_backup_restore_backfill_relay_preflight" in worker_source
+ assert (
+ "signal_worker_backup_restore_legacy_backfill_loop_skipped_fail_closed"
+ in worker_source
+ )
+ assert "backup_restore_legacy_backfill_task.cancel()" in worker_source
+
+
+@pytest.mark.asyncio
+async def test_missing_project_context_performs_no_database_or_dispatch(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ reserve = AsyncMock()
+ processor = AsyncMock()
+ monkeypatch.setattr(job, "_reserve_legacy_backup_page", reserve)
+
+ result = await job.run_backup_restore_legacy_backfill_once(
+ project_id="",
+ processor=processor,
+ )
+
+ assert result["status"] == "project_context_missing_fail_closed"
+ assert result["dispatch_total"] == 0
+ assert result["runtime_execution_authorized"] is False
+ assert result["telegram_dispatch_performed"] is False
+ reserve.assert_not_awaited()
+ processor.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_default_processor_requires_trusted_relay_before_database_scan(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ reserve = AsyncMock()
+ monkeypatch.setattr(job, "_reserve_legacy_backup_page", reserve)
+ monkeypatch.setattr(job.settings, "AGENT99_SRE_ALERT_RELAY_URL", "")
+ monkeypatch.setattr(job.settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "")
+
+ result = await job.run_backup_restore_legacy_backfill_once(project_id="awoooi")
+
+ assert result["status"] == "relay_preflight_failed_fail_closed"
+ assert result["auth_mode"] == "blocked"
+ assert result["relay_preflight"]["endpoint_class"] == "untrusted_or_missing"
+ assert result["dispatch_total"] == 0
+ reserve.assert_not_awaited()
+
+
+@pytest.mark.parametrize(
+ ("relay_url", "relay_token", "ready", "auth_mode", "endpoint_class"),
+ [
+ ("", "", False, "blocked", "untrusted_or_missing"),
+ (
+ "http://192.168.0.99:8787/agent99/sre-alert/",
+ "",
+ True,
+ "lan_allowlist",
+ "repo_known_private_lan",
+ ),
+ (
+ "https://192.168.0.99:8787/agent99/sre-alert/",
+ "",
+ False,
+ "blocked",
+ "untrusted_or_missing",
+ ),
+ (
+ "http://192.168.0.99:8787/not-agent99/",
+ "",
+ False,
+ "blocked",
+ "untrusted_or_missing",
+ ),
+ (
+ "https://relay.example.test/agent99/sre-alert/",
+ "test-token",
+ True,
+ "token",
+ "configured_token_endpoint",
+ ),
+ (
+ "https://user@relay.example.test/agent99/sre-alert/",
+ "test-token",
+ False,
+ "blocked",
+ "untrusted_or_missing",
+ ),
+ (
+ "http://relay.example.test/agent99/sre-alert/",
+ "test-token",
+ False,
+ "blocked",
+ "untrusted_or_missing",
+ ),
+ (
+ "http://192.168.0.99:8787/not-agent99/",
+ "test-token",
+ False,
+ "blocked",
+ "untrusted_or_missing",
+ ),
+ (
+ "http://192.168.0.99:8787/agent99/sre-alert/",
+ "test-token",
+ True,
+ "lan_allowlist",
+ "repo_known_private_lan",
+ ),
+ ],
+)
+def test_relay_preflight_is_exact_and_public_safe(
+ relay_url: str,
+ relay_token: str,
+ ready: bool,
+ auth_mode: str,
+ endpoint_class: str,
+) -> None:
+ result = job.build_backup_restore_backfill_relay_preflight(
+ relay_url=relay_url,
+ relay_token=relay_token,
+ )
+
+ assert result["ready"] is ready
+ assert result["auth_mode"] == auth_mode
+ assert result["endpoint_class"] == endpoint_class
+ assert result["token_present"] is bool(relay_token)
+ assert result["stores_secret"] is False
+ assert relay_token not in json.dumps(result) if relay_token else True
+
+
+@pytest.mark.asyncio
+async def test_default_processor_accepts_repo_known_lan_without_token(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ reserve = AsyncMock(
+ return_value={
+ "scanned": 0,
+ "reserved": 0,
+ "deduplicated": 0,
+ "source_total_at_start": 0,
+ "source_exhausted": True,
+ "source_drift_detected": False,
+ "scope_cap_reached": False,
+ "cursor": {},
+ }
+ )
+ claim = AsyncMock(return_value=[])
+ update_cursor = AsyncMock(
+ return_value={"status": "completed", "remaining_item_total": 0}
+ )
+ monkeypatch.setattr(job, "_reserve_legacy_backup_page", reserve)
+ monkeypatch.setattr(job, "_claim_legacy_backup_items", claim)
+ monkeypatch.setattr(job, "_update_cursor_counters", update_cursor)
+ monkeypatch.setattr(
+ job.settings,
+ "AGENT99_SRE_ALERT_RELAY_URL",
+ "http://192.168.0.99:8787/agent99/sre-alert/",
+ )
+ monkeypatch.setattr(job.settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "")
+
+ result = await job.run_backup_restore_legacy_backfill_once(project_id="awoooi")
+
+ assert result["status"] == "completed"
+ assert result["auth_mode"] == "lan_allowlist"
+ assert result["relay_preflight"]["ready"] is True
+ reserve.assert_awaited_once()
+ claim.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_one_time_loop_exits_after_durable_snapshot_completes(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ tick = AsyncMock(
+ return_value={
+ "status": "completed",
+ "source_total_at_start": 98,
+ "telegram_dispatch_performed": False,
+ }
+ )
+ sleep = AsyncMock()
+ monkeypatch.setattr(job.settings, "ENABLE_BACKUP_RESTORE_LEGACY_BACKFILL", True)
+ monkeypatch.setattr(
+ job.settings,
+ "AGENT99_SRE_ALERT_RELAY_URL",
+ "http://192.168.0.99:8787/agent99/sre-alert/",
+ )
+ monkeypatch.setattr(job.settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "")
+ monkeypatch.setattr(
+ job.settings,
+ "BACKUP_RESTORE_LEGACY_BACKFILL_STARTUP_SLEEP_SECONDS",
+ 0.0,
+ )
+ monkeypatch.setattr(job, "run_backup_restore_legacy_backfill_once", tick)
+ monkeypatch.setattr(job.asyncio, "sleep", sleep)
+
+ await job.run_backup_restore_legacy_backfill_loop()
+
+ tick.assert_awaited_once()
+ sleep.assert_awaited_once_with(0.0)
+
+
+@pytest.mark.asyncio
+async def test_default_enabled_loop_exits_without_sleep_when_relay_is_untrusted(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ tick = AsyncMock()
+ sleep = AsyncMock()
+ monkeypatch.setattr(job.settings, "ENABLE_BACKUP_RESTORE_LEGACY_BACKFILL", True)
+ monkeypatch.setattr(
+ job.settings,
+ "AGENT99_SRE_ALERT_RELAY_URL",
+ "http://192.168.0.98:8787/agent99/sre-alert/",
+ )
+ monkeypatch.setattr(job.settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "")
+ monkeypatch.setattr(job, "run_backup_restore_legacy_backfill_once", tick)
+ monkeypatch.setattr(job.asyncio, "sleep", sleep)
+
+ await job.run_backup_restore_legacy_backfill_loop()
+
+ tick.assert_not_awaited()
+ sleep.assert_not_awaited()
diff --git a/apps/api/tests/test_backup_restore_signal_automation.py b/apps/api/tests/test_backup_restore_signal_automation.py
new file mode 100644
index 000000000..a5656c1f4
--- /dev/null
+++ b/apps/api/tests/test_backup_restore_signal_automation.py
@@ -0,0 +1,369 @@
+from src.services.backup_restore_signal_automation import (
+ build_backup_restore_signal_automation_contract,
+ extract_backup_restore_evidence,
+)
+
+
+def test_backup_restore_contract_is_durable_and_fail_closed() -> None:
+ contract = build_backup_restore_signal_automation_contract(
+ project_id="awoooi",
+ evidence_text=(
+ 'Top evidence\n'
+ '├ alertname="BackupAggregateRunFailed" '
+ 'escrow_missing=5 restic failed\n'
+ '建議下一步'
+ ),
+ source_refs={
+ "fingerprints": [
+ "ai_automation_alert_card:backup_restore_escrow_signal:"
+ "backup_restore_escrow_triage"
+ ]
+ },
+ receipt_id="97ff9f41-7cdc-4893-8aab-634286fb128c",
+ run_id="0b25cafa-00a4-520b-8946-a3ea467b13fc",
+ source_row_exists=True,
+ contract_origin="synthesized_at_read",
+ declared_gates=[
+ "controlled_playbook_queue",
+ "runtime_write_gate=controlled",
+ ],
+ declared_runtime_write_gate_count=1,
+ declared_runtime_write_gate_state="controlled",
+ declared_controlled_playbook_queue=True,
+ declared_learning_writeback_status="ready",
+ )
+
+ assert contract["work_item"]["work_item_id"].startswith("BRR-WI-")
+ assert contract["work_item"]["owner"] == "backup_dr"
+ assert contract["contract_origin"] == "synthesized_at_read"
+ assert contract["contract_persisted_at_send"] is False
+ assert contract["policy"]["policy_version"] == (
+ "backup_restore_readback_policy_v2"
+ )
+ assert contract["policy"]["effective_at"] == "2026-07-11T19:30:58+08:00"
+ assert contract["policy"]["source_hash"].startswith("sha256:")
+ provenance = contract["contract_provenance"]
+ assert provenance["source_contract_present_at_send"] is False
+ assert provenance["recorded_policy_present"] is False
+ assert provenance["recorded_immutable_declared_present"] is False
+ assert provenance["synthesized_at_read"] is True
+ assert contract["immutable_declared"]["runtime_write_gate_count"] == 1
+ assert contract["immutable_declared"]["controlled_playbook_queue"] is True
+ assert contract["effective_policy_projection"]["runtime_write_gate_count"] == 0
+ assert contract["work_item"]["status"] == "projected_open_evidence_failed"
+ assert contract["candidate"]["status"] == (
+ "projected_source_row_observed_dispatch_receipt_missing"
+ )
+ assert contract["candidate"]["execution_state"] == "not_started"
+ assert contract["candidate"]["agent99_dispatch"]["dispatched"] is False
+ assert contract["receipt"]["status"] == (
+ "projected_from_historical_source_row"
+ )
+ assert contract["receipt"]["source_row_exists"] is True
+ assert contract["receipt"]["contract_persisted_at_send"] is False
+ assert contract["receipt"]["is_agent99_execution_receipt"] is False
+ assert contract["verifier"]["status"] == (
+ "projected_dispatch_receipt_missing_verifier_not_started"
+ )
+ assert contract["verifier"]["terminal"] is False
+ assert contract["verifier"]["no_false_green"] is True
+ assert set(contract["verifier"]["failed_fields"]) == {
+ "backup_status",
+ "escrow",
+ }
+ assert "source_resolution_receipt" in contract["verifier"]["missing_fields"]
+ assert contract["runtime_write_allowed"] is False
+ assert contract["runtime_execution_authorized"] is False
+ assert contract["production_write_performed"] is False
+ assert "run_restore" in contract["prohibited_actions"]
+ assert "remote_delete" in contract["prohibited_actions"]
+ assert "retention_change" in contract["prohibited_actions"]
+ assert "escrow_marker_write" in contract["prohibited_actions"]
+ assert all(
+ {"asset_id", "owner", "status", "next_action"} <= set(asset)
+ for asset in contract["asset_ledger"]
+ )
+
+
+def test_backup_restore_contract_never_closes_without_source_resolution() -> None:
+ observations = [
+ {
+ "field": field,
+ "status": "healthy",
+ "observed": True,
+ "safe_value": "explicit_success",
+ }
+ for field in (
+ "backup_status",
+ "freshness",
+ "offsite_verify",
+ "escrow",
+ "restore_drill",
+ )
+ ]
+
+ contract = build_backup_restore_signal_automation_contract(
+ project_id="awoooi",
+ evidence_observations=observations,
+ receipt_id="durable-message",
+ run_id="durable-run",
+ source_row_exists=True,
+ contract_origin="synthesized_at_read",
+ )
+
+ assert contract["work_item"]["status"] == (
+ "projected_open_evidence_incomplete"
+ )
+ assert contract["verifier"]["evidence_result"] == "degraded"
+ assert contract["verifier"]["terminal"] is False
+ assert contract["verifier"]["missing_fields"] == [
+ "source_resolution_receipt"
+ ]
+ assert contract["closure_state"] == "partial_degraded_safe_next_action"
+
+
+def test_backup_restore_work_item_clusters_recurrence_but_receipts_stay_unique() -> None:
+ shared = {
+ "project_id": "awoooi",
+ "source_refs": {
+ "fingerprints": [
+ "ai_automation_alert_card:backup_restore_escrow_signal:"
+ "backup_restore_escrow_triage"
+ ]
+ },
+ "source_row_exists": True,
+ "contract_origin": "synthesized_at_read",
+ }
+ first = build_backup_restore_signal_automation_contract(
+ **shared,
+ receipt_id="message-1",
+ run_id="run-1",
+ )
+ second = build_backup_restore_signal_automation_contract(
+ **shared,
+ receipt_id="message-2",
+ run_id="run-2",
+ )
+
+ assert first["work_item"]["work_item_id"] == second["work_item"]["work_item_id"]
+ assert first["candidate"]["candidate_id"] != second["candidate"]["candidate_id"]
+ assert first["receipt"]["receipt_id"] != second["receipt"]["receipt_id"]
+ assert first["verifier"]["verifier_id"] != second["verifier"]["verifier_id"]
+
+
+def test_backup_dispatch_requires_accepted_triggered_backupcheck_receipt() -> None:
+ receipt = {
+ "schema_version": "telegram_agent99_dispatch_receipt_projection_v1",
+ "status": "accepted_inbox_triggered",
+ "transport": "relay",
+ "alert_id": "awoooi-backup-alert",
+ "kind": "backup_health",
+ "suggested_mode": "BackupCheck",
+ "accepted": True,
+ "inbox_triggered": True,
+ "receipt_persisted": True,
+ "runtime_closure_verified": False,
+ "run_id": "agent99-run-123",
+ "trace_id": "00-12345678901234567890123456789012-1234567890123456-01",
+ "work_item_id": "agent99-dispatch:awoooi:INC-BACKUP:backupcheck",
+ }
+ contract = build_backup_restore_signal_automation_contract(
+ project_id="awoooi",
+ receipt_id="source-message",
+ run_id="source-run",
+ source_row_exists=True,
+ contract_origin="persisted_at_send",
+ contract_persisted_at_send=True,
+ agent99_dispatch_receipt=receipt,
+ )
+
+ dispatch = contract["candidate"]["agent99_dispatch"]
+ assert dispatch["status"] == "accepted_inbox_triggered"
+ assert dispatch["dispatched"] is True
+ assert dispatch["receipt_persisted"] is True
+ assert dispatch["identity_complete"] is True
+ assert dispatch["agent99_run_id"] == "agent99-run-123"
+ provenance = contract["contract_provenance"]
+ assert provenance["source_contract_present_at_send"] is True
+ assert provenance["recorded_policy_present"] is True
+ assert provenance["recorded_policy_source_hash"].startswith("sha256:")
+ assert provenance["recorded_immutable_declared_present"] is True
+ binding = contract["candidate"]["identity_binding"]
+ assert binding["status"] == "bound_dispatch_verifier_pending"
+ assert binding["complete"] is True
+ assert binding["brr_work_item_id"] == contract["work_item"]["work_item_id"]
+ assert binding["brr_verifier_id"] == contract["verifier"]["verifier_id"]
+ assert binding["agent99_run_id"] == "agent99-run-123"
+ assert binding["agent99_work_item_id"].startswith("agent99-dispatch:")
+ assert contract["candidate"]["execution_state"] == "dispatched"
+ assert contract["candidate"]["status"] == "dispatched_verifier_pending"
+ assert contract["verifier"]["status"] == "dispatched_verifier_pending"
+ assert contract["verifier"]["terminal"] is False
+ assert contract["verifier"]["agent99_outcome"]["status"] == (
+ "dispatch_accepted_outcome_pending"
+ )
+ assert contract["verifier"]["agent99_outcome"]["required_source_schema"] == (
+ "agent99_outcome_contract_v1"
+ )
+
+ wrong_route = dict(receipt, suggested_mode="ProviderFreshness")
+ blocked = build_backup_restore_signal_automation_contract(
+ project_id="awoooi",
+ receipt_id="source-message",
+ run_id="source-run",
+ source_row_exists=True,
+ contract_origin="persisted_at_send",
+ contract_persisted_at_send=True,
+ agent99_dispatch_receipt=wrong_route,
+ )
+ assert blocked["candidate"]["execution_state"] == "not_started"
+ assert blocked["candidate"]["agent99_dispatch"]["status"] == (
+ "dispatch_route_mismatch_fail_closed"
+ )
+ assert blocked["verifier"]["terminal"] is False
+
+ not_durable = dict(receipt, receipt_persisted=False)
+ blocked = build_backup_restore_signal_automation_contract(
+ project_id="awoooi",
+ receipt_id="source-message",
+ run_id="source-run",
+ source_row_exists=True,
+ contract_origin="persisted_at_send",
+ contract_persisted_at_send=True,
+ agent99_dispatch_receipt=not_durable,
+ )
+ assert blocked["candidate"]["execution_state"] == "not_started"
+ assert blocked["candidate"]["agent99_dispatch"]["status"] == (
+ "dispatch_receipt_not_persisted_fail_closed"
+ )
+
+ legacy = dict(
+ receipt,
+ schema_version="telegram_agent99_dispatch_receipt_v1",
+ receipt_persisted=False,
+ )
+ legacy_contract = build_backup_restore_signal_automation_contract(
+ project_id="awoooi",
+ receipt_id="legacy-source-message",
+ run_id="legacy-source-run",
+ source_row_exists=True,
+ contract_origin="synthesized_at_read",
+ agent99_dispatch_receipt=legacy,
+ )
+ assert legacy_contract["candidate"]["execution_state"] == "not_started"
+ assert legacy_contract["candidate"]["agent99_dispatch"]["status"] == (
+ "legacy_dispatch_receipt_unbound"
+ )
+ assert legacy_contract["candidate"]["identity_binding"]["complete"] is False
+ assert legacy_contract["verifier"]["terminal"] is False
+
+ failed_verifier_receipt = dict(
+ receipt,
+ post_verifier_passed=False,
+ current_run_state="failed",
+ current_verifier={
+ "schema_version": "agent99_independent_verifier_receipt_v1",
+ "status": "failed",
+ "run_id": receipt["run_id"],
+ "trace_id": receipt["trace_id"],
+ "work_item_id": receipt["work_item_id"],
+ "verifier_passed": False,
+ "source_event_resolved": False,
+ },
+ )
+ failed_contract = build_backup_restore_signal_automation_contract(
+ project_id="awoooi",
+ receipt_id="failed-source-message",
+ run_id="failed-source-run",
+ source_row_exists=True,
+ contract_origin="persisted_at_send",
+ contract_persisted_at_send=True,
+ agent99_dispatch_receipt=failed_verifier_receipt,
+ )
+ assert failed_contract["candidate"]["status"] == (
+ "verifier_failed_retry_required"
+ )
+ assert failed_contract["verifier"]["status"] == "failed_terminal"
+ assert failed_contract["verifier"]["result"] == "failed"
+ assert failed_contract["verifier"]["terminal"] is True
+ assert failed_contract["closure_state"] == (
+ "partial_degraded_safe_next_action"
+ )
+
+ false_green_receipt = dict(
+ receipt,
+ post_verifier_passed=True,
+ runtime_closure_verified=True,
+ closure_complete=True,
+ current_run_state="completed",
+ current_verifier={
+ "schema_version": "agent99_independent_verifier_receipt_v1",
+ "status": "success",
+ "run_id": receipt["run_id"],
+ "trace_id": receipt["trace_id"],
+ "work_item_id": receipt["work_item_id"],
+ "outcome_state": "resolved",
+ "transport_ok": True,
+ "verifier_passed": True,
+ "source_event_resolved": True,
+ "verified_at": "2026-07-11T20:20:00+08:00",
+ "evidence_refs": {},
+ },
+ current_learning_writeback={
+ "schema_version": "agent99_learning_writeback_receipt_v1",
+ "status": "success",
+ "run_id": receipt["run_id"],
+ "trace_id": receipt["trace_id"],
+ "work_item_id": receipt["work_item_id"],
+ "receipt_refs": {
+ "incident_closure_receipt_id": "inc-close-1",
+ "telegram_lifecycle_receipt_id": "tg-life-1",
+ "km_writeback_ack_id": "km-ack-1",
+ "playbook_trust_writeback_ack_id": "pb-ack-1",
+ "dr_scorecard_writeback_ack_id": "dr-ack-1",
+ },
+ },
+ )
+ false_green_contract = build_backup_restore_signal_automation_contract(
+ project_id="awoooi",
+ receipt_id="false-green-source-message",
+ run_id="false-green-source-run",
+ source_row_exists=True,
+ contract_origin="persisted_at_send",
+ contract_persisted_at_send=True,
+ agent99_dispatch_receipt=false_green_receipt,
+ )
+ assert false_green_contract["verifier"]["terminal"] is False
+ assert false_green_contract["candidate"]["agent99_dispatch"][
+ "runtime_closure_verified"
+ ] is False
+ assert set(false_green_contract["verifier"][
+ "missing_backup_evidence_refs"
+ ]) == {
+ "agent99_outcome_receipt_id",
+ "post_verifier_evidence_ref",
+ "source_event_evidence_ref",
+ "backup_status_evidence_ref",
+ "freshness_evidence_ref",
+ "offsite_verify_evidence_ref",
+ "escrow_evidence_ref",
+ "restore_drill_evidence_ref",
+ "source_resolution_receipt_ref",
+ }
+
+
+def test_backup_evidence_negative_phrases_override_good_tokens() -> None:
+ observations = extract_backup_restore_evidence(
+ "Top evidence\n"
+ "├ backup_status: 1 but not ok\n"
+ "└ restore_drill: 1 but not verified\n"
+ "建議下一步"
+ )
+ by_field = {item["field"]: item for item in observations}
+
+ assert by_field["backup_status"]["status"] == "failed"
+ assert by_field["backup_status"]["safe_value"] == "explicit_negative_result"
+ assert by_field["restore_drill"]["status"] == "failed"
+ assert by_field["restore_drill"]["safe_value"] == (
+ "explicit_negative_result"
+ )
diff --git a/apps/api/tests/test_controlled_alert_target_router.py b/apps/api/tests/test_controlled_alert_target_router.py
new file mode 100644
index 000000000..9ba973f3e
--- /dev/null
+++ b/apps/api/tests/test_controlled_alert_target_router.py
@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from src.api.v1 import webhooks
+from src.services.awooop_ansible_audit_service import (
+ _catalog_hints,
+ build_ansible_decision_audit_payload,
+ build_ansible_truth,
+)
+from src.services.controlled_alert_target_router import (
+ build_controlled_recovery_handoff,
+ build_controlled_recovery_promotion_contract,
+ resolve_controlled_alert_target,
+)
+
+
+def _cold_start_incident() -> dict:
+ return {
+ "incident_id": "INC-20260711-11C751",
+ "project_id": "awoooi",
+ "alertname": "ColdStartGateBlocked",
+ "alert_category": "general",
+ "affected_services": ["cold-start-gate"],
+ "signals": [
+ {
+ "alert_name": "ColdStartGateBlocked",
+ "labels": {
+ "component": "cold-start-gate",
+ "namespace": "default",
+ "host": "110",
+ },
+ "annotations": {
+ "summary": (
+ "full-stack cold-start recovery blocked; backup and "
+ "Docker evidence must be checked"
+ )
+ },
+ }
+ ],
+ }
+
+
+def test_normalizer_separates_source_namespace_from_execution_domain() -> None:
+ route = resolve_controlled_alert_target(
+ alertname="ColdStartGateBlocked",
+ target_resource="cold-start-gate",
+ namespace="default",
+ )
+
+ assert route is not None
+ assert route["source_namespace"] == "default"
+ assert route["normalized_execution_namespace"] == "host_recovery"
+ assert route["kubernetes_namespace_applicable"] is False
+ assert route["executor"] == "Agent99"
+ assert route["runtime_execution_authorized"] is False
+
+ assert resolve_controlled_alert_target(
+ alertname="SentryRpsZero",
+ target_resource="sentry",
+ namespace="awoooi-prod",
+ ) is None
+
+
+def test_cold_start_contract_requires_same_run_receipts_before_closure() -> None:
+ contract = build_controlled_recovery_promotion_contract(
+ alertname="ColdStartGateBlocked",
+ target_resource="cold-start-gate",
+ namespace="default",
+ incident_id="INC-20260711-11C751",
+ )
+
+ assert contract is not None
+ fields = {row["field"]: row for row in contract["fields"]}
+ assert fields["target_selector"]["status"] == "ready"
+ assert fields["controlled_apply_route"]["status"] == "ready"
+ for required in (
+ "source_sensor_receipt",
+ "check_mode_receipt",
+ "dispatch_receipt",
+ "post_apply_verifier",
+ "incident_closure_receipt",
+ "telegram_receipt",
+ "km_writeback",
+ "playbook_trust_writeback",
+ ):
+ assert fields[required]["status"] == "pending"
+ assert contract["runtime_execution_authorized"] is False
+ assert contract["owner_review_required"] is False
+ assert contract["needs_human"] is False
+ assert contract["completion_status"] == "partial"
+
+
+def test_cold_start_is_excluded_from_generic_ansible_keyword_catalog() -> None:
+ hints = _catalog_hints(_cold_start_incident(), None)
+
+ assert hints["match_mode"] == "domain_controlled_executor_route_v1"
+ assert hints["decision_effect"] == "delegate_to_domain_executor"
+ assert hints["candidates"] == []
+ assert hints["controlled_executor"]["executor"] == "Agent99"
+ assert "ansible:110-devops" in hints["unmatched_catalog_ids"]
+ assert "ansible:188-momo-backup-user" in hints["unmatched_catalog_ids"]
+
+ truth = build_ansible_truth([], incident=_cold_start_incident(), drift=None)
+ assert truth["not_used_reason"] == "non_kubernetes_control_plane_route"
+ assert truth["candidate_catalog"]["controlled_executor"]["route_id"] == (
+ "agent99_recover_after_owner_review"
+ )
+
+
+def test_cold_start_does_not_emit_ansible_candidate_audit() -> None:
+ incident = SimpleNamespace(
+ incident_id="INC-20260711-11C751",
+ project_id="awoooi",
+ alertname="ColdStartGateBlocked",
+ alert_category="general",
+ notification_type="TYPE-3",
+ severity=SimpleNamespace(value="P3"),
+ affected_services=["cold-start-gate"],
+ signals=[
+ SimpleNamespace(
+ alert_name="ColdStartGateBlocked",
+ labels={"component": "cold-start-gate", "namespace": "default"},
+ annotations={"summary": "full-stack cold-start blocked"},
+ )
+ ],
+ )
+
+ payload = build_ansible_decision_audit_payload(
+ incident=incident,
+ proposal_data={"source": "test", "risk_level": "low"},
+ decision_path="alert_webhook_controlled_router",
+ not_used_reason="test",
+ )
+
+ assert payload is None
+
+
+def test_handoff_stays_partial_until_dispatch_verifier_and_learning() -> None:
+ handoff = build_controlled_recovery_handoff(
+ alertname="custom",
+ target_resource="cold-start-gate",
+ namespace="default",
+ incident_id="INC-20260711-11C751",
+ )
+
+ assert handoff is not None
+ assert handoff["single_writer_executor"] == "Agent99"
+ assert handoff["queued"] is False
+ assert handoff["side_effect_performed"] is False
+ assert handoff["runtime_execution_authorized"] is False
+ assert handoff["owner_review_required"] is False
+ assert handoff["needs_human"] is False
+ assert handoff["active_blockers"] == [
+ "agent99_dispatch_receipt_readback_pending",
+ "post_apply_verifier_missing",
+ "km_playbook_writeback_missing",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_webhook_router_never_queues_generic_ansible_for_cold_start(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ queue = AsyncMock(side_effect=AssertionError("generic Ansible must not be queued"))
+ append = AsyncMock()
+ incident_lookup = AsyncMock(side_effect=AssertionError("incident lookup not needed"))
+ bridge = AsyncMock(
+ return_value={
+ "status": "dispatched",
+ "dispatchPerformed": True,
+ "identity": {
+ "run_id": "7cf8fdf7-0966-5ac6-95b2-09e32808b248",
+ "trace_id": "00-11111111111111111111111111111111-2222222222222222-01",
+ "work_item_id": "agent99-dispatch:awoooi:INC-20260711-11C751:recovery",
+ "idempotency_key": "agent99-controlled:stable-key",
+ },
+ "dispatchReceipt": {
+ "status": "accepted_inbox_triggered",
+ "accepted": True,
+ "inbox_triggered": True,
+ },
+ "correlatedReceipt": {
+ "status": "dispatch_accepted_verifier_pending",
+ "receipt_persisted": True,
+ "runtime_execution_authorized": False,
+ "runtime_closure_verified": False,
+ "verifier": {"status": "pending"},
+ "learning_writeback": {"status": "pending_verifier"},
+ },
+ }
+ )
+
+ monkeypatch.setattr(
+ "src.jobs.awooop_ansible_candidate_backfill_job.enqueue_ai_decision_ansible_candidate",
+ queue,
+ )
+ monkeypatch.setattr(
+ "src.repositories.alert_operation_log_repository.get_alert_operation_log_repository",
+ lambda: SimpleNamespace(append=append),
+ )
+ monkeypatch.setattr(
+ webhooks,
+ "get_incident_service",
+ lambda: SimpleNamespace(get_from_working_memory=incident_lookup),
+ )
+ monkeypatch.setattr(webhooks, "bridge_alertmanager_to_agent99", bridge)
+
+ handoff = await webhooks._try_auto_repair_background(
+ incident_id="INC-20260711-11C751",
+ approval_id="00000000-0000-0000-0000-000000000751",
+ alert_type="custom",
+ target_resource="cold-start-gate",
+ namespace="default",
+ risk_level="low",
+ source_alert_id="alert-11c751",
+ source_alertname="ColdStartGateBlocked",
+ source_fingerprint="source-fingerprint",
+ )
+
+ queue.assert_not_awaited()
+ incident_lookup.assert_not_awaited()
+ bridge.assert_awaited_once()
+ assert handoff["single_writer_executor"] == "Agent99"
+ assert handoff["execution_priority"] == 30
+ assert handoff["queued"] is True
+ assert handoff["runtime_execution_authorized"] is False
+ assert handoff["runtime_closure_verified"] is False
+ assert handoff["automation_run_id"] == (
+ "7cf8fdf7-0966-5ac6-95b2-09e32808b248"
+ )
+ append.assert_awaited_once()
+ receipt = append.await_args.kwargs
+ assert receipt["action_detail"] == "controlled_check_mode_queued"
+ assert receipt["success"] is True
+ assert receipt["context"]["safe_next_action"] == (
+ "run_independent_cold_start_verifier_then_km_playbook_writeback"
+ )
+ assert receipt["context"]["runtime_closure_verified"] is False
diff --git a/apps/api/tests/test_cs1_auto_execute.py b/apps/api/tests/test_cs1_auto_execute.py
index f74a4bb62..f510c8ff8 100644
--- a/apps/api/tests/test_cs1_auto_execute.py
+++ b/apps/api/tests/test_cs1_auto_execute.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import hashlib
import inspect
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -116,26 +117,31 @@ async def test_webhook_router_writes_queue_receipt_without_side_effect(
get_from_working_memory=AsyncMock(return_value=incident)
)
append = AsyncMock()
-
- queue_kwargs: dict = {}
-
- async def queue(**kwargs):
- queue_kwargs.update(kwargs)
- return {
- "schema_version": "ai_decision_controlled_executor_handoff_v1",
- "status": "controlled_check_mode_queued",
- "automation_run_id": "00000000-0000-0000-0000-000000000101",
- "queued": True,
- "side_effect_performed": False,
- "single_writer_executor": "awoooi-ansible-executor-broker",
- "active_blockers": [],
+ queue = AsyncMock()
+ bridge = AsyncMock(
+ return_value={
+ "status": "deduplicated",
+ "dispatchPerformed": False,
+ "identity": {
+ "run_id": "00000000-0000-0000-0000-000000000101",
+ "trace_id": "00000000-0000-0000-0000-000000000102",
+ "work_item_id": "agent99-dispatch:awoooi:INC-QUEUE:backup",
+ "idempotency_key": "agent99:backup:INC-QUEUE",
+ },
+ "dispatchReceipt": {
+ "accepted": True,
+ "inbox_triggered": True,
+ },
+ "correlatedReceipt": {"receipt_persisted": True},
}
+ )
monkeypatch.setattr(webhooks, "get_incident_service", lambda: incident_service)
monkeypatch.setattr(
"src.jobs.awooop_ansible_candidate_backfill_job.enqueue_ai_decision_ansible_candidate",
queue,
)
+ monkeypatch.setattr(webhooks, "bridge_alertmanager_to_agent99", bridge)
monkeypatch.setattr(
"src.repositories.alert_operation_log_repository.get_alert_operation_log_repository",
lambda: SimpleNamespace(append=append),
@@ -153,7 +159,12 @@ async def test_webhook_router_writes_queue_receipt_without_side_effect(
assert handoff["queued"] is True
assert handoff["side_effect_performed"] is False
assert handoff["execution_priority"] == 30
- assert queue_kwargs["proposal_data"]["execution_priority"] == 30
+ queue.assert_not_awaited()
+ bridge.assert_awaited_once()
+ expected_fingerprint = hashlib.sha256(
+ b"awoooi-prod:momo-postgres:MomoPostgresBackupFailed:momo-postgres"
+ ).hexdigest()[:32]
+ assert bridge.await_args.kwargs["fingerprint"] == expected_fingerprint
append.assert_awaited_once()
assert append.await_args.kwargs["context"]["side_effect_performed"] is False
assert append.await_args.kwargs["context"]["execution_priority"] == 30
diff --git a/apps/api/tests/test_executor_network_policy_boundary.py b/apps/api/tests/test_executor_network_policy_boundary.py
index f53ff7af2..0f49ae496 100644
--- a/apps/api/tests/test_executor_network_policy_boundary.py
+++ b/apps/api/tests/test_executor_network_policy_boundary.py
@@ -118,5 +118,7 @@ def test_cd_applies_rolls_back_and_verifies_network_boundary() -> None:
assert f"ssh-keyscan -T 5 {expected_hosts}" in workflow
assert "EXPECTED_HOSTS=5" in workflow
assert workflow.count(expected_hosts) >= 4
- assert "len(reachable) == len(sys.argv) - 1" in workflow
+ assert "except ConnectionRefusedError:" in workflow
+ assert "broker_ssh_refused_but_egress_permitted=" in workflow
+ assert "connected and len(permitted) == len(sys.argv) - 1" in workflow
assert "ssh-mcp-key known_hosts 更新失敗,停止部署" in workflow
diff --git a/apps/api/tests/test_incident_signal_source_compatibility.py b/apps/api/tests/test_incident_signal_source_compatibility.py
new file mode 100644
index 000000000..a924eaa3c
--- /dev/null
+++ b/apps/api/tests/test_incident_signal_source_compatibility.py
@@ -0,0 +1,273 @@
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+from datetime import UTC, datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from pydantic import ValidationError
+
+from src.api.v1 import auto_repair as auto_repair_api
+from src.api.v1 import incidents as incidents_api
+from src.api.v1 import webhooks as webhooks_api
+from src.models.incident import IncidentStatus, Severity
+from src.repositories import incident_repository as repository_module
+from src.repositories.incident_repository import IncidentDBRepository
+from src.services.incident_engine import IncidentEngineAdapter
+from src.services.incident_service import IncidentService
+
+SENSOR_SOURCES = ("journal", "node-exporter", "sensor-probe", "sensor-agent")
+D037_INCIDENT_ID = "INC-20260711-D037E5"
+
+
+def _record(
+ incident_id: str,
+ *,
+ status: IncidentStatus,
+ source: str | None,
+ malformed: bool = False,
+ auto_repair_count: int = 0,
+) -> SimpleNamespace:
+ timestamp = datetime(2026, 7, 11, 12, 0, tzinfo=UTC)
+ signal = {
+ "alert_name": f"Signal-{incident_id}",
+ "annotations": {},
+ "labels": {},
+ }
+ if not malformed:
+ signal.update({
+ "signal_id": incident_id[-8:],
+ "severity": Severity.P2.value,
+ "source": source,
+ "fired_at": timestamp.isoformat(),
+ })
+ frequency_snapshot = None
+ if auto_repair_count:
+ frequency_snapshot = {
+ "anomaly_key": f"anomaly-{incident_id}",
+ "auto_repair_count": auto_repair_count,
+ "last_repair_action": "ansible repair host service",
+ "last_repair_success": True,
+ }
+ return SimpleNamespace(
+ incident_id=incident_id,
+ status=status,
+ severity=Severity.P2,
+ signals=[signal],
+ affected_services=["awoooi-api"],
+ proposal_ids=[],
+ frequency_snapshot=frequency_snapshot,
+ created_at=timestamp,
+ updated_at=timestamp,
+ resolved_at=(timestamp if status is IncidentStatus.RESOLVED else None),
+ closed_at=None,
+ )
+
+
+class _RowsResult:
+ def __init__(self, records: list[SimpleNamespace]) -> None:
+ self._records = records
+
+ def scalars(self) -> _RowsResult:
+ return self
+
+ def all(self) -> list[SimpleNamespace]:
+ return self._records
+
+
+class _Db:
+ def __init__(self, records: list[SimpleNamespace]) -> None:
+ self.records = records
+ self.statements: list[object] = []
+
+ async def execute(self, statement: object) -> _RowsResult:
+ self.statements.append(statement)
+ return _RowsResult(self.records)
+
+
+def _install_fake_db(
+ monkeypatch: pytest.MonkeyPatch,
+ records: list[SimpleNamespace],
+) -> tuple[_Db, list[str | None]]:
+ db = _Db(records)
+ project_ids: list[str | None] = []
+
+ @asynccontextmanager
+ async def fake_get_db_context(project_id: str | None = None):
+ project_ids.append(project_id)
+ yield db
+
+ monkeypatch.setattr(repository_module, "get_db_context", fake_get_db_context)
+ return db, project_ids
+
+
+@pytest.mark.asyncio
+async def test_active_readback_accepts_sensor_sources_quarantines_malformed_and_excludes_d037(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ records = [
+ _record(
+ f"INC-20260711-SENSOR-{index}",
+ status=IncidentStatus.INVESTIGATING,
+ source=source,
+ )
+ for index, source in enumerate(SENSOR_SOURCES, start=1)
+ ]
+ records.extend([
+ _record(
+ D037_INCIDENT_ID,
+ status=IncidentStatus.RESOLVED,
+ source="alertmanager",
+ ),
+ _record(
+ "INC-20260711-3A4165",
+ status=IncidentStatus.INVESTIGATING,
+ source=None,
+ malformed=True,
+ ),
+ ])
+ db, project_ids = _install_fake_db(monkeypatch, records)
+
+ readback = await IncidentDBRepository().get_active_readback(
+ project_id="awoooi"
+ )
+
+ assert [incident.signals[0].source for incident in readback.incidents] == list(
+ SENSOR_SOURCES
+ )
+ assert D037_INCIDENT_ID not in {
+ incident.incident_id for incident in readback.incidents
+ }
+ assert [item.model_dump() for item in readback.degraded_records] == [{
+ "incident_id": "INC-20260711-3A4165",
+ "reason_code": "incident_signal_schema_invalid",
+ "quarantined_from_response": True,
+ }]
+ assert project_ids == ["awoooi"]
+ assert IncidentStatus.RESOLVED not in db.statements[0].compile().params["status_1"]
+
+
+@pytest.mark.asyncio
+async def test_unknown_source_is_fail_visible_without_blocking_valid_active_rows(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ records = [
+ _record(
+ "INC-20260711-UNKNOWN",
+ status=IncidentStatus.INVESTIGATING,
+ source="unregistered-sensor",
+ )
+ ]
+ _install_fake_db(monkeypatch, records)
+ service = IncidentService()
+ redis_read = AsyncMock()
+ monkeypatch.setattr(service, "get_from_working_memory", redis_read)
+
+ readback = await service.get_active_incidents_readback(project_id="awoooi")
+
+ assert readback.incidents == []
+ assert readback.degraded is True
+ assert readback.degraded_records[0].reason_code == (
+ "incident_signal_source_unsupported"
+ )
+ redis_read.assert_not_awaited()
+
+ class _DecisionManager:
+ async def _find_existing_tokens_for_incidents(self, _incident_ids):
+ return {}
+
+ monkeypatch.setattr(incidents_api, "get_incident_service", lambda: service)
+ monkeypatch.setattr(
+ incidents_api,
+ "get_decision_manager",
+ lambda: _DecisionManager(),
+ )
+ monkeypatch.setattr(auto_repair_api, "get_incident_service", lambda: service)
+
+ active = await incidents_api.list_incidents(
+ project_id="awoooi",
+ generate_missing_decisions=False,
+ )
+ history = await auto_repair_api.get_repair_history(
+ project_id="awoooi",
+ limit=20,
+ )
+
+ for response in (active, history):
+ assert response.degraded is True
+ assert response.degraded_count == 1
+ assert response.degraded_records[0].incident_id == (
+ "INC-20260711-UNKNOWN"
+ )
+ assert response.redis_fallback_used is False
+
+
+@pytest.mark.asyncio
+async def test_auto_repair_history_remains_usable_with_journal_and_malformed_rows(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ records = [
+ _record(
+ "INC-20260711-JOURNAL-HISTORY",
+ status=IncidentStatus.INVESTIGATING,
+ source="journal",
+ auto_repair_count=1,
+ ),
+ _record(
+ D037_INCIDENT_ID,
+ status=IncidentStatus.RESOLVED,
+ source="alertmanager",
+ auto_repair_count=1,
+ ),
+ _record(
+ "INC-20260711-3A4165",
+ status=IncidentStatus.INVESTIGATING,
+ source=None,
+ malformed=True,
+ ),
+ ]
+ _install_fake_db(monkeypatch, records)
+ monkeypatch.setattr(
+ auto_repair_api,
+ "get_incident_service",
+ IncidentService,
+ )
+
+ history = await auto_repair_api.get_repair_history(
+ project_id="awoooi",
+ limit=20,
+ )
+
+ assert history.count == 1
+ assert history.items[0].incident_id == "INC-20260711-JOURNAL-HISTORY"
+ assert D037_INCIDENT_ID not in {item.incident_id for item in history.items}
+ assert history.degraded is True
+ assert history.degraded_records[0].incident_id == "INC-20260711-3A4165"
+ assert history.degraded_records[0].reason_code == (
+ "incident_signal_schema_invalid"
+ )
+
+
+def test_signal_ingress_rejects_unregistered_source_before_stream_write() -> None:
+ with pytest.raises(ValidationError):
+ webhooks_api.SignalPayload(
+ source="unregistered-sensor",
+ alert_name="UnknownProducer",
+ severity="warning",
+ namespace="infra",
+ target="host",
+ )
+
+
+@pytest.mark.asyncio
+async def test_incident_engine_rejects_unknown_source_before_durable_processing() -> None:
+ brain_engine = SimpleNamespace(process_signal=AsyncMock(return_value=None))
+ adapter = IncidentEngineAdapter(brain_engine)
+
+ assert await adapter.process_signal({"source": "journal"}) is None
+ brain_engine.process_signal.assert_awaited_once_with({"source": "journal"})
+
+ with pytest.raises(ValueError, match="unsupported_signal_source"):
+ await adapter.process_signal({"source": "unregistered-sensor"})
+ assert brain_engine.process_signal.await_count == 1
diff --git a/apps/api/tests/test_runtime_bootstrap_guards.py b/apps/api/tests/test_runtime_bootstrap_guards.py
index 2e5d7ab7f..16009b268 100644
--- a/apps/api/tests/test_runtime_bootstrap_guards.py
+++ b/apps/api/tests/test_runtime_bootstrap_guards.py
@@ -365,3 +365,49 @@ def test_init_db_backfills_all_optional_knowledge_read_columns() -> None:
assert "ADD COLUMN IF NOT EXISTS path_type" in source
assert "ADD COLUMN IF NOT EXISTS symptoms_hash" in source
assert "CREATE INDEX IF NOT EXISTS ix_knowledge_symptoms_hash" in source
+
+
+def test_init_db_bootstraps_agent99_retry_schema_before_serving() -> None:
+ import inspect
+
+ from src.db import base as db_base
+
+ source = inspect.getsource(db_base._run_init_db_ddl)
+ column_ddl = (
+ "ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMP NULL"
+ )
+ index_ddl = "CREATE INDEX IF NOT EXISTS idx_run_state_retry_due"
+
+ assert "ALTER TABLE awooop_run_state" in source
+ assert column_ddl in source
+ assert index_ddl in source
+ assert "WHERE state = 'pending' AND next_attempt_at IS NOT NULL" in source
+ assert source.index(column_ddl) < source.index("ALTER TABLE approval_records")
+
+
+def test_agent99_retry_migration_matches_startup_bootstrap() -> None:
+ migrations = Path(__file__).resolve().parents[1] / "migrations"
+ up = (
+ migrations / "agent99_dispatch_next_attempt_at_2026-07-11.sql"
+ ).read_text(encoding="utf-8")
+ down = (
+ migrations / "agent99_dispatch_next_attempt_at_2026-07-11_down.sql"
+ ).read_text(encoding="utf-8")
+
+ assert "ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMP NULL" in up
+ assert "CREATE INDEX IF NOT EXISTS idx_run_state_retry_due" in up
+ assert "WHERE state = 'pending' AND next_attempt_at IS NOT NULL" in up
+ assert down.index("DROP INDEX IF EXISTS idx_run_state_retry_due") < (
+ down.index("DROP COLUMN IF EXISTS next_attempt_at")
+ )
+
+
+def test_api_runs_retry_schema_bootstrap_before_agent99_reconciler() -> None:
+ import inspect
+
+ from src import main as api_main
+
+ source = inspect.getsource(api_main.lifespan)
+ assert source.index("await init_db()") < source.index(
+ "run_agent99_controlled_dispatch_reconciler_loop()"
+ )
diff --git a/apps/api/tests/test_telegram_gateway_error_sanitizer.py b/apps/api/tests/test_telegram_gateway_error_sanitizer.py
index df84691b5..b3c22ae76 100644
--- a/apps/api/tests/test_telegram_gateway_error_sanitizer.py
+++ b/apps/api/tests/test_telegram_gateway_error_sanitizer.py
@@ -4,6 +4,7 @@ from src.services.telegram_gateway import (
_agent99_actionable_outbound_metadata,
_agent99_alert_from_actionable_outbound,
_agent99_alert_from_ai_automation_card,
+ _alert_notification_source_envelope_extra,
_merge_outbound_source_envelope_extra,
_outbound_source_envelope,
_sanitize_telegram_error,
@@ -187,6 +188,76 @@ full_log=/var/ossec/logs/alerts/alerts.json Authorization: Bearer abcdefghijklmn
assert "abcdefghijkl" not in str(envelope)
+def test_backup_restore_card_persists_fail_closed_readback_contract() -> None:
+ raw_alert = (
+ 'alertname="BackupAggregateRunFailed" host="110" '
+ 'backup_status=0 escrow_missing=5 restic failed '
+ 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz'
+ )
+ card = format_aiops_signal_alert_card(raw_alert)
+ envelope = _outbound_source_envelope(
+ "sendMessage",
+ {"chat_id": "-100123", "text": card, "parse_mode": "HTML"},
+ )
+
+ metadata = envelope["ai_automation_alert_card"]
+ contract = metadata["backup_restore_automation"]
+ assert metadata["event_type"] == "backup_restore_escrow_signal"
+ assert metadata["lane"] == "backup_restore_escrow_triage"
+ assert metadata["controlled_playbook_queue"] is False
+ assert metadata["runtime_write_gate_count"] == 0
+ assert metadata["runtime_write_gate_state"] == "evidence_only"
+ assert metadata["no_false_green"] is True
+ assert contract["work_item"]["owner"] == "backup_dr"
+ assert contract["candidate"]["mode"] == "BackupCheck"
+ assert contract["candidate"]["execution_state"] == "not_started"
+ assert metadata["declared_runtime_write_gate_count"] == 0
+ assert metadata["declared_runtime_write_gate_state"] == "closed"
+ assert metadata["declared_controlled_playbook_queue"] is True
+ assert contract["contract_origin"] == "generated_for_send"
+ assert contract["contract_persisted_at_send"] is False
+ assert contract["contract_provenance"][
+ "source_contract_present_at_send"
+ ] is False
+ assert contract["contract_provenance"]["recorded_policy_present"] is False
+ assert contract["contract_provenance"][
+ "recorded_immutable_declared_present"
+ ] is False
+ assert contract["policy"]["policy_version"] == (
+ "backup_restore_readback_policy_v2"
+ )
+ assert contract["receipt"]["status"] == (
+ "pending_source_message_persistence"
+ )
+ assert contract["receipt"]["is_agent99_execution_receipt"] is False
+ assert contract["verifier"]["status"] == (
+ "source_message_pending_verifier_not_started"
+ )
+ assert contract["verifier"]["agent99_outcome"]["status"] == (
+ "outcome_source_unavailable"
+ )
+ assert contract["runtime_write_allowed"] is False
+ assert contract["production_write_performed"] is False
+ assert "runtime_write_gate=0_until_verifier" in metadata["gates"]
+ assert "abcdefghijklmnopqrstuvwxyz" not in str(envelope)
+
+ notification_receipt = _alert_notification_source_envelope_extra(
+ safe_text=card,
+ effective_parse_mode="HTML",
+ reply_markup_present=False,
+ )["alert_notification"]
+ assert notification_receipt["controlled_apply_allowed"] is False
+ assert notification_receipt["manual_default_route_allowed"] is False
+
+ agent99_payload = _agent99_alert_from_ai_automation_card(card, source="unit-test")
+ assert agent99_payload is not None
+ assert agent99_payload["kind"] == "backup_health"
+ assert agent99_payload["suggestedMode"] == "BackupCheck"
+ assert agent99_payload["controlledApply"] is False
+ assert "BackupCheck readback candidate only" in agent99_payload["instruction"]
+ assert agent99_payload["awoooi"]["backupRestoreAutomation"] == contract
+
+
def test_ai_alert_card_builds_agent99_provider_freshness_signal() -> None:
raw_alert = """
SourceProviderIngestionStale provider_freshness_signal freshness window timeout
diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py
index e4963af8c..6b6a6fc4c 100644
--- a/apps/api/tests/test_telegram_message_templates.py
+++ b/apps/api/tests/test_telegram_message_templates.py
@@ -271,7 +271,12 @@ def test_aiops_signal_formatter_covers_non_host_alert_lanes(
assert event_type in result
assert lane in result
assert "controlled_playbook_queue" in result
- assert "runtime_write_gate=controlled" in result
+ if event_type == "backup_restore_escrow_signal":
+ assert "controlled_playbook_queue=readback_only" in result
+ assert "runtime_write_gate=0_until_verifier" in result
+ assert "runtime_write_gate=controlled" not in result
+ else:
+ assert "runtime_write_gate=controlled" in result
assert "Top evidence" in result
assert "禁止事項" in result