diff --git a/.gitignore b/.gitignore index bffb468..ebf67b0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ database/target /tmp /docs/* !/docs/agents/ +!/docs/adr/ /specs # Default ignored files diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..cd59bf4 --- /dev/null +++ b/CONTEXT.md @@ -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 diff --git a/docs/adr/0001-true-utc-dates.md b/docs/adr/0001-true-utc-dates.md new file mode 100644 index 0000000..a6a81aa --- /dev/null +++ b/docs/adr/0001-true-utc-dates.md @@ -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.