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