The Ticket That Started at 03:17
The pager went off at 03:17. A managed client’s nightly reporting job had failed across 40 endpoints. The error was short: The specified module 'ReportKit' was not loaded because no valid module file was found in any module directory. Twenty minutes into the investigation, the root cause was not the network, not DNS, not WinRM. It was a PowerShell module manifest that had shipped without a version bump and without its declared dependencies. This write-up is the post-mortem, layer by layer, of how a missing PSD1 value knocked out an entire reporting pipeline.
We kept the packet capture open just in case, but this one never went below Layer 7. The failure was entirely inside the module loader.
Incident Timeline
The client runs a mixed PowerShell 5.1 and PowerShell 7.4 fleet. Their ReportKit module sits in C:\Program Files\WindowsPowerShell\Modules\ReportKit on every server. A developer on their side had pushed version 2.3.0 the previous afternoon. The rollout script copied the new files over the existing 2.2.0 folder instead of dropping them in a new 2.3.0 subfolder.
- 17:42 — Developer copies new PSM1 and PSD1 over existing folder.
- 02:00 — Scheduled task fires on 40 endpoints.
- 02:01 —
Import-Module ReportKit -RequiredVersion 2.2.0fails on every host. - 03:17 — Monitoring rolls up the failures and pages our on-call.
- 03:38 — We pull the PSD1 and identify three defects in the manifest.
- 04:05 — Hotfix PSD1 shipped via Configuration Manager.
- 04:11 — Reporting job re-run succeeds on all 40 hosts.
Reading the Manifest Like a Packet Capture
When a module will not load, I treat the PSD1 the way I treat a pcap. Read it top to bottom, one field at a time, and do not assume any field is correct just because it looks right. The New-ModuleManifest cmdlet generates a PSD1 with every production element commented out. Most teams fill in half and ship.
Here is the minimal skeleton we now require on every client module:
@{
RootModule = 'ReportKit.psm1'
ModuleVersion = '2.3.0'
GUID = 'd4a1f2e8-8b7c-4c9a-9b1a-7f3e9c2d1a0b'
Author = 'SSE Infrastructure'
CompanyName = 'SSE'
PowerShellVersion = '5.1'
CompatiblePSEditions = @('Desktop','Core')
RequiredModules = @(
@{ ModuleName = 'SqlServer'; ModuleVersion = '22.0.59' }
)
FunctionsToExport = @('Get-ReportKitData','Export-ReportKitCsv')
CmdletsToExport = @()
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('reporting','sql')
ReleaseNotes = 'Add SQL 2022 support.'
}
}
}
The field that broke the client was ModuleVersion. It still read '2.2.0'. The scheduled task used Import-Module ReportKit -RequiredVersion 2.2.0, which looks correct, but -RequiredVersion is an exact-match parameter. PowerShell scanned $env:PSModulePath, found a folder named 2.2.0, read its PSD1, and matched. That much worked. What failed came next.
The ModuleVersion Field Is Not Cosmetic
The PSD1 specification says ModuleVersion must be a string that converts to a System.Version instance. Format is #.#.#.#, though three octets is accepted. Semantic versioning (MAJOR.MINOR.PATCH) is the accepted standard and is what Microsoft’s PowerShell documentation recommends.
Here is the opinionated part. Never overwrite an existing version folder. Always deploy a new module version into its own subfolder: ReportKit\2.3.0\. PowerShell 5.1 and later will happily coexist with multiple versions under the same parent folder. Overwriting files in place breaks -RequiredVersion and -MinimumVersion guarantees, and you lose the ability to roll back without pulling files from a backup.
The client’s developer had not made a new subfolder. The 2.3.0 PSM1 landed inside the 2.2.0 folder, but the PSD1 still claimed ModuleVersion = '2.2.0'. Import-Module -RequiredVersion 2.2.0 matched on version but loaded the new PSM1, which had a breaking change in Export-ReportKitCsv. The job crashed on the first function call.
RequiredModules: The Dependency That Actually Loads
The second defect was in RequiredModules. The developer had declared it as a bare string: RequiredModules = @('SqlServer'). That form tells PowerShell to require any version of the SqlServer module. The reporting code used a cmdlet that only exists from SqlServer 21.1 onward. A handful of endpoints still had SqlServer 21.0 loaded.
From PowerShell 3 onward, RequiredModules will actually import dependencies into the session if they are not already loaded. In PowerShell 2 it only checked for presence. Almost nobody runs PowerShell 2 anymore, but I have still seen production hosts with the old behavior assumed. The hashtable form pins the version:
RequiredModules = @(
@{
ModuleName = 'SqlServer'
ModuleVersion = '22.0.59'
GUID = '7b3e9c2d-1a0b-4c9a-9b1a-d4a1f2e88b7c'
}
)
The GUID is optional but useful when two modules share a name across registries. SANS publishes sensible guidance on software supply chain hygiene, and pinning dependencies by GUID is part of that story.
CompatiblePSEditions and the Silent Cross-Edition Failure
The third defect was subtler. The manifest did not declare CompatiblePSEditions. On PowerShell 7.4 hosts, the module loaded, but one helper function called Get-WmiObject. That cmdlet is not present in PowerShell 7 and was removed years ago. The correct cmdlet is Get-CimInstance. We cover that substitution in more depth in our writeup on PowerShell WMI service management.
Declaring CompatiblePSEditions = @('Desktop','Core') does not magically make a module compatible. What it does is tell the PowerShell host to refuse to load the module on an unsupported edition. That is the behavior you want. The client would have preferred a clear not compatible error at 17:43 over a silent degraded import at 02:01.
The Fix We Shipped at 04:05
Three edits went into the corrected PSD1.
- Move the files into a real
2.3.0subfolder and restore the2.2.0folder from the previous night’s backup snapshot. - Set
ModuleVersion = '2.3.0'in the new folder’s PSD1. Leave 2.2.0 intact. - Declare
RequiredModulesas a hashtable withModuleVersion = '22.0.59'and setCompatiblePSEditions = @('Desktop','Core').
We validated with:
Test-ModuleManifest -Path 'C:\Program Files\WindowsPowerShell\Modules\ReportKit\2.3.0\ReportKit.psd1'
Test-ModuleManifest is your ping equivalent for a PSD1. It returns the parsed object if every field validates. It returns an error if the manifest is malformed or if required fields are missing. Run it as part of your CI pipeline before any module ships.
The Caveat: Test-ModuleManifest Will Not Catch Logic Errors
Here is the limitation. Test-ModuleManifest validates structure and the existence of referenced files. It will not tell you that your RequiredModules version pin is wrong, or that your declared functions list does not match what the PSM1 actually exports. The developer’s original PSD1 would have passed Test-ModuleManifest clean. The defects were semantic, not syntactic.
The same applies to PowerShellVersion. Setting it to 5.1 does not mean your code actually runs on 5.1. It means the loader will refuse to import on anything older. If you develop on 7.4 and forget to test on 5.1, you will ship a module that loads but throws on the first $PSStyle reference.
Lessons From the Post-Mortem
We rolled three changes into the client’s release process the next day. If your team ships PowerShell modules, these are the practices we enforce across every technology roadmap engagement that includes automation work.
Always Deploy Into a Versioned Subfolder
Never copy new module files over an existing version folder. Create ReportKit\2.3.0\ next to ReportKit\2.2.0\. This preserves rollback and keeps -RequiredVersion and -MinimumVersion honest.
Pin Dependencies With Hashtables, Not Strings
Write RequiredModules as hashtables with ModuleName and ModuleVersion. Include the GUID if the dependency is published in multiple places. Bare strings accept any version and that is almost never what you want in production.
Declare CompatiblePSEditions Explicitly
If your module uses cmdlets that only exist in Windows PowerShell, declare CompatiblePSEditions = @('Desktop'). If you support both, list both. If you do not know, test on both editions before shipping. PowerShell variable scope and edition differences are covered in our piece on PowerShell variables, scope, and types.
Run Test-ModuleManifest in CI
Add a CI step that runs Test-ModuleManifest against every PSD1 in the repository. Fail the build if it errors. It is a cheap check and it would have caught two of the three defects in this incident.
Map Your Release Process to a Framework
Module publishing is a change-management activity. Clients with ISO 27001 or similar controls should treat it that way. The MITRE ATT&CK framework also maps module hijacking and unsigned script execution techniques that your manifest practices directly defend against. We dig into that mapping in our post on MITRE ATT&CK mapping in Sentinel detection rules.
The Practical Takeaway
A PowerShell module manifest is not metadata you fill in once and forget. It is a contract between your module and every machine that imports it. Three fields carry most of the weight: ModuleVersion, RequiredModules, and CompatiblePSEditions. Get those wrong and you will page someone at 03:17.
If you run PowerShell automation in production and want a second set of eyes on your module release process, or if you are building out change-controlled automation on your VPS hosting fleet, reach out at clients.sse.to/contact.php. Bring your PSD1. We will read it line by line.


