Private/Get-NSPScanBridgeDeliveryProfile.ps1

function Get-NSPScanBridgeDeliveryProfile {
    <#
    .SYNOPSIS
        Builds a device delivery profile from classified lifecycles: class, confidence, evidence,
        conservative policy recommendation and simulator replay settings.
    .DESCRIPTION
        Pure. Only Completed files decide the device class; mixed classes become Hybrid and no
        completed file leaves it Unknown. A single file caps confidence at Medium. File names are
        included only with -IncludeFileNames.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Lifecycle,
        [Parameter(Mandatory)][string]$DeviceName,
        [Parameter(Mandatory)][string]$SessionId,
        [Parameter(Mandatory)][double]$PollMilliseconds,
        [Parameter(Mandatory)][double]$QuietSeconds,
        [Parameter(Mandatory)][datetime]$StartUtc,
        [Parameter(Mandatory)][datetime]$EndUtc,
        [int]$IgnoredExistingFiles=0,
        [bool]$EndedEarly=$false,
        [switch]$IncludeFileNames
    )
    $rank=@{Low=0;Medium=1;High=2}
    $files=foreach ($lifecycle in $Lifecycle) {
        $class=Get-NSPScanBridgeDeliveryClassification -Lifecycle $lifecycle
        $entry=[ordered]@{Key=$lifecycle.Key}
        if ($IncludeFileNames) { $entry.Names=@($lifecycle.Names) }
        $entry.Extensions=(@($lifecycle.Names | ForEach-Object { [IO.Path]::GetExtension($_) }) -join ' > ')
        $entry.Outcome=$lifecycle.Outcome; $entry.Class=$class.Class; $entry.Confidence=$class.Confidence; $entry.Evidence=$class.Evidence
        $entry.Samples=$lifecycle.Samples.Count; $entry.WhileChangingSamples=@($lifecycle.MidWrite).Count
        $entry.FirstSeenUtc=if($lifecycle.Samples.Count){(@($lifecycle.Samples | Sort-Object Ms)[0].Utc).ToString('o')}else{$null}
        $entry.WriteDurationMs=$lifecycle.WriteDurationMs; $entry.LongestPauseMs=$lifecycle.LongestPauseMs
        $entry.FinalBytes=$lifecycle.FinalLength; $entry.BytesPerSecond=$lifecycle.BytesPerSecond
        $entry.Rename=$lifecycle.Rename; $entry.Collision=$lifecycle.Collision
        [pscustomobject]$entry
    }
    $files=@($files)
    $completed=@($files | Where-Object Outcome -eq 'Completed')
    $classes=@($completed | ForEach-Object Class | Select-Object -Unique)
    $evidence=New-Object System.Collections.Generic.List[string]
    $evidence.Add(('{0} file(s) observed, {1} completed; outcomes: {2}.' -f $files.Count,$completed.Count,((@($files | Group-Object Outcome | ForEach-Object { $_.Name+'='+$_.Count }) -join ', '))))
    if (-not $completed.Count) {
        $deviceClass='Unknown'; $confidence='Low'; $evidence.Add('No completed delivery to classify; repeat the capture with a test scan.')
    } elseif ($classes.Count -gt 1 -or $classes[0] -eq 'Hybrid') {
        $deviceClass='Hybrid'; $confidence='Low'
        $evidence.Add(('Completed files disagree: {0}.' -f ((@($completed | Group-Object Class | ForEach-Object { $_.Name+'='+$_.Count }) -join ', '))))
    } else {
        $deviceClass=$classes[0]
        $confidence=($completed | Sort-Object { $rank[$_.Confidence] } | Select-Object -First 1).Confidence
        if ($completed.Count -lt 2 -and $confidence -eq 'High') { $confidence='Medium'; $evidence.Add('Only one completed file; repeat to raise confidence.') }
    }
    $incomplete=@($files | Where-Object { $_.Outcome -in @('Incomplete','TempNotRenamed','NeverAvailable') }).Count
    if ($incomplete) { $evidence.Add(('{0} file(s) never became a complete PDF (cancelled or abandoned); ScanBridge holds such files in Source.' -f $incomplete)) }
    if (@($files | Where-Object Collision).Count) { $evidence.Add('A file name was overwritten or recreated during the capture; check for name collisions.') }
    if ($EndedEarly) { $evidence.Add('The capture was stopped early; results cover only what was seen.') }

    # Conservative policy: never below the ScanBridge default; twice the longest pause plus margin.
    $pause=[double](@($files | ForEach-Object LongestPauseMs) + 0 | Measure-Object -Maximum).Maximum
    $age=[int][math]::Min(3600,[math]::Max(20,[math]::Ceiling(2*$pause/1000)+10))
    $notes=New-Object System.Collections.Generic.List[string]
    $notes.Add('Keep the PDF structure check (header and %%EOF) and the exclusive-open check; both are already on in the worker.')
    $notes.Add('Size stability and a successful exclusive open are signals, not proof the writer finished.')
    switch ($deviceClass) {
        'TempRename' { $notes.Add('The device renames a temporary file to .pdf after writing. ScanBridge ignores non-PDF names, so the PDF appears complete; keep the minimum age as a margin.') }
        'DirectExclusive' { $notes.Add('The device writes the final .pdf name while holding it exclusively; the exclusive-open check detects an active writer.') }
        'DirectReadable' { $notes.Add('The device lets others read while it writes. An exclusive open still fails while its handle is open, but rely on the minimum age and structure check too.') }
        default { $notes.Add('Behavior is ambiguous: keep the defaults and do not lower MinimumAgeSeconds.') }
    }
    if (@($Lifecycle | Where-Object { $_.Rename -and $_.Rename.FromExtension -ieq '.pdf' -and $_.Rename.ToExtension -ieq '.pdf' }).Count) {
        $notes.Add('A temporary name ending in .pdf was renamed to its final name. The worker considers such a file while it is written; rely on the exclusive-open and structure checks, keep the minimum age, and prefer a device setting with a non-PDF temporary name.')
    }
    $recommendation=[pscustomobject]@{MinimumAgeSeconds=$age;LongestObservedPauseMs=$pause;Basis=('max(20, 2 x longest observed pause + 10 s), sampled every {0} ms' -f $PollMilliseconds);Notes=@($notes)}

    $simulator=$null
    if ($deviceClass -in @('TempRename','DirectExclusive','DirectReadable')) {
        $rates=@($completed | Where-Object { $_.BytesPerSecond -gt 0 } | ForEach-Object BytesPerSecond | Sort-Object)
        $sizes=@($completed | ForEach-Object FinalBytes | Sort-Object)
        $rate=if($rates.Count){$rates[[int][math]::Floor(($rates.Count-1)/2)]}else{$null}
        $simulator=[pscustomobject]@{
            Profile=if($deviceClass -eq 'TempRename'){'Rename'}else{'Normal'}
            FileSharing=if($deviceClass -eq 'DirectReadable'){'Read'}else{'None'}
            ChunkKiB=64
            DelayMs=if($rate){[int][math]::Min(60000,[math]::Round(65536/$rate*1000))}else{0}
            TargetBytes=$sizes[[int][math]::Floor(($sizes.Count-1)/2)]
            Basis='Median completed size and write rate; the delay spreads 64 KiB chunks over the observed rate. Approximate: bounded by the sampling interval.'
        }
    }
    $w32=try { (Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name Type -ErrorAction Stop).Type } catch { $null }
    [pscustomobject][ordered]@{
        SchemaVersion=1; SessionId=$SessionId; DeviceName=$DeviceName
        Classification=$deviceClass; Confidence=$confidence; Override=$null; Evidence=@($evidence)
        Recommendation=$recommendation; SimulatorSettings=$simulator
        Capture=[pscustomobject][ordered]@{StartUtc=$StartUtc.ToString('o');EndUtc=$EndUtc.ToString('o');PollMilliseconds=$PollMilliseconds;QuietSeconds=$QuietSeconds;IgnoredExistingFiles=$IgnoredExistingFiles;EndedEarly=$EndedEarly;FileNamesIncluded=[bool]$IncludeFileNames}
        Clock=[pscustomobject][ordered]@{Computer=if($IncludeFileNames){[Environment]::MachineName}else{$null};TimeZone=[TimeZoneInfo]::Local.Id;MonotonicHighResolution=[Diagnostics.Stopwatch]::IsHighResolution;TimeServiceType=$w32;RemoteOffsetMeasured=$false}
        Files=$files
        Limits=@(
            'File observation cannot show SMB CREATE access or share flags, chunk sizes or protocol commands; use SMB server ETW or a packet capture, with consent, for those.'
            ('Sampled every {0} ms: shorter writes and pauses are not seen, and timings are bounded by that interval.' -f $PollMilliseconds)
            'Probes briefly open each new file; a probe handle can delay a writer that reopens the file exclusively.'
            'Timestamps are this computer''s UTC clock plus a local monotonic stopwatch; no remote clock offset is measured here.'
            'Repeat the capture after firmware or scan-profile changes.'
        )
    }
}