core/private/Save-JaxUpdateNoticeState.ps1

function Save-JaxUpdateNoticeState {
    <#
    .SYNOPSIS
        Persist the local update-notice bookkeeping file.
    .DESCRIPTION
        Holds the last background-refresh timestamp and the shell sessions that
        have already seen a notice. Sessions older than the retention window are
        dropped so the file cannot grow without bound. Writing is best effort:
        a read-only or full HOME must not break a Jax run.

        The state is shared by every concurrent shell, so the on-disk file is
        RE-READ here and merged with the caller's snapshot: the caller may have
        loaded it seconds ago, and writing that stale view back would erase
        another shell's notice record and make the notice repeat there. The
        re-read and the write are serialized by a sibling lock file so two
        shells cannot interleave and lose one another's entry, and the write
        itself goes to a temp file that is then moved into place, so a reader
        never observes a half-written file.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Path,
        $State,
        [DateTime] $LastRefreshSpawnUtc = [DateTime]::MinValue,
        [string] $NotifiedSession,
        [string] $NotifiedVersion,
        [TimeSpan] $SessionRetention = [TimeSpan]::FromDays(7),
        [int] $MaxSessions = 100
    )

    $lock = $null
    try {
        $now = [DateTime]::UtcNow

        $directory = Split-Path -Parent $Path
        if (-not [string]::IsNullOrWhiteSpace($directory)) {
            New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop | Out-Null
        }

        # Advisory cross-process lock over the whole read-modify-write. Bounded
        # on purpose: a shell must never stall on bookkeeping, so a lock that
        # cannot be taken in time is skipped and we fall back to the merge below.
        $lockPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath("$Path.lock")
        $deadline = [DateTime]::UtcNow.AddMilliseconds(750)
        do {
            try {
                $lock = [System.IO.File]::Open(
                    $lockPath,
                    [System.IO.FileMode]::OpenOrCreate,
                    [System.IO.FileAccess]::ReadWrite,
                    [System.IO.FileShare]::None)
            } catch {
                Start-Sleep -Milliseconds 25
            }
        } while ($null -eq $lock -and [DateTime]::UtcNow -lt $deadline)

        # Merge the caller's snapshot with whatever is on disk RIGHT NOW: a
        # concurrent shell may have recorded its own notice since the caller
        # read the file, and a plain overwrite would drop it.
        $onDisk = Read-JaxJsonFile -Path $Path

        $lastSpawn = if ($LastRefreshSpawnUtc -ne [DateTime]::MinValue) {
            $LastRefreshSpawnUtc
        } else {
            $callerSpawn = ConvertTo-JaxUtcDate -Value (Get-JaxJsonProperty -Object $State -Name 'LastRefreshSpawnUtc')
            $diskSpawn = ConvertTo-JaxUtcDate -Value (Get-JaxJsonProperty -Object $onDisk -Name 'LastRefreshSpawnUtc')
            # The newest refresh wins — an older snapshot must not re-open the
            # refresh window for every other shell.
            if ($null -eq $callerSpawn) { $diskSpawn }
            elseif ($null -eq $diskSpawn) { $callerSpawn }
            elseif ($diskSpawn -gt $callerSpawn) { $diskSpawn }
            else { $callerSpawn }
        }

        $sessions = [System.Collections.Generic.List[object]]::new()
        $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
        $merged = @(Get-JaxJsonProperty -Object $onDisk -Name 'NotifiedSessions') +
                  @(Get-JaxJsonProperty -Object $State -Name 'NotifiedSessions')
        foreach ($entry in $merged) {
            if ($null -eq $entry) { continue }
            $session = [string](Get-JaxJsonProperty -Object $entry -Name 'Session')
            if ([string]::IsNullOrWhiteSpace($session)) { continue }
            if (-not [string]::IsNullOrWhiteSpace($NotifiedSession) -and $session -eq $NotifiedSession) { continue }
            if (-not $seen.Add($session)) { continue }
            $at = ConvertTo-JaxUtcDate -Value (Get-JaxJsonProperty -Object $entry -Name 'AtUtc')
            if ($null -eq $at -or ($now - $at) -ge $SessionRetention) { continue }
            $sessions.Add([ordered]@{
                Session = $session
                Version = [string](Get-JaxJsonProperty -Object $entry -Name 'Version')
                AtUtc   = $at.ToString('O')
            })
        }

        if (-not [string]::IsNullOrWhiteSpace($NotifiedSession)) {
            $sessions.Add([ordered]@{
                Session = $NotifiedSession
                Version = $NotifiedVersion
                AtUtc   = $now.ToString('O')
            })
        }

        if ($sessions.Count -gt $MaxSessions) {
            $sessions = [System.Collections.Generic.List[object]](
                @($sessions | Select-Object -Last $MaxSessions)
            )
        }

        $record = [ordered]@{
            LastRefreshSpawnUtc = if ($null -ne $lastSpawn) { $lastSpawn.ToString('O') } else { $null }
            NotifiedSessions    = @($sessions)
        }

        # Write-then-move: a concurrent reader sees either the old file or the
        # new one, never a truncated one mid-write. The temp name carries a
        # unique suffix so two writers in the same process cannot share it.
        $temp = "$Path.$PID.$([guid]::NewGuid().ToString('N')).tmp"
        try {
            $record | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temp -Encoding utf8 -ErrorAction Stop
            # .NET Move overwrites in one syscall; it needs native paths, which
            # can differ from the PowerShell location a relative -Path resolves against.
            [System.IO.File]::Move(
                $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($temp),
                $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path),
                $true)
        } finally {
            if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
        }
    } catch {
        # Bookkeeping is optional; at worst the notice repeats.
    } finally {
        if ($null -ne $lock) { $lock.Dispose() }
    }
}