Dates and timezones are inconsistent across the system and often display wrong or query incorrectly. The same photo shows a different clock in the web app than in the mobile app. Videos — whose dates are only ever found by the external metadata tool — display 4–5 hours off, and near midnight the AI's "immediate context" (messages, location, calendar around a photo) is anchored on the wrong day, throwing the insight off. Matching photos to SMS messages and location track points is approximate because the photo side of the join carries a wall clock the consumers must guess how to shift, while the message and location sides are already true instants.
Solution
Make the system keep one clock: the true instant — the actual moment a photo or video was captured, as Unix seconds. The wall clock (what the camera showed) becomes a display value, recovered from the instant plus a per-row camera offset that is resolved at ingest from the most specific evidence available (an explicit EXIF offset tag, then GPS position, then the server's zone, recorded as an assumption) and audited on the row. Day-level grouping (memories, timelines, daily summaries) uses the owner's display zone, stored as an IANA zone name so DST stays correct.
The change lands as: (1) a re-based date waterfall that emits true instants with per-row offset and audit data; (2) a per-tick backfill drain that converts the existing mixed-convention rows without touching the legacy column; (3) a one-shot pre-cutover audit report for the filename date class; (4) a widened API surface serving the new fields beside the frozen legacy ones; (5) a coordinated big-bang flip of the consumers onto the new fields — the consumers' changes are tracked in their own repos' trackers (filed alongside this spec); this spec defines the contract they flip to. After the flip, the legacy re-anchoring code in the consumers is deleted. The legacy column is retained for historical context.
User Stories
As the owner, when I view a photo's metadata in the web UI, I want the time to show the wall clock my camera displayed, so the memory reads the way I remember it.
As the owner, when I view the same photo in the mobile app, I want the exact same clock as the web UI, so two apps never disagree about when I took something.
As the owner, when I view a photo's surrounding messages, I want them matched by the photo's true instant, so a 9pm shot pulls 9pm messages even though its camera offset differs from mine.
As the owner, when I view a photo's location context, I want the location track queried at the photo's true instant, so the location shown is the one I was actually at.
As the owner, when a video's date comes from container metadata, I want it stored as the true instant, so the video's insight context is no longer 4–5 hours off or rolled to the previous day.
As the owner, when I generate an insight, I want the date the AI is told to be the same wall clock I see in the UI, so the AI's "immediate context" agrees with mine.
As the owner, when I browse memories grouped by day, I want the days to be my days (my display zone), so an anniversary photo lands on the right day even if it was taken in another zone.
As the owner, when I take a photo in a different timezone (travel), I want its camera offset resolved from the photo's own evidence (offset tag, then GPS), so the true instant is correct and the day grouping still lands in my zone.
As the owner, when a photo carries no zone evidence at all, I want the fallback assumption (server zone) to be recorded on the row, so I can find and audit the assumed rows.
As the owner, when a photo's true instant crosses a year boundary in my zone, I want the day it groups under to follow my zone's calendar, not the capture zone's.
As the owner, when I manually set a photo's date, I want to type the wall clock I think in (my display zone) and have the system store the resulting true instant, so editing feels like setting a date, not an epoch.
As the owner, when I manually change a date, I want the previous value snapshotted, so I can revert to it.
As the owner, when a filename carries the original date (screenshot-style or timestamp-style names), I want the filename to outrank EXIF, so the preserved original date survives edited or stripped metadata.
As the owner, when a filename is a Unix timestamp, I want it treated directly as a true instant, so no zone is guessed.
As the owner, before the migration changes anything, I want a one-shot report of every filename-class file showing its current date, its proposed date, and the proposed offset source, so I can spot-check the "feel" against my memory.
As the owner, during the migration window, I want the legacy date field to remain byte-identical, so any client still on the old contract keeps working and rollback is just flipping clients back.
As the owner, I want to see migration progress (rows remaining to convert), so I know when it is safe to flip the consumers.
As the owner, when the drain re-dates a photo across a day boundary in my zone, I want that day's derived day data (daily summaries, pre-generated reels) to be marked for regeneration, so "what happened this day" is never one row stale.
As the owner, I want my display zone stored as a named zone (America/New_York), not a fixed minute offset, so daylight-saving transitions never silently shift groupings by an hour.
As the owner, after the cutover, I want the consumers to contain no re-anchoring shift at all, so there is exactly one convention in the code and the next date bug is impossible by construction.
As the owner, I want videos date-resolved in the same drain and by the same waterfall as photos, so there is one date path for all media.
As the operator, when I ask "why does this row believe this date?", I want the answer readable from the stored date source and offset-source columns, without re-deriving it.
As the operator, when a library is offline (unmounted share), I want the drain to skip it and resume later, so an unplugged drive never causes a wrong or partial conversion.
As the operator, when the drain runs a second pass over already-converted rows, I want it to change nothing, so the steady state is quiet.
Implementation Decisions
Schema
New columns on the image-exif table (all nullable until the drain completes; the legacy date_taken and date_taken_source columns are frozen, not rewritten):
Column
Meaning
date_utc
The true instant (Unix seconds). NULL until the drain reaches the row.
camera_offset_minutes
Signed minutes of the camera offset (wall clock = instant − offset). NULL when the row is a true instant with no wall-clock meaning (e.g. filesystem-time rows) or when no evidence exists.
date_taken_tz_source
Audit of how the offset was resolved: offset_tag, gps, assumed_server, utc_direct (filename Unix timestamps), manual, or NULL (no wall-clock meaning).
The per-user preference store's fixed minute offset (tz_offset_minutes) is replaced by an IANA zone name (display_tz, default America/New_York); the minute value is always derived from the zone for the relevant year (DST-correct), never stored.
The legacy manual-edit snapshot columns continue to be used, but from now on snapshot the true instant plus offset rather than a mixed-convention epoch.
The date waterfall
The waterfall is re-based so every branch emits (true instant, date source, camera offset, tz source) instead of a mixed-convention epoch:
EXIF branch: the tag's wall clock is converted via the offset cascade.
External-tool branch: container dates (QuickTime/MP4) are treated as the camera's wall clock and converted via the same cascade — this replaces the current behavior of interpreting them in the server's local zone, which was the root of the video-date errors. The batch tool call per drain tick is kept (one subprocess per batch).
Filename branch: the existing pattern list is retained, but each pattern is explicitly tagged with its convention — wall-clock patterns (screenshot, dash, compact) go through the offset cascade; Unix-timestamp patterns are true instants directly. Within the filename date class, the filename outranks EXIF — a deliberate, documented inversion of the global EXIF-wins rule. Outside the class, EXIF still wins.
Filesystem-time branch: already a true instant; offset stays NULL.
Manual dates: entered as a wall clock in the display zone at edit time; stored as the resulting true instant, tz source manual, previous value snapshotted.
The offset cascade, in order: (1) an explicit EXIF offset tag on the same file (OffsetTime/OffsetTimeOriginal); (2) the file's GPS position resolved through a timezone database for the capture year (DST-correct); (3) the server zone, recorded as assumed_server.
The backfill drain
A new per-tick drain, following the exact shape of the existing drains (per-tick cap, idempotent, library-online-gated, errors don't burn the cap):
Selects rows where date_utc IS NULL, runs the re-based waterfall (with batched external-tool calls), and writes date_utc + camera_offset_minutes + date_taken_tz_source. It never writes the legacy columns.
Day-boundary invalidation: if the converted instant falls on a different calendar day (display zone) than the row's legacy value did, that day's derived day data (daily summaries, pre-generated reels) is marked stale; the existing nightly cadence regenerates it.
Progress surface: rows remaining (date_utc IS NULL) are exposed on the existing status surface so the operator can see when the flip is safe.
Videos are in scope for this drain (folds the separate video-date work in): their container dates resolve through the same waterfall.
The pre-cutover audit pass
A one-shot utility (the repo's existing utility-binary pattern) that lists every file matching a filename-class pattern: current date + source, proposed date + source + tz source. Operator-run before the drain starts, so the filename class can be spot-checked against memory and any pattern-misclassification fixed before anything converts.
API surface
/photos, image metadata, and /memories responses carry the new fields (date_utc, offset, tz source) alongside the legacy ones during the migration window. The legacy field is byte-identical throughout.
Grouping and day bucketing: use date_utc when present, display zone for the day; rows not yet drained fall back to the legacy value under its known per-source convention (the current behavior), so mixed rows mid-migration still group into the right day.
The manual-date endpoint accepts a wall clock in the display zone and stores the instant.
The insight-context builder (chat bootstrap and photo summary) formats the camera wall clock — instant minus camera offset, or the display zone when no offset exists — the same rule the UI uses, so prompt and UI agree by construction. The SMS/location/calendar windows are anchored on the true instant with no shift.
Consumer cutover (big bang)
This spec defines the contract; the consumer-side changes are tracked as their own tickets in the Apollo and FileViewer-React trackers (filed alongside this spec on the same Gitea instance). The sequence this spec constrains:
The drain completes (progress surface reports 0 remaining).
The web UI (Apollo) and mobile app (FileViewer) switch their reads to the new fields and delete their re-anchoring helpers (web UI: server-side re-anchor, frontend re-anchor, raw timeline anchor; mobile app: bare device-local formatting). The mobile app's branch point is its current main (persona-chat merge already in).
The insight-context builder flips in the same deploy as the server.
Rollback at any point before verification is complete: flip the clients back to the legacy fields; the legacy columns were never touched.
Testing Decisions
Good tests for this work verify external behavior only: given an input file (or seeded DB row), the date, source, offset, and tz source that come out — never the internal order of a branch. Three seams, all pre-existing:
The date waterfall (pure functions, no infrastructure — the largest existing in-process test suites sit here). The offset cascade per row (tag wins over GPS wins over assumed; DST-correct for the capture year); the filename class's per-pattern conventions (wall patterns through the cascade, Unix patterns direct); filename-over-EXIF priority inside the class and EXIF-wins outside it; manual-date conversion and snapshot; the external-tool branch no longer interpreting in server zone.
The drain (in-memory SQLite + fixture files, the pattern the existing drain tests use). Mixed-convention seeded rows convert to the expected instants; a second pass changes nothing (idempotency); the per-tick cap holds; day-boundary changes mark that day's derived data stale and nothing else does; offline libraries are skipped; the progress count is right.
The HTTP contract (the existing actix handler-test pattern). The new fields appear alongside byte-identical legacy ones; the manual-date endpoint round-trips (wall in display zone → instant out); grouping is correct for mixed rows mid-migration (a converted row and an unconverted row land in their respective right days).
Out of Scope
The consumer-repo code changes themselves — tracked as their own tickets in the Apollo and FileViewer-React trackers (this spec is the contract they implement against). SMS-API needs no change (already true instants).
The other architecture-review candidates: agent-loop consolidation, the mock LLM client, hash-keyed derived data as one module, the dual module tree, the ingest-spine split, the SMS summarizer, and the env-heavy backend resolution. Separate workstreams.
Deleting the legacy date_taken column — retained for historical context; retirement is a later ticket.
Changes to the SMS-API schema or the location track (both are already true instants; nothing moves there).
In-place content changes (the stale-content detection pass) — a known gap, tracked separately.
Multi-user display zones beyond the existing per-user preference row (single-user system).
Re-running face detection or insights as part of the re-date — only derived day data is invalidated, on the existing schedule.
Further Notes
Work happens on the feature/true-utc-dates branch; the vocabulary (CONTEXT.md) and ADR-0001 are already committed on it.
The pre-existing -480 minutes default on a New York server is a surviving symptom of the fixed-offset mistake; the zone-name preference fixes it prospectively.
The web UI's Python filename-parsing port (duplicated from the server) dies with the cutover — it exists only to re-derive dates the server will now send as instants.
The old ImageApi checkout on the operator's desktop is stale and should not be a source or target for this work.
Wayfinder map
Destination
The true-instant date contract is live in production: every consumer (this API, the Apollo web UI, FileViewer mobile) renders capture times from the server-computed wall_clock and anchors instant matching on true_instant (amendment, #146), both derived from date_utc + camera_offset_minutes + date_taken_tz_source and the owner's display zone; date queries (/photos range filters) agree with what the UI shows; the legacy date_taken column remains frozen and byte-identical; the big-bang cutover is complete and verified.
Notes
Execution epic. The spec is locked (this issue's body above + ADR-0001 + ADR-0002); each child ticket is a build slice with acceptance criteria. Resolving a ticket = implement, test, commit to a per-ticket feature branch, PR, then close.
One ticket per session; claim before working; resolution = comment + close + a pointer appended below.
Dates: container (video) dates, filesystem branch, manual edit — true instants: landed. Container dates (QuickTime/MP4) are true instants, not wall clocks — the spec's cascade premise for them was disproven on production data (uniform +4/+5 h delta = server EDT/EST). Exiftool path now takes raw strings + groups (-j -q -G1, no -d %s) and classifies: zoned → instant; naive + container group → instant (utc_direct); naive + photo group → cascade. Manual edits via manual_date_resolution (pure seam) in the display zone; revert snapshots both the legacy pair and the new-contract triple. Migrations condensed to one (2026-09-24-000000_add_true_utc_dates).
Dates: filename date class — per-pattern conventions + filename-over-EXIF priority: landed (PR #122, commit a97bf53). FilenameDate enum (Wall = screenshot/dash/compact patterns, Utc = unix-timestamp patterns) — no patterns added or removed. Both waterfalls run the class check first (shared filename_class_resolved): an in-class name is the original date of record and outranks EXIF/container dates; in-class files skip exiftool entirely. Wall → offset cascade over the prior's evidence (empty evidence → assumed_server); timestamp → utc_direct. Legacy date_taken byte-identical at all seven consumer sites. The EXIF-beats-filename note in CLAUDE.md is now out-of-class-only.
Dates: pre-cutover audit utility for the filename date class: landed (PR #123, commits 3aa6ce3, 89fc24c). cargo run --bin filename_audit — strictly read-only (review-verified), one entry per in-class file: pattern name, stored date + source, proposed date + source + tz_source + numeric offset, * on rows whose stored legacy value would change; deterministic, stable-sorted. Proposed values mirror the #118 drain exactly (empty-priors resolve_dates_batch, subprocess-free, effective-excludes union, probe_online gate with had_data from the image_exif count; disabled/offline libraries skipped, never partially processed). match_filename_date canonical matcher added; extract_date_from_filename is a behavior-preserving wrapper. The operator runs it against prod before starting the #118 drain — the report is the gating artifact.
Dates: the backfill drain — convert mixed-convention rows, progress surface, day-boundary invalidation: landed (PR #124, rebased on #123; final commits 98db2a4 + fcaf848, merged as cd375f1). backfill_utc_dates runs at the existing date-drain seam inside the online gate: eligibility date_utc IS NULL AND date_taken IS NOT NULL (partial index idx_image_exif_utc_backfill), empty-priors resolve_dates_batch (one exiftool per batch), writes only the new-contract triple. Shares DATE_BACKFILL_MAX_PER_TICK; per-library resume cursor so unconvertible rows (missing file, no faithful resolution) never stall the drain. Legacy manual rows convert via manual_date_resolution; EXIF/exiftool-legacy rows are never downgraded to fs_time. Progress: imageserver_date_backfill_remaining gauge + date_backfill_remaining on GET /libraries (counts all date_utc IS NULL). Day-boundary invalidation deletes day-span precomputed_reels (lib + all) covering the old/new display-zone month-day; daily summaries are SMS-derived, so untouched. The open-for-#119 notes resolved there (reads + pregen now on the new contract and display_tz); what remains is #120's cutover.
Dates: reads + insight context on the new contract (mixed-row safe): landed, merged (PR #125, commit 615941a, merge 7f34802). Test-server validation (2026-09-27): drain ran to its stable floor on a 5.2k-row corpus (14 + 18 unconvertible edited/exported files, exiftool-verified tag-free; day_changed: 38 as predicted), mixed-rows reads verified row-by-row against metadata, TZ env + wall-offset fallback fix (615941a) landed from the run; reels pregen validated (reel month_day matches the /memories day tiles in the owner's display zone). Validation complete — all checklist items done. New pure module src/date_view.rs — row_true_instant (window anchor), row_display_wall (prompt/UI date), row_display_day (grouping key, always the owner's day), filename_true_instant — with the per-source convention table for unconverted rows (exif/filename-wall/manual = wall in server or display zone; exiftool-video = one server-zone round-trip read back as UTC; exiftool-photo/ts/fs_time = legacy as-is or its render). /memories groups on render(true_instant, owner_display_zone) (prefs, not the client offset — the param is accepted and ignored) with exact MM-DD/%W/month matching in Rust over a full years-back SQL pre-filter; items carry the triple (null while unconverted). Reels selector + pregen take an IANA zone (REEL_PREGEN_DISPLAY_TZ); fixed_tz_offset deleted. Insight timestamp = row_true_instant (zero offset math for SMS/location/calendar windows) via shared InsightGenerator::resolve_date_anchor; prompt/chat date = row_display_wall (camera wall when the row carries an offset — Japan photo tells the AI the Japan day). GpsPhotoSummary carries the triple. 1,410 tests, fmt + clippy clean; two-axis review done, all findings fixed in-branch. Deferred to #120/follow-up: the (display_zone, server_zone)DateContext clump, date_from/date_to filters, tool_store_fact::valid_from.
Prefs: expose user_ai_prefs — GET/PUT /prefs + GET /timezones (existing columns, no migration): merged (PR #128). src/prefs.rs: GET/PUT /prefs (JWT, three-state partial: omitted=unchanged / null=clear / value=set via hand-rolled TriState deserializer) + GET /timezones (public, chrono_tz::TZ_VARIANTS). display_tz validated pre-write; frozen tz_offset_minutes preserved verbatim; no migration. 14 tests; suite 1436/0. Unblocks #127.
Prefs: owner defaults — model, backend, persona_id, reel span/length (new columns): merged (PR #129, feature/prefs-owner-defaults). Migration 2026-09-27-000000_add_owner_defaults adds 5 nullable columns; GET/PUT /prefs three-state partial update (TriState generalized for the i32 field); resolution request - pref - hard default wired at both insight handlers, all 3 chat-turn variants, and reel creation; capture_prefs carries owner-defaults through (no clobber). Validation pre-write, delegates to BackendKind::parse. 1463/0 (+27); spec all-conformant, standards clean.
Not yet specified
Config: move remaining non-private runtime config from .env into the owner-defaults row (ADR-0002) — the five #127 columns are the first batch; future candidates are per-request tuning values that don’t need to exist before boot (sampling knobs, TTS voice as a stored default, etc.). Secrets and boot-time values stay in .env by rule.
Full-corpus legacy-equivalence audit: the #118 drain writes only new columns; a read-only pass comparing resolver output to stored legacy date_taken across the ~21k rows (pre-drain confidence) is not yet specified. The #117 utility covers the filename class only (per spec) — the full-corpus pass remains to fold into #120's verification or be specified separately.
DateContext clump (deferred from #119): (display_zone, server_zone) travel together through the date_view callers; whether to fold them into one value, and whether that belongs before the cutover or after, is unsettled.
tool_store_fact::valid_from (deferred from #119): which clock the agentic fact store's valid_from should carry on the new contract is unexamined.
Out of scope
Consumer-side flips (Apollo #37, FileViewer-React #33) — tracked in their own repos; this map defines the contract they flip to.
Deleting the legacy date_taken column — retained for historical context per the spec.
## Problem Statement
Dates and timezones are inconsistent across the system and often display wrong or query incorrectly. The same photo shows a different clock in the web app than in the mobile app. Videos — whose dates are only ever found by the external metadata tool — display 4–5 hours off, and near midnight the AI's "immediate context" (messages, location, calendar around a photo) is anchored on the wrong day, throwing the insight off. Matching photos to SMS messages and location track points is approximate because the photo side of the join carries a wall clock the consumers must guess how to shift, while the message and location sides are already true instants.
## Solution
Make the system keep one clock: the **true instant** — the actual moment a photo or video was captured, as Unix seconds. The **wall clock** (what the camera showed) becomes a display value, recovered from the instant plus a per-row **camera offset** that is resolved at ingest from the most specific evidence available (an explicit EXIF offset tag, then GPS position, then the server's zone, recorded as an assumption) and audited on the row. Day-level grouping (memories, timelines, daily summaries) uses the owner's **display zone**, stored as an IANA zone name so DST stays correct.
The change lands as: (1) a re-based date waterfall that emits true instants with per-row offset and audit data; (2) a per-tick backfill drain that converts the existing mixed-convention rows without touching the legacy column; (3) a one-shot pre-cutover audit report for the **filename date class**; (4) a widened API surface serving the new fields beside the frozen legacy ones; (5) a coordinated big-bang flip of the consumers onto the new fields — the consumers' changes are tracked in their own repos' trackers (filed alongside this spec); this spec defines the contract they flip to. After the flip, the legacy re-anchoring code in the consumers is deleted. The legacy column is retained for historical context.
## User Stories
1. As the owner, when I view a photo's metadata in the web UI, I want the time to show the wall clock my camera displayed, so the memory reads the way I remember it.
2. As the owner, when I view the same photo in the mobile app, I want the exact same clock as the web UI, so two apps never disagree about when I took something.
3. As the owner, when I view a photo's surrounding messages, I want them matched by the photo's true instant, so a 9pm shot pulls 9pm messages even though its camera offset differs from mine.
4. As the owner, when I view a photo's location context, I want the location track queried at the photo's true instant, so the location shown is the one I was actually at.
5. As the owner, when a video's date comes from container metadata, I want it stored as the true instant, so the video's insight context is no longer 4–5 hours off or rolled to the previous day.
6. As the owner, when I generate an insight, I want the date the AI is told to be the same wall clock I see in the UI, so the AI's "immediate context" agrees with mine.
7. As the owner, when I browse memories grouped by day, I want the days to be *my* days (my display zone), so an anniversary photo lands on the right day even if it was taken in another zone.
8. As the owner, when I take a photo in a different timezone (travel), I want its camera offset resolved from the photo's own evidence (offset tag, then GPS), so the true instant is correct and the day grouping still lands in my zone.
9. As the owner, when a photo carries no zone evidence at all, I want the fallback assumption (server zone) to be recorded on the row, so I can find and audit the assumed rows.
10. As the owner, when a photo's true instant crosses a year boundary in my zone, I want the day it groups under to follow my zone's calendar, not the capture zone's.
11. As the owner, when I manually set a photo's date, I want to type the wall clock I think in (my display zone) and have the system store the resulting true instant, so editing feels like setting a date, not an epoch.
12. As the owner, when I manually change a date, I want the previous value snapshotted, so I can revert to it.
13. As the owner, when a filename carries the original date (screenshot-style or timestamp-style names), I want the filename to outrank EXIF, so the preserved original date survives edited or stripped metadata.
14. As the owner, when a filename is a Unix timestamp, I want it treated directly as a true instant, so no zone is guessed.
15. As the owner, before the migration changes anything, I want a one-shot report of every filename-class file showing its current date, its proposed date, and the proposed offset source, so I can spot-check the "feel" against my memory.
16. As the owner, during the migration window, I want the legacy date field to remain byte-identical, so any client still on the old contract keeps working and rollback is just flipping clients back.
17. As the owner, I want to see migration progress (rows remaining to convert), so I know when it is safe to flip the consumers.
18. As the owner, when the drain re-dates a photo across a day boundary in my zone, I want that day's derived day data (daily summaries, pre-generated reels) to be marked for regeneration, so "what happened this day" is never one row stale.
19. As the owner, I want my display zone stored as a named zone (America/New_York), not a fixed minute offset, so daylight-saving transitions never silently shift groupings by an hour.
20. As the owner, after the cutover, I want the consumers to contain no re-anchoring shift at all, so there is exactly one convention in the code and the next date bug is impossible by construction.
21. As the owner, I want videos date-resolved in the same drain and by the same waterfall as photos, so there is one date path for all media.
22. As the operator, when I ask "why does this row believe this date?", I want the answer readable from the stored date source and offset-source columns, without re-deriving it.
23. As the operator, when a library is offline (unmounted share), I want the drain to skip it and resume later, so an unplugged drive never causes a wrong or partial conversion.
24. As the operator, when the drain runs a second pass over already-converted rows, I want it to change nothing, so the steady state is quiet.
## Implementation Decisions
### Schema
New columns on the image-exif table (all nullable until the drain completes; the legacy `date_taken` and `date_taken_source` columns are frozen, not rewritten):
| Column | Meaning |
|---|---|
| `date_utc` | The true instant (Unix seconds). NULL until the drain reaches the row. |
| `camera_offset_minutes` | Signed minutes of the camera offset (wall clock = instant − offset). NULL when the row is a true instant with no wall-clock meaning (e.g. filesystem-time rows) or when no evidence exists. |
| `date_taken_tz_source` | Audit of how the offset was resolved: `offset_tag`, `gps`, `assumed_server`, `utc_direct` (filename Unix timestamps), `manual`, or NULL (no wall-clock meaning). |
The per-user preference store's fixed minute offset (`tz_offset_minutes`) is replaced by an IANA zone name (`display_tz`, default `America/New_York`); the minute value is always derived from the zone for the relevant year (DST-correct), never stored.
The legacy manual-edit snapshot columns continue to be used, but from now on snapshot the true instant plus offset rather than a mixed-convention epoch.
### The date waterfall
The waterfall is re-based so every branch emits `(true instant, date source, camera offset, tz source)` instead of a mixed-convention epoch:
- **EXIF branch**: the tag's wall clock is converted via the offset cascade.
- **External-tool branch**: container dates (QuickTime/MP4) are treated as the camera's wall clock and converted via the same cascade — this replaces the current behavior of interpreting them in the server's local zone, which was the root of the video-date errors. The batch tool call per drain tick is kept (one subprocess per batch).
- **Filename branch**: the existing pattern list is retained, but each pattern is explicitly tagged with its convention — wall-clock patterns (screenshot, dash, compact) go through the offset cascade; Unix-timestamp patterns are true instants directly. **Within the filename date class, the filename outranks EXIF** — a deliberate, documented inversion of the global EXIF-wins rule. Outside the class, EXIF still wins.
- **Filesystem-time branch**: already a true instant; offset stays NULL.
- **Manual dates**: entered as a wall clock in the display zone at edit time; stored as the resulting true instant, tz source `manual`, previous value snapshotted.
The offset cascade, in order: (1) an explicit EXIF offset tag on the same file (`OffsetTime`/`OffsetTimeOriginal`); (2) the file's GPS position resolved through a timezone database for the capture year (DST-correct); (3) the server zone, recorded as `assumed_server`.
### The backfill drain
A new per-tick drain, following the exact shape of the existing drains (per-tick cap, idempotent, library-online-gated, errors don't burn the cap):
- Selects rows where `date_utc` IS NULL, runs the re-based waterfall (with batched external-tool calls), and writes `date_utc` + `camera_offset_minutes` + `date_taken_tz_source`. It never writes the legacy columns.
- **Day-boundary invalidation**: if the converted instant falls on a different calendar day (display zone) than the row's legacy value did, that day's derived day data (daily summaries, pre-generated reels) is marked stale; the existing nightly cadence regenerates it.
- **Progress surface**: rows remaining (`date_utc IS NULL`) are exposed on the existing status surface so the operator can see when the flip is safe.
- Videos are in scope for this drain (folds the separate video-date work in): their container dates resolve through the same waterfall.
### The pre-cutover audit pass
A one-shot utility (the repo's existing utility-binary pattern) that lists every file matching a filename-class pattern: current date + source, proposed date + source + tz source. Operator-run before the drain starts, so the filename class can be spot-checked against memory and any pattern-misclassification fixed before anything converts.
### API surface
- `/photos`, image metadata, and `/memories` responses carry the new fields (`date_utc`, offset, tz source) alongside the legacy ones during the migration window. The legacy field is byte-identical throughout.
- Grouping and day bucketing: use `date_utc` when present, display zone for the day; rows not yet drained fall back to the legacy value under its known per-source convention (the current behavior), so mixed rows mid-migration still group into the right day.
- The manual-date endpoint accepts a wall clock in the display zone and stores the instant.
- The insight-context builder (chat bootstrap and photo summary) formats the **camera wall clock** — instant minus camera offset, or the display zone when no offset exists — the same rule the UI uses, so prompt and UI agree by construction. The SMS/location/calendar windows are anchored on the true instant with no shift.
### Consumer cutover (big bang)
This spec defines the contract; the consumer-side changes are tracked as their own tickets in the Apollo and FileViewer-React trackers (filed alongside this spec on the same Gitea instance). The sequence this spec constrains:
1. The drain completes (progress surface reports 0 remaining).
2. The web UI (Apollo) and mobile app (FileViewer) switch their reads to the new fields and delete their re-anchoring helpers (web UI: server-side re-anchor, frontend re-anchor, raw timeline anchor; mobile app: bare device-local formatting). The mobile app's branch point is its current `main` (persona-chat merge already in).
3. The insight-context builder flips in the same deploy as the server.
4. Rollback at any point before verification is complete: flip the clients back to the legacy fields; the legacy columns were never touched.
## Testing Decisions
Good tests for this work verify external behavior only: given an input file (or seeded DB row), the date, source, offset, and tz source that come out — never the internal order of a branch. Three seams, all pre-existing:
1. **The date waterfall** (pure functions, no infrastructure — the largest existing in-process test suites sit here). The offset cascade per row (tag wins over GPS wins over assumed; DST-correct for the capture year); the filename class's per-pattern conventions (wall patterns through the cascade, Unix patterns direct); filename-over-EXIF priority inside the class and EXIF-wins outside it; manual-date conversion and snapshot; the external-tool branch no longer interpreting in server zone.
2. **The drain** (in-memory SQLite + fixture files, the pattern the existing drain tests use). Mixed-convention seeded rows convert to the expected instants; a second pass changes nothing (idempotency); the per-tick cap holds; day-boundary changes mark that day's derived data stale and nothing else does; offline libraries are skipped; the progress count is right.
3. **The HTTP contract** (the existing actix handler-test pattern). The new fields appear alongside byte-identical legacy ones; the manual-date endpoint round-trips (wall in display zone → instant out); grouping is correct for mixed rows mid-migration (a converted row and an unconverted row land in their respective right days).
## Out of Scope
- The consumer-repo code changes themselves — tracked as their own tickets in the Apollo and FileViewer-React trackers (this spec is the contract they implement against). SMS-API needs no change (already true instants).
- The other architecture-review candidates: agent-loop consolidation, the mock LLM client, hash-keyed derived data as one module, the dual module tree, the ingest-spine split, the SMS summarizer, and the env-heavy backend resolution. Separate workstreams.
- Deleting the legacy `date_taken` column — retained for historical context; retirement is a later ticket.
- Changes to the SMS-API schema or the location track (both are already true instants; nothing moves there).
- In-place content changes (the stale-content detection pass) — a known gap, tracked separately.
- Multi-user display zones beyond the existing per-user preference row (single-user system).
- Re-running face detection or insights as part of the re-date — only derived *day* data is invalidated, on the existing schedule.
## Further Notes
- Work happens on the `feature/true-utc-dates` branch; the vocabulary (CONTEXT.md) and ADR-0001 are already committed on it.
- The pre-existing `-480` minutes default on a New York server is a surviving symptom of the fixed-offset mistake; the zone-name preference fixes it prospectively.
- The web UI's Python filename-parsing port (duplicated from the server) dies with the cutover — it exists only to re-derive dates the server will now send as instants.
- The old ImageApi checkout on the operator's desktop is stale and should not be a source or target for this work.
---
## Wayfinder map
## Destination
The true-instant date contract is live in production: every consumer (this API, the Apollo web UI, FileViewer mobile) renders capture times from the server-computed `wall_clock` and anchors instant matching on `true_instant` (amendment, #146), both derived from `date_utc` + `camera_offset_minutes` + `date_taken_tz_source` and the owner's display zone; date queries (`/photos` range filters) agree with what the UI shows; the legacy `date_taken` column remains frozen and byte-identical; the big-bang cutover is complete and verified.
## Notes
- **Execution epic.** The spec is locked (this issue's body above + ADR-0001 + ADR-0002); each child ticket is a build slice with acceptance criteria. Resolving a ticket = implement, test, commit to a per-ticket feature branch, PR, then close.
- One ticket per session; claim before working; resolution = comment + close + a pointer appended below.
- PRs: #121 (#114 + #115), #122 (#116), #123 (#117), #124 (#118), #125 (#119, merged), #146 (`wall_clock` + `true_instant` read surface; see the amendment comment on this issue).
- Repo conventions: `docs/agents/issue-tracker.md` (tea CLI), skills `code-review` + `pr` at handoff, test env vars per CLAUDE.md.
## Decisions so far
- [Dates: foundation — schema, EXIF branch re-base, offset cascade, display zone](https://git.crkc.xyz/Apps/ImageApi/issues/114): landed. New-contract columns + `display_zone_for` + 4-step offset cascade (offset tag → GPS → assumed server; true instants bypass it); legacy `date_taken` byte-identical.
- [Dates: container (video) dates, filesystem branch, manual edit — true instants](https://git.crkc.xyz/Apps/ImageApi/issues/115): landed. **Container dates (QuickTime/MP4) are true instants, not wall clocks** — the spec's cascade premise for them was disproven on production data (uniform +4/+5 h delta = server EDT/EST). Exiftool path now takes raw strings + groups (`-j -q -G1`, no `-d %s`) and classifies: zoned → instant; naive + container group → instant (`utc_direct`); naive + photo group → cascade. Manual edits via `manual_date_resolution` (pure seam) in the display zone; revert snapshots both the legacy pair and the new-contract triple. Migrations condensed to one (`2026-09-24-000000_add_true_utc_dates`).
- [Dates: filename date class — per-pattern conventions + filename-over-EXIF priority](https://git.crkc.xyz/Apps/ImageApi/issues/116): landed (PR #122, commit `a97bf53`). `FilenameDate` enum (`Wall` = screenshot/dash/compact patterns, `Utc` = unix-timestamp patterns) — no patterns added or removed. Both waterfalls run the class check **first** (shared `filename_class_resolved`): an in-class name is the original date of record and outranks EXIF/container dates; in-class files skip exiftool entirely. Wall → offset cascade over the prior's evidence (empty evidence → `assumed_server`); timestamp → `utc_direct`. Legacy `date_taken` byte-identical at all seven consumer sites. The EXIF-beats-filename note in CLAUDE.md is now out-of-class-only.
- [Dates: pre-cutover audit utility for the filename date class](https://git.crkc.xyz/Apps/ImageApi/issues/117): landed (PR #123, commits `3aa6ce3`, `89fc24c`). `cargo run --bin filename_audit` — strictly read-only (review-verified), one entry per in-class file: pattern name, stored date + source, proposed date + source + `tz_source` + numeric offset, `*` on rows whose stored legacy value would change; deterministic, stable-sorted. Proposed values mirror the #118 drain exactly (empty-priors `resolve_dates_batch`, subprocess-free, effective-excludes union, `probe_online` gate with `had_data` from the `image_exif` count; disabled/offline libraries skipped, never partially processed). `match_filename_date` canonical matcher added; `extract_date_from_filename` is a behavior-preserving wrapper. The operator runs it against prod before starting the #118 drain — the report is the gating artifact.
- [Dates: the backfill drain — convert mixed-convention rows, progress surface, day-boundary invalidation](https://git.crkc.xyz/Apps/ImageApi/issues/118): landed (PR #124, rebased on #123; final commits `98db2a4` + `fcaf848`, merged as `cd375f1`). `backfill_utc_dates` runs at the existing date-drain seam inside the online gate: eligibility `date_utc IS NULL AND date_taken IS NOT NULL` (partial index `idx_image_exif_utc_backfill`), empty-priors `resolve_dates_batch` (one exiftool per batch), writes only the new-contract triple. Shares `DATE_BACKFILL_MAX_PER_TICK`; per-library resume cursor so unconvertible rows (missing file, no faithful resolution) never stall the drain. Legacy `manual` rows convert via `manual_date_resolution`; EXIF/exiftool-legacy rows are never downgraded to `fs_time`. Progress: `imageserver_date_backfill_remaining` gauge + `date_backfill_remaining` on `GET /libraries` (counts all `date_utc IS NULL`). Day-boundary invalidation deletes day-span `precomputed_reels` (lib + `all`) covering the old/new display-zone month-day; daily summaries are SMS-derived, so untouched. The open-for-#119 notes resolved there (reads + pregen now on the new contract and `display_tz`); what remains is #120's cutover.
- [Dates: reads + insight context on the new contract (mixed-row safe)](https://git.crkc.xyz/Apps/ImageApi/issues/119): landed, **merged** (PR #125, commit `615941a`, merge `7f34802`). Test-server validation (2026-09-27): drain ran to its stable floor on a 5.2k-row corpus (14 + 18 unconvertible edited/exported files, exiftool-verified tag-free; `day_changed: 38` as predicted), mixed-rows reads verified row-by-row against metadata, `TZ` env + wall-offset fallback fix (`615941a`) landed from the run; reels pregen validated (reel `month_day` matches the `/memories` day tiles in the owner's display zone). **Validation complete** — all checklist items done. New pure module `src/date_view.rs` — `row_true_instant` (window anchor), `row_display_wall` (prompt/UI date), `row_display_day` (grouping key, always the owner's day), `filename_true_instant` — with the per-source convention table for unconverted rows (exif/filename-wall/manual = wall in server or display zone; exiftool-video = one server-zone round-trip read back as UTC; exiftool-photo/ts/fs_time = legacy as-is or its render). `/memories` groups on `render(true_instant, owner_display_zone)` (prefs, not the client offset — the param is accepted and ignored) with exact MM-DD/%W/month matching in Rust over a full years-back SQL pre-filter; items carry the triple (null while unconverted). Reels selector + pregen take an IANA zone (`REEL_PREGEN_DISPLAY_TZ`); `fixed_tz_offset` deleted. Insight timestamp = `row_true_instant` (zero offset math for SMS/location/calendar windows) via shared `InsightGenerator::resolve_date_anchor`; prompt/chat date = `row_display_wall` (camera wall when the row carries an offset — Japan photo tells the AI the Japan day). `GpsPhotoSummary` carries the triple. 1,410 tests, fmt + clippy clean; two-axis review done, all findings fixed in-branch. Deferred to #120/follow-up: the `(display_zone, server_zone)` `DateContext` clump, `date_from`/`date_to` filters, `tool_store_fact::valid_from`.
- [Prefs: expose user_ai_prefs — GET/PUT /prefs + GET /timezones (existing columns, no migration)](https://git.crkc.xyz/Apps/ImageApi/issues/126): **merged** (PR #128). `src/prefs.rs`: GET/PUT `/prefs` (JWT, three-state partial: omitted=unchanged / null=clear / value=set via hand-rolled `TriState` deserializer) + GET `/timezones` (public, `chrono_tz::TZ_VARIANTS`). display_tz validated pre-write; frozen `tz_offset_minutes` preserved verbatim; no migration. 14 tests; suite 1436/0. Unblocks #127.
- [Prefs: owner defaults — model, backend, persona_id, reel span/length (new columns)](https://git.crkc.xyz/Apps/ImageApi/issues/127): **merged** (PR #129, feature/prefs-owner-defaults). Migration 2026-09-27-000000_add_owner_defaults adds 5 nullable columns; GET/PUT /prefs three-state partial update (TriState<T> generalized for the i32 field); resolution request - pref - hard default wired at both insight handlers, all 3 chat-turn variants, and reel creation; capture_prefs carries owner-defaults through (no clobber). Validation pre-write, delegates to BackendKind::parse. 1463/0 (+27); spec all-conformant, standards clean.
## Not yet specified
- **Config: move remaining non-private runtime config from .env into the owner-defaults row** (ADR-0002) — the five #127 columns are the first batch; future candidates are per-request tuning values that don’t need to exist before boot (sampling knobs, TTS voice as a stored default, etc.). Secrets and boot-time values stay in .env by rule.
- **Full-corpus legacy-equivalence audit**: the #118 drain writes only new columns; a read-only pass comparing resolver output to stored legacy `date_taken` across the ~21k rows (pre-drain confidence) is not yet specified. The #117 utility covers the filename class only (per spec) — the full-corpus pass remains to fold into #120's verification or be specified separately.
- **`DateContext` clump** (deferred from #119): `(display_zone, server_zone)` travel together through the `date_view` callers; whether to fold them into one value, and whether that belongs before the cutover or after, is unsettled.
- **`tool_store_fact::valid_from`** (deferred from #119): which clock the agentic fact store's `valid_from` should carry on the new contract is unexamined.
## Out of scope
- Consumer-side flips (Apollo #37, FileViewer-React #33) — tracked in their own repos; this map defines the contract they flip to.
- Deleting the legacy `date_taken` column — retained for historical context per the spec.
Amendment: API surface + consumer read path (#146)
API surface (adds to "Implementation Decisions → API surface"): alongside the triple, the server now serves
wall_clock — naive ISO-8601 camera wall clock from date_view::row_display_wall (instant + camera offset; display zone when no offset; per-source fallback for unconverted rows) on /image/metadata + its mutation responses, /memories, /photos/gps-summary;
true_instant — on /image/metadata, from row_true_instant, for instant-to-instant matching.
Consumer cutover (amends "Consumer cutover" step 2): consumers read these two fields rather than computing instant + offset from the triple — the display rule lives in one function, shared with the insight prompts. Reason: #120's test run showed the drain's end state is a stable floor, not zero; triple-only consumers would need the per-source conventions permanently. FileViewer-React #33 is implemented this way (FVR PR #35); Apollo #37 should follow.
Erratum — schema table sign: the camera_offset_minutes row says "wall clock = instant − offset". With offset = local − UTC (Tokyo = +540), the wall is instant + offset, which is what row_display_wall implements (correctly). Only the spec text is wrong.
## Amendment: API surface + consumer read path (#146)
**API surface** (adds to "Implementation Decisions → API surface"): alongside the triple, the server now serves
- `wall_clock` — naive ISO-8601 camera wall clock from `date_view::row_display_wall` (instant + camera offset; display zone when no offset; per-source fallback for unconverted rows) on `/image/metadata` + its mutation responses, `/memories`, `/photos/gps-summary`;
- `true_instant` — on `/image/metadata`, from `row_true_instant`, for instant-to-instant matching.
**Consumer cutover** (amends "Consumer cutover" step 2): consumers read these two fields rather than computing instant + offset from the triple — the display rule lives in one function, shared with the insight prompts. Reason: #120's test run showed the drain's end state is a stable floor, not zero; triple-only consumers would need the per-source conventions permanently. FileViewer-React #33 is implemented this way (FVR PR #35); Apollo #37 should follow.
**Erratum — schema table sign:** the `camera_offset_minutes` row says "wall clock = instant − offset". With offset = local − UTC (Tokyo = +540), the wall is instant **+** offset, which is what `row_display_wall` implements (correctly). Only the spec text is wrong.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Destination reached: every child task is closed (#114–#119, #126, #127, #146, #161, #152, #120), the cutover is signed off on prod, and date queries now agree with what the UI shows. Three items on the map were never specified and are deferred, not done: the full-corpus legacy-equivalence audit, the DateContext clump, and the tool_store_fact::valid_from clock. Open a ticket for any of them if it becomes needed. Small leftovers: FileViewer still sends the ignored timezone_offset_minutes param (memories, notifications, reels), and Apollo's README still describes the old re-anchoring.
Destination reached: every child task is closed (#114–#119, #126, #127, #146, #161, #152, #120), the cutover is signed off on prod, and date queries now agree with what the UI shows. Three items on the map were never specified and are deferred, not done: the full-corpus legacy-equivalence audit, the DateContext clump, and the `tool_store_fact::valid_from` clock. Open a ticket for any of them if it becomes needed. Small leftovers: FileViewer still sends the ignored `timezone_offset_minutes` param (memories, notifications, reels), and Apollo's README still describes the old re-anchoring.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem Statement
Dates and timezones are inconsistent across the system and often display wrong or query incorrectly. The same photo shows a different clock in the web app than in the mobile app. Videos — whose dates are only ever found by the external metadata tool — display 4–5 hours off, and near midnight the AI's "immediate context" (messages, location, calendar around a photo) is anchored on the wrong day, throwing the insight off. Matching photos to SMS messages and location track points is approximate because the photo side of the join carries a wall clock the consumers must guess how to shift, while the message and location sides are already true instants.
Solution
Make the system keep one clock: the true instant — the actual moment a photo or video was captured, as Unix seconds. The wall clock (what the camera showed) becomes a display value, recovered from the instant plus a per-row camera offset that is resolved at ingest from the most specific evidence available (an explicit EXIF offset tag, then GPS position, then the server's zone, recorded as an assumption) and audited on the row. Day-level grouping (memories, timelines, daily summaries) uses the owner's display zone, stored as an IANA zone name so DST stays correct.
The change lands as: (1) a re-based date waterfall that emits true instants with per-row offset and audit data; (2) a per-tick backfill drain that converts the existing mixed-convention rows without touching the legacy column; (3) a one-shot pre-cutover audit report for the filename date class; (4) a widened API surface serving the new fields beside the frozen legacy ones; (5) a coordinated big-bang flip of the consumers onto the new fields — the consumers' changes are tracked in their own repos' trackers (filed alongside this spec); this spec defines the contract they flip to. After the flip, the legacy re-anchoring code in the consumers is deleted. The legacy column is retained for historical context.
User Stories
Implementation Decisions
Schema
New columns on the image-exif table (all nullable until the drain completes; the legacy
date_takenanddate_taken_sourcecolumns are frozen, not rewritten):date_utccamera_offset_minutesdate_taken_tz_sourceoffset_tag,gps,assumed_server,utc_direct(filename Unix timestamps),manual, or NULL (no wall-clock meaning).The per-user preference store's fixed minute offset (
tz_offset_minutes) is replaced by an IANA zone name (display_tz, defaultAmerica/New_York); the minute value is always derived from the zone for the relevant year (DST-correct), never stored.The legacy manual-edit snapshot columns continue to be used, but from now on snapshot the true instant plus offset rather than a mixed-convention epoch.
The date waterfall
The waterfall is re-based so every branch emits
(true instant, date source, camera offset, tz source)instead of a mixed-convention epoch:manual, previous value snapshotted.The offset cascade, in order: (1) an explicit EXIF offset tag on the same file (
OffsetTime/OffsetTimeOriginal); (2) the file's GPS position resolved through a timezone database for the capture year (DST-correct); (3) the server zone, recorded asassumed_server.The backfill drain
A new per-tick drain, following the exact shape of the existing drains (per-tick cap, idempotent, library-online-gated, errors don't burn the cap):
date_utcIS NULL, runs the re-based waterfall (with batched external-tool calls), and writesdate_utc+camera_offset_minutes+date_taken_tz_source. It never writes the legacy columns.date_utc IS NULL) are exposed on the existing status surface so the operator can see when the flip is safe.The pre-cutover audit pass
A one-shot utility (the repo's existing utility-binary pattern) that lists every file matching a filename-class pattern: current date + source, proposed date + source + tz source. Operator-run before the drain starts, so the filename class can be spot-checked against memory and any pattern-misclassification fixed before anything converts.
API surface
/photos, image metadata, and/memoriesresponses carry the new fields (date_utc, offset, tz source) alongside the legacy ones during the migration window. The legacy field is byte-identical throughout.date_utcwhen present, display zone for the day; rows not yet drained fall back to the legacy value under its known per-source convention (the current behavior), so mixed rows mid-migration still group into the right day.Consumer cutover (big bang)
This spec defines the contract; the consumer-side changes are tracked as their own tickets in the Apollo and FileViewer-React trackers (filed alongside this spec on the same Gitea instance). The sequence this spec constrains:
main(persona-chat merge already in).Testing Decisions
Good tests for this work verify external behavior only: given an input file (or seeded DB row), the date, source, offset, and tz source that come out — never the internal order of a branch. Three seams, all pre-existing:
Out of Scope
date_takencolumn — retained for historical context; retirement is a later ticket.Further Notes
feature/true-utc-datesbranch; the vocabulary (CONTEXT.md) and ADR-0001 are already committed on it.-480minutes default on a New York server is a surviving symptom of the fixed-offset mistake; the zone-name preference fixes it prospectively.Wayfinder map
Destination
The true-instant date contract is live in production: every consumer (this API, the Apollo web UI, FileViewer mobile) renders capture times from the server-computed
wall_clockand anchors instant matching ontrue_instant(amendment, #146), both derived fromdate_utc+camera_offset_minutes+date_taken_tz_sourceand the owner's display zone; date queries (/photosrange filters) agree with what the UI shows; the legacydate_takencolumn remains frozen and byte-identical; the big-bang cutover is complete and verified.Notes
wall_clock+true_instantread surface; see the amendment comment on this issue).docs/agents/issue-tracker.md(tea CLI), skillscode-review+prat handoff, test env vars per CLAUDE.md.Decisions so far
Dates: foundation — schema, EXIF branch re-base, offset cascade, display zone: landed. New-contract columns +
display_zone_for+ 4-step offset cascade (offset tag → GPS → assumed server; true instants bypass it); legacydate_takenbyte-identical.Dates: container (video) dates, filesystem branch, manual edit — true instants: landed. Container dates (QuickTime/MP4) are true instants, not wall clocks — the spec's cascade premise for them was disproven on production data (uniform +4/+5 h delta = server EDT/EST). Exiftool path now takes raw strings + groups (
-j -q -G1, no-d %s) and classifies: zoned → instant; naive + container group → instant (utc_direct); naive + photo group → cascade. Manual edits viamanual_date_resolution(pure seam) in the display zone; revert snapshots both the legacy pair and the new-contract triple. Migrations condensed to one (2026-09-24-000000_add_true_utc_dates).Dates: filename date class — per-pattern conventions + filename-over-EXIF priority: landed (PR #122, commit
a97bf53).FilenameDateenum (Wall= screenshot/dash/compact patterns,Utc= unix-timestamp patterns) — no patterns added or removed. Both waterfalls run the class check first (sharedfilename_class_resolved): an in-class name is the original date of record and outranks EXIF/container dates; in-class files skip exiftool entirely. Wall → offset cascade over the prior's evidence (empty evidence →assumed_server); timestamp →utc_direct. Legacydate_takenbyte-identical at all seven consumer sites. The EXIF-beats-filename note in CLAUDE.md is now out-of-class-only.Dates: pre-cutover audit utility for the filename date class: landed (PR #123, commits
3aa6ce3,89fc24c).cargo run --bin filename_audit— strictly read-only (review-verified), one entry per in-class file: pattern name, stored date + source, proposed date + source +tz_source+ numeric offset,*on rows whose stored legacy value would change; deterministic, stable-sorted. Proposed values mirror the #118 drain exactly (empty-priorsresolve_dates_batch, subprocess-free, effective-excludes union,probe_onlinegate withhad_datafrom theimage_exifcount; disabled/offline libraries skipped, never partially processed).match_filename_datecanonical matcher added;extract_date_from_filenameis a behavior-preserving wrapper. The operator runs it against prod before starting the #118 drain — the report is the gating artifact.Dates: the backfill drain — convert mixed-convention rows, progress surface, day-boundary invalidation: landed (PR #124, rebased on #123; final commits
98db2a4+fcaf848, merged ascd375f1).backfill_utc_datesruns at the existing date-drain seam inside the online gate: eligibilitydate_utc IS NULL AND date_taken IS NOT NULL(partial indexidx_image_exif_utc_backfill), empty-priorsresolve_dates_batch(one exiftool per batch), writes only the new-contract triple. SharesDATE_BACKFILL_MAX_PER_TICK; per-library resume cursor so unconvertible rows (missing file, no faithful resolution) never stall the drain. Legacymanualrows convert viamanual_date_resolution; EXIF/exiftool-legacy rows are never downgraded tofs_time. Progress:imageserver_date_backfill_remaininggauge +date_backfill_remainingonGET /libraries(counts alldate_utc IS NULL). Day-boundary invalidation deletes day-spanprecomputed_reels(lib +all) covering the old/new display-zone month-day; daily summaries are SMS-derived, so untouched. The open-for-#119 notes resolved there (reads + pregen now on the new contract anddisplay_tz); what remains is #120's cutover.Dates: reads + insight context on the new contract (mixed-row safe): landed, merged (PR #125, commit
615941a, merge7f34802). Test-server validation (2026-09-27): drain ran to its stable floor on a 5.2k-row corpus (14 + 18 unconvertible edited/exported files, exiftool-verified tag-free;day_changed: 38as predicted), mixed-rows reads verified row-by-row against metadata,TZenv + wall-offset fallback fix (615941a) landed from the run; reels pregen validated (reelmonth_daymatches the/memoriesday tiles in the owner's display zone). Validation complete — all checklist items done. New pure modulesrc/date_view.rs—row_true_instant(window anchor),row_display_wall(prompt/UI date),row_display_day(grouping key, always the owner's day),filename_true_instant— with the per-source convention table for unconverted rows (exif/filename-wall/manual = wall in server or display zone; exiftool-video = one server-zone round-trip read back as UTC; exiftool-photo/ts/fs_time = legacy as-is or its render)./memoriesgroups onrender(true_instant, owner_display_zone)(prefs, not the client offset — the param is accepted and ignored) with exact MM-DD/%W/month matching in Rust over a full years-back SQL pre-filter; items carry the triple (null while unconverted). Reels selector + pregen take an IANA zone (REEL_PREGEN_DISPLAY_TZ);fixed_tz_offsetdeleted. Insight timestamp =row_true_instant(zero offset math for SMS/location/calendar windows) via sharedInsightGenerator::resolve_date_anchor; prompt/chat date =row_display_wall(camera wall when the row carries an offset — Japan photo tells the AI the Japan day).GpsPhotoSummarycarries the triple. 1,410 tests, fmt + clippy clean; two-axis review done, all findings fixed in-branch. Deferred to #120/follow-up: the(display_zone, server_zone)DateContextclump,date_from/date_tofilters,tool_store_fact::valid_from.Prefs: expose user_ai_prefs — GET/PUT /prefs + GET /timezones (existing columns, no migration): merged (PR #128).
src/prefs.rs: GET/PUT/prefs(JWT, three-state partial: omitted=unchanged / null=clear / value=set via hand-rolledTriStatedeserializer) + GET/timezones(public,chrono_tz::TZ_VARIANTS). display_tz validated pre-write; frozentz_offset_minutespreserved verbatim; no migration. 14 tests; suite 1436/0. Unblocks #127.Prefs: owner defaults — model, backend, persona_id, reel span/length (new columns): merged (PR #129, feature/prefs-owner-defaults). Migration 2026-09-27-000000_add_owner_defaults adds 5 nullable columns; GET/PUT /prefs three-state partial update (TriState generalized for the i32 field); resolution request - pref - hard default wired at both insight handlers, all 3 chat-turn variants, and reel creation; capture_prefs carries owner-defaults through (no clobber). Validation pre-write, delegates to BackendKind::parse. 1463/0 (+27); spec all-conformant, standards clean.
Not yet specified
date_takenacross the ~21k rows (pre-drain confidence) is not yet specified. The #117 utility covers the filename class only (per spec) — the full-corpus pass remains to fold into #120's verification or be specified separately.DateContextclump (deferred from #119):(display_zone, server_zone)travel together through thedate_viewcallers; whether to fold them into one value, and whether that belongs before the cutover or after, is unsettled.tool_store_fact::valid_from(deferred from #119): which clock the agentic fact store'svalid_fromshould carry on the new contract is unexamined.Out of scope
date_takencolumn — retained for historical context per the spec.Companion tickets (big-bang cutover, same Gitea instance):
SMS-API: no change needed (already true instants).
Sequence: ImageApi work (this spec) → drain to 0 remaining → consumer flips in one coordinated deploy. Branch: feature/true-utc-dates (CONTEXT.md + ADR-0001 already committed).
Amendment: API surface + consumer read path (#146)
API surface (adds to "Implementation Decisions → API surface"): alongside the triple, the server now serves
wall_clock— naive ISO-8601 camera wall clock fromdate_view::row_display_wall(instant + camera offset; display zone when no offset; per-source fallback for unconverted rows) on/image/metadata+ its mutation responses,/memories,/photos/gps-summary;true_instant— on/image/metadata, fromrow_true_instant, for instant-to-instant matching.Consumer cutover (amends "Consumer cutover" step 2): consumers read these two fields rather than computing instant + offset from the triple — the display rule lives in one function, shared with the insight prompts. Reason: #120's test run showed the drain's end state is a stable floor, not zero; triple-only consumers would need the per-source conventions permanently. FileViewer-React #33 is implemented this way (FVR PR #35); Apollo #37 should follow.
Erratum — schema table sign: the
camera_offset_minutesrow says "wall clock = instant − offset". With offset = local − UTC (Tokyo = +540), the wall is instant + offset, which is whatrow_display_wallimplements (correctly). Only the spec text is wrong.🤖 Generated with Claude Code
Destination reached: every child task is closed (#114–#119, #126, #127, #146, #161, #152, #120), the cutover is signed off on prod, and date queries now agree with what the UI shows. Three items on the map were never specified and are deferred, not done: the full-corpus legacy-equivalence audit, the DateContext clump, and the
tool_store_fact::valid_fromclock. Open a ticket for any of them if it becomes needed. Small leftovers: FileViewer still sends the ignoredtimezone_offset_minutesparam (memories, notifications, reels), and Apollo's README still describes the old re-anchoring.