scripts/internal/continuous-co-review/review-signoff-evidence-gate.ps1

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

# T067 / FR-025: the deterministic co-review gate-floor decision (re-architected).
#
# "You cannot sign off on un-reviewed state." The first model (diff_hash recomputed from an
# operator-chosen baseline) was found unsound by the feature's own dogfooded co-reviews:
# HOLE A (gitignored source invisible) and HOLE B (the operator baseline was never verified
# as reviewed). The sound model:
# 1. FRESHNESS - the CURRENT reviewed-state tree-id (content-addressed; includes tracked,
# untracked, and gitignored source minus secrets) must equal a passing run's recorded
# reviewed_tree_id. (Closes HOLE A + the untracked/empty/diff-parsing nits.)
# 2. COVERAGE - that run's chain must reach the merge-base-with-trunk anchor with no gap,
# so everything the feature added on top of shipped trunk was reviewed. (Closes HOLE B.)
# 3. FAIL-CLOSED on every git/digest failure; an empty reviewed state never counts as fresh.
# 4. The only escape is a human-authorized, RECORDED partial-coverage override - never silent.
#
# This is the DECISION logic only. Wiring it into Invoke-SpecrewBoundaryStateSync as a
# throw-to-refuse gate stays deferred until the F-185 host-neutral gate-enforcement branch
# merges; Assert-ContinuousCoReviewSignoffGate is the thin throw-wrapper that wiring will call.

function New-ContinuousCoReviewSignoffGateDecision {
    param(
        [Parameter(Mandatory)]
        [ValidateSet('allow', 'block')]
        [string] $Decision,

        [Parameter(Mandatory)]
        [string] $Reason,

        [Parameter(Mandatory)]
        [string] $Message,

        [AllowNull()] [string] $CurrentTreeId,
        [AllowNull()] [string] $MatchedRunId,
        [AllowNull()] [string] $AnchorRef,
        [AllowNull()] $OverrideAuthorization,
        [AllowNull()] $EvidenceLabels,
        [AllowNull()] $Acknowledgement
    )

    return [pscustomobject][ordered]@{
        schema_version = '1.0'
        decision       = $Decision
        reason         = $Reason
        message        = $Message
        current_tree_id = $CurrentTreeId
        matched_run_id = $MatchedRunId
        anchor_ref     = $AnchorRef
        override       = $OverrideAuthorization
        evidence_labels = $EvidenceLabels
        acknowledgement = $Acknowledgement
    }
}

function Get-ContinuousCoReviewRunEvidenceLabels {
    # T094/FR-036 (iter-009 D4): a run record's 3-dimension assurance labels with CONSERVATIVE
    # defaults for records that predate the labels: completeness 'full' (promotion always required an
    # affirmative full pass), independence 'unverified' (unprovable -> not independent, SEC-004),
    # budget 'normal' ('time-extended' is NOT reduced assurance either way).
    param([AllowNull()] $Run)
    $labels = [pscustomobject]@{ completeness = 'full'; independence = 'unverified'; budget = 'normal' }
    if ($null -eq $Run) { return $labels }
    $recorded = Get-ContinuousCoReviewRunIndexProperty -Object $Run -Name 'evidence_labels'
    if ($null -eq $recorded) { return $labels }
    foreach ($dim in @('completeness', 'independence', 'budget')) {
        $val = [string](Get-ContinuousCoReviewRunIndexProperty -Object $recorded -Name $dim)
        if (-not [string]::IsNullOrWhiteSpace($val)) { $labels.$dim = $val }
    }
    return $labels
}

function Test-ContinuousCoReviewEvidenceIsDegraded {
    # D4 tiers: full + independent (any budget) is FULL assurance; anything else (partial OR a
    # not-provably-independent reviewer) is DEGRADED and needs a recorded human ack.
    param([Parameter(Mandatory)] $Labels)
    return (([string]$Labels.completeness -ne 'full') -or ([string]$Labels.independence -ne 'independent'))
}

function Add-ContinuousCoReviewDegradedAck {
    <#
        T094/FR-036: record the FIRST-CLASS human acknowledgement of degraded review evidence, as a
        durable per-run artifact (.specrew/review/inline/<run-id>/degraded-ack.json) the gate reads.
        TRUST BOUNDARY (same as the override + review-run.json, see Test-...OverrideAuthorization):
        construct only from a genuinely human-authored action (the `specrew review --ack-degraded`
        command / a captured human verdict), never from agent-forgeable input.
    #>

    param(
        [Parameter(Mandatory)][string] $RepoRoot,
        [Parameter(Mandatory)][string] $RunId,
        [Parameter(Mandatory)][string] $AuthorizedBy,
        [Parameter(Mandatory)][string] $Rationale,
        [datetime] $Now = [datetime]::UtcNow
    )
    if ([string]::IsNullOrWhiteSpace($AuthorizedBy) -or [string]::IsNullOrWhiteSpace($Rationale)) {
        throw 'Add-ContinuousCoReviewDegradedAck: -AuthorizedBy and -Rationale are both required (an ack is never implicit).'
    }
    $dir = Join-Path (Resolve-Path -LiteralPath $RepoRoot).Path ".specrew/review/inline/$RunId"
    New-Item -ItemType Directory -Path $dir -Force | Out-Null
    $ack = [pscustomobject][ordered]@{
        schema_version = '1.0'
        run_id         = $RunId
        authorized_by  = $AuthorizedBy
        rationale      = $Rationale
        acknowledged_at = $Now.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
    }
    $path = Join-Path $dir 'degraded-ack.json'
    Set-Content -LiteralPath $path -Value ($ack | ConvertTo-Json -Depth 8) -Encoding UTF8 -NoNewline
    return $ack
}

function Get-ContinuousCoReviewDegradedAck {
    param([Parameter(Mandatory)][string] $RepoRoot, [Parameter(Mandatory)][string] $RunId)
    $path = Join-Path (Resolve-Path -LiteralPath $RepoRoot).Path ".specrew/review/inline/$RunId/degraded-ack.json"
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null }
    try { return (Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json) } catch { return $null }
}

function Test-ContinuousCoReviewOverrideAuthorization {
    # A well-formed override is an object carrying a non-empty authorized_by AND rationale.
    # Anything less is ignored (the gate proceeds normally) - an override is never implicit.
    #
    # F3/F4 (145 adversarial review) - TRUST BOUNDARY, bound to the deferred F-185 wiring:
    # this decision layer AUTHENTICATES nothing (it trusts the structural object) and
    # PERSISTS nothing. The wiring owner (the boundary-sync integration) MUST (1) construct
    # this object only from a genuinely human-authored authorization (e.g. the captured
    # verdict / Add-SpecrewBoundaryAuthorization), never from agent-forgeable input, and
    # (2) persist the returned decision (incl. the override) to durable gate-verdict evidence
    # so "RECORDED, never silent" holds. The same boundary applies to the review-run.json
    # records the chain walk trusts. A test MUST assert override persistence when wired.
    param([AllowNull()] $OverrideAuthorization)

    if ($null -eq $OverrideAuthorization) {
        return $false
    }

    $authorizedBy = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $OverrideAuthorization -Name 'authorized_by')
    $rationale = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $OverrideAuthorization -Name 'rationale')
    return (-not [string]::IsNullOrWhiteSpace($authorizedBy)) -and (-not [string]::IsNullOrWhiteSpace($rationale))
}

function Get-ContinuousCoReviewChainReachesAnchor {
    # Walk the chain from the digest-matched run back toward the anchor: each link is a
    # passing run whose reviewed_ref equals the current run's baseline_ref. The chain reaches
    # the anchor when a baseline is an ancestor-of-or-equal-to the anchor (so [anchor, HEAD]
    # is fully covered); a baseline that is neither the anchor-or-earlier nor a prior pass's
    # reviewed point is a GAP (un-reviewed span -> block).
    param(
        [Parameter(Mandatory)]
        [string] $RepoRoot,

        [Parameter(Mandatory)]
        [object[]] $PassingRuns,

        [Parameter(Mandatory)]
        $MatchedRun,

        [Parameter(Mandatory)]
        [string] $AnchorRef
    )

    $byReviewedRef = @{}
    foreach ($run in @($PassingRuns)) {
        $reviewedRef = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $run -Name 'reviewed_ref')
        if (-not [string]::IsNullOrWhiteSpace($reviewedRef) -and -not $byReviewedRef.ContainsKey($reviewedRef)) {
            $byReviewedRef[$reviewedRef] = $run
        }
    }

    $current = $MatchedRun
    $visited = New-Object System.Collections.Generic.HashSet[string]
    for ($i = 0; $i -lt 4096; $i++) {
        $baseline = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $current -Name 'baseline_ref')
        if ([string]::IsNullOrWhiteSpace($baseline)) {
            return [pscustomobject]@{ reached = $false; gap_at = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $current -Name 'run_id') }
        }

        if (Get-ContinuousCoReviewGitIsAncestor -RepoRoot $RepoRoot -Ancestor $baseline -Descendant $AnchorRef) {
            return [pscustomobject]@{ reached = $true; gap_at = $null }
        }

        $runId = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $current -Name 'run_id')
        if (-not $visited.Add($runId)) {
            return [pscustomobject]@{ reached = $false; gap_at = 'cycle' }
        }

        if (-not $byReviewedRef.ContainsKey($baseline)) {
            return [pscustomobject]@{ reached = $false; gap_at = $baseline }
        }

        $current = $byReviewedRef[$baseline]
    }

    return [pscustomobject]@{ reached = $false; gap_at = 'chain-too-long' }
}

function Get-ContinuousCoReviewSignoffGateDecision {
    param(
        [Parameter(Mandatory)]
        [string] $RepoRoot,

        [string] $TrunkName = 'main',

        [string[]] $ExcludedPathPatterns = @(),

        [AllowNull()] $OverrideAuthorization,

        # T094/FR-036: an explicit degraded-evidence acknowledgement (authorized_by + rationale).
        # When omitted, the persisted per-run ack (degraded-ack.json) is honoured instead.
        [AllowNull()] $DegradedAcknowledgement
    )

    $resolvedRepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path

    # A well-formed human-authorized recorded override short-circuits, with the authorization
    # captured in the decision evidence (auditable, never silent).
    if (Test-ContinuousCoReviewOverrideAuthorization -OverrideAuthorization $OverrideAuthorization) {
        return New-ContinuousCoReviewSignoffGateDecision -Decision 'allow' -Reason 'human-authorized-partial-override' -Message 'Signoff allowed under a recorded human-authorized partial-coverage override.' -OverrideAuthorization $OverrideAuthorization
    }

    # 1. Current reviewed-state digest (fail-closed on any digest/git failure).
    $digest = Get-ContinuousCoReviewReviewedStateDigest -RepoRoot $resolvedRepoRoot -ExcludedPathPatterns $ExcludedPathPatterns
    if (-not $digest.ok) {
        return New-ContinuousCoReviewSignoffGateDecision -Decision 'block' -Reason 'digest-unresolvable' -Message "The current reviewed-state digest could not be computed ($($digest.failure_reason)); treat as unsafe."
    }
    if ($digest.is_empty) {
        return New-ContinuousCoReviewSignoffGateDecision -Decision 'block' -Reason 'empty-reviewed-state' -Message 'The current reviewable working tree is empty; there is no reviewed content to sign off on.' -CurrentTreeId $digest.tree_id
    }

    # 2. Trusted anchor = merge-base with the trunk (fail-closed if it cannot be resolved).
    $anchor = Get-ContinuousCoReviewMergeBaseAnchor -RepoRoot $resolvedRepoRoot -TrunkName $TrunkName
    if ([string]::IsNullOrWhiteSpace($anchor)) {
        return New-ContinuousCoReviewSignoffGateDecision -Decision 'block' -Reason 'anchor-unresolvable' -Message "The trusted anchor (merge-base with '$TrunkName') could not be resolved; coverage cannot be verified." -CurrentTreeId $digest.tree_id
    }

    # 3. Lineage-valid passing runs.
    $passingRuns = @(Get-ContinuousCoReviewPassingReviewRuns -RepoRoot $resolvedRepoRoot -AncestorOfRef 'HEAD')
    if ($passingRuns.Count -eq 0) {
        return New-ContinuousCoReviewSignoffGateDecision -Decision 'block' -Reason 'no-co-review-evidence' -Message 'No passing or escalated co-review run on this lineage; the current state has not been co-reviewed.' -CurrentTreeId $digest.tree_id -AnchorRef $anchor
    }

    # 4. Freshness: a passing run whose recorded reviewed_tree_id equals the current digest.
    $matched = $null
    $emptyTreeId = Get-ContinuousCoReviewEmptyTreeId
    foreach ($run in $passingRuns) {
        $recordedTreeId = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $run -Name 'reviewed_tree_id')
        if ([string]::IsNullOrWhiteSpace($recordedTreeId) -or $recordedTreeId -eq $emptyTreeId) {
            continue
        }
        if ($recordedTreeId -eq $digest.tree_id) {
            $matched = $run
            break
        }
    }
    if ($null -eq $matched) {
        return New-ContinuousCoReviewSignoffGateDecision -Decision 'block' -Reason 'stale-co-review-evidence' -Message 'The current working tree does not match any passing co-review; re-run continuous co-review before signoff.' -CurrentTreeId $digest.tree_id -AnchorRef $anchor
    }

    # 5. Coverage: the matched run's chain must reach the anchor with no gap.
    $chain = Get-ContinuousCoReviewChainReachesAnchor -RepoRoot $resolvedRepoRoot -PassingRuns $passingRuns -MatchedRun $matched -AnchorRef $anchor
    if (-not $chain.reached) {
        return New-ContinuousCoReviewSignoffGateDecision -Decision 'block' -Reason 'coverage-gap' -Message "The reviewed chain does not reach the trunk anchor (gap at $($chain.gap_at)); some feature content was never co-reviewed." -CurrentTreeId $digest.tree_id -MatchedRunId ([string] (Get-ContinuousCoReviewRunIndexProperty -Object $matched -Name 'run_id')) -AnchorRef $anchor
    }

    # 6. T094/FR-036 (iter-009 D4) - the TIERED assurance decision on the matched evidence:
    # full + independent (any budget: 'time-extended' is NOT reduced assurance) -> auto-allow;
    # partial OR not-provably-independent -> allow ONLY with a recorded first-class human ack.
    # NEVER deadlocks: the worst case is the ack ask below, always satisfiable via
    # `specrew review --ack-degraded <run-id> --ack-reason "<why>"`.
    $matchedRunId = [string] (Get-ContinuousCoReviewRunIndexProperty -Object $matched -Name 'run_id')
    $labels = Get-ContinuousCoReviewRunEvidenceLabels -Run $matched
    if (-not (Test-ContinuousCoReviewEvidenceIsDegraded -Labels $labels)) {
        return New-ContinuousCoReviewSignoffGateDecision -Decision 'allow' -Reason 'fresh-and-covered' -Message 'The current reviewed-state matches a passing co-review whose chain covers the feature back to the trunk anchor.' -CurrentTreeId $digest.tree_id -MatchedRunId $matchedRunId -AnchorRef $anchor -EvidenceLabels $labels
    }

    $ack = $DegradedAcknowledgement
    if (-not (Test-ContinuousCoReviewOverrideAuthorization -OverrideAuthorization $ack)) {
        $ack = Get-ContinuousCoReviewDegradedAck -RepoRoot $resolvedRepoRoot -RunId $matchedRunId
    }
    if (Test-ContinuousCoReviewOverrideAuthorization -OverrideAuthorization $ack) {
        return New-ContinuousCoReviewSignoffGateDecision -Decision 'allow' -Reason 'degraded-evidence-acknowledged' -Message ("Signoff allowed on DEGRADED review evidence (completeness={0}, independence={1}, budget={2}) under a recorded human acknowledgement." -f $labels.completeness, $labels.independence, $labels.budget) -CurrentTreeId $digest.tree_id -MatchedRunId $matchedRunId -AnchorRef $anchor -EvidenceLabels $labels -Acknowledgement $ack
    }

    return New-ContinuousCoReviewSignoffGateDecision -Decision 'block' -Reason 'degraded-evidence-needs-ack' -Message ("The matching co-review evidence is DEGRADED (completeness={0}, independence={1}, budget={2}); signing off on it needs a recorded human acknowledgement: run ``specrew review --ack-degraded {3} --ack-reason `"<why this assurance level is acceptable>`"`` (or re-run a full independent review)." -f $labels.completeness, $labels.independence, $labels.budget, $matchedRunId) -CurrentTreeId $digest.tree_id -MatchedRunId $matchedRunId -AnchorRef $anchor -EvidenceLabels $labels
}

function Assert-ContinuousCoReviewSignoffGate {
    param(
        [Parameter(Mandatory)]
        [string] $RepoRoot,

        [string] $TrunkName = 'main',

        [string[]] $ExcludedPathPatterns = @(),

        [AllowNull()] $OverrideAuthorization,

        [AllowNull()] $DegradedAcknowledgement
    )

    $decision = Get-ContinuousCoReviewSignoffGateDecision -RepoRoot $RepoRoot -TrunkName $TrunkName -ExcludedPathPatterns $ExcludedPathPatterns -OverrideAuthorization $OverrideAuthorization -DegradedAcknowledgement $DegradedAcknowledgement
    if ($decision.decision -eq 'block') {
        throw "[continuous-co-review-gate] review-signoff refused ($($decision.reason)): $($decision.message)"
    }

    return $decision
}