Payload/scripts/kaden/harness/lib/HarnessGateFilePolicy.ps1

# Gate-file recorder-only policy and provenance stamps.
# Dot-sourced into HarnessContract.psm1 so Export-ModuleMember -Function * keeps these helpers exported.
# Do not Import-Module this file standalone without Contract helpers already loaded.

function Get-HarnessGateFileKind {
    <#
    .SYNOPSIS
        Classify recorder-owned gate paths: projection, journal ledger, or provenance stamp.
    #>

    param(
        [AllowNull()][string]$Path,
        [string]$RepoRoot = ''
    )
    $n = ConvertTo-HarnessNormalizedRepoPath -Path $Path -RepoRoot $RepoRoot
    if ([string]::IsNullOrWhiteSpace($n)) {
        return ''
    }
    $leaf = ($n -split '[\\/]' | Select-Object -Last 1)
    if ($leaf -eq '.review-loop.json') {
        return 'review-loop'
    }
    if ($n -match '(^|/)journal/events\.jsonl$') {
        return 'journal'
    }
    if ($n -match '(^|/)journal/gate-file-provenance\.json$') {
        return 'provenance'
    }
    return ''
}

function Test-HarnessGateFileDirectWritePath {
    <#
    .SYNOPSIS
        True when Path is a recorder-owned gate file anywhere in the tree (projection, journal, or provenance stamp).
    #>

    param(
        [AllowNull()][string]$Path,
        [string]$RepoRoot = ''
    )
    return -not [string]::IsNullOrWhiteSpace((Get-HarnessGateFileKind -Path $Path -RepoRoot $RepoRoot))
}

function Get-HarnessGateFileDirectWriteDenyReason {
    param(
        [ValidateSet('review-loop', 'journal', 'provenance', '')][string]$Kind = ''
    )
    if ($Kind -eq 'journal') {
        return 'journal/events.jsonl is append-only through harness scripts. Use pwsh -File scripts/kaden/harness-invoke.ps1 (harness-journal, harness-record-phase, turn-start/stop); do not edit the journal directly.'
    }
    if ($Kind -eq 'provenance') {
        return 'journal/gate-file-provenance.json is recorder-only. Harness scripts stamp it after legitimate writes; do not edit the provenance stamp directly.'
    }
    return '.review-loop.json is recorder-only. Use pwsh -File scripts/kaden/harness-invoke.ps1 (harness-record-phase, turn-start/stop, init-harness-feature); do not edit the projection directly.'
}

function Get-HarnessGateFileProvenancePath {
    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    return Join-HarnessPath -Root $FeatureRoot -Child 'journal/gate-file-provenance.json'
}

function Get-HarnessGateFileSha256 {
    param([Parameter(Mandatory = $true)][string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) {
        return ''
    }
    try {
        $hash = Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop
        if ($null -eq $hash -or [string]::IsNullOrWhiteSpace([string]$hash.Hash)) {
            return ''
        }
        return [string]$hash.Hash.ToLowerInvariant()
    } catch {
        return ''
    }
}

function Write-HarnessGateFileProvenance {
    <#
    .SYNOPSIS
        Stamp the sha256 provenance for one gate file (journal/events.jsonl or .review-loop.json).
    .DESCRIPTION
        The read-modify-write of gate-file-provenance.json runs inside ONE exclusive open
        (retry-acquired through Invoke-HarnessWithFileLockRetry, SEC-JAH-01): concurrent
        journal appends each stamp provenance, and a plain WriteAllText retry would turn the
        old loud IOException into a silent last-writer-wins lost update. The exclusive open
        serializes the whole read-modify-write so every stamp lands.
        Lock ordering is by design journal-append-lock -> provenance-lock (the journal
        append stamps provenance before releasing its own lock); no harness path takes them
        in the reverse order, so there is no deadlock cycle.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][ValidateSet('review-loop', 'journal')][string]$GateFile,
        [string]$Source = 'harness-script',
        [string]$Sha256 = '',
        [switch]$ForceRepair
    )
    $journalDir = Join-HarnessPath -Root $FeatureRoot -Child 'journal'
    if (-not (Test-Path -LiteralPath $journalDir)) {
        New-Item -ItemType Directory -Path $journalDir -Force | Out-Null
    }
    $provPath = Get-HarnessGateFileProvenancePath -FeatureRoot $FeatureRoot
    $targetPath = if ($GateFile -eq 'journal') {
        Join-HarnessPath -Root $FeatureRoot -Child 'journal/events.jsonl'
    } else {
        Join-HarnessPath -Root $FeatureRoot -Child '.review-loop.json'
    }
    $hash = if (-not [string]::IsNullOrWhiteSpace($Sha256)) {
        $Sha256.ToLowerInvariant()
    } else {
        Get-HarnessGateFileSha256 -Path $targetPath
    }
    $stampArgs = @($provPath, $GateFile, $hash, $Source, [bool]$ForceRepair)
    $null = Invoke-HarnessWithFileLockRetry -MaxAttempts 8 -DelayMilliseconds 100 -ArgumentList $stampArgs -ScriptBlock {
        param($ProvenancePath, $StampGateFile, $StampHash, $StampSource, $StampForceRepair)
        # Exclusive open spans the whole read-modify-write: the doc read below cannot lose
        # another writer's entry because that writer holds this same lock while writing.
        $provStream = [System.IO.File]::Open(
            $ProvenancePath,
            [System.IO.FileMode]::OpenOrCreate,
            [System.IO.FileAccess]::ReadWrite,
            [System.IO.FileShare]::None)
        try {
            $provStream.Position = 0
            $reader = New-Object System.IO.StreamReader($provStream, [System.Text.Encoding]::UTF8, $false, 4096, $true)
            $existingText = $reader.ReadToEnd()
            $doc = $null
            if (-not [string]::IsNullOrWhiteSpace($existingText)) {
                try {
                    $doc = $existingText | ConvertFrom-Json
                } catch {
                    if (-not $StampForceRepair) {
                        throw "gate-file provenance stamp is unreadable at '$ProvenancePath'. Pass -ForceRepair to reset after confirming no dual-write forgery, or rebuild via harness scripts."
                    }
                    Write-Warning "Resetting unreadable gate-file provenance stamp at '$ProvenancePath' (-ForceRepair)."
                    $doc = $null
                }
            }
            if ($null -eq $doc) {
                $doc = [pscustomobject]@{
                    schema = 'harness.gate-file-provenance/v1'
                    files  = [pscustomobject]@{}
                }
            }
            if (-not ($doc.PSObject.Properties.Name -contains 'files') -or $null -eq $doc.files) {
                $doc | Add-Member -NotePropertyName 'files' -NotePropertyValue ([pscustomobject]@{}) -Force
            }
            $entry = [pscustomobject]@{
                sha256     = $StampHash
                updated_at = (Get-HarnessIso8601UtcNow)
                source     = $StampSource
            }
            $doc.files | Add-Member -NotePropertyName $StampGateFile -NotePropertyValue $entry -Force
            $json = $doc | ConvertTo-Json -Depth 6
            $provStream.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null
            $provStream.SetLength(0)
            $bytes = [System.Text.Encoding]::UTF8.GetBytes($json + [Environment]::NewLine)
            $provStream.Write($bytes, 0, $bytes.Length)
            $provStream.Flush()
        } finally {
            $provStream.Dispose()
        }
    }
}

function Add-HarnessGateFileProvenanceMismatchFinding {
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[object]]$Findings,
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)]$Files,
        [Parameter(Mandatory = $true)][ValidateSet('review-loop', 'journal')][string]$GateFile,
        [Parameter(Mandatory = $true)][string]$RelativePath,
        [Parameter(Mandatory = $true)][string]$Message
    )
    $entry = Get-HarnessProperty -Object $Files -Name $GateFile
    if ($null -eq $entry) {
        return
    }
    $expected = [string](Get-HarnessProperty -Object $entry -Name 'sha256')
    $actual = Get-HarnessGateFileSha256 -Path (Join-HarnessPath -Root $FeatureRoot -Child $RelativePath)
    if (-not [string]::IsNullOrWhiteSpace($expected) -and $actual -ne $expected) {
        [void]$Findings.Add((Format-HarnessValidationFinding -Code 'DIRECT_GATE_FILE_MUTATION' -Message $Message))
    }
}

function Test-HarnessGateFileProvenance {
    <#
    .SYNOPSIS
        Return DIRECT_GATE_FILE_MUTATION findings when gate-file bytes diverge from script provenance stamps.
        Missing stamps are ignored (legacy fixtures); mismatch after a script stamp fails closed.
    #>

    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    $findings = [System.Collections.Generic.List[object]]::new()
    $provPath = Get-HarnessGateFileProvenancePath -FeatureRoot $FeatureRoot
    if (-not (Test-Path -LiteralPath $provPath)) {
        return @()
    }
    try {
        $doc = Get-Content -LiteralPath $provPath -Raw -Encoding UTF8 | ConvertFrom-Json
    } catch {
        [void]$findings.Add((Format-HarnessValidationFinding -Code 'DIRECT_GATE_FILE_MUTATION' `
                    -Message 'gate-file provenance stamp is unreadable; rebuild via harness scripts (do not hand-edit gate files)'))
        return @($findings)
    }
    $files = Get-HarnessProperty -Object $doc -Name 'files'
    if ($null -eq $files) {
        return @()
    }

    Add-HarnessGateFileProvenanceMismatchFinding -Findings $findings -FeatureRoot $FeatureRoot -Files $files `
        -GateFile 'review-loop' -RelativePath '.review-loop.json' `
        -Message '.review-loop.json changed without script provenance (projection is recorder-only)'
    Add-HarnessGateFileProvenanceMismatchFinding -Findings $findings -FeatureRoot $FeatureRoot -Files $files `
        -GateFile 'journal' -RelativePath 'journal/events.jsonl' `
        -Message 'journal/events.jsonl changed without harness append provenance (journal is append-only through scripts)'

    return @($findings)
}

function Test-HarnessPretooluseSkipJournalAllowed {
    <#
    .SYNOPSIS
        True when -SkipJournal may suppress deny telemetry (isolated FeatureRoot under TEMP only).
    #>

    param([AllowNull()][string]$FeatureRoot = '')
    if ([string]::IsNullOrWhiteSpace($FeatureRoot)) {
        return $false
    }
    if (-not (Test-Path -LiteralPath $FeatureRoot)) {
        return $false
    }
    $full = [System.IO.Path]::GetFullPath($FeatureRoot)
    $temp = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
    return $full.StartsWith($temp, [System.StringComparison]::OrdinalIgnoreCase)
}