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:
1 parent
bf64d046d8
commit
ee773a34ef
3 files changed
+126
No files matched your search
@@ -10,6 +10,7 @@ database/target
|
||||
/tmp
|
||||
/docs/*
|
||||
!/docs/agents/
|
||||
!/docs/adr/
|
||||
/specs
|
||||
|
||||
# Default ignored files
|
||||
|
||||
+64
@@ -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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user