diff --git a/.build/GraphKitAuth.tasks.ps1 b/.build/GraphKitAuth.tasks.ps1
new file mode 100644
index 0000000..fb85683
--- /dev/null
+++ b/.build/GraphKitAuth.tasks.ps1
@@ -0,0 +1,1828 @@
+param([switch] $SkipTaskRegistration)
+
+$script:GraphKitAuthPayloadFiles = @(
+ 'GraphKit.Auth.Contracts.dll'
+ 'GraphKit.Auth.dll'
+ 'GraphKit.Auth.deps.json'
+ 'Microsoft.Identity.Client.dll'
+ 'Microsoft.IdentityModel.Abstractions.dll'
+)
+$script:GraphKitAuthProviderFiles = @(
+ 'GraphKit.Auth.dll'
+ 'GraphKit.Auth.deps.json'
+ 'Microsoft.Identity.Client.dll'
+ 'Microsoft.IdentityModel.Abstractions.dll'
+)
+$script:GraphKitAuthStage = $null
+$script:GraphKitAuthStageCaptureType = $null
+$script:GraphKitAuthAbiFixtureState = $null
+$script:GraphKitAuthAbiGitConfigState = $null
+$script:GraphKitAuthExpectedTestCount = 77
+
+function Assert-GraphKitAuthTestResult {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][xml] $Result)
+
+ $outcomes = @($Result.TestRun.Results.UnitTestResult | ForEach-Object { [string]$_.outcome })
+ $counters = $Result.TestRun.ResultSummary.Counters
+ $zeroCounterNames = @('failed','error','timeout','aborted','inconclusive','notExecuted',
+ 'notRunnable','disconnected','warning','inProgress','pending')
+ $nonZeroCounters = @($zeroCounterNames | Where-Object { [int]$counters.$_ -ne 0 })
+ if ($outcomes.Count -ne $script:GraphKitAuthExpectedTestCount -or
+ @($outcomes | Where-Object { $_ -cne 'Passed' }).Count -ne 0 -or
+ [int]$counters.total -ne $outcomes.Count -or [int]$counters.executed -ne $outcomes.Count -or
+ [int]$counters.passed -ne $outcomes.Count -or $nonZeroCounters.Count -ne 0) {
+ throw "GraphKit.Auth machine-readable test result is incomplete: expected exactly $($script:GraphKitAuthExpectedTestCount); total=$($outcomes.Count), passed=$(@($outcomes | Where-Object { $_ -ceq 'Passed' }).Count)."
+ }
+}
+
+function Initialize-GraphKitAuthStageCapture {
+ if ($null -ne $script:GraphKitAuthStageCaptureType) { return }
+ $root = Split-Path $PSScriptRoot -Parent
+ $helperPath = Join-Path $root 'scripts/private/GraphKit.AuthStageCapture.cs'
+ if (-not (Test-Path -LiteralPath $helperPath -PathType Leaf)) {
+ throw "The private GraphKit.Auth stage-capture helper is missing at '$helperPath'."
+ }
+ $helperBytes = [IO.File]::ReadAllBytes($helperPath)
+ $strictUtf8 = [Text.UTF8Encoding]::new($false, $true)
+ try { $template = $strictUtf8.GetString($helperBytes) }
+ catch { throw "The private GraphKit.Auth capture helper is not strict UTF-8." }
+ $marker = '__GRAPHKIT_AUTH_STAGE_CAPTURE_NAMESPACE__'
+ if (($template.Split([string[]]@($marker), [StringSplitOptions]::None).Length - 1) -ne 1) {
+ throw 'The private GraphKit.Auth capture helper must contain exactly one namespace marker.'
+ }
+ $helperHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($helperBytes)).ToLowerInvariant()
+ $nonce = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(16)).ToLowerInvariant()
+ $namespace = "GraphKit.R8.StageCapture.H$helperHash.N$nonce"
+ $expectedTypeName = "$namespace.GraphKitAuthStageCapture"
+ if (@([AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType($expectedTypeName, $false, $false) } | Where-Object { $null -ne $_ }).Count) {
+ throw "The generated GraphKit.Auth capture type '$expectedTypeName' already exists."
+ }
+ $compiled = @(Add-Type -TypeDefinition $template.Replace($marker, $namespace) -PassThru -ErrorAction Stop)
+ $compiledMatches = @($compiled | Where-Object FullName -CEQ $expectedTypeName)
+ $loaded = @([AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType($expectedTypeName, $false, $false) } | Where-Object { $null -ne $_ })
+ if ($compiledMatches.Count -ne 1 -or $loaded.Count -ne 1 -or -not [object]::ReferenceEquals($compiledMatches[0], $loaded[0])) {
+ throw 'The proof-bound GraphKit.Auth capture helper collided during compilation.'
+ }
+ $script:GraphKitAuthStageCaptureType = $compiledMatches[0]
+}
+
+function Get-GraphKitAuthOutputRoot {
+ param([Parameter(Mandatory)][string] $OutputRoot)
+ $resolved = [IO.Path]::GetFullPath($OutputRoot)
+ if ([IO.Path]::GetFileName($resolved) -ceq 'GraphKit.Auth') { return $resolved }
+ return Join-Path $resolved 'GraphKit.Auth'
+}
+
+function Get-GraphKitAuthPortableChildEntry {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $ParentPath,
+ [Parameter(Mandatory)][string] $ChildName,
+ [Parameter(Mandatory)][string] $Kind
+ )
+ Assert-GraphKitAuthSafeSegment -Value $ChildName -Kind $Kind
+ $parent = [IO.Path]::GetFullPath($ParentPath)
+ $expectedNfc = $ChildName.Normalize([Text.NormalizationForm]::FormC)
+ $portableMatches = @([IO.Directory]::EnumerateFileSystemEntries($parent) | Where-Object {
+ $actualName = [IO.Path]::GetFileName($_)
+ [string]::Equals(
+ $actualName.Normalize([Text.NormalizationForm]::FormC),
+ $expectedNfc,
+ [StringComparison]::OrdinalIgnoreCase)
+ })
+ if ($portableMatches.Count -gt 1) {
+ throw "The GraphKit.Auth $Kind has multiple entries for the portable name '$ChildName'."
+ }
+ if ($portableMatches.Count -eq 1) {
+ $actualName = [IO.Path]::GetFileName($portableMatches[0])
+ if ($actualName -cne $ChildName) {
+ throw "The GraphKit.Auth $Kind '$actualName' is a portable alias for '$ChildName'."
+ }
+ return [pscustomobject]@{
+ Exists = $true
+ Path = [string]$portableMatches[0]
+ Name = $actualName
+ }
+ }
+ return [pscustomobject]@{ Exists = $false; Path = $null; Name = $null }
+}
+
+function Remove-GraphKitAuthVerifiedEmptyDirectory {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $ParentPath,
+ [Parameter(Mandatory)] $ParentEvidence,
+ [Parameter(Mandatory)][string] $ChildName,
+ [Parameter(Mandatory)] $ChildEvidence,
+ [Parameter(Mandatory)][string] $Kind
+ )
+ $parent = [IO.Path]::GetFullPath($ParentPath)
+ $parentParent = Split-Path $parent -Parent
+ $parentName = [IO.Path]::GetFileName($parent)
+ $reopenedParent = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $parentParent, $parentName)
+ if ([string]$reopenedParent.NativeIdentity -cne [string]$ParentEvidence.NativeIdentity -or
+ [string]$reopenedParent.PhysicalPath -cne [string]$ParentEvidence.PhysicalPath) {
+ throw "The GraphKit.Auth $Kind parent changed; ambiguous cleanup was refused."
+ }
+ $entry = Get-GraphKitAuthPortableChildEntry -ParentPath $parent `
+ -ChildName $ChildName -Kind $Kind
+ if (-not $entry.Exists) {
+ throw "The GraphKit.Auth $Kind disappeared; ambiguous cleanup was refused."
+ }
+ $current = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName)
+ if ([string]$current.NativeIdentity -cne [string]$ChildEvidence.NativeIdentity -or
+ [string]$current.PhysicalPath -cne [string]$ChildEvidence.PhysicalPath -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $reopenedParent.PhysicalPath $current.PhysicalPath) -or
+ @([IO.Directory]::EnumerateFileSystemEntries($entry.Path)).Count -ne 0) {
+ throw "The GraphKit.Auth $Kind is not the identity-bound empty directory; ambiguous cleanup was refused."
+ }
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($entry.Path, $true, $true)
+ $reopened = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName)
+ if ([string]$reopened.NativeIdentity -cne [string]$ChildEvidence.NativeIdentity -or
+ [string]$reopened.PhysicalPath -cne [string]$ChildEvidence.PhysicalPath -or
+ @([IO.Directory]::EnumerateFileSystemEntries($entry.Path)).Count -ne 0) {
+ throw "The GraphKit.Auth $Kind changed while cleanup access was applied; ambiguous cleanup was refused."
+ }
+ Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop
+}
+
+function Initialize-GraphKitAuthOwnerDirectory {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $ParentPath,
+ [Parameter(Mandatory)] $ParentEvidence,
+ [Parameter(Mandatory)][string] $ChildName,
+ [Parameter(Mandatory)][string] $Kind,
+ [scriptblock] $AfterChildInspection,
+ [switch] $PreserveCreatedOnFailure
+ )
+ Initialize-GraphKitAuthStageCapture
+ Assert-GraphKitAuthSafeSegment -Value $ChildName -Kind $Kind
+ $parent = [IO.Path]::GetFullPath($ParentPath)
+ $parentParent = Split-Path $parent -Parent
+ $parentName = [IO.Path]::GetFileName($parent)
+ $reopenedParent = $script:GraphKitAuthStageCaptureType::InspectDirectory($parentParent, $parentName)
+ if ([string]$reopenedParent.NativeIdentity -cne [string]$ParentEvidence.NativeIdentity -or
+ [string]$reopenedParent.PhysicalPath -cne [string]$ParentEvidence.PhysicalPath) {
+ throw "The GraphKit.Auth $Kind parent changed before child creation."
+ }
+ $child = Join-Path $parent $ChildName
+ $created = $false
+ $reusedAtomicCollision = $false
+ $before = $null
+ try {
+ $entry = Get-GraphKitAuthPortableChildEntry -ParentPath $parent `
+ -ChildName $ChildName -Kind $Kind
+ if (-not $entry.Exists) {
+ try {
+ $before = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly(
+ $parent, $ChildName)
+ $created = $true
+ }
+ catch {
+ if ($_.Exception.Message -notmatch 'Atomic owner-only directory destination collision') {
+ throw
+ }
+ $entryAfterCollision = Get-GraphKitAuthPortableChildEntry -ParentPath $parent `
+ -ChildName $ChildName -Kind $Kind
+ if (-not $entryAfterCollision.Exists) { throw }
+ $before = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName)
+ if (-not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($before)) {
+ throw "The GraphKit.Auth $Kind collision did not resolve to an exact current-owner-only writable directory."
+ }
+ $reusedAtomicCollision = $true
+ }
+ }
+ $entry = Get-GraphKitAuthPortableChildEntry -ParentPath $parent `
+ -ChildName $ChildName -Kind $Kind
+ if (-not $entry.Exists) {
+ throw "The GraphKit.Auth $Kind path was not created."
+ }
+ if ($null -eq $before) {
+ $before = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName)
+ }
+ if (-not (Test-GraphKitAuthContainedPhysicalPath $reopenedParent.PhysicalPath $before.PhysicalPath)) {
+ throw "The GraphKit.Auth $Kind path is not one physically contained no-follow directory."
+ }
+ if ($created -and
+ -not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($before)) {
+ throw "The GraphKit.Auth $Kind did not begin with exact current-owner-only writable access."
+ }
+ if ($created -and $null -ne $AfterChildInspection) {
+ & $AfterChildInspection $Kind $child $before
+ }
+ if (-not $created -and -not $reusedAtomicCollision) {
+ if (-not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($before)) {
+ throw "The GraphKit.Auth $Kind is not exact current-owner-only writable before reuse."
+ }
+ }
+ $after = $script:GraphKitAuthStageCaptureType::InspectDirectory($parent, $ChildName)
+ if ([string]$after.NativeIdentity -cne [string]$before.NativeIdentity -or
+ [string]$after.PhysicalPath -cne [string]$before.PhysicalPath -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $reopenedParent.PhysicalPath $after.PhysicalPath) -or
+ -not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($after)) {
+ throw "The GraphKit.Auth $Kind path changed while owner-only access was applied."
+ }
+ return $after
+ }
+ catch {
+ $primary = $_
+ if ($created -and $null -ne $before -and -not $PreserveCreatedOnFailure) {
+ try {
+ Remove-GraphKitAuthVerifiedEmptyDirectory -ParentPath $parent `
+ -ParentEvidence $reopenedParent -ChildName $ChildName `
+ -ChildEvidence $before -Kind $Kind
+ }
+ catch {
+ throw "GraphKit.Auth $Kind initialization failed and ambiguous cleanup was refused: $($_.Exception.Message) Original failure: $($primary.Exception.Message)"
+ }
+ }
+ throw $primary
+ }
+}
+
+function Initialize-GraphKitAuthBuildAuthorityRoot {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $OutputRoot,
+ [scriptblock] $AfterChildInspection
+ )
+ Initialize-GraphKitAuthStageCapture
+ $output = [IO.Path]::GetFullPath($OutputRoot)
+ if (-not (Test-Path -LiteralPath $output -PathType Container)) {
+ $null = [IO.Directory]::CreateDirectory($output)
+ }
+ $outputParent = Split-Path $output -Parent
+ $outputName = [IO.Path]::GetFileName($output)
+ $outputEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $outputParent, $outputName)
+ $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $output `
+ -ParentEvidence $outputEvidence -ChildName 'GraphKit.Auth' `
+ -Kind 'build auth root' -AfterChildInspection $AfterChildInspection `
+ -PreserveCreatedOnFailure
+ $authRoot = Join-Path $output 'GraphKit.Auth'
+ $null = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot `
+ -ParentEvidence $authEvidence -ChildName 'capture' `
+ -Kind 'build capture root' -AfterChildInspection $AfterChildInspection `
+ -PreserveCreatedOnFailure
+ return $authEvidence
+}
+
+function New-GraphKitAuthBuildWorkRoot {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $OutputRoot,
+ [Parameter(Mandatory)][string] $RunId,
+ [scriptblock] $AfterCreate
+ )
+ Initialize-GraphKitAuthStageCapture
+ if ($RunId -cnotmatch '^[0-9a-f]{48}$') {
+ throw "The GraphKit.Auth build workspace run ID '$RunId' is not exactly 48 lowercase hexadecimal characters."
+ }
+ $authRoot = Get-GraphKitAuthOutputRoot $OutputRoot
+ $captureRoot = Join-Path $authRoot 'capture'
+ $output = [IO.Path]::GetFullPath($OutputRoot)
+ $outputEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($output)
+ $authEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ (Split-Path $authRoot -Parent), [IO.Path]::GetFileName($authRoot))
+ $captureEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $authRoot, 'capture')
+ if (-not (Test-GraphKitAuthContainedPhysicalPath `
+ $outputEvidence.PhysicalPath $authEvidence.PhysicalPath) -or
+ -not (Test-GraphKitAuthContainedPhysicalPath `
+ $authEvidence.PhysicalPath $captureEvidence.PhysicalPath) -or
+ -not (Test-GraphKitAuthOwnerOnlyWritableDirectory $captureEvidence)) {
+ throw 'The GraphKit.Auth build workspace capture root is not exact current-owner-only writable and physically contained.'
+ }
+ $name = ".build-$RunId"
+ $existing = Get-GraphKitAuthPortableChildEntry -ParentPath $captureRoot `
+ -ChildName $name -Kind 'build workspace'
+ if ($existing.Exists) {
+ throw "The GraphKit.Auth build workspace '$name' already exists."
+ }
+ $evidence = $null
+ try {
+ $evidence = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly(
+ $captureRoot, $name)
+ if ($null -ne $AfterCreate) { & $AfterCreate $evidence }
+ if (-not (Test-GraphKitAuthContainedPhysicalPath `
+ $captureEvidence.PhysicalPath $evidence.PhysicalPath) -or
+ -not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($evidence)) {
+ throw 'The GraphKit.Auth build workspace was not created with exact owner-only access and containment.'
+ }
+ }
+ catch {
+ $primary = $_
+ if ($null -ne $evidence) {
+ try {
+ Assert-GraphKitAuthExactDirectoryClosure -Directory (Join-Path $captureRoot $name) `
+ -ExpectedNames @() -Kind 'incomplete build workspace, which must be empty'
+ $recoveryQuarantine = New-GraphKitAuthTaskQuarantineRoot -OutputRoot $output
+ $partialWork = [pscustomobject]@{
+ Name = $name
+ Path = Join-Path $captureRoot $name
+ Evidence = $evidence
+ CapturePath = $captureRoot
+ CaptureEvidence = $captureEvidence
+ AuthPath = $authRoot
+ AuthEvidence = $authEvidence
+ OutputPath = $output
+ OutputEvidence = $outputEvidence
+ }
+ $null = Move-GraphKitAuthBuildWorkToQuarantine `
+ -BuildWork $partialWork -QuarantineRoot $recoveryQuarantine.Path
+ }
+ catch {
+ throw "GraphKit.Auth build workspace initialization failed and ambiguous cleanup was refused: $($_.Exception.Message) Original failure: $($primary.Exception.Message)"
+ }
+ }
+ throw $primary
+ }
+ return [pscustomobject]@{
+ PSTypeName = 'GraphKit.Auth.BuildWorkRoot'
+ Name = $name
+ Path = Join-Path $captureRoot $name
+ Evidence = $evidence
+ CapturePath = $captureRoot
+ CaptureEvidence = $captureEvidence
+ AuthPath = $authRoot
+ AuthEvidence = $authEvidence
+ OutputPath = $output
+ OutputEvidence = $outputEvidence
+ }
+}
+
+function Remove-GraphKitAuthVerifiedInstallCandidate {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $StageRoot,
+ [Parameter(Mandatory)][string] $InstallName,
+ [Parameter(Mandatory)] $InstallEvidence,
+ [Parameter(Mandatory)][string] $FullVersion,
+ [Parameter(Mandatory)] $TemporaryVersionEvidence,
+ [Parameter(Mandatory)][string] $CandidateStagePath
+ )
+ $installPath = Join-Path $StageRoot $InstallName
+ $temporaryVersion = Join-Path $installPath $FullVersion
+ $currentInstall = $script:GraphKitAuthStageCaptureType::InspectDirectory($StageRoot, $InstallName)
+ if ([string]$currentInstall.NativeIdentity -cne [string]$InstallEvidence.NativeIdentity -or
+ [string]$currentInstall.PhysicalPath -cne [string]$InstallEvidence.PhysicalPath) {
+ throw 'The GraphKit.Auth losing install candidate changed identity; ambiguous cleanup was refused.'
+ }
+ $currentVersion = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $installPath, $FullVersion)
+ if ([string]$currentVersion.NativeIdentity -cne [string]$TemporaryVersionEvidence.NativeIdentity -or
+ [string]$currentVersion.PhysicalPath -cne [string]$TemporaryVersionEvidence.PhysicalPath -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $currentInstall.PhysicalPath $currentVersion.PhysicalPath)) {
+ throw 'The GraphKit.Auth temporary version wrapper changed identity; ambiguous cleanup was refused.'
+ }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $installPath `
+ -ExpectedNames @($FullVersion) -Kind 'losing install root'
+ $digestName = [IO.Path]::GetFileName($CandidateStagePath)
+ if ($digestName -cnotmatch '^[0-9a-f]{64}$' -or
+ [IO.Path]::GetFullPath($CandidateStagePath) -cne [IO.Path]::GetFullPath((Join-Path $temporaryVersion $digestName))) {
+ throw 'The GraphKit.Auth candidate path is not the exact digest child of its temporary version wrapper.'
+ }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $temporaryVersion `
+ -ExpectedNames @($digestName) -Kind 'temporary version wrapper'
+ $verified = Test-GraphKitAuthSealedStage -StagePath $CandidateStagePath -FullVersion $FullVersion
+ $candidateEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $temporaryVersion, $digestName)
+ $payloadEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $verified.StagePath, 'payload')
+ $manifestEvidence = $script:GraphKitAuthStageCaptureType::InspectFile(
+ $verified.StagePath, 'manifest.json')
+ foreach ($file in @($verified.Manifest.files)) {
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly(
+ (Join-Path $verified.StagePath ([string]$file.path)), $false, $true)
+ }
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($verified.ManifestPath, $false, $true)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($verified.PayloadPath, $true, $true)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($verified.StagePath, $true, $true)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($temporaryVersion, $true, $true)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($installPath, $true, $true)
+
+ $reopenedInstall = $script:GraphKitAuthStageCaptureType::InspectDirectory($StageRoot, $InstallName)
+ $reopenedVersion = $script:GraphKitAuthStageCaptureType::InspectDirectory($installPath, $FullVersion)
+ $reopenedCandidate = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $temporaryVersion, $digestName)
+ $reopenedPayload = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $verified.StagePath, 'payload')
+ $reopenedManifest = $script:GraphKitAuthStageCaptureType::InspectFile(
+ $verified.StagePath, 'manifest.json')
+ if ([string]$reopenedInstall.NativeIdentity -cne [string]$InstallEvidence.NativeIdentity -or
+ [string]$reopenedInstall.PhysicalPath -cne [string]$InstallEvidence.PhysicalPath -or
+ [string]$reopenedVersion.NativeIdentity -cne [string]$TemporaryVersionEvidence.NativeIdentity -or
+ [string]$reopenedVersion.PhysicalPath -cne [string]$TemporaryVersionEvidence.PhysicalPath -or
+ [string]$reopenedCandidate.NativeIdentity -cne [string]$candidateEvidence.NativeIdentity -or
+ [string]$reopenedCandidate.PhysicalPath -cne [string]$candidateEvidence.PhysicalPath -or
+ [string]$reopenedPayload.NativeIdentity -cne [string]$payloadEvidence.NativeIdentity -or
+ [string]$reopenedPayload.PhysicalPath -cne [string]$payloadEvidence.PhysicalPath -or
+ [string]$reopenedManifest.NativeIdentity -cne [string]$manifestEvidence.NativeIdentity -or
+ [string]$reopenedManifest.PhysicalPath -cne [string]$manifestEvidence.PhysicalPath -or
+ [string]$reopenedManifest.Sha256 -cne $digestName -or
+ [long]$reopenedManifest.LinkCount -ne 1 -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $reopenedInstall.PhysicalPath $reopenedVersion.PhysicalPath) -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $reopenedVersion.PhysicalPath $reopenedCandidate.PhysicalPath) -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $reopenedCandidate.PhysicalPath $reopenedPayload.PhysicalPath)) {
+ throw 'The GraphKit.Auth losing install candidate changed before recursive deletion; ambiguous cleanup was refused.'
+ }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $installPath `
+ -ExpectedNames @($FullVersion) -Kind 'losing install root before deletion'
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $temporaryVersion `
+ -ExpectedNames @($digestName) -Kind 'temporary version wrapper before deletion'
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $verified.StagePath `
+ -ExpectedNames @('manifest.json','payload') -Kind 'losing candidate envelope before deletion'
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $verified.PayloadPath `
+ -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'losing candidate payload before deletion'
+ foreach ($record in @($verified.Manifest.files)) {
+ $actual = $script:GraphKitAuthStageCaptureType::InspectFile(
+ $verified.StagePath, [string]$record.path)
+ if ([string]$actual.NativeIdentity -cne [string]$record.nativeIdentity -or
+ [string]$actual.Sha256 -cne [string]$record.sha256 -or
+ [long]$actual.Length -ne [long]$record.length -or
+ [long]$actual.LinkCount -ne 1 -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $reopenedPayload.PhysicalPath $actual.PhysicalPath)) {
+ throw "The GraphKit.Auth losing candidate file '$($record.path)' changed before recursive deletion; ambiguous cleanup was refused."
+ }
+ }
+ Remove-Item -LiteralPath $installPath -Recurse -Force -ErrorAction Stop
+}
+
+function Remove-GraphKitAuthVerifiedCaptureCandidate {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $CaptureRoot,
+ [Parameter(Mandatory)][string] $CaptureName,
+ [Parameter(Mandatory)] $CaptureEvidence,
+ [Parameter(Mandatory)] $PayloadEvidence,
+ [Parameter(Mandatory)] $CapturedFileEvidence,
+ $ManifestWrite
+ )
+ $capture = Join-Path $CaptureRoot $CaptureName
+ $payload = Join-Path $capture 'payload'
+ $currentCapture = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $CaptureRoot, $CaptureName)
+ $currentPayload = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $capture, 'payload')
+ if ([string]$currentCapture.NativeIdentity -cne [string]$CaptureEvidence.NativeIdentity -or
+ [string]$currentCapture.PhysicalPath -cne [string]$CaptureEvidence.PhysicalPath -or
+ [string]$currentPayload.NativeIdentity -cne [string]$PayloadEvidence.NativeIdentity -or
+ [string]$currentPayload.PhysicalPath -cne [string]$PayloadEvidence.PhysicalPath -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $currentCapture.PhysicalPath $currentPayload.PhysicalPath)) {
+ throw 'The GraphKit.Auth incomplete capture changed identity; ambiguous cleanup was refused.'
+ }
+ $expectedPayloadNames = @($CapturedFileEvidence.Keys)
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $payload `
+ -ExpectedNames $expectedPayloadNames -Kind 'incomplete capture payload'
+ foreach ($name in $expectedPayloadNames) {
+ $expected = $CapturedFileEvidence[$name]
+ $actual = $script:GraphKitAuthStageCaptureType::InspectFile($payload, $name)
+ if ([string]$actual.NativeIdentity -cne [string]$expected.NativeIdentity -or
+ [string]$actual.PhysicalPath -cne [string]$expected.PhysicalPath -or
+ [string]$actual.Sha256 -cne [string]$expected.Sha256 -or
+ [long]$actual.Length -ne [long]$expected.Length -or
+ [long]$actual.LinkCount -ne 1 -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $currentPayload.PhysicalPath $actual.PhysicalPath)) {
+ throw "The GraphKit.Auth incomplete capture file '$name' changed; ambiguous cleanup was refused."
+ }
+ }
+ $expectedEnvelopeNames = [Collections.Generic.List[string]]::new()
+ $expectedEnvelopeNames.Add('payload')
+ if ($null -ne $ManifestWrite) { $expectedEnvelopeNames.Add('manifest.json') }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $capture `
+ -ExpectedNames @($expectedEnvelopeNames) -Kind 'incomplete capture envelope'
+ if ($null -ne $ManifestWrite) {
+ $expectedManifest = $ManifestWrite.Destination
+ $actualManifest = $script:GraphKitAuthStageCaptureType::InspectFile($capture, 'manifest.json')
+ if ([string]$actualManifest.NativeIdentity -cne [string]$expectedManifest.NativeIdentity -or
+ [string]$actualManifest.PhysicalPath -cne [string]$expectedManifest.PhysicalPath -or
+ [string]$actualManifest.Sha256 -cne [string]$expectedManifest.Sha256 -or
+ [long]$actualManifest.Length -ne [long]$expectedManifest.Length -or
+ [long]$actualManifest.LinkCount -ne 1) {
+ throw 'The GraphKit.Auth incomplete manifest changed; ambiguous cleanup was refused.'
+ }
+ }
+ foreach ($name in $expectedPayloadNames) {
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly(
+ (Join-Path $payload $name), $false, $true)
+ }
+ if ($null -ne $ManifestWrite) {
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly(
+ (Join-Path $capture 'manifest.json'), $false, $true)
+ }
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($payload, $true, $true)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($capture, $true, $true)
+ Remove-Item -LiteralPath $capture -Recurse -Force -ErrorAction Stop
+}
+
+function Remove-GraphKitAuthVerifiedEmptyInstallCandidate {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $StageRoot,
+ [Parameter(Mandatory)][string] $InstallName,
+ [Parameter(Mandatory)] $InstallEvidence,
+ [Parameter(Mandatory)][string] $FullVersion,
+ [Parameter(Mandatory)] $TemporaryVersionEvidence
+ )
+ $installRoot = Join-Path $StageRoot $InstallName
+ $temporaryVersionRoot = Join-Path $installRoot $FullVersion
+ $currentInstall = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $StageRoot, $InstallName)
+ $currentVersion = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $installRoot, $FullVersion)
+ if ([string]$currentInstall.NativeIdentity -cne [string]$InstallEvidence.NativeIdentity -or
+ [string]$currentInstall.PhysicalPath -cne [string]$InstallEvidence.PhysicalPath -or
+ [string]$currentVersion.NativeIdentity -cne [string]$TemporaryVersionEvidence.NativeIdentity -or
+ [string]$currentVersion.PhysicalPath -cne [string]$TemporaryVersionEvidence.PhysicalPath -or
+ @([IO.Directory]::EnumerateFileSystemEntries($temporaryVersionRoot)).Count -ne 0) {
+ throw 'The GraphKit.Auth incomplete install wrapper is ambiguous.'
+ }
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly(
+ $temporaryVersionRoot, $true, $true)
+ Remove-Item -LiteralPath $temporaryVersionRoot -Force -ErrorAction Stop
+ if (@([IO.Directory]::EnumerateFileSystemEntries($installRoot)).Count -ne 0) {
+ throw 'The GraphKit.Auth incomplete install root became non-empty during cleanup.'
+ }
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($installRoot, $true, $true)
+ Remove-Item -LiteralPath $installRoot -Force -ErrorAction Stop
+}
+
+function Assert-GraphKitAuthSafeSegment {
+ param([Parameter(Mandatory)][string] $Value, [Parameter(Mandatory)][string] $Kind)
+ if ([string]::IsNullOrWhiteSpace($Value) -or $Value -in @('.', '..') -or
+ [IO.Path]::IsPathRooted($Value) -or $Value.IndexOfAny([char[]]@('/', '\')) -ge 0 -or
+ -not $Value.IsNormalized([Text.NormalizationForm]::FormC)) {
+ throw "The GraphKit.Auth $Kind '$Value' is not one safe NFC path segment."
+ }
+}
+
+function Assert-GraphKitAuthPortableNameSet {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Names,
+ [Parameter(Mandatory)][string] $Kind
+ )
+ $portable = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ $normalized = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ foreach ($name in $Names) {
+ if ([string]::IsNullOrWhiteSpace($name) -or $name.IndexOf('\') -ge 0 -or
+ -not $name.IsNormalized([Text.NormalizationForm]::FormC)) {
+ throw "The GraphKit.Auth $Kind contains an unsafe or non-NFC name."
+ }
+ if (-not $portable.Add($name) -or
+ -not $normalized.Add($name.Normalize([Text.NormalizationForm]::FormC))) {
+ throw "The GraphKit.Auth $Kind contains a portable alias for '$name'."
+ }
+ }
+}
+
+function Assert-GraphKitAuthExactDirectoryClosure {
+ param(
+ [Parameter(Mandatory)][string] $Directory,
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $ExpectedNames,
+ [Parameter(Mandatory)][string] $Kind
+ )
+ if (-not (Test-Path -LiteralPath $Directory -PathType Container)) {
+ throw "The GraphKit.Auth $Kind directory '$Directory' is missing."
+ }
+ $actual = [Collections.Generic.List[string]]::new()
+ foreach ($entryPath in [IO.Directory]::EnumerateFileSystemEntries($Directory)) {
+ $actual.Add([IO.Path]::GetFileName($entryPath))
+ }
+ Assert-GraphKitAuthPortableNameSet -Names @($actual) -Kind $Kind
+ $actualSorted = @($actual | Sort-Object)
+ $expectedSorted = @($ExpectedNames | Sort-Object)
+ if (($actualSorted -join '|') -cne ($expectedSorted -join '|')) {
+ throw "The GraphKit.Auth $Kind closure is not exact. Expected '$($expectedSorted -join '|')'; found '$($actualSorted -join '|')'."
+ }
+}
+
+function Test-GraphKitAuthContainedPhysicalPath {
+ param([Parameter(Mandatory)][string] $Root, [Parameter(Mandatory)][string] $Candidate)
+ $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
+ $rootPath = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
+ $candidatePath = [IO.Path]::GetFullPath($Candidate)
+ $prefix = $rootPath + [IO.Path]::DirectorySeparatorChar
+ return $candidatePath.StartsWith($prefix, $comparison)
+}
+
+function Test-GraphKitAuthSealedPermission {
+ param($Evidence, [Parameter(Mandatory)][bool] $Directory)
+ if ($Evidence.OwnerWritable) { return $false }
+ if ($IsWindows) {
+ $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
+ return -not [string]::IsNullOrWhiteSpace([string]$Evidence.CurrentOwnerSid) -and
+ [string]$Evidence.OwnerSid -ceq [string]$Evidence.CurrentOwnerSid -and
+ [string]$Evidence.CurrentIdentitySid -ceq $currentSid -and
+ [bool]$Evidence.AccessRulesProtected -and
+ -not [bool]$Evidence.HasInheritedAccessRules -and
+ [bool]$Evidence.ExactOwnerOnlyAccess -and
+ ($Directory -or [bool]$Evidence.FileReadOnly)
+ }
+ $expected = if ($Directory) { 0x140 } else { 0x100 } # 0500 / 0400
+ return [int]$Evidence.UnixMode -eq $expected -and
+ [uint32]$Evidence.OwnerUid -eq [uint32]$Evidence.EffectiveUid
+}
+
+function Test-GraphKitAuthOwnerOnlyWritableDirectory {
+ param([Parameter(Mandatory)] $Evidence)
+ Initialize-GraphKitAuthStageCapture
+ return $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess($Evidence)
+}
+
+function ConvertTo-GraphKitAuthCanonicalJsonBytes {
+ param([Parameter(Mandatory)] $Value)
+ $json = $Value | ConvertTo-Json -Depth 12 -Compress
+ return [Text.UTF8Encoding]::new($false, $true).GetBytes($json)
+}
+
+function Get-GraphKitAuthSha256 {
+ param([Parameter(Mandatory)][byte[]] $Bytes)
+ return [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($Bytes)).ToLowerInvariant()
+}
+
+function Test-GraphKitAuthBytesEqual {
+ param([Parameter(Mandatory)][byte[]] $Left, [Parameter(Mandatory)][byte[]] $Right)
+ if ($Left.Length -ne $Right.Length) { return $false }
+ for ($index = 0; $index -lt $Left.Length; $index++) {
+ if ($Left[$index] -ne $Right[$index]) { return $false }
+ }
+ return $true
+}
+
+function Test-GraphKitAuthSealedStage {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $StagePath,
+ [Parameter(Mandatory)][string] $FullVersion
+ )
+ Initialize-GraphKitAuthStageCapture
+ Assert-GraphKitAuthSafeSegment -Value $FullVersion -Kind 'full version'
+ $stage = [IO.Path]::GetFullPath($StagePath)
+ if (-not (Test-Path -LiteralPath $stage -PathType Container)) { throw "GraphKit.Auth stage '$stage' is missing." }
+ $digestName = [IO.Path]::GetFileName($stage)
+ if ($digestName -cnotmatch '^[0-9a-f]{64}$') { throw "GraphKit.Auth stage '$stage' is not digest-named." }
+ $versionRoot = Split-Path $stage -Parent
+ if ([IO.Path]::GetFileName($versionRoot) -cne $FullVersion) {
+ throw "GraphKit.Auth stage version does not match '$FullVersion'."
+ }
+
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $stage -ExpectedNames @('manifest.json','payload') -Kind 'stage envelope'
+ $stageRoot = Split-Path $versionRoot -Parent
+ $versionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($stageRoot, $FullVersion)
+ $envelope = $script:GraphKitAuthStageCaptureType::InspectDirectory($versionRoot, $digestName)
+ $manifestEvidence = $script:GraphKitAuthStageCaptureType::InspectFile($stage, 'manifest.json')
+ if ($manifestEvidence.LinkCount -ne 1) { throw 'The GraphKit.Auth manifest is not link-count one.' }
+ if ($manifestEvidence.Sha256 -cne $digestName) { throw 'The GraphKit.Auth manifest digest does not match its stage path.' }
+ if (-not (Test-GraphKitAuthSealedPermission $versionEvidence $true) -or
+ -not (Test-GraphKitAuthSealedPermission $envelope $true) -or
+ -not (Test-GraphKitAuthSealedPermission $manifestEvidence $false)) {
+ throw 'The GraphKit.Auth version, envelope, or manifest is writable or has the wrong permission policy.'
+ }
+
+ $manifestBytes = $script:GraphKitAuthStageCaptureType::ReadFile($stage, 'manifest.json')
+ try { $manifest = [Text.UTF8Encoding]::new($false, $true).GetString($manifestBytes) | ConvertFrom-Json -Depth 12 }
+ catch { throw "The GraphKit.Auth canonical manifest is invalid JSON: $($_.Exception.Message)" }
+ if ((Get-GraphKitAuthSha256 $manifestBytes) -cne $digestName) { throw 'The GraphKit.Auth manifest changed during validation.' }
+ $canonical = ConvertTo-GraphKitAuthCanonicalJsonBytes $manifest
+ if (-not (Test-GraphKitAuthBytesEqual $manifestBytes $canonical)) {
+ throw 'The GraphKit.Auth manifest is not canonical UTF-8 JSON.'
+ }
+ if (($manifest.PSObject.Properties.Name -join '|') -cne 'schemaVersion|fullVersion|permissions|directories|files|manifest' -or
+ [int]$manifest.schemaVersion -ne 1 -or [string]$manifest.fullVersion -cne $FullVersion) {
+ throw 'The GraphKit.Auth manifest schema or version is invalid.'
+ }
+ if (($manifest.permissions.PSObject.Properties.Name -join '|') -cne 'file|directory' -or
+ [string]$manifest.permissions.file -cne $(if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0400' }) -or
+ [string]$manifest.permissions.directory -cne $(if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0500' })) {
+ throw 'The GraphKit.Auth manifest permission policy is invalid.'
+ }
+ if (($manifest.directories.PSObject.Properties.Name -join '|') -cne 'envelope|payload' -or
+ ($manifest.directories.envelope.PSObject.Properties.Name -join '|') -cne 'nativeIdentity' -or
+ ($manifest.directories.payload.PSObject.Properties.Name -join '|') -cne 'nativeIdentity') {
+ throw 'The GraphKit.Auth directory evidence schema is invalid.'
+ }
+ if ([string]$manifest.directories.envelope.nativeIdentity -cne [string]$envelope.NativeIdentity) {
+ throw 'The GraphKit.Auth envelope native identity changed.'
+ }
+
+ $payloadEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($stage, 'payload')
+ if ([string]$manifest.directories.payload.nativeIdentity -cne [string]$payloadEvidence.NativeIdentity -or
+ -not (Test-GraphKitAuthSealedPermission $payloadEvidence $true) -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $envelope.PhysicalPath $payloadEvidence.PhysicalPath)) {
+ throw 'The GraphKit.Auth payload directory identity, containment, or permission policy is invalid.'
+ }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory (Join-Path $stage 'payload') -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'payload'
+ if (@($manifest.files).Count -ne $script:GraphKitAuthPayloadFiles.Count) { throw 'The GraphKit.Auth manifest file count is invalid.' }
+ $expectedManifestPaths = @($script:GraphKitAuthPayloadFiles | ForEach-Object { "payload/$_" })
+ $manifestPaths = @($manifest.files | ForEach-Object { [string]$_.path })
+ if (($manifestPaths -join '|') -cne ($expectedManifestPaths -join '|')) { throw 'The GraphKit.Auth manifest file order or closure is invalid.' }
+ foreach ($record in @($manifest.files)) {
+ if (($record.PSObject.Properties.Name -join '|') -cne 'path|length|sha256|nativeIdentity|linkCount') {
+ throw "The GraphKit.Auth file evidence schema is invalid for '$($record.path)'."
+ }
+ $relative = [string]$record.path
+ if ($relative.IndexOf('\') -ge 0 -or -not $relative.IsNormalized([Text.NormalizationForm]::FormC)) {
+ throw "The GraphKit.Auth manifest path '$relative' is unsafe."
+ }
+ $fileEvidence = $script:GraphKitAuthStageCaptureType::InspectFile($stage, $relative)
+ if ($fileEvidence.LinkCount -ne 1 -or [long]$record.linkCount -ne 1 -or
+ [long]$record.length -ne $fileEvidence.Length -or [string]$record.sha256 -cne [string]$fileEvidence.Sha256 -or
+ [string]$record.nativeIdentity -cne [string]$fileEvidence.NativeIdentity -or
+ -not (Test-GraphKitAuthSealedPermission $fileEvidence $false) -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $payloadEvidence.PhysicalPath $fileEvidence.PhysicalPath)) {
+ throw "The GraphKit.Auth payload evidence failed for '$relative'."
+ }
+ }
+ if (($manifest.manifest.PSObject.Properties.Name -join '|') -cne 'linkCount' -or [long]$manifest.manifest.linkCount -ne 1) {
+ throw 'The GraphKit.Auth manifest policy record is invalid.'
+ }
+ return [pscustomobject]@{
+ StagePath = $stage
+ PayloadPath = Join-Path $stage 'payload'
+ ManifestPath = Join-Path $stage 'manifest.json'
+ ManifestSha256 = $digestName
+ FullVersion = $FullVersion
+ Manifest = $manifest
+ }
+}
+
+function New-GraphKitAuthSealedStage {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $OutputRoot,
+ [Parameter(Mandatory)][string] $FullVersion,
+ [Parameter(Mandatory)][string] $PayloadSourceRoot,
+ [scriptblock] $BeforeVersionInstall,
+ [scriptblock] $AfterOwnedDirectoryCreate,
+ [scriptblock] $AfterVersionDestinationCheck
+ )
+ Initialize-GraphKitAuthStageCapture
+ Assert-GraphKitAuthSafeSegment -Value $FullVersion -Kind 'full version'
+ $source = [IO.Path]::GetFullPath($PayloadSourceRoot)
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $source -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'capture source'
+ $authRoot = Get-GraphKitAuthOutputRoot $OutputRoot
+ $authParent = Split-Path $authRoot -Parent
+ if (-not (Test-Path -LiteralPath $authParent -PathType Container)) {
+ $null = [IO.Directory]::CreateDirectory($authParent)
+ }
+ $authParentParent = Split-Path $authParent -Parent
+ $authParentName = [IO.Path]::GetFileName($authParent)
+ $authParentEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $authParentParent, $authParentName)
+ $captureRoot = Join-Path $authRoot 'capture'
+ $stageRoot = Join-Path $authRoot 'stage'
+ $versionRoot = Join-Path $stageRoot $FullVersion
+ $authEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authParent `
+ -ParentEvidence $authParentEvidence -ChildName ([IO.Path]::GetFileName($authRoot)) `
+ -Kind 'auth root' -AfterChildInspection $AfterOwnedDirectoryCreate `
+ -PreserveCreatedOnFailure
+ $captureRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot `
+ -ParentEvidence $authEvidence -ChildName 'capture' -Kind 'capture root' `
+ -AfterChildInspection $AfterOwnedDirectoryCreate -PreserveCreatedOnFailure
+ $stageRootEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $authRoot `
+ -ParentEvidence $authEvidence -ChildName 'stage' -Kind 'stage root' `
+ -AfterChildInspection $AfterOwnedDirectoryCreate -PreserveCreatedOnFailure
+ $runId = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(24)).ToLowerInvariant()
+ $capture = Join-Path $captureRoot $runId
+ $payload = Join-Path $capture 'payload'
+ $installName = ".install-$runId"
+ $installRoot = Join-Path $stageRoot $installName
+ $temporaryVersionRoot = Join-Path $installRoot $FullVersion
+ $captureEvidence = $null
+ $payloadEvidence = $null
+ $installEvidence = $null
+ $temporaryVersionEvidence = $null
+ $candidateStagePath = $null
+ $manifestWrite = $null
+ $capturedFileEvidence = [ordered]@{}
+ try {
+ $captureEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $captureRoot `
+ -ParentEvidence $captureRootEvidence -ChildName $runId -Kind 'capture envelope' `
+ -AfterChildInspection $AfterOwnedDirectoryCreate
+ $payloadEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $capture `
+ -ParentEvidence $captureEvidence -ChildName 'payload' -Kind 'capture payload' `
+ -AfterChildInspection $AfterOwnedDirectoryCreate
+ $records = [Collections.Generic.List[object]]::new()
+ foreach ($name in $script:GraphKitAuthPayloadFiles) {
+ $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew(
+ $source, $name, $payload, $name, $true)
+ $capturedFileEvidence[$name] = $copy.Destination
+ if ($copy.Source.LinkCount -ne 1 -or $copy.Destination.LinkCount -ne 1) {
+ throw "GraphKit.Auth capture source or destination '$name' is not link-count one."
+ }
+ $records.Add([ordered]@{
+ path = "payload/$name"
+ length = [long]$copy.Destination.Length
+ sha256 = [string]$copy.Destination.Sha256
+ nativeIdentity = [string]$copy.Destination.NativeIdentity
+ linkCount = [long]1
+ })
+ }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $payload -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'new capture payload'
+ $envelopeEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($captureRoot, $runId)
+ $payloadEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($capture, 'payload')
+ $manifest = [ordered]@{
+ schemaVersion = 1
+ fullVersion = $FullVersion
+ permissions = [ordered]@{
+ file = if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0400' }
+ directory = if ($IsWindows) { 'windows-owner-read-execute' } else { 'unix-0500' }
+ }
+ directories = [ordered]@{
+ envelope = [ordered]@{ nativeIdentity = [string]$envelopeEvidence.NativeIdentity }
+ payload = [ordered]@{ nativeIdentity = [string]$payloadEvidence.NativeIdentity }
+ }
+ files = @($records)
+ manifest = [ordered]@{ linkCount = [long]1 }
+ }
+ $manifestBytes = ConvertTo-GraphKitAuthCanonicalJsonBytes $manifest
+ $manifestWrite = $script:GraphKitAuthStageCaptureType::WriteFileCreateNew(
+ $capture, 'manifest.json', $manifestBytes, $true)
+ $manifestPath = Join-Path $capture 'manifest.json'
+ foreach ($name in $script:GraphKitAuthPayloadFiles) {
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly((Join-Path $payload $name), $false, $false)
+ }
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($manifestPath, $false, $false)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($payload, $true, $false)
+ $installEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $stageRoot `
+ -ParentEvidence $stageRootEvidence -ChildName $installName -Kind 'temporary install root' `
+ -AfterChildInspection $AfterOwnedDirectoryCreate
+ $temporaryVersionEvidence = Initialize-GraphKitAuthOwnerDirectory -ParentPath $installRoot `
+ -ParentEvidence $installEvidence -ChildName $FullVersion -Kind 'temporary version root' `
+ -AfterChildInspection $AfterOwnedDirectoryCreate
+ $digest = Get-GraphKitAuthSha256 $manifestBytes
+ $candidateStagePath = Join-Path $temporaryVersionRoot $digest
+ [IO.Directory]::Move($capture, $candidateStagePath)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($candidateStagePath, $true, $false)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($temporaryVersionRoot, $true, $false)
+ $null = Test-GraphKitAuthSealedStage -StagePath $candidateStagePath -FullVersion $FullVersion
+ if ($null -ne $BeforeVersionInstall) {
+ & $BeforeVersionInstall $temporaryVersionRoot
+ }
+ $preMoveVersionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $installRoot, $FullVersion)
+ if ([string]$preMoveVersionEvidence.NativeIdentity -cne [string]$temporaryVersionEvidence.NativeIdentity -or
+ [string]$preMoveVersionEvidence.PhysicalPath -cne [string]$temporaryVersionEvidence.PhysicalPath) {
+ throw 'The GraphKit.Auth temporary version wrapper changed before atomic installation.'
+ }
+ $versionEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $stageRoot `
+ -ChildName $FullVersion -Kind 'stage version'
+ if ($versionEntry.Exists) {
+ throw "The GraphKit.Auth stage version '$FullVersion' already exists."
+ }
+ if ($null -ne $AfterVersionDestinationCheck) {
+ & $AfterVersionDestinationCheck $temporaryVersionRoot $versionRoot
+ }
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($temporaryVersionRoot, $true, $true)
+ $movedVersion = $false
+ try {
+ $script:GraphKitAuthStageCaptureType::MoveDirectoryCreateNew(
+ $temporaryVersionRoot, $versionRoot)
+ $movedVersion = $true
+ }
+ finally {
+ $moveParent = if ($movedVersion) { $stageRoot } else { $installRoot }
+ $movePath = if ($movedVersion) { $versionRoot } else { $temporaryVersionRoot }
+ $currentVersionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $moveParent, $FullVersion)
+ if ([string]$currentVersionEvidence.NativeIdentity -cne [string]$temporaryVersionEvidence.NativeIdentity) {
+ throw 'The GraphKit.Auth version wrapper changed during atomic installation; resealing was refused.'
+ }
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($movePath, $true, $false)
+ }
+ $finalVersionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $stageRoot, $FullVersion)
+ if ([string]$finalVersionEvidence.NativeIdentity -cne [string]$temporaryVersionEvidence.NativeIdentity -or
+ -not (Test-GraphKitAuthContainedPhysicalPath $stageRootEvidence.PhysicalPath $finalVersionEvidence.PhysicalPath)) {
+ throw 'The GraphKit.Auth atomic version install did not preserve its native identity and containment.'
+ }
+ $currentInstall = $script:GraphKitAuthStageCaptureType::InspectDirectory($stageRoot, $installName)
+ if ([string]$currentInstall.NativeIdentity -cne [string]$installEvidence.NativeIdentity -or
+ [string]$currentInstall.PhysicalPath -cne [string]$installEvidence.PhysicalPath -or
+ @([IO.Directory]::EnumerateFileSystemEntries($installRoot)).Count -ne 0) {
+ throw 'The GraphKit.Auth successful install left an ambiguous temporary wrapper.'
+ }
+ Remove-Item -LiteralPath $installRoot -Force -ErrorAction Stop
+ $finalPath = Join-Path $versionRoot $digest
+ $verified = Test-GraphKitAuthSealedStage -StagePath $finalPath -FullVersion $FullVersion
+ $verified | Add-Member -MemberType NoteProperty -Name ManifestInitialEvidence `
+ -Value $manifestWrite.DestinationInitial -Force
+ return $verified
+ }
+ catch {
+ $primary = $_
+ $cleanupFailures = [Collections.Generic.List[string]]::new()
+ if ($null -ne $captureEvidence) {
+ try {
+ $captureEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $captureRoot `
+ -ChildName $runId -Kind 'capture envelope cleanup'
+ if ($captureEntry.Exists) {
+ if ($null -ne $payloadEvidence) {
+ Remove-GraphKitAuthVerifiedCaptureCandidate -CaptureRoot $captureRoot `
+ -CaptureName $runId -CaptureEvidence $captureEvidence `
+ -PayloadEvidence $payloadEvidence -CapturedFileEvidence $capturedFileEvidence `
+ -ManifestWrite $manifestWrite
+ }
+ else {
+ Remove-GraphKitAuthVerifiedEmptyDirectory -ParentPath $captureRoot `
+ -ParentEvidence $captureRootEvidence -ChildName $runId `
+ -ChildEvidence $captureEvidence -Kind 'empty capture envelope cleanup'
+ }
+ }
+ }
+ catch { $cleanupFailures.Add($_.Exception.Message) }
+ }
+ if ($null -ne $candidateStagePath -and (Test-Path -LiteralPath $candidateStagePath -PathType Container)) {
+ try {
+ Remove-GraphKitAuthVerifiedInstallCandidate -StageRoot $stageRoot `
+ -InstallName $installName -InstallEvidence $installEvidence `
+ -FullVersion $FullVersion -TemporaryVersionEvidence $temporaryVersionEvidence `
+ -CandidateStagePath $candidateStagePath
+ }
+ catch { $cleanupFailures.Add($_.Exception.Message) }
+ }
+ elseif ($null -ne $installEvidence) {
+ try {
+ $installEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $stageRoot `
+ -ChildName $installName -Kind 'temporary install cleanup'
+ if ($installEntry.Exists) {
+ $temporaryVersionEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $installRoot `
+ -ChildName $FullVersion -Kind 'temporary version cleanup'
+ if ($null -ne $temporaryVersionEvidence -and $temporaryVersionEntry.Exists) {
+ Remove-GraphKitAuthVerifiedEmptyInstallCandidate -StageRoot $stageRoot `
+ -InstallName $installName -InstallEvidence $installEvidence `
+ -FullVersion $FullVersion -TemporaryVersionEvidence $temporaryVersionEvidence
+ }
+ elseif (-not $temporaryVersionEntry.Exists) {
+ Remove-GraphKitAuthVerifiedEmptyDirectory -ParentPath $stageRoot `
+ -ParentEvidence $stageRootEvidence -ChildName $installName `
+ -ChildEvidence $installEvidence -Kind 'empty temporary install cleanup'
+ }
+ else {
+ throw 'The GraphKit.Auth temporary version has no ownership evidence; ambiguous cleanup was refused.'
+ }
+ }
+ }
+ catch { $cleanupFailures.Add($_.Exception.Message) }
+ }
+ if ($cleanupFailures.Count -ne 0) {
+ throw "GraphKit.Auth stage creation failed and ambiguous cleanup was refused: $($cleanupFailures -join ' | ') Original failure: $($primary.Exception.Message)"
+ }
+ if ($primary.Exception.Message -match 'portable alias|stage version .* already exists\.$') {
+ throw $primary
+ }
+ $finalMatches = @([IO.Directory]::EnumerateFileSystemEntries($stageRoot) | Where-Object {
+ [IO.Path]::GetFileName($_) -ceq $FullVersion
+ })
+ if ($finalMatches.Count -eq 1) {
+ throw "GraphKit.Auth stage version '$FullVersion' already exists or won the atomic install: $($primary.Exception.Message)"
+ }
+ throw $primary
+ }
+}
+
+function Invoke-GraphKitAuthPrepareClean {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][string] $OutputRoot)
+ Initialize-GraphKitAuthStageCapture
+ $authRoot = Get-GraphKitAuthOutputRoot $OutputRoot
+ $authParent = Split-Path $authRoot -Parent
+ if (-not (Test-Path -LiteralPath $authParent -PathType Container)) { return @() }
+ $authEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $authParent `
+ -ChildName ([IO.Path]::GetFileName($authRoot)) -Kind 'Prepare auth root'
+ if (-not $authEntry.Exists) { return @() }
+ $authParentParent = Split-Path $authParent -Parent
+ $authParentName = [IO.Path]::GetFileName($authParent)
+ $authParentEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $authParentParent, $authParentName)
+ $authEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $authParent, [IO.Path]::GetFileName($authRoot))
+ if (-not (Test-GraphKitAuthContainedPhysicalPath $authParentEvidence.PhysicalPath $authEvidence.PhysicalPath)) {
+ throw 'The GraphKit.Auth Prepare auth root is not physically contained.'
+ }
+ if (-not (Test-GraphKitAuthOwnerOnlyWritableDirectory $authEvidence)) {
+ throw 'The GraphKit.Auth Prepare auth root is not exact current-owner-only writable.'
+ }
+ $captureEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $authRoot `
+ -ChildName 'capture' -Kind 'Prepare capture root'
+ if (-not $captureEntry.Exists) {
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $authRoot `
+ -ExpectedNames @() -Kind 'Prepare partial auth root, which must be empty'
+ return @()
+ }
+ $captureEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($authRoot, 'capture')
+ if (-not (Test-GraphKitAuthContainedPhysicalPath $authEvidence.PhysicalPath $captureEvidence.PhysicalPath)) {
+ throw 'The GraphKit.Auth Prepare capture root is not physically contained.'
+ }
+ if (-not (Test-GraphKitAuthOwnerOnlyWritableDirectory $captureEvidence)) {
+ throw 'The GraphKit.Auth Prepare capture root is not exact current-owner-only writable.'
+ }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $captureEntry.Path `
+ -ExpectedNames @() -Kind 'Prepare capture root, which must be empty'
+ $stageEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $authRoot `
+ -ChildName 'stage' -Kind 'Prepare stage root'
+ if (-not $stageEntry.Exists) {
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $authRoot `
+ -ExpectedNames @('capture') -Kind 'Prepare partial auth root'
+ return @()
+ }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $authRoot `
+ -ExpectedNames @('capture','stage') -Kind 'Prepare authority root'
+ $stageRoot = Join-Path $authRoot 'stage'
+ $stageRootEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($authRoot, 'stage')
+ if (-not (Test-GraphKitAuthContainedPhysicalPath $authEvidence.PhysicalPath $stageRootEvidence.PhysicalPath)) {
+ throw 'The GraphKit.Auth Prepare stage root is not physically contained.'
+ }
+ if (-not (Test-GraphKitAuthOwnerOnlyWritableDirectory $stageRootEvidence)) {
+ throw 'The GraphKit.Auth Prepare stage root is not exact current-owner-only writable.'
+ }
+ $verified = [Collections.Generic.List[object]]::new()
+ $versionPaths = @([IO.Directory]::EnumerateFileSystemEntries($stageRoot))
+ $versionNames = @($versionPaths | ForEach-Object { [IO.Path]::GetFileName($_) })
+ Assert-GraphKitAuthPortableNameSet -Names $versionNames -Kind 'stage version namespace'
+ foreach ($versionPath in $versionPaths) {
+ $version = [IO.Path]::GetFileName($versionPath)
+ $versionEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory($stageRoot, $version)
+ if (-not (Test-GraphKitAuthContainedPhysicalPath $stageRootEvidence.PhysicalPath $versionEvidence.PhysicalPath) -or
+ -not (Test-GraphKitAuthSealedPermission $versionEvidence $true)) {
+ throw "GraphKit.Auth prior stage version '$version' is linked, escaped, or writable."
+ }
+ $digests = @([IO.Directory]::EnumerateFileSystemEntries($versionPath))
+ if ($digests.Count -ne 1) { throw "GraphKit.Auth prior stage version '$version' is not an exact envelope container." }
+ $verified.Add((Test-GraphKitAuthSealedStage -StagePath $digests[0] -FullVersion $version))
+ }
+ foreach ($stage in $verified) {
+ foreach ($file in @($stage.Manifest.files)) {
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly((Join-Path $stage.StagePath ([string]$file.path)), $false, $true)
+ }
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stage.ManifestPath, $false, $true)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stage.PayloadPath, $true, $true)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly($stage.StagePath, $true, $true)
+ $script:GraphKitAuthStageCaptureType::SetOwnerOnly((Split-Path $stage.StagePath -Parent), $true, $true)
+ }
+ return @($verified)
+}
+
+function New-GraphKitAuthTaskQuarantineRoot {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][string] $OutputRoot)
+ Initialize-GraphKitAuthStageCapture
+ $output = [IO.Path]::GetFullPath($OutputRoot)
+ if (-not (Test-Path -LiteralPath $output -PathType Container)) {
+ $null = [IO.Directory]::CreateDirectory($output)
+ }
+ $outputEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($output)
+ $quarantineName = 'GraphKit.Auth.quarantine-' + [guid]::NewGuid().ToString('N')
+ $quarantine = Join-Path $output $quarantineName
+ $quarantineEvidence = $script:GraphKitAuthStageCaptureType::CreateDirectoryOwnerOnly(
+ $output, $quarantineName)
+ if (-not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess(
+ $quarantineEvidence) -or
+ -not (Test-GraphKitAuthContainedPhysicalPath `
+ $outputEvidence.PhysicalPath $quarantineEvidence.PhysicalPath)) {
+ throw 'The GraphKit.Auth generated-root quarantine was not created with exact owner-only access.'
+ }
+ return [pscustomobject]@{
+ PSTypeName = 'GraphKit.Auth.TaskQuarantineRoot'
+ Name = $quarantineName
+ Path = $quarantine
+ Evidence = $quarantineEvidence
+ OutputPath = $output
+ OutputEvidence = $outputEvidence
+ }
+}
+
+function Invoke-GraphKitAuthLiteralQuarantine {
+ param([Parameter(Mandatory)][string] $RepositoryRoot)
+ # Directory.Move is the identity-preserving quarantine primitive. Keep its
+ # destination beneath the repository output tree so source and destination
+ # remain on the same volume instead of depending on the OS temp volume.
+ $quarantineRoot = New-GraphKitAuthTaskQuarantineRoot `
+ -OutputRoot (Join-Path $RepositoryRoot 'output')
+ $quarantine = $quarantineRoot.Path
+ $relativeRoots = @(
+ 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin'
+ 'src/GraphKit.Auth/GraphKit.Auth.Contracts/obj'
+ 'src/GraphKit.Auth/GraphKit.Auth/bin'
+ 'src/GraphKit.Auth/GraphKit.Auth/obj'
+ 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin'
+ 'src/GraphKit.Auth/GraphKit.Auth.Tests/obj'
+ 'src/GraphKit.Auth/GraphKit.Auth.Tests/TestResults'
+ )
+ foreach ($relative in $relativeRoots) {
+ $source = Join-Path $RepositoryRoot $relative
+ if (Test-Path -LiteralPath $source) {
+ $destination = Join-Path $quarantine ($relative.Replace('/', '__'))
+ [IO.Directory]::Move([IO.Path]::GetFullPath($source), $destination)
+ }
+ }
+ return $quarantine
+}
+
+function Move-GraphKitAuthBuildWorkToQuarantine {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)] $BuildWork,
+ [Parameter(Mandatory)][string] $QuarantineRoot,
+ [scriptblock] $BeforeMove
+ )
+ Initialize-GraphKitAuthStageCapture
+ foreach ($property in @(
+ 'Name','Path','Evidence','CapturePath','CaptureEvidence',
+ 'AuthPath','AuthEvidence','OutputPath','OutputEvidence'
+ )) {
+ if ($null -eq $BuildWork.PSObject.Properties[$property]) {
+ throw "The GraphKit.Auth build workspace record is missing '$property'."
+ }
+ }
+ $name = [string]$BuildWork.Name
+ if ($name -cnotmatch '^\.build-[0-9a-f]{48}$') {
+ throw "The GraphKit.Auth build workspace name '$name' is not exact."
+ }
+ $captureRoot = [IO.Path]::GetFullPath([string]$BuildWork.CapturePath)
+ $source = [IO.Path]::GetFullPath([string]$BuildWork.Path)
+ if ($source -cne [IO.Path]::GetFullPath((Join-Path $captureRoot $name))) {
+ throw 'The GraphKit.Auth build workspace path is not the exact captured child.'
+ }
+ $authRoot = [IO.Path]::GetFullPath([string]$BuildWork.AuthPath)
+ $outputRoot = [IO.Path]::GetFullPath([string]$BuildWork.OutputPath)
+ $comparison = if ($IsWindows) {
+ [StringComparison]::OrdinalIgnoreCase
+ }
+ else { [StringComparison]::Ordinal }
+ if (-not $captureRoot.Equals((Join-Path $authRoot 'capture'), $comparison) -or
+ -not $authRoot.Equals((Join-Path $outputRoot 'GraphKit.Auth'), $comparison)) {
+ throw 'The GraphKit.Auth build workspace parent lineage is not exact.'
+ }
+ $quarantine = [IO.Path]::GetFullPath($QuarantineRoot)
+ $quarantineParent = Split-Path $quarantine -Parent
+ $quarantineName = [IO.Path]::GetFileName($quarantine)
+ if ($quarantineName -cnotmatch '^GraphKit\.Auth\.quarantine-[0-9a-f]{32}$') {
+ throw 'The GraphKit.Auth build workspace quarantine is not one task-specific sibling.'
+ }
+ if (-not $quarantineParent.Equals($outputRoot, $comparison)) {
+ throw 'The GraphKit.Auth build workspace quarantine is not beneath the exact captured output root.'
+ }
+ $currentOutput = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($outputRoot)
+ if ([string]$currentOutput.NativeIdentity -cne `
+ [string]$BuildWork.OutputEvidence.NativeIdentity -or
+ [string]$currentOutput.PhysicalPath -cne `
+ [string]$BuildWork.OutputEvidence.PhysicalPath) {
+ throw 'The GraphKit.Auth build workspace output parent changed identity; ambiguous cleanup was refused.'
+ }
+ $currentAuth = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $outputRoot, 'GraphKit.Auth')
+ if ([string]$currentAuth.NativeIdentity -cne `
+ [string]$BuildWork.AuthEvidence.NativeIdentity -or
+ [string]$currentAuth.PhysicalPath -cne `
+ [string]$BuildWork.AuthEvidence.PhysicalPath -or
+ -not (Test-GraphKitAuthContainedPhysicalPath `
+ $currentOutput.PhysicalPath $currentAuth.PhysicalPath)) {
+ throw 'The GraphKit.Auth build workspace authority parent changed identity; ambiguous cleanup was refused.'
+ }
+ $quarantineEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $quarantineParent, $quarantineName)
+ if (-not $script:GraphKitAuthStageCaptureType::HasInitialOwnerOnlyDirectoryAccess(
+ $quarantineEvidence) -or
+ -not (Test-GraphKitAuthContainedPhysicalPath `
+ $currentOutput.PhysicalPath $quarantineEvidence.PhysicalPath)) {
+ throw 'The GraphKit.Auth build workspace quarantine is not exact current-owner-only writable.'
+ }
+ if ($null -ne $BeforeMove) { & $BeforeMove $source $quarantine }
+ $currentOutput = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($outputRoot)
+ if ([string]$currentOutput.NativeIdentity -cne `
+ [string]$BuildWork.OutputEvidence.NativeIdentity -or
+ [string]$currentOutput.PhysicalPath -cne `
+ [string]$BuildWork.OutputEvidence.PhysicalPath) {
+ throw 'The GraphKit.Auth build workspace output parent changed identity before the move; ambiguous cleanup was refused.'
+ }
+ $currentAuth = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $outputRoot, 'GraphKit.Auth')
+ if ([string]$currentAuth.NativeIdentity -cne `
+ [string]$BuildWork.AuthEvidence.NativeIdentity -or
+ [string]$currentAuth.PhysicalPath -cne `
+ [string]$BuildWork.AuthEvidence.PhysicalPath -or
+ -not (Test-GraphKitAuthContainedPhysicalPath `
+ $currentOutput.PhysicalPath $currentAuth.PhysicalPath)) {
+ throw 'The GraphKit.Auth build workspace authority parent changed identity before the move; ambiguous cleanup was refused.'
+ }
+ $currentCapture = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath($captureRoot)
+ if ([string]$currentCapture.NativeIdentity -cne `
+ [string]$BuildWork.CaptureEvidence.NativeIdentity -or
+ [string]$currentCapture.PhysicalPath -cne `
+ [string]$BuildWork.CaptureEvidence.PhysicalPath) {
+ throw 'The GraphKit.Auth build workspace capture parent changed identity; ambiguous cleanup was refused.'
+ }
+ $current = $script:GraphKitAuthStageCaptureType::InspectDirectory($captureRoot, $name)
+ if ([string]$current.NativeIdentity -cne [string]$BuildWork.Evidence.NativeIdentity -or
+ [string]$current.PhysicalPath -cne [string]$BuildWork.Evidence.PhysicalPath -or
+ -not (Test-GraphKitAuthContainedPhysicalPath `
+ $currentCapture.PhysicalPath $current.PhysicalPath) -or
+ -not (Test-GraphKitAuthOwnerOnlyWritableDirectory $current)) {
+ throw 'The GraphKit.Auth build workspace changed identity before quarantine; ambiguous cleanup was refused.'
+ }
+ $currentQuarantine = $script:GraphKitAuthStageCaptureType::InspectDirectory(
+ $quarantineParent, $quarantineName)
+ if ([string]$currentQuarantine.NativeIdentity -cne `
+ [string]$quarantineEvidence.NativeIdentity -or
+ [string]$currentQuarantine.PhysicalPath -cne `
+ [string]$quarantineEvidence.PhysicalPath -or
+ -not (Test-GraphKitAuthContainedPhysicalPath `
+ $currentOutput.PhysicalPath $currentQuarantine.PhysicalPath)) {
+ throw 'The GraphKit.Auth build workspace quarantine changed identity before the move; ambiguous cleanup was refused.'
+ }
+ $destinationEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $quarantine `
+ -ChildName $name -Kind 'build workspace quarantine destination'
+ if ($destinationEntry.Exists) {
+ throw "The GraphKit.Auth build workspace quarantine destination '$name' already exists; no move was attempted."
+ }
+ $destination = Join-Path $quarantine $name
+ $script:GraphKitAuthStageCaptureType::MoveDirectoryCreateNew($source, $destination)
+ $moved = $script:GraphKitAuthStageCaptureType::InspectDirectory($quarantine, $name)
+ if ([string]$moved.NativeIdentity -cne [string]$BuildWork.Evidence.NativeIdentity -or
+ -not (Test-GraphKitAuthContainedPhysicalPath `
+ $currentQuarantine.PhysicalPath $moved.PhysicalPath) -or
+ -not (Test-GraphKitAuthOwnerOnlyWritableDirectory $moved)) {
+ throw 'The GraphKit.Auth quarantined build workspace changed identity; ambiguous cleanup was refused.'
+ }
+ $sourceEntry = Get-GraphKitAuthPortableChildEntry -ParentPath $captureRoot `
+ -ChildName $name -Kind 'quarantined build workspace source'
+ if ($sourceEntry.Exists) {
+ throw 'The GraphKit.Auth build workspace source was recreated during quarantine; ambiguous cleanup was refused.'
+ }
+ return [pscustomobject]@{
+ PSTypeName = 'GraphKit.Auth.QuarantinedBuildWorkRoot'
+ Name = $name
+ Path = $destination
+ Evidence = $moved
+ }
+}
+
+function Restore-GraphKitAuthGitConfigEnvironment {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)] $Environment)
+ foreach ($entry in @(Get-ChildItem Env:)) {
+ if ($entry.Name.StartsWith('GIT_CONFIG_', [StringComparison]::Ordinal)) {
+ Remove-Item -LiteralPath "Env:$($entry.Name)" -ErrorAction Stop
+ }
+ }
+ foreach ($entry in $Environment.GetEnumerator()) {
+ [Environment]::SetEnvironmentVariable([string]$entry.Key, [string]$entry.Value, 'Process')
+ }
+}
+
+function Enable-GraphKitAuthAbiTestGitExcludes {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $RepositoryRoot,
+ [Parameter(Mandatory)][string[]] $Patterns,
+ [scriptblock] $AfterFirstEnvironmentWrite
+ )
+ if ($null -ne $script:GraphKitAuthAbiGitConfigState) {
+ throw 'The process already has an active GraphKit.Auth ABI-test Git exclusion scope.'
+ }
+ if ($Patterns.Count -ne 5 -or @($Patterns | Select-Object -Unique).Count -ne 5) {
+ throw 'The GraphKit.Auth ABI-test exclusion inventory must contain exactly five unique entries.'
+ }
+ foreach ($pattern in $Patterns) {
+ if ([string]::IsNullOrWhiteSpace($pattern) -or -not $pattern.StartsWith('/', [StringComparison]::Ordinal) -or
+ $pattern.EndsWith('/', [StringComparison]::Ordinal) -or $pattern.IndexOfAny([char[]]'*?[') -ge 0 -or
+ -not $pattern.IsNormalized([Text.NormalizationForm]::FormC)) {
+ throw "The GraphKit.Auth ABI-test exclusion '$pattern' is not one root-anchored literal file pattern."
+ }
+ }
+
+ $environment = [ordered]@{}
+ foreach ($entry in Get-ChildItem Env:) {
+ if ($entry.Name.StartsWith('GIT_CONFIG_', [StringComparison]::Ordinal)) {
+ $environment[$entry.Name] = [string]$entry.Value
+ }
+ }
+ $priorCountText = [Environment]::GetEnvironmentVariable('GIT_CONFIG_COUNT', 'Process')
+ $priorCount = 0
+ if ($null -ne $priorCountText -and
+ (-not [int]::TryParse($priorCountText, [Globalization.NumberStyles]::None,
+ [Globalization.CultureInfo]::InvariantCulture, [ref]$priorCount) -or $priorCount -lt 0)) {
+ throw "The inherited GIT_CONFIG_COUNT '$priorCountText' is invalid."
+ }
+ $keyName = "GIT_CONFIG_KEY_$priorCount"
+ $valueName = "GIT_CONFIG_VALUE_$priorCount"
+ if ($null -ne [Environment]::GetEnvironmentVariable($keyName, 'Process') -or
+ $null -ne [Environment]::GetEnvironmentVariable($valueName, 'Process')) {
+ throw "The inherited process Git configuration collides at index $priorCount."
+ }
+
+ $excludePath = Join-Path ([IO.Path]::GetTempPath()) ('graphkit-auth-abi-excludes-' + [guid]::NewGuid().ToString('N'))
+ $bytes = [Text.UTF8Encoding]::new($false, $true).GetBytes(($Patterns -join "`n") + "`n")
+ $stream = [IO.FileStream]::new($excludePath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write,
+ [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough)
+ try {
+ $stream.Write($bytes, 0, $bytes.Length)
+ $stream.Flush($true)
+ }
+ finally { $stream.Dispose() }
+
+ try {
+ [Environment]::SetEnvironmentVariable($keyName, 'core.excludesFile', 'Process')
+ if ($null -ne $AfterFirstEnvironmentWrite) { & $AfterFirstEnvironmentWrite }
+ [Environment]::SetEnvironmentVariable($valueName, $excludePath, 'Process')
+ [Environment]::SetEnvironmentVariable('GIT_CONFIG_COUNT', ($priorCount + 1).ToString([Globalization.CultureInfo]::InvariantCulture), 'Process')
+ $script:GraphKitAuthAbiGitConfigState = [pscustomobject]@{
+ Environment = $environment
+ ExcludePath = $excludePath
+ }
+ }
+ catch {
+ Restore-GraphKitAuthGitConfigEnvironment -Environment $environment
+ if (Test-Path -LiteralPath $excludePath -PathType Leaf) { [IO.File]::Delete($excludePath) }
+ throw
+ }
+}
+
+function Disable-GraphKitAuthAbiTestGitExcludes {
+ [CmdletBinding()]
+ param()
+ $state = $script:GraphKitAuthAbiGitConfigState
+ if ($null -eq $state) { return }
+ try {
+ Restore-GraphKitAuthGitConfigEnvironment -Environment $state.Environment
+ }
+ finally {
+ if (Test-Path -LiteralPath $state.ExcludePath -PathType Leaf) { [IO.File]::Delete($state.ExcludePath) }
+ $script:GraphKitAuthAbiGitConfigState = $null
+ }
+}
+
+function Assert-GraphKitAuthAbiProjectedFileEvidence {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $RepositoryRoot,
+ [Parameter(Mandatory)][string] $RelativePath,
+ [Parameter(Mandatory)] $Expected
+ )
+ Initialize-GraphKitAuthStageCapture
+ $repositoryRootEvidence = $script:GraphKitAuthStageCaptureType::InspectDirectoryPath(
+ $RepositoryRoot)
+ $actual = $script:GraphKitAuthStageCaptureType::InspectFile($RepositoryRoot, $RelativePath)
+ if (-not $actual.IsRegularFile -or $actual.IsReparsePoint -or [long]$actual.LinkCount -ne 1 -or
+ -not (Test-GraphKitAuthContainedPhysicalPath `
+ $repositoryRootEvidence.PhysicalPath $actual.PhysicalPath) -or
+ [string]$actual.PhysicalPath -cne [string]$Expected.PhysicalPath -or
+ [string]$actual.NativeIdentity -cne [string]$Expected.NativeIdentity -or
+ [string]$actual.Sha256 -cne [string]$Expected.Sha256 -or
+ [long]$actual.Length -ne [long]$Expected.Length -or
+ [long]$actual.LinkCount -ne [long]$Expected.LinkCount) {
+ throw "The projected GraphKit.Auth ABI-test file '$RelativePath' was replaced, linked, changed, or escaped."
+ }
+ return $actual
+}
+
+function New-GraphKitAuthAbiTestFixture {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string] $RepositoryRoot,
+ [Parameter(Mandatory)][string] $OutputRoot
+ )
+ Initialize-GraphKitAuthStageCapture
+ $sourceManifest = Import-PowerShellDataFile -LiteralPath (Join-Path $RepositoryRoot 'source/GraphKit.psd1')
+ $baseVersion = [string]$sourceManifest.ModuleVersion
+ $builtManifestPath = Join-Path $RepositoryRoot "output/module/GraphKit/$baseVersion/GraphKit.psd1"
+ if (-not (Test-Path -LiteralPath $builtManifestPath -PathType Leaf)) {
+ throw "The GraphKit.Auth ABI test fixture has no built manifest at '$builtManifestPath'."
+ }
+ $builtManifest = Import-PowerShellDataFile -LiteralPath $builtManifestPath
+ $prerelease = [string]$builtManifest.PrivateData.PSData.Prerelease
+ $fullVersion = if ([string]::IsNullOrWhiteSpace($prerelease)) { $baseVersion } else { "$baseVersion-$prerelease" }
+ $versionRoot = Join-Path (Get-GraphKitAuthOutputRoot $OutputRoot) "stage/$fullVersion"
+ if (-not (Test-Path -LiteralPath $versionRoot -PathType Container)) {
+ throw "The sealed GraphKit.Auth stage for ABI testing is missing at '$versionRoot'."
+ }
+ $stageEntries = @([IO.Directory]::EnumerateFileSystemEntries($versionRoot))
+ if ($stageEntries.Count -ne 1 -or -not (Test-Path -LiteralPath $stageEntries[0] -PathType Container)) {
+ throw 'The sealed GraphKit.Auth stage for ABI testing is not one exact digest envelope.'
+ }
+ $verified = Test-GraphKitAuthSealedStage -StagePath $stageEntries[0] -FullVersion $fullVersion
+ $destinations = [ordered]@{
+ 'GraphKit.Auth.Contracts.dll' = 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release/net8.0/GraphKit.Auth.Contracts.dll'
+ 'GraphKit.Auth.dll' = 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.dll'
+ 'GraphKit.Auth.deps.json' = 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.deps.json'
+ 'Microsoft.Identity.Client.dll' = 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0/Microsoft.Identity.Client.dll'
+ 'Microsoft.IdentityModel.Abstractions.dll' = 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0/Microsoft.IdentityModel.Abstractions.dll'
+ }
+ $binRoots = @(
+ 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin'
+ 'src/GraphKit.Auth/GraphKit.Auth/bin'
+ 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin'
+ ) | ForEach-Object { Join-Path $RepositoryRoot $_ }
+ foreach ($binRoot in $binRoots) {
+ if (Test-Path -LiteralPath $binRoot) {
+ throw "The GraphKit.Auth ABI test fixture destination '$binRoot' already exists."
+ }
+ }
+ $trainScript = Join-Path $RepositoryRoot 'scripts/Get-GraphKitTrainVersion.ps1'
+ $baselineState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject
+ $statusBefore = @(& git -C $RepositoryRoot status --porcelain=v1 --untracked-files=all)
+ if ($LASTEXITCODE -ne 0) { throw 'Cannot capture Git status before the GraphKit.Auth ABI-test projection.' }
+ $patterns = @($destinations.Values | ForEach-Object { '/' + $_ })
+ Enable-GraphKitAuthAbiTestGitExcludes -RepositoryRoot $RepositoryRoot -Patterns $patterns
+ $script:GraphKitAuthAbiFixtureState = [pscustomobject]@{
+ BaselineState = $baselineState
+ StatusBefore = @($statusBefore)
+ CreatedPaths = [Collections.Generic.List[string]]::new()
+ CreatedDirectories = [Collections.Generic.List[string]]::new()
+ Completed = $false
+ ExpectedEvidence = [ordered]@{}
+ }
+ try {
+ foreach ($entry in $destinations.GetEnumerator()) {
+ $relativeFile = [string]$entry.Value
+ $destinationFile = Join-Path $RepositoryRoot $relativeFile
+ $destination = Split-Path $destinationFile -Parent
+ $missingDirectories = [Collections.Generic.Stack[string]]::new()
+ $candidateDirectory = [IO.Path]::GetFullPath($destination)
+ while (-not [IO.Directory]::Exists($candidateDirectory)) {
+ $missingDirectories.Push($candidateDirectory)
+ $parentDirectory = [IO.Directory]::GetParent($candidateDirectory)
+ if ($null -eq $parentDirectory) {
+ throw "The GraphKit.Auth ABI-test projection '$destination' has no existing ancestor."
+ }
+ $candidateDirectory = $parentDirectory.FullName
+ }
+ while ($missingDirectories.Count -gt 0) {
+ $createdDirectory = $missingDirectories.Pop()
+ $null = [IO.Directory]::CreateDirectory($createdDirectory)
+ $script:GraphKitAuthAbiFixtureState.CreatedDirectories.Add($createdDirectory)
+ }
+ $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew(
+ $verified.PayloadPath, $entry.Key, $destination, $entry.Key
+ )
+ $script:GraphKitAuthAbiFixtureState.CreatedPaths.Add($destinationFile)
+ $script:GraphKitAuthAbiFixtureState.ExpectedEvidence[$destinationFile] = $copy.Destination
+ $manifestRecord = @($verified.Manifest.files | Where-Object {
+ [string]$_.path -ceq "payload/$($entry.Key)"
+ })
+ if ($manifestRecord.Count -ne 1 -or
+ [string]$copy.Destination.Sha256 -cne [string]$manifestRecord[0].sha256 -or
+ [long]$copy.Destination.LinkCount -ne 1) {
+ throw "The GraphKit.Auth ABI test fixture '$($entry.Key)' does not match the sealed payload."
+ }
+ & git -C $RepositoryRoot check-ignore --quiet -- $relativeFile
+ if ($LASTEXITCODE -ne 0) {
+ throw "The exact GraphKit.Auth ABI-test projection '$relativeFile' is not excluded by its literal process scope."
+ }
+ }
+
+ $hiddenState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject
+ if ([string]$hiddenState.sourceStateSha256 -cne [string]$baselineState.sourceStateSha256 -or
+ [string]$hiddenState.version -cne [string]$baselineState.version) {
+ throw 'The exact GraphKit.Auth ABI-test projections changed the process-scoped source fingerprint.'
+ }
+ $sentinelRelative = '.graphkit-auth-abi-untracked-' + [guid]::NewGuid().ToString('N')
+ $sentinelPath = Join-Path $RepositoryRoot $sentinelRelative
+ try {
+ $sentinelBytes = [Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
+ $sentinel = [IO.FileStream]::new($sentinelPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write,
+ [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough)
+ try { $sentinel.Write($sentinelBytes, 0, $sentinelBytes.Length); $sentinel.Flush($true) }
+ finally { $sentinel.Dispose() }
+ & git -C $RepositoryRoot check-ignore --quiet -- $sentinelRelative
+ if ($LASTEXITCODE -eq 0) { throw 'The unrelated GraphKit.Auth ABI-test sentinel was unexpectedly excluded.' }
+ if ($LASTEXITCODE -ne 1) {
+ throw "The GraphKit.Auth ABI-test sentinel exclusion probe failed with git exit code '$LASTEXITCODE'; only exit 1 proves the sentinel is not ignored."
+ }
+ $sentinelState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject
+ if ([string]$sentinelState.sourceStateSha256 -ceq [string]$hiddenState.sourceStateSha256) {
+ throw 'An unrelated untracked source file did not change the dirty-source fingerprint.'
+ }
+ }
+ finally {
+ if (Test-Path -LiteralPath $sentinelPath -PathType Leaf) { [IO.File]::Delete($sentinelPath) }
+ }
+ $restoredHiddenState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject
+ if ([string]$restoredHiddenState.sourceStateSha256 -cne [string]$hiddenState.sourceStateSha256) {
+ throw 'The GraphKit.Auth ABI-test sentinel cleanup did not restore the projected source fingerprint.'
+ }
+ $script:GraphKitAuthAbiFixtureState.Completed = $true
+ }
+ catch {
+ try { Remove-GraphKitAuthAbiTestFixture -RepositoryRoot $RepositoryRoot }
+ catch { Write-Error -ErrorRecord $_ }
+ throw
+ }
+ return @($binRoots)
+}
+
+function Remove-GraphKitAuthAbiTestFixture {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][string] $RepositoryRoot)
+ $state = $script:GraphKitAuthAbiFixtureState
+ if ($null -eq $state) {
+ Disable-GraphKitAuthAbiTestGitExcludes
+ return
+ }
+ $failures = [Collections.Generic.List[string]]::new()
+ $allowedFiles = @(
+ 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release/net8.0/GraphKit.Auth.Contracts.dll'
+ 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.dll'
+ 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.deps.json'
+ 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0/Microsoft.Identity.Client.dll'
+ 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0/Microsoft.IdentityModel.Abstractions.dll'
+ ) | ForEach-Object { Join-Path $RepositoryRoot $_ }
+ $allowedFileSet = [Collections.Generic.HashSet[string]]::new(
+ $(if ($IsWindows) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal }))
+ foreach ($file in $allowedFiles) { $null = $allowedFileSet.Add([IO.Path]::GetFullPath($file)) }
+ $literalParents = @(
+ 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release/net8.0'
+ 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release'
+ 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin'
+ 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0'
+ 'src/GraphKit.Auth/GraphKit.Auth/bin/Release'
+ 'src/GraphKit.Auth/GraphKit.Auth/bin'
+ 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0'
+ 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release'
+ 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin'
+ ) | ForEach-Object { Join-Path $RepositoryRoot $_ }
+ $allowedParentSet = [Collections.Generic.HashSet[string]]::new(
+ $(if ($IsWindows) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal }))
+ foreach ($parent in $literalParents) {
+ $null = $allowedParentSet.Add([IO.Path]::GetFullPath($parent))
+ }
+ $createdPaths = @($state.CreatedPaths | ForEach-Object { [IO.Path]::GetFullPath([string]$_) })
+ try {
+ foreach ($file in $createdPaths) {
+ if (-not $allowedFileSet.Contains($file) -or -not $state.ExpectedEvidence.Contains($file)) {
+ $failures.Add("unregistered or out-of-bound projected path '$file'")
+ continue
+ }
+ if (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
+ $failures.Add("missing recorded projected file '$file'")
+ continue
+ }
+ $relativeFile = [IO.Path]::GetRelativePath($RepositoryRoot, $file).Replace('\', '/')
+ try {
+ $null = Assert-GraphKitAuthAbiProjectedFileEvidence -RepositoryRoot $RepositoryRoot `
+ -RelativePath $relativeFile -Expected $state.ExpectedEvidence[$file]
+ }
+ catch {
+ $failures.Add($_.Exception.Message)
+ continue
+ }
+ [IO.File]::Delete($file)
+ }
+ $createdParents = if ($null -ne $state.PSObject.Properties['CreatedDirectories']) {
+ @($state.CreatedDirectories | ForEach-Object { [IO.Path]::GetFullPath([string]$_) })
+ }
+ else {
+ @($literalParents | Where-Object {
+ $parentPrefix = [IO.Path]::GetFullPath($_).TrimEnd(
+ [IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar
+ ) + [IO.Path]::DirectorySeparatorChar
+ @($createdPaths | Where-Object {
+ $_.StartsWith($parentPrefix, $(if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }))
+ }).Count -ne 0
+ })
+ }
+ $createdParents = @($createdParents | Sort-Object Length -Descending -Unique)
+ foreach ($directory in $createdParents) {
+ if (-not $allowedParentSet.Contains($directory)) {
+ $failures.Add("unregistered or out-of-bound projected directory '$directory'")
+ continue
+ }
+ if (-not (Test-Path -LiteralPath $directory -PathType Container)) { continue }
+ if (@([IO.Directory]::EnumerateFileSystemEntries($directory)).Count -ne 0) {
+ $failures.Add("non-empty projected parent '$directory'")
+ continue
+ }
+ [IO.Directory]::Delete($directory, $false)
+ }
+ }
+ finally {
+ Disable-GraphKitAuthAbiTestGitExcludes
+ $script:GraphKitAuthAbiFixtureState = $null
+ }
+ if ($null -ne $state.BaselineState) {
+ $trainScript = Join-Path $RepositoryRoot 'scripts/Get-GraphKitTrainVersion.ps1'
+ $restoredState = & $trainScript -RepositoryRoot $RepositoryRoot -AsObject
+ $statusAfter = @(& git -C $RepositoryRoot status --porcelain=v1 --untracked-files=all)
+ if ($LASTEXITCODE -ne 0) { $failures.Add('cannot capture Git status after ABI-test cleanup') }
+ if ([string]$restoredState.sourceStateSha256 -cne [string]$state.BaselineState.sourceStateSha256 -or
+ (@($statusAfter) -join "`n") -cne (@($state.StatusBefore) -join "`n")) {
+ $failures.Add('source fingerprint or Git status was not restored after ABI-test cleanup')
+ }
+ }
+ if ($failures.Count -ne 0) {
+ throw "GraphKit.Auth ABI-test cleanup failed: $($failures -join '; ')."
+ }
+}
+
+if (-not $SkipTaskRegistration -and (Get-Command task -ErrorAction SilentlyContinue)) {
+ task Prepare_GraphKitAuth_Clean {
+ Invoke-GraphKitAuthPrepareClean -OutputRoot (Join-Path $BuildRoot 'output') | Out-Null
+ }
+
+ task Build_GraphKitAuth {
+ Initialize-GraphKitAuthStageCapture
+ $solution = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth.sln'
+ $providerProject = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj'
+ $testProject = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj'
+ $runId = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(24)).ToLowerInvariant()
+ $buildWork = $null
+ $publishRoot = $null
+ $providerPublish = $null
+ $payloadSource = $null
+ $resultRoot = $null
+ $quarantine = $null
+ $buildWorkQuarantined = $false
+ $primaryFailure = $null
+ try {
+ $null = Initialize-GraphKitAuthBuildAuthorityRoot -OutputRoot (Join-Path $BuildRoot 'output')
+ $buildWork = New-GraphKitAuthBuildWorkRoot -OutputRoot (Join-Path $BuildRoot 'output') `
+ -RunId $runId
+ $resultRoot = Join-Path $buildWork.Path 'dotnet-test'
+ $publishRoot = Join-Path $buildWork.Path 'publish'
+ $providerPublish = Join-Path $publishRoot 'provider'
+ $payloadSource = Join-Path $publishRoot 'payload'
+ $dotnetVersionOutput = @(& dotnet --version)
+ if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth could not query the dotnet SDK version.' }
+ $dotnetVersion = ([string] (@($dotnetVersionOutput | Where-Object {
+ -not [string]::IsNullOrWhiteSpace($_)
+ }) | Select-Object -Last 1)).Trim()
+ if ($dotnetVersion -cne '10.0.400') { throw 'GraphKit.Auth requires dotnet SDK 10.0.400 exactly.' }
+ & dotnet restore $solution --locked-mode
+ if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth locked restore failed.' }
+ & dotnet build $solution -c Release --no-restore
+ if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth Release build failed.' }
+ $null = [IO.Directory]::CreateDirectory($resultRoot)
+ & dotnet test $testProject -c Release --no-build --no-restore --logger "trx;LogFileName=GraphKit.Auth.trx" --results-directory $resultRoot
+ if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth Release tests failed.' }
+ $trxPath = Join-Path $resultRoot 'GraphKit.Auth.trx'
+ [xml]$trx = Get-Content -LiteralPath $trxPath -Raw
+ Assert-GraphKitAuthTestResult -Result $trx
+ $null = [IO.Directory]::CreateDirectory($providerPublish)
+ & dotnet publish $providerProject -c Release --no-build --no-restore --no-self-contained -o $providerPublish
+ if ($LASTEXITCODE -ne 0) { throw 'GraphKit.Auth provider publish failed.' }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $providerPublish -ExpectedNames $script:GraphKitAuthProviderFiles -Kind 'raw provider publish'
+ $builtProvider = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.dll'
+ $builtContracts = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth.Contracts/bin/Release/net8.0/GraphKit.Auth.Contracts.dll'
+ $builtProviderDeps = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth/bin/Release/net8.0/GraphKit.Auth.deps.json'
+ $testOutput = Join-Path $BuildRoot 'src/GraphKit.Auth/GraphKit.Auth.Tests/bin/Release/net8.0'
+ $lineagePairs = @(
+ [pscustomobject]@{ Left=$builtProvider; Right=(Join-Path $testOutput 'GraphKit.Auth.dll'); Kind='tested provider' }
+ [pscustomobject]@{ Left=$builtProvider; Right=(Join-Path $providerPublish 'GraphKit.Auth.dll'); Kind='published provider' }
+ [pscustomobject]@{ Left=$builtContracts; Right=(Join-Path $testOutput 'GraphKit.Auth.Contracts.dll'); Kind='tested contracts' }
+ [pscustomobject]@{ Left=$builtProviderDeps; Right=(Join-Path $providerPublish 'GraphKit.Auth.deps.json'); Kind='published dependency graph' }
+ [pscustomobject]@{ Left=(Join-Path $testOutput 'Microsoft.Identity.Client.dll'); Right=(Join-Path $providerPublish 'Microsoft.Identity.Client.dll'); Kind='tested MSAL' }
+ [pscustomobject]@{ Left=(Join-Path $testOutput 'Microsoft.IdentityModel.Abstractions.dll'); Right=(Join-Path $providerPublish 'Microsoft.IdentityModel.Abstractions.dll'); Kind='tested IdentityModel' }
+ )
+ foreach ($pair in $lineagePairs) {
+ $leftHash = (Get-FileHash -LiteralPath $pair.Left -Algorithm SHA256).Hash
+ $rightHash = (Get-FileHash -LiteralPath $pair.Right -Algorithm SHA256).Hash
+ if ($leftHash -cne $rightHash) { throw "The GraphKit.Auth $($pair.Kind) bytes do not share one Release lineage." }
+ }
+ $identities = [ordered]@{
+ 'GraphKit.Auth.Contracts.dll' = 'GraphKit.Auth.Contracts, Version=1.0.0.0'
+ 'GraphKit.Auth.dll' = 'GraphKit.Auth, Version=1.0.0.0'
+ 'Microsoft.Identity.Client.dll' = 'Microsoft.Identity.Client, Version=4.82.1.0'
+ 'Microsoft.IdentityModel.Abstractions.dll' = 'Microsoft.IdentityModel.Abstractions, Version=8.14.0.0'
+ }
+ foreach ($pair in $identities.GetEnumerator()) {
+ $path = if ($pair.Key -ceq 'GraphKit.Auth.Contracts.dll') { $builtContracts } else { Join-Path $providerPublish $pair.Key }
+ $identity = [Reflection.AssemblyName]::GetAssemblyName($path)
+ $actual = "$($identity.Name), Version=$($identity.Version)"
+ if ($actual -cne $pair.Value) { throw "Unexpected managed identity for '$($pair.Key)': '$actual'." }
+ }
+ $contractsInspectionContext = [Runtime.Loader.AssemblyLoadContext]::new(
+ "GraphKit.Auth.Contracts.BuildInspection.$runId", $true)
+ try {
+ $contractsAssembly = $contractsInspectionContext.LoadFromAssemblyPath($builtContracts)
+ $forbiddenReferences = @($contractsAssembly.GetReferencedAssemblies() | Where-Object {
+ $_.Name -eq 'GraphKit.Auth' -or $_.Name -like 'Microsoft.Identity*'
+ })
+ if ($forbiddenReferences.Count -ne 0) {
+ throw "GraphKit.Auth.Contracts has a forbidden runtime reference: $($forbiddenReferences.Name -join ', ')."
+ }
+ }
+ finally {
+ $contractsAssembly = $null
+ $contractsInspectionContext.Unload()
+ $contractsInspectionContext = $null
+ }
+ $null = [IO.Directory]::CreateDirectory($payloadSource)
+ foreach ($name in $script:GraphKitAuthProviderFiles) {
+ $null = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew($providerPublish, $name, $payloadSource, $name)
+ }
+ $contractsRoot = Split-Path $builtContracts -Parent
+ $null = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew($contractsRoot, 'GraphKit.Auth.Contracts.dll', $payloadSource, 'GraphKit.Auth.Contracts.dll')
+ $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $BuildRoot
+ $script:GraphKitAuthQuarantine = $quarantine
+ Write-Host "GraphKit.Auth generated roots quarantined at '$quarantine'."
+ $version = & (Join-Path $BuildRoot 'scripts/Get-GraphKitTrainVersion.ps1') -RepositoryRoot $BuildRoot -AsObject
+ $script:GraphKitAuthStage = New-GraphKitAuthSealedStage -OutputRoot (Join-Path $BuildRoot 'output') -FullVersion $version.version -PayloadSourceRoot $payloadSource
+ }
+ catch {
+ $primaryFailure = $_
+ throw
+ }
+ finally {
+ $cleanupFailures = [Collections.Generic.List[string]]::new()
+ if ($null -eq $quarantine) {
+ try {
+ $quarantine = Invoke-GraphKitAuthLiteralQuarantine -RepositoryRoot $BuildRoot
+ $script:GraphKitAuthQuarantine = $quarantine
+ Write-Host "GraphKit.Auth generated roots quarantined at '$quarantine'."
+ }
+ catch {
+ $cleanupFailures.Add(
+ "generated-root quarantine failed: $($_.Exception.Message)")
+ }
+ }
+ $buildWorkQuarantine = $quarantine
+ if ($null -ne $buildWork -and $null -eq $buildWorkQuarantine) {
+ try {
+ $buildWorkQuarantineRoot = New-GraphKitAuthTaskQuarantineRoot `
+ -OutputRoot (Join-Path $BuildRoot 'output')
+ $buildWorkQuarantine = $buildWorkQuarantineRoot.Path
+ $script:GraphKitAuthQuarantine = $buildWorkQuarantine
+ Write-Host "GraphKit.Auth independent build-workspace quarantine created at '$buildWorkQuarantine'."
+ }
+ catch {
+ $cleanupFailures.Add(
+ "independent build-workspace quarantine creation failed: $($_.Exception.Message)")
+ }
+ }
+ if ($null -ne $buildWork -and $null -ne $buildWorkQuarantine -and
+ -not $buildWorkQuarantined) {
+ try {
+ $movedBuildWork = Move-GraphKitAuthBuildWorkToQuarantine `
+ -BuildWork $buildWork -QuarantineRoot $buildWorkQuarantine
+ $buildWorkQuarantined = $true
+ Write-Host "GraphKit.Auth build workspace quarantined at '$($movedBuildWork.Path)'."
+ }
+ catch {
+ $cleanupFailures.Add(
+ "build-workspace quarantine failed: $($_.Exception.Message)")
+ }
+ }
+ if ($cleanupFailures.Count -ne 0) {
+ if ($null -eq $primaryFailure) {
+ throw "GraphKit.Auth build cleanup failed: $($cleanupFailures -join ' | ')."
+ }
+ foreach ($cleanupFailure in $cleanupFailures) {
+ Write-Warning `
+ "GraphKit.Auth cleanup also failed; the earlier build failure remains authoritative. $cleanupFailure" `
+ -WarningAction Continue
+ }
+ }
+ }
+ }
+
+ task Copy_GraphKitAuth_Into_BuiltModule {
+ . Set-SamplerTaskVariable
+ if ($null -eq $script:GraphKitAuthStage) { throw 'The current build has no sealed GraphKit.Auth stage.' }
+ $verified = Test-GraphKitAuthSealedStage -StagePath $script:GraphKitAuthStage.StagePath -FullVersion $script:GraphKitAuthStage.FullVersion
+ $destination = Join-Path $BuiltModuleBase 'Assemblies/GraphKit.Auth'
+ if (Test-Path -LiteralPath $destination) { throw "GraphKit.Auth built-module destination '$destination' already exists." }
+ $null = [IO.Directory]::CreateDirectory($destination)
+ foreach ($record in @($verified.Manifest.files)) {
+ $name = [IO.Path]::GetFileName([string]$record.path)
+ $copy = $script:GraphKitAuthStageCaptureType::CopyFileCreateNew($verified.PayloadPath, $name, $destination, $name)
+ if ([string]$copy.Destination.Sha256 -cne [string]$record.sha256 -or $copy.Destination.LinkCount -ne 1) {
+ throw "GraphKit.Auth built-module copy validation failed for '$name'."
+ }
+ }
+ Assert-GraphKitAuthExactDirectoryClosure -Directory $destination -ExpectedNames $script:GraphKitAuthPayloadFiles -Kind 'built-module payload'
+ $manifestPath = Join-Path $BuiltModuleBase 'GraphKit.psd1'
+ $manifestText = [IO.File]::ReadAllText($manifestPath)
+ if ($manifestText -notmatch '(?m)^\s*RequiredAssemblies\s*=\s*@\(\)\s*$') {
+ throw 'The built GraphKit manifest no longer has the expected empty RequiredAssemblies literal.'
+ }
+ $updated = [regex]::Replace($manifestText, '(?m)^(\s*RequiredAssemblies\s*=\s*)@\(\)(\s*)$', "`$1@('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')`$2", 1)
+ [IO.File]::WriteAllText($manifestPath, $updated, [Text.UTF8Encoding]::new($false))
+ $moduleManifest = Test-ModuleManifest -Path $manifestPath -ErrorAction Stop
+ if ((@($moduleManifest.RequiredAssemblies | ForEach-Object { $_.ToString() }) -join '|') -cne 'Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll') {
+ throw 'The built GraphKit manifest RequiredAssemblies value is not exact.'
+ }
+ }
+
+ task Pester_Tests_With_GraphKitAuth_ABI_Fixture {
+ $pesterExitCode = $null
+ try {
+ $script:GraphKitAuthAbiTestFixture = New-GraphKitAuthAbiTestFixture `
+ -RepositoryRoot $BuildRoot -OutputRoot (Join-Path $BuildRoot 'output')
+ $pwshPath = [Environment]::ProcessPath
+ & $pwshPath -NoLogo -NoProfile -File (Join-Path $BuildRoot 'build.ps1') `
+ -Tasks Pester_Tests_Stop_On_Fail
+ $pesterExitCode = $LASTEXITCODE
+ }
+ finally {
+ Remove-GraphKitAuthAbiTestFixture -RepositoryRoot $BuildRoot
+ }
+ if ($pesterExitCode -ne 0) {
+ throw "The guarded GraphKit.Auth ABI Pester run failed with exit code '$pesterExitCode'."
+ }
+ }
+}
diff --git a/.build/ReleaseProof.tasks.ps1 b/.build/ReleaseProof.tasks.ps1
new file mode 100644
index 0000000..bc0dab0
--- /dev/null
+++ b/.build/ReleaseProof.tasks.ps1
@@ -0,0 +1,20 @@
+<#
+ Invoke-Build integration for the canonical tested-release proof.
+
+ Capture runs before Pester and deletes stale proof/result material. Finalize runs only
+ after Pester and coverage gates, rechecks the captured candidate and complete result,
+ then writes tested-release-proof.json. The scripts hold the behavior so the exact same
+ boundary is exercised by focused subprocess tests.
+#>
+
+task Capture_Tested_Release_Proof_Candidate {
+ & (Join-Path $BuildRoot 'scripts/New-GraphKitTestedReleaseProof.ps1') `
+ -Stage Capture `
+ -RepositoryRoot $BuildRoot
+}
+
+task Record_Tested_Release_Proof {
+ & (Join-Path $BuildRoot 'scripts/New-GraphKitTestedReleaseProof.ps1') `
+ -Stage Finalize `
+ -RepositoryRoot $BuildRoot
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..a69e968
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+* text=auto eol=lf
+tests/Fixtures/GraphKitAuthParityCases.json text eol=lf
diff --git a/.github/powershell-release-sha256.json b/.github/powershell-release-sha256.json
new file mode 100644
index 0000000..d2adc6d
--- /dev/null
+++ b/.github/powershell-release-sha256.json
@@ -0,0 +1,27 @@
+{
+ "schemaVersion": 1,
+ "provenance": {
+ "7.4.19": {
+ "releaseUrl": "https://github.com/PowerShell/PowerShell/releases/tag/v7.4.19",
+ "hashesUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.4.19/hashes.sha256"
+ },
+ "7.6.5": {
+ "releaseUrl": "https://github.com/PowerShell/PowerShell/releases/tag/v7.6.5",
+ "hashesUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.6.5/hashes.sha256"
+ }
+ },
+ "sha256": {
+ "7.4.19/PowerShell-7.4.19-win-arm64.zip": "ac3a0249c0cd9f5b55f198f681485099ea73f45838dfd676457571a94d793463",
+ "7.4.19/PowerShell-7.4.19-win-x64.zip": "cd62ad6d8174cc6fb85b335a0058444bc934fe27c39fa97fe342134286d28af9",
+ "7.4.19/powershell-7.4.19-linux-arm64.tar.gz": "2b11aafacf574222abaf691a0b3b2d463e617d17fe337343c2fb93ea871a4691",
+ "7.4.19/powershell-7.4.19-linux-x64.tar.gz": "1b023e097b0e0546ad9566f7a2126cbe0eb8455fa7b0c5de558e317b8ddc16c8",
+ "7.4.19/powershell-7.4.19-osx-arm64.tar.gz": "fb9d6656d0c78c6d3f6e8d08ff15e5e0d867f886bf4ebecfde6484d2fa06c042",
+ "7.4.19/powershell-7.4.19-osx-x64.tar.gz": "bb67378d9b9d469d0c3863aa8a5576a38ad8eaa0fd7aae2c4819e7caf06cb79c",
+ "7.6.5/PowerShell-7.6.5-win-arm64.zip": "20514a755d16428dc4355c85e0883c859531e71cc3e122670aa1fccdbf96ba7e",
+ "7.6.5/PowerShell-7.6.5-win-x64.zip": "32eb8f6cdce08f86e987d625a2733e54ac3e289ae7e1621b14c0b5bcec2434ea",
+ "7.6.5/powershell-7.6.5-linux-arm64.tar.gz": "ed4084f215d8bce2edd23aa7cb1f1e7b0818e41363a635a22065d2701b6141df",
+ "7.6.5/powershell-7.6.5-linux-x64.tar.gz": "b34ab3b19acac1d3d4d0d3cfdb02acf62f457b0b6a962ff008132033f7566844",
+ "7.6.5/powershell-7.6.5-osx-arm64.tar.gz": "8196d4b4e7c21b7f6df9d45687bb4e42dc8335f330b580d9eb15f3ef5042a8c3",
+ "7.6.5/powershell-7.6.5-osx-x64.tar.gz": "3db1d177ab39511c1b6b73b05a1630a5db4e8dce22857ca76f14c5d98f2733fd"
+ }
+}
diff --git a/.github/scripts/Install-VerifiedPowerShellArchive.ps1 b/.github/scripts/Install-VerifiedPowerShellArchive.ps1
new file mode 100644
index 0000000..a6a9d83
--- /dev/null
+++ b/.github/scripts/Install-VerifiedPowerShellArchive.ps1
@@ -0,0 +1,42 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][string] $Version,
+ [Parameter(Mandatory)][string] $AssetName,
+ [Parameter(Mandatory)][string] $ArchivePath,
+ [Parameter(Mandatory)][string] $InstallDirectory,
+ [Parameter(Mandatory)][string] $HashMapPath
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version 3.0
+
+$hashMap = Get-Content -LiteralPath $HashMapPath -Raw | ConvertFrom-Json -AsHashtable
+if ([int]$hashMap.schemaVersion -ne 1) {
+ throw 'The reviewed PowerShell release hash map has an unsupported schema version.'
+}
+$key = "$Version/$AssetName"
+$matchingKeys = @($hashMap.sha256.Keys | Where-Object { [string]$_ -ceq $key })
+if ($matchingKeys.Count -ne 1) {
+ throw "PowerShell release asset '$key' has no reviewed SHA-256 mapping."
+}
+$expectedHash = [string]$hashMap.sha256[$matchingKeys[0]]
+if ($expectedHash -cnotmatch '^[0-9a-f]{64}$') {
+ throw "The reviewed digest for PowerShell release asset '$key' is not a lowercase 64-character SHA-256."
+}
+$actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
+if (-not [string]::Equals($actualHash, $expectedHash, [StringComparison]::Ordinal)) {
+ throw "PowerShell release asset '$key' does not match its reviewed SHA-256."
+}
+Write-Host "Verified SHA-256 for $AssetName."
+
+$null = New-Item -ItemType Directory -Path $InstallDirectory -Force
+if ($AssetName.EndsWith('.zip', [StringComparison]::Ordinal)) {
+ Expand-Archive -LiteralPath $ArchivePath -DestinationPath $InstallDirectory -Force
+}
+elseif ($AssetName.EndsWith('.tar.gz', [StringComparison]::Ordinal)) {
+ & tar -xzf $ArchivePath -C $InstallDirectory
+ if ($LASTEXITCODE -ne 0) { throw "PowerShell archive extraction failed for '$AssetName'." }
+}
+else {
+ throw "PowerShell release asset '$AssetName' is not a supported archive."
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 49f32b3..e75d741 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,8 +2,9 @@ name: CI
on:
push:
- branches: [main]
+ branches: [main, 'codex/**']
pull_request:
+ workflow_dispatch:
permissions:
contents: read
@@ -21,11 +22,34 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
+ with:
+ repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Assert exact source revision
+ shell: pwsh
+ env:
+ EXPECTED_SOURCE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $expected = $env:EXPECTED_SOURCE_SHA
+ $actual = (& git rev-parse HEAD).Trim()
+ if (-not [string]::Equals($actual, $expected, [StringComparison]::Ordinal)) {
+ throw "Checked-out source mismatch: expected $expected, got $actual"
+ }
+ Write-Host "Exact source revision asserted: $actual"
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.400'
# Each row installs its exact PowerShell version rather than trusting the
# runner image, then asserts the running version matches before doing work.
- # Pinned release tags + direct GitHub release assets: no third-party action,
- # no secrets, fork-safe.
+ # Pinned release tags + direct GitHub release assets + committed official
+ # checksums: no third-party action, no secrets, fork-safe.
- name: Install PowerShell ${{ matrix.pwsh-version }}
shell: pwsh
run: |
@@ -45,16 +69,13 @@ jobs:
$installDir = Join-Path $env:RUNNER_TOOL_CACHE "pwsh-$version"
$archive = Join-Path $env:RUNNER_TEMP $asset
+ $hashMap = Join-Path $env:GITHUB_WORKSPACE '.github/powershell-release-sha256.json'
- New-Item -ItemType Directory -Path $installDir -Force | Out-Null
Write-Host "Downloading $asset"
Invoke-WebRequest -Uri "https://github.com/PowerShell/PowerShell/releases/download/v$version/$asset" -OutFile $archive
-
- if ($IsWindows) {
- Expand-Archive -Path $archive -DestinationPath $installDir -Force
- } else {
- tar -xzf $archive -C $installDir
- }
+ & (Join-Path $env:GITHUB_WORKSPACE '.github/scripts/Install-VerifiedPowerShellArchive.ps1') `
+ -Version $version -AssetName $asset -ArchivePath $archive `
+ -InstallDirectory $installDir -HashMapPath $hashMap
Add-Content -Path $env:GITHUB_PATH -Value $installDir
Write-Host "Installed PowerShell $version to $installDir"
@@ -63,11 +84,9 @@ jobs:
shell: pwsh
run: |
$expected = '${{ matrix.pwsh-version }}'
- $actual = $PSVersionTable.PSVersion
- $expectedMajorMinor = (($expected -split '\.')[0..1] -join '.')
- $actualMajorMinor = "$($actual.Major).$($actual.Minor)"
- if ($actualMajorMinor -ne $expectedMajorMinor) {
- throw "PowerShell version mismatch: expected $expectedMajorMinor.x, got $actual"
+ $actual = $PSVersionTable.PSVersion.ToString()
+ if (-not [string]::Equals($actual, $expected, [StringComparison]::Ordinal)) {
+ throw "PowerShell version mismatch: expected $expected, got $actual"
}
Write-Host "PowerShell version asserted: $actual"
@@ -75,7 +94,7 @@ jobs:
shell: pwsh
run: pwsh -File ./build.ps1 -ResolveDependency -Tasks noop
- - name: Pack candidate
+ - name: Pack candidate with Build_GraphKitAuth
shell: pwsh
run: pwsh -File ./build.ps1 -Tasks pack
@@ -93,4 +112,21 @@ jobs:
if ($resultFiles.Count -gt 1) {
throw "Multiple NUnit result files produced: $($resultFiles.Name -join ', ')"
}
- pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 777 -AllowedSkips 0
+ pwsh -File ./tests/QA/Assert-GateResult.ps1 -ResultPath $resultFiles[0].FullName -MinimumTests 1482 -AllowedSkips 0
+ if ($LASTEXITCODE -ne 0) {
+ throw 'The standalone whole-result gate failed.'
+ }
+
+ $proofPath = 'output/testResults/tested-release-proof.json'
+ if (-not (Test-Path -LiteralPath $proofPath -PathType Leaf)) {
+ throw "No canonical tested-release proof was produced at $proofPath"
+ }
+ $packageFiles = @(Get-ChildItem -Path 'output/GraphKit.*.nupkg' -File -ErrorAction SilentlyContinue)
+ if ($packageFiles.Count -ne 1) {
+ throw "Expected exactly one GraphKit package, found $($packageFiles.Count): $($packageFiles.Name -join ', ')"
+ }
+ & ./scripts/Test-GraphKitReleaseProof.ps1 `
+ -PackagePath $packageFiles[0].FullName `
+ -ProofPath $proofPath `
+ -TestResultPath $resultFiles[0].FullName `
+ -RepositoryRoot $PWD | Out-Null
diff --git a/AGENTS.md b/AGENTS.md
index 9b885b7..ce3d283 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,11 +25,17 @@ The container run was worth more than the one auth mode it was built for, becaus
Two things about the container are worth knowing before repeating it. The immutable PSGallery `0.2.2` package was built while `Microsoft.PowerShell.SecretManagement` was a hard `RequiredModules` entry, so it had to be installed even for managed identity, which has no stored secret and never opens a vault. The `0.3.0` release removes that hard dependency: managed identity, injected credentials, help, catalog inspection and CI stay usable without SecretManagement, while first vault use explicitly validates and imports version 1.1.2 or newer and requires a registered extension. `Install-GraphKitPinned.ps1` preserves automatic SecretManagement installation for a `0.2.2` pin and makes it opt-in for `0.3.0`. None of this alters or republishes the stable `0.2.2` artifact.
+The pinned SecretManagement 1.1.2 `Get-Secret` surface does not expose a `Version` parameter.
+Profiles carrying version metadata therefore fail before vault access by design; with the supported
+provider, give each rotated secret, password, or certificate generation a distinct immutable name.
+Do not claim version-addressable vault retrieval merely because the profile schema can record the
+metadata.
+
Run the suite through `./build.ps1 -Tasks test`, never `Invoke-Pester ./tests` directly: the changelog checks are Sampler-generated and depend on build-injected variables, so a bare Pester run reports two false failures.
-**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The post-release development tree requires 777 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, or skips, and `tests/QA/Assert-GateResult.ps1` enforces the same minimum-count floor used by CI and package verification.
+**Remote CI contract.** `.github/workflows/ci.yml` runs PowerShell 7.4 and 7.6 across Windows, Ubuntu, and macOS. A source revision is CI-verified only when all six matrix jobs pass for that exact SHA; workflow existence or an older successful run is not evidence. The published `0.3.0` evidence is 772 deterministic tests. The post-release development tree requires 1482 deterministic tests under `./build.ps1 -Tasks test`, with zero failures, errors, skips, or NotRun tests, and `tests/QA/Assert-GateResult.ps1` enforces the same minimum-count floor used by CI and package verification. The synchronization test independently discovers the suite and removes only the repository's explicit platform-only surplus before accepting that shared floor, so agreeing stale copies are not sufficient.
-**Phase 5 (cutover) implementation and Ivy24 verification are complete.** All eight steps ran and were verified against the Ivy24 lab tenant: legacy-caller inventory, `Import-GraphLegacyProfile`, a private versioned package channel with publish/pin/install, a live read through the *installed* package, a GraphKit-backed data plane in IHA behind a default-off flag, reads and a reverted mutating write through it, and a full credential-generation rollover ending in the old generation's revocation. Catalog coverage of IHA's declared surface is 27 of 27 at the API version it actually calls. The 2026-08-15 cutover record preserved two operator actions because active customer repointing would have required the legacy fallback to remain. Current operator status on 2026-08-29 is that no legacy or customer-tenant consumer uses these paths, so that historical contingency is not a `0.3.0` release blocker; the repository does not independently inventory external consumers. Purging deleted directory data remains policy-controlled housekeeping rather than package work. Read `docs/cutover/2026-08-15-phase5-cutover.md` before revisiting the historical cutover.
+**Phase 5 (cutover) implementation and Ivy24 verification are complete.** All eight steps ran and were verified against the Ivy24 lab tenant: legacy-caller inventory, `Import-GraphLegacyProfile`, a private versioned package channel with publish/pin/install, a live read through the *installed* package, a GraphKit-backed data plane in IHA behind a default-off flag, reads and a reverted mutating write through it, and a full credential-generation rollover ending in the old generation's revocation. Catalog coverage of IHA's declared surface is 27 of 27 at the API version it actually calls. The 2026-08-15 cutover record preserved two operator actions because active customer repointing would have required the legacy fallback to remain. The owner has since confirmed that there are no installed users, legacy consumers, customer-tenant consumers, or repoint targets. R9 legacy import/migration, customer repointing, rollback-window operation, legacy-layer retirement, and deleted-directory purge are therefore **NotApplicable** and must not be executed merely to manufacture closeout evidence. Reusable `New-GraphAppRegistration` provisioning and actual role-grant verification remain applicable product work. Reopen adopter-specific gates only if an adopter is later identified. Read `docs/cutover/2026-08-15-phase5-cutover.md` before revisiting the historical cutover.
Three things from that work belong here because they change how you run the build:
@@ -52,7 +58,10 @@ These are standalone scripts, deliberately not module functions: converting the
Runtime flow:
1. `Register-GraphTenant` persists non-secret profile metadata; credentials stay in `Microsoft.PowerShell.SecretManagement`.
-2. `Get-GraphContext` resolves a profile into an immutable runtime context before parallel work begins.
+2. `Get-GraphContext` resolves a profile into an immutable runtime context before parallel work
+ begins. Built-in certificate, client-secret, managed-identity, and fixed-bearer modes use the
+ runspace-neutral `GraphKit.Auth` boundary; only caller-owned `-TokenProvider` and `-MsalFactory`
+ compatibility seams remain same-runspace-only.
3. Public commands resolve an operation descriptor from `source/Data/Operations/*.psd1`.
4. `Invoke-GraphOperation` validates URI/query/version/cloud/permission rules, then issues the request through a **GraphKit-owned `HttpClient`** using a token from the context's own MSAL confidential client.
5. The request pipeline handles paging, deadlines, cancellation, scoped admission control, and semantics-aware retry. GraphKit is the **sole retry owner**; there is no underlying handler that can retry behind its back.
@@ -64,12 +73,16 @@ Preserve these boundaries:
- **Do not use `Connect-MgGraph` / `Invoke-MgGraphRequest` as the transport.** Measured 2026-08-14: `Set-MgRequestContext` executed inside a `ForEach-Object -Parallel` child runspace **mutated the parent's configuration**. SDK state lives in process-global .NET statics shared across runspaces, and `Get-MgContext` takes no parameters because there is exactly one connection. A tenant switch in one runspace therefore retargets every other: a loop paging Tenant A would issue its next page with Tenant B's token while still labelling results Tenant A. Silent cross-tenant contamination is the worst failure this module could have. This also voids the no-replay guarantee (the SDK handler can retry a 503 before GraphKit sees it) and makes the promised split timeouts undeliverable (`Invoke-MgGraphRequest` exposes no timeout or cancellation parameters).
- Acquire tokens with **MSAL.NET** per context, which has no global state and still performs certificate assertion signing. Contexts own an `IGraphTokenSource`, not an MSAL client directly: managed identity uses `ManagedIdentityApplicationBuilder`/`AcquireTokenForManagedIdentity` rather than `ConfidentialClientApplicationBuilder`/`AcquireTokenForClient`, and fixed bearer tokens cannot refresh at all.
- **v1 consumes MSAL transitively** via `Microsoft.Graph.Authentication`, depended on **solely to deliver `Microsoft.Identity.Client.dll`**; never call `Connect-MgGraph`. Bind late, stay on long-stable surface, and **never ship a competing `Microsoft.Identity.Client.dll`** — four MSAL versions already coexist in a typical environment (Az.Accounts, Graph.Authentication, PSResourceGet) and first load wins in the default ALC. This is an accepted compatibility constraint, not a sound boundary: that DLL is a private implementation detail with no contract for location, load timing, or version. CI must run an import-order matrix in fresh processes.
-- The recorded end state is **`GraphKit.Auth`** — a compiled adapter referencing an explicit `Microsoft.Identity.Client` version, loaded into an isolated `AssemblyLoadContext`, exposing only GraphKit-owned request/result types so no MSAL type crosses the boundary. Much later, not v1. Because contexts own `IGraphTokenSource`, that swap is an implementation change, not an interface change.
+- The recorded end state is **`GraphKit.Auth`** — a compiled adapter referencing an explicit `Microsoft.Identity.Client` version, loaded into an isolated `AssemblyLoadContext`, exposing only GraphKit-owned request/result types so no MSAL type crosses the boundary. It is the active R8 gate and is not present in the immutable `0.3.0` package. Because contexts own `IGraphTokenSource`, that swap is an implementation change, not an interface change.
- Do not build another OAuth client and do not persist access tokens. Client-credentials flows return no refresh token: an expired token is replaced by reacquiring from the source credential, which already lives in SecretManagement.
- API version is per-operation metadata, never a global beta mode.
- Generic reads may use `Get-GraphObject`; do not introduce a universal generic mutation API.
- IntuneHealthAutomation retains its reports, Excel processing, checkpointing, console UI, and caching in its own repository; no adopted runtime currently makes their consolidation a GraphKit release condition.
-- A current context is interactive convenience only. Low-level work must accept `-Context` or `-ProfileId` and resolve it before entering runspaces.
+- A current context is interactive convenience only. Low-level work must accept `-Context` or
+ `-ProfileId`. Caller-owned legacy `-TokenProvider` and `-MsalFactory` sources must be created and
+ used in the same runspace; the public sender rejects a crossed legacy source before it joins a
+ shared token flight. Re-resolving mutable credentials independently in child runspaces is not
+ proof of the approved immutable-context contract and must not be described as equivalent.
Example planned usage:
@@ -111,7 +124,10 @@ Treat `build.ps1`, `build.yaml`, and `RequiredModules.psd1` as authoritative for
- Normalize transport outcomes before policy logic. Retry code should consume a stable result record rather than PowerShell exception internals.
- Inject `Send`, `UtcNow`, `Delay`, and `Jitter` into retry logic. Tests must use virtual time and deterministic jitter.
- Prefer `PSCustomObject` records or a small compiled type over many public PowerShell classes; class definitions persist awkwardly across test runs.
-- Resolve immutable contexts before asynchronous/runspace work. Shared throttle state must be thread-safe and scoped by cloud, tenant, client, resource family, and read/write class.
+- Resolve immutable contexts before asynchronous/runspace work. Built-in compiled token sources
+ are runspace-neutral; caller-owned legacy provider/factory seams remain same-runspace-only
+ fail-fast containment. Shared throttle state must remain thread-safe and scoped by cloud,
+ tenant, client, resource family, and read/write class.
- Error handling must preserve certainty: distinguish known failure from indeterminate commit. Never blanket-replay POST/PATCH after timeouts, resets, or ambiguous 5xx responses.
- A successful 2xx response with `Retry-After` remains success: update future pacing, never replay it.
- Treat `@odata.nextLink` as opaque, validate its Graph authority before forwarding authorization, and continue through empty pages carrying a next link.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cf6bed..6fb89bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- The compiled `GraphKit.Auth` authentication boundary. A dependency-free
+ `GraphKit.Auth.Contracts.dll` owns the GraphKit ABI-v1 DTOs, interfaces, strict loader,
+ proxies, and host lifetime and loads in the default `AssemblyLoadContext`. `GraphKit.Auth.dll`
+ and its locked `Microsoft.Identity.Client` 4.82.1 closure load in one named collectible
+ context per module import, so no MSAL type crosses the boundary. The built-in certificate,
+ client-secret, managed-identity, and fixed-bearer modes now construct runspace-neutral
+ compiled sources, while `Get-GraphContext -TokenProvider` and `-MsalFactory` remain the
+ caller-owned legacy same-runspace compatibility seams.
+
+### Fixed
+
+- Release gates now require exactly 77 passing `GraphKit.Auth` tests, independently reconcile the
+ portable Pester floor against fresh discovery, and verify every dynamically selected PowerShell
+ CI archive against a committed checksum copied from that version's official PowerShell release
+ `hashes.sha256` asset before extraction or execution.
+- The PSGallery preflight now scans the verifier-owned package snapshot rather than only selected
+ source-like entries: strict UTF-8 JSON plus printable ASCII/UTF-8 and UTF-16LE strings in every
+ shipped DLL are checked for private paths, identifiers, GUIDs, and contextual certificate
+ thumbprints. The authored `src/GraphKit.Auth` C# tree is scanned separately because compilation
+ can omit source-only literals. Diagnostics retain only fixed categories and SHA-256 evidence
+ fingerprints, and legitimate 40-hex source or vendor revisions are not treated as thumbprints.
+- Restored the `0.3.0` lazy SecretManagement contract after the R8 manifest regressed it to an
+ always-loaded package dependency. R8 packages again require only Graph Authentication at import;
+ SecretManagement 1.1.2+ is discovered at first vault-backed context resolution, while help,
+ catalog inspection, managed identity, and injected-provider contexts remain usable without it.
+- The real retry/sender path now acquires exactly one bearer per physical attempt. Tenant proof,
+ the `Authorization` header, and returned provenance are bound to that same token fingerprint;
+ a rotating no-expiry provider can no longer have one token proved and another sent.
+- A `401` refresh now propagates `WithForceRefresh($true)` into both confidential-client and
+ managed-identity MSAL builders instead of bypassing only GraphKit's local cache. Unproven
+ provider tenant claims no longer populate verified provenance.
+- Cancellation now flows through mutation tenant proof into its nested retry pipeline, so a
+ cancelled proof cannot outlive the caller or proceed to the mutation send.
+- The canonical acquisition tuple is now wired into the production sender. Concurrent contexts
+ share one ordinary or forced-refresh acquisition, while a cancelled waiter can leave without
+ cancelling the shared work or leaking a disposable wait handle. A cancelled leader is replaced
+ only when its own caller token was signalled, and followers adopt shared results through a
+ monotonic per-source cache so an older ordinary result cannot overwrite a newer forced refresh.
+- Credential generations now bind the exact canonical PFX byte snapshot, password/material
+ version references, and unambiguous length-prefixed fields. Mutable unversioned vault and
+ subject selectors are isolated per context, relative PFX paths remain bound after a working-
+ directory change, and resolver-owned password/PFX buffers are disposed or zeroed on every path.
+- GraphKit-owned HTTP clients and credential material now share a compiled module-lifecycle
+ coordinator. Removal cancels active work, waits on both operation and cancellation-callback
+ gates, disposes owned resources asynchronously in LIFO order, and remains bounded even when a
+ callback or `Dispose()` implementation does not cooperate; injected resources remain caller-owned.
+- The sender now rejects a legacy PowerShell token source before it crosses into a different
+ runspace, where nested PowerShell-class acquisition can hang. This is fail-fast containment;
+ the compiled, runspace-neutral `GraphKit.Auth` adapter remains the R8 completion gate.
+
## [0.3.0] - 2026-08-30
GraphKit `0.3.0` was published to PSGallery at `2026-08-30T04:38:20.12Z`. The 207381-byte public
diff --git a/RequiredModules.psd1 b/RequiredModules.psd1
index ff39b15..5f94d81 100644
--- a/RequiredModules.psd1
+++ b/RequiredModules.psd1
@@ -18,6 +18,7 @@
ModuleBuilder = '3.1.8'
ChangelogManagement = '3.1.0'
Sampler = '0.120.1'
+ 'Microsoft.PowerShell.PSResourceGet' = '1.2.0'
diff --git a/build.ps1 b/build.ps1
index 10f227e..454929d 100755
--- a/build.ps1
+++ b/build.ps1
@@ -329,6 +329,21 @@ process
. $taskFile.FullName
}
+ task package_graphkit_r8_nupkg {
+ . Set-SamplerTaskVariable
+
+ if (-not $BuiltModuleManifest) {
+ throw "No valid manifest found for project $ProjectName."
+ }
+ if (-not (Get-Command -Name Compress-PSResource -ErrorAction SilentlyContinue)) {
+ throw 'Compress-PSResource is required to create GraphKit R8 prerelease packages.'
+ }
+
+ Get-ChildItem -LiteralPath $OutputDirectory -Filter "$ProjectName.*.nupkg" -File -ErrorAction SilentlyContinue |
+ Remove-Item -Force -ErrorAction Stop
+ Compress-PSResource -Path $BuiltModuleBase -DestinationPath $OutputDirectory -ErrorAction Stop
+ }
+
# Synopsis: Empty task, useful to test the bootstrap process.
task noop { }
@@ -364,8 +379,39 @@ process
}
}
-begin
-{
+begin
+{
+ function Get-GraphKitValidatedTrainVersion
+ {
+ [CmdletBinding()]
+ [OutputType([string])]
+ param
+ (
+ [Parameter(Mandatory)]
+ [string]
+ $VersionScript,
+
+ [Parameter(Mandatory)]
+ [string]
+ $RepositoryRoot
+ )
+
+ $versionOutput = @(
+ & $VersionScript -RepositoryRoot $RepositoryRoot -ErrorAction Stop
+ )
+ if ($versionOutput.Count -ne 1 -or
+ $versionOutput[0] -isnot [string] -or
+ [string]::IsNullOrWhiteSpace([string] $versionOutput[0]))
+ {
+ throw (
+ "The GraphKit train-version script must return exactly one non-empty string; " +
+ "received $($versionOutput.Count) output object(s)."
+ )
+ }
+
+ return ([string] $versionOutput[0]).Trim()
+ }
+
# Find build config if not specified.
if (-not $BuildConfig)
{
@@ -524,6 +570,10 @@ begin
{
Write-Verbose -Message "Bootstrap completed. Handing back to InvokeBuild."
+ $versionScript = Join-Path $PSScriptRoot 'scripts/Get-GraphKitTrainVersion.ps1'
+ $env:ModuleVersion = Get-GraphKitValidatedTrainVersion `
+ -VersionScript $versionScript -RepositoryRoot $PSScriptRoot
+
if ($PSBoundParameters.ContainsKey('ResolveDependency'))
{
Write-Verbose -Message "Dependency already resolved. Removing task."
diff --git a/build.yaml b/build.yaml
index 85bf20e..d1dc8d7 100644
--- a/build.yaml
+++ b/build.yaml
@@ -46,12 +46,15 @@ NestedModule:
# Defining 'Workflows' (suite of InvokeBuild tasks) to be run using their alias
BuildWorkflow:
'.': # "." is the default Invoke-Build workflow. It is called when no -Tasks is specified to the build.ps1
- - build
+ - pack
- test
build:
+ - Prepare_GraphKitAuth_Clean
- Clean
+ - Build_GraphKitAuth
- Build_Module_ModuleBuilder
+ - Copy_GraphKitAuth_Into_BuiltModule
- Build_NestedModules_ModuleBuilder
- Create_changelog_release_output
@@ -59,7 +62,7 @@ BuildWorkflow:
pack:
- build
- - package_module_nupkg
+ - package_graphkit_r8_nupkg
@@ -67,11 +70,19 @@ BuildWorkflow:
test:
# Uncomment to modify the PSModulePath in the test pipeline (also requires the build configuration section SetPSModulePath).
#- Set_PSModulePath
- - Pester_Tests_Stop_On_Fail
+ # Invalidate stale proof and capture the exact package/module candidate before Pester.
+ - Capture_Tested_Release_Proof_Candidate
+ # The frozen Task 3/4 ABI tests resolve the fixed runtime closure from three historical source-build
+ # paths. One guarded task projects only the five sealed files under exact process-local Git exclusions,
+ # runs Pester, and removes the files plus empty parents in finally before proof finalization.
+ - Pester_Tests_With_GraphKitAuth_ABI_Fixture
# Use this task if pipeline uses code coverage and the module is using the
# pattern of Public, Private, Enum, Classes.
#- Convert_Pester_Coverage
- Pester_if_Code_Coverage_Under_Threshold
+ # Emit publication authority only after the unchanged candidate and complete result
+ # pair pass the same canonical verifier both publisher scripts consume.
+ - Record_Tested_Release_Proof
# Use this task when you have multiple parallel tests, which produce multiple
# code coverage files and needs to get merged into one file.
@@ -178,8 +189,3 @@ GitConfig:
# FilesToAdd:
# - 'CHANGELOG.md'
# UpdateChangelogOnPrerelease: false # Set to true to update changelog on pre-releases too
-
-
-
-
-
diff --git a/docs/cutover/2026-08-15-phase5-cutover.md b/docs/cutover/2026-08-15-phase5-cutover.md
index 45c4882..6c5758d 100644
--- a/docs/cutover/2026-08-15-phase5-cutover.md
+++ b/docs/cutover/2026-08-15-phase5-cutover.md
@@ -1,9 +1,11 @@
# Phase 5 Cutover — completion record, 2026-08-15
-> **Superseding operator status, 2026-08-29:** this is a historical execution record, not a
-> current `0.3.0` release checklist. No adopted legacy or customer-tenant runtime uses these
-> paths, so the retained fallback and operator actions described below are not present-day
-> migration blockers. Preserve the original account below as evidence of what was verified.
+> **Superseding operator status, confirmed 2026-09-01:** this is a historical execution record,
+> not a current release checklist. The owner confirmed that there are no installed users, legacy
+> consumers, customer-tenant consumers, or repoint targets. The adopter migration, customer
+> repointing, rollback-window, legacy-retirement, and deleted-directory-purge actions described
+> below are **NotApplicable** and must not be run merely for program closeout. Preserve the original
+> account below as evidence of what was verified.
> **Status as of 2026-08-16:** phase 5 is closed. GraphKit 0.1.0 and 0.1.1 are published to the
> public PowerShell Gallery and consumed by the TenantPulse rebuild; an external security review
@@ -277,7 +279,7 @@ installable from it. That changes the consumer story: IntuneHealthAutomation v2
channel. The channel and bundle remain useful for an offline host and for testing an unreleased
build, but they are no longer the distribution path.
-## Next moves
+## Historical next moves — superseded as a current checklist
1. ~~Run `Test-GraphKitOnWindows.ps1` on the Windows host~~ — **done, 15/15 on PowerShell 7.6.5**.
2. Grant the five outstanding scopes on the lab app and re-run the descriptor verification.
diff --git a/docs/superpowers/plans/2026-08-19-r1-outcome-composites.md b/docs/superpowers/plans/2026-08-19-r1-outcome-composites.md
index 6f77706..c53310b 100644
--- a/docs/superpowers/plans/2026-08-19-r1-outcome-composites.md
+++ b/docs/superpowers/plans/2026-08-19-r1-outcome-composites.md
@@ -39,7 +39,7 @@ TenantPulse's authoritative `source/Data/DatasetMap.psd1` contains exactly five
The existing check functions already define the compact row contracts:
-- RBAC: `roleDefinitionName`, `groupId`, `groupDisplayName`, `isManagementRestricted`, `isAssignableToRole`.
+- RBAC and assignment reporting: `roleDefinitionName`, `groupId`, `groupDisplayName`, `groupDescription`, `isManagementRestricted`, `isAssignableToRole`.
- BitLocker: `policyId`, `policyName`, `isFullDiskEncryption`.
- LAPS: `policyId`, `policyName`, `backsUpToEntra`, `hasSufficientComplexity`, `hasSufficientLength`, `hasPostAuthAction`.
- Security baseline: `id`, `name`, `templateFamily`, `hasAssignment`, `isDeprecated`, with native Boolean values.
diff --git a/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md
new file mode 100644
index 0000000..8c5bb73
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md
@@ -0,0 +1,1346 @@
+# GraphKit R8 Authentication Boundary Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship a digest-bound GraphKit `0.4.0-r8` prerelease whose four built-in authentication modes use a runspace-neutral compiled adapter with exact MSAL 4.82.1 isolated from the process default load context.
+
+**Architecture:** A dependency-free `GraphKit.Auth.Contracts.dll` loads in the default `AssemblyLoadContext` and owns the GraphKit ABI, strict loader, proxies, and lifetime. `GraphKit.Auth.dll` and its locked MSAL runtime closure load in one named collectible context per module import; only contract types cross. PowerShell resolves persisted credential material before a context leaves its creation runspace, then transfers owned framework types into the compiled source.
+
+**Tech Stack:** PowerShell 7.4/7.6, Sampler 0.120.1, ModuleBuilder 3.1.8, Pester 6.1.0, .NET SDK 10.0.400 targeting `net8.0`, Microsoft.Identity.Client 4.82.1, collectible `AssemblyLoadContext`, locked NuGet restore.
+
+---
+
+## Status (2026-09-01)
+
+Deterministic implementation is complete and green: Tasks 1-7 (prerelease identity, ABI and
+package boundary, dependency-free contract assembly, isolated provider, build/package/CI
+wiring, built-in cutover, and deterministic parity/runspace proof) plus Task 8 Steps 0-3
+(deterministic prerequisites and the digest-bound protected runner; the exact verified revision is
+recorded by the current release proof rather than pinned in this plan). The remaining checkboxes are
+approval-gated and out of scope for deterministic
+completion: Task 8 Steps 4-6 (protected live parity evidence), Task 9 (transitive MSAL removal,
+sequenced after live parity), and Task 10 (exact-SHA CI and publication).
+
+
+## File map
+
+New compiled source:
+
+- `global.json` — exact .NET SDK selection.
+- `src/GraphKit.Auth/Directory.Build.props` — deterministic, warning-clean, `net8.0` defaults.
+- `src/GraphKit.Auth/GraphKit.Auth.sln` — the two production projects and unit-test project.
+- `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj` — dependency-free shared ABI.
+- `src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs` — credentials, descriptors, requests, results, and interfaces.
+- `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs` — strict shared-contract resolver.
+- `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs` — provider load, validation, proxy ownership, unload.
+- `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs` — default-context proxy and disposal state.
+- `src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj` — isolated provider with MSAL 4.82.1.
+- `src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs` — descriptor validation and source construction.
+- `src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs` — cache, refresh, adoption, cancellation, disposal.
+- `src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs` — confidential-client and managed-identity acquisition.
+- `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj` — deterministic provider tests.
+- `src/GraphKit.Auth/GraphKit.Auth.Tests/*.cs` — factory, cache, force-refresh, cancellation, and ownership tests.
+- `src/GraphKit.Auth/**/packages.lock.json` — exact restored dependency graphs.
+
+New build and PowerShell integration:
+
+- `.build/GraphKitAuth.tasks.ps1` — locked build and allowlisted package staging.
+- `scripts/Get-GraphKitTrainVersion.ps1` — deterministic full prerelease identity.
+- `source/Private/TokenSources/New-GraphAuthTokenSource.ps1` — CLR descriptor bridge and ownership transfer.
+- `tests/QA/GraphKitAuthPackage.tests.ps1` — binary/runtime-closure/ALC/no-leak package gates.
+- `tests/Unit/Auth/GraphKitAuth.Tests.ps1` — public ABI and fixed-bearer behavior.
+- `tests/Concurrency/GraphKitAuthRunspace.Tests.ps1` — exact source/context cross-runspace proof.
+- `tests/Unit/Auth/GraphKitAuthParity.Tests.ps1` — legacy and compiled deterministic contract parity.
+
+Existing files to modify:
+
+- `build.ps1`, `build.yaml`, `.github/workflows/ci.yml` — generated version, compiled build task, SDK setup, and .NET tests.
+- `source/GraphKit.psd1` — `0.4.0-r8` and eventual dependency removal; generated assemblies are
+ referenced only in the built manifest.
+- `source/Private/Initialize-GraphModuleLifecycle.ps1` — host-first/source-later LIFO registration.
+- `source/Private/TokenSources/GraphTokenSource.ps1` — compiled production selection; retained compatibility classes.
+- `source/Private/TokenSources/New-GraphMsalApplication.ps1` — legacy parity/test-only scope.
+- `source/Private/Transport/Send-GraphHttpRequest.ps1` — CLR-source cache adoption and scoped legacy guard.
+- `source/Public/Get-GraphContext.ps1` — compiled built-ins, same-runspace provider/factory compatibility.
+- `source/Private/Assert-GraphMsalEnvironment.ps1` — remove default-ALC guard after cutover.
+- `scripts/New-GraphKitTestedReleaseProof.ps1`, `scripts/Test-GraphKitReleaseProof.ps1` — full prerelease/source-revision proof.
+- `scripts/Install-GraphKitPinned.ps1`, `scripts/Publish-GraphKitPackage.ps1`, `scripts/Publish-GraphKitToGallery.ps1` — prerelease-aware exact artifact handling.
+- `tests/QA/PackageIdentity.tests.ps1`, `tests/QA/PackageDependencies.tests.ps1`, `tests/QA/ImportOrderMatrix.tests.ps1`, `tests/QA/ReleaseProof.tests.ps1`, `tests/QA/ReleaseTruth.tests.ps1` — successor identity and isolated dependency assertions.
+- `README.md`, `AGENTS.md`, `CHANGELOG.md`, both governing specs — exact completed/evidence status.
+
+### Task 1: Freeze and test successor package identity
+
+**Files:**
+
+- Create: `scripts/Get-GraphKitTrainVersion.ps1`
+- Modify: `build.ps1`
+- Modify: `source/GraphKit.psd1`
+- Modify: `scripts/New-GraphKitTestedReleaseProof.ps1`
+- Modify: `scripts/Test-GraphKitReleaseProof.ps1`
+- Test: `tests/QA/PackageIdentity.tests.ps1`
+- Test: `tests/QA/ReleaseProof.tests.ps1`
+
+- [x] **Step 1: Write failing successor-version tests**
+
+Add assertions that source declares base `0.4.0`, the train is `r8`, the built/package version is
+`0.4.0-r8.g<12 hex>` for a clean tree, and the proof records the exact full version and 40-hex
+source revision. Add a fixture proving that a prerelease package is found under a base-version
+module directory.
+
+```powershell
+$metadata.version | Should -Match '^0\.4\.0-r8\.g[0-9a-f]{12}$'
+$proof.source.revision | Should -Match '^[0-9a-f]{40}$'
+$proof.module.version | Should -Be ([string] $metadata.version)
+```
+
+- [x] **Step 2: Run the focused tests and verify red**
+
+Run:
+
+```powershell
+./build.ps1 -Tasks pack
+./build.ps1 -Tasks test
+```
+
+Expected: failures naming stable `0.3.0`, missing source revision, and prerelease package discovery.
+
+- [x] **Step 3: Implement deterministic version generation**
+
+`Get-GraphKitTrainVersion.ps1` returns one string and nothing else:
+
+```powershell
+$base = '0.4.0'
+$train = 'r8'
+$revision = (& git -C $RepositoryRoot rev-parse HEAD).Trim().ToLowerInvariant()
+$diff = (& git -C $RepositoryRoot diff --binary HEAD)
+$suffix = if ([string]::IsNullOrEmpty($diff)) {
+ ''
+} else {
+ $bytes = [Text.Encoding]::UTF8.GetBytes($diff)
+ $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant()
+ ".d$($hash.Substring(0, 12))"
+}
+"$base-$train.g$($revision.Substring(0, 12))$suffix"
+```
+
+Set `$env:ModuleVersion` in `build.ps1` before Sampler resolves build metadata. Record the complete
+semantic version and source state in proof schema v2. Resolve the built directory from base
+`ModuleVersion` while resolving the package from full PSData prerelease/version metadata.
+
+- [x] **Step 4: Run identity/proof tests and verify green**
+
+Expected: every new identity fixture passes; no package named `GraphKit.0.3.0.nupkg` is produced.
+
+- [x] **Step 5: Commit**
+
+```bash
+git add build.ps1 source/GraphKit.psd1 scripts/Get-GraphKitTrainVersion.ps1 scripts/New-GraphKitTestedReleaseProof.ps1 scripts/Test-GraphKitReleaseProof.ps1 tests/QA/PackageIdentity.tests.ps1 tests/QA/ReleaseProof.tests.ps1
+git commit -m "build: establish the GraphKit R8 prerelease identity"
+```
+
+### Task 2: Add red ABI and package-boundary tests
+
+**Files:**
+
+- Create: `tests/Unit/Auth/GraphKitAuth.Tests.ps1`
+- Create: `tests/QA/GraphKitAuthPackage.tests.ps1`
+- Modify: `tests/QA/PackageDependencies.tests.ps1`
+
+- [x] **Step 1: Write the missing-artifact and ABI tests**
+
+The tests require these exact package paths:
+
+```text
+Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll
+Assemblies/GraphKit.Auth/GraphKit.Auth.dll
+Assemblies/GraphKit.Auth/GraphKit.Auth.deps.json
+Assemblies/GraphKit.Auth/Microsoft.Identity.Client.dll
+Assemblies/GraphKit.Auth/Microsoft.IdentityModel.Abstractions.dll
+```
+
+Load the contracts assembly and assert:
+
+```powershell
+[GraphKit.Auth.GraphAuthHost]::ContractMarker | Should -Be 'GraphKit.Auth.Abi/1'
+[GraphKit.Auth.IGraphTokenSource].GetMethod('Acquire').ReturnType.FullName |
+ Should -Be 'GraphKit.Auth.GraphTokenResult'
+```
+
+Reflect over every public type/member signature and fail when the declaring assembly or full type
+name contains `Microsoft.Identity.Client`.
+
+- [x] **Step 2: Run the two files and verify red**
+
+Expected: missing assembly/package path failures only.
+
+- [x] **Step 3: Commit tests only**
+
+```bash
+git add tests/Unit/Auth/GraphKitAuth.Tests.ps1 tests/QA/GraphKitAuthPackage.tests.ps1 tests/QA/PackageDependencies.tests.ps1
+git commit -m "test: define the GraphKit Auth package boundary"
+```
+
+### Task 3: Implement the dependency-free contract assembly
+
+**Files:**
+
+- Create: `global.json`
+- Create: `src/GraphKit.Auth/Directory.Build.props`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/Contracts.cs`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphTokenSourceProxy.cs`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthLoadContext.cs`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs`
+
+- [x] **Step 1: Pin the SDK and deterministic defaults**
+
+`global.json`:
+
+```json
+{
+ "sdk": {
+ "version": "10.0.400",
+ "rollForward": "disable",
+ "allowPrerelease": false
+ }
+}
+```
+
+`Directory.Build.props` sets `TargetFramework=net8.0`, `Nullable=enable`,
+`ImplicitUsings=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`,
+`ContinuousIntegrationBuild=true`, `DebugType=None`, and
+`RestorePackagesWithLockFile=true`.
+
+- [x] **Step 2: Implement ABI-v1 DTOs and interfaces**
+
+Use sealed mutable-result/plain-constructor types, not records and not PowerShell types. Validate
+null/empty strings, absolute HTTPS authorities/resources, GUID presence by auth mode, credential
+discriminator agreement, private-key presence, and non-empty generation before a provider loads.
+
+The result must retain a settable `VerifiedTenantId`:
+
+```csharp
+public sealed class GraphTokenResult
+{
+ public required string AccessToken { get; init; }
+ public DateTimeOffset ExpiresOnUtc { get; init; }
+ public DateTimeOffset ReceivedOnUtc { get; init; }
+ public required string TokenType { get; init; }
+ public required string[] Scopes { get; init; }
+ public string? VerifiedTenantId { get; set; }
+ public required string TokenFingerprint { get; init; }
+ public required string CredentialGeneration { get; init; }
+}
+```
+
+- [x] **Step 3: Implement strict loader and proxy lifetime**
+
+`GraphAuthLoadContext.Load` returns the default contracts assembly for the exact matching contract
+name and uses `AssemblyDependencyResolver` for every isolated dependency. It rejects a second
+contracts copy, an unexpected provider name/version, and a provider path outside the declared
+payload root. Host import validates `GraphKit.Auth.Abi/1`; an incompatible contracts assembly
+already loaded in the default context fails with an instruction to start a fresh PowerShell
+process.
+
+`GraphTokenSourceProxy` uses `Interlocked` state, forwards contract members, clears the inner source
+on dispose, and tells the host exactly once. It never catches and relabels provider exceptions.
+
+- [x] **Step 4: Build the contracts project**
+
+Run:
+
+```bash
+dotnet build src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphKit.Auth.Contracts.csproj -c Release
+```
+
+Expected: zero warnings and errors; no `Microsoft.Identity.Client` in `project.assets.json`.
+
+- [x] **Step 5: Commit**
+
+```bash
+git add global.json src/GraphKit.Auth
+git commit -m "feat: define the GraphKit Auth ABI"
+```
+
+### Task 4: Implement the isolated provider and deterministic .NET tests
+
+**Files:**
+
+- Create: `src/GraphKit.Auth/GraphKit.Auth/GraphKit.Auth.csproj`
+- Create: `src/GraphKit.Auth/GraphKit.Auth/GraphTokenSourceFactory.cs`
+- Create: `src/GraphKit.Auth/GraphKit.Auth/GraphTokenSource.cs`
+- Create: `src/GraphKit.Auth/GraphKit.Auth/MsalTokenClient.cs`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceTests.cs`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.sln`
+
+- [x] **Step 1: Write failing .NET source-contract tests**
+
+Use an internal fake acquisition client to prove cache reuse, adaptive refresh, forced-refresh
+replacement, cancellation, failed-acquisition fanout, generation rejection, fixed-bearer refusal,
+and exactly-once material disposal. A representative test is:
+
+```csharp
+[Fact]
+public void ForcedRefreshReplacesAnOlderCachedResult()
+{
+ using var source = SourceFixture.Refreshable("first", "second");
+ Assert.Equal("first", source.Acquire(false, CancellationToken.None).AccessToken);
+ Assert.Equal("second", source.Acquire(true, CancellationToken.None).AccessToken);
+ Assert.Equal("second", source.Acquire(false, CancellationToken.None).AccessToken);
+}
+```
+
+- [x] **Step 2: Run .NET tests and verify red**
+
+Run:
+
+```bash
+dotnet test src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj -c Release
+```
+
+Expected: missing provider/source types.
+
+- [x] **Step 3: Implement the minimal complete provider**
+
+`GraphKit.Auth.csproj` pins:
+
+```xml
+
+
+ false
+ runtime
+
+```
+
+The factory accepts one immutable `GraphTokenRequest` containing the source-constant identity and
+credential fields and creates one confidential-client or managed-identity MSAL application per
+source. Per-call force refresh and cancellation remain arguments to `IGraphTokenSource.Acquire`;
+there is no duplicate descriptor DTO. The source computes SHA-256 token fingerprints, uses
+`AuthenticationResult.ExpiresOn`, records `ReceivedOnUtc` at successful acquisition, validates
+generation on every result/adoption, and never parses a JWT. Fixed bearer returns
+`DateTimeOffset.MinValue` expiry and throws on force. Every MSAL exception is caught inside the
+isolated provider and converted to a GraphKit-owned `GraphAuthException` without preserving an
+MSAL `InnerException` or `Data` value.
+
+- [x] **Step 4: Lock restore and run tests green**
+
+Run:
+
+```bash
+dotnet restore src/GraphKit.Auth/GraphKit.Auth.sln --use-lock-file
+dotnet restore src/GraphKit.Auth/GraphKit.Auth.sln --locked-mode
+dotnet test src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj -c Release --no-restore
+```
+
+Expected: all tests pass, zero warnings, committed lock files name exact MSAL 4.82.1.
+
+- [x] **Step 5: Commit**
+
+```bash
+git add src/GraphKit.Auth
+git commit -m "feat: add the isolated GraphKit Auth provider"
+```
+
+### Task 5: Integrate compiled build, package, and CI
+
+**Approved base:** `a8b74d8df692e70bb89d1645796c6cecae31aafc`, independently approved with
+no Critical, Important, or Minor findings. Verify this literal HEAD and a clean tracked worktree
+before writing tests; stop if either differs.
+
+**Files:**
+
+- Create: `.build/GraphKitAuth.tasks.ps1`
+- Create: `scripts/private/GraphKit.AuthStageCapture.cs`
+- Modify: `build.yaml`
+- Modify: `.github/workflows/ci.yml`
+- Modify: this complete Task 5 section
+- Modify: `scripts/Test-GraphKitReleaseProof.ps1`
+- Modify: `source/GraphKit.psd1` only if needed to make its empty source-manifest
+ `RequiredAssemblies` intent explicit
+- Test: `tests/QA/GraphKitAuthPackage.tests.ps1`
+- Test: `tests/QA/BuiltModule.tests.ps1`
+- Test: `tests/QA/ReleaseProof.tests.ps1`
+
+Do not modify the PowerShell authentication bridge, remove `Microsoft.Graph.Authentication`, alter
+public commands, perform live authentication, touch a vault or tenant, create Azure resources, or
+implement Tasks 6-10. Preserve the immutable public `0.3.0` artifact.
+
+#### Controller rulings
+
+The sealed stage protects against accidental mutation and unprivileged or different-identity
+writers. Require fresh create-new topology, physical containment, no links or aliases, stable native
+identities, regular-file link count one, exact closure and digest revalidation, and owner-only sealed
+permissions. Directory link counts are platform-defined and are not required to be one. Do not
+claim resistance to the filesystem owner, administrator/root, writable ancestors, or an actor able
+to re-grant permissions. The path-based loader is not an adversarial atomic byte-binding mechanism.
+
+Keep mutable compiler output and authorized stage bytes separate:
+
+```text
+output/GraphKit.Auth/capture/.build-/publish/{provider/,payload/}
+output/GraphKit.Auth/capture/.build-/dotnet-test/
+output/GraphKit.Auth/capture//{manifest.json,payload/}
+output/GraphKit.Auth/stage///{manifest.json,payload/}
+```
+
+`stage` is never the direct `dotnet publish` destination. A version path is create-new and fails
+closed if it exists; it is never reused, merged, or overwritten. Bind Task 5 to one Release lineage:
+locked restore, build without restore, machine-readable xUnit with no build or restore and no
+skipped/unexecuted outcome, then provider publish without build or restore. Contracts, tested
+provider, and published provider must come from that one build. The build workspace is one exact
+owner-only child created before either mutable output root; no top-level `publish` or `dotnet-test`
+child is permitted beneath the GraphKit.Auth authority root.
+
+The focused private C# helper is authorized only for relative no-follow opens, physical
+containment, native identity and link count, stable-handle hashing, and platform permission
+evidence. Do not modify `GraphKit.SourceCapture.cs` or embed a large native implementation in the
+Invoke-Build task. The helper is build-time/private and changes no public or runtime ABI.
+
+- [x] **Step 1: Record the approved baseline and write genuine failing tests**
+
+Record the approved boundary before implementation: 48 .NET tests; 23 focused
+`GraphKitAuth.Tests.ps1` cases; 8 package cases; and 31 combined cases with 26 passed and exactly
+five missing direct package paths. Reds must be attributable to absent Task 5 behavior, with no
+discovery error, skip, or NotRun.
+
+Test all five package paths, exact five-file closure, existing-version refusal without mutation,
+and missing, extra, renamed, writable, byte-mutated, byte-identical-replaced, hard-linked,
+escaped-linked, case-aliased, separator-aliased, Unicode-normalization-aliased, symlink, and
+junction/reparse mutations. Require native identity stability and link count one for regular files
+and the manifest, but never require directory link count one. Keep the source `RequiredAssemblies`
+empty and the built value exact. Match stage, built-module, and archive digests. Use a fresh process
+to load contracts in Default ALC and the provider/MSAL/IdentityModel in one named collectible ALC,
+construct without acquisition from the reverified sealed payload, preserve any Default-ALC MSAL,
+and prove unload.
+
+Extend release-proof mutations for exact duplicates, portable case, NFC, separators, traversal,
+and ZIP external attributes encoding symlinks, reparse points, or non-regular files. Negative
+fixtures must not contact Graph, read a vault, acquire a token, or require external state.
+
+- [x] **Step 2: Implement one locked build lineage and fresh sealed staging**
+
+`Build_GraphKitAuth` asserts `dotnet --version` is exactly `10.0.400`, restores the solution once
+with `--locked-mode`, builds the complete Release solution with `--no-restore`, runs the Release test
+project with `--no-build --no-restore`, and publishes only the already-built provider with
+`--no-build --no-restore --no-self-contained` and no RID. Parse TRX and require at least the approved
+48 tests with `total = executed = passed` and zero failed, skipped, not-executed, aborted, timeout,
+or error outcomes. Preserve `TreatWarningsAsErrors` and compare provider and dependency identities
+and digests across the build/test/publish lineage.
+
+Raw provider publish is untrusted and contains exactly:
+
+```text
+GraphKit.Auth.dll
+GraphKit.Auth.deps.json
+Microsoft.Identity.Client.dll
+Microsoft.IdentityModel.Abstractions.dll
+```
+
+Obtain `GraphKit.Auth.Contracts.dll` separately from the same build, verify its dependency-free
+identity and lineage, and form the exact fixed five-file payload. Verify managed identities,
+including MSAL `4.82.1.0` and IdentityModel `8.14.0.0`; do not permit another runtime dependency.
+
+Create an owner-only capture on the same filesystem. Copy each file individually with create-new
+semantics, flush it, reopen without following links, and verify digest, native identity, link count,
+closure, containment, and aliases. Write a fixed-property-order canonical UTF-8-without-BOM manifest
+containing no absolute path, run ID, timestamp, or volatile data. It records full module version,
+ordinal payload paths, lengths, SHA-256 values, native file identities, link counts, directory
+identities, and final permission policy; it does not recursively contain its own hash or identity.
+The manifest itself is a no-follow regular link-count-one file.
+
+Seal all files and directories against owner writes, using exact Unix modes or protected Windows
+ACLs rather than the read-only attribute alone. Hash canonical manifest bytes, same-filesystem
+atomically rename the capture into `stage//`, then reopen and
+revalidate the manifest-name/hash binding, closures, identities, permissions, aliases, and physical
+containment. `manifest.json` is metadata; only `payload/` supplies runtime bytes.
+
+`Prepare_GraphKitAuth_Clean` runs before Sampler `Clean`. It first verifies every prior stage and
+may unseal only exact physically contained envelopes whose canonical manifest, digest path,
+identities, permissions, and closure pass. Missing or forged manifests, partial sealing, links,
+aliases, and containment ambiguity fail closed before `Clean`.
+
+Machine-readable .NET results and provider-publish scratch live only below one unique owner-only
+`output/GraphKit.Auth/capture/.build-/` workspace. Once the sealed stage no longer depends
+on its payload source, an outer `finally` atomically moves that exact captured workspace, with
+no-replace semantics, into a task-specific `output/GraphKit.Auth.quarantine-/` sibling. This
+workspace move is independent of the source-build quarantine and runs on both success and failure,
+so a completed build restores an empty `capture` root before returning. A hard process death can
+leave the workspace in `capture`; the next Prepare must fail closed rather than delete it.
+
+Separately, after capture and before module version is recalculated, move only these literal source
+generated roots intact into the same recoverable task-specific quarantine, including on failure:
+
+```text
+src/GraphKit.Auth/GraphKit.Auth.Contracts/bin
+src/GraphKit.Auth/GraphKit.Auth.Contracts/obj
+src/GraphKit.Auth/GraphKit.Auth/bin
+src/GraphKit.Auth/GraphKit.Auth/obj
+src/GraphKit.Auth/GraphKit.Auth.Tests/bin
+src/GraphKit.Auth/GraphKit.Auth.Tests/obj
+src/GraphKit.Auth/GraphKit.Auth.Tests/TestResults
+```
+
+Do not resolve these through recursion, wildcard expansion, or discovery. Quarantine must finish
+before module version or proof state is recaptured.
+
+The approved Task 3/4 ABI Pester boundary resolves its actual-provider candidate from three of
+those historical source-build paths. After release-proof capture, materialize only the sealed,
+immediately reverified five payload files at those exact paths for that test boundary; refuse any
+pre-existing destination and require every copied digest and link count to match the manifest.
+Use a temporary process-scoped `core.excludesFile` containing exactly five root-anchored literal
+file patterns, saving and restoring every inherited `GIT_CONFIG_*` value without changing any Git
+configuration file. Prove an unrelated untracked sentinel still changes source identity. One
+outer `try/finally` runs Pester, removes exactly the five files and only empty literal parents,
+restores Git environment, and proves fingerprint/status restoration before release-proof
+finalization runs with no exclusion active. This is test-fixture projection from the authorized
+stage, not another build or another package source.
+
+- [x] **Step 3: Copy only a freshly reverified stage into the built module**
+
+Immediately before copy, repeat the full sealed-stage verification. Create
+`Assemblies/GraphKit.Auth` fresh and copy the five literal names individually with create-new
+semantics; never use `Copy-Item *`. Rehash destinations and reject extras, links, aliases, or
+replacement. Only after contracts exists, update only the built manifest so `RequiredAssemblies`
+is exactly `@('Assemblies/GraphKit.Auth/GraphKit.Auth.Contracts.dll')`, then run
+`Test-ModuleManifest`. Keep the source value empty and retain both current runtime modules.
+
+The build order is exact:
+
+```text
+Prepare_GraphKitAuth_Clean
+ -> Clean
+ -> Build_GraphKitAuth
+ -> Build_Module_ModuleBuilder
+ -> Copy_GraphKitAuth_Into_BuiltModule
+ -> Build_NestedModules_ModuleBuilder
+ -> Create_changelog_release_output
+ -> package_graphkit_r8_nupkg
+```
+
+- [x] **Step 4: Reverify before import and harden canonical proof**
+
+The release-proof verifier must match packed runtime bytes to the sealed manifest and reject
+duplicates, portable-case and NFC-equivalent collisions, backslashes, absolute/drive paths,
+empty/dot/dot-dot segments, and ZIP link/reparse/non-regular encodings. The package test
+independently proves the exact five-file auth subtree. Reverify the stage immediately before the
+fresh-process `GraphAuthHost` probe; never load that probe from raw publish or the mutable built
+copy. Built and archive bytes independently match all five stage digests.
+
+PowerShell 7.4 CI is the future .NET 8 runtime/import evidence; local xUnit on this host rolls the
+net8 project to installed .NET 10. Record the distinction and do not claim observed .NET 8 evidence
+until the exact-SHA PowerShell 7.4 row passes.
+
+- [x] **Step 5: Enforce exact-event-source CI and run all gates**
+
+CI retains all six Windows/Ubuntu/macOS by PowerShell `7.4.19`/`7.6.5` rows and triggers on pushes
+to `main` and `codex/**`, pull requests, and manual dispatch. Checkout selects PR head repository
+and full head SHA for PRs, otherwise current repository and `github.sha`, with `fetch-depth: 0`.
+Immediately assert `git rev-parse HEAD` ordinally against that event SHA before SDK setup or restore.
+Use one `actions/setup-dotnet@v4` for `10.0.400`, assert the complete three-part PowerShell version,
+and reach `Build_GraphKitAuth` through pack in every row. A same-SHA push run is later release
+authority; the PR merge-ref run is supplementary.
+
+Run locked dependency restore, pack, focused Auth ABI/package/built/release-proof Pester, then the
+full `./build.ps1 -Tasks test`. Require zero failures, discovery errors, skips, and NotRun. After
+the dirty candidate is green, commit once, then repeat pack/test on the exact clean commit because
+its prerelease identity changes. Run the standalone whole-result gate and canonical verifier
+against the already-tested package. Require clean status, `git diff --check`, unchanged lock graphs,
+exact five-file closure, no generated files tracked, all seven literal generated roots absent, and
+no blanket ignore rule. Retain ignored package, proof, and stage evidence.
+
+- [x] **Step 6: Commit and report**
+
+```bash
+git add .build/GraphKitAuth.tasks.ps1 scripts/private/GraphKit.AuthStageCapture.cs build.yaml source/GraphKit.psd1 .github/workflows/ci.yml docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md scripts/Test-GraphKitReleaseProof.ps1 tests/QA/GraphKitAuthPackage.tests.ps1 tests/QA/BuiltModule.tests.ps1 tests/QA/ReleaseProof.tests.ps1
+git commit -m "build: package the isolated GraphKit Auth runtime"
+```
+
+Write ignored `.superpowers/sdd/2026-08-30-r8-graphkit-auth/task-5-report.md` after the commit with
+the exact SHA, red/green counts, SDK/runtime distinction, threat boundary, platform fixtures,
+dependency closure, stage/build/archive digests, remaining external CI evidence, and concerns. No
+push, PR, publication, tenant, vault, token acquisition, Azure, merge, or gallery action occurs.
+
+### Task 6: Cut built-in context construction over to compiled sources
+
+**Files:**
+
+- Create: `source/Private/TokenSources/New-GraphAuthTokenSource.ps1`
+- Modify: `source/Private/Initialize-GraphModuleLifecycle.ps1`
+- Modify: `source/Private/TokenSources/GraphTokenSource.ps1`
+- Modify: `source/Private/Transport/Send-GraphHttpRequest.ps1`
+- Modify: `source/Public/Get-GraphContext.ps1`
+- Test: `tests/Unit/Auth/GraphKitAuth.Tests.ps1`
+- Test: `tests/Unit/Profiles/Get-GraphContext.Tests.ps1`
+- Test: `tests/Adapter/Send-GraphHttpRequest.Tests.ps1`
+
+- [x] **Step 1: Write failing bridge/ownership tests**
+
+Assert that production certificate, client-secret, managed-identity, and bearer contexts return an
+object implementing `GraphKit.Auth.IGraphTokenSource`; `-TokenProvider` and `-MsalFactory` return the
+legacy same-runspace source. Assert that persisted PFX bytes are read once, unsupported vault
+version metadata fails before vault access, and failed host creation disposes owned material.
+
+- [x] **Step 2: Verify red**
+
+Run the three focused Pester files. Expected: built-in contexts still return PowerShell classes.
+
+- [x] **Step 3: Implement the bridge**
+
+`New-GraphAuthTokenSource` constructs a `GraphTokenRequest` and transfers material only
+after generation verification. Production `New-GraphTokenSource` selects it when `-MsalFactory` is
+absent. Register the host before any source and register each compiled source as GraphKit-owned.
+
+In the sender, adopt shared results for either legacy `GraphTokenSourceBase` or compiled
+`IGraphTokenSource`. Apply the creation-runspace preflight only to the legacy base class.
+
+- [x] **Step 4: Run focused tests green and commit**
+
+```bash
+git add source/Private/TokenSources/New-GraphAuthTokenSource.ps1 source/Private/Initialize-GraphModuleLifecycle.ps1 source/Private/TokenSources/GraphTokenSource.ps1 source/Private/Transport/Send-GraphHttpRequest.ps1 source/Public/Get-GraphContext.ps1 tests/Unit/Auth/GraphKitAuth.Tests.ps1 tests/Unit/Profiles/Get-GraphContext.Tests.ps1 tests/Adapter/Send-GraphHttpRequest.Tests.ps1
+git commit -m "feat: use compiled token sources for built-in auth"
+```
+
+
+### Task 7: Prove deterministic parity and genuine cross-runspace use
+
+**Approved base:** exact independently approved clean Task 6 commit
+`d16ca572f3746a596456dc8421d4b821f8bcc583`. At dispatch, require
+`git rev-parse HEAD` to equal that SHA and `git status --short` to be empty. The stale tracked
+Task 7 plan section is a known exception on this sealed base; replacing it is the first tracked
+Task 7 edit and remains part of the one Task 7 implementation commit.
+
+**Files:**
+
+- Modify: `docs/superpowers/plans/2026-08-30-r8-graphkit-auth.md`
+- Create: `tests/Fixtures/GraphKitAuthParityCases.json`
+- Create: `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphTokenSourceParityTests.cs`
+- Create: `tests/Unit/Auth/GraphKitAuthParity.Tests.ps1`
+- Create: `tests/Concurrency/GraphKitAuthRunspace.Tests.ps1`
+- Modify: `src/GraphKit.Auth/GraphKit.Auth.Tests/GraphKit.Auth.Tests.csproj`
+- Modify: `src/GraphKit.Auth/GraphKit.Auth.Tests/OwnershipTests.cs`
+- Modify: `src/GraphKit.Auth/GraphKit.Auth.Contracts/GraphAuthHost.cs`
+- Modify: `source/Private/TokenSources/GraphTokenSource.ps1`
+- Modify: `tests/Unit/TokenSources/GraphTokenSource.Tests.ps1`
+- Modify: `tests/Concurrency/TokenIsolation.Tests.ps1`
+- Modify: `tests/Adapter/GraphModuleLifecycleSender.Tests.ps1`
+- Modify: `tests/Unit/Transport/GraphModuleLifecycle.Tests.ps1`
+- Modify: `tests/Unit/Auth/GraphKitAuth.Tests.ps1`
+
+Do not change public command signatures, the frozen CLR ABI, runtime package closure,
+dependency/import guard, package identity policy, or Task 8+ implementation files. Do not contact
+Graph, a real vault/credential, tenant, IMDS, or Azure; do not push, publish, or use Pester
+parallelism. A locked-cache miss that would require network access stops for controller authority.
+
+#### Controller rulings
+
+Parity covers normalized behavior common to legacy and compiled sources using one strict checked-in
+matrix with independently authored literal expectations for each runner. Disposal, reference
+clearing, owned-material drain, host shutdown, and ALC unload are compiled-only successor guarantees
+because legacy PowerShell sources are not `IDisposable`; never retrofit legacy disposal for symmetry.
+
+Parent-created contexts and sources enter child thread runspaces only through a unique AppDomain
+holder. Child `ArgumentList` carries primitive manifest paths, holder keys, and flags. A required
+child module import may automatically create that module instance's ordinary module-scoped host,
+but Step 5 children never construct a host, context, source, profile, or vault and use only the exact
+parent context/source recovered from the holder; their automatic import host is unused and cleaned.
+Step 6's separate owning lifecycle job may create the synthetic public fixed-bearer context solely
+to prove module removal, later-use refusal, and ALC collection. Every child module is removed and its
+cleanup observed before the parent ALC collection assertion.
+
+The real fixed-bearer runspace gate uses public `Get-GraphContext` against a temporary raw schema-1
+profile containing a clearly synthetic inline `Credential.Token`. It does not use
+`Register-GraphTenant`, SecretManagement, a vault, `-MsalFactory`, or private source construction;
+this test fixture is not a supported operator workflow for real bearer tokens.
+
+Add private bounded waiter-count instrumentation to `GraphTokenFlight` as the outer-flight
+happens-before gate. Do not alter keys, results, removal races, cancellation replacement, public
+commands, or the frozen CLR ABI.
+
+Prove lifecycle by composition without production marker hooks: provider xUnit owns source/material
+drain; retained Task 3 gates own host/proxy shutdown and unload; module tests own actual registration
+plus generic test-probe LIFO; packaged fixed bearer owns exact runspace crossing, use-after-removal,
+and ALC collection.
+
+Tests for behavior already delivered by Tasks 3-6, including compiled runspace neutrality, may
+characterize green on the clean base only when a reversible semantic mutation makes the intended
+named case fail and the source is restored byte-for-byte. New Task 7 waiter instrumentation and
+dead-host-field removal require genuine base failures; any lifecycle behavior absent on the base
+does too. The new runspace harness itself is not a production red: mutation-prove that it rejects
+source reconstruction, legacy cross-runspace use, lost `ReferenceEquals`, and leaked child module
+hosts. Discovery/setup errors, stale output, skips, NotRun, timing, and external state are invalid
+reds.
+
+Five count authorities remain separate. Task 7 records and asserts exact post-discovery equality for
+.NET, focused Pester, Task 6 owning, expanded regression, and whole Pester. The existing build check
+accepting at least 48 .NET tests is not Task 7 count authority; durable global ratchet
+synchronization remains Task 9 scope.
+
+- [x] **Step 1: Replace the tracked plan section and record exact clean baselines**
+
+Verify exact HEAD and clean status, then replace the complete tracked Task 7 section with this final
+controller contract before authoring tests. Do not create an intervening documentation commit.
+
+Record these exact clean Task 6 baselines:
+
+```text
+.NET GraphKit.Auth: 48
+Task 7 focused Pester: 95
+Task 6 owning Pester: 246
+Task 6 expanded regression: 440
+Whole repository Pester: 1,180
+```
+
+The focused baseline is the sum of the five existing Task 7 files on clean Task 6:
+`TokenIsolation` 8, `GraphTokenSource` 48, `GraphModuleLifecycleSender` 2,
+`GraphModuleLifecycle` 13, and `GraphKitAuth` 24. The two new files contribute zero at base.
+
+- [x] **Step 2: Add the strict shared 16-row matrix and test-only discovery**
+
+Create `GraphKitAuthParityCases.json` with exact top-level fields `schemaVersion`, `rowCount`, and
+`rows`; require schema `1` and `rowCount` `16`. Every row has exactly `id`, `runners`, `scenario`,
+`authMode`, `callLayerByRunner`, `input`, and `expectedByRunner`. Every row runs once in both
+`xunit-compiled` and `pester-legacy`.
+
+| Row ID | Auth mode | xUnit layer | Pester layer |
+| --- | --- | --- | --- |
+| `construction-certificate` | Certificate | construction only | construction only |
+| `construction-client-secret` | ClientSecret | construction only | construction only |
+| `construction-managed-identity` | user-assigned ManagedIdentity | construction only | construction only |
+| `construction-bearer-token` | BearerToken | construction only | construction only |
+| `ordinary-cache-hit` | Certificate | direct source | direct source |
+| `expired-result-refresh` | ClientSecret | direct source | direct source |
+| `ordinary-forced-ordinary` | ManagedIdentity | direct source | direct source |
+| `acquisition-failure-fanout-retry` | Certificate | compiled internal source flight | legacy production outer keyed flight |
+| `caller-cancellation-no-cache` | ClientSecret | direct source | direct source |
+| `fixed-bearer-cache-force-refusal` | BearerToken | direct source | direct source |
+| `fingerprint-certificate` | Certificate | direct source | direct source |
+| `fingerprint-client-secret` | ClientSecret | direct source | direct source |
+| `fingerprint-managed-identity` | ManagedIdentity | direct source | direct source |
+| `fingerprint-bearer-token` | BearerToken | direct source | direct source |
+| `adoption-generation-mismatch` | Certificate | direct source | direct source |
+| `adoption-valid` | ManagedIdentity | direct source | direct source |
+
+Each `expectedByRunner` record is independently and literally authored for its runner with one
+closed field set: source contract/metadata, token sequence, expiries, token types, ordered scopes,
+tenant proof, fingerprint, generation, received-time rule, application/provider acquisition counts,
+force flags, reference identity, normalized failure kind, cache state, and final flight-registry
+count. Null and empty values remain explicit. Neither runner derives expectations from production
+code, matrix input, or the other runner.
+
+Compare token, expiry, type, ordered scopes, tenant proof, fingerprint, generation, and source
+metadata ordinally. Check reference identity separately. Normalize only `AcquisitionFailure`,
+`Canceled`, `RefreshRefused`, `GenerationMismatch`, and `Disposed`. Legacy `ReceivedOnUtc` is
+wall-clock: require non-default, monotonic, and no later than valid expiry. Compiled acquisition time
+uses the injected clock; adopted literal results retain their supplied time in both runners.
+
+Construction expects zero token acquisitions. Compiled Certificate, ClientSecret, and
+ManagedIdentity create exactly one application/client during factory construction; legacy
+`-MsalFactory` remains uninvoked until acquisition. BearerToken creates none.
+
+Use these exact fingerprint inputs and literal lowercase SHA-256 values:
+
+```text
+task7-fingerprint-certificate
+245d574cc6b41262c018a65535f9937601045f29abff3df9d112e491048948b6
+task7-fingerprint-client-secret
+b4ec6c20d74417b5be0b54ce83bc39a9f0b2e990ec173e6ee9373491a65de55e
+task7-fingerprint-managed-identity
+6973fa751d466a0429077804c84708dc98188047d415ca992a583a6bf7744866
+task7-fingerprint-bearer-token
+04fa2face75f1b6e4df7e9270266abec23741a9e91c7fad9b12b1c8585c30bca
+```
+
+Both loaders independently reject these nine permanent malformed cases:
+
+```text
+unsupported-schema-version
+incorrect-row-count
+duplicate-row-id
+missing-required-row-id
+unknown-property
+missing-required-property
+duplicate-json-property
+invalid-runner-call-layer
+missing-runner-expectation
+```
+
+They also reject unknown or wrong-typed fields, invalid ID/runner sets, and duplicate JSON
+properties before executing any semantic row. Mutation cases must be individually discoverable in
+both runners rather than hidden in a setup failure.
+
+Link the exact repository fixture into the .NET test output:
+
+```xml
+
+```
+
+xUnit loads only `AppContext.BaseDirectory/Fixtures/GraphKitAuthParityCases.json`; Pester loads only
+`/tests/Fixtures/GraphKitAuthParityCases.json`. No current-directory or alternate fallback is
+allowed. Each reports the same matrix SHA-256 and proves no row was filtered.
+
+After all test-only files/changes discover successfully and before any production edit, record a
+literal per-file and per-theory inventory. Let `D`, `F`, `O`, `E`, and `W` be net new .NET,
+Task 7 focused, Task 6 owning, expanded-regression, and whole-repository discoveries. Assert exact
+equality, never a minimum:
+
+```text
+.NET exact total = 48 + D
+Task 7 focused exact total = 95 + F
+Task 6 owning exact total = 246 + O
+Expanded exact total = 440 + E
+Whole Pester exact total = 1,180 + W
+```
+
+The planned .NET ledger is `+16` semantic rows, `+9` loader-mutation cases, `+2`
+Certificate/ClientSecret drain-theory cases, and `-1` superseded single-mode fact: planned net
+`D = 26`, exact .NET total `74`. Any discovery difference stops the task for inventory
+reconciliation before production work. Additional reviewed cases must be itemized into `D`.
+
+Run the test-only base phase. Characterization-green Task 3-6 families, including the new harness
+over already runspace-neutral compiled sources, require reversible mutation proof; genuinely absent
+Task 7 waiter/dead-field/lifecycle behavior must fail for the intended reason. Restore every
+mutation byte-for-byte and repack before proceeding.
+
+- [x] **Step 3: Make compiled-source drain deterministic for both owned modes**
+
+Replace the existing single-mode, delay-based active-acquisition disposal fact with a
+Certificate/ClientSecret theory. The fake client signals entry, waits on the supplied cancellation
+token, records cancellation, exits, and only then may material cleanup record disposal. Require:
+
+```text
+acquire-entered -> cancellation-observed -> acquire-exited -> material-disposed
+```
+
+Require one client and one material disposal, bounded event/task completion, and no sleep, finite
+`Task.Delay`, or `CancelAfter` as ordering evidence.
+
+- [x] **Step 4: Add exact outer-flight waiter instrumentation**
+
+Add private thread-safe follower entry/departure counts to `GraphTokenFlight`. Increment only after
+a follower obtains the exact registry flight and before it awaits; decrement in `finally`. Expose
+only minimal in-module observation to tests.
+
+Remove duration-based scheduling assumptions from the Task 7 file set. Leaders/providers wait on
+explicit gates. Tests require exact bounded waiter counts before release, failure, cancellation,
+disposal, or replacement. Cancellation-aware infinite waits used only to model work until
+cancellation are allowed; elapsed duration is never evidence.
+
+Cover ordinary collapse, provider-failure fanout, leader-cancellation replacement,
+production-sender collapse, ordinary/forced partitioning, concurrent credential reuse,
+active-source disposal, and exact empty-registry cleanup.
+
+- [x] **Step 5: Prove exact parent-source use across thread runspaces**
+
+Use `Start-ThreadJob` with a GUID AppDomain holder containing the parent context/source,
+ready/go/release gates, a `ConcurrentQueue