scripts/internal/continuous-co-review/review-result-ingestor.ps1

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

# F-198 / T047: reviewer output is untrusted candidate data in run-owned staging. The controller
# validates identity/schema/bounds, classifies runtime/currentness, and alone publishes immutable
# result.json plus an informational Markdown projection. Markdown is never an authority input.

if (-not (Get-Command -Name 'Resolve-ReviewResultClassification' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'review-authority-core.ps1') }
if (-not (Get-Command -Name 'Publish-ReviewRunResultFact' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'review-authority-store.ps1') }

function Get-ReviewRunStagingPaths {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$StagingRoot,
        [Parameter(Mandatory)][string]$CampaignId,
        [Parameter(Mandatory)][string]$RunId
    )
    if (-not (Test-ReviewAuthorityIdentifier -Value $CampaignId -Kind campaign)) { throw "review-staging-invalid-campaign-id:$CampaignId" }
    if (-not (Test-ReviewAuthorityIdentifier -Value $RunId -Kind run)) { throw "review-staging-invalid-run-id:$RunId" }
    $root = [IO.Path]::GetFullPath($StagingRoot)
    $runRoot = Join-Path $root "campaigns/$CampaignId/runs/$RunId/staging"
    return [pscustomobject]@{
        staging_path = $runRoot
        candidate_result_path = Join-Path $runRoot 'candidate.json'
        candidate_report_path = Join-Path $runRoot 'candidate.md'
        implementer_evidence_path = Join-Path $runRoot 'implementer-evidence.json'
    }
}

function Initialize-ReviewRunStaging {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$StagingRoot,
        [Parameter(Mandatory)][string]$CampaignId,
        [Parameter(Mandatory)][string]$RunId
    )
    $paths = Get-ReviewRunStagingPaths -StagingRoot $StagingRoot -CampaignId $CampaignId -RunId $RunId
    [IO.Directory]::CreateDirectory($paths.staging_path) | Out-Null
    return $paths
}

function Read-ReviewCandidateResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][string]$ExpectedRunId,
        [Parameter(Mandatory)][string]$ExpectedTargetDigest,
        [int]$MaxBytes = $script:ReviewAuthorityCandidateLimits.max_candidate_bytes
    )
    if (-not [IO.File]::Exists($Path)) { return [pscustomobject]@{ present = $false; valid = $false; category = 'candidate-missing'; errors = @('candidate-missing'); candidate = $null } }
    $stream = [IO.FileStream]::new($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
    try {
        if ($stream.Length -gt $MaxBytes) { return [pscustomobject]@{ present = $true; valid = $false; category = 'payload-too-large'; errors = @("payload-too-large:$MaxBytes"); candidate = $null } }
        $reader = [IO.StreamReader]::new($stream, [Text.UTF8Encoding]::new($false, $true), $false, 4096, $true)
        try {
            try { $json = $reader.ReadToEnd() }
            catch [Text.DecoderFallbackException] {
                return [pscustomobject]@{ present = $true; valid = $false; category = 'invalid-utf8'; errors = @('invalid-utf8'); candidate = $null }
            }
        }
        finally { $reader.Dispose() }
    }
    finally { $stream.Dispose() }
    $validation = Test-ReviewAuthorityContractJson -ContractName ReviewerCandidate -Json $json -MaxBytes $MaxBytes -ExpectedRunId $ExpectedRunId -ExpectedTargetDigest $ExpectedTargetDigest
    if (-not $validation.valid) { return [pscustomobject]@{ present = $true; valid = $false; category = $validation.category; errors = $validation.errors; candidate = $null } }
    $candidate = $json.Trim() | ConvertFrom-Json -Depth 20 -ErrorAction Stop
    return [pscustomobject]@{ present = $true; valid = $true; category = 'valid'; errors = @(); candidate = $candidate }
}

function ConvertTo-ReviewReportText {
    param([AllowNull()]$Value, [int]$MaxLength = 4000)
    $text = if ($null -eq $Value) { '' } else { [string]$Value }
    $text = $text -replace "`r?`n", ' '
    $text = $text.Replace('|', '\|')
    if ($text.Length -gt $MaxLength) { return $text.Substring(0, $MaxLength) + '…' }
    return $text
}

function ConvertTo-ReviewRunReportMarkdown {
    param([Parameter(Mandatory)]$Result)
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add('# Review Result') | Out-Null; $lines.Add('') | Out-Null
    foreach ($pair in @(
        @('Campaign', $Result.campaign_id), @('Run', $Result.run_id), @('Harness', $Result.harness_id),
        @('Target digest', $Result.target_digest), @('Completion', $Result.completion), @('Verdict', $Result.verdict),
        @('Runtime outcome', $Result.runtime_outcome), @('Currentness', $Result.currentness),
        @('Can approve current snapshot', ([string]$Result.can_approve_current).ToLowerInvariant())
    )) { $lines.Add(('- **{0}**: `{1}`' -f $pair[0], (ConvertTo-ReviewReportText -Value $pair[1] -MaxLength 256))) | Out-Null }
    $lines.Add('') | Out-Null
    $lines.Add('## Summary') | Out-Null; $lines.Add('') | Out-Null
    $lines.Add((ConvertTo-ReviewReportText -Value $Result.summary)) | Out-Null
    if (-not [string]::IsNullOrWhiteSpace([string]$Result.failure_reason)) {
        $lines.Add('') | Out-Null; $lines.Add(('Failure reason: {0}' -f (ConvertTo-ReviewReportText -Value $Result.failure_reason -MaxLength 2000))) | Out-Null
    }
    $lines.Add('') | Out-Null; $lines.Add('## Findings') | Out-Null; $lines.Add('') | Out-Null
    if (@($Result.findings).Count -eq 0) { $lines.Add('No validated findings were published.') | Out-Null }
    else {
        $lines.Add('| ID | Severity | Relevance | Location | Finding |') | Out-Null
        $lines.Add('| --- | --- | --- | --- | --- |') | Out-Null
        foreach ($finding in @($Result.findings)) {
            $findingText = '{0}: {1}' -f (ConvertTo-ReviewReportText -Value $finding.title -MaxLength 200), (ConvertTo-ReviewReportText -Value $finding.description -MaxLength 1000)
            $lines.Add(('| `{0}` | {1} | {2} | {3} | {4} |' -f $finding.finding_id, $finding.severity, $finding.relevance, (ConvertTo-ReviewReportText -Value $finding.location -MaxLength 500), $findingText)) | Out-Null
        }
    }
    $lines.Add('') | Out-Null
    $lines.Add('_This Markdown is a controller-generated projection. Authority is the sibling immutable `result.json`._') | Out-Null
    return ($lines -join "`n")
}

function Write-ReviewRunReportCreateNew {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$StoreRoot,
        [Parameter(Mandatory)][string]$CampaignId,
        [Parameter(Mandatory)][string]$RunId,
        [Parameter(Mandatory)][string]$Content
    )
    $relative = (Get-ReviewAuthorityCampaignRelativeRoot -CampaignId $CampaignId) + "/runs/$RunId/report.md"
    $path = Get-ReviewAuthorityStorePath -StoreRoot $StoreRoot -RelativePath $relative
    [IO.Directory]::CreateDirectory((Split-Path -Parent $path)) | Out-Null
    $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Content)
    try {
        $stream = [IO.FileStream]::new($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [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 [IO.IOException] {
        if (-not [IO.File]::Exists($path)) { throw }
        $existing = [IO.File]::ReadAllText($path, [Text.UTF8Encoding]::new($false))
        if ($existing -ceq $Content) { return [pscustomobject]@{ created = $false; idempotent = $true; path = $path } }
        throw "review-store-corruption:conflicting-report-projection:$relative"
    }
}

function Invoke-ReviewResultIngress {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$StoreRoot,
        [Parameter(Mandatory)][string]$StagingRoot,
        [Parameter(Mandatory)][string]$CampaignId,
        [Parameter(Mandatory)][string]$RunId,
        [Parameter(Mandatory)][string]$TargetDigest,
        [Parameter(Mandatory)][string]$HarnessId,
        [Parameter(Mandatory)][ValidateSet('completed', 'preflight-failed', 'claim-contended', 'launch-failed', 'timed-out', 'terminated', 'invalid-output', 'identity-mismatch', 'containment-violated', 'abandoned')][string]$RuntimeOutcome,
        [Parameter(Mandatory)][bool]$Invoked,
        [Parameter(Mandatory)][bool]$TerminationVerified,
        [Parameter(Mandatory)][ValidateSet('verified', 'violated', 'unknown')][string]$Containment,
        [Parameter(Mandatory)][ValidateSet('current', 'snapshot-moved', 'unknown')][string]$Currentness,
        [Parameter(Mandatory)][string]$StartedAt,
        [Parameter(Mandatory)][string]$EndedAt,
        [Parameter(Mandatory)][long]$DurationMs,
        [string]$FailureReason,
        [string]$ControllerDegradeReason,
        [object[]]$PriorFindings = @()
    )
    $paths = Get-ReviewRunStagingPaths -StagingRoot $StagingRoot -CampaignId $CampaignId -RunId $RunId
    $candidateLimits = Get-ReviewAuthorityCandidateLimits
    $candidateRead = Read-ReviewCandidateResult -Path $paths.candidate_result_path -ExpectedRunId $RunId -ExpectedTargetDigest $TargetDigest -MaxBytes $candidateLimits.max_candidate_bytes
    $effectiveOutcome = $RuntimeOutcome
    if ($RuntimeOutcome -ceq 'completed' -and -not $candidateRead.valid) {
        $effectiveOutcome = if ($candidateRead.category -ceq 'identity-mismatch') { 'identity-mismatch' } else { 'invalid-output' }
    }
    $classification = Resolve-ReviewResultClassification -RuntimeOutcome $effectiveOutcome -Invoked $Invoked -TerminationVerified $TerminationVerified -Containment $Containment -Currentness $Currentness -Candidate $candidateRead.candidate -CandidateValid ([bool]$candidateRead.valid)
    if (-not $classification.publish_permitted) {
        return [pscustomobject]@{ published = $false; reason = $classification.reason; result_path = $null; report_path = $null; candidate_category = $candidateRead.category }
    }

    $candidateFindings = @()
    if ($candidateRead.valid) { $candidateFindings = @($candidateRead.candidate.findings | Where-Object { $null -ne $_ }) }
    $links = if ($candidateFindings.Count -gt 0) { @(Resolve-ReviewFindingLineage -RunId $RunId -CurrentFindings $candidateFindings -PriorFindings $PriorFindings) } else { @() }
    $terminalFindings = [System.Collections.Generic.List[object]]::new()
    for ($i = 0; $i -lt $candidateFindings.Count; $i++) {
        $candidateFinding = $candidateFindings[$i]; $link = $links[$i]
        $terminalFindings.Add([pscustomobject][ordered]@{
            finding_id = [string]$link.finding_id; source_local_id = [string]$candidateFinding.local_id; lineage_id = [string]$link.lineage_id
            severity = [string]$candidateFinding.severity; title = [string]$candidateFinding.title; description = [string]$candidateFinding.description
            location = $(if ($candidateFinding.PSObject.Properties.Name -contains 'location') { $candidateFinding.location } else { $null })
            relevance = $Currentness; resolution = 'open'
        }) | Out-Null
    }
    $validationState = if ($candidateRead.valid) { 'valid' } elseif (-not $candidateRead.present) { 'not-produced' } else { 'invalid' }
    $derivedFailure = if (-not [string]::IsNullOrWhiteSpace($FailureReason)) { $FailureReason }
    elseif ($candidateRead.valid -and $classification.reason -ceq 'complete-result') { $null }
    elseif (-not $candidateRead.valid) { '{0}: {1}' -f $candidateRead.category, ($candidateRead.errors -join ',') }
    else { [string]$classification.reason }
    $derivedFailure = ConvertTo-ReviewAuthorityBoundedText -Value $derivedFailure -MaximumLength 2000
    $summary = if ($candidateRead.valid) { [string]$candidateRead.candidate.summary } else { [string]$classification.reason }
    $completion = [string]$classification.completion
    $verdict = [string]$classification.verdict
    $canApproveCurrent = [bool]$classification.can_approve_current
    if (-not [string]::IsNullOrWhiteSpace($ControllerDegradeReason)) {
        # Controller-known missing evidence outranks a reviewer's optimistic candidate. Preserve
        # validated findings, but never let a design-blind run become approval authority.
        $completion = 'partial'; $verdict = 'incomplete'; $canApproveCurrent = $false
        $combinedFailure = if ([string]::IsNullOrWhiteSpace($derivedFailure)) { $ControllerDegradeReason } else { "$ControllerDegradeReason $derivedFailure" }
        $derivedFailure = ConvertTo-ReviewAuthorityBoundedText -Value $combinedFailure -MaximumLength 2000
    }
    $result = [pscustomobject][ordered]@{
        schema_version = '1.0'; campaign_id = $CampaignId; run_id = $RunId; target_digest = $TargetDigest; harness_id = $HarnessId
        completion = $completion; verdict = $verdict; runtime_outcome = $effectiveOutcome
        termination_verified = $TerminationVerified; containment = $Containment; currentness = $Currentness; validation = $validationState
        can_approve_current = $canApproveCurrent; failure_reason = $derivedFailure; summary = $summary; findings = @($terminalFindings)
        started_at = $StartedAt; ended_at = $EndedAt; duration_ms = $DurationMs
    }
    $published = Publish-ReviewRunResultFact -StoreRoot $StoreRoot -CampaignId $CampaignId -RunId $RunId -Fact $result
    $report = Write-ReviewRunReportCreateNew -StoreRoot $StoreRoot -CampaignId $CampaignId -RunId $RunId -Content (ConvertTo-ReviewRunReportMarkdown -Result $result)
    return [pscustomobject]@{
        published = $true; reason = $(if ($published.created) { 'terminal-result-published' } else { 'terminal-result-idempotent' })
        result = $result; result_path = $published.path; report_path = $report.path; candidate_category = $candidateRead.category
    }
}