Skip to content

low: value time.Time API fields — omitempty is a no-op and some render in local time, not UTC (codebase-wide) #546

Description

@xe-nvdk

Context

Two latent defects share the same set of struct fields — value time.Time fields on JSON API responses:

(1) omitempty is a no-op on a value time.Time. encoding/json never omits a time.Time struct for omitempty (a struct is not an "empty" kind, and time.Time implements json.Marshaler), so the zero value always marshals as "0001-01-01T00:00:00Z" instead of being omitted. The tag misleadingly implies the field is optional.

(2) Some are stamped in local time, not UTC. Arc's convention is UTC everywhere. The MQTT stats timestamps are set with a bare time.Now() (and reconstructed via time.Unix, which returns local), so connected_since / last_message_at render with the server's local offset — inconsistent with every other timestamp in the API.

Surfaced during #328 (MQTT lastMessageAt mutex→atomic). Left out of scope there; investigated codebase-wide here.

The uniform rule (matches the already-correct sites)

  • A value time.Time field never carries omitempty (it does nothing).
  • A genuinely-optional timestamp (a meaningful "unset" state) is *time.Time, set only when a real value exists — then omitempty actually omits it.
  • Every Arc-stamped timestamp is UTC (time.Now().UTC(), and reconstruct with .UTC()).

Reference for the pattern: internal/queryregistry/registry.go, internal/tiering/tier.go (×4), internal/backup/manifest.go, and auth's own TokenInfo.ExpiresAt — all already *time.Time.

Sites + treatment

Site Fix
internal/mqtt/subscription.go:135 LastMessageAt → *time.Time, UTC (set only when a message was received)
internal/mqtt/subscription.go:136 ConnectedSince → *time.Time, UTC (set only when connected)
internal/auth/auth.go:67 LastUsedAt → *time.Time (already UTC from SQLite; matches sibling ExpiresAt)
internal/license/client.go:71 VerifyResponse.ExpiresAt drop ,omitempty (decode-side/debug-only; authoritative signed struct already uses plain tag)
internal/license/client.go:131 ActivateResponse.ExpiresAt drop ,omitempty (same)
internal/compaction/subprocess.go:52 PartitionTime drop ,omitempty (internal IPC, already UTC, zero is a real sentinel; its completion.go twin already uses a plain tag)

Note on compatibility

Switching a field to *time.Time is a minor response-shape change: clients currently reading "0001-01-01T00:00:00Z" will instead see the field absent. Acceptable — the placeholder carried no information. The MQTT UTC change alters the offset in the rendered timestamp (same instant, Z instead of a local offset). Both worth a changelog line.

Scope

One focused PR sweeping all six sites under the rule above, using the existing *time.Time sites as the pattern.

Origin

Follow-up to #328; broadened to codebase-wide (omitempty + UTC) after grepping the pattern.

Activity

  1. changed the title [-]low(mqtt): omitempty on time.Time stats fields is a no-op — zero timestamps render as 0001-01-01T00:00:00Z[/-] [+]low: omitempty on value time.Time fields is a no-op — zero timestamps render as 0001-01-01T00:00:00Z (codebase-wide)[/+] on Jul 21, 2026
  2. changed the title [-]low: omitempty on value time.Time fields is a no-op — zero timestamps render as 0001-01-01T00:00:00Z (codebase-wide)[/-] [+]low: value time.Time API fields — omitempty is a no-op and some render in local time, not UTC (codebase-wide)[/+] on Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions