scripts/internal/continuous-co-review/verification-plan-contract.ps1

# HARD dependency: the ONE path-identity primitive, loaded into THIS scope before anything below
# compares a path. See the note where the shadowing duplicate used to live.
if (-not (Get-Command -Name 'Get-ContinuousCoReviewPathCaseSensitive' -ErrorAction SilentlyContinue)) {
    . (Join-Path $PSScriptRoot 'path-identity.ps1')
}

# T019 / FR-048 — the framework-NEUTRAL, ORDERED verification-PLAN contract (amended 2026-07-13).
#
# WHAT THIS IS: the contract layer for the verification plan a downstream command-plan SUPPLIER
# produces and the universal T018 recorded-run runner EXECUTES (see verification-plan-runner.ps1).
# Given a plan or a command object these functions return a DECISION. NOTHING here discovers, infers,
# selects, or invents a command — command DISCOVERY/inference is a SEPARATE downstream concern this
# layer deliberately does NOT do. All functions are PURE except Test-...VerificationPathSafe, which
# consults the filesystem ONLY to dereference an existing symlink/junction for the escape check.
#
# THE INVARIANTS this contract encodes (maintainer amendment 2026-07-13):
# 1. FRAMEWORK-NEUTRAL: a command is just { command_id, executable, arguments, provenance, ... }.
# pytest / cargo test / dotnet test / a custom shell script are all equally acceptable — this
# layer carries NO per-framework knowledge and never privileges one technology over another.
# 2. ORDER IS LOAD-BEARING: commands execute in DECLARED order and this layer NEVER sorts them.
# 3. STABLE IDENTITY: the plan carries a `plan_id`; each command carries a `command_id` that is
# REQUIRED and UNIQUE within the plan (evidence joins bind on command_id + reviewed-tree digest).
# 4. ARGUMENTS ARE A STRING ARRAY, NOT A SHELL STRING: shell behaviour must be an explicit
# interpreter invocation (pwsh -File ... / bash -lc ...). A single-string `arguments` is REJECTED.
# 5. PATH SAFETY: working_directory / result_path MUST be repository-relative + canonical; a rooted
# path, a `..` escape, or a symlink/junction resolving outside RepoRoot is REJECTED.
# 6. TIMEOUT IS BOUNDED BY ENGINE POLICY: a supplier can NEVER request an unlimited run. A requested
# 0/absent resolves to the engine DEFAULT; a request over the engine MAX is clamped.
# 7. AUDITABLE PROVENANCE OBJECT: provenance is { kind, source, provider?, profile? } — not a bare
# enum — so every command records HOW it entered the plan.
# 8. NO SECRETS: neither a plan nor recorded evidence may embed literal env VALUES. Env customization
# is declared as `env_refs` (env var NAMES only); a literal `env`/`environment` map is REJECTED.
# 9. AN EMPTY PLAN IS NEVER A SILENT SUCCESS: a null/empty/all-invalid plan resolves to the EXPLICIT
# `verification-not-configured` state, never a fabricated pass.
#
# Reuses Get-ContinuousCoReviewContractProp (StrictMode-safe property read) from review-identity-
# contracts.ps1; bootstraps it if this file is dot-sourced before that one.

if (-not (Get-Command -Name 'Get-ContinuousCoReviewContractProp' -ErrorAction SilentlyContinue)) {
    $ricPath = Join-Path $PSScriptRoot 'review-identity-contracts.ps1'
    if (Test-Path -LiteralPath $ricPath -PathType Leaf) { . $ricPath }
}

# StrictMode-safe property read that PRESERVES array-ness. The shared Get-ContinuousCoReviewContractProp
# returns $prop.Value directly, which PowerShell ENUMERATES on return — so an empty-array property reads
# back as $null and a single-element array reads back as a scalar. That is fatal to the array-vs-string
# type checks below (arguments / env_refs / commands), so those reads use this accessor, whose `, $val`
# wrapper stops the enumeration and keeps @() as @(), @('x') as a 1-element array, and 'x' as a string.
function Get-ContinuousCoReviewVerificationRawProp {
    param([Parameter(Mandatory)][AllowNull()]$Object, [Parameter(Mandatory)][string]$Name)
    if ($null -eq $Object) { return $null }
    if ($Object -is [System.Collections.IDictionary]) {
        if ($Object.Contains($Name)) { return , $Object[$Name] }
        return $null
    }
    $prop = $Object.PSObject.Properties[$Name]
    if ($null -eq $prop) { return $null }
    return , $prop.Value
}

# ENGINE-POLICY timeout bounds. A supplier's requested timeout is always resolved through these — a
# request can never buy an unlimited run. (Module constants, read through the accessors below.)
$script:ContinuousCoReviewMaxVerificationTimeoutSeconds = 3600
$script:ContinuousCoReviewDefaultVerificationTimeoutSeconds = 900

function Get-ContinuousCoReviewMaxVerificationTimeoutSeconds { return $script:ContinuousCoReviewMaxVerificationTimeoutSeconds }
function Get-ContinuousCoReviewDefaultVerificationTimeoutSeconds { return $script:ContinuousCoReviewDefaultVerificationTimeoutSeconds }

# The FOUR — and only four — valid provenance KIND values a supplier may stamp on a command, naming
# HOW the command entered the plan. This layer validates membership only; it never RESOLVES provenance.
function Get-ContinuousCoReviewVerificationProvenanceValues {
    return @('project-config', 'project-detected', 'profile-selected', 'provider-gated')
}

# Resolve a supplier's REQUESTED timeout to the effective, ENGINE-BOUNDED seconds. A requested 0/absent
# (or negative) becomes the engine DEFAULT — NEVER unlimited; a request over the engine MAX is clamped
# to the max and flagged. WIDE-TYPED (review finding f2, run 20260714T193411985): the schema puts NO
# maximum on timeout_seconds, so a contract-valid Int64 (or a JSON integer beyond Int64, parsed as
# BigInteger) must CLAMP deterministically per FR-048 - a narrowing [int] cast previously threw an
# OverflowException that aborted the whole plan with no durable failed-attempt record. Pure; returns
# { effective_seconds; clamped; source; reason }.
function Resolve-ContinuousCoReviewVerificationTimeout {
    param([AllowNull()]$Requested = 0)
    $max = Get-ContinuousCoReviewMaxVerificationTimeoutSeconds
    $default = Get-ContinuousCoReviewDefaultVerificationTimeoutSeconds
    # Normalize to a wide numeric: null/non-numeric -> 0 (engine default); beyond Int64 -> Int64.MaxValue
    # (the clamp below reduces it to the engine max anyway - deterministic, never a throw).
    $reqNum = 0L
    if ($null -ne $Requested) {
        try { $reqNum = [long]$Requested } catch { $reqNum = [long]::MaxValue }
    }
    if ($reqNum -le 0) {
        return [pscustomobject]@{ effective_seconds = $default; clamped = $false; source = 'engine-default'; reason = "requested 0/absent -> engine default ${default}s (a supplier can never request an unlimited run)" }
    }
    if ($reqNum -gt $max) {
        return [pscustomobject]@{ effective_seconds = $max; clamped = $true; source = 'engine-max-clamp'; reason = "requested ${reqNum}s exceeds the engine max ${max}s -> clamped to ${max}s" }
    }
    return [pscustomobject]@{ effective_seconds = [int]$reqNum; clamped = $false; source = 'supplier-requested'; reason = $null }
}

# The path comparison for CONTAINMENT checks (review finding f1, run 20260714T172315119) comes from
# the ONE path-identity primitive, loaded above.
#
# A SECOND function of this exact name used to live here, taking no parameters and keying off
# `$IsWindows`. Because `_load.ps1` loads this file AFTER path-identity.ps1, that duplicate SHADOWED
# the real primitive for every consumer in a loaded context - and since it declared no `param()`
# block, PowerShell silently swallowed the `-Path`/`-WhenUndetermined` arguments its callers passed
# instead of failing. Every call site that had been "routed through the primitive" was therefore
# still getting the OS-family answer, which is precisely the defect the primitive exists to remove
# and the reason this class kept reappearing one call site at a time across five review rounds.
# There must be exactly ONE definition of this name in the tree; a structural test now enforces it.

# CLOSED-SCHEMA property enforcement (review finding f5, run 20260714T172315119): the authoritative
# verification-plan.schema.json declares additionalProperties:false at every level - an unknown property
# (including a literal secret-bearing map under ANY name) must be REJECTED at the validation boundary,
# not just the two hard-coded env/environment names. Returns the unknown property names (empty = clean).
function Get-ContinuousCoReviewUnknownProperties {
    param([Parameter(Mandatory)][AllowNull()]$Object, [Parameter(Mandatory)][string[]]$Allowed)
    if ($null -eq $Object) { return @() }
    $names = if ($Object -is [System.Collections.IDictionary]) { @($Object.Keys | ForEach-Object { [string]$_ }) }
    else { @($Object.PSObject.Properties.Name) }
    return @($names | Where-Object { $_ -notin $Allowed })
}

# PRESENCE test (review finding f1, run 20260714T180554025): schema conformance is about a property EXISTING,
# not its value being non-null - '"env": null' is as forbidden as a populated map under additionalProperties:false.
function Test-ContinuousCoReviewPropertyPresent {
    param([Parameter(Mandatory)][AllowNull()]$Object, [Parameter(Mandatory)][string]$Name)
    if ($null -eq $Object) { return $false }
    if ($Object -is [System.Collections.IDictionary]) { return $Object.Contains($Name) }
    return ($null -ne $Object.PSObject.Properties[$Name])
}

# SCHEMA TYPE checks (review finding f2, run 20260714T180554025): the authoritative schema types every field;
# the in-code validator must VALIDATE them, never coerce - '"require_result": "false"' casting to $true and a
# numeric command_id stringifying are exactly the schema-invalid-but-accepted class. JSON integers arrive as
# int/long from ConvertFrom-Json; PS-built plans must supply real types too.
function Test-ContinuousCoReviewIsSchemaString {
    param([AllowNull()]$Value)
    return ($Value -is [string])
}
function Test-ContinuousCoReviewIsSchemaInteger {
    param([AllowNull()]$Value)
    # BigInteger included (review finding f2, run 20260714T193411985): a JSON integer beyond Int64 parses as
    # BigInteger and is schema-valid where the contract sets no maximum; policy clamping bounds it downstream.
    return (($Value -is [int]) -or ($Value -is [long]) -or ($Value -is [int16]) -or ($Value -is [byte]) -or ($Value -is [System.Numerics.BigInteger]))
}

# PATH SAFETY (FR-048 amendment 4). A working_directory / result_path MUST be repository-relative and
# resolve INSIDE RepoRoot. REJECTED when: null-safe-empty is fine; a rooted/absolute path; a `..`
# escape (checked lexically via GetFullPath so a not-yet-created path is still guarded); or — for a
# path that already exists — a symlink/junction whose real target resolves OUTSIDE RepoRoot. Containment
# comparisons are PLATFORM-APPROPRIATE (case-sensitive off Windows). Returns
# { safe; reason; canonical_relative }.
function Test-ContinuousCoReviewVerificationPathSafe {
    param(
        [Parameter(Mandatory)][string]$RepoRoot,
        [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$Path
    )
    if ([string]::IsNullOrWhiteSpace($Path)) {
        return [pscustomobject]@{ safe = $true; reason = $null; canonical_relative = '' }
    }
    # Rooted/absolute (drive-letter, UNC, or leading slash) is rejected up front for a crisp reason.
    if ([System.IO.Path]::IsPathRooted($Path)) {
        return [pscustomobject]@{ safe = $false; reason = "path '$Path' is absolute/rooted; must be repository-relative"; canonical_relative = $null }
    }
    $rootFull = ([System.IO.Path]::GetFullPath($RepoRoot)).TrimEnd([char]'\', [char]'/')
    $rootPrefix = $rootFull + [System.IO.Path]::DirectorySeparatorChar
    # 'same' is the refusing direction for a containment guard: when the volume cannot be
    # determined, treat case variants as one path so an aliased path is refused, never admitted.
    $pathCmp = Get-ContinuousCoReviewPathComparison -Path $rootFull -WhenUndetermined 'same'
    $combined = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($rootFull, $Path))
    # LEXICAL escape guard: after canonicalizing '..', the path must still sit under the root prefix.
    if (-not ($combined.Equals($rootFull, $pathCmp) -or $combined.StartsWith($rootPrefix, $pathCmp))) {
        return [pscustomobject]@{ safe = $false; reason = "path '$Path' escapes the repository root via '..'"; canonical_relative = $null }
    }
    # SYMLINK/JUNCTION escape guard on EVERY existing path COMPONENT, not only the final item (review finding
    # f1, run 20260714T123137002): an escaping link ANYWHERE in the chain re-roots everything BELOW it outside
    # the repository, and a path whose final item is an ordinary file/dir under such a link would previously
    # read as safe. Walk root-down; when a component is a link, resolve its FINAL target, require the target
    # inside RepoRoot, and CONTINUE the walk from the resolved location so links nested below a linked
    # directory are validated too. A not-yet-created suffix stops the walk (the lexical guard above already
    # bounds it). Only resolvable for components that exist on disk.
    $relSuffix = $combined.Substring($rootFull.Length).TrimStart([char]'\', [char]'/')
    $currentReal = $rootFull
    foreach ($component in ($relSuffix -split '[\\/]+')) {
        if ([string]::IsNullOrWhiteSpace($component)) { continue }
        $currentReal = [System.IO.Path]::Combine($currentReal, $component)
        if (-not (Test-Path -LiteralPath $currentReal)) { break }
        try {
            $item = Get-Item -LiteralPath $currentReal -Force -ErrorAction Stop
            $target = $item.ResolveLinkTarget($true)
            if ($null -ne $target) {
                $real = ([System.IO.Path]::GetFullPath($target.FullName)).TrimEnd([char]'\', [char]'/')
                if (-not ($real.Equals($rootFull, $pathCmp) -or ($real + [System.IO.Path]::DirectorySeparatorChar).StartsWith($rootPrefix, $pathCmp))) {
                    return [pscustomobject]@{ safe = $false; reason = "path '$Path' traverses component '$component' which resolves via a symlink/junction OUTSIDE the repository root"; canonical_relative = $null }
                }
                # keep walking through the RESOLVED location so nested links are validated against the root too.
                $currentReal = $real
            }
        }
        catch { $null = $_ }
    }
    $canonical = $combined.Substring($rootFull.Length).TrimStart([char]'\', [char]'/').Replace('\', '/')
    return [pscustomobject]@{ safe = $true; reason = $null; canonical_relative = $canonical }
}

# PROVENANCE OBJECT validation (FR-048 amendment 6). provenance MUST be an object (never a bare string):
# { kind (one of the four values), source (required non-empty), provider (required when
# kind='provider-gated'), profile (required when kind='profile-selected') }. Returns { valid; reason }.
function Test-ContinuousCoReviewVerificationProvenance {
    param([Parameter(Mandatory)][AllowNull()]$Provenance)
    if ($null -eq $Provenance) {
        return [pscustomobject]@{ valid = $false; reason = 'provenance is required (an object { kind, source, ... })' }
    }
    if ($Provenance -is [string]) {
        return [pscustomobject]@{ valid = $false; reason = 'provenance must be an OBJECT { kind, source, ... }, not a bare string/enum' }
    }
    # CLOSED KEY SET (finding f5): the schema declares additionalProperties:false on provenance.
    $unknownProv = Get-ContinuousCoReviewUnknownProperties -Object $Provenance -Allowed @('kind', 'source', 'provider', 'profile')
    if (@($unknownProv).Count -gt 0) {
        return [pscustomobject]@{ valid = $false; reason = "provenance carries unknown propert$(if (@($unknownProv).Count -gt 1) { 'ies' } else { 'y' }) '$($unknownProv -join "', '")' (the schema is CLOSED: kind, source, provider, profile only)" }
    }
    # SCHEMA TYPES (finding f2, run 20260714T180554025): every provenance field is a string - a numeric
    # source (etc.) must be REJECTED, never silently stringified.
    foreach ($sf in @('kind', 'source', 'provider', 'profile')) {
        if ((Test-ContinuousCoReviewPropertyPresent -Object $Provenance -Name $sf) -and -not (Test-ContinuousCoReviewIsSchemaString -Value (Get-ContinuousCoReviewVerificationRawProp -Object $Provenance -Name $sf))) {
            return [pscustomobject]@{ valid = $false; reason = "provenance.$sf must be a STRING (schema type; values are validated, never coerced)" }
        }
    }
    $kind = [string](Get-ContinuousCoReviewContractProp -Object $Provenance -Name 'kind')
    if ($kind -notin (Get-ContinuousCoReviewVerificationProvenanceValues)) {
        return [pscustomobject]@{ valid = $false; reason = "provenance.kind '$kind' is not one of: $((Get-ContinuousCoReviewVerificationProvenanceValues) -join ', ')" }
    }
    $source = [string](Get-ContinuousCoReviewContractProp -Object $Provenance -Name 'source')
    if ([string]::IsNullOrWhiteSpace($source)) {
        return [pscustomobject]@{ valid = $false; reason = 'provenance.source is required (the config path / detection signal / profile name / provider id)' }
    }
    if ($kind -eq 'provider-gated') {
        $provider = [string](Get-ContinuousCoReviewContractProp -Object $Provenance -Name 'provider')
        if ([string]::IsNullOrWhiteSpace($provider)) {
            return [pscustomobject]@{ valid = $false; reason = "provenance.provider is required when kind='provider-gated'" }
        }
    }
    if ($kind -eq 'profile-selected') {
        $profile = [string](Get-ContinuousCoReviewContractProp -Object $Provenance -Name 'profile')
        if ([string]::IsNullOrWhiteSpace($profile)) {
            return [pscustomobject]@{ valid = $false; reason = "provenance.profile is required when kind='profile-selected'" }
        }
    }
    return [pscustomobject]@{ valid = $true; reason = $null }
}

# Validate ONE VerificationCommand. Invalid when: the object is null; command_id is null/empty;
# executable is null/empty; arguments is present but NOT a string array (a single shell string is
# rejected); provenance fails Test-...VerificationProvenance; a literal env/environment map is present
# (secrets forbidden); env_refs is present but not an array of non-empty NAMES (no 'NAME=value'
# literals); or a working_directory/result_path is path-UNSAFE. -RepoRoot enables real path resolution;
# when omitted, path safety falls back to a non-existent sentinel root so lexical escapes are still
# caught. Pure (except the path-safety filesystem symlink check). Returns { valid; reason }.
function Test-ContinuousCoReviewVerificationCommand {
    param(
        [Parameter(Mandatory)][AllowNull()]$Command,
        [string]$RepoRoot
    )
    if ($null -eq $Command) {
        return [pscustomobject]@{ valid = $false; reason = 'command is null' }
    }
    # CLOSED KEY SET (review finding f5, run 20260714T172315119): the schema declares
    # additionalProperties:false - an unknown property (including a literal secret-bearing map under ANY
    # name, not only the two hard-coded env/environment names below) is REJECTED at the boundary. The
    # env/environment special-case stays AFTER this for its clearer, teaching error message.
    $unknownCmd = Get-ContinuousCoReviewUnknownProperties -Object $Command -Allowed @('command_id', 'executable', 'arguments', 'working_directory', 'timeout_seconds', 'result_path', 'require_result', 'provenance', 'env_refs', 'label', 'env', 'environment')
    if (@($unknownCmd).Count -gt 0) {
        return [pscustomobject]@{ valid = $false; reason = "command carries unknown propert$(if (@($unknownCmd).Count -gt 1) { 'ies' } else { 'y' }) '$($unknownCmd -join "', '")' (the schema is CLOSED; no secret values can ride an unrecognized field)" }
    }
    # SCHEMA TYPES (review finding f2, run 20260714T180554025): validate, never coerce. String fields must BE
    # strings (a numeric command_id is invalid, not '123'); timeout_seconds is a nonnegative INTEGER;
    # require_result is a BOOLEAN ('"false"' must not cast to $true).
    foreach ($sf in @('command_id', 'executable', 'working_directory', 'result_path', 'label')) {
        if ((Test-ContinuousCoReviewPropertyPresent -Object $Command -Name $sf) -and -not (Test-ContinuousCoReviewIsSchemaString -Value (Get-ContinuousCoReviewVerificationRawProp -Object $Command -Name $sf))) {
            return [pscustomobject]@{ valid = $false; reason = "$sf must be a STRING (schema type; values are validated, never coerced)" }
        }
    }
    if (Test-ContinuousCoReviewPropertyPresent -Object $Command -Name 'timeout_seconds') {
        $toRaw = Get-ContinuousCoReviewVerificationRawProp -Object $Command -Name 'timeout_seconds'
        if (-not (Test-ContinuousCoReviewIsSchemaInteger -Value $toRaw)) {
            return [pscustomobject]@{ valid = $false; reason = 'timeout_seconds must be an INTEGER (schema type; never coerced from a string/decimal)' }
        }
        if ($toRaw -lt 0) {
            return [pscustomobject]@{ valid = $false; reason = 'timeout_seconds must be >= 0 (schema minimum)' }
        }
    }
    if (Test-ContinuousCoReviewPropertyPresent -Object $Command -Name 'require_result') {
        $rrRaw = Get-ContinuousCoReviewVerificationRawProp -Object $Command -Name 'require_result'
        if ($rrRaw -isnot [bool]) {
            return [pscustomobject]@{ valid = $false; reason = "require_result must be a BOOLEAN (schema type; the string 'false' would coerce to `$true - validated, never coerced)" }
        }
    }
    $commandId = [string](Get-ContinuousCoReviewContractProp -Object $Command -Name 'command_id')
    if ([string]::IsNullOrWhiteSpace($commandId)) {
        return [pscustomobject]@{ valid = $false; reason = 'command_id is required (a stable id, unique within the plan)' }
    }
    $executable = [string](Get-ContinuousCoReviewContractProp -Object $Command -Name 'executable')
    if ([string]::IsNullOrWhiteSpace($executable)) {
        return [pscustomobject]@{ valid = $false; reason = 'executable is null or empty (a command MUST name a non-empty executable)' }
    }

    # ARGUMENTS: strictly a string ARRAY. A single string is the shell-string smell and is rejected.
    $argsRaw = Get-ContinuousCoReviewVerificationRawProp -Object $Command -Name 'arguments'
    if ($null -ne $argsRaw) {
        if (($argsRaw -is [string]) -or ($argsRaw -isnot [System.Collections.IEnumerable]) -or ($argsRaw -is [System.Collections.IDictionary])) {
            return [pscustomobject]@{ valid = $false; reason = 'arguments must be a string ARRAY, not a shell string (use an explicit interpreter, e.g. pwsh -File ... / bash -lc ...)' }
        }
        foreach ($a in @($argsRaw)) {
            if ($a -isnot [string]) {
                return [pscustomobject]@{ valid = $false; reason = 'every arguments entry must be a string' }
            }
        }
    }

    # PROVENANCE object.
    $prov = Get-ContinuousCoReviewContractProp -Object $Command -Name 'provenance'
    $provCheck = Test-ContinuousCoReviewVerificationProvenance -Provenance $prov
    if (-not $provCheck.valid) {
        return [pscustomobject]@{ valid = $false; reason = $provCheck.reason }
    }

    # NO SECRETS: a literal env/environment map is forbidden; only env_refs (NAMES) are allowed. PRESENCE-based
    # (review finding f1, run 20260714T180554025): '"env": null' is as forbidden as a populated map - the schema
    # sets additionalProperties:false, so the PROPERTY existing is the violation, not its value.
    foreach ($forbidden in @('env', 'environment')) {
        if (Test-ContinuousCoReviewPropertyPresent -Object $Command -Name $forbidden) {
            return [pscustomobject]@{ valid = $false; reason = "a literal '$forbidden' map is forbidden (no secret values in a plan); declare env var NAMES via env_refs instead" }
        }
    }
    $envRefs = Get-ContinuousCoReviewVerificationRawProp -Object $Command -Name 'env_refs'
    if ($null -ne $envRefs) {
        if (($envRefs -is [string]) -or ($envRefs -isnot [System.Collections.IEnumerable]) -or ($envRefs -is [System.Collections.IDictionary])) {
            return [pscustomobject]@{ valid = $false; reason = 'env_refs must be an ARRAY of env var NAMES' }
        }
        foreach ($n in @($envRefs)) {
            if (($n -isnot [string]) -or [string]::IsNullOrWhiteSpace([string]$n)) {
                return [pscustomobject]@{ valid = $false; reason = 'each env_refs entry must be a non-empty env var NAME' }
            }
            if (([string]$n).Contains('=')) {
                return [pscustomobject]@{ valid = $false; reason = "env_refs entry '$n' looks like a literal 'NAME=value' — only NAMES are allowed (no secret values)" }
            }
        }
    }

    # PATH SAFETY for working_directory + result_path.
    $sentinelRoot = if ([string]::IsNullOrWhiteSpace($RepoRoot)) { [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), '__ccr_nonexistent_repo_root__') } else { $RepoRoot }
    foreach ($pathField in @('working_directory', 'result_path')) {
        $pv = [string](Get-ContinuousCoReviewContractProp -Object $Command -Name $pathField)
        if (-not [string]::IsNullOrWhiteSpace($pv)) {
            $safe = Test-ContinuousCoReviewVerificationPathSafe -RepoRoot $sentinelRoot -Path $pv
            if (-not $safe.safe) {
                return [pscustomobject]@{ valid = $false; reason = "$pathField unsafe: $($safe.reason)" }
            }
        }
    }

    return [pscustomobject]@{ valid = $true; reason = $null }
}

# StrictMode-safe test: is the value an ordered list (not a string, not a dictionary)?
function Test-ContinuousCoReviewVerificationIsCommandList {
    param([AllowNull()]$Value)
    return ($Value -is [System.Collections.IEnumerable]) -and ($Value -isnot [string]) -and ($Value -isnot [System.Collections.IDictionary])
}

# Validate a whole VerificationPlan STRUCTURALLY. Invalid when: the plan is null; plan_id is null/empty;
# commands is not a list; ANY command is invalid; or command_ids are DUPLICATED across the plan. ORDER
# is preserved (walked as declared; the first invalid command is reported by its index) and NEVER
# sorted. Pure (path safety aside). Returns { valid; reason; command_count }.
function Test-ContinuousCoReviewVerificationPlan {
    param(
        [Parameter(Mandatory)][AllowNull()]$Plan,
        [string]$RepoRoot
    )
    if ($null -eq $Plan) {
        return [pscustomobject]@{ valid = $false; reason = 'plan is null'; command_count = 0 }
    }
    # SCHEMA VERSION + CLOSED KEY SET (review finding f5, run 20260714T172315119): the authoritative
    # schema requires schema_version const '1.0' and additionalProperties:false at the plan level.
    $svRaw = Get-ContinuousCoReviewVerificationRawProp -Object $Plan -Name 'schema_version'
    if ((Test-ContinuousCoReviewPropertyPresent -Object $Plan -Name 'schema_version') -and -not (Test-ContinuousCoReviewIsSchemaString -Value $svRaw)) {
        return [pscustomobject]@{ valid = $false; reason = 'plan schema_version must be the STRING ''1.0'' (schema type; a numeric 1.0 is not the contract const)'; command_count = 0 }
    }
    $schemaVersion = [string]$svRaw
    if ($schemaVersion -cne '1.0') {
        $svReason = if ([string]::IsNullOrWhiteSpace($schemaVersion)) { 'plan schema_version is required (exactly ''1.0'' for this contract)' } else { "plan schema_version '$schemaVersion' is unsupported (exactly '1.0' for this contract; a future shape bumps it)" }
        return [pscustomobject]@{ valid = $false; reason = $svReason; command_count = 0 }
    }
    $unknownPlan = Get-ContinuousCoReviewUnknownProperties -Object $Plan -Allowed @('schema_version', 'plan_id', 'commands')
    if (@($unknownPlan).Count -gt 0) {
        return [pscustomobject]@{ valid = $false; reason = "plan carries unknown propert$(if (@($unknownPlan).Count -gt 1) { 'ies' } else { 'y' }) '$($unknownPlan -join "', '")' (the schema is CLOSED: schema_version, plan_id, commands only)"; command_count = 0 }
    }
    if ((Test-ContinuousCoReviewPropertyPresent -Object $Plan -Name 'plan_id') -and -not (Test-ContinuousCoReviewIsSchemaString -Value (Get-ContinuousCoReviewVerificationRawProp -Object $Plan -Name 'plan_id'))) {
        return [pscustomobject]@{ valid = $false; reason = 'plan_id must be a STRING (schema type; values are validated, never coerced)'; command_count = 0 }
    }
    $planId = [string](Get-ContinuousCoReviewContractProp -Object $Plan -Name 'plan_id')
    if ([string]::IsNullOrWhiteSpace($planId)) {
        return [pscustomobject]@{ valid = $false; reason = 'plan_id is required (a stable plan identity)'; command_count = 0 }
    }
    $commands = Get-ContinuousCoReviewVerificationRawProp -Object $Plan -Name 'commands'
    if (-not (Test-ContinuousCoReviewVerificationIsCommandList -Value $commands)) {
        return [pscustomobject]@{ valid = $false; reason = 'plan commands is not a list (a VerificationPlan MUST carry an ordered commands array)'; command_count = 0 }
    }
    $ordered = @($commands)   # preserve DECLARED order; never sort
    $seenIds = @{}
    for ($i = 0; $i -lt $ordered.Count; $i++) {
        $check = Test-ContinuousCoReviewVerificationCommand -Command $ordered[$i] -RepoRoot $RepoRoot
        if (-not $check.valid) {
            return [pscustomobject]@{ valid = $false; reason = "command at index $i is invalid: $($check.reason)"; command_count = $ordered.Count }
        }
        $cid = [string](Get-ContinuousCoReviewContractProp -Object $ordered[$i] -Name 'command_id')
        if ($seenIds.ContainsKey($cid)) {
            return [pscustomobject]@{ valid = $false; reason = "duplicate command_id '$cid' (command_id must be unique within the plan)"; command_count = $ordered.Count }
        }
        $seenIds[$cid] = $true
    }
    return [pscustomobject]@{ valid = $true; reason = $null; command_count = $ordered.Count }
}

# Resolve the CONFIGURATION STATE of a plan: is there any runnable verification, or not? This is the
# gate the executor consults. `verification-not-configured` when the plan is null, its commands are not
# a list, the list is EMPTY, or NO command is valid — an empty plan is the EXPLICIT not-configured
# state, NEVER a silent success. `configured` when at least one command is valid. Pure (path safety
# aside). Returns { state; command_count; reason }.
function Resolve-ContinuousCoReviewVerificationPlanState {
    param(
        [Parameter(Mandatory)][AllowNull()]$Plan,
        [string]$RepoRoot
    )
    if ($null -eq $Plan) {
        return [pscustomobject]@{ state = 'verification-not-configured'; command_count = 0; reason = 'plan is null' }
    }
    $commands = Get-ContinuousCoReviewVerificationRawProp -Object $Plan -Name 'commands'
    if (-not (Test-ContinuousCoReviewVerificationIsCommandList -Value $commands)) {
        return [pscustomobject]@{ state = 'verification-not-configured'; command_count = 0; reason = 'plan declares no commands list' }
    }
    $ordered = @($commands)
    if ($ordered.Count -eq 0) {
        return [pscustomobject]@{ state = 'verification-not-configured'; command_count = 0; reason = 'plan declares zero commands (an empty plan is the explicit verification-not-configured state, never a silent success)' }
    }
    $validCount = 0
    foreach ($c in $ordered) {
        if ((Test-ContinuousCoReviewVerificationCommand -Command $c -RepoRoot $RepoRoot).valid) { $validCount++ }
    }
    if ($validCount -eq 0) {
        return [pscustomobject]@{ state = 'verification-not-configured'; command_count = $ordered.Count; reason = 'plan declares commands but none are valid' }
    }
    return [pscustomobject]@{ state = 'configured'; command_count = $ordered.Count; reason = $null }
}

# T019 EVIDENCE-JOIN validator (FR-048 amendment 10). Given the plan's per-command execution evidence,
# the plan, and the CURRENT reviewed-tree digest, decide per record whether it is injectable. REJECTS a
# record that is: DIGEST-MISMATCHED (record digest empty or != current — absolute precedence, matching
# the review-identity evidence contract); UNJOINABLE (command_id empty or not a command in the plan); or
# a DUPLICATE (its command_id appears more than once WITHOUT distinct attempt numbers — ambiguous, so
# EVERY occurrence is refused). ATTEMPT HISTORY (review finding f2, run 20260714T201103653): same-id
# records carrying DISTINCT `attempt` numbers are legitimate history, not ambiguity - the LATEST attempt
# is the injectable authoritative record and earlier attempts classify `attempt-superseded-history`
# (kept visible downstream, never treated as the current verdict, never erased). Returns an ordered
# array of { command_id; injectable; classification }.
function Test-ContinuousCoReviewPlanEvidenceInjectable {
    param(
        [Parameter(Mandatory)][AllowNull()]$PlanEvidence,
        [Parameter(Mandatory)][AllowNull()]$Plan,
        [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$CurrentDigest
    )
    # The plan's declared command_id set (the only ids a record may join to).
    $planIds = @{}
    $planCommands = Get-ContinuousCoReviewVerificationRawProp -Object $Plan -Name 'commands'
    foreach ($c in @($planCommands)) {
        if ($null -eq $c) { continue }
        $cid = [string](Get-ContinuousCoReviewContractProp -Object $c -Name 'command_id')
        if (-not [string]::IsNullOrWhiteSpace($cid)) { $planIds[$cid] = $true }
    }
    # Group same-id occurrences: DISTINCT attempt numbers = legitimate history (latest wins, earlier =
    # superseded history); same-id occurrences WITHOUT distinct attempts = ambiguous duplicates (all refused).
    $idGroups = @{}
    foreach ($rec in @($PlanEvidence)) {
        if ($null -eq $rec) { continue }
        $cid = [string](Get-ContinuousCoReviewContractProp -Object $rec -Name 'command_id')
        if ([string]::IsNullOrWhiteSpace($cid)) { continue }
        if (-not $idGroups.ContainsKey($cid)) { $idGroups[$cid] = New-Object System.Collections.Generic.List[object] }
        $idGroups[$cid].Add($rec) | Out-Null
    }
    $idVerdicts = @{}   # cid -> @{ ambiguous = bool; latest = <rec> }
    foreach ($cid in @($idGroups.Keys)) {
        # .ToArray(), not @(): the @() wrap over a generic List here trips a PS binder 'Argument types do not
        # match' - ToArray is the deterministic materialization.
        $group = $idGroups[$cid].ToArray()
        if ($group.Count -le 1) { $idVerdicts[$cid] = @{ ambiguous = $false; latest = $group[0] }; continue }
        $attempts = @($group | ForEach-Object { $a = Get-ContinuousCoReviewContractProp -Object $_ -Name 'attempt'; if ($null -ne $a) { [int]$a } else { $null } })
        $allNumbered = (@($attempts | Where-Object { $null -eq $_ }).Count -eq 0)
        $distinct = (@($attempts | Sort-Object -Unique).Count -eq $group.Count)   # specrew-dedup-not-a-path
        if ($allNumbered -and $distinct) {
            $latest = $group[0]; $latestA = [int]$attempts[0]
            for ($gi = 1; $gi -lt $group.Count; $gi++) { if ([int]$attempts[$gi] -gt $latestA) { $latest = $group[$gi]; $latestA = [int]$attempts[$gi] } }
            $idVerdicts[$cid] = @{ ambiguous = $false; latest = $latest }
        }
        else { $idVerdicts[$cid] = @{ ambiguous = $true; latest = $null } }
    }

    $results = @()
    foreach ($rec in @($PlanEvidence)) {
        $cid = [string](Get-ContinuousCoReviewContractProp -Object $rec -Name 'command_id')
        $recDigest = [string](Get-ContinuousCoReviewContractProp -Object $rec -Name 'reviewed_digest_tree_id')
        $injectable = $false
        if ([string]::IsNullOrWhiteSpace($CurrentDigest) -or [string]::IsNullOrWhiteSpace($recDigest) -or ($recDigest -cne $CurrentDigest)) {
            $classification = 'digest-mismatch-not-injected'
        }
        elseif ([string]::IsNullOrWhiteSpace($cid) -or (-not $planIds.ContainsKey($cid))) {
            $classification = 'unjoinable-no-matching-command'
        }
        elseif ($idVerdicts.ContainsKey($cid) -and [bool]$idVerdicts[$cid].ambiguous) {
            $classification = 'duplicate-command-id-surfaced'
        }
        elseif ($idVerdicts.ContainsKey($cid) -and ($null -ne $idVerdicts[$cid].latest) -and (-not [object]::ReferenceEquals($idVerdicts[$cid].latest, $rec))) {
            # earlier attempt of a legitimately re-run command: HISTORY - visible, never the current verdict.
            $classification = 'attempt-superseded-history'
        }
        else {
            $injectable = $true
            $classification = 'exact-digest-command-joined'
        }
        $results += [pscustomobject]@{ command_id = $cid; injectable = $injectable; classification = $classification }
    }
    # Emit the per-record results ENUMERATED so a caller's @(...) collects them FLAT (a `, @(...)` wrap
    # would surface as a single nested array object and collapse the count for multi-record inputs).
    return @($results)
}