Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions cmd/prog/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
}
75 changes: 53 additions & 22 deletions cmd/prog/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "", " ")
Expand Down Expand Up @@ -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{
Expand All @@ -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, "", " ")
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions docs/adr/0001-json-timestamp-format.md
Original file line number Diff line number Diff line change
@@ -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 <project> -c <concept> --json |
jq -r '.[].created_at | select(endswith("Z") | not)'
```
Loading