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'
    }
    if ($n -match '(^|/)journal/turn-open\.json$') {
        return 'turn-open'
    }
    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', 'turn-open', '')][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.'
    }
    if ($Kind -eq 'turn-open') {
        return (Get-HarnessTurnOpenMarkerDenyReason)
    }
    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 Invoke-HarnessGateProvenanceDocumentMutation {
    <#
    .SYNOPSIS
        Exclusive read-modify-write chassis for journal/gate-file-provenance.json (SEC-JAH-01).
    .DESCRIPTION
        Shared by gate-file and registered-artifact stamp writers so lock / ForceRepair /
        rewrite semantics cannot twin-stamp-drift. MutateDocument receives the parsed doc
        and a caller context object; it mutates the doc in place. Always ensures files and
        artifacts bags exist before MutateDocument runs.
        Lock ordering remains journal-append-lock -> provenance-lock.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param(
        [Parameter(Mandatory = $true)][string]$ProvenancePath,
        [Parameter(Mandatory = $true)][scriptblock]$MutateDocument,
        [AllowNull()][object]$Context = $null,
        [switch]$ForceRepair
    )
    $stampArgs = @($ProvenancePath, [bool]$ForceRepair, $MutateDocument, $Context)
    $null = Invoke-HarnessWithFileLockRetry -MaxAttempts 8 -DelayMilliseconds 100 -ArgumentList $stampArgs -ScriptBlock {
        param($LockedProvenancePath, $StampForceRepair, $MutateDocument, $MutateContext)
        # 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(
            $LockedProvenancePath,
            [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 '$LockedProvenancePath'. Pass -ForceRepair to reset after confirming no dual-write forgery, or rebuild via harness scripts."
                    }
                    Write-Warning "Resetting unreadable gate-file provenance stamp at '$LockedProvenancePath' (-ForceRepair)."
                    $doc = $null
                }
            }
            if ($null -eq $doc) {
                $doc = [pscustomobject]@{
                    schema    = 'harness.gate-file-provenance/v1'
                    files     = [pscustomobject]@{}
                    artifacts = [pscustomobject]@{}
                }
            }
            if (-not ($doc.PSObject.Properties.Name -contains 'files') -or $null -eq $doc.files) {
                $doc | Add-Member -NotePropertyName 'files' -NotePropertyValue ([pscustomobject]@{}) -Force
            }
            if (-not ($doc.PSObject.Properties.Name -contains 'artifacts') -or $null -eq $doc.artifacts) {
                $doc | Add-Member -NotePropertyName 'artifacts' -NotePropertyValue ([pscustomobject]@{}) -Force
            }
            & $MutateDocument $doc $MutateContext
            $json = $doc | ConvertTo-Json -Depth 8
            $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 Read-HarnessGateFileProvenanceDocument {
    <#
    .SYNOPSIS
        Load gate-file-provenance.json with shared fail-closed policy for Test-* helpers.
    .OUTPUTS
        PSCustomObject with Doc (nullable) and Findings (list; populated when unreadable).
    #>

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

function Write-HarnessGateFileProvenance {
    <#
    .SYNOPSIS
        Stamp the sha256 provenance for one gate file (journal/events.jsonl or .review-loop.json).
    .DESCRIPTION
        Delegates exclusive RMW to Invoke-HarnessGateProvenanceDocumentMutation (SEC-JAH-01).
        Lock ordering is by design journal-append-lock -> provenance-lock.
    #>

    [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
    }
    $ctx = [pscustomobject]@{
        GateFile = $GateFile
        Hash     = $hash
        Source   = $Source
    }
    Invoke-HarnessGateProvenanceDocumentMutation -ProvenancePath $provPath -ForceRepair:$ForceRepair -Context $ctx -MutateDocument {
        param($Doc, $Context)
        $entry = [pscustomobject]@{
            sha256     = $Context.Hash
            updated_at = (Get-HarnessIso8601UtcNow)
            source     = $Context.Source
        }
        $Doc.files | Add-Member -NotePropertyName $Context.GateFile -NotePropertyValue $entry -Force
    }
}

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)
    $loaded = Read-HarnessGateFileProvenanceDocument -FeatureRoot $FeatureRoot -OnUnreadableCode 'DIRECT_GATE_FILE_MUTATION'
    if ($null -eq $loaded.Doc) {
        return @($loaded.Findings)
    }
    $findings = [System.Collections.Generic.List[object]]::new()
    foreach ($f in @($loaded.Findings)) { [void]$findings.Add($f) }
    $files = Get-HarnessProperty -Object $loaded.Doc -Name 'files'
    if ($null -eq $files) {
        return @($findings)
    }

    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 Write-HarnessRegisteredArtifactProvenance {
    <#
    .SYNOPSIS
        Stamp sha256 custody hash for one registered gate-report artifact under artifacts{} in gate-file-provenance.json.
    .DESCRIPTION
        Recorded after sanctioned custody (persist-quality / record-review / record-slice-verdict).
        Refuses blank hashes and decision-book paths. RelativePath must stay under the feature root
        (Resolve-HarnessPathUnderFeatureRoot -ThrowOnEscape). Exclusive RMW uses
        Invoke-HarnessGateProvenanceDocumentMutation (same chassis as Write-HarnessGateFileProvenance).
        Prefer Complete-HarnessRegisteredArtifactCustodyStamp at recorder call sites.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$RelativePath,
        [string]$Source = 'harness-script',
        [string]$Slice = '',
        [string]$Sha256 = '',
        [switch]$ForceRepair
    )
    $rel = ($RelativePath -replace '\\', '/').TrimStart('/')
    if ([string]::IsNullOrWhiteSpace($rel)) {
        throw 'Write-HarnessRegisteredArtifactProvenance requires a non-empty RelativePath.'
    }
    # Decision books stay editable by design; never freeze residuals.md or disposition registers.
    if (Test-HarnessDecisionBookRelativePath -RelativePath $rel) {
        return
    }
    $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 = Resolve-HarnessPathUnderFeatureRoot -FeatureRoot $FeatureRoot -RelativePath $rel -ThrowOnEscape
    $hash = if (-not [string]::IsNullOrWhiteSpace($Sha256)) {
        $Sha256.ToLowerInvariant()
    } else {
        Get-HarnessGateFileSha256 -Path $targetPath
    }
    if ([string]::IsNullOrWhiteSpace($hash)) {
        throw "Write-HarnessRegisteredArtifactProvenance: empty sha256 for '$rel' (refuse blank custody stamp)."
    }
    $ctx = [pscustomobject]@{
        Rel    = $rel
        Hash   = $hash
        Source = $Source
        Slice  = $Slice
    }
    Invoke-HarnessGateProvenanceDocumentMutation -ProvenancePath $provPath -ForceRepair:$ForceRepair -Context $ctx -MutateDocument {
        param($Doc, $Context)
        $entry = [pscustomobject]@{
            sha256        = $Context.Hash
            registered_at = [string](Get-HarnessIso8601UtcNow)
            source        = $Context.Source
            slice         = $Context.Slice
        }
        $Doc.artifacts | Add-Member -NotePropertyName $Context.Rel -NotePropertyValue $entry -Force
    }
    # Intentionally no pipeline output - callers assign paths separately.
}

function Complete-HarnessRegisteredArtifactCustodyStamp {
    <#
    .SYNOPSIS
        Sanctioned post-custody hash stamp for all three recorders (persist-quality / record-review / record-slice-verdict).
    .DESCRIPTION
        Single indirection over Write-HarnessRegisteredArtifactProvenance so a fourth recorder
        has one named entry point to call after journal registration.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$RelativePath,
        [string]$Source = 'harness-script',
        [string]$Slice = '',
        [string]$Sha256 = '',
        [switch]$ForceRepair
    )
    Write-HarnessRegisteredArtifactProvenance -FeatureRoot $FeatureRoot -RelativePath $RelativePath `
        -Source $Source -Slice $Slice -Sha256 $Sha256 -ForceRepair:$ForceRepair
}

function Test-HarnessGateArtifactDrift {
    <#
    .SYNOPSIS
        Return GATE_ARTIFACT_DRIFT findings when registered gate-report bytes diverge from custody hashes.
    .DESCRIPTION
        Stem is Drift (not Provenance) on purpose: finding code GATE_ARTIFACT_DRIFT and US-RI-05
        speak "drift," while Test-HarnessGateFileProvenance covers DIRECT_GATE_FILE_MUTATION on files{}.
        Load path shares Read-HarnessGateFileProvenanceDocument. When Feature/Slice are supplied,
        findings include NextCommand enrichment (Add-HarnessGateArtifactDriftFinding stays a foreach).
    #>

    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [string]$Feature = '',
        [string]$Slice = ''
    )
    $loaded = Read-HarnessGateFileProvenanceDocument -FeatureRoot $FeatureRoot -OnUnreadableCode 'GATE_ARTIFACT_DRIFT'
    if ($null -eq $loaded.Doc) {
        $out = [System.Collections.Generic.List[object]]::new()
        foreach ($f in @($loaded.Findings)) {
            $code = [string](Get-HarnessProperty -Object $f -Name 'code' -Default 'GATE_ARTIFACT_DRIFT')
            $msg = [string](Get-HarnessProperty -Object $f -Name 'message' -Default '')
            $sev = [string](Get-HarnessProperty -Object $f -Name 'severity' -Default '')
            $next = Get-HarnessValidationNextCommand -Code $code -Feature $Feature -Slice $Slice
            [void]$out.Add((Format-HarnessValidationFinding -Code $code -Message $msg -Severity $sev -NextCommand $next))
        }
        return @($out)
    }
    $findings = [System.Collections.Generic.List[object]]::new()
    $artifacts = Get-HarnessProperty -Object $loaded.Doc -Name 'artifacts'
    if ($null -eq $artifacts) {
        return @()
    }
    foreach ($prop in @($artifacts.PSObject.Properties)) {
        $rel = [string]$prop.Name
        if ([string]::IsNullOrWhiteSpace($rel)) { continue }
        # Decision books stay editable by design; never emit GATE_ARTIFACT_DRIFT for them.
        if (Test-HarnessDecisionBookRelativePath -RelativePath $rel) {
            continue
        }
        $entry = $prop.Value
        $expected = [string](Get-HarnessProperty -Object $entry -Name 'sha256' -Default '')
        if ([string]::IsNullOrWhiteSpace($expected)) { continue }
        $msg = $null
        try {
            $full = Resolve-HarnessPathUnderFeatureRoot -FeatureRoot $FeatureRoot -RelativePath $rel -ThrowOnEscape
        } catch {
            $msg = "Registered gate-report artifact '$rel' path escapes feature root (GATE_ARTIFACT_DRIFT). Specialist re-issue: write a new report identity, then re-persist through the sanctioned recorder."
        }
        if ($null -eq $msg -and -not (Test-Path -LiteralPath $full)) {
            $msg = "Registered gate-report artifact '$rel' is missing after custody (GATE_ARTIFACT_DRIFT). Specialist re-issue: write a new report identity, then re-persist through the sanctioned recorder."
        }
        if ($null -eq $msg) {
            $actual = Get-HarnessGateFileSha256 -Path $full
            if ($actual -ne $expected) {
                $msg = "Registered gate-report artifact '$rel' bytes diverge from custody hash (GATE_ARTIFACT_DRIFT). Specialist re-issue: write a new report identity, then re-persist through the sanctioned recorder (harness-persist-quality / harness-record-review / harness-record-slice-verdict)."
            }
        }
        if ($null -ne $msg) {
            $next = Get-HarnessValidationNextCommand -Code 'GATE_ARTIFACT_DRIFT' -Feature $Feature -Slice $Slice
            [void]$findings.Add((Format-HarnessValidationFinding -Code 'GATE_ARTIFACT_DRIFT' -Message $msg -NextCommand $next))
        }
    }
    return @($findings)
}

function Add-HarnessGateArtifactDriftFinding {
    <#
    .SYNOPSIS
        Append GATE_ARTIFACT_DRIFT findings into a validation findings list (full + preflight).
    #>

    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.IList]$Findings,
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [string]$Feature = '',
        [string]$Slice = ''
    )
    foreach ($drift in @(Test-HarnessGateArtifactDrift -FeatureRoot $FeatureRoot -Feature $Feature -Slice $Slice)) {
        [void]$Findings.Add($drift)
    }
}
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)
}