diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 46fdb75..06077af 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,13 +16,17 @@ jobs:
validate:
name: Windows validation
runs-on: windows-latest
- timeout-minutes: 20
+ # The full repository validation runs 43 persistence suites sequentially in
+ # Windows PowerShell child processes. On a hosted runner that takes about 33
+ # minutes, so the job limit must cover it with margin for the lifecycle,
+ # release-contract and TrayHost steps that follow.
+ timeout-minutes: 90
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Set up Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
@@ -49,3 +53,7 @@ jobs:
- name: Run production TrayHost smoke test
shell: pwsh
run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index de23853..41a3509 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,6 +11,10 @@ on:
required: true
type: string
+concurrency:
+ group: ccod-draft-${{ github.event.inputs.tag || github.ref_name }}
+ cancel-in-progress: false
+
permissions:
contents: read
@@ -19,6 +23,7 @@ jobs:
runs-on: windows-latest
outputs:
tag: ${{ steps.release.outputs.tag }}
+ commit: ${{ steps.commit.outputs.commit }}
steps:
- name: Validate release tag
id: release
@@ -27,25 +32,76 @@ jobs:
CCOD_RELEASE_TAG_INPUT: ${{ inputs.tag }}
run: |
$tag = if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { [string]$env:CCOD_RELEASE_TAG_INPUT } else { [string]$env:GITHUB_REF_NAME }
- $tag = $tag.Trim()
- if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') {
+ if ($tag -cnotmatch '^v\d+\.\d+\.\d+\z') {
throw "Release tag must be canonical vX.Y.Z; received '$tag'."
}
"tag=$tag" >> $env:GITHUB_OUTPUT
- build:
+ - name: Check out the validated release tag
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ ref: ${{ steps.release.outputs.tag }}
+ fetch-depth: 1
+
+ - name: Resolve immutable release commit
+ id: commit
+ shell: pwsh
+ run: |
+ $commit = ([string](& git rev-parse HEAD)).Trim().ToLowerInvariant()
+ if ($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[0-9a-f]{40}$') {
+ throw "Release tag did not resolve to a canonical commit: '$commit'."
+ }
+ "commit=$commit" >> $env:GITHUB_OUTPUT
+
+ preflight:
needs: prepare
outputs:
tag: ${{ needs.prepare.outputs.tag }}
+ commit: ${{ needs.prepare.outputs.commit }}
+ runs-on: windows-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ ref: ${{ needs.prepare.outputs.commit }}
+
+ - name: Run clean release runner
+ shell: pwsh
+ env:
+ CCOD_RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
+ run: |
+ $tag = $env:CCOD_RELEASE_TAG
+ if ($tag -cnotmatch '^v\d+\.\d+\.\d+\z') {
+ throw "Release tag output is invalid: '$tag'."
+ }
+ $version = $tag.Substring(1)
+ Import-Module (Join-Path $PWD 'tools\Test-CleanReleaseRunner.psm1') -Force -DisableNameChecking
+ $result = Test-CcodCleanReleaseRunner -RepositoryRoot $PWD -ExpectedVersion $version
+ $artifact = Join-Path $PWD 'build\preflight\clean-preflight.json'
+ New-Item -ItemType Directory -Path (Split-Path $artifact -Parent) -Force | Out-Null
+ Copy-Item -LiteralPath $result.PreflightPath -Destination $artifact -Force
+
+ - name: Upload immutable clean preflight evidence
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+ with:
+ name: CodexRemote-fix clean preflight
+ path: build/preflight/clean-preflight.json
+ if-no-files-found: error
+
+ build:
+ needs: preflight
+ outputs:
+ tag: ${{ needs.preflight.outputs.tag }}
+ commit: ${{ needs.preflight.outputs.commit }}
runs-on: windows-latest
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
- ref: ${{ needs.prepare.outputs.tag }}
+ ref: ${{ needs.preflight.outputs.commit }}
- name: Set up Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
@@ -65,160 +121,109 @@ jobs:
shell: pwsh
run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+
- name: Build CodexRemote-fix portable bundle
shell: pwsh
env:
- CCOD_RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
+ CCOD_RELEASE_TAG: ${{ needs.preflight.outputs.tag }}
run: |
$version = $env:CCOD_RELEASE_TAG.Substring(1)
./build/build.ps1 -Version $version
- name: Upload immutable CodexRemote-fix portable bundle
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: CodexRemote-fix portable bundle
- path: build/dist/*
+ path: build/dist
if-no-files-found: error
- publish:
+ stage:
needs: build
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
- ref: ${{ needs.build.outputs.tag }}
+ ref: ${{ needs.build.outputs.commit }}
- name: Set up Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 22
- name: Download immutable CodexRemote-fix portable bundle
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: CodexRemote-fix portable bundle
path: build/dist
- - name: Verify downloaded release candidate contract
+ - name: Download immutable clean preflight evidence
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
+ with:
+ name: CodexRemote-fix clean preflight
+ path: build/preflight
+
+ - name: Materialize transferred clean preflight evidence
shell: pwsh
env:
CCOD_RELEASE_TAG: ${{ needs.build.outputs.tag }}
run: |
- npm run test:release-contract
$tag = $env:CCOD_RELEASE_TAG
- if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') {
- throw "Release tag output is invalid: '$tag'."
- }
$version = $tag.Substring(1)
- . ./tools/Test-ReleaseDefender.ps1 -Library
- $manifestPath = Join-Path $PWD ("build\dist\CodexRemote-fix-$version-release-manifest.json")
- Test-CcodReleaseAssetManifest -ManifestPath $manifestPath -AssetDirectory (Join-Path $PWD 'build\dist') -ExpectedVersion $version | Out-Null
- $setupManifestPath = Join-Path $PWD ("build\dist\CodexRemote-fix-$version-setup-release-manifest.json")
- Test-CcodReleaseAssetManifest -ManifestPath $setupManifestPath -AssetDirectory (Join-Path $PWD 'build\dist') -ExpectedVersion $version | Out-Null
+ $source = Join-Path $PWD 'build\preflight\clean-preflight.json'
+ $evidence = Join-Path $env:RUNNER_TEMP 'ccod-draft-evidence'
+ New-Item -ItemType Directory -Path $evidence -Force | Out-Null
+ Copy-Item -LiteralPath $source -Destination (Join-Path $evidence ("CodexRemote-fix-$version-clean-preflight.json")) -Force
- - name: Publish GitHub release
+ - name: Verify downloaded release candidate contract
shell: pwsh
env:
- GH_TOKEN: ${{ github.token }}
CCOD_RELEASE_TAG: ${{ needs.build.outputs.tag }}
+ CCOD_RELEASE_COMMIT: ${{ needs.build.outputs.commit }}
run: |
+ npm run test:release-contract
$tag = $env:CCOD_RELEASE_TAG
- if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') {
+ if ($tag -cnotmatch '^v\d+\.\d+\.\d+\z') {
throw "Release tag output is invalid: '$tag'."
}
$version = $tag.Substring(1)
- $changelog = Get-Content -LiteralPath (Join-Path $PWD 'CHANGELOG.md') -Raw -Encoding UTF8
- $releaseSection = [regex]::Match($changelog, '(?ms)^## ' + [regex]::Escape($tag) + '\r?\n(.*?)(?=^## |\z)')
- if (-not $releaseSection.Success) {
- throw "CHANGELOG.md has no release section for $tag"
- }
- $englishSection = [regex]::Match($releaseSection.Groups[1].Value, '(?ms)^### English\r?\n(.*?)(?=^### |\z)')
- if (-not $englishSection.Success) {
- throw "CHANGELOG.md has no English release section for $tag"
- }
- $notesPath = Join-Path $env:RUNNER_TEMP ("CodexRemote-fix-$tag-release-notes.md")
- Set-Content -LiteralPath $notesPath -Value ("# CodexRemote-fix $version`n`n" + $englishSection.Groups[1].Value.Trim() + "`n") -Encoding UTF8
- & gh release view $tag --repo $env:GITHUB_REPOSITORY --json assets 2>$null | Out-Null
- $exists = $LASTEXITCODE -eq 0
- $existingAssets = @()
- if ($exists) {
- $existingAssets = @(& gh release view $tag --repo $env:GITHUB_REPOSITORY --json assets --jq '.assets[].name')
- if ($LASTEXITCODE -ne 0) {
- throw "Could not inspect existing release assets for $tag."
- }
- }
- $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json') })
- $assetNames = @($assets | ForEach-Object { $_.Name })
- $unexpected = @($existingAssets | Where-Object { $assetNames -cnotcontains $_ })
- if ($unexpected.Count -ne 0) {
- throw "Existing release $tag contains unexpected assets and cannot be safely updated: $($unexpected -join ', ')"
- }
- foreach ($asset in $assets) {
- if ($existingAssets -ccontains $asset.Name) {
- $downloadRoot = Join-Path $env:RUNNER_TEMP ('ccod-existing-asset-' + [guid]::NewGuid().ToString('N'))
- New-Item -ItemType Directory -Path $downloadRoot -Force | Out-Null
- try {
- & gh release download $tag --repo $env:GITHUB_REPOSITORY --pattern $asset.Name --dir $downloadRoot
- if ($LASTEXITCODE -ne 0) {
- throw "Could not download existing release asset $($asset.Name)."
- }
- $existingAsset = Join-Path $downloadRoot $asset.Name
- if (-not (Test-Path -LiteralPath $existingAsset -PathType Leaf) -or
- (Get-FileHash -LiteralPath $existingAsset -Algorithm SHA256).Hash.ToLowerInvariant() -cne (Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256).Hash.ToLowerInvariant()) {
- throw "Existing release asset $($asset.Name) differs from the immutable candidate."
- }
- } finally {
- if (Test-Path -LiteralPath $downloadRoot) { Remove-Item -LiteralPath $downloadRoot -Recurse -Force -ErrorAction SilentlyContinue }
- }
- }
+ Import-Module (Join-Path $PWD 'tools\ReleaseAssetContract.psm1') -Force
+ $names = @(Get-CcodExpectedReleaseAssetNames -Version $version)
+ $candidate = Test-CcodExactReleaseAssetSet -AssetDirectory (Join-Path $PWD 'build/dist') -Version $version
+ if ($env:CCOD_RELEASE_COMMIT -cnotmatch '^[0-9a-f]{40}\z' -or $candidate.GitCommit -cne $env:CCOD_RELEASE_COMMIT) {
+ throw 'CCOD_RELEASE_BUILD_COMMIT_MISMATCH'
}
- if (-not $exists) {
- gh release create $tag --repo $env:GITHUB_REPOSITORY --title "CodexRemote-fix $version" --notes-file $notesPath
- } else {
- gh release edit $tag --repo $env:GITHUB_REPOSITORY --title "CodexRemote-fix $version" --notes-file $notesPath
- }
- foreach ($asset in $assets) {
- if ($existingAssets -cnotcontains $asset.Name) {
- & gh release upload $tag --repo $env:GITHUB_REPOSITORY $asset.FullName
- if ($LASTEXITCODE -ne 0) {
- throw "Could not upload release asset $($asset.Name)."
- }
- }
+ $manifestPath = Join-Path $PWD ('build\dist\CodexRemote-fix-' + $version + '-release-manifest.json')
+ Test-CcodReleaseAssetManifest -ManifestPath $manifestPath -AssetDirectory (Join-Path $PWD 'build\dist') -ExpectedVersion $version | Out-Null
+ $setupManifestPath = Join-Path $PWD ('build\dist\CodexRemote-fix-' + $version + '-setup-release-manifest.json')
+ Test-CcodReleaseAssetManifest -ManifestPath $setupManifestPath -AssetDirectory (Join-Path $PWD 'build\dist') -ExpectedVersion $version | Out-Null
+ if ($names.Count -ne 11) {
+ throw 'Release candidate does not expose the exact 11 public names.'
}
- - name: Read back published GitHub release assets
+ - name: Stage private GitHub draft
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
CCOD_RELEASE_TAG: ${{ needs.build.outputs.tag }}
run: |
$tag = $env:CCOD_RELEASE_TAG
- if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') {
+ if ($tag -cnotmatch '^v\d+\.\d+\.\d+\z') {
throw "Release tag output is invalid: '$tag'."
}
- $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json') })
- $assetNames = @($assets | ForEach-Object { $_.Name })
- $publishedNames = @(& gh release view $tag --repo $env:GITHUB_REPOSITORY --json assets --jq '.assets[].name')
- if ($LASTEXITCODE -ne 0 -or ((@($publishedNames | Sort-Object) -join '|') -cne (@($assetNames | Sort-Object) -join '|'))) {
- throw "Published release $tag does not expose the exact expected asset set for read-back."
- }
- $readBackRoot = Join-Path $env:RUNNER_TEMP ('ccod-release-readback-' + [guid]::NewGuid().ToString('N'))
- New-Item -ItemType Directory -Path $readBackRoot -Force | Out-Null
- try {
- foreach ($asset in $assets) {
- & gh release download $tag --repo $env:GITHUB_REPOSITORY --pattern $asset.Name --dir $readBackRoot
- if ($LASTEXITCODE -ne 0) {
- throw "Could not read back published release asset $($asset.Name)."
- }
- $readBackAsset = Join-Path $readBackRoot $asset.Name
- if (-not (Test-Path -LiteralPath $readBackAsset -PathType Leaf) -or
- (Get-FileHash -LiteralPath $readBackAsset -Algorithm SHA256).Hash.ToLowerInvariant() -cne (Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256).Hash.ToLowerInvariant()) {
- throw "Published release asset $($asset.Name) does not match the immutable candidate bytes."
- }
- }
- } finally {
- if (Test-Path -LiteralPath $readBackRoot) { Remove-Item -LiteralPath $readBackRoot -Recurse -Force -ErrorAction SilentlyContinue }
+ $version = $tag.Substring(1)
+ $notesPath = Join-Path $env:RUNNER_TEMP ("CodexRemote-fix-$tag-release-notes.md")
+ & (Join-Path $PWD 'tools\New-GitHubReleaseNotes.ps1') -ChangelogPath (Join-Path $PWD 'CHANGELOG.md') -Tag $tag -OutputPath $notesPath | Out-Null
+ $evidence = Join-Path $env:RUNNER_TEMP 'ccod-draft-evidence'
+ $preflight = Join-Path $evidence ("CodexRemote-fix-$version-clean-preflight.json")
+ if (-not (Test-Path -LiteralPath $preflight -PathType Leaf)) {
+ throw "Transferred clean preflight evidence is missing: '$preflight'."
}
+ Import-Module (Join-Path $PWD 'tools\Invoke-GitHubDraftRelease.psm1') -Force -DisableNameChecking
+ Invoke-CcodGitHubDraftRelease -Mode Stage -Tag $tag -AssetDirectory (Join-Path $PWD 'build\dist') -EvidenceDirectory $evidence -NotesPath $notesPath | Out-Null
+ Invoke-CcodGitHubDraftRelease -Mode Verify -Tag $tag -AssetDirectory (Join-Path $PWD 'build\dist') -EvidenceDirectory $evidence | Out-Null
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md
new file mode 100644
index 0000000..4c9448c
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md
@@ -0,0 +1,303 @@
+# Task 1 report: pinned, bounded tray receipt store
+
+Date: 2026-08-28
+
+Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`
+
+Branch: `codex/v2522-install-runtime-reliability`
+
+## Status
+
+Task 1 is implemented and focused verification is green. This report does not
+claim the later asynchronous sink, bounded UI completion, or child-session
+trace from Tasks 2 and 3.
+
+## Files
+
+- `src/trayhost/TrayTerminalDiagnostic.cs`
+ - Replaced the caller-selected path append helper with
+ `TrayTerminalDiagnosticStore`.
+ - The production constructor has no path parameter. `TryGetDefaultPath`
+ resolves only
+ `%LOCALAPPDATA%\CodexControlOtherDevices\logs\trayhost-actions.log`.
+ - Opens LocalAppData, product, and logs through `CreateFileW` directory
+ handles using backup-semantics/open-reparse flags, `FILE_LIST_DIRECTORY`,
+ attribute/security read access, read/write sharing, and no delete sharing.
+ - Validates the actual opened directory handles for disk type, directory and
+ non-reparse attributes, expected final path, and (for product/logs) exact
+ current-user/SYSTEM/Administrators owner/DACL policy.
+ - Opens the fixed leaf through `CreateFileW(OPEN_ALWAYS)` with open-reparse
+ and write-through flags plus read sharing only. It rejects non-disk,
+ directory, reparse, device, multi-link, unexpected-final-path, query, and
+ sharing failures before any content write or truncation.
+ - Reads length, performs rollover with `SetLength(0)`, writes the complete
+ UTF-8 no-BOM record, and calls `Flush(true)` through the already validated
+ leaf handle. It returns `true` only after the flush succeeds.
+- `src/trayhost/Program.cs`
+ - Owns one `TrayTerminalDiagnosticStore` and passes
+ `TryAppendDurably` to the existing transport callback.
+ - Disposes the store during the existing shutdown cleanup.
+ - No command-line input, protocol type, wire field, direction, sequence, or
+ authentication rule changed.
+- `tests/trayhost/TrayHostNativeSelfTest.cs`
+ - Added exact UTF-8/sanitization and fixed-production-path checks.
+ - Added temporary-root reparse-directory, reparse-leaf, ordinary-directory
+ leaf, hard-link leaf, unsafe DACL, directory-replacement barrier, concurrent
+ writer, exact 64-KiB, crossing-rollover, and oversized-file checks.
+ - The directory replacement and hard-link cases retain an outside sentinel
+ byte-for-byte.
+- `tests/trayhost/Invoke-TrayHostSelfTest.ps1`
+ - Defines `TRAYHOST_SELF_TEST` only for the temporary NativeOnly test
+ executable. The temporary-root factory and barriers are compiled out of
+ every production build and do not create a production CLI, environment
+ switch, listener, or external control surface.
+
+## TDD evidence
+
+### Clean baseline
+
+Command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Output before test changes, exit `0`:
+
+```text
+TrayHost native self-tests passed: 17
+```
+
+### Initial RED
+
+The behavioral test surface was added before production code.
+
+Command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Exit `1`; exact decisive output:
+
+```text
+tests\trayhost\TrayHostNativeSelfTest.cs(66,20): error CS0246: ... TrayTerminalDiagnosticStore ...
+CCOD_TRAYHOST_NATIVE_COMPILE_FAILED
+```
+
+This was the expected missing-feature failure: the current implementation did
+not provide a store that owned native handles or exposed the temporary-only
+barrier contract.
+
+### Directory replacement behavior RED and native root cause
+
+After the first minimal implementation compiled, the barrier remained red:
+
+```text
+TrayHost native self-test failed: System.InvalidOperationException
+the opened logs handle denies rename replacement
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+A temporary in-memory Win32 probe isolated the cause. With the same
+no-delete-share flags, an attribute-only directory handle allowed
+`Directory.Move`; adding `FILE_LIST_DIRECTORY` made the move fail with sharing
+violation `0x80070020`:
+
+```text
+ReadAttributes : move-succeeded
+ListDirectory : IOException:80070020
+Delete : IOException:80070020
+GenericRead : IOException:80070020
+ReadAttributesDelete : IOException:80070020
+```
+
+The production directory open therefore requests the minimal
+`FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL` access needed for
+the no-delete-share pin to enforce the approved barrier.
+
+### ACL RED
+
+An additional test added an Everyone full-control ACE to the existing logs
+directory. Before handle-based ACL validation, NativeOnly exited `1` with:
+
+```text
+TrayHost native self-test failed: System.InvalidOperationException
+an unexpected logs DACL is rejected before writing
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The green implementation queries the security descriptor through the opened
+directory handle with `GetSecurityInfo`. It never repairs an existing
+unexpected ACL and rejects it before opening or creating the receipt leaf.
+
+## Fresh GREEN evidence
+
+All commands below were rerun after the last source/test change.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost native self-tests passed: 21
+```
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+```
+
+Exit `0`, no output. This compiled the complete production TrayHost and ran
+`--headless-smoke` without the self-test symbol.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost transport self-tests passed: 10
+```
+
+This preserves the existing rule that a `false` persistence result cannot
+produce About/failure feedback.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProtocolOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost protocol self-tests passed: 11
+```
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ParentClientOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost parent-client self-tests passed: 4
+TrayHost production correlation trace passed: 2
+```
+
+The ParentClient run also printed the repository's existing PowerShell warning
+about unapproved verbs in `TrayHostClient`; no command in this task changed
+that module or warning surface.
+
+```powershell
+git diff --check
+```
+
+Exit `0`; only Git's existing LF-to-CRLF conversion notices were printed.
+
+## Commit
+
+- `365fde2430723fc8f80106e86490f7fb70be163f` —
+ `fix: pin tray terminal receipt storage`
+
+## Concerns and explicit boundaries
+
+- `Program` still invokes the receipt callback synchronously because the
+ bounded background sink is Task 2. This commit fixes filesystem identity,
+ pinning, safety, and bounded storage only; it does not yet make the
+ authenticated reader non-blocking.
+- Existing product/log directories with a different owner or DACL fail closed
+ and are not repaired. This follows the approved design, but a pre-existing
+ inherited/broadened directory will suppress receipt durability and therefore
+ generic feedback until a separately authorized migration or repair policy is
+ designed.
+- The private ACL, reparse, hard-link, and sharing controls contain accidental
+ replacement and unsafe filesystem objects. They are not represented as a
+ security boundary against a fully hostile process already running as the
+ same Windows user.
+- No installer, installed Codex process, real tray UI, push, release, or public
+ operation was run. Release-wide and real-machine acceptance remain outside
+ Task 1.
+
+## Fix round 1/5: inherited compatibility parents
+
+Review finding: the first implementation required the existing product and
+`logs` directories to have the protected receipt-private ACL. Supported
+current and legacy installations create those parents with normal inherited
+ACLs, so production receipt writes returned `false` before opening a leaf.
+
+### Compatibility ruling and implementation
+
+- The fixed production leaf moved from the legacy direct path to
+ `%LOCALAPPDATA%\CodexControlOtherDevices\logs\tray-receipts\trayhost-actions.log`.
+- LocalAppData, product, and `logs` remain pinned compatibility parents. Their
+ actual opened handles must still prove disk-directory type, non-reparse
+ attributes, and the exact final path, and the handles continue to deny
+ delete sharing while the receipt child and leaf are opened and written.
+- The store no longer creates product or `logs`; supported installation/runtime
+ setup remains responsible for those parents. A missing, wrong-type,
+ reparse-point, or unqueryable parent fails closed.
+- Only the fixed `logs\tray-receipts` child is created with the protected
+ current-user/SYSTEM/Administrators ACL. Its reopened native handle must pass
+ the strict owner/DACL, non-reparse, type, and final-path checks. An existing
+ arbitrary or broadened child remains rejected and is never repaired.
+- The direct legacy `logs\trayhost-actions.log` leaf is never opened, reused,
+ truncated, or written.
+
+### RED evidence
+
+The compatibility test precreated product and `logs` with normal inherited
+ACLs, asserted `AreAccessRulesProtected == false`, placed a byte sentinel in
+the legacy direct leaf, and requested a durable receipt. Before the production
+fix, this command exited `1`:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Exact decisive output:
+
+```text
+TrayHost native self-test failed: System.InvalidOperationException
+inherited supported parents accept a durable receipt in the dedicated private child
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+This is the reviewed compatibility defect: the old store rejected supported
+inherited parents before it could create a safe receipt boundary.
+
+### GREEN and regression evidence
+
+After the change, the compatibility test proves durable success, exact private
+ACL creation on `tray-receipts`, exact UTF-8 content in the new leaf, and
+byte-identical retention of the direct legacy leaf. Existing negative coverage
+was retained or moved to the correct boundary: reparse compatibility parent,
+reparse receipt child, reparse/directory/hard-link leaf, broadened receipt-child
+ACL, concurrent writer, replacement barrier/outside sentinel, and exact or
+crossing 64-KiB rollover.
+
+Fresh pre-commit commands and results:
+
+```text
+NativeOnly: exit 0, TrayHost native self-tests passed: 22
+ProductionOnly: exit 0, headless compile/run emitted no output
+TransportOnly: exit 0, TrayHost transport self-tests passed: 10
+ProtocolOnly: exit 0, TrayHost protocol self-tests passed: 11
+ParentClientOnly: exit 0, TrayHost parent-client self-tests passed: 4
+Production trace: TrayHost production correlation trace passed: 2
+git diff --check: exit 0 (only repository LF-to-CRLF notices)
+```
+
+The ParentClient command again emitted the repository's existing unapproved-
+verbs warning for `TrayHostClient`; this fix did not modify that module.
+
+### Fix-round commit and remaining boundary
+
+- `f207496df1ceee744a09bbc5c3d24a48695e58bc` —
+ `fix: preserve tray receipt parent compatibility`
+
+Task 1 now supports the inherited parent ACLs created by existing
+installations while retaining a dedicated fail-closed private receipt child.
+The asynchronous/non-blocking sink remains Task 2. No installer, installed
+process, real UI, release, push, or public operation was run in this fix round.
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md
new file mode 100644
index 0000000..86f9638
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md
@@ -0,0 +1,359 @@
+# Task 2 report: asynchronous bounded tray terminal receipts
+
+Date: 2026-08-28
+
+Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`
+
+Branch: `codex/v2522-install-runtime-reliability`
+
+## Status
+
+Task 2 is implemented and its focused transport/native acceptance is green.
+The authenticated reader now consumes a correlated terminal result before one
+zero-wait sink submission; receipt store I/O and durable UI publication run on
+one bounded background writer. This report does not claim the Task 3 shared
+child-session/full production trace or the Task 4 release gate.
+
+## Implementation
+
+- `src/trayhost/TrayTerminalReceiptSink.cs`
+ - Adds a typed `TrayTerminalReceipt` containing the correlated immutable
+ action result plus the existing sanitized `TrayTerminalDiagnostic` used by
+ the accepted handle-pinned store.
+ - Binds each receipt to the `HostTransport` that correlated it and permits
+ publication at most once.
+ - Starts exactly one background receipt writer. No error, timeout, or hang
+ starts a replacement writer.
+ - `TrySubmit` uses a non-waiting admission gate and admits at most eight
+ outstanding receipts, including the receipt currently inside store I/O or
+ its durable callback.
+ - Store `false`/throw and callback `false`/throw drop only feedback and allow
+ the same writer to process later receipts.
+ - Disposal closes admission, clears queued work, suppresses late callbacks,
+ and joins the one writer for at most 100 ms. If store I/O remains hung, the
+ original background writer owns deferred store cleanup after it eventually
+ returns; shutdown never starts or waits for a replacement.
+- `src/trayhost/HostTransport.cs`
+ - `TryAcknowledgeAction` validates correlation under the existing lock,
+ creates the typed terminal receipt, removes the pending action, and returns
+ without file I/O or capacity waiting.
+ - `TryPublishDurableReceipt` accepts only a once-publishable receipt from the
+ same transport. Completed About maps to About work; rejected/failed maps to
+ generic failure; all other completed commands map to no UI.
+ - About and generic feedback share one eight-item UI bound. Queue or callback
+ failure consumes the publication but leaves no retryable feedback.
+ - UI work is provisional while the application-post callback is unresolved.
+ Consumers return immediately without seeing it; callback failure removes
+ it, while callback success makes it ready. If a posted work message probes
+ the provisional item before callback return, the transport posts one
+ replacement work message after marking it ready. Replacement-post failure
+ removes the ready item and returns its shared UI capacity.
+ - Presentation and receipt callbacks are separate. Application receipt work
+ posting remains outside the transport correlation lock.
+- `src/trayhost/Program.cs`
+ - Composes one existing `TrayTerminalDiagnosticStore`, one `HostTransport`,
+ and one `TrayTerminalReceiptSink`.
+ - The authenticated reader calls correlation and one `TrySubmit`, ignores
+ admission failure after the action is consumed, and no longer posts UI work
+ immediately for terminal wire results.
+ - The durable callback is the only terminal path that posts About/failure
+ work. Its production callback checks the actual `PostMessage` result and
+ fails closed before UI visibility when posting fails. Sink disposal
+ precedes window/transport disposal, and the sink owns safe deferred store
+ disposal if its writer is hung.
+ - No command-line selector, message type, wire field, direction, sequence,
+ capability, authentication, or storage-security rule changed.
+- `tests/trayhost/TrayHostTransportSelfTest.cs`
+ - Retains the parent/presentation/pending/replay/result-correlation coverage
+ and replaces the synchronous-writer expectations with Task 2 behavior.
+ - Uses a controllable fake store that blocks before open, during write, and
+ during flush.
+ - Covers current and following terminal returns under 250 ms, pending removal
+ before submission, eight outstanding including current, immediate ninth
+ drop, deterministic busy admission, closed admission, store failure,
+ callback failure, provisional UI invisibility, early-message repost, later
+ recovery on the same writer, shared UI bound, trusted once-only
+ publication, and hung-store disposal.
+ - The blocked following result is completed About, while the current result
+ is failed OpenLogs, so both About and generic feedback are proven absent
+ until explicit durable success.
+- `tests/trayhost/TrayHostNativeSelfTest.cs`
+ - Adds a fake-native-window integration test using the real handle-pinned
+ store through the asynchronous sink. Generic native feedback is unavailable
+ before durable success and appears exactly once afterward.
+- `tests/trayhost/Invoke-TrayHostSelfTest.ps1`
+ - Adds the new source to the existing temporary transport/native compilation
+ list. Production compilation already collects every `src/trayhost/*.cs`.
+- `tests/trayhost/TrayHostParentClientSelfTest.cs`
+ - Migrates the current temporary peer caller to the typed receipt/sink API so
+ the complete production source set compiles. This is only an API adaptation;
+ it is not the Task 3 shared production child-session trace.
+
+## TDD evidence
+
+### Clean baseline
+
+Before test changes:
+
+```text
+TransportOnly: exit 0, TrayHost transport self-tests passed: 10
+NativeOnly: exit 0, TrayHost native self-tests passed: 22
+```
+
+### RED
+
+The new behavioral tests and temporary compile list were written before the
+new production source/API.
+
+Command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+```
+
+Exit `1`; decisive output:
+
+```text
+CCOD_TRAYHOST_SOURCE_MISSING
+FullyQualifiedErrorId : CCOD_TRAYHOST_SOURCE_MISSING
+```
+
+This was the expected missing-feature RED: the planned
+`TrayTerminalReceiptSink.cs` and typed receipt API did not yet exist. No
+production source had been changed when this run was captured.
+
+### Callback-visibility RED
+
+Post-commit concurrency review identified that the first implementation placed
+UI work in a consumable queue before its application callback returned. A
+callback that later threw could therefore race a queue consumer. A test held a
+callback that would throw and probed the UI queue concurrently. Before the
+two-phase UI work fix, TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+provisional UI work returns immediately but remains unavailable while callback outcome is unknown
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The fixed transport keeps the item provisional and nonblocking until callback
+success, or removes it before visibility on callback failure.
+
+### Early-work-message repost RED
+
+The provisional design also needs to recover when the posted work message is
+processed before the callback returns and before the item becomes ready. The
+repost behavior was removed before its test was added. Without the repost,
+TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+an early work-message probe causes one replacement UI post
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The green implementation records the early probe, marks the item ready only
+after callback success, and posts one replacement message without duplicating
+the UI item.
+
+### Replacement-post failure RED
+
+A final callback-failure test made the replacement post throw after the first
+post succeeded but was processed while the item remained provisional. Before
+the fail-closed replacement handling, TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+replacement callback failure reports publication failure
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The green path removes the ready-but-unnotified item, returns `false`, and
+retains no latent UI work or occupied UI capacity.
+
+### Focused GREEN and stability
+
+After the final source/test change, the focused gates were rerun:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost transport self-tests passed: 13
+```
+
+The final transport suite was also run ten consecutive times in one stability
+probe; all ten runs reported `TrayHost transport self-tests passed: 13` with
+exit `0`.
+
+The first attempted ten-run probe exposed a test-helper defect: `WaitUntil`
+evaluated a consuming `TryTake*` condition twice, so the second evaluation saw
+an already-consumed queue and reported a false failure. The helper now caches
+the successful evaluation; no production change was made for that incident,
+and the complete ten-run probe above was rerun from the start.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost native self-tests passed: 23
+```
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+```
+
+Exit `0`, no output. This compiles every production TrayHost source, including
+the new sink and all current production callers, then runs `--headless-smoke`.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProtocolOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost protocol self-tests passed: 11
+```
+
+No wire implementation file changed; the protocol run is the byte-level
+regression gate for the existing schema/directions/sequences.
+
+```powershell
+git diff --check
+```
+
+Exit `0`. Git printed only repository LF-to-CRLF conversion notices.
+
+## Diagnostic-only run, not Task 3 acceptance
+
+`ParentClientOnly` was run once after adapting its current temporary caller and
+exited `0`. That command still uses the pre-Task-3 fake peer/correlation trace;
+it does not exercise the approved shared `TrayHostChildSession` seam and is not
+counted as production-trace acceptance here.
+
+## Commit
+
+- `7549dde154fd6c2430a13aa9ddcf66698560f717` —
+ `fix: isolate tray terminal receipt writes`
+- `1f1ecc5f72fbbb1c070d62a745b36be003b50506` —
+ `fix: hide provisional tray receipt UI`
+- `1212579676a1b4773450ed961094a02c6322d0f7` —
+ `fix: drop failed tray receipt reposts`
+
+## Concerns and explicit boundaries
+
+- Task 3 remains required to extract the shared child session and prove current
+ and stale results through the real authenticated ParentClient/PowerShell
+ path with a temporary fake native runtime. The existing fake-peer diagnostic
+ is not that proof.
+- The legacy `tests/persistence/Supervisor.SelfTest.ps1` correlation bridge has
+ an explicit pre-Task-2 source list and old synchronous HostTransport calls.
+ It is a test-only caller, not production source, and is intentionally deferred
+ for the Task 3 trace/bridge migration. The full persistence suite is therefore
+ not claimed by Task 2.
+- Task 4 release-workflow/provenance gating and release-wide validation remain
+ deferred. The production build itself discovers every TrayHost `.cs` source,
+ so the new sink is compiled without a new runtime selector.
+- A receipt store that never returns leaves the one background writer alive
+ until process termination; this is the approved no-replacement failure mode.
+ Admission and pending-action capacity remain bounded and responsive.
+- No installer, installed Codex process, real tray UI, push, release, install,
+ signing, or public operation was run.
+
+## Fix round 1/5: tokened STA visibility and atomic callback admission
+
+Review found two remaining races in the first Task 2 implementation:
+
+1. A receipt became consumable before its replacement UI-post callback had
+ returned successfully. An unrelated ordinary `WmApp+2` drain could display
+ the item before a later callback failure attempted to retract it.
+2. The sink checked `_closed` and then invoked the durable callback separately.
+ `Dispose` could close and return in that gap before a new callback began.
+
+### Implementation
+
+- `HostTransport` now assigns each bounded receipt UI item one nonzero,
+ process-randomized `uint` token and keeps the item uncommitted while its
+ token-post callback is unresolved.
+- Receipt UI lives in one token-keyed eight-item map shared by About and
+ failure. Ordinary no-token drains always return no item; only
+ `TryTakeReceiptUi(exactToken, ...)` can consume a committed receipt.
+- An early exact-token delivery records an observation without exposing UI.
+ The transport permits one bounded replacement post while the item remains
+ uncommitted. A false/throwing post, a second early delivery, disposal, or an
+ invalid token retracts the item before any UI result is returned.
+- `TrayHostApplication` owns internal `WmApp+3` receipt-token posting and
+ decoding. `WmApp+2` remains exclusively the presentation/shutdown work path.
+ The pipe protocol, CLI inputs, authenticated message types, and wire fields
+ are unchanged.
+- `Program` removed receipt drains from ordinary work and maps only an exact
+ `WmApp+3` token to About or generic failure. Shutdown closes the sink, then
+ disposes the transport before destroying the window.
+- `TrayTerminalReceiptSink` now sets an explicit `_callbackClaimed` state under
+ the same `_gate` used by `Dispose`, before invoking the callback, and clears
+ it in `finally`. Once close wins the gate, no new callback claim succeeds.
+ An already-started callback may finish, but a disposed transport rejects its
+ token publication.
+- The callback-handoff barrier is compiled only into the temporary
+ `TRAYHOST_RECEIPT_SELF_TEST` TransportOnly executable. No production test
+ selector, environment switch, listener, or external control was added.
+
+### TDD RED evidence
+
+The existing shared-drain race was first captured behaviorally. Before the
+tokened path, TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+ordinary work cannot expose or display receipt UI while the replacement callback is unresolved
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The durable-return/callback-begin handoff was then blocked deterministically.
+Before atomic callback admission, TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+close gate prevents any new durable callback from starting after Dispose
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The desired token APIs were also written before implementation. TransportOnly
+then failed compilation because the token-aware callback,
+`TrayTerminalReceiptUiKind`, and `TryTakeReceiptUi` did not exist; NativeOnly
+failed because `TrayHostApplication.TryDecodeReceiptWorkToken` did not exist.
+
+The final deterministic NativeOnly race delivers the first `WmApp+3` token
+before its post returns, blocks the replacement token post, concurrently
+dispatches ordinary `WmApp+2`, then throws the token callback. It asserts zero
+generic dialogs before and after stale-token replay.
+
+### Fresh GREEN and stability evidence
+
+All commands below were run after the final source/test change:
+
+```text
+TransportOnly: 10 consecutive runs, each exit 0, TrayHost transport self-tests passed: 16
+NativeOnly: 5 consecutive runs, each exit 0, TrayHost native self-tests passed: 25
+ProductionOnly: exit 0, complete production compile plus --headless-smoke, no output
+ProtocolOnly: exit 0, TrayHost protocol self-tests passed: 11
+git diff --check: exit 0; only repository LF-to-CRLF conversion notices
+```
+
+### Commit and remaining boundary
+
+- `85882890202f420479728a9ed1532f2085b970c8` —
+ `fix: seal tray receipt dispatch races`
+
+Task 3 still owns the shared child-session/authenticated production trace and
+the legacy Supervisor correlation-bridge migration; neither is claimed by this
+fix round. Task 4 release gating also remains deferred. No installer, installed
+Codex process, real tray UI, push, release, signing, install, or public operation
+was run.
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md
new file mode 100644
index 0000000..af3ef87
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md
@@ -0,0 +1,227 @@
+# Task 3 report: authenticated child-session production trace
+
+## Outcome
+
+Task 3 now runs the real authenticated TrayHost child session in temporary
+child processes without adding a shipped test selector or using
+`TrayHostParentClient.TestProcessFactory`.
+
+- `Program.RunChild` is a thin argument/identity/Windows-runtime adapter.
+- `TrayHostChildSession` owns the existing bootstrap, directional keys,
+ authenticated reader/writer sequences, action registration, presentation and
+ shutdown work, async receipt admission, and token-bound UI drain.
+- `WindowsTrayHostRuntime` composes the unchanged `Win32TrayPlatform`,
+ `TrayWindow`, and `TrayHostApplication` behavior.
+- `TrayHostProductionTraceSelfTest.cs` is compiled only under the runner's
+ temporary directory. Its fake native platform remains under `tests/`; no
+ test runtime, scenario, witness, macro, or selector is present in
+ `src/trayhost`.
+- The old ParentClient fake peer is again only a narrow authenticated
+ parent-writer test. It no longer calls production dispatch, HostTransport
+ receipt drains, or the dialog directly.
+- The deferred Supervisor correlation bridge now uses the real async
+ `TrayTerminalReceiptSink` and `TrayHostApplication` token dispatch rather
+ than the removed synchronous `TryTakeFailedAction` path.
+
+## End-to-end evidence
+
+### Current action
+
+The trace launches a distinct requested executable through the normal
+`TrayHostParentClient.Start` path. After the real parent/child identity and
+authenticated handshake, the temporary runtime calls the real
+`TrayWindow.HandleContextMenu`; its fake `INativeTrayPlatform` returns
+`OpenLogs` from `TrackPopupMenuEx`. The shared child session registers and
+writes that exact action. The real PowerShell `Receive-CcodTrayHostEvents`
+delivers it to `Invoke-CcodSupervisorCommand`, whose in-memory `OpenLogs`
+adapter runs once and whose terminal result returns through
+`Send-CcodTrayHostActionResult` and the real ParentClient writer.
+
+The child reader authenticates and correlates the completion, the real async
+sink flushes exactly one sanitized receipt, and `HostTransport` publishes no
+receipt UI for a completed `OpenLogs`. The witness contains no dialog, no
+duplicate action, no fault, and the normal parent-requested shutdown receives
+an authenticated acknowledgement.
+
+### Stale authenticated negative
+
+The separately compiled temporary stale fixture emits an explicit revision-8
+`OpenLogs` through the same child-session command delegate; this is test-only
+and does not use a runtime-id convention or production selector. The real
+PowerShell client receives the authenticated action. Because only revision 1
+is acknowledged, `Invoke-CcodSupervisorCommand` rejects it with
+`CCOD_TRAY_ACTION_STALE` before the `OpenLogs` adapter and sends the exact
+correlated terminal result through ParentClient.
+
+The child consumes the pending action, flushes one receipt containing only
+`command`, `revision`, `status`, and canonical `code`, then posts a nonzero
+token on `WmApp+3`. The test-only message pump invokes the real
+`TrayHostApplication` handler; only `TryTakeReceiptUi(exactToken, ...)` reaches
+the runtime's `TrayWindow.ShowActionFailed` delegation. The witness asserts
+the order `durable receipt -> token post -> generic dialog`, exactly one dialog
+with caption `trace-0` and text `trace-15`, and no internal code or correlation
+identifier in that dialog. Normal authenticated shutdown then completes.
+
+## TDD RED evidence
+
+The real-process trace and runner were written before the production seam.
+Running the required command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1
+```
+
+exited 1 with the expected missing-seam failure:
+
+```text
+tests\trayhost\TrayHostProductionTraceSelfTest.cs(84,46): error CS0246: ITrayHostRuntime
+CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED
+```
+
+No `src/trayhost` file had been changed when this RED was captured.
+
+Running the complete Supervisor fixture then exposed the Task 2 report's
+explicitly deferred legacy bridge migration. It exited 1 at
+`Supervisor.SelfTest.ps1:818`: `HostTransport.cs(107)` could not resolve
+`TrayTerminalReceipt` because the pre-Task-2 Add-Type list omitted the async
+sink, and the bridge still used the old synchronous drain. Adding the actual
+sink/application sources and migrating the bridge to token dispatch made the
+same fixture pass 98 tests.
+
+## Fresh GREEN and stability evidence
+
+All commands below ran on the final production/test source tree and exited 0:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1
+# TrayHost production child-session trace passed: 2
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ParentClientOnly
+# TrayHost parent-client self-tests passed: 4
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostClient.SelfTest.ps1
+# 9 cases passed; TrayHost client self-tests passed.
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+# TrayHost native self-tests passed: 25
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+# TrayHost transport self-tests passed: 16
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProtocolOnly
+# TrayHost protocol self-tests passed: 11
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+# complete production compile and --headless-smoke, exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+# Supervisor self-tests passed: 98
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1
+# 6 build/provenance cases; TrayHost build self-tests passed.
+```
+
+The direct production-trace command was additionally run three consecutive
+times on the final tree. Every iteration launched both current and stale child
+processes and printed `TrayHost production child-session trace passed: 2`.
+
+`git diff --check` exited 0; Git printed only the repository's expected
+LF-to-CRLF conversion notices.
+
+## Production CLI and Task 2 boundary
+
+The trace runner separately compiles the production executable with
+`/main:Program` and invokes it with `--production-trace-test-selector`; the
+test requires exit code 2. A final source audit found no trace selector,
+`TRAYHOST_TRACE` define, trace runtime-id convention, or test-factory
+assignment in `Program.cs`, `TrayHostChildSession.cs`, or
+`WindowsTrayHostRuntime.cs`. `Program.Main` still recognizes only the existing
+single `--headless-smoke` form and seven-argument `--child` form.
+
+`HostTransport`, `TrayTerminalReceiptSink`, `TrayHostWire`,
+`TrayHostParentClient`, and the PowerShell wire surface were not changed.
+Native 25/25 and transport 16/16 confirm the Task 2 tokened `WmApp+3`
+publication, provisional-visibility, repost, disposal, and failure behavior
+remains green.
+
+## Remaining risks and safety boundary
+
+- The broad trace deliberately uses a fake native platform and a temporary
+ handle-flushed witness store. It proves the shared session, authenticated
+ wire, ParentClient, PowerShell client/Supervisor decision, async sink,
+ tokened application work, and generic dialog mapping; it is not an installed
+ desktop automation or real shell-notification-area test.
+- Native receipt-store pinning/reparse/hard-link/rollover behavior remains
+ covered by the focused Task 1/native tests, not duplicated here.
+- Installer, installed-runtime, Defender, release workflow, signing, and
+ independent review remain later acceptance boundaries.
+- No real UI, Codex process, installer, installation, release, push, signing,
+ or public operation was started by Task 3.
+
+## Fix round 1/5: ShutdownAck is not process-exit proof
+
+Independent review found that the first trace treated `ShutdownAck` as natural
+child termination. `TrayHostParentClient.ReaderLoop` enqueued `Exited` and set
+its stopped event immediately after reading the authenticated ACK, while
+`Dispose` subsequently terminated the child job. A child that wrote the ACK
+and then hung could therefore pass the trace before being force-killed.
+
+### Deterministic RED
+
+The trace runner now compiles a third temporary executable with a test-only
+`TRAYHOST_TRACE_HANG_AFTER_ACK` define. It uses the same child session,
+handshake, authenticated action/result, and shutdown wire. Its fake runtime
+records that the shared child session has written the shutdown ACK but
+deliberately does not return from its run loop. The PowerShell test retains the
+actual process identified by `TrayHostStartReceipt.HostPid` before `Dispose`
+and asserts that ACK alone cannot publish `Exited` or satisfy
+`WaitForStopped` while that process is still alive.
+
+Before the production fix, the required trace command exited 1 with the exact
+behavioral failure:
+
+```text
+ASSERT_EQUAL: ShutdownAck alone cannot satisfy WaitForStopped while the child is alive expected=[False] actual=[True]
+CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED
+```
+
+### Production correction
+
+`TrayHostParentClient` now treats the authenticated ACK only as permission to
+wait for graceful termination. Its reader performs a bounded wait on the exact
+launched `Process` and requires exit code 0 before it enqueues `Exited` and
+signals stopped. A timeout, inaccessible process, or nonzero exit becomes the
+existing bounded `CCOD_TRAYHOST_TRANSPORT_FAILED` fault; `Dispose` may then use
+the existing job cleanup, but that cleanup can no longer masquerade as natural
+exit.
+
+The normal current and stale cases retain their child `Process` handles and
+independently require `WaitForExit(4000)` and exit code 0 before calling
+`Dispose`. The hung-after-ACK case requires a 250 ms stopped wait to return
+false, no `Exited` event, and a still-live child; after the bounded parent wait
+it requires the transport fault while the child remains live until explicit
+cleanup.
+
+No production CLI argument, listener, environment switch, external control
+surface, wire field, message type, or authentication rule was added or
+changed. The hang behavior and compile define exist only in the temporary
+test executable.
+
+### Fresh fix-round verification
+
+All covering commands ran after the correction and exited 0:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1
+# TrayHost production child-session trace passed: 3
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ParentClientOnly
+# TrayHost parent-client self-tests passed: 4
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+# complete production compile plus --headless-smoke, exit 0
+```
+
+`git diff --check` exited 0 with only the repository's expected LF-to-CRLF
+conversion notices. No real UI, Codex process, installer, release, install,
+signing, push, or public operation ran in this fix round.
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md
new file mode 100644
index 0000000..4aba45b
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md
@@ -0,0 +1,226 @@
+# Task 4 report: fail-closed release trace and TrayHost provenance
+
+## Outcome
+
+Task 4 closes both release-boundary gaps without changing the TrayHost wire,
+CLI, authentication, installer, version metadata, or installed runtime.
+
+- CI job `validate` and release job `build` each contain exactly one step named
+ `Run authenticated TrayHost production trace`, with `shell: pwsh` and the
+ exact run value
+ `./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly`.
+- The release trace is immediately before the asset-producing build step and
+ structurally precedes the real PowerShell command
+ `./build/build.ps1 -Version $version`.
+- `Test-CcodTrayHostArtifact` now compares provenance with the exact current
+ `src/trayhost/*.cs` name-to-SHA-256 set. Missing records, count differences,
+ duplicate names, unexpected or case-different names, malformed hashes, and
+ name/hash mismatches fail with `CCOD_TRAYHOST_SOURCE_TAMPERED`.
+- The production validator never resolves a path supplied by provenance. It
+ hashes the enumerated current sources first, stores them in an ordinal
+ dictionary, and validates records against that bounded set.
+
+Implementation commits:
+
+```text
+1743de6 test: gate release on tray receipt trace
+f0e388e fix: reject bypassed tray trace gates
+```
+
+## TDD RED evidence
+
+### Release workflow gate
+
+Only the structural release test had been changed when this command ran:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1
+```
+
+It exited 1 after all preceding release/manifest/Defender cases passed:
+
+```text
+CCOD_SELFTEST_FAILED case=CI-and-release-build-jobs-uniquely-gate-asset-production-on-the-authenticated-TrayHost-trace error=CCOD_RELEASE_TRACE_GATE_INVALID
+CCOD_RELEASE_TRACE_GATE_INVALID
+```
+
+No workflow production file had been changed before this RED. Adding the exact
+CI/release steps then made the same command exit 0.
+
+### Exact source provenance
+
+The provenance fixture and negative tests were added before changing
+`build/TrayHostBuild.psm1`. The same required ReleaseWorkflow command exited 1:
+
+```text
+CCOD_SELFTEST_FAILED case=TrayHost-artifact-validation-requires-the-exact-compiled-source-name-and-hash-set error=ASSERT_THROWS
+ASSERT_THROWS: expected CCOD_TRAYHOST_SOURCE_TAMPERED
+```
+
+The old verifier had accepted provenance with `TrayHostChildSession.cs`
+removed. After the exact-set validator was implemented, the same command
+exited 0 and all four provenance mutations were rejected.
+
+## Workflow evidence
+
+- `.github/workflows/ci.yml:53-55` contains the sole CI trace step under
+ `jobs.validate.steps`.
+- `.github/workflows/release.yml:68-70` contains the sole release trace step
+ under `jobs.build.steps`.
+- `.github/workflows/release.yml:72-78` places it before the build step and
+ the AST-confirmed `build.ps1 -Version` invocation.
+- `ReleaseWorkflow.SelfTest.ps1` parses job and step indentation rather than
+ searching the whole file. It parses each PowerShell `run` body into an AST,
+ counts the real trace/build commands, and compares their step indexes.
+- Controlled negative YAML fixtures reject nonempty `if` and
+ `continue-on-error` modifiers, a comment-only decoy, a trace in `publish`, a
+ post-build trace, an anonymous pre-build command, duplicate named steps, and
+ a duplicate trace invocation under a different name. Explicitly empty or
+ absent execution modifiers remain acceptable.
+
+The checked-in workflows each had one trace name and one exact trace run value
+at final review.
+
+## Provenance evidence
+
+The final source enumeration contained 19 `.cs` files. Both the lightweight
+release fixture and the real compiled artifact test cover:
+
+- omission of `TrayHostChildSession.cs`;
+- a same-count duplicate of `TrayHostChildSession.cs` replacing
+ `WindowsTrayHostRuntime.cs`;
+- a changed source-name set;
+- a wrong hash for `WindowsTrayHostRuntime.cs`.
+
+The real build test also requires the provenance names to equal the canonical
+sorted source names and explicitly requires both new production source files.
+The validator uses ordinal name comparison and canonical lowercase 64-digit
+SHA-256 values.
+
+## Command ledger
+
+### Required/focused tests
+
+| Command | Result |
+| --- | --- |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` | RED 1: exit 1, missing workflow gate (`CCOD_RELEASE_TRACE_GATE_INVALID`). |
+| same ReleaseWorkflow command after workflow change | exit 0. |
+| same ReleaseWorkflow command after provenance test, before verifier change | RED 2: exit 1, missing source record was wrongly accepted (`ASSERT_THROWS`). |
+| final same ReleaseWorkflow command | exit 0; release workflow self-tests passed, including execution-modifier bypasses, structural decoys, and provenance mutations. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1` | exit 0; six build/provenance cases passed against a real compiled artifact. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly` | exit 0; `TrayHost production child-session trace passed: 3`. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` | exit 0; 20 controlled adapter/temp-root scenarios passed. |
+| all repository `.ps1`/`.psm1` files through `System.Management.Automation.Language.Parser` | exit 0; 92 files parsed. |
+| all `src` JS/MJS files through `node --check` | exit 0; 5 files parsed. |
+| `node.exe tests/CleanroomSelfTest.js` | exit 0; JSON result `ok: true`, 19 cases. |
+| `node.exe tests/PackageCheckerSelfTest.mjs` | exit 0. |
+| `git diff --check` | exit 0; only the repository's existing LF-to-CRLF warnings. |
+
+## Fix round 1/5: execution modifiers cannot bypass the trace gate
+
+Independent review found that the first workflow structure retained only
+`Name`, `Shell`, and `Run`. A required trace step could therefore keep every
+checked field while adding `if: ${{ false }}` or
+`continue-on-error: true`; the former skips the trace and the latter tolerates
+its failure.
+
+Two deterministic temporary YAML fixtures were added before changing the
+parser or contract. Running the required command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1
+```
+
+exited 1 after the real workflow and all preceding release/provenance checks
+passed:
+
+```text
+CCOD_SELFTEST_FAILED case=authenticated-trace-workflow-gate-rejects-comments-wrong-jobs-duplicates-and-post-build-placement error=ASSERT_THROWS
+ASSERT_THROWS: expected CCOD_RELEASE_TRACE_GATE_INVALID
+```
+
+This was the expected RED: the old contract accepted the first conditional
+trace fixture. The step model now retains `If` and `ContinueOnError` from both
+inline and following YAML keys. The exact required trace step must have both
+values empty; any nonempty literal or expression fails with
+`CCOD_RELEASE_TRACE_GATE_INVALID`. A positive fixture proves explicitly empty
+modifiers remain accepted, while the existing valid workflow proves absence
+remains accepted.
+
+Fresh fix-round verification on the final implementation tree:
+
+| Command | Result |
+| --- | --- |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` | exit 0; both execution-modifier bypass fixtures rejected and all release checks passed. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1` | exit 0; six build/provenance cases passed. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly` | exit 0; production child-session trace passed 3 cases. |
+
+Fix-round scope was limited to
+`tests/persistence/ReleaseWorkflow.SelfTest.ps1`; no workflow production step,
+TrayHost CLI, wire, authentication, build/provenance implementation, live
+Supervisor, or controlled isolation harness was changed or invoked.
+
+## Full Persistence/Validate isolation boundary
+
+The direct, non-npm aggregate command was attempted once:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/PersistenceSelfTest.ps1
+```
+
+It exited 1 before reaching Task 4 tests:
+
+```text
+CCOD_SELFTEST_FAILED case=selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer error=ASSERT_EXACT
+CCOD_PERSISTENCE_SELFTEST_FAILED test=Bootstrap.SelfTest.ps1 exit=1
+```
+
+Running `Bootstrap.SelfTest.ps1` alone twice reproduced
+`previous fallback succeeds expected=[0] actual=[1]`. A corrected zero-wait
+production mutex probe exited 0 and reported:
+
+```text
+CCOD_ACCOUNT_TRANSITION_PROBE outcome=TimedOut
+```
+
+An initial diagnostic probe invocation had a parent-shell quoting error and
+exited 1 before importing the module or touching a mutex; the corrected
+single-quoted probe above is the relevant result.
+
+The installed Supervisor was live, and an independent probe showed that the
+fixed account transition boundary was unavailable. `tests/Validate.ps1 -SkipInstalledPackageCheck`
+was not started because it unconditionally invokes the same Persistence
+aggregate; repeating it could not produce independent evidence. No process was
+stopped and no kernel-name, SID, or safety check was forged to bypass the
+boundary. The existing fully controlled InstalledLifecycle harness and the
+safe pre-Persistence validation components were run instead and passed.
+
+## Scope
+
+Changed implementation/test files:
+
+- `.github/workflows/ci.yml`
+- `.github/workflows/release.yml`
+- `build/TrayHostBuild.psm1`
+- `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+- `tests/persistence/TrayHostBuild.SelfTest.ps1`
+
+This task did not modify production TrayHost wire fields, message types,
+sequence/authentication rules, CLI selectors, child-session behavior, receipt
+storage, installer behavior, version metadata, README/CHANGELOG content, or
+Task 5 static manifest/version binding.
+
+No real UI, Codex process, installer, installation, release, push, package
+signing, or public operation was started. Tests used temporary artifacts and
+the Task 3 fake-native production trace only.
+
+## Remaining risks
+
+- Full `PersistenceSelfTest.ps1` and therefore `Validate.ps1` still require a
+ fresh run when the real account transition mutex is safely quiescent. This
+ task did not stop the live Supervisor to manufacture a green aggregate.
+- The production trace proves the shared authenticated child-session path with
+ a fake native platform; installed-machine notification-area and Defender
+ validation remain later release acceptance boundaries.
+- Version, Windows manifest identity, README/CHANGELOG, and final asset
+ acceptance are Task 5 scope and were intentionally not changed here.
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md
new file mode 100644
index 0000000..c1db9ce
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md
@@ -0,0 +1,187 @@
+# Task 3 report: stable verified Electron process trees
+
+## Outcome and commit
+
+Implemented Task 3 only in the linked worktree
+`C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`.
+
+Implementation commit: `b34b0d1` (`fix: stabilize verified Electron process trees`).
+
+The report is committed separately after this entry so that it can name the
+implementation commit; a commit cannot embed its own final object ID.
+
+## Files changed
+
+- `src/persistence/modules/ProcessControl.psm1`
+- `src/persistence/modules/SessionEngine.psm1`
+- `src/persistence/Supervisor.ps1`
+- `tests/persistence/ProcessControl.SelfTest.ps1`
+- `tests/persistence/SessionEngine.SelfTest.ps1`
+- `tests/persistence/Supervisor.SelfTest.ps1`
+- `.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md`
+
+No other source, test, installer, release, WindowsApps, DPAPI, or UI file was
+modified.
+
+## Implementation
+
+`Get-CcodStableVerifiedProcessTree` now wraps the existing one-pass verified
+tree reader with a fixed retry budget. A retry is eligible only after a failed
+or empty verified-tree acquisition. Before the retry it directly rereads the
+original root PID with the same status evidence and applies the existing exact
+`Test-CcodProcessMatch` contract across PID, creation time, session, SID,
+executable path, package family, command line, parent/top-level shape, mode,
+renderer port, and main port. A successful tree must also contain exactly one
+root member that exactly matches the original snapshot. Missing, malformed,
+changed, or differently mapped roots return no tree. The 50 ms adapter delay is
+entered only after the failed-tree condition and exact root reread both hold.
+
+SessionEngine routes every pre-mutation current-package `GetTree` boundary in
+Close, Recover, recovery cleanup, and CloseRequested replay through that stable
+helper. Journal creation and all stop mutations remain after stable acquisition.
+The existing per-member pre-stop, post-stop, and final absence/identity proofs
+were not changed.
+
+Supervisor post-worker proof confirmation now has the same bounded policy. It
+retries one empty ordinary/special enumeration only when a direct read of the
+candidate PID still matches all twelve snapshot fields. It then performs one
+condition-based 50 ms adapter delay and one final refresh. A nonempty mismatch,
+ambiguity, missing or malformed direct read, creation-time drift, debug-port
+drift, or mode drift is not delayed or retried and receives no proof authority.
+The lifecycle result continues to reduce through the stable
+`CCOD_REMOTE_PROOF_REBIND_FAILED` reason, and `FailedSpecialProofKey` remains
+bound to the original candidate PID and creation time.
+
+One pre-existing recovery fixture was made deterministic by fixing its
+preferred renderer port to the exact port in its expected root snapshot. The
+new stable helper correctly exposed that the old fixture supplied a 41001 tree
+root while the launch fixture selected 9335.
+
+## Baseline
+
+Before test or production changes:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1
+exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1
+exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+existing output reached the final production-adapter cases without a failure; the
+initial combined shell capture timed out before its receipt, so the suite was
+rerun independently during RED/GREEN and final verification below.
+```
+
+## RED evidence
+
+Tests were added before production changes.
+
+### RED 1: missing stable process-tree helper
+
+Command:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1
+```
+
+Result: exit `1` at
+`retries one transient verified tree miss while the exact root identity remains stable`:
+
+```text
+The term 'Get-CcodStableVerifiedProcessTree' is not recognized
+```
+
+### RED 2: SessionEngine did not retry transient tree churn
+
+Command:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1
+```
+
+Result: exit `1` at
+`retries transient Electron tree churn before committing and closes every verified member`:
+
+```text
+expected=[Closed] actual=[Error]
+```
+
+### RED 3: Supervisor had no bounded second rebind
+
+Command:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+```
+
+After correcting the empty-array fixture to emit one array object, result was
+exit `1`:
+
+```text
+one transient empty rebind enumeration receives exactly one bounded retry
+expected=[3] actual=[2]
+```
+
+The count includes one pre-worker candidate observation plus the two required
+post-worker observations.
+
+### RED 4: Supervisor retry had no condition-based delay
+
+After the immediate bounded retry was implemented, a timing boundary test was
+added before adding a Delay adapter. The same Supervisor command exited `1`:
+
+```text
+same-candidate retry enters one bounded condition-based delay
+expected=[50] actual=[]
+```
+
+## GREEN and final verification
+
+Fresh committed-content verification command:
+
+```powershell
+$tests=@(
+ 'tests/persistence/ProcessControl.SelfTest.ps1',
+ 'tests/persistence/SessionEngine.SelfTest.ps1',
+ 'tests/persistence/Supervisor.SelfTest.ps1'
+)
+foreach($test in $tests){
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File $test
+ if($LASTEXITCODE -ne 0){ throw "$test failed" }
+}
+```
+
+Result: exit `0` for all three suites.
+
+- ProcessControl: 40 behavioral cases passed, including transient empty-tree
+ retry and creation-time drift fail-closed coverage.
+- SessionEngine: 58 behavioral cases passed, including retry-before-journal,
+ child-first stop of every captured member, root drift with zero close
+ mutation, ordinary close behavior, and all existing per-member proof cases.
+- Supervisor: 93 behavioral cases passed. The suite printed
+ `Supervisor self-tests passed: 93`, including one exact condition-based
+ 50 ms retry, creation-time drift with zero delay, port/mode drift with zero
+ delay, no proof authority for changed roots, and the stable
+ `CCOD_REMOTE_PROOF_REBIND_FAILED` terminal result.
+
+`git diff --cached --check` exited `0` before the implementation commit.
+
+## Scope, safety, and concerns
+
+- No real Codex, ChatGPT, installer, tray, or UI process was started, stopped,
+ signaled, or controlled. All process behavior used fixtures/adapters.
+- Nothing was installed, pushed, published, released, uploaded, or signed.
+- WindowsApps and DPAPI/device-key boundaries were not changed.
+- Exact root identity was not relaxed. Retry eligibility uses the same full
+ snapshot matcher, including mode and both debug ports.
+- The only delay is fixed at 50 ms and is adapter-driven. It is called only
+ after an empty/failed acquisition plus an exact candidate-root reread; all
+ mismatch tests assert zero delay.
+- The repository-wide suite was not run because other persistence integration
+ paths may exercise real process-stop boundaries, which conflicts with this
+ task's explicit fixture-only authority. The three brief-named focused suites
+ are green.
+- No independent agent review was run because the task explicitly prohibited
+ spawning agents. Parent integration review remains the next external gate.
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
new file mode 100644
index 0000000..f7a2224
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
@@ -0,0 +1,628 @@
+# Task 4 report: tray action revision correlation and diagnostics
+
+## Outcome and commit
+
+Implemented Task 4 only in the linked worktree
+`C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`.
+
+Implementation commit: `b312174` (`test: cover tray action revision correlation`).
+
+The report is committed separately after this entry so that it can name the
+implementation commit; a commit cannot embed its own final object ID.
+
+## Files changed
+
+- `src/persistence/Supervisor.ps1`
+- `tests/persistence/Supervisor.SelfTest.ps1`
+- `tests/persistence/TrayHostClient.SelfTest.ps1`
+- `tests/trayhost/TrayHostTransportSelfTest.cs`
+- `.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md`
+
+No protocol, installer, release, lifecycle worker, Codex, WindowsApps, DPAPI,
+or user-facing localization file was changed. The planned production files
+`src/persistence/modules/TrayHostClient.psm1`, `src/trayhost/HostTransport.cs`,
+and `src/trayhost/TrayWindow.cs` required no production change: the existing
+authenticated result boundary already preserved action id, revision, terminal
+status, and canonical error code, and the existing TrayWindow already reduced
+terminal failures to the approved generic localized dialog. Their behavior is
+now covered more precisely by the wrapper, transport, and native tests.
+
+## Root cause and implementation
+
+The native menu action already reached TrayHost, the PowerShell wrapper already
+queued the exact command and presentation revision, and Supervisor already
+authorized actions against the exact acknowledged-presentation map. The missing
+boundary was in `Send-CcodSupervisorTrayActionResult`: it forwarded a terminal
+result to TrayHost without first writing a support-safe record. Once TrayHost
+accepted a rejected or failed result, it queued only the generic failure UI, so
+the end user saw the intended generic wording but the local log had no command,
+revision, terminal status, or stable code with which to diagnose the failure.
+
+`Write-CcodSupervisorTrayActionTerminal` now writes a fixed schema before the
+terminal result is sent to TrayHost:
+
+- `schemaVersion`
+- `timestampUtc`
+- `component`
+- `stage`
+- `code`
+- `outcome`
+- `command`
+- `revision`
+- `status`
+
+The record deliberately excludes action ids, transaction ids, filesystem
+paths, tokens, exception messages, and arbitrary result text. `Completed` uses
+the safe success code `CCOD_TRAY_ACTION_COMPLETED`. `Rejected` and `Failed`
+preserve an exact canonical `CCOD_*` code; malformed codes fall back to
+`CCOD_TRAY_ACTION_FAILED`. Canonical validation uses the absolute `\z` regex
+terminator so a trailing newline cannot be accepted as part of a log code.
+
+`Accepted` is not terminal and is not logged by this helper. Lifecycle actions
+therefore receive one terminal record only when their later `Completed` or
+`Failed` result is produced. Existing result delivery, transaction correlation,
+authenticated pipe framing, and generic dialog wording are unchanged.
+
+## TDD and RED evidence
+
+Tests were written before the production implementation.
+
+### RED 1: terminal diagnostic record was absent
+
+Command:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+```
+
+Result: exit `1` after all earlier cases passed. The acknowledged revision 7
+test reached the real `OpenLogs` adapter, but no local terminal record existed:
+
+```text
+ASSERT_EQUAL_FAILED: terminal action writes exactly one local diagnostic record expected=[1] actual=[0]
+```
+
+This RED established that the failure was the missing diagnostic boundary, not
+an invalid test fixture, PowerShell parse error, or broken command handler.
+
+### RED 2: end-of-line regex semantics allowed an unsafe code
+
+After the initial minimal logger was green, a sanitation test was added before
+tightening the canonical-code matcher. PowerShell's `$` anchor accepted a
+terminal line-feed boundary, and the test exited `1`:
+
+```text
+ASSERT_EQUAL_FAILED: malformed terminal code is replaced by the canonical fallback
+expected=[CCOD_TRAY_ACTION_FAILED] actual=[CCOD_INJECTED\n]
+```
+
+The production matcher was then changed from `$` to `\z`; the test proves the
+newline-bearing value cannot enter the local diagnostic record.
+
+### Focused baseline outside the missing logger
+
+Before the production change, these two brief-named boundaries were already
+green:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostClient.SelfTest.ps1
+exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+exit 0; TrayHost transport self-tests passed: 8
+```
+
+They showed that the authenticated wrapper and host-side action-result queues
+were not the missing production behavior. Their assertions were then extended
+to prove the exact stale result reaches the parent-client boundary once and
+that HostTransport retains the exact revision, terminal status, and canonical
+code before the generic UI consumes the failure.
+
+## GREEN and final verification
+
+Fresh pre-commit verification ran the three brief-named focused suites, the
+native TrayHost suite, and the whitespace gate:
+
+```powershell
+$tests=@(
+ @{Name='TrayHost client';Command=@('tests\persistence\TrayHostClient.SelfTest.ps1')},
+ @{Name='Supervisor';Command=@('tests\persistence\Supervisor.SelfTest.ps1')},
+ @{Name='TrayHost transport';Command=@('tests\trayhost\Invoke-TrayHostSelfTest.ps1','-TransportOnly')},
+ @{Name='TrayHost native';Command=@('tests\trayhost\Invoke-TrayHostSelfTest.ps1','-NativeOnly')}
+)
+foreach($test in $tests){
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File @($test.Command)
+ if($LASTEXITCODE -ne 0){throw "$($test.Name) failed"}
+}
+git diff --check
+```
+
+Result: exit `0` throughout.
+
+- TrayHost client: 8 behavioral cases passed. The only output warning is the
+ pre-existing PowerShell warning about unapproved imported verb names.
+- Supervisor: 96 behavioral cases passed.
+- TrayHost transport: 8 behavioral cases passed.
+- TrayHost native: 15 behavioral cases passed.
+- `git diff --check`: exit `0`.
+- `git diff --cached --check`: exit `0` immediately before commit `b312174`.
+
+The new Supervisor cases prove the required ordering and side-effect boundary:
+
+1. revision 7 is present in `AcknowledgedPresentations`;
+2. `OpenLogs` is invoked exactly once;
+3. `CCOD_TRAY_ACTION_COMPLETED` is logged with command `OpenLogs`, revision 7,
+ and status `Completed`;
+4. the log write occurs after the handler and before `ActionResult:Completed`;
+5. revision 8 has no acknowledged map entry;
+6. the stale action invokes `OpenLogs` zero times;
+7. `CCOD_TRAY_ACTION_STALE` is logged before `ActionResult:Rejected`.
+
+The native suite retains the existing assertion that failure feedback uses only
+the acknowledged snapshot's localized generic title and `Action failed` text.
+No internal code is added to the dialog.
+
+## Scope, safety, and concerns
+
+- No real Codex or ChatGPT process was started, stopped, signaled, or controlled.
+- No production TrayHost process, installer, release build, or interactive
+ product UI was launched. The explicitly required native self-test exercised
+ its test-owned Win32 owner/fake platform and P/Invoke surface only.
+- Nothing was installed, pushed, published, released, uploaded, or signed.
+- WindowsApps, application binaries, and DPAPI/device-key data were untouched.
+- The authenticated pipe protocol, action-result wire schema, capability gate,
+ and revision ACK map were not weakened or changed.
+- The repository-wide suite was not run because this task authorized the three
+ focused suites plus native TrayHost verification; broader persistence tests
+ may cross real process-control boundaries. All task-required suites are green.
+- No independent agent review was run because the task explicitly prohibited
+ spawning agents. Parent integration review remains the next external gate.
+
+## Fix round 1: durable diagnostic gate and real correlation chain
+
+Independent review identified that implementation commit `b312174` attempted
+the terminal log before delivery but swallowed `WriteLog` failure and still sent
+the terminal `ActionResult`. That allowed HostTransport to queue the generic
+failure dialog without any durable local terminal record. Review also found
+that the original wrapper, Supervisor, transport, and native assertions were
+separate tests rather than one test traversing their real correlation chain.
+
+Fix implementation commit: `94ca92f`
+(`fix: gate tray feedback on durable diagnostics`).
+
+### RED evidence
+
+The `WriteLog` failure test was added before changing production code:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+```
+
+Result: exit `1` at the new case after all preceding cases passed:
+
+```text
+ASSERT_EQUAL_FAILED: failed diagnostic persistence prevents terminal feedback delivery
+expected=[False] actual=[True]
+```
+
+This proved the exact reviewed defect: the Supervisor returned
+`Delivered=True` even though the local action record had not persisted.
+
+The real correlation test was also written before the production fix. It uses
+the test-only `TrayHostCorrelationTestBridge`, which compiles and composes the
+checked-in production `TrayWindow`, `NativeMenu`, `HostTransport`, action/result
+types, and revision behavior. No replacement protocol or external channel is
+introduced.
+
+### Implementation and pending-action safety
+
+`Write-CcodSupervisorTrayActionTerminal` now returns `true` only after the
+existing `WriteLog` adapter completes without error. It returns `false` after
+adding `CCOD_SUPERVISOR_LOG_FAILED` to the Supervisor cleanup receipt on any
+clock, schema-construction, or log-write exception.
+
+For terminal `Completed`, `Rejected`, and `Failed` results,
+`Send-CcodSupervisorTrayActionResult` now stops immediately with
+`Delivered=false` when that durable-record gate fails. It does not call
+`SendTrayActionResult`, so HostTransport cannot queue About work or generic
+failure feedback without the required local record. `Accepted` remains a
+nonterminal lifecycle acknowledgement and retains its existing authenticated
+delivery path.
+
+The pending HostTransport action is safe under this outcome:
+
+- it receives no false terminal acknowledgement;
+- it cannot display generic terminal feedback without a durable diagnostic;
+- it remains protected by the existing action-id replay cache and eight-entry
+ pending-action bound;
+- lifecycle completion receives `Delivered=false`, so the existing repair and
+ SafeExit gates do not mark the terminal notification complete or exit the UI;
+- the Supervisor cleanup receipt exposes `CCOD_SUPERVISOR_LOG_FAILED` for the
+ enclosing host outcome.
+
+No retry or unauthenticated fallback was added, so the change cannot duplicate
+a lifecycle terminal result or weaken action identity/capability authority.
+
+### Real end-to-end correlation coverage
+
+The new Supervisor behavioral case executes both branches through one actual
+chain:
+
+```text
+TrayWindow.HandleContextMenu
+ -> NativeMenu selected command and displayed revision
+ -> HostTransport.TryRegisterAction
+ -> Supervisor AcknowledgedPresentations revision/capability gate
+ -> actual OpenLogs adapter or stale rejection
+ -> sanitized WriteLog record
+ -> Supervisor SendTrayActionResult boundary
+ -> HostTransport.TryAcknowledgeAction
+```
+
+For revision 7, the same action selected by the native menu is placed in the
+ACK map, invokes `OpenLogs` exactly once, returns `Completed`, and is accepted
+by the real HostTransport with revision 7. For revision 8, no ACK-map entry is
+created; `OpenLogs` is invoked zero times, Supervisor returns
+`CCOD_TRAY_ACTION_STALE`, and the real HostTransport accepts the exact rejected
+terminal result and preserves revision 8 and the stale code before generic UI
+consumption.
+
+The bridge is test-only and calls the production in-process APIs directly. It
+does not start a production TrayHost process, open an external control surface,
+or alter the authenticated wire protocol.
+
+### Diagnostic record shape ruling
+
+Tests now treat `command`, `revision`, `status`, and `code` as required inclusive
+fields. They retain and validate the sanitized standard envelope
+`schemaVersion`, `timestampUtc`, `component`, `stage`, and `outcome`. They also
+explicitly assert the absence of `ActionId`, `TransactionId`, path, token,
+exception, message, and raw arbitrary-text properties. The envelope is a local
+log schema, not a new wire protocol.
+
+### GREEN and fresh verification
+
+After the minimal fail-closed implementation, the Supervisor suite passed 98
+cases, including the new durable-log failure and real correlation tests. Fresh
+pre-commit verification then ran every Task 4 required suite:
+
+```text
+TrayHost client: exit 0; 8 behavioral cases
+Supervisor: exit 0; 98 behavioral cases
+TrayHost transport: exit 0; 8 behavioral cases
+TrayHost native: exit 0; 15 behavioral cases
+git diff --check: exit 0
+git diff --cached --check: exit 0
+```
+
+The existing lifecycle safety tests remained green, including SafeExit refusal
+to shut down when its correlated terminal result is not delivered and terminal
+lifecycle completion only after acknowledgement.
+
+### Fix-round scope and concerns
+
+- Changed only `src/persistence/Supervisor.ps1`, its behavioral self-test, the
+ test-only C# correlation bridge, and this report.
+- No real Codex/ChatGPT or production TrayHost process was started, stopped, or
+ controlled. No installer, release, install, push, signing, WindowsApps, DPAPI,
+ or interactive product UI operation occurred.
+- No independent agent was used because this fix round explicitly prohibited
+ agents. Parent integration review remains the next review gate.
+
+## Fix round 2: release pending actions through a host-side diagnostic gate
+
+Re-review found that fix round 1's suppression of the terminal ACK prevented
+generic feedback without a record, but permanently retained the corresponding
+HostTransport pending entry. Eight Supervisor-log failures could therefore fill
+the pending-action bound and disable later tray commands.
+
+Fix implementation commit: `3bdd575`
+(`fix: release tray actions after diagnostic failure`).
+
+### TDD and RED evidence
+
+Tests were changed before production code. The new transport regression
+required a typed terminal record and a diagnostic-writer HostTransport boundary.
+The focused transport compile failed with the expected missing production API:
+
+```text
+CS0246: TrayTerminalDiagnostic could not be found
+CS1729: HostTransport does not contain a constructor that takes 2 arguments
+CCOD_TRAYHOST_NATIVE_COMPILE_FAILED
+```
+
+The Supervisor correlation suite independently failed to compile its real
+native/transport bridge for the same missing type. This established that the
+repeated-failure recovery behavior did not exist.
+
+After the first implementation, a second behavioral test treated a missing
+writer as a persistence failure. It failed before the default was hardened:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+missing diagnostic writer cannot authorize generic feedback
+```
+
+The default was then changed to fail closed for feedback while still consuming
+the authenticated terminal result.
+
+### Implementation
+
+`TrayTerminalDiagnostic` is a new internal strong type. Its constructor accepts
+only an existing `TrayCommand`, positive revision, terminal
+`TrayActionResultStatus`, and canonical result code. `Completed` maps to
+`CCOD_TRAY_ACTION_COMPLETED`; malformed failure values map to
+`CCOD_TRAY_ACTION_FAILED`. It cannot carry an action id, transaction id, path,
+token, exception, or arbitrary message.
+
+`TrayTerminalDiagnosticLog.TryAppend` writes exactly one fixed-format line:
+
+```text
+command= revision= status= code=
+```
+
+It returns `true` only after `File.AppendAllText` closes successfully and returns
+`false` without throwing on persistence failure. The native self-test writes to
+an isolated temporary file, verifies the line byte-for-byte, and verifies that
+an invalid file target returns `false`.
+
+Production `Program` derives a fixed current-user path under
+`LocalApplicationData\CodexControlOtherDevices\logs\trayhost-actions.log` and
+injects the typed writer into HostTransport. No path or arbitrary payload comes
+from the authenticated message, and no new pipe message, command, external
+control surface, or unauthenticated channel was added.
+
+HostTransport now processes a valid authenticated terminal result in this
+order while holding its existing correlation lock:
+
+1. require the exact pending action id and presentation revision;
+2. enforce lifecycle Accepted-before-Completed rules;
+3. build the typed terminal diagnostic from the pending command and result;
+4. attempt the injected local write;
+5. remove the pending action regardless of write success;
+6. enqueue About or generic failure feedback only when the write succeeded;
+7. return terminal acceptance so the authenticated reader does not treat a
+ consumed valid result as a protocol violation.
+
+If no writer exists or the writer returns/throws failure, no feedback is
+queued, but pending capacity is released. If the bounded feedback queue is
+already full, the additional terminal result is likewise consumed and its
+pending entry released; only the redundant UI item is suppressed.
+
+Supervisor again sends the authenticated terminal result after attempting its
+standard sanitized envelope log. This gives two safe cases:
+
+- Supervisor log succeeds: the standard timestamped local record already
+ exists, and TrayHost also gates feedback on its typed host receipt.
+- Supervisor log fails: `CCOD_SUPERVISOR_LOG_FAILED` remains in the cleanup
+ receipt, while the host receipt either persists before feedback or suppresses
+ feedback. In both cases the authenticated terminal result releases pending
+ capacity.
+
+The standard Supervisor envelope and its inclusive-field ruling were not
+changed.
+
+### Repeated failure and recovery proof
+
+The transport regression submits ten distinct authenticated failed actions
+while the diagnostic writer returns false. Every action registers, every exact
+terminal result is accepted, every pending entry is released, and zero generic
+feedback items are available. After switching the same writer to success, a new
+failed action registers and produces feedback, followed by a new command that
+registers and completes. The last typed record is asserted as exactly
+`OpenLogs`, revision 22, `Completed`, and `CCOD_TRAY_ACTION_COMPLETED`.
+
+The Supervisor end-to-end test repeats ten stale native-menu actions while both
+Supervisor and host diagnostic writes fail. The same real
+`TrayWindow -> NativeMenu -> HostTransport -> Supervisor ACK map -> result ACK`
+chain accepts every terminal result without invoking `OpenLogs` or producing
+feedback. After both writers recover and revision 2 is acknowledged, the next
+native command registers, reaches `OpenLogs` exactly once, and completes.
+
+### Fresh verification
+
+Fresh pre-commit verification results:
+
+```text
+TrayHost client: exit 0; 8 behavioral cases
+Supervisor: exit 0; 98 behavioral cases
+TrayHost transport: exit 0; 9 behavioral cases
+TrayHost native: exit 0; 16 behavioral cases
+TrayHost production compile/headless smoke: exit 0
+git diff --check: exit 0
+git diff --cached --check: exit 0
+```
+
+The production smoke compiles every checked-in TrayHost C# source, including
+the new diagnostic type, and runs only the existing no-UI headless path.
+
+### Fix-round scope and concerns
+
+- No authenticated wire field or result status changed.
+- No real Codex/ChatGPT or production interactive TrayHost process was started,
+ stopped, signaled, or controlled.
+- Nothing was installed, pushed, published, released, signed, or written to
+ WindowsApps/DPAPI. Runtime logging was tested only through an isolated temp
+ path; the production path was compiled but not exercised against user data.
+- No independent agent was used because this fix round explicitly prohibited
+ agents. Parent re-review remains the next gate.
+
+## Fix round 3: bounded reparse-safe receipts and production correlation trace
+
+Independent re-review retained three Important findings: the host action log
+was unbounded, its fixed LocalAppData path lacked reparse-component checks,
+diagnostic I/O ran while holding HostTransport's correlation lock, and the
+in-process bridge did not prove the real TrayHostClient/ParentClient/Wire/Program
+authenticated path.
+
+Fix implementation commit: `9c1e4df`
+(`fix: harden tray terminal diagnostics`).
+
+### RED evidence
+
+Tests were added before each production change.
+
+The blocking-writer transport test deliberately held the diagnostic writer for
+up to three seconds, then attempted to register another action. Current code
+failed because writer I/O still held `_gate`:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+blocking terminal I/O cannot hold the correlation lock or pending capacity
+```
+
+The bounded-retention native test failed at compile time before the fixed log
+contract existed:
+
+```text
+CS0117: TrayTerminalDiagnosticLog does not contain a definition for MaximumBytes
+CCOD_TRAYHOST_NATIVE_COMPILE_FAILED
+```
+
+After adding the first lock/retention implementation, a missing-writer assertion
+was retained from fix round 2 and remained green: no writer cannot authorize
+generic feedback but still releases the terminal pending action.
+
+The production authenticated trace test referenced Program's actual result
+dispatch before it was extracted. ParentClient-only compilation failed:
+
+```text
+CS0117: Program does not contain a definition for TryDispatchAuthenticatedActionResult
+CCOD_TRAYHOST_NATIVE_COMPILE_FAILED
+```
+
+This proved the old peer was not traversing Program's production result branch.
+
+### Bounded deterministic retention
+
+`TrayTerminalDiagnosticLog.MaximumBytes` is fixed at 64 KiB. Each record is
+encoded as UTF-8 without a BOM. Under the logger's private file gate, the writer
+selects one of two deterministic modes:
+
+- append when existing bytes plus the complete new record remain within 64 KiB;
+- recreate the file with only the complete newest record when the next append
+ would exceed 64 KiB or an already oversized file is encountered.
+
+The stream is closed after `Flush(true)`. The test writes 1,400 distinct
+records, proves the file never exceeds `MaximumBytes`, and proves the complete
+latest record remains at the end after rollover. This prevents long-running
+tray action volume from growing the file without bound.
+
+### Fixed-path and reparse defense
+
+Production no longer calls an unconditional `Directory.CreateDirectory` over
+the full path. `TryGetDefaultPath` starts from the system-provided current-user
+LocalApplicationData root and creates only the fixed
+`CodexControlOtherDevices` and `logs` child directories, one level at a time.
+Before each creation it verifies the existing parent chain, and after creation
+it verifies the new directory.
+
+`TryAppend` accepts only an absolute path whose leaf is exactly
+`trayhost-actions.log`. It rejects a directory leaf, a reparse-point file, any
+missing directory, or any existing directory component with the
+`FileAttributes.ReparsePoint` bit. Production supplies only the fixed path; no
+wire field, CLI value, action property, or user payload can select a path.
+
+The native test creates a real junction inside an isolated temporary root and
+proves a fixed-name log beneath that junction is rejected. All temporary files
+and the junction are removed by the test.
+
+### Diagnostic I/O outside the correlation lock
+
+HostTransport now validates the exact action id/revision and lifecycle state,
+constructs the typed diagnostic, and removes the terminal action from
+`_pendingActions` while holding `_gate`. It then releases `_gate` before calling
+the writer. After the writer returns, it reacquires `_gate` only to enqueue the
+About/generic feedback item when persistence succeeded and the transport is
+still active.
+
+The blocking-writer regression starts terminal acknowledgement on a background
+thread, waits until the writer is blocked, then registers a distinct action.
+Registration completes within 250 ms, proving neither correlation lock nor
+pending capacity is held by file I/O. After releasing the writer, both the first
+terminal acknowledgement and the concurrently registered action complete.
+
+False, throwing, absent, blocked, and full-feedback-queue outcomes continue to
+release pending capacity. Only persisted records authorize UI feedback.
+
+### Production authenticated correlation trace
+
+`Program.TryDispatchAuthenticatedActionResult` is the exact production reader
+operation extracted from the existing ActionResult branch:
+
+```text
+TrayHostWire.ReadActionResult(payload)
+ -> HostTransport.TryAcknowledgeAction(result)
+```
+
+The production reader itself now calls this internal function. No new CLI mode,
+frame type, wire field, external endpoint, or unauthenticated channel was added.
+
+The ParentClient self-test compiles Program and the real native/transport/wire
+sources into its isolated test executable. Its existing test-only peer process
+performs the real parent/host bootstrap, key derivation, MAC verification,
+sequence checks, and Action/ActionResult serialization. The peer registers the
+same action in real HostTransport and sends it over `TrayHostWire`; the result
+returns over the real authenticated `TrayHostParentClient` writer and is
+consumed by Program's production dispatch.
+
+`TrayHostProductionTrace.SelfTest.ps1` then loads that same compiled assembly
+and uses the real production PowerShell wrapper:
+
+```text
+TrayHostClient.Receive-CcodTrayHostEvents
+ -> TrayHostParentClient authenticated event
+ -> current or stale terminal decision fixture
+ -> TrayHostClient.Send-CcodTrayHostActionResult
+ -> TrayHostParentClient authenticated writer
+ -> TrayHostWire ActionResult
+ -> Program production dispatch
+ -> HostTransport diagnostic and feedback gate
+```
+
+The current case preserves `OpenLogs` revision 1, receives `Completed`, and
+produces no generic failure feedback. The stale case preserves `OpenLogs`
+revision 8 and `CCOD_TRAY_ACTION_STALE`; after the persisted typed receipt, the
+real feedback queue is consumed and `TrayWindow.ShowActionFailed` runs against a
+fake native platform. It proves the dialog contains only the acknowledged
+snapshot's generic `string-0|string-15` title/message, not the internal code.
+The fake platform creates no real window or interactive UI.
+
+Test-only peer arguments remain implemented only by the self-test main type;
+they are not recognized by production Program and therefore create no product
+control surface.
+
+### Fresh verification
+
+Fresh verification completed with these results:
+
+```text
+TrayHost client: exit 0; 8 behavioral cases
+Supervisor: exit 0; 98 behavioral cases
+TrayHost transport: exit 0; 10 behavioral cases
+TrayHost native: exit 0; 17 behavioral cases
+TrayHost parent-client: exit 0; 4 behavioral cases
+TrayHost production correlation trace: exit 0; 2 current/stale cases
+TrayHost production compile/headless smoke: exit 0
+git diff --check: exit 0
+git diff --cached --check: exit 0
+```
+
+The first combined verification command exceeded its 30-second tool window
+after the ParentClient cases and before the trace receipt was printed. It was
+not counted as evidence for the remaining stages. ParentClient+production trace
+and ProductionOnly smoke were rerun independently and both returned explicit
+exit `0` receipts.
+
+### Fix-round scope and concerns
+
+- Changed only TrayHost terminal logging/correlation production files, focused
+ tests/trace orchestration, and this report.
+- No real Codex/ChatGPT or production interactive TrayHost process was started,
+ stopped, signaled, or controlled. Native behavior used fake platforms; the
+ subprocess trace used only the compiled self-test peer.
+- Nothing was installed, pushed, published, released, signed, or written to
+ WindowsApps/DPAPI. Reparse and rollover writes were confined to unique temp
+ roots.
+- No independent agent was used because the fix-round instruction prohibited
+ agents. Parent re-review remains the next gate.
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
new file mode 100644
index 0000000..f5356b2
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
@@ -0,0 +1,737 @@
+# Task 5 report — v2.5.22 release metadata, documentation, and contract
+
+## Scope and safety boundary
+
+- Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`
+- Starting reviewed HEAD: `1d6bd1f` (`docs: record tray trace gate bypass fix`)
+- Implemented only release metadata, native build/artifact version verification,
+ release-coupled tests, stable README release references, CHANGELOG notes, and
+ deterministic GitHub release-note extraction.
+- Did not install or run the installer, start a real Codex/UI process, stop the
+ real Supervisor/TrayHost, publish, push, sign, tag, or write WindowsApps/DPAPI.
+
+## TDD evidence
+
+### Release version contract RED
+
+After changing only the ReleaseWorkflow contract to require v2.5.22:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 1
+ASSERT_EQUAL: package metadata is the 2.5.22 release
+expected=[2.5.22] actual=[2.5.21]
+```
+
+All preceding release gates, including real ISCC compilation, had passed. This
+proved the new contract failed for the intended missing release metadata.
+
+### Native static-metadata gate RED
+
+The next test called the real TrayHost/portable build entrypoints against
+controlled package, AssemblyInfo, and manifest mismatches before production
+changes:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\TrayHostBuild.SelfTest.ps1
+exit 1
+expected=[CCOD_TRAYHOST_VERSION_MISMATCH]
+actual=[CCOD_TRAYHOST_SOURCE_MISSING]
+```
+
+This proved a requested version mismatch was not rejected before compiler input
+resolution.
+
+### Artifact manifest and PE version RED
+
+A real compiled TrayHost artifact was validated against a copied repository
+contract. The test changed the manifest and then aligned package/static
+metadata/provenance to a false `9.9.9` while retaining the original PE:
+
+```text
+exit 1
+expected=[CCOD_TRAYHOST_VERSION_MISMATCH|CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID]
+actual=[|]
+```
+
+The old validator accepted both mutations. The final mutation coverage also
+changes valid-version manifest bytes without changing the identity, proving the
+manifest SHA binding independently.
+
+### Release-note extraction RED/GREEN
+
+The behavior test first failed because the workflow extractor did not exist.
+After the tool was added, a CRLF fixture produced a second intentional RED
+because the target English section retained CRLF bytes. The final extractor
+normalizes output to LF and emits only the exact target English section; the
+test excludes Unreleased, newer-version, Chinese, and older-version decoys.
+
+## Implementation
+
+### Version sources
+
+- `package.json`: `2.5.22`
+- TrayHost AssemblyVersion/FileVersion and Windows manifest: `2.5.22.0`
+- Portable launcher AssemblyVersion/FileVersion and Windows manifest:
+ `2.5.22.0`
+
+Historical CHANGELOG entries and non-release presentation/runtime fixtures
+remain unchanged. In particular, the TrayHost native `2.5.21` strings are
+acknowledged-presentation test inputs, `InstalledLifecycleHarness` uses
+`2.5.21-new` runtime fixtures, and ReleaseWorkflow retains an intentional old
+payload/structural workflow fixture.
+
+### Fail-closed native build and artifact verification
+
+`build/TrayHostBuild.psm1` now:
+
+- requires requested `-Version` to match `package.json`;
+- requires exactly one matching AssemblyVersion and AssemblyFileVersion;
+- parses the Windows assembly manifest and requires the exact identity name,
+ `win32` type, and `.0` value;
+- applies that contract to both TrayHost and portable build entrypoints before
+ compiler/reference resolution;
+- reads the compiled PE FileVersion and ProductVersion and requires exact
+ `.0` values before publishing either artifact;
+- makes `Test-CcodTrayHostArtifact` repeat the static contract, bind the source
+ manifest/config hashes from provenance, and reject PE version drift.
+
+Regression cases independently mutate package version, AssemblyVersion,
+AssemblyFileVersion, manifest version, manifest bytes, provenance/static
+version, and PE version relationship.
+
+### Documentation and release notes
+
+- README/README.zh-CN now name v2.5.22 only in the stable status, Quick Start,
+ and release-asset descriptions; no What's new block or internal error code
+ was added.
+- Removed the English README's literal `\r\n` text.
+- Corrected the asset contract: portable publishes its payload manifest as an
+ external asset; Setup embeds and hash-binds its versioned
+ `installer-payload.manifest.json`.
+- CHANGELOG has four concise v2.5.22 English bullets followed by Chinese notes.
+- `.github/workflows/release.yml` calls the behavior-tested
+ `tools/New-GitHubReleaseNotes.ps1`, so the GitHub body contains only the
+ requested release's English section.
+
+## Verification before primary commit
+
+```text
+TrayHostBuild.SelfTest.ps1
+ exit 0; 8 cases
+
+ReleaseWorkflow.SelfTest.ps1
+ exit 0; all cases, including real ISCC, real TrayHost PE validation,
+ CRLF-to-LF English-only release notes, and v2.5.22 documentation contract
+
+Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+ exit 0; 3 production child-session trace cases
+
+Invoke-TrayHostSelfTest.ps1 -NativeOnly
+ exit 0; 25 native fake-platform cases
+
+PowerShell AST parse
+ exit 0; TrayHostBuild module, three release-coupled test scripts,
+ and New-GitHubReleaseNotes.ps1
+
+version audit
+ package=2.5.22
+ tray Assembly/File/manifest=2.5.22.0
+ portable Assembly/File/manifest=2.5.22.0
+```
+
+### InstallLifecycle environment boundary
+
+The full requested test was run without stopping the real Supervisor:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+exit 1 after approximately 67 seconds
+case=transactional-uninstall-reaches-ReadyForInno-only-after-recovery-protection-stop-task-proof-and-application-removal
+error=CCOD_LIFECYCLE_LEASE_TIMEOUT
+message=Lifecycle ownership mutex timed out
+```
+
+That failing case begins at line 1876. The v2.5.22 release-coupled builder case
+begins at line 2588, so it was not reached and is not reported as passing. No
+mutex bypass or real Supervisor stop was attempted. `npm test` / aggregate
+Validate were not run because they would recurse into the same known live
+mutex boundary, contrary to the task's safety constraint.
+
+## Clean release build
+
+The primary release commit was created as:
+
+```text
+49f88e2 release: prepare CodexRemote-fix v2.5.22
+```
+
+From that clean HEAD, the normal release workflow completed:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File build\build.ps1 -Version 2.5.22
+exit 0
+```
+
+Generated assets remained under `build/dist` only:
+
+```text
+CodexRemote-fix-2.5.22-windows-x64.zip
+ 55d0b0e383faa208f0795dbeb6b2ae01051807fdd2d40ddea115951ae6fae02b
+CodexRemote-fix-2.5.22-setup.exe
+ 298dd4c821b83df3bcca32ffe0193bcaa55eff1ddcdf3115680198c635d668a0
+CodexRemote-fix-2.5.22-payload-manifest.json
+ 08cbbd5eb0093b15d461f42912e1395cf2c90da0c1da503b41b9bda1293b2492
+CodexRemote-fix-2.5.22-release-manifest.json
+ e313e9943888f3b346d20f7d6627fbf60254de40c6518d47e7c3ba46bd6fd18e
+CodexRemote-fix-2.5.22-setup-release-manifest.json
+ b7a95f58b0cb3178a3a2e7d208b5ba931c2fe7d391970ec297c1b8c70db17579
+CodexRemote-fix-2.5.22-trayhost-provenance.json
+ 95bc8535ba54b4a0bfc4356fd4162e71800b599b0a1b42476c0ce9912ab0ac40
+```
+
+Both release manifests passed `Test-CcodReleaseAssetManifest` for expected
+version `2.5.22`; portable reported distribution `portable-zip`. Both manifests
+and TrayHost provenance bound commit
+`49f88e2bd511a0f98ce93f48967e4cf3a333f93b`. The generated TrayHost and
+portable PE FileVersion/ProductVersion values were all `2.5.22.0`.
+
+The installer was compiled but never executed. No asset was installed,
+published, pushed, signed, or tagged. A post-build documentation precision fix
+corrected the actual embedded filename from the design spelling
+`installer-payload-manifest.json` to the implementation spelling
+`installer-payload.manifest.json`; therefore these local artifacts are retained
+as build evidence and must not be treated as immutable publish candidates for
+the follow-up documentation HEAD without a fresh clean rebuild.
+
+## Remaining acceptance boundary
+
+- A real setup install/upgrade, reboot, remote connection, About/language/logs,
+ repair, and Defender outcome remain outside this Task 5 local build scope.
+- Independent parent review remains required before any tag, push, publish,
+ signing, or installation decision.
+
+## Fix round 1 — portable artifact verification and release-note body
+
+Independent review found two Important gaps: the freshly compiled portable
+launcher was copied into the payload without an artifact validator analogous to
+TrayHost, and the GitHub notes extractor synthesized an H1 instead of returning
+only the selected English section body.
+
+### RED evidence
+
+The portable regression was added before production code and called the wished-
+for validator against a real compiled launcher:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\TrayHostBuild.SelfTest.ps1
+exit 1
+Test-CcodPortableLauncherArtifact is not recognized
+```
+
+The release-note expectation was independently changed to the exact English
+body with one LF, producing:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 1
+expected=[- Target English note.\n- Second target English note.\n]
+actual=[# CodexRemote-fix 2.5.22\n\n- Target English note.\n- Second target English note.\n]
+```
+
+### Implementation
+
+`Test-CcodPortableLauncherArtifact` now revalidates:
+
+- requested version against package, portable AssemblyInfo, and the portable
+ Windows manifest;
+- PE FileVersion and ProductVersion;
+- schema/product/version/framework/reference-pack, exact commit, and canonical
+ timestamp provenance;
+- exact `PortableLauncher.cs` and `AssemblyInfo.cs` source name/hash set;
+- icon, manifest, source config, executable, and artifact config hashes.
+
+`build/build.ps1` invokes that validator immediately after portable compilation
+and before the first portable EXE/config/provenance copy into the payload. The
+tamper regression changes commit, timestamp, every required hash class, source
+hashes, and a fully aligned false static/provenance version whose PE remains
+2.5.22.0.
+
+`New-GitHubReleaseNotes.ps1` now writes only the normalized target English body
+with exactly one trailing LF. The GitHub release title remains the workflow's
+separate `gh release create/edit --title` responsibility.
+
+### GREEN evidence and boundary
+
+```text
+TrayHostBuild.SelfTest.ps1
+ exit 0; 8 cases including portable validator tamper coverage
+
+ReleaseWorkflow.SelfTest.ps1
+ exit 0; English-body-only extraction and build-before-copy validator order
+```
+
+No formal release build, installer execution, install, publish, push, tag,
+signing, or real Codex/UI operation was performed in this fix round. The final
+fresh clean release build remains gated on scoped independent re-review.
+
+## Fix round 2 — CRLF-stable release-note fixture
+
+Final-candidate validation on a clean CRLF checkout exposed a test portability
+defect. The fixture already contained CRLF, but the test blindly replaced every
+LF with CRLF, producing CRCRLF. The real extractor then correctly rejected the
+malformed heading boundary:
+
+```text
+ReleaseWorkflow.SelfTest.ps1
+exit 1
+CCOD_RELEASE_NOTES_RELEASE_SECTION_INVALID
+```
+
+A focused local reproduction independently confirmed the old expression:
+
+```text
+CRCRLF_REPRODUCED=True
+```
+
+The fixture now normalizes CRLF and lone CR to LF before converting LF to CRLF.
+This preserves the intended CRLF-input coverage on LF, CRLF, and mixed-line-
+ending checkouts without changing production extraction behavior.
+
+Fresh GREEN evidence:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 0
+Release workflow self-tests passed.
+```
+
+No build, installer execution, install, publish, push, tag, or signing occurred
+in this fix round.
+
+## Fix round 3 — current portable branding regression
+
+The final controlled isolated `npm test` reached
+`InstallLifecycle.SelfTest.ps1:2942` and produced the expected RED in
+`README and release workflow publish current portable-release branding`. The
+release-coupled regression still required v2.5.21 setup/ZIP names at lines
+2949/2950/2957/2958 and the retired inline
+`englishSection = [regex]::Match` implementation at lines 2971/2972.
+
+The regression now requires the v2.5.22 setup and portable asset names in both
+Quick Start sections. It preserves the public GitHub release title assertion,
+requires workflow use of `tools\New-GitHubReleaseNotes.ps1`, and rejects a
+second inline English extractor. The missing-English boundary is exercised
+against the real tool with a controlled changelog and requires
+`CCOD_RELEASE_NOTES_ENGLISH_SECTION_INVALID` with no notes output.
+
+Local bounded verification:
+
+```text
+PowerShell AST parse of InstallLifecycle.SelfTest.ps1
+ exit 0
+
+git diff --check
+ exit 0
+```
+
+The local full InstallLifecycle suite was not represented as GREEN because the
+normal desktop environment retains the earlier real-Supervisor lifecycle mutex
+boundary. The parent will rerun the controlled isolated aggregate after scoped
+re-review. No build, installer execution, install, publish, push, tag, release,
+or signing occurred in this fix round.
+
+## Final security-fix wave — exact activation bytes, hard-link leaves, and Setup proof
+
+### Scope and safety boundary
+
+- Started from clean reviewed head `31fd365` in the named v2.5.22 worktree.
+- Preserved package/native version `2.5.22` / `2.5.22.0`, the authenticated
+ TrayHost protocol, receipt architecture, and existing CI/trace gates.
+- Did not execute an installer, install or upgrade the product, start or stop
+ real Codex/TrayHost/Supervisor/UI processes, publish, push, tag, sign, or
+ read/write WindowsApps or DPAPI device-key data.
+
+### RED evidence
+
+#### Exact activation bytes
+
+A real cross-process barrier supplied a test-only `powershell.exe` wrapper.
+The wrapper stopped after the activation parent verified the payload and before
+the child interpreter ran. The test then replaced the accepted manifest,
+installer, and lifecycle module. The old pathname flow returned success but
+proved that it parsed/executed the replacements:
+
+```text
+ReleaseWorkflow.SelfTest.ps1
+exit 1
+case=activation-executes-only-the-verified-manifest-installer-and-module-bytes-across-the-child-launch-barrier
+expected=[original:2.5.22]
+actual=[replacement:9.9.9]
+```
+
+No production test flag, environment switch, listener, or unauthenticated
+control input was added.
+
+#### Hard-linked install leaves
+
+The lifecycle regression created an outside sentinel and hard-linked each
+stable target leaf in turn. The old copy path did not reject either target:
+
+```text
+InstallLifecycle.SelfTest.ps1
+exit 1
+case=stable-bootstrap-and-public-uninstaller-reject-hard-linked-target-leaves-before-outside-bytes-change
+ASSERT_THROWS: expected CCOD_INSTALL_UNSAFE_LEAF
+```
+
+A separately compiled and safely executed Inno predicate fixture placed a hard
+link inside the existing Setup tree. The old `IsSafeExistingSetupTree` accepted
+the leaf, reached the simulated overwrite, and therefore did not write the
+fixture's success receipt (`result.txt` missing).
+
+#### Independent Setup artifact and provenance
+
+The real-ISCC artifact test was added before its validator:
+
+```text
+ReleaseWorkflow.SelfTest.ps1
+exit 1
+case=compiled-Setup-independently-binds-PE-versions-commit-and-activation-payload-manifest-hash
+ASSERT_TRUE: independent Setup artifact validator exists
+```
+
+After the validator API existed, the setup release fixture added the required
+setup-provenance asset before the release validator changed. The old validator
+then failed its exact asset contract, proving the provenance was not yet part
+of the accepted release:
+
+```text
+CCOD_RELEASE_MANIFEST_INVALID
+Release manifest does not bind the exact required asset set
+```
+
+#### Failure cleanup
+
+The cleanup regression injected a deterministic exception after creating the
+exact GUID-named installer payload stage and destination inventory. It first
+failed because the production finally scope did not exist:
+
+```text
+ASSERT_TRUE: build exposes its production temporary Setup scope
+```
+
+### Implementation contract
+
+#### Activation byte sealing
+
+- The activation parent opens the compile-bound manifest once, hashes and
+ parses the same byte array, and opens every current manifest record with
+ read-only/no-write/no-delete sharing.
+- It writes only those verified byte arrays into a unique sibling stage,
+ rehashes each staged leaf, and holds read handles for the entire child
+ lifetime. Original payload path replacement after the barrier cannot change
+ the child's inputs.
+- The child independently opens and holds the staged manifest/records, hashes
+ and parses the same manifest bytes, verifies exact version/package/file set,
+ and only then imports `InstallLifecycle.psm1`.
+- Lifecycle receives the accepted manifest as Base64 exact bytes plus the
+ expected SHA-256; it does not reopen the accepted manifest pathname for JSON
+ parsing. Existing final Ready pointer/runtime-manifest/version proof remains.
+
+#### Hard-link-safe leaves
+
+- Lifecycle now uses `CreateFileW` plus `GetFileInformationByHandle` and
+ requires `NumberOfLinks == 1`, regular/non-reparse shape, no alternate data
+ streams, and canonical install-root containment before/after copies.
+- Production copy writes a fresh same-directory temporary leaf and uses a
+ namespace replace/move, avoiding in-place mutation of an existing inode.
+- Stable `bootstrap.ps1`, the public uninstaller, staged runtime leaves,
+ runtime manifest, and recursively deleted install trees use the same leaf
+ contract.
+- Inno `PrepareToInstall` recursively checks every existing Setup leaf with
+ native handle link count plus stream enumeration before `[Files]` writes.
+
+#### Independent Setup artifact/provenance
+
+- `build/SetupArtifact.psm1` verifies final Setup PE `FileVersion` and
+ `ProductVersion` equal `.0`.
+- The final PE version resource independently binds product/version, the exact
+ 40-hex source commit, and the exact 64-hex installer payload-manifest SHA-256.
+ The negative test reuses the same final EXE with a mismatched expected hash
+ and requires `CCOD_SETUP_PAYLOAD_BINDING_INVALID`; no textual binary scan is
+ claimed.
+- `CodexRemote-fix-2.5.22-setup-provenance.json` records canonical
+ version/commit/timestamp, payload manifest name/length/hash/file count, Inno
+ template and inventory hashes, ISCC hash/version, and the expected PE
+ resource contract. It is embedded as a `dontcopy` Setup source and published
+ as an explicit release asset.
+- The setup release manifest hash-binds Setup, checksum, TrayHost provenance,
+ and Setup provenance. `Test-CcodReleaseAssetManifest` revalidates provenance
+ plus the final PE and rejects asset or metadata tampering.
+- Provenance validation reads the raw canonical timestamp representation and
+ accepts the JSON integer types produced by both Windows PowerShell 5.1 and
+ pwsh 7.
+
+#### Cleanup and documentation
+
+- The installer payload stage and destination inventory now live inside one
+ exact `try/finally` scope. Cleanup requires the build-root parent, exact GUID
+ leaf pattern, expected file/directory kind, and a reparse-free tree.
+- README and README.zh-CN now call v2.5.22 a release candidate and explicitly
+ leave stable Windows acceptance pending real install/upgrade/reboot/repair/UI/
+ Defender evidence. No What's-new block was added.
+
+### GREEN and regression evidence
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 0
+Release workflow self-tests passed.
+ includes real ISCC compilation, activation swap barrier, Inno hard-link
+ fixture, Setup PE positive/negative proof, failure cleanup, and release
+ provenance validation
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+exit 0; 3 production child-session trace cases
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -NativeOnly
+exit 0; 25 native fake-platform cases
+
+PowerShell AST parse of all modified .ps1/.psm1 files
+exit 0
+
+git diff --check
+exit 0
+```
+
+The lifecycle suite passed the new activation/hard-link cases and continued to
+the already documented live-Supervisor boundary. It was not reported green:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+exit 1
+case=transactional-uninstall-reaches-ReadyForInno-only-after-recovery-protection-stop-task-proof-and-application-removal
+error=CCOD_LIFECYCLE_LEASE_TIMEOUT
+```
+
+No mutex bypass and no real Supervisor stop was attempted.
+
+### Clean-head build evidence
+
+Normal build from clean implementation head
+`1f4259f7003d29d1023a567d15c877c22dfabd6c`:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File build\build.ps1 -Version 2.5.22
+exit 0
+TEMP_SETUP_INPUT_COUNT=0
+```
+
+Both Windows PowerShell 5.1 and pwsh 7 independently returned
+`portable=True` and `setup=True`. Setup FileVersion/ProductVersion were both
+`2.5.22.0`; portable/setup release manifests and Setup provenance all bound
+commit `1f4259f7003d29d1023a567d15c877c22dfabd6c`. The embedded activation
+payload-manifest SHA-256 was
+`e03753640f65f28aebdf46eb113f8ad26317169cbfc424e1629a6795c752a4f1`.
+
+Final retained build evidence hashes:
+
+```text
+CodexRemote-fix-2.5.22-windows-x64.zip
+ a70419842a2509ae2035e6890474b3e7258d5f64682edb8813d6782adc1c299b
+CodexRemote-fix-2.5.22-setup.exe
+ 5f650bc187112a5256f5a5b5d37972a6400cfe6e32586956830458adfc6b53e9
+CodexRemote-fix-2.5.22-setup-provenance.json
+ 8f0d1973b69d440ca8dad699bb9065b3030ea4f455d757fc5278e706f6cc76e9
+CodexRemote-fix-2.5.22-setup-release-manifest.json
+ 559fa329221fcd092356119b8b4aa8bf4562afe173a432ca4251969db5c4a9e6
+```
+
+Two earlier ignored artifact sets were preserved rather than deleted at:
+
+```text
+C:\Users\33384\AppData\Local\Temp\ccod-v2522-pre-final-security-build-0ff1f869906848418dc15e9ef89d5375
+C:\Users\33384\AppData\Local\Temp\ccod-v2522-ca5a5f7-build-00f3b177a3974b02bee3fe3f99f2cbc0
+```
+
+### Commits and remaining acceptance boundary
+
+```text
+cb36d0b test: expose activation payload byte swap
+ed782ec fix: seal activation bytes and reject linked leaves
+ca5a5f7 fix: bind setup artifact provenance
+1f4259f fix: validate setup provenance across hosts
+```
+
+- The compiled installer was never executed. Generated assets are unsigned,
+ unpublished local evidence only.
+- Real setup install/upgrade, reboot, remote connection, About/language/logs,
+ repair, Defender, and controlled isolated aggregate acceptance remain
+ pending. README intentionally does not call v2.5.22 stable.
+
+## Final security fix round 1 — whole-install delete leaf preflight
+
+Scoped re-review found that `Remove-CcodLifecycleInstallTree` did not consume
+the strict recursive tree predicate. It checked only each top-level child's
+path/reparse ancestry and then called `Remove-Item -Recurse`, so a nested hard
+link bypassed the already implemented regular/non-reparse/no-ADS/single-link
+leaf contract.
+
+### RED
+
+The regression created an ordinary in-tree file plus a nested hard link to an
+outside sentinel, then invoked the real private whole-install delete function:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+exit 1
+case=whole-install-deletion-rejects-a-nested-hard-link-before-removing-any-in-tree-leaf
+expected=[CCOD_INSTALL_UNSAFE_LEAF]
+actual=[]
+```
+
+The empty error id proves the old path accepted the hard link and completed
+recursive deletion instead of failing closed.
+
+### GREEN implementation and evidence
+
+`Remove-CcodLifecycleInstallTree` now calls
+`Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $root` before
+enumerating or deleting anything. That existing predicate walks every nested
+leaf and applies the native link-count, ADS, regular-file, reparse, and root
+containment checks. Therefore a failing leaf is detected before either the
+hard-link name or any ordinary in-tree leaf is removed.
+
+The full lifecycle suite passed the new regression and advanced to the same
+later live-Supervisor boundary; it is still not represented as aggregate
+GREEN:
+
+```text
+InstallLifecycle.SelfTest.ps1
+new whole-install hard-link case passed
+later exit 1
+case=transactional-uninstall-reaches-ReadyForInno-only-after-recovery-protection-stop-task-proof-and-application-removal
+error=CCOD_LIFECYCLE_LEASE_TIMEOUT
+```
+
+Additional fresh verification:
+
+```text
+ReleaseWorkflow.SelfTest.ps1
+exit 0; real ISCC and release workflow self-tests passed
+
+PowerShell AST parse of modified module/test
+exit 0
+
+git diff --check
+exit 0
+```
+
+Commits:
+
+```text
+f1ee9b7 test: expose linked whole-install deletion
+1a4abec fix: preflight whole-install tree deletion
+```
+
+No installer/build/release/install operation was performed in this narrow
+round. No real process was started or stopped, and no WindowsApps or DPAPI data
+was accessed.
+
+## Final remediation round 2 — pinned write boundaries and canonical provenance
+
+### Deterministic RED evidence
+
+Activation stage substitution used the real production function set extracted
+from `Activate-CcodRemoteFix.ps1`. A debugger barrier fired after the old stage
+pathname check and before the first verified leaf write. The barrier renamed
+the stage and replaced it with an outside junction:
+
+```text
+case=activation-pins-the-stage-directory-before-any-verified-leaf-write
+expected=[blocked]
+actual=[substituted]
+```
+
+The real compiled Inno harness retained the old preflight result, launched a
+concurrent PowerShell substitution attempt, and then simulated the Setup write:
+
+```text
+case=Inno-exposes-a-pre-write-payload-directory-reparse-gate
+expected concurrent attack=[blocked]
+actual=[substituted]
+```
+
+The Setup provenance fixture supplied canonical template, inventory, compiler,
+and payload files while its record used self-consistent false `111...`,
+`222...`, and `333...` hashes. The old validator accepted the record:
+
+```text
+case=Setup-provenance-rejects-self-consistent-wrong-canonical-build-input-hashes
+ASSERT_THROWS: expected CCOD_SETUP_PROVENANCE_INVALID
+```
+
+### Implementation
+
+- Activation now pins the app directory and atomically creates the stage and
+ every nested stage directory with relative `NtCreateFile(FILE_CREATE)`
+ handles. Each leaf transitions from its writer handle to identity/read pins,
+ is rehashed through the pinned read handle, and remains non-writable and
+ non-deletable through the child installer lifetime. All pins close in reverse
+ order before exact safe cleanup.
+- Inno retains a global native handle set from `PrepareToInstall` until the
+ start of `ssPostInstall`. Existing directories are opened with read/delete
+ access and no delete sharing; existing leaves are held with read/write
+ sharing but no delete sharing. The real harness proves the concurrent
+ directory rename/junction substitution is blocked while the legitimate
+ overwrite succeeds. `DeinitializeSetup` closes every pin on abort.
+- Setup provenance validation now requires the actual canonical Inno template,
+ generated destination inventory, ISCC executable, and installer payload
+ manifest paths. It compares real hashes, compiler version, payload length,
+ and payload file count rather than only checking SHA-256 text shape.
+- The exact Setup payload manifest and generated inventory are release-bound
+ assets. Build and publish validation use them with the repository template
+ and installed canonical ISCC path; release upload/read-back includes `.iss`.
+- README's historical Windows 11 validation line is explicitly qualified as
+ historical development evidence, not current v2.5.22 real-machine
+ acceptance.
+
+### GREEN evidence
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 0
+Release workflow self-tests passed.
+ - original source-byte swap barrier passed
+ - activation stage substitution was blocked
+ - real compiled Inno concurrent substitution was blocked
+ - simulated legitimate Setup overwrite succeeded
+ - outside sentinel remained unchanged
+ - false canonical provenance hashes were rejected
+ - real ISCC fixture compilation passed
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+exit 0; 3 cases
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -NativeOnly
+exit 0; 25 cases
+
+PowerShell parser checks and git diff --check
+exit 0
+```
+
+Commits:
+
+```text
+2633509 test: expose activation stage substitution
+39e838f fix: pin activation stage write boundary
+7bdd78c test: expose setup write TOCTOU and provenance drift
+f5021b0 fix: pin setup writes and verify provenance inputs
+```
+
+No real installer, Codex, TrayHost, or Supervisor process was run or stopped.
+No build candidate was published, pushed, tagged, signed, or installed, and no
+WindowsApps or DPAPI data was accessed. Controlled aggregate validation, final
+candidate rebuild, complete branch review, and real-machine acceptance remain
+with the controller.
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/progress.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/progress.md
new file mode 100644
index 0000000..c36cf5a
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/progress.md
@@ -0,0 +1,413 @@
+# SDD ledger — plan: docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md
+
+## Context
+
+- Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`
+- Branch: `codex/v2522-install-runtime-reliability`
+- Base: `03de616` (`docs: plan immutable generation release model`)
+- Spec: `docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md`
+- User confirmed the no-privilege immutable-generation model on 2026-08-29.
+- Older Task 1 handle/DACL implementation remains unreleased historical branch
+ work and will be rewritten or superseded; no old `build/dist` asset is valid.
+
+## Preflight plan scan
+
+| Producer / consumer | Interface or state | Result |
+| --- | --- | --- |
+| Task 1 -> Task 2 | opaque create-only generation and pointer primitives | Compatible; Task 2 must not reintroduce overwrite/recursive delete. |
+| Task 2 -> Task 3 | sealed generation, phase record, compensation | Compatible; Setup passes package hash and consumes strict Ready. |
+| Task 3 -> Task 4 | immutable stable generation/bootstrap | Compatible; registration occurs only after Ready. |
+| Task 2 -> Task 5 | pointer/receipt/manifest identity | Compatible; Defender failure must precede activation. |
+| Task 3 -> Task 5 | sealed Setup package and expected hashes | Compatible; package scan has a concrete bootstrap hook. |
+| Task 5 -> Task 6 | explicit 11 assets and dual receipt contract | Compatible; release code consumes one asset-name source. |
+| Task 6 -> Task 7 | clean candidate and private draft | Compatible; Stage never promotes or rebuilds. |
+| Task 7 -> Task 8 | official draft and downloaded asset evidence | Compatible; Preflight is the only live scan producer. |
+| Task 8 -> promotion | automated lifecycle and manual tray/remote evidence | Compatible; Promote is local and evidence-bound. |
+
+## Rulings
+
+- Ruling: the current-user hostile-writer guarantee is removed — a process with
+ unrestricted same-user write/DACL authority cannot be reliably defeated by a
+ current-user utility without an elevated/signed broker. The product instead
+ guarantees unique create-only generations, strict validation, atomic pointer
+ commits, and retention on failure. Cost if wrong: this is a narrower security
+ claim, but it is enforceable without changing install privileges.
+- Ruling: install-time cleanup retires or retains old generations — it does not
+ recursively delete a nonempty tree. Physical reclamation is a separate,
+ exclusive maintenance/uninstall operation. Cost if wrong: disk usage grows
+ until proven reclamation, but failed installs cannot partially erase a usable
+ generation.
+- Ruling: stable bootstrap is selected by the immutable generation/pointer
+ contract; Setup does not overwrite a writable `{app}` tree before Ready.
+ Legacy Inno registration is migrated only after the new generation is Ready.
+ Cost if wrong: migration has an extra registration step, but stale app files
+ cannot become the activation source.
+- Ruling: official browser-downloaded draft assets are the live Defender scan
+ source. No workflow or test fabricates ZoneId 3; public promotion waits for
+ two actual receipts and the current-machine acceptance chain.
+
+Task 1: in progress (base `03de616`)
+
+Task 1: fix round 1/5 opened after review of `03de616..e3ad4f7` found one
+Critical source/stream handoff window and three Important issues: physical
+retirement remained despite the revised create-only-record model, long-lived
+PowerShell re-import did not rebind its runtime type, and pointer/retirement/
+close race coverage was incomplete. The review also clarified that module
+session/reflection access is outside the unprivileged threat boundary. Ruling:
+retirement is record-only, sealed pins remain private through the handoff, and
+the runtime ABI is explicitly rebound on every import. Cost if wrong: the
+process-local API is not a sandbox, but ordinary callers cannot obtain raw
+handles or a partially verified generation.
+
+Task 1: fix round 1/5 opened after scoped review of `e3ad4f7..90d9d8d` found
+one Critical commit-then-failable handoff and two Important gaps: final leaves
+were visible before sealing, and streams could escape transaction ownership on
+early I/O failure. Ruling: every content write is registered before fallible
+I/O, sealed through a private temporary leaf, and only then renamed no-replace;
+record-only retirement is state-committed only after its record rename. Cost if
+wrong: temporary diagnostic leaves may remain, but no caller observes a partial
+final generation or a commit reported failed after it is visible.
+
+Task 1: scoped re-review of fix round 1 remains open. The reviewer marked the
+handoff/retirement/coverage set NOT ADDRESSED because a record or final leaf can
+still be visible before a potentially failing handoff, retirement record state
+can diverge after commit, and early destination streams may not be transaction
+owned. Fix round 2 is opened against `90d9d8d`; see
+`task-1-fix1-brief.md` for the complete required RED/GREEN changes. No later
+task may consume this file layer until the scoped re-review is clean.
+
+Task 1: fix round 2/5 opened after scoped review of `90d9d8d..e74cfdf` found
+one Critical retirement record commit-then-fail path and one Important
+exception-unsafe Close path. Ruling: retirement performs no fallible action
+after record publication, and Close attempts every registered resource before
+settling its closed state. Cost if wrong: a failed cleanup may retain a bounded
+temporary, but it cannot report a committed record as failed or leave unrelated
+handles locked behind the first disposal error.
+
+Task 1: complete (commits `e3ad4f7..f86d439`, scoped review clean). The final
+review of `e74cfdf..f86d439` marked retirement commit consistency and
+exception-safe/retriable Close ADDRESSED, with no new Critical/Important
+findings. Focused immutable-generation self-test is 16/16; aggregate remains
+blocked only by the documented local Bootstrap fallback/mutex case.
+
+Task 2: blocked before implementation. The Task 1 API cannot open an existing
+retained generation read-only or write generic scoped state/receipt/log records,
+so it cannot implement post-pointer compensation without bypassing the
+create-only boundary. Ruling: extend Task 1 with retained-generation and
+scoped-record primitives first; Task 2 must not invent a second path-based file
+API. Cost if wrong: one additional reviewed Task 1 interface round, but
+compensation remains bound to the same immutable file layer.
+
+Task 1: fix round 3/5 opened for the retained-generation/scoped-record API
+extension; see `task-1-fix3-brief.md`.
+
+Task 1: fix round 3/5 implementation `14178fc` passed its RED/GREEN suites,
+but the scoped re-review found one Important issue: Windows PowerShell
+`ConvertFrom-Json` collapses duplicate top-level `runtimeId` members, so the
+claimed uniqueness check was not strict. Four other findings (transitive
+read-only aliases, nested retained listing, V3 re-import, and transaction-root
+input) were addressed. Ruling: treat duplicate-key rejection as load-bearing
+and open fix round 4/5 with a fresh implementer; no later task may consume the
+extension until the scoped re-review is clean. Cost if wrong: a crafted
+manifest with duplicate identity keys could be accepted as a retained target.
+
+Task 1: fix round 4/5 (`14178fc..e80b829`) addressed the duplicate top-level
+`runtimeId` finding. A scoped re-review marked the finding ADDRESSED, with
+Spec Compliance PASS, Task Quality PASS, and no new Critical/Important
+findings. The bounded lexical key pass composes with `ConvertFrom-Json` to
+reject duplicate members while preserving decoded semantic values. Focused
+coverage is 25/25; aggregate Bootstrap ASSERT_EXACT remains an environmental
+boundary. Task 1 is complete (`e3ad4f7..e80b829`, review clean).
+
+Task 2 ruling before resumption: stable-shell candidates remain inside each
+immutable generation; the scheduled task and post-Ready shortcut/registration
+adapters select the generation-specific bootstrap, while legacy root shell
+files are retained and never overwritten in the install hot path. This keeps
+the create-only file layer honest and still permits upgrades from an old
+fixed-root task after the new generation reaches verified readiness. The
+existing `Copy-CcodInstallSealedSource` is used only with a writable generation
+capability; no root overwrite or second path-based copy API is introduced.
+Cost if wrong: an old shortcut may need one post-Ready registration update, but
+no incomplete upgrade can replace the only startup shell.
+
+Task 2 ruling: immutable initialization evidence and operational state are
+separate planes. The transaction writes create-only defaults below
+`state\install-initializations\`; only when absent are they
+materialized into the existing mutable `state\*.json` and
+`state\ui-preferences.json` operational plane. The Supervisor updates that
+operational plane only after `Ready`; upgrades never overwrite existing or
+partial files. Reason: Task1 sealing those runtime state files ReadOnly caused
+real StateStore/UI writes to fail with Windows error 5. Cost if wrong: one
+extra immutable evidence set per install, but normal tray/state/recovery
+updates remain functional rather than silently breaking after activation.
+
+Task 2 ruling: same-version idempotence and package-conflict behavior require
+an explicit canonical `SealedPackageSha256`. A direct/legacy invocation without
+that identity follows the normal upgrade path and cannot infer package equality
+from `package.json`. Task 3 Setup and portable entrypoints must pass the sealed
+package identity. Cost if wrong: older direct callers may perform an unnecessary
+upgrade, but no unsealed or weakly identified package can silently short-circuit
+activation.
+
+Task 2 initial implementation `2101485` passed focused and aggregate tests,
+but scoped review failed it with one Critical and six Important findings. The
+Critical was a real generation-bootstrap break: the task points at the new
+generation bootstrap while that bootstrap still requires root `active.json`,
+uses the old runtime-ID format, and lifecycle readiness rejects its parent.
+Ruling: fix round 1 must expand Task 2 to bootstrap and Bootstrap self-tests;
+it implements the same append-only pointer and canonical runtime-ID contract.
+Cost if wrong: a fresh install could report fake-test Ready yet fail on actual
+task startup or after reboot.
+
+Ruling: runtime identity is `projectVersion-fileDigest16-nonce32`, revalidated
+by manifest and bootstrap; transaction records bind old/new manifest hashes,
+the global entrypoint rejects every nonterminal/ambiguous chain, baseline
+evidence is written for each generation, and Ready terminalization may never
+append Failed after a visible Ready receipt. Cost if wrong: an attacker or
+failure could select an unbound generation, bypass a pending transaction, or
+leave contradictory terminal evidence.
+
+Task 2 fix round 1 `bd5b690` passed focused suites and aggregate but scoped
+re-review remained FAIL: one Critical production fence prevented actual fresh
+or append-only upgrade pointer commits, two Important selector consumers still
+accepted ADS/multilink/wrong root fallbacks, and the Ready-final-snapshot path
+was nonterminal but not recoverable. Ruling: fix round 2 adds a V4 opaque
+state-only transaction usable only to append a revalidated missing terminal
+state record; it cannot touch generations or pointers. The production fence
+must validate the append-only pointer directly, and every pre-runtime consumer
+must reject unsafe selector roots/leaves before legacy fallback. Cost if wrong:
+real installation can fail before bootstrap, authorization can trust unsafe
+selector storage, or terminalization can remain permanently stuck.
+
+Task 2 fix round 2 `f10f725` passed focused and fresh aggregate, but scoped
+re-review remained FAIL with Important selector-authorization gaps: several
+consumers treated every selector-root lookup failure as absence and fell back
+to legacy `active.json`; StaticProbe accepted selector schema version 2; V3 to
+V4 re-import evidence loaded V4 first; and Static/Uninstall hostile matrices
+omitted malformed JSON. Ruling: fix round 3 must permit legacy fallback only
+on a proven ItemNotFound outcome, require schema version 1 everywhere, use a
+child V3-first import process, and add malformed-selector regressions. Cost if
+wrong: ACL/I/O errors or malformed selectors can silently authorize an old
+runtime and the ABI containment claim remains unproved.
+
+Task 2 fix round 3 `8eb6920` passed focused and aggregate but scoped
+re-review remained FAIL with two Important absence-proof gaps: an intermediate
+`state` regular file can surface ItemNotFound for the child selector and allow
+legacy fallback, and StaticProbe treats an adapter null/no-output result as a
+proven missing selector. Ruling: fix round 4 must require a real nonreparse
+`state` directory before a child absence can be accepted, and StaticProbe must
+use a discriminated explicit absence result rather than optional empty output.
+Cost if wrong: malformed parent storage or an adapter contract failure can
+silently authorize a stale legacy runtime.
+
+Task 2 fix round 4 `d3ec629` passed focused and aggregate but scoped
+re-review remained FAIL with one Important uninstall compatibility gap: when
+the `state` ancestor is truly absent, Uninstall resolves its child first and
+rejects before the permitted legacy pointer fallback. Ruling: fix round 5 must
+prove `state` separately, then probe the child only under a real plain
+directory; add a no-state legacy fallback regression distinct from later epoch
+validation. Cost if wrong: an otherwise valid legacy uninstall cannot recover
+its selected runtime when the new selector plane has never been created.
+
+Task 2 verification ruling: the Bootstrap release-failure test's 4-second
+whole-process watchdog is a reproducible test-harness flake, not a production
+deadlock. The real bootstrap exits about 5.1 seconds after cold start while
+correctly logging release failure and killing the fallback child; the harness
+kills it first at 4 seconds. Replace the fixed start-to-exit budget with a
+release-attempt marker, a bounded 15-second watchdog, and a 2-second
+marker-to-exit assertion while retaining log, child-exit, and mutex-release
+proofs. Cost if wrong: the test becomes slightly more structured, but it keeps
+detecting a swallowed release failure without being load-sensitive.
+
+Task 2: fix round 5 `46692e7` addressed the no-state Uninstall fallback and
+the reproducible Bootstrap test-harness timing flake. Scoped re-review marked
+Spec Compliance PASS and Task Quality PASS with no Critical/Important
+findings. Fresh local focused verification and a full
+`tests\PersistenceSelfTest.ps1` aggregate both exited zero. Task 2 is complete
+(`2101485..46692e7`, review clean).
+
+Task 3 initial implementation `9e1c41c` passed focused suites but scoped
+review failed with four Important gaps: portable did not propagate explicit
+sealed identity; schema-2 provenance validation was partial; compiled Inno was
+not executed through a safe pre-activation failure fixture; and the report
+omitted the exact commit. Ruling: fix round 1 must bind portable identity,
+enforce exact nested provenance schemas, run a mismatched-hash compiled Setup
+fixture that fails before activation with no product state, and record the
+full commit. Cost if wrong: portable differs from Setup identity semantics,
+provenance can be cosmetically altered, and the no-write Inno claim lacks
+runtime evidence.
+
+Task 3 reporting ruling: a Git commit cannot record its own final hash. The
+fix round therefore uses an implementation commit followed by a report-only
+evidence commit that records the implementation commit's exact 40-hex SHA;
+the scoped review package covers both commits. Cost if wrong: one additional
+auditable documentation commit, but no self-referential or ambiguous HEAD
+claim.
+
+Task 3 fix round 1 `51def5c..728b627` passed focused suites but scoped review
+remained FAIL with three Important binding gaps: portable identity was not tied
+to the copied/executed payload, schema-2 provenance accepted duplicate raw JSON
+members, and the compiled Setup failure fixture did not snapshot LocalAppData
+product state. Ruling: fix round 2 must use the post-copy verified identity,
+strictly reject duplicate keys at every provenance object level, and compare a
+read-only product-tree snapshot before/after the wrong-hash compiled Setup run.
+Cost if wrong: a raced portable payload or ambiguous provenance can be accepted,
+and the pre-activation no-product-state claim is incomplete.
+
+Task 3 fix round 2 `1cb9162..a5d5958` passed focused suites but scoped
+re-review remained FAIL with one Important test-compliance gap: the portable
+race test called only the copy helper, so its child/lifecycle absence assertions
+did not prove the portable entrypoint stopped before execution. Ruling: fix
+round 3 must invoke `Install-CodexRemote-fix.ps1` with a marker-capable child
+fixture and race the verified source at the validation-to-copy boundary; prove
+no child marker or lifecycle state before failure. Cost if wrong: portable
+production may be correct but the required end-to-end race evidence is absent.
+
+Task 3 fix round 3 `58bd760..9e971d1` passed focused suites but scoped
+re-review remained FAIL: its actual-entrypoint mutation occurred during mocked
+Defender and was caught by the earlier post-scan manifest validation, not by
+the final validation-to-File.Copy race. Ruling: fix round 4 must use the real
+entrypoint while mutating at the production copy barrier after the copy helper's
+own validation, and require `CCOD_PORTABLE_COPY_HASH_MISMATCH` plus no child or
+lifecycle mutation. Cost if wrong: the full-path test validates an earlier
+check but not the claimed final copy boundary.
+
+Task 4 initial implementation `e43d9ce..97cf17d` passed focused suites but
+scoped review failed with one Critical and four Important findings: installed
+uninstall finalizes before deleting its selected generation, matched-only
+uninstall cleanup is too weak, failed post-Ready registration cannot retry,
+legacy cleanup can partially delete, and exported shortcut capability accepts
+arbitrary source paths. Ruling: fix round 1 must add a verified external
+generation finalizer, exact registration/shortcut matching with compensating
+legacy migration, post-Ready retry, and sealed-generation-only shortcut source
+capabilities. Cost if wrong: Windows Settings uninstall deterministically
+fails, user replacements can be deleted, registration can remain permanently
+absent, or an arbitrary source can be registered as product shortcut.
+
+Task 3 fix round 4 `141ab71..70bc231` addressed the actual production module
+File.Copy-boundary portable race. Scoped re-review marked Spec Compliance PASS
+and Task Quality PASS with no Critical/Important findings. Fresh local
+ReleaseWorkflow, InstallLifecycle, parser/diff verification, and the full
+`tests\PersistenceSelfTest.ps1` aggregate all exited zero. Task 3 is complete
+(`9e1c41c..70bc231`, review clean).
+
+Task 3 fix round 4 `141ab71..70bc231` addressed the production module
+File.Copy-boundary portable race. Scoped re-review marked Spec Compliance PASS
+and Task Quality PASS with no Critical/Important findings. Fresh local
+ReleaseWorkflow, InstallLifecycle, parser/diff verification, and the full
+`tests\PersistenceSelfTest.ps1` aggregate all exited zero. Task 3 is complete
+(`9e1c41c..70bc231`, review clean).
+
+Task 2 fix round 5 implementation passed its final verification matrix. The
+Uninstall bootstrap now resolves and proves `state` separately, resolves
+`state\active-generation` only below a proven plain directory, and permits a
+valid legacy pointer when the whole state plane is absent without bypassing
+the later lifecycle-epoch boundary. The real no-state regression includes an
+invalid-pointer negative control so skipping authorization cannot satisfy it.
+The Bootstrap release-failure regression now uses the ruled marker-relative
+timing contract without changing production Bootstrap behavior. All eight
+focused suites passed (26, 26, 21, 41, 17, 116, 24, and 9 cases), all three
+changed PowerShell files parsed, `git diff --check` passed, and the full
+`tests\PersistenceSelfTest.ps1` aggregate exited `0`. Task 2 fix round 5 is
+ready for scoped re-review.
+
+Task 3: complete. Setup now compiles from one version/commit-bound sealed ZIP,
+its canonical package manifest, the hash-bound temporary activation bootstrap,
+and setup provenance as exactly four `dontcopy` inputs. It sets
+`CreateAppDir=no` and `Uninstallable=no`, has no pre-Ready `{app}` product,
+shortcut, registry, uninstall, recursive extraction, or legacy app-bootstrap
+path, and retains its input locks through strict append-only Ready validation.
+The real disposable package/bootstrap test proves exact
+`SealedPackageSha256` propagation. Final focused verification passed
+ReleaseWorkflow, InstallLifecycle 113/113, parser 8/8, `git diff --check`, and
+the zero-violation static boundary audit. Task 4 retains all product
+registration work; Task 5 retains Defender/public-asset work.
+
+Task 3 fix round 1 implementation
+`51def5c9ae2f5e0722b9083e478b17c507e9cee8` addressed all four Important
+findings from review of `46692e7..9e1c41c`: portable explicitly propagates the
+twice-verified sealed identity; schema-two Setup provenance has exact ordered
+nested schemas and a 24-case mutation matrix; a real wrong-hash production
+Setup execution now fails nonzero from `PrepareToInstall` before activation or
+product output; and `task-3-report.md` records full commit identities. Final
+focused ReleaseWorkflow and InstallLifecycle 113/113 suites passed, along with
+parser 4/4, diff check, and the zero-violation no-`{app}` boundary scan. The
+fix-round report evidence is a separate report-only commit so the report can
+truthfully contain the immutable implementation SHA.
+
+Task 3 fix round 2 implementation
+`1cb91621f2565318143dffe08844a91a350a36d9` addressed the three Important
+findings from review of `9e1c41c..728b627`: portable identity is derived from
+the revalidated published copy and has a real validation-to-copy race test;
+raw duplicate JSON members are rejected before deserialization at every
+provenance object depth; and the wrong-hash compiled Setup fixture compares a
+bounded read-only before/after snapshot of the actual LocalAppData product
+tree. Final ReleaseWorkflow and InstallLifecycle 113/113 suites passed, with
+parser 4/4, diff check, and zero no-`{app}` boundary violations. The exact
+implementation SHA is recorded through the separate report-evidence commit.
+
+Task 3 fix round 3 implementation
+`58bd7603fa83133d78d2f62ac045ec00d25134c9` replaced the helper-only portable
+copy race with an actual `Install-CodexRemote-fix.ps1` invocation. A controlled
+test-local Defender mutation changes the child source after initial validation;
+the production entrypoint's post-Defender revalidation rejects it with
+`CCOD_PORTABLE_MANIFEST_INVALID` before child marker or fixture lifecycle state.
+ReleaseWorkflow and InstallLifecycle 113/113 passed, with parser 1/1 and clean
+diff check. The exact implementation SHA is recorded in the separate report
+evidence commit.
+
+Task 4 fix round 1 `2e76698..0fdc392` passed focused suites but scoped review
+remained FAIL with four Important gaps: finalizer path/wrapper/epoch binding
+was insufficient and removed the whole root; current cleanup accepted coherently
+replaced registry/shortcuts; product shortcut authority was pre-Ready/unselected;
+and production legacy restoration suppressed restore failures. Ruling: fix
+round 2 binds finalizer to exact transaction/runtime root/manifest/epoch and
+deletes only that root, makes cleanup/retry capability derive exact Ready
+transaction identity, and fails loudly on any uncompensated legacy restore.
+Cost if wrong: deletion can target a sibling/root, user replacements can be
+removed, or a failed migration can silently leave partial legacy state.
+
+Task 4 and its legacy/uninstall reliability successors are complete through `9d7cc90`, with evidence and
+review acceptance through `3eea951`. The true v2.5.21 lifecycle bridge, exact historical registration
+profiles, durable pre-write migration plan, same-SID/new-session TaskRemoved recovery, post-reclamation
+staged resume, pinned generation reclamation, and Runtime V7 ABI closure all passed their scoped reviews.
+The latest frozen persistence aggregate exited 0. Task 5 is now in progress; Tasks 6–8 remain blocked.
+
+Task 5 implementation `fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4` centralizes the exact ordered
+11-asset contract, binds schema-two Defender receipts to explicit origin/status/clock and post-scan
+identity evidence, requires two distinct official Internet-download receipts cross-checked against the
+real asset directory, and scans Setup's held sealed package before any extraction or lifecycle mutation.
+Frozen verification passed ReleaseWorkflow 58/58, PortableRelease 5/5, InstallLifecycle 151/151, parser
+103/103, diff check, and all 39 aggregate suites. No real Defender/build/install/network/release action
+was performed. Task 5 scoped review FAIL (1 Critical, 3 Important): the public Defender function exposes
+full-fact adapters; the central contract shallow-validates nested payload/provenance; asset identity is not
+handle-held across scan/promotion; and receipt writing lacks production read-back. Task 5 fix round 1 is in
+progress. Tasks 6–8 remain blocked.
+
+Task 5 fix round 1 implementation `9f8a9e7f4aa8ba6028941026cbca776cbd908610`
+closes the four scoped-review findings without changing the 11 public names,
+receipt schema, candidate docs, or Setup/portable ordering. Public Defender
+surfaces no longer accept adapters or a library bypass; nested portable/Setup
+payload and provenance are exact; scan and promotion hold asset/receipt
+identities through the decision; and receipts require production write/read-back.
+Frozen verification passed ReleaseWorkflow 63 declared cases, PortableRelease
+5/5, InstallLifecycle 151/151, parser 104/104, diff check, and all 39 aggregate
+suites. No real Defender/build/install/network/release action was performed.
+Task 5 scoped re-review of `fb163b7..9f8a9e7` is PASS (Spec Compliance PASS,
+Task Quality PASS, 0 Critical, 0 Important). Original findings 1-4 are closed.
+Task 5 is complete. Task 6 is in progress; Tasks 7-8 remain blocked.
+
+Task 6 fix round is implemented in the current worktree after the first scoped review
+returned 4 Critical and 7 Important findings. The fix adds fail-closed clean-runner
+inspection and create-only preflight evidence; binds prepare/preflight/build/stage to
+one resolved 40-hex commit and transfers preflight as a pinned artifact; removes the
+GitHub environment bypass and pins every gh call to `naipi11/CodexRemote-fix`; uses a
+real same-tag named mutex; separates `defender/`, `verification/`, and `acceptance/`
+evidence; persists and revalidates Verify hashes; and requires exact remote asset/state
+read-back before and after promotion. No live GitHub, Defender, network, publication,
+installation, or reboot action was performed.
+Focused Task 6 slices, full ReleaseWorkflow, Bootstrap 27/27, PowerShell/YAML parsing,
+static security checks, and the formal `npm test` validation all passed. The old
+InstallLifecycle branding assertion was updated to the new draft-tool ownership and
+its standalone suite passed 151. A constrained independent re-review inspected the exact staged Task 6 diff and returned PASS with no findings. The implementation was committed as `6d9bafa96c252ece692e8aff35e420285479b7b6`, and the required public module wrappers were restored and verified in corrective commit `325985d9a56bc222be1eb6a73dfb60b6326706da`; the verification report is committed separately. No live GitHub, Defender, network, publication, installation, or reboot action was performed.
+Task 6 fix-round implementation and independent acceptance are complete. Task 7/8 official draft acceptance remains blocked until its own gates are implemented.
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md
new file mode 100644
index 0000000..b0623ef
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md
@@ -0,0 +1,537 @@
+# Task 1 report: immutable generation file layer
+
+## Status
+
+Implemented and committed the unprivileged immutable-generation data plane in
+the assigned worktree. No release, push, tag, signing, installer execution,
+product process control, WindowsApps change, DPAPI change, or persistent DACL
+mutation was performed.
+
+## RED evidence
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `1`
+- Expected failure: the old module exported the pinned/DACL transaction API;
+ the required seven immutable-generation operations were absent.
+- Additional retirement immutability RED: the same focused command exited `1`
+ because a retired capability could still create `post-retire.bin`; GREEN now
+ rejects it with `CCOD_INSTALL_GENERATION_RETIRED`.
+
+## GREEN and verification evidence
+
+- Focused command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `0`
+- Result: `10/10` named cases passed, including duplicate generation/leaf and
+ pointer generation collisions, private pinned source/destination handles,
+ flush plus same-handle length/SHA-256, manifest immutability, reparse/ADS/
+ multilink rejection, forged/cross-transaction/closed capabilities, handle
+ release, no-replace monotonic pointer records, and nonempty retirement.
+- Parser command: explicit PowerShell parser pass over the four changed files.
+- Parser exit code: `0` (`Changed PowerShell parser check passed.`)
+- Diff command: `git diff --check`
+- Diff exit code: `0` (only Git LF-to-CRLF checkout warnings).
+
+## Aggregate evidence and known local limitation
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1`
+- Exit code: `1`
+- Exact boundary: `Bootstrap.SelfTest.ps1`, case
+ `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`,
+ error `ASSERT_EXACT`.
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\Validate.ps1 -SkipInstalledPackageCheck`
+- Exit code: `1`
+- Exact top-level marker: `CCOD_VALIDATION_FAILURE[0] Persistence self-test
+ failed with exit code 1; CCOD_SELFTEST_FAILED case=selects-previous-runtime-
+ after-active-exits-before-ready-and-swaps-pointer error=ASSERT_EXACT`.
+- Classification: the task brief's known local Bootstrap fallback/mutex
+ aggregate boundary. It is recorded as environmental and is not reported as
+ a pass. The focused file-layer suite and changed-file parser checks pass.
+
+## Windows behavior
+
+- Generation and directory leaves use relative `NtCreateFile` `FILE_CREATE`
+ semantics below validated native parent handles.
+- Sealed sources are opened without write/delete sharing; destinations are
+ created once, flushed, rehashed through the same handle, then handed off to
+ read-only pins without exposing streams or handles.
+- Active pointer records are append-only, zero-padded generation files written
+ to unique temporary leaves and atomically renamed without replacement.
+- Retirement validates the entire owned tree, retains the generation root
+ handle while descendant handles close, then performs one no-replace relative
+ rename into `retired\.`. Bytes and the original SDDL
+ remain unchanged; a create-only retirement record is retained.
+- The only exported CLR type is the inert `CcodInstallCapabilityMarker`; all
+ path/handle operations remain on an internal type. PowerShell capabilities
+ have no public fields and are bound through module-private weak tables.
+
+## Commit
+
+- `e3ad4f703884ed6893f89565b2bcdc925977a37d` — `fix: use immutable generation file primitives`
+
+## Concerns
+
+- This is deliberately a current-user ordinary-race boundary, not protection
+ against a same-user process with unrestricted DACL/write authority.
+- The append-only pointer reader/consumer migration and complete manifest
+ semantic validation belong to later plan tasks; Task 1 enforces create-only
+ storage, manifest presence, owned runtime identity, and monotonic record
+ collision behavior.
+- Failed no-replace record publication can retain a unique `.ccod.*.tmp` leaf
+ for diagnosis; there is intentionally no install-hot-path recursive cleanup.
+
+## Fix round 1 — review remediation
+
+This section supersedes the original report's description of physical
+generation retirement. Retirement is now record-only; the nonempty generation
+is never renamed or deleted.
+
+### RED evidence
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `1`
+- Exact first failure: case
+ `force-re-import-rebinds-the-current-runtime-ABI-before-real-use`; after two
+ `Import-Module -Force` calls, real `Open-CcodInstallGeneration` failed because
+ `$script:CcodRuntimeType` was unset (`VariableIsUndefined`).
+
+### GREEN evidence
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `0`
+- Result: `13/13` named cases passed. New coverage proves:
+ - ABI v2 rebinding works after two forced imports and a real create;
+ - a 16-worker write/name-exchange attack during handle handoff either leaves
+ a final strict handle revalidated for identity/length/SHA-256 or causes a
+ stable seal failure that cannot commit;
+ - successful sealed leaves remain readable by path before transaction close;
+ - close races a real synchronized CLR relative create and releases source,
+ destination, generation, and retained diagnostic temporary handles;
+ - pointer generation `1 -> 2`, UInt64 overflow rejection, and two independent
+ Windows PowerShell contenders with exactly one no-replace winner;
+ - record-only retirement, collision, record-path I/O failure, post-retirement
+ mutation rejection, and repeated in-transaction idempotence.
+- Reliability rerun: three consecutive expanded focused runs exited `0`.
+- Parser command: explicit parser pass over the four Task 1 PowerShell files.
+- Parser exit code: `0` (`Changed PowerShell parser check passed.`)
+- Diff command: `git diff --check`
+- Diff exit code: `0` (only LF-to-CRLF checkout warnings).
+
+### Aggregate evidence
+
+- `tests\PersistenceSelfTest.ps1`: exit `1` at the unchanged known local case
+ `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`,
+ error `ASSERT_EXACT`.
+- `tests\Validate.ps1 -SkipInstalledPackageCheck`: exit `1` with the same
+ `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker.
+- These aggregate results remain environmental boundaries and are not called a
+ pass. The expanded immutable-generation suite exits zero.
+
+### Windows behavior after remediation
+
+- The current CLR bridge uses `CcodInstallGenerationCapabilityMarkerV2` ABI 2;
+ every module import resolves the marker and rebinds the internal runtime type
+ even when the AppDomain already contains it.
+- Destination handoff opens a temporary read bridge only while the private
+ verified writer still pins the identity. After writer close it opens the
+ strict read handle and immediately rechecks file identity, final path, plain
+ file/stream/link invariants, length, and SHA-256 on that same strict handle.
+- Retirement creates exactly
+ `state\retired-generations\.json` through the atomic no-replace
+ record path. The original `runtime\` tree and handles remain in
+ place. State changes to retired only after record publication succeeds.
+- `ExpectedPreviousGeneration = UInt64.MaxValue` fails before addition with
+ `CCOD_INSTALL_POINTER_GENERATION_OVERFLOW`.
+
+### Fix commit
+
+- `90d9d8d6f4aa851073fb8973a47ac0c9c0f18b7d` — `fix: close immutable generation review gaps`
+
+### Remaining concerns
+
+- Retirement idempotence is guaranteed for repeated calls using the same live
+ opaque transaction. Cross-process crash reconciliation and consumption of
+ retired-generation records belong to the later lifecycle/state migration.
+- A failed no-replace collision deliberately retains its unique temporary
+ record for diagnosis; `Close-CcodInstallFileTransaction` releases its handle,
+ and no recursive cleanup is added.
+
+## Fix round 2 — temp-before-validation and commit-final publication
+
+This section supersedes the fix-round-1 handoff description. No final
+generation leaf, manifest, pointer record, or retirement record is created
+before its private temporary object has completed copy/write, durable flush,
+plain-file validation, length verification, and SHA-256 verification.
+
+### RED evidence
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `1`
+- Exact failure: case
+ `requested-final-leaf-is-absent-until-private-temporary-bytes-are-sealed`.
+ A concurrent ordinary path reader observed requested `payload.bin` at length
+ `0` while the expected sealed length was `22020096` bytes.
+
+### GREEN evidence
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `0`
+- Result: `15/15` named cases passed. The two added regressions prove:
+ - the first observable requested final leaf already has the exact sealed
+ length and SHA-256, while no successful `.ccod.*.tmp` name remains; and
+ - a source-hash failure publishes no final leaf, the source stream is owned
+ by the transaction until `Close(Failed)`, and all private temp handles are
+ released deterministically.
+- Reliability evidence: three consecutive expanded focused runs exited `0`;
+ the final post-change focused run also exited `0`.
+- Parser command: explicit parser pass over the four Task 1 PowerShell files.
+- Parser exit code: `0` (`Changed PowerShell parser check passed.`)
+- Diff command: `git diff --check`
+- Diff exit code: `0` (only LF-to-CRLF checkout warnings).
+
+### Aggregate evidence
+
+- `tests\PersistenceSelfTest.ps1`: exit `1` at the unchanged known local case
+ `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`,
+ error `ASSERT_EXACT`.
+- `tests\Validate.ps1 -SkipInstalledPackageCheck`: exit `1` with the same
+ `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker.
+- These aggregates remain environmental boundaries and are not called passes.
+
+### Windows behavior after round 2
+
+- `Copy` opens and transaction-registers its source before length/hash I/O and
+ creates a generated `.ccod..tmp` destination registered before copy,
+ flush, or hash can throw.
+- `Write` uses the identical private-temp path for manifest, pointer, and
+ retirement JSON records.
+- `SealAndPublish` closes the private writer under the unguessable temp name,
+ reopens and verifies that temp through a strict read handle, closes it,
+ opens the validated rename handle, applies the read-only file attribute,
+ pre-registers the final dictionary key, and performs one native no-replace
+ relative rename. After rename success it performs no open, flush, hash,
+ stream handoff, or result allocation.
+- Native `Write` returns an existing token rather than allocating an array
+ after publication. PowerShell copy/manifest/pointer/retirement result objects
+ and retirement capability are prepared before the native commit.
+- Before pointer eligibility or retirement, `EnsurePublishedPin` reopens every
+ published generation file by relative handle and revalidates identity, final
+ path, link/stream type, length, and SHA-256; the read handle then becomes
+ transaction-owned.
+- Retirement remains exactly one create-only
+ `state\retired-generations\.json` record. The in-memory retired
+ transition occurs only after the native record publication returns success.
+
+### Fix commit
+
+- `e74cfdf28fa05c2b0f8c61634b6542f41e734f45` — `fix: seal temporary leaves before publication`
+
+### Remaining concerns
+
+- Between copy publication and later pointer/retirement eligibility checks,
+ an unrestricted same-user writer can clear the read-only attribute or rename
+ the file. That actor is outside the approved unprivileged boundary; the
+ eligibility path nevertheless reopens and rejects any identity/path/hash or
+ unknown-leaf change before writing the control record.
+- Failed no-replace publication retains a read-only diagnostic temp file but
+ no stream handle. It is intentionally not recursively reclaimed in the
+ install hot path.
+
+## Fix round 2 scoped re-review — terminal retirement commit and exception-safe close
+
+### RED evidence
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `1`
+- Exact failing case:
+ `close-retries-a-failing-disposal-and-releases-every-other-registered-resource`.
+ An injected first `FileStream.Dispose` exception stopped cleanup; the
+ generation directory remained locked, and fixture cleanup failed with
+ `The process cannot access the file ... because it is being used by another
+ process.` This also proved a later close was short-circuited by the runtime's
+ already-disposed flag.
+
+### GREEN evidence
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `0`
+- Result: `16/16` named cases passed. The new deterministic disposal regression
+ registers an injected throwing stream plus a real source, destination, and
+ generation tree. It proves:
+ - the first close attempts the throwing stream once and returns the stable,
+ sanitized `CCOD_INSTALL_CLOSE_FAILED`;
+ - the same first close continues to release the source and every generation
+ pin, so both can be moved immediately; and
+ - the second close retries the retained failed resource (attempt count 2)
+ and reports the same bounded cleanup code rather than returning early.
+- Reliability evidence: three consecutive expanded focused runs exited `0`.
+- Parser command: explicit parser pass over the four Task 1 PowerShell files.
+- Parser exit code: `0` (`Changed PowerShell parser check passed.`)
+- Diff command: `git diff --check`
+- Diff exit code: `0` (only LF-to-CRLF checkout warnings).
+
+### Aggregate evidence
+
+- `tests\PersistenceSelfTest.ps1`: exit `1` at the unchanged known local case
+ `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`,
+ error `ASSERT_EXACT`.
+- `tests\Validate.ps1 -SkipInstalledPackageCheck`: exit `1` with the same
+ `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker.
+- These aggregates remain environmental boundaries and are not called passes.
+
+### Implementation behavior
+
+- Native `Retire` now completes all generation/tree validation before calling
+ the shared temp-record publisher. After the no-replace retirement-record
+ rename, it immediately returns the already-existing generation token; the
+ previous post-publication `ValidateCurrent(generation)` call is removed.
+- The PowerShell retirement capability and bounded result are allocated before
+ the native record commit. Only after native success are the prepared result
+ stored and the private `Retired` flag set. Collision/write failure leaves
+ those state fields unchanged.
+- Native `Close` snapshots and attempts every registered external stream and
+ pin independently. Successfully released pins are removed; failures remain
+ registered for retry. The runtime is closed to further operations from the
+ first attempt onward, but subsequent cleanup calls still process retained
+ failures.
+- `Pin.Dispose` marks a pin closed only after its handle/stream disposal
+ succeeds, allowing a genuine failed release to be retried.
+- The exported close wrapper records deterministic closed/cleanup-failed state
+ and always maps internal cleanup exceptions to the fixed
+ `CCOD_INSTALL_CLOSE_FAILED` message.
+
+### Fix commit
+
+- `f86d4397b4a56343db9124f655c923949e6084c0` — `fix: finalize retirement and close cleanup`
+
+### Remaining concerns
+
+- A resource whose `Dispose` permanently fails remains intentionally registered
+ and each later `Close` reports `CCOD_INSTALL_CLOSE_FAILED`; all other
+ resources are nevertheless released. No recursive deletion or DACL fallback
+ is attempted.
+
+## Fix round 3 — retained-generation targets and scoped records
+
+### RED evidence
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `1`
+- Exact first failure: the export-surface case expected
+ `Open-CcodInstallRetainedGeneration`, `New-CcodInstallDirectory`, and
+ `Write-CcodInstallRecord`, but the current module exported none of them and
+ pointer commit still accepted a naked runtime ID.
+
+### GREEN evidence
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit code: `0`
+- Result: `19/19` named cases passed. Added deterministic coverage proves:
+ - install-root `state` and nested `receipts` directories can be opened or
+ created only through opaque same-transaction scopes and single-segment
+ leaves;
+ - JSON/JSONL records use the private-temp publisher, are complete on first
+ visibility, collide without replacement, and reject multi-segment leaves;
+ - an existing generation is recursively opened read-only only when its final
+ path, complete structural tree, manifest hash, and manifest runtime ID
+ match; missing/wrong-hash/reparse/ADS/multilink/unowned fixtures fail with
+ stable codes;
+ - every create/write operation rejects a retained capability;
+ - pointer generations 1 and 2 can be followed by a generation-3 compensation
+ record targeting the retained old runtime;
+ - retained targets from a different transaction on the same root or a
+ different install root are rejected; and
+ - forced module re-import remains active before all new exported operations.
+- Reliability evidence: three consecutive expanded focused runs exited `0`;
+ the final pre-commit focused run also exited `0`.
+- Parser command: explicit parser pass over the four Task 1 PowerShell files.
+- Parser exit code: `0` (`Changed PowerShell parser check passed.`)
+- Diff command: `git diff --check`
+- Diff exit code: `0` (only LF-to-CRLF checkout warnings).
+
+### Aggregate evidence
+
+- `tests\PersistenceSelfTest.ps1`: exit `1` at the unchanged known local case
+ `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`,
+ error `ASSERT_EXACT`.
+- `tests\Validate.ps1 -SkipInstalledPackageCheck`: exit `1` with the same
+ `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker.
+- These aggregates remain environmental boundaries and are not called passes.
+
+### Interface behavior
+
+- `Open-CcodInstallRetainedGeneration` is tied to an existing writable file
+ transaction. Native code opens the existing `runtime\` root and
+ every descendant through relative read-only handles, rejects reparse/ADS/
+ multilink/final-path changes, hashes each file, verifies the exact manifest
+ hash and uniquely matching manifest runtime ID, and then issues an opaque
+ generation scope marked `ReadOnly`.
+- Retained scopes may be used only as `TargetGeneration`; directory creation,
+ source copy, manifest write, and scoped record write all fail with
+ `CCOD_INSTALL_GENERATION_READ_ONLY`.
+- `Commit-CcodInstallActivePointer` no longer accepts `NewRuntimeId`. It derives
+ the target runtime ID only from a same-transaction opaque generation scope
+ and revalidates either the owned new tree or the retained read-only tree
+ before its monotonic no-replace pointer record.
+- `New-CcodInstallDirectory` treats the opaque transaction itself as the
+ install-root parent and composes native single-leaf open/create for bounded
+ state directories. Returned child capabilities are recursively composable.
+- `Write-CcodInstallRecord` accepts only a same-transaction writable directory,
+ sanitized record object, and single-segment leaf; it shares the existing
+ temp-before-validation/no-replace publisher.
+- Native directory enumeration now removes duplicate names returned across
+ repeated `NtQueryDirectoryFile` buffers before retained-tree pin creation.
+
+### Fix commit
+
+- `c1e3ef08f0c820db79872f8438d66d1ced94eeba` — `feat: add retained generation file capabilities`
+
+### Remaining concerns
+
+- Retained manifest runtime-ID validation is intentionally narrow and is also
+ bound to the caller-supplied exact manifest SHA-256. Full manifest schema,
+ file-list, version, and commit semantics remain the Task 2/RuntimeManifest
+ consumer's responsibility.
+- This round adds only Task 1 file-layer capabilities; no lifecycle module or
+ installer path was migrated.
+
+## Retained-capability extension fix — transitive read-only and ABI V3
+
+### RED evidence
+
+The self-test supports `CCOD_INSTALL_EXTENSION_RED_CASE` only to select one
+deterministic regression while preserving all earlier focused cases. Each
+targeted command used:
+
+```powershell
+$env:CCOD_INSTALL_EXTENSION_RED_CASE=''
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1
+```
+
+All five targeted RED runs exited `1` for their intended production gap:
+
+- `retained-alias`: `ASSERT_THROWS`, expected
+ `CCOD_INSTALL_GENERATION_READ_ONLY`; an alias reopened through
+ transaction-root `runtime\` remained writable.
+- `nested-retained`: `CCOD_INSTALL_RETAINED_GENERATION_INVALID`, message
+ `retained open failed: Access is denied`; nested retained directories lacked
+ directory-listing access.
+- `semantic-runtime-id`: `ASSERT_THROWS`, expected
+ `CCOD_INSTALL_RETAINED_RUNTIME_ID_MISMATCH`; regex matching accepted a nested
+ `metadata.runtimeId` without a matching top-level property.
+- `child-transaction`: `ASSERT_THROWS`, expected
+ `CCOD_INSTALL_TRANSACTION_INVALID`; a child directory capability was
+ accepted as `-FileTransaction`.
+- `v3-reimport`: `TypeNotFound` for
+ `CcodInstallGenerationCapabilityMarkerV3`; re-import still bound ABI V2.
+
+The unfiltered required command also exited `1`; its first new failure was the
+retained-alias `CCOD_INSTALL_GENERATION_READ_ONLY` assertion.
+
+### GREEN evidence
+
+- The same five targeted commands each exited `0`.
+- Full command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Full focused exit code: `0`.
+- Result: `24/24` named cases passed.
+- Parser command: explicit PowerShell parser pass over
+ `InstallFileTransaction.psm1` and
+ `InstallFileTransaction.SelfTest.ps1`.
+- Parser exit code: `0` (`TASK1_PARSER_EXIT=0`).
+- Diff command:
+ `git diff --check -- src/persistence/modules/InstallFileTransaction.psm1 tests/persistence/InstallFileTransaction.SelfTest.ps1`
+- Diff exit code: `0` (only LF-to-CRLF checkout warnings).
+
+### Aggregate evidence
+
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1`
+ exited `1` at the unchanged known local case
+ `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`,
+ error `ASSERT_EXACT`.
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\Validate.ps1 -SkipInstalledPackageCheck`
+ exited `1` with the same `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker.
+- These aggregates remain environmental boundaries and are not called passes.
+
+### Implementation behavior
+
+- The current CLR bridge is
+ `CcodInstallGenerationCapabilityMarkerV3` / internal runtime V3 with ABI 3.
+ Every import resolves and binds V3 even when the AppDomain already contains
+ the old V2 marker.
+- Native `retainedScopes` covers the retained generation and every descendant
+ token. Directory opens propagate read-only state from a retained parent and
+ also recognize a retained root reopened from the transaction-root alias.
+ The PowerShell wrapper asks native `IsRetained` for each returned directory,
+ so all exported create/write routes fail closed with
+ `CCOD_INSTALL_GENERATION_READ_ONLY`.
+- Nested retained child directories are pinned with
+ `LIST_DIRECTORY | READ_ATTRIBUTES | SYNCHRONIZE`, while files remain
+ read-only. Recursive final-path, reparse, ADS, multilink, identity, length,
+ and SHA-256 validation remains intact.
+- Native code returns the exact hash-bound manifest text. The wrapper parses it
+ with `ConvertFrom-Json`, requires a top-level object and exactly one
+ top-level `runtimeId` string equal to the requested ID, and rejects escaped
+ or nested-only occurrences.
+- `Open-CcodInstallRetainedGeneration` requires the supplied
+ `-FileTransaction` object itself to be the transaction-root capability;
+ child directory capabilities fail with `CCOD_INSTALL_TRANSACTION_INVALID`.
+
+### Scope note
+
+- The paused uncommitted Task 2 changes in `InstallLifecycle.psm1` and
+ `InstallLifecycle.SelfTest.ps1` were neither edited nor staged by this fix.
+
+## Duplicate retained-manifest identity fix
+
+### RED evidence
+
+- Targeted command:
+ `$env:CCOD_INSTALL_EXTENSION_RED_CASE='duplicate-runtime-id'; powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Targeted exit code: `1`.
+- Exact failure: case
+ `rejects-duplicate-matching-top-level-runtimeId-members-in-a-retained-manifest`
+ reached `ASSERT_THROWS`, expected
+ `CCOD_INSTALL_RETAINED_RUNTIME_ID_MISMATCH`. This proved Windows PowerShell
+ collapsed two equal top-level `runtimeId` members before the old property
+ count check.
+- Unfiltered focused command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Unfiltered RED exit code: `1` with the same exact case and assertion.
+
+### GREEN and verification evidence
+
+- The same targeted duplicate command exited `0`.
+- Full focused command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Full focused exit code: `0`; `25/25` named cases passed, including all
+ earlier retained alias, nested tree, semantic identity, transaction-root,
+ and ABI V3 regressions.
+- Parser command: explicit PowerShell parser pass over
+ `InstallFileTransaction.psm1` and
+ `InstallFileTransaction.SelfTest.ps1`.
+- Parser exit code: `0` (`TASK1_PARSER_EXIT=0`).
+- Diff command:
+ `git diff --check -- src/persistence/modules/InstallFileTransaction.psm1 tests/persistence/InstallFileTransaction.SelfTest.ps1 .superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md`
+- Diff exit code: `0` (`TASK1_DIFF_CHECK_EXIT=0`; only LF-to-CRLF checkout
+ warnings).
+
+### Implementation behavior
+
+- `ConvertFrom-Json` remains the authoritative complete JSON parser and
+ decoded-value source. The identity check still requires a top-level object
+ whose decoded, case-exact `runtimeId` value is a string equal to the
+ requested runtime ID.
+- A bounded lexical pass now enumerates decoded top-level property names before
+ accepting that parsed value. It preserves duplicate member occurrences,
+ honors JSON string escaping in keys, and ignores nested objects, arrays, and
+ escaped key-like text inside values. Two matching top-level `runtimeId`
+ members are therefore rejected.
+- The regression fixture supplies the exact SHA-256 of the duplicate-key
+ manifest and exercises the real retained-open path. Native SHA-256,
+ full-tree, path, reparse, ADS, multilink, and handle checks are unchanged.
+- The paused Task 2 lifecycle files remained unstaged and were not edited by
+ this fix.
+
+### Commit
+
+- `fix: reject duplicate retained manifest identities`
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-2-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-2-report.md
new file mode 100644
index 0000000..7f35626
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-2-report.md
@@ -0,0 +1,513 @@
+# Task 2 report: immutable lifecycle generations
+
+## Status
+
+Implemented the Task 2 immutable lifecycle migration in the assigned worktree.
+The install path now stages a unique manifest-bound generation, appends the
+transaction and active-generation chains, selects the generation bootstrap for
+the scheduled task, retains legacy root shells and failed/old generations, and
+uses a higher compensating pointer after post-pointer failure.
+
+No release, push, tag, signing, build, installation, real product process
+start/stop, WindowsApps access, DPAPI access, or external write was performed.
+
+## RED evidence
+
+- Recovery baseline command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+ exited `1` at `first-install-stages-verifies-activates-task-and-persists-consent`,
+ old assertion `stable bootstrap copied`. The obsolete root-shell expectation
+ was replaced by the approved generation-specific shell contract.
+- Runtime identity RED exited `1` because `New-CcodUniqueRuntimeId` had no
+ `ContentSha256` parameter. GREEN binds the full SHA-256 of
+ `projectVersion + NUL + sealedPackageSha256` and a per-attempt nonce into a
+ 96-character runtime ID.
+- Activation receipt RED exited `1` because `StartingProtection` wrote mutable
+ `state\post-install-activation.json`. GREEN writes phase-specific create-only
+ records below `state\activation-receipts` through the opaque transaction.
+- Operational-state RED reproduced `UnauthorizedAccessException` and
+ `CCOD_ATOMIC_REPLACE_FAILED` after the Task 1 record seal made status and UI
+ files read-only. GREEN separates immutable initialization evidence below
+ `state\install-initializations\` from writable operational state
+ materialized only when all operational leaves are absent.
+- Real idempotence RED expected `AlreadyInstalled` but received `Upgraded`.
+ GREEN performs no pointer, runtime, task, or process mutation only when the
+ active Ready transaction has the exact explicitly supplied package identity;
+ a different explicit identity returns `CCOD_INSTALL_PACKAGE_CONFLICT`.
+- Bounded reader RED exited `1` because `Read-CcodStrictJson` had no
+ `MaxBytes` parameter. GREEN rejects oversized records before JSON parsing.
+- Transaction-chain identity RED exited `1` because a contiguous snapshot
+ chain could change `oldRuntimeId`. GREEN binds every immutable transaction
+ identity field across all snapshots.
+
+## GREEN and verification evidence
+
+- Lifecycle command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+- Lifecycle exit/result: `0`; `Install lifecycle self-tests passed: 108`.
+- Runtime manifest command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1`
+- Runtime manifest exit/result: `0`; 16 named cases passed.
+- Uninstall bootstrap command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1`
+- Uninstall bootstrap exit/result: `0`; `Uninstall bootstrap self-tests passed: 12`.
+- Persistence I/O command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\PersistenceIO.SelfTest.ps1`
+- Persistence I/O exit/result: `0`; 24 named cases passed.
+- Aggregate command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1`
+- Aggregate exit: `0` (`TASK2_AGGREGATE_EXIT=0`). The prior local
+ Bootstrap/mutex boundary did not reproduce in this fresh run.
+- Parser command: explicit
+ `[Management.Automation.Language.Parser]::ParseFile` pass over all eight
+ changed PowerShell code/test files.
+- Parser exit: `0` (`TASK2_PARSER_EXIT=0`); every file reported `PARSER_OK`.
+- Diff command: `git diff --check`
+- Diff exit: `0` (`TASK2_DIFF_CHECK_EXIT=0`; checkout emitted only LF-to-CRLF
+ warnings).
+
+## Behavior and boundaries
+
+- Runtime IDs are unique per attempt and retain a full deterministic digest of
+ project version plus canonical sealed package identity. Runtime manifest
+ validation still rejects runtime-ID, project-version, file-list, length, and
+ content tampering.
+- `state\active-generation\<20-digit>.json` is the only new active selector.
+ New installs never write `active.json`; legacy `active.json` remains read-only
+ migration input.
+- Transaction snapshots have exact fields and types, strict positive integer
+ generations, bounded safe reads, contiguous phases, immutable identity, and
+ rejection for unknown objects, reparse points, ADS, multilinks, competing
+ nonterminal chains, duplicate/gapped/post-terminal chains, and ambiguous
+ terminal selection.
+- Initial settings, status, verified packages, transition, and UI preference
+ evidence is create-only and generation-bound. The operational state plane is
+ separately created only when entirely absent and remains writable; the tests
+ perform real StateStore and UI write/read round trips without changing file
+ attributes.
+- Pre-pointer failures retain the failed candidate and leave the old pointer
+ and legacy root bootstrap/uninstaller bytes unchanged. Post-pointer failures
+ either append a higher compensation pointer to the retained old runtime and
+ prove it ready, or persist exactly
+ `Failed/CCOD_INSTALL_ROLLBACK_FAILED`. Both generations remain retained.
+- Stable shell candidates remain inside the immutable runtime generation. The
+ scheduled-task adapter is updated only after pointer commit and targets that
+ generation's bootstrap. There is no root shell overwrite or install-hot-path
+ recursive runtime deletion.
+- Idempotence/conflict decisions require an explicit canonical
+ `SealedPackageSha256`. A legacy/direct call that omits it cannot claim package
+ identity and follows the normal unique-generation path; Task 3 Setup must
+ always pass the sealed package hash.
+- Task 1 file-layer modules are imported lazily without `-Force` only when an
+ immutable install operation is actually invoked. This preserves the staged
+ uninstall module import closure and avoids rebinding a live Task 1
+ transaction.
+
+## Concerns
+
+- The current-user ordinary-race boundary remains unchanged: this does not
+ claim protection from an unrestricted same-user writer or in-process module
+ introspection.
+- The pre-Task-3 activation owner still consumes the legacy single receipt in
+ its synthetic compatibility tests. Task 3 owns migration of the sealed Setup
+ bootstrap/validator to the create-only receipt chain and must pass the
+ canonical package identity explicitly.
+- Failed and superseded generations intentionally consume disk until a
+ separately proven maintenance/uninstall reclamation path runs.
+
+## Commit
+
+- `fix: commit lifecycle through immutable generations` (this Task 2 commit)
+
+## Fix round 1 — complete the real generation-bootstrap lifecycle
+
+### Review status and RED evidence
+
+The scoped review of `e80b829..2101485` failed with one Critical and six
+Important findings. This round keeps `2101485` and remediates all seven.
+
+- Bootstrap command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1`
+ exited `1`; the new real generation-bootstrap case expected exit `0` but
+ received `1` because production still required root `active.json`.
+- RuntimeManifest command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1`
+ exited `1` at the canonical identity assertion because production still
+ emitted `projectVersion-digest16` without a nonce.
+- The lifecycle review regressions then exposed: transaction-root callbacks
+ executing in the wrong module session, a sealed-source fixture failing
+ before generation creation, missing upgrade baselines, cross-root state/UI
+ writes, global nonterminal state accepted at the real entry, freshly read old
+ manifest hashes used for compensation, and contradictory Failed evidence
+ after a visible Ready receipt.
+- The first aggregate attempt exited `1` at
+ `StaticProbeWorker.SelfTest.ps1` with `CCOD_STATIC_RUNTIME_UNAUTHORIZED`;
+ that independent runtime authorizer still used the old ID and legacy-only
+ selector. The second aggregate attempt exited `1` at
+ `UiPreferences.SelfTest.ps1` because the upgrade baseline path had
+ accidentally changed the public duplicate-initialize contract.
+
+### GREEN and verification evidence
+
+- Bootstrap: `24/24`, exit `0`. Coverage includes a copied generation
+ bootstrap actually launching its selected Supervisor with no `active.json`,
+ append-only selector unknown/reparse/ADS/multilink rejection, and legacy
+ fallback only when the chain is absent.
+- InstallLifecycle: `114/114`, exit `0`. Coverage includes same-generation
+ bootstrap parent readiness, legacy-root parent rejection under append-only
+ selection, global pointer-less/ambiguous transaction exclusion, fresh and
+ upgrade baselines, cross-root rejection, partial operational state,
+ recorded-old-manifest compensation, and the Ready final-snapshot gap.
+- RuntimeManifest: 18 named cases, exit `0`, including canonical
+ `projectVersion-fileDigest16-nonce32`, copied/renamed identity with recorded
+ manifest hash, wrong nonce/content, and selector ADS/multilink checks.
+- UninstallBootstrap: `13/13`, exit `0`, including canonical-ID and
+ append-only generation-7 authorization without legacy `active.json`.
+- PersistenceIO: 24 named cases, exit `0`.
+- StaticProbeWorker: 37 named cases, exit `0`, including append-only runtime
+ authorization without legacy `active.json`.
+- UiPreferences focused suite: 9 cases, exit `0`; the default duplicate
+ initialize behavior remains `CCOD_UI_PREFERENCES_EXISTS`, while only the
+ root-bound lifecycle path opts into reading an existing upgrade baseline.
+- Parser: explicit `ParseFile` checks passed for all 12 changed PowerShell
+ code/test files.
+- Diff: `git diff --check` exited `0`; only LF-to-CRLF checkout warnings were
+ emitted.
+- Aggregate:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1`
+ exited `0` (`FIX1_AGGREGATE_FINAL_EXIT=0`).
+
+### Remediated behavior
+
+- The generation bootstrap and every audited runtime authorizer read a strict,
+ contiguous append-only active-generation chain first and use legacy
+ `active.json` only when that chain is absent.
+- Runtime identity is exactly
+ `projectVersion-fileDigest16-nonce32`; RuntimeManifest, bootstrap,
+ StaticProbeWorker, and UninstallBootstrap independently recompute it from
+ sorted file records.
+- The install entry rejects any global nonterminal or ambiguous transaction
+ before creating a generation, independent of pointer/package identity.
+- Every fresh or upgrade generation records all five immutable initialization
+ baselines. Complete operational state is snapshotted but not overwritten;
+ partial/malformed operational state fails closed.
+- StateStore and UiPreferences no longer accept file-transaction capabilities.
+ InstallLifecycle first validates the opaque transaction root, then writes
+ baselines itself, so root A cannot be combined with StateRoot B.
+- Active pointer readers reject unknown objects, reparse points, ADS, and
+ multi-linked leaves. Transaction records bind both old and new manifest
+ SHA-256 values; compensation uses only the recorded old hash.
+- A final transaction-snapshot failure after a visible Ready activation receipt
+ returns `CCOD_INSTALL_READY_FINALIZATION_PENDING`, leaves the transaction at
+ `ProtectionReady`, and writes no contradictory Failed receipt or snapshot.
+
+### Fix commit
+
+- `fix: complete generation bootstrap lifecycle` (this fix-round commit)
+
+## Fix round 2 — recover append-only lifecycle finalization
+
+### RED and root-cause evidence
+
+- The pre-recovery lifecycle baseline command
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+ exited `0` with `Install lifecycle self-tests passed: 114`; this proved the
+ existing suite covered only creation of the `ProtectionReady` gap, not a
+ later invocation that completes it.
+- After adding the second-invocation recovery regression, the same command
+ exited `1` with
+ `CCOD_SELFTEST_FAILED case=a-later-invocation-recovers-one-missing-Ready-transaction-snapshot-without-another-install-mutation error=CCOD_INSTALL_SOURCE_MISSING`.
+ The real entry touched the vanished source checkout before attempting any
+ state-only recovery.
+- The first implementation run exited `1` with
+ `CCOD_INSTALL_READY_RECOVERY_INVALID`; temporary bounded diagnostics traced
+ that failure to calling the private, non-exported
+ `Get-CcodRuntimeDirectoryForId`. The final implementation derives the
+ already validated runtime path locally and applies the existing contained
+ install-path proof; the temporary diagnostic text was removed.
+
+### GREEN and final verification evidence
+
+- InstallFileTransaction command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+ exited `0`; all 26 named cases passed. Coverage includes V4 state-only
+ create-only records, duplicate-record rejection, generation/payload/
+ manifest/retained/pointer/retirement exclusion, and a forced re-import with
+ the V3 marker already present.
+- InstallLifecycle command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+ exited `0` with `Install lifecycle self-tests passed: 116`. A later
+ invocation with the source checkout removed appends exactly one matching
+ Ready transaction snapshot and creates no runtime, pointer, task start, or
+ Failed record. Malformed Ready receipt, mismatched append-only pointer, and
+ changed manifest bytes each remain `ProtectionReady` and write nothing.
+- Bootstrap exited `0` with `Bootstrap self-test passed: 24`.
+- RuntimeManifest exited `0`; all 19 named cases reported `True`, including
+ real default-adapter fresh and append-only upgrade pointer commits.
+- StaticProbeWorker exited `0`; all 38 named cases reported `True`, including
+ unsafe selector root/leaf/JSON/generation rejection.
+- UninstallBootstrap exited `0` with
+ `Uninstall bootstrap self-tests passed: 14`.
+- PersistenceIO exited `0`; all 24 named cases reported `True`.
+- UiPreferences exited `0`; all 9 named cases reported `PASS`.
+- Explicit PowerShell parser checks passed for all 12 changed `.ps1`/`.psm1`
+ files (`PARSER_COUNT=12`).
+- `git diff --check` exited `0`; it emitted only checkout LF-to-CRLF warnings.
+- Aggregate command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1`
+ exited `0`; after expanding the forced-V3-reimport denial matrix, the final
+ aggregate rerun also exited `0`. Both aggregate runs emitted no stdout/stderr
+ result text.
+
+### Recovered behavior and boundaries
+
+- `Open-CcodInstallStateTransaction` is ABI V4 and can traverse only
+ root-to-`state` directories and publish create-only state records. It cannot
+ create/open/mutate a generation, copy payload bytes, write a generation
+ manifest, select a retained generation, commit an active pointer, or retire
+ a generation.
+- `Invoke-CcodInstall` distinguishes only the exact recoverable
+ `ProtectionReady` head from other nonterminal transactions. It revalidates
+ immutable transaction identity, the exact append-only active runtime and
+ generation, the recorded new-manifest SHA-256, the current runtime manifest,
+ and exactly one matching Ready activation receipt before and after opening
+ the state-only capability.
+- Recovery occurs before source validation and performs no install, task,
+ process, pointer, log, activation-receipt, or Failed write. A proof mismatch
+ returns `CCOD_INSTALL_READY_RECOVERY_INVALID`; a state-only append failure
+ remains retryable as `CCOD_INSTALL_READY_FINALIZATION_PENDING`.
+- Selector readers in Bootstrap, StaticProbeWorker, and UninstallBootstrap
+ reject a non-directory selector root and unsafe, malformed, duplicate,
+ non-integral, or noncanonical leaves. Legacy `active.json` fallback remains
+ available only when the append-only root is absent.
+- No push, tag, release, build, installation, product-process action,
+ WindowsApps access, DPAPI access, or external write was performed.
+
+### Remaining concerns
+
+- The documented current-user ordinary-race boundary is unchanged; this is not
+ a sandbox against unrestricted same-user or in-process code.
+- Failed and superseded immutable generations remain retained until the
+ separately proven reclamation path runs.
+
+## Fix round 3 — fail closed selector fallback
+
+### RED and root-cause evidence
+
+- RuntimeManifest command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1`
+ exited `1` at
+ `default-immutable-fence-rejects-selector-root-files-and-non-not-found-lookup-failures-before-pointer-commit`
+ with `CCOD_INSTALL_POINTER_COMMIT_FAILED` and `The directory name is
+ invalid`. A selector-root file passed the authorization read and failed only
+ later inside pointer commit.
+- Bootstrap command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1`
+ exited `1` at
+ `bootstrap-selector-fallback-requires-proven-ItemNotFound-instead-of-a-lookup-error`;
+ the wished-for selector lookup seam did not yet exist
+ (`NamedParameterNotFound,Read-CcodBootstrapActivePointer`).
+- StaticProbeWorker command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\StaticProbeWorker.SelfTest.ps1`
+ exited `1` in the hostile selector matrix with
+ `ASSERT_THROWS: expected CCOD_STATIC_RUNTIME_UNAUTHORIZED`. The exact
+ identified case was `schema`: selector `schemaVersion:2` was authorized.
+- UninstallBootstrap command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1`
+ exited `1` at
+ `uninstall-selector-fallback-requires-proven-ItemNotFound-instead-of-a-lookup-error`;
+ the wished-for selector lookup seam did not yet exist
+ (`NamedParameterNotFound,Get-CcodUninstallBootstrapVerifiedRuntimeContext`).
+
+### GREEN and final verification evidence
+
+- InstallFileTransaction exited `0`; all 26 named cases passed. The V3/V4
+ evidence now uses a fresh child `powershell.exe`: V3 is defined first, V4 is
+ proven absent before import, the current module then exposes ABI V4, one
+ state record is appended, and generation leaf, copy, manifest, retained,
+ pointer, and retirement operations are rejected through exports only.
+- Bootstrap exited `0` with `Bootstrap self-test passed: 25`.
+- RuntimeManifest exited `0`; all 20 named cases reported `True`.
+- StaticProbeWorker exited `0`; all 39 named cases reported `True`.
+- UninstallBootstrap exited `0` with
+ `Uninstall bootstrap self-tests passed: 15`.
+- InstallLifecycle exited `0` with
+ `Install lifecycle self-tests passed: 116`.
+- PersistenceIO exited `0`; all 24 named cases reported `True`.
+- UiPreferences exited `0`; all 9 named cases reported `PASS`.
+- Explicit parser checks passed all 9 changed `.ps1`/`.psm1` files
+ (`PARSER_COUNT=9`).
+- `git diff --check` exited `0`; it emitted only checkout LF-to-CRLF warnings.
+- Aggregate command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1`
+ exited `0` and emitted no stdout/stderr result text.
+
+### Behavior and boundaries
+
+- RuntimeManifest/default fence, Bootstrap, StaticProbeWorker, and
+ UninstallBootstrap permit legacy `active.json` fallback only after a proven
+ ItemNotFound result for `state\active-generation`. Root files, reparse
+ points, access/I/O lookup errors, null/non-proof adapter returns, malformed
+ stores, and unsupported records fail with the component's bounded pointer or
+ runtime authorization error.
+- StaticProbeWorker now requires append-only selector `schemaVersion` to be
+ exactly integer `1`. Static and Uninstall matrices cover malformed JSON,
+ unsupported schema, duplicate key, ADS, multi-link, root/leaf reparse,
+ fractional generation, and noncanonical names before runtime authorization.
+ Bootstrap retains equivalent hostile-selector coverage.
+- Every fix2 invariant remains covered, including the real default pointer
+ fence, state-only Ready finalization, recorded-manifest compensation, and no
+ root-shell overwrite.
+- No push, tag, release, build, installation, product-process action,
+ WindowsApps access, DPAPI access, or external write was performed.
+
+### Remaining concerns
+
+- The documented current-user ordinary-race boundary remains unchanged.
+- Failed and superseded immutable generations remain retained until the
+ separately proven reclamation path runs.
+
+## Fix round 4 — prove selector absence safely
+
+### RED and root-cause evidence
+
+- RuntimeManifest exited `1` at
+ `legacy-fallback-rejects-a-state-ancestor-file-before-active-runtime-authorization`
+ with `ASSERT_THROWS: expected CCOD_RUNTIME_POINTER_INVALID`. A valid legacy
+ pointer was accepted when the intermediate `state` object was a regular file.
+- Bootstrap exited `1` at
+ `bootstrap-legacy-fallback-rejects-a-state-ancestor-file` with
+ `ASSERT_THROWS: expected CCOD_BOOTSTRAP_POINTER_INVALID`; the child lookup's
+ ItemNotFound result hid the invalid parent object.
+- StaticProbeWorker exited `1` at
+ `selector-lookup-requires-an-explicit-discriminated-absence-proof` with
+ `CCOD_STATIC_RUNTIME_UNAUTHORIZED`: an actual ItemNotFound adapter result was
+ collapsed into generic adapter failure. After the discriminated lookup was
+ added, the next RED run reached
+ `legacy-authorization-rejects-a-state-ancestor-file` and exited `1` with
+ `ASSERT_THROWS`, proving that the parent check remained independently absent.
+- UninstallBootstrap exited `1` at
+ `uninstall-legacy-fallback-rejects-a-state-ancestor-file-at-the-selector-boundary`
+ with `ASSERT_TRUE`; it did not reject the malformed parent at the bounded
+ selector authorization boundary.
+
+### GREEN and final verification evidence
+
+- InstallFileTransaction exited `0`; all 26 named cases passed, including the
+ V4 state-only no-escape and V3-first re-import evidence.
+- Bootstrap exited `0` with `Bootstrap self-test passed: 26`.
+- RuntimeManifest exited `0`; all 21 named cases reported `True`.
+- StaticProbeWorker exited `0`; all 41 named cases reported `True`, including
+ actual ItemNotFound fallback, null/empty/malformed/unknown result rejection,
+ and the `state`-file boundary.
+- UninstallBootstrap exited `0` with
+ `Uninstall bootstrap self-tests passed: 16`.
+- InstallLifecycle exited `0` with
+ `Install lifecycle self-tests passed: 116`.
+- PersistenceIO exited `0`; all 24 named cases reported `True`.
+- UiPreferences exited `0`; all 9 named cases reported `PASS`.
+- Explicit PowerShell parser checks passed all 8 changed `.ps1`/`.psm1` files
+ (`PARSER_COUNT=8`).
+- `git diff --check` exited `0`; it emitted only checkout LF-to-CRLF warnings.
+- Aggregate command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1`
+ exited `0` and emitted no suite failure text.
+
+### Behavior and boundaries
+
+- RuntimeManifest/default fence, Bootstrap, StaticProbeWorker, and
+ UninstallBootstrap now consult legacy `active.json` only when `state` itself
+ has an exact ItemNotFound outcome, or when `state` is a proven plain
+ nonreparse directory and `state\active-generation` has an exact ItemNotFound
+ outcome. A parent file, reparse point, inaccessible object, lookup failure,
+ or other non-directory fails the component's bounded authorization contract.
+- StaticProbe selector lookup now returns one exact discriminated
+ `{ Status, Item }` object. Only `Status='Missing'` with a null item, produced
+ from an exact ItemNotFound exception, represents absence; `Found` requires a
+ nonnull item, and null, empty, malformed, unknown, diagnostic, or exceptional
+ adapter results fail before runtime import.
+- Every earlier Task 2 invariant remains covered, including V4 state-only
+ recovery containment, append-only Ready finalization, canonical runtime
+ identity, the real default pointer fence, recorded-manifest compensation,
+ strict selector leaf validation, and no root-shell overwrite.
+- No push, tag, release, build, installation, product-process action,
+ WindowsApps access, DPAPI access, or external write was performed.
+
+### Remaining concerns
+
+- The documented current-user ordinary-race boundary remains unchanged.
+- Failed and superseded immutable generations remain retained until the
+ separately proven reclamation path runs.
+
+## Fix round 5 — permit proven legacy selector fallback
+
+### RED and root-cause evidence
+
+- UninstallBootstrap exited `1` at
+ `uninstall-legacy-fallback-accepts-a-valid-pointer-when-the-entire-state-plane-is-absent`.
+ The regression expected a valid legacy pointer to reach the later missing
+ lifecycle-epoch boundary, but production rejected the selector path first.
+ `UninstallBootstrap` resolved `state\active-generation` before it performed
+ the separate ItemNotFound lookup for `state`, so a genuinely absent selector
+ plane could never take the permitted compatibility path.
+- The first focused matrix also reproduced the independent Bootstrap harness
+ failure at
+ `fails-promptly-when-fallback-lifecycle-ownership-cannot-be-released`:
+ `ASSERT_EXACT` reported `TimedOut` expected `False`, actual `True`. The old
+ test timed the whole cold launch with a 4-second watchdog even though the
+ injected release attempt occurred at about 3.1 seconds, fallback promotion
+ at about 5.0 seconds, the stable failure log at about 5.03 seconds, and
+ process exit at about 5.08 seconds. The harness killed the bootstrap before
+ its `finally`; this was not a production bootstrap deadlock.
+
+### GREEN and final verification evidence
+
+- Uninstall now resolves and probes `state` first. Exact ItemNotFound selects
+ legacy fallback without resolving its child; only a proven plain nonreparse
+ state directory permits resolving and probing `state\active-generation`.
+ The no-state regression first proves that an invalid legacy pointer is still
+ rejected, then restores the valid pointer and proves authorization reaches
+ the later missing lifecycle-epoch boundary rather than skipping validation.
+- The Bootstrap release-failure test is deterministic and test-only. Its
+ injected release function writes a marker immediately before throwing, the
+ whole-process watchdog is 15 seconds, and the assertion requires exit `1`
+ within 2 seconds after the marker. It retains the stable failure-log,
+ fallback-child-exited, and AccountTransition-mutex-reacquired proofs. No
+ production bootstrap control flow or timeout changed.
+- InstallFileTransaction exited `0`; all 26 named cases passed.
+- Bootstrap exited `0` with `Bootstrap self-test passed: 26`.
+- RuntimeManifest exited `0`; all 21 named cases reported `True`.
+- StaticProbeWorker exited `0`; all 41 named cases reported `True`.
+- UninstallBootstrap exited `0` with
+ `Uninstall bootstrap self-tests passed: 17`.
+- InstallLifecycle exited `0` with
+ `Install lifecycle self-tests passed: 116`.
+- PersistenceIO exited `0`; all 24 named cases reported `True`.
+- UiPreferences exited `0`; all 9 named cases reported `PASS`.
+- Explicit PowerShell parser checks passed all 3 changed `.ps1` files
+ (`TASK2_FIX5_PARSER_COUNT=3`).
+- `git diff --check` exited `0` (`TASK2_FIX5_DIFF_CHECK_EXIT=0`); it emitted
+ only checkout LF-to-CRLF warnings.
+- Aggregate command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1`
+ exited `0` (`TASK2_FIX5_AGGREGATE_EXIT=0`) with no suite failure output.
+
+### Behavior and boundaries
+
+- A wholly absent `state` plane is now the exact permitted Uninstall legacy
+ compatibility case. Existing state files, reparse points, inaccessible
+ objects, null or malformed lookup results, and non-ItemNotFound failures
+ still return `CCOD_UNINSTALL_RUNTIME_INVALID` at the selector boundary.
+- When `state` is valid, child absence alone permits legacy fallback; every
+ hostile selector-root/leaf, JSON, schema, generation, ADS, and multilink
+ negative case remains covered and green.
+- Legacy selection does not bypass runtime-manifest, cleanup-payload, bootstrap
+ fingerprint, or lifecycle-epoch validation. No production Bootstrap change,
+ release, install, product-process action, WindowsApps access, DPAPI access,
+ push, tag, signing, or external write was performed.
+
+### Remaining concerns
+
+- The documented current-user ordinary-race boundary remains unchanged.
+- Failed and superseded immutable generations remain retained until the
+ separately proven reclamation path runs.
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-3-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-3-report.md
new file mode 100644
index 0000000..b6f6a20
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-3-report.md
@@ -0,0 +1,341 @@
+# Task 3 report: sealed Setup package and temporary bootstrap
+
+## Status
+
+Implemented the Task 3 sealed installer package and no-`{app}` Setup bootstrap.
+Setup now embeds exactly four `dontcopy` inputs, retains package/manifest/
+bootstrap locks across activation and validation, and accepts success only from
+the same temporary bootstrap's strict append-only `Ready` validation.
+
+No release build, installation, product process control, external service,
+WindowsApps access, DPAPI access, signing, push, tag, or publication occurred.
+
+## RED evidence
+
+The following REDs were observed in order. Obsolete-test REDs are identified
+separately from production defects so they are not mistaken for regressions.
+
+- `ReleaseWorkflow.SelfTest.ps1` exited `1` because
+ `build\InstallerPackage.psm1` did not exist.
+- After the package matrix went GREEN, ReleaseWorkflow exited `1` because the
+ production ISS lacked `CreateAppDir=no` and still exposed the old `{app}`
+ product-copy contract.
+- Expected obsolete-contract REDs then identified old destination-inventory,
+ `{app}\payload`, direct portable-launcher, recursive/reparse-gate, shortcut,
+ icon, uninstall, installed-validation, and legacy receipt assertions. They
+ were replaced with Task 3 no-product-write and append-only assertions; no
+ production compatibility fallback was added.
+- The first real sealed-package ISCC compile exited `2` with
+ `Unknown identifier 'CreateGuid'`; the compiled path now uses the proven
+ `CoCreateGuid` / `StringFromGUID2` contract.
+- PE validation REDs exposed Inno version-string field truncation for full
+ 64-character manifest/bootstrap hashes. The final PE contract binds each as
+ exact 32+32 fields and independently recomposes both hashes.
+- The first real package-to-bootstrap execution returned
+ `CCOD_INSTALLER_PACKAGE_INVALID`. The cause was compact PowerShell text such
+ as `return[pscustomobject]` and `throw'CODE'` being parsed as command names.
+ The syntax was corrected and the same real test then passed.
+- The Setup provenance RED failed because the old schema bound only the payload
+ manifest. Schema 2 now records and revalidates package, package-manifest, and
+ bootstrap hashes plus canonical build inputs and the PE split contract.
+- `InstallLifecycle.SelfTest.ps1` REDs identified the pre-Task-3 mutable
+ `state\post-install-activation.json` fixtures. Ready, Failed, and progress
+ fixtures now use separate append-only leaves. A correlated nonterminal leaf
+ returns `CCOD_ACTIVATION_RECEIPT_NOT_READY`; no receipt returns
+ `CCOD_ACTIVATION_RECEIPT_MISSING`; conflicting terminal leaves fail closed.
+
+## Implemented behavior
+
+- `New-CcodInstallerPackage` creates one ZIP containing exactly the
+ manifest-listed payload plus `installer-payload.manifest.json` and emits a
+ canonical package manifest bound to version and 40-hex commit.
+- `Test-CcodInstallerPackage` rejects missing, extra, duplicate,
+ case-colliding, unsafe, length-changed, content-changed, version-changed,
+ commit-changed, package-hash-changed, and manifest-hash-changed inputs.
+- `build.ps1` creates and immediately revalidates the sealed package, binds the
+ package, manifest, and bootstrap hashes into generated ISCC input, and uses
+ schema-2 sealed Setup provenance.
+- The ISS has `CreateAppDir=no`, `Uninstallable=no`, no product `[Files]`
+ destination, and no `[Icons]`, `[Registry]`, `[UninstallRun]`, recursive
+ extraction, legacy shortcut deletion, or `{app}` bootstrap execution.
+- Inno extracts four named temporary inputs, hashes/locks/rehashes package,
+ manifest, and bootstrap, executes that exact bootstrap path, then invokes it
+ again with `-ValidateReceiptOnly` while the locks remain held.
+- The bootstrap validates and extracts only the ZIP manifest entry set into a
+ fresh private root, passes the exact package SHA-256 as
+ `-SealedPackageSha256`, and validates only append-only activation receipts.
+- A real disposable test proves package -> temporary bootstrap -> child hash
+ propagation and append-only Ready. A separate replacement barrier proves
+ locked original bootstrap bytes, not replacement bytes, are executed.
+
+## GREEN and verification evidence
+
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1`
+ exited `0`; final output: `Release workflow self-tests passed.` The suite
+ includes a real Inno Setup 6.7.3 production-script compile.
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+ exited `0`; final output: `Install lifecycle self-tests passed: 113`.
+- Explicit PowerShell parser validation passed all eight changed `.ps1`/
+ `.psm1` files (`PARSER_COUNT=8`).
+- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were emitted.
+- Static boundary audit reported `BOUNDARY_VIOLATIONS=0` for pre-Ready `{app}`
+ writes, product sections, recursive extraction, old app bootstrap execution,
+ Setup mode, and mutable receipt-reader authority.
+- The full aggregate was not requested by the Task 3 brief and was not run; no
+ aggregate-pass claim is made.
+
+## Boundaries and remaining work
+
+- Task 4 still owns post-Ready uninstall registration, shortcuts, icons, and
+ exact legacy registration/shortcut migration.
+- Task 5 still owns Defender scanning and the final exact public asset contract.
+- The current-user ordinary-writer boundary and retained-generation disk-use
+ boundary from Tasks 1-2 are unchanged.
+
+## Commit
+
+- Initial implementation commit:
+ `9e1c41c72d296c2d0c014a45561a855699ebd39b`
+ (`fix: bootstrap sealed installer package`).
+
+## Fix round 1: portable identity, exact provenance, and executed Setup proof
+
+### Review findings and RED evidence
+
+Scoped review of `46692e7..9e1c41c72d296c2d0c014a45561a855699ebd39b`
+failed with four Important findings.
+
+- Portable identity RED: `ReleaseWorkflow.SelfTest.ps1` exited `1` because
+ `Invoke-CcodPortableLifecycleInstaller` did not exist. The portable wrapper
+ revalidated its manifest after Defender but discarded that result and called
+ `Install-CodexControlOtherDevices.ps1` without `-SealedPackageSha256`.
+- Exact provenance RED: the new schema-two mutation matrix exited `1` with
+ `ASSERT_THROWS: expected CCOD_SETUP_PROVENANCE_INVALID`. The validator
+ accepted altered nested evidence because it did not require every property
+ order/type/name/length/count/hash.
+- Executed Setup RED: the deliberately wrong-package-hash production Setup
+ compiled successfully and executed its real temporary input path, but the
+ test observed exit `0`. Its runtime log proved the hash gate did run:
+ `CurStepChanged raised an exception` with
+ `CCOD_SETUP_INPUT_BINDING_INVALID`; Inno defaulted the suppressed error box
+ to OK and still returned success.
+- Report RED: the initial report said only “Task 3 HEAD”, which did not provide
+ an immutable 40-hex object identity.
+
+### Remediated behavior
+
+- Portable now retains both the initial and post-Defender verified
+ `PayloadManifestSha256`, requires an exact canonical match before invoking
+ the lifecycle child, and passes that value explicitly as
+ `-SealedPackageSha256`. A changed canonical identity fails with
+ `CCOD_PORTABLE_PACKAGE_IDENTITY_INVALID` before the child marker can run.
+- `Test-CcodSealedSetupBuildProvenance` requires exact ordered top-level and
+ nested schemas, JSON types, artifact names, actual lengths and SHA-256s,
+ manifest `fileCount` and `payloadManifestSha256`, canonical version/commit/
+ timestamp, compiler/template/inventory identity, and the complete split PE
+ contract. Twenty-four independent mutations are rejected.
+- The same `ExtractAndLockCcodInputs` gate now runs from `PrepareToInstall`.
+ A nonempty error uses Inno's actual install-failure contract; `CurStepChanged`
+ consumes only the three already-held verified handles. The executed
+ wrong-hash fixture exits nonzero, records
+ `CCOD_SETUP_INPUT_BINDING_INVALID`, never runs its inert activation marker,
+ creates no `/DIR` app output, and never logs the real LocalAppData product
+ root.
+
+### GREEN and verification evidence
+
+- Final `ReleaseWorkflow.SelfTest.ps1`: exit `0`, including the portable exact
+ identity test, 24-case provenance mutation matrix, real production ISCC
+ compile, and bounded wrong-hash Setup execution.
+- Final `InstallLifecycle.SelfTest.ps1`: exit `0`;
+ `Install lifecycle self-tests passed: 113`.
+- Explicit parser checks passed all four changed PowerShell code/test files.
+- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were emitted.
+- Static no-`{app}` boundary audit reported `FIX1_BOUNDARY_VIOLATIONS=0`.
+- No aggregate run was performed in this fix round; no aggregate-pass claim is
+ made.
+- No release build, real install, product process control, external service,
+ WindowsApps, DPAPI, signing, push, tag, or publication action occurred.
+
+### Exact implementation commit
+
+- `51def5c9ae2f5e0722b9083e478b17c507e9cee8`
+ (`fix: harden sealed setup evidence`).
+
+## Fix round 2: copied portable identity and raw evidence binding
+
+### Review findings and RED evidence
+
+Scoped review of
+`9e1c41c72d296c2d0c014a45561a855699ebd39b..728b62710c0729b40908e92887d3fe70903edbba`
+remained FAIL with three Important findings.
+
+- Copied portable identity RED: `ReleaseWorkflow.SelfTest.ps1` exited `1`
+ with `NamedParameterNotFound` for `CopiedSealedPackageSha256`. The wrapper
+ still used a source identity and `Copy-CcodPortablePayload` returned its
+ pre-copy manifest result rather than an identity revalidated from the
+ published target.
+- Raw provenance RED: a top-level duplicate `schemaVersion` member remained
+ accepted because Windows PowerShell `ConvertFrom-Json` collapsed it before
+ the parsed-object exact-schema checks.
+- Product-state evidence RED: the executed wrong-hash Setup fixture exited `1`
+ because `Get-CcodReadOnlyProductTreeSnapshot` did not exist. The prior test
+ checked only its isolated `/DIR`, marker, and log rather than comparing the
+ actual current-user product root.
+
+### Remediated behavior
+
+- `Copy-CcodPortablePayload` now validates the moved published installer tree
+ and returns that copied manifest identity. The portable wrapper requires the
+ initial source, post-Defender source, and copied identities to be the same
+ canonical SHA-256, and only the copied value reaches the lifecycle child.
+- A production-module breakpoint regression changes source bytes exactly
+ between manifest validation and `File.Copy`. Destination rehashing returns
+ `CCOD_PORTABLE_COPY_HASH_MISMATCH`; no installer root, lifecycle child, or
+ lifecycle state becomes visible.
+- A bounded raw JSON scanner walks objects and arrays before any provenance or
+ package-manifest `ConvertFrom-Json`, decodes escaped property names, rejects
+ duplicate semantic member names at every depth, and enforces 1 MiB / depth
+ 32 limits. Duplicate mutations at the top level and all five nested
+ provenance objects fail with `CCOD_SETUP_PROVENANCE_INVALID` while the prior
+ 24 exact-schema mutations remain covered.
+- The wrong-hash production Setup fixture now snapshots the exact LocalAppData
+ product root before and after execution. The read-only snapshot manually
+ traverses without following reparse points, rejects alternate streams, is
+ bounded to 8192 entries / 256 MiB per file / 1 GiB total, and records root/
+ directory metadata plus file metadata and SHA-256. Before and after canonical
+ snapshots are identical. Fixture cleanup still targets only its unique temp
+ root and never creates, modifies, or removes the real product root.
+
+### GREEN and verification evidence
+
+- Final `ReleaseWorkflow.SelfTest.ps1`: exit `0`, including copied-identity,
+ full copy-race, six-level raw duplicate, and actual product-tree snapshot
+ coverage.
+- Final `InstallLifecycle.SelfTest.ps1`: exit `0`;
+ `Install lifecycle self-tests passed: 113`.
+- Explicit parser checks passed all four changed PowerShell code/test files.
+- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were emitted.
+- Static no-`{app}` audit reported `FIX2_BOUNDARY_VIOLATIONS=0`.
+- No aggregate run was performed in this fix round; no aggregate-pass claim is
+ made.
+- No release build, real install, product-process control, external service,
+ WindowsApps, DPAPI operation, signing, push, tag, or publication occurred.
+
+### Exact implementation commit
+
+- `1cb91621f2565318143dffe08844a91a350a36d9`
+ (`fix: bind portable sealed execution`).
+
+## Fix round 3: actual portable entrypoint race evidence
+
+### Review finding and RED evidence
+
+Scoped review of
+`728b62710c0729b40908e92887d3fe70903edbba..a5d595892f134a5ef957feab4ec4e68228cec97b`
+remained FAIL with one Important test-compliance finding. The race test invoked
+`Copy-CcodPortablePayload` directly inside the module and therefore did not
+prove that the real `Install-CodexRemote-fix.ps1` control flow stopped before
+its child and lifecycle boundaries. This review failure is the RED evidence;
+the production post-Defender revalidation behavior itself did not require a
+code change.
+
+### Remediated evidence
+
+- The replacement fixture copies the production
+ `Install-CodexRemote-fix.ps1` byte-for-byte and asserts the copy hash before
+ invoking that actual script path.
+- It uses the production `PortableRelease.psm1`, a valid manifest, and an
+ isolated child installer that would create both a marker and fixture
+ lifecycle directory if execution reached it.
+- Test-local Defender commands remain entirely inside the fixture process.
+ `Start-MpScan` changes the child source after the entrypoint's initial
+ manifest validation and before its copy boundary.
+- The actual entrypoint returns `CCOD_PORTABLE_MANIFEST_INVALID` from its
+ post-Defender `Test-CcodPortablePayloadManifest` call. Only after it returns,
+ the test proves the marker and fixture lifecycle root are both absent.
+- The helper identity test remains as unit coverage, but no helper-only race
+ test is claimed as end-to-end evidence.
+
+### GREEN and verification evidence
+
+- Final `ReleaseWorkflow.SelfTest.ps1`: exit `0`; the named actual-entrypoint
+ validation-to-copy race case passed.
+- Final `InstallLifecycle.SelfTest.ps1`: exit `0`;
+ `Install lifecycle self-tests passed: 113`.
+- The changed PowerShell test file parsed with zero errors (`1/1`).
+- `git diff --check` exited `0`; only the checkout LF-to-CRLF warning was
+ emitted.
+- No aggregate run was performed in this fix round; no aggregate-pass claim is
+ made.
+- No external release, push, tag, signing, install, product-process,
+ WindowsApps, or DPAPI action occurred.
+
+### Exact implementation commit
+
+- `58bd7603fa83133d78d2f62ac045ec00d25134c9`
+ (`test: exercise portable sealed race end to end`).
+
+## Fix round 4: final portable copy-barrier evidence
+
+### Review finding and RED evidence
+
+Scoped review of
+`a5d595892f134a5ef957feab4ec4e68228cec97b..9e971d1fe26c0f60d8e8519318c11006798dc493`
+remained FAIL with one Important test-compliance finding. The actual-entrypoint
+fixture changed its child during the mocked Defender gate, so the production
+post-Defender manifest revalidation returned
+`CCOD_PORTABLE_MANIFEST_INVALID`; it did not exercise the later validated
+source-to-`File.Copy` barrier.
+
+The unfinished fix-round-4 test was run before completion. The full
+`ReleaseWorkflow.SelfTest.ps1` exited `1` in
+`actual portable entrypoint rejects the final File.Copy barrier race before
+child or lifecycle state`: its
+`CCOD_PORTABLE_COPY_HASH_MISMATCH` assertion failed because the fixture never
+performed the intended source mutation and `SourceMutated` remained false.
+This was the expected RED for the missing test-local copy-boundary condition.
+
+### Remediated evidence
+
+- The fixture invokes a byte-identical temporary copy of the production
+ `Install-CodexRemote-fix.ps1` and uses a byte-identical temporary copy of the
+ production `PortableRelease.psm1`; both identities are asserted before the
+ entrypoint runs.
+- The existing test-local entrypoint breakpoint redirects only the module's
+ private expected-installer-root function to a new disposable fixture root.
+ No public production parameter or externally reachable test switch was
+ added.
+- The manifest order is asserted so the marker-capable child reaches the first
+ production payload copy boundary. A breakpoint on the copied module's unique
+ ordinary `[IO.File]::Copy($sourceFile,$destination,$false)` line writes
+ distinct child text after the module has validated its source record and
+ immediately before that production copy executes.
+- The production copy and destination SHA-256 comparison then run unchanged
+ and return `CCOD_PORTABLE_COPY_HASH_MISMATCH`. Only after the actual
+ entrypoint returns does the test prove that the child marker, fixture
+ lifecycle/install root, and published fixture installer root are all absent.
+- The separate post-Defender source-revalidation fixture remains intact and
+ continues to cover its earlier `CCOD_PORTABLE_MANIFEST_INVALID` boundary.
+
+### GREEN and verification evidence
+
+- Final `ReleaseWorkflow.SelfTest.ps1`: exit `0`; final output
+ `Release workflow self-tests passed.` The actual-entrypoint final
+ `File.Copy` barrier case passed.
+- Final `InstallLifecycle.SelfTest.ps1`: exit `0`; final output
+ `Install lifecycle self-tests passed: 113`.
+- The changed PowerShell test file parsed with zero errors (`1/1`).
+- `git diff --check` exited `0`; only the checkout LF-to-CRLF warning was
+ emitted.
+- No aggregate run was performed in this fix round; no aggregate-pass claim is
+ made.
+- No real installer, network, external action, listener, product process
+ control, release, push, tag, signing, install, WindowsApps, or DPAPI action
+ occurred. All new runtime files were temporary fixture files below a unique
+ disposable root.
+
+### Exact implementation commit
+
+- `141ab716001e7c1048db10e248101fa8cd3f54ae`
+ (`test: exercise portable copy barrier race`).
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-4-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-4-report.md
new file mode 100644
index 0000000..a9145e5
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-4-report.md
@@ -0,0 +1,552 @@
+# Task 4 report: post-Ready product registration
+
+## Status
+
+Implemented current-user uninstall registration and the exact Start-menu and
+desktop `CodexRemote-fix.lnk` records after the Task 2 terminal `Ready` proof.
+All three new records are read back before the exact legacy migration is
+authorized. Product-registration failures are reported after the file
+transaction closes and never enter install compensation, append `Failed`, or
+downgrade an already durable `Ready` transaction.
+
+The direct installed uninstaller now runs only from the canonical selected
+`2.5.22--` generation, invokes that generation's
+manifest-bound uninstall bootstrap, and completes through the verified
+external transaction payload. No Inno uninstaller is required.
+
+No real registry or shortcut mutation, installer/uninstaller/product
+execution, release build, network write, push, tag, signing, or publication was
+performed.
+
+## RED evidence
+
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1`
+ exited `1` because
+ `src\persistence\modules\ProductRegistration.psm1` did not exist.
+- After the six registration behavior groups were added, the lifecycle suite
+ exited `1` with `CCOD_INSTALL_ADAPTER_INVALID`, target
+ `RegisterProduct`; there was no post-Ready lifecycle seam.
+- The uninstall bootstrap suite exited `1` with
+ `CCOD_UNINSTALL_ADAPTER_INVALID`, target `RemoveProductRegistration`; there
+ was no verified post-cleanup product-state remover.
+- The file-transaction suite exited `1` because the exact capability export
+ surface did not contain the fixed SpecialFolder open/copy operations.
+- The Ready-finalization recovery regression exited `1`: expected one product
+ registration after the missing Ready snapshot was recovered, actual zero.
+
+## GREEN evidence
+
+- Product registration:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1`
+ exited `0`; `Product registration self-tests passed: 6`.
+- File capability:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+ exited `0`; all 26 named cases passed.
+- Uninstall bootstrap:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1`
+ exited `0`; `Uninstall bootstrap self-tests passed: 17`.
+- Install lifecycle:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+ exited `0`; `Install lifecycle self-tests passed: 115`.
+- Explicit PowerShell parser checks passed all nine changed `.ps1`/`.psm1`
+ implementation and test files (`PARSER_FAILURES=0`).
+- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were
+ emitted.
+- The aggregate `tests\PersistenceSelfTest.ps1` was not requested by the Task
+ 4 brief and was not run; no aggregate-pass claim is made.
+
+## Implemented boundaries
+
+- `New-CcodProductRegistration`, `Test-CcodProductRegistration`,
+ `Commit-CcodProductRegistration`, and
+ `Remove-CcodLegacyProductRegistration` enforce the canonical runtime ID,
+ exact version/hash, exact runtime bootstrap/uninstaller paths, fixed HKCU
+ product key, and two fixed shortcut leaves.
+- Shortcut candidates are generated before runtime identity calculation,
+ copied into `registration/` inside the immutable generation, included in its
+ manifest/runtime-ID binding, and copied after Ready only through pinned
+ current-user Programs/Desktop handles. Arbitrary destination paths and
+ shortcut leaf names are not accepted.
+- The shortcut candidates start the one fixed current-user Supervisor task.
+ Task 2 already binds that task action to the selected generation bootstrap;
+ this avoids embedding a not-yet-computable runtime ID in candidate bytes
+ while still resolving the sealed bootstrap selected and proven Ready.
+- Normal install and the bounded missing-Ready-snapshot recovery both perform
+ post-Ready registration. The recovery uses the state transaction only for
+ fixed SpecialFolder capability operations; generation and pointer mutation
+ remain unavailable.
+- Uninstall removes only a matching current product runtime key and the two
+ exact new shortcut paths after protected application cleanup. Legacy
+ migration requires the canonical AppId, verified new registration, and the
+ exact legacy shortcut allowlist before deletion starts.
+
+## Compatibility concerns
+
+- Legacy migration is deliberately conservative: a missing or changed legacy
+ shortcut name, wrong AppId, or unreadable legacy entry retains all legacy
+ state for later diagnosis rather than attempting partial cleanup.
+- The ordinary same-user writer and in-process introspection limits documented
+ by the immutable-generation design remain unchanged.
+- No live Windows Settings launch, real shell-link promotion, or installed
+ uninstall was executed in this task; those remain part of later installed
+ acceptance.
+
+## Implementation commit
+
+- `e43d9ce18c5d79bb438e08c9ff2973be592277aa`
+ (`feat: register product only after readiness`).
+
+## Fix round 1: matched uninstall and retriable registration
+
+Scoped review of `70bc231..97cf17d` found one Critical and four Important
+issues. The installed wrapper could synchronously continue after deleting its
+own generation; product cleanup could remove an entire key without exact
+value/shortcut evidence; `AlreadyInstalled` skipped registration retry;
+legacy removal was not compensating; and shortcut copy accepted a state-only
+transaction plus arbitrary absolute source path.
+
+### RED evidence
+
+- UninstallBootstrap first exited `1` because
+ `src\persistence\InstalledUninstallFinalizer.ps1` did not exist.
+- ProductRegistration exited `1` with `CCOD_PRODUCT_ADAPTER_INVALID`, target
+ `ReadLegacyEntry`, before exact current-state and per-entry legacy
+ compensation adapters existed.
+- InstallLifecycle exited `1` in the exact Ready/package idempotence case:
+ registration calls expected `1`, actual `0`.
+- InstallFileTransaction exited `1` because the export surface lacked
+ `Open-CcodInstallProductRegistrationTransaction` and
+ `Open-CcodInstallRetainedFile`; product shortcut copy still exposed
+ `SourcePath`.
+
+### GREEN and verification evidence
+
+- ProductRegistration exited `0` with
+ `Product registration self-tests passed: 9`. Coverage includes exact
+ registry/shortcut evidence, unknown/replaced/reparse/ambiguous no-delete,
+ mid-sequence legacy restoration, and replacement-preserving compensation.
+- InstallFileTransaction exited `0`; all 27 named cases passed. The new case
+ proves product-only retained-file authority, no arbitrary shortcut source
+ path, state-only denial, and product-scope denial for state writes.
+- UninstallBootstrap exited `0` with
+ `Uninstall bootstrap self-tests passed: 19`. The installed path now stops at
+ `TaskRemoved`; its staged finalizer waits for exact wrapper exit, validates
+ transaction and selected generation, performs matched removal, proves root
+ absence, removes only matched product state, and then finalizes. Wrong
+ wrapper/generation/transaction cases delete nothing.
+- InstallLifecycle exited `0` with
+ `Install lifecycle self-tests passed: 116`. Both normal and missing-Ready
+ recovery registration failures remain outside rollback/Failed and are
+ reconciled by a later exact same-package `AlreadyInstalled` invocation.
+- Explicit parser checks passed all 11 changed PowerShell source/test files
+ (`PARSER_COUNT=11`, `FAILURES=0`).
+- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were
+ emitted.
+- The aggregate `tests\PersistenceSelfTest.ps1` was not run in this fix round;
+ no aggregate-pass claim is made.
+
+### Remediated boundaries
+
+- The selected-generation wrapper launches a hidden external finalizer from
+ the ACL-bound staged payload and returns. Application removal cannot begin
+ until the exact wrapper PID/creation-time identity has exited.
+- Current product cleanup accepts only the exact allowed registry value names,
+ kinds and values plus both shortcut hashes, targets, arguments, and regular
+ file identities. Registry values are revalidated and removed individually;
+ a key is deleted only after it is empty and has no subkeys.
+- Legacy migration captures exact registry kinds/values and shortcut bytes,
+ deletes entries one by one, and restores prior removals in reverse order
+ only while their names remain absent. A replacement is never overwritten.
+- Product shortcut promotion now accepts only an opaque file capability opened
+ from one selected generation at either fixed manifest-relative candidate.
+ Product-only transactions cannot write state, generations, manifests,
+ pointers, or retirements; state-only transactions cannot acquire shortcut
+ authority.
+
+### Compatibility and external boundaries
+
+- Conservative mismatch behavior may leave current or legacy registration for
+ diagnosis instead of partially deleting it.
+- No real registry, shortcut, installer, uninstaller, product process,
+ WindowsApps, DPAPI, network, release, push, tag, signing, or publication
+ action was performed.
+
+### Fix implementation commit
+
+- `2e76698d535e6e824df1c57d81314be1c2e053e8`
+ (`fix: complete matched product lifecycle`).
+
+## Fix round 2: exact Ready-bound finalization and cleanup
+
+Scoped review of `97cf17d..0fdc392` found four Important gaps: installed
+finalization did not durably bind wrapper/manifest/epoch and removed too broad
+a root; coherent registration replacement could self-authorize cleanup;
+product source authority did not require latest selected Ready evidence; and
+legacy restore failures were suppressed.
+
+### RED evidence
+
+- UninstallBootstrap exited `1` with `CCOD_INSTALLED_FINALIZER_INVALID`, target
+ `RemoveSelectedGeneration`, then separately with `NamedParameterNotFound`
+ for `Invoke-CcodUninstallBootstrap -WrapperIdentity`.
+- ProductRegistration exited `1` because `Remove-CcodProductRegistration`
+ lacked `ReadyEvidence`; the fixed `schtasks.exe` negative later failed by not
+ throwing `CCOD_PRODUCT_REGISTRATION_NOT_READY`.
+- InstallFileTransaction exited `1` because
+ `Open-CcodInstallProductRegistrationTransaction` lacked `ReadyEvidence`.
+- ProductRegistration exited `1` with `CCOD_PRODUCT_ADAPTER_INVALID`, target
+ `WriteLegacyCompensationFailure`.
+
+### GREEN evidence
+
+- ProductRegistration: `10/10`, exit `0`.
+- InstallFileTransaction: `27/27`, exit `0`.
+- UninstallBootstrap: `19/19`, exit `0`.
+- InstallLifecycle: `116/116`, exit `0`.
+- Explicit parser checks: `10/10`, zero failures.
+- `git diff --check`: exit `0`; only LF-to-CRLF checkout warnings.
+- Aggregate was not run; no aggregate-pass claim is made.
+
+### Boundaries
+
+- Prepare durably records exact wrapper PID/creation/session/SID, selected
+ runtime root, manifest hash, generation, Ready evidence and lease epoch.
+ Finalizer requires an initially verified wrapper and its later exact exit,
+ current epoch, matching leaf/manifest, and deletes only that generation.
+- Registration and cleanup bind canonical install root, Ready runtime and
+ generation, package/manifest/candidate hashes, exact arguments, and the
+ canonical `%System%\schtasks.exe` target. Coherent arbitrary replacements
+ preserve every entry.
+- Product-only and retained-file capabilities require the latest append-only
+ pointer, matching manifest and one matching Ready install record; pre-Ready,
+ stale/unselected, absolute, state-only and wrong-relative sources fail.
+- Legacy compensation verifies absence before each restore. Replacement or
+ restore failure is never overwritten or swallowed: it writes an explicit
+ unresolved record and returns `CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED`.
+ A normal migration failure is reported only after full restoration.
+
+No real registry, shortcut, uninstall, product process, WindowsApps, DPAPI,
+network, release, push, tag, signing, or publication action was performed.
+
+### Fix implementation commit
+
+- `283ecbe3eb012f1e78377e9336aacc7b3acc9336`
+ (`fix: bind matched product finalization`).
+
+### Supplemental pre-cleanup target gate
+
+Final pre-commit audit added a noncanonical Ready target negative. Its RED
+returned the expected validation error but observed `Cleanup=1`: validation
+failure was captured without clearing the fresh context. The fix clears every
+failed fresh context, validates the same canonical Ready invariant on stored
+resume transactions, and rejects before cleanup. Fresh UninstallBootstrap is
+`20/20`, exit `0`; focused parser `4/4` and diff check exit `0`.
+
+- Supplemental commit: `67c79d82e18c805f27aec8e05553938f1f706380`
+ (`fix: reject invalid Ready evidence before cleanup`).
+
+## Fix round 3: strict retained authority and default finalizer proof
+
+Independent review found five Important gaps in stored Ready validation,
+retained authority, current-state preflight, the current partially removed
+legacy entry, and mock-only finalizer coverage.
+
+### RED evidence
+
+- Stored noncanonical Ready target returned the expected error but observed
+ `Cleanup=1`; failed fresh validation retained its context.
+- Product authority accepted a fabricated five-field Ready object and did not
+ bind non-derived fields such as `oldRuntimeId`.
+- Current product inspection did not reject an unknown registry subkey before
+ returning deletable entries.
+- A partially mutated current Registry entry was absent from the outer
+ unresolved compensation set.
+- Installed finalizer tests replaced all five filesystem/identity boundaries
+ rather than exercising their defaults.
+
+### GREEN evidence
+
+- ProductRegistration: `11/11`, exit `0`.
+- InstallFileTransaction: `27/27`, exit `0`.
+- UninstallBootstrap: `21/21`, exit `0`.
+- InstallLifecycle: `116/116`, exit `0`.
+- Full `tests\PersistenceSelfTest.ps1` aggregate: exit `0` after about
+ 7 minutes 15 seconds; it emitted no failure output.
+- Parser: `8/8`, zero failures. `git diff --check`: exit `0`.
+
+### Remediated boundaries
+
+- Product authority accepts the complete ordered 12-field lifecycle Ready
+ record. The product capability stores its canonical complete identity; each
+ retained-file open compares the caller's complete record, including
+ `oldRuntimeId`, then rechecks the latest append-only selector and unique
+ strict Ready record.
+- Current cleanup rejects registry subkeys before returning any entry.
+- Legacy compensation includes the entry whose removal itself failed; partial
+ Registry restore failure is recorded unresolved and cannot be reported as a
+ fully restored migration failure.
+- A disposable default finalizer fixture preserves the production transaction
+ reader, selected-generation/manifest validator, epoch reader, selected-root
+ remover, and absence proof. Only wrapper wait, product registry cleanup and
+ receipt finalization are replaced. It deletes the selected runtime while
+ preserving sibling install state.
+- Normal/recovery registration retry and durable Ready preservation remain
+ green. No real registry, shortcut, uninstall, product, network, release,
+ signing, tag, push, or publication action occurred.
+
+### Implementation commit
+
+- `1ff17bb8759e3d5312c11336ca3b2188bb3a4fe2`
+ (`fix: enforce strict retained product authority`).
+
+## Fix round 4: durable Ready identity and real finalizer negatives
+
+Independent review of `7c333e0..a83840b` found five Important gaps: fresh and
+stored uninstall paths did not share one current-root Ready invariant; product
+authority still reduced a strict lifecycle record to five fields and did not
+prove a complete selector/store at every retained-file open; legacy/current
+registry negatives were adapter-shaped rather than production comparisons; and
+the installed-finalizer evidence covered only a safe adapter-assisted positive.
+
+### RED evidence
+
+- UninstallBootstrap exited `1` in
+ `fresh and stored Ready evidence require one exact current-install-root
+ invariant before cleanup`: a fresh rooted but unrelated `installRoot` was
+ accepted instead of throwing `CCOD_UNINSTALL_BOOTSTRAP_INVALID`. The same
+ case also covers stored `TaskRemoved` wrong-root and extra-field records with
+ an explicit zero-cleanup assertion.
+- InstallFileTransaction exited `1` with
+ `ASSERT_THROWS: expected CCOD_INSTALL_PRODUCT_SCOPE`: capability open first
+ accepted a caller record whose `oldRuntimeId` differed while all five derived
+ fields matched. The completed matrix also covers transaction ID/owned-set
+ mismatch, a fabricated five-field object, pointer gap/unexpected directory,
+ a stale N capability after N+1, changed persisted Ready identity, and a
+ scalar `ownedObjectNames` value.
+- ProductRegistration exited `1` because the production-private
+ `Test-CcodCurrentProductRegistryPreflight` did not exist. The regression calls
+ that same helper with an unexpected subkey and proves zero removals.
+- ProductRegistration exited `1` because the production-private
+ `Compare-CcodLegacySnapshotEntry` did not exist. Its fixture keeps the
+ Registry key present while removing one captured value; the result must be
+ `Mismatch`, the unresolved create-only record must contain `Registry`, and
+ the stable error must be `CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED`.
+- The first no-adapter staged-finalizer child matrix exited `1` instead of the
+ stable `3`: `Write-Error` re-threw under the script's Stop preference. After
+ that was fixed, the wrong stored Ready root reached selected-generation
+ deletion, exposing a dot-sourced `$InstallRoot` parameter collision in the
+ default transaction reader. Both failures occurred before the final GREEN.
+- InstallFileTransaction exited `1` with
+ `ASSERT_THROWS: expected CCOD_INSTALL_PRODUCT_SCOPE` after hard-linked
+ pointer, transaction, and manifest leaves were added; the first hard-linked
+ authority leaf was accepted.
+- InstallFileTransaction exited `1` because the production-private
+ `Read-CcodInstallAuthorityFile` did not exist. The wished-for contract returns
+ bytes and SHA-256 from one verified native handle and rejects a multi-link.
+
+### GREEN and final verification evidence
+
+- ProductRegistration: `13/13`, exit `0`.
+- InstallFileTransaction: `29/29`, exit `0`.
+- UninstallBootstrap: `23/23`, exit `0`. The no-adapter child matrix stages the
+ real `InstalledUninstallFinalizer.ps1` below a fake process `LOCALAPPDATA` and
+ covers same-bootstrap sibling, wrong runtime path, stale epoch, wrapper
+ PID/creation mismatch, wrong stored Ready root, and an extra Ready field.
+ Every case preserves the selected generation and sibling/root state; cases
+ beyond the early path/identity gates also prove the disposable wrapper exited.
+- InstallLifecycle: `116/116`, exit `0`, preserving normal/recovery registration
+ retry and durable Ready behavior.
+- Explicit PowerShell parser: `8/8`, `PARSER_FAILURES=0`, exit `0`.
+- Static gates: `WEAK_SUBSTITUTE_COUNT=0` and
+ `AUTHORITY_PATH_READ_COUNT=0`, exit `0`; the default finalizer reader receives
+ and forwards `ExpectedInstallRoot`.
+- `git diff --check`: exit `0`; only checkout LF-to-CRLF warnings were emitted.
+- Full `tests\PersistenceSelfTest.ps1` aggregate: exit `0`; no failure output.
+- Final independent scoped re-review found no remaining Critical or Important
+ findings.
+
+### Remediated boundaries
+
+- One ordered ten-field Ready helper validates fresh contexts, durable stored
+ transactions/resume, and default finalizer authorization against the exact
+ current install root before selected-root deletion.
+- Product capability open validates the ordered/type-strict 12-field lifecycle
+ record, complete canonical contiguous selector, all canonical transaction
+ chains, the unique full persisted terminal Ready record, and the selected
+ manifest/package identity. Every retained-file open repeats that proof and
+ compares caller, persisted, and stored capability identities.
+- Selector JSON, lifecycle JSON, and manifest hashes are read from one native
+ no-write/no-delete-share handle. The handle rejects reparse, ADS and
+ multi-link leaves, checks final path and file identity before and after the
+ read, and hashes the returned bytes rather than reopening by path.
+- Current-product subkeys fail a production pure preflight before shortcut
+ inspection. Legacy Registry compensation distinguishes exact, absent, and
+ mismatched key/value/kind snapshots; a partial key is never called restored
+ or overwritten.
+- The installed finalizer accepts only a canonical absolute process
+ `LOCALAPPDATA` when it is present, otherwise falls back to the current-user
+ special folder. The staged default reader carries the expected install root
+ through dot-sourcing without a parameter-name collision.
+
+No real registry, shortcut, installer, uninstaller, product, network, release,
+signing, tag, push, or publication action occurred.
+
+### Fix implementation commit
+
+- `3cd891221acfd569fd29e2244b5200fa28e43106`
+ (`fix: prove durable Ready product authority`).
+
+## Fix round 5: canonical lifecycle authority, proof-to-use lease, and first-install handoff
+
+Independent review of `a83840b..a60759b` found three Important production
+gaps: product authority maintained a weaker parallel transaction-chain reader;
+selector proof and retained-file use were not serialized against an N+1
+commit; and normal first install passed its still-writable generation
+transaction to the default product-registration path.
+
+This was the fifth and final permitted Task 4 repair round. The implementation
+below is submitted for a new independent review; Task 4 is not claimed complete.
+
+### RED evidence
+
+- InstallFileTransaction exited `1` because a selected terminal Ready chain
+ plus an unrelated strict Prepared head was accepted instead of throwing
+ `CCOD_INSTALL_PRODUCT_SCOPE`. The companion Ready-to-Failed continuation
+ regression was added to the same canonical-head boundary.
+- The real two-process coordination regression reached
+ `Enter-CcodLifecycleOwnership` and `Set-CcodActiveRuntime` for N+1 while an N
+ product transaction was active. Before the lease fix, N+1 committed and the
+ subsequent N retained-file use failed with `CCOD_INSTALL_PRODUCT_SCOPE`;
+ proof and use were not one stable authority operation.
+- Clearing only the product transaction's stored authority-lease state while
+ retaining the real mutex still allowed a retained-file open; the test exited
+ `1` with `ASSERT_THROWS: expected CCOD_INSTALL_PRODUCT_SCOPE`.
+- The safe default first-install integration initially failed adapter
+ resolution because no lower product-side-effect seam existed. Once only the
+ registry/shortcut side effects were isolated, the old path failed with
+ `CCOD_PRODUCT_REGISTRATION_FAILED`; a targeted diagnostic recorded
+ `CCOD_INSTALL_RETAINED_GENERATION_INVALID` and a sharing violation because
+ the writable generation transaction was still pinned after Ready.
+- The new post-Ready close-failure case first exited `1` with
+ `CCOD_INSTALL_ADAPTER_INVALID`, target `CloseReadyGeneration`, before the
+ narrow close handoff existed.
+
+### GREEN and final verification evidence
+
+- InstallFileTransaction: all 32 named cases passed, exit `0`. Coverage includes
+ the unrelated Prepared head, Ready-to-Failed continuation, missing live
+ authority lease, and a real N/N+1 coordination process pair.
+- ProductRegistration: `13/13`, exit `0`.
+- UninstallBootstrap: `23/23`, exit `0`.
+- InstallLifecycle: `119/119`, exit `0`. The real default registration path
+ opens both retained shortcut candidates through product-only authority;
+ normal first install succeeds, product-write failure remains durable Ready
+ and retries through AlreadyInstalled, and post-Ready writable-close failure
+ performs zero product writes, appends no Failed snapshot, and retries.
+- Explicit PowerShell parser checks passed all four changed source/test files:
+ `PARSER_COUNT=4`, `PARSER_FAILURES=0`.
+- Static gates passed: `WEAK_SUBSTITUTE_COUNT=0`,
+ `AUTHORITY_PATH_READ_COUNT=0`, `CANONICAL_RESOLVER_CALL_COUNT=1`,
+ `WRITABLE_REGISTRATION_SOURCE_COUNT=0`, `STRICT_PRODUCT_OPEN_COUNT=1`, and
+ `READY_CLOSE_ADAPTER_CALL_COUNT=2`.
+- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were emitted.
+- Full `tests\PersistenceSelfTest.ps1` aggregate exited `0` with no failure
+ output. Its long-running child at the observed midpoint was the existing
+ TrayHost production-trace suite, not the new authority/lifecycle tests.
+
+### Remediated boundaries
+
+- InstallLifecycle owns the one canonical pure
+ `Resolve-CcodInstallTransactionRecordHead` implementation. Its normal reader
+ and InstallFileTransaction's native-handle authority reader both call that
+ implementation; product authority no longer carries a reduced parallel
+ chain checker. The native-handle selector, manifest, and transaction-file
+ reads remain intact, and the canonical record validator now also requires
+ `ownedObjectNames` to be an array.
+- Product transaction open acquires the same current-user global
+ `AccountTransition` mutex used by lifecycle ownership before reading the
+ selector. The opaque transaction retains that lease through canonical
+ selector/manifest/transaction proof and retained-file opens. Close attempts
+ both native cleanup and lease release on failure paths; retained-file open
+ explicitly requires the live product-only lease on its owning thread.
+- The deterministic N/N+1 test uses the real lifecycle-ownership and active
+ pointer commit paths. N+1 blocks while N opens its retained shortcut, commits
+ generation 2 only after N closes, and the closed stale N capability cannot
+ be reused.
+- Normal and recovered Ready paths close their writable generation/state
+ transaction before product registration. The default RegisterProduct adapter
+ always opens and owns a separate strict selected product transaction; the
+ writable input is never used as shortcut authority. A close or registration
+ failure is post-Ready, performs no lifecycle rollback, and is reconciled by
+ a later same-package invocation.
+
+No real registry, shortcut, installer, uninstaller, product process, network,
+release, signing, tag, push, or publication action occurred.
+
+### Fix implementation commit
+
+- `a9900a4b00508f9ed694375ea94a2bfb97437e96`
+ (`fix: serialize strict product authority`).
+
+## Fix round 6: durable product-close outcome and atomic lock-wait proof
+
+This architecture-level repair addresses the two remaining product-close and
+concurrency-proof gaps. It does not claim Task 4 complete. No real registry,
+shortcut, installer, uninstaller, product, network, release, signing, tag,
+push, or publication action was performed.
+
+### RED evidence
+
+- The default-registration close regression initially failed with
+ `CCOD_INSTALL_ADAPTER_INVALID`: there was no narrow
+ `CloseProductTransaction` seam at the owned strict-product-transaction
+ boundary. The prior default `RegisterProduct` `finally` also discarded an
+ owned transaction close exception and could return a verified receipt.
+- The previous N/N+1 test announced a marker before it had reached the real
+ `AccountTransition` wait, so it could not prove that the child was actually
+ blocked on the same mutex.
+
+### GREEN evidence
+
+- `tests\persistence\InstallFileTransaction.SelfTest.ps1`: exit `0`; all 32
+ named cases passed. The N/N+1 case runs the child with `-Mta`, atomically
+ signals its real mutex attempt through `SignalAndWait`, proves N can open its
+ retained shortcut while the child waits, then proves N+1 commits only after
+ N closes and stale N is unusable.
+- `tests\persistence\InstallLifecycle.SelfTest.ps1`: exit `0`; `120/120`.
+ The new default-path integration forces both the initial owned close and its
+ bounded same-thread retry to fail, observes no successful receipt and a
+ durable `Ready` record with no `Failed` snapshot, then proves an exact
+ same-package reconciliation drains the pending cleanup before opening fresh
+ strict product authority and returns verified success.
+- `tests\persistence\ProductRegistration.SelfTest.ps1`: exit `0`;
+ `13/13`.
+- `tests\persistence\UninstallBootstrap.SelfTest.ps1`: exit `0`; `23/23`.
+- Explicit PowerShell parser checks on the three changed PowerShell files
+ passed with `PARSER_FAILURES=0`. `git diff --check` exited `0`; only
+ checkout LF-to-CRLF warnings were emitted.
+- A fresh full `tests\PersistenceSelfTest.ps1` aggregate remains pending at
+ this evidence point. No aggregate-pass or Task 4 completion claim is made.
+
+### Remediated boundaries
+
+- `CloseProductTransaction` is a narrow lifecycle adapter. The default
+ delegates to the normal `Ready` file-transaction close. A first close
+ failure always makes the current registration call fail as
+ `CCOD_PRODUCT_REGISTRATION_FAILED`, even if its one bounded same-thread
+ cleanup retry succeeds; no verified receipt is returned from that call.
+- If both close attempts fail, the exact opaque transaction and its bound
+ same-module close delegate remain in a synchronized, process-local pending
+ table keyed by the canonical install root and owner managed-thread ID. The
+ next registration on that same thread drains it before opening fresh product
+ authority. A different thread or another cleanup failure fails closed; no
+ lease or capability is transferred across threads or processes.
+- The normal install, Ready-finalization recovery, and exact
+ `AlreadyInstalled` reconciliation all pass the same close adapter to the
+ default registration path. The writable generation/state handoff remains
+ closed before product registration begins.
+
+### Fix implementation commit
+
+- `2e86de17a7c454cc56edd123b6c6487c5194f32d`
+ (`fix: retain failed product transaction cleanup`).
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-brief.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-brief.md
new file mode 100644
index 0000000..9ac9c3b
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-brief.md
@@ -0,0 +1,19 @@
+# Task 5 brief — exact assets and dual Defender evidence
+
+Implement Task 5 from `docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md` with RED -> GREEN. Read the matching design section and existing release/Defender code before editing.
+
+## Required contracts
+
+1. Create `tools/ReleaseAssetContract.psm1` as the single source of the exact ordered 11 public v2.5.22 asset names. `Test-CcodExactReleaseAssetSet` rejects missing, extra, duplicate/case-varied, directory, reparse, unsafe ancestry, or noncanonical entries and cross-validates portable/Setup checksum, provenance, payload manifest, release manifest, version and 40-hex commit bindings.
+2. Extend `Invoke-CcodReleaseDefenderCheck` to accept only `TrustedWorkflowArtifact` or `InternetDownload`, exact Setup or ZIP plus matching checksum/manifest, and a create-only regular evidence leaf. `InternetDownload` requires the actual Zone.Identifier ADS with ZoneId 3 and exposes no synthesis switch. `TrustedWorkflowArtifact` requires exact workflow artifact identity. Require Defender service/AV/real-time enabled, current nonempty platform/engine/signature metadata and timestamp, bounded scan timestamps, successful custom scan, and zero new detections. The canonical receipt contains no source path or raw Defender output.
+3. `Test-CcodReleasePromotionEvidence` validates the two distinct official-download receipts (Setup and portable ZIP), their exact asset/manifest hashes, version, commit and `InternetDownload` origin. Reject duplicate, swapped, missing, extra, reused or mismatched receipts. Trusted-workflow receipts remain separately origin-bound evidence and never substitute for the two official-download receipts.
+4. Keep portable scanning before activation. Add Setup sealed-package scanning before the lifecycle can create `Prepared`, transaction-root or product state. A scan failure must produce no activation mutation.
+5. Update build/manifest validation to consume the centralized 11-name contract. Update README/README.zh-CN/CHANGELOG only to enforced candidate behavior: no unqualified stable/verified claim and no per-version What's New clutter on the README home page. Receipts are evidence, never public assets.
+
+## RED coverage
+
+Cover exact asset-set mutations; every receipt binding field; Setup/ZIP duplicate/swap/missing; both origin rules; ZoneId absent/other/synthetic attempt; disabled Defender/service/real-time; absent/stale signature; scan error/detection; existing/reparse evidence; write failure; portable and Setup scan failure before mutation; and documentation wording.
+
+## Verification and boundaries
+
+Run ReleaseWorkflow, PortableRelease, InstallLifecycle, changed parsers, `git diff --check`, and the full persistence aggregate from frozen source. Tests use adapters only and cannot claim a live Defender pass. Do not execute real scans, builds, network, GitHub, installation, restart, reboot or publication. Commit implementation and evidence separately, then request a scoped review. Tasks 6–8 stay blocked.
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-brief.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-brief.md
new file mode 100644
index 0000000..263f2f1
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-brief.md
@@ -0,0 +1,12 @@
+# Task 5 fix round 1 — non-synthesizable Defender authority
+
+Read Task 5's report and scoped review. Fix all four findings without changing the 11 public names, receipt schema, candidate docs, or Setup/portable ordering.
+
+## Required RED -> GREEN
+
+1. Remove `Adapters` from the public `Invoke-CcodReleaseDefenderCheck` interface and remove the public script-library bypass. Put adapter-taking logic behind a non-exported module core used only through module-scope tests. Production adapters must resolve the real Defender cmdlets and actual Zone stream; no public parameter/capability may replace Zone, Defender status, scan, detections, clock, hashes, or writer. Add a RED that enumerates public parameters/exports and attempts the old forged receipt path.
+2. Move or share deep portable/Setup manifest validation with `ReleaseAssetContract.psm1`. `Test-CcodExactReleaseAssetSet` and promotion must reject malformed nested payload/provenance/inventory, wrong file lengths/hashes, ZIP entry/root-launcher mismatch, and Setup nested payload/provenance mismatch even when outer manifests and receipt hashes are self-consistent. Replace bogus baseline fixture hashes with real internally consistent bytes and add one mutation per nested binding.
+3. Hold read-only, no-write/no-delete shared handles for candidate/checksum/manifest/Zone from initial proof through scan, receipt durable read-back, and return. Promotion must hold all 11 asset handles and both receipt handles across its complete decision, revalidate identity/hash and exact directory membership before return, and reject scan ABA, post-revalidation replacement, and promotion-time replacement. Use canonical final-path/file-ID/reparse/ADS/multilink checks where the existing native helpers support them.
+4. Serialize one canonical receipt byte sequence, write create-only, flush, no-replace publish, reopen the final leaf, verify regular/nonreparse/default-stream identity, strict UTF-8/duplicate-free/exact 25 fields, bytes/hash and every field against the expected receipt, then return. A no-op, corrupting, wrong-target, stale-object or write-then-replace adapter must fail. Failed scan receipts require the same read-back before the stable scan error is returned.
+
+Run ReleaseWorkflow, PortableRelease, InstallLifecycle, all changed parsers, `git diff --check`, and the complete persistence aggregate from frozen source. Commit implementation and evidence separately and request a scoped fix-round review. Do not run real Defender/build/network/install/reboot/release operations. Tasks 6–8 remain blocked.
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-report.md
new file mode 100644
index 0000000..d01c54c
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-report.md
@@ -0,0 +1,101 @@
+# Task 5 fix round 1 report: non-synthesizable Defender authority
+
+## Status
+
+Implemented Task 5 fix round 1 in commit
+`9f8a9e7f4aa8ba6028941026cbca776cbd908610`
+(`fix: close Defender authority review gaps`). The previous Task 5
+implementation commit remains `fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4`.
+The scoped review that failed Task 5 saw `1109854..fb163b7`. This round
+covers `fb163b7..9f8a9e7`.
+
+This remains candidate infrastructure only. It does not claim a live
+Defender scan, a production build, a published release, Windows
+acceptance, or completion of Tasks 6-8. Scoped re-review is still
+required.
+
+No real Defender command, official build, installation, uninstall,
+registry or shortcut mutation, network request, GitHub action, restart,
+reboot, signing, tag, push, upload, promotion, or publication was
+performed. Defender and activation tests used temporary files and
+module-scope adapters only.
+
+## Finding 1: public Defender surfaces cannot synthesize authority
+
+Public `Invoke-CcodReleaseDefenderCheck` and `tools/Test-ReleaseDefender.ps1`
+now accept only candidate, checksum, manifest, origin, optional workflow
+identity, version, commit, and evidence path. They expose neither
+`-Adapters` nor `-Library`. Production adapters resolve only real
+Defender cmdlets, the actual Zone stream, and the create-only receipt
+publisher. Adapter-taking logic lives in non-exported
+`Invoke-CcodReleaseDefenderCheckCore`; tests reach it only through
+module-scope invocation.
+
+RED: `fix1-public` enumerates public parameters/exports and attempts the
+old forged receipt path. Actual first-failure shape, before this round,
+was a public function that accepted `-Adapters` and a script `-Library`
+bypass.
+
+## Finding 2: nested payload and provenance are now exact
+
+`Test-CcodExactReleaseAssetSet` and promotion consume deep portable and
+Setup validation from `tools/ReleaseAssetContract.psm1`. Portable
+validation checks payload records, ZIP membership, root launcher hashes,
+and embedded payload-manifest identity. Setup validation checks nested
+package-manifest, provenance, inventory grammar, and PE binding. The
+Task 5 fixture now uses internally consistent nested bytes instead of
+placeholder hashes.
+
+RED: `fix1-deep` mutates nested payload length/hash, ZIP root launcher,
+ZIP extra entry, TrayHost nested schema, Setup package payload binding,
+Setup provenance nested schema/length, and inventory grammar while
+keeping outer hashes self-consistent. Each mutation fails closed with
+`CCOD_RELEASE_ASSET_SET_INVALID`.
+
+## Finding 3: scan and promotion hold identities through the decision
+
+Candidate, checksum, manifest, Zone, all 11 public assets, and both
+receipts are pinned with read-only no-write/no-delete handles. Shared
+final-path, file-id, reparse, ADS, and multilink checks revalidate
+identity through scan, receipt durable read-back, and promotion return.
+Promotion rechecks exact directory membership before return.
+
+RED: `fix1-handle-scan` same-byte ABA writes are denied; `fix1-handle-post`
+post-revalidation replacement is denied; `fix1-handle-promotion` cannot
+replace a held asset or receipt before return.
+
+## Finding 4: receipts require production write/read-back
+
+Receipt publication serializes one canonical UTF-8 byte sequence, writes
+create-only under the held evidence directory, flushes, publishes
+no-replace, reopens the final leaf, and compares regular/nonreparse
+identity, exact 25 fields, bytes, hash, and object. Failed scan receipts
+use the same read-back before the stable scan error is returned.
+
+RED: `fix1-receipt-NoOp`, `Corrupt`, `WrongTarget`, `StaleObject`,
+`ReplacedReadback`, and `FailedNoOp` all fail with
+`CCOD_DEFENDER_EVIDENCE_WRITE_FAILED`.
+
+## Frozen GREEN evidence
+
+The final source was frozen at pre-commit diff hash
+`be3fc4f1c194ccc4d64bae4609e86531a10cb1da2a8f9813ca0962ad58f387a2`.
+From that unchanged source:
+
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests\\persistence\\ReleaseWorkflow.SelfTest.ps1` exited `0`; 63 declared cases passed, including public-surface, deep nested, handle, receipt read-back, dual promotion, origins, status, clocks, Setup order, portable races, build wiring, and bilingual documentation.
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests\\persistence\\PortableRelease.SelfTest.ps1` exited `0`; `5/5`.
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests\\persistence\\InstallLifecycle.SelfTest.ps1` exited `0`; `151/151`. The existing cross-process observation still prints `exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`.
+- PowerShell parser checks covered all 104 repository `.ps1`/`.psm1` files with `PARSER_FAILURES=0`.
+- `git diff --check` exited `0`; only checkout LF-to-CRLF notices appeared.
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests\\PersistenceSelfTest.ps1` exited `0`; all 39 registered persistence suites completed.
+
+## Implementation commit and review boundary
+
+- Implementation: `9f8a9e7f4aa8ba6028941026cbca776cbd908610`
+ (`fix: close Defender authority review gaps`).
+- Previous Task 5 implementation: `fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4`.
+- Plan/brief baseline remains `1109854`; fix-round brief is
+ `task-5-fix1-brief.md`.
+- Tasks 6-8 remain blocked. Task 5 needs a scoped re-review of
+ `fb163b7..9f8a9e7` before it can be marked complete.
+
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-report.md
new file mode 100644
index 0000000..eb1ec5d
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-report.md
@@ -0,0 +1,200 @@
+# Task 5 report: exact assets and dual Defender evidence
+
+## Status
+
+Implemented the deterministic Task 5 release-security foundation in commit
+`fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4`. The build, release-manifest
+validator, Defender checker, promotion validator, Setup bootstrap, portable
+entrypoint, tests, and candidate documentation now share one exact public
+asset contract.
+
+This is candidate infrastructure only. It does not claim a live Defender
+scan, a production build, a published release, stable Windows acceptance, or
+completion of Tasks 6-8. Scoped review is still required.
+
+No real Defender command, official build, installation, uninstall, registry
+or shortcut mutation, network request, GitHub action, restart, reboot, signing,
+tag, push, upload, promotion, or publication was performed. Defender and
+activation tests used temporary files and test-local adapters/functions only.
+
+## Exact public asset authority
+
+`tools/ReleaseAssetContract.psm1` is the single production source for the
+ordered 11-name set:
+
+1. `CodexRemote-fix-2.5.22-windows-x64.zip`
+2. `CodexRemote-fix-2.5.22-windows-x64.zip.sha256.txt`
+3. `CodexRemote-fix-2.5.22-trayhost-provenance.json`
+4. `CodexRemote-fix-2.5.22-payload-manifest.json`
+5. `CodexRemote-fix-2.5.22-release-manifest.json`
+6. `CodexRemote-fix-2.5.22-setup.exe`
+7. `CodexRemote-fix-2.5.22-setup.exe.sha256.txt`
+8. `CodexRemote-fix-2.5.22-setup-provenance.json`
+9. `CodexRemote-fix-2.5.22-setup-payload-manifest.json`
+10. `CodexRemote-fix-2.5.22-setup-destination-inventory.iss`
+11. `CodexRemote-fix-2.5.22-setup-release-manifest.json`
+
+`Test-CcodExactReleaseAssetSet` requires a canonical plain directory with
+exactly those 11 regular non-reparse leaves. It rejects missing, extra,
+case-varied, duplicate, directory, reparse, unsafe-ancestry, noncanonical,
+manifest-order, hash, commit, timestamp, and shared-provenance mutations.
+
+Portable manifest public records are indices `0,1,2,3`, with index `4` as its
+manifest leaf. Setup manifest public records are indices `5,6,2,7,8,9`, with
+index `10` as its manifest leaf. Both distributions bind the same lower-case
+40-hex commit, canonical build timestamp, and TrayHost provenance bytes.
+Checksums bind the exact candidate leaf and bytes. A bounded recursive lexical
+JSON pass rejects duplicate property names at every object depth, including an
+escaped spelling such as `\u0067itCommit`, before deserialization.
+
+`build/build.ps1` imports this module only after the clean-checkout gate,
+derives all 11 paths by their fixed indices, writes the two manifest views,
+then validates the complete exact set. `tools/Test-ReleaseDefender.ps1` also
+uses the central names for its portable and Setup manifest validators.
+
+## Defender receipt contract
+
+`Invoke-CcodReleaseDefenderCheck` now requires the exact candidate, matching
+checksum, explicit matching manifest, expected version/commit, evidence path,
+and one of two origins. Its ordered schema-two receipt has exactly these 25
+fields:
+
+`schemaVersion, assetType, assetName, assetSha256, checksumName,
+checksumSha256, manifestName, manifestSha256, version, gitCommit, origin,
+workflowArtifactIdentity, zoneId, defenderServiceEnabled, antivirusEnabled,
+realTimeProtectionEnabled, defenderPlatformVersion, defenderEngineVersion,
+signatureVersion, signatureUpdatedAtUtc, scanStartedAtUtc,
+scanCompletedAtUtc, detectionCount, outcome, errorCode`.
+
+- `InternetDownload` requires the actual `Zone.Identifier` stream with exactly
+ one `ZoneId` property whose value is `3`. The public tool has no Zone
+ synthesis parameter, and workflow identity must be null.
+- `TrustedWorkflowArtifact` has a null Zone and requires the exact ordered
+ GitHub Actions identity fields `provider, repository, runId, runAttempt,
+ artifactId, artifactName, artifactDigest, gitCommit`. Provider, repository,
+ artifact name, positive integral identifiers, `sha256:<64hex>` digest, and
+ expected commit are all exact.
+- Defender service, antivirus, and real-time protection must be enabled.
+ Platform, engine, signature version, and signature timestamp must be
+ present. The signature can be at most 72 hours old at scan start and at most
+ five minutes in the future. Completion cannot precede start or exceed two
+ hours; no invalid-clock fallback is used.
+- The checker records the pre/post detection difference, requires zero new
+ detections for success, and preserves stable scan/detection/write errors.
+ It revalidates candidate, checksum, manifest, expected commit, hashes, and
+ Internet Zone after the scan and before writing a receipt.
+- Default evidence writing requires an existing canonical plain parent and a
+ new `.json` leaf. It uses a create-new temporary file, durable flush, a
+ second target preflight, no-replace move, and exact cleanup. Existing files,
+ directories, reparse leaves, reparse ancestry, noncanonical paths, and ADS
+ targets are rejected before scanning.
+
+The receipt contains no source path or raw Defender output.
+
+## Promotion authority
+
+`Test-CcodReleasePromotionEvidence` requires both `-EvidenceDirectory` and the
+mandatory authoritative `-AssetDirectory`, plus version and expected commit.
+It first validates the exact 11 assets and their common commit. It then accepts
+exactly these two receipt leaves:
+
+- `CodexRemote-fix-2.5.22-setup.internet-download.defender.json`
+- `CodexRemote-fix-2.5.22-windows-x64.internet-download.defender.json`
+
+Both must be schema-two `InternetDownload`/Zone-3/Completed/zero-detection
+receipts. Setup receipt hashes are compared with actual contract indices
+`5,6,10`; portable receipt hashes are compared with indices `0,1,4`.
+Missing, extra, duplicate, swapped, reused, forged-but-well-formed, field-
+mismatched, or trusted-workflow substitutions fail closed. Trusted workflow
+receipts remain separate CI evidence and cannot satisfy official-download
+promotion.
+
+## Pre-mutation scan ordering
+
+- Setup holds the already verified package seal, scans the canonical package
+ path returned by that seal, and only then creates the extraction temp root,
+ expands payload bytes, opens activation state, writes any activation log or
+ receipt, or launches the lifecycle worker. The production-shaped failure
+ injection proves no package temp root or product-state entry appears.
+- Portable retains `manifest validate -> Defender scan -> manifest revalidate
+ -> copy -> lifecycle`. Its local gate now records the same service/AV/
+ real-time/platform/engine/signature/timestamp facts. A real entrypoint copy
+ with a throwing test-local scan proves no child or lifecycle state is
+ created; existing post-scan and final-copy race tests remain green.
+
+## Candidate documentation
+
+README and README.zh-CN remain candidate-scoped. The old `always published`,
+`始终发布`, and `已验证……均可用` claims were removed. The exact 11-name list is
+present once in each release section inside a collapsed `` block, and
+Defender receipts are explicitly evidence rather than public assets. No
+version-specific What's New section was added. CHANGELOG adds one concise
+English and Chinese bullet describing the enforced dual-download evidence.
+
+## RED evidence
+
+The following failures were observed before the corresponding production
+implementation:
+
+- Asset contract: exit `1`, `ASSERT_TRUE: central release asset contract
+ module exists`.
+- Defender v2 API: exit `1`,
+ `NamedParameterNotFound,Invoke-CcodReleaseDefenderCheck` for
+ `-CandidatePath`.
+- Promotion: exit `1`, missing central asset module.
+- Evidence writer: exit `1`, missing Defender `-CandidatePath` API.
+- Setup ordering: exit `1`, exact scan call count expected `1`, actual `0`.
+- Documentation: exit `1`, `English README makes no unconditional publication
+ or verification claim`.
+- Authoritative promotion follow-up: exit `1`,
+ `NamedParameterNotFound,Test-CcodReleasePromotionEvidence` for mandatory
+ `-AssetDirectory`; the old shape could not compare receipts with real
+ assets.
+- Lexical JSON follow-up: exit `1`, `ASSERT_THROWS: expected
+ CCOD_RELEASE_ASSET_SET_INVALID`; a duplicate top-level property was folded.
+- Zone follow-up: exit `1`, `ASSERT_THROWS: expected
+ CCOD_DEFENDER_ZONE_REQUIRED`; the first matching ZoneId line was accepted.
+- Post-scan identity follow-up: exit `1`, `ASSERT_THROWS: expected
+ CCOD_RELEASE_ASSET_HASH_MISMATCH`; candidate mutation during the fake scan
+ reached receipt creation.
+
+During GREEN iteration, the first asset-path implementation looped at the
+drive root after trimming `C:\` to `C:`; the session was interrupted and root
+termination was corrected. The first default evidence writer closure could
+not resolve its validator and was corrected without weakening preflight.
+Two test-oracle defects were also corrected: base names were no longer counted
+inside checksum names, and restoration of a mutated EXE explicitly restored
+its test Zone ADS before later cases.
+
+## Frozen GREEN evidence
+
+The final source was frozen at pre-commit diff hash
+`55eaabfc6770bee68afda76c6996ff41d569e2f0`. From that unchanged source:
+
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File
+ tests\persistence\ReleaseWorkflow.SelfTest.ps1` exited `0`; all 58 declared
+ cases passed, including exact assets, dual promotion, origins, full status,
+ unique Zone, clocks, post-scan identity, create-only evidence, Setup order,
+ portable failure/races, build wiring, and bilingual documentation.
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File
+ tests\persistence\PortableRelease.SelfTest.ps1` exited `0`; `5/5`.
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File
+ tests\persistence\InstallLifecycle.SelfTest.ps1` exited `0`; `151/151`.
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File
+ tests\PersistenceSelfTest.ps1` exited `0`; the sorted aggregate completed
+ all 39 registered persistence suites.
+- PowerShell parser checks covered all 103 repository `.ps1`/`.psm1` files
+ with `PARSER_FAILURES=0`.
+- `git diff --check` exited `0`; only checkout LF-to-CRLF notices appeared.
+- Before commit there were zero active worktree self-test children and zero
+ matching Task 5 temporary directories. Two exact directories left by the
+ intentionally interrupted early RED run were inspected as plain temp
+ directories and removed before this check.
+
+## Implementation commit and review boundary
+
+- Implementation: `fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4`
+ (`feat: require dual Defender release evidence`).
+- Plan/brief baseline: `1109854`.
+- Tasks 6-8 remain blocked. Task 5 needs a scoped review of
+ `1109854..fb163b7` before it can be marked complete.
diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-6-fix-round-verification.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-6-fix-round-verification.md
new file mode 100644
index 0000000..091b69c
--- /dev/null
+++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-6-fix-round-verification.md
@@ -0,0 +1,66 @@
+# Task 6 Fix Round Verification Report
+
+Date: 2026-09-04
+Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`
+Branch: `codex/v2522-install-runtime-reliability`
+Implementation commit: `6d9bafa96c252ece692e8aff35e420285479b7b6`
+Corrective module-boundary commit: `325985d9a56bc222be1eb6a73dfb60b6326706da`
+
+## Scope
+
+Task 6 adds a fail-closed clean release runner and a draft-only GitHub release flow. The implementation does not perform a live release. It transfers one immutable commit and clean-runner preflight record, validates the exact eleven-asset contract, creates only a private draft, persists strict verification evidence, and requires separated Defender, verification, and acceptance evidence before promotion.
+
+## TDD evidence
+
+### RED
+
+Command:
+
+```powershell
+powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command '$env:CCOD_TASK6_RED_CASE="fix1-evidence-types"; & (Join-Path (Get-Location) "tests/persistence/ReleaseWorkflow.SelfTest.ps1")'
+```
+
+Observed result: exit code `1`, with `CCOD_SELFTEST_FAILED` and `ASSERT_THROWS: expected CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID`. The failing case proved that a single-element JSON array in a scalar acceptance field was accepted before the fix.
+
+### GREEN
+
+Command:
+
+```powershell
+powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command '$env:CCOD_TASK6_RED_CASE="fix1-evidence-types"; & (Join-Path (Get-Location) "tests/persistence/ReleaseWorkflow.SelfTest.ps1")'
+```
+
+Observed result: exit code `0`; the focused case reported `True` and `Release workflow self-tests passed.`
+
+## Verification results
+
+All commands were run from the exact worktree above.
+
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` — exit `0`, including the public-module-boundary and strict evidence-type cases after the corrective module-boundary commit.
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Bootstrap.SelfTest.ps1` — exit `0`, `27/27`.
+- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstallLifecycle.SelfTest.ps1` — exit `0`, `151` passed.
+- `npm test` — exit `0`; `Validation passed: PowerShell, JavaScript, clean-room runtime, package checker, persistence tests, repository files, and package preflight.`
+- PowerShell AST parsing of all changed `.ps1` files — passed.
+- YAML parsing of `ci.yml` and `release.yml` — passed.
+- `git diff --cached --check` and `git diff --check` — passed.
+- Added-line security scan — no hardcoded secret pattern, dangerous eval/shell pattern, floating action reference, or production release bypass.
+- No test, build, GitHub, Defender, installation, reboot, tag, push, or publication action was performed.
+
+## Independent review
+
+A constrained independent reviewer inspected the exact staged Task 6 implementation diff in read-only mode and returned:
+
+```json
+{"verdict":"PASS","findings":[]}
+```
+
+The reviewer reported no file modifications and no external operations. Earlier review attempts that ended with reviewer API HTTP 404 were not counted as acceptance.
+
+## Commit separation
+
+- Implementation commits: `6d9bafa96c252ece692e8aff35e420285479b7b6` and corrective module-boundary commit `325985d9a56bc222be1eb6a73dfb60b6326706da`.
+- This report and the progress ledger are evidence only and are committed separately after the implementation commits.
+
+## Remaining gates
+
+Task 7/8 official draft acceptance and the real downloaded-asset Defender receipt gate remain blocked. No public release is claimed by this report.
diff --git a/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/final-fix-report.md b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/final-fix-report.md
new file mode 100644
index 0000000..d685a7a
--- /dev/null
+++ b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/final-fix-report.md
@@ -0,0 +1,186 @@
+# Final fix report: durable product cleanup recovery gaps
+
+## Scope and review status
+
+- Final-review base: `b1aec464b255661596d19c6a5d2bdb6ead4a7003`.
+- Implementation: `9e00ccf3ce4fb1f5e2f1e80c415c8fd787b71509`
+ (`fix: close durable cleanup recovery gaps`).
+- The implementation commit contains only:
+ - `src/persistence/modules/InstallLifecycle.psm1`
+ - `tests/persistence/InstallFileTransaction.SelfTest.ps1`
+ - `tests/persistence/InstallLifecycle.SelfTest.ps1`
+- This is the plan's single final-fix wave. It fixes the three final-review
+ Important findings together. It does not claim release readiness or
+ installed-product acceptance; one scoped independent re-review remains.
+
+## Root causes and fixes
+
+### Old Pending before selector advance
+
+The previous flow first committed the new selector and terminal Ready record,
+then called registration for the new Ready transaction. That registration saw
+the old Pending record only as foreign history, while strict old authority had
+already become stale and could no longer be opened for recovery.
+
+An upgrade now acquires the real outer `AccountTransition` product-cleanup
+lease before creating the new generation or any new nonterminal transaction
+record. It resolves cleanup against the exact currently selected old ordered
+12-field Ready transaction, then retains that lease across the selector commit.
+The later same-thread `InstallLease` and lifecycle ownership acquisitions are
+recursive acquisitions of the same mutex. Normal release is LIFO: lifecycle
+ownership, install lease, then the pre-upgrade product-cleanup lease. Every
+exception path attempts the same order, and a resolve failure exits before a
+new selector or Ready record is written.
+
+The existing resolver behavior remains exact: a live foreign owner blocks, a
+same-thread retained capability may retry, and a proven-dead owner must open,
+bind, and close fresh strict old authority before appending Completed.
+
+### Fence binding failure cleanup
+
+After strict `OpenProduct` succeeded, a binding failure previously reached a
+Ready close. Its missing-fence assertion ran before native close and authority
+lease release, permanently retaining the inner recursive mutex.
+
+The process-local cleanup entry now records whether the exact durable fence was
+successfully bound. An unbound strict transaction is closed directly with
+`Disposition Failed`; that path always attempts native runtime cleanup and the
+inner authority lease release and never appends Completed. On successful abort
+the stale transaction capability is discarded while durable Pending remains.
+If the abort itself fails, the exact capability remains retryable; a later
+same-thread call first retries the Failed abort, then opens and binds fresh
+strict authority. Only a successful bound Ready close permits Completed.
+
+### Exact crash temporary recovery
+
+The create-only writer can leave a read-only `.ccod.<32-lowercase-hex>.tmp`
+after abrupt process termination. The fence reader previously classified that
+internal crash artifact as an unknown object forever.
+
+The final design deliberately does not alter the independently reviewed IFT
+seal/rename ABI. Delete-on-close was rejected for this wave because the current
+write-close, strict-reopen, read-only, and no-replace rename sequence did not
+provide a sufficiently narrow proof that delete-pending could be cleared on
+every rename/collision/exception path without reopening a crash window.
+
+Instead, the fence reader performs the brief's narrow recovery while the real
+outer product-cleanup lease is live. A private inert-marker CLR bridge:
+
+- accepts only the exact lowercase internal temporary name;
+- pins install root, `state`, and `product-cleanup-fences` through relative
+ non-reparse native directory handles;
+- opens one leaf with `DELETE`, read/write-attribute access, and zero sharing;
+- proves non-directory, non-reparse, default-stream-only, single-link identity,
+ exact final path, and stable volume/file index;
+- clears only that pinned orphan's read-only attribute, arms native delete
+ disposition, revalidates the same identity, closes the handle, and requires
+ the pinned parent enumeration to show the leaf absent;
+- re-enumerates the whole fence plane before any history is accepted.
+
+Unknown names, reparse objects, ADS, hardlinks, and any open/nonexclusive exact
+temporary still fail closed and are not removed or ignored. There is no broad,
+recursive, wildcard, or path-only deletion.
+
+## RED evidence
+
+### Upgrade with old Pending
+
+- Runner: Windows PowerShell 5.1 targeted execution of the checked-in lifecycle
+ case `upgrade resolves a dead-owner old Pending while the old Ready is still
+ selected`.
+- Exit: `1`.
+- Harness result:
+ `CCOD_SELFTEST_FAILED case=upgrade-resolves-a-dead-owner-old-Pending-while-the-old-Ready-is-still-selected error=ASSERT_EQUAL`.
+- Exact failure:
+ `dead-owner old Pending is reconciled before selector advance expected=[] actual=[A different product cleanup transaction remains Pending]`.
+- Meaning: the old code made the new Ready visible before trying to reconcile
+ old cleanup and could then only reject it as foreign Pending.
+
+### Bind failure leaks inner authority
+
+- Runner: Windows PowerShell 5.1 targeted execution of the checked-in lifecycle
+ case `fence bind failure aborts the real strict transaction before exact retry
+ recovery`.
+- Exit: `1`.
+- Harness result:
+ `CCOD_SELFTEST_FAILED case=fence-bind-failure-aborts-the-real-strict-transaction-before-exact-retry-recovery error=ASSERT_TRUE`.
+- Exact failure:
+ `Failed abort releases the inner real AccountTransition mutex for another process`.
+- The fixture used real strict `OpenProduct` and the real inner
+ `AccountTransition` mutex. Only the private fence-proof call was injected to
+ fail. The second Windows PowerShell process exhausted its real 15-second mutex
+ wait. Pending/no-receipt/no-product-write assertions had already passed.
+
+### Abrupt writer crash artifact
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File
+ tests\persistence\InstallFileTransaction.SelfTest.ps1`.
+- Exit: `1`.
+- Harness result:
+ `CCOD_SELFTEST_FAILED case=abrupt-writer-exit-cannot-strand-an-exact-internal-create-only-temporary-leaf error=ASSERT_EQUAL`.
+- Exact failure:
+ `process termination deletes every exact internal temporary through its native handle disposition expected=[0] actual=[1]`.
+- The child used a real state transaction and real create-only collision, wrote
+ its collision marker, remained alive, and was then terminated. Exactly one
+ `.ccod.<32-lowercase-hex>.tmp` remained.
+- After selecting narrow recovery, the final checked-in form also failed before
+ production support with stable `CCOD_PRODUCT_REGISTRATION_FAILED` and
+ `Product cleanup fence plane contains an unknown object`.
+
+An attempted ADS fixture using a .NET path API was rejected by that API and was
+not counted as RED evidence; it was replaced with the repository's existing
+Windows ADS fixture pattern. A first bind retry fixture omitted staged shortcut
+candidates and was likewise rejected as behavior evidence before the final
+green run.
+
+## Focused GREEN evidence
+
+- Old-Pending upgrade targeted runner: exit `0`; the old transaction retained
+ `1:Pending|1:Completed`, then the distinct new Ready transaction recorded its
+ own `1:Pending|1:Completed`.
+- Bind-failure targeted runner: exit `0`; the other process acquired the real
+ mutex and exited `23` on live Pending before strict authority. The later exact
+ retry succeeded with three bind attempts and three lower closes: one unbound
+ Failed abort, one fresh bound recovery Ready close, and one new verification
+ Ready close.
+- Hostile-temporary targeted runner: exit `0`; unexpected name, reparse, ADS,
+ hardlink, and live/open exact-name cases all remained present and failed
+ closed before product or shortcut writes.
+- Crash-artifact case is included in the final IFT suite and passed after the
+ real child crash left one exact orphan, narrow recovery removed it, canonical
+ Pending remained readable, and the original final record hash stayed exact.
+
+## Fresh final matrix after source freeze
+
+| Command | Exit | Result |
+|---|---:|---|
+| `tests\persistence\InstallFileTransaction.SelfTest.ps1` | 0 | 35 named cases; `Install file transaction self-test passed.` |
+| `tests\persistence\KernelObjects.SelfTest.ps1` | 0 | 16 named cases passed. |
+| `tests\persistence\ProductRegistration.SelfTest.ps1` | 0 | `Product registration self-tests passed: 13`. |
+| `tests\persistence\UninstallBootstrap.SelfTest.ps1` | 0 | `Uninstall bootstrap self-tests passed: 23`. |
+| `tests\persistence\InstallLifecycle.SelfTest.ps1` | 0 | `Install lifecycle self-tests passed: 128`. |
+| PowerShell parser over all three changed PowerShell files | 0 | `PARSER_TOTAL=3 FAIL=0`. |
+| `git diff --check` | 0 | No whitespace errors; only checkout LF-to-CRLF warnings. |
+| `tests\PersistenceSelfTest.ps1` | 0 | Fresh tracked aggregate; no failure output. |
+
+The final lifecycle run retained the required fresh-process observation:
+`CCOD_CROSS_PROCESS_OBSERVED exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`.
+
+The full focused and aggregate suites also retain the prior exact ten-field
+fence and ordered twelve-field Ready proof, create-only history, real MTA
+`SignalAndWait`, stale-N and closed-N rejection, live-owner blocking, dead-owner
+strict recovery, and the absence of post-Ready lifecycle Failed or false
+verified receipts.
+
+## No-real-action statement and remaining boundary
+
+No real registry, Start-menu/Desktop shortcut, installer, uninstaller,
+installed product, product process, network, release, push, tag, signing,
+publication, installation, restart, or reboot action was performed. Tests used
+only temporary fixtures, marker-file product adapters, test child processes,
+and private named mutex/event objects.
+
+Please perform one fresh scoped independent review of the implementation and
+evidence commits, especially the pre-upgrade recursive mutex lifetime, unbound
+Failed-abort retry state, and native orphan identity/delete sequence. A green
+matrix and aggregate are not release-readiness or installed-product evidence.
diff --git a/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-1-report.md b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-1-report.md
new file mode 100644
index 0000000..89eda37
--- /dev/null
+++ b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-1-report.md
@@ -0,0 +1,262 @@
+# Task 1 report: durable cross-process product-cleanup fence
+
+## Review status
+
+Task 1 implementation and focused evidence are recorded for independent task
+review. This report does not claim Task 1 or Task 4 complete and makes no
+release-readiness claim.
+
+## Scope and commit
+
+- Base: `25eedda46a7963e8951a44d472b902015d38697d`
+- Implementation: `271a6e57ccfcc0568151f620c5cef0e3d43e1416`
+ (`fix: fence durable product cleanup`)
+- Production/tests commit contains only:
+ - `src/persistence/modules/InstallLifecycle.psm1`
+ - `src/persistence/modules/InstallFileTransaction.psm1`
+ - `tests/persistence/InstallLifecycle.SelfTest.ps1`
+ - `tests/persistence/InstallFileTransaction.SelfTest.ps1`
+
+## RED evidence
+
+### Private lower native-close seam
+
+- Command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit: `1`
+- Exact failure:
+ `private-lower-close-seam-preserves-the-real-transaction-cleanup-retry-path`
+ reached `ASSERT_THROWS: expected CCOD_INSTALL_CLOSE_FAILED`.
+- Meaning: the old close path did not traverse the injected lower native-close
+ boundary. The later seam is module-private and leaves the real authority
+ lease-release code in effect.
+
+### Same-process durable Pending
+
+- Command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+- Exit: `1`
+- Exact failure:
+ `first lower close failure leaves exactly one durable Pending record
+ expected=[1] actual=[0]`.
+- This was the valid rerun after narrowing the seam to `ProductOnly`; an
+ earlier cleanup/setup failure was explicitly rejected as RED evidence.
+
+### Fresh-process release-before-fence window
+
+- Command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+- Exit: `1`
+- Exact observation before the fix:
+ `CCOD_CROSS_PROCESS_OBSERVED exit=0 mutex=True authority=True product=True verified=True error=`
+- Exact assertion:
+ `fresh process fails closed on the durable pending fence expected=[23]
+ actual=[0]`.
+- The child was a fresh Windows PowerShell process. It received paths and the
+ package hash only, acquired the real `AccountTransition` mutex, and read the
+ install state itself; it received no parent in-memory cleanup signal.
+
+### IFT release guard
+
+- Command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit: `1`
+- Exact failure:
+ `Ready product close rejects release before a durable cleanup fence is bound`
+ reached `ASSERT_THROWS: expected CCOD_INSTALL_CLOSE_FAILED`.
+
+### Proven-dead owner recovery
+
+- Targeted Windows PowerShell helper execution invoked the checked-in
+ `Invoke-CcodLifecycleDeadOwnerCleanupTest` fixture after loading the helper
+ prefix of `InstallLifecycle.SelfTest.ps1` in a fresh process.
+- Exit: `1`
+- Exact failure:
+ `dead owner requires a fresh strict cleanup close before the new verification
+ close expected=[2] actual=[1]`.
+- The fixture persisted a Pending record with PID `2147483647`, a canonical old
+ creation time, and the current SID under a real outer account lease. The old
+ implementation appended Completed without a fresh strict open/close.
+
+## GREEN evidence
+
+### Lifecycle focused suite
+
+- Command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+- Exit: `0`
+- Result: `Install lifecycle self-tests passed: 122`
+- Exact fresh-process observation:
+ `CCOD_CROSS_PROCESS_OBSERVED exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`
+- Covered behavior includes one-attempt lower close failure, durable Pending,
+ two same-thread retained-capability failures with no receipt, exact retry and
+ Completed append, live-owner cross-process blocking, and proven-dead owner
+ fresh strict cleanup before a new verification transaction.
+
+### Install file transaction focused suite
+
+- Command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit: `0`
+- Result: `34/34` declared named cases passed and the suite printed
+ `Install file transaction self-test passed.`
+- New cases prove an unbound Ready product close retains its same-thread lease
+ and that the private lower-close seam preserves retry behavior.
+
+### Parser and diff
+
+- Parser command: parse the two changed modules and two changed self-tests with
+ `[System.Management.Automation.Language.Parser]::ParseFile`.
+- Parser exit: `0`, output `PowerShell parser passed: 4/4`.
+- Command: `git diff --check`
+- Exit: `0`; output contained only Git LF-to-CRLF checkout warnings.
+
+## Durable ordering and recovery behavior
+
+- The state plane is
+ `state\product-cleanup-fences\.Pending|Completed..json`.
+ Records are written through an IFT state-only transaction and
+ `Write-CcodInstallRecord` create-only publication.
+- Each payload has exactly the ordered ten fields required by the brief. The
+ containing path binds the canonical install root; every read also reproves
+ the complete ordered 12-field Ready transaction and selected generation.
+- A real outer `AccountTransition` lease covers resolve, Pending publication,
+ strict product open/use, lower close, and Completed publication. IFT refuses
+ a Ready product close unless the exact Pending record is same-handle read and
+ bound first.
+- A live matching owner blocks before product authority opens. The same owner
+ on the same managed thread retries only the retained original capability.
+- A proven-dead owner is reconciled without old-process memory: under the fresh
+ outer lease, code opens a new strict product transaction, binds the existing
+ Pending record, performs real close, and appends Completed only after success.
+ Failure leaves Pending and fails closed.
+
+## No-real-action statement
+
+No real registry, Start-menu/Desktop shortcut, installer, uninstaller,
+installed product, product process, network, release, push, tag, signing,
+installation, restart, or reboot action was performed. Product effects in all
+tests were in-memory or temporary marker-file adapters. Named mutexes and
+temporary fixture state were the only real OS primitives exercised.
+
+## Compatibility and remaining review boundary
+
+- Existing `CCOD_INSTALL_CLOSE_FAILED` same-thread retry semantics remain.
+- Test-only authority used solely for mutex exclusion now closes with
+ disposition `Failed`; real product registration uses Ready only after a
+ durable Pending bind.
+- Full aggregate/release validation and Task 4 completion are outside this
+ Task 1 report and remain for the plan's later independent verification.
+
+## Fix round 1: fail-closed strict open and transaction-partitioned history
+
+### Scope and implementation commit
+
+- Review base: `ffb5dd0` (`docs: record durable product cleanup evidence`).
+- Fix implementation: `79b4825244fe53b9af8d10bf2b7e4efae347c909`
+ (`fix: partition durable cleanup history`).
+- The implementation commit contains only:
+ - `src/persistence/modules/InstallLifecycle.psm1`
+ - `tests/persistence/InstallLifecycle.SelfTest.ps1`
+- The fix keeps every record under the exact ten-field validator and canonical
+ filename validator, validates Pending/Completed sequencing independently per
+ `transactionId`, binds current records to the exact current Ready identity,
+ ignores only fully completed foreign history, and blocks every unresolved
+ foreign Pending record.
+- A new Pending entry starts with `CloseCompleted = false`. A strict open
+ failure therefore retains Pending and cannot append Completed. A later
+ same-owner/same-thread call first opens, binds, and closes fresh strict
+ authority for that Pending fence before starting a new verification attempt.
+
+### RED evidence
+
+#### Strict product open failure
+
+- Command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+- Exit: `1`.
+- Exact first failure:
+ `CCOD_SELFTEST_FAILED case=strict-product-open-failure-retains-Pending-and-later-reconciles-before-verified-success error=ASSERT_EQUAL`
+- Exact assertion:
+ `ASSERT_EQUAL: strict product open failure leaves only its durable Pending record expected=[1] actual=[2]`.
+- The test first created a production-shaped temporary Ready generation with
+ both sealed registration shortcut candidates, then used the existing
+ module-private native dispatcher boundary to fail only `OpenProduct` after
+ Pending publication. It observed no returned receipt or real product action.
+
+#### Completed older transaction poisoning a new Ready upgrade
+
+- Targeted Windows PowerShell execution loaded lines 1-681 (imports and helper
+ definitions) from the checked-in lifecycle self-test, parsed the same file
+ with the PowerShell AST, selected the exact test named
+ `completed cleanup history from an older Ready transaction permits new upgrade registration`,
+ and invoked its checked-in scriptblock. The runner was passed through
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand` using
+ UTF-16LE solely to preserve shell quoting.
+- Exit: `1`.
+- Exact valid failure:
+ `ASSERT_EQUAL: older completed cleanup history is ignored for the new Ready attempt sequence expected=[] actual=[CCOD_PRODUCT_REGISTRATION_FAILED]`.
+- The valid fixture kept project version `2.5.22` and the same computed package
+ hash, changed a sealed payload byte to create a distinct runtime and Ready
+ transaction, and started with the prior Ready transaction's complete
+ Pending/Completed history. Thus failure occurred at fence-history validation,
+ not package identity or adapter setup.
+- An earlier same-version/different-explicit-hash attempt stopped at
+ `CCOD_INSTALL_PACKAGE_CONFLICT`, and an interim `2.5.23` attempt skipped the
+ version-gated product-registration path. Neither was counted as RED evidence.
+
+#### Prior Pending preservation
+
+- The same targeted runner selected
+ `unresolved Pending from an older transaction blocks new Ready registration fail closed`.
+- Before the production fix it exited `0` and printed
+ `TARGETED_PRIOR_PENDING_PASS`: the pre-existing behavior blocked, performed
+ zero product and shortcut writes, emitted no receipt, retained the foreign
+ Pending record, and appended no lifecycle Failed snapshot. This was retained
+ as a must-stay-green regression while completed history was partitioned.
+
+### GREEN evidence
+
+- Targeted strict-open runner: exit `0`,
+ `TARGETED_STRICT_OPEN_GREEN`.
+- Targeted completed-history upgrade runner: exit `0`,
+ `TARGETED_UPGRADE_HISTORY_GREEN`.
+- Targeted prior-Pending runner: exit `0`,
+ `TARGETED_PRIOR_PENDING_GREEN`.
+- The direct unknown-field negative is included in the full lifecycle suite. It
+ temporarily clears and restores the read-only bit only on its temporary test
+ fence, adds `unknownFenceField`, and proves default registration fails closed
+ before product writes. A first attempt to overwrite the still-read-only file
+ was rejected as fixture setup failure and was not counted as behavior evidence.
+
+#### Full focused suites
+
+- Command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+- Exit: `0`.
+- Result: `Install lifecycle self-tests passed: 126`.
+- Exact cross-process observation remained:
+ `CCOD_CROSS_PROCESS_OBSERVED exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`.
+- Command:
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit: `0`.
+- Result: all `34/34` named cases printed `True`, followed by
+ `Install file transaction self-test passed.`
+
+#### Parser and diff
+
+- Parsed the two production modules and two focused self-tests with
+ `[System.Management.Automation.Language.Parser]::ParseFile`.
+- Exit: `0`; output: `PowerShell parser passed: 4/4`.
+- Command: `git diff --check`.
+- Exit: `0`; output: `git diff --check passed`, plus only Git checkout
+ LF-to-CRLF warnings.
+
+### No-real-action and remaining boundary
+
+No real registry, Start-menu/Desktop shortcut, installer, uninstaller,
+installed product, product process, network, release, push, tag, signing,
+installation, restart, or reboot action was performed. All product effects were
+temporary in-memory or marker-file adapters. This fix round is ready for scoped
+re-review; it does not claim Task 1, Task 4, aggregate validation, or release
+readiness complete.
diff --git a/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-2-report.md b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-2-report.md
new file mode 100644
index 0000000..2c8f4c8
--- /dev/null
+++ b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-2-report.md
@@ -0,0 +1,118 @@
+# Task 2 report: atomic real-mutex proof and final verification
+
+## Scope and review status
+
+Task 2 consumes Task 1's durable Pending/Completed cleanup fence without
+changing Task 1 production behavior. The only final source change is the
+atomic-proof test in `tests/persistence/InstallFileTransaction.SelfTest.ps1`.
+`KernelObjects.psm1` is unchanged. This report records evidence for fresh
+independent review; it makes no release-readiness claim.
+
+## Atomic protocol
+
+The parent opens N under the Task 1 real outer `AccountTransition` lease,
+creates the exact durable Pending fence, and binds it to the strict product
+transaction. The MTA child receives only paths and event name. Its
+`EnterMutex` adapter calls the real `Enter-CcodMutex -Kind
+AccountTransition`; its only wait override is:
+
+```powershell
+[Threading.WaitHandle]::SignalAndWait($enteredActualWaitEvent,$mutexHandle,15000,$false)
+```
+
+After the parent observes that signal it proves that N+1 has neither written
+its result nor exited, opens N's retained shortcut, closes N's strict product
+authority, and completes N's durable fence. It then proves N+1 remains
+blocked while N's outer lease is still live, releases that outer lease, and
+requires N+1 to commit generation 2. The test also asserts that the closed N
+capability cannot be reused.
+
+## Fix round 1: fresh stale-N Ready authority
+
+After N+1 commits, the test now separately invokes a **fresh**
+`Open-CcodInstallProductRegistrationTransaction` with N's exact old Ready
+record and requires `CCOD_INSTALL_PRODUCT_SCOPE`. If a transaction were
+returned, the test closes that temporary transaction with `Failed` before the
+assertion reports failure, so the negative probe cannot strand an authority
+lease. This validates latest-pointer/canonical-head authority selection; the
+existing `CCOD_INSTALL_TRANSACTION_CLOSED` assertion remains separately tied
+to reusing the already-closed N capability.
+
+### Fix-round temporary-mutation RED
+
+The temporary IFT-only mutation omitted the selected-pointer stale rejection
+and, only when the latest pointer generation was newer than the supplied Ready
+generation, skipped the matching canonical-head rejection. Same-generation
+Prepared/Failed rejection remained active. It was restored fully before
+GREEN.
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File
+ tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit: `1`
+- Exact failure: `ASSERT_THROWS: expected CCOD_INSTALL_PRODUCT_SCOPE` in
+ `product-authority-holds-the-real-commit-coordination-lease-across-proof-and-retained-file-use`.
+
+Thus the new assertion demonstrably detects a stale-N selector/head removal;
+the first failure was its own fresh-open boundary, not a pre-existing
+same-generation lifecycle-head test.
+
+## Temporary-mutation RED
+
+The only temporary mutation replaced the real `Enter-CcodMutex` call in
+`Enter-CcodLifecycleProductCleanupLease` with a shape-valid test lease having
+no mutex handle. This removed only N's real *outer* lease. It was restored
+immediately after the run; it is not present in the final diff.
+
+- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File
+ tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- Exit: `1`
+- Exact failure: `ASSERT_TRUE: N+1 commit remains blocked after N strict
+ authority closes while its durable outer AccountTransition lease is live`.
+
+This is the intended post-close blocked assertion. Earlier test shaping
+attempts were rejected because removing the inner lease failed before the
+blocked assertion during retained/fence validation; the final test opens the
+retained generation only after the first blocked proof and tests the outer
+lease only after the inner strict authority is closed. The accepted RED was
+not caused by STA, adapter loading, marker timing, or a mocked mutex.
+
+## Final GREEN and verification
+
+All commands below were fresh direct runs after production restoration.
+
+Fix-round focused command:
+`powershell.exe -NoProfile -ExecutionPolicy Bypass -File
+tests\persistence\InstallFileTransaction.SelfTest.ps1` exited `0` and printed
+`Install file transaction self-test passed.`
+
+| Command | Exit | Result |
+|---|---:|---|
+| `tests\persistence\InstallFileTransaction.SelfTest.ps1` | 0 | `Install file transaction self-test passed.` |
+| `tests\persistence\KernelObjects.SelfTest.ps1` | 0 | 16 named cases passed; real cross-process mutex, ACL, timeout, abandoned, and same-thread-release cases included. |
+| `tests\persistence\ProductRegistration.SelfTest.ps1` | 0 | `Product registration self-tests passed: 13` |
+| `tests\persistence\UninstallBootstrap.SelfTest.ps1` | 0 | `Uninstall bootstrap self-tests passed: 23` |
+| `tests\persistence\InstallLifecycle.SelfTest.ps1` | 0 | `Install lifecycle self-tests passed: 126` |
+| `git diff --check` | 0 | no whitespace errors (only Git LF-to-CRLF checkout warnings) |
+| `tests\PersistenceSelfTest.ps1` | 0 | fresh tracked direct aggregate exit; no failure output |
+
+The lifecycle run retained Task 1's fail-closed cross-process observation:
+`exit=23 mutex=True authority=False product=False verified=False
+error=CCOD_PRODUCT_REGISTRATION_FAILED`.
+
+`[System.Management.Automation.Language.Parser]::ParseFile` parsed the one
+final changed PowerShell file successfully: `PowerShell parser passed: 1/1`.
+
+## No-real-action statement
+
+No real registry, Start-menu/Desktop shortcut, installer, uninstaller,
+installed product, product process, network, release, tag, signing, push,
+publication, installation, restart, or reboot action was performed. Tests
+used temporary fixtures, marker adapters, named mutexes, and temporary event
+handles only.
+
+## Review request and remaining boundary
+
+Please perform fresh independent review of the final test diff and this
+evidence, with particular attention to the two post-signal blocked assertions
+and exact Task 1 fence ordering. A green focused suite and aggregate are not
+release or installed-product acceptance evidence.
diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/final-fix-report.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/final-fix-report.md
new file mode 100644
index 0000000..d56da5e
--- /dev/null
+++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/final-fix-report.md
@@ -0,0 +1,153 @@
+# Final fix wave report — legacy proof ordering and reclamation tail recovery
+
+## 结论与范围
+
+本计划唯一一次 final-review fix wave 已实现,implementation commit 为
+`58db6fd`(`fix: close legacy upgrade recovery gaps`),基线为 `6b9bc7e`。
+本轮只修改 5 个 production PowerShell 文件和 3 个 self-test 文件;没有执行真实
+registry、shortcut、scheduled task、安装、卸载、进程重启、系统重启、网络、push、tag、
+release 或发布操作。
+
+本报告只声明 final-fix implementation 与本地验证结果。独立 scoped re-review 仍是单独
+gate;原 release plan Tasks 5–8 仍保持 blocked,本报告不作 release-readiness 声明。
+
+## Finding 1:selector commit 成功但 PointerCommitted journal 丢失
+
+### RED
+
+新增真实 legacy migration 失败点:generation-two selector 通过真实
+`Set-CcodActiveRuntime` commit/read-back 后、`PointerCommitted` snapshot append 前抛错。
+第一次尝试留下 canonical `1 old -> 2 failed-new -> 3 old` selector,transaction 中有
+`RuntimePromoted`、无 `PointerCommitted`,terminal head 为 `Failed`。第二次同包调用的实际
+RED 为:
+
+```text
+InstallLifecycle.SelfTest.ps1 -> exit 1
+case=real-v2.5.21-selector-commit-with-a-missing-PointerCommitted-journal-resumes-the-exact-retained-runtime
+error=CCOD_INSTALL_UPGRADE_SOURCE_INVALID
+Failed migration phases do not match the exact selector profile
+```
+
+### GREEN
+
+`PostPointerCompensated` 现在可由 exact three-record selector compensation 与同一 immutable
+transaction identity / `RuntimePromoted` proof 分类,不再把 journal snapshot 当作唯一 pointer
+commit proof。普通 pre-pointer 或任意 `RuntimePromoted -> Failed` 链仍不能进入该分支;selector、
+transaction、manifest、package 或 foreign-chain 变异继续在新 mutation 前失败。重试复用同一
+generation-two runtime,并只追加 generation-four Ready。
+
+## Finding 2:current overlap write 洗掉 legacy profile 缺陷
+
+### RED
+
+production-shaped lifecycle fixture 模拟 current shortcut write 会补齐 missing overlap 并替换
+foreign same-name leaf。未修复顺序在 `MissingMain` 首个 zero-write 断言的实际结果为:
+
+```text
+InstallLifecycle.SelfTest.ps1 -> exit 1
+case=legacy-profile-is-captured-before-any-current-product-or-shortcut-write
+ASSERT_EQUAL: MissingMain is rejected before the current registry write expected=[0] actual=[1]
+```
+
+同一 table 同时覆盖 missing Desktop 与 foreign main target/arguments/working-directory。
+
+### GREEN
+
+新增 mandatory pre-registration migration plan。它在任何 current registry/shortcut write 前:
+
+- 解析一个完整 checked-in historical profile;
+- 绑定 AppId、DisplayVersion、InstallLocation 与 quoted Inno uninstall command;
+- 捕获 registry values/kinds 和每个 shortcut 的 canonical path、bytes、SHA-256;
+- 按对应历史版本校验 target、arguments 与 working directory。
+
+current registration read-back 后,cleanup 只消费此前 captured plan;overlap 只在 exact current
+proof 下保留,legacy-only entry 删除前逐项 unchanged re-read,删除后再次验证同一 current proof。
+registry compensation 继续使用 same-handle / create-only、replacement-safe 路径。missing main、
+missing Desktop 和 foreign same-name 三项均在 current writes 为 0 时失败。
+
+## Finding 3:irreversible generation reclamation 后的尾段不可恢复
+
+### RED
+
+四个 post-reclaim failure injection 均先成功使 selected generation absent,再执行第二次调用。
+未修复实现的实际结果为:
+
+```text
+PhaseWrite -> CCOD_INSTALLED_FINALIZER_INVALID, phase=TaskRemoved
+ProductShortcuts -> CCOD_INSTALLED_FINALIZER_INVALID, phase=ReadyForInno
+FinalReceipt -> CCOD_INSTALLED_FINALIZER_INVALID, phase=ReadyForInno
+FinalRegistry -> CCOD_INSTALLED_FINALIZER_INVALID, phase=Completed
+```
+
+### GREEN
+
+`PrepareInstalled` 现在把完整 staged payload record set、transaction/user、selected runtime /
+manifest / epoch、historical wrapper、resume script path/length/SHA-256 和 exact resume command
+写入 external transaction。generation reclaim 前,product `UninstallString` 先切到 staged
+`InstalledUninstallFinalizer.ps1 -Resume`,随后写完整 recovery metadata 并 exact read-back;
+old/new partial registry publish 可收敛,foreign mixed state 拒绝。
+
+finalizer 顺序为:
+
+1. preload 验证 current locator、transaction ACL/no-reparse、duplicate-free JSON 与完整 17-file
+ staged payload length/SHA;
+2. 依既有顺序取得 Local uninstall lock,再取得 Global `AccountTransition`,锁内重读 transaction;
+3. 验证 caller SID、fixed InstallRoot、derived RuntimeRoot、Ready/manifest/epoch 与 exact command;
+4. initial 或 replacement runtime wrapper 必须等待其 exact PID/creation/session/SID 退出;public
+ staged `-Resume` 不复用旧 caller identity,但在 tree 仍存在时会证明 latest bound wrapper 已退出;
+5. `TaskRemoved + present` 重新验证并 reclaim;`TaskRemoved + absent` 只有 exact write-ahead recovery
+ anchor 才可推进;随后 durable `ReadyForInno`;
+6. absent-or-exact 地清理 shortcuts,保持 Apps recovery registry;
+7. 写并重读 `Completed` transaction 和 exact `Completed` receipt;transaction 已 Completed 而
+ receipt 丢失时只补 receipt;
+8. 最后通过 exact open registry handle 删除完整 recovery key,不先逐 value 删除。此后没有必需
+ 的 fallible write。已收敛的 serial replay 在 receipt exact 且 anchor absent 时为 no-op。
+
+额外预审修复包括:existing `TaskRemoved` replacement `PrepareInstalled` 直接返回,不再重入
+application cleanup;replacement wrapper identity 单独持久化且不改写 historical identity;同 SID
+新登录 session 可恢复;epoch 在 reclaim 紧邻边界二次读取;Bootstrap 与 finalizer 均保持
+Local -> Global lock acquisition order,避免 ABBA。
+
+## 最终验证
+
+最终源码冻结后运行:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1
+ Product registration self-tests passed: 24; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+ Install lifecycle self-tests passed: 150; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1
+ Uninstall bootstrap self-tests passed: 37; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1
+ 37 declared cases passed; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1
+ 21/21; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1
+ 21/21; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ManualWrappers.SelfTest.ps1
+ pass; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1
+ 26/26; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1
+ exit 0; successful aggregate intentionally emits no stdout summary
+```
+
+此外,8 个修改的 PowerShell 文件经 parser 检查均为 `0 errors`;`git diff --check` exit 0,
+只有 checkout LF/CRLF conversion warning。测试只使用 GUID temp roots、内存 adapters、受控
+child PowerShell 与 disposable registry doubles;未触碰真实 product/shortcut/task/install state。
+
+## Review gate
+
+请求以 `6b9bc7e..58db6fd` 为 implementation review range 做一次 scoped re-review,重点核对三项
+原 Important、replacement-wrapper/AccountTransition 预审修复、完整 staged payload binding,
+以及 Apps registry-last / Completed-receipt repair 顺序。该 review 未通过前不进入下一 wave 或
+release claim。
diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/progress.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/progress.md
new file mode 100644
index 0000000..6ca9578
--- /dev/null
+++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/progress.md
@@ -0,0 +1,46 @@
+# SDD ledger — plan: docs/superpowers/plans/2026-09-02-v2522-legacy-upgrade-safe-reclamation.md
+
+## Preflight interface scan
+
+| Producer task | Consumer task | Shared interface | Finding | Ruling |
+|---|---|---|---|---|
+| Task 1 | Task 2 | True v2.5.21 active.json/no-transaction fixture | Product migration tests must consume the real legacy shape, not a current-format Ready disguised as old. | Ruling: Task 1 fixture is the source of truth for Task 2 integration. Cost if wrong: old installations pass tests but fail before staging or at registration. |
+| Task 1 | Task 1 | Durable cleanup resolver | A real legacy version cannot have a cleanup fence or Ready transaction. | Ruling: skip resolver only after proving every append-only/fence plane absent and exact legacy active runtime valid; mixed state fails closed. Cost if wrong: malformed current installs bypass cleanup. |
+| Task 2 | Task 2 | Historical shortcut profiles | Requiring the union of every historical name is impossible. | Ruling: match one exact checked-in installer profile and dynamic snapshot count. Cost if wrong: legitimate v2.5.21 upgrades never converge or foreign shortcuts are deleted. |
+| Task 3 | Task 3 | Installed finalizer reclamation | Path validation before recursive Remove-Item is not bound to deletion. | Ruling: hold exact native handles for the complete manifest tree and delete bottom-up by handle only. Cost if wrong: concurrent path changes can redirect or invalidate deletion. |
+
+## Status
+
+Task 1: in progress. Broad whole-branch review found one Critical true-legacy upgrade blocker and two independent Important production defects.
+
+Task 1 fix round 1 Ruling: support two exact append-only retry profiles. A pre-pointer failure has latest selector generation 1 on the legacy runtime and may converge by appending generation 2 to the retained new runtime. A post-pointer compensated failure has canonical `1 old -> 2 new -> 3 old` and may converge only by appending generation 4 back to the same retained new runtime. Never delete or rewrite selector history. Cost if wrong: a transient post-pointer failure either becomes unrecoverable or corrupts the monotonic selector chain.
+
+Task 1: fix round 1/5 (1 addressed, 0 open; legacy migration retry after pre/post pointer failure; commits 812b34e..4504c1a).
+
+Task 1: complete (commits 2aef5ff..4504c1a, scoped re-review clean: Spec Compliance PASS, Task Quality PASS).
+
+Task 2: in progress. Consumes Task 1 true v2.5.21 fixture and replaces the impossible union-of-history shortcut requirement with exact installer profiles.
+
+Task 2: fix round 1/5 (1 addressed, 0 open; registry compensation replacement overwrite; commits be47fad..3682b81).
+
+Task 2: complete (commits 4504c1a..3682b81, scoped re-review clean: Spec Compliance PASS, Task Quality PASS).
+
+Task 3: in progress. Replace pathname recursive generation deletion with manifest-complete, handle-pinned native reclamation.
+
+Task 3 Ruling: Windows rejects delete disposition on a nonempty directory even when every child is already pinned/armed. Use a two-phase protocol: fully pin/revalidate the exact tree; arm every file while reversible and disarm all on any file-mark failure; only after all file marks succeed enter the irreversible commit, close files, then mark/close directories deepest-first and the selected root last. Cost if wrong: a post-commit filesystem failure can leave a partially reclaimed selected tree, but all affected identities remain inside the fully pinned selected generation and no sibling/outside path can be reached.
+
+Task 1: fix round 1/5 (1 addressed, 0 open; exact pre/post pointer legacy migration retry; commits 812b34e..4504c1a).
+
+Task 1: complete (commits 2aef5ff..4504c1a, scoped re-review clean: Spec Compliance PASS, Task Quality PASS).
+
+Task 2: fix round 1/5 (1 addressed, 0 open; registry replacement-safe compensation; commits be47fad..3682b81).
+
+Task 2: complete (commits 4504c1a..3682b81, scoped re-review clean: Spec Compliance PASS, Task Quality PASS).
+
+Task 3: complete (commits 3682b81..6b9bc7e, task review clean: Spec Compliance PASS, Task Quality PASS).
+
+Final whole-plan review: FAIL (0 Critical, 3 Important): compensated selector may lack PointerCommitted journal evidence; legacy overlap proof occurs after current overwrite; irreversible reclamation has no durable tail resume. One final fix wave permitted.
+
+Final fix wave: implementation complete at `58db6fd` (3 original Important addressed in one wave). Final frozen verification: ProductRegistration 24, InstallLifecycle 150, UninstallBootstrap 37, InstallFileTransaction 37, RuntimeManifest 21, InstalledLifecycleHarness 21, Bootstrap 26, ManualWrappers pass; all modified PowerShell parsers 0 errors; `git diff --check` and full `tests\PersistenceSelfTest.ps1` exit 0.
+
+Final scoped re-review of `6b9bc7e..58db6fd`: **FAIL** (1 Critical, 1 Important). The original three findings and the staged-resume/AccountTransition fixes were addressed, but two independent cross-invocation gaps remain: a same-SID new-session replacement `PrepareInstalled` is rejected by the generic session comparison before it can publish `resumeWrapper*`; and the legacy pre-capture plan is memory-only, so a crash after a current overlap write prevents the next process from reconstructing the historical proof. Per the one-review/one-fix-wave ruling, this plan is closed as failed rather than receiving a second fix wave. Successor plan: `docs/superpowers/plans/2026-09-03-v2522-cross-process-retry-closure.md`. Tasks 5–8 and release readiness remain blocked.
diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-1-report.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-1-report.md
new file mode 100644
index 0000000..5bf1b89
--- /dev/null
+++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-1-report.md
@@ -0,0 +1,217 @@
+# Task 1 报告:v2.5.21 legacy lifecycle upgrade bridge
+
+## 结论
+
+Task 1 已在隔离 worktree
+`C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`
+实现并通过要求的 focused tests。实现提交为
+`5114a09953e2566b1127788ffbad115c11ae072f`(`fix: bridge legacy lifecycle upgrade`),
+基线为 `2aef5ff2a74219a82e98fe26c2f8170a5232a280`。
+review fix round 1 实现提交为
+`52d3962b9bc91c91350317a148a145efdc9aa0c9`(`fix: resume failed legacy migration`)。
+
+本报告不声称 Task 2/Task 3、整分支验收或 release readiness 已完成。
+
+## 历史来源与 fixture
+
+fixture 直接以 merge-base
+`19803a79af3302c49f129e6a6b3ff7ffaf1b930b` 为格式来源:
+
+- `active.json` 是严格 schema 2,字段顺序为
+ `schemaVersion,activeRuntime,previousRuntime,generation,updatedAtUtc`;
+- runtime manifest 是 schema 1,字段为
+ `schemaVersion,projectVersion,runtimeId,files`;
+- v2.5.21 runtime ID 使用旧的 `version-digest16` 两段式算法,digest 来自按
+ ordinal path 排序后的 `path/length/sha256` manifest records,不含 v2.5.22 nonce;
+- mutable activation receipt 位于 `state\post-install-activation.json`;
+- fixture 不创建 `state\active-generation`、`state\install-transactions` 或
+ `state\product-cleanup-fences`。
+
+测试没有用“projectVersion 写成 2.5.21、但 runtime ID/Ready 仍是当前格式”的伪 legacy
+fixture。另有显式负例拒绝带 v2.5.22 nonce 的 current-format v2.5.21 lookalike。
+
+## 实现
+
+新增私有分类接口:
+
+```text
+Get-CcodLegacyUpgradeCompatibilityContext
+ -InstallRoot
+ -ExistingPointer
+ -ActiveValidation
+ -GlobalTransaction
+```
+
+初始实现返回两种 `Kind`;fix round 1 增加第三种严格恢复分类:
+
+- `CurrentReady`:append-only selector 与 transaction plane 均存在;selected pointer、
+ valid current manifest、manifest SHA-256 和 canonical terminal Ready transaction 的
+ runtime/generation 完全一致。此分支仍执行原 durable product-cleanup resolver。
+- `ProvenLegacyWithoutReady`:三个新 plane 均有精确 ItemNotFound 证明;raw
+ `active.json` 是上述严格 schema-2 generation-one 形状;active runtime tree/manifest
+ 通过 contained path、non-reparse、no-ADS、single-link 和 hash/file-set 校验;current
+ validator 的唯一失败是 `CCOD_RUNTIME_ID_MISMATCH`;随后使用历史算法复算并精确得到
+ v2.5.21 两段式 runtime ID。只有此分支跳过不存在的旧 Ready cleanup resolver。
+- `LegacyMigrationRetry`:只接受一个已到 `RuntimePromoted` 的严格 v2.5.22 Failed
+ transaction、仍完整有效的 failed runtime/manifest/package、无 cleanup fence,以及下列一个
+ canonical selector profile。此分支复用 retained failed runtime,不创建另一个 runtime tree。
+
+在真实 legacy upgrade 中,同一个 opaque file transaction 先打开 manifest-bound retained
+legacy generation,并发布 generation-one selector genesis;普通新 runtime pointer 随后以
+generation two 提交。旧 `active.json` 保持 byte-for-byte 不变,只作为迁移证据。成功路径会
+写出 canonical Ready transaction;新 runtime 在 Ready 前失败时,旧 runtime 会被重新选择并
+启动。
+
+## TDD 证据
+
+### 基线
+
+- `InstallLifecycle.SelfTest.ps1`:129/129,exit 0。
+- `InstalledLifecycleHarness.SelfTest.ps1`:20/20,exit 0。
+
+### RED 1:真实旧 runtime ID
+
+命令:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+```
+
+结果:exit 1。
+
+```text
+CCOD_SELFTEST_FAILED case=real-v2.5.21-legacy-lifecycle-state-upgrades-through-the-v2.5.22-install-entrypoint error=CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN
+The active runtime manifest is invalid before upgrade
+```
+
+这比计划中预期的 null Ready resolver 更早:当前 validator 先拒绝旧两段式 runtime ID。
+实现因此只接管 exact `CCOD_RUNTIME_ID_MISMATCH`,其他 validator failure 保持拒绝。
+
+### RED 2:不可重建的 legacy generation
+
+加入 generation two、但没有 append-only history 的 legacy pointer 后,命令 exit 1:
+
+```text
+ASSERT_EQUAL: legacy generation without reconstructable append-only history creates no immutable generation expected=[1] actual=[2]
+```
+
+分类随后收紧为 generation one,并在 staging 前拒绝无法安全重建历史的输入。
+
+### RED 3:generation-one previousRuntime 不一致
+
+加入 generation one 但 `previousRuntime` 非 null 的 lookalike 后,命令 exit 1:
+
+```text
+ASSERT_EQUAL: generation-one active pointer with a previous runtime uses the expected fail-closed boundary expected=[CCOD_INSTALL_UPGRADE_SOURCE_INVALID] actual=[]
+```
+
+raw legacy pointer 随后收紧为 generation one 且 `previousRuntime = null`。
+
+### 最终 GREEN
+
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+ -> `Install lifecycle self-tests passed: 140`,exit 0。
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1`
+ -> 21/21(含 exact checksum-bound v2.5.21 -> v2.5.22 adapted Upgrade),exit 0。
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1`
+ -> 21/21,exit 0。
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+ -> 30/30,exit 0。
+- PowerShell parser:3 个修改文件均 0 errors。
+- `git diff --check`:exit 0。
+
+Lifecycle 覆盖还包括:孤立 selector、无 Ready fence plane、schema-one active、
+generation/history ambiguity、current-format lookalike、wrong manifest version、runtime junction,
+均在 staging/task mutation 前失败;以及 pre-pointer 与 post-pointer 两类 Ready 前失败均恢复
+旧 runtime。
+
+## 无真实操作证明
+
+- 所有 install roots、source roots、installer bytes 和证据目录均位于 GUID 临时目录;
+- scheduled task、Supervisor/TrayHost、product registration、shortcut 和 installed scenario 均由
+ 现有 fake/adapters 驱动;
+- 没有运行真实 Setup/installer,没有修改真实 registry/shortcut/scheduled task/device key,
+ 没有安装、重启、重启系统、网络、push、tag 或 release 操作;
+- 测试创建的 junction 只位于临时目录,使用 `.NET Directory.Delete(link)` 删除链接并验证
+ 外部临时 target 未被跟随。
+
+## 边界与后续
+
+- 本 Task 只支持有完整历史证据可安全映射为 selector genesis 的 v2.5.21
+ generation-one profile。generation greater than one 无法从单个 mutable active pointer 重建
+ 完整 append-only 历史,因此在 staging 前 fail closed;如要支持该形状,需要单独设计并提供
+ 可验证历史来源,不能伪造 selector records。
+- Task 2 的 exact historical product-registration profile 迁移尚未实现;Task 1 测试只用适配器
+ 证明 lifecycle/Ready/rollback 边界。
+- 尚待独立 reviewer 对本提交做 Task 1 scoped review;本报告不替代该门禁。
+
+## Fix round 1:Failed legacy migration 可精确重试
+
+### Review finding 与裁定
+
+第一次 true-legacy migration 在 Ready 前失败后,原分类器只接受 `CurrentReady` 或三个新 plane
+全缺失的 `ProvenLegacyWithoutReady`。因此第二次完全相同的升级会在 staging 前返回
+`CCOD_INSTALL_UPGRADE_SOURCE_INVALID`,即使失败 runtime 已经完整 sealed 且旧 runtime 已恢复。
+
+本轮接受两个、且仅两个 selector profile:
+
+- pre-pointer:latest selector 是 generation 1 legacy;重试复用原 Failed transaction 的
+ `newRuntimeId`,提交 selector/Ready generation 2;
+- post-pointer compensated:完整 selector chain 必须是
+ `1 legacy -> 2 failed-new -> 3 legacy`;重试追加 generation 4 指向同一 failed-new runtime,
+ Ready transaction 使用 generation 4。旧 selector records 不删除、不改写。
+
+### RED
+
+在现有 pre-pointer 与 post-pointer failure tests 中移除 transient failure 后立即执行第二次真实
+`Invoke-CcodInstall`。两者最初均 exit 1:
+
+```text
+CCOD_SELFTEST_FAILED case=real-v2.5.21-upgrade-failure-before-new-pointer-commit-keeps-the-legacy-selector-active-and-restarts-it error=CCOD_INSTALL_UPGRADE_SOURCE_INVALID
+CCOD_SELFTEST_FAILED case=real-v2.5.21-upgrade-failure-before-new-Ready-restarts-the-retained-legacy-runtime error=CCOD_INSTALL_UPGRADE_SOURCE_INVALID
+```
+
+自审另加入 premature-Failed RED:把 transaction chain 缩短为
+`Prepared -> PackageVerified -> Failed`,但保留 selector/runtime。旧实现错误接受并成功重试:
+
+```text
+ASSERT_EQUAL: Failed chain without RuntimePromoted evidence fails through the retry compatibility boundary expected=[CCOD_INSTALL_UPGRADE_SOURCE_INVALID] actual=[]
+```
+
+根因是原 retry 分类只绑定 Failed head、selector 和 manifest,没有证明第一次尝试已经完成
+runtime promotion 与 immutable initialization。修复要求 chain 包含 `RuntimePromoted`,并交叉要求
+pre-pointer profile 不得包含 `PointerCommitted`、post-pointer compensated profile 必须包含
+`PointerCommitted`。
+
+### 实现边界
+
+- `LegacyMigrationRetry` 重新验证 raw legacy `active.json`、v2.5.21 historical manifest/hash、
+ transaction store 中唯一 transaction ID、Failed immutable identity、current v2.5.22 failed
+ runtime manifest/hash、exact source file records、sealed package SHA-256 和 selector chain。
+- IFT ABI 从 V4 升为 V5,增加私有 migration-retry transaction capability。它只能在 `state`
+ 子树写 create-only records、打开一个 manifest-bound retained generation、并通过该 retained
+ capability append pointer;不能创建 generation、copy payload、写 manifest、retire generation
+ 或进入 product special-folder surface。公共 export surface 未扩大。
+- Failed 是 terminal,因此 retry 使用新的 transaction ID,但复用原 sealed runtime。新 Ready
+ transaction 的 generation 与新 append pointer 精确一致。
+- 负例覆盖:legacy manifest identity drift、Failed `newGeneration` drift、foreign terminal chain、
+ 缺少 `RuntimePromoted`、cleanup fence plane、unexpected selector generation、failed runtime
+ manifest bytes drift、different sealed package identity。全部在 runtime/pointer/transaction/task
+ 新 mutation 前失败。
+
+### 最终 GREEN(fix round 1)
+
+- `InstallLifecycle.SelfTest.ps1`:148/148,exit 0。
+- `InstalledLifecycleHarness.SelfTest.ps1`:21/21,exit 0。
+- `RuntimeManifest.SelfTest.ps1`:21/21,exit 0。
+- `InstallFileTransaction.SelfTest.ps1`:36/36,exit 0。
+- 四个修改 PowerShell 文件 parser:0 errors。
+- `git diff --check`:exit 0。
+
+一次候选 final run 曾返回 `CCOD_INSTALL_TRANSACTION_INVALID`:私有 opener 用强制 re-import
+创建了另一个 IFT module instance,而后续 exported commands 仍绑定旧 instance 的 capability
+table。最终实现从同一个 canonical module instance 调用私有 opener;修复后的 Lifecycle
+148/148 与 IFT 36/36 均重新运行通过。
+
+本轮仍只使用 GUID 临时目录、fake/adapters 和子进程测试;没有执行真实 installer、registry、
+shortcut、scheduled task、产品进程、网络、安装、重启、push、tag 或 release 操作。
diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-2-report.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-2-report.md
new file mode 100644
index 0000000..08818f9
--- /dev/null
+++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-2-report.md
@@ -0,0 +1,196 @@
+# Task 2:精确历史注册 profile 迁移报告
+
+## 结论
+
+Task 2 已在隔离 worktree `codex/v2522-install-runtime-reliability` 中实现并完成要求的 focused verification。
+
+- 基线:`4504c1afa665bccd3cc9f9e1ffd748c795bf936d`
+- initial implementation commit:`6be1025`(`fix: migrate exact legacy registration profiles`)
+- fix round 1 implementation commit:`69c762d`(`fix: make legacy registry compensation create-only`)
+- 未运行真实安装器,未读写真实 registry、Start menu、Desktop shortcut、scheduled task 或产品进程。
+- 未执行 push、tag、release、安装、重启或 reboot。
+- 本报告只声明 Task 2 focused scope;全计划 aggregate、installed acceptance 与 Task 3 不在本次完成声明内。
+
+## 历史来源与版本边界
+
+历史 profile 直接来自 checked-in `build/CodexControlOtherDevices.iss` 与对应 `package.json`:
+
+| Profile | 版本范围 | AppId | 精确 shortcut 集合 |
+|---|---:|---|---|
+| `CodexControlOtherDevicesInitial` | `2.1.0` | `{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}` | 4 个旧品牌 Start-menu shortcut,无 Desktop shortcut |
+| `CodexControlOtherDevicesDesktop` | `2.1.1`–`2.1.6` | 同上 | 相同 4 个 Start-menu shortcut,加 `Desktop\Codex 设备连接 (Device Connection).lnk` |
+| `CodexRemoteFix` | `2.2.0`–`2.5.21` | 同上 | 3 个 `Programs\CodexRemote-fix\...` shortcut,加 `Desktop\CodexRemote-fix.lnk` |
+
+来源核对:
+
+- tag `v2.1.0`:版本 `2.1.0`,4 个 `[Icons]` entry;无 Desktop entry。
+- commit `2d8da0f8e86dd06571f2dd6428143fab9beb5918`:版本变为 `2.1.1`,首次增加旧品牌 Desktop entry。
+- tag `v2.1.6`:保留上述 5-entry 旧品牌布局。
+- commit `fedc840a66f9019de406c5e55dabd403df3b0611` / tag `v2.2.0`:切换为 4-entry `CodexRemote-fix` 布局。
+- Task 1 的真实 merge-base `19803a79af3302c49f129e6a6b3ff7ffaf1b930b` 与 tag `v2.5.21` 均为版本 `2.5.21`、相同 AppId、相同 4-entry `CodexRemote-fix` 布局。
+
+三个 profile 都要求 registry `DisplayVersion` 位于本 profile 的闭区间内,并要求 `UninstallString` 精确等于由 canonical absolute `InstallLocation` 绑定出的 `"{installLocation}\unins000.exe"`,不接受附加参数、未加引号或外部路径。
+
+## 实现
+
+`ProductRegistration.psm1` 新增并导出:
+
+- `Get-CcodLegacyRegistrationProfiles`
+- `Resolve-CcodLegacyRegistrationProfile -LegacyRegistration`
+
+解析采用 Ordinal exact set:只允许一个完整 profile,不要求历史名称并集,也不接受重复、缺失、额外、跨 profile、大小写变化或 reparse/unsafe shortcut evidence。
+
+真实 reader 会:
+
+- 读取 legacy AppId key 的 `DisplayVersion`、`InstallLocation` 与 `UninstallString`;
+- 枚举两个已知 Start-menu group 的全部直接子项,因此 unknown/extra entry 不会被 allowlist scan 隐藏;
+- 读取两个历史 Desktop candidate;
+- 把 directory/reparse object 标记为 unsafe,使 resolver 在任何删除前 fail closed。
+
+Legacy snapshot 数量改为 `1 registry entry + selected profile shortcut count`,并再次校验 snapshot 中 registry/shortcut 名称与所选 profile 完全一致。
+
+### v2.5.21 重叠路径保护
+
+自审发现 v2.2.0–v2.5.21 的 main Start-menu 与 Desktop 路径和 v2.5.22 current shortcut 路径重叠。新注册会先安全替换这两个 leaf;legacy cleanup 不能随后把 current replacement 删除。
+
+因此 cleanup 现在要求 exact current three-record proof:current registry、Start-menu、Desktop 的 runtime/version/package/name/hash 必须一致。只有 snapshot 中 path 和 SHA-256 同时绑定该 proof 的两个重叠 entry 才从 legacy deletion set 排除。实际删除的是 legacy registry、compatibility shortcut、uninstall shortcut。删除后再次读取并逐字段验证同一个 current proof;若漂移,则按反序补偿所有已删除 legacy-only entry。
+
+旧 profile 与 current 路径不重叠,仍完整删除 `5` 或 `6` 个 snapshot entry。
+
+原有安全语义保持:
+
+- 删除失败后只反序恢复已删除或部分删除的 exact entry;
+- replacement 已占用名称时不覆盖;
+- restore 失败或 replacement mismatch 会写 create-only unresolved compensation record;
+- current proof 或 profile 不完整时执行零 legacy deletion。
+
+## TDD 证据
+
+### 干净基线
+
+- `ProductRegistration.SelfTest.ps1`:`13/13`,exit `0`。
+- `InstallLifecycle.SelfTest.ps1`:`148/148`,exit `0`。
+
+### RED 1:真实 v2.5.21 四 shortcut 集合
+
+Production 尚未修改时运行:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1
+```
+
+结果:exit `1`;首个真实 case 为 `real-v2.5.21-registration-removes-its-exact-four-shortcut-profile`,错误为 `CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID`,原因是旧实现要求 union-of-eight。
+
+随后加入 table-driven profile 与 negative matrix;旧实现仍在 exact legacy cleanup 前以同一错误失败。
+
+### GREEN 1:profile resolution 与动态 snapshot
+
+- ProductRegistration:`17/17`,exit `0`。
+
+覆盖 profile 边界以及 AppId、missing、extra、cross-profile、case-varied、reparse、version binding/gap/too-new、uninstall outside/arguments/unquoted、relative InstallLocation、same-count foreign snapshot。
+
+### RED 2:current shortcut overlap
+
+自审补充 overlap RED 后,旧 profile implementation 运行 ProductRegistration suite:exit `1`;首失败为:
+
+```text
+ASSERT_EQUAL: exact migration removes only the three non-current legacy entries expected=[3] actual=[5]
+```
+
+该 RED 证明仅按 profile snapshot 全删会删掉刚写入并验证的 current Start-menu/Desktop replacement。
+
+另先写入两个 safety RED:overlap snapshot hash mismatch 必须在零删除前失败;cleanup 后 current proof 漂移必须反序补偿三项 legacy-only 删除。
+
+### 最终 GREEN
+
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1`
+ - `Product registration self-tests passed: 19`
+ - exit `0`
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+ - `Install lifecycle self-tests passed: 148`
+ - exit `0`
+
+Lifecycle 的真实 v2.5.21 fixture 现在经过实际 ProductRegistration module,仅把 registry/shortcut 外部副作用替换为内存 adapters。断言:
+
+- 新 current registration 在 terminal Ready 后完成;
+- legacy cleanup 删除恰好 registry、compatibility、uninstall 三项并保留两个 proof-bound current shortcuts;
+- 无 unresolved compensation record;
+- 第二次同 package 调用返回 `AlreadyInstalled`,不增加 runtime/selector,不安装 task、不启动进程、不重复 legacy deletion。
+
+最终静态检查:
+
+- `src\persistence\modules\ProductRegistration.psm1` parser:`0` errors。
+- `tests\persistence\ProductRegistration.SelfTest.ps1` parser:`0` errors。
+- `tests\persistence\InstallLifecycle.SelfTest.ps1` parser:`0` errors。
+- `git diff --check`:exit `0`。
+
+## Fix round 1:registry replacement 不可被补偿覆盖
+
+### Review finding
+
+独立 review 指出原 registry 删除/补偿存在 pathname race:
+
+- `Remove-CcodLegacySnapshotEntry` 在部分 value 删除失败后关闭原 key handle;
+- catch 随后通过 `Test-Path`、`New-Item -Force`、`Get-ItemProperty` 与 `New-ItemProperty -Force` 按路径补值;
+- 若原 key 已被重命名/删除且同路径出现 replacement,captured legacy values 会被写入 replacement。
+
+同样,完整删除后的 `Restore-CcodLegacySnapshotEntry` 使用 `Test-Path` 后再 `New-Item -Force`,不能区分本次新建 key 与在竞争窗口内已存在的 replacement。
+
+### Production-shaped RED
+
+测试导入 production module 的隔离副本,调用真实 `Remove-CcodLegacyProductRegistration`、`Remove-CcodLegacySnapshotEntry`、`Read-CcodLegacySnapshotEntry` 与 `Restore-CcodLegacySnapshotEntry`;只在 module 内替换 registry/native OS boundary,没有访问真实 HKCU。
+
+部分删除场景在第一次 `DeleteValue` 后让原 key 脱离路径、在同路径放入 replacement,并在第二次删除抛错。未修复代码的实际结果:
+
+```text
+ProductRegistration.SelfTest.ps1 -> exit 1
+case=partial-registry-compensation-never-writes-captured-values-into-a-replacement-key
+lower=TEST_PARTIAL_REGISTRY_DELETE_FAILURE
+replacement before={"DisplayName":"replacement-product","ReplacementSentinel":"unchanged"}
+replacement after ={"DisplayName":"replacement-product","ReplacementSentinel":"unchanged","NoModify":1}
+ReplacementWrites=1
+```
+
+捕获 RED 后,最终 regression fixture 把 sentinel 加强为 Binary `de ad be ef`;GREEN 同时校验 replacement 的所有 values、Binary bytes 与 value kinds 均不变。
+
+另分别观察两个 create-only RED:
+
+- replacement 在 absent-check 与 restore 之间赢得竞争:exit `1`,旧 `New-Item -Force` 把 `DisplayName` 改成 captured value 并加入 `NoModify`。
+- 路径保持完全 absent:exit `1`,断言 `native create-only open expected=[1] actual=[0]`,证明旧实现仍走 provider `New-Item`。
+
+Native wrapper 编译测试还捕获过一次真实类型错误:`RegistryKey.Handle` 在目标 Windows PowerShell/.NET 中是 `SafeRegistryHandle`,不能直接作为 `IntPtr` 传入。实现改用 `DangerousGetHandle()` 后才进入 GREEN。
+
+### Fix
+
+- 打开原 legacy key 时请求 `QueryValues | SetValue | EnumerateSubKeys | Delete`,删除通过原 open handle 调用 `NtDeleteKey`,不再按路径删除 registry key。
+- mid-delete 失败只通过仍打开的原 handle 写回全部 captured values;随后从同一 handle 重读 exact names/kinds/values,并以 `NtCompareObjects` 对比原 handle 与当前路径重新打开的 handle。
+- 若原 handle 已不再绑定该路径或不能精确验证,状态变为 `PartialUnresolved`;后续禁止降级到 path-level create,replacement 保持不变并记录 `Registry` unresolved。
+- 只有状态明确为 `Deleted` 的完整删除才允许 path-level restore。
+- path-level restore 使用窄 `RegCreateKeyExW(HKEY_CURRENT_USER, ...)` wrapper 并读取 disposition。只有 `REG_CREATED_NEW_KEY` 映射为 `CreatedNew` 后才能写值;`REG_OPENED_EXISTING_KEY` 立即关闭、零写入并标记 unresolved。
+- 新建后通过同一 handle 重读 exact names/kinds/values,并以 `NtCompareObjects` 证明仍绑定目标路径后才接受。写入/验证失败时尝试通过该新建 handle 删除部分 key,绝不删除同路径 replacement。
+- shortcut replacement、profile resolution、overlap retention、current proof revalidation 与 reverse compensation 行为未改变。
+
+### Fix round 1 GREEN
+
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1`
+ - `Product registration self-tests passed: 23`
+ - exit `0`
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+ - `Install lifecycle self-tests passed: 148`
+ - exit `0`
+- 三个相关 PowerShell 文件 parser:各 `0` errors。
+- `git diff --check`:exit `0`。
+- 本机只读 symbol check:`NtCompareObjects=True`、`NtDeleteKey=True`、`RegCreateKeyExW=True`。
+- Windows PowerShell registry rights 表达式结果:`0x1000b`,包含 exact-handle delete 所需 `DELETE`。
+
+Fix-round 测试同时证明:
+
+- partial failure 把 captured values 恢复到原 open handle,但路径 identity 已变时仍 fail unresolved;`NativeCreateCalls=0`、provider `New-Item` calls=`0`。
+- `OpenedExisting` disposition 对 replacement values、Binary bytes 与 kinds 均执行零修改,并只记录 `Registry` unresolved。
+- `CreatedNew` disposition 写回完整 captured values/kinds,same-handle 重读通过后才被视为成功补偿。
+
+## 审查边界与后续
+
+- Fix round 1 focused self-review 未发现新的 Task 2 blocker。
+- Fix round 1 scoped re-review 仍由 controller 执行;本报告不把自审当成独立 review。
+- 全 persistence aggregate、installed scenario、Setup/Defender、真实升级与发布仍在后续总体验收 gate 之后。
diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-3-report.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-3-report.md
new file mode 100644
index 0000000..becdbc8
--- /dev/null
+++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-3-report.md
@@ -0,0 +1,144 @@
+# Task 3:manifest-bound native generation reclamation 报告
+
+## 结论与范围
+
+Task 3 已在隔离 worktree `codex/v2522-install-runtime-reliability` 中实现,并完成要求的 focused、shared 与 aggregate 验证。
+
+- 基线:`3682b810138ea43127762c92a860d45d96ca3d3d`
+- implementation commit:`1d57736197df0bfcd1c0e54d96be63dfa54602df`
+- 新接口:`Remove-CcodVerifiedGenerationTree -InstallRoot -RuntimeRoot -RuntimeId -ExpectedManifestSha256`
+- 未运行真实安装器,未读写真实安装目录、registry、Start menu、Desktop shortcut、scheduled task、产品进程或真实 DPAPI/device-key store。
+- 未执行 push、tag、release、安装、重启或 reboot。
+- 本报告只声明 Task 3 implementation/verification scope;controller 的独立 scoped review 与最终 whole-plan review 仍是单独 gate。
+
+## 根因与生产改动
+
+原 `InstalledUninstallFinalizer.ps1` 先用路径验证 selected generation,随后重新把权限降级为 pathname,并执行:
+
+```powershell
+Remove-Item -LiteralPath $expected -Recurse -Force
+```
+
+验证与物理删除之间没有持有任何对象身份。树在验证后增加未知对象、发生 identity replacement,或只有一个 child 被其他 handle 打开时,旧实现会整树删除或先部分删除再失败。
+
+新 `GenerationReclamation.psm1` 只导出 `Remove-CcodVerifiedGenerationTree`。其 CLR assembly 只公开 inert `CcodGenerationReclamationMarkerV1`;实际 native runtime 为 internal type。
+
+生产路径现在:
+
+1. 严格校验 canonical `RuntimeRoot == \runtime\` 与 64-hex manifest binding。
+2. 绝对打开并验证 install root,然后只通过 parent native handle 相对打开 `runtime`、selected generation 和每个 descendant;所有 selected-tree handle 请求 `DELETE`、read-attributes、list/read-data 与 synchronize 权限,share 只允许 read,不允许 write/delete sharing,并始终使用 open-reparse-point 语义。
+3. 每个 held object 保存 final path、volume serial、file ID、type、parent/name;拒绝 reparse、ADS/non-default stream,regular file 还要求 single link。
+4. 从 pinned manifest handle 读取严格 UTF-8 JSON;拒绝任意层 duplicate property、非精确字段、非 canonical record、乱序/重复 path、错误 runtimeId digest/nonce binding。
+5. exact file set 明确为 `manifest.json + manifest.files`;exact directory set为这些 manifest files 的全部 parent directories。额外空目录也被拒绝。
+6. 全树 second enumeration、final path、volume/file ID、stream/link/length/hash revalidation 通过后才进入 disposition。
+7. `UninstallBootstrap.ps1` 的 durable payload allowlist 增加该 module;default installed finalizer 只调用该 API,生产文件不再包含 `Remove-Item`、`Copy-Item` 或 `Move-Item` 路径删除/替换。
+
+成功结果是显式对象:
+
+```text
+phase=Completed
+result=Reclaimed
+runtimeId=
+fileCount=
+directoryCount=
+```
+
+只有该 proof 返回且 selected root absence 再次成立,transaction 才从 `TaskRemoved` 推进到 `ReadyForInno`;之后才允许 product removal 和 completion receipt。
+
+## Windows disposition ruling
+
+临时 NTFS 树的 native probe 得到:
+
+- classic `FileDispositionInfo` 对 readonly immutable file 返回 Win32 `5`;
+- `FileDispositionInfoEx(Delete | IgnoreReadonly = 0x11)` 可 arm,也可用 flags `0` disarm;
+- 当 child 仅被标记、handle 仍打开且名称仍存在时,对 parent directory 标记删除返回 Win32 `145` (`Directory not empty`);加入 POSIX flag 仍为 `145`,本机 ON_CLOSE 组合返回 `50`。
+
+因此“files + all parent directories 全部 arm、所有 handle 始终保持打开、最后统一 close”在目标 Windows 语义上不可实现。controller 接受以下必要细化:
+
+- `PinnedValidated`:完整 pin、manifest exact-set 与 second validation;零 disposition。
+- `FilesArmed`:先 arm 全部 files,仍为可逆屏障。任一 file-arm 失败时,对已 arm handles 反向 disarm,并重新验证完整树;成功恢复后报 `CCOD_GENERATION_RECLAMATION_INVALID`。若恢复本身失败,使用独立 `CCOD_GENERATION_RECLAMATION_ROLLBACK_FAILED`。
+- `CommitStarted`:只有全部 file arm 成功后才进入不可逆提交;close files,随后 deepest directories 逐个 mark+close,selected root last。
+- commit 阶段的 directory mark/close/absence failure 使用稳定 `CCOD_GENERATION_RECLAMATION_COMMIT_FAILED`。此时 selected generation 可能只剩部分失败边界,但 install root、siblings、state/device key 与 outside objects 不受影响;finalizer 不执行 absence proof、product removal 或 completion receipt,也不写假成功。
+
+## TDD 证据
+
+### 干净基线
+
+- `UninstallBootstrap.SelfTest.ps1`:`23/23`,exit `0`。
+- `InstallFileTransaction.SelfTest.ps1`:baseline suite pass,exit `0`。
+
+### 有效 RED:当前 pathname deletion
+
+首个 production-path RED 先执行真实 `ValidateSelectedGeneration`,验证成功后插入 `unexpected-after-validation.bin`,再走默认 installed finalizer:
+
+```text
+exit=1
+case=installed-finalizer-rejects-an-unexpected-post-validation-child-without-deleting-any-generation-state
+error=CCOD_RECLAMATION_RED_PATHNAME_DELETE
+recursive pathname deletion accepted and removed the changed selected tree
+```
+
+随后以 `CCOD_RECLAMATION_RED_CASE` 分别运行以下六项;旧生产实现全部 exit `1` 并命中 `CCOD_RECLAMATION_RED_PATHNAME_DELETE`:
+
+- `UnexpectedFile`
+- `UnexpectedDirectory`(额外空目录)
+- `Reparse`
+- `Hardlink`
+- `Ads`
+- `OpenChild`
+
+前五项证明旧实现接受变化后整树删除;`OpenChild` 证明旧递归路径删除可先删除其他 manifest members,再在 open child 处失败。
+
+### 有效 RED:新 native contract
+
+- `IdentityChange`:exit `1` / `CCOD_RECLAMATION_RED_MODULE_MISSING`;wished-for pinned boundary 尚不存在。
+- `MarkFailure`:exit `1` / `CCOD_RECLAMATION_RED_MODULE_MISSING`;wished-for arm/disarm retry boundary 尚不存在。
+- IFT production-shape case:exit `1` / `CCOD_RECLAMATION_RED_MODULE_MISSING`;真实 immutable transaction 创建并 Ready-close 的 readonly generation 尚无 reclamation API。
+- commit-failure injection:exit `1` / `ASSERT_THROWS expected CCOD_GENERATION_RECLAMATION_COMMIT_FAILED`;seam 尚未被 commit phase 消费。
+
+测试构造期间出现过并已排除的非有效 RED:并行启动 suite 争用同一 user mutex、loop closure metadata error,以及长路径上的 PowerShell ADS cmdlet fixture error。它们未被计作功能证据;对应用例改为串行、同步 case capture 和短 `manifest.json` ADS target 后重新得到上述有效 RED。
+
+### 最终 GREEN
+
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1`
+ - `Uninstall bootstrap self-tests passed: 32`
+ - exit `0`
+ - 包含 six hostile mutations、identity exchange blocked、file-arm disarm/full-tree retry、CommitStarted stable failure,以及 default staged finalizer negative matrix。
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1`
+ - `37` 个 declared cases 全部 pass
+ - exit `0`
+ - 新 case 使用真实 IFT 生成 nested readonly generation;结果只删除 selected generation,install root/outside sentinel 不变。
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1`
+ - `Install lifecycle self-tests passed: 148`
+ - exit `0`
+ - 既有 unapproved-verb warnings 与负向 `CCOD_CROSS_PROCESS_OBSERVED ... CCOD_PRODUCT_REGISTRATION_FAILED` 不改变最终 pass。
+- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1`
+ - exit `0`
+ - aggregate 成功时无 stdout 汇总;只读进程观察确认其依次推进 Lifecycle、ProductRegistration、StateStore、TrayUi 等 child suites 后正常退出。
+
+### Parser、surface 与 diff
+
+- 以下 5 个文件 PowerShell parser 各 `0` errors:
+ - `src\persistence\modules\GenerationReclamation.psm1`
+ - `src\persistence\InstalledUninstallFinalizer.ps1`
+ - `src\persistence\UninstallBootstrap.ps1`
+ - `tests\persistence\UninstallBootstrap.SelfTest.ps1`
+ - `tests\persistence\InstallFileTransaction.SelfTest.ps1`
+- module exports:仅 `Remove-CcodVerifiedGenerationTree`。
+- marker ABI:`1`;assembly exported CLR types:仅 `CcodGenerationReclamationMarkerV1`。
+- production static scan:`InstalledUninstallFinalizer.ps1` 与 `GenerationReclamation.psm1` 中 `Remove-Item|Copy-Item|Move-Item` 零命中。
+- `git diff --check`:exit `0`;只有 checkout line-ending conversion warning,无 whitespace error。
+
+## 自审与审查边界
+
+自审逐项确认:
+
+- 所有 selected-tree 对象从已持有 parent handle 相对打开;reparse 不会被 follow。
+- 每个 regular file 在任何 disposition 前已绑定 path、volume/file ID、default stream、single link、length 与 SHA-256。
+- manifest 本身属于 exact file set,不由 manifest 自引用;其 hash 绑定 durable transaction。
+- unexpected file、unexpected empty directory、reparse、hardlink、ADS、open child、root identity exchange 均在零删除前失败。
+- file-arm failure 可逆恢复后,完整同一 tree 可由第二次调用成功 reclaim。
+- success 只删除 selected generation;sibling generation、install root、state 和 device-key sentinel 均保留。
+- CommitStarted failure 不声称 tree rollback;它明确 fail closed,不进入 product/final receipt。
+
+独立 scoped review 与 fresh whole-plan review 由 controller 使用 implementation commit `1d57736197df0bfcd1c0e54d96be63dfa54602df` 执行;本报告不把 implementer 自审当成独立 review。Tasks 5–8 的原 release plan 仍保持 blocked,直到本计划完成独立 review 和最终总 gate。
diff --git a/.superpowers/sdd/2026-09-02-v2522-rollback-lease-ordering/task-1-report.md b/.superpowers/sdd/2026-09-02-v2522-rollback-lease-ordering/task-1-report.md
new file mode 100644
index 0000000..7450204
--- /dev/null
+++ b/.superpowers/sdd/2026-09-02-v2522-rollback-lease-ordering/task-1-report.md
@@ -0,0 +1,177 @@
+# Task 1 report: release rollback cleanup lease before bootstrap
+
+## Scope and commit
+
+- Plan base: `3ec2a3d13f67995018b104840d121a6fadd2dcbd`.
+- Implementation: `a178f01a69a622ac9bd20091d6fdb326132dd9b3`
+ (`fix: release rollback cleanup lease`).
+- The implementation commit contains only:
+ - `src/persistence/modules/InstallLifecycle.psm1`
+ - `tests/persistence/InstallLifecycle.SelfTest.ps1`
+- Evidence is recorded in this report and committed separately after the
+ implementation source freeze.
+
+This is the minimal follow-up for the rollback lease-ordering residual. It does
+not claim release readiness, installed-product acceptance, or whole-branch
+review completion.
+
+## Root cause
+
+An upgrade acquires `upgradeProductCleanupLease` as the outer real
+`AccountTransition` mutex acquisition before resolving old durable product
+cleanup. On a failure before selector commit, rollback released lifecycle
+ownership and the inner install lease, but then called `StartSupervisorTask`
+while the outer acquisition was still live. The scheduled-task bootstrap needs
+the same real account mutex. It therefore timed out before it could restore and
+prove the retained old runtime Ready. The recoverable original activation
+failure was incorrectly escalated to `CCOD_INSTALL_ROLLBACK_FAILED`.
+
+## Test-first cross-process proof
+
+The new regression creates a temporary old Ready installation, mutates the
+source payload to begin a real upgrade, and injects failure in the
+`SetActiveRuntime` adapter before the production pointer write. It then proves:
+
+- the real selector still names the old runtime at generation 1;
+- exact old Supervisor exit occurred before rollback;
+- rollback invoked the fake scheduled-task start boundary;
+- that boundary launched a hidden, bounded independent `powershell.exe` child;
+- the child obtained its SID from `WindowsIdentity.GetCurrent()` and imported
+ the production `KernelObjects.psm1`;
+- the child called production `Enter-CcodMutex -Kind AccountTransition` with a
+ 1500 ms mutex timeout;
+- `attempted.txt` was written immediately before that real acquisition, while
+ `acquired.txt` was written only after the returned lease had exact
+ `Outcome='Acquired'`;
+- the parent process wait was independently bounded at 10000 ms;
+- rollback readiness could become `Ready` only for the retained old runtime,
+ generation 1, after the real acquired marker existed.
+
+No fake mutex or pre-call acquisition marker is used. The process helper is
+test-only and does not create or start a Windows Scheduled Task.
+
+## RED evidence
+
+### Clean baseline before the test
+
+Command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+```
+
+- Exit: `0`.
+- Result: `Install lifecycle self-tests passed: 128`.
+
+### Test-only change against the old production module
+
+The same complete command was run after adding only the cross-process helper
+and rollback regression, before modifying production code.
+
+- Exit: `1`.
+- Harness result:
+
+```text
+CCOD_SELFTEST_FAILED case=pre-pointer-rollback-releases-the-outer-cleanup-lease-before-old-Supervisor-bootstrap error=ASSERT_TRUE
+```
+
+- Exact failing assertion:
+
+```text
+ASSERT_TRUE: old bootstrap acquires AccountTransition before readiness (childExit=42; outcome=TimedOut; childFailure=)
+```
+
+The preceding pointer, previous-protection-stopped, and bootstrap-attempted
+assertions passed. The child completed normally enough to return the production
+mutex outcome `TimedOut`; there was no adapter, parser, process-launch, or child
+setup failure. This is the intended behavioral RED.
+
+## Implementation and exact ordering
+
+The pre-selector rollback branch now performs the following order:
+
+1. Close the shutdown gate if it is still live.
+2. Release live lifecycle ownership and require exact release proof.
+3. Release the acquired install lease and require a literal Boolean `true`.
+4. Release `upgradeProductCleanupLease` with
+ `Exit-CcodLifecycleProductCleanupLease`.
+5. Require a literal Boolean `true` and
+ `upgradeProductCleanupLease.Lease.Released=true`.
+6. Only after that proof, set `upgradeProductCleanupLease=$null`.
+7. Read the rollback clock, invoke `StartSupervisorTask`, and wait for the old
+ runtime readiness proof.
+
+This is strict LIFO for the nested lifecycle, install, and outer product
+cleanup acquisitions. If outer release throws or its exact result is not
+proven, control leaves the rollback body before task start; the existing
+rollback boundary normalizes the outcome to stable
+`CCOD_INSTALL_ROLLBACK_FAILED`.
+
+The successful forward-install path is unchanged. It still retains the outer
+product-cleanup lease across old-Pending resolution and the new selector
+commit, then releases lifecycle ownership, the install lease, and the outer
+lease before starting the new runtime. The fix therefore does not reopen the
+old-Pending/new-selector race.
+
+The enclosing `finally` now retries outer cleanup only when the context, its
+lease, and a still-unreleased lease are all present. A successful rollback
+release nulls the caller variable, and an already-released non-null context is
+also excluded, so final cleanup cannot release the mutex twice.
+
+## GREEN evidence
+
+Command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+```
+
+- Exit: `0`.
+- Result: `Install lifecycle self-tests passed: 129`.
+- The new case proved `PointerCommitted=false`,
+ `PreviousProtectionStopped=true`, `BootstrapAttempted=true`,
+ `BootstrapMutexAcquired=true`, and `OldRuntimeOutcome='Ready'`.
+- After successful rollback, the original injected pre-pointer failure remained
+ `CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN`; it was not replaced by a false
+ rollback-fatal code.
+- The run retained the existing fresh-process observation:
+ `CCOD_CROSS_PROCESS_OBSERVED exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`.
+
+## Fresh final matrix after source freeze
+
+| Command | Exit | Result |
+|---|---:|---|
+| `tests\persistence\InstallLifecycle.SelfTest.ps1` | 0 | `Install lifecycle self-tests passed: 129`. |
+| `tests\persistence\KernelObjects.SelfTest.ps1` | 0 | 16 named cases, all `True`. |
+| `tests\persistence\InstallFileTransaction.SelfTest.ps1` | 0 | `Install file transaction self-test passed.` |
+| PowerShell parser over both changed PowerShell files | 0 | `PARSE_OK`; token counts 31424 and 40901. |
+| `git diff --check` | 0 | No whitespace errors; only checkout LF-to-CRLF warnings. |
+| `tests\PersistenceSelfTest.ps1` | 0 | Fresh tracked aggregate over 39 discovered `*.SelfTest.ps1` files; no failure output. |
+
+The aggregate intentionally captures child self-test output and emits only a
+stable failure line when a child exits nonzero, so empty aggregate stdout with
+exit `0` is its normal passing result.
+
+The focused and aggregate runs retain the prior real cross-process product
+fence, bind-failure abort, abrupt-writer crash temporary recovery, atomic
+`SignalAndWait`, stale-N and closed-N rejection, exact create-only histories,
+and wrong-thread/double-cleanup mutex tests. No test was removed, skipped, or
+target-filtered for the final matrix.
+
+## No-real-action statement
+
+No real scheduled task, installed product, registry, Start-menu/Desktop
+shortcut, installer, uninstaller, Codex/product process, network, push, tag,
+release, signing, publication, installation, restart, or reboot action was
+performed. Tests used temporary fixture directories, fake task/product
+adapters, hidden bounded test child processes, and private named mutex/event
+objects only.
+
+## Remaining review boundary
+
+Request a fresh task and whole-branch independent review of the implementation
+and evidence commits. In particular, re-check the pre-selector rollback order,
+the unchanged post-selector success protection, release-failure normalization,
+and final live-lease guard. The green focused matrix and aggregate are not
+installed Windows acceptance, Defender evidence, reboot acceptance, or release
+proof.
diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/implementation-brief.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/implementation-brief.md
new file mode 100644
index 0000000..49c07c9
--- /dev/null
+++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/implementation-brief.md
@@ -0,0 +1,10 @@
+# Implementation brief — cross-process retry closure
+
+Read the successor plan, the prior `final-fix-report.md`, and the final scoped review result supplied by the parent agent.
+
+Implement exactly two tasks in order with RED -> GREEN:
+
+1. Same-SID/new-session `TaskRemoved` replacement wrapper recovery before the staged Apps anchor exists. The generic session matcher must remain strict elsewhere. Revalidate under Local uninstall -> Global AccountTransition, persist only `resumeWrapper*`, short-circuit cleanup, and prove `-WrapperResume` waits for the exact new wrapper.
+2. A create-only Ready-bound durable legacy migration plan written before current product writes. A second process must recover after each overlap/current-readback failure without accepting a washed live profile when no exact prior plan exists.
+
+Do not perform any real system or external action. Do not run tests concurrently with another implementation agent. After each task, run its focused suites. After both tasks, freeze source and run the complete final gate from the plan. Commit implementation and evidence separately; report exact SHAs, commands, counts, exits, parser/diff/aggregate results, and a clean status. Do not claim release readiness.
diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/progress.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/progress.md
new file mode 100644
index 0000000..c1db1a3
--- /dev/null
+++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/progress.md
@@ -0,0 +1,39 @@
+# SDD ledger — cross-process retry closure
+
+Plan: `docs/superpowers/plans/2026-09-03-v2522-cross-process-retry-closure.md`
+
+## Review provenance
+
+- Prior implementation: `58db6fd`; evidence: `ea86451`.
+- Prior plan's only final scoped review: FAIL, 1 Critical + 1 Important.
+- Critical: generic context matching rejects same-SID new-session `TaskRemoved` before replacement-wrapper persistence.
+- Important: legacy pre-capture plan is memory-only and cannot survive a partial current overlap write.
+
+## Rulings
+
+- Session relaxation is allowed only for exact installed `TaskRemoved`/`Failed(resumePhase=TaskRemoved)` after locked fresh proof. Cost if wrong: a foreign or stale runtime can adopt an uninstall transaction.
+- A live current/historical hybrid is never authority by itself. It is acceptable only when a previously create-only durable plan proves the original complete historical layout and the current leaf matches the same Ready/package. Cost if wrong: the original wash-before-proof defect returns.
+- The durable plan is append-only audit state; it is not deleted on success and never contains device-key material.
+
+## Status
+
+Task 1: complete. Initial implementation/evidence `9973bfd..bf4d215`; fix round 1 implementation/evidence `bf939c6..6107151`. Fix-round scoped re-review: Spec Compliance PASS, Task Quality PASS, 0 Critical, 0 Important. Final focused evidence: UninstallBootstrap 45/45, ManualWrappers pass, parser 2/2, `git diff --check` exit 0. No real system action.
+
+Task 2: initial implementation `148f6ee`, evidence `66a91aa`; scoped review FAIL (0 Critical, 3 Important).
+Fix round 1 implementation `a0c8d71`; evidence is recorded by the commit containing the corrected
+`task-2-fix1-brief.md`, updated `task-2-report.md`, and this ledger. Fix round 1 closes crash-safe
+fixed-directory ACL normalization/registered-handle cleanup, exact six-field profile and raw-path schema,
+independent `powershell.exe` replay, and published-write/read-back failure convergence. Final focused evidence:
+InstallFileTransaction 44 groups, ProductRegistration 30/30, InstallLifecycle 151/151, parser 4/4, and
+`git diff --check` exit 0. No real system action.
+
+Fix round 1 frozen gate: complete, including UninstallBootstrap 45/45, RuntimeManifest 21 groups,
+InstalledLifecycleHarness 21 groups, Bootstrap 26/26, ManualWrappers 12 groups, and
+`tests\PersistenceSelfTest.ps1` exit 0. Fix-round scoped re-review: Spec Compliance PASS, Task Quality PASS,
+0 Critical, 0 Important. Task 2 is complete.
+
+Successor whole-plan final review: FAIL (0 Critical, 1 Important). All Task 1/2 behavioral and interaction
+boundaries passed, but the Task 2 fix modified embedded CLR RuntimeV6 without advancing its marker/type ABI;
+a process that preloaded the pre-fix V6 can silently reuse it after force re-import. Per the fixed review scope,
+this successor plan is closed as failed rather than receiving another whole-plan fix wave. Successor micro-plan:
+`docs/superpowers/plans/2026-09-03-v2522-runtime-v7-closure.md`. Tasks 5–8 remain blocked.
diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-fix1-brief.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-fix1-brief.md
new file mode 100644
index 0000000..0c56b2e
--- /dev/null
+++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-fix1-brief.md
@@ -0,0 +1,12 @@
+# Task 1 fix round 1 — failed TaskRemoved and durable freshness
+
+Read the Task 1 report and scoped review. Fix all four findings together; do not start Task 2.
+
+## Required RED -> GREEN
+
+1. Treat exact `Failed(resumePhase=TaskRemoved)` with an installed binding as the same narrowly authorized replacement-wrapper recovery profile as `TaskRemoved`. Under the existing Local uninstall -> Global AccountTransition order, revalidate fresh context and every immutable field, normalize back to TaskRemoved, persist the new wrapper binding, and return without `RunCleanup`. Same-session and new-session tests must prove zero application removal. All other Failed resume phases remain strict.
+2. Give each replacement-wrapper persistence a fresh canonical `updatedAtUtc` strictly later than the prior transaction, clear the error, and make the TaskRemoved receipt bind that timestamp. Reject an old receipt, null/empty error-code ambiguity, write no-op, write failure, stale read-back, or malformed read-back. A retry after an interrupted replacement write must converge without changing historical evidence.
+3. Require historical `wrapperUserSid == transaction.userSid` and `wrapperSessionId == transaction.sessionId` before any replacement write. Add zero-write negatives for both mutations.
+4. Add a production-shaped/disk-backed test that obtains selector/Ready/manifest/epoch/payload evidence through the real verified-runtime path, changes only the current-session seam, and uses real atomic transaction/receipt storage with independent deserialized objects. Adapter-only tests may remain for fault injection but cannot be the sole proof.
+
+Run UninstallBootstrap, ManualWrappers, changed parsers and `git diff --check`. Commit implementation and evidence separately, update the ledger/report, leave a clean tree, and request a scoped re-review. No real system/external actions and no release claim.
diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-report.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-report.md
new file mode 100644
index 0000000..23f3bd4
--- /dev/null
+++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-report.md
@@ -0,0 +1,167 @@
+# Task 1 report — same-SID new-session TaskRemoved recovery
+
+## 结论与范围
+
+Task 1 已按 successor plan 完成,implementation commit 为 `9973bfd`
+(`fix: recover installed TaskRemoved across sessions`),基线为 `a5ffe4a`。
+本任务只修改:
+
+- `src/persistence/UninstallBootstrap.ps1`
+- `tests/persistence/UninstallBootstrap.SelfTest.ps1`
+
+Task 2 未开始、未修改。本任务没有执行真实 registry、shortcut、scheduled task、安装、卸载、
+进程重启、系统重启、网络、push、tag、release 或发布操作;device-key/DPAPI state 未触碰。
+
+## 根因与 RED
+
+旧实现对所有 existing transaction 先调用 generic
+`Assert-CcodUninstallBootstrapTransactionMatchesContext`。该 matcher 正确要求 session ID 相同,
+但也因此在一个精确、已到 `TaskRemoved` 的 installed transaction 上,拒绝同 SID 的新登录
+session,尚未进入 replacement-wrapper 持久化分支。
+
+production-shaped RED 使用:durable transaction session `1`、fresh verified context session `99`、
+相同 SID、exact `TaskRemoved`、完整 installed binding、尚无 Apps recovery anchor,以及 exact 新
+wrapper identity。production 未修改时实际结果:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1
+exit 1
+case=same-SID-new-session-TaskRemoved-replacement-persists-a-fresh-wrapper-without-rerunning-cleanup
+error=CCOD_UNINSTALL_TRANSACTION_MISMATCH
+The existing uninstall transaction does not match the current verified runtime
+```
+
+栈命中真实 generic context matcher,不是 adapter/setup 失败。
+
+## 实现
+
+generic matcher 与 `New-CcodUninstallBootstrapResumeContext` 继续严格匹配 SID + session;只有
+`PrepareInstalled` 的 exact `TaskRemoved` installed transaction 进入新专用 matcher。
+
+专用路径在既有 Local uninstall -> Global `AccountTransition` 顺序取得两把锁后:
+
+1. 再次执行真实 `ValidateInvocation`,获得 fresh runtime/selector/Ready/manifest/epoch proof;
+2. 要求 transaction ID/current locator、SID、runtime ID、generation、epoch、完整 Ready evidence、
+ payload records、selected runtime path、staged resume path/hash 与 immutable installed binding
+ 全部一致;唯一允许不同的是 fresh context session;
+3. 保留 `transaction.sessionId` 与 historical `wrapper*`,只写 exact `resumeWrapperPid`、
+ `resumeWrapperCreationTimeUtc`、`resumeWrapperSessionId`、`resumeWrapperUserSid`;
+4. 原子写 transaction 后锁内重读完整 transaction,再写并重读 exact TaskRemoved receipt;
+5. 直接返回 verified existing TaskRemoved,绝不再次调用 `RunCleanup`。
+
+若该专用 transaction/receipt 持久化或 read-back 失败,generic catch 不把 historical TaskRemoved
+改写为 Failed。普通 pre-TaskRemoved、不同 SID 或 runtime/generation/epoch/Ready/payload mismatch
+仍走 strict failure boundary。
+
+public runtime wrapper 随后使用已存在的 `-WrapperResume` 参数集;finalizer 必须匹配并等待新持久化
+的 PID/creation/session/SID exact identity,之后才允许 generation reclamation。测试还覆盖第一次
+external finalizer start 失败、没有 anchor,第二次同 SID session 99 public invocation 成功持久化
+replacement wrapper 并完成 exact wait。
+
+## TDD 与负例
+
+最终 self-test 新增/强化:
+
+- same-SID session 1 -> 99 exact TaskRemoved GREEN;fresh validation 调用两次,第二次位于
+ AccountTransition acquisition 之后;
+- existing TaskRemoved `RunCleanup` 调用数保持 `0`;historical session/PID 不变;
+- DifferentSid、Runtime、Generation、Epoch、Ready、Payload、PreTaskRemoved 七项全部零 transaction /
+ receipt write、零 cleanup;
+- WrapperResume 的 PID、creation time、session、SID 四项 mutation 在 wait/reclaim 前拒绝;
+- finalizer-start failure 后的新 session replacement path 等待的确切 identity 为 PID `43`、
+ creation `2030-02-03T03:04:06.0000000Z`、session `99`。
+
+## 最终验证
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1
+ Uninstall bootstrap self-tests passed: 40
+ exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ManualWrappers.SelfTest.ps1
+ Manual wrapper self-tests passed
+ exit 0
+```
+
+`src/persistence/UninstallBootstrap.ps1` 与
+`tests/persistence/UninstallBootstrap.SelfTest.ps1` 的 PowerShell parser 均为 `0 errors`;
+`git diff --check` exit 0,仅有 checkout LF/CRLF conversion warning。
+
+## 边界
+
+本报告不声明 Task 2、successor plan final gate、fresh scoped review 或 release readiness 完成。
+Tasks 5–8 继续 blocked。
+
+## Fix round 1 — Failed/TaskRemoved 与 durable freshness
+
+### Review findings 与 RED
+
+scoped review 对 `9973bfd` 判定 FAIL(1 Critical、3 Important)。本轮在 production 未修改时
+得到四组有效 RED:
+
+```text
+Disk-backed real runtime:
+ ASSERT_TRUE: replacement persistence advances updatedAtUtc strictly
+
+Failed(resumePhase=TaskRemoved):
+ same session actual=CCOD_UNINSTALL_PREPARE_FAILED cleanup=1
+ new session actual=CCOD_UNINSTALL_TRANSACTION_MISMATCH cleanup=0
+
+Historical wrapper binding:
+ HistoricalSid / HistoricalSession did not throw CCOD_UNINSTALL_TRANSACTION_INVALID
+
+Freshness:
+ stale prior TaskRemoved receipt did not throw CCOD_UNINSTALL_TRANSACTION_WRITE_FAILED
+```
+
+其中 disk-backed RED 使用真实 v2.5.22 runtime manifest、七代 append-only selector、canonical
+`Prepared -> ... -> Ready` install transaction、真实 lifecycle epoch 与完整 payload records;external
+transaction/receipt 通过 production atomic writer 写入受保护临时目录,并由 production reader 形成
+独立反序列化对象。唯一替换的是 current-session seam 和禁止误入 cleanup 的 adapter。
+
+### Fix
+
+实现提交:`bf939c6`(`fix: harden TaskRemoved replacement recovery`)。
+
+- 专用 candidate 同时接受 exact `TaskRemoved` 与 exact
+ `Failed(resumePhase=TaskRemoved)`;后者在持久化前规范化回 TaskRemoved,清空 errorCode,
+ same/new session 均不进入 `RunCleanup`。
+- 每次 replacement persistence 必须从 adapter 获取 canonical UTC,且严格晚于 prior
+ `updatedAtUtc`;transaction 与 TaskRemoved receipt 精确绑定该新 timestamp。
+- receipt 比较显式区分 `null` 与空字符串;stale/null/malformed receipt、transaction no-op、
+ transaction/receipt write failure、stale/malformed transaction read-back 均返回
+ `CCOD_UNINSTALL_TRANSACTION_WRITE_FAILED`。
+- historical `wrapperUserSid` 必须等于 transaction SID,historical `wrapperSessionId` 必须等于
+ transaction session;不一致时在任何 replacement write 前拒绝。
+- transaction write 后及 receipt write 后都从 durable adapter 重新读取;专用恢复失败不把原
+ TaskRemoved 另写成 Failed。receipt write 中断后,下一次 exact wrapper 可写入更晚 timestamp、
+ 替换仅 `resumeWrapper*` 并收敛。
+
+### Fix round 1 tests
+
+新增/强化覆盖:
+
+- real disk-backed verified-runtime / transaction / receipt independent deserialization;
+- Failed/TaskRemoved same session 与 session 99 两条 early-return;
+- historical SID/session zero-write negatives;
+- DifferentSid、runtime、generation、epoch、Ready、payload、pre-TaskRemoved negatives;
+- stale receipt、empty error、null receipt、transaction no-op/write failure/stale/malformed read-back、
+ receipt write failure/malformed receipt;
+- interrupted receipt write 后的新 wrapper retry;
+- WrapperResume PID/creation/session/SID exact-wait matrix。
+
+最终无过滤验证:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1
+ Uninstall bootstrap self-tests passed: 45
+ exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ManualWrappers.SelfTest.ps1
+ Manual wrapper self-tests passed
+ exit 0
+```
+
+两个修改文件 parser 均为 `0 errors`;`git diff --check` exit 0,仅有 LF/CRLF checkout warning。
+未执行任何真实系统或外部操作。Task 2 未修改、仍 pending;本报告不作 final-gate 或 release
+readiness 声明。
diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-fix1-brief.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-fix1-brief.md
new file mode 100644
index 0000000..47fdf3b
--- /dev/null
+++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-fix1-brief.md
@@ -0,0 +1,11 @@
+# Task 2 fix round 1 — directory creation recovery and exact durable evidence
+
+Read the Task 2 report and scoped review. Fix the three Important findings without weakening the already accepted V6 scope, Ready/fence/lease, overlap, or cleanup-tail boundaries.
+
+## Required RED -> GREEN
+
+1. Cover the CREATE-to-ACL crash shape for `state\legacy-registration-migrations`. A newly created directory must never leave an unregistered handle on Set/Validate failure. A later ProductTransaction may normalize only an empty, non-reparse directory owned by the current SID whose inherited ACL contains only the explicitly trusted SID/SYSTEM/Administrators full-control principals; nonempty, foreign-owner, untrusted-ACE, ADS, or identity-drift cases fail closed. Reader-first retry must recover the known empty crash artifact before returning no plan. Prefer atomic creation security if the native layer can provide it more safely.
+2. Require exact nested schema. Persisted `profile` must have exactly the six canonical profile fields (`profileId,appId,minimumVersion,maximumVersion,uninstallCommandShape,shortcutNames`) in canonical order and byte-equivalent values to `Resolve-CcodLegacyRegistrationProfile`. `expectedInstallRoot`, every historical shortcut path/target/working-directory, and other identity paths must equal their canonical strings, not merely normalize to them. Add extra/missing/reordered profile and equivalent-noncanonical path REDs.
+3. Add separate-`powershell.exe` replay evidence that reads serialized plan/live-state data and invokes the production durable replay parser/classifier with no parent-process object identity. Add published-but-threw writer and post-write read-back failure cases: first invocation performs zero current writes; the later invocation reads the existing exact leaf, does not republish it, and converges. Use independent serialized objects for all read-backs.
+
+Run IFT, ProductRegistration and Lifecycle focused suites, then repeat the complete frozen gate, parsers and `git diff --check`. Commit implementation and evidence separately, update the ledger/report, and request a scoped fix-round review. No real system/external actions or release claim.
diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-report.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-report.md
new file mode 100644
index 0000000..3709ad4
--- /dev/null
+++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-report.md
@@ -0,0 +1,246 @@
+# Task 2 report — durable legacy migration plan replay
+
+## 结论与范围
+
+Task 2 已按 successor plan 完成,implementation commit 为 `148f6ee`
+(`fix: persist legacy product migration plan`),基线为 Task 1 复审通过后的 `bc0441d`。
+本任务只修改 6 个实现/测试文件:
+
+- `src/persistence/modules/InstallFileTransaction.psm1`
+- `src/persistence/modules/ProductRegistration.psm1`
+- `src/persistence/modules/InstallLifecycle.psm1`
+- `tests/persistence/InstallFileTransaction.SelfTest.ps1`
+- `tests/persistence/ProductRegistration.SelfTest.ps1`
+- `tests/persistence/InstallLifecycle.SelfTest.ps1`
+
+没有执行真实 registry、shortcut、scheduled task、安装、卸载、进程重启、系统重启、网络、push、
+tag、release 或发布操作;device-key/DPAPI state 未触碰。所有破坏性边界仅在独立临时 fixture
+中模拟,wrapper 的安装路径只执行 `WhatIf`。
+
+## 根因与 RED
+
+旧实现只把完整历史 profile 保存在调用栈内。第一次 current registration 若已替换一个或两个与
+v2.5.21 重叠的 `.lnk`,第二个进程重新执行 live capture 时会把 current/historical 混合态当成
+不完整历史 profile,永久返回 `CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID`。
+
+production 未修改时,首组 production-shaped RED 的实际结果为:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1
+exit 1
+case=second-registration-recovers-from-partial-current-overlap-writes-only-through-a-durable-pre-capture-plan
+expected=DesktopWrite:Recovered|CurrentReadBack:Recovered
+actual=DesktopWrite:CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID|CurrentReadBack:CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID
+```
+
+专用 IFT wishful API 的 RED 首错为 export surface 缺少
+`Read/Write-CcodInstallLegacyMigrationPlan`。真实 lifecycle RED 在 v2.5.21 -> v2.5.22 入口命中
+`state\legacy-registration-migrations` `PathNotFound`,证明 orchestration 尚未持久化计划。
+
+自审再得到三项有效 RED:
+
+- registry 已删除而 legacy-only shortcuts 仍在时,second replay 错误返回
+ `legacyPresent=False`,会永久遗留旧快捷方式;
+- Ready proof generation 与 transaction generation 不一致时,旧 matcher 没有拒绝;
+- live ProductTransaction 内刚发布的 plan 可被移动替换,`expected moved=False, actual=True`。
+
+oversized historical leaf、ACL/ADS/multilink/reparse、partial cleanup、foreign replacement 与四种
+overlap 组合在同一 RED/hostile 批次加入;最终均由下述 focused/final gate 覆盖。
+
+## 实现
+
+### IFT V6 专用能力
+
+ABI 升为 V6,仅新增两个固定用途 export;没有导出可读任意 state leaf 的通用 reader。
+
+- 固定目录:`state\legacy-registration-migrations`;
+- 固定叶:`<20-digit Ready generation>..json`;
+- 每次读写都要求 exact ProductTransaction root、live `AccountTransition` lease、durable Pending
+ cleanup fence,并在锁内重读完整 selector/manifest/persisted Ready authority;
+- write 为 no-replace create-only,最大 1 MiB;read 使用相对目录 native handle;
+- plan 目录与文件使用 protected exact ACL:current SID、SYSTEM、Administrators 三项 FullControl,
+ owner 为 current SID;延迟读取在同一 native handle 上复验 ACL/owner;
+- 拒绝 reparse、ADS、multilink、路径/对象 identity 变化;刚发布的 plan handle 保持到 product
+ transaction close,replacement 在 live proof-to-use 区间被阻止;
+- generic product state directory/write surface 仍保持拒绝。
+
+### canonical durable schema 与 replay
+
+schema v1 固定并按顺序绑定:
+
+`schemaVersion, transactionId, runtimeId, runtimeGeneration, manifestSha256, packageSha256,
+readyTransaction, appId, expectedInstallRoot, legacyPresent, legacyRegistration, profile, snapshot,
+expectedCurrentProof`。
+
+历史 snapshot 保存 exact registry values/kinds,以及每个 shortcut 的 canonical path、原始 bytes、
+length-bound hash、target、arguments、working directory;单个历史 shortcut 限制为 1..262144 bytes。
+`expectedCurrentProof` 额外绑定 version/runtime/package 和两个 current shortcut 的 canonical
+destination、candidate path/length/SHA、target、arguments、working directory。该 proof 只取首次
+持久记录,后续进程不以新生成候选替换它。
+
+reader 要求 strict UTF-8、所有 object 无 duplicate properties、完整 exact field set、canonical JSON
+bytes 与 1 MiB 总界限;Ready transaction/runtime/generation/manifest/package 会交叉匹配,而不仅比较
+单个 hash。
+
+已有 exact plan 的 second process 逐项分类 overlap:captured historical exact 或同一 persisted
+current proof exact;HH、CH、HC、CC 四态都收敛,foreign/missing required overlap 拒绝。没有 plan 的
+washed hybrid 仍在 plan/current write 前失败。
+
+legacy-only cleanup 也按 durable snapshot 做 `Absent=already done / Exact=delete / Mismatch=fail`。
+因此 registry-first 删除后在 0/1/2 个 legacy-only 删除边界崩溃,second process 都只继续剩余 exact
+项;registry present 的 partial shortcut 边界也可收敛;foreign replacement 零删除拒绝。registry
+absent 不再被当成整个 cleanup 已完成。
+
+### Lifecycle 接线
+
+`RegisterProduct` 在既有 Local/Global 顺序和 cleanup fence 内打开同一个 owned ProductTransaction,
+先读取或发布并 read-back exact durable plan,之后才允许 current registry / StartMenu / Desktop 写入。
+second `Invoke-CcodInstall` 使用新 fake adapters、丢弃所有进程内 plan,并从同一磁盘叶在
+`AlreadyInstalled` 路径恢复;不会新增 runtime 或 selector。
+
+## 负例与故障边界
+
+最终 focused tests 覆盖:
+
+- Desktop write 前(StartMenu 已替换)与 current read-back(两项已替换)跨调用恢复;
+- HH、CH、HC、CC 四种独立 overlap;
+- no-plan washed hybrid、tampered current proof、wrong Ready/package/generation、duplicate JSON、
+ unsafe path、foreign hybrid;
+- Ready proof/transaction generation、manifest、package 内部不一致;
+- historical shortcut 262145-byte rejection;
+- registry-first 0/1/2 legacy-only partial deletion、registry-present partial deletion与 foreign tail;
+- IFT wrong scope/Ready/fence、oversized record、collision、live replacement、file/directory ACL、ADS、
+ multilink、file/directory reparse;
+- disk plan leaf/schema/Ready identity,以及真实 lifecycle 新进程形态的 second invocation。
+
+## frozen final gate
+
+source 冻结后顺序执行,结果如下:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1
+ 41 named test groups; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1
+ Product registration self-tests passed: 28; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+ Install lifecycle self-tests passed: 151; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1
+ Uninstall bootstrap self-tests passed: 45; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1
+ 21 named test groups; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1
+ 21 named test groups; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1
+ Bootstrap self-test passed: 26; exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ManualWrappers.SelfTest.ps1
+ 12 named test groups; exit 0
+
+PowerShell parser: 6/6 changed files, 0 errors
+git diff --check: exit 0 (仅 checkout LF/CRLF conversion warning)
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1
+ exit 0; success path is silent
+```
+
+Lifecycle 的既有 cross-process guard 仍输出:
+`exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`。
+
+## 边界
+
+Task 2 与 successor plan 的本地 final gate 已完成;fresh scoped review 仍待 parent agent 发起。
+本报告不声明 Tasks 5–8、合并、发布或 release readiness 完成。
+
+## Fix round 1 — CREATE/ACL recovery、exact nested schema 与独立进程 replay
+
+### Review findings 与 RED
+
+Task 2 scoped review 对 `148f6ee..66a91aa` 判定 FAIL(0 Critical、3 Important)。本轮在
+production 未修改时分别运行三组 RED:
+
+```text
+IFT reader-first safe empty recovery:
+ exit 1
+ safe inherited empty directory reached OpenExistingLegacyPlanDirectory
+ ACL validation threw after result.Handle was detached
+ fixture cleanup failed with "being used by another process"
+
+IFT CREATE Set/Validate failure seam:
+ exit 1
+ InvokeMethodOnNull (the exact native failure boundary did not exist)
+
+Product exact nested schema:
+ exit 1
+ ProfileExtra was accepted
+ ASSERT_THROWS: expected CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID
+
+Product post-write read-back:
+ exit 1
+ raw "fixture durable plan read-back failure" escaped instead of the stable product error
+```
+
+在第一轮 production 修复后,独立 `powershell.exe` replay 又得到一条真实 RED:serialized live
+registry 的 `values` 被独立反序列化为 `PSCustomObject`,旧 comparator 直接访问 `.Keys`,child
+返回 `The property 'Keys' cannot be found`。该失败证明先前同进程 adapter 测试依赖了 parent
+object identity。
+
+### Fix
+
+实现提交:`a0c8d71`(`fix: harden durable legacy plan recovery`)。
+
+- V6 保持不变。新建 plan 目录的 Pin 在 ACL Set/Validate 前注册;任何后续失败均由 transaction
+ close 释放,避免 detached native handle。
+- writer 与 reader-first 共用同一恢复判定。只允许 current SID owner、空目录、无 reparse/ADS、
+ 未 protected 且仅继承 current SID/SYSTEM/Administrators 三项 FullControl 的 CREATE artifact;
+ normalization 前后都复验 exact native identity,之后设置并验证既有 protected exact ACL。
+- nonempty、untrusted ACE、foreign owner、错误 flags/rights/principal、identity drift 均 fail closed,
+ 且不会被 normalization 改写。测试对 Set 与 Validate 两个 crash seam 都证明 live handle 阻止
+ replacement、close 后立即释放、later reader-first 返回 no-plan 并收敛。
+- persisted profile 必须严格为
+ `profileId,appId,minimumVersion,maximumVersion,uninstallCommandShape,shortcutNames` 六字段、原顺序,
+ canonical JSON 值与 `Resolve-CcodLegacyRegistrationProfile` 完全一致,并将 `profile.appId` 与顶层
+ AppId 交叉绑定。
+- `expectedInstallRoot` 与每条 historical shortcut 的 path/target/workingDirectory 现在既要解析到
+ expected identity,也要求 raw string 本身就是 canonical string;等价的 `\.\` spelling 被拒绝。
+- initial plan read 与 post-write read-back adapter failures 都归一化为
+ `CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID`。published-then-threw 与 read-back-failed 两种首调均保持
+ 零 current write;later invocation 只读取 existing bytes,publish count 保持 `1` 并完成 cleanup。
+- registry comparator 通过同一个 strict map facade 同时处理 ordered dictionary 与独立反序列化的
+ `PSCustomObject`,不再依赖 parent-process object shape。
+
+### Fix round 1 tests 与 frozen gate
+
+新增覆盖 reader-first safe empty recovery、Set/Validate registered-handle cleanup、nonempty/untrusted
+ACE/foreign-owner shared native predicate、profile extra/missing/reordered/value、raw root/path/target/
+working-directory、published-then-threw、post-write read-back failure,以及完全独立
+`powershell.exe` serialized plan/live-state replay。
+
+focused 结果:InstallFileTransaction 44 groups、ProductRegistration 30/30、InstallLifecycle 151/151,
+均 exit 0。source freeze 后完整 gate:
+
+```text
+InstallFileTransaction.SelfTest.ps1 44 groups exit 0
+ProductRegistration.SelfTest.ps1 30/30 exit 0
+InstallLifecycle.SelfTest.ps1 151/151 exit 0
+UninstallBootstrap.SelfTest.ps1 45/45 exit 0
+RuntimeManifest.SelfTest.ps1 21 groups exit 0
+InstalledLifecycleHarness.SelfTest.ps1 21 groups exit 0
+Bootstrap.SelfTest.ps1 26/26 exit 0
+ManualWrappers.SelfTest.ps1 12 groups exit 0
+PowerShell parser 4/4 changed files, 0 errors
+git diff --check exit 0
+tests\PersistenceSelfTest.ps1 exit 0 (silent success)
+```
+
+Lifecycle cross-process guard 仍为
+`exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`。
+未执行任何真实 registry、shortcut、scheduled task、安装、卸载、重启、网络、push、tag、release
+或发布操作;失败 RED 遗留的单一临时 fixture 已在进程退出后按 exact temp path 清理。
+
+Fix round 1 的 fresh scoped review 仍待 parent agent 发起;不作 release-readiness 声明。
diff --git a/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/implementation-brief.md b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/implementation-brief.md
new file mode 100644
index 0000000..2827ba1
--- /dev/null
+++ b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/implementation-brief.md
@@ -0,0 +1,7 @@
+# Implementation brief — Runtime V7 ABI closure
+
+Read the micro-plan and the successor whole-plan final review. Use RED -> GREEN.
+
+The RED must preload a self-contained stale V6 marker/runtime in a new `powershell.exe`, import the current module, and prove the module selected stale V6. Then change every production marker/runtime/ABI reference to V7/7 and update exact tests. Do not change file-layer behavior, exports, durable plan schema, or product/lifecycle logic.
+
+After focused GREEN, freeze source and run the full gate in the plan. Commit implementation and evidence separately, leave a clean worktree, and report exact SHAs/counts/exits. No real system or external actions.
diff --git a/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/progress.md b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/progress.md
new file mode 100644
index 0000000..18c220d
--- /dev/null
+++ b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/progress.md
@@ -0,0 +1,26 @@
+# SDD ledger — Runtime V7 ABI closure
+
+Plan: `docs/superpowers/plans/2026-09-03-v2522-runtime-v7-closure.md`
+
+## Provenance
+
+- Pre-fix V6 implementation: `148f6ee`.
+- Final V6 behavioral fix: `a0c8d71`; evidence: `f38ddff`.
+- Successor whole-plan review: FAIL, 0 Critical + 1 Important, solely because the modified CLR retained V6/ABI 6.
+
+## Ruling
+
+Any embedded CLR implementation change requires a new marker, runtime type and ABI integer. Cost if wrong:
+long-lived PowerShell processes silently execute an older security/recovery implementation despite importing
+new PowerShell source.
+
+## Status
+
+Runtime V7 task: complete. Implementation `9d7cc90`; evidence is recorded by the commit containing
+`runtime-v7-report.md` and this ledger update. Frozen evidence: InstallFileTransaction 45 groups,
+ProductRegistration 30/30, InstallLifecycle 151/151, UninstallBootstrap 45/45, RuntimeManifest 21 groups,
+InstalledLifecycleHarness 21 groups, Bootstrap 26/26, ManualWrappers 12 groups, parser 2/2,
+`git diff --check` exit 0, and `tests\PersistenceSelfTest.ps1` exit 0. No real system action.
+
+Fresh scoped V7 review: Spec Compliance PASS, Task Quality PASS, 0 Critical, 0 Important. Runtime V7
+micro-plan is complete. Tasks 5–8 remain blocked until their own gates are implemented and verified.
diff --git a/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/runtime-v7-report.md b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/runtime-v7-report.md
new file mode 100644
index 0000000..f7c2c35
--- /dev/null
+++ b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/runtime-v7-report.md
@@ -0,0 +1,82 @@
+# Runtime V7 ABI closure report
+
+## 结论与范围
+
+micro-plan implementation commit 为 `9d7cc90`(`fix: advance install runtime ABI to V7`),基线为
+`ec8dac0`。实现只修改:
+
+- `src/persistence/modules/InstallFileTransaction.psm1`
+- `tests/persistence/InstallFileTransaction.SelfTest.ps1`
+
+没有修改 exports、durable plan schema、file-layer 行为、ProductRegistration 或 InstallLifecycle
+逻辑。
+
+## RED
+
+独立 `powershell.exe` child 先通过自包含 `Add-Type` 预载最小 stale:
+
+- `CcodInstallGenerationCapabilityMarkerV6`,ABI `6`;
+- internal `CcodInstallGenerationRuntimeV6`,不含最终实现方法。
+
+然后 child 对当前模块执行 force-import。production 未修改时实际结果:
+
+```text
+InstallFileTransaction.SelfTest.ps1
+exit 1
+case=stale-V6-preload-cannot-capture-the-final-runtime-ABI-across-force-re-imports
+child exit=17
+SELECTED_RUNTIME=CcodInstallGenerationRuntimeV6
+```
+
+这证明长寿命 AppDomain 中已有 V6 marker 时,原模块会跳过新的 embedded C#,并把
+`$script:CcodRuntimeType` 绑定到 stale V6。
+
+## 实现与 GREEN
+
+production 仅做机械 ABI 迁移:
+
+- `CcodInstallGenerationCapabilityMarkerV6` -> `...MarkerV7`;
+- `CcodInstallGenerationRuntimeV6` -> `...RuntimeV7`;
+- `CapabilityAbi` 与 import-time exact assertion 从 `6` -> `7`;
+- 所有 constructor、out parameter、runtime allocation 与 reflection binding 同步改为 V7。
+
+exact tests 同步为 V7/7。独立 child 保留 stale V6,证明:
+
+1. V6 marker/runtime 仍在同一 AppDomain;
+2. force-import 选择 `CcodInstallGenerationRuntimeV7`;
+3. repeated force-import 复用同一个正确 V7 `System.Type`;
+4. V7 state transaction 实际写入 create-only record;
+5. 既有 V4-first child 也只选择 V7。
+
+首轮 GREEN 的 coexistence oracle 曾错误使用 `'RuntimeV6' -as [type]` 检查 internal stale runtime,
+因此返回 null。独立诊断证明 global type resolution 为 null、但
+`staleMarker.Assembly.GetType('CcodInstallGenerationRuntimeV6')` 返回该 internal type;修正测试
+oracle 后,没有追加 production 改动。
+
+## Frozen gate
+
+source freeze 后结果:
+
+```text
+InstallFileTransaction.SelfTest.ps1 45 groups exit 0
+ProductRegistration.SelfTest.ps1 30/30 exit 0
+InstallLifecycle.SelfTest.ps1 151/151 exit 0
+UninstallBootstrap.SelfTest.ps1 45/45 exit 0
+RuntimeManifest.SelfTest.ps1 21 groups exit 0
+InstalledLifecycleHarness.SelfTest.ps1 21 groups exit 0
+Bootstrap.SelfTest.ps1 26/26 exit 0
+ManualWrappers.SelfTest.ps1 12 groups exit 0
+PowerShell parser 2/2 changed files, 0 errors
+git diff --check exit 0
+tests\PersistenceSelfTest.ps1 exit 0 (silent success)
+```
+
+Lifecycle 的既有 cross-process guard 仍输出:
+`exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`。
+
+## 安全边界
+
+未执行真实 registry、shortcut、scheduled task、安装、卸载、进程重启、系统重启、网络、push、
+tag、release 或发布操作;所有新 child 行为仅使用独立临时目录并完成清理。
+
+fresh scoped V7 review 仍待 parent agent 发起;不声明 Tasks 5–8 或 release readiness 完成。
diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1
index 9837167..5ca32f0 100644
--- a/Activate-CcodRemoteFix.ps1
+++ b/Activate-CcodRemoteFix.ps1
@@ -1,7 +1,15 @@
[CmdletBinding()]
param(
- [Parameter(Mandatory)][string]$AppRoot,
+ [string]$AppRoot,
[Parameter(Mandatory)][string]$InstallRoot,
+ [string]$PayloadRoot,
+ [string]$PackagePath,
+ [string]$PackageManifestPath,
+ [string]$ExpectedPackageSha256,
+ [string]$ExpectedPackageManifestSha256,
+ [string]$ExpectedGitCommit,
+ [string]$ExpectedVersion,
+ [string]$ExpectedPayloadManifestSha256,
[string]$ActivationId,
[switch]$ValidateReceiptOnly,
[switch]$ValidateReceiptWithTimeout,
@@ -51,6 +59,580 @@ function Write-CcodActivationRecord {
} catch { }
}
+function Get-CcodActivationFileSha256 {
+ param([Parameter(Mandatory)][string]$Path)
+ $stream=$null;$sha=[Security.Cryptography.SHA256]::Create()
+ try{$stream=[IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read);return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()}
+ finally{if($null-ne$stream){$stream.Dispose()};$sha.Dispose()}
+}
+
+function Get-CcodSetupDefenderDetectionKeys {
+ param($Records)
+ $keys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach ($record in @($Records)) {
+ if ($null -eq $record) { continue }
+ $threat = if ($null -ne $record.PSObject.Properties['ThreatID']) { [string]$record.ThreatID } else { '' }
+ $time = if ($null -ne $record.PSObject.Properties['InitialDetectionTime']) { [string]$record.InitialDetectionTime } else { '' }
+ $resources = if ($null -ne $record.PSObject.Properties['Resources']) { (@($record.Resources) -join '|') } else { '' }
+ if (-not [string]::IsNullOrWhiteSpace($threat) -or -not [string]::IsNullOrWhiteSpace($time) -or -not [string]::IsNullOrWhiteSpace($resources)) { [void]$keys.Add("$threat|$time|$resources") }
+ }
+ Write-Output -NoEnumerate $keys
+}
+
+function Invoke-CcodSetupPackageDefenderGate {
+ param([Parameter(Mandatory)][string]$PackagePath)
+ $started = [datetime]::UtcNow
+ try { $status = Get-MpComputerStatus -ErrorAction Stop }
+ catch { throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID' }
+ if ($null -eq $status -or
+ $null -eq $status.PSObject.Properties['AMServiceEnabled'] -or $status.AMServiceEnabled -isnot [bool] -or -not $status.AMServiceEnabled -or
+ $null -eq $status.PSObject.Properties['AntivirusEnabled'] -or $status.AntivirusEnabled -isnot [bool] -or -not $status.AntivirusEnabled -or
+ $null -eq $status.PSObject.Properties['RealTimeProtectionEnabled'] -or $status.RealTimeProtectionEnabled -isnot [bool] -or -not $status.RealTimeProtectionEnabled -or
+ $null -eq $status.PSObject.Properties['AMProductVersion'] -or $status.AMProductVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AMProductVersion) -or
+ $null -eq $status.PSObject.Properties['AMEngineVersion'] -or $status.AMEngineVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AMEngineVersion) -or
+ $null -eq $status.PSObject.Properties['AntivirusSignatureVersion'] -or $status.AntivirusSignatureVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AntivirusSignatureVersion) -or
+ $null -eq $status.PSObject.Properties['AntivirusSignatureLastUpdated'] -or $status.AntivirusSignatureLastUpdated -isnot [datetime]) {
+ throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID'
+ }
+ $signatureUpdated = ([datetime]$status.AntivirusSignatureLastUpdated).ToUniversalTime()
+ if ($signatureUpdated -lt $started.AddHours(-72) -or $signatureUpdated -gt $started.AddMinutes(5)) { throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID' }
+ try { $before = Get-CcodSetupDefenderDetectionKeys -Records @(Get-MpThreatDetection -ErrorAction Stop) }
+ catch { throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID' }
+ try { Start-MpScan -ScanType CustomScan -ScanPath $PackagePath -ErrorAction Stop }
+ catch { throw 'CCOD_SETUP_DEFENDER_SCAN_FAILED' }
+ $completed = [datetime]::UtcNow
+ if ($completed -lt $started -or $completed -gt $started.AddHours(2)) { throw 'CCOD_SETUP_DEFENDER_CLOCK_INVALID' }
+ try { $after = Get-CcodSetupDefenderDetectionKeys -Records @(Get-MpThreatDetection -ErrorAction Stop) }
+ catch { throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID' }
+ $newDetections = @($after | Where-Object { -not $before.Contains($_) })
+ if ($newDetections.Count -ne 0) { throw 'CCOD_SETUP_DEFENDER_DETECTIONS_FOUND' }
+ return [pscustomobject][ordered]@{
+ defenderServiceEnabled=$true
+ antivirusEnabled=$true
+ realTimeProtectionEnabled=$true
+ defenderPlatformVersion=[string]$status.AMProductVersion
+ defenderEngineVersion=[string]$status.AMEngineVersion
+ signatureVersion=[string]$status.AntivirusSignatureVersion
+ signatureUpdatedAtUtc=$signatureUpdated.ToString('o',[Globalization.CultureInfo]::InvariantCulture)
+ scanStartedAtUtc=$started.ToString('o',[Globalization.CultureInfo]::InvariantCulture)
+ scanCompletedAtUtc=$completed.ToString('o',[Globalization.CultureInfo]::InvariantCulture)
+ detectionCount=0
+ }
+}
+
+function Test-CcodActivationPackageRelativePath {
+ param($Path)
+ return $Path-is[string]-and$Path-cmatch'^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$'-and-not$Path.Contains('//')-and-not$Path.Contains('..')-and-not$Path.Contains(':')-and-not$Path.Contains('\')-and-not$Path.EndsWith('/')
+}
+
+function Open-CcodInstallerPackageSeal {
+ param([Parameter(Mandatory)][string]$PackagePath,[Parameter(Mandatory)][string]$PackageManifestPath,[Parameter(Mandatory)][string]$ExpectedPackageSha256,[Parameter(Mandatory)][string]$ExpectedPackageManifestSha256,[Parameter(Mandatory)][string]$ExpectedVersion,[Parameter(Mandatory)][string]$ExpectedGitCommit)
+ if($ExpectedPackageSha256-cnotmatch'^[0-9a-f]{64}$'-or$ExpectedPackageManifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ExpectedVersion-cnotmatch'^\d+\.\d+\.\d+$'-or$ExpectedGitCommit-cnotmatch'^[0-9a-f]{40}$'){throw 'CCOD_INSTALLER_PACKAGE_INVALID'}
+ Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop
+ $package=[IO.Path]::GetFullPath($PackagePath);$manifestPath=[IO.Path]::GetFullPath($PackageManifestPath);$packageStream=$null;$manifestStream=$null;$archive=$null
+ try{
+ foreach($path in @($package,$manifestPath)){$item=Get-Item -LiteralPath $path -Force -ErrorAction Stop;if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne 0-or$item.Length-le 0-or$item.Length-gt 536870912){throw 'CCOD_INSTALLER_PACKAGE_INVALID'}}
+ $packageStream=[IO.File]::Open($package,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read);$sha=[Security.Cryptography.SHA256]::Create();try{$actualPackage=[BitConverter]::ToString($sha.ComputeHash($packageStream)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};$packageStream.Position=0;if($actualPackage-cne$ExpectedPackageSha256){throw 'CCOD_INSTALLER_PACKAGE_HASH_MISMATCH'}
+ $manifestStream=[IO.File]::Open($manifestPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read);$sha=[Security.Cryptography.SHA256]::Create();try{$actualManifest=[BitConverter]::ToString($sha.ComputeHash($manifestStream)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};$manifestStream.Position=0;if($actualManifest-cne$ExpectedPackageManifestSha256){throw 'CCOD_INSTALLER_PACKAGE_MANIFEST_HASH_MISMATCH'}
+ $reader=[IO.StreamReader]::new($manifestStream,[Text.UTF8Encoding]::new($false,$true),$true,4096,$true);try{$text=$reader.ReadToEnd()}finally{$reader.Dispose()};$manifestStream.Position=0;try{$manifest=$text|ConvertFrom-Json -ErrorAction Stop}catch{throw 'CCOD_INSTALLER_PACKAGE_INVALID'}
+ if($manifest-isnot[pscustomobject]-or(@($manifest.PSObject.Properties.Name)-join',')-cne'schemaVersion,product,version,gitCommit,payloadManifest,files'-or$manifest.schemaVersion-isnot[int]-or$manifest.schemaVersion-ne 1-or$manifest.product-cne'CodexRemote-fix'-or$manifest.version-cne$ExpectedVersion-or$manifest.gitCommit-cne$ExpectedGitCommit-or$manifest.payloadManifest.name-cne'installer-payload.manifest.json'-or$manifest.payloadManifest.sha256-cnotmatch'^[0-9a-f]{64}$'){throw 'CCOD_INSTALLER_PACKAGE_INVALID'}
+ $records=@($manifest.files);if($records.Count-eq 0){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$expected=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase);$previous=$null
+ foreach($record in $records){if($record-isnot[pscustomobject]-or(@($record.PSObject.Properties.Name)-join',')-cne'path,length,sha256'-or-not(Test-CcodActivationPackageRelativePath $record.path)-or$record.length-isnot[ValueType]-or[decimal]$record.length-ne[decimal][int64]$record.length-or[int64]$record.length-lt 0-or$record.sha256-cnotmatch'^[0-9a-f]{64}$'-or($null-ne$previous-and[StringComparer]::Ordinal.Compare($previous,[string]$record.path)-ge 0)-or$expected.ContainsKey([string]$record.path)){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$expected.Add([string]$record.path,$record);$previous=[string]$record.path}
+ $archive=[IO.Compression.ZipArchive]::new($packageStream,[IO.Compression.ZipArchiveMode]::Read,$true);$entries=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
+ foreach($entry in @($archive.Entries)){if(-not(Test-CcodActivationPackageRelativePath $entry.FullName)-or$entries.ContainsKey($entry.FullName)-or$entry.Name.Length-eq 0){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$entries.Add($entry.FullName,$entry)}
+ if($entries.Count-ne$expected.Count+1-or-not$entries.ContainsKey('installer-payload.manifest.json')){throw 'CCOD_INSTALLER_PACKAGE_INVALID'}
+ foreach($pair in $expected.GetEnumerator()){$entry=$entries[$pair.Key];$record=$pair.Value;if([int64]$entry.Length-ne[int64]$record.length){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$stream=$entry.Open();$sha=[Security.Cryptography.SHA256]::Create();try{$hash=[BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose();$stream.Dispose()};if($hash-cne[string]$record.sha256){throw 'CCOD_INSTALLER_PACKAGE_INVALID'}}
+ $payloadEntry=$entries['installer-payload.manifest.json'];$stream=$payloadEntry.Open();$sha=[Security.Cryptography.SHA256]::Create();try{$payloadHash=[BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose();$stream.Dispose()};if([int64]$payloadEntry.Length-ne[int64]$manifest.payloadManifest.length-or$payloadHash-cne[string]$manifest.payloadManifest.sha256){throw 'CCOD_INSTALLER_PACKAGE_INVALID'}
+ return [pscustomobject]@{PackagePath=$package;PackageStream=$packageStream;ManifestStream=$manifestStream;Archive=$archive;Manifest=$manifest;Entries=$entries;PayloadManifestSha256=$payloadHash}
+ }catch{if($null-ne$archive){$archive.Dispose()};if($null-ne$manifestStream){$manifestStream.Dispose()};if($null-ne$packageStream){$packageStream.Dispose()};throw}
+}
+
+function Expand-CcodInstallerPackageSeal {
+ param([Parameter(Mandatory)]$Seal,[Parameter(Mandatory)][string]$DestinationRoot)
+ $root=[IO.Path]::GetFullPath($DestinationRoot);if([IO.File]::Exists($root)-or[IO.Directory]::Exists($root)){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};[IO.Directory]::CreateDirectory($root)|Out-Null
+ try{foreach($entry in @($Seal.Archive.Entries)){$target=[IO.Path]::GetFullPath((Join-Path $root $entry.FullName.Replace('/','\')));if(-not$target.StartsWith($root.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$parent=Split-Path $target -Parent;if(-not[IO.Directory]::Exists($parent)){[IO.Directory]::CreateDirectory($parent)|Out-Null};$source=$entry.Open();$destination=[IO.File]::Open($target,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);try{$source.CopyTo($destination);$destination.Flush($true)}finally{$destination.Dispose();$source.Dispose()}};return $root}catch{if([IO.Directory]::Exists($root)){Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue};throw}
+}
+
+function Close-CcodInstallerPackageSeal { param($Seal) if($null-eq$Seal){return};foreach($name in @('Archive','ManifestStream','PackageStream')){try{if($null-ne$Seal.$name){$Seal.$name.Dispose()}}catch{}} }
+
+function Get-CcodActivationBytesSha256 {
+ param([Parameter(Mandatory)][byte[]]$Bytes)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant()
+ } finally {
+ $sha.Dispose()
+ }
+}
+
+function Open-CcodActivationLockedBytes {
+ param(
+ [Parameter(Mandatory)][string]$Path,
+ [ValidateRange(1,268435456)][int64]$MaximumBytes = 268435456
+ )
+
+ $full = [IO.Path]::GetFullPath($Path)
+ $stream = [IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ try {
+ if ($stream.Length -lt 0 -or $stream.Length -gt $MaximumBytes) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $memory = [IO.MemoryStream]::new()
+ try {
+ $stream.CopyTo($memory)
+ $bytes = $memory.ToArray()
+ } finally { $memory.Dispose() }
+ return [pscustomobject]@{ Path=$full; Stream=$stream; Bytes=$bytes }
+ } catch {
+ $stream.Dispose()
+ throw
+ }
+}
+
+function Initialize-CcodActivationStageNative {
+ if ($null -ne ('CcodActivationStageNative' -as [type])) { return }
+ Add-Type -TypeDefinition @'
+using System;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using Microsoft.Win32.SafeHandles;
+
+public static class CcodActivationStageNative
+{
+ private const uint FILE_READ_DATA = 0x00000001;
+ private const uint FILE_LIST_DIRECTORY = 0x00000001;
+ private const uint FILE_READ_ATTRIBUTES = 0x00000080;
+ private const uint GENERIC_READ = 0x80000000;
+ private const uint GENERIC_WRITE = 0x40000000;
+ private const uint SYNCHRONIZE = 0x00100000;
+ private const uint FILE_SHARE_READ = 0x00000001;
+ private const uint FILE_SHARE_WRITE = 0x00000002;
+ private const uint FILE_OPEN = 0x00000001;
+ private const uint FILE_CREATE = 0x00000002;
+ private const uint FILE_DIRECTORY_FILE = 0x00000001;
+ private const uint FILE_NON_DIRECTORY_FILE = 0x00000040;
+ private const uint FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020;
+ private const uint FILE_OPEN_REPARSE_POINT = 0x00200000;
+ private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
+ private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
+ private const uint OPEN_EXISTING = 3;
+ private const uint OBJ_CASE_INSENSITIVE = 0x00000040;
+ private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
+ private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct UNICODE_STRING
+ {
+ public ushort Length;
+ public ushort MaximumLength;
+ public IntPtr Buffer;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct OBJECT_ATTRIBUTES
+ {
+ public int Length;
+ public IntPtr RootDirectory;
+ public IntPtr ObjectName;
+ public uint Attributes;
+ public IntPtr SecurityDescriptor;
+ public IntPtr SecurityQualityOfService;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct IO_STATUS_BLOCK
+ {
+ public IntPtr Status;
+ public UIntPtr Information;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct FILETIME
+ {
+ public uint Low;
+ public uint High;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct BY_HANDLE_FILE_INFORMATION
+ {
+ public uint FileAttributes;
+ public FILETIME CreationTime;
+ public FILETIME LastAccessTime;
+ public FILETIME LastWriteTime;
+ public uint VolumeSerialNumber;
+ public uint FileSizeHigh;
+ public uint FileSizeLow;
+ public uint NumberOfLinks;
+ public uint FileIndexHigh;
+ public uint FileIndexLow;
+ }
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security,
+ uint creation, uint flags, IntPtr template);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information);
+
+ [DllImport("ntdll.dll")]
+ private static extern int NtCreateFile(out IntPtr fileHandle, uint desiredAccess,
+ ref OBJECT_ATTRIBUTES objectAttributes, out IO_STATUS_BLOCK ioStatusBlock, IntPtr allocationSize,
+ uint fileAttributes, uint shareAccess, uint createDisposition, uint createOptions,
+ IntPtr eaBuffer, uint eaLength);
+
+ private static void ValidateLeaf(string name)
+ {
+ if (String.IsNullOrWhiteSpace(name) || name == "." || name == ".." || name.IndexOf('\\') >= 0 || name.IndexOf('/') >= 0)
+ throw new ArgumentException("Relative stage leaf is invalid", "name");
+ }
+
+ private static SafeFileHandle OpenRelative(SafeFileHandle parent, string name, uint access, uint share,
+ uint disposition, uint options)
+ {
+ ValidateLeaf(name);
+ if (parent == null || parent.IsInvalid || parent.IsClosed) throw new ArgumentException("Parent directory pin is invalid", "parent");
+ IntPtr nameBuffer = IntPtr.Zero;
+ IntPtr unicodePointer = IntPtr.Zero;
+ try
+ {
+ nameBuffer = Marshal.StringToHGlobalUni(name);
+ UNICODE_STRING unicode = new UNICODE_STRING();
+ unicode.Length = checked((ushort)(name.Length * 2));
+ unicode.MaximumLength = checked((ushort)((name.Length + 1) * 2));
+ unicode.Buffer = nameBuffer;
+ unicodePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING)));
+ Marshal.StructureToPtr(unicode, unicodePointer, false);
+ OBJECT_ATTRIBUTES attributes = new OBJECT_ATTRIBUTES();
+ attributes.Length = Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES));
+ attributes.RootDirectory = parent.DangerousGetHandle();
+ attributes.ObjectName = unicodePointer;
+ attributes.Attributes = OBJ_CASE_INSENSITIVE;
+ IO_STATUS_BLOCK statusBlock;
+ IntPtr rawHandle;
+ int status = NtCreateFile(out rawHandle, access, ref attributes, out statusBlock, IntPtr.Zero,
+ 0, share, disposition, options | FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT,
+ IntPtr.Zero, 0);
+ if (status < 0 || rawHandle == IntPtr.Zero || rawHandle == new IntPtr(-1))
+ throw new InvalidOperationException("NtCreateFile failed: 0x" + unchecked((uint)status).ToString("x8"));
+ return new SafeFileHandle(rawHandle, true);
+ }
+ finally
+ {
+ if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer);
+ if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer);
+ }
+ }
+
+ public static SafeFileHandle OpenExistingDirectory(string path)
+ {
+ SafeFileHandle handle = CreateFileW(path, FILE_READ_ATTRIBUTES | SYNCHRONIZE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING,
+ FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
+ BY_HANDLE_FILE_INFORMATION information;
+ if (!GetFileInformationByHandle(handle, out information) ||
+ (information.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 ||
+ (information.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
+ {
+ int error = Marshal.GetLastWin32Error();
+ handle.Dispose();
+ if (error != 0) throw new Win32Exception(error);
+ throw new InvalidOperationException("Existing stage parent is not a plain directory");
+ }
+ return handle;
+ }
+
+ public static SafeFileHandle CreateDirectoryRelative(SafeFileHandle parent, string name)
+ {
+ return OpenRelative(parent, name, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | SYNCHRONIZE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_CREATE, FILE_DIRECTORY_FILE);
+ }
+
+ public static SafeFileHandle CreateLeafRelative(SafeFileHandle parent, string name)
+ {
+ return OpenRelative(parent, name, GENERIC_WRITE | SYNCHRONIZE,
+ FILE_SHARE_READ, FILE_CREATE, FILE_NON_DIRECTORY_FILE);
+ }
+
+ public static SafeFileHandle OpenLeafIdentityRelative(SafeFileHandle parent, string name)
+ {
+ return OpenRelative(parent, name, FILE_READ_ATTRIBUTES | SYNCHRONIZE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_OPEN, FILE_NON_DIRECTORY_FILE);
+ }
+
+ public static SafeFileHandle OpenLeafReadRelative(SafeFileHandle parent, string name)
+ {
+ return OpenRelative(parent, name, GENERIC_READ | SYNCHRONIZE,
+ FILE_SHARE_READ, FILE_OPEN, FILE_NON_DIRECTORY_FILE);
+ }
+}
+'@
+}
+
+function Get-CcodActivationPinnedDirectory {
+ param(
+ [Parameter(Mandatory)][Collections.Generic.Dictionary[string,object]]$Directories,
+ [Parameter(Mandatory)][Collections.Generic.List[object]]$DirectoryPins,
+ [Parameter(Mandatory)][AllowEmptyString()][string]$RelativeDirectory
+ )
+ $normalized = $RelativeDirectory.Replace('/','\').Trim('\')
+ if ([string]::IsNullOrEmpty($normalized)) { return $Directories[''] }
+ $current = ''
+ $parent = $Directories['']
+ foreach ($segment in ($normalized -split '\\')) {
+ if ($segment -notmatch '^[A-Za-z0-9._-]+$' -or $segment -in @('.','..')) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' }
+ $current = if ([string]::IsNullOrEmpty($current)) { $segment } else { "$current\$segment" }
+ if ($Directories.ContainsKey($current)) { $parent = $Directories[$current]; continue }
+ $created = [CcodActivationStageNative]::CreateDirectoryRelative($parent,[string]$segment)
+ $DirectoryPins.Add($created)
+ $Directories.Add($current,$created)
+ $parent = $created
+ }
+ return $parent
+}
+
+function Open-CcodActivationPinnedLeafBytes {
+ param(
+ [Parameter(Mandatory)]$ParentDirectory,
+ [Parameter(Mandatory)][string]$Leaf,
+ [Parameter(Mandatory)][byte[]]$Bytes
+ )
+ $writerHandle = $null
+ $identityHandle = $null
+ $writer = $null
+ try {
+ $writerHandle = [CcodActivationStageNative]::CreateLeafRelative($ParentDirectory,$Leaf)
+ $writer = [IO.FileStream]::new($writerHandle,[IO.FileAccess]::Write,65536,$false)
+ $writerHandle = $null
+ $writer.Write($Bytes,0,$Bytes.Length)
+ $writer.Flush($true)
+ $identityHandle = [CcodActivationStageNative]::OpenLeafIdentityRelative($ParentDirectory,$Leaf)
+ $writer.Dispose(); $writer = $null
+ $readHandle = [CcodActivationStageNative]::OpenLeafReadRelative($ParentDirectory,$Leaf)
+ $stream = [IO.FileStream]::new($readHandle,[IO.FileAccess]::Read,65536,$false)
+ $memory = [IO.MemoryStream]::new()
+ try { $stream.CopyTo($memory); $readBytes = $memory.ToArray() }
+ finally { $memory.Dispose() }
+ $identityHandle.Dispose(); $identityHandle = $null
+ return [pscustomobject]@{ Stream=$stream; Bytes=$readBytes }
+ } catch {
+ if ($null -ne $writer) { $writer.Dispose() }
+ elseif ($null -ne $writerHandle) { $writerHandle.Dispose() }
+ if ($null -ne $identityHandle) { $identityHandle.Dispose() }
+ throw
+ }
+}
+
+function Assert-CcodActivationNonReparsePath {
+ param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path)
+ $canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+ $canonicalPath = [IO.Path]::GetFullPath($Path)
+ $prefix = $canonicalRoot + '\'
+ if (-not ($canonicalPath.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase) -or $canonicalPath.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase))) {
+ throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID'
+ }
+ $cursor = $canonicalPath
+ while ($true) {
+ $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
+ if ($cursor.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase)) { break }
+ $parent = Split-Path $cursor -Parent
+ if ([string]::IsNullOrWhiteSpace($parent) -or $parent -ceq $cursor) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' }
+ $cursor = $parent
+ }
+ return $canonicalPath
+}
+
+function Test-CcodActivationAlternateDataStreams {
+ param([Parameter(Mandatory)][string]$Path)
+ try {
+ foreach ($stream in @(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop)) {
+ if ([string]$stream.Stream -cnotin @(':$DATA','::$DATA','$DATA')) { return $true }
+ }
+ return $false
+ } catch {
+ throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
+ }
+}
+
+function Remove-CcodActivationPayloadStage {
+ param([Parameter(Mandatory)][string]$AppRoot,[Parameter(Mandatory)][string]$StageRoot)
+ if (-not [IO.Directory]::Exists($StageRoot)) { return }
+ $app = [IO.Path]::GetFullPath($AppRoot).TrimEnd('\')
+ $stage = [IO.Path]::GetFullPath($StageRoot).TrimEnd('\')
+ $prefix = $app + '\'
+ if (-not $stage.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -or
+ [IO.Path]::GetFileName($stage) -cnotmatch '^\.activation-payload-stage-[0-9a-f]{32}$') { return }
+ foreach ($item in @(Get-ChildItem -LiteralPath $stage -Force -Recurse -ErrorAction SilentlyContinue)) {
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return }
+ }
+ $stageItem = Get-Item -LiteralPath $stage -Force -ErrorAction SilentlyContinue
+ if ($null -eq $stageItem -or ($stageItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return }
+ Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+function Close-CcodActivationPayloadSeal {
+ param($Seal)
+ if ($null -eq $Seal) { return }
+ foreach ($stream in @($Seal.Streams)) {
+ if ($null -ne $stream) { try { $stream.Dispose() } catch { } }
+ }
+ $directoryPins = @($Seal.DirectoryPins)
+ for ($index=$directoryPins.Count-1;$index-ge0;$index--) {
+ if ($null -ne $directoryPins[$index]) { try { $directoryPins[$index].Dispose() } catch { } }
+ }
+ Remove-CcodActivationPayloadStage -AppRoot $Seal.AppRoot -StageRoot $Seal.StageRoot
+}
+
+function New-CcodActivationPayloadSeal {
+ param(
+ [Parameter(Mandatory)][string]$AppRoot,
+ [Parameter(Mandatory)][string]$Root,
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$ExpectedManifestSha256
+ )
+
+ if ($Version -cnotmatch '^\d+\.\d+\.\d+$' -or $ExpectedManifestSha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+ $app = [IO.Path]::GetFullPath($AppRoot).TrimEnd('\')
+ $payload = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+ $expectedPayload = [IO.Path]::GetFullPath((Join-Path (Join-Path $app 'payload') $Version)).TrimEnd('\')
+ if (-not $payload.Equals($expectedPayload,[StringComparison]::OrdinalIgnoreCase)) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' }
+ [void](Assert-CcodActivationNonReparsePath -Root $app -Path $app)
+ [void](Assert-CcodActivationNonReparsePath -Root $app -Path (Join-Path $app 'payload'))
+ [void](Assert-CcodActivationNonReparsePath -Root $app -Path $payload)
+ $packagePath = Join-Path $payload 'package.json'
+ $manifestPath = Join-Path $payload 'installer-payload.manifest.json'
+ $modulePath = Join-Path $payload 'src\persistence\modules\InstallLifecycle.psm1'
+ if (-not [IO.File]::Exists($packagePath) -or -not [IO.File]::Exists($manifestPath) -or -not [IO.File]::Exists($modulePath)) {
+ throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
+ }
+ [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $manifestPath)
+ $sourceLocks = [Collections.Generic.List[object]]::new()
+ $stageLocks = [Collections.Generic.List[object]]::new()
+ $stageDirectoryPins = [Collections.Generic.List[object]]::new()
+ $stageRoot = Join-Path $app ('.activation-payload-stage-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $manifestLock = Open-CcodActivationLockedBytes -Path $manifestPath -MaximumBytes 4194304
+ $sourceLocks.Add($manifestLock)
+ $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop
+ if ($manifestItem.PSIsContainer -or $manifestLock.Bytes.Length -le 0 -or
+ (Test-CcodActivationAlternateDataStreams -Path $manifestPath) -or
+ (Get-CcodActivationBytesSha256 -Bytes $manifestLock.Bytes) -cne $ExpectedManifestSha256) {
+ throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
+ }
+ try {
+ $manifestText = [Text.UTF8Encoding]::new($false,$true).GetString($manifestLock.Bytes)
+ $manifest = $manifestText | ConvertFrom-Json -ErrorAction Stop
+ } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $properties = @($manifest.PSObject.Properties.Name)
+ $expectedProperties = @('schemaVersion','projectVersion','files')
+ if ($manifest -isnot [pscustomobject] -or $properties.Count -ne $expectedProperties.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ for ($index=0;$index-lt$expectedProperties.Count;$index++) {
+ if ($properties[$index] -cne $expectedProperties[$index]) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ }
+ if ($manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or $manifest.projectVersion -isnot [string] -or $manifest.projectVersion -cne $Version) {
+ throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
+ }
+ $records = @($manifest.files)
+ if ($records.Count -eq 0) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ $snapshots = [Collections.Generic.List[object]]::new()
+ $previous = $null
+ foreach ($record in $records) {
+ $recordProperties = @($record.PSObject.Properties.Name)
+ if ($record -isnot [pscustomobject] -or $recordProperties.Count -ne 3 -or $recordProperties[0] -cne 'path' -or $recordProperties[1] -cne 'length' -or $recordProperties[2] -cne 'sha256' -or
+ $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or
+ $record.length -isnot [ValueType] -or [decimal]$record.length -ne [decimal][int64]$record.length -or [int64]$record.length -lt 0 -or
+ $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ if (($null-ne$previous -and [StringComparer]::Ordinal.Compare([string]$previous,[string]$record.path)-ge 0) -or -not $seen.Add([string]$record.path)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $previous = [string]$record.path
+ $source = [IO.Path]::GetFullPath((Join-Path $payload ($record.path.Replace('/','\'))))
+ [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $source)
+ $locked = Open-CcodActivationLockedBytes -Path $source
+ $sourceLocks.Add($locked)
+ $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ (Test-CcodActivationAlternateDataStreams -Path $source) -or [int64]$locked.Bytes.Length -ne [int64]$record.length -or
+ (Get-CcodActivationBytesSha256 -Bytes $locked.Bytes) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' }
+ $snapshots.Add([pscustomobject]@{ Relative=[string]$record.path; Bytes=$locked.Bytes; Sha256=[string]$record.sha256 })
+ }
+ foreach ($required in @('package.json','Install-CodexControlOtherDevices.ps1','src/persistence/modules/InstallLifecycle.psm1','src/persistence/modules/RuntimeManifest.psm1')) {
+ if (-not $seen.Contains($required)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ }
+ $actualFileList = [Collections.Generic.List[string]]::new()
+ foreach ($actualItem in @(Get-ChildItem -LiteralPath $payload -Force -Recurse -ErrorAction Stop)) {
+ if (($actualItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
+ if ($actualItem.PSIsContainer -or $actualItem.FullName.Equals($manifestPath,[StringComparison]::OrdinalIgnoreCase)) { continue }
+ $actualFileList.Add($actualItem.FullName.Substring($payload.Length + 1).Replace('\','/'))
+ }
+ $actualFiles = @($actualFileList)
+ if ($actualFiles.Count -ne $records.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ foreach ($actual in $actualFiles) { if (-not $seen.Contains([string]$actual)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } }
+ $packageSnapshot = @($snapshots | Where-Object { $_.Relative -ceq 'package.json' })
+ try { $package = [Text.UTF8Encoding]::new($false,$true).GetString([byte[]]$packageSnapshot[0].Bytes) | ConvertFrom-Json -ErrorAction Stop }
+ catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ if ($packageSnapshot.Count -ne 1 -or $package.version -isnot [string] -or [string]$package.version -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+
+ Initialize-CcodActivationStageNative
+ $appPin = [CcodActivationStageNative]::OpenExistingDirectory($app)
+ $stageDirectoryPins.Add($appPin)
+ $stageName = [IO.Path]::GetFileName($stageRoot)
+ $stagePin = [CcodActivationStageNative]::CreateDirectoryRelative($appPin,$stageName)
+ $stageDirectoryPins.Add($stagePin)
+ $directories = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::Ordinal)
+ $directories.Add('',$stagePin)
+ [void](Assert-CcodActivationNonReparsePath -Root $app -Path $stageRoot)
+ foreach ($snapshot in @($snapshots) + @([pscustomobject]@{Relative='installer-payload.manifest.json';Bytes=$manifestLock.Bytes;Sha256=$ExpectedManifestSha256})) {
+ $destination = [IO.Path]::GetFullPath((Join-Path $stageRoot ([string]$snapshot.Relative).Replace('/','\')))
+ $stagePrefix = $stageRoot.TrimEnd('\') + '\'
+ if (-not $destination.StartsWith($stagePrefix,[StringComparison]::OrdinalIgnoreCase)) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' }
+ $relativeDirectory = [IO.Path]::GetDirectoryName(([string]$snapshot.Relative).Replace('/','\'))
+ $parentPin = Get-CcodActivationPinnedDirectory -Directories $directories -DirectoryPins $stageDirectoryPins -RelativeDirectory $relativeDirectory
+ $stageLock = Open-CcodActivationPinnedLeafBytes -ParentDirectory $parentPin -Leaf ([IO.Path]::GetFileName($destination)) -Bytes ([byte[]]$snapshot.Bytes)
+ [void](Assert-CcodActivationNonReparsePath -Root $stageRoot -Path $destination)
+ $stageLocks.Add($stageLock)
+ if ((Get-CcodActivationBytesSha256 -Bytes $stageLock.Bytes) -cne [string]$snapshot.Sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' }
+ }
+ return [pscustomobject]@{
+ AppRoot=$app
+ StageRoot=[IO.Path]::GetFullPath($stageRoot)
+ ManifestPath=Join-Path $stageRoot 'installer-payload.manifest.json'
+ ManifestBytes=$manifestLock.Bytes
+ Streams=@($stageLocks | ForEach-Object { $_.Stream })
+ DirectoryPins=@($stageDirectoryPins)
+ }
+ } catch {
+ foreach ($locked in @($stageLocks)) { try { $locked.Stream.Dispose() } catch { } }
+ for ($pinIndex=$stageDirectoryPins.Count-1;$pinIndex-ge0;$pinIndex--) { try { $stageDirectoryPins[$pinIndex].Dispose() } catch { } }
+ Remove-CcodActivationPayloadStage -AppRoot $app -StageRoot $stageRoot
+ throw
+ } finally {
+ foreach ($locked in @($sourceLocks)) { try { $locked.Stream.Dispose() } catch { } }
+ }
+}
+
+function Assert-CcodActivatedRuntimeVersion {
+ param(
+ [Parameter(Mandatory)][string]$Root,
+ [Parameter(Mandatory)][string]$RuntimeId,
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$RuntimeModulePath
+ )
+
+ $module = Import-Module $RuntimeModulePath -Force -PassThru
+ try {
+ $pointer = Read-CcodActiveRuntime -InstallRoot $Root
+ if ($pointer.activeRuntime -cne $RuntimeId) { throw 'CCOD_ACTIVATION_RUNTIME_VERSION_MISMATCH' }
+ $runtimeRoot = Join-Path (Join-Path $Root 'runtime') $RuntimeId
+ $validation = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $RuntimeId
+ if (-not $validation.Valid -or [string]$validation.Manifest.projectVersion -cne $Version) {
+ throw 'CCOD_ACTIVATION_RUNTIME_VERSION_MISMATCH'
+ }
+ } finally {
+ Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue
+ }
+}
+
function Assert-CcodActivationReceiptPathSafe {
param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path)
$canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar)
@@ -76,8 +658,21 @@ function Assert-CcodActivationReceiptPathSafe {
function Read-CcodTerminalActivationReceipt {
param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$ExpectedActivationId)
- $path = Join-Path $Root 'state\post-install-activation.json'
- if (-not [IO.File]::Exists($path)) { throw 'CCOD_ACTIVATION_RECEIPT_MISSING' }
+ $directory = Join-Path $Root 'state\activation-receipts'
+ if (-not [IO.Directory]::Exists($directory)) { throw 'CCOD_ACTIVATION_RECEIPT_MISSING' }
+ try { Assert-CcodActivationReceiptPathSafe -Root $Root -Path $directory } catch { throw 'CCOD_ACTIVATION_RECEIPT_INVALID' }
+ $terminal = @(
+ foreach ($phase in @('Ready','Failed')) {
+ $candidate = Join-Path $directory ("$ExpectedActivationId.$phase.json")
+ if ([IO.File]::Exists($candidate)) { $candidate }
+ }
+ )
+ if ($terminal.Count -eq 0) {
+ if (Test-CcodActivationReceiptObserved -Root $Root -ExpectedActivationId $ExpectedActivationId) { throw 'CCOD_ACTIVATION_RECEIPT_NOT_READY' }
+ throw 'CCOD_ACTIVATION_RECEIPT_MISSING'
+ }
+ if ($terminal.Count -ne 1) { throw 'CCOD_ACTIVATION_RECEIPT_INVALID' }
+ $path = $terminal[0]
try {
Assert-CcodActivationReceiptPathSafe -Root $Root -Path $path
$item = Get-Item -LiteralPath $path -Force -ErrorAction Stop
@@ -109,6 +704,7 @@ function Read-CcodTerminalActivationReceipt {
($receipt.phase -cnotin @('Ready','Failed') -and ($receipt.ready -or $null -ne $receipt.errorCode))) {
throw 'CCOD_ACTIVATION_RECEIPT_INVALID'
}
+ if ([IO.Path]::GetFileName($path) -cne ("$ExpectedActivationId.$($receipt.phase).json")) { throw 'CCOD_ACTIVATION_RECEIPT_INVALID' }
return $receipt
}
@@ -158,7 +754,9 @@ function Invoke-CcodOwnedInstallWorker {
param(
[Parameter(Mandatory)][string]$PowerShellPath,
[Parameter(Mandatory)][string]$InstallScript,
+ [Parameter(Mandatory)][string]$VerifiedPayloadRoot,
[Parameter(Mandatory)][string]$StateRoot,
+ [string]$SealedPackageSha256,
[Parameter(Mandatory)][int]$FirstReceiptTimeout,
[Parameter(Mandatory)][int]$ActivationTimeout
)
@@ -179,6 +777,16 @@ function Invoke-CcodOwnedInstallWorker {
'-ActivationId',
$ActivationId
)
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
+ $arguments += @(
+ '-ExpectedVersion',$ExpectedVersion,
+ '-PayloadManifestPath',(Join-Path $VerifiedPayloadRoot 'installer-payload.manifest.json'),
+ '-ExpectedPayloadManifestSha256',$ExpectedPayloadManifestSha256
+ )
+ }
+ if (-not [string]::IsNullOrWhiteSpace($SealedPackageSha256)) {
+ $arguments += @('-SealedPackageSha256',$SealedPackageSha256)
+ }
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $PowerShellPath
$startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ')
@@ -191,8 +799,7 @@ function Invoke-CcodOwnedInstallWorker {
while (-not $process.HasExited) {
if (-not $firstReceiptObserved) {
try {
- $null = Read-CcodTerminalActivationReceipt -Root $StateRoot -ExpectedActivationId $ActivationId
- $firstReceiptObserved = $true
+ $firstReceiptObserved = Test-CcodActivationReceiptObserved -Root $StateRoot -ExpectedActivationId $ActivationId
} catch { }
}
if (-not $firstReceiptObserved -and $clock.ElapsedMilliseconds -ge $FirstReceiptTimeout) {
@@ -235,12 +842,15 @@ function Invoke-CcodBoundedReceiptValidator {
$PSCommandPath,
'-AppRoot',
$AppRoot,
+ '-PayloadRoot',
+ $PayloadRoot,
'-InstallRoot',
$InstallRoot,
'-ValidateReceiptOnly',
'-ActivationId',
$ActivationId
)
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { $arguments += @('-ExpectedVersion',$ExpectedVersion,'-ExpectedPayloadManifestSha256',$ExpectedPayloadManifestSha256) }
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $hostExecutable
$startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ')
@@ -286,6 +896,59 @@ function Show-CcodActivationFailure {
}
if ($ValidateReceiptOnly -and $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_VALIDATOR_MODE_INVALID' -ErrorAction Continue; exit 3 }
+$packageBound = -not [string]::IsNullOrWhiteSpace($PackagePath) -or -not [string]::IsNullOrWhiteSpace($PackageManifestPath) -or -not [string]::IsNullOrWhiteSpace($ExpectedPackageSha256) -or -not [string]::IsNullOrWhiteSpace($ExpectedPackageManifestSha256) -or -not [string]::IsNullOrWhiteSpace($ExpectedGitCommit)
+$packageSeal = $null
+$packageAppRoot = $null
+$payloadSeal = $null
+try {
+if ($packageBound) {
+ if ([string]::IsNullOrWhiteSpace($PackagePath) -or [string]::IsNullOrWhiteSpace($PackageManifestPath) -or [string]::IsNullOrWhiteSpace($ExpectedPackageSha256) -or [string]::IsNullOrWhiteSpace($ExpectedPackageManifestSha256) -or [string]::IsNullOrWhiteSpace($ExpectedVersion) -or [string]::IsNullOrWhiteSpace($ExpectedGitCommit)) { throw 'CCOD_INSTALLER_PACKAGE_INVALID' }
+ try {
+ $packageSeal = Open-CcodInstallerPackageSeal -PackagePath $PackagePath -PackageManifestPath $PackageManifestPath -ExpectedPackageSha256 $ExpectedPackageSha256 -ExpectedPackageManifestSha256 $ExpectedPackageManifestSha256 -ExpectedVersion $ExpectedVersion -ExpectedGitCommit $ExpectedGitCommit
+ $packageDefender = Invoke-CcodSetupPackageDefenderGate -PackagePath ([string]$packageSeal.PackagePath)
+ $packageAppRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-package-' + [guid]::NewGuid().ToString('N'))
+ [IO.Directory]::CreateDirectory((Join-Path $packageAppRoot 'payload')) | Out-Null
+ $PayloadRoot = Join-Path (Join-Path $packageAppRoot 'payload') $ExpectedVersion
+ [void](Expand-CcodInstallerPackageSeal -Seal $packageSeal -DestinationRoot $PayloadRoot)
+ $AppRoot = $packageAppRoot
+ $ExpectedPayloadManifestSha256 = [string]$packageSeal.PayloadManifestSha256
+ } catch {
+ $candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0]
+ $code = if ($candidate -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $candidate } elseif ($_.Exception.Message -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $Matches[0] } else { 'CCOD_INSTALLER_PACKAGE_INVALID' }
+ Write-Error $code -ErrorAction Continue
+ exit 3
+ }
+} else {
+ if ([string]::IsNullOrWhiteSpace($AppRoot)) { Write-Error 'CCOD_INSTALL_INPUT_INVALID' -ErrorAction Continue; exit 3 }
+ if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $AppRoot }
+}
+
+function Test-CcodActivationReceiptObserved {
+ param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$ExpectedActivationId)
+ $directory = Join-Path $Root 'state\activation-receipts'
+ if (-not [IO.Directory]::Exists($directory)) { return $false }
+ try {
+ Assert-CcodActivationReceiptPathSafe -Root $Root -Path $directory
+ foreach ($phase in $script:CcodActivationReceiptPhases) {
+ $path = Join-Path $directory ("$ExpectedActivationId.$phase.json")
+ if ([IO.File]::Exists($path)) {
+ Assert-CcodActivationReceiptPathSafe -Root $Root -Path $path
+ return $true
+ }
+ }
+ } catch { return $false }
+ return $false
+}
+$PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot)
+if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
+ try { $payloadSeal = New-CcodActivationPayloadSeal -AppRoot $AppRoot -Root $PayloadRoot -Version $ExpectedVersion -ExpectedManifestSha256 $ExpectedPayloadManifestSha256 }
+ catch {
+ $candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0]
+ $code = if ($candidate -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $candidate } elseif ($_.Exception.Message -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $Matches[0] } else { 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ Write-Error $code -ErrorAction Continue
+ exit 3
+ }
+}
if ([string]::IsNullOrWhiteSpace($ActivationId)) {
if ($ValidateReceiptOnly -or $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_ID_INVALID' -ErrorAction Continue; exit 3 }
$ActivationId = [guid]::NewGuid().ToString('D')
@@ -299,7 +962,12 @@ if ($ValidateReceiptWithTimeout) {
if ($ValidateReceiptOnly) {
try {
$terminalReceipt = Read-CcodTerminalActivationReceipt -Root ([IO.Path]::GetFullPath($InstallRoot)) -ExpectedActivationId $ActivationId
- if ($terminalReceipt.phase -ceq 'Ready') { exit 0 }
+ if ($terminalReceipt.phase -ceq 'Ready') {
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
+ Assert-CcodActivatedRuntimeVersion -Root ([IO.Path]::GetFullPath($InstallRoot)) -RuntimeId ([string]$terminalReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $payloadSeal.StageRoot 'src\persistence\modules\RuntimeManifest.psm1')
+ }
+ exit 0
+ }
if ($terminalReceipt.phase -ceq 'Failed') { exit 2 }
throw 'CCOD_ACTIVATION_RECEIPT_NOT_READY'
} catch {
@@ -313,11 +981,12 @@ $clock = [Diagnostics.Stopwatch]::StartNew()
try {
$root = [IO.Path]::GetFullPath($AppRoot)
$stateRoot = [IO.Path]::GetFullPath($InstallRoot)
- $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1'
+ $executionPayloadRoot = if ($null -ne $payloadSeal) { [string]$payloadSeal.StageRoot } else { $PayloadRoot }
+ $installScript = Join-Path $executionPayloadRoot 'Install-CodexControlOtherDevices.ps1'
if (-not [IO.File]::Exists($installScript)) { throw 'CCOD_ACTIVATION_INSTALL_SCRIPT_MISSING' }
$powershell = (Get-Command powershell.exe -ErrorAction Stop).Source
Write-CcodActivationRecord -Code 'STARTED' -DurationMilliseconds $clock.ElapsedMilliseconds
- $installExitCode = Invoke-CcodOwnedInstallWorker -PowerShellPath $powershell -InstallScript $installScript -StateRoot $stateRoot -FirstReceiptTimeout $FirstReceiptTimeoutMilliseconds -ActivationTimeout $ActivationTimeoutMilliseconds
+ $installExitCode = Invoke-CcodOwnedInstallWorker -PowerShellPath $powershell -InstallScript $installScript -VerifiedPayloadRoot $executionPayloadRoot -StateRoot $stateRoot -SealedPackageSha256 $(if($packageBound){$ExpectedPackageSha256}else{$null}) -FirstReceiptTimeout $FirstReceiptTimeoutMilliseconds -ActivationTimeout $ActivationTimeoutMilliseconds
if ($installExitCode -ne 0) {
$reportedErrorCode = $null
try {
@@ -328,6 +997,9 @@ try {
throw 'CCOD_ACTIVATION_RUNTIME_FAILED'
}
$activationReceipt = Read-CcodFinalActivationReceipt -Root $stateRoot -ExpectedActivationId $ActivationId
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
+ Assert-CcodActivatedRuntimeVersion -Root $stateRoot -RuntimeId ([string]$activationReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $payloadSeal.StageRoot 'src\persistence\modules\RuntimeManifest.psm1')
+ }
Write-CcodActivationRecord -Code 'RUNTIME_ACTIVATED' -DurationMilliseconds $clock.ElapsedMilliseconds
} catch {
$candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0]
@@ -357,3 +1029,8 @@ if ($Prompt) {
Write-CcodActivationRecord -Code 'COMPLETED' -DurationMilliseconds $clock.ElapsedMilliseconds
exit 0
+} finally {
+ Close-CcodActivationPayloadSeal -Seal $payloadSeal
+ Close-CcodInstallerPackageSeal -Seal $packageSeal
+ if ($null -ne $packageAppRoot -and [IO.Directory]::Exists($packageAppRoot)) { Remove-Item -LiteralPath $packageAppRoot -Recurse -Force -ErrorAction SilentlyContinue }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index efe008c..ca61f20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,24 @@ This file keeps the release record. GitHub Release bodies are generated from the
No unreleased changes.
+## v2.5.22
+
+### English
+
+- Bound Setup activation and Ready reporting to verified manifest, installer, and module bytes; rejected hard-linked install leaves and independently checked Setup PE/provenance against the version, commit, and payload-manifest hash.
+- Added bounded same-root process-tree rereads while preserving strict root and per-process identity checks during close and remote rebind.
+- Correlated native tray actions with the acknowledged presentation revision and durable, bounded terminal diagnostics; stale actions now produce a sanitized support code.
+- Made v2.5.22 candidate promotion require two distinct InternetDownload Defender receipts for the exact Setup and portable ZIP identities; workflow-artifact evidence cannot substitute.
+- Added manifest- and state-verified uninstall residue cleanup with durable retry after partial deletion; unknown files, protected user data, and unavailable process observations block completion instead of being silently ignored.
+
+### 简体中文
+
+- 将 Setup 激活与 Ready 报告绑定到已验证的 manifest、installer 和 module 字节;写入前拒绝硬链接叶子,并独立核对 Setup PE/构建 provenance 的版本、commit 与 payload-manifest 哈希。
+- 为同一根进程加入有界 process-tree 重读,同时在关闭和远程重绑定时继续严格校验根进程与每个成员的身份。
+- 将原生托盘操作绑定到已确认的 presentation revision 和持久、有界的终态诊断;过期操作会记录清理后的支持代码。
+- v2.5.22 候选版本只有在精确 Setup 与便携 ZIP 分别具备不同的 InternetDownload Defender 收据后才能提升;工作流产物证据不能替代它们。
+- 新增基于清单与状态核验的卸载残留清理,部分删除后可持久化重试;遇到未知文件、受保护用户数据或无法确认的进程状态时停止,不再静默忽略并报告完成。
+
## v2.5.21
### English
diff --git a/Install-CodexControlOtherDevices.ps1 b/Install-CodexControlOtherDevices.ps1
index 3b5d71f..718e4a7 100644
--- a/Install-CodexControlOtherDevices.ps1
+++ b/Install-CodexControlOtherDevices.ps1
@@ -4,11 +4,138 @@ param(
[switch]$EnableCandidateCompatibleUpdates,
[switch]$RepairState,
[switch]$DoNotStart,
- [string]$ActivationId
+ [string]$ActivationId,
+ [string]$ExpectedVersion,
+ [string]$PayloadManifestPath,
+ [string]$ExpectedPayloadManifestSha256,
+ [string]$SealedPackageSha256
)
$ErrorActionPreference = 'Stop'
+function Get-CcodInstallerBytesSha256 {
+ param([Parameter(Mandatory)][byte[]]$Bytes)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant() }
+ finally { $sha.Dispose() }
+}
+
+function Open-CcodInstallerLockedBytes {
+ param([Parameter(Mandatory)][string]$Path,[int64]$MaximumBytes = 268435456)
+ $full = [IO.Path]::GetFullPath($Path)
+ $stream = [IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ try {
+ if ($stream.Length -lt 0 -or $stream.Length -gt $MaximumBytes) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $memory = [IO.MemoryStream]::new()
+ try { $stream.CopyTo($memory); $bytes = $memory.ToArray() } finally { $memory.Dispose() }
+ return [pscustomobject]@{Path=$full;Stream=$stream;Bytes=$bytes}
+ } catch { $stream.Dispose(); throw }
+}
+
+function Assert-CcodInstallerPayloadPath {
+ param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path)
+ $canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+ $candidate = [IO.Path]::GetFullPath($Path)
+ $prefix = $canonicalRoot + '\'
+ if (-not ($candidate.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase) -or $candidate.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase))) {
+ throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID'
+ }
+ $cursor = $candidate
+ while ($true) {
+ $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
+ if ($cursor.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase)) { break }
+ $cursor = Split-Path $cursor -Parent
+ }
+ return $candidate
+}
+
+function Test-CcodInstallerAlternateDataStreams {
+ param([Parameter(Mandatory)][string]$Path)
+ try {
+ foreach ($stream in @(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop)) {
+ if ([string]$stream.Stream -cnotin @(':$DATA','::$DATA','$DATA')) { return $true }
+ }
+ return $false
+ } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+}
+
+function Open-CcodInstallerPayloadSeal {
+ param(
+ [Parameter(Mandatory)][string]$SourceRoot,
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$ManifestPath,
+ [Parameter(Mandatory)][string]$ExpectedManifestSha256
+ )
+ if ($Version -cnotmatch '^\d+\.\d+\.\d+$' -or $ExpectedManifestSha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+ $root = [IO.Path]::GetFullPath($SourceRoot).TrimEnd('\')
+ [void](Assert-CcodInstallerPayloadPath -Root $root -Path $root)
+ $manifestFile = Assert-CcodInstallerPayloadPath -Root $root -Path $ManifestPath
+ $locks = [Collections.Generic.List[object]]::new()
+ try {
+ $manifestLock = Open-CcodInstallerLockedBytes -Path $manifestFile -MaximumBytes 4194304
+ $locks.Add($manifestLock)
+ if ((Test-CcodInstallerAlternateDataStreams -Path $manifestFile) -or
+ (Get-CcodInstallerBytesSha256 -Bytes $manifestLock.Bytes) -cne $ExpectedManifestSha256) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ try {
+ $manifestText = [Text.UTF8Encoding]::new($false,$true).GetString($manifestLock.Bytes)
+ $manifest = $manifestText | ConvertFrom-Json -ErrorAction Stop
+ } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $fields = @($manifest.PSObject.Properties.Name)
+ if ($manifest -isnot [pscustomobject] -or ($fields -join ',') -cne 'schemaVersion,projectVersion,files' -or
+ $manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or
+ $manifest.projectVersion -isnot [string] -or $manifest.projectVersion -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+ $records = @($manifest.files)
+ if ($records.Count -eq 0) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ $previous = $null
+ $packageBytes = $null
+ foreach ($record in $records) {
+ $recordFields = @($record.PSObject.Properties.Name)
+ if ($record -isnot [pscustomobject] -or ($recordFields -join ',') -cne 'path,length,sha256' -or
+ $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or
+ $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or
+ $record.length -isnot [ValueType] -or [decimal]$record.length -ne [decimal][int64]$record.length -or [int64]$record.length -lt 0 -or
+ $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$' -or
+ ($null -ne $previous -and [StringComparer]::Ordinal.Compare([string]$previous,[string]$record.path) -ge 0) -or
+ -not $seen.Add([string]$record.path)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $previous = [string]$record.path
+ $source = Assert-CcodInstallerPayloadPath -Root $root -Path (Join-Path $root ([string]$record.path).Replace('/','\'))
+ $locked = Open-CcodInstallerLockedBytes -Path $source
+ $locks.Add($locked)
+ $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ (Test-CcodInstallerAlternateDataStreams -Path $source) -or [int64]$locked.Bytes.Length -ne [int64]$record.length -or
+ (Get-CcodInstallerBytesSha256 -Bytes $locked.Bytes) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' }
+ if ([string]$record.path -ceq 'package.json') { $packageBytes = $locked.Bytes }
+ }
+ foreach ($required in @('package.json','Install-CodexControlOtherDevices.ps1','src/persistence/modules/InstallLifecycle.psm1','src/persistence/modules/RuntimeManifest.psm1')) {
+ if (-not $seen.Contains($required)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ }
+ $actual = [Collections.Generic.List[string]]::new()
+ foreach ($item in @(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop)) {
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
+ if ($item.PSIsContainer -or $item.FullName.Equals($manifestFile,[StringComparison]::OrdinalIgnoreCase)) { continue }
+ $actual.Add($item.FullName.Substring($root.Length + 1).Replace('\','/'))
+ }
+ if ($actual.Count -ne $records.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ foreach ($relative in $actual) { if (-not $seen.Contains($relative)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } }
+ try { $package = [Text.UTF8Encoding]::new($false,$true).GetString([byte[]]$packageBytes) | ConvertFrom-Json -ErrorAction Stop }
+ catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ if ($package.version -isnot [string] -or [string]$package.version -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+ return [pscustomobject]@{ManifestBytes=$manifestLock.Bytes;Locks=@($locks | ForEach-Object {$_.Stream})}
+ } catch {
+ foreach ($locked in @($locks)) { try { $locked.Stream.Dispose() } catch { } }
+ throw
+ }
+}
+
+function Close-CcodInstallerPayloadSeal {
+ param($Seal)
+ if ($null -eq $Seal) { return }
+ foreach ($stream in @($Seal.Locks)) { if ($null -ne $stream) { try { $stream.Dispose() } catch { } } }
+}
+
function Resolve-CcodInstallerModule {
param([Parameter(Mandatory)][string]$CheckoutRoot)
@@ -24,20 +151,44 @@ function Resolve-CcodInstallerModule {
return $modulePath
}
-$script:InstallerModule = Resolve-CcodInstallerModule -CheckoutRoot $PSScriptRoot
-Import-Module $script:InstallerModule -Force
-
if ([string]::IsNullOrWhiteSpace($InstallRoot)) {
$InstallRoot = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices'
}
-$receipt = Invoke-CcodInstall `
- -SourceRoot $PSScriptRoot `
- -InstallRoot $InstallRoot `
- -EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) `
- -RepairState:([bool]$RepairState) `
- -DoNotStart:([bool]$DoNotStart) `
- -ActivationId $ActivationId
+$payloadBound = -not [string]::IsNullOrWhiteSpace($ExpectedVersion) -or -not [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or -not [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256)
+$payloadSeal = $null
+$installerModule = $null
+try {
+ if ($payloadBound) {
+ if ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256)) {
+ throw 'CCOD_INSTALL_INPUT_INVALID'
+ }
+ $payloadSeal = Open-CcodInstallerPayloadSeal -SourceRoot $PSScriptRoot -Version $ExpectedVersion -ManifestPath $PayloadManifestPath -ExpectedManifestSha256 $ExpectedPayloadManifestSha256
+ }
+ $script:InstallerModule = Resolve-CcodInstallerModule -CheckoutRoot $PSScriptRoot
+ $installerModule = Import-Module $script:InstallerModule -Force -PassThru
+ $invoke = @{
+ SourceRoot = $PSScriptRoot
+ InstallRoot = $InstallRoot
+ EnableCandidateCompatibleUpdates = [bool]$EnableCandidateCompatibleUpdates
+ RepairState = [bool]$RepairState
+ DoNotStart = [bool]$DoNotStart
+ ActivationId = $ActivationId
+ }
+ if ($payloadBound) {
+ $invoke.ExpectedVersion = $ExpectedVersion
+ $invoke.PayloadManifestPath = $PayloadManifestPath
+ $invoke.ExpectedPayloadManifestSha256 = $ExpectedPayloadManifestSha256
+ $invoke.PayloadManifestBytesBase64 = [Convert]::ToBase64String([byte[]]$payloadSeal.ManifestBytes)
+ }
+ if (-not [string]::IsNullOrWhiteSpace($SealedPackageSha256)) {
+ $invoke.SealedPackageSha256 = $SealedPackageSha256
+ }
+ $receipt = Invoke-CcodInstall @invoke
+} finally {
+ if ($null -ne $installerModule) { Remove-Module -Name $installerModule.Name -Force -ErrorAction SilentlyContinue }
+ Close-CcodInstallerPayloadSeal -Seal $payloadSeal
+}
Write-Host ''
Write-Host 'CodexRemote-fix - install result' -ForegroundColor Cyan
diff --git a/Install-CodexRemote-fix.ps1 b/Install-CodexRemote-fix.ps1
index 816a4e7..8b15217 100644
--- a/Install-CodexRemote-fix.ps1
+++ b/Install-CodexRemote-fix.ps1
@@ -62,21 +62,29 @@ function Get-CcodPortableInstallerDetectionKeys {
function Invoke-CcodPortableInstallerDefenderGate {
param([Parameter(Mandatory)][string]$PayloadRoot)
try {
+ $started = [DateTime]::UtcNow
$status = Get-MpComputerStatus -ErrorAction Stop
- if ($null -eq $status -or $null -eq $status.PSObject.Properties['AMProductVersion'] -or
- $null -eq $status.PSObject.Properties['AntivirusSignatureVersion'] -or
+ if ($null -eq $status -or
+ $null -eq $status.PSObject.Properties['AMServiceEnabled'] -or $status.AMServiceEnabled -isnot [bool] -or -not $status.AMServiceEnabled -or
+ $null -eq $status.PSObject.Properties['AntivirusEnabled'] -or $status.AntivirusEnabled -isnot [bool] -or -not $status.AntivirusEnabled -or
+ $null -eq $status.PSObject.Properties['RealTimeProtectionEnabled'] -or $status.RealTimeProtectionEnabled -isnot [bool] -or -not $status.RealTimeProtectionEnabled -or
+ $null -eq $status.PSObject.Properties['AMProductVersion'] -or
$status.AMProductVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AMProductVersion) -or
+ $null -eq $status.PSObject.Properties['AMEngineVersion'] -or
+ $status.AMEngineVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AMEngineVersion) -or
+ $null -eq $status.PSObject.Properties['AntivirusSignatureVersion'] -or
$status.AntivirusSignatureVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AntivirusSignatureVersion) -or
- $null -eq $status.PSObject.Properties['RealTimeProtectionEnabled'] -or $status.RealTimeProtectionEnabled -isnot [bool] -or
- -not $status.RealTimeProtectionEnabled) {
- throw 'Defender status is incomplete or real-time protection is not enabled.'
+ $null -eq $status.PSObject.Properties['AntivirusSignatureLastUpdated'] -or $status.AntivirusSignatureLastUpdated -isnot [datetime]) {
+ throw 'Defender status is incomplete or protection is not enabled.'
}
+ $signatureUpdated = ([datetime]$status.AntivirusSignatureLastUpdated).ToUniversalTime()
+ if ($signatureUpdated -lt $started.AddHours(-72) -or $signatureUpdated -gt $started.AddMinutes(5)) { throw 'Defender signature timestamp is stale or future-dated.' }
$before = Get-CcodPortableInstallerDetectionKeys -Records @(Get-MpThreatDetection -ErrorAction Stop)
- $started = [DateTime]::UtcNow
$scanError = $null
try { Start-MpScan -ScanType CustomScan -ScanPath $PayloadRoot -ErrorAction Stop }
catch { $scanError = $_ }
$completed = [DateTime]::UtcNow
+ if ($completed -lt $started -or $completed -gt $started.AddHours(2)) { throw 'Defender scan clock is reversed or exceeds the two-hour bound.' }
$after = Get-CcodPortableInstallerDetectionKeys -Records @(Get-MpThreatDetection -ErrorAction Stop)
$newDetections = @($after | Where-Object { -not $before.Contains($_) })
if ($null -ne $scanError) {
@@ -89,10 +97,16 @@ function Invoke-CcodPortableInstallerDefenderGate {
Throw-CcodPortableInstallerError 'CCOD_PORTABLE_DEFENDER_DETECTIONS_FOUND' 'The portable payload disappeared during its Defender scan.' $PayloadRoot
}
return [pscustomobject][ordered]@{
+ defenderServiceEnabled = $true
+ antivirusEnabled = $true
+ realTimeProtectionEnabled = $true
defenderPlatformVersion = [string]$status.AMProductVersion
+ defenderEngineVersion = [string]$status.AMEngineVersion
signatureVersion = [string]$status.AntivirusSignatureVersion
+ signatureUpdatedAtUtc = $signatureUpdated.ToString('o',[Globalization.CultureInfo]::InvariantCulture)
scanStartedAtUtc = $started.ToString('o',[Globalization.CultureInfo]::InvariantCulture)
scanCompletedAtUtc = $completed.ToString('o',[Globalization.CultureInfo]::InvariantCulture)
+ detectionCount = 0
}
} catch {
if ($_.FullyQualifiedErrorId -match '^CCOD_PORTABLE_') { throw }
@@ -122,6 +136,29 @@ function Get-CcodPortableInstallerActiveRuntime {
return [pscustomobject][ordered]@{ RuntimeId=$active.activeRuntime; Generation=$generation }
}
+function Invoke-CcodPortableLifecycleInstaller {
+ param(
+ [Parameter(Mandatory)][string]$InstallerPath,
+ [Parameter(Mandatory)][string]$InstallRoot,
+ [Parameter(Mandatory)][string]$CopiedSealedPackageSha256,
+ [Parameter(Mandatory)][string]$InitialSealedPackageSha256,
+ [Parameter(Mandatory)][string]$RevalidatedSourceSealedPackageSha256,
+ [switch]$EnableCandidateCompatibleUpdates,
+ [switch]$DoNotStart
+ )
+ if ($CopiedSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or
+ $InitialSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or
+ $RevalidatedSourceSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or
+ $CopiedSealedPackageSha256 -cne $InitialSealedPackageSha256 -or
+ $CopiedSealedPackageSha256 -cne $RevalidatedSourceSealedPackageSha256) {
+ Throw-CcodPortableInstallerError 'CCOD_PORTABLE_PACKAGE_IDENTITY_INVALID' 'The initial, revalidated source, and copied portable payload identities differ before lifecycle activation.' $CopiedSealedPackageSha256
+ }
+ return & $InstallerPath -InstallRoot $InstallRoot `
+ -EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) `
+ -DoNotStart:([bool]$DoNotStart) `
+ -SealedPackageSha256 $CopiedSealedPackageSha256
+}
+
$bundleRoot = Assert-CcodPortableInstallerPlainDirectory -Path $PSScriptRoot -Kind 'Portable bundle root'
$payloadRoot = Assert-CcodPortableInstallerPlainDirectory -Path (Join-Path $bundleRoot 'payload') -Kind 'Portable payload root'
$payloadManifestPath = Assert-CcodPortableInstallerRegularFile -Path (Join-Path $bundleRoot 'payload-manifest.json') -Kind 'Portable payload manifest'
@@ -137,7 +174,7 @@ if ($null -eq $package -or $package.version -isnot [string] -or $package.version
$payload = Test-CcodPortablePayloadManifest -PayloadRoot $payloadRoot -ManifestPath $payloadManifestPath -ExpectedVersion $package.version
$defender = Invoke-CcodPortableInstallerDefenderGate -PayloadRoot $payloadRoot
-Test-CcodPortablePayloadManifest -PayloadRoot $payloadRoot -ManifestPath $payloadManifestPath -ExpectedVersion $package.version -ExpectedGitCommit $payload.GitCommit | Out-Null
+$revalidatedPayload = Test-CcodPortablePayloadManifest -PayloadRoot $payloadRoot -ManifestPath $payloadManifestPath -ExpectedVersion $package.version -ExpectedGitCommit $payload.GitCommit
$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
if ([string]::IsNullOrWhiteSpace($localAppData) -or -not [IO.Path]::IsPathRooted($localAppData)) {
@@ -151,7 +188,11 @@ if (-not $PSCmdlet.ShouldProcess($installRoot,'Install the verified CodexRemote-
$copied = Copy-CcodPortablePayload -PayloadRoot $payloadRoot -ManifestPath $payloadManifestPath
$installerPath = Assert-CcodPortableInstallerRegularFile -Path (Join-Path $copied.InstallerRoot 'Install-CodexControlOtherDevices.ps1') -Kind 'Installed lifecycle installer'
try {
- $installReceipt = & $installerPath -InstallRoot $installRoot -EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) -DoNotStart:([bool]$DoNotStart)
+ $installReceipt = Invoke-CcodPortableLifecycleInstaller -InstallerPath $installerPath -InstallRoot $installRoot `
+ -CopiedSealedPackageSha256 ([string]$copied.Manifest.PayloadManifestSha256) `
+ -InitialSealedPackageSha256 ([string]$payload.PayloadManifestSha256) `
+ -RevalidatedSourceSealedPackageSha256 ([string]$revalidatedPayload.PayloadManifestSha256) `
+ -EnableCandidateCompatibleUpdates:$EnableCandidateCompatibleUpdates -DoNotStart:$DoNotStart
} catch {
Throw-CcodPortableInstallerError 'CCOD_PORTABLE_INSTALL_FAILED' 'The verified portable payload could not activate the protected runtime.' $_
}
diff --git a/README.md b/README.md
index 7728700..1b76257 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
> [!NOTE]
-> **Release status:** v2.5.21 is the current stable release. The setup installer, EXE-based portable ZIP, tray actions, restart-and-repair flow, and persistent remote connection are covered by the release validation gates.
+> **Release status:** v2.5.22 is the current release candidate. Its setup installer and EXE-based portable ZIP are version- and manifest-bound; stable Windows acceptance still requires recorded install, upgrade, reboot, repair, UI, and Defender verification.
CodexRemote-fix enables the UI that ships with Codex Desktop for Windows but is hidden by a runtime defect:
@@ -50,19 +50,19 @@ that existing page and leaves the Codex UI, account authorization, and enrollmen
## Quick start
-1. Download either `CodexRemote-fix-2.5.21-setup.exe` (recommended installer) or `CodexRemote-fix-2.5.21-windows-x64.zip` from [Releases](https://github.com/naipi11/CodexRemote-fix/releases). Download and verify the matching `.sha256.txt` before continuing.
+1. When the v2.5.22 candidate assets are available on [Releases](https://github.com/naipi11/CodexRemote-fix/releases), download either `CodexRemote-fix-2.5.22-setup.exe` (recommended installer) or `CodexRemote-fix-2.5.22-windows-x64.zip`. Download and verify the matching `.sha256.txt` before continuing.
2. If you chose the setup installer, run it and follow the wizard. If you chose the portable ZIP, extract it into a new empty folder and double-click `CodexRemote-fix.exe`.
Both paths validate the payload, run a Microsoft Defender custom scan, and preserve the DPAPI device-key store.
3. The tray supervisor starts automatically. When the connection reports **Connected**, open **Settings → Connections → Control other devices** to enroll or use the device. Windows 10 users should ensure that .NET Framework 4.8 is installed for the native TrayHost.
-The current portable bundle, checksum, release manifest, and payload manifest are always published on the
-[Releases](https://github.com/naipi11/CodexRemote-fix/releases) page.
+Use the [Releases](https://github.com/naipi11/CodexRemote-fix/releases) page only after the candidate's exact asset set and checksums have been posted.
-Before replacing a portable build, use its installed `Uninstall-CodexControlOtherDevices.ps1`; settings and the DPAPI\r\ndevice-key store stay in place.
+Before replacing a portable build, use its installed `Uninstall-CodexControlOtherDevices.ps1`;
+settings and the DPAPI device-key store stay in place.
-Verified on Windows 11 · Codex Desktop `26.820.7780.0` · Node.js `22.23.1`:
-the hidden controller tab, restart-and-repair recovery, and persistent remote connection are working.
+Historical development checks used Windows 11, Codex Desktop `26.820.7780.0`,
+and Node.js `22.23.1`; they are not current v2.5.22 real-machine acceptance.
## Everyday use
@@ -85,12 +85,26 @@ The tray reports two independent, truthful status lines instead of inferring rea
## Releases
-Every tagged release ships a Windows setup installer and an EXE-entry portable ZIP, each with its SHA-256 checksum, release manifest, and the shared payload manifest. The `.github/workflows/release.yml` workflow builds both from the tag automatically, so v2.5.21 includes:
+The v2.5.22 candidate has an exact 11-file public asset contract. Defender scan receipts are acceptance evidence and are not public assets.
-- `CodexRemote-fix-2.5.21-setup.exe` and its checksum
-- `CodexRemote-fix-2.5.21-windows-x64.zip` and its checksum
-- `CodexRemote-fix-2.5.21-payload-manifest.json`
-- `CodexRemote-fix-2.5.21-release-manifest.json` and `CodexRemote-fix-2.5.21-setup-release-manifest.json`
+
+Exact 11-file candidate asset contract
+
+- `CodexRemote-fix-2.5.22-windows-x64.zip`
+- `CodexRemote-fix-2.5.22-windows-x64.zip.sha256.txt`
+- `CodexRemote-fix-2.5.22-trayhost-provenance.json`
+- `CodexRemote-fix-2.5.22-payload-manifest.json`
+- `CodexRemote-fix-2.5.22-release-manifest.json`
+- `CodexRemote-fix-2.5.22-setup.exe`
+- `CodexRemote-fix-2.5.22-setup.exe.sha256.txt`
+- `CodexRemote-fix-2.5.22-setup-provenance.json`
+- `CodexRemote-fix-2.5.22-setup-payload-manifest.json`
+- `CodexRemote-fix-2.5.22-setup-destination-inventory.iss`
+- `CodexRemote-fix-2.5.22-setup-release-manifest.json`
+
+
+
+The portable distribution publishes its payload manifest as an asset. Setup embeds and hash-binds its versioned `installer-payload.manifest.json`.
Each release appends a short English change summary to the GitHub release body. The bilingual release history is kept in [CHANGELOG.md](CHANGELOG.md).
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c8db449..93de331 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -16,7 +16,7 @@
> [!NOTE]
-> **发布状态:** v2.5.21 是当前稳定版。安装包、以 EXE 启动的便携 ZIP、托盘操作、重启并修复流程和常驻远程连接均纳入发布验证门禁。
+> **发布状态:** v2.5.22 是当前候选发布版。安装包与以 EXE 启动的便携 ZIP 都已绑定版本和 manifest;Windows 稳定版验收仍需记录安装、升级、重启、修复、UI 与 Defender 的实机验证结果。
CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、但因运行时缺陷被隐藏的:
@@ -46,19 +46,19 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、
## 快速开始
-1. 从 [Releases](https://github.com/naipi11/CodexRemote-fix/releases) 下载 `CodexRemote-fix-2.5.21-setup.exe`(推荐安装包)或便携 ZIP `CodexRemote-fix-2.5.21-windows-x64.zip`;下载并核对对应的 `.sha256.txt`。
+1. 当 v2.5.22 候选资产已出现在 [Releases](https://github.com/naipi11/CodexRemote-fix/releases) 页面时,下载 `CodexRemote-fix-2.5.22-setup.exe`(推荐安装包)或便携 ZIP `CodexRemote-fix-2.5.22-windows-x64.zip`;下载并核对对应的 `.sha256.txt`。
2. 选择安装包时直接运行并跟随向导;选择便携 ZIP 时解压到新空目录,然后双击 `CodexRemote-fix.exe`。
两条路径都会校验载荷、执行 Microsoft Defender 自定义扫描,并保留 DPAPI 设备密钥。
3. 托盘守护程序会自动启动。连接状态显示 **已连接** 后,打开 **设置 → 连接 → 控制其他设备** 即可注册或使用。Windows 10 用户请确认已安装 .NET Framework 4.8,原生 TrayHost 需要该组件。
-当前便携 ZIP、SHA-256、release manifest 与 payload manifest 始终发布在
-[Releases](https://github.com/naipi11/CodexRemote-fix/releases) 页面。
+仅在候选版本的精确资产集合与校验值已经发布后,才从
+[Releases](https://github.com/naipi11/CodexRemote-fix/releases) 页面获取文件。
替换便携版本前,请先运行已安装的 `Uninstall-CodexControlOtherDevices.ps1`;设置和 DPAPI 设备密钥会保留。
-已验证:Windows 11 · Codex Desktop `26.820.7780.0` · Node.js `22.23.1`;
-隐藏的控制器标签、重启并修复流程以及常驻远程连接均可用。
+历史开发检查使用 Windows 11、Codex Desktop `26.820.7780.0` 与 Node.js `22.23.1`;
+这些记录不构成当前 v2.5.22 的实机验收。
## 日常使用
@@ -81,12 +81,26 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、
## 发布(Releases)
-每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,各自带 SHA-256 校验文件、release manifest,并共享 payload manifest。`.github/workflows/release.yml` 会在 tag 上自动构建两者,因此 v2.5.21 提供:
+v2.5.22 候选版本采用精确的 11 文件公开资产契约。Defender 扫描收据仅作为验收证据,不是公开资产。
-- `CodexRemote-fix-2.5.21-setup.exe` 及其校验文件
-- `CodexRemote-fix-2.5.21-windows-x64.zip` 及其校验文件
-- `CodexRemote-fix-2.5.21-payload-manifest.json`
-- `CodexRemote-fix-2.5.21-release-manifest.json` 和 `CodexRemote-fix-2.5.21-setup-release-manifest.json`
+
+精确的 11 文件候选资产契约
+
+- `CodexRemote-fix-2.5.22-windows-x64.zip`
+- `CodexRemote-fix-2.5.22-windows-x64.zip.sha256.txt`
+- `CodexRemote-fix-2.5.22-trayhost-provenance.json`
+- `CodexRemote-fix-2.5.22-payload-manifest.json`
+- `CodexRemote-fix-2.5.22-release-manifest.json`
+- `CodexRemote-fix-2.5.22-setup.exe`
+- `CodexRemote-fix-2.5.22-setup.exe.sha256.txt`
+- `CodexRemote-fix-2.5.22-setup-provenance.json`
+- `CodexRemote-fix-2.5.22-setup-payload-manifest.json`
+- `CodexRemote-fix-2.5.22-setup-destination-inventory.iss`
+- `CodexRemote-fix-2.5.22-setup-release-manifest.json`
+
+
+
+便携发行版会外发自己的 payload manifest;Setup 则内嵌并以哈希绑定版本化的 `installer-payload.manifest.json`。
GitHub Release 正文只发布英文更新说明;本 README 继续保留中英文使用说明。
完整历史见 [CHANGELOG.md](CHANGELOG.md)。
diff --git a/Uninstall-CodexControlOtherDevices.ps1 b/Uninstall-CodexControlOtherDevices.ps1
index 13b4e43..2e56ce0 100644
--- a/Uninstall-CodexControlOtherDevices.ps1
+++ b/Uninstall-CodexControlOtherDevices.ps1
@@ -15,6 +15,47 @@ function Throw-CcodPublicUninstallError {
[InvalidOperationException]::new($Message),$Id,[Management.Automation.ErrorCategory]::InvalidOperation,$Target)
}
+function Invoke-CcodPublicUninstallPrepare {
+ param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Runtime,[Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$PrepareMode,$Identity)
+ # Dot-source only definitions, then call the function. The script's CLI tail
+ # deliberately exits instead of returning a receipt to a PowerShell caller.
+ . $Path
+ Invoke-CcodUninstallBootstrap -InstallerRoot $Runtime -InstallRoot $Root -Mode $PrepareMode -WrapperIdentity $Identity
+}
+
+function Start-CcodPublicInstalledFinalizer {
+ param([Parameter(Mandatory)][string]$SourceBootstrap,[Parameter(Mandatory)][string]$PayloadRoot,[Parameter(Mandatory)][object[]]$PayloadRecords,[Parameter(Mandatory)][string]$PowerShellPath,[Parameter(Mandatory)][string]$Arguments,[Parameter(Mandatory)][string]$TransactionDirectory)
+ $payloadAuthority=$null;$readyPipe=$null;$child=$null;$handedOff=$false
+ try {
+ . $SourceBootstrap
+ $payloadAuthority=Open-CcodUninstallBootstrapPayloadAuthority -PayloadRoot $PayloadRoot -Records $PayloadRecords
+ $readyPipe=[IO.Pipes.AnonymousPipeServerStream]::new([IO.Pipes.PipeDirection]::In,[IO.HandleInheritability]::Inheritable)
+ $readyHandle=$readyPipe.GetClientHandleAsString()
+ $child=Start-Process -FilePath $PowerShellPath -ArgumentList ($Arguments+' -AuthorityReadyHandle '+$readyHandle) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $TransactionDirectory 'installed-finalizer.stdout.log') -RedirectStandardError (Join-Path $TransactionDirectory 'installed-finalizer.stderr.log') -PassThru -ErrorAction Stop
+ [void]$child.Handle
+ $readyPipe.DisposeLocalCopyOfClientHandle()
+ $buffer=[byte[]]::new(4);$received=0;$clock=[Diagnostics.Stopwatch]::StartNew()
+ while ($received -lt $buffer.Length) {
+ $remaining=15000-[int]$clock.ElapsedMilliseconds
+ if ($remaining -le 0) { throw 'finalizer authority timeout' }
+ $pending=$readyPipe.ReadAsync($buffer,$received,$buffer.Length-$received)
+ if (-not $pending.Wait($remaining) -or $pending.Result -le 0) { throw 'finalizer authority unavailable' }
+ $received+=$pending.Result
+ }
+ if ([BitConverter]::ToInt32($buffer,0) -ne $child.Id) { throw 'finalizer authority identity' }
+ # The exact native child now owns its complete staged payload closure.
+ # These external holds may close; installed holds close before wrapper exit.
+ $handedOff=$true
+ return $child
+ } finally {
+ if ($null -ne $readyPipe) { $readyPipe.Dispose() }
+ if (-not $handedOff -and $null -ne $child) {
+ try { if (-not $child.HasExited) { $child.Kill();$child.WaitForExit() } } finally { $child.Dispose() }
+ }
+ if ($null -ne $payloadAuthority) { $payloadAuthority.Dispose() }
+ }
+}
+
if ($KeepCurrentSpecialSession -or $BackupDeviceKeyStore -or $RemoveDeviceKeyStore) {
Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_OPTION_REMOVED' 'Legacy session and device-key uninstall options were removed. The device key remains in place.' $PSBoundParameters
}
@@ -29,6 +70,33 @@ if ([string]::IsNullOrWhiteSpace($localAppData) -or -not [IO.Path]::IsPathRooted
}
$expectedInstallerRoot = [IO.Path]::GetFullPath((Join-Path $localAppData 'CodexControlOtherDevices-installer'))
$expectedInstallRoot = [IO.Path]::GetFullPath((Join-Path $localAppData 'CodexControlOtherDevices'))
+$runtimeParent = [IO.Path]::GetFullPath((Join-Path $expectedInstallRoot 'runtime'))
+$runtimePrefix = $runtimeParent.TrimEnd('\') + '\'
+$installedRuntimeId = if ($installerRoot.StartsWith($runtimePrefix,[StringComparison]::OrdinalIgnoreCase)) { $installerRoot.Substring($runtimePrefix.Length) } else { $null }
+if ($null -ne $installedRuntimeId -and $installedRuntimeId -cmatch '^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}$' -and $installedRuntimeId.IndexOf('\') -lt 0) {
+ $bootstrapPath = [IO.Path]::GetFullPath((Join-Path $installerRoot 'src\persistence\UninstallBootstrap.ps1'))
+ if (-not [IO.File]::Exists($bootstrapPath)) { Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_INVALID' 'The sealed generation uninstall bootstrap is missing.' $bootstrapPath }
+ $bootstrapItem = Get-Item -LiteralPath $bootstrapPath -Force -ErrorAction Stop
+ if ($bootstrapItem.PSIsContainer -or (($bootstrapItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_INVALID' 'The sealed generation uninstall bootstrap is unsafe.' $bootstrapPath }
+ if (-not $PSCmdlet.ShouldProcess($expectedInstallRoot,'Run manifest-bound cleanup from the selected sealed generation')) { return [pscustomobject][ordered]@{Outcome='WhatIf';KeptDeviceKeyStore=$true} }
+ $current=[Diagnostics.Process]::GetCurrentProcess();$windowsIdentity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$wrapperId=[int]$current.Id;$wrapperCreated=$current.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture);$wrapperIdentity=[pscustomobject]@{pid=$wrapperId;creationTimeUtc=$wrapperCreated;sessionId=[int]$current.SessionId;userSid=[string]$windowsIdentity.User.Value}}finally{$current.Dispose();$windowsIdentity.Dispose()}
+ try { $prepared=Invoke-CcodPublicUninstallPrepare -Path $bootstrapPath -Runtime $installerRoot -Root $expectedInstallRoot -PrepareMode PrepareInstalled -Identity $wrapperIdentity }
+ catch { Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_PREPARE_FAILED' 'Sealed generation cleanup did not reach its external finalization boundary.' $_ }
+ if($null-eq$prepared-or$prepared.transactionId-isnot[string]-or$prepared.transactionId-cnotmatch'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'-or$prepared.phase-cne'TaskRemoved'-or$prepared.runtimeId-cne$installedRuntimeId){Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_PREPARE_FAILED' 'Sealed generation cleanup returned an invalid receipt.' $prepared}
+ $transactionRoot=[IO.Path]::GetFullPath((Join-Path $localAppData 'CodexRemote-fix-uninstall'))
+ $transactionDirectory=[IO.Path]::GetFullPath((Join-Path $transactionRoot $prepared.transactionId))
+ $finalizer=[IO.Path]::GetFullPath((Join-Path $transactionDirectory 'payload\src\persistence\InstalledUninstallFinalizer.ps1'))
+ if(-not[IO.File]::Exists($finalizer)){Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_MISSING' 'The verified external installed finalizer is missing.' $finalizer}
+ $powershellPath=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'WindowsPowerShell\v1.0\powershell.exe'
+ if(-not[IO.File]::Exists($powershellPath)){Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_MISSING' 'The Windows PowerShell host is unavailable.' $powershellPath}
+ function ConvertTo-CcodInstalledUninstallLiteral([string]$Value){
+ return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)\z', '$1$1') + '"'
+ }
+ $historical=$prepared.installedBinding;$resumeExisting=$null-ne$historical-and([int]$historical.wrapperPid-ne$wrapperId-or[string]$historical.wrapperCreationTimeUtc-cne$wrapperCreated-or[int]$historical.wrapperSessionId-ne[int]$wrapperIdentity.sessionId-or[string]$historical.wrapperUserSid-cne[string]$wrapperIdentity.userSid)
+ $arguments=if($resumeExisting){'-NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0} -WrapperResume -TransactionId {1} -RuntimeRoot {2} -InstallRoot {3} -WrapperProcessId {4} -WrapperCreationTimeUtc {5}'-f(ConvertTo-CcodInstalledUninstallLiteral $finalizer),(ConvertTo-CcodInstalledUninstallLiteral $prepared.transactionId),(ConvertTo-CcodInstalledUninstallLiteral $installerRoot),(ConvertTo-CcodInstalledUninstallLiteral $expectedInstallRoot),$wrapperId,(ConvertTo-CcodInstalledUninstallLiteral $wrapperCreated)}else{'-NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0} -TransactionId {1} -RuntimeRoot {2} -InstallRoot {3} -WrapperProcessId {4} -WrapperCreationTimeUtc {5}'-f(ConvertTo-CcodInstalledUninstallLiteral $finalizer),(ConvertTo-CcodInstalledUninstallLiteral $prepared.transactionId),(ConvertTo-CcodInstalledUninstallLiteral $installerRoot),(ConvertTo-CcodInstalledUninstallLiteral $expectedInstallRoot),$wrapperId,(ConvertTo-CcodInstalledUninstallLiteral $wrapperCreated)}
+ try{$process=Start-CcodPublicInstalledFinalizer -SourceBootstrap $bootstrapPath -PayloadRoot (Join-Path $transactionDirectory 'payload') -PayloadRecords @($prepared.installedBinding.payloadRecords) -PowerShellPath $powershellPath -Arguments $arguments -TransactionDirectory $transactionDirectory}catch{Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_START_FAILED' 'The external installed finalizer could not acquire its verified payload; application state was retained.' $_}
+ return [pscustomobject][ordered]@{Outcome='InstalledFinalizationStarted';TransactionId=[string]$prepared.transactionId;FinalizerProcessId=[int]$process.Id;KeptDeviceKeyStore=$true}
+}
$portableMarkerPath = Join-Path $installerRoot 'portable-release.json'
if ([IO.File]::Exists($portableMarkerPath) -or [IO.Directory]::Exists($portableMarkerPath)) {
if ($installerRoot -cne $expectedInstallerRoot) {
@@ -51,7 +119,7 @@ if ([IO.File]::Exists($portableMarkerPath) -or [IO.Directory]::Exists($portableM
return [pscustomobject][ordered]@{ Outcome='WhatIf'; KeptDeviceKeyStore=$true }
}
try {
- $prepared = & $bootstrapPath -InstallerRoot $installerRoot -InstallRoot $expectedInstallRoot -Mode Prepare
+ $prepared = Invoke-CcodPublicUninstallPrepare -Path $bootstrapPath -Runtime $installerRoot -Root $expectedInstallRoot -PrepareMode Prepare -Identity $null
} catch {
Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_PORTABLE_PREPARE_FAILED' 'Protected portable uninstall cleanup did not reach its finalization boundary.' $_
}
@@ -75,7 +143,7 @@ if ([IO.File]::Exists($portableMarkerPath) -or [IO.Directory]::Exists($portableM
}
function ConvertTo-CcodPublicUninstallPowerShellLiteral {
param([Parameter(Mandatory)][string]$Value)
- return "'" + $Value.Replace("'","''") + "'"
+ return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)\z', '$1$1') + '"'
}
$stdoutPath = Join-Path $transactionDirectory 'portable-finalizer.stdout.log'
$stderrPath = Join-Path $transactionDirectory 'portable-finalizer.stderr.log'
diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss
index 49445df..7ab3d00 100644
--- a/build/CodexControlOtherDevices.iss
+++ b/build/CodexControlOtherDevices.iss
@@ -1,9 +1,34 @@
[Setup]
-#ifndef TrayHostArtifactDirectory
-#define TrayHostArtifactDirectory SourcePath + "\generated\trayhost"
+#ifndef ProjectVersion
+#error ProjectVersion must be supplied by the release builder
#endif
-#ifndef PortableArtifactDirectory
-#define PortableArtifactDirectory SourcePath + "\generated\portable"
+#ifndef InstallerPackagePath
+#error InstallerPackagePath must be supplied by the release builder
+#endif
+#ifndef InstallerPackageManifestPath
+#error InstallerPackageManifestPath must be supplied by the release builder
+#endif
+#ifndef InstallerPackageSha256
+#error InstallerPackageSha256 must be supplied by the release builder
+#endif
+#ifndef InstallerPackageManifestSha256
+#error InstallerPackageManifestSha256 must be supplied by the release builder
+#endif
+#define InstallerPackageManifestSha256First Copy(InstallerPackageManifestSha256, 1, 32)
+#define InstallerPackageManifestSha256Last Copy(InstallerPackageManifestSha256, 33, 32)
+#ifndef ActivationBootstrapPath
+#error ActivationBootstrapPath must be supplied by the release builder
+#endif
+#ifndef ActivationBootstrapSha256
+#error ActivationBootstrapSha256 must be supplied by the release builder
+#endif
+#define ActivationBootstrapSha256First Copy(ActivationBootstrapSha256, 1, 32)
+#define ActivationBootstrapSha256Last Copy(ActivationBootstrapSha256, 33, 32)
+#ifndef SetupGitCommit
+#error SetupGitCommit must be supplied by the release builder
+#endif
+#ifndef SetupProvenancePath
+#error SetupProvenancePath must be supplied by the release builder
#endif
AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}
AppName=CodexRemote-fix
@@ -14,15 +39,21 @@ AppPublisherURL=https://github.com/naipi11/CodexRemote-fix
AppSupportURL=https://github.com/naipi11/CodexRemote-fix/issues
AppUpdatesURL=https://github.com/naipi11/CodexRemote-fix/releases
VersionInfoVersion={#ProjectVersion}.0
-DefaultDirName={localappdata}\CodexControlOtherDevices-installer
-DefaultGroupName=CodexRemote-fix
-UsePreviousGroup=no
+VersionInfoTextVersion={#ProjectVersion}.0
+VersionInfoProductName={#ActivationBootstrapSha256Last}
+VersionInfoProductTextVersion={#InstallerPackageManifestSha256First}
+VersionInfoDescription={#InstallerPackageManifestSha256Last}
+VersionInfoCompany={#SetupGitCommit}
+VersionInfoCopyright={#InstallerPackageSha256}
+VersionInfoOriginalFileName={#ActivationBootstrapSha256First}
+DefaultDirName={localappdata}\CodexControlOtherDevices
+CreateAppDir=no
+Uninstallable=no
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir=dist
OutputBaseFilename=CodexRemote-fix-{#ProjectVersion}-setup
SetupIconFile=..\assets\codexremote-fix\codexremote-fix.ico
-UninstallDisplayIcon={app}\assets\CodexRemote-fix.ico
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
@@ -37,405 +68,152 @@ MinVersion=10.0.17763
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
-Source: "..\README.md"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\README.zh-CN.md"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\LICENSE"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\NOTICE.md"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\SECURITY.md"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\package.json"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\build\CodexControlOtherDevices.iss"; DestDir: "{app}\build"; Flags: ignoreversion
-Source: "..\build\build.ps1"; DestDir: "{app}\build"; Flags: ignoreversion
-Source: "..\build\build-trayhost.ps1"; DestDir: "{app}\build"; Flags: ignoreversion
-Source: "..\build\TrayHostBuild.psm1"; DestDir: "{app}\build"; Flags: ignoreversion
-Source: "..\build\TrayHostReferencePack.psm1"; DestDir: "{app}\build"; Flags: ignoreversion
-Source: "..\build\trayhost-packages.lock.json"; DestDir: "{app}\build"; Flags: ignoreversion
-Source: "{#TrayHostArtifactDirectory}\CodexRemote.TrayHost.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
-Source: "{#TrayHostArtifactDirectory}\CodexRemote.TrayHost.exe.config"; DestDir: "{app}\bin"; Flags: ignoreversion
-Source: "{#TrayHostArtifactDirectory}\trayhost-build-provenance.json"; DestDir: "{app}\bin"; Flags: ignoreversion
-Source: "{#PortableArtifactDirectory}\CodexRemote.Portable.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
-Source: "{#PortableArtifactDirectory}\CodexRemote.Portable.exe.config"; DestDir: "{app}\bin"; Flags: ignoreversion
-Source: "{#PortableArtifactDirectory}\portable-launcher-provenance.json"; DestDir: "{app}\bin"; Flags: ignoreversion
-Source: "..\.github\workflows\release.yml"; DestDir: "{app}\.github\workflows"; Flags: ignoreversion
-Source: "..\assets\codexremote-fix\codexremote-fix.ico"; DestDir: "{app}\assets"; DestName: "CodexRemote-fix.ico"; Flags: ignoreversion
-Source: "..\assets\codexremote-fix\codexremote-fix.ico"; DestDir: "{app}\assets\codexremote-fix"; Flags: ignoreversion
-Source: "..\docs\*"; DestDir: "{app}\docs"; Flags: ignoreversion recursesubdirs createallsubdirs
-Source: "..\src\*"; DestDir: "{app}\src"; Flags: ignoreversion recursesubdirs createallsubdirs
-Source: "..\tests\*"; DestDir: "{app}\tests"; Flags: ignoreversion recursesubdirs createallsubdirs
-Source: "..\Install-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\Uninstall-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\Start-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\Reset-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\Test-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\Activate-CcodRemoteFix.ps1"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\Prompt-CcodRestart.ps1"; DestDir: "{app}"; Flags: ignoreversion
-
-[InstallDelete]
-Type: files; Name: "{userprograms}\Codex Control other devices\Codex Control other devices for Windows.lnk"
-Type: files; Name: "{userprograms}\Codex Control other devices\Open the tray supervisor.lnk"
-Type: files; Name: "{userprograms}\Codex Control other devices\Compatibility check.lnk"
-Type: files; Name: "{userprograms}\Codex Control other devices\Uninstall Codex Control other devices.lnk"
-Type: files; Name: "{userprograms}\Codex Control other devices\CodexRemote-fix.lnk"
-Type: files; Name: "{userprograms}\Codex Control other devices\CodexRemote-fix compatibility check.lnk"
-Type: files; Name: "{userprograms}\Codex Control other devices\Uninstall CodexRemote-fix.lnk"
-Type: dirifempty; Name: "{userprograms}\Codex Control other devices"
-Type: files; Name: "{userdesktop}\Codex 设备连接 (Device Connection).lnk"
-
-[Icons]
-Name: "{group}\CodexRemote-fix"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{localappdata}\CodexControlOtherDevices\bootstrap.ps1"" -InstallRoot ""{localappdata}\CodexControlOtherDevices"" -EntryMode Explicit"; WorkingDir: "{localappdata}\CodexControlOtherDevices"; IconFilename: "{app}\assets\CodexRemote-fix.ico"
-Name: "{group}\CodexRemote-fix compatibility check"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\Test-CodexControlOtherDevices.ps1"""; WorkingDir: "{app}"; IconFilename: "{app}\assets\CodexRemote-fix.ico"
-Name: "{group}\Uninstall CodexRemote-fix"; Filename: "{app}\unins000.exe"; IconFilename: "{app}\assets\CodexRemote-fix.ico"
-Name: "{userdesktop}\CodexRemote-fix"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{localappdata}\CodexControlOtherDevices\bootstrap.ps1"" -InstallRoot ""{localappdata}\CodexControlOtherDevices"" -EntryMode Explicit"; WorkingDir: "{localappdata}\CodexControlOtherDevices"; IconFilename: "{app}\assets\CodexRemote-fix.ico"
+Source: "{#InstallerPackagePath}"; DestName: "ccod-installer-package.zip"; Flags: dontcopy
+Source: "{#InstallerPackageManifestPath}"; DestName: "ccod-installer-package.manifest.json"; Flags: dontcopy
+Source: "{#ActivationBootstrapPath}"; DestName: "ccod-activation-bootstrap.ps1"; Flags: dontcopy
+Source: "{#SetupProvenancePath}"; DestName: "ccod-setup-provenance.json"; Flags: dontcopy
[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
const
- ACTIVATION_TIMEOUT_MILLISECONDS = 300000;
- FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS = 90000;
- ACTIVATION_POLL_MILLISECONDS = 50;
- VALIDATION_RETRY_MILLISECONDS = 500;
- VALIDATION_TIMEOUT_MILLISECONDS = 2000;
- VALIDATION_LAUNCH_BUDGET_MILLISECONDS = 5000;
- CCOD_FILE_ATTRIBUTE_DIRECTORY = $00000010;
- CCOD_FILE_ATTRIBUTE_REPARSE_POINT = $00000400;
- CCOD_INVALID_FILE_ATTRIBUTES = $FFFFFFFF;
+ CCOD_GENERIC_READ = $80000000;
+ CCOD_FILE_SHARE_READ = $00000001;
+ CCOD_OPEN_EXISTING = 3;
+ CCOD_FILE_ATTRIBUTE_NORMAL = $00000080;
+ CCOD_INVALID_HANDLE_VALUE = -1;
+ CCOD_EXPECTED_PACKAGE_SHA256 = '{#InstallerPackageSha256}';
+ CCOD_EXPECTED_PACKAGE_MANIFEST_SHA256 = '{#InstallerPackageManifestSha256}';
+ CCOD_EXPECTED_BOOTSTRAP_SHA256 = '{#ActivationBootstrapSha256}';
-type
- TActivationPhase = (apNone, apStoppingPreviousRuntime, apInstallingRuntime,
- apActivatingRuntime, apStartingProtection, apReady, apFailed);
-
-function GetTickCount64(): Int64;
- external 'GetTickCount64@kernel32.dll stdcall';
+var
+ CcodInputHandles: array of Integer;
+ CcodPackagePath: String;
+ CcodPackageManifestPath: String;
+ CcodBootstrapPath: String;
+
+function CreateFileW(const FileName: String; DesiredAccess, ShareMode,
+ SecurityAttributes, CreationDisposition, FlagsAndAttributes,
+ TemplateFile: Cardinal): Integer;
+ external 'CreateFileW@kernel32.dll stdcall';
+function CloseHandle(Handle: Integer): Boolean;
+ external 'CloseHandle@kernel32.dll stdcall';
function CoCreateGuid(var Guid: TGUID): HResult;
external 'CoCreateGuid@ole32.dll stdcall';
-function StringFromGUID2(var Guid: TGUID; GuidString: String; MaxCharacters: Integer): Integer;
+function StringFromGUID2(var Guid: TGUID; GuidString: String;
+ MaxCharacters: Integer): Integer;
external 'StringFromGUID2@ole32.dll stdcall';
-function GetFileAttributesW(const FileName: String): Cardinal;
- external 'GetFileAttributesW@kernel32.dll stdcall';
-function NewActivationId(): String;
+procedure CloseCcodInputHandles();
var
- Guid: TGUID;
- GuidString: String;
- GuidLength: Integer;
-begin
- if CoCreateGuid(Guid) <> 0 then
- RaiseException('CodexRemote-fix activation correlation could not be created.');
- SetLength(GuidString, 39);
- GuidLength := StringFromGUID2(Guid, GuidString, 39);
- if GuidLength <> 39 then
- RaiseException('CodexRemote-fix activation correlation could not be formatted.');
- SetLength(GuidString, 38);
- Result := LowerCase(Copy(GuidString, 2, 36));
- if Length(Result) <> 36 then
- RaiseException('CodexRemote-fix activation correlation is invalid.');
-end;
-
-function IsSafeActivationFile(const FileName: String): Boolean;
-var
- Attributes, DirectoryAttributes, RootAttributes: Cardinal;
- DirectoryName, RootName: String;
+ Index: Integer;
begin
- Attributes := GetFileAttributesW(FileName);
- if (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or
- ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) or
- ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then Exit;
- DirectoryName := ExtractFileDir(FileName);
- RootName := ExtractFileDir(DirectoryName);
- DirectoryAttributes := GetFileAttributesW(DirectoryName);
- RootAttributes := GetFileAttributesW(RootName);
- Result := (DirectoryAttributes <> CCOD_INVALID_FILE_ATTRIBUTES) and
- (RootAttributes <> CCOD_INVALID_FILE_ATTRIBUTES) and
- ((DirectoryAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and
- ((RootAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and
- ((DirectoryAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0) and
- ((RootAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0) and
- ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) and
- ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0);
+ for Index := GetArrayLength(CcodInputHandles) - 1 downto 0 do
+ if CcodInputHandles[Index] <> CCOD_INVALID_HANDLE_VALUE then
+ CloseHandle(CcodInputHandles[Index]);
+ SetArrayLength(CcodInputHandles, 0);
end;
-procedure RefuseStaleActivationReceipt(const ReceiptPath: String);
+function LockCcodInput(const Path, ExpectedSha256: String): Boolean;
var
- Attributes: Cardinal;
+ Handle: Integer;
+ Count: Integer;
begin
- Attributes := GetFileAttributesW(ReceiptPath);
- if Attributes <> CCOD_INVALID_FILE_ATTRIBUTES then
+ Result := False;
+ if Lowercase(GetSHA256OfFile(Path)) <> ExpectedSha256 then Exit;
+ Handle := CreateFileW(Path, CCOD_GENERIC_READ, CCOD_FILE_SHARE_READ, 0,
+ CCOD_OPEN_EXISTING, CCOD_FILE_ATTRIBUTE_NORMAL, 0);
+ if Handle = CCOD_INVALID_HANDLE_VALUE then Exit;
+ if Lowercase(GetSHA256OfFile(Path)) <> ExpectedSha256 then
begin
- if not IsSafeActivationFile(ReceiptPath) then
- RaiseException('CodexRemote-fix refused an unsafe stale activation receipt.');
- if not DeleteFile(ReceiptPath) then
- RaiseException('CodexRemote-fix could not remove a stale activation receipt.');
+ CloseHandle(Handle);
+ Exit;
end;
- if GetFileAttributesW(ReceiptPath) <> CCOD_INVALID_FILE_ATTRIBUTES then
- RaiseException('CodexRemote-fix refused a stale activation receipt.');
+ Count := GetArrayLength(CcodInputHandles);
+ SetArrayLength(CcodInputHandles, Count + 1);
+ CcodInputHandles[Count] := Handle;
+ Result := True;
end;
-function LoadBoundedActivationReceipt(const ReceiptPath: String; var Content: AnsiString): Boolean;
-var
- ReceiptSize: Int64;
+function ExtractAndLockCcodInputs(): Boolean;
begin
Result := False;
- if not FileSize64(ReceiptPath, ReceiptSize) then Exit;
- if (ReceiptSize <= 0) or (ReceiptSize > 16384) then Exit;
- Result := LoadStringFromFile(ReceiptPath, Content);
-end;
-
-function IsJsonWhitespace(const Character: Char): Boolean;
-begin
- Result := (Character = ' ') or (Character = #9) or
- (Character = #10) or (Character = #13);
+ ExtractTemporaryFile('ccod-installer-package.zip');
+ ExtractTemporaryFile('ccod-installer-package.manifest.json');
+ ExtractTemporaryFile('ccod-activation-bootstrap.ps1');
+ ExtractTemporaryFile('ccod-setup-provenance.json');
+ CcodPackagePath := ExpandConstant('{tmp}\ccod-installer-package.zip');
+ CcodPackageManifestPath := ExpandConstant('{tmp}\ccod-installer-package.manifest.json');
+ CcodBootstrapPath := ExpandConstant('{tmp}\ccod-activation-bootstrap.ps1');
+ if not LockCcodInput(CcodPackagePath, CCOD_EXPECTED_PACKAGE_SHA256) then Exit;
+ if not LockCcodInput(CcodPackageManifestPath, CCOD_EXPECTED_PACKAGE_MANIFEST_SHA256) then Exit;
+ if not LockCcodInput(CcodBootstrapPath, CCOD_EXPECTED_BOOTSTRAP_SHA256) then Exit;
+ Result := True;
end;
-function HasJsonStringValue(const Content, FieldName, FieldValue: String): Boolean;
+function NewActivationId(): String;
var
- FieldIndex, RelativeIndex, SearchOffset, ValueIndex: Integer;
- FieldToken, ValueToken: String;
-begin
- Result := False;
- FieldToken := '"' + FieldName + '"';
- ValueToken := '"' + FieldValue + '"';
- SearchOffset := 1;
- while SearchOffset <= Length(Content) do
- begin
- RelativeIndex := Pos(FieldToken, Copy(Content, SearchOffset,
- Length(Content) - SearchOffset + 1));
- if RelativeIndex = 0 then Exit;
- FieldIndex := SearchOffset + RelativeIndex - 1;
- ValueIndex := FieldIndex + Length(FieldToken);
- while ValueIndex <= Length(Content) do
- begin
- if not IsJsonWhitespace(Content[ValueIndex]) then Break;
- ValueIndex := ValueIndex + 1;
- end;
- if (ValueIndex <= Length(Content)) and (Content[ValueIndex] = ':') then
- begin
- ValueIndex := ValueIndex + 1;
- while ValueIndex <= Length(Content) do
- begin
- if not IsJsonWhitespace(Content[ValueIndex]) then Break;
- ValueIndex := ValueIndex + 1;
- end;
- if Copy(Content, ValueIndex, Length(ValueToken)) = ValueToken then
- begin
- Result := True;
- Exit;
- end;
- end;
- SearchOffset := FieldIndex + Length(FieldToken);
- end;
-end;
-
-function DetectActivationPhase(const Content: String): TActivationPhase;
-begin
- Result := apNone;
- if HasJsonStringValue(Content, 'phase', 'StoppingPreviousRuntime') then
- Result := apStoppingPreviousRuntime
- else if HasJsonStringValue(Content, 'phase', 'InstallingRuntime') then
- Result := apInstallingRuntime
- else if HasJsonStringValue(Content, 'phase', 'ActivatingRuntime') then
- Result := apActivatingRuntime
- else if HasJsonStringValue(Content, 'phase', 'StartingProtection') then
- Result := apStartingProtection
- else if HasJsonStringValue(Content, 'phase', 'Ready') then
- Result := apReady
- else if HasJsonStringValue(Content, 'phase', 'Failed') then
- Result := apFailed;
-end;
-
-procedure UpdateActivationStartingPresentation();
-begin
- WizardForm.StatusLabel.Caption := 'Starting CodexRemote-fix activation...';
- WizardForm.ProgressGauge.Position := 70;
- WizardForm.Update;
+ Guid: TGUID;
+ Value: String;
+ Length: Integer;
+begin
+ if CoCreateGuid(Guid) <> 0 then RaiseException('CCOD_SETUP_ACTIVATION_ID_FAILED');
+ SetLength(Value, 39);
+ Length := StringFromGUID2(Guid, Value, 39);
+ if Length <> 39 then RaiseException('CCOD_SETUP_ACTIVATION_ID_FAILED');
+ SetLength(Value, 38);
+ Result := Lowercase(Copy(Value, 2, 36));
+end;
+
+function GetCcodBootstrapParameters(const ActivationId: String;
+ ValidateOnly: Boolean): String;
+begin
+ Result := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' +
+ CcodBootstrapPath + '" -PackagePath "' + CcodPackagePath +
+ '" -PackageManifestPath "' + CcodPackageManifestPath +
+ '" -ExpectedPackageSha256 "{#InstallerPackageSha256}' +
+ '" -ExpectedPackageManifestSha256 "{#InstallerPackageManifestSha256}' +
+ '" -ExpectedVersion "{#ProjectVersion}' +
+ '" -ExpectedGitCommit "{#SetupGitCommit}' +
+ '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') +
+ '" -ActivationId "' + ActivationId + '"';
+ if ValidateOnly then Result := Result + ' -ValidateReceiptOnly';
end;
-procedure UpdateActivationPresentation(const Phase: TActivationPhase);
+function PrepareToInstall(var NeedsRestart: Boolean): String;
begin
- case Phase of
- apStoppingPreviousRuntime:
- begin WizardForm.StatusLabel.Caption := 'Stopping the previous protected runtime...'; WizardForm.ProgressGauge.Position := 20; end;
- apInstallingRuntime:
- begin WizardForm.StatusLabel.Caption := 'Installing the verified runtime...'; WizardForm.ProgressGauge.Position := 40; end;
- apActivatingRuntime:
- begin WizardForm.StatusLabel.Caption := 'Committing the runtime generation...'; WizardForm.ProgressGauge.Position := 60; end;
- apStartingProtection:
- begin WizardForm.StatusLabel.Caption := 'Starting Supervisor and TrayHost protection...'; WizardForm.ProgressGauge.Position := 80; end;
- apReady:
- begin WizardForm.StatusLabel.Caption := 'Verifying activation completion...'; WizardForm.ProgressGauge.Position := 95; end;
- apFailed:
- begin WizardForm.StatusLabel.Caption := 'Activation failed safely.'; WizardForm.ProgressGauge.Position := 95; end;
+ Result := '';
+ CloseCcodInputHandles();
+ try
+ if not ExtractAndLockCcodInputs() then
+ Result := 'CCOD_SETUP_INPUT_BINDING_INVALID';
+ except
+ Result := 'CCOD_SETUP_INPUT_BINDING_INVALID';
end;
-end;
-
-function ReadActivationProgressPhase(const ReceiptPath, ExpectedActivationId: String): TActivationPhase;
-var
- Content: AnsiString;
- Text: String;
-begin
- Result := apNone;
- if not FileExists(ReceiptPath) then Exit;
- if not IsSafeActivationFile(ReceiptPath) then Exit;
- if not LoadBoundedActivationReceipt(ReceiptPath, Content) then Exit;
- Text := String(Content);
- if HasJsonStringValue(Text, 'activationId', ExpectedActivationId) then
- Result := DetectActivationPhase(Text);
+ if Result <> '' then CloseCcodInputHandles();
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
- ActivationResultCode: Integer;
- ValidationResultCode: Integer;
- PromptResultCode: Integer;
- Parameters, ReceiptPath, ActivationId: String;
+ ActivationId, Parameters: String;
+ ActivationResultCode, ValidationResultCode: Integer;
begin
- if CurStep <> ssPostInstall then
- Exit;
+ if CurStep <> ssPostInstall then Exit;
+ if GetArrayLength(CcodInputHandles) <> 3 then
+ RaiseException('CCOD_SETUP_INPUT_BINDING_INVALID');
ActivationId := NewActivationId();
- ReceiptPath := ExpandConstant('{localappdata}\CodexControlOtherDevices\state\post-install-activation.json');
- RefuseStaleActivationReceipt(ReceiptPath);
- UpdateActivationStartingPresentation();
- Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' +
- ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') +
- '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') +
- '" -ActivationId "' + ActivationId + '" -FirstReceiptTimeoutMilliseconds ' +
- IntToStr(FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS) + ' -ActivationTimeoutMilliseconds ' +
- IntToStr(ACTIVATION_TIMEOUT_MILLISECONDS);
- if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE,
- ewWaitUntilTerminated, ActivationResultCode) then
- begin
- Log('CodexRemote-fix activation owner could not be started: ' + SysErrorMessage(ActivationResultCode));
- RaiseException('CodexRemote-fix activation process could not be started.');
- end;
- if ActivationResultCode <> 0 then
- begin
- Log('CodexRemote-fix activation owner exited unsuccessfully, result code ' + IntToStr(ActivationResultCode) + '.');
- SuppressibleMsgBox('CodexRemote-fix activation failed safely. Use the support code in post-install-activation.log.', mbError, MB_OK, IDOK);
- RaiseException('CodexRemote-fix activation failed.');
- end;
- Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' +
- ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') +
- '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') +
- '" -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds ' + IntToStr(VALIDATION_TIMEOUT_MILLISECONDS) +
- ' -ActivationId "' + ActivationId + '"';
- if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE,
- ewWaitUntilTerminated, ValidationResultCode) then
- begin
- Log('CodexRemote-fix activation validator could not be started: ' + SysErrorMessage(ValidationResultCode));
- RaiseException('CodexRemote-fix activation validator could not be started.');
- end;
- if ValidationResultCode <> 0 then
- begin
- Log('CodexRemote-fix activation validator rejected the terminal receipt, result code ' + IntToStr(ValidationResultCode) + '.');
- SuppressibleMsgBox('CodexRemote-fix activation failed safe validation. Use the support code in post-install-activation.log.', mbError, MB_OK, IDOK);
- RaiseException('CodexRemote-fix activation receipt validation failed.');
- end;
- if ValidationResultCode = 0 then
- begin
- WizardForm.StatusLabel.Caption := 'CodexRemote-fix activation is ready.';
- WizardForm.ProgressGauge.Position := 100;
- WizardForm.Update;
- if not WizardSilent then
- begin
- Parameters := '-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\Prompt-CcodRestart.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ActivationId "' + ActivationId + '"';
- if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, ewWaitUntilTerminated, PromptResultCode)) or (PromptResultCode <> 0) then
- SuppressibleMsgBox('Codex restart was not submitted. Restart Codex manually when convenient.', mbInformation, MB_OK, IDOK);
- end;
- end;
-end;
-
-function IsCanonicalUninstallTransactionId(const Value: String): Boolean;
-var
- Index: Integer;
- Character: Char;
-begin
- Result := Length(Value) = 36;
- if not Result then Exit;
- for Index := 1 to Length(Value) do
- begin
- Character := Value[Index];
- if (Index = 9) or (Index = 14) or (Index = 19) or (Index = 24) then
- begin
- if Character <> '-' then begin Result := False; Exit; end;
- end
- else if not (((Character >= '0') and (Character <= '9')) or ((Character >= 'a') and (Character <= 'f'))) then
- begin
- Result := False;
- Exit;
- end;
- end;
-end;
-
-function TryReadUninstallTransactionId(var TransactionId: String): Boolean;
-var
- CurrentPath, Text, Marker: String;
- Content: AnsiString;
- Position: Integer;
-begin
- Result := False;
- TransactionId := '';
- CurrentPath := ExpandConstant('{localappdata}\CodexRemote-fix-uninstall\current.json');
- if not IsSafeActivationFile(CurrentPath) then Exit;
- if not LoadBoundedActivationReceipt(CurrentPath, Content) then Exit;
- Text := String(Content);
- Marker := '"transactionId":"';
- Position := Pos(Marker, Text);
- if Position = 0 then Exit;
- Position := Position + Length(Marker);
- if Position + 36 > Length(Text) + 1 then Exit;
- TransactionId := Copy(Text, Position, 36);
- if (Position + 36 > Length(Text)) or (Text[Position + 36] <> '"') or not IsCanonicalUninstallTransactionId(TransactionId) then
- begin
- TransactionId := '';
- Exit;
- end;
- Result := True;
-end;
-
-function GetExternalUninstallBootstrapPath: String;
-var
- TransactionId: String;
-begin
- Result := '';
- if not TryReadUninstallTransactionId(TransactionId) then Exit;
- Result := ExpandConstant('{localappdata}\CodexRemote-fix-uninstall\') + TransactionId + '\payload\src\persistence\UninstallBootstrap.ps1';
- if not FileExists(Result) then Result := '';
-end;
-
-function InitializeUninstall(): Boolean;
-var
- ResultCode: Integer;
- Parameters: String;
-begin
- Parameters := '-NoProfile -ExecutionPolicy Bypass -File "' +
- ExpandConstant('{app}\src\persistence\UninstallBootstrap.ps1') + '" -InstallerRoot "' +
- ExpandConstant('{app}') + '" -InstallRoot "' +
- ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -Mode Prepare';
- Result := Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters,
- '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0);
- if not Result then
- begin
- Log('CodexRemote-fix uninstall bootstrap refused pre-deletion cleanup, result code ' + IntToStr(ResultCode) + '.');
- SuppressibleMsgBox('CodexRemote-fix could not verify safe cleanup. No installer files were removed; retry uninstall after resolving the reported support code.', mbError, MB_OK, IDOK);
- end;
+ Parameters := GetCcodBootstrapParameters(ActivationId, False);
+ if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters,
+ '', SW_HIDE, ewWaitUntilTerminated, ActivationResultCode)) or
+ (ActivationResultCode <> 0) then RaiseException('CCOD_SETUP_ACTIVATION_FAILED');
+ Parameters := GetCcodBootstrapParameters(ActivationId, True);
+ if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters,
+ '', SW_HIDE, ewWaitUntilTerminated, ValidationResultCode)) or
+ (ValidationResultCode <> 0) then RaiseException('CCOD_SETUP_READY_VALIDATION_FAILED');
+ WizardForm.StatusLabel.Caption := 'CodexRemote-fix activation is ready.';
+ WizardForm.ProgressGauge.Position := 100;
end;
-procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
-var
- ResultCode: Integer;
- Parameters, BootstrapPath: String;
+procedure DeinitializeSetup();
begin
- if CurUninstallStep <> usPostUninstall then Exit;
- BootstrapPath := GetExternalUninstallBootstrapPath;
- if BootstrapPath = '' then
- begin
- Log('CodexRemote-fix uninstall completion receipt could not locate the staged bootstrap.');
- RaiseException('CCOD_UNINSTALL_FINALIZATION_MISSING');
- end;
- Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + BootstrapPath +
- '" -InstallerRoot "' + ExpandConstant('{app}') + '" -InstallRoot "' +
- ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -Mode FinalizeReceipt';
- if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters,
- '', SW_HIDE, ewWaitUntilTerminated, ResultCode)) or (ResultCode <> 0) then
- begin
- Log('CodexRemote-fix uninstall completion receipt was not finalized, result code ' + IntToStr(ResultCode) + '.');
- RaiseException('CCOD_UNINSTALL_FINALIZATION_FAILED');
- end;
+ CloseCcodInputHandles();
end;
diff --git a/build/InstallerPackage.psm1 b/build/InstallerPackage.psm1
new file mode 100644
index 0000000..0c773e0
--- /dev/null
+++ b/build/InstallerPackage.psm1
@@ -0,0 +1,243 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$script:CcodInstallerPackageMaximumBytes = 536870912
+$script:CcodInstallerPackageManifestMaximumBytes = 4194304
+$script:CcodInstallerPackageEntryMaximumBytes = 268435456
+
+function Throw-CcodInstallerPackageError {
+ param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Message,$Target)
+ throw [Management.Automation.ErrorRecord]::new(
+ [IO.InvalidDataException]::new($Message),
+ $Id,
+ [Management.Automation.ErrorCategory]::InvalidData,
+ $Target
+ )
+}
+
+function Get-CcodInstallerPackageSha256 {
+ param([Parameter(Mandatory)][string]$Path)
+ $stream = $null
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ $stream = [IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()
+ } finally {
+ if ($null -ne $stream) { $stream.Dispose() }
+ $sha.Dispose()
+ }
+}
+
+function Get-CcodInstallerPackageStreamSha256 {
+ param([Parameter(Mandatory)][IO.Stream]$Stream)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ $Stream.Position = 0
+ $value = [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant()
+ $Stream.Position = 0
+ return $value
+ } finally { $sha.Dispose() }
+}
+
+function Test-CcodInstallerPackageRelativePath {
+ param([AllowNull()][string]$Path)
+ return $Path -is [string] -and $Path -cmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -and
+ -not $Path.Contains('//') -and -not $Path.Contains('..') -and -not $Path.Contains(':') -and
+ -not $Path.Contains('\') -and -not $Path.EndsWith('/')
+}
+
+function Get-CcodInstallerPackageTopLevelJsonPropertyCount {
+ param([Parameter(Mandatory)][string]$Json,[Parameter(Mandatory)][string]$Name)
+ $count=0;$depth=0;$inString=$false;$escaped=$false;$expectKey=$false;$captureKey=$false;$keyStart=-1
+ for($index=0;$index-lt$Json.Length;$index++){
+ $character=$Json[$index]
+ if($inString){
+ if($escaped){$escaped=$false;continue}
+ if($character-ceq'\'){$escaped=$true;continue}
+ if($character-ceq'"'){
+ if($captureKey){
+ try{$key=$Json.Substring($keyStart,$index-$keyStart+1)|ConvertFrom-Json -ErrorAction Stop}catch{return -1}
+ if($key-isnot[string]){return -1};if($key-ceq$Name){$count++};$expectKey=$false
+ }
+ $inString=$false;$captureKey=$false
+ }
+ continue
+ }
+ if($character-ceq'"'){$inString=$true;$captureKey=($depth-eq 1-and$expectKey);if($captureKey){$keyStart=$index};continue}
+ if($character-ceq'{'-or$character-ceq'['){$depth++;if($depth-eq 1-and$character-ceq'{'){$expectKey=$true};continue}
+ if($character-ceq'}'-or$character-ceq']'){$depth--;if($depth-lt 0){return -1};continue}
+ if($character-ceq','-and$depth-eq 1){$expectKey=$true}
+ }
+ if($inString-or$depth-ne 0){return -1};return $count
+}
+
+function Assert-CcodInstallerPackageRegularFile {
+ param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Kind,[int64]$MaximumBytes = 536870912)
+ $full = [IO.Path]::GetFullPath($Path)
+ if (-not [IO.File]::Exists($full)) { Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' "$Kind is missing" $full }
+ $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $item.Length -le 0 -or $item.Length -gt $MaximumBytes) {
+ Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' "$Kind is not a bounded regular file" $full
+ }
+ return $full
+}
+
+function Test-CcodInstallerPackageJsonInteger {
+ # ConvertFrom-Json yields [long] for JSON integers under PowerShell 7 and [int]
+ # under Windows PowerShell 5.1, so accept both shapes at one exact value.
+ param($Value,[long]$Expected=0)
+ return ($Value-is[int]-or$Value-is[long])-and[decimal]$Value-eq[decimal][long]$Value-and[long]$Value-eq$Expected
+}
+
+function Read-CcodInstallerPayloadManifest {
+ param([Parameter(Mandatory)][byte[]]$Bytes,[Parameter(Mandatory)][string]$ExpectedVersion)
+ try { $text=[Text.UTF8Encoding]::new($false,$true).GetString($Bytes);$manifest=$text|ConvertFrom-Json -ErrorAction Stop }
+ catch { Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest JSON is invalid' $null }
+ foreach($name in @('schemaVersion','projectVersion','files')){if((Get-CcodInstallerPackageTopLevelJsonPropertyCount $text $name)-ne 1){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest contains duplicate or missing fields' $name}}
+ if($manifest-isnot[pscustomobject]-or(@($manifest.PSObject.Properties.Name)-join',')-cne'schemaVersion,projectVersion,files'-or
+ -not(Test-CcodInstallerPackageJsonInteger $manifest.schemaVersion 1)-or$manifest.projectVersion-isnot[string]-or$manifest.projectVersion-cne$ExpectedVersion){
+ Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest metadata is invalid' $null
+ }
+ $records=@($manifest.files);if($records.Count-eq 0){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest is empty' $null}
+ $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase);$previous=$null
+ foreach($record in $records){
+ if($record-isnot[pscustomobject]-or(@($record.PSObject.Properties.Name)-join',')-cne'path,length,sha256'-or
+ -not(Test-CcodInstallerPackageRelativePath ([string]$record.path))-or
+ $record.length-isnot[ValueType]-or[decimal]$record.length-ne[decimal][int64]$record.length-or[int64]$record.length-lt 0-or[int64]$record.length-gt$script:CcodInstallerPackageEntryMaximumBytes-or
+ $record.sha256-isnot[string]-or$record.sha256-cnotmatch'^[0-9a-f]{64}$'-or
+ ($null-ne$previous-and[StringComparer]::Ordinal.Compare($previous,[string]$record.path)-ge 0)-or-not$seen.Add([string]$record.path)){
+ Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest file record is invalid' $record
+ }
+ $previous=[string]$record.path
+ }
+ return [pscustomobject]@{Text=$text;Manifest=$manifest;Records=$records}
+}
+
+function Read-CcodInstallerPackageManifest {
+ param([Parameter(Mandatory)][byte[]]$Bytes,[Parameter(Mandatory)][string]$ExpectedVersion,[Parameter(Mandatory)][string]$ExpectedGitCommit)
+ try{$text=[Text.UTF8Encoding]::new($false,$true).GetString($Bytes);$manifest=$text|ConvertFrom-Json -ErrorAction Stop}catch{Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package manifest JSON is invalid' $null}
+ foreach($name in @('schemaVersion','product','version','gitCommit','payloadManifest','files')){if((Get-CcodInstallerPackageTopLevelJsonPropertyCount $text $name)-ne 1){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package manifest contains duplicate or missing fields' $name}}
+ if($manifest-isnot[pscustomobject]-or(@($manifest.PSObject.Properties.Name)-join',')-cne'schemaVersion,product,version,gitCommit,payloadManifest,files'-or
+ -not(Test-CcodInstallerPackageJsonInteger $manifest.schemaVersion 1)-or$manifest.product-isnot[string]-or$manifest.product-cne'CodexRemote-fix'-or
+ $manifest.version-isnot[string]-or$manifest.version-cne$ExpectedVersion-or$manifest.gitCommit-isnot[string]-or$manifest.gitCommit-cne$ExpectedGitCommit-or
+ $manifest.payloadManifest-isnot[pscustomobject]-or(@($manifest.payloadManifest.PSObject.Properties.Name)-join',')-cne'name,length,sha256'-or
+ $manifest.payloadManifest.name-isnot[string]-or$manifest.payloadManifest.name-cne'installer-payload.manifest.json'-or
+ $manifest.payloadManifest.length-isnot[ValueType]-or[decimal]$manifest.payloadManifest.length-ne[decimal][int64]$manifest.payloadManifest.length-or[int64]$manifest.payloadManifest.length-le 0-or[int64]$manifest.payloadManifest.length-gt$script:CcodInstallerPackageManifestMaximumBytes-or
+ $manifest.payloadManifest.sha256-isnot[string]-or$manifest.payloadManifest.sha256-cnotmatch'^[0-9a-f]{64}$'){
+ Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package manifest metadata is invalid' $null
+ }
+ return [pscustomobject]@{Text=$text;Manifest=$manifest;Records=@($manifest.files)}
+}
+
+function Assert-CcodInstallerPackageFileRecords {
+ param([Parameter(Mandatory)]$Records)
+ $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase);$previous=$null
+ foreach($record in @($Records)){
+ if($record-isnot[pscustomobject]-or(@($record.PSObject.Properties.Name)-join',')-cne'path,length,sha256'-or-not(Test-CcodInstallerPackageRelativePath ([string]$record.path))-or
+ $record.length-isnot[ValueType]-or[decimal]$record.length-ne[decimal][int64]$record.length-or[int64]$record.length-lt 0-or[int64]$record.length-gt$script:CcodInstallerPackageEntryMaximumBytes-or
+ $record.sha256-isnot[string]-or$record.sha256-cnotmatch'^[0-9a-f]{64}$'-or($null-ne$previous-and[StringComparer]::Ordinal.Compare($previous,[string]$record.path)-ge 0)-or-not$seen.Add([string]$record.path)){
+ Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package file record is invalid' $record
+ };$previous=[string]$record.path
+ }
+ if($seen.Count-eq 0){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package file list is empty' $null}
+ return [pscustomobject]@{Set=$seen;Count=$seen.Count}
+}
+
+function Open-CcodInstallerPackageSeal {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$PackagePath,[Parameter(Mandatory)][string]$ManifestPath,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageSha256,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedManifestSha256,
+ [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit
+ )
+ Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop
+ $package=Assert-CcodInstallerPackageRegularFile $PackagePath 'Installer package' $script:CcodInstallerPackageMaximumBytes
+ $manifestPathFull=Assert-CcodInstallerPackageRegularFile $ManifestPath 'Installer package manifest' $script:CcodInstallerPackageManifestMaximumBytes
+ $packageStream=$null;$manifestStream=$null;$archive=$null
+ try{
+ $packageStream=[IO.File]::Open($package,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ if((Get-CcodInstallerPackageStreamSha256 $packageStream)-cne$ExpectedPackageSha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_HASH_MISMATCH' 'Installer package hash mismatch' $package}
+ $manifestStream=[IO.File]::Open($manifestPathFull,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ if((Get-CcodInstallerPackageStreamSha256 $manifestStream)-cne$ExpectedManifestSha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_MANIFEST_HASH_MISMATCH' 'Installer package manifest hash mismatch' $manifestPathFull}
+ $manifestBytes=New-Object byte[] ([int]$manifestStream.Length);$manifestStream.Position=0;$read=$manifestStream.Read($manifestBytes,0,$manifestBytes.Length);if($read-ne$manifestBytes.Length){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package manifest read was incomplete' $manifestPathFull}
+ $manifestResult=Read-CcodInstallerPackageManifest -Bytes $manifestBytes -ExpectedVersion $ExpectedVersion -ExpectedGitCommit $ExpectedGitCommit
+ $expectedResult=Assert-CcodInstallerPackageFileRecords -Records $manifestResult.Records
+ $expected=$expectedResult.Set
+ $archive=[IO.Compression.ZipArchive]::new($packageStream,[IO.Compression.ZipArchiveMode]::Read,$true)
+ $entries=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase)
+ foreach($entry in @($archive.Entries)){
+ $name=[string]$entry.FullName
+ if(-not(Test-CcodInstallerPackageRelativePath $name)-or$entries.ContainsKey($name)-or$entry.Name.Length-eq 0-or$entry.Length-lt 0-or$entry.Length-gt$script:CcodInstallerPackageEntryMaximumBytes){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry set is unsafe or duplicate' $name}
+ $entries.Add($name,$entry)
+ }
+ if($entries.Count-ne($expected.Count+1)-or-not$entries.ContainsKey('installer-payload.manifest.json')){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry set differs from its manifest' $package}
+ foreach($path in $expected){if(-not$entries.ContainsKey($path)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry is missing' $path}}
+ $payloadEntry=$entries['installer-payload.manifest.json'];$payloadBytes=New-Object byte[] ([int]$payloadEntry.Length);$entryStream=$payloadEntry.Open();try{$offset=0;while($offset-lt$payloadBytes.Length){$count=$entryStream.Read($payloadBytes,$offset,$payloadBytes.Length-$offset);if($count-le 0){break};$offset+=$count};if($offset-ne$payloadBytes.Length){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest entry read was incomplete' $null}}finally{$entryStream.Dispose()}
+ $payloadSha=[Security.Cryptography.SHA256]::Create();try{$payloadHash=[BitConverter]::ToString($payloadSha.ComputeHash($payloadBytes)).Replace('-','').ToLowerInvariant()}finally{$payloadSha.Dispose()}
+ if([int64]$payloadBytes.Length-ne[int64]$manifestResult.Manifest.payloadManifest.length-or$payloadHash-cne[string]$manifestResult.Manifest.payloadManifest.sha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Embedded payload manifest binding is invalid' $null}
+ $payloadResult=Read-CcodInstallerPayloadManifest -Bytes $payloadBytes -ExpectedVersion $ExpectedVersion
+ if(@($payloadResult.Records).Count-ne@($manifestResult.Records).Count){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload and package manifests differ' $null}
+ for($i=0;$i-lt@($payloadResult.Records).Count;$i++){if(($payloadResult.Records[$i]|ConvertTo-Json -Compress)-cne($manifestResult.Records[$i]|ConvertTo-Json -Compress)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload and package file records differ' $null}}
+ foreach($record in @($manifestResult.Records)){$entry=$entries[[string]$record.path];if([int64]$entry.Length-ne[int64]$record.length){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry length mismatch' $record.path};$s=$entry.Open();$sha=[Security.Cryptography.SHA256]::Create();try{$hash=[BitConverter]::ToString($sha.ComputeHash($s)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose();$s.Dispose()};if($hash-cne[string]$record.sha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry hash mismatch' $record.path}}
+ return [pscustomobject]@{Valid=$true;PackagePath=$package;ManifestPath=$manifestPathFull;PackageSha256=$ExpectedPackageSha256;ManifestSha256=$ExpectedManifestSha256;PayloadManifestSha256=$payloadHash;Manifest=$manifestResult.Manifest;PayloadManifestBytes=$payloadBytes;PackageStream=$packageStream;ManifestStream=$manifestStream;Archive=$archive;Entries=$entries}
+ }catch{if($null-ne$archive){$archive.Dispose()};if($null-ne$manifestStream){$manifestStream.Dispose()};if($null-ne$packageStream){$packageStream.Dispose()};throw}
+}
+
+function Close-CcodInstallerPackageSeal { param($Seal) if($null-eq$Seal){return};foreach($name in @('Archive','ManifestStream','PackageStream')){try{$value=$Seal.$name;if($null-ne$value){$value.Dispose()}}catch{}} }
+
+function Expand-CcodInstallerPackageSeal {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)]$Seal,[Parameter(Mandatory)][string]$DestinationRoot)
+ if($null-eq$Seal-or-not[bool]$Seal.Valid){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package seal is invalid' $null}
+ $destination=[IO.Path]::GetFullPath($DestinationRoot)
+ if([IO.File]::Exists($destination)-or[IO.Directory]::Exists($destination)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Package extraction destination already exists' $destination}
+ [IO.Directory]::CreateDirectory($destination)|Out-Null
+ try{
+ $records=@($Seal.Manifest.files)+@([pscustomobject][ordered]@{path='installer-payload.manifest.json';length=[int64]$Seal.PayloadManifestBytes.Length;sha256=[string]$Seal.PayloadManifestSha256})
+ foreach($record in $records){
+ $relative=[string]$record.path;$target=[IO.Path]::GetFullPath((Join-Path $destination $relative.Replace('/','\')));$prefix=$destination.TrimEnd('\')+'\'
+ if(-not$target.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Package extraction path escaped its destination' $relative}
+ $parent=Split-Path $target -Parent;if(-not[IO.Directory]::Exists($parent)){[IO.Directory]::CreateDirectory($parent)|Out-Null}
+ $entry=$Seal.Entries[$relative];$source=$entry.Open();$output=[IO.File]::Open($target,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
+ try{$source.CopyTo($output);$output.Flush($true)}finally{$output.Dispose();$source.Dispose()}
+ if([int64](Get-Item -LiteralPath $target -Force).Length-ne[int64]$record.length-or(Get-CcodInstallerPackageSha256 $target)-cne[string]$record.sha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Extracted package entry failed verification' $relative}
+ }
+ return $destination
+ }catch{if([IO.Directory]::Exists($destination)){Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction SilentlyContinue};throw}
+}
+
+function Test-CcodInstallerPackage {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][string]$PackagePath,[Parameter(Mandatory)][string]$ManifestPath,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageSha256,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedManifestSha256,[Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit)
+ $seal=$null;try{$seal=Open-CcodInstallerPackageSeal @PSBoundParameters;return [pscustomobject]@{Valid=$true;PackageSha256=$seal.PackageSha256;ManifestSha256=$seal.ManifestSha256;PayloadManifestSha256=$seal.PayloadManifestSha256;FileCount=@($seal.Manifest.files).Count}}finally{Close-CcodInstallerPackageSeal $seal}
+}
+
+function New-CcodInstallerPackage {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][string]$PayloadRoot,[Parameter(Mandatory)][string]$PayloadManifestPath,[Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$GitCommit,[Parameter(Mandatory)][string]$OutputPath,[Parameter(Mandatory)][string]$ManifestOutputPath)
+ Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop
+ Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop
+ $root=[IO.Path]::GetFullPath($PayloadRoot).TrimEnd('\');if(-not[IO.Directory]::Exists($root)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload root is missing' $root};$rootItem=Get-Item -LiteralPath $root -Force;if(($rootItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne 0){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload root is a reparse point' $root}
+ $payloadManifest=Assert-CcodInstallerPackageRegularFile $PayloadManifestPath 'Payload manifest' $script:CcodInstallerPackageManifestMaximumBytes;$payloadBytes=[IO.File]::ReadAllBytes($payloadManifest);$payloadResult=Read-CcodInstallerPayloadManifest -Bytes $payloadBytes -ExpectedVersion $Version
+ $records=@($payloadResult.Records);$actual=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ foreach($item in @(Get-ChildItem -LiteralPath $root -Force -Recurse)) { if(($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne 0){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload contains a reparse point' $item.FullName};if($item.PSIsContainer-or$item.FullName.Equals($payloadManifest,[StringComparison]::OrdinalIgnoreCase)){continue};$relative=$item.FullName.Substring($root.Length+1).Replace('\','/');[void]$actual.Add($relative) }
+ if($actual.Count-ne$records.Count){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload file set differs from its manifest' $root}
+ foreach($record in $records){$path=Join-Path $root ([string]$record.path).Replace('/','\');if(-not[IO.File]::Exists($path)-or-not$actual.Contains([string]$record.path)-or[int64](Get-Item -LiteralPath $path -Force).Length-ne[int64]$record.length-or(Get-CcodInstallerPackageSha256 $path)-cne[string]$record.sha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload file differs from its manifest' $record.path}}
+ $payloadSha=[Security.Cryptography.SHA256]::Create();try{$payloadHash=[BitConverter]::ToString($payloadSha.ComputeHash($payloadBytes)).Replace('-','').ToLowerInvariant()}finally{$payloadSha.Dispose()}
+ $packageManifest=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$Version;gitCommit=$GitCommit;payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64]$payloadBytes.Length;sha256=$payloadHash};files=$records}
+ $output=[IO.Path]::GetFullPath($OutputPath);$manifestOutput=[IO.Path]::GetFullPath($ManifestOutputPath);if(-not[IO.Path]::IsPathRooted($OutputPath)-or-not[IO.Path]::IsPathRooted($ManifestOutputPath)-or$output-eq$manifestOutput-or[IO.File]::Exists($output)-or[IO.Directory]::Exists($output)-or[IO.File]::Exists($manifestOutput)-or[IO.Directory]::Exists($manifestOutput)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Package outputs must be distinct absent absolute paths' $output}
+ foreach($parent in @((Split-Path $output -Parent),(Split-Path $manifestOutput -Parent))){if(-not[IO.Directory]::Exists($parent)){[IO.Directory]::CreateDirectory($parent)|Out-Null}}
+ $tempPackage=Join-Path (Split-Path $output -Parent) ('.ccod.'+[guid]::NewGuid().ToString('N')+'.zip.tmp');$tempManifest=Join-Path (Split-Path $manifestOutput -Parent) ('.ccod.'+[guid]::NewGuid().ToString('N')+'.json.tmp')
+ try{
+ $archive=[IO.Compression.ZipFile]::Open($tempPackage,[IO.Compression.ZipArchiveMode]::Create);try{foreach($record in $records){$entry=$archive.CreateEntry([string]$record.path,[IO.Compression.CompressionLevel]::Optimal);$source=[IO.File]::OpenRead((Join-Path $root ([string]$record.path).Replace('/','\')));$dest=$entry.Open();try{$source.CopyTo($dest)}finally{$dest.Dispose();$source.Dispose()}};$entry=$archive.CreateEntry('installer-payload.manifest.json',[IO.Compression.CompressionLevel]::Optimal);$dest=$entry.Open();try{$dest.Write($payloadBytes,0,$payloadBytes.Length)}finally{$dest.Dispose()}}finally{$archive.Dispose()}
+ [IO.File]::WriteAllText($tempManifest,(($packageManifest|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false))
+ $packageHash=Get-CcodInstallerPackageSha256 $tempPackage;$manifestHash=Get-CcodInstallerPackageSha256 $tempManifest
+ Test-CcodInstallerPackage -PackagePath $tempPackage -ManifestPath $tempManifest -ExpectedPackageSha256 $packageHash -ExpectedManifestSha256 $manifestHash -ExpectedVersion $Version -ExpectedGitCommit $GitCommit|Out-Null
+ [IO.File]::Move($tempPackage,$output);[IO.File]::Move($tempManifest,$manifestOutput)
+ return [pscustomobject]@{PackagePath=$output;ManifestPath=$manifestOutput;PackageSha256=$packageHash;ManifestSha256=$manifestHash;PayloadManifestSha256=$payloadHash;FileCount=$records.Count}
+ }catch{foreach($path in @($tempPackage,$tempManifest)){if([IO.File]::Exists($path)){[IO.File]::Delete($path)}};if([IO.File]::Exists($output)-and-not[IO.File]::Exists($manifestOutput)){[IO.File]::Delete($output)};throw}
+}
+
+Export-ModuleMember -Function New-CcodInstallerPackage,Test-CcodInstallerPackage,Open-CcodInstallerPackageSeal,Close-CcodInstallerPackageSeal,Expand-CcodInstallerPackageSeal,Get-CcodInstallerPackageSha256
diff --git a/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1
new file mode 100644
index 0000000..441091b
--- /dev/null
+++ b/build/SetupArtifact.psm1
@@ -0,0 +1,492 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+# PowerShell 7.5+ materializes ISO-8601 JSON strings as [datetime]. These modules
+# validate provenance timestamps as canonical UTC text, so read every JSON document
+# with -DateKind String when the shell offers it and keep the Windows PowerShell 5.1
+# behavior (dates stay text) identical across both shells.
+$script:CcodSetupArtifactJsonKeepsDateText = @((Get-Command ConvertFrom-Json).Parameters.Keys) -contains 'DateKind'
+
+function Test-CcodSetupArtifactJsonInteger {
+ # ConvertFrom-Json yields [long] for JSON integers under PowerShell 7 and [int]
+ # under Windows PowerShell 5.1, so accept both shapes at one exact value.
+ param($Value,[long]$Expected=0)
+ return ($Value-is[int]-or$Value-is[long])-and[decimal]$Value-eq[decimal][long]$Value-and[long]$Value-eq$Expected
+}
+
+function ConvertFrom-CcodSetupArtifactJson {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][AllowEmptyString()][string]$Json)
+ if ($script:CcodSetupArtifactJsonKeepsDateText) { return $Json | ConvertFrom-Json -DateKind String -ErrorAction Stop }
+ return $Json | ConvertFrom-Json -ErrorAction Stop
+}
+
+function Throw-CcodSetupArtifactError {
+ param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Message,$Target)
+ throw [Management.Automation.ErrorRecord]::new(
+ [InvalidOperationException]::new($Message),
+ $Id,
+ [Management.Automation.ErrorCategory]::InvalidData,
+ $Target
+ )
+}
+
+function Get-CcodSetupArtifactHash {
+ param([Parameter(Mandatory)][string]$Path)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ $stream = $null
+ try {
+ $stream = [IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()
+ } finally {
+ if ($null -ne $stream) { $stream.Dispose() }
+ $sha.Dispose()
+ }
+}
+
+function Test-CcodSetupCanonicalUtc {
+ param([AllowNull()][string]$Value)
+ if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
+ $parsed = [DateTime]::MinValue
+ return [DateTime]::TryParseExact($Value,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind,[ref]$parsed) -and
+ $parsed.Kind -eq [DateTimeKind]::Utc -and
+ $parsed.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) -ceq $Value
+}
+
+function Initialize-CcodSetupJsonMemberScanner {
+ if ($null -ne ('CcodSetupJsonMemberScannerV1' -as [type])) { return }
+ Add-Type -TypeDefinition @'
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+public static class CcodSetupJsonMemberScannerV1
+{
+ public static void AssertUnique(string json)
+ {
+ if (json == null || json.Length == 0 || json.Length > 1048576) throw new InvalidDataException("json bounds");
+ int index = 0;
+ ParseValue(json, ref index, 0);
+ Skip(json, ref index);
+ if (index != json.Length) throw new InvalidDataException("trailing json");
+ }
+
+ private static void ParseValue(string json, ref int index, int depth)
+ {
+ if (depth > 32) throw new InvalidDataException("json depth");
+ Skip(json, ref index);
+ if (index >= json.Length) throw new InvalidDataException("missing value");
+ char c = json[index];
+ if (c == '{') { ParseObject(json, ref index, depth + 1); return; }
+ if (c == '[') { ParseArray(json, ref index, depth + 1); return; }
+ if (c == '"') { ParseString(json, ref index); return; }
+ int start = index;
+ while (index < json.Length && json[index] != ',' && json[index] != '}' && json[index] != ']' && !Char.IsWhiteSpace(json[index])) index++;
+ if (index == start) throw new InvalidDataException("invalid scalar");
+ }
+
+ private static void ParseObject(string json, ref int index, int depth)
+ {
+ index++;
+ var names = new HashSet(StringComparer.Ordinal);
+ Skip(json, ref index);
+ if (index < json.Length && json[index] == '}') { index++; return; }
+ while (true)
+ {
+ Skip(json, ref index);
+ if (index >= json.Length || json[index] != '"') throw new InvalidDataException("object key");
+ string name = ParseString(json, ref index);
+ if (!names.Add(name)) throw new InvalidDataException("duplicate object member: " + name);
+ Skip(json, ref index);
+ if (index >= json.Length || json[index] != ':') throw new InvalidDataException("object colon");
+ index++;
+ ParseValue(json, ref index, depth);
+ Skip(json, ref index);
+ if (index >= json.Length) throw new InvalidDataException("object end");
+ if (json[index] == '}') { index++; return; }
+ if (json[index] != ',') throw new InvalidDataException("object comma");
+ index++;
+ }
+ }
+
+ private static void ParseArray(string json, ref int index, int depth)
+ {
+ index++;
+ Skip(json, ref index);
+ if (index < json.Length && json[index] == ']') { index++; return; }
+ while (true)
+ {
+ ParseValue(json, ref index, depth);
+ Skip(json, ref index);
+ if (index >= json.Length) throw new InvalidDataException("array end");
+ if (json[index] == ']') { index++; return; }
+ if (json[index] != ',') throw new InvalidDataException("array comma");
+ index++;
+ }
+ }
+
+ private static string ParseString(string json, ref int index)
+ {
+ if (json[index] != '"') throw new InvalidDataException("string start");
+ index++;
+ var value = new StringBuilder();
+ while (index < json.Length)
+ {
+ char c = json[index++];
+ if (c == '"') return value.ToString();
+ if (c < 0x20) throw new InvalidDataException("string control");
+ if (c != '\\') { value.Append(c); continue; }
+ if (index >= json.Length) throw new InvalidDataException("string escape");
+ char escaped = json[index++];
+ switch (escaped)
+ {
+ case '"': value.Append('"'); break;
+ case '\\': value.Append('\\'); break;
+ case '/': value.Append('/'); break;
+ case 'b': value.Append('\b'); break;
+ case 'f': value.Append('\f'); break;
+ case 'n': value.Append('\n'); break;
+ case 'r': value.Append('\r'); break;
+ case 't': value.Append('\t'); break;
+ case 'u':
+ if (index + 4 > json.Length) throw new InvalidDataException("unicode escape");
+ int code;
+ if (!Int32.TryParse(json.Substring(index, 4), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out code)) throw new InvalidDataException("unicode escape");
+ value.Append((char)code); index += 4; break;
+ default: throw new InvalidDataException("unknown escape");
+ }
+ }
+ throw new InvalidDataException("unterminated string");
+ }
+
+ private static void Skip(string json, ref int index)
+ {
+ while (index < json.Length && Char.IsWhiteSpace(json[index])) index++;
+ }
+}
+'@
+}
+
+function Assert-CcodSetupUniqueJsonMembers {
+ param([Parameter(Mandatory)][string]$Json,[Parameter(Mandatory)]$Target)
+ try {
+ Initialize-CcodSetupJsonMemberScanner
+ [CcodSetupJsonMemberScannerV1]::AssertUnique($Json)
+ } catch {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'JSON contains duplicate members or is outside the bounded raw schema' $Target
+ }
+}
+
+function Get-CcodSetupDescription {
+ param([Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version)
+ return "CCODSETUP $Version"
+}
+
+function Assert-CcodSetupRegularFile {
+ param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Kind)
+ $full = [IO.Path]::GetFullPath($Path)
+ if (-not [IO.File]::Exists($full)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_ARTIFACT_INVALID' "$Kind is missing" $full }
+ $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_ARTIFACT_INVALID' "$Kind must be a regular non-reparse file" $full
+ }
+ return $full
+}
+
+function Test-CcodSetupArtifact {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$SetupPath,
+ [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit,
+ [ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPayloadManifestSha256,
+ [ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageSha256,
+ [ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageManifestSha256,
+ [ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedActivationBootstrapSha256
+ )
+
+ $setup = Assert-CcodSetupRegularFile -Path $SetupPath -Kind 'Setup artifact'
+ $expectedPeVersion = "$ExpectedVersion.0"
+ $expectedDescription = Get-CcodSetupDescription -Version $ExpectedVersion
+ try { $versionInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo($setup) }
+ catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PE_VERSION_INVALID' 'Setup PE version metadata could not be read' $setup }
+ $fileVersion = ([string]$versionInfo.FileVersion).Trim()
+ $productVersion = ([string]$versionInfo.ProductVersion).Trim()
+ $productName = ([string]$versionInfo.ProductName).Trim()
+ $fileDescription = ([string]$versionInfo.FileDescription).Trim()
+ $companyName = ([string]$versionInfo.CompanyName).Trim()
+ $legalCopyright = ([string]$versionInfo.LegalCopyright).Trim()
+ $originalFilename = ([string]$versionInfo.OriginalFilename).Trim()
+ $sealedContract = -not [string]::IsNullOrWhiteSpace($ExpectedPackageSha256) -or -not [string]::IsNullOrWhiteSpace($ExpectedPackageManifestSha256) -or -not [string]::IsNullOrWhiteSpace($ExpectedActivationBootstrapSha256)
+ if ($sealedContract -and ([string]::IsNullOrWhiteSpace($ExpectedPackageSha256) -or [string]::IsNullOrWhiteSpace($ExpectedPackageManifestSha256) -or [string]::IsNullOrWhiteSpace($ExpectedActivationBootstrapSha256))) {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PAYLOAD_BINDING_INVALID' 'The sealed Setup PE contract requires all three hashes' $setup
+ }
+ $expectedProductVersion = if ($sealedContract) { $ExpectedPackageManifestSha256.Substring(0,32) } else { $expectedPeVersion }
+ if ($sealedContract) { $expectedDescription = $ExpectedPackageManifestSha256.Substring(32,32) }
+ $expectedProductName = if ($sealedContract) { $ExpectedActivationBootstrapSha256.Substring(32,32) } else { 'CodexRemote-fix' }
+ $expectedOriginalFilename = if ($sealedContract) { $ExpectedActivationBootstrapSha256.Substring(0,32) } else { $originalFilename }
+ if ($fileVersion -cne $expectedPeVersion -or $productVersion -cne $expectedProductVersion) {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PE_VERSION_INVALID' ("Setup PE FileVersion/ProductVersion do not match the release version: file={0}; product={1}; expected={2}" -f [string]$versionInfo.FileVersion,[string]$versionInfo.ProductVersion,$expectedPeVersion) $setup
+ }
+ $expectedCopyright = if ($sealedContract) { $ExpectedPackageSha256 } else { $ExpectedPayloadManifestSha256 }
+ if ($productName -cne $expectedProductName -or $fileDescription -cne $expectedDescription -or
+ $companyName -cne $ExpectedGitCommit -or $legalCopyright -cne $expectedCopyright -or
+ ($sealedContract -and $originalFilename -cne $expectedOriginalFilename)) {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PAYLOAD_BINDING_INVALID' ("Setup PE does not bind the expected version, commit, package, manifest, and bootstrap hashes: product={0}; productVersion={1}; description={2}; company={3}; copyright={4}; original={5}" -f $productName,$productVersion,$fileDescription,$companyName,$legalCopyright,$originalFilename) $setup
+ }
+ return [pscustomobject][ordered]@{
+ Valid = $true
+ Sha256 = Get-CcodSetupArtifactHash -Path $setup
+ FileVersion = $fileVersion
+ ProductVersion = $productVersion
+ ProductName = $productName
+ FileDescription = $fileDescription
+ CompanyName = $companyName
+ LegalCopyright = $legalCopyright
+ OriginalFilename = $originalFilename
+ PackageManifestSha256 = if ($sealedContract) { $productVersion + $fileDescription } else { $null }
+ ActivationBootstrapSha256 = if ($sealedContract) { $originalFilename + $productName } else { $null }
+ }
+}
+
+function New-CcodSetupBuildProvenance {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$GitCommit,
+ [Parameter(Mandatory)][string]$BuildTimestampUtc,
+ [Parameter(Mandatory)][string]$PayloadManifestPath,
+ [Parameter(Mandatory)][string]$InnoTemplatePath,
+ [Parameter(Mandatory)][string]$DestinationInventoryPath,
+ [Parameter(Mandatory)][string]$CompilerPath,
+ [Parameter(Mandatory)][string]$OutputPath
+ )
+
+ if (-not (Test-CcodSetupCanonicalUtc $BuildTimestampUtc)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance timestamp is not canonical UTC' $BuildTimestampUtc }
+ $payload = Assert-CcodSetupRegularFile -Path $PayloadManifestPath -Kind 'Installer payload manifest'
+ $template = Assert-CcodSetupRegularFile -Path $InnoTemplatePath -Kind 'Inno template'
+ $inventory = Assert-CcodSetupRegularFile -Path $DestinationInventoryPath -Kind 'Destination inventory'
+ $compiler = Assert-CcodSetupRegularFile -Path $CompilerPath -Kind 'Inno compiler'
+ try { $payloadRecord = [IO.File]::ReadAllText($payload,[Text.UTF8Encoding]::new($false)) | ConvertFrom-Json -ErrorAction Stop }
+ catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer payload manifest is invalid JSON' $payload }
+ if (-not (Test-CcodSetupArtifactJsonInteger $payloadRecord.schemaVersion 1) -or
+ $payloadRecord.projectVersion -isnot [string] -or $payloadRecord.projectVersion -cne $Version -or
+ @($payloadRecord.files).Count -eq 0) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer payload manifest metadata is invalid' $payload }
+ $payloadHash = Get-CcodSetupArtifactHash -Path $payload
+ $compilerVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($compiler)
+ $record = [ordered]@{
+ schemaVersion = 1
+ product = 'CodexRemote-fix'
+ version = $Version
+ gitCommit = $GitCommit
+ buildTimestampUtc = $BuildTimestampUtc
+ payloadManifest = [ordered]@{
+ name = 'installer-payload.manifest.json'
+ length = [int64](Get-Item -LiteralPath $payload -Force).Length
+ sha256 = $payloadHash
+ fileCount = [int]@($payloadRecord.files).Count
+ }
+ buildInputs = [ordered]@{
+ innoTemplateSha256 = Get-CcodSetupArtifactHash -Path $template
+ destinationInventorySha256 = Get-CcodSetupArtifactHash -Path $inventory
+ compilerSha256 = Get-CcodSetupArtifactHash -Path $compiler
+ compilerFileVersion = [string]$compilerVersion.FileVersion
+ }
+ peContract = [ordered]@{
+ fileVersion = "$Version.0"
+ productVersion = "$Version.0"
+ productName = 'CodexRemote-fix'
+ fileDescription = Get-CcodSetupDescription -Version $Version
+ companyName = $GitCommit
+ legalCopyright = $payloadHash
+ }
+ }
+ $output = [IO.Path]::GetFullPath($OutputPath)
+ if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Refusing to overwrite Setup provenance' $output }
+ [IO.File]::WriteAllText($output,(($record | ConvertTo-Json -Depth 8) + [Environment]::NewLine),[Text.UTF8Encoding]::new($false))
+ return [pscustomobject]$record
+}
+
+function Test-CcodSetupBuildProvenance {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$ProvenancePath,
+ [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPayloadManifestSha256,
+ [string]$ExpectedBuildTimestampUtc,
+ [Parameter(Mandatory)][string]$InnoTemplatePath,
+ [Parameter(Mandatory)][string]$DestinationInventoryPath,
+ [Parameter(Mandatory)][string]$CompilerPath,
+ [Parameter(Mandatory)][string]$PayloadManifestPath
+ )
+ $path = Assert-CcodSetupRegularFile -Path $ProvenancePath -Kind 'Setup provenance'
+ $template = Assert-CcodSetupRegularFile -Path $InnoTemplatePath -Kind 'Inno template'
+ $inventory = Assert-CcodSetupRegularFile -Path $DestinationInventoryPath -Kind 'Destination inventory'
+ $compiler = Assert-CcodSetupRegularFile -Path $CompilerPath -Kind 'Inno compiler'
+ $payload = Assert-CcodSetupRegularFile -Path $PayloadManifestPath -Kind 'Installer payload manifest'
+ try { $payloadRaw = [IO.File]::ReadAllText($payload,[Text.UTF8Encoding]::new($false)); $payloadRecord = ConvertFrom-CcodSetupArtifactJson -Json $payloadRaw }
+ catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Canonical installer payload manifest is invalid' $payload }
+ $actualPayloadHash = Get-CcodSetupArtifactHash -Path $payload
+ $actualCompilerVersion = ([string][Diagnostics.FileVersionInfo]::GetVersionInfo($compiler).FileVersion).Trim()
+ try { $raw = [IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false)); $record = ConvertFrom-CcodSetupArtifactJson -Json $raw }
+ catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is invalid JSON' $path }
+ $timestampMatches = [regex]::Matches($raw,'"buildTimestampUtc"\s*:\s*"(?[^"\\]+)"')
+ $timestampText = if ($timestampMatches.Count -eq 1) { [string]$timestampMatches[0].Groups['value'].Value } else { $null }
+ $fields = @('schemaVersion','product','version','gitCommit','buildTimestampUtc','payloadManifest','buildInputs','peContract')
+ if ($record -isnot [pscustomobject] -or ((@($record.PSObject.Properties.Name | Sort-Object) -join '|') -cne (@($fields | Sort-Object) -join '|')) -or
+ -not (Test-CcodSetupArtifactJsonInteger $record.schemaVersion 1) -or $record.product -isnot [string] -or $record.product -cne 'CodexRemote-fix' -or
+ $record.version -isnot [string] -or $record.version -cne $ExpectedVersion -or $record.gitCommit -isnot [string] -or $record.gitCommit -cne $ExpectedGitCommit -or
+ -not (Test-CcodSetupCanonicalUtc $timestampText) -or
+ (-not [string]::IsNullOrWhiteSpace($ExpectedBuildTimestampUtc) -and $timestampText -cne $ExpectedBuildTimestampUtc) -or
+ $record.payloadManifest.sha256 -isnot [string] -or $record.payloadManifest.sha256 -cne $ExpectedPayloadManifestSha256 -or $record.payloadManifest.sha256 -cne $actualPayloadHash -or
+ $record.payloadManifest.name -isnot [string] -or $record.payloadManifest.name -cne 'installer-payload.manifest.json' -or
+ [int64]$record.payloadManifest.length -ne [int64](Get-Item -LiteralPath $payload -Force).Length -or
+ [int]$record.payloadManifest.fileCount -ne [int]@($payloadRecord.files).Count -or
+ $record.peContract.fileVersion -isnot [string] -or $record.peContract.fileVersion -cne "$ExpectedVersion.0" -or
+ $record.peContract.productVersion -isnot [string] -or $record.peContract.productVersion -cne "$ExpectedVersion.0" -or
+ $record.peContract.productName -isnot [string] -or $record.peContract.productName -cne 'CodexRemote-fix' -or
+ $record.peContract.fileDescription -isnot [string] -or $record.peContract.fileDescription -cne (Get-CcodSetupDescription -Version $ExpectedVersion) -or
+ $record.peContract.companyName -isnot [string] -or $record.peContract.companyName -cne $ExpectedGitCommit -or
+ $record.peContract.legalCopyright -isnot [string] -or $record.peContract.legalCopyright -cne $ExpectedPayloadManifestSha256 -or
+ $record.buildInputs.innoTemplateSha256 -isnot [string] -or $record.buildInputs.innoTemplateSha256 -cne (Get-CcodSetupArtifactHash -Path $template) -or
+ $record.buildInputs.destinationInventorySha256 -isnot [string] -or $record.buildInputs.destinationInventorySha256 -cne (Get-CcodSetupArtifactHash -Path $inventory) -or
+ $record.buildInputs.compilerSha256 -isnot [string] -or $record.buildInputs.compilerSha256 -cne (Get-CcodSetupArtifactHash -Path $compiler) -or
+ $record.buildInputs.compilerFileVersion -isnot [string] -or ([string]$record.buildInputs.compilerFileVersion).Trim() -cne $actualCompilerVersion) {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is not exactly bound to the release contract' $path
+ }
+ return $record
+}
+
+function New-CcodSealedSetupBuildProvenance {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$GitCommit,
+ [Parameter(Mandatory)][string]$BuildTimestampUtc,
+ [Parameter(Mandatory)][string]$PackagePath,
+ [Parameter(Mandatory)][string]$PackageManifestPath,
+ [Parameter(Mandatory)][string]$ActivationBootstrapPath,
+ [Parameter(Mandatory)][string]$InnoTemplatePath,
+ [Parameter(Mandatory)][string]$DestinationInventoryPath,
+ [Parameter(Mandatory)][string]$CompilerPath,
+ [Parameter(Mandatory)][string]$OutputPath
+ )
+ if (-not (Test-CcodSetupCanonicalUtc $BuildTimestampUtc)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance timestamp is invalid' $BuildTimestampUtc }
+ $package=Assert-CcodSetupRegularFile $PackagePath 'Installer package';$manifest=Assert-CcodSetupRegularFile $PackageManifestPath 'Installer package manifest';$bootstrap=Assert-CcodSetupRegularFile $ActivationBootstrapPath 'Activation bootstrap';$template=Assert-CcodSetupRegularFile $InnoTemplatePath 'Inno template';$inventory=Assert-CcodSetupRegularFile $DestinationInventoryPath 'Destination inventory';$compiler=Assert-CcodSetupRegularFile $CompilerPath 'Inno compiler'
+ try{$manifestRecord=ConvertFrom-CcodSetupArtifactJson -Json ([IO.File]::ReadAllText($manifest,[Text.UTF8Encoding]::new($false)))}catch{Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer package manifest JSON is invalid' $manifest}
+ if(-not(Test-CcodSetupArtifactJsonInteger $manifestRecord.schemaVersion 1)-or$manifestRecord.product-cne'CodexRemote-fix'-or$manifestRecord.version-cne$Version-or$manifestRecord.gitCommit-cne$GitCommit-or@($manifestRecord.files).Count-eq0){Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer package manifest identity is invalid' $manifest}
+ $packageHash=Get-CcodSetupArtifactHash $package;$manifestHash=Get-CcodSetupArtifactHash $manifest;$bootstrapHash=Get-CcodSetupArtifactHash $bootstrap;$compilerInfo=[Diagnostics.FileVersionInfo]::GetVersionInfo($compiler)
+ $record=[ordered]@{
+ schemaVersion=2;product='CodexRemote-fix';version=$Version;gitCommit=$GitCommit;buildTimestampUtc=$BuildTimestampUtc
+ installerPackage=[ordered]@{name='installer-package.zip';length=[int64](Get-Item $package -Force).Length;sha256=$packageHash}
+ installerPackageManifest=[ordered]@{name='installer-package.manifest.json';length=[int64](Get-Item $manifest -Force).Length;sha256=$manifestHash;fileCount=[int]@($manifestRecord.files).Count;payloadManifestSha256=[string]$manifestRecord.payloadManifest.sha256}
+ activationBootstrap=[ordered]@{name='Activate-CcodRemoteFix.ps1';length=[int64](Get-Item $bootstrap -Force).Length;sha256=$bootstrapHash}
+ buildInputs=[ordered]@{innoTemplateSha256=Get-CcodSetupArtifactHash $template;destinationInventorySha256=Get-CcodSetupArtifactHash $inventory;compilerSha256=Get-CcodSetupArtifactHash $compiler;compilerFileVersion=[string]$compilerInfo.FileVersion}
+ peContract=[ordered]@{fileVersion="$Version.0";packageManifestFirst=$manifestHash.Substring(0,32);packageManifestLast=$manifestHash.Substring(32,32);bootstrapFirst=$bootstrapHash.Substring(0,32);bootstrapLast=$bootstrapHash.Substring(32,32);companyName=$GitCommit;legalCopyright=$packageHash}
+ }
+ $output=[IO.Path]::GetFullPath($OutputPath);if([IO.File]::Exists($output)-or[IO.Directory]::Exists($output)){Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Refusing to overwrite sealed Setup provenance' $output}
+ [IO.File]::WriteAllText($output,(($record|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false));return [pscustomobject]$record
+}
+
+function Test-CcodSealedSetupBuildProvenance {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$ProvenancePath,[Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageSha256,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageManifestSha256,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedActivationBootstrapSha256,[Parameter(Mandatory)][string]$ExpectedBuildTimestampUtc,
+ [Parameter(Mandatory)][string]$PackagePath,[Parameter(Mandatory)][string]$PackageManifestPath,[Parameter(Mandatory)][string]$ActivationBootstrapPath,[Parameter(Mandatory)][string]$InnoTemplatePath,[Parameter(Mandatory)][string]$DestinationInventoryPath,[Parameter(Mandatory)][string]$CompilerPath
+ )
+ $path=Assert-CcodSetupRegularFile $ProvenancePath 'Sealed Setup provenance'
+ $package=Assert-CcodSetupRegularFile $PackagePath 'Installer package'
+ $manifest=Assert-CcodSetupRegularFile $PackageManifestPath 'Installer package manifest'
+ $bootstrap=Assert-CcodSetupRegularFile $ActivationBootstrapPath 'Activation bootstrap'
+ $template=Assert-CcodSetupRegularFile $InnoTemplatePath 'Inno template'
+ $inventory=Assert-CcodSetupRegularFile $DestinationInventoryPath 'Destination inventory'
+ $compiler=Assert-CcodSetupRegularFile $CompilerPath 'Inno compiler'
+ try {
+ $raw=[IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false))
+ Assert-CcodSetupUniqueJsonMembers -Json $raw -Target $path
+ $record=ConvertFrom-CcodSetupArtifactJson -Json $raw
+ $manifestRaw=[IO.File]::ReadAllText($manifest,[Text.UTF8Encoding]::new($false))
+ Assert-CcodSetupUniqueJsonMembers -Json $manifestRaw -Target $manifest
+ $packageManifest=ConvertFrom-CcodSetupArtifactJson -Json $manifestRaw
+ } catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance or package manifest JSON is invalid' $path }
+
+ function Test-CcodExactObjectProperties {
+ param($Value,[string[]]$Names)
+ return $Value-is[pscustomobject]-and(@($Value.PSObject.Properties.Name)-join',')-ceq($Names-join',')
+ }
+ function Test-CcodJsonInteger {
+ param($Value)
+ return ($Value-is[int]-or$Value-is[long])-and[decimal]$Value-eq[decimal][long]$Value
+ }
+ function Test-CcodExactString {
+ param($Value,[string]$Expected)
+ return $Value-is[string]-and$Value-ceq$Expected
+ }
+
+ $top=@('schemaVersion','product','version','gitCommit','buildTimestampUtc','installerPackage','installerPackageManifest','activationBootstrap','buildInputs','peContract')
+ $artifactFields=@('name','length','sha256')
+ $manifestFields=@('name','length','sha256','fileCount','payloadManifestSha256')
+ $buildFields=@('innoTemplateSha256','destinationInventorySha256','compilerSha256','compilerFileVersion')
+ $peFields=@('fileVersion','packageManifestFirst','packageManifestLast','bootstrapFirst','bootstrapLast','companyName','legalCopyright')
+ $packageManifestFields=@('schemaVersion','product','version','gitCommit','payloadManifest','files')
+ $payloadManifestFields=@('name','length','sha256')
+ if(-not(Test-CcodExactObjectProperties $record $top)-or
+ -not(Test-CcodExactObjectProperties $record.installerPackage $artifactFields)-or
+ -not(Test-CcodExactObjectProperties $record.installerPackageManifest $manifestFields)-or
+ -not(Test-CcodExactObjectProperties $record.activationBootstrap $artifactFields)-or
+ -not(Test-CcodExactObjectProperties $record.buildInputs $buildFields)-or
+ -not(Test-CcodExactObjectProperties $record.peContract $peFields)-or
+ -not(Test-CcodExactObjectProperties $packageManifest $packageManifestFields)-or
+ -not(Test-CcodExactObjectProperties $packageManifest.payloadManifest $payloadManifestFields)){
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance property schema is invalid' $path
+ }
+ $actualCompilerVersion=([string][Diagnostics.FileVersionInfo]::GetVersionInfo($compiler).FileVersion).Trim()
+ $packageLength=[long](Get-Item $package -Force).Length
+ $manifestLength=[long](Get-Item $manifest -Force).Length
+ $bootstrapLength=[long](Get-Item $bootstrap -Force).Length
+ if(-not(Test-CcodSetupArtifactJsonInteger $record.schemaVersion 2)-or
+ -not(Test-CcodExactString $record.product 'CodexRemote-fix')-or
+ -not(Test-CcodExactString $record.version $ExpectedVersion)-or
+ -not(Test-CcodExactString $record.gitCommit $ExpectedGitCommit)-or
+ -not(Test-CcodExactString $record.buildTimestampUtc $ExpectedBuildTimestampUtc)-or
+ -not(Test-CcodSetupCanonicalUtc $record.buildTimestampUtc)-or
+ -not(Test-CcodSetupArtifactJsonInteger $packageManifest.schemaVersion 1)-or
+ -not(Test-CcodExactString $packageManifest.product 'CodexRemote-fix')-or
+ -not(Test-CcodExactString $packageManifest.version $ExpectedVersion)-or
+ -not(Test-CcodExactString $packageManifest.gitCommit $ExpectedGitCommit)-or
+ -not(Test-CcodExactString $packageManifest.payloadManifest.name 'installer-payload.manifest.json')-or
+ -not(Test-CcodJsonInteger $packageManifest.payloadManifest.length)-or[long]$packageManifest.payloadManifest.length-le0-or
+ @($packageManifest.files).Count-le0-or
+ -not(Test-CcodExactString $record.installerPackage.name 'installer-package.zip')-or
+ -not(Test-CcodJsonInteger $record.installerPackage.length)-or[long]$record.installerPackage.length-ne$packageLength-or
+ -not(Test-CcodExactString $record.installerPackage.sha256 $ExpectedPackageSha256)-or$record.installerPackage.sha256-cne(Get-CcodSetupArtifactHash $package)-or
+ -not(Test-CcodExactString $record.installerPackageManifest.name 'installer-package.manifest.json')-or
+ -not(Test-CcodJsonInteger $record.installerPackageManifest.length)-or[long]$record.installerPackageManifest.length-ne$manifestLength-or
+ -not(Test-CcodExactString $record.installerPackageManifest.sha256 $ExpectedPackageManifestSha256)-or$record.installerPackageManifest.sha256-cne(Get-CcodSetupArtifactHash $manifest)-or
+ -not(Test-CcodSetupArtifactJsonInteger $record.installerPackageManifest.fileCount @($packageManifest.files).Count)-or
+ -not(Test-CcodExactString $record.installerPackageManifest.payloadManifestSha256 ([string]$packageManifest.payloadManifest.sha256))-or
+ $packageManifest.payloadManifest.sha256-isnot[string]-or$packageManifest.payloadManifest.sha256-cnotmatch'^[0-9a-f]{64}$'-or
+ -not(Test-CcodExactString $record.activationBootstrap.name 'Activate-CcodRemoteFix.ps1')-or
+ -not(Test-CcodJsonInteger $record.activationBootstrap.length)-or[long]$record.activationBootstrap.length-ne$bootstrapLength-or
+ -not(Test-CcodExactString $record.activationBootstrap.sha256 $ExpectedActivationBootstrapSha256)-or$record.activationBootstrap.sha256-cne(Get-CcodSetupArtifactHash $bootstrap)-or
+ -not(Test-CcodExactString $record.buildInputs.innoTemplateSha256 (Get-CcodSetupArtifactHash $template))-or
+ -not(Test-CcodExactString $record.buildInputs.destinationInventorySha256 (Get-CcodSetupArtifactHash $inventory))-or
+ -not(Test-CcodExactString $record.buildInputs.compilerSha256 (Get-CcodSetupArtifactHash $compiler))-or
+ -not(Test-CcodExactString $record.buildInputs.compilerFileVersion $actualCompilerVersion)-or
+ -not(Test-CcodExactString $record.peContract.fileVersion "$ExpectedVersion.0")-or
+ -not(Test-CcodExactString $record.peContract.packageManifestFirst $ExpectedPackageManifestSha256.Substring(0,32))-or
+ -not(Test-CcodExactString $record.peContract.packageManifestLast $ExpectedPackageManifestSha256.Substring(32,32))-or
+ -not(Test-CcodExactString $record.peContract.bootstrapFirst $ExpectedActivationBootstrapSha256.Substring(0,32))-or
+ -not(Test-CcodExactString $record.peContract.bootstrapLast $ExpectedActivationBootstrapSha256.Substring(32,32))-or
+ -not(Test-CcodExactString $record.peContract.companyName $ExpectedGitCommit)-or
+ -not(Test-CcodExactString $record.peContract.legalCopyright $ExpectedPackageSha256)){
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance is not exactly bound' $path
+ }
+ return $record
+}
+
+Export-ModuleMember -Function Get-CcodSetupArtifactHash,Get-CcodSetupDescription,New-CcodSetupBuildProvenance,Test-CcodSetupBuildProvenance,New-CcodSealedSetupBuildProvenance,Test-CcodSealedSetupBuildProvenance,Test-CcodSetupArtifact
diff --git a/build/TrayHostBuild.psm1 b/build/TrayHostBuild.psm1
index cbfd380..b46022d 100644
--- a/build/TrayHostBuild.psm1
+++ b/build/TrayHostBuild.psm1
@@ -1,6 +1,19 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
+# PowerShell 7.5+ materializes ISO-8601 JSON strings as [datetime]. These modules
+# validate provenance timestamps as canonical UTC text, so read every JSON document
+# with -DateKind String when the shell offers it and keep the Windows PowerShell 5.1
+# behavior (dates stay text) identical across both shells.
+$script:CcodTrayHostJsonKeepsDateText = @((Get-Command ConvertFrom-Json).Parameters.Keys) -contains 'DateKind'
+
+function ConvertFrom-CcodTrayHostJson {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][AllowEmptyString()][string]$Json)
+ if ($script:CcodTrayHostJsonKeepsDateText) { return $Json | ConvertFrom-Json -DateKind String -ErrorAction Stop }
+ return $Json | ConvertFrom-Json -ErrorAction Stop
+}
+
function Get-CcodTrayHostHash {
param([Parameter(Mandatory)][string]$Path)
$sha=[Security.Cryptography.SHA256]::Create()
@@ -35,6 +48,53 @@ function Test-CcodTrayHostOutputDirectory {
if([string]::IsNullOrWhiteSpace($full) -or $full -eq [IO.Path]::GetPathRoot($full).TrimEnd('\')){throw 'CCOD_TRAYHOST_OUTPUT_PATH_INVALID'}
}
+function Assert-CcodNativeVersionContract {
+ param(
+ [Parameter(Mandatory)][string]$RepositoryRoot,
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][ValidateSet('trayhost','portable')][string]$Component
+ )
+ $definition=if($Component -ceq 'trayhost'){
+ [pscustomobject]@{ManifestName='CodexRemote.TrayHost.manifest';IdentityName='CodexRemote.fix.TrayHost';MissingError='CCOD_TRAYHOST_SOURCE_MISSING';VersionError='CCOD_TRAYHOST_VERSION_MISMATCH'}
+ }else{
+ [pscustomobject]@{ManifestName='CodexRemote.Portable.manifest';IdentityName='CodexRemote.fix.Portable';MissingError='CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING';VersionError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH'}
+ }
+ $repo=[IO.Path]::GetFullPath($RepositoryRoot)
+ $sourceRoot=Join-Path $repo ('src\'+$Component)
+ $packagePath=Join-Path $repo 'package.json'
+ $assemblyInfoPath=Join-Path $sourceRoot 'AssemblyInfo.cs'
+ $manifestPath=Join-Path $sourceRoot $definition.ManifestName
+ foreach($required in @($packagePath,$assemblyInfoPath,$manifestPath)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw $definition.MissingError}}
+ try{
+ $package=Get-Content -LiteralPath $packagePath -Raw|ConvertFrom-Json
+ if([string]$package.version -cne $Version){throw $definition.VersionError}
+ $expectedNativeVersion="$Version.0"
+ $assemblyInfo=[IO.File]::ReadAllText($assemblyInfoPath,[Text.UTF8Encoding]::new($false))
+ $assemblyVersions=@([regex]::Matches($assemblyInfo,'(?m)^\s*\[assembly:\s*AssemblyVersion\("(?[^"]+)"\)\]\s*$'))
+ $fileVersions=@([regex]::Matches($assemblyInfo,'(?m)^\s*\[assembly:\s*AssemblyFileVersion\("(?[^"]+)"\)\]\s*$'))
+ if($assemblyVersions.Count -ne 1 -or $fileVersions.Count -ne 1 -or
+ [string]$assemblyVersions[0].Groups['version'].Value -cne $expectedNativeVersion -or
+ [string]$fileVersions[0].Groups['version'].Value -cne $expectedNativeVersion){throw $definition.VersionError}
+ [xml]$manifest=[IO.File]::ReadAllText($manifestPath,[Text.UTF8Encoding]::new($false))
+ $namespace=[Xml.XmlNamespaceManager]::new($manifest.NameTable)
+ $namespace.AddNamespace('asmv1','urn:schemas-microsoft-com:asm.v1')
+ $identities=@($manifest.SelectNodes('/asmv1:assembly/asmv1:assemblyIdentity',$namespace))
+ if($identities.Count -ne 1 -or [string]$identities[0].version -cne $expectedNativeVersion -or
+ [string]$identities[0].name -cne [string]$definition.IdentityName -or [string]$identities[0].type -cne 'win32'){throw $definition.VersionError}
+ return [pscustomobject][ordered]@{SourceRoot=$sourceRoot;AssemblyInfoPath=$assemblyInfoPath;ManifestPath=$manifestPath;NativeVersion=$expectedNativeVersion}
+ }catch{
+ if(([string]$_.FullyQualifiedErrorId -split ',')[0] -like "$($definition.MissingError)*"){throw}
+ throw $definition.VersionError
+ }
+}
+
+function Assert-CcodNativePeVersion {
+ param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Version,[Parameter(Mandatory)][string]$ErrorId)
+ $expected="$Version.0"
+ try{$versionInfo=[Diagnostics.FileVersionInfo]::GetVersionInfo([IO.Path]::GetFullPath($Path))}catch{throw $ErrorId}
+ if([string]$versionInfo.FileVersion -cne $expected -or [string]$versionInfo.ProductVersion -cne $expected){throw $ErrorId}
+}
+
function Invoke-CcodTrayHostBuild {
[CmdletBinding()]
param(
@@ -46,11 +106,12 @@ function Invoke-CcodTrayHostBuild {
)
if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_TRAYHOST_VERSION_INVALID'}
$repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($OutputDirectory);Test-CcodTrayHostOutputDirectory $out
+ $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component trayhost
if([string]::IsNullOrWhiteSpace($GitCommit)){$GitCommit=Get-CcodTrayHostGitCommit -RepositoryRoot $repo}else{$GitCommit=$GitCommit.ToLowerInvariant()}
if($GitCommit -cnotmatch '^[0-9a-f]{40}$'){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'}
if([string]::IsNullOrWhiteSpace($BuildTimestampUtc)){$BuildTimestampUtc=[datetime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture)}
if(-not (Test-CcodTrayHostCanonicalUtc $BuildTimestampUtc)){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'}
- $sourceRoot=Join-Path $repo 'src\trayhost';$manifest=Join-Path $sourceRoot 'CodexRemote.TrayHost.manifest';$config=Join-Path $sourceRoot 'CodexRemote.TrayHost.exe.config';$icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico'
+ $sourceRoot=$versionContract.SourceRoot;$manifest=$versionContract.ManifestPath;$config=Join-Path $sourceRoot 'CodexRemote.TrayHost.exe.config';$icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico'
foreach($required in @($manifest,$config,$icon)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_TRAYHOST_SOURCE_MISSING'}}
$sources=@(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File|Sort-Object Name)
if($sources.Count -lt 10){throw 'CCOD_TRAYHOST_SOURCE_INCOMPLETE'}
@@ -65,6 +126,7 @@ function Invoke-CcodTrayHostBuild {
$compilerArgs+=@($sources|ForEach-Object FullName)
& $compiler @compilerArgs
if($LASTEXITCODE -ne 0 -or -not(Test-Path -LiteralPath $exe -PathType Leaf)){throw 'CCOD_TRAYHOST_COMPILE_FAILED'}
+ Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID'
$configOut=Join-Path $work 'CodexRemote.TrayHost.exe.config';Copy-Item -LiteralPath $config -Destination $configOut -Force
$stdoutPath=Join-Path $work 'stdout.txt';$stderrPath=Join-Path $work 'stderr.txt'
$smoke=Start-Process -FilePath $exe -ArgumentList '--headless-smoke' -Wait -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath
@@ -83,15 +145,38 @@ function Invoke-CcodTrayHostBuild {
function Test-CcodTrayHostArtifact {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$RepositoryRoot,[Parameter(Mandatory)][string]$Version,[Parameter(Mandatory)][string]$ArtifactDirectory,[string]$ExpectedGitCommit)
+ if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_TRAYHOST_VERSION_INVALID'}
$repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($ArtifactDirectory);Test-CcodTrayHostOutputDirectory $out
+ $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component trayhost
$exe=Join-Path $out 'CodexRemote.TrayHost.exe';$config=Join-Path $out 'CodexRemote.TrayHost.exe.config';$provenancePath=Join-Path $out 'trayhost-build-provenance.json'
foreach($path in @($exe,$config,$provenancePath)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_TRAYHOST_ARTIFACT_MISSING'}}
- $provenance=Get-Content -LiteralPath $provenancePath -Raw|ConvertFrom-Json
+ $provenance=ConvertFrom-CcodTrayHostJson -Json (Get-Content -LiteralPath $provenancePath -Raw)
$commit=$provenance.PSObject.Properties['gitCommit'];$timestamp=$provenance.PSObject.Properties['buildTimestampUtc']
if([int]$provenance.schemaVersion -ne 1 -or [string]$provenance.version -cne $Version -or [string]$provenance.targetFramework -cne 'net48' -or $null -eq $commit -or $null -eq $timestamp -or $commit.Value -isnot [string] -or $commit.Value -cnotmatch '^[0-9a-f]{40}$' -or $timestamp.Value -isnot [string] -or -not(Test-CcodTrayHostCanonicalUtc $timestamp.Value) -or (-not [string]::IsNullOrWhiteSpace($ExpectedGitCommit) -and $commit.Value -cne $ExpectedGitCommit)){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'}
- $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico'
- if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon)){throw 'CCOD_TRAYHOST_ARTIFACT_TAMPERED'}
- $sourceRoot=Join-Path $repo 'src\trayhost';foreach($record in @($provenance.sourceFiles)){ $source=Join-Path $sourceRoot ([string]$record.name);if(-not(Test-Path -LiteralPath $source -PathType Leaf) -or [string]$record.sha256 -cne (Get-CcodTrayHostHash $source)){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'} }
+ $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico';$sourceConfig=Join-Path $versionContract.SourceRoot 'CodexRemote.TrayHost.exe.config'
+ foreach($required in @($icon,$sourceConfig)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_TRAYHOST_SOURCE_MISSING'}}
+ if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or
+ [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or
+ [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon) -or
+ [string]$provenance.manifestSha256 -cne (Get-CcodTrayHostHash $versionContract.ManifestPath) -or
+ [string]$provenance.configSha256 -cne (Get-CcodTrayHostHash $sourceConfig)){throw 'CCOD_TRAYHOST_ARTIFACT_TAMPERED'}
+ Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID'
+ $sourceRoot=$versionContract.SourceRoot
+ $currentSources=@(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File|Sort-Object Name)
+ $sourceFilesProperty=$provenance.PSObject.Properties['sourceFiles']
+ $sourceRecords=if($null -eq $sourceFilesProperty){@()}else{@($sourceFilesProperty.Value)}
+ if($sourceRecords.Count -ne $currentSources.Count){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
+ $expectedSources=[Collections.Generic.Dictionary[string,string]]::new([StringComparer]::Ordinal)
+ foreach($source in $currentSources){$expectedSources.Add($source.Name,(Get-CcodTrayHostHash $source.FullName))}
+ $seenSources=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach($record in $sourceRecords){
+ if($null -eq $record){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
+ $nameProperty=$record.PSObject.Properties['name'];$hashProperty=$record.PSObject.Properties['sha256']
+ if($null -eq $nameProperty -or $null -eq $hashProperty -or $nameProperty.Value -isnot [string] -or $hashProperty.Value -isnot [string]){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
+ $name=[string]$nameProperty.Value;$hash=[string]$hashProperty.Value
+ if(-not $seenSources.Add($name) -or -not $expectedSources.ContainsKey($name) -or $hash -cnotmatch '^[0-9a-f]{64}$' -or $hash -cne $expectedSources[$name]){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
+ }
+ if($seenSources.Count -ne $expectedSources.Count){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
return [pscustomobject][ordered]@{Valid=$true;Executable=$exe;Version=$Version;GitCommit=[string]$commit.Value;Sha256=(Get-CcodTrayHostHash $exe)}
}
@@ -106,12 +191,13 @@ function Invoke-CcodPortableLauncherBuild {
)
if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_PORTABLE_LAUNCHER_VERSION_INVALID'}
$repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($OutputDirectory);Test-CcodTrayHostOutputDirectory $out
+ $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component portable
if([string]::IsNullOrWhiteSpace($GitCommit)){$GitCommit=Get-CcodTrayHostGitCommit -RepositoryRoot $repo}else{$GitCommit=$GitCommit.ToLowerInvariant()}
if($GitCommit -cnotmatch '^[0-9a-f]{40}$'){throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'}
if([string]::IsNullOrWhiteSpace($BuildTimestampUtc)){$BuildTimestampUtc=[datetime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture)}
if(-not (Test-CcodTrayHostCanonicalUtc $BuildTimestampUtc)){throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'}
- $sourceRoot=Join-Path $repo 'src\portable';$launcher=Join-Path $sourceRoot 'PortableLauncher.cs';$assemblyInfo=Join-Path $sourceRoot 'AssemblyInfo.cs';
- $manifest=Join-Path $sourceRoot 'CodexRemote.Portable.manifest';$config=Join-Path $sourceRoot 'CodexRemote.Portable.exe.config';
+ $sourceRoot=$versionContract.SourceRoot;$launcher=Join-Path $sourceRoot 'PortableLauncher.cs';$assemblyInfo=$versionContract.AssemblyInfoPath;
+ $manifest=$versionContract.ManifestPath;$config=Join-Path $sourceRoot 'CodexRemote.Portable.exe.config';
$icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico'
foreach($required in @($launcher,$assemblyInfo,$manifest,$config,$icon)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING'}}
Import-Module (Join-Path $repo 'build\TrayHostReferencePack.psm1') -Force
@@ -124,6 +210,7 @@ function Invoke-CcodPortableLauncherBuild {
$compilerArgs+=@($launcher,$assemblyInfo)
& $compiler @compilerArgs
if($LASTEXITCODE -ne 0 -or -not(Test-Path -LiteralPath $exe -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_COMPILE_FAILED'}
+ Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_VERSION_INVALID'
$configOut=Join-Path $work 'CodexRemote.Portable.exe.config';Copy-Item -LiteralPath $config -Destination $configOut -Force
$provenance=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$Version;gitCommit=$GitCommit;buildTimestampUtc=$BuildTimestampUtc;targetFramework='net48';compiler=[ordered]@{name='csc.exe';sha256=(Get-CcodTrayHostHash $compiler)};referenceRoot='locked-net48';sourceFiles=@([ordered]@{name='PortableLauncher.cs';sha256=(Get-CcodTrayHostHash $launcher)},[ordered]@{name='AssemblyInfo.cs';sha256=(Get-CcodTrayHostHash $assemblyInfo)});iconSha256=(Get-CcodTrayHostHash $icon);manifestSha256=(Get-CcodTrayHostHash $manifest);configSha256=(Get-CcodTrayHostHash $config);artifactSha256=(Get-CcodTrayHostHash $exe);configArtifactSha256=(Get-CcodTrayHostHash $configOut)}
$provenancePath=Join-Path $work 'portable-launcher-provenance.json';$provenance|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $provenancePath -Encoding UTF8
@@ -136,4 +223,44 @@ function Invoke-CcodPortableLauncherBuild {
}finally{if(Test-Path -LiteralPath $work){Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue}}
}
-Export-ModuleMember -Function Invoke-CcodTrayHostBuild,Test-CcodTrayHostArtifact,Invoke-CcodPortableLauncherBuild
+function Test-CcodPortableLauncherArtifact {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][string]$RepositoryRoot,[Parameter(Mandatory)][string]$Version,[Parameter(Mandatory)][string]$ArtifactDirectory,[string]$ExpectedGitCommit)
+ if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_PORTABLE_LAUNCHER_VERSION_INVALID'}
+ $repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($ArtifactDirectory);Test-CcodTrayHostOutputDirectory $out
+ $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component portable
+ $exe=Join-Path $out 'CodexRemote.Portable.exe';$config=Join-Path $out 'CodexRemote.Portable.exe.config';$provenancePath=Join-Path $out 'portable-launcher-provenance.json'
+ foreach($path in @($exe,$config,$provenancePath)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_MISSING'}}
+ try{$provenance=ConvertFrom-CcodTrayHostJson -Json (Get-Content -LiteralPath $provenancePath -Raw)}catch{throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'}
+ $commit=$provenance.PSObject.Properties['gitCommit'];$timestamp=$provenance.PSObject.Properties['buildTimestampUtc'];$compiler=$provenance.PSObject.Properties['compiler']
+ if([int]$provenance.schemaVersion -ne 1 -or [string]$provenance.product -cne 'CodexRemote-fix' -or
+ [string]$provenance.version -cne $Version -or [string]$provenance.targetFramework -cne 'net48' -or
+ [string]$provenance.referenceRoot -cne 'locked-net48' -or $null -eq $commit -or $null -eq $timestamp -or $null -eq $compiler -or
+ $commit.Value -isnot [string] -or $commit.Value -cnotmatch '^[0-9a-f]{40}$' -or
+ $timestamp.Value -isnot [string] -or -not(Test-CcodTrayHostCanonicalUtc $timestamp.Value) -or
+ (-not [string]::IsNullOrWhiteSpace($ExpectedGitCommit) -and $commit.Value -cne $ExpectedGitCommit) -or
+ [string]$compiler.Value.name -cne 'csc.exe' -or [string]$compiler.Value.sha256 -cnotmatch '^[0-9a-f]{64}$'){throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'}
+ $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico';$sourceConfig=Join-Path $versionContract.SourceRoot 'CodexRemote.Portable.exe.config'
+ foreach($required in @($icon,$sourceConfig)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING'}}
+ if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or
+ [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or
+ [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon) -or
+ [string]$provenance.manifestSha256 -cne (Get-CcodTrayHostHash $versionContract.ManifestPath) -or
+ [string]$provenance.configSha256 -cne (Get-CcodTrayHostHash $sourceConfig)){throw 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'}
+ $expectedSources=[Collections.Generic.Dictionary[string,string]]::new([StringComparer]::Ordinal)
+ foreach($name in @('AssemblyInfo.cs','PortableLauncher.cs')){$expectedSources.Add($name,(Get-CcodTrayHostHash (Join-Path $versionContract.SourceRoot $name)))}
+ $sourceFilesProperty=$provenance.PSObject.Properties['sourceFiles'];$sourceRecords=if($null -eq $sourceFilesProperty){@()}else{@($sourceFilesProperty.Value)}
+ if($sourceRecords.Count -ne $expectedSources.Count){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'}
+ $seenSources=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach($record in $sourceRecords){
+ if($null -eq $record){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'}
+ $nameProperty=$record.PSObject.Properties['name'];$hashProperty=$record.PSObject.Properties['sha256']
+ if($null -eq $nameProperty -or $null -eq $hashProperty -or $nameProperty.Value -isnot [string] -or $hashProperty.Value -isnot [string]){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'}
+ $name=[string]$nameProperty.Value;$hash=[string]$hashProperty.Value
+ if(-not $seenSources.Add($name) -or -not $expectedSources.ContainsKey($name) -or $hash -cnotmatch '^[0-9a-f]{64}$' -or $hash -cne $expectedSources[$name]){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'}
+ }
+ Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_VERSION_INVALID'
+ return [pscustomobject][ordered]@{Valid=$true;Executable=$exe;Version=$Version;GitCommit=[string]$commit.Value;Sha256=(Get-CcodTrayHostHash $exe)}
+}
+
+Export-ModuleMember -Function Invoke-CcodTrayHostBuild,Test-CcodTrayHostArtifact,Invoke-CcodPortableLauncherBuild,Test-CcodPortableLauncherArtifact
diff --git a/build/build.ps1 b/build/build.ps1
index 3fc8236..79ec98b 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -2,7 +2,8 @@
param(
[string]$Version,
[switch]$UseExistingTrayHost,
- [string]$TrayHostArtifactDirectory
+ [string]$TrayHostArtifactDirectory,
+ [switch]$Library
)
Set-StrictMode -Version Latest
@@ -104,6 +105,230 @@ function Write-CcodBuildUtf8 {
[IO.File]::WriteAllText($Path,$Text,[Text.UTF8Encoding]::new($false))
}
+function Assert-CcodBuildInstallerDestinationInventory {
+ param([Parameter(Mandatory)][string]$Path)
+ $inventoryPath = Assert-CcodBuildRegularFile -Path $Path -Kind 'Installer destination inventory'
+ foreach ($line in [IO.File]::ReadAllLines($inventoryPath,[Text.UTF8Encoding]::new($false))) {
+ if ($line -match '^\s*\[[^\[\]\r\n]+\]\s*(?:;.*)?$') {
+ throw "Installer destination inventory contains an Inno section header: $line"
+ }
+ }
+ return $inventoryPath
+}
+
+function Assert-CcodBuildInnoPreprocessorLines {
+ param(
+ [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines,
+ [Parameter(Mandatory)][string]$Kind
+ )
+ $allowedSimpleDirectives = @(
+ '#ifndef TrayHostArtifactDirectory',
+ '#define TrayHostArtifactDirectory SourcePath + "\generated\trayhost"',
+ '#ifndef PortableArtifactDirectory',
+ '#define PortableArtifactDirectory SourcePath + "\generated\portable"',
+ '#ifndef InstallerPayloadDirectory',
+ '#error InstallerPayloadDirectory must be supplied by the release builder',
+ '#ifndef InstallerPayloadManifestSha256',
+ '#error InstallerPayloadManifestSha256 must be supplied by the release builder',
+ '#ifndef ProjectVersion',
+ '#error ProjectVersion must be supplied by the release builder',
+ '#ifndef InstallerPackagePath',
+ '#error InstallerPackagePath must be supplied by the release builder',
+ '#ifndef InstallerPackageManifestPath',
+ '#error InstallerPackageManifestPath must be supplied by the release builder',
+ '#ifndef InstallerPackageSha256',
+ '#error InstallerPackageSha256 must be supplied by the release builder',
+ '#ifndef InstallerPackageManifestSha256',
+ '#error InstallerPackageManifestSha256 must be supplied by the release builder',
+ '#define InstallerPackageManifestSha256First Copy(InstallerPackageManifestSha256, 1, 32)',
+ '#define InstallerPackageManifestSha256Last Copy(InstallerPackageManifestSha256, 33, 32)',
+ '#ifndef ActivationBootstrapPath',
+ '#error ActivationBootstrapPath must be supplied by the release builder',
+ '#ifndef ActivationBootstrapSha256',
+ '#error ActivationBootstrapSha256 must be supplied by the release builder',
+ '#define ActivationBootstrapSha256First Copy(ActivationBootstrapSha256, 1, 32)',
+ '#define ActivationBootstrapSha256Last Copy(ActivationBootstrapSha256, 33, 32)',
+ '#ifndef SetupGitCommit',
+ '#error SetupGitCommit must be supplied by the release builder',
+ '#ifndef SetupProvenancePath',
+ '#error SetupProvenancePath must be supplied by the release builder',
+ '#endif'
+ )
+ $allowedInlineConstructs = @(
+ '{#ProjectVersion}',
+ '{#TrayHostArtifactDirectory}',
+ '{#PortableArtifactDirectory}',
+ '{#InstallerPayloadDirectory}',
+ '{#InstallerPayloadManifestSha256}',
+ '{#InstallerPackagePath}',
+ '{#InstallerPackageManifestPath}',
+ '{#InstallerPackageSha256}',
+ '{#InstallerPackageManifestSha256}',
+ '{#InstallerPackageManifestSha256First}',
+ '{#InstallerPackageManifestSha256Last}',
+ '{#ActivationBootstrapPath}',
+ '{#ActivationBootstrapSha256}',
+ '{#ActivationBootstrapSha256First}',
+ '{#ActivationBootstrapSha256Last}',
+ '{#SetupGitCommit}',
+ '{#SetupProvenancePath}'
+ )
+ for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) {
+ $line = [string]$Lines[$lineIndex]
+ $lineNumber = $lineIndex + 1
+ if ($line -match '\\\s*$') {
+ throw "$Kind requires an include-free simple preprocessor source; line continuation is not permitted at line $lineNumber."
+ }
+ if ($line -match '^\s*#' -and $allowedSimpleDirectives -cnotcontains $line) {
+ throw "$Kind requires an include-free exact preprocessor source; unsafe simple directive at line ${lineNumber}: $line"
+ }
+ $inlineStart = $line.IndexOf('{#',[StringComparison]::Ordinal)
+ while ($inlineStart -ge 0) {
+ $inlineEnd = $line.IndexOf('}',$inlineStart + 2)
+ if ($inlineEnd -lt 0) {
+ throw "$Kind contains an unterminated inline preprocessor construct at line $lineNumber."
+ }
+ $inlineConstruct = $line.Substring($inlineStart,$inlineEnd - $inlineStart + 1)
+ if ($allowedInlineConstructs -cnotcontains $inlineConstruct) {
+ throw "$Kind contains an unsafe inline preprocessor construct at line ${lineNumber}: $inlineConstruct"
+ }
+ $inlineStart = $line.IndexOf('{#',$inlineEnd + 1,[StringComparison]::Ordinal)
+ }
+ }
+}
+
+function New-CcodBuildGeneratedInnoScript {
+ param(
+ [Parameter(Mandatory)][string]$TemplatePath,
+ [Parameter(Mandatory)][string]$InventoryPath,
+ [Parameter(Mandatory)][string]$OutputPath
+ )
+ $template = Assert-CcodBuildRegularFile -Path $TemplatePath -Kind 'Inno Setup template'
+ $inventory = Assert-CcodBuildInstallerDestinationInventory -Path $InventoryPath
+ $output = [IO.Path]::GetFullPath($OutputPath)
+ if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) {
+ throw "Refusing to overwrite generated Inno Setup script: $output"
+ }
+ $templateDirectory = [IO.Path]::GetFullPath((Split-Path $template -Parent)).TrimEnd('\')
+ $outputDirectory = [IO.Path]::GetFullPath((Split-Path $output -Parent)).TrimEnd('\')
+ if (-not $outputDirectory.Equals($templateDirectory,[StringComparison]::OrdinalIgnoreCase) -or
+ [IO.Path]::GetExtension($output) -cne '.iss') {
+ throw 'The generated Inno Setup script must be a sibling .iss file of its template.'
+ }
+
+ $marker = '// CCOD_INSTALLER_DESTINATION_INVENTORY'
+ $templateSource = [IO.File]::ReadAllText($template,[Text.UTF8Encoding]::new($false))
+ Assert-CcodBuildInnoPreprocessorLines -Lines ([IO.File]::ReadAllLines($template,[Text.UTF8Encoding]::new($false))) -Kind 'The Inno Setup template'
+ $markerCount = [regex]::Matches($templateSource,[regex]::Escape($marker)).Count
+ $markerLineCount = [regex]::Matches($templateSource,'(?m)^\s*// CCOD_INSTALLER_DESTINATION_INVENTORY\s*$').Count
+ if ($markerCount -ne 1 -or $markerLineCount -ne 1) {
+ throw "The Inno Setup template must contain exactly one inventory marker comment; found $markerCount."
+ }
+
+ $inventorySource = [IO.File]::ReadAllText($inventory,[Text.UTF8Encoding]::new($false)).TrimEnd("`r","`n")
+ $generatedSource = $templateSource.Replace($marker,$inventorySource)
+ if ($generatedSource.Contains($marker)) {
+ throw 'The generated Inno Setup script contains an unresolved inventory marker.'
+ }
+ Assert-CcodBuildInnoPreprocessorLines -Lines ([regex]::Split($generatedSource,'\r\n|\n|\r')) -Kind 'The generated Inno Setup script'
+
+ try {
+ Write-CcodBuildUtf8 -Path $output -Text $generatedSource
+ $writtenSource = [IO.File]::ReadAllText((Assert-CcodBuildRegularFile -Path $output -Kind 'Generated Inno Setup script'),[Text.UTF8Encoding]::new($false))
+ if (-not $writtenSource.Equals($generatedSource,[StringComparison]::Ordinal)) {
+ throw 'The generated Inno Setup script changed during write verification.'
+ }
+ return $output
+ } catch {
+ if ([IO.File]::Exists($output)) { Remove-Item -LiteralPath $output -Force -ErrorAction SilentlyContinue }
+ throw
+ }
+}
+
+function Invoke-CcodBuildInnoCompiler {
+ param(
+ [Parameter(Mandatory)][string]$TemplatePath,
+ [Parameter(Mandatory)][string]$InventoryPath,
+ [Parameter(Mandatory)][string]$IsccPath,
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Arguments,
+ [Parameter(Mandatory)][string]$SetupPath
+ )
+ $template = Assert-CcodBuildRegularFile -Path $TemplatePath -Kind 'Inno Setup template'
+ $compiler = Assert-CcodBuildRegularFile -Path $IsccPath -Kind 'Inno Setup compiler'
+ $setup = [IO.Path]::GetFullPath($SetupPath)
+ if ([IO.File]::Exists($setup) -or [IO.Directory]::Exists($setup)) {
+ throw "Refusing to compile over an existing setup output: $setup"
+ }
+ $generatedScriptPath = Join-Path (Split-Path $template -Parent) ('.ccod-generated-setup-' + [guid]::NewGuid().ToString('N') + '.iss')
+ try {
+ $generatedScript = New-CcodBuildGeneratedInnoScript -TemplatePath $template -InventoryPath $InventoryPath -OutputPath $generatedScriptPath
+ $compilerArguments = @($Arguments) + @($generatedScript)
+ & $compiler @compilerArguments
+ $compilerExitCode = $LASTEXITCODE
+ if ($compilerExitCode -ne 0 -or -not (Test-Path -LiteralPath $setup -PathType Leaf)) {
+ throw "Inno Setup compilation failed with exit code $compilerExitCode"
+ }
+ } finally {
+ if ([IO.File]::Exists($generatedScriptPath)) {
+ $generatedFull = [IO.Path]::GetFullPath($generatedScriptPath)
+ $templateDirectory = [IO.Path]::GetFullPath((Split-Path $template -Parent)).TrimEnd('\') + '\'
+ $generatedItem = Get-Item -LiteralPath $generatedFull -Force -ErrorAction Stop
+ if (-not $generatedFull.StartsWith($templateDirectory,[StringComparison]::OrdinalIgnoreCase) -or
+ [IO.Path]::GetFileName($generatedFull) -cnotmatch '^\.ccod-generated-setup-[0-9a-f]{32}\.iss$' -or
+ (($generatedItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
+ throw "Refusing to clean an unexpected generated Inno Setup path: $generatedFull"
+ }
+ Remove-Item -LiteralPath $generatedFull -Force -ErrorAction Stop
+ }
+ }
+}
+
+function Remove-CcodBuildTemporarySetupInput {
+ param(
+ [Parameter(Mandatory)][string]$BuildRoot,
+ [Parameter(Mandatory)][string]$Path,
+ [Parameter(Mandatory)][ValidateSet('PayloadDirectory','DestinationInventory')][string]$Kind
+ )
+ $root = [IO.Path]::GetFullPath($BuildRoot).TrimEnd('\')
+ $candidate = [IO.Path]::GetFullPath($Path)
+ $parent = [IO.Path]::GetFullPath((Split-Path $candidate -Parent)).TrimEnd('\')
+ $leaf = [IO.Path]::GetFileName($candidate)
+ $expectedPattern = if ($Kind -ceq 'PayloadDirectory') { '^\.installer-payload-stage-[0-9a-f]{32}$' } else { '^\.installer-destination-inventory-[0-9a-f]{32}\.iss$' }
+ if (-not $parent.Equals($root,[StringComparison]::OrdinalIgnoreCase) -or $leaf -cnotmatch $expectedPattern) {
+ throw "Refusing to clean an unexpected temporary Setup input: $candidate"
+ }
+ if ($Kind -ceq 'PayloadDirectory') {
+ if (-not [IO.Directory]::Exists($candidate)) { return }
+ $rootItem = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop
+ if (($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to clean a reparse Setup payload stage: $candidate" }
+ foreach ($item in @(Get-ChildItem -LiteralPath $candidate -Force -Recurse -ErrorAction Stop)) {
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to clean a Setup payload stage containing a reparse point: $($item.FullName)" }
+ }
+ Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction Stop
+ return
+ }
+ if (-not [IO.File]::Exists($candidate)) { return }
+ $inventory = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop
+ if ($inventory.PSIsContainer -or ($inventory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to clean an unsafe Setup destination inventory: $candidate" }
+ Remove-Item -LiteralPath $candidate -Force -ErrorAction Stop
+}
+
+function Invoke-CcodBuildTemporarySetupScope {
+ param(
+ [Parameter(Mandatory)][string]$BuildRoot,
+ [Parameter(Mandatory)][string]$InstallerPayloadDirectory,
+ [Parameter(Mandatory)][string]$DestinationInventoryPath,
+ [Parameter(Mandatory)][scriptblock]$Action
+ )
+ try { & $Action $InstallerPayloadDirectory $DestinationInventoryPath }
+ finally {
+ Remove-CcodBuildTemporarySetupInput -BuildRoot $BuildRoot -Path $InstallerPayloadDirectory -Kind PayloadDirectory
+ Remove-CcodBuildTemporarySetupInput -BuildRoot $BuildRoot -Path $DestinationInventoryPath -Kind DestinationInventory
+ }
+}
+
+if ($Library) { return }
+
$repoRoot = Split-Path $PSScriptRoot -Parent
$package = Get-Content -LiteralPath (Join-Path $repoRoot 'package.json') -Raw | ConvertFrom-Json
$packageVersion = ([string]$package.version).TrimStart('v')
@@ -113,6 +338,11 @@ $Version = $Version.TrimStart('v')
if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "Invalid project version for the portable bundle: $Version" }
if ($Version -cne $packageVersion) { throw "Requested release version $Version does not match package.json version $packageVersion" }
Assert-CcodBuildCleanCheckout -RepositoryRoot $repoRoot
+$releaseAssetContractPath = Join-Path $repoRoot 'tools\ReleaseAssetContract.psm1'
+if (-not [IO.File]::Exists($releaseAssetContractPath)) { throw "Release asset contract is missing: $releaseAssetContractPath" }
+Import-Module $releaseAssetContractPath -Force -ErrorAction Stop
+$releaseAssetNames = @(Get-CcodExpectedReleaseAssetNames -Version $Version)
+if ($releaseAssetNames.Count -ne 11) { throw 'Release asset contract did not return exactly eleven ordered names.' }
$gitCommit = Get-CcodBuildGitCommit -RepositoryRoot $repoRoot
$buildTimestampUtc = [DateTime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture)
@@ -126,18 +356,25 @@ if ($UseExistingTrayHost) {
$dist = Join-Path $PSScriptRoot 'dist'
[IO.Directory]::CreateDirectory($dist) | Out-Null
-$bundle = Join-Path $dist "CodexRemote-fix-$Version-windows-x64.zip"
-$checksum = "$bundle.sha256.txt"
-$provenance = Join-Path $dist "CodexRemote-fix-$Version-trayhost-provenance.json"
-$payloadManifestAsset = Join-Path $dist "CodexRemote-fix-$Version-payload-manifest.json"
-$releaseManifest = Join-Path $dist "CodexRemote-fix-$Version-release-manifest.json"
-$setupExe = Join-Path $dist "CodexRemote-fix-$Version-setup.exe"
-$setupChecksum = "$setupExe.sha256.txt"
-foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum)) {
+$bundle = Join-Path $dist $releaseAssetNames[0]
+$checksum = Join-Path $dist $releaseAssetNames[1]
+$provenance = Join-Path $dist $releaseAssetNames[2]
+$payloadManifestAsset = Join-Path $dist $releaseAssetNames[3]
+$releaseManifest = Join-Path $dist $releaseAssetNames[4]
+$setupExe = Join-Path $dist $releaseAssetNames[5]
+$setupChecksum = Join-Path $dist $releaseAssetNames[6]
+$setupProvenance = Join-Path $dist $releaseAssetNames[7]
+$setupPayloadInput = Join-Path $dist $releaseAssetNames[8]
+$setupInventoryInput = Join-Path $dist $releaseAssetNames[9]
+$setupReleaseManifest = Join-Path $dist $releaseAssetNames[10]
+foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum,$setupProvenance,$setupPayloadInput,$setupInventoryInput,$setupReleaseManifest)) {
if ([IO.File]::Exists($path) -or [IO.Directory]::Exists($path)) { throw "Refusing to overwrite immutable release output: $path" }
}
$stageRoot = Join-Path $PSScriptRoot ('.portable-stage-' + [guid]::NewGuid().ToString('N'))
+$installerPayloadDirectory = Join-Path $PSScriptRoot ('.installer-payload-stage-' + [guid]::NewGuid().ToString('N'))
+$installerDestinationInventoryPath = Join-Path $PSScriptRoot ('.installer-destination-inventory-' + [guid]::NewGuid().ToString('N') + '.iss')
+Invoke-CcodBuildTemporarySetupScope -BuildRoot $PSScriptRoot -InstallerPayloadDirectory $installerPayloadDirectory -DestinationInventoryPath $installerDestinationInventoryPath -Action ({
try {
[IO.Directory]::CreateDirectory($stageRoot) | Out-Null
$payloadRoot = Join-Path $stageRoot 'payload'
@@ -157,7 +394,8 @@ try {
'src/persistence/LifecycleWorker.ps1',
'src/persistence/bootstrap.ps1',
'src/persistence/UninstallBootstrap.ps1',
- 'src/persistence/PortableUninstallFinalizer.ps1'
+ 'src/persistence/PortableUninstallFinalizer.ps1',
+ 'src/persistence/InstalledUninstallFinalizer.ps1'
)) {
Copy-CcodBuildPayloadFile -Source (Join-Path $repoRoot ($relative.Replace('/','\'))) -PayloadRoot $payloadRoot -Relative $relative
}
@@ -172,7 +410,9 @@ try {
$relative = $runtimeFile.FullName.Substring($runtimeRoot.TrimEnd('\').Length + 1).Replace('\','/')
Copy-CcodBuildPayloadFile -Source $runtimeFile.FullName -PayloadRoot $payloadRoot -Relative ('src/runtime/' + $relative)
}
- Invoke-CcodPortableLauncherBuild -RepositoryRoot $repoRoot -Version $Version -OutputDirectory (Join-Path $PSScriptRoot 'generated\portable') -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc | Out-Null
+ $portableArtifact = Join-Path $PSScriptRoot 'generated\portable'
+ Invoke-CcodPortableLauncherBuild -RepositoryRoot $repoRoot -Version $Version -OutputDirectory $portableArtifact -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc | Out-Null
+ Test-CcodPortableLauncherArtifact -RepositoryRoot $repoRoot -Version $Version -ArtifactDirectory $portableArtifact -ExpectedGitCommit $gitCommit | Out-Null
foreach ($trayHostFile in @('CodexRemote.TrayHost.exe','CodexRemote.TrayHost.exe.config','trayhost-build-provenance.json')) {
Copy-CcodBuildPayloadFile -Source (Join-Path $trayHostArtifact $trayHostFile) -PayloadRoot $payloadRoot -Relative ('bin/' + $trayHostFile)
}
@@ -186,6 +426,33 @@ try {
Remove-Module -Name $lifecycleModule.Name -Force -ErrorAction SilentlyContinue
}
$payloadRecords = Get-CcodBuildPayloadRecords -PayloadRoot $payloadRoot
+ [IO.Directory]::CreateDirectory($installerPayloadDirectory) | Out-Null
+ foreach ($record in $payloadRecords) {
+ Copy-CcodBuildPayloadFile -Source (Join-Path $payloadRoot ([string]$record.path).Replace('/','\')) -PayloadRoot $installerPayloadDirectory -Relative ([string]$record.path)
+ }
+ $installerPayloadManifestPath = Join-Path $installerPayloadDirectory 'installer-payload.manifest.json'
+ $installerPayloadGenerator = Join-Path $repoRoot 'tools\New-InstallerPayloadManifest.ps1'
+ if (-not [IO.File]::Exists($installerPayloadGenerator)) { throw "Installer payload manifest generator is missing: $installerPayloadGenerator" }
+ $installerPayloadManifest = & $installerPayloadGenerator -PayloadRoot $installerPayloadDirectory -ProjectVersion $Version -OutputPath $installerPayloadManifestPath
+ if (@($installerPayloadManifest.files).Count -ne $payloadRecords.Count) { throw 'Installer payload generator record count differs from the verified portable payload.' }
+ for ($recordIndex = 0; $recordIndex -lt $payloadRecords.Count; $recordIndex++) {
+ if ([string]$installerPayloadManifest.files[$recordIndex].path -cne [string]$payloadRecords[$recordIndex].path -or
+ [int64]$installerPayloadManifest.files[$recordIndex].length -ne [int64]$payloadRecords[$recordIndex].length -or
+ [string]$installerPayloadManifest.files[$recordIndex].sha256 -cne [string]$payloadRecords[$recordIndex].sha256) {
+ throw 'Installer payload generator records differ from the verified portable payload.'
+ }
+ }
+ $installerPayloadManifestSha256 = Get-CcodBuildFileSha256 -Path $installerPayloadManifestPath
+ $installerPackagePath = Join-Path $installerPayloadDirectory 'installer-package.zip'
+ $installerPackageManifestPath = Join-Path $installerPayloadDirectory 'installer-package.manifest.json'
+ Import-Module (Join-Path $PSScriptRoot 'InstallerPackage.psm1') -Force
+ $installerPackage = New-CcodInstallerPackage -PayloadRoot $installerPayloadDirectory -PayloadManifestPath $installerPayloadManifestPath -Version $Version -GitCommit $gitCommit -OutputPath $installerPackagePath -ManifestOutputPath $installerPackageManifestPath
+ Test-CcodInstallerPackage -PackagePath $installerPackagePath -ManifestPath $installerPackageManifestPath -ExpectedPackageSha256 $installerPackage.PackageSha256 -ExpectedManifestSha256 $installerPackage.ManifestSha256 -ExpectedVersion $Version -ExpectedGitCommit $gitCommit | Out-Null
+ $activationBootstrapPath = Assert-CcodBuildRegularFile -Path (Join-Path $repoRoot 'Activate-CcodRemoteFix.ps1') -Kind 'Activation bootstrap'
+ $activationBootstrapSha256 = Get-CcodBuildFileSha256 -Path $activationBootstrapPath
+ $destinationInventoryGenerator = Join-Path $repoRoot 'tools\New-InstallerDestinationInventory.ps1'
+ if (-not [IO.File]::Exists($destinationInventoryGenerator)) { throw "Installer destination inventory generator is missing: $destinationInventoryGenerator" }
+ & $destinationInventoryGenerator -RepositoryRoot $repoRoot -PayloadRoot $installerPayloadDirectory -ProjectVersion $Version -InnoScriptPath (Join-Path $PSScriptRoot 'CodexControlOtherDevices.iss') -OutputPath $installerDestinationInventoryPath | Out-Null
$payloadManifestPath = Join-Path $stageRoot 'payload-manifest.json'
$payloadManifest = [ordered]@{
schemaVersion = 1
@@ -223,9 +490,6 @@ try {
)
}
Write-CcodBuildUtf8 -Path $releaseManifest -Text (($releaseRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine)
- $releaseValidationTool = Join-Path $repoRoot 'tools\Test-ReleaseDefender.ps1'
- if (-not (Test-Path -LiteralPath $releaseValidationTool -PathType Leaf)) { throw "Release manifest validator is missing: $releaseValidationTool" }
- . $releaseValidationTool -Library
Test-CcodReleaseAssetManifest -ManifestPath $releaseManifest -AssetDirectory $dist -ExpectedVersion $Version | Out-Null
} finally {
if ([IO.Directory]::Exists($stageRoot)) {
@@ -256,14 +520,27 @@ $isccCandidates = @(
$iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and [IO.File]::Exists($_) } | Select-Object -First 1
if (-not $iscc) { throw 'Inno Setup 6 (ISCC.exe) was not found. Install it with: winget install --id JRSoftware.InnoSetup --exact' }
$issPath = Join-Path $PSScriptRoot 'CodexControlOtherDevices.iss'
-$portableArtifact = Join-Path $PSScriptRoot 'generated\portable'
-& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/O$dist\." $issPath
-if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) {
- throw "Inno Setup compilation failed with exit code $LASTEXITCODE"
-}
-$setupHash = Get-CcodBuildFileSha256 -Path $setupExe
+Import-Module (Join-Path $PSScriptRoot 'SetupArtifact.psm1') -Force
+[IO.File]::Copy((Assert-CcodBuildRegularFile -Path $installerPackageManifestPath -Kind 'Installer package manifest'),$setupPayloadInput,$false)
+[IO.File]::Copy((Assert-CcodBuildRegularFile -Path $installerDestinationInventoryPath -Kind 'Installer destination inventory'),$setupInventoryInput,$false)
+$setupProvenanceRecord = New-CcodSealedSetupBuildProvenance -Version $Version -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc -PackagePath $installerPackagePath -PackageManifestPath $installerPackageManifestPath -ActivationBootstrapPath $activationBootstrapPath -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -OutputPath $setupProvenance
+Test-CcodSealedSetupBuildProvenance -ProvenancePath $setupProvenance -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPackageSha256 $installerPackage.PackageSha256 -ExpectedPackageManifestSha256 $installerPackage.ManifestSha256 -ExpectedActivationBootstrapSha256 $activationBootstrapSha256 -ExpectedBuildTimestampUtc $buildTimestampUtc -PackagePath $installerPackagePath -PackageManifestPath $installerPackageManifestPath -ActivationBootstrapPath $activationBootstrapPath -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc | Out-Null
+$isccArguments = @(
+ "/DProjectVersion=$Version",
+ "/DInstallerPackagePath=$installerPackagePath",
+ "/DInstallerPackageManifestPath=$installerPackageManifestPath",
+ "/DInstallerPackageSha256=$($installerPackage.PackageSha256)",
+ "/DInstallerPackageManifestSha256=$($installerPackage.ManifestSha256)",
+ "/DActivationBootstrapPath=$activationBootstrapPath",
+ "/DActivationBootstrapSha256=$activationBootstrapSha256",
+ "/DSetupGitCommit=$gitCommit",
+ "/DSetupProvenancePath=$setupProvenance",
+ "/O$dist\."
+)
+Invoke-CcodBuildInnoCompiler -TemplatePath $issPath -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe
+$setupValidation = Test-CcodSetupArtifact -SetupPath $setupExe -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPackageSha256 $installerPackage.PackageSha256 -ExpectedPackageManifestSha256 $installerPackage.ManifestSha256 -ExpectedActivationBootstrapSha256 $activationBootstrapSha256
+$setupHash = [string]$setupValidation.Sha256
Write-CcodBuildUtf8 -Path $setupChecksum -Text ("{0} *{1}" -f $setupHash,[IO.Path]::GetFileName($setupExe))
-$setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json"
$setupRecord = [ordered]@{
schemaVersion = 1
product = 'CodexRemote-fix'
@@ -273,15 +550,28 @@ $setupRecord = [ordered]@{
assets = @(
[ordered]@{ name = [IO.Path]::GetFileName($setupExe); sha256 = $setupHash },
[ordered]@{ name = [IO.Path]::GetFileName($setupChecksum); sha256 = Get-CcodBuildFileSha256 -Path $setupChecksum },
- [ordered]@{ name = [IO.Path]::GetFileName($provenance); sha256 = Get-CcodBuildFileSha256 -Path $provenance }
+ [ordered]@{ name = [IO.Path]::GetFileName($provenance); sha256 = Get-CcodBuildFileSha256 -Path $provenance },
+ [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodBuildFileSha256 -Path $setupProvenance },
+ [ordered]@{ name = [IO.Path]::GetFileName($setupPayloadInput); sha256 = Get-CcodBuildFileSha256 -Path $setupPayloadInput },
+ [ordered]@{ name = [IO.Path]::GetFileName($setupInventoryInput); sha256 = Get-CcodBuildFileSha256 -Path $setupInventoryInput }
)
}
Write-CcodBuildUtf8 -Path $setupReleaseManifest -Text (($setupRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine)
Test-CcodReleaseAssetManifest -ManifestPath $setupReleaseManifest -AssetDirectory $dist -ExpectedVersion $Version | Out-Null
+Test-CcodExactReleaseAssetSet -AssetDirectory $dist -Version $Version | Out-Null
Write-Host ''
Write-Host 'Installer build completed:' -ForegroundColor Green
Write-Host (" Setup: {0}" -f $setupExe)
Write-Host (" SHA-256: {0}" -f $setupChecksum)
+Write-Host (" Provenance: {0}" -f $setupProvenance)
Write-Host (" Manifest: {0}" -f $setupReleaseManifest)
Write-Host ''
+})
+
+# NOTE: this action must stay a plain scriptblock. PowerShell 7 gives
+# GetNewClosure() a fresh dynamic module whose command resolution cannot see the
+# functions defined in this script, so wrapping the action would break every
+# local call such as Copy-CcodBuildPayloadFile. Windows PowerShell 5.1 happens to
+# resolve those names, so the defect only appears under the pwsh used by the
+# release workflow.
diff --git a/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
new file mode 100644
index 0000000..0a3cfea
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
@@ -0,0 +1,242 @@
+# v2.5.22 Async Tray Receipt Reliability Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make terminal tray receipts non-blocking, bounded, handle-verified,
+and genuinely traceable through the authenticated production child flow.
+
+**Architecture:** Terminal action correlation remains in `HostTransport`, but
+receipt persistence moves to one bounded background sink. A native pinned
+handle store writes the fixed local receipt without pathname re-resolution.
+An internal child-session seam lets a temporary trace executable exercise the
+same authenticated child behavior with a fake native platform.
+
+**Tech Stack:** Windows PowerShell 5.1, C#/.NET Framework 4.8, Win32 file
+handles, existing TrayHost authenticated protocol and self-tests.
+
+**Spec:** `docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md`
+
+## Global Constraints
+
+- Preserve the existing wire message types, directions, payloads, sequences,
+ MAC checks, command IDs, and current-user boundaries exactly.
+- Receipt disk text contains only command, revision, terminal status, and a
+ canonical code; never persist action IDs, transaction IDs, paths, tokens,
+ exception text, or arbitrary payload text.
+- No production command-line test mode, listener, environment-variable switch,
+ or external control surface may be added.
+- All terminal actions leave the pending set before any receipt I/O or queue
+ wait; no writer hang may create a replacement writer thread.
+- Generic UI feedback is produced only after durable receipt success; its queue
+ is bounded and runs on the existing STA work path.
+- Keep primary checkout untouched; work only in this linked worktree.
+
+---
+
+### Task 1: Add a pinned, bounded receipt store
+
+**Files:**
+- Modify: `src/trayhost/TrayTerminalDiagnostic.cs`
+- Modify: `src/trayhost/Program.cs`
+- Test: `tests/trayhost/TrayHostNativeSelfTest.cs`
+- Test: `tests/trayhost/Invoke-TrayHostSelfTest.ps1`
+
+**Interfaces:**
+- `TrayTerminalDiagnosticStore` owns validated native directory/leaf handles.
+- `TryAppendDurably(TrayTerminalDiagnostic)` returns `true` only after a full
+ write and `Flush(true)` through the validated leaf handle.
+- `TryGetDefaultPath` remains fixed to the current-user receipt leaf; no caller
+ can choose another production path.
+
+- [ ] **Step 1: Write failing native-store tests**
+
+Add a temporary-root test that precreates a reparse directory, reparse leaf,
+hard-link leaf, and an outside sentinel. Add a barrier that attempts to
+replace the logs directory after the store has opened its directory chain.
+Assert each unsafe case returns `false` and the sentinel bytes remain unchanged.
+Add 64-KiB boundary and rollover tests that verify one whole latest record is
+retained through the open handle.
+
+- [ ] **Step 2: Run RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly`
+
+Expected: compile or behavioral failure because the current path-based store
+does not own native handles or the barrier contract.
+
+- [ ] **Step 3: Implement the minimal handle-pinned store**
+
+Open the existing LocalAppData/product/logs compatibility parents and the fixed
+leaf through `CreateFileW` safe handles. Create only
+`logs\tray-receipts` as a protected current-user/SYSTEM/Administrators child,
+then reopen and validate its exact handle; legacy direct receipt leaves are
+never reused. Reject reparse, non-directory, non-file, multi-link,
+unexpected-final-path, share, and handle-query failures before writes. Keep
+handles while using `SetLength`, write, and `Flush(true)` for the 64-KiB
+rollover. Do not re-resolve the leaf path for append or truncation.
+
+- [ ] **Step 4: Run GREEN**
+
+Run the native suite and production headless compile smoke. Assert the store
+write is bounded, handle-validated, and cannot alter an outside sentinel.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: pin tray terminal receipt storage
+```
+
+### Task 2: Make terminal receipt dispatch asynchronous and bounded
+
+**Files:**
+- Create: `src/trayhost/TrayTerminalReceiptSink.cs`
+- Modify: `src/trayhost/HostTransport.cs`
+- Modify: `src/trayhost/Program.cs`
+- Modify: `src/trayhost/TrayHostApplication.cs`
+- Test: `tests/trayhost/TrayHostTransportSelfTest.cs`
+- Test: `tests/trayhost/TrayHostNativeSelfTest.cs`
+
+**Interfaces:**
+- `HostTransport.TryAcknowledgeAction` returns a typed terminal receipt for a
+ terminal result after removing its pending entry.
+- `TrayTerminalReceiptSink.TrySubmit` is zero-wait and admits at most eight
+ outstanding receipts.
+- `HostTransport.TryPublishDurableReceipt` accepts only a trusted in-memory
+ receipt and places one bounded UI item.
+
+- [ ] **Step 1: Write failing sink/transport tests**
+
+Use a controllable fake store that blocks before open, during write, and during
+flush. Assert current and following authenticated terminal results return
+within 250 ms, the ninth outstanding receipt is dropped immediately, no pending
+entry remains, and no generic UI work exists until an explicit durable-success
+callback. Add disposal and recovery tests without starting a replacement
+writer.
+
+- [ ] **Step 2: Run RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly`
+
+Expected: the current synchronous writer blocks the terminal call and the new
+receipt-sink API is unavailable.
+
+- [ ] **Step 3: Implement one worker and bounded UI completion**
+
+Separate correlation from receipt work. The reader submits once and returns;
+only the single background writer calls the store. On durable success it posts
+bounded tokened About/failure work to an internal STA message path separate from
+presentation/shutdown drains; no unrelated work message may make an unresolved
+receipt visible. Callback admission and disposal must be atomic: no durable
+callback begins after close. On admission, store, callback, queue, token-post,
+or disposal failure, consume the terminal result but show no feedback. Do not
+alter the wire result or add a new message type.
+
+- [ ] **Step 4: Run GREEN**
+
+Run transport and native suites. Confirm a hung writer cannot stall the reader
+or action capacity, and a later durable receipt still produces exactly one
+generic feedback item.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: isolate tray terminal receipt writes
+```
+
+### Task 3: Trace the actual child session without a shipped test hook
+
+**Files:**
+- Create: `src/trayhost/TrayHostChildSession.cs`
+- Create: `src/trayhost/WindowsTrayHostRuntime.cs`
+- Modify: `src/trayhost/Program.cs`
+- Modify: `src/trayhost/TrayHostApplication.cs`
+- Create: `tests/trayhost/TrayHostProductionTraceSelfTest.cs`
+- Modify: `tests/trayhost/Invoke-TrayHostSelfTest.ps1`
+- Modify: `tests/persistence/TrayHostProductionTrace.SelfTest.ps1`
+- Test: `tests/trayhost/TrayHostParentClientSelfTest.cs`
+- Test: `tests/persistence/TrayHostClient.SelfTest.ps1`
+
+**Interfaces:**
+- `TrayHostChildSession.Run` accepts only internal stream/runtime/sink
+ dependencies; `Program.Main` exposes no new arguments.
+- `WindowsTrayHostRuntime` composes the existing Windows platform, window, and
+ application; the temporary trace executable supplies a fake implementation.
+
+- [ ] **Step 1: Write failing real-trace tests**
+
+Compile a temporary trace executable with the same child session and a fake
+native platform. Start it through normal `TrayHostParentClient.Start` (without
+`TestProcessFactory`). Require a current native `OpenLogs` selection to reach
+the PowerShell TrayHostClient/Supervisor fixture and a stale authenticated
+negative action to show only the generic dialog after durable receipt success.
+
+- [ ] **Step 2: Run RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1`
+
+Expected: it fails because `Program.RunChild` cannot be composed with a
+test-only runtime while preserving the production child session.
+
+- [ ] **Step 3: Extract the minimal internal session seam**
+
+Move the current child bootstrap, reader/writer sequencing, work drain, and
+shutdown behavior into `TrayHostChildSession`. `Program.RunChild` becomes a
+thin argument/identity/Windows-runtime adapter. The trace executable shares
+the child session and real protocol code but has no production CLI selector.
+
+- [ ] **Step 4: Run GREEN**
+
+Run ParentClient, TrayHostClient, production trace, native, transport, and
+headless-smoke suites. Confirm current completion has no generic dialog;
+stale rejection has one generic dialog with no internal code or correlation
+data; no production executable recognizes test arguments.
+
+- [ ] **Step 5: Commit**
+
+```text
+test: trace authenticated tray terminal receipts
+```
+
+### Task 4: Revalidate v2.5.22 release inputs
+
+**Files:**
+- Modify: `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+- Test: `tests/PersistenceSelfTest.ps1`
+- Test: `tests/Validate.ps1`
+
+- [ ] **Step 1: Write a failing release-surface test**
+
+Require the TrayHost provenance record to include every newly compiled source
+and require the release workflow to run the production trace before producing
+setup or portable assets.
+
+- [ ] **Step 2: Run RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+Expected: it fails until the release validation includes the receipt trace.
+
+- [ ] **Step 3: Add the smallest release gate**
+
+Invoke the production trace in the existing TrayHost/release validation path;
+preserve existing manifest, Defender, payload, and exact-version checks.
+
+- [ ] **Step 4: Run GREEN and commit**
+
+Run release workflow plus the persistence/validation suites under the existing
+controlled isolation harness. Commit:
+
+```text
+test: gate release on tray receipt trace
+```
+
+## Plan self-review
+
+- Task 1 handles filesystem identity and bounded storage without changing
+ authenticated action semantics.
+- Task 2 consumes terminal actions before zero-wait sink admission and never
+ lets a writer block the reader or pending-action capacity.
+- Task 3 proves the shared production child session end-to-end in a temporary
+ executable, so tests cannot create a production test control path.
+- Task 4 makes the new trace a release gate before the existing v2.5.22
+ metadata/build/reinstall acceptance task begins.
diff --git a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
new file mode 100644
index 0000000..246a845
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
@@ -0,0 +1,342 @@
+# v2.5.22 Install and Runtime Reliability Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship v2.5.22 so a fresh or legacy upgrade can only activate the
+payload supplied by that setup, while stable Codex roots survive transient
+Electron child churn and tray action failures remain diagnosable.
+
+**Architecture:** Setup uses a versioned immutable payload directory and a
+hash manifest as the lifecycle source allowlist. Lifecycle close/recovery
+retries only indeterminate child-tree reads while retaining the exact top-level
+root identity boundary. Tray actions retain the authenticated protocol but
+produce a sanitized local diagnostic result and receive end-to-end coverage.
+
+**Tech Stack:** Windows PowerShell 5.1, Inno Setup, C# TrayHost, Node.js 22,
+existing PowerShell persistence self-tests.
+
+**Spec:** `docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md`
+
+## Global Constraints
+
+- Release version is exactly `2.5.22`; setup, portable ZIP, runtime manifests,
+ README, and release contracts must agree.
+- Never modify Codex binaries or `WindowsApps`; preserve the current-user DPAPI
+ device-key file.
+- A changed PID, creation time, session, user SID, package family, executable
+ path, mode, or debug-port identity is fail-closed and must not be retried as
+ success.
+- Every implementation change begins with a failing behavioral test, then the
+ smallest production change, then focused and full validation.
+- Keep the primary checkout untouched; all work occurs in this worktree.
+
+---
+
+### Task 1: Bind setup activation to an immutable payload
+
+**Files:**
+- Modify: `build/build.ps1`
+- Modify: `build/CodexControlOtherDevices.iss`
+- Modify: `Activate-CcodRemoteFix.ps1`
+- Modify: `Install-CodexControlOtherDevices.ps1`
+- Modify: `src/persistence/modules/InstallLifecycle.psm1`
+- Test: `tests/persistence/InstallLifecycle.SelfTest.ps1`
+- Test: `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+**Interfaces:**
+- Build writes `installer-payload.manifest.json` with ordered
+ `{schemaVersion,projectVersion,files}` records in the setup payload root.
+- `Activate-CcodRemoteFix.ps1` receives `-ExpectedVersion` and validates the
+ payload before activation and the active runtime after `Ready`.
+- `Invoke-CcodInstall` receives `-ExpectedVersion` and `-PayloadManifestPath`.
+
+- [ ] **Step 1: Write failing lifecycle tests**
+
+```powershell
+$oldPayload = New-CcodLifecycleSourceFixture -Root $source -Version '2.5.13'
+Assert-CcodThrows {
+ Invoke-CcodInstall -SourceRoot $source -InstallRoot $install `
+ -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters
+} 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
+Assert-CcodEqual $oldRuntime $pointer.activeRuntime 'old payload cannot replace the active runtime'
+```
+
+Add a second test whose matching manifest has an extra stale source file. It
+must prove the staged runtime contains only manifest-listed records.
+
+- [ ] **Step 2: Run the focused test and confirm RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstallLifecycle.SelfTest.ps1`
+
+Expected: the new test fails because `Invoke-CcodInstall` does not accept or
+enforce the expected version/manifest yet.
+
+- [ ] **Step 3: Implement the smallest payload-binding path**
+
+Generate the ordered manifest during build, copy the release payload into
+`{app}\payload\{#ProjectVersion}`, and pass that directory plus
+`-ExpectedVersion {#ProjectVersion}` to activation. Validate each source file
+against the manifest before staging, reject an unexpected project version, and
+reread `active.json` plus the selected runtime manifest after readiness before
+writing `Ready`.
+
+- [ ] **Step 4: Run focused tests and prove GREEN**
+
+Run the lifecycle test, release workflow test, and Inno compile contract test.
+Expected: matching new/upgrade fixtures are Ready; mismatched and stale payload
+fixtures fail before active-pointer mutation.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: bind setup activation to immutable payload
+```
+
+### Task 2: Compile an include-free generated setup script
+
+**Files:**
+- Modify: `build/build.ps1`
+- Modify: `build/CodexControlOtherDevices.iss`
+- Modify: `tools/New-InstallerDestinationInventory.ps1`
+- Test: `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+**Interfaces:**
+- The checked-in `.iss` contains exactly one inventory marker comment.
+- Build writes a temporary generated `.iss` by replacing that marker with the
+ verified inventory procedure and invokes ISCC only on the generated file.
+- The template accepts only the required literal build directives and rejects
+ all external/line-spanned/pragma/generated-code preprocessor directives.
+
+- [ ] **Step 1: Write failing behavior tests**
+
+```powershell
+$template = @'
+[Setup]
+AppName=fixture
+[Files]
+Source: "first.txt"; DestDir: "{app}\first"
+# \
++ "extra.iss"
+'@
+Assert-CcodThrows {
+ & $generator -RepositoryRoot $root -PayloadRoot $payload -ProjectVersion '2.5.22' `
+ -InnoScriptPath $templatePath -OutputPath $inventoryPath
+} 'include'
+Assert-CcodFalse (Test-Path -LiteralPath $inventoryPath) 'include bypass produces no partial inventory'
+```
+
+Add actual ISCC tests showing that the generated script compiles with its
+injected inventory, while templates with literal or line-spanned include,
+pragma, emit, and unknown preprocessor directives are rejected before ISCC is
+launched.
+
+- [ ] **Step 2: Run the focused test and confirm RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+Expected: the line-spanned include-alias fixture is accepted before the new
+template boundary exists.
+
+- [ ] **Step 3: Implement the generated include-free compiler input**
+
+Build reads the checked-in template, verifies every literal preprocessor line
+is one of the fixed required directives, rejects continuation characters and
+all other directives, substitutes the generated inventory text once, writes a
+GUID-named temporary `.iss`, validates that the result has no marker or unsafe
+preprocessor directive, and passes that generated path to ISCC. Clean the
+temporary script in `finally`, including when the compiler returns nonzero.
+
+- [ ] **Step 4: Run focused tests and prove GREEN**
+
+Run ReleaseWorkflow and the real ISCC fixture. Confirm the compiled setup
+source contains the expected injected procedure; literal and line-spanned
+include aliases plus other code-generating directives leave neither setup nor
+inventory output; a nonzero compiler also leaves no generated `.iss`.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: compile include-free setup inventory
+```
+
+### Task 3: Retry only transient Electron tree reads
+
+**Files:**
+- Modify: `src/persistence/modules/ProcessControl.psm1`
+- Modify: `src/persistence/modules/SessionEngine.psm1`
+- Modify: `src/persistence/Supervisor.ps1`
+- Test: `tests/persistence/ProcessControl.SelfTest.ps1`
+- Test: `tests/persistence/SessionEngine.SelfTest.ps1`
+- Test: `tests/persistence/Supervisor.SelfTest.ps1`
+
+**Interfaces:**
+- Add `Get-CcodStableVerifiedProcessTree` that accepts a root snapshot,
+ `StatusEvidence`, a fixed retry budget, and adapters.
+- It returns a verified tree only if every successful attempt preserves the
+ original root identity; otherwise it returns no tree.
+- `Confirm-CcodSupervisorLifecycleProofCandidate` uses the same bounded
+ candidate-root rebind rule and records a stable failure reason without
+ granting a changed root any authority.
+
+- [ ] **Step 1: Write failing process-tree tests**
+
+```powershell
+$attempt = 0
+$tree = Get-CcodStableVerifiedProcessTree -Root $root -Adapters @{
+ GetVerifiedTree = { $attempt++; if ($attempt -eq 1) { @() } else { @($root,$child) } }
+ GetProcess = { param($pid,$status) if ($pid -eq $root.Pid) { $root } else { $child } }
+ Delay = { param($milliseconds) }
+}
+Assert-CcodEqual 2 $tree.Count 'one transient empty tree is retried without relaxing root identity'
+```
+
+Add a companion test where the second root snapshot has a different creation
+time; it must return no tree and perform no close mutation.
+
+Add a Supervisor test where the first post-worker rebind sees no special root
+but the second sees the same candidate root; it must publish `RemoteVerified`.
+Add its counterpart where the second root has a different creation time; it
+must remain `CCOD_REMOTE_PROOF_REBIND_FAILED`.
+
+- [ ] **Step 2: Run focused tests and confirm RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1`
+
+Expected: the new helper is unavailable and the tests fail for that missing
+behavior.
+
+- [ ] **Step 3: Implement bounded stable acquisition**
+
+Use a fixed small retry count and condition-based delay only around a failed
+tree acquisition. On each retry reread the root and require
+`Test-CcodProcessMatch` against the original root. Replace pre-mutation
+`GetTree` calls in close and recovery paths with this helper; leave per-member
+pre-stop/post-stop/final proofs unchanged.
+
+- [ ] **Step 4: Run focused tests and prove GREEN**
+
+Run ProcessControl, SessionEngine, and Supervisor self-tests. Confirm child
+churn retries successfully, a transient same-identity rebind succeeds, root
+drift remains fail-closed, and ordinary close tests still prove every stopped
+member.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: stabilize verified Electron process trees
+```
+
+### Task 4: Make tray action correlation testable and diagnosable
+
+> **Superseded for execution:** Task 4 reached an approved architectural
+> correction after three reviewed fix rounds. Execute
+> `docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md` before this
+> plan's Task 5. Its receipt sink preserves this task's authenticated
+> correlation and generic-dialog constraints while making persistence bounded
+> and non-blocking.
+
+**Files:**
+- Modify: `src/persistence/modules/TrayHostClient.psm1`
+- Modify: `src/persistence/Supervisor.ps1`
+- Modify: `src/trayhost/HostTransport.cs`
+- Modify: `src/trayhost/TrayWindow.cs`
+- Test: `tests/persistence/TrayHostClient.SelfTest.ps1`
+- Test: `tests/persistence/Supervisor.SelfTest.ps1`
+- Test: `tests/trayhost/TrayHostTransportSelfTest.cs`
+
+**Interfaces:**
+- A terminal tray action record carries `command`, `revision`, `status`, and a
+ stable `CCOD_*` code to the local diagnostic log.
+- A current acknowledged presentation revision reaches its command handler;
+ an unacknowledged revision is rejected as `CCOD_TRAY_ACTION_STALE`.
+
+- [ ] **Step 1: Write failing end-to-end action tests**
+
+```powershell
+$action = [pscustomobject]@{ ActionId=[guid]::NewGuid(); Command='OpenLogs'; Revision=[UInt64]7 }
+$hostState.Tray.AcknowledgedPresentations['7'] = $enabledPresentation
+$result = Invoke-CcodSupervisorCommand $hostState $adapters $action
+Assert-CcodEqual 'Completed' $result.Status 'acknowledged revision reaches OpenLogs'
+```
+
+Add the same command with revision `8` and no ACK map entry; assert
+`Rejected` plus `CCOD_TRAY_ACTION_STALE` and one sanitized diagnostic record.
+
+- [ ] **Step 2: Run focused tests and confirm RED**
+
+Run the TrayHost client, Supervisor, and transport tests. Expected failure:
+the diagnostic record and full correlation behavior are absent.
+
+- [ ] **Step 3: Implement only the action correlation record and tests**
+
+Record terminal action outcomes after Supervisor authorization and before the
+host collapses them into the generic dialog. Keep the generic dialog wording
+and authenticated pipe protocol unchanged. Do not add external control or a
+new unauthenticated command channel.
+
+- [ ] **Step 4: Run focused tests and prove GREEN**
+
+Run all three focused suites and the native TrayHost self-test. Confirm a
+stale revision cannot perform any action and a current revision carries the
+exact terminal code into local diagnostics.
+
+- [ ] **Step 5: Commit**
+
+```text
+test: cover tray action revision correlation
+```
+
+### Task 5: Version, documentation, and release acceptance
+
+**Files:**
+- Modify: `package.json`
+- Modify: `src/trayhost/AssemblyInfo.cs`
+- Modify: `src/portable/AssemblyInfo.cs`
+- Modify: `README.md`
+- Modify: `README.zh-CN.md`
+- Modify: `CHANGELOG.md`
+- Test: `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+- [ ] **Step 1: Write failing version-contract assertions**
+
+Add release contract coverage requiring version `2.5.22`, the versioned setup
+payload route, and both setup/portable manifests to expose the same version.
+
+- [ ] **Step 2: Run the release contract test and confirm RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+Expected: it fails while the product metadata is still `2.5.21`.
+
+- [ ] **Step 3: Update metadata and concise user documentation**
+
+Set every product version to `2.5.22`. Keep the README stable-release section
+concise; put technical repair detail in `CHANGELOG.md` and the GitHub release
+body.
+
+- [ ] **Step 4: Run focused and full validation**
+
+Run `npm.cmd test`, release contract tests, native TrayHost tests, and a clean
+release build. Review setup and portable manifests byte-for-byte against the
+generated assets.
+
+- [ ] **Step 5: Commit**
+
+```text
+release: prepare CodexRemote-fix v2.5.22
+```
+
+## Plan self-review
+
+- Task 1 produces the immutable source/expected-version contract consumed by
+ the new setup flow and release contract.
+- Task 2 removes the preprocessor include ambiguity from the Task 1 setup
+ source before any release compiler invocation.
+- Task 3 changes only pre-mutation tree acquisition and explicitly leaves all
+ identity and post-stop checks strict.
+- Task 4 has no dependency on Task 3 and can be reviewed independently; its
+ diagnostics make future live failures actionable without weakening protocol
+ authentication.
+- Task 5 consumes the completed behavior and performs no release publication;
+ publishing, installation, and reboot remain a separately authorized final
+ acceptance step.
diff --git a/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md b/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md
new file mode 100644
index 0000000..ff490be
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md
@@ -0,0 +1,611 @@
+# v2.5.22 Immutable Generation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the unreleased v2.5.22 install and upgrade flow create only
+complete immutable generations, atomically select one active generation, and
+prove official draft assets on the current machine before public promotion.
+
+**Architecture:** The file layer creates and verifies unique generation trees
+with private handle capabilities and never overwrites an existing generation
+or recursively deletes a nonempty tree during install. The lifecycle appends a
+bounded phase record and uses a monotonic pointer/compensation protocol. Inno
+Setup is a bootstrap-only shell, while dual Defender receipts, exact assets,
+old-version upgrade, reboot, tray, and remote evidence form the final release
+promotion chain.
+
+**Tech Stack:** Windows PowerShell 5.1, .NET Framework/C# native interop,
+Inno Setup 6, Node.js 22, GitHub Actions, Microsoft Defender, existing
+PowerShell persistence and TrayHost self-tests.
+
+**Spec:** `docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md`
+
+## Global Constraints
+
+- Keep the unreleased version exactly `2.5.22`; no tag or public release is
+ created until the final external acceptance task.
+- Never modify `ChatGPT.exe`, `app.asar`, `WindowsApps`, account state, or the
+ DPAPI device-key store.
+- Preserve authenticated TrayHost wire messages, process identity checks, and
+ current-user installation semantics. Do not add a control channel, listener,
+ production test switch, or remote protocol.
+- The product's security boundary is current-user integrity and ordinary race
+ resistance. Do not claim protection against a same-user process with
+ unrestricted write/DACL authority; an elevated/signed broker is out of scope.
+- Staging creates unique generation files only. Existing generations and stable
+ files are never overwritten before readiness. Install-time cleanup retires
+ or retains old generations; it never recursively deletes a nonempty tree.
+- A failed pre-pointer phase leaves the old pointer and stable shell unchanged.
+ A post-pointer failure writes a higher compensating pointer or terminal
+ `Failed/CCOD_INSTALL_ROLLBACK_FAILED`; it never decrements a generation.
+- `Ready` requires matching pointer, generation manifest, expected version,
+ Supervisor, authenticated TrayHost, and activation receipt.
+- Setup/portable assets are selected by an explicit 11-name allowlist, not an
+ extension glob. Public promotion requires two real downloaded-asset Defender
+ receipts, complete automated acceptance, and individual manual evidence.
+- Every production change follows TDD RED → GREEN → focused review. Test
+ fixtures must not stop or start the real current-machine product.
+
+---
+
+### Task 1: Reduce the file layer to immutable generation primitives
+
+**Files:**
+- Modify: `src/persistence/modules/InstallFileTransaction.psm1`
+- Modify: `tests/persistence/InstallFileTransaction.SelfTest.ps1`
+- Modify: `tests/PersistenceSelfTest.ps1`
+- Modify: `tests/Validate.ps1`
+
+**Interfaces:**
+
+Replace the failed DACL/retirement implementation with these private
+create-only operations:
+
+```powershell
+Open-CcodInstallGeneration -InstallRoot -RuntimeId
+Open-CcodInstallRetainedGeneration -InstallRoot -RuntimeId -ExpectedManifestSha256
+New-CcodInstallDirectory -Transaction -Parent -Leaf [-CreateIfMissing]
+New-CcodInstallGenerationLeaf -Generation -Leaf
+Copy-CcodInstallSealedSource -Generation -SourcePath -Leaf -ExpectedLength -ExpectedSha256
+Write-CcodInstallGenerationManifest -Generation -Manifest