extensions/specrew-speckit/scripts/design-decision-store.ps1

# THE DESIGN-DECISION RECORD - the artifact a co-decided design choice lands in, and the rules that make it
# checkable.
#
# WHY THIS EXISTS. At the design-analysis stop the methodology requires a real choice from the human:
# design-workshop.md's co-design rule says in as many words *do NOT hand down finished options*. The
# machinery offered nowhere to put the answer. `approved for plan with Option 2` is defined nowhere; the
# boundary approval anchor admits an option only as a LEADING prefix and caps it at 1-2, so a three-option
# decision cannot be expressed at all and the trailing form falls inside the boundary name. So crews wrote
# the choice into design-analysis.md as prose - the one place nothing validates - and the gate could not see
# that a decision had been made, by whom, or between what (B4F-045).
#
# ITS OWN PHRASE FAMILY, SEPARATE BY CONSTRUCTION. A design decision is not a boundary verdict: it settles
# what to build, not permission to proceed, and conflating them would let one typed sentence do both. The
# separation is structural rather than conventional - the decision phrase begins with a word the approval
# anchor cannot match, so no string can ever be read as both. Test-SpecrewDesignDecisionPhrase and the
# boundary anchor are asserted disjoint in BOTH directions by the suite, because a separation that holds
# only in the direction someone happened to test is not a separation.
#
# WHAT THE RULES ENFORCE, and each one is the co-design rule made checkable:
# - at least TWO options, because a choice between one thing is a hand-down wearing a choice's clothes;
# - the chosen option must be one of the options RECORDED, so a decision cannot name something the human
# was never shown;
# - a rationale, because the value of the record is why, not which.

$script:SpecrewDesignDecisionSchemaVersion = '1.0'

# Timestamps are read through the one helper beside this file - see timestamp-read.ps1 for the three sites
# that were each wrong in their own way about a value ConvertFrom-Json had coerced.
$script:SpecrewDesignDecisionTimestampReadPath = Join-Path $PSScriptRoot 'timestamp-read.ps1'
if (-not (Get-Command ConvertTo-SpecrewUtcTimestamp -ErrorAction SilentlyContinue) -and
    (Test-Path -LiteralPath $script:SpecrewDesignDecisionTimestampReadPath -PathType Leaf)) {
    . $script:SpecrewDesignDecisionTimestampReadPath
}

# THE CONFIRMATION VALUE NAMES WHAT IT ACTUALLY IS, AND IT IS NOT `human-confirmed`.
#
# No hook captures a receipt for a design decision yet. What this record carries is the CREW'S DECLARATION
# of what the human typed - the agent's word for the human's - and calling that `human-confirmed` would
# claim an authority no artifact supports. This batch has already measured what that claim costs: five
# `human-confirmed` receipts were minted for a workshop question that had never been asked, and the record
# could not tell them from real ones because both validated identically.
#
# So the value is `crew-declared-human-reply`, which a reader can weigh correctly without being told.
# BETA5 closes the gap: `design decision - option N` becomes a reserved phrase in the handover store, the
# hook mints a receipt for it like any other typed turn, and this record cites that receipt - at which
# point the value can honestly become a confirmation.
$script:SpecrewDesignDecisionConfirmation = 'crew-declared-human-reply'
$script:SpecrewDesignDecisionScope = 'design-decision'

function Get-SpecrewDesignDecisionPath {
    [OutputType([string])]
    param([Parameter(Mandatory)][string] $ProjectRoot, [Parameter(Mandatory)][string] $FeatureRef)
    return (Join-Path (Join-Path (Join-Path $ProjectRoot 'specs') (Split-Path -Leaf $FeatureRef)) 'design-decisions.json')
}

function Test-SpecrewDesignDecisionPhrase {
    # THE PHRASE FAMILY. It opens with `design decision`, which the boundary anchor - built around the verb
    # `approv(e|ed|es)` - cannot match under any input. That is the entire collision-avoidance mechanism, and
    # it is deliberately a property of the grammar rather than a rule someone has to remember.
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string] $Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $false }
    return ($Text -imatch '^\s*design\s+decision\b')
}

function Test-SpecrewDesignDecisionRecord {
    # THE GATE'S VIEW. Returns a result object rather than a bare boolean so a refusal can name which rule
    # failed - the standard this batch keeps rediscovering: a check that reports only "invalid" makes the
    # reader guess, and they guess wrong.
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][AllowNull()]$Record)

    function New-Result { param([bool]$Valid, [string]$Reason) [pscustomobject]@{ valid = $Valid; reason = $Reason } }

    if ($null -eq $Record -or $Record -is [System.Array] -or $Record -is [string] -or $Record -is [ValueType]) {
        return (New-Result -Valid $false -Reason 'design-decision-record-invalid')
    }
    foreach ($required in @('question', 'chosen', 'rationale', 'confirmation', 'confirmation_scope', 'human_turn')) {
        $property = $Record.PSObject.Properties[$required]
        if (-not $property -or $property.Value -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$property.Value)) {
            return (New-Result -Valid $false -Reason ("design-decision-{0}-missing" -f ($required -replace '_', '-')))
        }
    }
    # `recorded_at` is checked as a READABLE TIMESTAMP, not as a [string], and the difference is not
    # pedantry: ConvertFrom-Json silently converts an ISO-8601 timestamp into a [datetime], so a record this
    # very script had just written failed its own read-back with `recorded-at-missing`. The writer's verify
    # step caught it before anything shipped, which is the argument for having a verify step - but a type
    # check that a correct round-trip cannot survive is the check being wrong, not the data. It was the first
    # of three such sites in one batch; all three now read through ConvertTo-SpecrewUtcTimestamp, which
    # accepts whatever shape survived JSON and answers the only question that matters: when.
    $recordedAtProperty = $Record.PSObject.Properties['recorded_at']
    if (-not $recordedAtProperty -or $null -eq $recordedAtProperty.Value -or
        [string]::IsNullOrWhiteSpace([string]$recordedAtProperty.Value)) {
        return (New-Result -Valid $false -Reason 'design-decision-recorded-at-missing')
    }
    if ((Get-Command ConvertTo-SpecrewUtcTimestamp -ErrorAction SilentlyContinue) -and
        $null -eq (ConvertTo-SpecrewUtcTimestamp -Value $recordedAtProperty.Value)) {
        return (New-Result -Valid $false -Reason 'design-decision-recorded-at-unreadable')
    }
    if ([string]$Record.confirmation -cne $script:SpecrewDesignDecisionConfirmation) {
        return (New-Result -Valid $false -Reason 'design-decision-confirmation-invalid')
    }
    if ([string]$Record.confirmation_scope -cne $script:SpecrewDesignDecisionScope) {
        return (New-Result -Valid $false -Reason 'design-decision-scope-invalid')
    }
    # The human's own typed sentence, kept verbatim, and it must belong to THIS family. A record carrying a
    # boundary approval in this field would be a design decision minted from permission to proceed.
    if (-not (Test-SpecrewDesignDecisionPhrase -Text ([string]$Record.human_turn))) {
        return (New-Result -Valid $false -Reason 'design-decision-human-turn-not-this-family')
    }

    $optionsProperty = $Record.PSObject.Properties['options']
    if (-not $optionsProperty -or $optionsProperty.Value -isnot [System.Array]) {
        return (New-Result -Valid $false -Reason 'design-decision-options-invalid')
    }
    $options = @($optionsProperty.Value)
    # TWO IS THE MINIMUM, and this is the co-design rule enforced rather than restated. One option presented
    # and "chosen" is exactly the handing-down the skill forbids; nothing downstream could tell it apart from
    # a real decision once it was prose.
    if ($options.Count -lt 2) {
        return (New-Result -Valid $false -Reason 'design-decision-needs-at-least-two-options')
    }
    $ids = New-Object System.Collections.Generic.List[string]
    foreach ($option in $options) {
        if ($option -is [string] -or $option -is [ValueType] -or $null -eq $option) {
            return (New-Result -Valid $false -Reason 'design-decision-option-shape-invalid')
        }
        $idProperty = $option.PSObject.Properties['id']
        $summaryProperty = $option.PSObject.Properties['summary']
        if (-not $idProperty -or [string]::IsNullOrWhiteSpace([string]$idProperty.Value) -or
            -not $summaryProperty -or [string]::IsNullOrWhiteSpace([string]$summaryProperty.Value)) {
            return (New-Result -Valid $false -Reason 'design-decision-option-shape-invalid')
        }
        $id = ([string]$idProperty.Value).Trim()
        if ($ids.Contains($id)) { return (New-Result -Valid $false -Reason 'design-decision-option-ids-not-unique') }
        $ids.Add($id) | Out-Null
    }
    # THE CHOICE MUST BE ONE OF THE OPTIONS RECORDED. Without this the record could name a choice the human
    # was never shown, which is the same defect as handing options down, arriving one step later.
    if (-not $ids.Contains(([string]$Record.chosen).Trim())) {
        return (New-Result -Valid $false -Reason 'design-decision-chosen-not-among-options')
    }
    return (New-Result -Valid $true -Reason 'design-decision-valid')
}

function Read-SpecrewDesignDecisions {
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][string] $ProjectRoot, [Parameter(Mandatory)][string] $FeatureRef)

    $path = Get-SpecrewDesignDecisionPath -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef
    $result = [pscustomobject]@{ present = $false; path = $path; valid = $true; reason = 'design-decisions-absent'; decisions = [ordered]@{} }
    try {
        if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $result }
        $result.present = $true
        $document = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 20 -ErrorAction Stop
        if ($null -eq $document -or $document -is [System.Array]) {
            $result.valid = $false; $result.reason = 'design-decisions-root-invalid'; return $result
        }
        if ([string]$document.schema_version -cne $script:SpecrewDesignDecisionSchemaVersion) {
            $result.valid = $false; $result.reason = 'design-decisions-schema-invalid'; return $result
        }
        $decisionsProperty = $document.PSObject.Properties['decisions']
        if (-not $decisionsProperty -or $null -eq $decisionsProperty.Value -or $decisionsProperty.Value -is [System.Array]) {
            $result.valid = $false; $result.reason = 'design-decisions-map-invalid'; return $result
        }
        foreach ($entry in $decisionsProperty.Value.PSObject.Properties) {
            $validation = Test-SpecrewDesignDecisionRecord -Record $entry.Value
            if (-not $validation.valid) {
                $result.valid = $false
                $result.reason = ("{0}:{1}" -f [string]$entry.Name, [string]$validation.reason)
                return $result
            }
            $result.decisions[[string]$entry.Name] = $entry.Value
        }
        $result.reason = 'design-decisions-valid'
        return $result
    }
    catch {
        $result.valid = $false; $result.reason = 'design-decisions-unreadable'; return $result
    }
}