Skip to content

v2.5.79 - truthful claims

Choose a tag to compare

@nanoDBA nanoDBA released this 09 Sep 19:51

v2.5.79 (2026-09-09)

Truthfulness release. v2.5.78 fixed the headless servicing path and shipped a note claiming a capability the binary did not have; running the matrix against two guests found that claim was one of several. Six of the fixes below are cases where the updater said something that was not so — a dead preference described as active, a deliberate withhold logged as a crash, a reboot announced that never happened, a watchdog documented in a comment and never armed — and one is a fix that reopened an old defect the moment it started working.

Four of these were found on a machine after the unit suite had agreed with the code. Two more — a crash on the ordinary Windows Update path, and this release's own false claim about -WhatIf — were found by the two-axis review, before they shipped.

Fixed

  • The resume account's SID preference was dead code. $state.ResumeUserSid was assigned inside an empty catch while neither the state constructor nor the add-if-missing normaliser declared the property. Assigning an undeclared property on a [pscustomobject] throws, the empty catch swallowed it, and Resolve-BootUpdateResumeAccount was therefore always called with an empty -PreferredSid. AzureAD\ and MicrosoftAccount\ machines degraded to a SYSTEM-only continuation — exactly the case the SID existed to cover. The property is now declared in both places, and the discovery moved into Update-BootUpdateResumeIdentity so a test can walk constructor → discovery → resolver: every existing test called the resolver directly with a SID it supplied itself, which is why this survived a release. The discovery also records the SID and the name together, so a SID left by an earlier pass for a different account can no longer outrank the fresh name at a resolver that prefers SIDs absolutely. v2.5.78's note that the SID "is now preferred" is corrected below.
  • Fixing that reopened the unbounded wait for an interactive user. On a machine nobody signs into, LogonUI still records a past session. With the SID discovery working, ResumeUser became populated for the first time, the cycle concluded a user was known, waited on a logon trigger that will never fire, armed no dated retry, and took no further pass — sitting at UserContextPending with both tasks armed and nothing able to fire them. That is the unbounded wait -MaxUserIdentityWaits was added to prevent, walked back in through the door the fix opened. The bound now asks whether a user-context pass can still happen (Test-BootUpdateInteractiveUserPresent) rather than whether a name is known: a cycle running as the user is the session; a SYSTEM pass looks for a console user. Behaviour change worth stating: a machine whose user has signed out is now bounded too, where v2.5.78 would wait indefinitely. After -MaxUserIdentityWaits rediscovery attempts its user-scope work is recorded as deferred inventory and the cycle completes with a qualified claim, rather than withholding forever. The probe reads Win32_ComputerSystem.UserName, which is the console session, so an RDP-only server is treated as having no interactive user — the safe direction, but not the same question.
  • An update the machine installs and is then offered again was treated as retry fuel forever. KB5007651, the Windows Security platform update, installs, genuinely changes the machine, is recorded by Windows Update with result code 2, and is offered again by the very next scan. Measured on a cold guest across one run: AMProductVersion advanced 4.18.23110.3 → 4.18.26080.3 and a Platform directory appeared where the guest had none, while Windows Update history gained five KB5007651 entries, every one result code 2 — and the update stayed applicable. Row B ran six passes still looping when the row's 45-minute budget ran out. An update that Windows Update itself records as installed successfully DURING THIS RUN and still offers on the final scan is now recorded as deferred inventory with kind ReofferedAfterSuccess, is not counted as a verified update and not counted as retry fuel, and the completion claim is qualified. Only result code 2 qualifies, so a failed install remains a real failure and stays retryable — which is the whole reason for reading the agent's result code rather than PSWindowsUpdate's Installed [n] Updates prose. Convergence is withheld on the count of unexplained updates, and a clean pass retracts the previous pass's observation. The window is the run rather than the boot: a first attempt at this scoped it to the current boot, and lab row B showed the cycle then loses the evidence across the very restart the classification exists to stop repeating.
  • The dated watchdog the restart path documents had never been armed. Start-BootUpdateRestart registers a watchdog before every restart for the case its own comment names: if the user takes the documented shutdown /a escape hatch, neither startup nor logon fires, so a time trigger is what keeps the resume chain alive. The parameter is declared [Nullable[datetime]], but PowerShell's parameter binder converts that to a plain System.DateTime, which has no HasValue member — so $RetryAt.HasValue evaluated to $null, never $true, and every -RetryAt caller registered a task with no time trigger. -RetrySoon is a [switch] and was unaffected, which is why the two-minute retries everywhere else kept working and hid this. Matrix row C proved it from the machine: after cancelling the countdown, Export-ScheduledTask on the guest returned a single LogonTrigger, the task never ran, and the cycle sat untouched for the whole 40-minute row with Resume chain verified printed beside it.
  • A timeout kill orphaned the installer it had started, and the next passes collided with it. Remove-ProcessTree kills the provider and its descendants, but msiexec.exe is parented to services.exe and is not in that tree. On 2026-09-06 a killed Acrobat Reader install left msiexec alive for 28 minutes and six machine-scope packages failed 0x652 = 1618 against the updater's own orphan — three iterations and 39.6 minutes spent colliding with itself. Killing harder is the wrong fix; tearing down an MSI transaction mid-write is how a machine ends up with a half-installed product. After either timeout kill the cycle now asks the question Windows Installer actually arbitrates — is Global\_MSIExecute held — and waits up to 30 minutes for the transaction to finish, a bound chosen to cover the 28-minute case that motivated it. Asking the mutex rather than looking for a running msiexec.exe matters: msiexec runs as a service process for reasons unrelated to an active transaction, so a process-name wait would block on nothing. A mutex that cannot be examined — an ACL denial, say — is treated as held rather than absent, and the outcome travels out with the timeout result so a later 1618 can be attributed rather than guessed at.
  • A deliberately withheld phase was logged as a crash. An unfinished phase leaves one footprint — LastPhaseStarted set, the Done flag false — for three different histories, and only one is a crash. Row B emitted "Previous run crashed during [WindowsUpdate]" on three of its seven passes while nothing had crashed, which makes a healthy withhold-and-retry cycle read as repeated crashes in the log and in every diagnostics bundle built from it. The message now uses the previous pass's own terminal disposition: a withheld verification and a planned restart each say so, and a real crash keeps its warning.
  • A pass that followed no reboot announced that it had resumed after one. Matrix row D drove five passes with the restart command rejected every time, and every pass after the first announced RESUMED (after reboot) while the same line reported Reboots: 0/5. The banner verb now comes from the boot observation the cycle already computes, and a same-boot retry says RESUMED (same boot).
  • The diagnostics manifest inferred pending-file cleanup state from the absence of a log line. The cycle declines to emit that line for four unrelated reasons — an empty advisory set, duplicate suppression, the phase not running, and the explicit 3010/1641 short-circuit — and all four collapsed to Persistent=null. A completed, converged run whose truthful answer was Persistent=false reported null. The cycle now writes BootUpdateCycle.pending-cleanup.json on an evidence lifecycle — never TTL'd, never invalidated on a fingerprint, written even under -WhatIf, appended per probe and keyed by session and pass, because whether a fingerprint survived a restart is only answerable across passes. A damaged file is moved aside rather than overwritten. The duplicate guard stays in the log; it no longer decides what is known. The exporter prefers the sidecar and keeps log parsing only for bundles that predate it, which now report unknown-legacy-log rather than a bare null. See ADR-0004.
  • The explicit-reboot short-circuit skipped that recording entirely. Get-ConfirmedPendingReboot returns early on a 3010/1641 request, correctly bypassing the settle-and-recheck wait — but it also bypassed the recording, and it fires precisely when real servicing has just happened and the pending-file evidence is most worth having. One registry read now refreshes the snapshot before it returns, with the record marked explicit-reboot so a reader can see it did not come from a full two-probe confirmation. No settle wait was added to that path.
  • The beads export wrote a real name and email address into a public repository. The identity appeared in created_by (274), owner (211), assignee (113) and author (2) — 600 fields across 216 rows. The export path now sanitises all four (bd's own unknown author is preserved, because replacing it would invent attribution rather than remove it), the existing rows were rewritten once, and the one remaining occurrence in ticket prose was corrected in the tracker itself so it survives future exports. Forward-only, deliberately: the exposure is a name already on a public GitHub profile, and rewriting history is not worth it.

Changed

  • Boot-session identity is now one call that returns what it decided. The cluster produced the same defect class twice — a persisted timestamp compared without normalisation — and both times the cause was the shape of the interface, not the logic: callers gathered three readings through three getters, handed raw timestamps to the state update, then re-derived the new-boot decision at the logging site from those same raw values. Update-BootUpdateBootSession now takes state and returns { NewBoot, Reason, RebootCounted, State }, with normalisation, both tolerances, the anchoring policy, the phase-gated reboot increment and the retry-budget reset all inside it. The cycle logs which signal saw the reboot, so a fast restart the 120-second identity window cannot see says monotonic instead of going unexplained.
  • docs/TESTING.md now states how to report a gate, so the PASS / PARTIAL / FAIL / NOT RUN vocabulary is written down rather than inferred each release: PASS requires that every enabled phase converged, no reboot evidence remains, health checks pass, and both continuation tasks and active state are absent; anything less is PARTIAL or FAIL; NOT RUN is never folded into PASS; and a summary line may say PASS only if every row it summarises is PASS.
  • The lab harness gained the things these rows needed and lost two defects of its own. Each poll is appended to host-timeline.txt as it happens, so a killed or timed-out row still leaves its timeline and Get-Content -Wait is a live view across guests. A row can start from a guest with no PowerShell 7 (through upd.cmd, syncing over the Hyper-V guest service channel, because PowerShell Direct sessions cannot be opened there under any configuration name). Rows record UAC consent prompts and whether PowerShell 7 appeared during the run. The two defects: a one-line guest log collapsed to a String, which made $string -match return a boolean whose .Count is 1 and ended a row announcing a converged cycle that had not started; and an injection that threw still reported Injected=true.

Corrections to v2.5.78

  • v2.5.78 said the resume account now prefers the SID recorded beside the name. It did not. The assignment threw into an empty catch on every run, so -PreferredSid was always empty and the behaviour was exactly what it had been before. The note described the intent of the change, not the behaviour of the binary. Fixed in this release, and now covered by a test that drives the state object rather than calling the resolver directly.
  • v2.5.78's validation block reported Multi-reboot convergence: PASS (rows A and B). Row B was not a pass. The same notes said, two paragraphs later, that row B "reached a truthful terminal state, not convergence" and that the image could not converge. Under the rule in docs/TESTING.md a scenario passes only when every enabled phase converged and both tasks and active state are absent; row B met neither. That the summary line said PASS while one of its two rows was not a pass — and while five further rows were NOT RUN, which v2.5.78 did state in prose below the block — is the fault being corrected. v2.5.77 reported PARTIAL with per-row detail, which was the right shape, and this release returns to it and writes the rule down so the next one does not have to re-derive it.
  • v2.5.78 filed the KB5007651 question as "not claimed either way". That was the honest position at the time, but it left the updater retrying an unwinnable loop in the meantime. It is now answered with evidence and handled: the install is real, the re-offer is environmental, and the updater names it instead of retrying it.

Validation

Unit/process behavior:       PASS     (478 tests, 0 failed)
User/SYSTEM boundary:        PASS
Published launcher upgrade:  PASS     (from v2.5.43)
Live bootstrap:              PASS     (row F - but see the qualification below)
Provider integration:        NOT RUN
Multi-reboot convergence:    PARTIAL  (rows A and B PASS against this build; C, D, E, F, G below)
Release assets:              PASS     (20: 10 scripts + 10 SHA-256 sidecars)

The gate line is PARTIAL, not PASS, because a summary line may say PASS only when every row it summarises is PASS. Rows A and B both pass; rows D and G are partial, and rows C, E and F did not run against this build.

Reporting follows the rule now written down in docs/TESTING.md: PASS only when every enabled phase converged, no reboot evidence remains, health checks pass, and both continuation tasks and active state are absent. NOT RUN is its own word.

Rows A and B ran against the shipping build — commit c9bb8e9, the same orchestrator both rows verified by SHA-256 at sync time. Every other row ran against an earlier commit and is reported with that stated.

  • Row A — interactive user, three armed reboots, lab-a. PASS. A-v2579-final-boot-upd-matrix-20260909-145814. Four passes, converged in 10.2 minutes with an unqualified claim and 2 verified updates. RebootsClaimed 3 = RebootsObservedOS 3, no state file, no continuation tasks, no pending CBS. Two of the three reboots were 103 and 74 seconds apart — inside the 120-second identity tolerance — and were detected by the monotonic signal, which the log now names: "Observed a new Windows boot session (monotonic); completed reboot count is now 3." Each boot was observed exactly once.
  • Row B — SYSTEM continuation with nobody signed in, lab-b. PASS, with a qualified claim. B-v2579-final-lab-b-20260909-145818. Six passes, converged in 38.1 minutes: "BOOT UPDATE CYCLE COMPLETE WITH DEFERRED INVENTORY | 6 iteration(s) | 3 reboot(s) | 2 verified updates". RebootsClaimed 3 = RebootsObservedOS 3, no state file, no continuation tasks. ConsoleUser empty and ExplorerCount 0 for the whole run, so the headless claim is about the machine and not about the harness. Both new behaviours are visible in it: the re-offer classification firing after a reboot — "KB5007651 installed successfully 2 time(s) during this run … Recorded as deferred inventory rather than retried" — and the bounded wait ending — "No interactive user appeared after 2 rediscovery attempts". Run with -MaxUserIdentityWaits 2; the shipped default is 10.
  • Row C — cancelled delayed restart, lab-b. PASS, against commit 0651d8c, one commit before the boot-session restructure. C-cancelled-restart-v4-lab-b-20260909-105952. Three passes, RebootsClaimed 1 = RebootsObservedOS 1, no state, no tasks. The cancel landed at 11:11:20 and the dated watchdog fired at 11:18:01 with "RESUMED (same boot) | Pass: 2 | Reboots: 0/5" — the restart that did not happen was not counted. Against the previous build the same row was a FAIL (C-cancelled-restart-v3-…-101428): nothing ran for the whole 40 minutes, which is how the never-armed watchdog was found.
  • Row D — failed restart command, lab-b. PARTIAL, against commit 918f641. D-failed-restart-lab-b-20260909-092536. It meets its own acceptance in full: the failure is surfaced ("shutdown.exe rejected the restart request (exit 1)", five times), every pass reports Reboots: 0/5 and RebootsObservedOS is 0, no completion claim is made, and the cycle stops itself — "Same-boot recovery limit 5 reached … Continuation tasks were removed and verified absent." It is PARTIAL rather than PASS because convergence is impossible while the injected fault holds, so the retained state and pending CBS are by design.
  • Row E — delayed reboot signal. PASS under v2.5.77, E-delayed-signal-boot-upd-matrix-20260908-213738. NOT re-run against this build.
  • Row F — PowerShell 5.1-only bootstrap, lab-b. PASS, F-ps51-bootstrap-v3-lab-b-20260909-095955, from a checkpoint built for it by uninstalling PowerShell 7. The harness confirmed the starting condition rather than assuming it (guest PowerShell 7 present at launch: False), the bootstrap installed PowerShell 7.6.6 after verifying its Microsoft publisher signature, and the cycle converged. Qualification: upd.cmd's documented path recovers the checksummed bundle from the latest GitHub release, and the log shows "Checksummed bundle updated to v2.5.78" — so this validates the fresh-install path, not this build.
  • Row G — killed-process recovery, lab-a. PARTIAL, against commit 0651d8c. G-killed-after-promotion-v2-boot-upd-matrix-20260909-110032. State integrity is established: the state file parsed valid immediately before and after the kill, no .tmp remained, and the post-kill state carried Phase=WindowsUpdate with WingetDone and ChocolateyDone true, so the promoted work survived. The two acceptance clauses that need a resume are NOT RUN: nothing resumed for the remaining 37 minutes. See Known limitations.

Two things about the gates themselves, stated rather than smoothed over:

  • The unit gate failed once at 1 of 477 during this release and passed on four subsequent full runs. Invoke-TestGates.ps1 threw a bare count and discarded the results, so the failing test cannot be named. It now reports them, so the next intermittent failure is diagnosable. Whether that one failure was a real defect is not known.
  • Release assets were verified after tagging: the release is not a draft, carries 20 assets (10 scripts and 10 SHA-256 sidecars), and targets the pushed commit. The compatibility-installer hash pinned in README.md was carried forward from v2.5.78 rather than re-derived, and has now been checked against the published asset by downloading it: 67662B3B02252FF6DE045FCDF28FB74D8DEB6FDA8080C46B1DAFC7BFBE54ABE3, matching.

No live update cycle was run on the maintainer's own machine. Every reboot, package install and scheduled task in this validation happened on a disposable guest.

Known limitations

  • A cycle killed mid-pass is not resumed until the next logon or boot. Matrix row G established that state integrity holds through a kill — the state file parsed valid before and after, no .tmp remained, and the phases already promoted were recorded as done — but nothing resumed for the remainder of the row. The chain armed at pass start is boot- and logon-scoped, and Task Scheduler's restart-on-failure does not cover a pass launched by Deploy-BootUpdateCycle.ps1 or upd.cmd rather than by the task, which is always true of the first pass. The fix needs a repeating watchdog trigger in the resume chain and its own lab row; it is filed, and shipping it on the strength of a green unit suite is the failure mode this release exists to correct. The two acceptance clauses that depend on a resume are therefore NOT RUN for this release, not passed.
  • The number of UAC consents the fresh-install flow demands was not measured. Row F records MaxConsentPrompts: 0, but the row launches through a scheduled task at RunLevel Highest, which is already elevated and cannot raise a consent dialog. That zero is an observation about the harness, not about the flow.
  • Row F validated the published v2.5.78 bundle, not this build. upd.cmd's documented fresh-install path recovers the checksummed bundle from the latest GitHub release, and the log shows Checksummed bundle updated to v2.5.78. It is evidence that the PowerShell 5.1 bootstrap works; it is not evidence about v2.5.79.
  • The orphaned-installer wait and the pending-cleanup sidecar have unit cover only. Neither has been exercised on a guest: no lab row this cycle timed out a package, and no diagnostics bundle has yet been captured from a machine running the new sidecar. -5vbd, which would generalise the sidecar's shape, stays deferred until such a bundle has been read.
  • The compatibility-installer hash in README.md is carried forward, not re-derived. Install-UpdCompat.ps1 is unchanged since v2.5.78, so the published asset should hash identically — but that is confirmed by the immutable published-launcher gate after the release is cut, not before.
  • Row C's decisive observation was read live and not archived. The Export-ScheduledTask output showing a single LogonTrigger was taken from the running guest; the evidence directory holds the log, timeline, summary and console capture, but not the task XML.