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') }
# Round-19 (DRIFT-199-I001-130): declared-coverage containment asks the VOLUME about case, so the
# path-identity primitive must be loadable here. Guarded on the exact function used, not a sibling.
if (-not (Get-Command -Name 'Get-ContinuousCoReviewPathComparer' -ErrorAction SilentlyContinue)) { . (Join-Path $PSScriptRoot 'path-identity.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 Test-ReviewFindingStatesFailureScenario {
    # T005 / FR-006. A prompt is a REQUEST; a contract needs a REJECTION. The reviewer prompt demands an
    # explicit `Failure scenario:` clause on every finding, and THIS is where that demand either binds or
    # is decoration - if a scenario-less finding is accepted at its stated severity anyway, "every finding
    # states a concrete failure scenario or it is not a finding" changes nothing about what happens.
    #
    # Detection is deliberately the LITERAL clause, not a heuristic read of the prose. A contract is
    # explicit by construction; the generosity lives in the CONSEQUENCE (demote, never discard), not in
    # pretending to infer a failure scenario from wording. `-> ` alone is not enough - a clause header
    # with nothing after it is not a scenario either.
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Description)

    if ([string]::IsNullOrWhiteSpace($Description)) { return $false }
    $match = [regex]::Match($Description, '(?is)failure\s+scenario\s*:\s*(?<body>.+)$')
    if (-not $match.Success) { return $false }
    return (([string]$match.Groups['body'].Value).Trim().Length -ge 12)
}

function Resolve-ReviewFindingGatingEligibility {
    # T005 / FR-006 - DEMOTE, NEVER DISCARD (maintainer ruling 2026-08-10).
    #
    # The fail direction matters more than the rule: losing a real blocking finding is worse than
    # admitting a weak one. So a finding without a concrete failure scenario is not dropped and not
    # rejected - it lands BELOW THE GATING FLOOR as a `minor`, carried as a recorded follow-up exactly
    # like any other minor. That keeps the signal, removes its power to hold sign-off hostage, and is the
    # same shape as the existing "minors never gate" rule rather than a new mechanism beside it.
    #
    # It also attacks the gold-plating economics at the point where they bite: an observation with no
    # failure scenario can still be REPORTED, it just cannot cost the human a round.
    #
    # Only a GATING severity can be demoted. A finding that never gated is returned untouched - in
    # particular its description is NOT rewritten, because a demotion note on something that was never
    # demoted would be a lie in the record.
    [CmdletBinding()]
    param([AllowNull()][object[]]$Findings)

    $graded = [System.Collections.Generic.List[object]]::new()
    foreach ($finding in @($Findings)) {
        if ($null -eq $finding) { continue }
        $severity = ([string](Get-ReviewAuthorityProperty -Object $finding -Name 'severity')).Trim().ToLowerInvariant()
        $description = [string](Get-ReviewAuthorityProperty -Object $finding -Name 'description')
        $gates = ($severity -ceq 'blocking' -or $severity -ceq 'major')
        $demote = $gates -and -not (Test-ReviewFindingStatesFailureScenario -Description $description)

        $copy = [pscustomobject]@{}
        foreach ($property in $finding.PSObject.Properties) {
            $copy | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value -Force
        }
        if ($demote) {
            $copy | Add-Member -NotePropertyName 'severity' -NotePropertyValue 'minor' -Force
            # The demotion is recorded IN the finding rather than only in a counter, so a human reading
            # the follow-up list can see why this one is not gating and judge it for themselves. The
            # original severity is named, never silently erased.
            $note = ('[demoted to minor: no concrete failure scenario, so it cannot gate; reported as {0} by the reviewer] ' -f $severity)
            $copy | Add-Member -NotePropertyName 'description' -NotePropertyValue ($note + $description) -Force
        }
        $copy | Add-Member -NotePropertyName 'demoted' -NotePropertyValue $demote -Force
        # The reviewer's ORIGINAL severity as structured data, not only inside the note above. The
        # human-facing surface has to say "reported as blocking" rather than a vague "reported higher",
        # and the alternative is re-parsing our own prose out of the description - a string contract
        # between two files, which is the shape that drifts silently.
        $copy | Add-Member -NotePropertyName 'demoted_from' -NotePropertyValue $(if ($demote) { $severity } else { '' }) -Force
        $graded.Add($copy) | Out-Null
    }
    return @($graded)
}

# W33: A COMPLETE CODE REVIEW MUST HAVE EXAMINED CODE.
#
# Measured 2026-08-19/20 (KeyContextAI). Two runs recorded `pass`/`complete`/`current` with zero
# findings against a frozen target that held the whole implementation, and neither had read any of
# it. Their own summaries said so, in plain language: "the frozen iteration 001 plan" (57s) and
# "the frozen iteration artifacts" (67s), against 186s for the one run that walked the source. The
# reviewer was HONEST both times. The record held the truth and nothing consumed it, so a review of
# a planning document became the recorded independent review of an implementation, and - because a
# passing run becomes the baseline the next round advances from - every later round inherited it.
#
# The controller cannot know which files a reviewer opened, and a duration threshold is a guess. So
# the candidate DECLARES what it examined and the controller checks that declaration against the
# target it froze. This catches the honest-but-misframed reviewer, which is the case that actually
# occurred; it does not pretend to catch a lying one.
#
# FAIL-OPEN ON ABSENCE, deliberately. A deployed reviewer that never emits `examined_paths` behaves
# exactly as it does today. Fail-closed would wedge the signoff gate shut on every project already
# in flight, which is a worse failure than the one being fixed.
function Test-ReviewExaminedPathIsSource {
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Path)
    # A NEGATIVE rule on purpose: everything is source unless it is recognisably a record or a
    # document. An unknown extension counts as source, so a language nobody here anticipated is
    # never mistaken for paperwork and never triggers a degrade.
    # NOT TrimStart('./') - that trims those two CHARACTERS repeatedly, so '.specrew/config.yml'
    # arrives as 'specrew/config.yml' and a governance file classifies as source. Strip the one
    # leading './' and nothing else.
    $p = ([string]$Path).Trim().Replace('\', '/')
    while ($p.StartsWith('./')) { $p = $p.Substring(2) }
    if ([string]::IsNullOrWhiteSpace($p)) { return $false }
    if ($p -match '(?i)^(specs|docs)/') { return $false }
    # Round-16 finding (DRIFT-199-I001-126): .github/ was non-source WHOLESALE, so a commit
    # touching only .github/workflows/publish.yml read as records-only and signoff reused a review
    # of a tree that never held the executable change - while reviewed-state-digest already treated
    # workflows as reviewable, so the two consumers disagreed. The exemption now names the
    # GOVERNANCE RECORDS and host mirrors it was always about; everything else under .github,
    # workflows and composite actions included, is reviewable source.
    if ($p -match '(?i)^(?:\.specrew|\.squad|\.specify|\.agents|\.cursor|\.copilot|\.claude)/') { return $false }
    if ($p -match '(?i)^\.github/(?:skills|agents|prompts|instructions|chatmodes|ISSUE_TEMPLATE)/') { return $false }
    if ($p -match '(?i)^\.github/[^/]+\.md$') { return $false }
    if ($p -match '(?i)\.(md|markdown|txt|rst|adoc)$') { return $false }
    return $true
}

function Resolve-ReviewDeclaredCoverage {
    # Returns what the candidate SAYS it examined, split by whether it is source. `declared` is the
    # gate: when the reviewer said nothing, there is nothing to check and nothing is claimed.
    #
    # Round-11 blocking finding (DRIFT-199-I001-118): a PRESENT empty list is not "said nothing" -
    # it is an honest declaration of ZERO coverage, and mapping it to declared=false let a
    # complete/pass candidate that opened no files stay approval authority over a source-bearing
    # target. Only ABSENCE of the field keeps the legacy fail-open, for reviewers that predate the
    # declared-coverage contract.
    #
    # Round-17 finding (DRIFT-199-I001-127): A CLAIM ABOUT THE FROZEN TREE IS CHECKED AGAINST IT.
    # Nothing ever resolved a declared path: `examined_paths: ["src/nonexistent.cs"]` classified as
    # source BY NAME, skipped the no-source degrade, and let a complete/pass result authorize a
    # snapshot whose code was never opened - the hollow review W33 exists to catch, reachable by
    # naming a file that does not exist. With -TargetRoot supplied, a path counts as source only when
    # it resolves to a file INSIDE that root; without it the classifier keeps its by-name behavior,
    # the same fail-open default W33's -TargetHasSource uses for callers that cannot supply it.
    param([object]$Candidate, [AllowNull()][string]$TargetRoot)
    $empty = [pscustomobject]@{ declared = $false; paths = @(); source_paths = @() }
    if ($null -eq $Candidate) { return $empty }
    if (-not ($Candidate.PSObject.Properties.Name -contains 'examined_paths')) { return $empty }
    $paths = @(@($Candidate.examined_paths) |
            ForEach-Object { [string]$_ } |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    $rootFull = ''
    if (-not [string]::IsNullOrWhiteSpace($TargetRoot)) {
        try { $rootFull = [IO.Path]::GetFullPath($TargetRoot).TrimEnd([char]92, [char]47) } catch { $rootFull = '' }
    }
    $sourcePaths = @($paths | Where-Object {
            if (-not (Test-ReviewExaminedPathIsSource -Path $_)) { return $false }
            if ([string]::IsNullOrWhiteSpace($rootFull)) { return $true }
            $rel = ([string]$_).Trim().Replace([char]92, [char]47)
            while ($rel.StartsWith('./')) { $rel = $rel.Substring(2) }
            if ([string]::IsNullOrWhiteSpace($rel)) { return $false }
            # Round-19 finding (DRIFT-199-I001-130): REJECT, do not normalize. A declared path must
            # be genuinely repo-relative and traversal-free - `..` anywhere, a rooted path, or a
            # drive qualifier is not a claim about the frozen tree, whatever it resolves to on this
            # disk. Checked BEFORE resolution so the containment test is never the only guard.
            if (@($rel -split '/') -contains '..') { return $false }
            if ($rel.StartsWith('/') -or $rel -match '^[A-Za-z]:') { return $false }
            $full = ''
            try { $full = [IO.Path]::GetFullPath((Join-Path $rootFull $rel)) } catch { return $false }
            # Containment on the VOLUME's own case rule, never a hard-coded one: a case-sensitive
            # volume can hold a sibling root differing only in case, and folding case there let a
            # path outside the frozen tree pass as coverage of it. 'distinct' when undetermined
            # keeps the two roots apart rather than merging them.
            $prefix = $rootFull + [IO.Path]::DirectorySeparatorChar
            $containmentComparison = [StringComparison]::Ordinal
            if (Get-Command -Name 'Get-ContinuousCoReviewPathComparer' -ErrorAction SilentlyContinue) {
                $volumeComparer = Get-ContinuousCoReviewPathComparer -Path $rootFull -WhenUndetermined 'distinct'
                if ($volumeComparer.Equals(($rootFull.ToUpperInvariant()), ($rootFull.ToLowerInvariant()))) {
                    $containmentComparison = [StringComparison]::OrdinalIgnoreCase
                }
            }
            if (-not $full.StartsWith($prefix, $containmentComparison)) { return $false }
            return [IO.File]::Exists($full)
        })
    return [pscustomobject]@{
        declared     = $true
        paths        = @($paths)
        source_paths = @($sourcePaths)
    }
}

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,
        # W33. Whether the tree the controller FROZE contains source at all. Supplied by the caller
        # because only the orchestrator holds the snapshot. Default $false keeps every existing
        # caller - fixtures included - on exactly today's behaviour: with no claim that the target
        # held code, a declared docs-only review is not evidence of anything being missed.
        [bool]$TargetHasSource = $false,
        # Round-17 (DRIFT-199-I001-127): the frozen snapshot root, so a declared path can be RESOLVED
        # rather than trusted by name. Same supplier as TargetHasSource - only the orchestrator holds
        # the snapshot - and same fail-open default: absent means today's by-name classification.
        [AllowNull()][string]$TargetRoot,
        [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 $_ }) }
    # T005 / FR-006: the failure-scenario contract binds HERE, before lineage and before publication, so a
    # scenario-less finding is carried into the terminal result as a non-gating follow-up rather than
    # arriving at the human with the power to demand a round.
    if ($candidateFindings.Count -gt 0) { $candidateFindings = @(Resolve-ReviewFindingGatingEligibility -Findings $candidateFindings) }
    $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'
            # T005 / FR-006, maintainer ruling 2026-08-10. These two marks are carried into the TERMINAL
            # result rather than being left on the in-memory graded copy. A demotion the human cannot see
            # is a SILENCING - the reviewer meant this to gate, and without the marks it arrives in the
            # follow-up list indistinguishable from a typo. The projection below is an explicit field
            # list, so anything not named here is dropped: that is precisely how the marks were lost.
            demoted = [bool]$candidateFinding.demoted
            demoted_from = [string]$candidateFinding.demoted_from
        }) | 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 }
    # W32: A FAILURE FIELD MUST NOT CARRY A SUCCESS TOKEN.
    #
    # `$classification.reason` is the classifier's verdict word, not a fault, so a completed run whose
    # candidate merely declared partial coverage landed `failure_reason: "completed"` in the immutable
    # store. Read back later that is worse than empty: it invites "the run failed with 'completed'".
    # Partiality is already carried honestly by `completion` and `verdict`; this field is for faults.
    if (-not [string]::IsNullOrWhiteSpace($derivedFailure) -and
        ([string]$derivedFailure -ceq [string]$effectiveOutcome -or [string]$derivedFailure -ceq 'completed')) {
        $derivedFailure = $null
    }
    $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

    # W33. A candidate that declares its coverage and declares no source, against a target the
    # controller knows holds source, has not reviewed the code - whatever its verdict says. Routed
    # through the SAME degrade the design-context rule uses, so it cannot approve the current target
    # and cannot become the baseline a later round advances from.
    $coverage = Resolve-ReviewDeclaredCoverage -Candidate $candidateRead.candidate -TargetRoot $TargetRoot
    if ($candidateRead.valid -and $TargetHasSource -and $coverage.declared -and
        @($coverage.source_paths).Count -eq 0) {
        $coverageDegrade = if (@($coverage.paths).Count -eq 0) {
            # The empty declaration reads differently from a docs-only one: nothing was opened at all.
            'REVIEW_EXAMINED_NO_SOURCE: the review declares it examined no files at all, while the frozen target contains source; this run is partial evidence about the code and cannot approve the current target.'
        }
        else {
            $examinedNote = (@($coverage.paths) | Select-Object -First 5) -join ', '
            ('REVIEW_EXAMINED_NO_SOURCE: the review declares it examined only records or documents ({0}), while the frozen target contains source; this run is partial evidence about the code and cannot approve the current target.' -f $examinedNote)
        }
        $ControllerDegradeReason = if ([string]::IsNullOrWhiteSpace($ControllerDegradeReason)) { $coverageDegrade }
        else { "$ControllerDegradeReason $coverageDegrade" }
    }

    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
        # W33. The coverage the verdict rests on, carried into the terminal record. This
        # projection is an explicit field list - the same one that silently dropped the demotion
        # marks - so a field added upstream and not named here never reaches a single reader.
        examined_paths = @($coverage.paths)
    }
    $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
    }
}