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 {
    [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
    $doc = $null
    if (Test-Path -LiteralPath $provPath) {
        try {
            $doc = Get-Content -LiteralPath $provPath -Raw -Encoding UTF8 | ConvertFrom-Json
        }
        catch {
            if (-not $ForceRepair) {
                throw "gate-file provenance stamp is unreadable at '$provPath'. Pass -ForceRepair to reset after confirming no dual-write forgery, or rebuild via harness scripts."
            }
            Write-Warning "Resetting unreadable gate-file provenance stamp at '$provPath' (-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
    }
    $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
    }
    $entry = [pscustomobject]@{
        sha256     = $hash
        updated_at = (Get-HarnessIso8601UtcNow)
        source     = $Source
    }
    $doc.files | Add-Member -NotePropertyName $GateFile -NotePropertyValue $entry -Force
    $json = $doc | ConvertTo-Json -Depth 6
    [System.IO.File]::WriteAllText($provPath, $json + [Environment]::NewLine)
}

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)
}