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

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

# T073 / FR-025 / SC-019 / SC-020: the boundary-sync wiring for the deterministic
# co-review signoff gate floor.
#
# The DECISION logic lives in review-signoff-evidence-gate.ps1 (loaded via _load.ps1). This
# file is the thin, testable SEAM that Invoke-SpecrewBoundaryStateSync calls so the 1500-line
# sync function stays free of gate logic:
#
# 1. Get-ContinuousCoReviewGateEnforcementEnabled - returns TRUE by default. The old opt-in
# scalar made the review-signoff backstop inert in exactly the host-switch/compaction case
# it is supposed to catch. A present co_review_gate_enforcement key is now informational:
# false/off/disabled no longer bypass review-signoff.
#
# 2. Invoke-ContinuousCoReviewSignoffGateIfEnabled - the boundary-sync entry point. It is a
# no-op for every boundary except review-signoff. At review-signoff it dot-sources the
# gate module, writes durable gate-decision evidence, and throws on any `block` decision.
# Only a MISSING-gate-infrastructure failure (the _load.ps1 dot-source itself failing) is
# re-wrapped as a clear error; a real block-refusal is NEVER disguised as infrastructure.
#
# This function emits NOTHING on the allow path. Invoke-SpecrewBoundaryStateSync returns a
# single result object that a downstream dispatcher serializes; a stray emission here would make
# that result a 2-element array and silently suppress the pending-verdict stop.

function Get-ContinuousCoReviewGateEnforcementEnabled {
    <#
    .SYNOPSIS
        Return $true for the review-signoff hard gate. The old opt-in/off switch is retained
        only as a parsed compatibility surface and does not bypass review-signoff.
    #>

    param([Parameter(Mandatory = $true)][string]$ProjectRoot)

    $configPath = Join-Path $ProjectRoot '.specrew/config.yml'
    if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) {
        return $true
    }

    # Value grammar matches the sibling specrew_version reader in sync-boundary-state.ps1: strip an
    # optional single OR double quote, stop the value at a '#', and tolerate a trailing inline comment.
    # (The earlier `"?...[^"#]...` form silently dropped `true # comment` and `'true'` -> a governance
    # gate the operator believed was ON would stay OFF. 145 F1.)
    foreach ($line in Get-Content -LiteralPath $configPath -Encoding UTF8) {
        if ($line -match '^\s*co_review_gate_enforcement:\s*[''"]?(?<value>[^''"#]+?)[''"]?\s*(?:#.*)?$') {
            return $true
        }
    }

    return $true
}

function Write-ContinuousCoReviewSignoffGateDecisionEvidence {
    param(
        [Parameter(Mandatory = $true)][string]$ProjectRoot,
        [Parameter(Mandatory = $true)][string]$BoundaryType,
        [Parameter(Mandatory = $true)]$Decision
    )

    $root = Join-Path $ProjectRoot '.specrew/review/signoff-gate'
    $historyRoot = Join-Path $root 'history'
    New-Item -ItemType Directory -Path $historyRoot -Force | Out-Null

    $recordedAt = [datetime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
    $record = [pscustomobject][ordered]@{
        schema_version = '1.0'
        boundary_type  = $BoundaryType
        recorded_at    = $recordedAt
        decision       = $Decision
    }
    $json = $record | ConvertTo-Json -Depth 100
    $latestPath = Join-Path $root 'latest.json'
    $historyPath = Join-Path $historyRoot ('{0}-{1}.json' -f ($recordedAt -replace '[:-]', ''), ([guid]::NewGuid().ToString('N').Substring(0, 8)))

    foreach ($path in @($latestPath, $historyPath)) {
        if (Get-Command -Name 'Write-SpecrewFileAtomic' -ErrorAction SilentlyContinue) {
            Write-SpecrewFileAtomic -Path $path -Content $json
        }
        else {
            [System.IO.File]::WriteAllText($path, $json, [System.Text.UTF8Encoding]::new($false))
        }
    }
}

# Get-ContinuousCoReviewTrunkName retired 2026-07-13: the shared co-review-trunk-resolver.ps1 now owns trunk
# resolution end-to-end. The config `co_review_trunk` is precedence level 1 there (Get-ContinuousCoReviewConfiguredTrunk),
# and the gate auto-detects the rest (origin/HEAD, upstream, conventional refs, single pre-feature branch) instead
# of defaulting to 'main'. The wiring below no longer pre-reads a trunk; it lets the gate resolve.

function Invoke-ContinuousCoReviewSignoffGateIfEnabled {
    <#
    .SYNOPSIS
        At the review-signoff boundary, refuse signoff unless the current reviewed-state
        matches fresh, anchor-covered co-review evidence (FR-025). A no-op for any
        other boundary.
    .DESCRIPTION
        Lets the gate's block-refusal throw propagate (fail-closed). Wraps ONLY the gate-module
        dot-source failure in a clear infrastructure error so a missing gate is never silently
        swallowed AND a real refusal is never mislabeled as infrastructure.
    #>

    param(
        [Parameter(Mandatory = $true)][string]$ProjectRoot,
        [Parameter(Mandatory = $true)][string]$BoundaryType
    )

    if ($BoundaryType -ne 'review-signoff') {
        return
    }

    if (-not (Get-ContinuousCoReviewGateEnforcementEnabled -ProjectRoot $ProjectRoot)) {
        return
    }

    # Lazy-load the decision module (and its deps) only when the gate will actually fire.
    # A dot-source failure here is genuine missing infrastructure, not a co-review verdict.
    try {
        $gateLoaderPath = Join-Path $PSScriptRoot '_load.ps1'
        if (-not (Test-Path -LiteralPath $gateLoaderPath -PathType Leaf)) {
            throw "continuous-co-review gate loader not found at '$gateLoaderPath'."
        }
        . $gateLoaderPath
        $humanAuthorityPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'bootstrap/HumanAuthorityStore.ps1'
        if (-not (Test-Path -LiteralPath $humanAuthorityPath -PathType Leaf)) {
            throw "human authority store not found at '$humanAuthorityPath'."
        }
        . $humanAuthorityPath
    }
    catch {
        throw ("[continuous-co-review-gate] review-signoff cannot be evaluated because the gate infrastructure failed to load: {0}" -f $_.Exception.Message)
    }

    # The throw on a `block` decision propagates verbatim (fail-closed). The trunk is resolved by the shared
    # resolver INSIDE the gate (config co_review_trunk -> origin/HEAD -> upstream -> conventional refs -> single
    # pre-feature branch, else fail-closed with a config instruction), so a non-`main`-trunk repo no longer fails
    # closed and there is no 'main' default to pre-read here. The decision is persisted before either throw/allow
    # so a failed signoff leaves inspectable evidence.
    $decision = Get-ContinuousCoReviewSignoffGateDecision -RepoRoot $ProjectRoot
    if ($decision.decision -eq 'block') {
        $treeId = [string]$decision.current_tree_id
        $campaignId = if ($decision.PSObject.Properties['campaign_id']) { [string]$decision.campaign_id } else { '' }
        if (-not [string]::IsNullOrWhiteSpace($treeId) -and -not [string]::IsNullOrWhiteSpace($campaignId)) {
            # SPECREW-AUTHORITY-CONSUMER: partial-review-signoff
            $capturedOverride = Get-SpecrewReviewSignoffOverrideAuthorization -ProjectRoot $ProjectRoot `
                -ExpectedTargetTreeId $treeId -ExpectedCampaignId $campaignId
            # W77: THE ACCEPTANCE CARRIES ACROSS THE COMMITS THIS GATE'S OWN PREFLIGHT DEMANDED.
            #
            # The lookup above is exact-tree, so an acceptance typed at the landing is orphaned the
            # moment the sync's preflight requires the lint commits and the owed review.md - the gate
            # then refuses a movement it caused, and asks the human to authorize the same gap twice.
            # Carried only across a RECORDS-ONLY delta: no source moved, so the coverage gap the human
            # accepted is the same gap. A source delta still refuses, correctly.
            if ($null -eq $capturedOverride -and (Get-Command Get-SpecrewCarriedSignoffOverrideAuthorization -ErrorAction SilentlyContinue)) {
                $featureRef = if ($decision.PSObject.Properties['feature_id']) { [string]$decision.feature_id } else { '' }
                try {
                    $capturedOverride = Get-SpecrewCarriedSignoffOverrideAuthorization -ProjectRoot $ProjectRoot `
                        -CurrentTreeId $treeId -CampaignId $campaignId -FeatureId $featureRef
                }
                catch { $capturedOverride = $null }
            }
            if ($null -ne $capturedOverride) {
                $decision = Get-ContinuousCoReviewSignoffGateDecision -RepoRoot $ProjectRoot -OverrideAuthorization $capturedOverride
            }
        }
    }
    if ($decision.decision -eq 'block') {
        $treeId = [string]$decision.current_tree_id
        $campaignId = if ($decision.PSObject.Properties['campaign_id']) { [string]$decision.campaign_id } else { '' }
        if ($treeId -cmatch '^[a-f0-9]{40,64}$' -and $campaignId -cmatch '^cmp-[a-z0-9][a-z0-9-]{1,190}$') {
            $request = Write-SpecrewReviewSignoffOverrideRequest -ProjectRoot $ProjectRoot -TargetTreeId $treeId `
                -CampaignId $campaignId -RunId ([string]$decision.matched_run_id)
            $decision | Add-Member -NotePropertyName override_request -NotePropertyValue $request -Force
            # THE MESSAGE MUST NAME THE SEQUENCE, NOT ONLY THE PHRASE.
            #
            # Measured, 2026-08-30 (DRIFT-199-I002-033/-034): a maintainer typed a valid approval three
            # times and signoff refused three times. Not once was the phrase wrong. The approval binds to
            # the reviewed-state digest, and ANY commit under `specs/**` between the request being written
            # and the retry moves that digest - so writing the very records a signoff needs invalidates the
            # approval that permits it. The corrected operator sequence needs three clauses, one of which
            # ("re-run the gate first, to refresh the pending request") no user would guess and this message
            # did not teach. `latest-result-not-current` named the symptom and left no reachable action.
            #
            # The fourth clause is newer and cost a fourth attempt: the capture reads the rationale as
            # EVERYTHING from the dash to the end of the message and rejects it over 2000 characters, so an
            # approval followed by any further discussion in the same message is discarded in silence.
            # Until that is fixed at its source, the message has to say it.
            $decision.message = [string]$decision.message +
                ' If you intentionally accept partial coverage for this exact tree, do these three things in order, with NO commits anywhere between them:' +
                ' (1) run this same command once, which refreshes the pending request against your files as they are right now - an approval binds to a specific state, and a request from before your last commit is already stale;' +
                ' (2) type, as a normal chat message and as the WHOLE message with nothing after it: approved for partial review signoff - <why accepting it is safe>;' +
                ' (3) run this same command again immediately.' +
                ' Writing records - a drift entry, a plan note - between (1) and (3) moves the state your approval was bound to and it will refuse again, which is not a rejection of your reasoning.' +
                ' Specrew captures that typed reply before the next attempt; command-line identity fields are not authority.'
        }
    }
    Write-ContinuousCoReviewSignoffGateDecisionEvidence -ProjectRoot $ProjectRoot -BoundaryType $BoundaryType -Decision $decision
    if ($decision.decision -eq 'block') {
        throw "[continuous-co-review-gate] review-signoff refused ($($decision.reason)): $($decision.message)"
    }
}