scripts/internal/continuous-co-review/review-identity-contracts.ps1

# T019 — review identity, lineage, evidence, and artifact-lifecycle CONTRACTS (characterization slice).
#
# STATUS: the UNWIRED contract layer. Authored 2026-07-13; CONTRACT-CORRECTION pass 2026-07-13 after the
# maintainer's needs-rework review (wiring against the first cut would have encoded stale-authority + cleanup
# defects). Every function here is PURE (no filesystem/git/process I/O, no global state) and NOTHING in the
# shipped runtime calls it yet — it is deliberately NOT added to _load.ps1. Step 6 of T019 ("implement against
# those contracts") wires these decisions into the live navigator / Stop path / orchestrator; that is a SEPARATE
# slice not started. These functions are the executable specification the fixtures assert against.
#
# Corrections applied (maintainer review 2026-07-13):
# 1. baseline_tree_id (the tree the auto-fire diffs advance from = last ACCEPTED reviewed tree) is SEPARATE
# from commit refs (git ancestry keeps using reviewed_ref/baseline_ref commits).
# 2. digest-mismatch precedence is ABSOLUTE across every outcome: a stale result never blocks, requests a
# decision, or authorizes a packet.
# 3. finding->run joins FAIL CLOSED: source_run_id must equal run_id, and tree + baseline identities present.
# 4. a transient artifact is prunable ONLY after its owning run is terminal/reaped or explicitly abandoned.
# 5. a deterministic, persisted lineage id + a monotonic authority rule for same-digest concurrent completions.
# 6. injection validates the envelope digest AND every embedded suite/run digest.
#
# Full current->target characterization: iterations/003/research/t019-review-identity-and-artifact-lifecycle.md.

# StrictMode-safe property read: returns the property value or $null when absent.
function Get-ContinuousCoReviewContractProp {
    param([Parameter(Mandatory)]$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 -ne $prop) { return $prop.Value }
    return $null
}

# Reviewed-tree id, tolerating the two shipped spellings (durable run record `reviewed_tree_id`,
# status.json `reviewed_digest_tree_id`). Returns '' when neither is present.
function Get-ContinuousCoReviewRecordReviewedTreeId {
    param([Parameter(Mandatory)]$RunRecord)
    $t = [string](Get-ContinuousCoReviewContractProp -Object $RunRecord -Name 'reviewed_tree_id')
    if ([string]::IsNullOrWhiteSpace($t)) {
        $t = [string](Get-ContinuousCoReviewContractProp -Object $RunRecord -Name 'reviewed_digest_tree_id')
    }
    return $t
}

# ---------------------------------------------------------------------------------------------------
# Correction 1 — AUTO-FIRE BASELINE is a TREE-ID, separate from commit ancestry.
# The next auto-fire diffs advance from the last ACCEPTED reviewed TREE (a tree-id / digest), NOT a commit
# ref. Git ancestry (the lineage chain walk) continues to use commit refs (reviewed_ref / baseline_ref).
# When no accepted run exists, the runtime falls back to the merge-base anchor (a commit ref) — signalled here
# by a null baseline_tree_id so the caller uses the commit-ref path.
function Resolve-ContinuousCoReviewAutoFireBaselineTreeId {
    param([object[]]$AcceptedRuns = @())
    $accepted = @($AcceptedRuns | Where-Object { $null -ne $_ })
    if ($accepted.Count -eq 0) {
        return [pscustomobject]@{ baseline_tree_id = $null; from_run_id = $null; reason = 'no accepted run; fall back to the merge-base anchor (commit ref)' }
    }
    # "last accepted" = the accepted run with the max run_id (run_ids are fire-time-sortable, so this is monotone).
    $last = $accepted | Sort-Object { [string](Get-ContinuousCoReviewContractProp -Object $_ -Name 'run_id') } | Select-Object -Last 1
    return [pscustomobject]@{
        baseline_tree_id = Get-ContinuousCoReviewRecordReviewedTreeId -RunRecord $last
        from_run_id      = [string](Get-ContinuousCoReviewContractProp -Object $last -Name 'run_id')
        reason           = 'auto-fire diffs advance from this last-accepted reviewed TREE; ancestry uses commit refs separately'
    }
}

# Correction 5 (part a) + final-correction 3 — DETERMINISTIC, persisted lineage id from the STABLE lineage anchor
# + target, with inputs CANONICALIZED before hashing so aliases that resolve to the same commit yield the SAME id.
# The caller MUST pass the RESOLVED anchor COMMIT ID (git rev-parse — never a symbolic ref like HEAD/branch) and
# the canonical target identity; this pure function then normalizes the string form (commit id trimmed +
# lowercased; target trimmed, case-preserving since refs are case-sensitive on POSIX). The advancing
# baseline_tree_id CANNOT key the lineage; the resolved anchor + target do not change across the lineage.
function Get-ContinuousCoReviewLineageId {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$AnchorCommitId,
        [Parameter(Mandatory)][AllowEmptyString()][string]$TargetIdentity
    )
    $anchor = ([string]$AnchorCommitId).Trim().ToLowerInvariant()
    $target = ([string]$TargetIdentity).Trim()
    $material = "$anchor`n$target"
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($material)
    $hash = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($bytes)).Replace('-', '').ToLowerInvariant()
    return 'lin-' + $hash.Substring(0, 16)
}

# ---------------------------------------------------------------------------------------------------
# Correction 6 — EVIDENCE injection validates the envelope digest AND every embedded suite/run digest.
# An envelope claiming digest X that contains an embedded run/suite stamped digest Y is a surfaced mismatch,
# never injected. DRIFT-198-I003-002: a digest-A record (full OR subset) is never injected into a digest-B
# review; empty digests never match (fail-closed).
function Test-ContinuousCoReviewEvidenceInjectable {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$EnvelopeDigest,
        [Parameter(Mandatory)][AllowEmptyString()][string]$ReviewDigest,
        [string[]]$EmbeddedDigests = @(),
        [switch]$IsSubset
    )
    $envelopeMatch = (-not [string]::IsNullOrWhiteSpace($EnvelopeDigest)) -and ($EnvelopeDigest -ceq $ReviewDigest)
    if (-not $envelopeMatch) {
        $cls = if ($IsSubset) { 'partial-injection-mismatch-surfaced' } else { 'envelope-digest-mismatch-not-injected' }
        return [pscustomobject]@{ injectable = $false; classification = $cls }
    }
    foreach ($d in @($EmbeddedDigests)) {
        if ([string]::IsNullOrWhiteSpace([string]$d) -or ([string]$d -cne $ReviewDigest)) {
            return [pscustomobject]@{ injectable = $false; classification = 'embedded-digest-mismatch-surfaced' }
        }
    }
    return [pscustomobject]@{ injectable = $true; classification = 'exact-digest-injected' }
}

# ---------------------------------------------------------------------------------------------------
# RUN LINEAGE + in-flight dedup. At most ONE tracked in-flight review per lineage; a Stop-fired review that
# finds a running review for its lineage waits/polls it, never launches a duplicate.
function Test-ContinuousCoReviewInFlightDuplicate {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$LineageId,
        [object[]]$InFlightRegistry = @()
    )
    $existing = $null
    foreach ($entry in @($InFlightRegistry)) {
        if ($null -eq $entry) { continue }
        $entryLineage = [string](Get-ContinuousCoReviewContractProp -Object $entry -Name 'lineage_id')
        $entryStatus = [string](Get-ContinuousCoReviewContractProp -Object $entry -Name 'status')
        if (($entryLineage -eq $LineageId) -and ($entryStatus -eq 'running')) { $existing = $entry; break }
    }
    if ($null -ne $existing) {
        return [pscustomobject]@{
            is_duplicate    = $true
            existing_run_id = [string](Get-ContinuousCoReviewContractProp -Object $existing -Name 'run_id')
            launch          = $false
            action          = 'wait-poll-existing'
        }
    }
    return [pscustomobject]@{ is_duplicate = $false; existing_run_id = $null; launch = $true; action = 'launch' }
}

# Pure digest supersession: a completion is authoritative ONLY when its reviewed digest equals the current
# tree. A different digest is superseded (never a fresh block or authorization). Same-digest ties are resolved
# by Resolve-ContinuousCoReviewSameDigestAuthority (correction 5).
function Test-ContinuousCoReviewResultSuperseded {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$CompletingDigest,
        [Parameter(Mandatory)][AllowEmptyString()][string]$CurrentDigest
    )
    $authoritative = (-not [string]::IsNullOrWhiteSpace($CompletingDigest)) -and ($CompletingDigest -ceq $CurrentDigest)
    return [pscustomobject]@{
        authoritative  = $authoritative
        classification = if ($authoritative) { 'current' } else { 'superseded' }
    }
}

# Final-correction 1 — SAME-DIGEST authority is an atomically-acquired LINEAGE LEASE / generation, NOT a
# timestamp (max run_id) tie-break. A later clean run must NEVER erase an earlier blocking run by timestamp.
# The lease { lineage_id, generation, authoritative_run_id } is acquired + persisted BEFORE a reviewer is
# spawned; ONLY the lease owner (matching run_id AND generation) may become authoritative. A conflicting legacy
# same-digest terminal result (a non-owner, or a stale generation) FAILS CLOSED — never silently authoritative,
# and must be explicitly reconciled. (In step 6 the acquisition is atomic before spawn; this is the pure shape.)
function Grant-ContinuousCoReviewLineageLease {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$LineageId,
        [Parameter(Mandatory)][int]$Generation,
        [Parameter(Mandatory)][AllowEmptyString()][string]$RunId
    )
    return [pscustomobject]@{ lineage_id = $LineageId; generation = $Generation; authoritative_run_id = $RunId }
}

function Resolve-ContinuousCoReviewLeaseAuthority {
    param(
        [Parameter(Mandatory)]$Lease,
        [Parameter(Mandatory)][AllowEmptyString()][string]$CompletingRunId,
        [int]$CompletingGeneration = -1
    )
    $leaseRun = [string](Get-ContinuousCoReviewContractProp -Object $Lease -Name 'authoritative_run_id')
    $leaseGen = Get-ContinuousCoReviewContractProp -Object $Lease -Name 'generation'
    $genMatch = ($CompletingGeneration -lt 0) -or ($null -eq $leaseGen) -or ([int]$leaseGen -eq $CompletingGeneration)
    $isOwner = (-not [string]::IsNullOrWhiteSpace($CompletingRunId)) -and ($leaseRun -ceq $CompletingRunId) -and $genMatch
    if ($isOwner) {
        return [pscustomobject]@{ authoritative = $true; disposition = 'authoritative'; reason = $null }
    }
    return [pscustomobject]@{
        authoritative = $false
        disposition   = 'conflicting-fail-closed'
        reason        = 'not the lease owner for this generation; a legacy/conflicting same-digest result fails closed and must be explicitly reconciled'
    }
}

# The blocking-findings invariant, enforced SEPARATELY from authority: a CLEAN result must NEVER erase blocking
# findings by timestamp. Blocking findings recorded for a digest are STICKY — a clean incoming result preserves
# them (they require explicit resolution, never a later-timestamp overwrite). `erased` is false by contract.
function Test-ContinuousCoReviewBlockingPreserved {
    param(
        [int]$ExistingBlockingCount = 0,
        [Parameter(Mandatory)][AllowEmptyString()][string]$IncomingOutcome
    )
    $preserved = $ExistingBlockingCount -gt 0
    return [pscustomobject]@{
        blocking_preserved = $preserved
        erased             = $false
        reason             = if ($preserved -and ($IncomingOutcome -eq 'clean')) { 'a clean result never erases existing blocking findings by timestamp; they persist until explicitly resolved' } else { $null }
    }
}

# ---------------------------------------------------------------------------------------------------
# Correction 3 — PER-FINDING identity, FAIL-CLOSED. A finding's global identity is valid ONLY when
# source_run_id EXACTLY equals the run's run_id AND the reviewed tree AND baseline (tree) identities are both
# present. Any violation returns valid=$false with a reason; a mixed run set is separated per-finding only on
# valid joins.
function Get-ContinuousCoReviewFindingIdentity {
    param(
        [Parameter(Mandatory)]$Finding,
        [Parameter(Mandatory)]$RunRecord
    )
    $findingId = [string](Get-ContinuousCoReviewContractProp -Object $Finding -Name 'finding_id')
    $sourceRunId = [string](Get-ContinuousCoReviewContractProp -Object $Finding -Name 'source_run_id')
    $runId = [string](Get-ContinuousCoReviewContractProp -Object $RunRecord -Name 'run_id')
    $reviewedTreeId = Get-ContinuousCoReviewRecordReviewedTreeId -RunRecord $RunRecord
    $baselineTreeId = [string](Get-ContinuousCoReviewContractProp -Object $RunRecord -Name 'baseline_tree_id')

    $reason = $null
    if ([string]::IsNullOrWhiteSpace($sourceRunId) -or [string]::IsNullOrWhiteSpace($runId) -or ($sourceRunId -cne $runId)) {
        $reason = 'source_run_id must equal the run record run_id (fail-closed)'
    }
    elseif ([string]::IsNullOrWhiteSpace($reviewedTreeId)) {
        $reason = 'reviewed tree identity absent (fail-closed)'
    }
    elseif ([string]::IsNullOrWhiteSpace($baselineTreeId)) {
        $reason = 'baseline_tree_id identity absent (fail-closed)'
    }
    return [pscustomobject]@{
        valid            = [string]::IsNullOrEmpty($reason)
        reason           = $reason
        finding_id       = $findingId
        source_run_id    = $sourceRunId
        run_id           = $runId
        reviewed_tree_id = $reviewedTreeId
        baseline_tree_id = $baselineTreeId
        fingerprint      = [string](Get-ContinuousCoReviewContractProp -Object $Finding -Name 'fingerprint')
    }
}

# ---------------------------------------------------------------------------------------------------
# Correction 2 — FR-045 Stop-ordering routing, with ABSOLUTE digest-mismatch precedence. A stale result
# (reviewed digest != current) is superseded for EVERY outcome — it never blocks (actionable), requests a
# decision (human-judgment), reports as a failure to act on, or authorizes a packet (clean). Only a terminal
# result at the EXACT current digest routes by outcome. launch_review is ALWAYS false on the Stop path.
function Resolve-ContinuousCoReviewStopRouting {
    param(
        [Parameter(Mandatory)][bool]$ReviewTerminal,
        [Parameter(Mandatory)][AllowEmptyString()][string]$ReviewOutcome,
        [Parameter(Mandatory)][bool]$DigestMatchesCurrent,
        [bool]$InFlightPresent = $false,
        [bool]$HumanDispositioned = $false,
        [bool]$DispositionDurableAndDigestBound = $false
    )
    function New-Suppressed([string]$action) {
        [pscustomobject]@{ render_packet = $false; render_marker = $false; launch_review = $false; action = $action; capturable_as_verdict = $false }
    }
    function New-Packet([string]$action) {
        [pscustomobject]@{ render_packet = $true; render_marker = $true; launch_review = $false; action = $action; capturable_as_verdict = $true }
    }

    # Final-correction 2a: a REQUIRED review is absent AND nothing is in flight -> suppress packet/marker and
    # route to required-review-not-started (NOT wait-poll-existing, which implies a run exists to wait on).
    if ((-not $ReviewTerminal) -and (-not $InFlightPresent)) { return New-Suppressed 'required-review-not-started' }

    # Not terminal but a run IS in flight (running / duplicate Stop): wait, never a packet, never a duplicate.
    if (-not $ReviewTerminal) { return New-Suppressed 'wait-poll-existing' }

    # ABSOLUTE PRECEDENCE: a stale (digest-mismatched) terminal result of ANY outcome is superseded.
    if (-not $DigestMatchesCurrent) {
        $action = if ($InFlightPresent) { 'wait-poll-existing' } else { 're-review-current-digest' }
        return New-Suppressed $action
    }

    # Terminal AND at the exact current digest: route by outcome.
    switch ($ReviewOutcome) {
        'clean'          { return New-Packet 'render-boundary-packet' }
        'actionable'     { return New-Suppressed 'fix-and-re-review' }
        'human-judgment' {
            # Final-correction 2b: a human-DISPOSITIONED current-digest review renders the boundary packet ONLY
            # when the disposition is DURABLE and DIGEST-BOUND; otherwise it stays a narrow, non-capturable question.
            if ($HumanDispositioned -and $DispositionDurableAndDigestBound) { return New-Packet 'render-boundary-packet-human-dispositioned' }
            return New-Suppressed 'narrow-non-boundary-question'
        }
        'infra-failure'  { return New-Suppressed 'report-specific-failure' }
        default          { return New-Suppressed 'report-specific-failure' }
    }
}

# ---------------------------------------------------------------------------------------------------
# ARTIFACT lifecycle classes. Base class is path-static; disposition is digest + run-state driven.
function Get-ContinuousCoReviewArtifactClass {
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Path)
    $p = ([string]$Path).Replace('\', '/')
    $mk = {
        param($base, $tracked, $supersedable, $retention)
        [pscustomobject]@{ base_class = $base; git_tracked = $tracked; supersedable = $supersedable; retention = $retention }
    }
    if ($p -match '(^|/)\.specrew/review/pending(/|$)')      { return & $mk 'transient' $false $false 'prunable only after the owning run is terminal/reaped/abandoned' }
    if ($p -match '(^|/)\.specrew/runtime(/|$)')             { return & $mk 'transient' $false $false 'machine-local runtime state; prunable only when its owning cycle/run is terminal' }
    if ($p -match '(^|/)\.review(/|$)')                      { return & $mk 'transient' $false $false 'ephemeral disposable-worktree bundle; prunable only after the run ends' }
    if ($p -match '(^|/)\.specrew/review/inline/')           { return & $mk 'durable'   $true  $true  'retained until superseded by a later reviewed digest for its lineage; then archive or prune per T019 policy' }
    if ($p -match '(^|/)\.specrew/review/test-evidence/')    { return & $mk 'durable'   $true  $true  'retained for its digest; prunable once no live lineage references it (T019 policy)' }
    if ($p -match '(^|/)\.specrew/review/signoff-gate/')     { return & $mk 'durable'   $true  $false 'latest.json overwritten each decision; history/ append-only (not supersedable)' }
    return & $mk 'unknown' $false $false 'unclassified — a contract gap to resolve before it accumulates'
}

# Correction 4 — a durable record resolves to one of the five lifecycle states; a TRANSIENT record is
# prunable ONLY after its owning run is terminal/reaped or explicitly abandoned (never while the run is
# running). The archive-vs-prune WINDOW for obsolete durable records is a T019-owned policy input.
function Resolve-ContinuousCoReviewRecordDisposition {
    param(
        [Parameter(Mandatory)][ValidateSet('transient', 'durable', 'unknown')][string]$BaseClass,
        [bool]$IsLatestForLineage = $true,
        [ValidateSet('retain', 'archive', 'prune')][string]$ObsoletePolicy = 'retain',
        [ValidateSet('running', 'terminal', 'reaped', 'abandoned', 'unknown')][string]$OwningRunState = 'unknown'
    )
    if ($BaseClass -eq 'transient') {
        if ($OwningRunState -in @('terminal', 'reaped', 'abandoned')) { return 'prunable' }
        return 'transient'   # in-use while its owning run is running/unknown — NOT prunable
    }
    if ($BaseClass -eq 'unknown') { return 'unknown' }
    if ($IsLatestForLineage) { return 'durable' }
    switch ($ObsoletePolicy) {
        'archive' { return 'archived' }
        'prune'   { return 'prunable' }
        default   { return 'superseded' }
    }
}