Skip to content

TSG ghost CSV: safety and correctness fixes from a four-family multi-model review - #345

Open
John Neemes (1008covingtonlane) wants to merge 4 commits into
Azure:mainfrom
1008covingtonlane:tsg-storage-ghost-csv-multimodel-fixes
Open

TSG ghost CSV: safety and correctness fixes from a four-family multi-model review#345
John Neemes (1008covingtonlane) wants to merge 4 commits into
Azure:mainfrom
1008covingtonlane:tsg-storage-ghost-csv-multimodel-fixes

Conversation

@1008covingtonlane

Copy link
Copy Markdown
Collaborator

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 $GhostPathPattern from an earlier block, and PowerShell -match against 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 into Move-VMStorage. Confirmed by running it, not by reasoning about it:

"C:\ClusterStorage\UserStorage_1\vm.vhdx" -match $null   ->  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 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

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 C:\ClusterStorage was created, not when it was ghosted. Now stated plainly, with the reader pointed at the cluster log instead.

Also in this PR:

  • probe $env:SystemDrive rather than a hardcoded C:, so a node whose system drive is not C: is not reported clean because the wrong volume was inspected
  • match string arrays in cluster parameters; a bare -is [string] test silently skipped multi-valued parameters
  • drop Select-String -List, which stopped at the first match per file and missed later references
  • write Get-ClusterLog to an explicit path, since an elevated shell starts in System32
  • warn on logs skipped for size instead of silently passing a clean result
  • report an incomplete enumeration as UNVERIFIED rather than empty

Terms

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-SmbOpenFile sees only remote SMB opens, so a local AV, backup, or filter-driver handle (the same lock class that creates the ghost) is never checked
  • Get-ChildItem -Recurse on Windows PowerShell 5.1 follows volume mount points, so a scan intended to detect a reparse point can descend through it
  • Get-VHD cannot open shared VHD Sets, so those are permanently flagged UNREADABLE with no documented escape
  • the [System.IO.Directory]::Delete reparse-safety rationale does not hold as written
  • Get-VM -Name accepts wildcards, so a partial name can select multiple VMs
  • Step 3's "exactly one category" does not fit a node with an empty .000 and a referenced .001
  • the preflight table prints but never halts

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
Copilot AI balanced review requested due to automatic review settings August 20, 2026 11:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +497 to +501
> [!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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +436 to 446
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 = '' }
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>'... }

Comment on lines +1293 to +1297
# 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 })

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
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.

2 participants