Files
ImageApi/docs/adr/0001-true-utc-dates.md
T
Cameron Cordes ee773a34ef docs(dates): record the true-instant date contract
- CONTEXT.md: date/time vocabulary (true instant, wall clock, camera
  offset, display zone, filename date class, ...)
- ADR-0001: dates are true instants; wall clocks are a display concern
- un-ignore docs/adr/ so decisions land in git
2026-09-24 18:23:46 -04:00

62 lines
3.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-0001: Dates are true instants; wall clocks are a display concern
**Status**: accepted (2026-09-24)
## Context
`image_exif.date_taken` (Unix i64) accumulated four conventions in one column, indistinguishable
without joining on `date_taken_source`: EXIF rows were camera wall-clocks reinterpreted as UTC,
exiftool rows were naive dates interpreted in the server's local zone, filename rows were
deliberate wall-as-UTC, and filesystem-time rows were true instants. SMS-API and the Apollo
location track were already true UTC. Every consumer had to re-derive the missing shift
(Apollo's `_naive_to_real_utc`, the frontend `naiveAsUtcToRealUtc`, FileViewer's bare
device-local formatting, `/memories`'s per-client offset) and they disagreed: the same photo
showed different clocks in the two apps, exiftool-sourced videos (whose dates only come from
exiftool) were double-shifted, and the insight-context prompt formatted a value 4–5 hours
wrong for video rows, rolling across midnight near day edges.
## Decision
The date a media file was captured is stored as a **true instant** (Unix seconds of the
actual moment). The wall clock the camera showed is a **display value**, recovered from the
instant plus a per-row **camera offset** resolved at ingest by a cascade (explicit EXIF
offset tag → GPS position → server zone, the last recorded as an assumption). Day-level
grouping uses the owner's **display zone** (an IANA zone name). Manual edits enter as wall
clocks in the display zone and are stored as instants.
Consequences of the decision:
- Matching photo↔SMS, photo↔location, and photo↔calendar becomes instant-to-instant with no
per-consumer shift; the re-anchoring helpers in the consumers are deleted, not migrated.
- A photo's displayed time can differ from the owner's local time (a photo taken in Japan
shows the Japan wall clock); grouping still uses the owner's day. This is deliberate.
- The legacy `date_taken` column is frozen during migration (a new `date_utc` column is
populated by a per-tick backfill drain), so rollback is a client-side flip and the old
contract stays byte-identical until cutover. The legacy column is retained after cutover
for historical context.
## Considered options
- **Standardize on wall-clock-as-UTC** (fix only the exiftool branch): cheapest, but every
consumer would still carry a display-zone re-anchor as a proxy for the camera's zone, and
cross-zone photo↔SMS matching would stay approximate. Rejected.
- **Two parallel fields as a permanent contract** (wall-as-UTC `date_taken` + `date_utc`):
honest, but leaves the ambiguity in the interface forever. The dual-field shape is used
*temporarily* (migration window) only.
- **Server-side conversion per request** (keep the column as-is, convert on read): pushes
the mixed-convention join onto every query and every consumer; rejected.
## Non-obvious rules worth remembering
- **Filename date class outranks EXIF** — for files whose filename carries the capture date
(screenshots, timestamp exports), the filename is the original date of record, inverting
the global EXIF-wins rule. The class is a documented exception, not a bug.
- **Offset resolution is per-row and audited** (`date_taken_tz_source`), because a library
contains photos from many zones; a single global offset would be wrong for every trip.
- **The display zone is a zone name, not minutes** — a hardcoded offset silently breaks at
DST. (The legacy `tz_offset_minutes` default of -480 on a New York server is a surviving
symptom of this.)
- **Re-dating across a day boundary invalidates derived day data** (daily summaries, reel
pre-gen), which regenerates on its normal schedule; the backfill drain detects the day
change per row.