extensions/specrew-speckit/scripts/timestamp-read.ps1

# ONE TIMESTAMP READER, because three sites were bitten by the same thing in one batch.
#
# ConvertFrom-Json coerces an ISO-8601 string into a [datetime] on the way back. Every site that then did
# something reasonable-looking with the result was wrong in a different way:
#
# 1. the design-decision record's read-back verify checked `recorded_at -is [string]` and failed on a
# record the script had just written;
# 2. the turn-token listing re-parsed the coerced value through a string, lost the sub-second part, and
# two tokens issued 30 ms apart compared EQUAL - the (since retired) newest-wins sort went arbitrary;
# 3. the in-flight render cooldown cast the coerced [datetime] to a string, which dropped the UTC
# designator and the fraction, then parsed it as LOCAL time - a render 0.5 s old read as ~3 hours old
# on this machine's zone and the cooldown was bypassed.
#
# Three instances is a pattern, and the pattern is "a type surviving JSON". So every timestamp field is read
# through this one function, which accepts whatever JSON handed back - a string, a [datetime], a
# [DateTimeOffset], or a unix-millisecond number - and returns a UTC [DateTimeOffset] or $null. It never
# goes through a string it did not receive as a string, and it never assumes local time.

function ConvertTo-SpecrewUtcTimestamp {
    [OutputType([Nullable[DateTimeOffset]])]
    param([AllowNull()]$Value)

    if ($null -eq $Value) { return $null }
    try {
        if ($Value -is [DateTimeOffset]) { return ([DateTimeOffset]$Value).ToUniversalTime() }
        if ($Value -is [datetime]) {
            # The JSON coercion hands back Kind Local for a string carrying a designator (already shifted to this
            # machine's zone - the cast to [DateTimeOffset] below undoes that correctly) and Kind Unspecified for
            # a bare one. Treating Unspecified as local is the site-3 mistake: nothing here was written in a
            # local zone, so a bare stamp is UTC.
            $dt = [datetime]$Value
            if ($dt.Kind -eq [DateTimeKind]::Unspecified) { $dt = [DateTime]::SpecifyKind($dt, [DateTimeKind]::Utc) }
            return ([DateTimeOffset]$dt).ToUniversalTime()
        }
        if ($Value -is [long] -or $Value -is [int] -or $Value -is [double] -or $Value -is [decimal]) {
            # Unix milliseconds. A number cannot be coerced into something else by JSON, which is why the
            # ordering and cooldown fields are written as numbers alongside their ISO strings.
            return [DateTimeOffset]::FromUnixTimeMilliseconds([long]$Value)
        }
        $text = ([string]$Value).Trim()
        if ([string]::IsNullOrWhiteSpace($text)) { return $null }
        if ($text -match '^\d{10,}$') { return [DateTimeOffset]::FromUnixTimeMilliseconds([long]$text) }
        # Invariant culture, and AssumeUniversal for a string that carries no designator: nothing here was
        # ever written in a local zone, so a bare timestamp is UTC, not "wherever this machine is".
        return [DateTimeOffset]::Parse($text, [System.Globalization.CultureInfo]::InvariantCulture,
            ([System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal))
    }
    catch { return $null }
}

function Get-SpecrewUtcNowMilliseconds {
    # The write-side twin: the number a timestamp field carries beside its ISO string, so the read side
    # never has to depend on a type surviving JSON.
    [OutputType([long])]
    param()
    return [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
}