TenantPulse
0.1.0
Read-only tenant health engine that evaluates Microsoft Intune and Entra configuration against a versioned set of checks, producing a deterministic, pseudonymized, scored snapshot.
Minimum PowerShell version
7.4
Installation Options
Owners
Copyright
(c) 2026 Adam Gell. Licensed under MIT.
Package Details
Author(s)
- Adam Gell
Tags
Intune Entra TenantHealth Graph
Functions
Export-PulseReport Get-PulseCheckCatalog Get-PulseTenantSnapshot Invoke-PulseAssessment Invoke-PulseCheck
Dependencies
-
- GraphKit (>= 0.1.1)
Release Notes
## [0.1.0] - 2026-08-18
### Added
- Repository scaffold: Sampler build pipeline, module manifest, QA gates, and CI workflow.
- Snapshot store: `New-PulseSnapshotStore`, `Write-PulseDataset`, `Read-PulseDataset`,
`Get-PulseSnapshotManifest`, `Set-PulseManifestEntry`, and the canonical serialization
primitive `ConvertTo-PulseCanonicalJson`, with a hashed, reason-carrying manifest that
every later collector/evaluator/renderer component builds on.
- Operator key + pseudonymization: `Get-PulseOperatorKey` auto-creates a 32-byte key
(0600-equivalent permissions on non-Windows) at `~/.tenantpulse/operator.key` on first
call and refuses to create or read one inside a snapshot root; `Get-PulsePseudonym`
turns any value into a stable `tp-`-prefixed HMAC-SHA256 pseudonym under that key, so
raw tenant IDs never need to appear in snapshots or reports.
- Check descriptor schema + loader: `Import-PulseCheckCatalog` reads and validates every
`.psd1` check descriptor under a directory (default `source/Data/Checks`) with
`Import-PowerShellDataFile`, returning an ordinally-sorted-by-`Id` array of
`TenantPulse.CheckDescriptor` objects; an empty/missing catalog returns an empty array
rather than throwing. `Test-PulseCheckDescriptor` validates one descriptor's schema
with explicit per-field type enforcement (scalar `[string]` vs. required/allowed-empty
`[string[]]`, reported as `must be a <type>, got <actual type>` rather than silently
coercing), Id/Severity/Effort/Impact/Rule.Type patterns and enums, Rule.Function
resolving via `Get-Command` at import time, required Consulting fields,
References.Research/Authorities and, once Task 1.5 lands `DatasetMap.psd1`,
cross-checks Data.Datasets membership against a map parsed exactly once per catalog
load. All validation errors across a catalog are aggregated into a single thrown
error, each line prefixed with its source filename and naming the offending
descriptor and property, rather than failing on the first bad file. Schema documented
verbatim in `source/Data/Checks/README.md`.
- Evaluator: `Invoke-PulseEvaluation` runs a check catalog against a snapshot store and
produces the canonical findings document (`source/Private/Evaluate/FindingsSchema.md`),
plus an in-memory-only `RedactionMap` covering every evidence identity seen (built at
evaluate, applied at render). `New-PulseFinding` is the rule-result contract every
Function rule (`Test-Pulse* -Datasets <hashtable>`) returns to produce `Pass`/`Warn`/
`Fail` with evidence; Expression rules resolve straight to `[bool]` with no evidence.
`NotApplicable` and `Error` are engine-assigned only: a missing/`Failed`/`Skipped`
dataset degrades a check to `NotApplicable` quoting the manifest's own (already
redacted) reason verbatim, and a throwing or malformed-output rule becomes `Error`
without aborting the rest of the run. `Get-PulseGateStatus` is a Phase 1 gate stub -
every gate resolves `Unknown`, which never degrades a check. Findings are sorted
ordinally by check Id, each finding's evidence ordinally by sortKey then identity, and
`generatedUtc` is pinned to the snapshot manifest's own `createdUtc` (never wall
clock), so re-evaluating the same snapshot is byte-identical through
`ConvertTo-PulseCanonicalJson`.
- **Settings expansion (Phase 2, `-ExpandSettings`)**: `Get-PulseTenantSnapshot
-ExpandSettings` (and `Invoke-PulseAssessment -ExpandSettings`, a straight pass-through)
decomposes every policy TenantPulse can currently setting-expand into individual
canonical setting rows, on top of Phase 1's ordinary check-driven collection. Two typed
expansion drivers cover two different policy shapes: `Invoke-PulseSettingsCatalogExpansion`
walks the modern, definitionId-driven Settings Catalog (`configurationPolicies` +
`ConfigurationPolicySetting.ListBeta` per policy); `Invoke-PulseTypedPolicyExpansion`
covers compliance and legacy device configuration policies (`deviceCompliancePolicies`,
`deviceConfigurations`), the older polymorphic `@odata.type`-typed Graph resources,
decomposed via a hand-maintained property map (`source/Data/TypedPolicyMaps.psd1`) since
no Graph-side settings catalog exists for them. `-ExpandSettings` is **default-off,
deliberately**: the live gate against the Ivy24 lab tenant passed clean end to end, but
actually flipping the default surfaced two real, wider-blast-radius costs (a
`PSAvoidDefaultValueSwitchParameter` QA-gate lint failure, and at least two existing
`Get-PulseTenantSnapshot` unit tests asserting on the manifest shape a default-on flip
changes for every caller, not just opt-in ones) not appropriate to absorb under this same
task's time budget - flipping the default is real, scoped follow-up work, not done here.
Every expansion artifact is recorded in `manifest.expansions.<name>` and written to an
immutable, content-addressed file, never a fixed name: three row-producer "families"
(`settingsCatalog`, `compliance`, `deviceConfiguration`) each publish
`expanded/<name>.<sha256>.jsonl` (one canonical JSON object per line, row-schema v1), and
a single conflict-detection pass over every row from all three families publishes
`expanded/conflicts.<sha256>.json` - one JSON document naming every `settingDefinitionId`
that two or more policies disagree on, each with a four-state `assignmentOverlap` verdict
(`proven`/`possible`/`none`/`unknown`; see `FindingsSchema.md`'s "Settings expansion
artifacts" section for the exact schema). **The assignments-deferred split**: Settings
Catalog rows carry `assignments: null` in this core slice because
`ConfigurationPolicyAssignment.ListBeta` (the descriptor that would resolve which
devices/users a Settings Catalog policy actually targets) is not yet a released GraphKit
descriptor - any conflict involving such a row reports `assignmentOverlap: 'unknown'`
with an explicit `'assignments-deferred: awaiting GraphKit release'` reason rather than a
silently wrong verdict. Compliance and legacy device configuration policies do NOT have
this gap - their assignment descriptors (`DeviceCompliancePolicyAssignment.List`/
`DeviceConfigurationAssignment.List`) already shipped in GraphKit 0.1.1, so their rows
carry real assignment data today. Closing the Settings Catalog side is tracked as its own
follow-up slice ("Phase 2b"), not a silent limitation. A dedicated, serial perf container
(`tests/Perf/ScaleAndMemory.Tests.ps1`, run via `./build.ps1 -Tasks build,perftest`, never
part of the default test workflow) measures and budgets, at `[measured] x1.5` headroom, a
5,000-policy synthetic Settings Catalog expansion + conflict-detection compute pass
(mocked Graph), a 50,000-row `managedDevices` write+read memory ceiling, and raw
per-policy dataset write scaling - see `docs/spike/2026-08-16-t27-perf-container.md` for
the full recorded numbers, hardware, and method, and `docs/STATUS.md`'s own scale section
for the documented gaps it surfaced. **Live-gated against the Ivy24 lab tenant end to
end**: 781 Settings Catalog policies (`Partial`, 64 per-instance gaps, 0%
unresolved-name rate, 131 redacted secret values), 40 compliance policies (`Partial`, 6
gapped on an unmapped `@odata.type`), 15 device configuration policies (`Expanded`, zero
gaps, 8 redacted secret values), and conflict detection surfacing **165 real conflict
entries** across all three families (`assignmentOverlap` breakdown: `none`=8,
`possible`=34, `unknown`=123, the 123 all involving at least one assignments-deferred
Settings Catalog row) - not a zero-conflicts-by-luck outcome. `-FromSnapshot` re-derived
all four expansion artifacts byte-identical to the original run.
### Changed
- Check descriptor loader hardening (post-review): closed PowerShell-coercion type holes
that let array-typed `Id`/`Severity`/`Rule.Type`/etc. silently pass their
pattern/enum checks and land wrong-typed in the loaded descriptor (also closing the
matching duplicate-Id-detection bypass); prefixed every aggregated error line with its
source filename so two files sharing a bad Id are distinguishable; hoisted
`DatasetMap.psd1` parsing to once per catalog load with its own aggregated-error
handling for a malformed map instead of a raw exception, and excluded a
`DatasetMap.psd1` living inside the catalog directory from descriptor scanning;
switched catalog ordering from culture-aware `Sort-Object` to an ordinal
`[string]::CompareOrdinal` index-sort, matching `ConvertTo-PulseCanonicalJson`'s
established pattern for deterministic ordering.
- **Final fix-wave, contract:** `Get-PulseTenantSnapshot`'s snapshot manifest `tenant`
pseudonym is now derived from the resolved GraphKit tenant id (`$Context.TenantId`),
never from `-ProfileId` - the same tenant under two differently-named profiles now
pseudonymizes identically, and renaming a profile no longer changes the pseudonym.
`Get-GraphContext` is now called before the snapshot store is created so the tenant id
is available before anything is written. `Get-PulseTenantSnapshot -Path` and
`Get-PulseCheckCatalog -Path` are renamed to `-OutputPath`/`-CatalogPath` respectively
(`-Path` kept as a deprecated alias for one release). `Export-PulseReport`'s return
object now carries `FindingsPath`, matching `Invoke-PulseAssessment`'s return shape.
- **Final fix-wave, snapshot-store boundary:** `New-PulseSnapshotStore` now clears
`datasets/`, `reference/` and `expanded/` before reuse (unconditionally, not behind a
`-Force` flag), so a prior run's or a foreign file left in an existing store path can
no longer silently survive into a new run. `Get-PulseSnapshotStore` now type-validates
`datasets` (non-null object), `createdUtc` (a parseable timestamp) and `producer`
(non-null object) when opening a store, throwing a specific path+field error instead of
producing a confident-but-wrong evaluation or a mid-run crash. `Write-Pulse
FileList
- TenantPulse.nuspec
- TenantPulse.psm1
- Data\.gitkeep
- Data\Checks\TP.ENT.0013.psd1
- Data\Checks\TP.ENT.0009.psd1
- Data\Checks\TP.ENT.0024.psd1
- Data\Checks\TP.INT.0009.psd1
- Data\Checks\TP.ENT.0019.psd1
- Data\Checks\TP.INT.0002.psd1
- Data\Checks\TP.ENT.0003.psd1
- Data\Checks\TP.ENT.0002.psd1
- Data\Checks\TP.ENT.0022.psd1
- Data\Checks\README.md
- Data\Checks\TP.ENT.0001.psd1
- Data\Checks\TP.INT.0021.psd1
- Data\Checks\TP.INT.0001.psd1
- Data\Checks\TP.INT.0026.psd1
- Data\Checks\TP.INT.0011.psd1
- Data\Checks\TP.ENT.0010.psd1
- TenantPulse.psd1
- Data\TypedPolicyMaps.psd1
- Data\Checks\TP.ENT.0005.psd1
- Data\Checks\TP.INT.0012.psd1
- Data\Checks\TP.INT.0013.psd1
- Data\Checks\TP.ENT.0004.psd1
- Data\Checks\TP.ENT.0023.psd1
- Data\Checks\TP.INT.0022.psd1
- Data\Checks\.gitkeep
- Data\Checks\TP.INT.0023.psd1
- Data\Checks\TP.INT.0015.psd1
- Data\Checks\TP.ENT.0021.psd1
- Data\Checks\TP.INT.0020.psd1
- Data\Checks\TP.ENT.0016.psd1
- Data\Checks\TP.ENT.0007.psd1
- Data\Checks\TP.ENT.0011.psd1
- Data\Checks\TP.INT.0007.psd1
- Data\Checks\TP.ENT.0006.psd1
- Data\DatasetMap.psd1
- Data\Checks\TP.INT.0008.psd1
- Data\Checks\TP.INT.0004.psd1
- Data\Checks\TP.ENT.0008.psd1
- Data\Checks\TP.INT.0025.psd1
- Data\Checks\TP.INT.0014.psd1
- Data\Checks\TP.ENT.0015.psd1
- Data\Checks\TP.INT.0019.psd1
- Data\Checks\TP.INT.0003.psd1
- Data\Checks\TP.ENT.0018.psd1
- Data\Checks\TP.INT.0016.psd1
- Data\Checks\TP.ENT.0017.psd1
- Data\Checks\TP.ENT.0020.psd1
- Data\Checks\TP.INT.0030.psd1
- Data\Checks\TP.INT.0006.psd1
- Data\Checks\TP.INT.0027.psd1
- Data\Checks\TP.INT.0031.psd1
- en-US\about_TenantPulse.help.txt
- Data\Checks\TP.INT.0024.psd1
- Data\Checks\TP.INT.0028.psd1
- Data\Checks\TP.INT.0005.psd1
- Data\Checks\TP.INT.0029.psd1
- Data\Checks\TP.ENT.0012.psd1
Version History
| Version | Downloads | Last updated |
|---|---|---|
| 0.1.3 | 9 | 8/20/2026 |
| 0.1.2 | 6 | 8/20/2026 |
| 0.1.1 | 5 | 8/19/2026 |
| 0.1.0 (current version) | 4 | 8/19/2026 |