fix: emit all JSON timestamps as UTC RFC3339 via a Timestamp type - #6
Merged
Merged
Conversation
JSON timestamps were inconsistent and partly unusable with jq. Logs came
out as UTC ("2026-08-26T02:38:39Z") but learnings kept the local offset
they were stored with ("2026-01-09T15:12:45-06:00"), and jq's
fromdateiso8601 rejects numeric offsets. Items had no timestamps in JSON
at all, so staleness queries over `list --json` were impossible.
The inconsistency came from formatting at each call site with
`.Format(time.RFC3339)`, which preserves whatever offset the value
carries. A shared helper would fix today's sites but relies on future
contributors knowing it exists, so the rule now lives on a type instead:
`Timestamp` (a defined type over time.Time) implements MarshalJSON to
write UTC with whole seconds, the only form jq accepts. JSON structs
declare their time fields as `Timestamp`, so a new field copied from its
neighbors gets the right format. A plain time.Time field would not work
as a substitute: its built-in MarshalJSON emits fractional seconds,
which jq also rejects.
- LogJSON and LearningJSON `created_at` are now Timestamp
- ItemListJSON and ItemShowJSON gain `created_at` and `updated_at`
- Timestamp also implements UnmarshalJSON, since tests decode output
back into these structs
Scope: JSON output only. The database still stores Go's time.Time
String() format, so SQL date functions still return NULL; that needs
the separate DSN change and migration. `ready --json` stays minimal
without timestamps, and `status` still has no JSON mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reasoning for emitting JSON timestamps as UTC RFC 3339 with whole seconds lived only in the Timestamp type's doc comment and this PR's history. A comment goes away with the code it is attached to, and history is only found by someone who already knows to look, so the decision needs a record that survives refactors of the type. Adds docs/adr/0001-json-timestamp-format.md: the normative contract (UTC `Z`, no fractional seconds, no offsets, new fields use Timestamp), why jq's fromdateiso8601 dictates that form, the rejected alternatives (local offsets, plain time.Time, epoch integers, a helper, an AST lint test), consequences including the remaining time.Time gap, and runnable verification. This is the first ADR in the repo; docs/adr/ is the proposed home for future ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
JSON output from
progcouldn't be reliably processed with jq. Timestamps came out in two formats: logs as UTC (2026-08-26T02:38:39Z) and learnings with the local offset they were stored with (2026-01-09T15:12:45-06:00). jq'sfromdateiso8601rejects the second form. Items had no timestamps in JSON at all, so a staleness query overlist --jsonwasn't possible.The cause was formatting at each call site with
.Format(time.RFC3339), which keeps whatever offset the value carries.What changed
The formatting rule now lives on a type rather than at call sites.
Timestampis a defined type overtime.TimewhoseMarshalJSONwrites UTC with whole seconds — the only form jq accepts. JSON structs declare their time fields asTimestamp, so a new field copied from its neighbors gets the right format without anyone needing to know about a helper.LogJSONandLearningJSONcreated_atare nowTimestampItemListJSONandItemShowJSONgaincreated_atandupdated_atTimestampalso implementsUnmarshalJSON, since the tests decode output back into these structsdocs/adr/0001-json-timestamp-format.mdrecords the decision, the contract, rejected alternatives, and runnable verification, so the reasoning survives refactors of the typeA plain
time.Timefield is not a safe substitute: its built-inMarshalJSONemits fractional seconds (…45.123456789Z), which jq 1.6 also rejects. A new field declared astime.Timewould compile and look fine in review — that's the remaining gap, deliberately left to review rather than an AST-based guard test.Before / after
This now works:
Validation: new test asserts a
-06:00time with nanoseconds marshals to exactly"2026-01-09T21:12:45Z"and round-trips to the same instant. Against the real database, all 1,354 timestamps inlist --jsonparse withfromdateiso8601.Scope
JSON output only; text output is unchanged. The database still stores Go's
time.Time.String()format, so SQL date functions still return NULL — that needs the separate_time_format=sqliteDSN change plus a migration.ready --jsonstays minimal without timestamps, andstatusstill has no JSON mode.Most of the
main.godiff is gofmt realigning struct fields around the longer type name.🤖 Generated with Claude Code