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
This commit is contained in:
Cameron Cordes committed 2026-09-24 18:23:46 -04:00
1 parent bf64d046d8
commit ee773a34ef
3 files changed
+126

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ database/target
/tmp
/docs/*
!/docs/agents/
!/docs/adr/
/specs
# Default ignored files
+64
View File
@@ -0,0 +1,64 @@
# ImageApi
A photo/video memory system: it ingests media from one or more libraries, derives metadata
(hashes, dates, faces, insights) that follows the bytes rather than the path, and serves it
to two consumers (the Apollo web UI and the FileViewer mobile app) plus a message store (SMS-API).
## Language
### Dates & time
**True instant**:
The actual moment a photo or video was captured, as Unix seconds. The one clock the whole
system matches against — SMS messages, location track points, and photo dates are all true
instants, so they compare directly with no conversion.
_Avoid_: epoch, real UTC, wall time
**Wall clock**:
The local time the capturing device showed (e.g. "14:30 on the beach"), independent of where
the owner is. A wall clock is not an instant until paired with a zone; it is what the UI's
metadata line and insight prompts show so memories read the way the camera remembered them.
_Avoid_: local time, naive date, camera time, date_taken
**Camera offset**:
The zone offset that converts a photo's wall clock into its true instant, resolved per photo
at ingest from the most specific evidence available (an explicit EXIF offset tag, then GPS
position, then the server's zone as a flagged assumption). Recorded on the row so the
conversion is auditable.
_Avoid_: timezone, tz, server offset
**Display zone**:
The IANA zone name of the owner, used for day-level grouping (memories buckets, timelines,
daily summaries) and for rendering wall clocks when a photo carries no camera offset. Stored
as a zone name, never a fixed minute offset, so DST changes stay correct.
_Avoid_: timezone_offset, tz_offset_minutes, client offset
**Date source**:
Which step of the ingest waterfall filled a photo's date — EXIF tag, external metadata
tool, filename, filesystem time, or a manual edit. Recorded per row so weak entries can be
re-resolved when a better tool arrives and so any row's date can be explained.
_Avoid_: provenance, origin
**Filename date class**:
Filenames whose name itself carries the capture date (screenshots, timestamp-named exports).
For this class the filename is the original date of record and outranks EXIF, even though
EXIF wins everywhere else. Each pattern in the class carries its own convention: some names
encode a wall clock, others encode a true instant.
_Avoid_: regex dates, name parsing
**Manual date**:
A date the operator sets by hand, expressed as a wall clock in the display zone at edit time
and stored as the resulting true instant. The prior value is kept as the original date.
_Avoid_: corrected date, user date
**Re-anchoring (legacy)**:
The pre-instant habit of storing wall clocks as if they were UTC and having every consumer
shift them back by a zone. Survives only in the frozen legacy column and in the consumer
code that migrates off it; new code never re-anchors.
_Avoid_: tz fix, offset correction, naive handling
**Derived day data**:
Insights and pre-computed artifacts grouped by calendar day (daily message summaries,
reel pre-generation). Because it is keyed on the day, re-dating a photo across a day
boundary invalidates the derived data for that day, which regenerates on its normal schedule.
_Avoid_: cache, nightly output
+61
View File
@@ -0,0 +1,61 @@
# 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.