# ADR-007: Timestamps are fixed-width, and written through one helper ## Status Accepted ## Date 2026-10-12 ## Context Timestamps are stored as TEXT and compared as strings: `ORDER BY updated_at`, `MAX(updated_at)`, `run_after <= ?`, `expires_at < ?`. String comparison is only correct if the strings are the same width, which is easy to get wrong. It was wrong. Two formats were in use: - SQLite's `strftime('%Y-%m-%dT%H:%M:%fZ','now')` — exactly three fractional digits. - Go's `time.RFC3339Nano` — **variable** width, because it trims trailing zeros. ## The bug `RFC3339Nano` produces `.5Z` for 500 milliseconds and `.55Z` for 550. Compared as strings, `".5Z" > ".55Z"` — but `.500s < .550s`. The ordering is inverted. This is **not** a cross-format problem. It is wrong even in a column written 100% by Go, because the fraction width varies within that column. Live data showed 9-digit, 8-digit and 3-digit values in the same column: ``` jobs.run_after ...16:57:11.383879091Z ← 9 digits jobs.run_after ...16:53:06.28925937Z ← 8 digits, trailing zero trimmed media_items.added_at ...01:28:19.733Z ← 3 digits, SQLite default ``` It silently affected the **Continue shelf** (`MAX(updated_at)`) and **Most Viewed** tiebreaks — both user-visible. ## Decision All Go-side timestamp writes go through `core.Stamp`, which produces a **fixed-width** format matching SQLite's own default exactly: ```go t.UTC().Format("2006-01-02T15:04:05.000Z") ``` ## Alternatives Considered ### Store Unix nanoseconds as INTEGER - **Pros:** unambiguously sortable; no format question at all. - **Cons:** every existing row and every query would need converting, and the values stop being human-readable in the database — which matters when you are debugging by hand. - **Rejected:** too large a migration for a bug that a format change fixes. ### Store RFC3339 with a fixed 6 or 9 digits - **Pros:** fixed width, more precision. - **Cons:** would not match SQLite's default, so any row that ever took the column default would still be a different width — the same bug in a new shape. - **Rejected:** matching the database's own format is what makes both sources comparable. ### Sort in Go instead of in SQL - **Pros:** no format constraint. - **Cons:** would require loading every row to sort, defeating the indexes. - **Rejected:** the index does the sort; the format must cooperate. ## Consequences - All 14 Go-side write sites use `core.Stamp` / `core.Now`. Verified: zero variable-width writes remain. - **Read paths were deliberately left alone.** `time.RFC3339Nano` parses both the old and the new shapes, so existing rows keep working with no migration. - A test proves the property directly: `Stamp` at .500s sorts **before** `Stamp` at .550s as a string, and the output matches what SQLite's own default produces, byte for byte. - **The general rule:** if a value is ever compared in SQL, its textual form must be fixed-width.