scripts/internal/continuous-co-review/review-authority-store.ps1
|
$ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest # F-198 / T045: dependency-free immutable JSON repositories. There is no generic lock, mutable # revision/CAS layer, database, event store, process-owner handoff, or delete-on-completion path. # Every authority fact has one deterministic unique path and is published with FileMode.CreateNew. if (-not (Get-Command -Name 'Test-ReviewAuthorityContractObject' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'review-authority-core.ps1') } # HARD dependency (DRIFT-198-I009-018): absent, store containment silently compared with a # DIFFERENT case rule instead of the volume's own. if (-not (Get-Command -Name 'Get-ContinuousCoReviewPathCaseSensitive' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'path-identity.ps1') } function ConvertTo-ReviewAuthorityCanonicalValue { param([AllowNull()]$Value) if ($null -eq $Value) { return $null } # PowerShell's pipeline adapter can make scalar items report as [pscustomobject] even though # their BaseObject is still a string/value type. Preserve scalars before object-property # canonicalization so arrays of strings/numbers never serialize as {Length:...} wrappers. if ($Value -is [string] -or $Value -is [ValueType]) { return $Value } if ($Value -is [System.Collections.IDictionary]) { $ordered = [ordered]@{} foreach ($key in @($Value.Keys | Sort-Object { [string]$_ })) { $ordered[[string]$key] = ConvertTo-ReviewAuthorityCanonicalValue -Value $Value[$key] } return [pscustomobject]$ordered } if ($Value -is [pscustomobject]) { $ordered = [ordered]@{} foreach ($property in @($Value.PSObject.Properties | Sort-Object Name)) { $ordered[$property.Name] = ConvertTo-ReviewAuthorityCanonicalValue -Value $property.Value } return [pscustomobject]$ordered } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { $items = @($Value | ForEach-Object { ConvertTo-ReviewAuthorityCanonicalValue -Value $_ }) Write-Output -NoEnumerate $items return } return $Value } function ConvertTo-ReviewAuthorityCanonicalJson { param([Parameter(Mandatory)]$Fact) return ((ConvertTo-ReviewAuthorityCanonicalValue -Value $Fact) | ConvertTo-Json -Depth 30 -Compress) } function Assert-ReviewAuthoritySafeRelativePath { param([Parameter(Mandatory)][string]$RelativePath) $normalized = $RelativePath.Replace('\', '/') if ([string]::IsNullOrWhiteSpace($normalized) -or [System.IO.Path]::IsPathRooted($RelativePath) -or @($normalized.Split('/') | Where-Object { $_ -ceq '..' }).Count -gt 0) { throw "review-store-invalid-relative-path:$RelativePath" } } function Get-ReviewAuthorityStorePath { # Lexical containment is NOT containment (DRIFT-198-I009-041). GetFullPath only folds '..' # and separators, so a reparse point AT THE STORE ROOT, or at any CAMPAIGN/RUN ancestor # beneath it, still compares as under the root while Directory.CreateDirectory, # FileStream(CreateNew/Open), and Directory.EnumerateFiles all follow it outside the # intended store. This store holds the campaign's IMMUTABLE authority facts - grants, # reservations, spend, results - the evidence chain every certification claim in this # repository's own review history rests on. Measured before this fix: a symlinked store # root or a symlinked `campaigns/` ancestor causes a written fact to land, unrefused, inside # the external link target (probe evidence recorded against T082 in the iteration-010 drift # log). Fourth appearance of this containment class after DRIFT-198-I009-011 (retirement # delete), -025 (managed-file write), -031 (deployment mutators - all five, one choke # point). This module's every read, write, and enumeration resolves its path through this # ONE function, so hardening it here covers the whole module rather than each call site. param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$RelativePath ) Assert-ReviewAuthoritySafeRelativePath -RelativePath $RelativePath $root = [System.IO.Path]::GetFullPath($StoreRoot).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) $full = [System.IO.Path]::GetFullPath((Join-Path $root $RelativePath)) $prefix = $root + [System.IO.Path]::DirectorySeparatorChar # Store containment: refuse an aliased path rather than admit it when the volume is unknown. $comparison = Get-ContinuousCoReviewPathComparison -Path $root -WhenUndetermined 'same' if (-not $full.StartsWith($prefix, $comparison)) { throw "review-store-path-escape:$RelativePath" } # The store root need not exist yet - CreateDirectory below creates the whole chain on a # project's first-ever review, and nothing can be maliciously linked before it exists. Where # it DOES exist, a reparse point there is refused before anything beneath it is touched. if (Test-Path -LiteralPath $root) { $rootItem = Get-Item -LiteralPath $root -Force -ErrorAction Stop if (($rootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "review-store-root-link-unsupported:$StoreRoot" } } # Walk every EXISTING component from the root down to the target - the campaign directory, # the run directory, any intermediate kind folder (grants/reservations/spend/releases) - and # refuse a reparse point at any of them. A not-yet-created component is skipped: it cannot # redirect a write that has not reached it, and CreateDirectory will create it as an # ordinary directory. $relative = $full.Substring($root.Length).Trim([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) $current = $root foreach ($segment in @($relative -split '[\\/]' | Where-Object { -not [string]::IsNullOrEmpty($_) })) { $current = Join-Path $current $segment if (-not (Test-Path -LiteralPath $current)) { continue } $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "review-store-path-link-unsupported:$RelativePath" } } return $full } function Write-ReviewAuthorityImmutableFact { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$RelativePath, [Parameter(Mandatory)]$Fact, [Parameter(Mandatory)][ValidateSet( 'ReviewCampaign', 'ReviewRun', 'ReviewInvocation', 'ReviewerCandidate', 'ReviewResult', 'GrantFact', 'ReservationFact', 'SpendFact', 'ReleaseFact', 'ClaimFact', 'HumanDispositionFact', 'RecoveryFact', 'ReviewFinalizationFact' )][string]$ContractName, [string]$ExpectedCampaignId, [string]$ExpectedRunId, [string]$ExpectedTargetDigest ) $validation = Test-ReviewAuthorityContractObject -ContractName $ContractName -InputObject $Fact -ExpectedCampaignId $ExpectedCampaignId -ExpectedRunId $ExpectedRunId -ExpectedTargetDigest $ExpectedTargetDigest if (-not $validation.valid) { throw ('review-store-contract-invalid:{0}:{1}' -f $validation.category, ($validation.errors -join ',')) } $path = Get-ReviewAuthorityStorePath -StoreRoot $StoreRoot -RelativePath $RelativePath $directory = Split-Path -Parent $path [System.IO.Directory]::CreateDirectory($directory) | Out-Null $json = ConvertTo-ReviewAuthorityCanonicalJson -Fact $Fact $bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($json) try { $stream = [System.IO.FileStream]::new($path, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() } return [pscustomobject]@{ created = $true; idempotent = $false; path = $path } } catch [System.IO.IOException] { if (-not [System.IO.File]::Exists($path)) { throw } # Validate that the completed CreateNew winner is readable, then compare the canonical bytes # that were actually persisted. ConvertFrom-Json coerces ISO-8601 strings to DateTime on newer # PowerShell versions; reserializing that object can change offset/precision and falsely turn an # identical replay into corruption. $null = Read-ReviewAuthorityFactFile -Path $path $existing = [System.IO.File]::ReadAllText($path, [System.Text.UTF8Encoding]::new($false)) if ($existing -ceq $json) { return [pscustomobject]@{ created = $false; idempotent = $true; path = $path } } throw "review-store-corruption:conflicting-immutable-fact:$RelativePath" } } function Read-ReviewAuthorityFactFile { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [ValidateSet( 'ReviewCampaign', 'ReviewRun', 'ReviewInvocation', 'ReviewerCandidate', 'ReviewResult', 'GrantFact', 'ReservationFact', 'SpendFact', 'ReleaseFact', 'ClaimFact', 'HumanDispositionFact', 'RecoveryFact', 'ReviewFinalizationFact' )][string]$ContractName, [int]$MaxBytes = 1048576 ) $stream = $null for ($attempt = 0; $attempt -lt 20; $attempt++) { try { $stream = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read); break } catch [System.IO.IOException] { if ($attempt -eq 19) { throw "review-store-fact-unavailable:$Path" }; Start-Sleep -Milliseconds 10 } } try { if ($stream.Length -le 0 -or $stream.Length -gt $MaxBytes) { throw "review-store-corruption:invalid-fact-size:$Path" } $reader = [System.IO.StreamReader]::new($stream, [System.Text.UTF8Encoding]::new($false), $true, 4096, $true) try { $json = $reader.ReadToEnd() } finally { $reader.Dispose() } } finally { if ($null -ne $stream) { $stream.Dispose() } } try { $fact = $json | ConvertFrom-Json -Depth 30 -ErrorAction Stop } catch { throw "review-store-corruption:invalid-json:$Path" } if (-not [string]::IsNullOrWhiteSpace($ContractName)) { $validation = Test-ReviewAuthorityContractObject -ContractName $ContractName -InputObject $fact if (-not $validation.valid) { throw ('review-store-corruption:invalid-contract:{0}:{1}' -f $ContractName, ($validation.errors -join ',')) } } return $fact } function Get-ReviewAuthorityCampaignRelativeRoot { param([Parameter(Mandatory)][string]$CampaignId) if (-not (Test-ReviewAuthorityIdentifier -Value $CampaignId -Kind campaign)) { throw "review-store-invalid-campaign-id:$CampaignId" } return "campaigns/$CampaignId" } function Get-ReviewAuthorityCampaignFacts { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][ValidateSet('grants', 'reservations', 'spend', 'releases')][string]$Kind ) $contract = switch ($Kind) { 'grants' { 'GrantFact' }; 'reservations' { 'ReservationFact' }; 'spend' { 'SpendFact' }; 'releases' { 'ReleaseFact' } } $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + "/$Kind" $path = Get-ReviewAuthorityStorePath -StoreRoot $StoreRoot -RelativePath $relative if (-not [System.IO.Directory]::Exists($path)) { return @() } $facts = [System.Collections.Generic.List[object]]::new() foreach ($file in [System.IO.Directory]::EnumerateFiles($path, '*.json', [System.IO.SearchOption]::AllDirectories) | Sort-Object) { $facts.Add((Read-ReviewAuthorityFactFile -Path $file -ContractName $contract)) | Out-Null } return @($facts) } function Add-ReviewCampaignGrantFact { [CmdletBinding()] param([Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)]$Fact) $campaignId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'campaign_id') $grantId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'grant_id') $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $campaignId) + "/grants/$grantId.json" return Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $Fact -ContractName GrantFact -ExpectedCampaignId $campaignId } function Request-ReviewCampaignReservationFact { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)][string]$ReservationId, [Parameter(Mandatory)][string]$ObservedAt ) for ($attempt = 0; $attempt -lt 101; $attempt++) { $grants = @(Get-ReviewAuthorityCampaignFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -Kind grants) $reservations = @(Get-ReviewAuthorityCampaignFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -Kind reservations) $spends = @(Get-ReviewAuthorityCampaignFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -Kind spend) $releases = @(Get-ReviewAuthorityCampaignFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -Kind releases) $decision = Resolve-ReviewCampaignReservationDecision -CampaignId $CampaignId -RunId $RunId -ReservationId $ReservationId -ObservedAt $ObservedAt -Grants $grants -Reservations $reservations -Spends $spends -Releases $releases if (-not $decision.permitted) { return [pscustomobject]@{ acquired = $false; reason = $decision.reason; fact = $null } } $fact = $decision.fact # A pre-invocation release restores an allowance slot. Each reuse appends an immutable # generation; concurrent contenders derive the same next generation and CreateNew selects # exactly one winner before all losers reread the authority facts. $slotReservations = @($reservations | Where-Object { [string](Get-ReviewAuthorityProperty -Object $_ -Name 'grant_id') -ceq [string]$fact.grant_id -and [int](Get-ReviewAuthorityProperty -Object $_ -Name 'slot') -eq [int]$fact.slot }) $generation = $slotReservations.Count + 1 $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + ('/reservations/{0}/slot-{1:d3}/generation-{2:d3}.json' -f $fact.grant_id, [int]$fact.slot, $generation) try { $write = Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $fact -ContractName ReservationFact -ExpectedCampaignId $CampaignId -ExpectedRunId $RunId if ($write.created) { return [pscustomobject]@{ acquired = $true; reason = 'reservation-created'; fact = $fact; path = $write.path } } return [pscustomobject]@{ acquired = $false; reason = 'reservation-already-owned-by-run'; fact = $fact; path = $write.path } } catch { if ($_.Exception.Message -notlike 'review-store-corruption:conflicting-immutable-fact:*') { throw } # Expected slot-contention race: reread all immutable facts and choose the next visible free slot. continue } } throw 'review-store-corruption:reservation-contention-did-not-converge' } function Write-ReviewCampaignSpendFact { [CmdletBinding()] param([Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)]$Fact) $campaignId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'campaign_id') $reservationId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'reservation_id') $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $campaignId) + "/spend/$reservationId.json" return Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $Fact -ContractName SpendFact -ExpectedCampaignId $campaignId } function Write-ReviewCampaignReleaseFact { [CmdletBinding()] param([Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)]$Fact) $campaignId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'campaign_id') $reservationId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'reservation_id') $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $campaignId) + "/releases/$reservationId.json" return Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $Fact -ContractName ReleaseFact -ExpectedCampaignId $campaignId } function Write-ReviewRunAuthorityFact { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)][ValidateSet('requested', 'reserved', 'preflighted', 'claimed', 'invoked', 'validating')][string]$Stage, [Parameter(Mandatory)]$Fact ) if ([string](Get-ReviewAuthorityProperty -Object $Fact -Name 'state') -cne $Stage) { throw "review-store-contract-invalid:run-stage-mismatch:$Stage" } $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + "/runs/$RunId/$Stage.json" return Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $Fact -ContractName ReviewRun -ExpectedCampaignId $CampaignId -ExpectedRunId $RunId } function Publish-ReviewRunResultFact { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)]$Fact ) $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + "/runs/$RunId/result.json" return Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $Fact -ContractName ReviewResult -ExpectedCampaignId $CampaignId -ExpectedRunId $RunId } function Get-ReviewAuthorityCampaignRunResults { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId ) $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + '/runs' $runsRoot = Get-ReviewAuthorityStorePath -StoreRoot $StoreRoot -RelativePath $relative if (-not [IO.Directory]::Exists($runsRoot)) { return @() } $results = [Collections.Generic.List[object]]::new() foreach ($runDirectory in [IO.Directory]::EnumerateDirectories($runsRoot) | Sort-Object) { $runId = [IO.Path]::GetFileName($runDirectory) if (-not (Test-ReviewAuthorityIdentifier -Value $runId -Kind run)) { throw "review-store-corruption:invalid-run-directory:$runId" } $path = Join-Path $runDirectory 'result.json' if (-not [IO.File]::Exists($path)) { continue } $fact = Read-ReviewAuthorityFactFile -Path $path -ContractName ReviewResult if ([string]$fact.campaign_id -cne $CampaignId -or [string]$fact.run_id -cne $runId) { throw "review-store-corruption:result-path-identity-mismatch:$runId" } $results.Add($fact) | Out-Null } return @($results) } function Get-ReviewRunLatestStateFact { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][string]$RunId ) foreach ($stage in @('validating', 'invoked', 'claimed', 'preflighted', 'reserved', 'requested')) { $fact = Get-ReviewRunAuthorityFact -StoreRoot $StoreRoot -CampaignId $CampaignId -RunId $RunId -Stage $stage if ($null -ne $fact) { return $fact } } return $null } function Write-ReviewCampaignHumanDispositionFact { [CmdletBinding()] param([Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)]$Fact) $campaignId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'campaign_id') $runId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'run_id') $dispositionId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'disposition_id') $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $campaignId) + "/dispositions/$runId/$dispositionId.json" return Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $Fact -ContractName HumanDispositionFact -ExpectedCampaignId $campaignId -ExpectedRunId $runId -ExpectedTargetDigest ([string]$Fact.target_digest) } function Get-ReviewCampaignHumanDispositionFacts { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [string]$RunId ) $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + '/dispositions' if (-not [string]::IsNullOrWhiteSpace($RunId)) { if (-not (Test-ReviewAuthorityIdentifier -Value $RunId -Kind run)) { throw "review-store-invalid-run-id:$RunId" } $relative += "/$RunId" } $root = Get-ReviewAuthorityStorePath -StoreRoot $StoreRoot -RelativePath $relative if (-not [IO.Directory]::Exists($root)) { return @() } $facts = [Collections.Generic.List[object]]::new() foreach ($file in [IO.Directory]::EnumerateFiles($root, '*.json', [IO.SearchOption]::AllDirectories) | Sort-Object) { $fact = Read-ReviewAuthorityFactFile -Path $file -ContractName HumanDispositionFact $expectedRelative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + "/dispositions/$([string]$fact.run_id)/$([string]$fact.disposition_id).json" $expectedPath = Get-ReviewAuthorityStorePath -StoreRoot $StoreRoot -RelativePath $expectedRelative if ([IO.Path]::GetFullPath($file) -cne [IO.Path]::GetFullPath($expectedPath) -or [string]$fact.campaign_id -cne $CampaignId -or (-not [string]::IsNullOrWhiteSpace($RunId) -and [string]$fact.run_id -cne $RunId)) { throw 'review-store-corruption:human-disposition-path-identity-mismatch' } $facts.Add($fact) | Out-Null } return @($facts) } function Write-ReviewCampaignFinalizationFact { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)]$Fact ) $campaignId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'campaign_id') $runId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'run_id') $reviewedDigest = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'reviewed_digest') $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $campaignId) + '/finalization.json' return Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $Fact ` -ContractName ReviewFinalizationFact -ExpectedCampaignId $campaignId -ExpectedRunId $runId -ExpectedTargetDigest $reviewedDigest } function Get-ReviewCampaignFinalizationFact { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId ) $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + '/finalization.json' $path = Get-ReviewAuthorityStorePath -StoreRoot $StoreRoot -RelativePath $relative if (-not [IO.File]::Exists($path)) { return $null } $fact = Read-ReviewAuthorityFactFile -Path $path -ContractName ReviewFinalizationFact if ([string]$fact.campaign_id -cne $CampaignId) { throw 'review-store-corruption:finalization-path-identity-mismatch' } return $fact } function Get-ReviewRunAuthorityFact { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)][ValidateSet('requested', 'reserved', 'preflighted', 'claimed', 'invoked', 'validating', 'recovery', 'result')][string]$Stage ) $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + "/runs/$RunId/$Stage.json" $path = Get-ReviewAuthorityStorePath -StoreRoot $StoreRoot -RelativePath $relative if (-not [System.IO.File]::Exists($path)) { return $null } $contract = if ($Stage -ceq 'result') { 'ReviewResult' } elseif ($Stage -ceq 'recovery') { 'RecoveryFact' } else { 'ReviewRun' } return Read-ReviewAuthorityFactFile -Path $path -ContractName $contract } function Write-ReviewRunRecoveryFact { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)]$Fact ) $campaignId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'campaign_id') $runId = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'run_id') $targetDigest = [string](Get-ReviewAuthorityProperty -Object $Fact -Name 'target_digest') $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $campaignId) + "/runs/$runId/recovery.json" return Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $Fact -ContractName RecoveryFact ` -ExpectedCampaignId $campaignId -ExpectedRunId $runId -ExpectedTargetDigest $targetDigest } function Get-ReviewAuthorityClaimFacts { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][string]$TargetLineage ) if (-not (Test-ReviewAuthorityIdentifier -Value $TargetLineage -Kind lineage)) { throw "review-store-invalid-lineage-id:$TargetLineage" } $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + "/claims/$TargetLineage" $path = Get-ReviewAuthorityStorePath -StoreRoot $StoreRoot -RelativePath $relative if (-not [System.IO.Directory]::Exists($path)) { return @() } $facts = [System.Collections.Generic.List[object]]::new() foreach ($file in [System.IO.Directory]::EnumerateFiles($path, '*.json', [System.IO.SearchOption]::TopDirectoryOnly) | Sort-Object) { $fact = Read-ReviewAuthorityFactFile -Path $file -ContractName ClaimFact $fact | Add-Member -NotePropertyName '_fact_path' -NotePropertyValue $file $facts.Add($fact) | Out-Null } return @($facts) } function Get-ReviewAuthorityActiveClaim { param([object[]]$Facts = @()) $byGeneration = @{} foreach ($fact in @($Facts)) { $generation = [int](Get-ReviewAuthorityProperty -Object $fact -Name 'generation') if (-not $byGeneration.ContainsKey($generation)) { $byGeneration[$generation] = [System.Collections.Generic.List[object]]::new() } $byGeneration[$generation].Add($fact) | Out-Null } foreach ($generation in @($byGeneration.Keys | Sort-Object -Descending)) { $factsAtGeneration = @($byGeneration[$generation]) $held = @($factsAtGeneration | Where-Object { [string](Get-ReviewAuthorityProperty -Object $_ -Name 'disposition') -ceq 'held' }) $retired = @($factsAtGeneration | Where-Object { [string](Get-ReviewAuthorityProperty -Object $_ -Name 'disposition') -in @('released', 'abandoned') }) if ($held.Count -gt 1 -or $retired.Count -gt 1) { throw "review-store-corruption:conflicting-claim-generation:$generation" } if ($held.Count -eq 1 -and $retired.Count -eq 0) { return $held[0] } } return $null } function Request-ReviewAuthorityClaim { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)][string]$TargetLineage, [Parameter(Mandatory)][string]$ObservedAt ) for ($attempt = 0; $attempt -lt 100; $attempt++) { $facts = @(Get-ReviewAuthorityClaimFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -TargetLineage $TargetLineage) $active = Get-ReviewAuthorityActiveClaim -Facts $facts if ($null -ne $active) { return [pscustomobject]@{ acquired = $false; reason = 'active-claim'; claim = $active } } $generation = if ($facts.Count -eq 0) { 1 } else { 1 + [int](($facts | ForEach-Object { [int]$_.generation } | Measure-Object -Maximum).Maximum) } $fact = [pscustomobject][ordered]@{ schema_version = '1.0'; fact_type = 'claim-held'; campaign_id = $CampaignId; run_id = $RunId target_lineage = $TargetLineage; generation = $generation; disposition = 'held'; observed_at = $ObservedAt } $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + ('/claims/{0}/{1:d8}-held.json' -f $TargetLineage, $generation) try { $write = Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $fact -ContractName ClaimFact -ExpectedCampaignId $CampaignId -ExpectedRunId $RunId if ($write.created) { return [pscustomobject]@{ acquired = $true; reason = 'claim-created'; claim = $fact; path = $write.path } } return [pscustomobject]@{ acquired = $false; reason = 'claim-already-held-by-run'; claim = $fact; path = $write.path } } catch { if ($_.Exception.Message -notlike 'review-store-corruption:conflicting-immutable-fact:*') { throw } continue } } throw 'review-store-corruption:claim-contention-did-not-converge' } function Complete-ReviewAuthorityClaim { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)][string]$TargetLineage, [Parameter(Mandatory)][ValidateSet('released', 'abandoned')][string]$Disposition, [Parameter(Mandatory)][string]$ObservedAt ) $facts = @(Get-ReviewAuthorityClaimFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -TargetLineage $TargetLineage) $active = Get-ReviewAuthorityActiveClaim -Facts $facts if ($null -eq $active) { return [pscustomobject]@{ completed = $false; reason = 'no-active-claim' } } if ([string]$active.run_id -cne $RunId) { return [pscustomobject]@{ completed = $false; reason = 'claim-owned-by-other-run' } } $generation = [int]$active.generation $factType = if ($Disposition -ceq 'released') { 'claim-released' } else { 'claim-abandoned' } $fact = [pscustomobject][ordered]@{ schema_version = '1.0'; fact_type = $factType; campaign_id = $CampaignId; run_id = $RunId target_lineage = $TargetLineage; generation = $generation; disposition = $Disposition; observed_at = $ObservedAt } $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + ('/claims/{0}/{1:d8}-{2}.json' -f $TargetLineage, $generation, $Disposition) $write = Write-ReviewAuthorityImmutableFact -StoreRoot $StoreRoot -RelativePath $relative -Fact $fact -ContractName ClaimFact -ExpectedCampaignId $CampaignId -ExpectedRunId $RunId return [pscustomobject]@{ completed = $true; reason = $(if ($write.created) { 'claim-retired' } else { 'claim-retirement-idempotent' }); fact = $fact; path = $write.path } } function Get-ReviewRunReconciliationPlan { [CmdletBinding()] param( [Parameter(Mandatory)][string]$StoreRoot, [Parameter(Mandatory)][string]$CampaignId, [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)][string]$TargetLineage ) $result = Get-ReviewRunAuthorityFact -StoreRoot $StoreRoot -CampaignId $CampaignId -RunId $RunId -Stage result $validating = Get-ReviewRunAuthorityFact -StoreRoot $StoreRoot -CampaignId $CampaignId -RunId $RunId -Stage validating $invoked = Get-ReviewRunAuthorityFact -StoreRoot $StoreRoot -CampaignId $CampaignId -RunId $RunId -Stage invoked $reservations = @(Get-ReviewAuthorityCampaignFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -Kind reservations | Where-Object { [string]$_.run_id -ceq $RunId }) $spends = @(Get-ReviewAuthorityCampaignFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -Kind spend | Where-Object { [string]$_.run_id -ceq $RunId }) $releases = @(Get-ReviewAuthorityCampaignFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -Kind releases | Where-Object { [string]$_.run_id -ceq $RunId }) $claims = @(Get-ReviewAuthorityClaimFacts -StoreRoot $StoreRoot -CampaignId $CampaignId -TargetLineage $TargetLineage) $activeClaim = Get-ReviewAuthorityActiveClaim -Facts $claims $actions = [System.Collections.Generic.List[string]]::new() if ($null -ne $result) { if ($null -ne $activeClaim -and [string]$activeClaim.run_id -ceq $RunId) { $actions.Add('retire-claim-released') | Out-Null } else { $actions.Add('complete') | Out-Null } } elseif ($null -ne $validating) { $actions.Add('continue-validation-and-classification') | Out-Null } elseif ($spends.Count -gt 0 -or $null -ne $invoked) { $actions.Add('publish-spent-abandoned-result') | Out-Null if ($null -ne $activeClaim -and [string]$activeClaim.run_id -ceq $RunId) { $actions.Add('retire-claim-abandoned') | Out-Null } } elseif ($reservations.Count -gt 0 -and $releases.Count -eq 0) { $actions.Add('release-non-invoked-reservation') | Out-Null if ($null -ne $activeClaim -and [string]$activeClaim.run_id -ceq $RunId) { $actions.Add('retire-claim-abandoned') | Out-Null } } else { $actions.Add('no-work') | Out-Null } return [pscustomobject]@{ run_id = $RunId; actions = @($actions); reservation_count = $reservations.Count; spend_count = $spends.Count; release_count = $releases.Count; active_claim_run_id = $(if ($null -ne $activeClaim) { [string]$activeClaim.run_id } else { $null }) } } |