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

# Report immutability: turn-open marker + registered gate-report write denial.
# 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-HarnessTurnOpenMarkerPath {
    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    return Join-HarnessPath -Root $FeatureRoot -Child 'journal/turn-open.json'
}

function Get-HarnessTurnOpenMarkerDenyReason {
    return 'journal/turn-open.json is recorder-only. Turn scripts write and clear it; do not edit the marker with agent editor tools.'
}

function New-HarnessTurnOpenMarkerDocument {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory marker document - no persistent state change.')]
    param(
        [Parameter(Mandatory = $true)][string]$Feature,
        [Parameter(Mandatory = $true)][string]$Slice,
        [Parameter(Mandatory = $true)][int]$ActivitySeq,
        [string]$OpenedAt = ''
    )
    $opened = if ([string]::IsNullOrWhiteSpace($OpenedAt)) {
        Get-HarnessIso8601UtcNow
    } else {
        $OpenedAt
    }
    return [pscustomobject]@{
        schema       = 'harness.turn-open/v1'
        feature      = $Feature
        slice        = $Slice
        activity_seq = $ActivitySeq
        opened_at    = $opened
    }
}

function Test-HarnessTurnOpenMarkerDocument {
    <#
    .SYNOPSIS
        Pure schema check for harness.turn-open/v1 marker documents.
    #>

    param([AllowNull()]$Document)
    if ($null -eq $Document) {
        return $false
    }
    $schema = [string](Get-HarnessProperty -Object $Document -Name 'schema' -Default '')
    if ($schema -ne 'harness.turn-open/v1') {
        return $false
    }
    $feature = [string](Get-HarnessProperty -Object $Document -Name 'feature' -Default '')
    $slice = [string](Get-HarnessProperty -Object $Document -Name 'slice' -Default '')
    $seqRaw = Get-HarnessProperty -Object $Document -Name 'activity_seq' -Default $null
    $opened = [string](Get-HarnessProperty -Object $Document -Name 'opened_at' -Default '')
    if ([string]::IsNullOrWhiteSpace($feature) -or [string]::IsNullOrWhiteSpace($slice) -or [string]::IsNullOrWhiteSpace($opened)) {
        return $false
    }
    if ($null -eq $seqRaw) {
        return $false
    }
    try {
        [void][int]$seqRaw
    } catch {
        return $false
    }
    return $true
}

function Read-HarnessTurnOpenMarker {
    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    $path = Get-HarnessTurnOpenMarkerPath -FeatureRoot $FeatureRoot
    if (-not (Test-Path -LiteralPath $path)) {
        return $null
    }
    try {
        $raw = [System.IO.File]::ReadAllText($path)
        $doc = ConvertFrom-HarnessJson -Json $raw
    } catch {
        return $null
    }
    if (-not (Test-HarnessTurnOpenMarkerDocument -Document $doc)) {
        return $null
    }
    return $doc
}

function Write-HarnessTurnOpenMarker {
    [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]$Feature,
        [Parameter(Mandatory = $true)][string]$Slice,
        [Parameter(Mandatory = $true)][int]$ActivitySeq,
        [string]$OpenedAt = ''
    )
    $journalDir = Join-HarnessPath -Root $FeatureRoot -Child 'journal'
    if (-not (Test-Path -LiteralPath $journalDir)) {
        New-Item -ItemType Directory -Path $journalDir -Force | Out-Null
    }
    $doc = New-HarnessTurnOpenMarkerDocument -Feature $Feature -Slice $Slice -ActivitySeq $ActivitySeq -OpenedAt $OpenedAt
    $path = Get-HarnessTurnOpenMarkerPath -FeatureRoot $FeatureRoot
    $json = $doc | ConvertTo-Json -Compress -Depth 4
    [System.IO.File]::WriteAllText($path, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
    return $path
}

function Clear-HarnessTurnOpenMarker {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Non-interactive harness write - agents and hooks must not prompt.')]
    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    $path = Get-HarnessTurnOpenMarkerPath -FeatureRoot $FeatureRoot
    if (Test-Path -LiteralPath $path) {
        Remove-Item -LiteralPath $path -Force -ErrorAction Stop
    }
}

function Get-HarnessOpenTurnActivityStartedSeq {
    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$Slice
    )
    if (-not (Get-Command Get-HarnessJournalEvents -ErrorAction SilentlyContinue)) {
        return $null
    }
    if (-not (Test-HarnessSliceTurnOpen -FeatureRoot $FeatureRoot -Slice $Slice)) {
        return $null
    }
    $events = @(Get-HarnessJournalEvents -FeatureRoot $FeatureRoot | Where-Object {
            [string]$_.slice -eq $Slice
        } | Sort-Object { [int](Get-HarnessProperty -Object $_ -Name 'seq' -Default 0) })
    $latestStart = $null
    foreach ($evt in $events) {
        $kind = [string](Get-HarnessProperty -Object $evt -Name 'kind' -Default '')
        $activity = Get-HarnessProperty -Object $evt -Name 'activity'
        if ($kind -eq 'ActivityStarted' -and (Test-HarnessJournalTurnBoundaryActivity -Activity $activity -Name 'start')) {
            $latestStart = $evt
        } elseif ($kind -eq 'ActivityStopped' -and (Test-HarnessJournalTurnBoundaryActivity -Activity $activity -Name 'stop')) {
            $latestStart = $null
        }
    }
    if ($null -eq $latestStart) {
        return $null
    }
    return [int](Get-HarnessProperty -Object $latestStart -Name 'seq' -Default 0)
}

function Test-HarnessTurnOpenMarkerAgreesWithJournal {
    <#
    .SYNOPSIS
        True when a valid marker projects an open journal turn (same slice + activity_seq).
        Missing marker is not disagreement (legacy fixtures / mid-write); stale or mismatched marker fails closed.
    #>

    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [AllowNull()]$Marker = $null
    )
    if ($null -eq $Marker) {
        $Marker = Read-HarnessTurnOpenMarker -FeatureRoot $FeatureRoot
    }
    if ($null -eq $Marker) {
        return $false
    }
    if (-not (Test-HarnessTurnOpenMarkerDocument -Document $Marker)) {
        return $false
    }
    $slice = [string]$Marker.slice
    $markerSeq = [int]$Marker.activity_seq
    if (-not (Test-HarnessSliceTurnOpen -FeatureRoot $FeatureRoot -Slice $slice)) {
        return $false
    }
    $journalSeq = Get-HarnessOpenTurnActivityStartedSeq -FeatureRoot $FeatureRoot -Slice $slice
    if ($null -eq $journalSeq) {
        return $false
    }
    return ($markerSeq -eq [int]$journalSeq)
}

function Test-HarnessFeatureOwnershipTurnOpenForEnforcement {
    <#
    .SYNOPSIS
        Whether write enforcement treats this feature root as having an open ownership turn.
        Marker present must agree with the journal (fail closed on disagreement).
        Unreadable or schema-invalid marker files also fail closed.
        Marker absent falls back to journal open-turn state (fixture / rollout compatibility).
    #>

    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    $markerPath = Get-HarnessTurnOpenMarkerPath -FeatureRoot $FeatureRoot
    if (Test-Path -LiteralPath $markerPath) {
        $marker = Read-HarnessTurnOpenMarker -FeatureRoot $FeatureRoot
        if ($null -eq $marker) {
            return $false
        }
        return (Test-HarnessTurnOpenMarkerAgreesWithJournal -FeatureRoot $FeatureRoot -Marker $marker)
    }
    $open = @(Get-HarnessOpenTurnSliceId -FeatureRoot $FeatureRoot)
    return ($open.Count -gt 0)
}

function Get-HarnessFeatureRootsWithJournal {
    <#
    .SYNOPSIS
        List feature roots under RepoRoot that have a journal ledger, optionally including FeatureRoot.
    #>

    param(
        [string]$RepoRoot = '',
        [string]$FeatureRoot = ''
    )
    $roots = [System.Collections.Generic.List[string]]::new()
    if (-not [string]::IsNullOrWhiteSpace($FeatureRoot) -and (Test-Path -LiteralPath $FeatureRoot)) {
        [void]$roots.Add($FeatureRoot)
    }
    if (-not [string]::IsNullOrWhiteSpace($RepoRoot)) {
        $featuresRoot = Join-HarnessPath -Root $RepoRoot -Child 'docs/harness/features'
        if (Test-Path -LiteralPath $featuresRoot) {
            Get-ChildItem -LiteralPath $featuresRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object {
                $journal = Join-HarnessPath -Root $_.FullName -Child 'journal/events.jsonl'
                if (Test-Path -LiteralPath $journal) {
                    [void]$roots.Add($_.FullName)
                }
            }
        }
    }
    return @($roots | Select-Object -Unique)
}

function Test-HarnessAnyOwnershipTurnOpenForEnforcement {
    param(
        [string]$RepoRoot = '',
        [string]$FeatureRoot = ''
    )
    # Fixture callers pass -FeatureRoot; do not mix in live RepoRoot journals.
    $roots = if (-not [string]::IsNullOrWhiteSpace($FeatureRoot) -and (Test-Path -LiteralPath $FeatureRoot)) {
        @($FeatureRoot)
    } else {
        @(Get-HarnessFeatureRootsWithJournal -RepoRoot $RepoRoot)
    }
    foreach ($root in $roots) {
        if (Test-HarnessFeatureOwnershipTurnOpenForEnforcement -FeatureRoot $root) {
            return $true
        }
    }
    return $false
}

function Get-HarnessFeatureRelativeReportPath {
    <#
    .SYNOPSIS
        Map a repo-relative edit path to feature slug + feature-relative path for report-tree classes.
    #>

    param(
        [AllowNull()][string]$Path,
        [string]$RepoRoot = '',
        [string]$FeatureRoot = ''
    )
    $n = ConvertTo-HarnessNormalizedRepoPath -Path $Path -RepoRoot $RepoRoot
    if ([string]::IsNullOrWhiteSpace($n)) {
        return $null
    }
    if ($n -match '^docs/harness/features/([^/]+)/(.+)$') {
        return [pscustomobject]@{
            FeatureSlug   = $Matches[1]
            RelativePath  = ($Matches[2] -replace '\\', '/')
            NormalizedPath = $n
        }
    }
    if (-not [string]::IsNullOrWhiteSpace($FeatureRoot) -and (Test-Path -LiteralPath $FeatureRoot)) {
        $rootFull = [System.IO.Path]::GetFullPath($FeatureRoot).TrimEnd('\', '/')
        $candidate = $Path
        if (-not [System.IO.Path]::IsPathRooted($candidate)) {
            if (-not [string]::IsNullOrWhiteSpace($RepoRoot)) {
                $candidate = Join-HarnessPath -Root $RepoRoot -Child ($n -replace '/', [System.IO.Path]::DirectorySeparatorChar)
            } else {
                $candidate = Join-HarnessPath -Root $FeatureRoot -Child ($n -replace '/', [System.IO.Path]::DirectorySeparatorChar)
            }
        }
        try {
            $candFull = [System.IO.Path]::GetFullPath($candidate)
        } catch {
            return $null
        }
        $prefix = $rootFull + [System.IO.Path]::DirectorySeparatorChar
        if ($candFull.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
            $rel = $candFull.Substring($prefix.Length) -replace '\\', '/'
            $slug = Split-Path -Leaf $FeatureRoot
            return [pscustomobject]@{
                FeatureSlug    = $slug
                RelativePath   = $rel
                NormalizedPath = ('docs/harness/features/' + $slug + '/' + $rel)
            }
        }
    }
    return $null
}

function Test-HarnessDecisionBookRelativePath {
    param([AllowNull()][string]$RelativePath)
    if ([string]::IsNullOrWhiteSpace($RelativePath)) {
        return $false
    }
    $rel = ($RelativePath -replace '\\', '/').TrimStart('/')
    return ($rel -eq 'quality/disposition-register.json' -or
        $rel -eq 'security/disposition-register.json' -or
        $rel -eq 'residuals.md')
}

function Test-HarnessDecisionBookPath {
    param(
        [AllowNull()][string]$Path,
        [string]$RepoRoot = '',
        [string]$FeatureRoot = ''
    )
    $mapped = Get-HarnessFeatureRelativeReportPath -Path $Path -RepoRoot $RepoRoot -FeatureRoot $FeatureRoot
    if ($null -eq $mapped) {
        return $false
    }
    return (Test-HarnessDecisionBookRelativePath -RelativePath $mapped.RelativePath)
}

function Test-HarnessReportTreeRelativePath {
    <#
    .SYNOPSIS
        True for gate-report tree paths (quality/security/.reviews/slice-review narrative), excluding decision books.
    #>

    param([AllowNull()][string]$RelativePath)
    if ([string]::IsNullOrWhiteSpace($RelativePath)) {
        return $false
    }
    $rel = ($RelativePath -replace '\\', '/').TrimStart('/')
    if (Test-HarnessDecisionBookRelativePath -RelativePath $rel) {
        return $false
    }
    if ($rel -match '^(quality|security|\.reviews)/') {
        return $true
    }
    if ($rel -match '^slice-review-[^/]+\.md$') {
        return $true
    }
    return $false
}

function Test-HarnessReportTreeEditPath {
    param(
        [AllowNull()][string]$Path,
        [string]$RepoRoot = '',
        [string]$FeatureRoot = ''
    )
    $mapped = Get-HarnessFeatureRelativeReportPath -Path $Path -RepoRoot $RepoRoot -FeatureRoot $FeatureRoot
    if ($null -eq $mapped) {
        return $false
    }
    return (Test-HarnessReportTreeRelativePath -RelativePath $mapped.RelativePath)
}

function Get-HarnessSliceReviewNarrativeSiblingRelativePath {
    <#
    .SYNOPSIS
        Discover slice-review narrative siblings for pair-freeze (FeatureRoot I/O by design).
    .DESCRIPTION
        Default enumerator walks FeatureRoot for dated and legacy narrative names.
        Pass -EnumerateFiles to inject names for unit tests without seeding disk.
    #>

    param(
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [Parameter(Mandatory = $true)][string]$SliceToken,
        [scriptblock]$EnumerateFiles = $null
    )
    $names = if ($null -ne $EnumerateFiles) {
        @(& $EnumerateFiles $FeatureRoot $SliceToken)
    } else {
        $discovered = [System.Collections.Generic.List[string]]::new()
        Get-ChildItem -LiteralPath $FeatureRoot -Filter ("slice-review-$SliceToken-*.md") -File -ErrorAction SilentlyContinue |
            ForEach-Object { [void]$discovered.Add($_.Name) }
        $legacyName = "slice-review-$SliceToken.md"
        $legacy = Join-Path $FeatureRoot $legacyName
        if (Test-Path -LiteralPath $legacy) {
            [void]$discovered.Add($legacyName)
        }
        @($discovered)
    }
    return @($names | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | ForEach-Object { [string]$_ })
}

function Add-HarnessRegisteredArtifactCandidate {
    <#
    .SYNOPSIS
        Add one journal-harvested artifact path (and pair-freeze siblings) to a custody HashSet.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.HashSet[string]]$Paths,
        [Parameter(Mandatory = $true)][AllowEmptyString()][string]$RawArtifact,
        [Parameter(Mandatory = $true)][string]$FeatureRoot,
        [scriptblock]$EnumerateNarrativeSiblings = $null
    )
    if ([string]::IsNullOrWhiteSpace($RawArtifact)) {
        return
    }
    $rel = ($RawArtifact -replace '\\', '/').TrimStart('/')
    if ($rel -match '^docs/harness/features/[^/]+/(.+)$') {
        $rel = $Matches[1]
    }
    if ([string]::IsNullOrWhiteSpace($rel)) {
        return
    }
    [void]$Paths.Add($rel)
    if ($rel -match '^quality/(.+)\.md$' -and $rel -notmatch '-data\.md$') {
        [void]$Paths.Add(('quality/' + $Matches[1] + '-data.json'))
    }
    if ($rel -match '^\.reviews/slice-verdict-(.+)\.yaml$') {
        $sliceToken = $Matches[1]
        $enumArgs = @{
            FeatureRoot = $FeatureRoot
            SliceToken  = $sliceToken
        }
        if ($null -ne $EnumerateNarrativeSiblings) {
            $enumArgs['EnumerateFiles'] = $EnumerateNarrativeSiblings
        }
        foreach ($sibling in @(Get-HarnessSliceReviewNarrativeSiblingRelativePath @enumArgs)) {
            [void]$Paths.Add($sibling)
        }
    }
}
function Get-HarnessRegisteredArtifactRelativePathSet {
    <#
    .SYNOPSIS
        Build the set of feature-relative paths under recorder custody for write denial.
    .DESCRIPTION
        Harvests artifact paths from journal rows. Pair freeze is applied via
        Add-HarnessRegisteredArtifactCandidate.
    #>

    param([Parameter(Mandatory = $true)][string]$FeatureRoot)
    $paths = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
    if (-not (Get-Command Get-HarnessJournalEvents -ErrorAction SilentlyContinue)) {
        return , $paths
    }
    foreach ($evt in @(Get-HarnessJournalEvents -FeatureRoot $FeatureRoot)) {
        $candidates = [System.Collections.Generic.List[string]]::new()
        $refArt = [string](Get-HarnessProperty -Object (Get-HarnessProperty -Object $evt -Name 'refs') -Name 'artifact' -Default '')
        if (-not [string]::IsNullOrWhiteSpace($refArt)) {
            [void]$candidates.Add($refArt)
        }
        $payload = Get-HarnessProperty -Object $evt -Name 'payload'
        if ($null -ne $payload) {
            $pArt = [string](Get-HarnessProperty -Object $payload -Name 'artifact' -Default '')
            if (-not [string]::IsNullOrWhiteSpace($pArt)) {
                [void]$candidates.Add($pArt)
            }
            $vArt = [string](Get-HarnessProperty -Object $payload -Name 'verdict_artifact' -Default '')
            if (-not [string]::IsNullOrWhiteSpace($vArt)) {
                [void]$candidates.Add($vArt)
            }
            foreach ($a in @((Get-HarnessProperty -Object $payload -Name 'artifacts' -Default @()))) {
                if (-not [string]::IsNullOrWhiteSpace([string]$a)) {
                    [void]$candidates.Add([string]$a)
                }
            }
            $cycle = Get-HarnessProperty -Object $payload -Name 'cycle'
            if ($null -ne $cycle) {
                $cArt = [string](Get-HarnessProperty -Object $cycle -Name 'verdict_artifact' -Default '')
                if ([string]::IsNullOrWhiteSpace($cArt)) {
                    $cArt = [string](Get-HarnessProperty -Object $cycle -Name 'artifact' -Default '')
                }
                if (-not [string]::IsNullOrWhiteSpace($cArt)) {
                    [void]$candidates.Add($cArt)
                }
            }
        }
        foreach ($raw in $candidates) {
            Add-HarnessRegisteredArtifactCandidate -Paths $paths -RawArtifact $raw -FeatureRoot $FeatureRoot
        }
    }
    return , $paths
}

function Test-HarnessRegisteredGateReportPath {
    param(
        [AllowNull()][string]$Path,
        [string]$RepoRoot = '',
        [string]$FeatureRoot = ''
    )
    $mapped = Get-HarnessFeatureRelativeReportPath -Path $Path -RepoRoot $RepoRoot -FeatureRoot $FeatureRoot
    if ($null -eq $mapped) {
        return $false
    }
    if (-not (Test-HarnessReportTreeRelativePath -RelativePath $mapped.RelativePath)) {
        return $false
    }
    $root = $FeatureRoot
    if ([string]::IsNullOrWhiteSpace($root) -and -not [string]::IsNullOrWhiteSpace($RepoRoot)) {
        $root = Join-HarnessPath -Root $RepoRoot -Child ('docs/harness/features/' + $mapped.FeatureSlug)
    }
    if ([string]::IsNullOrWhiteSpace($root) -or -not (Test-Path -LiteralPath $root)) {
        return $false
    }
    $set = Get-HarnessRegisteredArtifactRelativePathSet -FeatureRoot $root
    if ($null -eq $set) {
        return $false
    }
    return [bool]$set.Contains(($mapped.RelativePath -replace '\\', '/'))
}

function Get-HarnessRegisteredReportDenyReason {
    return 'Registered gate reports are frozen after recorder custody (ADR-0015). 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).'
}

function Test-HarnessShellCommandIsSanctionedIntegrityWriter {
    <#
    .SYNOPSIS
        True when the shell command is a harness-invoke of a sanctioned recorder/writer
        that may touch registered reports or gate-file-provenance.json.
    #>

    param([AllowNull()][string]$Command)
    if ([string]::IsNullOrWhiteSpace($Command)) {
        return $false
    }
    if (-not (Test-HarnessCommandStartsWithInvokeFrontDoor -Command $Command)) {
        return $false
    }
    return [bool]($Command -match '(?i)(?:harness-persist-quality|harness-record-review|harness-record-slice-verdict|harness-record-phase|harness-turn-start|harness-turn-stop|init-harness-feature)\.ps1\b')
}

function Test-HarnessShellCommandTargetsFrozenIntegrityPath {
    <#
    .SYNOPSIS
        True when a shell/beforeShellExecution command targets a registered gate report
        or journal/gate-file-provenance.json (dual-write launder vector F-2026-10-20-005).
    .DESCRIPTION
        Sanctioned harness-invoke recorders are excluded. Path match is substring-based
        on registered relative paths and the provenance stamp leaf.
    #>

    param(
        [AllowNull()][string]$Command,
        [string]$RepoRoot = '',
        [string]$FeatureRoot = ''
    )
    if ([string]::IsNullOrWhiteSpace($Command)) {
        return $false
    }
    if (Test-HarnessShellCommandIsSanctionedIntegrityWriter -Command $Command) {
        return $false
    }
    if ($Command -match '(?i)(?:^|[\s''"`;/\\])(?:[\w./\\-]*[/\\])?journal[/\\]gate-file-provenance\.json\b') {
        return $true
    }

    $roots = [System.Collections.Generic.List[string]]::new()
    if (-not [string]::IsNullOrWhiteSpace($FeatureRoot) -and (Test-Path -LiteralPath $FeatureRoot)) {
        [void]$roots.Add($FeatureRoot)
    } elseif (-not [string]::IsNullOrWhiteSpace($RepoRoot)) {
        foreach ($r in @(Get-HarnessFeatureRootsWithJournal -RepoRoot $RepoRoot)) {
            [void]$roots.Add($r)
        }
    }

    $cmdNorm = $Command -replace '\\', '/'
    foreach ($root in $roots) {
        $set = Get-HarnessRegisteredArtifactRelativePathSet -FeatureRoot $root
        if ($null -eq $set -or $set.Count -eq 0) {
            continue
        }
        $slug = ''
        if (Get-Command Get-HarnessFeatureSlugFromRoot -ErrorAction SilentlyContinue) {
            $slug = [string](Get-HarnessFeatureSlugFromRoot -FeatureRoot $root)
        }
        foreach ($rel in @($set)) {
            $relNorm = ([string]$rel) -replace '\\', '/'
            if ([string]::IsNullOrWhiteSpace($relNorm)) {
                continue
            }
            if ($cmdNorm.IndexOf($relNorm, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
                return $true
            }
            if (-not [string]::IsNullOrWhiteSpace($slug)) {
                $prefixed = "docs/harness/features/$slug/$relNorm"
                if ($cmdNorm.IndexOf($prefixed, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
                    return $true
                }
            }
            $full = (Join-HarnessPath -Root $root -Child $relNorm) -replace '\\', '/'
            if ($cmdNorm.IndexOf($full, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
                return $true
            }
        }
    }
    return $false
}

function Get-HarnessUnregisteredDraftDenyReason {
    param(
        [string]$Feature = '<slug>',
        [string]$Slice = '<id>'
    )
    $start = Get-HarnessTurnStartInvokeCommand -Feature $Feature -Slice $Slice -Phase 'RED'
    return "Unregistered report-tree drafts require an open ownership turn. Start first: $start"
}