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.
Context
Two latent defects share the same set of struct fields — value
time.Timefields on JSON API responses:(1)
omitemptyis a no-op on a valuetime.Time.encoding/jsonnever omits atime.Timestruct foromitempty(a struct is not an "empty" kind, andtime.Timeimplementsjson.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 viatime.Unix, which returns local), soconnected_since/last_message_atrender with the server's local offset — inconsistent with every other timestamp in the API.Surfaced during #328 (MQTT
lastMessageAtmutex→atomic). Left out of scope there; investigated codebase-wide here.The uniform rule (matches the already-correct sites)
time.Timefield never carriesomitempty(it does nothing).*time.Time, set only when a real value exists — thenomitemptyactually omits it.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 ownTokenInfo.ExpiresAt— all already*time.Time.Sites + treatment
internal/mqtt/subscription.go:135LastMessageAt*time.Time, UTC (set only when a message was received)internal/mqtt/subscription.go:136ConnectedSince*time.Time, UTC (set only when connected)internal/auth/auth.go:67LastUsedAt*time.Time(already UTC from SQLite; matches siblingExpiresAt)internal/license/client.go:71VerifyResponse.ExpiresAt,omitempty(decode-side/debug-only; authoritative signed struct already uses plain tag)internal/license/client.go:131ActivateResponse.ExpiresAt,omitempty(same)internal/compaction/subprocess.go:52PartitionTime,omitempty(internal IPC, already UTC, zero is a real sentinel; itscompletion.gotwin already uses a plain tag)Note on compatibility
Switching a field to
*time.Timeis 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,Zinstead 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.Timesites as the pattern.Origin
Follow-up to #328; broadened to codebase-wide (omitempty + UTC) after grepping the pattern.