Skip to content

Abort the install when an upgrade migration fails (#1497)#1498

Merged
erikdarlingdata merged 41 commits into
devfrom
feature/1497-addserverdialog-upgrade-abort
Jul 13, 2026
Merged

Abort the install when an upgrade migration fails (#1497)#1498
erikdarlingdata merged 41 commits into
devfrom
feature/1497-addserverdialog-upgrade-abort

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Fixes #1497.

The bug

AddServerDialog logged "Upgrade failures detected. Continuing with full installation to ensure consistency..." when an upgrades/* script failed, ran the full install over the partially-upgraded database anyway, and then wrote an installation_history row using _installResult.Success — which is FilesFailed == 0 for the install files only and excludes upgrade failures entirely.

That row is permanent damage:

  1. LogInstallationHistoryAsync writes installation_status (InstallationService.cs:1236).
  2. GetInstalledVersionAsync reads SELECT TOP 1 installer_version ... WHERE installation_status = 'SUCCESS' ORDER BY installation_date DESC (:964-968).
  3. FilterUpgrades only offers hops .Where(x => x.ToVersion > current) (ScriptProvider.cs).

So a failed migration got stamped SUCCESS at the target version, the server then reported as current with the ALTERs never applied, and the hop was never offered again. Silent, permanent schema drift; the only recovery was a clean install.

ExecuteAllUpgradesAsync already documents the contract in its own comment:

Stop at the first failed hop. Later hops assume this one's schema changes applied; running them against a partially-upgraded database compounds the damage. The caller aborts the whole install when totalFailureCount > 0.

The CLI installer honored it. Only the Dashboard's single-server path did not.

The fix

Dashboard/AddServerDialog.xaml.cs — aborts on upgrade failure without running the install or writing a history row, and folds the upgrade counts into the history write, matching the CLI. Writing no SUCCESS row is precisely what makes the failed hop get re-offered on the next attempt, and upgrade scripts are written to be idempotent ("each column gets its own guarded ALTER so a re-run after a partial failure resumes cleanly"), so retrying after fixing the error resumes cleanly. The UI restores to the state the run was launched from — not Initial, which would leave "Reinstall Objects" on screen with the clean-install checkbox newly reachable behind it.

Installer.Core/ScriptProvider.csFilterUpgrades now throws ArgumentException on a version with no parseable numeric core, instead of returning an empty list. An empty list means "no upgrades needed", which must never be the answer to "I could not read the version you gave me". That silent path is what allows a status string like "Unreachable" to skip every migration while reporting zero failures. null/blank still means a fresh install and still returns empty.

Installer.Core/InstallationService.csExecuteAllUpgradesAsync catches that and returns (0, 1, 0) so callers abort through the existing totalFailureCount > 0 contract rather than hitting an unhandled exception (the CLI's Main has no outer try/catch).

Installer/Program.cs — the CLI's abort was nested inside if (upgradeCount > 0), so the (0, 1, 0) discovery-failure return skipped it: the CLI printed "No pending upgrades found." and ran the whole install anyway. (The history row was still written FAILED, since totalFailureCount folds in upgradeFailureCount, so nothing was stranded — but the install shouldn't have run, and the Dashboard aborting while the CLI didn't is the exact divergence this branch set out to remove.) The abort now sits outside the upgradeCount block, because discovery can fail before any hop runs.

Two latent bugs the strict parse would have detonated

Both pre-existing, both found in review, both closed here:

  • Two-part versions crashed. Version.TryParse("3.1") succeeds with Build == -1, and new Version(3, 1, -1) throws ArgumentOutOfRangeException. Now clamped.
  • Pre-release versions would hard-abort every upgrade. GetAppVersion strips a +metadata suffix but not -prerelease. The moment <InformationalVersion> becomes 3.2.0-rc1, targetVersion stops parsing — which, with the new throw, would abort every Dashboard upgrade. Rather than patch the seven call sites that each hand-strip suffixes, FilterUpgrades now normalizes at the choke point, mirroring SingleInstanceDecision.ParseProductVersion (re-stated locally to keep Installer.Core dependency-free). Genuine garbage like "Unreachable" still throws.

The same bug, still reachable through the Dashboard's front door

Review of the repair commit found the stranding bug still live by a second route, and this one needed no failed migration at all.

AddServerDialog's pre-install discovery (:537) called GetInstalledVersionAsync with the soft, null-returning overload. That method's own comment names the hazard:

The installer passes throwOnError=true so a transient/permission error can't be mistaken for "database absent" and silently trigger a fresh install over an existing database (no upgrades, then logged as SUCCESS — the #538 hazard). Soft callers (Dashboard version column, adversarial tests) keep the null fallback.

But :537 is not the version column. It is the discovery that decides install-vs-upgrade — the same class of caller as the CLI, which correctly passes throwOnError: true. So a connection timeout, a database left OFFLINE/RESTORING, or a permissions blip returned null, read as "no database", and dropped the dialog into the fresh-install path: every migration skipped, install scripts run over the existing database, installation_history stamped SUCCESS at the target version, every pending hop stranded.

Discovery now passes throwOnError: true and enters a new Connected_StatusUnknown state that hides the install button and explains why. An _installBlockedReason guard also hard-blocks InstallOrUpgrade_Click, so the protection is structural rather than depending on a hidden button.

The worst bug was not in the file everyone was reviewing

Twenty-two rounds of review went at the dialog, because that is where the findings kept coming from. That was survivorship bias: findings kept coming from the dialog because it was the only place anyone was looking. When a reviewer finally looked one call deeper, it found this in Installer.Core/InstallationService.cs:

CleanInstallAsync drops the three SQL Agent jobs, both Extended Events sessions, and then the database itself (SET SINGLE_USER WITH ROLLBACK IMMEDIATE, DROP DATABASE) — before the first install script runs. It was the single most destructive window in the installer, and the only one with no cancellation check in front of it; the first ThrowIfCancellationRequested sat after it. And because a cancelled SqlCommand faults with SqlException rather than OperationCanceledException, the clean-install error handler swallowed the cancel as an ordinary failure and returned normally — so the caller's cancellation path never ran.

Click Cancel Install mid-drop and the dialog announced "Installation completed with 1 error(s)" over a database that was already gone, restored the version verdict it had deliberately discarded for safety, and — because Save skips the connection test once that verdict is stamped — offered to save the server without ever reconnecting to it.

Fixed, and pinned with tests that fail against the old code (verified, not assumed) — which is possible precisely because the fix lives in Installer.Core and not in the dialog.

The bug behind the bugs

Fourteen reviewers could not break the extracted, CI-pinned decision core. They broke the dialog every single round — and the same defect kept coming back wearing different clothes, because the server-name box stays editable during every await (a connection test is ~10s; unbounded for Entra MFA interactive auth):

Stale-but-consistent state the stale-verdict guard catches every time. HALF-REPLACED state is the one thing it structurally cannot catch — because the stamp is what every consumer tests before trusting the other three facts. Move the stamp to the new server while the version still describes the old one, and the guard doesn't miss the lie, it affirms it: "PerformanceMonitor v3.1.0 is up to date", Save enabled, over a server with no PerformanceMonitor database at all.

That is why those four fields now have a single writer, callable only at a guarded publish point, and why the destructive checkboxes are cleared at one choke point (TransitionToState) instead of at each exit — the previous approach had been patched into four separate exits and still missed a fifth.

Four smaller fixes from the same review:

  • Repair-loop trap. InstallComplete disables the expander RepairCheckBox lives in, so after a successful repair the box couldn't be unticked — and the success message told the user to do exactly that. Clicking "Upgrade Now" would repair again, forever. Repair is now cleared automatically before the transition.
  • Connected_Current had no recovery. It collapsed the install button, so a damaged-object server already at the current version had no non-destructive repair in the Dashboard, while --repair handled it fine. It now offers Repair; with current == target there are no migrations to skip, so it's a plain idempotent reinstall.
  • CLI put currentVersion (an installer_version value) into installer_info_version on repair. That column records which binary ran, so infoVersion now passes through unchanged.
  • Summary report claimed appVersion after a repair, contradicting its own history row. Now uses historyVersion.

Blast radius

_installedVersion is only ever a real database version or null — never a UI sentinel — so the new throw cannot fire from the Dashboard's normal flow. It fires only on genuinely corrupt version data, where failing loudly is the correct outcome.

It is also one of four fields that describe the probed server (_coreServerInfo, _serverVersion, _verdictServerName, _installedVersion), and after review they have exactly one writer: PublishProbedServer, called only at points that actually publish a verdict, always after the guard that could still abandon the publish. That coupling is load-bearing, not cosmetic — see below.

How this was tested

  • Installer.Tests (non-DB, CI filter): 152 passed, 0 failed. The three decisions that can corrupt the ledger are extracted into Installer.Core as pure functions and pinned there: InstallGuard (is installing safe?), RepairOutcome (are a repair's file failures the expected kind, and is an upgrade still pending?), InstalledVersionClassifier (clean install vs. run every migration). Installer.Tests is CI-wired; Dashboard.Tests is not, so tests placed there could never fail a PR.
  • Dashboard.Tests: 732 passed, 0 failed.
  • All four projects build clean; CLI smoke-tested (--repair --reinstall → exit 1).
  • DB-touching Installer.Tests (VersionDetectionTests, IdempotencyTests, AdversarialTests) deliberately not run — they create production-named Agent jobs on the real test server.

Sixteen rounds of adversarial review. From round 9 on, every surviving finding was in AddServerDialog.xaml.cs — the one file with zero test coverage — and the bugs were all one of two shapes: a fact read before an await and acted on after it, or a predicate over DialogState that didn't enumerate every state. Six independent reviewers failed to break the ledger invariant or reach a destructive action without consent. That asymmetry is the honest summary of this PR: the extracted, CI-pinned core is solid; the dialog is correct only as far as careful reading gets you, which is why the manual pass below is not optional.

Still needs a live click-through — I cannot drive WPF clicks

Everything below is in the untested dialog. 1–4 are the data-integrity core. 5–12 are the retarget class — the server-name box stays editable while the verdict, the version, the block reason and the destructive checkboxes all describe whichever server was last checked.

Setup: a test server at an older version, and a deliberately broken upgrades/{from}-to-{to}/*.sql (reference a missing object).

Data integrity

  1. Edit server → upgrade. Expect the abort ("Installation aborted: N upgrade script(s) failed"), no install run, and no new row in config.installation_history — the version check still reports the old version, so the hop is still offered. (Leaving no SUCCESS row is exactly what makes it get re-offered.)
  2. Tick Repair → re-run. Install scripts run, migrations skipped, and still no history row (a repair changes no version — writing one would make the guess a fact). A few procedures reporting errors here is expected: they compile against a schema the pending migration hasn't created yet.
  3. The repair completes → an "Upgrade Now" handoff appears saying the server is still at the old version.
  4. Fix the script, click Upgrade Now. It applies and stamps the new version.

Retarget (each one was a real bug found in review)
5. Repair a server, get the handoff, then type one character into the server box and delete it. The handoff must withdraw and come back — not vanish permanently.
6. Repair → click the handoff's Upgrade Now → let it succeed → type a character in the server box. It must not re-assert "still at v3.0.0 — the pending upgrade has not been applied".
7. Install on server A → retype the box to B. "Installation completed successfully!" must withdraw, with a notice — not sit there under B's name.
8. Get to "needs upgrade" on A, tick Perform clean install (drops existing database), then retype the box to B and click through. It must not drop B's database. (This was reachable.)
9. On "needs upgrade", type a character and delete it. Upgrade Now must come back (the verdict was mis-addressed, not wrong).
10. Point at an unsupported instance (SQL 2014) → get the block → retype to a 2016+ instance. The panel must not still say "SQL Server 2014 is not supported".
11. Click Save, and while the connection test runs (~10s), edit the server name. Save must say "nothing was saved, click Save again" — not silently do nothing.
12. Click Save → click Test Connection → click Save again. Must not throw (this assigned DialogResult to an already-closed window; the app survives it, but you get an "An error occurred" box and a server that was silently not saved).

The two I would most want a human on:

  1. The silent-corruption one. Edit an existing, working server (say PROD01). Change the Server Name box to something else (TEST99). Click Save, and while the ~10s connection test is running, press ESC. Now reopen Manage Servers. PROD01 must still be PROD01. (It wasn't: the dialog writes into the same object ServerManager holds, so the abandoned save renamed the live server in place — while ShowDialog reported "cancelled" and nothing on screen said otherwise. It then reached disk on its own the next time any server connected. PROD01 came back as TEST99, kept PROD01's id and credentials, and stopped being monitored.)
  2. The one that matters when it goes wrong. Tick Perform clean install (drops existing database) on a server that HAS PerformanceMonitor, click Install Now, and click Cancel Install while "Performing clean install..." is on screen.
    • It must say cancellednot "Installation completed with 1 error(s)". DROP DATABASE runs before the first install script, so at that moment the database is already gone.
    • The install log must still be on screen with the SQL error text.
    • The status must read unknown ("a clean install was requested, which drops the database…"), and installing must be blocked until you re-check.
    • No new row in config.installation_history.
    • Then click Save — it must re-run the connection test (it used to skip it, because the cancelled run had re-stamped the verdict).
  3. The most ordinary path in the product. Point at an instance with no PerformanceMonitor database → Check for Updates"No PerformanceMonitor database found". Save must be DISABLED. The only ways forward are Install Now or the explicit "Skip, just add server" link (which enables Save itself). Save is the default button, so press Enter here too — it must do nothing.

SQL auth specifically: after any install, confirm the monitoring-credentials panel still appears. A bug here persisted the installer's account — routinely sa — as the ongoing monitoring login.

CLI equivalent for 1–2: PerformanceMonitorInstaller.exe <server> ... (aborts), then ... --repair. A repair with a pending upgrade exits 0 (its failures are the expected kind) and now prints "Repair completed with N expected error(s)" rather than claiming a clean install.

The repair path (the escape hatch the abort needs)

Aborting removes an accidental self-healing path: an upgrade script can fail because an object it ALTERs is missing or damaged, and the full install running afterwards used to quietly recreate it. With the abort in place, the only escape hatch left would be Clean Install — which drops the database. So both front ends gain a non-destructive repair:

  • Dashboard — "Repair: reinstall objects, skip upgrade scripts" in Advanced Options, mutually exclusive with clean install. The upgrade-abort message now points at it.
  • CLI--repair, mutually exclusive with --reinstall (which drops the database, leaving nothing to repair).

Both run the idempotent install scripts without migrations, so the missing objects come back and the pending upgrade can then be re-run.

The load-bearing detail: a repair that skips pending migrations writes no installation_history row at all. installer_version is exactly what GetInstalledVersionAsync reads back, and FilterUpgrades only offers hops newer than it — so stamping the target version here would strand every pending migration, i.e. re-create the bug this PR exists to fix. But writing the version we merely read is not safe either: GetInstalledVersionAsync returns a 0.0.0 sentinel meaning "installed, but the version is unreadable — try every upgrade", and persisting that as a SUCCESS row would turn a guess into a fact. Writing nothing leaves the previous row as the version of record, so the pending upgrade is still offered.

That sentinel is also why RepairOutcome splits what was one flag into two questions, because they disagree for exactly that value: "may I excuse these file failures?"no (we cannot certify a success we cannot explain), and "is there an upgrade to run next?"yes (every hop may be pending). Answering both with one flag told operators "already at the current version, so there is no upgrade to apply" for a server with every migration waiting — and withheld the button that would have applied them.

The Dashboard resolves this to repairFromVersion (the version being repaired from, null when not repairing) rather than a bare bool, so the history write cannot record the old version after a clean install even if both boxes were somehow set.

Related

Found while reviewing #963, whose bulk-upgrade path mirrors this same defect across N servers at once. The semantics to mirror there are called out in that review.

🤖 Generated with Claude Code

erikdarlingdata and others added 6 commits July 12, 2026 10:37
AddServerDialog logged "Continuing with full installation to ensure
consistency..." when an upgrade script failed, ran the full install over the
partially-upgraded database anyway, and then wrote an installation_history row
using _installResult.Success -- which is FilesFailed == 0 for the install files
only and excludes upgrade failures entirely.

Because GetInstalledVersionAsync reads the most recent installation_status =
'SUCCESS' row and FilterUpgrades only offers hops with ToVersion > current, that
SUCCESS row at the target version stranded the failed migration permanently: the
server reported as current with the upgrades/* ALTERs never applied, and the hop
was never offered again.

ExecuteAllUpgradesAsync already documents the contract -- "The caller aborts the
whole install when totalFailureCount > 0" -- and the CLI installer honors it
(Program.cs). Only the Dashboard's single-server path did not.

- AddServerDialog now aborts on upgrade failure without running the install or
  writing a history row, and folds the upgrade counts into the history write,
  matching the CLI. Leaving no SUCCESS row is what makes the failed hop get
  re-offered, and upgrade scripts are idempotent, so re-running after fixing the
  error resumes cleanly.

- FilterUpgrades now throws ArgumentException on a present-but-unparseable
  version instead of returning an empty list. Empty means "no upgrades needed",
  which must never be the answer to "I could not read the version you gave me" --
  that silent path is what lets a status string like "Unreachable" skip every
  migration and still report zero failures.

- ExecuteAllUpgradesAsync translates that into a reported failure (0, 1, 0) so
  callers abort through the existing contract instead of hitting an unhandled
  exception; the CLI's Main has no outer try/catch.

Adds seven regression tests to UpgradeOrderingTests covering blank, unparseable,
and status-string versions through both ScriptProvider and ExecuteAllUpgradesAsync.

Found while reviewing #963, whose bulk-upgrade path mirrors the same defect
across N servers at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three defects found in review of the previous commit.

1. The CLI's abort was nested inside `if (upgradeCount > 0)`, so the new
   (0, 1, 0) "discovery failed" return skipped it entirely: the CLI printed
   "No pending upgrades found." and ran the whole install anyway. The history
   row was still written FAILED (totalFailureCount folds in upgradeFailureCount),
   so nothing was stranded, but the install should not have run -- and the
   Dashboard aborting while the CLI did not is the exact divergence this branch
   set out to remove. The abort now sits outside the upgradeCount block, since
   discovery can fail before any hop runs.

2. Version.TryParse("3.1") succeeds with Build == -1, and the Version(int,int,int)
   ctor rejects that -- a two-part version threw ArgumentOutOfRangeException
   (an ArgumentException, so the new catch would have reported it as an
   unreadable version). Pre-existing latent crash; now clamped.

3. GetAppVersion strips a "+metadata" suffix but not "-prerelease". The moment
   <InformationalVersion> becomes e.g. "3.2.0-rc1", targetVersion stops parsing
   -- which, with the new throw, would hard-abort every upgrade. Rather than fix
   the seven call sites that each strip suffixes by hand, FilterUpgrades now
   normalizes at the choke point, mirroring SingleInstanceDecision.ParseProductVersion
   (re-stated locally to keep Installer.Core dependency-free). Genuine garbage
   like "Unreachable" still throws.

Also zeroes the progress bar on the Dashboard abort path, matching the cancel path.

Adds four regression tests: pre-release / build-metadata / combined suffixes on
the target version, and a two-part version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Aborting on a failed migration removed an accidental self-healing path: an
upgrade script can fail precisely BECAUSE an object it ALTERs is missing or
damaged, and the full install running afterwards used to recreate it. With the
abort in place the only escape hatch left was Clean Install, which drops the
database.

Repair runs the idempotent install scripts WITHOUT running migrations, so the
missing objects come back and the pending upgrade can then be re-run.

  Dashboard: "Repair: reinstall objects, skip upgrade scripts" in Advanced
             Options, mutually exclusive with the destructive clean install.
             The upgrade-abort message now points at it.
  CLI:       --repair, mutually exclusive with --reinstall (which would drop the
             database, leaving nothing to repair).

The load-bearing detail: a repair must NOT record a SUCCESS history row at the
TARGET version. installer_version is what GetInstalledVersionAsync reads back,
and FilterUpgrades only offers hops newer than it -- so stamping the target here
would strand every pending migration, which is the exact bug this branch exists
to fix. Both paths record history at the version the database is still at, so the
upgrade is still offered afterwards.

Dashboard resolves that to `repairFromVersion` (the version being repaired FROM,
null when not repairing) rather than a bare bool, so the history write cannot
record the old version after a clean install even if both boxes were somehow set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
erikdarlingdata and others added 22 commits July 12, 2026 11:25
…dialog-upgrade-abort

# Conflicts:
#	CHANGELOG.md
Review of the repair commit found the SAME stranding bug still reachable through
the Dashboard's front door, plus five smaller issues.

1. HIGH -- AddServerDialog's pre-install discovery called GetInstalledVersionAsync
   with the soft (null-returning) overload. GetInstalledVersionAsync's own comment
   names the hazard: "The installer passes throwOnError=true so a transient/
   permission error can't be mistaken for 'database absent' and silently trigger a
   fresh install over an existing database (no upgrades, then logged as SUCCESS --
   the #538 hazard). Soft callers (Dashboard version column, adversarial tests)
   keep the null fallback."

   But :537 is not the version column -- it is the discovery that decides
   install-vs-upgrade, the same class of caller as the CLI. A timeout, an OFFLINE/
   RESTORING database or a permissions blip came back as null, which reads as "no
   database": upgrades skipped, install run over the existing database, history
   stamped SUCCESS at the target version, every pending hop stranded. Exactly the
   bug this branch exists to kill.

   Now passes throwOnError: true and drops into a new Connected_StatusUnknown state
   that hides the install button and explains why. A _databaseStatusUnknown field
   also hard-blocks InstallOrUpgrade_Click, so the guard is structural rather than
   depending on a hidden button.

2. Repair-loop trap: InstallComplete disables the expander RepairCheckBox lives in,
   so after a successful repair the box could not be unticked -- and the success
   message told the user to do exactly that. Clicking "Upgrade Now" would repair
   again, forever. Repair is now cleared automatically before the transition.

3. Connected_Current collapsed the install button, so a damaged-object server that
   was already at the current version had NO non-destructive recovery in the
   Dashboard, while --repair handled it fine. It now offers Repair; with current ==
   target there are no migrations to skip, so it is a plain idempotent reinstall.

4. CLI put currentVersion (an installer_version value) into installer_info_version
   on repair. That column records which binary ran, so infoVersion passes through.

5. Dashboard summary report claimed appVersion after a repair, contradicting its
   own history row. Now uses historyVersion.

6. CLI's hoisted currentVersion reset moved in with its sibling per-iteration
   resets, so a clean-install iteration can never reuse the prior one's version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…opened

Two independent adversarial reviews converged on the same critical defect in the
previous commit, plus a stale-state hole neither the abort nor the repair covered.

1. CRITICAL -- Connected_Current is a catch-all, not "installed == target".
   The routing sends installedVer >= appVer AND the unparseable case into it. On
   dev that was harmless: the state collapsed the install button, so no action was
   reachable. The previous commit un-hid the button (labelled "Repair") for all
   three.

   A 3.1.0 Dashboard against a server a colleague upgraded to 3.2.0 lands in
   Connected_Current, reports "up to date", and offers Repair. Clicking it runs the
   3.1.0 install scripts over the 3.2.0 schema -- every CREATE OR ALTER proc and
   view reverts to its older body -- then stamps installation_history SUCCESS at
   3.1.0. A silent downgrade: this branch's own bug class, inverted.

   Connected_Current now means installedVer == appVer, exactly. A newer server and
   an unparseable version both block instead, via _installBlockedReason (which
   replaces the _databaseStatusUnknown bool and carries the reason to the UI).

2. CRITICAL -- the install path trusted cached dialog state, not the connection it
   was about to write to. BuildInstallerConnectionString() reads ServerNameTextBox
   live, but _installedVersion was only ever assigned in DetectDatabaseStatusAsync.
   Retype the server box after a Test Connection and click straight through, and the
   installer writes to server B while reasoning about server A's version -- and since
   the cached version is what suppresses hops, that runs zero migrations and stamps
   SUCCESS at the wrong version, stranding server B's upgrades for good.

   InstallOrUpgrade_Click now re-reads the version against the connection it is about
   to write to, with throwOnError: true. That also closes the TOCTOU between discovery
   and install for every state, not just the new one.

3. Connected_Current no longer shows InstallationPanel. That panel holds the
   "drops existing database" clean-install checkbox, which must never sit behind a
   button labelled Repair.

4. Sticky mode flags. AdvancedOptionsExpander was re-enabled only on the abort,
   cancel and fatal paths, so after a completed install it stayed disabled for the
   dialog's life -- with whatever mode checkboxes were ticked frozen inside it. And
   Repair was cleared only on the SUCCESS branch. Combined: a failed repair left
   Repair ticked and un-untickable, and every later click repaired again, skipping
   migrations. Worse, the form is re-enabled at InstallComplete, so the server box
   could be retargeted with a ticked checkbox carried onto a server that never
   consented -- "Clean install" included, which drops the database.

   The expander is now unfrozen in the transition reset block, and both mode
   checkboxes are cleared on every completed run and on every fresh detection.

5. NormalizeVersion stripped +metadata but not -prerelease, and formatted a two-part
   version as "3.1.-1". A 3.2.0-rc1 InformationalVersion therefore failed to parse and
   sent EVERY server to "up to date". Now strips both suffixes and clamps Build,
   matching ScriptProvider.ParseVersionCore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… of the guard

Both Round-2 reviewers converged on the same two criticals. The Round-1 fixes were
incomplete in exactly the way that mattered.

1. CRITICAL -- the install-time re-probe refreshed _installedVersion but never
   re-ran the SAFETY VERDICT on it. Only the throw case was handled; the
   "installed is NEWER than this build" case was still decided by
   _installBlockedReason, a value computed by the last Test Connection against the
   server name as it was then. ServerNameTextBox has no TextChanged handler, so
   retargeting invalidates nothing.

   That case is invisible to every other guard: FilterUpgrades selects
   ToVersion > current && ToVersion <= target, so installed > target yields zero
   hops AND zero failures -- the abort never fires. The install then runs this
   binary's older scripts over the newer database, reverting every CREATE OR ALTER
   proc and view, and writes SUCCESS at the LOWER version. No user error needed:
   test a server, have a colleague upgrade it with a newer build, click Upgrade Now.

   The classification is now one method, GetInstallBlockReason, called by BOTH the
   detect path and the install click -- against the connection it is about to write
   to. It also closes a second leak: repair took repairFromVersion straight from the
   un-revalidated probe, so a garbage installer_version could be written back verbatim.

2. CRITICAL -- the re-probe put an await in front of the only re-entrancy guard.
   TransitionToState(Installing) used to be this handler's first statement; it
   collapses DatabaseStatusPanel, the Border InstallUpgradeButton lives in, and that
   collapse is the ONLY thing that makes the button unclickable (SetFormEnabled never
   touches it). Awaiting a network round trip in front of it left the button live for
   the whole connect timeout -- unbounded for interactive Entra -- and the handler is
   async void.

   Two clicks meant two concurrent installs: the second clobbers _installCts, so the
   first's finally disposes the second's source and nulls the field, leaving Cancel a
   silent no-op on the run that is actually still going; _installResult is clobbered;
   two history rows; and with clean install ticked, one DROP racing another's install.
   Cancel/ESC during the probe was also a no-op because _installCts did not exist yet
   -- the dialog closed and the install ran on regardless.

   The UI is now frozen and the CTS armed BEFORE the first await, and the probe takes
   the token.

3. HIGH -- the CLI had no ahead-of-installer guard at all, and --repair skipped the
   only thing that validated the recorded version (ExecuteAllUpgradesAsync, hence
   ParseVersionCore) before writing it straight back as installer_version. An
   unreadable value got re-persisted, after which every non-repair run aborted forever
   and only --repair "worked". Both are now checked up front, for repair too.

4. MEDIUM -- a successful repair leaves migrations unapplied by design, but
   InstallComplete collapses the panel InstallUpgradeButton lives in, so the completion
   message pointed at a button that was not on screen; the obvious next click was
   Save & Connect, leaving the half-migrated database this feature exists to escape.

ScriptProvider.TryParseVersionCore is now public (null instead of throwing) so the
Dashboard and CLI share one parser rather than drifting. 16 new parser tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…all doors

Two more reviewers, six more findings. The invariant held throughout -- these are
about the repair feature being honest, and about doors left open around it.

1. Repair on a database with pending migrations reports errors, and that is
   INHERENT, not a bug. Verified against SQL2022: install scripts compile against
   the CURRENT schema, and ALTER PROCEDURE binds columns at compile time, so
   install/23_process_blocked_process_xml.sql -- whose body reads
   collect.blocking_BlockedProcessReport.monitor_loop, a column the 3.0.0-to-3.1.0
   migration adds -- fails with Msg 207 "Invalid column name" on a 3.0.0 database.
   install/02 only creates that table IF OBJECT_ID IS NULL, with no column backfill
   (contrast the /*Add columns for existing installs*/ pattern it does carry for
   collect.memory_stats).

   Nothing is damaged -- a failed CREATE OR ALTER leaves the old body intact, the
   run records PARTIAL rather than SUCCESS, and the upgrade's own install pass
   recompiles them once the schema is current. But the completion path GATED the
   "run the upgrade next" handoff on _installResult.Success, so the user was left
   staring at "completed with N error(s)" and no next step -- landing precisely the
   half-migrated database the feature exists to escape. The handoff is no longer
   gated, and the expected errors are explained rather than presented as failure.

2. Repair now writes NO installation_history row, in both apps. It changes no
   version, and that table is the version ledger -- echoing back a version we merely
   READ is how a guess becomes a fact. GetInstalledVersionAsync returns "1.0.0" as
   the #538 fallback when a database exists with no SUCCESS row, meaning "unknown,
   try every upgrade"; repair was persisting that sentinel as a SUCCESS row, i.e.
   materializing the guess as truth -- the same failure mode this branch exists to
   prevent. Writing nothing leaves the previous row as the version of record, so the
   pending upgrade is still offered.

3. CheckForUpdatesButton was never disabled by SetFormEnabled, so it stayed live
   during Installing. Clicking it re-ran detection, which transitions back to a
   Connected_* state -- re-showing the install button and collapsing the running
   install's progress panel -- and a second click then started a CONCURRENT install
   against the same database. The Round-2 comment reasoned that the panel collapse
   was "the ONLY thing that makes the button unclickable"; the identical sentence was
   true of this button and was not followed through. One line in SetFormEnabled.

4. The cancel and fatal paths land in Initial, which did not clear the mode
   checkboxes -- so my own "cleared on EVERY outcome" comment was false. Tick
   "Clean install (drops existing database)", have the install throw, retype the
   server box, click again: it DROPS a database that was never consented to. Cleared
   in Initial as well as InstallComplete.

5. Cancelling during the install-time probe landed in BlockInstall, not the cancel
   handler: SqlCommand does not surface cancellation as an OperationCanceledException
   -- it faults with a SqlException ("Operation cancelled by user."). The user's own
   Cancel was reported as an unknown-database-state block that hid the install button.

6. The repair handoff never appeared for SQL auth: InstallComplete chains straight
   into MonitoringCredentials, which does not re-show the panel either -- so the auth
   mode most likely to hit this got the dead end. The summary report also named the
   database's version where it prints "Installer Version", which identifies the binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g about repair

Both Round-4 reviewers converged again. The two worst both end in a DROPPED
DATABASE by different routes.

1. CRITICAL -- concurrent install via an IN-FLIGHT detection. Round 3 disabled
   CheckForUpdatesButton during Installing, which closed "start a detection during
   an install". It did not close "a detection that was ALREADY running when the
   install started": its continuation transitions back to a Connected_* state,
   which un-collapses DatabaseStatusPanel over the running install, re-shows the
   install button, re-enables Advanced Options -- and both detection paths then
   re-enable their buttons in unconditional finally blocks, overwriting the guard.

   From there: expand Advanced Options mid-install, tick "Perform clean install
   (drops existing database)", click Upgrade Now -- and CleanInstallAsync DROPS the
   database out from under the running install. Even without the tick, the second
   run disposes the first's still-in-use CancellationTokenSource and leaves Cancel
   pointing at the wrong run.

   Guarded structurally on _currentState == Installing: the detection bails before
   touching any UI, both finallys stop re-arming, and the click handler refuses to
   start a second install.

2. HIGH -- the CLI told the truth to nobody. A --repair on a behind database is
   DESIGNED to fail some install files (Msg 207: the install scripts compile against
   the current schema, and ALTER PROCEDURE binds columns at compile time). The
   Dashboard says so in as many words. The CLI reported it as PartialInstallation,
   exit code 4, "Installation completed with N error(s). Review errors above" -- so
   a script gating on %ERRORLEVEL% treats a good repair as failed, and the operator
   reading "repair failed" reaches for --reinstall, which DROPS the database. That is
   the precise destructive outcome this feature exists to avoid. The CLI now
   distinguishes the expected case, prints the same handoff, and exits Success.

3. HIGH -- "Repair completed with expected errors" was asserted for failures that
   are NOT expected. ExecuteInstallationAsync aborts the whole pass on a critical
   file (01_/02_/03_), so a repair that died on 02_create_tables.sql reinstalled
   nothing -- and the log read "[ERROR] Critical installation file failed. Aborting."
   immediately followed by "[OK] Repair complete... click Upgrade Now". Both surfaces
   now gate the "expected" wording, the handoff, and the exit code on
   Patterns.IsCriticalFile.

4. HIGH -- the repair handoff re-showed DatabaseStatusPanel in MonitoringCredentials,
   which also contains the "Skip, just add server" link -- unreachable there before.
   SkipInstall_Click flips _currentState to Initial, after which Save_Click silently
   saves the INSTALLER credentials instead of the monitoring credentials the user
   just typed. The link is now collapsed in the handoff.

5. GetInstallBlockReason is the pure decision core of both new blocks and had no
   tests. It is now internal (InternalsVisibleTo Dashboard.Tests already existed) and
   pinned by 16 cases, including the silent-downgrade case that produces zero hops and
   zero failures so nothing else catches it. It also now blames the BUILD, not the
   database, when it is the Dashboard's own version that will not parse -- and tells a
   user with an unreadable recorded version how to get unstuck.

6. Cancel is no longer armed outside Installing; CHANGELOG named a field that no
   longer exists and overstated the repair history rule (the Connected_Current
   "Repair" button has no pending migrations to skip, so it does write a row).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ls for sa

Both reviewers again. The two worst were both caused by earlier rounds' fixes.

1. CRITICAL -- the repair handoff silently persisted the INSTALLER account as the
   monitoring credential. Round 4 put a live "Upgrade Now" button in front of
   MonitoringCredentials, a state that used to be terminal. Save_Click gated the
   monitoring-credential read on _currentState == MonitoringCredentials -- and every
   exit from the install that button starts (abort, cancel, fatal, version block)
   transitions away from it, without clearing the fields.

   Golden path, SQL auth: upgrade fails -> tick Repair -> repair succeeds -> handoff
   -> user unticks "Use same credentials" and types the low-privilege monitoring
   login -> clicks Upgrade Now -> the migration fails again -> abort -> _currentState
   is Initial -> Save. The typed login is discarded and the installer account, usually
   sysadmin, is written to Credential Manager as the ongoing monitoring credential.
   Now keyed on what the user actually entered, which cannot drift with state.

2. HIGH -- GetInstallBlockReason returned early for "no database" BEFORE validating
   the app's own version. appVersion is exactly what a fresh install writes to
   installation_history.installer_version, so a Dashboard with an unparseable
   InformationalVersion would poison a brand-new server's ledger at birth -- after
   which both surfaces refuse to touch it ever again, recoverable only by hand-editing
   the row or a destructive reinstall. The check now runs first, and is pinned.

3. CRITICAL (my Round-4 over-correction) -- repairUsable never checked that a pending
   upgrade EXISTS. The whole "these file failures are expected" story rests on there
   being a migration-added column to be missing. Without one, --repair against an
   up-to-date server reported "Installation completed successfully", printed "20
   object(s) could not be compiled because the pending upgrade has not run yet" when
   there was no pending upgrade, and exited 0. Round 4 traded a false negative for a
   worse false positive: the %ERRORLEVEL% operator the fix existed to protect now saw
   SUCCESS with 20 broken procedures. Both apps now gate on installed < target.

4. The CLI's criticalFileFailed guard was provably dead -- a critical file already
   early-returns CriticalScriptFailed long before the summary -- so it made an
   over-broad flag look discriminating. Removed; the early return is the real guard.

5. --repair with no readable installation fell through to a FULL fresh install and
   stamped the target version: a whole new database on a mistyped server, or, where
   the database exists but its history table does not, a target-version stamp that
   strands every hop. Both surfaces now refuse.

6. Save_Click had no InstallInProgress guard, so a connection test still in flight
   when an install starts would fall through to DialogResult/Close() and orphan the
   install. And the title-bar X bypassed Cancel_Click entirely -- ESC was covered, the
   window chrome was not -- leaving the install running with the dialog destroyed. Both
   closed; NormalizeVersion now delegates to TryParseVersionCore rather than keeping a
   second copy of the parser in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Repair state

First round where the previous round's fixes all HELD under verification. Six
findings left; two mattered.

1. HIGH -- cancelling a Connected_Current "Repair" put "Perform clean install
   (drops existing database)" behind a button still labelled [Repair]. The three
   exit handlers did TransitionToState(Initial) and then force-showed
   InstallationPanel -- but Initial never resets InstallUpgradeButton.Content or
   DatabaseStatusText. So after a cancel (or any transient error, no user action
   needed) the dialog read "is up to date... click Repair to reinstall", showed a
   [Repair] button, and newly exposed Advanced Options with the clean-install
   checkbox. Tick, click, database dropped.

   That is the exact invariant the code documents ("InstallationPanel stays
   collapsed on purpose: it holds the clean-install checkbox, which must never sit
   behind a button labelled Repair"). The exit paths now restore the state the
   install was LAUNCHED from, so Connected_Current keeps its panel collapsed and
   surfaces the failure in the panel that is actually visible there.

2. The last stranding door, fixed at the root. GetInstalledVersionAsync returned
   null for TWO different things: no database, and a database whose
   config.installation_history is missing -- which it even LOGS as "old or corrupted
   install" before answering "fresh install". Both apps then run the install scripts
   over the live schema, skip every migration, and stamp the target version SUCCESS.
   Permanently stranded: the precise bug this branch exists to prevent, reachable
   without any failed migration at all.

   Worse, Round 5's new "nothing to repair" refusals RECOMMENDED that path in as many
   words. It now tells the two cases apart by whether the database actually holds
   collect objects (verified against SQL2022: a real install has 62), falling back to
   the same "1.0.0" sentinel the #538 guard uses -- "unknown, attempt every upgrade",
   the safe direction, since every upgrade script is IF NOT EXISTS guarded. An empty
   pre-created database still reads as a clean install, so that path is unchanged
   (VersionDetectionTests.DatabaseExists_NoHistoryTable_ReturnsNull still holds --
   its database has no collect tables).

3. The two most dangerous decisions in the branch were hand-copied into both apps and
   pinned by nothing, while the CHANGELOG stated them as a contract. Extracted to
   Installer.Core/RepairOutcome and pinned by 13 cases. Getting it wrong is dangerous
   in BOTH directions: calling a good repair a failure sends the operator to a
   destructive reinstall; calling a broken one a success ships uncompiled procedures
   past a CI gate.

4. DetectDatabaseStatusAsync committed _coreServerInfo/_installedVersion to the shared
   fields BEFORE its InstallInProgress guards, so a stale detection could clobber the
   version a running install was reasoning about. Safe today only by statement ordering
   -- one inserted await and it is THE INVARIANT again. Now lands in a local, guards,
   then commits.

5. The CLI never validated its OWN version, so an unparseable build version skipped the
   downgrade guard and a fresh install would write it straight to installer_version,
   bricking a brand-new server. The Dashboard blocks on this; now both do.

6. The "nothing to repair" refusal re-enabled Save in the one state that deliberately
   disables it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round 6's fixes all held under verification, and the invariant survived. Seven more
findings; three mattered a lot.

1. CRITICAL -- RestoreAfterInstall left the form permanently disabled on the exact
   path this PR exists to add. TransitionToState(Installing) calls
   SetFormEnabled(false); Round 6 replaced the exit paths' TransitionToState(Initial)
   (which re-enables) with a restore to the pre-install state -- and
   Connected_NeedsUpgrade and Connected_Current never call SetFormEnabled(true). I
   had added it to Connected_NoDatabase and Connected_StatusUnknown and missed the
   only two states the restore actually lands in.

   So EVERY upgrade abort left the server box, all credential fields, Test Connection
   and Check for Updates dead for the life of the dialog -- and if the failure was a
   login problem, the field you need to fix is the one you cannot touch.

2. HIGH -- ResetScheduleCheckBox was never cleared anywhere, and it TRUNCATEs
   config.collection_schedule. Same bug class Round 6 closed for the clean-install
   box, on the third checkbox behind the same collapsed panel: tick it for SERVER-A,
   retype the box to SERVER-B, and it wipes B's tuned intervals from an invisible tick
   with no consent. Cleared with the other mode flags.

3. HIGH -- RepairOutcome trusted the "1.0.0" UNKNOWN sentinel as a real version. It is
   GetInstalledVersionAsync's guess for "installed, but I cannot read the version", and
   it sorts below every real version -- so "is an upgrade pending?" answered yes
   unconditionally, and every REAL repair failure on such a server was reported as
   expected and exited 0. A schema-current 3.1.0 server whose history rows are all
   FAILED, with four genuinely broken procedures, would have passed a %ERRORLEVEL% gate
   while telling the operator to ignore the errors. The sentinel is now a named constant
   and explicitly rejected.

4. My InstallBlockReasonTests could never fail a PR: Dashboard.Tests is not wired into
   CI at all (749 tests that never run; #963 is what fixes that). Tests that cannot fail
   a PR are decoration -- so the DECISION moved to Installer.Core/InstallGuard, which IS
   CI-wired, and the CLI's three hand-copied guards now share it. Only the wording is
   per-surface now.

5. VersionDetectionTests hand-replicates GetInstalledVersionAsync's SQL, and Round 6
   changed the method without touching the replica -- so the test file named for this
   exact bug class was certifying the PRE-change behavior while the riskiest line in the
   diff had zero coverage. Replica updated; both shapes now pinned (empty database ->
   null, ledger-lost database -> sentinel). Compiled, not run: these hit a real server.

6. The CLI's self-version check sat inside the upgrade branch, so --reinstall -- a FRESH
   install, the exact case its own comment cites -- skipped it. Hoisted.

7. The Connected_Current button labelled "Repair" does NOT take the repair path (the
   checkbox is unreachable there, so it writes a history row). Renamed "Reinstall
   Objects"; "Repair" now names one thing. And RestoreAfterInstall no longer hides the
   install log -- it re-shows the panel with Advanced Options DISABLED, since WPF
   propagates IsEnabled to children, so the clean-install box stays untickable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ered two questions

R8a could not break the invariant and confirmed six of seven Round-7 fixes hold.
Both reviewers then found the same two real defects, and both are mine.

1. CRITICAL -- I reopened the clean-install drop bug by fixing something else.
   Round 6 rerouted the cancel/fatal/abort handlers away from
   TransitionToState(Initial) to RestoreAfterInstall(_preInstallState). Nothing calls
   TransitionToState(Initial) any more -- so the mode-flag clearing living in that
   case became DEAD CODE, and RestoreAfterInstall never cleared them. It re-enables
   the form on top of a still-ticked "Perform clean install (drops existing
   database)": cancel a run, retype the server box, click again, and it DROPS a
   database that never consented. Verbatim the scenario the now-dead comment was
   written to prevent, and the third time this bug class has appeared. Cleared in
   RestoreAfterInstall, which is now the single exit path.

2. CRITICAL -- one boolean was answering two different questions, and they disagree
   for the unknown sentinel. "May I excuse these file failures?" must answer NO for an
   unreadable version (we cannot certify a success we cannot explain). "Is there an
   upgrade to run next?" must answer YES for the same input -- the version is unknown,
   so every hop may be pending. Reusing FailuresAreExpected for both printed

       Repair complete. This server is still at v1.0.0 and no version was recorded.
       This server was already at the current version, so there is no upgrade to apply.

   two lines apart, for a server with eleven migrations waiting -- and withheld the
   "Upgrade Now" handoff that would have applied them, leaving exactly the
   half-migrated database this feature exists to escape. And this branch INCREASED the
   sentinel's reachability (Round 6's ledger-lost fallback), then broke the messaging
   for the state it added. Split into FailuresAreExpected / HasPendingUpgrade /
   IsVersionUnknown, pinned by a test that asserts the two questions disagree.

3. "Reinstall Objects" had no nothing-to-reinstall refusal. That state's button is new
   (dev collapsed it), the server box stays editable, and the install-time re-read runs
   against whatever is in it now -- so retyping to a bare instance made the button
   perform a FULL fresh install: new database, jobs, XE sessions, from a button that
   promised to reinstall existing objects. The Repair checkbox got that refusal; the
   button, whose label makes the same promise, never did.

4. The CLI's InstallGuard switch was non-exhaustive (correct only because a duplicate
   check runs earlier -- remove that and it falls through silently). GetInstallBlockReason
   was still internal for a test that no longer exists. Two CHANGELOG claims were false:
   it named a deleted test file, and claimed both surfaces block a newer database
   outright, when the CLI's destructive --reinstall is the deliberate escape hatch.

Known and stated plainly: the ledger-lost probe's only test lives in VersionDetectionTests,
which CI excludes because it hits a real server. The SQL is verified empirically against
SQL2022; the test cannot fail a PR. That limitation is the test project's, not this branch's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All four Round-8 fixes hold; R9a could not break the invariant. Ten findings, and
the top three are the same root cause: the dialog restored a state computed for a
DIFFERENT server.

1. CRITICAL -- the restore rendered a stale verdict over a fresh version.
   _preInstallState is captured BEFORE the install-time re-read, but the states
   render from _installedVersion, which the re-read overwrites. The server box stays
   editable, so those two describe different servers whenever it was retyped.

   Test Connection on an up-to-date server -> Connected_Current ("Reinstall
   Objects"). Retype the box to one four migrations behind. Click. The re-read
   correctly finds v2.5.0 and the upgrade runs; a hop fails; the abort fires ->
   RestoreAfterInstall -> Connected_Current renders "PerformanceMonitor v2.5.0 is up
   to date." over a HALF-UPGRADED database, with Save enabled. The user reads "up to
   date" and clicks Save & Connect.

   The restore state is now DERIVED from what the re-read actually found
   (DeriveConnectedState), which detection now shares. That also keeps
   _preInstallState out of InstallComplete/MonitoringCredentials -- reachable through
   the repair handoff -- which RestoreAfterInstall would otherwise re-enter with
   Advanced Options unfrozen.

2. CRITICAL -- the "nothing to reinstall" refusal guarded "Reinstall Objects" but not
   "Upgrade Now", which makes the identical promise. Connected_NeedsUpgrade + retype
   to a bare instance + click: _installedVersion is null, so the upgrade block is
   skipped and a FULL fresh install lands on a server the user never consented to --
   new database, Agent jobs, XE sessions, plus a SUCCESS row -- from a button that
   said "Upgrade Now". Both button arms are covered now.

3. HIGH -- that refusal was also the ONE exit path that did not clear
   ResetScheduleCheckBox. Tick Repair + "Reset collection schedule" on PROD-A, retype
   to a bare server, get refused (Repair cleared, ResetSchedule survives), retype back
   to PROD-A, click Install Now -> TRUNCATE TABLE config.collection_schedule. PROD-A's
   tuned intervals, gone. Fourth appearance of the sticky-checkbox class; all five
   exits now clear all three.

4. Both InstallGuard switches defaulted to "safe to install" -- a new InstallBlock
   member would silently become an allow, which is the single failure mode the guard
   exists to prevent. Both now refuse instead.

5. The CLI named a FailuresAreExpected result "repairHasPendingUpgrade" -- the exact
   one-name-two-questions confusion Round 8 split the API to kill, reintroduced as a
   variable name. Renamed. Two comments cited causes that later rounds had already
   fixed, and the block-reason doc still said "three cases" when there are four.

CHANGELOG now states plainly that the ledger-lost probe's test cannot fail a PR
(VersionDetectionTests needs a real SQL Server and CI excludes it); the query was
verified by hand against SQL Server 2022.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…entinel out of the version space

First fresh-eyes reviewer to come back with no critical: "I could not find a
correctness bug in the shipping decision paths... none of these is the 17th
stranding bug." Two of its five findings were already fixed by Round 9. Three real
ones remain.

1. The single most consequential branch in the installer -- "database exists, no
   history table": fresh install, or run every migration? -- had NO test that could
   fail a PR. Its only cover was VersionDetectionTests, which CI excludes because it
   needs a real SQL Server, AND which hand-copied the SQL rather than calling
   production. That copy had already drifted (production does USE PerformanceMonitor;
   the replica did not), so the exact failure mode throwOnError exists for -- an
   OFFLINE/RESTORING database throwing rather than reading as "absent" -- was never
   exercised.

   The DECISION is now a pure function, InstalledVersionClassifier, taking the facts
   (database exists / history table exists / collect table count / latest SUCCESS row)
   and returning the version to act on. Pinned by 7 cases in Installer.Tests, which CI
   actually runs. The SQL stays where it must, but it now only gathers facts -- and the
   DB test calls the same classifier, so the decision can no longer drift out from under
   the tests.

2. The unknown sentinel was "1.0.0" -- a real shipped tag. And the "your recorded
   version is unreadable" message tells operators to correct the SUCCESS row by hand,
   where "1.0.0" is precisely what someone would type to force a full re-run. They would
   then be told their version could not be read, and --repair would exit 4 on a server
   with eleven pending hops. Moved to "0.0.0": no release is 0.0.0, it still sorts below
   every real version so every hop is still offered, and it is never persisted (repair
   writes no history row), so this is purely internal.

3. The Dashboard's newer-than-this-build block is a DEAD END: it blocks clean install
   too (Connected_StatusUnknown collapses the panel the checkbox lives in), and its
   message only said "Update this Dashboard first" -- useless if the database was written
   by an unreleased build. It now carries the whole escape route, including the CLI's
   --reinstall. The CHANGELOG claimed the message already pointed there, and stated the
   --repair exit-code contract as a binary when the code deliberately has three cases.
   Both corrected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mer again

Both reviewers found the same critical, and the state-machine lens gave the verdict
I asked it for: NOT sound, needs a structural fix.

"Every critical from rounds 6-9 is one bug: the server box stays editable while
_currentState, _installedVersion, _installBlockedReason, _coreServerInfo,
_preInstallState and five checkboxes all describe a server that may no longer be the
one in the box. That is not bad luck; it is what happens when an invariant is enforced
at N call sites instead of one."

That is exactly right, and it is why this kept reopening. So this round fixes the
class, not the instances.

1. CRITICAL -- the repair handoff arms a live "Upgrade Now" button while the state is
   InstallComplete/MonitoringCredentials. The "nothing to reinstall" guard listed only
   Connected_NeedsUpgrade and Connected_Current, so from the handoff: retype the server
   box to a bare instance, click the big accent button in front of you, and you get a
   FULL FRESH INSTALL -- database, tables, procs, SQL Agent jobs, XE sessions, community
   dependencies -- plus a SUCCESS row, on a production server that never consented.

   Root cause: one field, _preInstallState, was answering two different questions --
   "what did the button PROMISE?" and "where should a failure RESTORE to?" -- and was
   consumed for the promise 50 lines BEFORE being re-derived for the restore. Split into
   _launchState (the promise, fixed at click time, via PromisesExistingInstall which now
   covers all four states that can arm a non-"Install Now" button) and _preInstallState
   (the restore target, from the facts).

2. HIGH -- _preInstallState is also consumed by the cancel/fatal paths INSIDE that
   window, where this server's version has not been read yet and the fields still
   describe the last one. Cancel during the re-read and the dialog restored
   "PerformanceMonitor v3.1.0 is up to date." under the NEW server's name, Save enabled.
   It is now null until the facts are in, and RestoreAfterInstall refuses to render a
   verdict it never obtained.

3. THE STRUCTURAL FIX -- the verdict is now STAMPED with the server it was read from,
   and TransitionToState refuses to render any Connected_* verdict whose stamp no longer
   matches the box, falling back to "the server name changed; click Test Connection".
   One check, one place. A stale verdict is now unrenderable no matter which consumer
   forgets to re-check -- which is the failure mode that produced three separate
   criticals across nine rounds.

   Detection also takes an epoch, so two interleaved runs cannot publish the first
   server's verdict under the second's name; and the install-time re-read now refreshes
   _coreServerInfo too, so the summary report cannot name this server with the previous
   one's SQL version and edition.

4. GetAppVersion's version-less fallback was "0.0.0" -- which Round 9b had just made the
   unknown sentinel. A build with no version would have written a parseable "0.0.0"
   SUCCESS row that every later read takes as "unknown, replay every migration". It now
   returns "Unknown", which does not parse, so InstallGuard blocks it -- matching the CLI.

5. The sentinel is no longer rendered as a fact ("PerformanceMonitor v0.0.0 is
   installed") on either surface, and SkipInstall_Click -- the one exit that bypasses
   TransitionToState -- now clears the destructive checkboxes like every other exit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R11b could not break the decision core or the ledger invariant -- it attacked both
hard and said so. But it found the Round-10 structural guard was self-satisfying, and
it was right.

1. CRITICAL -- the guard could never fail. RecordVerdictFor read
   ServerNameTextBox.Text AFTER the awaits, so it stamped the verdict with whatever
   was in the box NOW, not the server we had actually read. VerdictMatchesServerBox
   then compared the box to itself. Retype the box during a detect -- which is
   seconds, and unbounded for Entra interactive, with no TextChanged handler and
   nothing disabling the box -- and the dialog rendered SERVER-A's verdict under
   SERVER-B's name: "PerformanceMonitor v3.1.0 is up to date." with Save enabled, over
   a bare instance. The exact lie the guard's own comment says it stops.

   The stamp is now captured WITH the connection string built from it, before any
   await. And a TextChanged handler makes a retype supersede an in-flight detection
   (it publishes nothing) and demotes a now-stale on-screen verdict through the guard.

2. HIGH -- the unsupported-SQL-version branch wrote the panels by hand and never
   called TransitionToState, so the guard had no hook there at all: even a correct
   stamp would not have protected it, and it left _currentState pointing at the
   previous server's state. Routed through BlockInstall.

3. HIGH -- the install-time re-read fetched _coreServerInfo precisely BECAUSE "after a
   retarget it describes a different box", and then never checked IsSupportedVersion.
   The 2016+ gate was detect-time only, so retargeting to an older server and clicking
   through ran 2016+ syntax onto SQL 2014 -- a junk database and a FAILED history row
   on a server the gate exists to refuse. Re-reading the fact and not using it was the
   whole hole. Now gated.

4. MEDIUM -- the verdict was stamped with WHO, not WHEN: a clean install DROPS the
   database, so cancelling mid-run restored "PerformanceMonitor v3.0.0 is installed"
   over a server whose database had just been dropped, Save enabled. The verdict is
   now forgotten before a clean install runs.

5. Three files had picked up a UTF-8 BOM (dev has none) -- an editor artifact in files
   the release tooling reads. Stripped. The CLI also promised "the pending upgrade
   still needs to run afterwards" for every --repair, including servers already
   current; now gated on there actually being one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Superseded() tests InstallInProgress, but that flag is only read when the
continuation RESUMES. Nothing bumped the epoch when an install STARTED, so a
detection that spanned the whole run woke up afterwards, found the install
finished, and published its pre-install verdict over the result: "Upgrade Now"
returning on a server that had just been upgraded (wiping the SQL-auth
monitoring-credentials panel with it), or coming back live while
_installBlockedReason was still set, so every click was a "Blocked" dialog. The
epoch is the only half of that test that cannot un-fire. Bump it on install start.

_installBlockedReason was cleared in the detection's prologue, before the await,
while the publish it belongs to is guarded. That makes the clear unconditional
and the publish conditional, which is what let an install that blocked itself
mid-flight get retroactively un-blocked by a detection that then declined to
publish anything. Committed with the verdict now, not ahead of it.

The retarget guard only covered the three Connected_* verdict states, but
PromisesExistingInstall already knew that the repair handoff arms a live
"Upgrade Now" in InstallComplete/MonitoringCredentials too -- the two predicates
disagreed about the same question. Retyping the server box there left SQL-A's
version claim and a live upgrade button sitting under SQL-B's name: verbatim the
lie the guard says it stops. Withdraw the handoff on a retarget.

_serverVersion was written nowhere but Test Connection, so retyping the box and
clicking Check for Updates rendered the new server's name beside the old
server's version. Committed alongside _coreServerInfo, which was already being
re-read two lines away.

GetAppVersion's "Unknown" fallback sat one branch too late: an assembly with no
AssemblyVersion reports 0.0.0.0, so the branch above it returned "0.0.0" -- which
IS UnknownVersionSentinel, the exact collision the comment claims to avoid. A
version-less build must land on something unparseable so InstallGuard blocks it.
…dialog-upgrade-abort

# Conflicts:
#	CHANGELOG.md
Round 12's handoff withdrawal was one-way. It hid the version claim and the
"Upgrade Now" button when the box was retargeted, but nothing restored them when
the name came back -- so typing one character into the server box and deleting it
destroyed the only button that applies the pending upgrade, while the install log
still said to click it. Repair writes no history row, so the upgrade was still
offered on the next dialog open; nothing on screen said so. The handoff is now
re-rendered rather than painted once: idempotent, reversible, and it explains
itself while withdrawn instead of silently vanishing.

Save_Click carried the same un-firing guard this branch just fixed elsewhere:
InstallInProgress alone, read after an await, is false again once the install
FINISHES. An install started and completed during Save's connection test fell
straight through to DialogResult/Close() -- destroying the dialog as the install
landed, so a SQL-auth user never saw the monitoring-credentials panel and the
INSTALLER's account (routinely sa) was persisted as the ongoing monitoring login.
Guarded with the epoch, which cannot un-fire. The same fix covers a retype during
the test, which would otherwise persist the new name on the strength of a
connection proven against the old one.

skipConnectionTest keyed on _currentState alone, so retyping the box after an
install skipped the connection test entirely and saved an un-contacted server.
The stamp is what makes "we just installed, so this connection is proven" true,
so it now tests the stamp.

BlockInstall is an exit from a RUN, but it left the clean-install / repair /
reset-schedule ticks set -- unreachable today only because the state it lands in
happens to collapse the panel holding them. That is a rendering accident standing
in for a consent check, and it is the same shape as the drop-the-database bug
that has reopened more than any other here. It clears them itself now.

The connection header was built from the LIVE box plus the PREVIOUS server's
version, and rendered in the one connected state the stamp guard does not cover
-- the state a demotion LANDS in. Mid-keystroke it read "Connected to S
(Microsoft SQL Server 2019 ...)": a connection never made, to a half-typed name.

The CLI had the 0.0.0.0 hole the Dashboard just closed: GetName().Version is
never null, so its "?? Unknown" could not fire, and a version-less build would
write 0.0.0.0 into the ledger -- which reads back as the unknown sentinel and
replays every migration forever. Both surfaces write to the same ledger, so the
guard had to exist on both. The comment claiming CLI parity was false; now it
isn't. Also stopped printing the sentinel as a fact ("v0.0.0") at the three sites
that still did -- the CLI contradicted itself inside a single run.

Found by two adversarial reviewers on the Round-12 diff. Neither could break the
ledger invariant, reach a destructive action without consent, or find a stuck UI.
_handoffStatusText was written only under repairCompleted and never cleared. So:
repair a server, click the "Upgrade Now" the handoff gives you, let the upgrade
SUCCEED -- and the field still held "PerformanceMonitor is still at v3.0.0, the
pending upgrade has not been applied". Any later keystroke in the server box
re-rendered exactly that, with a live Upgrade Now button, on a server that had
just been upgraded to 3.1.0.

Introduced by the very commit that made the handoff re-renderable, and it is the
same bug class this whole branch exists to kill: a fact outliving the run that
produced it. Cleared at run start; the completion block re-sets it only when THIS
run was a repair that left an upgrade pending.

Found by self-review, not by a reviewer.
…m at the choke point

The clean-install / repair / reset-schedule ticks authorize DROP DATABASE,
skipping every migration, and TRUNCATE config.collection_schedule -- against the
server that was on screen when they were ticked. Keeping that true has been
patched into RestoreAfterInstall, then the detection prologue, then InstallComplete,
then BlockInstall. Round 13's reviewer found the fourth one already had a sibling
that bypassed it: the stale-name demotion rewrites the state IN PLACE and never
calls BlockInstall, so a ticked clean install still survived a retarget there --
unreachable today only because the state it lands in happens to collapse the panel
holding the checkboxes. That is a rendering accident standing in for a consent
check, on the code path that drops a database, and it is the bug that has reopened
more times than any other in this file.

Cleared in TransitionToState instead -- the one choke point every state change
passes through -- for every state except Installing, which is the state that is
about to READ them. Verified the checkbox handlers only enforce mutual exclusion
and never transition, so ticking cannot untick itself, and that the ticks are read
after Installing is entered.

Also from that round:

An ordinary install/upgrade/reinstall left "Installation completed successfully!"
and its log on screen after the box was retyped. Only the repair handoff was
withdrawn -- because that was the path the previous round happened to be looking at,
while the common case had no withdrawal at all. A user could retarget after an
install and click Save believing PerformanceMonitor had been installed on the server
now in the box. Withdrawn and restored together now, with the reason stated once.

A clean install forgets the verdict on the way in (it is about to drop the database,
so a cancel must not restore a verdict describing it) and never re-stamped it on
success -- so a completed clean install read as un-addressed: Save re-ran the
connection test, which on Entra means a second interactive MFA prompt, and the first
keystroke would have withdrawn a success message that was true. Re-stamped with the
server the run actually wrote to.

The stale-name demotion is now reversible. The verdict was never wrong, only
mis-addressed, so typing the name back restores it -- previously one keystroke and a
backspace left "Upgrade Now" gone for good, under a notice that the server name had
changed, which by then was no longer true. Tracked separately from a REAL block
(unsupported version, unreadable version, newer-than-binary), which still cannot be
typed away.

CLI: a repair with a pending upgrade exits 0 by contract, but it was printing
"Installation completed successfully! / All collector stored procedures" over N
procedures that demonstrably failed to compile -- contradicted by its own next
paragraph. The exit code is the contract for scripts; the banner is for the human.

CHANGELOG said the unknown sentinel was 1.0.0. It is 0.0.0 -- and the whole reason
it was moved off 1.0.0 is that 1.0.0 is a real shipped version.
Connected_StatusUnknown is where a stale-verdict demotion LANDS, so the guard in
TransitionToState -- which only ever demotes INTO it -- can never cover what is
already sitting there. It is also where a REAL block sits, and a real block is a
fact about one specific server: detect an unsupported SQL 2014 instance, retype the
box to a 2022 one, and the panel still read "SQL Server 2014 is not supported" --
about a server the dialog had never looked at. Verbatim the lie the guard exists to
stop, in the one state the guard structurally cannot reach.

Two things were missing. The verdict was stamped only on the success path, so a
block was an unattributed fact with nothing to compare the box against; it is now
stamped as soon as the connection facts land, which is the point from which every
statement is ABOUT that server. And TextChanged now handles the landing pad: a real
block loses the previous server's specifics on a retarget, while a demotion keeps
its message (which already says exactly this) and its undo.

SkipInstall_Click writes _currentState directly rather than transitioning, and
bumped no epoch -- so a detection still in flight landed afterwards, published its
verdict, and put the install panel and install button straight back on screen,
silently undoing the skip the user had just chosen.

Save was re-enterable, and the second one CRASHES. RunConnectionTestAsync disables
Save for its duration, but Test Connection stays live and its finally re-enables the
form wholesale -- Save included. Click Save, click Test Connection, click Save again:
both complete, the first closes the window, the second sets DialogResult on a CLOSED
window. That is an InvalidOperationException on an async void handler: unhandled,
and it takes the app down. Pre-existing; the epoch guard happened to close every
variant where a detect or install intervenes, but not this one.

And the comment I wrote last round claiming the abandoned save "is never silent"
was false. From Initial -- a fresh dialog, the most common way to reach Save -- a
retype renders nothing, so the guard swallowed the click: fix a typo during the
10-second connection test and Save just... does nothing, while the user believes the
server was added. It says so now.

The stale-server notice was three hand-copied strings that had already drifted
("re-check" vs "check"). One const.
…kes it AFFIRM

CRITICAL, and it was Round 15's own fix. Stamping the verdict as soon as the
connection facts landed -- so that a BLOCK would be an attributed fact rather than
an anonymous one -- put the stamp BEFORE the guard that can still abandon the
publish. The comment waved it through: "re-stamped on the success path with the
same value; harmless." It is only re-stamped if that path is REACHED.

Retype the server box during a detection and the version read is superseded, so it
returns having already moved _verdictServerName and _serverVersion to the NEW server
while _installedVersion and _currentState still describe the OLD one.
VerdictMatchesServerBox() then answers TRUE. The guard did not miss the lie; it
CONFIRMED it -- "PerformanceMonitor v3.1.0 is up to date", Save enabled, over a
server with no PerformanceMonitor database at all. Fifteen rounds of hardening that
guard, and the way in was to make it say yes.

Stale-but-consistent facts the guard catches every time. Half-replaced facts are the
one thing it structurally cannot catch, because the stamp IS the thing every consumer
tests before trusting the other two.

So the three now move as one, through PublishProbedServer, committed only where a
verdict about that server is actually published -- never in the prologue, never
before a guard that can still abandon it. That is the rule this method already stated
at the top of the probe ("Land in a LOCAL, guard, and only then commit to the shared
field") and that Round 15 broke. TestConnection_Click's lone _serverVersion write --
the last place that set one of the three on its own, post-await, with no stamp beside
it -- is gone with it; the detection it calls publishes all three itself.

Also from that round:

The outer catch in DetectDatabaseStatusAsync was the one continuation with no
Superseded() check, so a stale detection whose connection test threw would paint its
error over a running install's screen.

RunConnectionTestAsync blanked StatusText in its finally -- but after a retarget
withdraws the post-run panels, that notice is the ONLY thing left on screen. A failed
Test Connection then left a dialog with no panels and no explanation. It borrows the
label now, and gives it back.

RestoreAfterInstall's comment claimed it was "the only exit path from a run", which
was wrong twice over and pointed the next reader at the wrong mechanism on the code
path that runs DROP DATABASE. The choke point in TransitionToState is the guarantee;
these three lines are belt and braces, and now say so.
…dialog

Round 17 taught StatusText to be borrowed and given back, so a retarget's "this may
describe a different server" notice would survive a failed connection test. It read
the label BEFORE the awaits and wrote it back AFTER them, with no epoch guard --
which is Shape 1, the exact defect this branch has spent nine rounds killing, freshly
introduced by the fix for the round before. Retype away, click Test Connection, retype
BACK during the connect: the screen correctly restores itself, then the failed test's
finally pastes the stale notice over the top of it. The label is only ours to give
back if nothing bumped the epoch while we were away.

_installedVersion was the FOURTH fact. PublishProbedServer committed three atomically
and left it behind at both block sites -- where we either never read the version
(unsupported server) or could not (the read threw) -- so a block described the
PREVIOUS server's database. Sealed today only because a block lands in a state that
happens to collapse both buttons: a rendering accident standing in for an invariant,
which is precisely what this file keeps getting caught by. It moves with the other
three now, and a block publishes null, because null is the truth there.

Close the dialog during a save's connection test -- ESC, Cancel, the X -- and the
close lands first, then the test completes and SaveAsync walks on into DialogResult on
a window that is gone, which throws. The re-entrancy flag did not cover it: that is
one Save, not two. And ESC is far easier to hit than the three precise clicks the flag
does cover.

Correction: the comment I wrote last round said that throw "takes the app down". It
does not -- App.OnDispatcherUnhandledException handles it (App.xaml.cs:173). It is an
alarming error box and a silently unsaved server. Overstated, now accurate.

SkipInstall_Click was the only epoch-bumper that didn't clear StatusText, so "Checking
database status..." sat there for the rest of the dialog's life announcing a check
nobody was running.

RunConnectionTestAsync's ServerVersion return is gone -- Round 17 made both callers
discard it. It used to be assigned straight to _serverVersion, which is one of the
four facts that may only be committed as a set. The @@Version round-trip stays as a
liveness check; its result is discarded explicitly.

And one in the CI-pinned core, which is the only part of this that a test can hold:
InstalledVersionClassifier treated a BLANK installer_version as an answer. The column
is NOT NULL, so a row hand-edited to '' -- and the "unreadable version" message we
show an operator invites exactly that edit -- came back as "" rather than null, and ""
is not a version: FilterUpgrades resolves it to ZERO hops, which reads as "already
current" and strands every migration. Blank is the absence of an answer. Two tests.
… was the signal

CRITICAL, and I introduced it. _dialogClosed guarded DialogResult -- 47 lines AFTER
the assignments it needed to guard.

In edit mode ServerConnection is not a copy. It is the same object ServerManager holds
in its list: MainWindow hands us item.Server directly. So the assignments in SaveAsync
mutate the live server IN PLACE. Edit PROD01, retype the box to TEST99, click Save, and
press ESC during the 10-second connection test: the dialog closes, ShowDialog returns
false, the caller never calls UpdateServer, and the user is entirely satisfied nothing
was saved. Then the test lands, SaveAsync walks past InstallInProgress and the epoch,
renames the user's monitored server, and hits _dialogClosed -- and returns. Silently.
It reaches disk on its own, the next time UpdateLastConnected fires for ANY server.
PROD01 becomes TEST99, keeps PROD01's id and credential, and stops being monitored.

The part I got wrong is worth naming. Before Round 18, assigning DialogResult to a
closed window THREW, and the app surfaced it. The corruption was already there -- what
my fix removed was the only evidence of it. A silent bug is not a fixed bug, and
"stopped the exception" is not the same as "stopped the damage".

The guard belongs where every other guard in this branch already sits: before the facts
are committed, not after. One check covers it -- RunConnectionTestAsync is the only
await in SaveAsync -- and it also kills the orphaned "Do you still want to save this
connection?" box that was popping up with no dialog left behind it.

Two smaller ones from the same round:

The abandoned-save message blamed "the server details changed" for all four epoch
bumpers. Check for Updates and "Skip, just add server" bump it too, so the message
accused the user of a change they never made. Worded for what actually happened.

TestConnection_Click's failure box read the server box AFTER the await -- the last
read-the-box-after-the-await in the file. Retype during the connect and it said "Could
not connect to A" about a server that is fine, for a failure that belonged to B.
MAJOR, and mine, from Round 11. RestoreAfterInstall's "we have no verdict to
restore" branch returns BEFORE the line that re-shows InstallationPanel, and it
gets there through BlockInstall, which lands in a state that COLLAPSES that panel.
So the install log -- the only place the actual SQL error text lives -- disappears.

And _preInstallState == null overwhelmingly means CLEAN INSTALL, because that is
where I null it on purpose. CleanInstallAsync runs before the file loop, so any
cancel or failure after that point arrives here with the database ALREADY DROPPED.
The user gets one exception message, no log, and no database. The branch eleven
lines below has said "show the log either way -- it is the only place the actual
SQL error text lives" since it was written. This one just didn't.

The reason it gave was false there too: "this server's installed version was not
read." It WAS read -- we discarded it on purpose, because we were about to drop the
database out from under it. Those are two different failures and they need two
different sentences.

Also from that round:

Round 19 swept the orphaned modals out of SaveAsync and left the identical shape in
its siblings. Close the dialog during a Test Connection and its failure box still
pops with no dialog behind it, telling the user to "Click Test to try again" on a
window that no longer exists. _dialogClosed now lives inside Superseded() as well,
because "the dialog is gone" is just another reason a detection's answer is no longer
wanted -- and Superseded() is the one question every publish point already asks, so
one line covers all of them instead of a guard per caller that the next caller forgets.

Two connection tests could overlap: Test Connection stayed live during a Save's test.
The second one then borrowed the FIRST one's "Testing connection..." label and
faithfully restored it at the end, leaving that message on screen forever with nothing
running behind it. (It is also what re-armed Save mid-save and gave the double
DialogResult its way in.) Both entry points are disabled for the duration now.

And the install path was still writing three of the four coupled facts separately
across an await, never writing the fourth. Not exploitable -- the box is frozen and
every other handler bails on InstallInProgress -- but "not exploitable because
something else happens to be disabled" is enablement standing in for an invariant, and
that substitution is precisely what this file keeps getting caught by. It publishes
them as a set now, so PublishProbedServer really is the sole writer it claims to be.
Two user-visible bugs found in the last review rounds that the entry set did not
cover: a save abandoned with ESC still renamed the live server object in place
(the dialog edits the same instance ServerManager holds, not a copy) and it
reached disk on its own; and a cancelled or failed CLEAN install hid the install
log -- on the one path where DROP DATABASE has already run and the log is the
only place the SQL error text lives.
…leaned on it

CRITICAL. Round 19 put the _dialogClosed guard after the await and reasoned that
nothing could re-enter before the commit. MessageBox.Show pumps a nested Win32
message loop, and the "Do you still want to save this connection?" box sits between
that guard and the assignments -- which, in edit mode, rename the user's live
monitored server IN PLACE, because ServerConnection is the object ServerManager
holds, not a copy.

Its owner is whatever GetActiveWindow() returns, and that is NULL when the calling
thread does not own the foreground window -- so the box does not necessarily disable
the dialog behind it. This file passed an owner to exactly ZERO of its eleven
MessageBox calls while four other windows in this app pass one. All eleven now do.

But the owner is the smaller half. The real error was mine: a previous reviewer told
me the modal disables the dialog, and I wrote that into the comment and let the guard
sit where it was. That is "unreachable because something else happens to be disabled"
-- the exact substitution I have condemned in this file five times. Do not reason
about re-entrancy windows. Guard where the damage is: immediately before the commit,
with nothing between.

MAJOR -- and it can leave an install that cannot be cancelled by anything, including
closing the dialog. The "Nothing to Reinstall" refusal called TransitionToState BEFORE
its MessageBox: the transition re-enables the form, puts a live "Install Now" back on
screen and clears Installing, so while that box pumped, the InstallInProgress backstop
was already false. Click Install Now and a SECOND run starts with its own
CancellationTokenSource -- then the first run unwinds into a finally that disposed and
nulled _installCts unconditionally, i.e. the second run's token. CancelInstallOnClose
is the thing that stops an install when the dialog closes, and it would be cancelling
null. Modal first, while the form is still frozen; and the finally now clears the field
only if it still owns the token it created.

Also: only one server probe at a time (a detection left Test/Save armed, so a
connection test could start on top of one, borrow ITS "Checking database status..."
label, and restore it forever over a settled screen). A fresh detection now clears the
previous run's install log, which was reappearing under a different server's verdict --
PROD01's log and "Installation completed successfully!" sitting under "No
PerformanceMonitor database found". The not-connected branch no longer returns
silently, which made "Click Test Connection" a dead end that did literally nothing.
The summary report names installTimeServerName, the last read-the-box-after-the-await
in the file. And a real block's reason survives a retype-and-retype-back, instead of
being permanently replaced by "the server name changed" -- which also stops the FIRST
keystroke from wiping the one sentence that tells a user their database was dropped.
A block is a fact about a specific server, and the guard attributes it by the
stamp. That branch deliberately refuses to move the stamp -- correctly, because it
established no facts -- so BlockInstall there would have left the block attributed
to the PREVIOUS server, and the next keystroke would have restated it as 'the
server name changed', which is not what happened.

And the only way to attribute it properly would be to stamp without the facts to
go with it, which is precisely the half-replaced state that made the guard AFFIRM
a lie. Fixing the dead-end must not cost the invariant. It is a plain report of
what failed; the install stays blocked by whatever already blocked it.
…ed one

MAJOR, and it is in Installer.Core, which is why eleven rounds of staring at the
dialog never found it. CleanInstallAsync drops the three Agent jobs, both XE
sessions, and then the DATABASE -- SET SINGLE_USER WITH ROLLBACK IMMEDIATE, DROP
DATABASE -- all before a single install file runs. That is the most destructive and
least recoverable window in the installer, and it was the one window with no
cancellation check in front of it: the first ThrowIfCancellationRequested sits AFTER
the clean install.

A cancelled SqlCommand faults with SqlException, not OperationCanceledException, so
the clean-install catch swallowed the cancel as an ordinary failure and RETURNED
NORMALLY. The Dashboard's cancel path never ran. Instead the user was told
"Installation completed with 1 error(s)" over a database that may already be gone;
the dialog re-stamped the verdict it had deliberately discarded for safety; and
because Save skips the connection test once that verdict is stamped, the server could
then be saved without ever being reconnected to. Guarded, and surfaced as a cancel.

Pinned with tests, because unlike everything in the dialog this lives where a test
can hold it -- and they FAIL against the old code, which I checked rather than
assumed.

The re-stamp was never gated on success at all. Its comment asserted "but the run
SUCCEEDED" while the code checked nothing, so a clean install that FAILED restored
the verdict it had thrown away. It is now gated -- and it is a PUBLISH, not a lone
stamp: moving the stamp alone put it back while _installedVersion still held the
PRE-DROP version, so the guard would have gone true over a half-replaced fact set,
which is the one shape it structurally cannot catch. A failed clean install now goes
where a cancelled one goes: status unknown, log on screen, install blocked.

And two regressions I introduced in Round 21:

The probe's finally re-armed Save unconditionally -- clobbering Connected_NoDatabase,
which turns Save OFF on purpose so the user must click Install Now or take the
explicit "Skip, just add server" link. That handed the consent away on the most
ordinary path there is: point at a bare instance, click Check for Updates, read "No
PerformanceMonitor database found" -- and Save is live, and it is IsDefault, so ENTER
commits a server the Dashboard can read nothing from. Save is owned by the state; the
finally now only restores what it borrowed.

And the install log was being cleared in the detection PROLOGUE, before it knew
whether it would publish anything. The not-connected branch publishes nothing by
design, so it left InstallComplete on screen with an empty log, a blank status line
and a 0% bar -- destroying, on a failed install, the only on-screen copy of the SQL
error text. It is discarded WITH the verdict that replaces it now. Same rule as
_installBlockedReason, same reason.

Also: _reportPath had no per-run reset, so a failed report write left "View Report"
pointing at the previous server's file.
…abase regression

The first is the most serious defect this review found, and it was not in the file
twenty-two rounds were spent on: cancelling a clean install returned NORMALLY --
'Installation completed with 1 error(s)' over a database DROP DATABASE had already
taken -- because a cancelled SqlCommand faults with SqlException, not
OperationCanceledException, and the clean-install handler swallowed it.

The second is a button-gating regression introduced while fixing an overlapping-probe
bug: Save is disabled on Connected_NoDatabase on PURPOSE (Install Now, or the
explicit 'Skip, just add server' link), and re-arming it unconditionally handed that
consent away on the most ordinary path in the product.
…stall hole

Security pass on the SQL and credential paths came back close to clean -- SQL is
parameterized, no password reaches the log/report/history/console, TLS defaults to
Mandatory with TrustServerCertificate off. One real finding.

GenerateSummaryReport built its filename from the user-typed server name and stripped
only the backslash, so a name like "x/../../Users/Public/foo" turned Path.Combine into
a write OUTSIDE the report directory -- forward slash is a separator, ".." is parent,
":" opens an NTFS alternate data stream. Called by BOTH front ends. Mostly self-
inflicted for an interactive operator, but it matters in the CLI's unattended mode
where the server list may come from a lower-trust inventory while running under a
privileged account. The CLI's own error-log writer already sanitizes the same input
with GetInvalidFileNameChars; this shared report writer was the one that was missed.
Full invalid-char stripping now, plus a containment assert so a future filename change
cannot reintroduce an arbitrary write silently. Pinned with tests that fail on the old
code (the forward-slash and ADS cases), verified not assumed.

And a parity gap in the exclusivity guard THIS branch introduced: --repair is
mutually exclusive with --reinstall, but not with --uninstall -- which is dispatched
before any repair logic and, in automated mode, drops the database with no prompt. So
"--repair --uninstall" let the destructive mode win silently, on a command line that
also asked to destroy nothing. Both destructive companions are rejected now, not just
one.

Left as a flagged sharp edge, not fixed: a CLI password beginning with "--" is dropped
by the positional filter and the run then fails with a misleading "Password is
required". Pre-existing, fails safe (no wrong connection, no leak), documented
workaround (PM_SQL_PASSWORD). Touching positional-arg parsing to fix it risks the exact
class of accident this branch exists to prevent, so it is Erik's call.
…an Round 22

R23a's InstallationService pass found the sibling of the clean-install cancel bug.
The file loop's broad catch(Exception) counted a cancelled SqlCommand (which faults
as SqlException, not OperationCanceledException) as a file FAILURE and returned
Success=false. The loop-top ThrowIfCancellationRequested only re-surfaces a cancel if
control reaches the top of the loop again -- which it does NOT for a critical file
(break) or the last file (loop ends), so those two cancels fell through to "completed
with N error(s)" and the caller's FAILURE path instead of its CANCELLATION path. Same
class as the clean-install bug Round 22 fixed; same dedicated catch-when-cancelled
pattern applied here, ahead of the general catch, so a cancel is never recorded as a
file failure.

(I first wrote this as a `when (!IsCancellationRequested)` filter plus a post-loop
throw -- which is broken: the filter makes the SqlException skip the catch AND
everything after it, so the post-loop throw is never reached and a raw SqlException
escapes. The dedicated converting-catch is the correct shape and matches the
clean-install branch.)

Coverage boundary stated honestly in the test file: the DB-free tests pin the
PRE-file windows (a pre-cancelled token trips the guards before any command runs); a
cancel that lands INSIDE a running SqlCommand needs a live server and is a
click-through item, not a fake unit test.

Backed OUT the SET SINGLE_USER / DROP / MULTI_USER / THROW guard I had added for the
"failed DROP strands the DB in SINGLE_USER" edge. docs-first-verifier against MS Learn
showed it does not deliver: the timeout it targets is a CLIENT CommandTimeout, which
fires an attention, and TRY/CATCH is documented not to trap attentions -- and the
whole pattern is invalid on Azure SQL MI (SET SINGLE_USER is non-modifiable there, so
the batch dies before the TRY). It is a real MINOR edge that needs a C#-side fix
across three files; parked as a follow-up rather than shipped as theatre.
…word CLI hint

Two parked items from the #1498 review, both installer-safety.

3a -- the clean-install teardown. SET SINGLE_USER WITH ROLLBACK IMMEDIATE is
non-modifiable on Azure SQL Managed Instance (EngineEdition 8), so the teardown
batch died on that line there before the DROP; gated to box SQL Server now, MI
drops directly. And SINGLE_USER + DROP are two autocommitted statements -- a DROP
that fails (single-user-slot race) or hits the client timeout/cancel AFTER
SINGLE_USER committed left the database stranded in SINGLE_USER, semi-bricked. A
server-side TRY/CATCH cannot see a client-attention timeout, so the un-brick is
C#-side: best-effort SET MULTI_USER on a fresh connection before re-raising the
original error. Applied to InstallationService.CleanInstallAsync and ServerManager's
drop path (which now warns if the un-brick itself fails); TestDatabaseHelper gets the
MI gate for pattern-correctness. Every SQL construct (EngineEdition 8 = MI,
SINGLE_USER/MULTI_USER non-modifiable on MI, DROP supported on MI, DB_ID null-check,
bare-comparison of the sql_variant EngineEdition) was verified against MS Learn for
both box and MI. Plain SET MULTI_USER, not WITH ROLLBACK IMMEDIATE, deliberately --
it does not force-kill a session that raced into the slot.

3b -- a SQL-auth password beginning with "--" (e.g. "--h7x") is indistinguishable
from a flag, so the positional filter dropped it and the run reported "Password is
required" for a password that WAS given. The error now adds a precise note -- only
when a "--"-token that is not a recognized flag actually appeared -- pointing at
PM_SQL_PASSWORD. Parsing is unchanged on purpose: accepting "--"-values positionally
is exactly what would let a mistyped flag become a password.
The testability follow-up the multi-round review kept pointing at. The
AddServerDialog flow's DialogState enum and its pure predicates lived inside the WPF
dialog, which is not CI-wired, so nothing tested them -- and the recurring bug shape
was "a predicate over the state that forgot a state" (the destructive-checkbox rule
missing an exit; IsServerVerdictState and PromisesExistingInstall disagreeing about
the same server).

The enum is now Installer.Core.ServerSetupState and the predicates are
Installer.Core.ServerSetupPolicy -- IsServerVerdictState, PromisesExistingInstall,
StateConsumesModeSelections (the destructive-tick rule), IsInstalling, and
DeriveConnectedState (which already called RepairOutcome, also in Installer.Core, so
no new dependency). Pinned by a full state x predicate matrix in Installer.Tests: one
assertion per predicate per state, plus a guard that the enum still has exactly the
eight states the tables cover, plus the two tricky relationships that were themselves
bugs when "fixed" to be consistent (the repair-handoff states promise an existing
install but are NOT verdict states; Installing is neither). A switch that forgets a
state now fails a test instead of only misbehaving in the untested UI.

Behavior-preserving: the dialog aliases the enum as DialogState (via `using`), so its
40+ references are unchanged, and calls the extracted predicates at the ~7 sites.
Nothing about the logic changed -- all 732 Dashboard tests pass unchanged, and the
33 new matrix tests are additive (Installer.Tests 157 -> 190). CA1707 (underscores in
the now-public Connected_* members) is suppressed with justification: the grouping is
a deliberate, readable convention carried verbatim from the dialog.
…dialog-upgrade-abort

# Conflicts:
#	CHANGELOG.md
@erikdarlingdata erikdarlingdata merged commit be9cdf3 into dev Jul 13, 2026
2 checks passed
@erikdarlingdata erikdarlingdata deleted the feature/1497-addserverdialog-upgrade-abort branch July 13, 2026 14:30
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