TSG ghost CSV: safety and correctness fixes from a four-family multi-model review - #345
Conversation
Ran the persona lenses across four frontier model families (Opus, GPT, Gemini, Grok). Each found real defects the others missed. Fixes here are the safety-class and factually-wrong ones. Unset pattern matched EVERY path. Steps 2A to 2C depend on $GhostPathPattern from an earlier block, and PowerShell -match against an unset variable returns true for everything, so pasting a step into a fresh session reported every VM on the node as referencing a ghost root and could feed all of them into Move-VMStorage. Confirmed by running it: "C:\ClusterStorage\UserStorage_1\ vm.vhdx" -match $null is True. The definition now fails loudly if the pattern is empty, and each paste-ready block re-defines it if missing. A ghost root that is ITSELF a mount point was invisible. Step 1C tested only the CHILDREN for the ReparsePoint attribute, so a numbered root that is itself a volume mount point reported IsReparsePoint = False and classified as safe to delete. That is the worst possible miss: the entire root is live storage. The root is now tested first. Verify the fix was weaker than the detection it confirms. It matched only attached disk paths, so a differencing or checkpoint PARENT left on a ghost root passed verification, the exact data-loss case Step 2A warns about. It now walks the full parent chain, and also checks reparse points and platform content, which Step 3 requires for Path A but verification omitted. Unverified nodes read as clean. Verification enumerates only nodes in State 'Up' while Step 3 requires clearance on EVERY node, and ghost roots often appear while a node is drained for a solution update. It now warns explicitly that skipped nodes are unverified and the condition is not resolved. CreationTime guidance was wrong. A ghost root is produced by RENAMING the CSV root, and an NTFS rename PRESERVES CreationTime, so it reflects when the original ClusterStorage was created, not when it was ghosted. Now stated, with the reader pointed at the cluster log instead. Also: probe $env:SystemDrive rather than a hardcoded C:; match string ARRAYS in cluster parameters, which a bare -is [string] test skipped; drop Select-String -List, which stopped at the first match per file; write Get-ClusterLog to an explicit path, since an elevated shell starts in System32; warn on logs skipped for size instead of silently passing; and report an incomplete enumeration as UNVERIFIED rather than empty. Terms added that three of four families flagged as undefined: fan-out, canonical, parent chain / differencing disk, VHD-Set and .vhdpmem, solution update, and the MOC acronym expansion. All 25 PowerShell blocks parse-checked with the PowerShell AST parser. Lint A. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d31ddd7-9fbb-4ff0-8aa7-3f9d88060ed7
There was a problem hiding this comment.
Pull request overview
Hardens the ghost CSV troubleshooting guide following multi-model safety review.
Changes:
- Improves ghost-root and reference detection.
- Strengthens verification and incomplete-scan reporting.
- Clarifies terminology and timestamp guidance.
Suppressed comments (1)
TSG/Storage/Troubleshoot-Storage-GhostCsvMountPoints.md:1316
- An unreadable VHD makes this verification stop walking the chain without recording any finding. A ghost-root parent behind that unreadable link can therefore still yield
References = 0, unlike Step 2A and the audit function, which correctly treat incomplete chains as blockers. Record the unreadable path in$refs(or a separate unverified collection) before breaking.
$vhd = Get-VHD -Path $path -ErrorAction SilentlyContinue
if (-not $vhd) { break } # missing or unreadable: chain ends here
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| > [!WARNING] | ||
| > Every step in this section depends on `$GhostPathPattern`. If you open a **new** | ||
| > PowerShell session, reconnect, or paste a single step on its own, re-run the block | ||
| > above first. Each paste-ready block below re-defines the pattern if it is missing, but | ||
| > only for the session it runs in. |
There was a problem hiding this comment.
Fixed in 5ae3384c, and you found a real inconsistency: the warning text made a claim the code did not honour.
Rather than guard only 2B and 2C, I audited every powershell block programmatically for "uses $GhostPathPattern but neither defines it nor receives it as param($Pattern)". That found two more unguarded consumers in Path B beyond the two you named. All are now guarded, and the audit reports zero remaining:
unguarded consuming blocks: 0
So the warning is now true rather than aspirational. Fixing only the two named blocks would have left the same fail-open in the more dangerous Path B.
| if ($enumErrors.Count) { | ||
| [pscustomobject]@{ | ||
| GhostRoot = $root | ||
| Child = '<ENUMERATION INCOMPLETE>' | ||
| IsReparsePoint = $null | ||
| Detail = "$($enumErrors.Count) path(s) could not be read; treat this root as UNVERIFIED, not clean." | ||
| } | ||
| } | ||
| if (-not $children) { | ||
| [pscustomobject]@{ GhostRoot = $root; Child = '<empty>'; IsReparsePoint = $false; Detail = '' } | ||
| } |
There was a problem hiding this comment.
Fixed in 5ae3384c. Correct, and this was the worse of the two orderings: a root whose scan FAILED emitted the <ENUMERATION INCOMPLETE> row and then fell through to also emit <empty>, which the results table calls the lowest-risk outcome. An incomplete scan could therefore read as unverified and safe at the same time, and the safe reading is the one an operator acts on.
It is now an elseif, with the reason recorded inline so it does not get "simplified" back into two separate if statements later:
if ($enumErrors.Count) { ...'<ENUMERATION INCOMPLETE>'... }
# elseif, NOT a separate if: a root whose enumeration FAILED must not also emit the
# '<empty>' row, because the results table calls empty the lowest-risk outcome and an
# incomplete scan would then read as both unverified and safe at the same time.
elseif (-not $children) { ...'<empty>'... }| # Every node must be inspected, not just the reachable ones. A node that is Down, Paused, or | ||
| # unreachable is UNVERIFIED, not clean, and ghost roots frequently appear precisely while a | ||
| # node is drained for a solution update. Surface the gap instead of silently omitting it. | ||
| $allNodes = (Get-ClusterNode).Name | ||
| $missingNodes = @($allNodes | Where-Object { $_ -notin $nodes }) |
There was a problem hiding this comment.
Fixed in 5ae3384c. This was the sharpest of the three: cluster state Up does not mean reachable, and $missingNodes only compared cluster STATE. A node that is Up but fails Invoke-Command (WinRM stopped, credentials, firewall) returned no row at all, and a missing row was silently indistinguishable from a clean one, which is exactly what the new comment claimed to prevent.
Verification now captures the results and the remoting errors, then reconciles the nodes that actually ANSWERED against full cluster membership, and warns by name for any node that returned nothing:
$nodeResults = Invoke-Command ... -ErrorAction SilentlyContinue -ErrorVariable +remotingErrors
$answered = @($results | Select-Object -ExpandProperty Node -ErrorAction SilentlyContinue)
$noAnswer = @($allNodes | Where-Object { short-name not in $answered })
if ($noAnswer.Count) { Write-Warning "... returned NO RESULT and are UNVERIFIED ..." }
The short-name normalisation is deliberate: Get-ClusterNode returns FQDNs while $env:COMPUTERNAME does not, so comparing them raw would have flagged every node as unanswered.
Verified behaviourally rather than by inspection. Simulating three nodes where one answers, one is Down, and one is Up but silent, both non-answering nodes are reported and the Up-but-silent node is caught.
All three findings were correct. Pattern guard was missing from 2B and 2C. The warning text claimed every paste-ready block re-defines the pattern if missing, but only 2A's blocks actually did, so the claim was false and both blocks kept the original fail-open behaviour. Auditing every block programmatically rather than fixing just the two named found two MORE unguarded consumers in Path B that the review did not flag. All consuming blocks are now guarded; the audit reports zero remaining. Incomplete enumeration could also report empty. The UNVERIFIED row fell through to a separate `if (-not $children)`, so a root whose scan FAILED emitted both the unverified row and the '<empty>' row, and the results table calls empty the lowest-risk outcome. Now an elseif, so an incomplete scan cannot simultaneously appear safe. An Up node that fails Invoke-Command produced no row and no warning. Cluster state Up does not mean reachable: WinRM, credentials, or a firewall can break remoting on an otherwise healthy node, and a missing row is not a clean row. Verification now captures the results and remoting errors, reconciles the nodes that actually ANSWERED against full cluster membership, and warns by name for any node that returned nothing. Verified behaviourally: a simulated node that is Up but silent is correctly reported as unverified. All 25 PowerShell blocks re-parsed with the AST parser, 0 failures. Lint A. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d31ddd7-9fbb-4ff0-8aa7-3f9d88060ed7
Deep review by GPT, Gemini and Grok on the post-fix document. Many findings were inconsistencies I INTRODUCED in the previous round by fixing one site and not its siblings, which is the real lesson here. Gates that were weaker than the checks they enforce: - the Path A audit still used -is [string] for cluster parameters after Step 2C was widened to string arrays, so a multi-valued parameter holding a ghost path could reach SafeToDelete = True - the root-reparse test added to 1C was missing from the audit, the delete preflight, the late re-check, the verify scan, and the Path A/1C prose, so a numbered root that IS a mount point still passed every gate but one - verification broke silently on an unreadable parent chain while Step 2A and the audit both treat UNREADABLE as blocking; it now reports UnverifiedChains - the audit swallowed Get-VMHardDiskDrive errors while failing closed on Get-VHD - uninspected cluster resources were a note beside a clean verify table Coverage gaps none of my own reads found: - Get-VMDvdDrive was never checked anywhere, so an ISO mounted from a ghost root was invisible and would have been deleted (Gemini) - Path B never checked whether a disk is ALSO attached to another VM, so Move-VMStorage could relocate a shared VHDX out from under a peer VM (Grok) - the infrastructure check covered attached disks only, not config, checkpoint, or paging paths Also: added the missing Step 2G open-handle check that Step 3 required but Step 2 never defined, and made it explicit that Get-SmbOpenFile sees only REMOTE opens so a local antivirus or filter-driver handle, the kind that creates the ghost, is not proven absent; made the preflight THROW instead of printing a table a reader walks past, and had it actually test remoting rather than assume it; blocked wildcard and platform VM names in Path B; classify each root separately and evaluate most-severe-first, with a route for a non-VM reference; cleared the read-only attribute before Directory.Delete and stopped the batch on failure rather than leaving a root half-deleted; fixed the Step 1A CreationTime claim that contradicted 2F; and replaced every hardcoded C:\ with $env:SystemDrive. One finding was NOT acted on. Gemini reported that Get-ClusterParameter natively throws for resources with no parameters, which would make the audit block healthy clusters permanently. Measured live earlier against 3 clusters, 195 resources and 14 resource types: zero throws. The premise is wrong, so acting on it would have introduced a bug. 27 PowerShell blocks AST-parsed, 0 failures. Lint A, 17 anchors resolve. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d31ddd7-9fbb-4ff0-8aa7-3f9d88060ed7
The Path C bundle collected the CSV mount-point list, the ghost root list, and cluster parameters, but none of the findings that actually SEND a case to Path C: VM disk and parent-chain references, DVD/ISO references, VM config, checkpoint and paging paths, SMB open files, reparse points, and platform content. Support therefore had to re-run Steps 1 and 2 themselves before they could start, which is the slowest possible opening to a case that by definition involves live platform data. The bundle now exports all of those to references-and-content.csv across every Up node, and records IsReparsePoint on each ghost root in ghost-roots.csv. The walk matches the rest of the guide: it distinguishes a missing disk file, which ends the chain, from an unreadable one, which is recorded as UnreadableChain, and it tests the root itself for a reparse point, not only its descendants. 27 PowerShell blocks AST-parsed, 0 failures. Lint A. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d31ddd7-9fbb-4ff0-8aa7-3f9d88060ed7
Fixes from a four-family multi-model review
Follow-up to #344. The persona lenses were re-run across four frontier model families (Opus, GPT, Gemini, Grok) and each found real defects the others missed. This PR fixes the safety-class and factually-wrong ones.
Safety
An unset pattern matched every path. Steps 2A to 2C depend on
$GhostPathPatternfrom an earlier block, and PowerShell-matchagainst an unset variable returns true for everything. Pasting a step into a fresh session therefore reported every VM on the node as referencing a ghost root, and could feed all of them intoMove-VMStorage. Confirmed by running it, not by reasoning about it:The definition now fails loudly if the pattern is empty, and each paste-ready block re-defines it if missing.
A ghost root that is itself a mount point was invisible. Step 1C tested only the children for the
ReparsePointattribute, so a numbered root that is itself a volume mount point reportedIsReparsePoint = Falseand classified as safe to delete. That is the worst available miss: the whole root is live storage. The root is now tested first.Verify the fix was weaker than the detection it confirms. It matched only attached disk paths, so a differencing or checkpoint parent left on a ghost root passed verification, which is the exact data-loss case Step 2A warns about. It now walks the full parent chain, and also checks reparse points and platform content that Step 3 requires for Path A but verification omitted.
Unverified nodes read as clean. Verification enumerates only nodes in
State 'Up'while Step 3 requires clearance on every node, and ghost roots frequently appear precisely while a node is drained for a solution update. It now warns explicitly that skipped nodes are unverified and the condition is not resolved.Correctness
CreationTimeguidance was wrong. A ghost root is produced by renaming the CSV root, and an NTFS rename preservesCreationTime, so it reflects when the originalC:\ClusterStoragewas created, not when it was ghosted. Now stated plainly, with the reader pointed at the cluster log instead.Also in this PR:
$env:SystemDriverather than a hardcodedC:, so a node whose system drive is not C: is not reported clean because the wrong volume was inspected-is [string]test silently skipped multi-valued parametersSelect-String -List, which stopped at the first match per file and missed later referencesGet-ClusterLogto an explicit path, since an elevated shell starts inSystem32UNVERIFIEDrather than emptyTerms
Added the terms three of four families independently flagged as undefined: fan-out, canonical, parent chain / differencing disk, VHD-Set and
.vhdpmem, solution update, and the MOC acronym expansion.Validation
All 25 PowerShell blocks parse-checked with the PowerShell AST parser (
Parser::ParseFile), 0 failures. Structure and safety lint: A, all checks pass, all relative and in-page anchor links resolve.Known remaining, not in this PR
Deliberately left for a separate change so this one stays reviewable:
Get-SmbOpenFilesees only remote SMB opens, so a local AV, backup, or filter-driver handle (the same lock class that creates the ghost) is never checkedGet-ChildItem -Recurseon Windows PowerShell 5.1 follows volume mount points, so a scan intended to detect a reparse point can descend through itGet-VHDcannot open shared VHD Sets, so those are permanently flaggedUNREADABLEwith no documented escape[System.IO.Directory]::Deletereparse-safety rationale does not hold as writtenGet-VM -Nameaccepts wildcards, so a partial name can select multiple VMs.000and a referenced.001