[CmdletBinding()] param( [ValidateSet("Check", "Apply")] [string]$Mode = "Check", [Parameter(Mandatory = $true)] [string]$TraceId, [Parameter(Mandatory = $true)] [string]$RunId, [Parameter(Mandatory = $true)] [string]$WorkItemId, [string]$AgentRoot = "C:\Wooo\Agent99" ) $ErrorActionPreference = "Stop" $safeIdentityPattern = "^[A-Za-z0-9][A-Za-z0-9._:@+\-]{0,127}$" foreach ($identity in @($TraceId, $RunId, $WorkItemId)) { if ($identity -notmatch $safeIdentityPattern) { throw "controlled_identity_invalid" } } $pendingDir = Join-Path $AgentRoot "state\completion-callbacks\pending" $processedCommandDir = Join-Path $AgentRoot "queue\processed" $evidenceDir = Join-Path $AgentRoot "evidence" $migrationRoot = Join-Path $AgentRoot "state\completion-callbacks\migration-backups" $lockPath = Join-Path $AgentRoot "state\completion-callbacks\incident-id-migration.lock" $script:MigrationLock = $null function Get-Sha256Text { param([string]$Value) $sha = [Security.Cryptography.SHA256]::Create() try { $bytes = [Text.Encoding]::UTF8.GetBytes($Value) return (($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -join "") } finally { $sha.Dispose() } } function Get-Sha256File { param([string]$Path) return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } function Write-Utf8NoBom { param([string]$Path, [string]$Value) $encoding = New-Object System.Text.UTF8Encoding($false) [IO.File]::WriteAllText($Path, $Value, $encoding) } function Write-ImmutableJson { param([string]$Path, [object]$Value) if (Test-Path -LiteralPath $Path) { throw "immutable_receipt_exists" } $temporaryPath = "$Path.tmp.$PID.$([Guid]::NewGuid().ToString('N'))" try { $json = $Value | ConvertTo-Json -Depth 12 $null = $json | ConvertFrom-Json Write-Utf8NoBom $temporaryPath $json [IO.File]::Move($temporaryPath, $Path) } finally { if (Test-Path -LiteralPath $temporaryPath) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } } function Get-CallbackRows { $rows = @() $blockers = @() if (-not (Test-Path -LiteralPath $pendingDir -PathType Container)) { return [pscustomobject]@{ rows = @(); blockers = @() } } foreach ($file in @(Get-ChildItem -LiteralPath $pendingDir -Filter "*.json" -File | Sort-Object Name)) { try { $payload = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json $runIdValue = [string]$payload.run_id $priorAlertId = [string]$payload.alert_id if (-not $runIdValue -or $runIdValue -notmatch "^[A-Za-z0-9._-]{1,128}$") { throw "pending_run_id_invalid" } if (-not $priorAlertId) { throw "pending_alert_id_missing" } $processedPath = Join-Path $processedCommandDir "processed-$runIdValue.json" if (-not (Test-Path -LiteralPath $processedPath -PathType Leaf)) { throw "processed_result_missing" } $processed = Get-Content -LiteralPath $processedPath -Raw | ConvertFrom-Json $canonicalIncidentId = "" if ($processed.PSObject.Properties["identity"] -and $processed.identity) { $canonicalIncidentId = [string]$processed.identity.incident_id } if (-not $canonicalIncidentId -and $processed.PSObject.Properties["incidentId"]) { $canonicalIncidentId = [string]$processed.incidentId } if ( -not $canonicalIncidentId -or $canonicalIncidentId.Length -gt 30 -or $canonicalIncidentId -notmatch "^INC-[A-Za-z0-9-]+$" ) { throw "canonical_incident_id_invalid" } $rows += [pscustomobject]@{ file = $file.Name processedFile = Split-Path -Leaf $processedPath runId = $runIdValue priorAlertIdLength = $priorAlertId.Length priorAlertIdSha256 = Get-Sha256Text $priorAlertId canonicalIncidentId = $canonicalIncidentId needsMigration = [bool]($priorAlertId -ne $canonicalIncidentId) beforeFileSha256 = Get-Sha256File $file.FullName } } catch { $blockers += [pscustomobject]@{ file = $file.Name reason = $_.Exception.Message } } } [pscustomobject]@{ rows = @($rows); blockers = @($blockers) } } $scan = Get-CallbackRows $candidateRows = @($scan.rows | Where-Object { $_.needsMigration }) $identityToken = (Get-Sha256Text "$TraceId|$RunId|$WorkItemId").Substring(0, 20) $receiptPath = Join-Path $evidenceDir "agent99-callback-incident-id-migration-$identityToken.json" $backupDir = Join-Path $migrationRoot "backup-$identityToken" $baseReceipt = [ordered]@{ schemaVersion = "agent99_callback_incident_id_migration_v1" status = "pending" observedAt = (Get-Date -Format o) mode = $Mode traceId = $TraceId runId = $RunId workItemId = $WorkItemId pendingCount = @($scan.rows).Count + @($scan.blockers).Count candidateCount = $candidateRows.Count unchangedCount = @($scan.rows | Where-Object { -not $_.needsMigration }).Count blockerCount = @($scan.blockers).Count blockers = @($scan.blockers) rows = @($scan.rows) receiptPath = $receiptPath backupPath = if ($Mode -eq "Apply") { $backupDir } else { $null } secretValueRead = $false privateKeyValueRead = $false tokenValueRead = $false rawExternalAlertIdLogged = $false remoteWritePerformed = $false pendingPayloadWritePerformed = $false rollbackAttempted = $false rollbackVerified = $false modifiedCount = 0 completedAt = $null failureType = $null } if ($Mode -eq "Check") { $baseReceipt.status = if (@($scan.blockers).Count -gt 0) { "blocked_check" } else { "check_ready" } $baseReceipt | ConvertTo-Json -Depth 12 -Compress if (@($scan.blockers).Count -gt 0) { exit 2 } exit 0 } if (@($scan.blockers).Count -gt 0) { $baseReceipt.status = "blocked_before_apply" $baseReceipt | ConvertTo-Json -Depth 12 -Compress exit 2 } New-Item -ItemType Directory -Force -Path $evidenceDir, $migrationRoot | Out-Null try { $script:MigrationLock = [IO.File]::Open( $lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None ) } catch { $baseReceipt.status = "blocked_single_flight_lock_unavailable" $baseReceipt | ConvertTo-Json -Depth 12 -Compress exit 75 } $modifiedRows = @() try { if (Test-Path -LiteralPath $receiptPath) { throw "immutable_receipt_exists" } if (Test-Path -LiteralPath $backupDir) { throw "immutable_backup_path_exists" } New-Item -ItemType Directory -Path $backupDir | Out-Null $baseReceipt.remoteWritePerformed = $true foreach ($row in $candidateRows) { $pendingPath = Join-Path $pendingDir $row.file $backupPath = Join-Path $backupDir $row.file $replaceBackupPath = Join-Path $backupDir "$($row.file).replace-original" if ((Get-Sha256File $pendingPath) -ne $row.beforeFileSha256) { throw "pending_payload_changed_after_check" } Copy-Item -LiteralPath $pendingPath -Destination $backupPath if ((Get-Sha256File $backupPath) -ne $row.beforeFileSha256) { throw "pending_payload_backup_mismatch" } $payload = Get-Content -LiteralPath $pendingPath -Raw | ConvertFrom-Json $payload.alert_id = $row.canonicalIncidentId $temporaryPath = "$pendingPath.migrate.$PID.$([Guid]::NewGuid().ToString('N'))" try { $json = $payload | ConvertTo-Json -Depth 12 Write-Utf8NoBom $temporaryPath $json $candidate = Get-Content -LiteralPath $temporaryPath -Raw | ConvertFrom-Json if ([string]$candidate.alert_id -ne $row.canonicalIncidentId) { throw "candidate_post_condition_failed" } [IO.File]::Replace($temporaryPath, $pendingPath, $replaceBackupPath, $true) if ((Get-Sha256File $replaceBackupPath) -ne $row.beforeFileSha256) { throw "atomic_replace_backup_mismatch" } $modifiedRows += $row } finally { if (Test-Path -LiteralPath $temporaryPath) { Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue } } $baseReceipt.pendingPayloadWritePerformed = $true $postPayload = Get-Content -LiteralPath $pendingPath -Raw | ConvertFrom-Json if ([string]$postPayload.alert_id -ne $row.canonicalIncidentId) { throw "post_migration_readback_failed" } } $baseReceipt.status = if ($candidateRows.Count -gt 0) { "migrated_verified" } else { "no_change_verified" } $baseReceipt.modifiedCount = $modifiedRows.Count $baseReceipt.completedAt = (Get-Date -Format o) Write-ImmutableJson $receiptPath $baseReceipt $baseReceipt | ConvertTo-Json -Depth 12 -Compress exit 0 } catch { $failureType = $_.Exception.Message $baseReceipt.rollbackAttempted = [bool]($modifiedRows.Count -gt 0) $rollbackOk = $true foreach ($row in $modifiedRows) { try { $pendingPath = Join-Path $pendingDir $row.file $backupPath = Join-Path $backupDir $row.file $failedReplacementPath = Join-Path $backupDir "$($row.file).failed-migrated" $temporaryPath = "$pendingPath.rollback.$PID.$([Guid]::NewGuid().ToString('N'))" Copy-Item -LiteralPath $backupPath -Destination $temporaryPath [IO.File]::Replace($temporaryPath, $pendingPath, $failedReplacementPath, $true) if ((Get-Sha256File $pendingPath) -ne $row.beforeFileSha256) { $rollbackOk = $false } } catch { $rollbackOk = $false } } $baseReceipt.rollbackVerified = [bool]($baseReceipt.rollbackAttempted -and $rollbackOk) $baseReceipt.status = if ($baseReceipt.rollbackVerified) { "rolled_back_verified" } else { "migration_failed" } $baseReceipt.failureType = $failureType $baseReceipt.modifiedCount = $modifiedRows.Count $baseReceipt.completedAt = (Get-Date -Format o) if (-not (Test-Path -LiteralPath $receiptPath)) { Write-ImmutableJson $receiptPath $baseReceipt } $baseReceipt | ConvertTo-Json -Depth 12 -Compress exit 2 } finally { if ($script:MigrationLock) { $script:MigrationLock.Dispose() $script:MigrationLock = $null } }