From b9e6d3973c2b1c42c726379ca06a00928ff916c8 Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Tue, 22 Sep 2026 21:55:08 -0500 Subject: [PATCH 1/2] fix(json): emit all timestamps as UTC RFC3339 via a Timestamp type 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 --- cmd/prog/json_test.go | 33 ++++++++++++++++--- cmd/prog/main.go | 75 ++++++++++++++++++++++++++++++------------- 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/cmd/prog/json_test.go b/cmd/prog/json_test.go index 891795e..839d9d0 100644 --- a/cmd/prog/json_test.go +++ b/cmd/prog/json_test.go @@ -184,7 +184,7 @@ func TestShowJSON_FullDetail(t *testing.T) { for _, l := range logs { logEntries = append(logEntries, LogJSON{ Message: l.Message, - CreatedAt: l.CreatedAt.Format(time.RFC3339), + CreatedAt: Timestamp(l.CreatedAt), }) } out := ItemShowJSON{ @@ -247,9 +247,9 @@ func TestShowJSON_FullDetail(t *testing.T) { if len(result.Logs) != 1 || result.Logs[0].Message != "Started work" { t.Errorf("logs = %v", result.Logs) } - // Verify log timestamp is RFC3339 - if _, err := time.Parse(time.RFC3339, result.Logs[0].CreatedAt); err != nil { - t.Errorf("log created_at not RFC3339: %q", result.Logs[0].CreatedAt) + // Unmarshal already rejects non-RFC3339 values; check one was present. + if time.Time(result.Logs[0].CreatedAt).IsZero() { + t.Error("log created_at missing") } } @@ -281,7 +281,7 @@ func TestShowJSON_EmptyArrayFields(t *testing.T) { for _, l := range logs { logEntries = append(logEntries, LogJSON{ Message: l.Message, - CreatedAt: l.CreatedAt.Format(time.RFC3339), + CreatedAt: Timestamp(l.CreatedAt), }) } out := ItemShowJSON{ @@ -460,3 +460,26 @@ func TestListJSON_NoLogsField(t *testing.T) { t.Error("list JSON should NOT contain 'logs' field") } } + +func TestTimestamp_MarshalsAsUTCWholeSeconds(t *testing.T) { + // A time stored with a local offset and sub-second precision must marshal + // in the one form jq's fromdateiso8601 accepts. + cst := time.FixedZone("CST", -6*60*60) + in := time.Date(2026, 1, 9, 15, 12, 45, 123456789, cst) + + b, err := json.Marshal(Timestamp(in)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got, want := string(b), `"2026-01-09T21:12:45Z"`; got != want { + t.Errorf("marshal = %s, want %s", got, want) + } + + var back Timestamp + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !time.Time(back).Equal(in.Truncate(time.Second)) { + t.Errorf("round-trip changed the instant: got %v, want %v", time.Time(back), in) + } +} diff --git a/cmd/prog/main.go b/cmd/prog/main.go index 9bc9982..c6cdc2a 100644 --- a/cmd/prog/main.go +++ b/cmd/prog/main.go @@ -318,6 +318,8 @@ Examples: DefinitionOfDone: item.DefinitionOfDone, Labels: labels, Dependencies: deps, + CreatedAt: Timestamp(item.CreatedAt), + UpdatedAt: Timestamp(item.UpdatedAt), }) } b, err := json.MarshalIndent(output, "", " ") @@ -458,7 +460,7 @@ Examples: for _, l := range logs { logEntries = append(logEntries, LogJSON{ Message: l.Message, - CreatedAt: l.CreatedAt.Format(time.RFC3339), + CreatedAt: Timestamp(l.CreatedAt), }) } output := ItemShowJSON{ @@ -473,6 +475,8 @@ Examples: DefinitionOfDone: item.DefinitionOfDone, Labels: labels, Dependencies: deps, + CreatedAt: Timestamp(item.CreatedAt), + UpdatedAt: Timestamp(item.UpdatedAt), Logs: logEntries, } b, err := json.MarshalIndent(output, "", " ") @@ -2695,39 +2699,66 @@ type ItemShowJSON struct { DefinitionOfDone *string `json:"definition_of_done"` Labels []string `json:"labels"` Dependencies []string `json:"dependencies"` + CreatedAt Timestamp `json:"created_at"` + UpdatedAt Timestamp `json:"updated_at"` Logs []LogJSON `json:"logs"` } // ItemListJSON is the JSON serialization format for list (show schema minus logs). type ItemListJSON struct { - ID string `json:"id"` - Title string `json:"title"` - Type string `json:"type"` - Status string `json:"status"` - Priority int `json:"priority"` - Project string `json:"project"` - Parent *string `json:"parent"` - Description string `json:"description"` - DefinitionOfDone *string `json:"definition_of_done"` - Labels []string `json:"labels"` - Dependencies []string `json:"dependencies"` + ID string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Status string `json:"status"` + Priority int `json:"priority"` + Project string `json:"project"` + Parent *string `json:"parent"` + Description string `json:"description"` + DefinitionOfDone *string `json:"definition_of_done"` + Labels []string `json:"labels"` + Dependencies []string `json:"dependencies"` + CreatedAt Timestamp `json:"created_at"` + UpdatedAt Timestamp `json:"updated_at"` +} + +// Timestamp is the type for every time field in JSON output. It marshals as +// UTC RFC3339 with whole seconds ("2026-01-09T21:12:45Z") because that is the +// only form jq's fromdateiso8601 accepts: it rejects numeric offsets and +// fractional seconds, and a plain time.Time field would emit both. +type Timestamp time.Time + +func (t Timestamp) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Time(t).UTC().Format(time.RFC3339)) +} + +func (t *Timestamp) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + parsed, err := time.Parse(time.RFC3339, s) + if err != nil { + return err + } + *t = Timestamp(parsed) + return nil } // LogJSON is the JSON serialization format for log entries. type LogJSON struct { - Message string `json:"message"` - CreatedAt string `json:"created_at"` + Message string `json:"message"` + CreatedAt Timestamp `json:"created_at"` } // LearningJSON is the JSON serialization format for learnings. type LearningJSON struct { - ID string `json:"id"` - Summary string `json:"summary"` - Detail string `json:"detail,omitempty"` - Concepts []string `json:"concepts"` - Files []string `json:"files,omitempty"` - CreatedAt string `json:"created_at"` - Status string `json:"status"` + ID string `json:"id"` + Summary string `json:"summary"` + Detail string `json:"detail,omitempty"` + Concepts []string `json:"concepts"` + Files []string `json:"files,omitempty"` + CreatedAt Timestamp `json:"created_at"` + Status string `json:"status"` } func printLearningsJSON(learnings []model.Learning) error { @@ -2739,7 +2770,7 @@ func printLearningsJSON(learnings []model.Learning) error { Detail: l.Detail, Concepts: l.Concepts, Files: l.Files, - CreatedAt: l.CreatedAt.Format(time.RFC3339), + CreatedAt: Timestamp(l.CreatedAt), Status: string(l.Status), } if lj.Concepts == nil { From 48458e5559996e73d34111026f06d7ef84766171 Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Tue, 22 Sep 2026 22:12:10 -0500 Subject: [PATCH 2/2] docs(adr): record JSON timestamp format decision 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 --- docs/adr/0001-json-timestamp-format.md | 103 +++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/adr/0001-json-timestamp-format.md diff --git a/docs/adr/0001-json-timestamp-format.md b/docs/adr/0001-json-timestamp-format.md new file mode 100644 index 0000000..a31fa80 --- /dev/null +++ b/docs/adr/0001-json-timestamp-format.md @@ -0,0 +1,103 @@ +# ADR 0001: JSON timestamps are UTC RFC 3339 with whole seconds + +- **Status:** Accepted +- **Date:** 2026-09-22 +- **Introduced in:** #6 + +## Purpose + +`prog`'s `--json` output exists so scripts and agents can process tasks, logs, +and learnings with standard tools, jq in particular. Timestamps are the field +those tools most often compute with: "what hasn't been touched in 30 days" is +the canonical query. That only works if every timestamp, from every command, +parses with the same tool call. + +Before this decision, logs came out as UTC (`2026-08-26T02:38:39Z`), +learnings kept their stored local offset (`2026-01-09T15:12:45-06:00`), and +items had no timestamps at all. jq's `fromdateiso8601` rejected the learning +form, so a timestamp-based pipeline broke depending on which command fed it. + +## Decision + +Every timestamp in JSON output is formatted as RFC 3339 in UTC with whole +seconds, e.g. `2026-01-09T21:12:45Z`. + +The rule lives on a type, `Timestamp` in `cmd/prog`, whose `MarshalJSON` +applies it. JSON output structs declare time fields as `Timestamp`. + +## Scope + +Covers every `*_at` field emitted by `--json` on any command. Today that is +`list`, `show`, and `context`. + +Non-goals: +- **Database storage format.** How timestamps are stored in `~/.prog/prog.db` + is a separate decision. Changing storage MUST NOT change this JSON format. +- **Human-readable text output.** Non-JSON output may use any format, + including local time. +- **Which commands expose timestamps.** `ready --json` omits them by design; + adding timestamps to a command is allowed and MUST follow this format. + +## Behavior Contract + +- JSON timestamps MUST be RFC 3339 strings ending in `Z` (UTC). +- JSON timestamps MUST have whole-second precision; they MUST NOT contain a + fractional-seconds component. +- JSON timestamps MUST NOT contain a numeric offset such as `-06:00`, even + when the stored value carries one. +- Every JSON timestamp MUST be accepted by jq's `fromdateiso8601`. +- Formatting MUST preserve the instant: converting to UTC and truncating to + the second is the only transformation allowed. +- New timestamp fields in JSON output structs MUST use `Timestamp`, not + `time.Time` or `string`. + +## Why this form + +jq 1.6's `fromdateiso8601` only accepts the pattern `%Y-%m-%dT%H:%M:%SZ`. +That pattern rules out numeric offsets and fractional seconds, which is why +both are forbidden rather than merely discouraged. Most other consumers (Go, +JavaScript `Date`, Python 3.11+ `datetime.fromisoformat`, `date -d`) also accept +this form, so it is the most widely parseable choice rather than a +jq-specific one. + +Alternatives considered: + +- **Local time with offset.** Preserves the author's wall clock, but jq + rejects it, and consumers comparing timestamps would have to normalize + anyway. +- **Plain `time.Time` fields.** Go's built-in `MarshalJSON` emits RFC 3339 + with nanoseconds (`…45.123456789Z`), which jq rejects. It is the most + tempting shortcut and the one this decision most needs to prevent. +- **Unix epoch integers.** jq handles these without parsing, but they are + unreadable in raw output and lose self-description. Rejected for human + and agent readability. +- **A shared formatting helper.** Fixes existing call sites, but a new field + is correct only if its author knows the helper exists. A type puts the + rule on the field declaration, where copying a neighboring field gets it + right. +- **A lint/AST test that rejects `time.Time` in JSON structs.** Would close + the remaining gap mechanically. Rejected as more machinery than the risk + warrants; the type plus review is the accepted barrier. + +## Consequences + +- Sub-second precision is dropped. Events within the same second get equal + timestamps; consumers that need ordering within a second should rely on + array order, not timestamps. +- The author's local time is not recoverable from JSON. Consumers that + display local time must convert. +- A field declared as `time.Time` in a JSON struct compiles and looks + correct but violates this contract. Review is the only guard. + +## Verification + +- `go test ./cmd/prog -run TestTimestamp` asserts that a `-06:00` time with + nanoseconds marshals to exactly `"2026-01-09T21:12:45Z"` and round-trips + to the same instant. +- Against a real database, this must produce no errors and no output: + + ```bash + prog list --json | jq -r '.[] | .created_at, .updated_at | fromdateiso8601' >/dev/null + prog context -p -c --json | + jq -r '.[].created_at | select(endswith("Z") | not)' + ```