extensions/specrew-speckit/scripts/workshop-authority-store.ps1

Set-StrictMode -Version Latest

function Get-SpecrewWorkshopAuthorityReceiptPath {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string] $ProjectRoot)

    return Join-Path ([IO.Path]::GetFullPath($ProjectRoot)) '.specrew/runtime/workshop-authority.jsonl'
}

function Get-SpecrewWorkshopResponseAuthority {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $Phase,
        [Parameter(Mandatory)][string] $Response
    )

    $normalized = ($Response -replace '\s+', ' ').Trim().ToLowerInvariant()
    $delegated = $normalized -match '^(?:please\s+)?(?:you decide|decide for me|choose for me|use your (?:best )?judg(?:e)?ment)(?:\b.*)?$'
    $skipped = $normalized -match '^(?:skip(?:\s+(?:it|this|this lens|this question|the lens))?|not applicable|n/?a|move on without(?:\b.*)?)(?:[.!])?$'

    if ($Phase -eq 'agenda') {
        if ($delegated -or $skipped) {
            return [pscustomobject]@{ confirmation = 'invalid'; confirmation_scope = 'lens-selection' }
        }
        return [pscustomobject]@{ confirmation = 'human-confirmed'; confirmation_scope = 'lens-selection' }
    }
    if ($delegated) {
        return [pscustomobject]@{ confirmation = 'human-delegated'; confirmation_scope = 'explicit-delegation' }
    }
    if ($skipped) {
        return [pscustomobject]@{ confirmation = 'human-skipped'; confirmation_scope = 'explicit-skip' }
    }
    return [pscustomobject]@{ confirmation = 'human-confirmed'; confirmation_scope = 'lens-question' }
}

function Get-SpecrewWorkshopAuthorityHash {
    param([AllowEmptyString()][string] $Text)
    $bytes = [Text.Encoding]::UTF8.GetBytes([string]$Text)
    return ([Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes))).ToLowerInvariant()
}

function Get-SpecrewWorkshopRepairProposalPath {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$ProjectRoot)

    return Join-Path ([IO.Path]::GetFullPath($ProjectRoot)) '.specrew/handover/workshop-controller-repair-proposal.json'
}

function Get-SpecrewWorkshopRepairAuthorityPath {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$ProjectRoot)

    return Join-Path ([IO.Path]::GetFullPath($ProjectRoot)) '.specrew/runtime/workshop-controller-repair-authority.jsonl'
}

function Get-SpecrewWorkshopFileSha256 {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)

    return ([Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([IO.File]::ReadAllBytes($Path)))).ToLowerInvariant()
}

function Get-SpecrewWorkshopIntakeLenses {
    # THE INTAKE LENSES: the ones that run BEFORE the agenda exists, and therefore can never appear in
    # `selected` - `selected` is the agenda's output, and these are its input.
    #
    # This set was previously implied in three places that could not disagree out loud: the agenda catalog
    # filtered `product-domain` out of the selectable lenses; the transition table had a
    # `pending-product-projection` state class for exactly one workshop key named `product-domain`; and the
    # receipt store's phase ValidateSet carried `product-domain` as a first-class phase beside `lens`. The
    # lens WRITER knew none of it, and refused the first lens of every workshop as "not one of the topics
    # this workshop agreed to cover" - which was true, and was never going to stop being true.
    # The rule, one layer up: never enumerate a set by hand in several places when one
    # definition can be read by all of them.
    [OutputType([string[]])]
    param()
    # NOT `return , @(...)`: the comma operator wraps the array in another array, so a `-ccontains` on the
    # result compares against a nested array and is always false. Callers wrap with @() themselves.
    return @('product-domain')
}

function Test-SpecrewWorkshopIntakeLens {
    [OutputType([bool])]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Lens)
    return (@(Get-SpecrewWorkshopIntakeLenses) -ccontains $Lens)
}

function Resolve-SpecrewWorkshopStateTransition {
    # This is the finite pre-agenda transition contract shared by the writer, reader, and governed repair path.
    # Keep the state population closed and test every state x operation cell; a new state or operation must extend
    # that table before it can become reachable in production.
    [CmdletBinding()]
    param(
        [AllowNull()][object]$Controller,
        [Parameter(Mandatory)]
        [ValidateSet('initialize', 'read', 'render-agenda', 'confirm-agenda', 'confirm-lens', 'confirm-intake-lens', 'request-repair', 'apply-repair')]
        [string]$Operation
    )

    $stateClass = 'missing'
    if ($null -ne $Controller) {
        $agendaStatus = if ($Controller.PSObject.Properties['agenda_status']) { [string]$Controller.agenda_status } else { '' }
        if ($agendaStatus -eq 'pending-confirmation') {
            $selectedCount = if ($Controller.PSObject.Properties['selected']) { @($Controller.selected).Count } else { -1 }
            $workshopKeys = @()
            if ($Controller.PSObject.Properties['workshop'] -and $null -ne $Controller.workshop) {
                $workshopKeys = @($Controller.workshop.PSObject.Properties | ForEach-Object { [string]$_.Name })
            }
            else { $workshopKeys = @('__invalid__') }
            $technicalKeys = @($workshopKeys | Where-Object { $_ -cne 'product-domain' })
            $agendaCount = if ($Controller.PSObject.Properties['agenda'] -and $null -ne $Controller.agenda) { @($Controller.agenda.PSObject.Properties).Count } else { -1 }
            $skippedCount = if ($Controller.PSObject.Properties['skipped'] -and $null -ne $Controller.skipped) { @($Controller.skipped.PSObject.Properties).Count } else { -1 }
            $confirmation = if ($Controller.PSObject.Properties['agenda_confirmation']) { [string]$Controller.agenda_confirmation } else { '' }
            $confirmationScope = if ($Controller.PSObject.Properties['agenda_confirmation_scope']) { [string]$Controller.agenda_confirmation_scope } else { '' }
            $receipt = if ($Controller.PSObject.Properties['agenda_turn_receipt']) { [string]$Controller.agenda_turn_receipt } else { '' }
            $canonicalPending = ($selectedCount -eq 0 -and $technicalKeys.Count -eq 0 -and $agendaCount -eq 0 -and
                $skippedCount -eq 0 -and $confirmation -eq 'pending' -and $confirmationScope -eq 'lens-selection' -and $receipt -eq 'pending')
            if ($selectedCount -lt 0 -or $agendaCount -lt 0 -or $skippedCount -lt 0 -or $workshopKeys -contains '__invalid__') { $stateClass = 'invalid' }
            elseif ($canonicalPending -and $workshopKeys.Count -eq 0) { $stateClass = 'pending-empty' }
            elseif ($canonicalPending -and $workshopKeys.Count -eq 1 -and $workshopKeys[0] -ceq 'product-domain') { $stateClass = 'pending-product-projection' }
            else { $stateClass = 'pending-inconsistent' }
        }
        elseif ($agendaStatus -eq 'confirmed') {
            $selectedCount = if ($Controller.PSObject.Properties['selected']) { @($Controller.selected).Count } else { 0 }
            $agendaCount = if ($Controller.PSObject.Properties['agenda'] -and $null -ne $Controller.agenda) { @($Controller.agenda.PSObject.Properties).Count } else { 0 }
            $confirmation = if ($Controller.PSObject.Properties['agenda_confirmation']) { [string]$Controller.agenda_confirmation } else { '' }
            $stateClass = if ($selectedCount -gt 0 -and $agendaCount -eq $selectedCount -and $confirmation -eq 'human-confirmed') {
                'confirmed-complete'
            }
            else { 'confirmed-incomplete' }
        }
        else { $stateClass = 'invalid' }
    }

    $allowed = switch ($Operation) {
        'initialize' { $stateClass -eq 'missing' }
        'read' { $stateClass -in @('pending-empty', 'pending-product-projection', 'confirmed-complete') }
        'render-agenda' { $stateClass -in @('pending-empty', 'pending-product-projection') }
        'confirm-agenda' { $stateClass -in @('pending-empty', 'pending-product-projection') }
        # FR-027 (iteration 002, T018): a LENS closes only from a confirmed, complete agenda - the workshop
        # is running and the topic list is settled. Adding the operation to this closed table is what makes
        # the governed lens writer reachable at all; the table's own test pins every cell.
        'confirm-lens' { $stateClass -eq 'confirmed-complete' }
        # THE INTAKE LENS CLOSES BEFORE THE AGENDA EXISTS, because it is what produces the agenda. Closing
        # it from `pending-empty` is precisely how the controller reaches `pending-product-projection`, a
        # state class this table already had and nothing could legally produce. Without this cell the
        # workshop deadlocks at its first lens on EVERY greenfield feature: `confirm-lens` demanded a
        # confirmed agenda, and confirming the agenda demanded the product-domain records that only this
        # step persists. Two locally correct refusals, jointly a trap.
        # `confirmed-complete` IS IN THIS SET, AND SINCE 2026-09-09 NOTHING CAN ACT ON IT. Read this before
        # deleting it as dead.
        #
        # It was added for a stranded project: C:\Temp\ConsoleFractal, 2026-08-30 - agenda confirmed, six
        # technical lenses selected, `workshop` EMPTY, the intake lens never recorded. The recovery it enabled
        # was field-proved against that original specimen on 2026-09-01, on four checks, and none of the four
        # asked the reader what the recovery LEFT BEHIND. Measured 2026-09-09: it writes `product-domain` into
        # the map of a controller whose `selected` does not contain it, which the accessor reads as
        # `workshop-record-not-selected` - invalid, every lens stopped. That is the same state the router-skill
        # project was found in after a crew "restored" the cleared entry (B4F-035). The sanctioned recovery and
        # the corrupting repair were one operation.
        #
        # The stranded state also turned out to be INDISTINGUISHABLE from a healthy post-agenda one: confirming
        # an agenda clears the map by design, so `records on disk` and `receipt minted` are true of both. What
        # actually un-deadlocks the stranded projects is the reader tolerating the intake key, not this write.
        #
        # SO THE CELL STAYS AND confirm-workshop-lens.ps1 REFUSES DOWNSTREAM OF IT - deliberately, because that
        # is where the good message lives. Removing the cell would make this table produce the refusal instead,
        # and its text names the governed repair, which refuses every confirmed agenda and would clear nothing
        # (the remedy-wrong-for-state family, B4F-030). A permission nothing can use, with a refusal that tells
        # the reader the truth, beats a refusal that sends them somewhere that cannot help.
        'confirm-intake-lens' { $stateClass -in @('pending-empty', 'pending-product-projection', 'confirmed-complete') }
        'request-repair' { $stateClass -eq 'pending-inconsistent' }
        'apply-repair' { $stateClass -eq 'pending-inconsistent' }
    }
    return [pscustomobject]@{
        state_class = $stateClass
        operation   = $Operation
        allowed     = [bool]$allowed
        reason      = if ($allowed) { 'allowed' } else { 'workshop-transition-not-allowed' }
    }
}

function Get-SpecrewWorkshopRepairAuthorization {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][string]$ProposalId
    )

    $path = Get-SpecrewWorkshopRepairAuthorityPath -ProjectRoot $ProjectRoot
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null }
    $latest = $null
    foreach ($line in [IO.File]::ReadLines($path, [Text.Encoding]::UTF8)) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        try { $record = $line | ConvertFrom-Json -Depth 6 -ErrorAction Stop }
        catch { throw 'workshop-repair-authority-store-invalid' }
        if ([string]$record.schema_version -cne '1' -or [string]$record.proposal_id -cne $ProposalId) { continue }
        if ([string]$record.authorization -cne 'human-approved') { throw 'workshop-repair-authority-record-invalid' }
        $latest = $record
    }
    return $latest
}

function Write-SpecrewWorkshopRepairAuthorization {
    # A repair is destructive to controller projections even though durable workshop files remain. It is therefore
    # authorized only by an exact typed human reply bound to the immutable proposal and current controller bytes.
    # SPECREW-AUTHORITY-CONTROL: workshop-repair-human-authorization
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][string]$Response,
        [AllowNull()][string]$HostKind,
        [AllowNull()][string]$SourceEvent
    )

    if ($Response.Trim() -cne 'approved for workshop repair') { return $null }
    $canonicalSourceEvent = ConvertTo-SpecrewWorkshopSourceEvent -SourceEvent $SourceEvent
    if ($null -eq $canonicalSourceEvent -or -not (Test-SpecrewWorkshopHumanResponseText -Text $Response)) { return $null }
    $root = [IO.Path]::GetFullPath($ProjectRoot)
    if (Test-SpecrewWorkshopResponseIsHookOutput -ProjectRoot $root -Response $Response) { return $null }
    $proposalPath = Get-SpecrewWorkshopRepairProposalPath -ProjectRoot $root
    if (-not (Test-Path -LiteralPath $proposalPath -PathType Leaf)) { return $null }

    try {
        $proposal = Get-Content -LiteralPath $proposalPath -Raw -Encoding UTF8 -ErrorAction Stop |
            ConvertFrom-Json -Depth 8 -ErrorAction Stop
        if ([string]$proposal.schema_version -cne '1.0' -or [string]$proposal.feature_ref -cnotmatch '^[0-9]{3}-[a-z0-9][a-z0-9-]{0,63}$' -or
            [string]$proposal.proposal_id -cnotmatch '^[a-f0-9]{64}$' -or [string]$proposal.controller_sha256 -cnotmatch '^[a-f0-9]{64}$') { return $null }
        $controllerPath = Join-Path (Join-Path (Join-Path $root 'specs') ([string]$proposal.feature_ref)) 'lens-applicability.json'
        if (-not (Test-Path -LiteralPath $controllerPath -PathType Leaf) -or
            (Get-SpecrewWorkshopFileSha256 -Path $controllerPath) -cne [string]$proposal.controller_sha256) { return $null }
        $existing = Get-SpecrewWorkshopRepairAuthorization -ProjectRoot $root -ProposalId ([string]$proposal.proposal_id)
        if ($null -ne $existing) { return $existing }

        $record = [ordered]@{
            schema_version   = '1'
            proposal_id      = [string]$proposal.proposal_id
            feature_ref      = [string]$proposal.feature_ref
            controller_sha256 = [string]$proposal.controller_sha256
            authorization    = 'human-approved'
            response_hash    = Get-SpecrewWorkshopAuthorityHash -Text $Response.Trim()
            source_event     = $canonicalSourceEvent
            host_kind        = [string]$HostKind
            recorded_at      = [DateTimeOffset]::UtcNow.ToString('o')
        }
        $path = Get-SpecrewWorkshopRepairAuthorityPath -ProjectRoot $root
        $dir = Split-Path -Parent $path
        if (-not (Test-Path -LiteralPath $dir -PathType Container)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        $lockPath = $path + '.lock'
        $lockStream = $null
        try {
            $deadline = [DateTimeOffset]::UtcNow.AddSeconds(2)
            do {
                try { $lockStream = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) }
                catch { if ([DateTimeOffset]::UtcNow -ge $deadline) { throw }; Start-Sleep -Milliseconds 25 }
            } until ($null -ne $lockStream)
            [IO.File]::AppendAllText($path, (($record | ConvertTo-Json -Depth 5 -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false))
        }
        finally {
            if ($null -ne $lockStream) { $lockStream.Dispose() }
            Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
        }
        return [pscustomobject]$record
    }
    catch { return $null }
}

function Get-SpecrewWorkshopRefusalContractText {
    [CmdletBinding()]
    param(
        [ValidateSet('preserved', 'none')][string]$AnswerState = 'preserved'
    )

    $answerStatus = if ($AnswerState -eq 'preserved') {
        'Tell the human calmly what you were doing, that their answers are safe and nothing has been lost, and what you could not complete without assigning blame.'
    }
    else {
        'Tell the human calmly what you were doing, that no answer was recorded yet, and what you could not complete without assigning blame.'
    }
    return @(
        'Try the action above once. If it does not resolve the situation, do not retry and do not edit this project''s workshop records by hand.'
        $answerStatus
        'Give one concrete next action you can take and ask the human for approval before taking it.'
    ) -join ' '
}

function ConvertTo-SpecrewWorkshopAgendaBinding {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][Collections.IDictionary]$Agenda,
        [Parameter(Mandatory)][Collections.IDictionary]$Skipped
    )

    $selectedRecords = [Collections.Generic.List[object]]::new()
    foreach ($entry in $Agenda.GetEnumerator()) {
        $value = $entry.Value
        $selectedRecords.Add([ordered]@{
            lens = [string]$entry.Key
            depth = [string]$value.depth
            decision = ([string]$value.decision).Trim()
        }) | Out-Null
    }
    $skippedRecords = [Collections.Generic.List[object]]::new()
    foreach ($entry in $Skipped.GetEnumerator()) {
        $skippedRecords.Add([ordered]@{
            lens = [string]$entry.Key
            reason = ([string]$entry.Value).Trim()
        }) | Out-Null
    }
    return [ordered]@{
        schema_version = '1.0'
        selected = $selectedRecords.ToArray()
        skipped = $skippedRecords.ToArray()
    }
}

function Get-SpecrewWorkshopAgendaDigest {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Binding)

    $json = $Binding | ConvertTo-Json -Depth 8 -Compress
    return Get-SpecrewWorkshopAuthorityHash -Text $json
}

function Get-SpecrewWorkshopAgendaChangedLenses {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$ExpectedBinding,
        [Parameter(Mandatory)]$ActualBinding
    )

    $expected = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::Ordinal)
    $actual = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::Ordinal)
    $position = 0
    foreach ($entry in @($ExpectedBinding.selected)) {
        $expected[[string]$entry.lens] = ('selected|{0}|{1}|{2}' -f $position, [string]$entry.depth, ([string]$entry.decision).Trim())
        $position++
    }
    $position = 0
    foreach ($entry in @($ExpectedBinding.skipped)) {
        $expected[[string]$entry.lens] = ('skipped|{0}|{1}' -f $position, ([string]$entry.reason).Trim())
        $position++
    }
    $position = 0
    foreach ($entry in @($ActualBinding.selected)) {
        $actual[[string]$entry.lens] = ('selected|{0}|{1}|{2}' -f $position, [string]$entry.depth, ([string]$entry.decision).Trim())
        $position++
    }
    $position = 0
    foreach ($entry in @($ActualBinding.skipped)) {
        $actual[[string]$entry.lens] = ('skipped|{0}|{1}' -f $position, ([string]$entry.reason).Trim())
        $position++
    }

    $all = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
    foreach ($lens in $expected.Keys) { [void]$all.Add($lens) }
    foreach ($lens in $actual.Keys) { [void]$all.Add($lens) }
    return @($all | Where-Object {
            -not $expected.ContainsKey($_) -or -not $actual.ContainsKey($_) -or $expected[$_] -cne $actual[$_]
        } | Sort-Object)
}

function Test-SpecrewWorkshopAgendaVisibleInText {
    [CmdletBinding()]
    param(
        [AllowNull()][string]$Text,
        [AllowNull()][string]$CanonicalAgendaText
    )

    if ([string]::IsNullOrWhiteSpace($Text) -or [string]::IsNullOrWhiteSpace($CanonicalAgendaText)) { return $false }
    # ConversationCaptureAccessor intentionally collapses host transcript whitespace. The binding is the
    # structured agenda digest; this visibility check only proves that the complete canonical content appeared
    # in the assistant turn, independent of CRLF/LF and accessor whitespace normalization.
    $normalize = {
        param([string]$Value)
        return (($Value -replace '\s+', ' ').Trim())
    }
    $visible = & $normalize $Text
    $agenda = & $normalize $CanonicalAgendaText
    return ($visible.IndexOf($agenda, [StringComparison]::Ordinal) -ge 0)
}

function ConvertTo-SpecrewWorkshopSourceEvent {
    param([AllowNull()][string]$SourceEvent)
    $key = ([string]$SourceEvent).Trim().ToLowerInvariant() -replace '[-_]', ''
    switch ($key) {
        'userpromptsubmit' { return 'UserPromptSubmit' }
        'userpromptsubmitted' { return 'UserPromptSubmit' }
        'preinvocation' { return 'PreInvocation' }
        default { return $null }
    }
}

function Test-SpecrewWorkshopHumanResponseText {
    [CmdletBinding()]
    param([AllowNull()][string] $Text)

    if ([string]::IsNullOrWhiteSpace($Text)) { return $false }
    $trimmed = $Text.Trim()

    # Host stop-block output can be re-submitted by the host as a userPromptSubmitted event. It is still hook
    # machinery, not a typed human answer. Codex gives that machinery an explicit envelope; Copilot 1.0.79
    # replays the plain Specrew directive itself. Authority is fail-closed on both shapes.
    if ($trimmed -match '(?is)^\s*<(?:hook_prompt\b|task-notification\b|turn_aborted\b|system-reminder\b|environment_context\b)[\s\S]*</(?:hook_prompt|task-notification|turn_aborted|system-reminder|environment_context)>\s*$') { return $false }
    if ($trimmed -match '(?is)^\s*(?:Specrew:|Specrew\s+review\s+[-—]|\[specrew-|AWAITING YOUR VERDICT:|BOUNDARY NOT READY FOR A VERDICT:|Campaign review authority\b)') { return $false }
    return $true
}

function Test-SpecrewWorkshopResponseIsHookOutput {
    # SPECREW-AUTHORITY-CONSUMER: workshop-hook-output-identity
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ProjectRoot,
        [Parameter(Mandatory)][string]$Response
    )
    $path = Join-Path ([IO.Path]::GetFullPath($ProjectRoot)) '.specrew/runtime/hook-output-authority.jsonl'
    if (Test-Path -LiteralPath ($path + '.unhealthy') -PathType Leaf) { return $true }
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $false }
    $hash = Get-SpecrewWorkshopAuthorityHash -Text $Response.Trim()
    foreach ($line in [IO.File]::ReadLines($path, [Text.Encoding]::UTF8)) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        try { $record = $line | ConvertFrom-Json -Depth 5 -ErrorAction Stop }
        catch { throw 'workshop-hook-output-authority-journal-invalid' }
        if ([string]$record.schema_version -cne '1.0' -or [string]::IsNullOrWhiteSpace([string]$record.output_hash)) {
            throw 'workshop-hook-output-authority-record-invalid'
        }
        if ([string]$record.output_hash -ceq $hash) { return $true }
    }
    return $false
}

function Write-SpecrewWorkshopAuthorityReceipt {
    # SPECREW-AUTHORITY-CONTROL: workshop-agenda-question-identity
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $ProjectRoot,
        [Parameter(Mandatory)][string] $Response,
        [AllowNull()][string] $HostKind,
        [AllowNull()][string] $SourceEvent
    )

    $canonicalSourceEvent = ConvertTo-SpecrewWorkshopSourceEvent -SourceEvent $SourceEvent
    if ($null -eq $canonicalSourceEvent) { return $null }
    $root = [IO.Path]::GetFullPath($ProjectRoot)
    if (-not (Test-SpecrewWorkshopHumanResponseText -Text $Response)) { return $null }
    if (Test-SpecrewWorkshopResponseIsHookOutput -ProjectRoot $root -Response $Response) { return $null }
    $questionPath = Join-Path $root '.specrew/handover/workshop-question.json'
    if (-not (Test-Path -LiteralPath $questionPath -PathType Leaf)) { return $null }

    try {
        $questionItem = Get-Item -LiteralPath $questionPath -ErrorAction Stop
        if ($questionItem.Length -le 0 -or $questionItem.Length -gt 65536) { return $null }
        $question = Get-Content -LiteralPath $questionPath -Raw -Encoding UTF8 -ErrorAction Stop |
            ConvertFrom-Json -Depth 10 -ErrorAction Stop
        if ([string]$question.schema -cne 'v3' -or [string]$question.status -cne 'workshop-active') { return $null }
        $featureRef = [string]$question.feature_ref
        $phase = [string]$question.phase
        $lens = [string]$question.lens
        $questionHash = [string]$question.message_hash
        $agendaDigest = ''
        $agendaBinding = $null
        if ($featureRef -cnotmatch '^[0-9]{3}-[a-z0-9][a-z0-9-]{0,63}$' -or
            $phase -cnotin @('product-domain', 'agenda', 'lens') -or
            [string]::IsNullOrWhiteSpace($questionHash)) { return $null }
        if ($phase -eq 'lens' -and $lens -cnotmatch '^[a-z][a-z0-9-]{1,63}$') { return $null }
        if ($phase -eq 'agenda') {
            $digestProperty = $question.PSObject.Properties['agenda_digest']
            $bindingProperty = $question.PSObject.Properties['agenda_binding']
            if (-not $digestProperty -or [string]$digestProperty.Value -cnotmatch '^[a-f0-9]{64}$' -or
                -not $bindingProperty -or $null -eq $bindingProperty.Value) { return $null }
            $agendaDigest = [string]$digestProperty.Value
            $agendaBinding = $bindingProperty.Value
            if ((Get-SpecrewWorkshopAgendaDigest -Binding $agendaBinding) -cne $agendaDigest) { return $null }
        }
        $controllerPath = Join-Path (Join-Path (Join-Path $root 'specs') $featureRef) 'lens-applicability.json'
        if (-not (Test-Path -LiteralPath $controllerPath -PathType Leaf)) { return $null }
        $controller = Get-Content -LiteralPath $controllerPath -Raw -Encoding UTF8 -ErrorAction Stop |
            ConvertFrom-Json -Depth 20 -ErrorAction Stop
        if ([string]$controller.human_turn_contract -cne 'typed-turns-v1' -or
            [string]$controller.agenda_contract -cne 'complete-coverage-v1') { return $null }
        if ($phase -in @('product-domain', 'agenda') -and [string]$controller.agenda_status -cne 'pending-confirmation') { return $null }
        $featureRoot = Join-Path (Join-Path $root 'specs') $featureRef
        $hasProductMarkdown = Test-Path -LiteralPath (Join-Path $featureRoot 'workshop/product-domain.md') -PathType Leaf
        $hasProductStructured = Test-Path -LiteralPath (Join-Path $featureRoot 'workshop/product-domain.yml') -PathType Leaf
        if ($phase -eq 'product-domain' -and $hasProductMarkdown -and $hasProductStructured) { return $null }
        if ($phase -eq 'agenda' -and (-not $hasProductMarkdown -or -not $hasProductStructured)) { return $null }
        if ($phase -eq 'lens') {
            if ([string]$controller.agenda_status -cne 'confirmed' -or @($controller.selected) -cnotcontains $lens) { return $null }
            $recordProperty = if ($controller.PSObject.Properties['workshop']) { $controller.workshop.PSObject.Properties[$lens] } else { $null }
            if ($recordProperty -and $recordProperty.Value.PSObject.Properties['moved_on'] -and [bool]$recordProperty.Value.moved_on) { return $null }
        }

        $authority = Get-SpecrewWorkshopResponseAuthority -Phase $phase -Response $Response
        $responseHash = Get-SpecrewWorkshopAuthorityHash -Text $Response
        $receiptId = Get-SpecrewWorkshopAuthorityHash -Text ($featureRef + '|' + $phase + '|' + $lens + '|' + $questionHash + '|' + $agendaDigest + '|' + $responseHash)
        $record = [ordered]@{
            schema_version      = '1'
            receipt_id         = $receiptId
            feature_ref        = $featureRef
            iteration_number   = [string]$question.iteration_number
            phase              = $phase
            lens               = $lens
            question_hash      = $questionHash
            response_hash      = $responseHash
            confirmation       = [string]$authority.confirmation
            confirmation_scope = [string]$authority.confirmation_scope
            source_event       = $canonicalSourceEvent
            host_kind          = [string]$HostKind
            recorded_at        = [DateTimeOffset]::UtcNow.ToString('o')
        }
        if ($phase -eq 'agenda') {
            $record['agenda_digest'] = $agendaDigest
            $record['agenda_binding'] = $agendaBinding
        }

        $path = Get-SpecrewWorkshopAuthorityReceiptPath -ProjectRoot $root
        $dir = Split-Path -Parent $path
        if (-not (Test-Path -LiteralPath $dir -PathType Container)) {
            New-Item -ItemType Directory -Path $dir -Force | Out-Null
        }
        $line = ($record | ConvertTo-Json -Depth 5 -Compress) + [Environment]::NewLine
        $lockPath = $path + '.lock'
        $lockStream = $null
        try {
            $deadline = [DateTimeOffset]::UtcNow.AddSeconds(2)
            do {
                try { $lockStream = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) }
                catch { if ([DateTimeOffset]::UtcNow -ge $deadline) { throw }; Start-Sleep -Milliseconds 25 }
            } until ($null -ne $lockStream)
            [IO.File]::AppendAllText($path, $line, [Text.UTF8Encoding]::new($false))
        }
        finally {
            if ($null -ne $lockStream) { $lockStream.Dispose() }
            Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
        }
        return [pscustomobject]$record
    }
    catch { return $null }
}

function Get-SpecrewWorkshopReceiptDisclosure {
    # PRED-BETA4-053: a typed confirmation the receipt writer CANNOT bind is said, in the turn, with the remedy.
    # Measured on the walk project (e9334af1): the canonical agenda block had been rendered only as a tool
    # result, the assistant turn before the confirm did not contain it, the Stop hook therefore bound no
    # agenda digest into workshop-question.json, and Write-SpecrewWorkshopAuthorityReceipt returned $null at
    # prompt entry - silently, one of its twelve exits. The coordinator learned of it when the persist threw.
    # The verdict path got this disclosure in beta4 (PRED-026); this is the workshop path's. Narrow by
    # construction: agenda phase only (the one with a visibility proof to read), a confirmation-shaped reply,
    # and the question active without a digest. Lens questions carry no visibility proof; not covered here.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $ProjectRoot,
        [AllowNull()][string] $Response
    )
    try {
        if (-not (Test-SpecrewWorkshopHumanResponseText -Text $Response)) { return $null }
        $root = [IO.Path]::GetFullPath($ProjectRoot)
        if (Test-SpecrewWorkshopResponseIsHookOutput -ProjectRoot $root -Response $Response) { return $null }
        $questionPath = Join-Path $root '.specrew/handover/workshop-question.json'
        if (-not (Test-Path -LiteralPath $questionPath -PathType Leaf)) { return $null }
        $question = Get-Content -LiteralPath $questionPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -Depth 10 -ErrorAction Stop
        if ([string]$question.schema -cne 'v3' -or [string]$question.status -cne 'workshop-active' -or [string]$question.phase -cne 'agenda') { return $null }
        $digestProperty = $question.PSObject.Properties['agenda_digest']
        if ($digestProperty -and [string]$digestProperty.Value -cmatch '^[a-f0-9]{64}$') { return $null }   # visible and bound: the receipt handles it
        # A question is not a confirmation, whatever the classifier would bind (it binds any non-skip reply on
        # the agenda); the disclosure names a confirmation, so it fires only for a reply that reads as one.
        if (($Response -replace '\s+', ' ').Trim().EndsWith('?')) { return $null }
        $authority = Get-SpecrewWorkshopResponseAuthority -Phase 'agenda' -Response $Response
        if ([string]$authority.confirmation -cne 'human-confirmed') { return $null }
        $line = "Specrew: your agenda confirmation was received but NOT bound - the canonical agenda block was not in the assistant's previous message (a tool result is not a message), so nothing was recorded. Render the block in your message, then ask again."
        try {
            $journal = Join-Path $root '.specrew/runtime/handover-journal.jsonl'
            $dir = Split-Path -Parent $journal
            if (-not (Test-Path -LiteralPath $dir -PathType Container)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
            $row = [ordered]@{
                event = 'workshop-confirmation-not-bound-disclosed'; recorded_at = [DateTimeOffset]::UtcNow.ToString('o')
                feature_ref = [string]$question.feature_ref; phase = 'agenda'; reason = 'agenda-block-not-in-assistant-message'
                response_hash = (Get-SpecrewWorkshopAuthorityHash -Text $Response)
            }
            [IO.File]::AppendAllText($journal, (($row | ConvertTo-Json -Compress -Depth 5) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false))
        }
        catch { $null = $_ }
        return $line
    }
    catch { return $null }
}

function Get-SpecrewWorkshopAuthorityReceipt {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $ProjectRoot,
        [Parameter(Mandatory)][string] $FeatureRef,
        [Parameter(Mandatory)][ValidateSet('product-domain', 'agenda', 'lens')][string] $Phase,
        [AllowNull()][string] $Lens,
        [AllowNull()][string] $ReceiptId
    )

    $path = Get-SpecrewWorkshopAuthorityReceiptPath -ProjectRoot $ProjectRoot
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null }
    try {
        $latest = $null
        foreach ($line in [IO.File]::ReadLines($path, [Text.Encoding]::UTF8)) {
            if ([string]::IsNullOrWhiteSpace($line)) { continue }
            if ([Text.Encoding]::UTF8.GetByteCount($line) -gt 65536) { throw 'workshop-authority-record-size-invalid' }
            try { $record = [string]$line | ConvertFrom-Json -Depth 8 -ErrorAction Stop }
            catch { throw 'workshop-authority-record-json-invalid' }
            if ([string]$record.schema_version -cne '1' -or [string]$record.feature_ref -cne $FeatureRef -or
                [string]$record.phase -cne $Phase) { continue }
            if (-not [string]::IsNullOrWhiteSpace($Lens) -and [string]$record.lens -cne $Lens) { continue }
            if (-not [string]::IsNullOrWhiteSpace($ReceiptId) -and [string]$record.receipt_id -cne $ReceiptId) { continue }
            $latest = $record
        }
        return $latest
    }
    catch { throw ('workshop-authority-store-invalid:' + $_.Exception.Message) }
}

function Test-SpecrewWorkshopAuthorityReceipt {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $ProjectRoot,
        [Parameter(Mandatory)][string] $FeatureRef,
        [Parameter(Mandatory)][ValidateSet('product-domain', 'agenda', 'lens')][string] $Phase,
        [AllowNull()][string] $Lens,
        [Parameter(Mandatory)][string] $ReceiptId,
        [Parameter(Mandatory)][string] $Confirmation,
        [Parameter(Mandatory)][string] $ConfirmationScope,
        [AllowNull()][string] $AgendaDigest
    )

    $record = Get-SpecrewWorkshopAuthorityReceipt -ProjectRoot $ProjectRoot -FeatureRef $FeatureRef -Phase $Phase -Lens $Lens
    if ($null -eq $record) { return $false }
    $digestMatches = [string]::IsNullOrWhiteSpace($AgendaDigest) -or
        ($record.PSObject.Properties['agenda_digest'] -and [string]$record.agenda_digest -ceq $AgendaDigest)
    return ([string]$record.receipt_id -ceq $ReceiptId -and [string]$record.confirmation -ceq $Confirmation -and
        [string]$record.confirmation_scope -ceq $ConfirmationScope -and [string]$record.confirmation -cne 'invalid' -and $digestMatches)
}