Skip to content

feat(migration): guide users through the Store migration - #2

Closed
natalie-aguinaldo wants to merge 29 commits into
user/natalie-aguinaldo/inno-to-store-migrationfrom
user/natalie-aguinaldo/inno-to-store-shipping
Closed

natalie-aguinaldo wants to merge 29 commits into
user/natalie-aguinaldo/inno-to-store-migrationfrom
user/natalie-aguinaldo/inno-to-store-shipping

Conversation

@natalie-aguinaldo

@natalie-aguinaldo natalie-aguinaldo commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Related: openclaw#1374

Stacked PR 2 of 2: complete migration experience and production enablement. Depends on openclaw#1461 (feat(migration): add gated Inno-to-Store migration preview). This draft is intentionally opened in the contributor fork against user/natalie-aguinaldo/inno-to-store-migration, so the review diff contains PR 2 rather than the whole foundation.

Shipping goal: resolve the remaining scope of openclaw#1374 in PR 2, including production enablement and release acceptance. There is no planned PR 3. The migration-capable Inno app and enabled Store app may ship in one coordinated release; a separate preliminary Inno release is not required.

Current head: 5714c8aa. Production migration is enabled by default for Release non-Dev builds, so the coordinated v2026.9.5 Inno and Store artifacts carry both migration surfaces, and CI publishes a package developers can actually install to exercise them.

Since this writeup was first drafted the branch has been through an independent adversarial review that found four fail-open defects, and PR 1's branch has been merged in. See Correctness hardening and Merging PR 1.

Enabling the switch is a merge-time decision; shipping it is not. Real Store, ARM64, and live-gateway acceptance are tag-time gates listed under Outstanding acceptance. This draft is not rollout approval, and merging it within the fork does not ship upstream or close the upstream issue.

GitHub does not support Allow edits from maintainers on this same-repository stacked draft (the API rejects it as cross-repository-only). Enable it when publishing a cross-repository PR upstream; this draft does not grant upstream maintainers write access to the fork.

What Problem This Solves

The migration foundation needs a clear, accessible handoff that obtains consent, stops the previous app safely, and guides users through removing it before the Store app becomes active.

User Impact

Explicit Debug previews and opted-in production-identity Release builds provide a wizard-style migration window with Migrate / Not now, progress, manual close / Retry recovery, and Open Installed apps. Supported settings, gateway data, identities, and models remain in place. Users must uninstall the previous Inno app themselves; migration does not force-kill or automatically uninstall it.

Release non-Dev builds at the current head enable this flow by default. The shared build policy pins the source floor and product ID and now sets MigrationProductionEnabled=true; Debug and Dev builds still cannot inherit production activation. The confirmed Store listing is OpenClaw, product ID 9NFPR3BGDRR5. Actual Store-distributed handoff and release acceptance are still pending, and are now tag-time gates rather than merge-time gates.

Enablement and the developer migration test package

Neither existing MSIX artifact can exercise migration. The unsigned Store package is a submission asset and cannot be installed, and the Dev package is refused by StoreMigrationStartupGuard because its identity is not the production one. Enabling the flag alone would therefore have produced artifacts nobody could test.

CI now also publishes openclaw-msix-dev-migration-test-<arch> from scripts\Export-MigrationTestMsix.ps1: the production-identity Store package, test-signed with a disposable certificate whose subject matches the production publisher. "Dev" in that name means for developers; the package identity is production. Its INSTALL.txt states plainly that it shares a package family name with the Store release and is therefore not side-by-side, that migration runs against real data directories because the guard rejects path redirection, and that it will adopt and uninstall an Inno installation. The exporter refuses an already-signed package, and refuses any package whose assembly lacks the migration metadata, so the artifact cannot silently ship disabled. Measured export results are under Real behavior proof.

Because every unhappy Store-side path stops launch rather than degrading, docs/RELEASING.md now also requires confirming before the tag that the released Inno installer registers DisplayVersion 2026.9.5 and DisplayName OpenClaw Companion version 2026.9.5. A prerelease suffix or mismatched name is rejected by InnoInstallationDetector as an unsupported installation, which blocks startup for that user. The already-published v2026.9.5-alpha.* builds carry a prerelease suffix and fall into that category.

Why This Change Was Made

  • Replace the preview's native dialog loop with a serial workflow and a localized WinUI surface.
  • Reuse the setup wizard's mascot, Mica backdrop, typography, themed cards, non-dismissible safety InfoBars, and neutral-left/accent-right footer with 100-pixel minimum action widths. Keep consent concise and show only the immediate action at each stage, without entering the gateway-installation pipeline.
  • Record protected explicit consent before opening the Store listing or requesting graceful shutdown. An inventory intent is not consent.
  • Use the existing current-user activation pipe for a bounded, fixed shutdown request, with manual close / Retry if the source does not exit.
  • Keep admission, preparation, completion, and finalization in PR 1's coordinators. Normal startup remains blocked until verified finalization.
  • Preserve all six locales, accessible control names, polite status announcements, and initial focus on Not now.

Ownership: StoreMigrationStartupGuard previously hosted the native dialog loop; StoreMigrationWorkflow now owns serial interaction, StoreMigrationOperations adapts existing migration coordinators, and StoreMigrationWindow applies the UI. The startup guard retains the compile-time gate and pre-services window lifetime. InnoMigrationHandoff owns the gated consent/listing/shutdown integration.

Stack status and merge resolution

PR 1 at 22db82746143542f3d69f69813f6884b43a3b780 was integrated first, and PR 1's ten subsequent commits were merged in at f284c258 (see Merging PR 1). Published merge 3fd9358d406359b18731e1d7ebea648c4437c76c resolved the foundation/main conflicts; this follow-up changes presentation, localization, documentation, and tests only. The serial workflow, migration coordinators, startup gate, shutdown protocol, and fixed source floor are unchanged.

PR 2 retains its WinUI workflow and directly labeled Migrate / Not now / Retry buttons, not PR 1's obsolete native dialog loop. All six locales retain the legacy templates and concise composed PR 2 disclosures. Production/native XAML metadata delegation remains intact.

Selected release plan

The selected target is the next Inno release after the current published v2026.9.4: v2026.9.5, with MigrationMinimumSourceVersion=2026.9.5.0 pinned in Migration.Build.props. Both PR 1's safeguards and PR 2's complete migration flow must be included in that coordinated release. No separate preparatory Inno release is required.

Users on older Inno versions must update to v2026.9.5 or newer before migrating to Store. The minimum stays fixed in subsequent releases; it must not track each later app version or the Store package version.

This resolves release-version selection and configuration pinning, not artifact verification or production activation. If the next release receives a different version or does not contain both PRs, revise the pinned minimum before shipping.

Remaining work for the complete shipping change

Integration, build policy, enablement, product-ID wiring, release selection and pinning, the test-signed x64 VM journey, and the documentation updates are complete and described above. Everything still outstanding is listed once under Outstanding acceptance, and all of it gates the tag rather than the merge.

The same release can introduce the full migration flow and establish the minimum supported Inno version. The minimum is a compatibility requirement for the installed source uninstaller, not a requirement for a separate earlier release.

Evidence

Head: 5714c8aa, with PR 1 22db8274 and PR 1's current branch tip as ancestors.

Everything in the screenshot sections below still describes the shipped flow; the commits since 72e69409 change error-path behavior, uninstall safety, CI gating, and tests, not the workflow the screenshots show.

  • Full build, Shared/Tray suites, and 13 mounted migration UI tests passed on current-head source before commit, with no subsequent source edits.
  • The native-icon regression failed before the fix with a zero icon handle, then passed with both small and large icon handles populated.
  • Manual proof at f0017f4d used its exact production-identity Release x64 Inno fixture and test-signed MSIX. All 980 MSIX and 972 Inno payload entries matched the built artifacts.
  • Developer screenshots show consent, close/Retry, recorded completion/removal, and the normal Store interface after finalization.
  • Independent preservation checks passed before and after the real Inno uninstall. Current-run logs then recorded finalization followed by normal Store startup.
  • Failed automation attempts are not counted as passing proof. The successful manual run and its independent state checks are the evidence below.

Correctness hardening after adversarial review

The migration flow was put through an independent read-only review after the UI work settled. It refuted most
of my own earlier self-assessment and found four HIGH-severity defects that shared one root cause: the
completion receipt was authoritative only where a receipt was successfully read, so every error path fell
through to "start normally." On a machine that had already migrated, that means the old Inno app launches again
against data the Store app now owns.

Defect Fix
File.Exists fails open. It returns false for a directory of the same name, a missing parent, or invalid characters, and never throws, so the catch { return true; } fallback was dead code and the doc comment promised the opposite of the behavior. MigrationStartupRecordReader.CompletionFileExists now uses File.GetAttributes, which throws distinguishably. Only FileNotFoundException and DirectoryNotFoundException return false; everything else returns true.
The uninstall reparse guard checked only the leaf directory. A junction at gateways redirects the whole subtree while gateways\<id> still reports as an ordinary directory, so a recursive delete followed it. The reviewer reproduced real data loss under PowerShell 5.1. Uninstall-LocalGateway.ps1 now walks every ancestor up to $DataDir before deleting, and stops at a UNC share root so folder-redirected AppData cannot block uninstall.
StoreMigrationStartupGuard inspected twice and discarded the first result, so a successful first pass followed by a failing second pass lost the receipt. The guard captures the admission and passes it into the workflow, which seeds _holdsCompletedHandoff from it.
The general catch in the workflow could not tell "no receipt" from "could not tell," and StoreMigrationStartupCoordinator threw InvalidOperationException when a completed migration had no receipt. IStoreMigrationOperations gained HoldsCompletionReceipt(), deliberately independent of Inspect(), with a non-throwing variant that fails safe to true. The coordinator returns a RecoveryRequired decision instead of throwing.

Source-text contract tests gave false assurance here. The reparse-ordering assertions passed while the bug
was live, because they asserted that markup existed, not that it ran. They were replaced with a behavioral
test that extracts the shipped guard text from Uninstall-LocalGateway.ps1, wraps it in a harness, and runs it
under powershell.exe against a real junction. It was confirmed to fail against the pre-fix code and pass
after, and the junction case it catches is exactly the one the leaf-only guard missed.

Two further release-path gaps were closed in the same round:

  • The migration switch was not really a switch. Export-MigrationTestMsix.ps1 throws when the packaged
    assembly carries no MigrationMinimumSourceVersion, and that step ran unconditionally in build-msix, which
    release depends on. Setting MigrationProductionEnabled=false therefore made every release impossible
    rather than producing a release without migration. CI now resolves the effective switch from
    Migration.Build.props (honoring an environment override) and gates both the export and its upload on it.
  • installer.iss was only compiled for real in the tag-only release job, so a Pascal Script error would
    have been found at release time. CI now runs scripts/Test-InstallerScriptCompiles.ps1 -RequireCompiler on
    every PR.

Merging PR 1 into this branch

PR 1's branch gained ten commits while this one was in review. They were merged at f284c258. Six files
conflicted. Each was resolved in favor of whichever side made the later, narrower decision, and PR 1's
behavioral fixes were ported into this branch's architecture rather than reverting the architecture to match
them.

File Resolution
StoreMigrationStartupGuard.cs Kept this branch's windowed flow. PR 1's durable autostart refusal was ported instead: the applier rethrows StoreMigrationAutoStartRefusedException, and the workflow maps StartupPreferenceRefused to a new StartupRefused stage before the allows-normal-startup fold. It reuses the existing Migration_StoreStartupRefused string and stays outside BlocksStartup, so the user is told that only Windows can re-enable the startup task without being trapped.
MigrationOperationLock.cs Took PR 1's inline DACL hardening and ScrubExistingEntries, which this branch's MigrationRecordStorage extraction had silently weakened.
InnoMigrationStartupGuard.cs Both branches independently fixed the same launch-bricking bug. Took PR 1's narrower version: only a contended lock blocks Inno startup, an unreadable one degrades to the receipt check. This branch's blanket "never block" had lost the in-progress interlock.
StoreMigrationFinalizationCoordinator.cs Kept PR 1's broadened AllowsNormalStartup, so a transient StartupPreferenceFailed no longer strands the user on a failure card. Only a durable refusal notifies.
MigrationRecordTests.cs Took PR 1's exit-code semantics, where a receipt that exists but does not validate is exit 2 and never exit 0, alongside this branch's new consent kind.
InnoMigrationContractTests.cs Unioned both suites. PR 1's StorePreviewChoices_UseNativeYesNoWithoutActionLegends was deliberately dropped because it asserts the MessageBoxW flow this branch deleted, and its Task.Run(...).ConfigureAwait(false) assertion no longer applies to an applier that plain-awaits.
docs/RELEASING.md Kept this branch's full shipping journey.

The merge surfaced four test failures that were genuine semantic collisions rather than noise, all from PR 1's
broadened AllowsNormalStartup and this branch's new StartupRefused stage. They were resolved by following
PR 1's semantics and adding behavioral coverage for the durable-refusal path.

Change Type

  • Bug fix
  • Feature
  • Refactor
  • Docs or instructions
  • Tests or validation
  • Security hardening
  • Chore or infrastructure

Scope

  • Tray or WinUI UX
  • Windows node capability
  • Local MCP or winnode
  • Gateway, connection, or pairing
  • Setup or onboarding
  • Permissions, privacy, or security
  • Tests, CI, or docs

Required proof pools

  • windows-winui-interactive: migration and Inno consent UX, focus/accessibility, localization, and activation behavior.
  • windows-clean-installer-upgrade: exact supported Inno to Store package handoff, protected uninstall, and finalization.
  • windows-11-arm64: architecture-matched ARM64 package/runtime acceptance.
  • windows-wsl-gateway-e2e: existing managed WSL gateway preservation and continued management after handoff.

These declarations request proof; they do not claim the complete pools passed.

Validation

Commands ran in the isolated PR 2 worktree with OPENCLAW_REPO_ROOT set to that worktree. UI and Tray checks used isolated OPENCLAW_TRAY_DATA_DIR directories.

Command Result Source tested
.\build.ps1 Passed Current head
dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore 4,087 passed, 33 skipped, 0 failed Current head
dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore 3,227 passed, 0 failed Current head
dotnet test .\tests\OpenClaw.Connection.Tests\OpenClaw.Connection.Tests.csproj --no-restore 1,201 passed, 0 failed Current head
.\scripts\Test-InstallerScriptCompiles.ps1 Passed for x64, arm64, and DevBuild Current head
Focused Migration2LocalizationTests and InnoMigrationContractTests 62 passed, 0 failed 0651b419
Focused MigrationBuildConfigurationTests 23 passed, 0 failed Current head
.\scripts\validate-docs.ps1 Passed, 49 Markdown files checked Current head
Behavioral junction test CleanupScript_DoesNotDeleteThroughAJunction Passed, and confirmed red against the pre-fix leaf-only guard Current head
.\scripts\test-ci-workflow-contract.ps1 Passed Current head
.\scripts\test-migration-test-msix.ps1 Passed Current head
Focused Migration2LocalizationTests, InnoMigrationContractTests, and StoreMigrationWorkflowTests 89 passed, 0 failed f0017f4d
Mounted migration UI command below 14 passed, 0 skipped, 0 failed Current head
dotnet test .\tests\OpenClaw.Tray.UITests\OpenClaw.Tray.UITests.csproj `
  -c Debug -r win-x64 --no-restore `
  --filter 'FullyQualifiedName~StoreMigrationWindowProofTests'

The fourteenth test is new: it mounts the production window, drives finalization to StartupPreferenceRefused, and asserts the refusal copy, the collapsed Primary button, the polite live region, and that dismissing resumes startup because no receipt blocks it.

Mounted tests cover light/dark rendering, native small/large window icons, native button styles and spacing, stage headings, safety notices, and compact scrolling without hiding footer actions. The compact test enlarges heading/status fonts; it is not OS-wide text-scaling proof.

Historical regression coverage at the preceding published merge 3fd9358d: Connection 1,123 passed / 1 skipped; combined migration/native-chat/markdown/tool UI 97 passed / 1 skipped. Those broader suites were not rerun for this presentation-only follow-up. The prior UI skip was ReactorChatLayoutProofTests.ReasoningPicker_PointerReleaseCommitsButCaptureLossDoesNot.

Real behavior proof

Enablement and the migration test package (current head)

The flag flip was verified against a real Release binary rather than only the MSBuild probe. A plain dotnet build -c Release -r win-x64 of the tray project produced an assembly carrying the pinned policy:

MigrationStoreProductId=9NFPR3BGDRR5
MigrationMinimumSourceVersion=2026.9.5.0

scripts\Build-StoreMsix.ps1 -Architecture x64 then produced a real unsigned Store package (Identity: OpenClawFoundation.OpenClaw 2026.9.5.0 x64), and scripts\Export-MigrationTestMsix.ps1 was run against it end to end with the real Windows SDK SignTool.exe:

Check Result
Exported files OpenClaw-MigrationTest-x64.msix (157,079,494 bytes), OpenClaw-MigrationTest.cer, msix-metadata.json, INSTALL.txt
Package identity OpenClawFoundation.OpenClaw, CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC, 2026.9.5.0
Signer subject CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC (matches the manifest publisher)
Metadata signing=migration-test-only, migrationEnabled=True, sideBySideWithStore=False
Disposable private key Absent from Cert:\CurrentUser\My after the run

Get-AuthenticodeSignature reports UnknownError on the signing machine because that disposable certificate is deliberately not trusted there; trusting it is step 3 of the generated INSTALL.txt. The exporter verifies the signer thumbprint rather than machine trust, so it does not modify machine trust in CI.

Not verified / blocked: this package was not installed or launched. Installing it replaces the production package family, so it belongs on a disposable VM, and the Store-side migration UI proof below is still the f0017f4d run.

Store migration entry point on the Inno Settings page (current head)

This is the complete path a user takes, from the installed Inno app to the Store app finishing the move. Steps 1 and 2 are current-head proof. Steps 4 through 7 reuse the f0017f4d manual VM run, because the Store listing is not live, so a Store-distributed install cannot be exercised yet.

  1. Inno app, Settings page. A migration-capable Inno build shows a recommended card above the General section. Current head, installer C4D2F8E4BD98B91E4C692A21DA2078301E43AE4333EA242E26D8C0A27D1A0A17, 972/972 payload hashes matched, measured at 985.6 client DIPs.

Current-head VM proof: Settings promotion card with badge beside the title and the action inline on the right

In context with the settings rows below it, showing matching row height and text alignment:

Current-head VM proof: promotion card above the General settings rows

  1. Confirmation dialog. Selecting the action opens a confirmation before anything is recorded. Opening the dialog grants nothing; consent is written only on the primary path.

Current-head VM proof: confirmation dialog asking Move to the Store version?

  1. Consent and handoff. Choosing the primary action records protected consent bound to the source version, user, architecture, canonical paths, and target package identity, then opens the Store listing. InnoMigrationHandoff.GrantAndLaunchAsync grants before launching, so a user who abandons the Store has still granted. Choosing the neutral action records nothing.

  2. Install from Microsoft Store. The user installs the Store package themselves. Not exercised from a live listing; the f0017f4d run used a locally test-signed MSIX.

  3. Store app requests shutdown. With a valid grant, the Store app asks the Inno app to exit through the current-user activation pipe. There is no force-kill. If the source does not exit, the user closes it manually and clicks Retry.

Manual VM proof: close guidance with separate safety warning and Retry

  1. Migration and guided removal. After migration is recorded, the Inno app no longer starts normally, and the Store app guides the user through uninstalling it. Nothing is removed automatically.

Manual VM proof: remove the previous app, preserve setup, and return to Retry

  1. Finalization and normal startup. Settings, gateway, identities, approvals, and models stay in place.

Manual VM proof: normal Store interface with the expected synthetic gateway connection error

Layout bug found by VM proof, not by tests

The card action was specified to sit inline beside the text. VM capture showed it stacked below the text at 1085.6 and 1517.6 client DIPs. The VisualStateManager.VisualStateGroups block was declared on a Grid nested inside the card, and WinUI only evaluates state triggers declared on the page root, so the AdaptiveTrigger never activated at any width. The full Tray suite passed throughout, because the contract test only asserted that the trigger existed somewhere in the card markup.

Re-proofing after moving the group to the page root then showed the fallback was unreachable in principle: HubWindow declares MinWidth="1000", so a 720 trigger is permanently satisfied and the stacked state can never render for any user. Repeated attempts to size the window to 680 DIPs produced 985.6. The states, trigger, and setters were therefore removed and the card is a single row. The contract test now asserts that no AdaptiveTrigger or VisualStateManager group exists in the file, and names the window minimum as the reason.

Deliberate reversal of the disclosure contract added in f0017f4d

Migration2LocalizationTests.InnoDialog_DisclosesSafetyBoundariesInEveryLocale was added in f0017f4d to require the long-form consent copy verbatim in every locale, including the 30-day term and the neutral/primary button labels. That test has been rewritten to assert a reduced set of disclosures.

This is an intentional policy change, not an accidental weakening. The dialog previously restated the uninstall and Retry steps, which Migration2_AwaitingRemoval already delivers at the moment they apply, so the same user read them twice, the first time out of context. The dialog now discloses what the user is agreeing to at that moment: the Store opens, the Store app can close this app and finish the move without asking again, and supported data stays in place. The 30-day expiry is still enforced by InnoMigrationConsentStore; it is no longer recited in the dialog. docs/RELEASING.md acceptance disclosures for the Store preview window cover different keys and are unchanged.

Current-head native icon diagnostics

StoreMigrationWindow now calls the existing WinUIEx SetIcon("Assets\\openclaw.ico") helper, matching other application windows. Its custom XAML title-bar image alone did not populate native HWND icons.

The new Consent_AssignsNativeIconsForTaskbarAndWindowSwitcher test mounts the production window with in-memory migration operations and queries WM_GETICON on its live HWND. It failed before the production fix because an icon handle was zero. Current-head output:

Native window icon type 0: nonzero handle.
Native window icon type 1: nonzero handle.
Test Run Successful.
Total tests: 13
     Passed: 13

Types 0 and 1 are the native small and large icons. This directly verifies native icon assignment. A developer-captured Explorer taskbar-hover screenshot from an isolated current-head VM preview confirms that the OpenClaw icon is visibly rendered. The restored VM originals were not modified for this follow-up.

Current-head taskbar preview

Current-head VM proof: OpenClaw icon visible in the migration window taskbar thumbnail

The screenshot was captured from the isolated 72e6940 preview with in-memory operations. It is visual icon proof, not another migration lifecycle run.

Startup-refusal window state (current head)

StartupRefused arrived with the PR 1 merge and is the newest visible state. It is reached when migration
finishes but Windows refuses to enable the startup task, which is durable and cannot be retried from inside
the app. The window therefore states what happened, tells the user where to change it, and offers only Close.

Current-head mounted proof: startup-refusal notice with Close as the only action

Captured at 5714c8aa from the mounted production window with in-memory operations, the same harness used for
the native icon proof. It is visual proof of the rendered state, not a migration lifecycle run: no real
finalization, package, or startup-task change is exercised.

Manual VM migration at f0017f4d

  • Provider: existing local IXPTools / Hyper-V, VM OpenClaw-Migration-Auto-x64-a91896be, VM ID d0cd56dc-6a26-4493-8bc6-89f6cf2de790. No cloud lease ID or hosted run URL.
  • Source: f0017f4d035d70093f2ffe7e8b2fd3373c2bfaf1, tested before commit with no later source edits. Release x64, production package identity, DevBuild=false, migration enabled only by proof-time CLI override. Checked-in source floor 2026.9.5.0 was not overridden; the checked-in production default was still disabled at that time and is enabled at the current head.
  • Artifacts: actual installed Inno fixture 2026.9.5.0 and locally test-signed MSIX 2026.9.21.0. These are not official Store/release artifacts.
  • Fixture: synthetic gateway.example.test configuration and credential, with startup/node/MCP disabled. Connection/authentication errors in the normal-app screenshot are expected for that non-live gateway; they do not prove gateway connectivity.
  • Actions: developer launched the previous app, opened the Store migration app, confirmed Migrate, exercised close/Retry, opened Installed apps, manually uninstalled only the previous app, then clicked Retry in the existing migration window.
  • Pre-uninstall check: the installed Test-InnoMigration.ps1 returned exit code 10 for the canonical current-user source, confirming migration-preservation eligibility. The source app was no longer running.
  • After uninstall: source registration, executable, and uninstaller were absent; no uninstall/cleanup worker remained. All eight tracked preservation entries, including settings, gateway, generated identity, and all three protected records, retained their hashes.
  • Finalization: guest logs on 2026-09-22 showed Store migration finalized: at 21:18:39.499, followed by Application started (WinUI 3) at 21:18:40.755, after the pre-Retry verification gate. The same verified Store process continued. Consent, intent, and completion records were removed by finalization; settings, gateway, and generated identity hashes remained unchanged.
  • Original fixture safety: the seven originals were backed up and hash-verified separately. Safe closeout passed: all seven originals were restored and SHA-256 verified after terminal process checks. No app, worker, installer, cleanup process, or proof task remained. The final synthetic state and original backups were retained in the VM. No credentials or private identity files were uploaded.

Store-side consent at f0017f4d

Steps 5 through 7 of the walkthrough above are the rest of this run's screenshots; they are not repeated here. The one view the walkthrough does not cover is the Store-side consent window, whose Inno-side counterpart has since been rewritten.

Manual VM proof: concise consent with three sections, warning, Not now and Migrate

All are developer-captured from the real VM and the exact f0017f4d tested build, not mounted fake-operations captures. Every embedded URL returned HTTP 200 with image/png, and downloaded SHA-256 hashes match the inspected developer screenshots.

Live migration at current head (5714c8aa)

A second manual VM run exercised the whole lifecycle against the current head instead of f0017f4d, and used the checked-in production default rather than a proof-time CLI override.

  • Provider: local Hyper-V guest OC-AUTO-A91896, user MigrationTest. No cloud lease ID or hosted run URL.
  • Source: 5714c8aa88a62fec8e339ba1215fda432eee6b58, clean tree (sourceTreeDirty: false in msix-metadata.json).
  • Artifacts: locally built Inno OpenClawCompanion-Setup-x64.exe (ProductVersion 2026.9.5, payload FileVersion 2026.9.5.0) and locally test-signed MSIX OpenClawFoundation.OpenClaw 2026.9.5.0. Neither is an official Store or release artifact.
  • Admission pre-check: the registration satisfied every InnoInstallationDetector condition (canonical per-user path, DisplayName matching DisplayVersion, publisher, exact quoted unins000.exe uninstall string, complete payload, release identity, matching executable FileVersion), with no machine-wide registration present.
Step Observed Guest log
Baseline Test-InnoMigration.ps1 exit 0 n/a
Admission real Inno install detected, consent shown startup admission: ConsentRequired (receipt: False)
Fail closed on corrupt identity preparation refused, no receipt, source untouched preparation failed: Device identity is malformed or unreadable.
Receipt absent while packaged checker exit 11 plus its preserve warning n/a
Prepare and complete validated receipt written, checker exit 10 preparation completed / completion recorded
Real uninstall registration, executable, and Test-InnoMigration.ps1 all absent n/a
Finalization receipt consumed, consent and intent records removed FinalizationRequired (receipt: True) / finalized
Restart normal startup, no migration window, gateway present n/a

Preservation result. Comparing SHA-256 of every tracked file across the real uninstall, gateways.json, gateways\<id>\device-key-ed25519.json, and settings.json were unchanged. Only consent.dpapi, consent.lock, and intent.dpapi disappeared, which is finalization consuming its own records.

Live current-head admission against a real Inno installation

The refusal below and the checker's exit-11 branch are proven here for the first time against a live migration rather than a mounted window. The card is deliberately generic.

Live fail-closed refusal after a corrupt device identity

Live removal stage after a validated receipt was written

Normal startup after finalization, with the migrated gateway intact

What this run does not establish.

  • The MSIX is locally test-signed, so the Store-distributed identity and handoff gate is unchanged.
  • The gateway fixture is synthetic (wss://gateway.example.test/ with a shared token) and was never paired, so this is not live gateway continuity. The authentication error in the normal-startup capture is expected.
  • The preserved gateways.json was hand-authored mid-run after an operator seeding mistake overwrote the pre-existing fixture. The preservation assertion still holds, because it compares hashes across the real uninstall, but that file is not an organically produced artifact.
  • The guest had already run migrations on 2026-09-22, so %APPDATA%\OpenClawTray was not pristine: a stale zero-byte prepare.lock and the generated identity directory predate this run. The source install was fresh and the baseline checker returned 0.

Unpaired-gateway block found by VM proof

The live run also exposed a user state with no way forward. A gateway that was added but never paired
has no device, shared, or bootstrap token, so completion refused and directed the user to restore a
configuration that was never damaged. Retrying could not clear it, and deleting the gateway record
reached the same stage through NoActiveGateway.

The refusal protected nothing. Migration leaves gateway state in place, both installs read the same
roaming directory, and the resolved credential was only null-checked, never written to the receipt.
Proving the damaged-credential half showed that branch was unreachable: MigrationInventory.CaptureIdentities
rejects an unreadable or corrupt identity during preparation, so completion was only ever reachable when no
credential was configured. The old gate was also weaker than it looked, because a corrupt identity carrying
any fallback token passed it.

The check was removed in 71509850, together with the unreachable NoActiveGateway and
CredentialUnavailable states, the CredentialUnavailable stage and its localized strings, and the
ICredentialResolver dependency, so the coordinator structurally cannot resolve credentials. Coverage was
added for completing without a credential, for an unpaired gateway, and for damage introduced both before
preparation and between preparation and completion.

Outstanding acceptance and disclosures

Tag-time gates. The switch is checked in as true so the coordinated release carries migration, but the tag must not be published until these pass against that tag's real artifacts. If acceptance fails, set MigrationProductionEnabled to false and retag rather than shipping an unverified launch gate.

  • Store-distributed package identity, signature, and handoff. No Store-distributed install has been exercised; every Store-side capture is a locally test-signed VM journey (f0017f4d and 5714c8aa).
  • Native ARM64 acceptance. Earlier ARM64 cross-builds are not native-runtime proof.
  • Live gateway acceptance: local WSL gateway continuity and management including its Node.js, remote-gateway migration without local WSL, no changes to Windows-installed Node.js, and real settings, credential, identity, approval, and Local AI preservation.
  • Signed x64 and ARM64 coverage of both consent entry paths, shutdown and manual retry, interruption and recovery, uninstall preservation, finalization, startup preference, and protocol activation.
  • Confirmation that the actual v2026.9.5 artifacts contain both PRs' safeguards and register the required DisplayVersion and DisplayName.

What the existing proof does not establish.

  • The manual run proves recovery through Retry, not uninterrupted automatic graceful-shutdown progression.
  • Startup and protocol continuation, Narrator, OS-wide scaling and high-DPI, and translated-locale visual acceptance.
  • The StartupRefused capture is a mounted-window render, not a real refusal by Windows. That path is covered by unit tests only.
  • Steps 4 through 7 of the entry-point walkthrough are f0017f4d evidence, not current-head captures. Those Store-side screenshots predate the Settings card redesign, the Inno consent rewrite, and the icon fix. The lifecycle they depict is re-proven at the current head above.
  • Source-text contract assertions confirm markup, not rendering. Both the card layout regression and the reparse-guard bug passed a fully green suite while live; each is now covered by a test verified red-to-green.
  • Proof-pool declarations request work. They are not evidence that the pools ran.

Known and accepted.

  • Finalization has no escape hatch. If finalization can never complete on a given machine, the receipt keeps blocking Inno startup indefinitely. Uninstall still works and the Store app still runs, so the user is not locked out of the product. Shipping as is; an escape-after-N-failures counter would require a record-schema change in the PowerShell 5.1-compatible codec.
  • MigrationRecordStorage.CreateProtectedDirectory, used by the consent store, does not yet carry PR 1's DACL scrubbing. Pre-existing, not a merge regression.

Incident disclosure.

  • Automated lifecycle attempts failed on harness process tracking, log serialization, or timeout handling. None is counted as full lifecycle acceptance.
  • One earlier harness restored fixture data while an uninstaller was still pending. When subsequently approved, that uninstall entered ordinary gateway cleanup. Its verified process tree was contained, the exact pre-proof checkpoint was restored, and all seven original hashes were verified before the successful manual run. Evidence was preserved. The replacement harness adds fail-closed process and receipt lifetime barriers, but the accepted lifecycle proof here is manual.

Rubber-duck review

The correctness hardening above came from a bounded independent adversarial review of the unpushed commits, which refuted five of six self-reported claims and found the four fail-open defects. Every fix it prompted carries a regression test, and the junction test was verified red-to-green against the actual pre-fix guard.

The scoped 11-file concise UI change received a bounded read-only, non-Codex rubber-duck review with no actionable regressions. Earlier foundation/shipping integration reviews remain historical evidence. The current UI review does not claim official release or gateway acceptance.

The three-file icon follow-up received direct source review against the existing ChatWindow icon assignment pattern, with no actionable findings. No new second-model review is claimed for this one-line production change.

The structured autoreview helper was unavailable: Claude/Pi executables were absent and Codex was excluded by request. No successful helper run is claimed.

Security Impact

  • New permissions or capabilities? No new node/MCP capability or elevation requirement.
  • Secrets or tokens handling changed? Yes: a separate same-user DPAPI consent record binds source version, identity, paths, architecture, and package. It contains no exported credential payload, expires, and never authorizes destructive uninstall. Invalid records are preserved.
  • New or changed network calls? A configured migration action opens the pinned Windows Store listing via its URI handler. This is not an arbitrary endpoint or a new credential-bearing request.
  • Command or tool execution surface changed? A fixed private current-user pipe message requests canonical Inno shutdown after consent/source validation. It is not a public deep link or arbitrary command. Timeouts fall back to manual close; no force-kill.
  • Data access scope changed? No expansion beyond the supported current-user migration roots. Reads do not create missing consent records or lock files.

Compatibility and Migration

  • Backward compatible? Release non-Dev builds now enable migration. Unsupported source installations fail closed in enabled migration builds, which stops launch rather than degrading, so the pre-tag DisplayVersion and DisplayName check above is required.
  • Config or environment changes? Central Migration.Build.props carries build-only MigrationProductionEnabled=true, pinned MigrationMinimumSourceVersion=2026.9.5.0, and MigrationStoreProductId=9NFPR3BGDRR5. Explicit Debug preview properties remain separate. No runtime or environment-variable production-enablement setting. CI gains one staging step, one artifact, and one contract-test step.
  • Migration needed? For explicitly enabled proof builds until release acceptance. Confirm migration, allow Inno to exit (or close it manually and Retry), wait for successful validation, open Installed apps, uninstall only the previous Inno app, then Retry to finalize.
  • The redesigned window does not construct SetupWindow, provision a replacement gateway, or bypass the pre-services startup guard.

Review Conversations

  • I replied to or resolved every bot review conversation addressed by this PR.
  • I left unresolved only conversations that still need maintainer judgment.

natalie-aguinaldo and others added 2 commits September 22, 2026 11:16
Add the migration window and serial workflow, protected explicit consent, Inno Store handoff, graceful shutdown with manual Retry, Installed apps guidance, accessibility, and localization. Reuse the migration foundation and keep production migration disabled.

Validation: full build; Shared 4039 passed, 33 skipped; Tray 3065 passed; Connection 1098 passed, 1 skipped; focused UI 16 passed. Test-signed x64 VM manual-fallback and graceful-shutdown migration journeys passed. Non-Codex review completed with no remaining actionable findings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1fff6a10-5ecd-40d8-89fe-9a053aba38a5
Reuse the wizard mascot, Mica backdrop, centered heading, themed card, and persistent footer actions while preserving the independent migration workflow and disabled production gate. Cover light and dark themes, compact layouts, enlarged text, and accessible actions in mounted UI tests.

Validation: full build passed; Shared 4039 passed, 33 skipped; Tray 3065 passed; focused migration UI 12 passed. Inspected current mounted XAML screenshots; no renewed VM or compositor-Mica proof is claimed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
@natalie-aguinaldo natalie-aguinaldo added the status: 🚢 actively landing Maintainer-owned implementation, validation, or landing is actively in progress. label Sep 23, 2026
natalie-aguinaldo and others added 2 commits September 22, 2026 17:39
Integrate source lifetime safeguards, keep consent compatible with running Inno, centralize release build policy, and pin the selected 2026.9.5.0 source floor. Production remains disabled pending real release acceptance.

Validation: full build passed; Shared4039 passed/33 skipped; Tray3108 passed; Connection1123 passed/1 skipped; mounted migration UI12 passed before the floor-only change. Configured x64 and ARM64 artifacts build; native ARM64 and official Store acceptance remain blocked.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
Merge PR1 at22db8274 including current main. Preserve the dedicated PR2 migration workflow and direct button labels, combine migration/chat locale resources and architecture owners, and retain production XAML metadata delegation in the UI test host.

Validation: full build passed; focused migration93 passed; Shared4087 passed/33 skipped; Tray3160 passed; Connection1123 passed/1 skipped; mounted migration/native-chat UI97 passed/1 skipped. Eleven current-source migration screenshots captured. Non-Codex merge-resolution review found no actionable issues. Production remains disabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
@natalie-aguinaldo natalie-aguinaldo added status: 🚢 actively landing Maintainer-owned implementation, validation, or landing is actively in progress. and removed status: 🚢 actively landing Maintainer-owned implementation, validation, or landing is actively in progress. labels Sep 23, 2026
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
@natalie-aguinaldo natalie-aguinaldo added status: 🚢 actively landing Maintainer-owned implementation, validation, or landing is actively in progress. and removed status: 🚢 actively landing Maintainer-owned implementation, validation, or landing is actively in progress. labels Sep 23, 2026
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
@natalie-aguinaldo natalie-aguinaldo added status: 🚢 actively landing Maintainer-owned implementation, validation, or landing is actively in progress. and removed status: 🚢 actively landing Maintainer-owned implementation, validation, or landing is actively in progress. labels Sep 23, 2026
natalie-aguinaldo and others added 13 commits September 22, 2026 23:34
…t copy

Align the Settings promotion card with the other cards on the page and cut
the consent dialog from four paragraphs to a short question.

The card dropped its accent icon tile, which no other card on the page has
and which pushed the text column out of alignment with the rows below. The
badge now leads the title instead of stacking above it, so a long localized
title cannot clip it. The action moves inline to the right of the text at
720px and wider, and reflows below the text when narrower.

The consent dialog now asks a question and discloses only what the user is
actually agreeing to. It previously restated the uninstall and Retry steps,
which Migration2_AwaitingRemoval already delivers at the point where they
apply.

This reverses the disclosure contract added in f0017f4. That test required
the long-form copy verbatim in every locale, including the 30-day term and
the button labels. The rewritten test keeps the substantive disclosures and
drops the ones that only repeated later stages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
VM proof showed the action stacked below the card text at 1085.6 and 1517.6
DIPs, where it should have sat inline. The VisualStateManager group was
declared on a Grid nested inside the card, and WinUI only evaluates state
triggers declared on the page root, so the AdaptiveTrigger never activated at
any window width.

Moving the group to the root fixed it, and proof then showed the narrow state
is unreachable: HubWindow sets MinWidth=1000, so a 720 trigger is always
satisfied and the stacked fallback can never render. The states, trigger, and
setters are removed and the card is a single row, matching every other
settings row. The action cannot starve the text column because it is capped at
MaxWidth=320 and wraps its own label, leaving roughly 650 DIPs for text at the
narrowest possible window.

The contract test previously asserted only that the trigger existed somewhere
in the card, which passed while the feature was dead. It now asserts that no
AdaptiveTrigger or VisualStateManager group exists in the file at all, and
names the window minimum as the reason.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
…ackage

Migration.Build.props now defaults MigrationProductionEnabled to true, so the
coordinated 2026.9.5 Inno and Store artifacts carry both migration surfaces.
Debug, Dev, and preview builds stay excluded by the existing conditions.

Neither existing MSIX artifact can exercise migration: the Store package is an
unsigned submission asset, and the Dev package is refused by the Store guard
because its identity is not the production one. CI now also publishes
openclaw-msix-dev-migration-test-<arch>, the production-identity package
test-signed with a disposable certificate whose subject matches the production
publisher. The exporter refuses an already-signed package and refuses any build
without migration compiled in, so the artifact cannot ship silently disabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
…ock failure

InnoMigrationStartupGuard ran unconditionally in every build, including
non-migration ones, and treated a failed MigrationOperationLock acquisition
as fatal. Both models in the dual-model review flagged it independently.

- Gate the guard body behind !INNO_MIGRATION_PREVIEW && !PRODUCTION_MIGRATION,
  mirroring StoreMigrationStartupGuard. The lease field and call-site ordering
  stay intact so the contract test's ordering assertions still hold.
- Log and continue when the lease cannot be acquired instead of blocking
  launch. InnoSourceActivityVerifier scans the source image by exact path,
  including instances that never take the runtime file lock, so the Store-side
  interlock is preserved without the lease.
- Add FormatException to the receipt-rejected catch filter. BinaryReader
  .ReadString throws it on a malformed 7-bit length prefix, which previously
  escaped as an unhandled exception.

Validation: build.ps1 clean; Shared 4087 passed; Tray 3185 passed;
Connection 1123 passed; InnoMigrationContractTests 24 passed. Both compile
paths (gated-off and enabled) build with 0 errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
…om review

Mechanical follow-ups from the dual-model PR review. No workflow states or
user-facing decisions change; each item makes an existing path handle reality
it already claimed to handle.

Engine:
- Add FormatException to the adoption, completion, and finalization catch
  filters. BinaryReader.ReadString throws it on a malformed 7-bit length
  prefix, verified with a standalone probe, so a corrupt record previously
  escaped as an unhandled exception instead of a ValidationFailed result.
  Add CryptographicException to the adoption filter for the same reason.
- Report a reparse point above the record directory as Unavailable rather
  than Invalid. Invalid routes to RecoveryRequired, which tells the user to
  check logs and preserve data; recovery cannot repair a junction. Adds
  MigrationRecordCodec.HasReparsePointAncestor so only the startup reader
  changes classification. RejectReparsePoints and every other caller,
  including InnoInstallationDetector's deliberate Unsupported result, keep
  their existing behavior.

UI:
- Clear the migration window error bar when the stage changes. An "Installed
  apps" launch failure previously stayed on screen through every later stage.

Scripts and CI:
- Reject gateway record ids that are not a single path segment before
  Remove-Item -Recurse -Force on the derived identity directory.
- Prefer the host architecture when resolving SignTool and order candidates by
  parsed SDK version instead of lexicographically, so 10.0.26100 beats
  10.0.9xxxx and an ARM64 runner does not sign through emulation.
- Verify the disposable signing key is gone instead of discarding the removal
  error. The check runs after the finally block so it cannot mask an in-flight
  failure.
- Assert SignTool's real arguments in the harness; the fake previously
  accepted any arguments beginning with "sign".
- Set retention-days to 30 on the migration test artifact to match the
  disposable certificate lifetime. The package is not timestamped, so a longer
  retention would only offer an artifact that can no longer install.

Tests: new ReparsePointAncestor_IsUnavailableNotRecovery. The FormatException
filters are defensive and not reachable through the public API, which only
encodes well-formed records.

Validation: build.ps1 clean; Shared 4087 passed; Tray 3185 passed; Connection
1124 passed; test-migration-test-msix.ps1 passed; test-ci-workflow-contract.ps1
passed; MigrationRecordCodec.cs still compiles under csc.exe /langversion:5 for
the Inno uninstall path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
Store-side migration treated "the window closed without finishing" as a reason
to withhold the app. Declining the offer, an unsupported source install, a
failed inspection, or a transient error all left the user with an app that
would not start, and relaunching only re-showed the same window. Refusing to
launch repairs none of those states.

Separate the two concepts that were conflated in AllowsNormalStartup:

- AllowsNormalStartup still answers "can we skip the migration window".
- The new BlocksStartup answers "would starting normally abandon data", and is
  true only for FinalizationRequired and AwaitingInnoRemoval, the two states
  that hold a completion receipt.

StoreMigrationWorkflow tracks that as _holdsCompletedHandoff, seeded from the
admission and set again when CompleteAsync writes a receipt mid-session. It is
deliberately not derived from the current stage: finalization can fail into
InspectionFailed while a receipt still exists, and a stage mapping would then
let the app start and abandon it.

Also fix a launch-blocking defect found while reading the admission path.
InnoInstallationDetector.Detect parsed the registered version before the
payload-file check, then discarded it on the Unsupported return, so a
pre-migration Inno install could never reach UpdateInno. Carry
RegisteredVersion on the payload-missing path and return UpdateInno when it is
below the pinned floor, so those users are told to update instead of being
told migration is unavailable.

Update RELEASING.md to describe the new dismissal and blocking semantics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
Two release-path gaps found in review.

The migration switch was not really a switch. Export-MigrationTestMsix.ps1
throws when the packaged assembly carries no MigrationMinimumSourceVersion,
and that step runs unconditionally in build-msix, which release depends on
through build-msix-bundle and ci-gate. Setting MigrationProductionEnabled to
false therefore made every release impossible rather than producing a release
without migration. Resolve the effective switch from Migration.Build.props
(honoring an environment override) and gate both the export and its upload on
it. Both steps need the same condition because the upload sets
if-no-files-found: error.

RELEASING.md also promised more than the switch delivers. It is a
pre-publication gate: once a Store package carrying PRODUCTION_MIGRATION ships,
the floor and the flow are compiled into that package and a later Inno build
cannot disable migration for those users. Say so.

installer.iss compared GetFileAttributesW's failure sentinel against an
unsigned $FFFFFFFF literal whose type Pascal Script resolves inconsistently. A
wrong comparison here is silent and fail-open: the reparse-point bit would be
read out of a failure value. Declare the import as Integer and compare against
-1 instead. The bit pattern is unchanged and the $400 test is unaffected.

installer.iss was only compiled in the tag-only release job, so that class of
error would have surfaced at release time. Add Test-InstallerScriptCompiles.ps1
and run it in fast-validation. It compiles the x64, arm64, and DevBuild
variants against a stub payload, covering the migration lock code that lives
inside #ifndef DevBuild.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
…ate name

Dual-model review found that BlocksStartup whitelisted state names, so a
completion receipt could coexist with a state that reads as informational and
the app would start anyway, abandoning moved data. Reported reachable paths:
an Inno self-update leaving the recorded source version mismatched, an
unsupported/drifted source, an architecture mismatch, and an undecodable or
locked receipt.

- Carry HoldsCompletionReceipt on StoreMigrationStartupDecision and derive
  BlocksStartup from it, so a receipt outranks every informational state.
- Report CompletionPresent from the record reader by existence, so a receipt
  that cannot be decoded or is locked still protects the handoff. Invalid no
  longer conflates a corrupt receipt with a corrupt intent.
- Treat closing as blocking until the first admission resolves; dismiss was
  reachable while the initial inspection was still in flight.
- OR the workflow handoff flag instead of assigning it, so Retry re-inspecting
  into a transient failure cannot drop a receipt already observed this session.
- Install Inno Setup in fast-validation. The ISCC gate added in a0c5050 ran
  with -RequireCompiler on an image that does not ship it, which would have
  failed every PR.

Validation: build.ps1 5/5, docs 49, Shared 4087/0, Tray 3197/0,
Connection 1150/0, UITests StoreMigrationWindowProof 13/0, ISCC 3 variants,
test-ci-workflow-contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
…erify key removal on failure

Two remaining review findings, both on the uninstall/export scripts.

- Uninstall-LocalGateway.ps1 validated the gateway id as a single path segment
  but still called Remove-Item -Recurse on a directory that could itself be a
  junction. Windows PowerShell 5.1, which is what runs this during Inno
  uninstall, recurses through reparse points, so the delete would destroy the
  junction target. Reject reparse points there the way MigrationRecordCodec
  already does everywhere else. Anchor the id pattern with \A and \z, since
  .NET lets $ match before a trailing newline.
- Export-MigrationTestMsix.ps1 verified that the disposable signing key was
  gone only after the try/finally, so the check never ran on the failure path,
  which is when a key is most likely to be left in Cert:\CurrentUser\My. Warn
  from the finally as well, and keep the hard failure on the success path.

Validation: Tray 3198/0 (new CleanupScript_RefusesToDeleteThroughAReparsePoint),
test-migration-test-msix, PowerShell parse check on both scripts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
MigrationPathIsOrdinary walked ancestors with ExtractFileDir until it reached a
fixed point. That terminates correctly on a local disk, where ExtractFileDir
returns 'C:\' for 'C:\', but a UNC path has no such fixed point:
ExtractFileDir('\\server\share') yields '\\server', which is not a filesystem
object. GetFileAttributesW then fails with a code that is neither
ERROR_FILE_NOT_FOUND nor ERROR_PATH_NOT_FOUND, the guard exits False, and
InitializeUninstall refuses to uninstall at all.

With AppData redirected to a share, that wedges the user both ways: the Inno
app cannot be removed, so migration stays in AwaitingInnoRemoval and the Store
app never starts.

Stop at the share root instead. Every component below it is still checked, so
this restores the local walk's semantics rather than relaxing the guard.

Validation: Tray 3199/0 (new Installer_StopsTheReparseWalkAtAUncShareRoot),
ISCC 3 variants.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
A second review round found that the receipt-based blocking introduced earlier
only held where a receipt was successfully read. Every error path still fell
through to "start normally", which is the data-divergence this feature exists
to prevent.

MigrationStartupRecordReader: File.Exists reports false for an inaccessible
path, an invalid path, and a directory of the same name, so it cannot tell
absence from failure and failed open on exactly the paths that matter. Probe
attributes instead and treat only a definite not-found as proof that nothing
moved. The rejected-path handler now probes too: refusing to read through a
reparse point is not evidence that data stayed put.

StoreMigrationStartupCoordinator: read records before the version/architecture
policy check, so a misconfigured build cannot return a non-blocking decision
while a receipt is on disk. A Completed status with no decoded record now
recovers instead of throwing, because that throw escaped into the UI error
boundary and was reported as a plain inspection failure, dropping the receipt
the status proves exists.

StoreMigrationWorkflow: an inspection that threw never observed a receipt, but
the finally still marked admission resolved, so closing the window resumed
startup. Probe the receipt directly on that path, and accept the caller's
admission so a receipt seen by StoreMigrationStartupGuard is not discarded by a
failing second pass.

Uninstall-LocalGateway.ps1: the reparse guard checked only the identity
directory. A junction at 'gateways' redirects the whole subtree while the leaf
still looks ordinary, so Remove-Item -Recurse deleted the target. Walk every
ancestor up to the data directory.

Export-MigrationTestMsix.ps1: guard the residue probe inside the finally, which
could itself throw under ErrorActionPreference Stop and mask the real failure.

The new junction coverage executes the shipped guard text against real
junctions; the previous source-order assertions passed while the bug was live.
Verified failing against the leaf-only guard before restoring the fix.

Validation: build.ps1 5/5, docs 49, Shared 4087/0, Tray 3205/0,
Connection 1156/0, UITests StoreMigrationWindowProof 13/0, ISCC 3 variants,
PowerShell parse checks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
PR1's branch gained ten commits while this branch was in review. Resolve the
six conflicts in favour of whichever side made the later, narrower decision,
and port PR1's two behavioural fixes into this branch's architecture rather
than reverting the architecture to match them.

Resolutions:
- StoreMigrationStartupGuard.cs keeps the windowed flow. PR1's durable
  autostart refusal is ported instead: StoreMigrationAutoStartApplier now
  rethrows StoreMigrationAutoStartRefusedException for durable refusals, and
  the workflow maps StartupPreferenceRefused to a new StartupRefused stage
  before the allows-normal-startup fold. StartupRefused reuses the existing
  Migration_StoreStartupRefused string and stays outside BlocksStartup.
- MigrationOperationLock.cs restores PR1's inline DACL hardening and
  ScrubExistingEntries, which this branch's MigrationRecordStorage extraction
  had silently weakened.
- InnoMigrationStartupGuard.cs takes PR1's narrower lock handling: only a
  contended lock blocks Inno startup, an unreadable one degrades to the
  receipt check. Both branches fixed the same bricking bug; PR1's carve-out
  keeps the in-progress interlock this branch had dropped.
- StoreMigrationFinalizationCoordinator.AllowsNormalStartup keeps PR1's
  broadened set, so a transient StartupPreferenceFailed no longer strands the
  user on a failure card. The workflow theory follows that semantics and a new
  behavioural test covers the durable refusal.
- MigrationRecordTests.cs adopts PR1's exit-code semantics (a receipt that
  exists but does not validate is exit 2, never 0) alongside this branch's new
  consent kind.
- InnoMigrationContractTests.cs unions both suites. PR1's
  StorePreviewChoices_UseNativeYesNoWithoutActionLegends is deliberately
  dropped because it asserts the MessageBox flow this branch deleted, and
  PR1's Task.Run(...).ConfigureAwait(false) assertion no longer applies to an
  applier that plain-awaits.
- docs/RELEASING.md keeps this branch's full shipping journey.

Validation: build.ps1 5/5, docs 49, Shared 4087/0, Tray 3227/0, Connection
1201/0, UITests 13/0, installer.iss compiles for x64, arm64 and DevBuild.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
The StartupRefused stage arrived with the PR 1 merge and is the only visible
window state with no capture. It renders new user-facing copy, hides Primary
because there is nothing to retry, and focuses Dismiss.

Mount the production window with in-memory operations, drive finalization to
StartupPreferenceRefused, and assert the refusal string, the collapsed Primary,
the polite live region, and that dismissing resumes startup because no receipt
blocks it. Capture the rendered state under OPENCLAW_VISUAL_TEST.

Validation: build.ps1 5/5, docs 49, Shared 4087/0, Tray 3227/0, UITests 14/0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
natalie-aguinaldo and others added 2 commits September 24, 2026 15:48
Eight resource values were XML-escaped one time too many, so users saw the
raw escape sequence instead of the intended character.

The MSIX uninstall warning in Settings > Local Gateway rendered "Windows
Settings &#x2192; Apps", losing the navigation-path arrow. The InfoBar
carries an x:Uid, so the .resw value wins at runtime and the correct XAML
attribute literal is only a design-time fallback; the string was escaped
again when it was lifted into .resw. fr-fr, nl-nl, zh-cn and zh-tw were a
step worse at "&amp;#x2192;" because the translation pass escaped it once
more.

The same re-escaping damaged seven ampersand keys in fr-fr and nl-nl, where
"Save & connect" and "Read & write" rendered as "Enregistrer &amp; connecter"
and "Lezen &amp; schrijven".

Store the literal characters instead of numeric references so another
escaping pass cannot reintroduce this, matching the roughly eighteen arrows
per locale that are already stored literally.

Add Resources_DoNotContainOverEscapedXmlEntities, which decodes every .resw
value and fails when an entity survives decoding, since a well-formed
resource can never produce one. Verified it is not vacuous by reinjecting
both damage families and confirming it reports the offending locale, key and
token.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
A user who added a gateway but never finished pairing could not migrate.
Completion resolved an operator credential and refused when none existed,
and the resulting card told them to "restore the gateway configuration in
the previous app" even though nothing was damaged. Retry could not clear it,
and removing the gateway record landed on the same stage through
NoActiveGateway, so there was no way forward. Reproduced in a VM end to end.

The check protected nothing. Migration never moves gateway state: both
installs read the same roaming directory, so a credential resolves exactly
the same before and after. The resolved credential was used only for a null
check; it is never written to the receipt and never copied.

The damaged case it appeared to cover is handled earlier and more strictly.
MigrationInventory.CaptureIdentities rejects any unreadable or corrupt
identity, and capture runs during preparation and again at completion before
the fingerprint compare. A test that damages key material proves preparation
throws, so completion was only ever reachable when no credential was
configured, which is the case that should be allowed. Note the old gate was
weaker than it looked: a corrupt identity with any fallback token passed it.

Remove the check and the now-unreachable NoActiveGateway and
CredentialUnavailable completion states, the unreachable
StoreMigrationStage.CredentialUnavailable and its window arm, and the
orphaned Migration2_CredentialUnavailable and
Migration_StoreCredentialUnavailable strings across all six locales. Drop the
ICredentialResolver dependency so the coordinator structurally cannot resolve
credentials, and invert the contract assertion to guard that.

Add coverage for completing without a credential, for an unpaired gateway,
and for damage introduced both before preparation and between preparation and
completion, so the earlier fail-closed path stays proven.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
natalie-aguinaldo and others added 8 commits September 24, 2026 16:38
…re-migration' into user/natalie-aguinaldo/inno-to-store-shipping
Adversarial dual-model review found that RecoveryRequired could strand a
user permanently: the workflow refused to re-run from Recovery, the window
collapsed the primary action there, BlocksStartup stayed true while a
receipt was held, and the guidance text forbade the only remedy that works.

- Recovery is retryable again, so removing the previous app is noticed.
- Recovery shows the Installed apps shortcut alongside Retry.
- StoreMigrationRecoveryDiscard removes migration records that cannot be
  decoded at all, under prepare.lock and with reparse rejection, and
  re-reads status under the lock so a decodable record is never discarded.
  Uninstall preserves data while the Store package is registered, so this
  cannot lose user state.
- The uninstall cleanup reparse guard now walks descendants, because
  PowerShell 5.1 Remove-Item -Recurse follows a junction planted inside
  the identity directory.
- Copy no longer claims gateway-credential validation or a paused startup
  that is not held.

Validation: build.ps1; Shared 4087; Tray 3243; Connection 1208;
Tray UI StoreMigrationWindowProofTests 15.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
The re-review of f9d5d66 found the recovery escape hatch still had three ways
to leave a user stuck.

Records were classified through the aggregate startup status, which stops at
the first unreadable file. A corrupt receipt therefore hid a perfectly good
intent behind Invalid, and discarding took both. Classification is now per
file, so a corrupt receipt costs only the receipt.

A decodable intent left behind after the previous app is uninstalled could
never be acted on again, but the old gate withheld discard because the record
still decoded. Recovery then nagged forever with no way out. Discard now also
covers a leftover consent or intent once the source is gone and no readable
receipt remains.

The previous app holds the migration lease for its entire lifetime, which makes
a contended discard the most likely first outcome. That result was only logged,
so the button looked dead. The window now reports it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
…tion

The third review pass found the discard could still strand a user.

Opening prepare.lock with FileMode.Open meant a missing lease file threw before
any planning ran, and that throw was caught as "nothing to discard". Records
stayed on disk while the window reported success, so recovery returned on the
next pass with nothing to show for it. The lease is now opened with
OpenOrCreate, and only a missing directory counts as nothing to discard.

Classification had the repository's exception convention backwards. Operational
failures were rethrown and anything else nominated a file for deletion, so a
record that failed to parse was reported as lock contention while an unexpected
error made a file deletable. Reading and decoding are now separate: only the
same allowlist the startup reader uses marks a record corrupt, and everything
else propagates. A rejected path shape travels as MigrationPathRejectedException
so it is no longer reported as the previous app holding the lease.

A contended discard also left its message on screen for every later stage, and
overwrote the launch failure. A new pass now clears it, and the window shows it
only in recovery.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
…ntention

BinaryReader reports a malformed string length as a plain IOException. The
decode allowlist did not include it, so a record that decrypted but did not
parse escaped classification as an operational failure. CanDiscard then
returned false and hid the button while startup stayed blocked, which is the
lockout this class exists to remove. The decode block has no file IO left in
it, so IOException there can only mean a malformed record.

The damaged-record test only cut the protected bytes in half, which fails in
DPAPI and never reached the parser, so it passed either way. It is now joined
by a record that decrypts cleanly and carries a negative string length.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
…on failure

The discard offer only exists on the recovery screen. The startup reader
decoded records without separating the file read from the parse, so a record
that decrypted but carried a malformed string length surfaced as a plain
IOException and was reported as an inspection failure. A receipt in that state
blocked startup from a screen with no way out, which is the lockout the discard
was added to remove.

Reading the bytes and parsing them are now separate, and only the parse treats
an IOException as corruption, the same split InnoMigrationConsentStore already
uses. A path rejection still propagates ahead of it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
…re-migration' into user/natalie-aguinaldo/inno-to-store-shipping
Granting consent rewrote the directory owner and DACL on every call once the
directory existed. That rewrite needs WRITE_DAC and WRITE_OWNER, which a roaming
or administratively hardened profile can withhold from an ordinary launch, and a
refusal is not an invalid record: it escapes the consent store's allowlist and
surfaces as a generic inspection failure, blocking a migration whose state was
already correct.

Reuse the drift check the operation lock already applies for the same reason
instead of duplicating it, so an already-hardened directory is left alone while
real drift is still corrected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52
@natalie-aguinaldo

Copy link
Copy Markdown
Owner Author

Closing in favor of openclaw#1519 now that PR 1 merged

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant