Carries the #114 foundation (schema + EXIF branch re-base + offset cascade + display zone, ADR-0001) and the #115 work built on it. Both tickets closed.
Summary
The exiftool branch of the date waterfall stops using -d "%s" (which re-interpreted exiftool's rendered strings in the server's local zone — the "videos off by 4–5 h" bug) and parses raw strings + group prefixes in Rust:
resolve_exiftool_payload(file) # one `exiftool -j -q -G1` per batch
for tag in priority order
reading = parse(raw string, group)
if zoned (own offset / Z) -> true instant (utc_direct)
if naive + container group -> true instant (utc_direct) # creation_time = seconds-since-1904 UTC
if naive + photo-EXIF group -> cascade: OffsetTime* -> GPS -> server zone
legacy date_taken := server-local reading of the rendered naive string # byte-identical to old -d %s
new triple := date_utc / camera_offset_minutes / date_taken_tz_source
Manual date edit:
POST /image/exif/date { date_taken = wall clock rendered naive-as-UTC }
zone = user_ai_prefs.display_tz (default America/New_York)
(utc, offset) = camera_tz::manual_date_resolution(zone, date_taken) # DST-correct seam
first override: snapshot legacy pair + new triple (3 new columns)
write date_taken verbatim + (utc, offset, 'manual')
DELETE -> restore both pairs, null snapshot columns
src/
├── date_resolver.rs # exiftool branch: raw strings + group classification
├── camera_tz.rs # + manual_date_resolution pure seam
├── database/mod.rs # set/clear manual snapshot the new-contract triple too
└── handlers/image.rs # resolves display_tz from prefs, passes to DAO
migrations/2026-09-24-… # single additive migration: + date_utc / camera_offset_minutes /
# date_taken_tz_source / original_date_utc /
# original_camera_offset_minutes / original_date_taken_tz_source /
# user_ai_prefs.display_tz
Evidence
Before: 541 exiftool-sourced videos in the prod DB; stored value − raw container instant = the uniform server EDT/EST offset across all sampled files; a mirrorless video landed 4 h off from the same-moment photo burst.
After:photo_and_video_same_instant — photo 2024:07:04 12:00:00 (OffsetTimeOriginal −04:00) and video QuickTime 2024:07:04 16:00:00 both store 1720108800 (16:00Z).
Legacy byte-identity: naive 2024-07-04 12:00:00 on an EDT server → 1720108800, exactly what the old -d %s produced (verified against a real file carrying an OffsetTime tag — the old path ignored it).
Migration up/down verified against a 21,298-row copy of the production DB.
Merge Danger
Door: two-way — additive nullable columns; legacy date_taken stays byte-identical; no consumer reads the new columns yet (read-side cutover is #120). Rollback = revert; the migration has a down.sql.
Blast Radius: ingest + per-tick date-backfill drain + the manual date endpoint only.
Carries the #114 foundation (schema + EXIF branch re-base + offset cascade + display zone, ADR-0001) and the #115 work built on it. Both tickets closed.
## Summary
The exiftool branch of the date waterfall stops using `-d "%s"` (which re-interpreted exiftool's rendered strings in the server's local zone — the "videos off by 4–5 h" bug) and parses raw strings + group prefixes in Rust:
```text
resolve_exiftool_payload(file) # one `exiftool -j -q -G1` per batch
for tag in priority order
reading = parse(raw string, group)
if zoned (own offset / Z) -> true instant (utc_direct)
if naive + container group -> true instant (utc_direct) # creation_time = seconds-since-1904 UTC
if naive + photo-EXIF group -> cascade: OffsetTime* -> GPS -> server zone
legacy date_taken := server-local reading of the rendered naive string # byte-identical to old -d %s
new triple := date_utc / camera_offset_minutes / date_taken_tz_source
```
Manual date edit:
```text
POST /image/exif/date { date_taken = wall clock rendered naive-as-UTC }
zone = user_ai_prefs.display_tz (default America/New_York)
(utc, offset) = camera_tz::manual_date_resolution(zone, date_taken) # DST-correct seam
first override: snapshot legacy pair + new triple (3 new columns)
write date_taken verbatim + (utc, offset, 'manual')
DELETE -> restore both pairs, null snapshot columns
```
```text
src/
├── date_resolver.rs # exiftool branch: raw strings + group classification
├── camera_tz.rs # + manual_date_resolution pure seam
├── database/mod.rs # set/clear manual snapshot the new-contract triple too
└── handlers/image.rs # resolves display_tz from prefs, passes to DAO
migrations/2026-09-24-… # single additive migration: + date_utc / camera_offset_minutes /
# date_taken_tz_source / original_date_utc /
# original_camera_offset_minutes / original_date_taken_tz_source /
# user_ai_prefs.display_tz
```
## Evidence
- **Before:** 541 exiftool-sourced videos in the prod DB; stored value − raw container instant = the uniform server EDT/EST offset across all sampled files; a mirrorless video landed 4 h off from the same-moment photo burst.
- **After:** `photo_and_video_same_instant` — photo `2024:07:04 12:00:00` (OffsetTimeOriginal −04:00) and video QuickTime `2024:07:04 16:00:00` both store `1720108800` (16:00Z).
- **Legacy byte-identity:** naive `2024-07-04 12:00:00` on an EDT server → `1720108800`, exactly what the old `-d %s` produced (verified against a real file carrying an OffsetTime tag — the old path ignored it).
- 712 tests pass; `cargo fmt --check` + `cargo clippy --all-targets` clean.
- Migration up/down verified against a 21,298-row copy of the production DB.
## Merge Danger
**Door:** two-way — additive nullable columns; legacy `date_taken` stays byte-identical; no consumer reads the new columns yet (read-side cutover is #120). Rollback = revert; the migration has a `down.sql`.
**Blast Radius:** ingest + per-tick date-backfill drain + the manual date endpoint only.
- 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
Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):
- camera_tz module: offset cascade (offset tag -> GPS zone -> server
zone) as a pure, testable function; bundled offline TZF geometry for
GPS -> IANA resolution; DST-correct offsets for the capture date;
display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
the winning date tag; resolve_date_taken takes an ExifPrior and
emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
-> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
camera_offset_minutes / date_taken_tz_source and
user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
legacy pair; watcher, upload, and set_image_gps call sites feed the
cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
/image/metadata and /photos/exif expose it (null for unconverted
rows); reel requests mirror display_tz into prefs.
cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
The example referenced an unimported DbError whose type didn't even
match the closure's anyhow::Error, and the classifier body returned
(). Rewritten as a self-contained io::Error example with an assertion.
The exiftool branch of the date waterfall now pulls raw strings with
group prefixes (one `exiftool -j -q -G1` subprocess per batch, no
`-d %s`) and classifies each candidate:
- zoned strings (own offset / Z) are true instants;
- naive strings from container groups (QuickTime/Track*/Apple) are
UTC-rendered creation_time fields -> true instants (utc_direct).
Running them through the camera wall-clock cascade was the
"videos off by 4-5 hours" bug: it reinterpreted a UTC instant in
the server zone;
- naive strings from photo-EXIF groups are camera wall clocks and run
the offset cascade (OffsetTime* pairing + GPS evidence pulled in
the same exiftool pass).
The legacy date_taken column stays byte-identical to the old
server-local interpretation (reproduced in Rust, DST-aware); the
new-contract date_utc / camera_offset_minutes / date_taken_tz_source
columns carry the true instant.
Manual date edit (POST /image/exif/date) reinterprets the
naive-as-UTC epoch the client sent as a wall clock in the user's
display zone (camera_tz::manual_date_resolution, tz_source =
'manual'), snapshots the pre-override new-contract triple next to the
existing legacy snapshot pair (3 new columns, migration
2026-09-25-000000), and revert restores both pairs.
ADR-0001 records the container-date finding; CLAUDE.md date-pipeline
section updated.
Neither migration had shipped, so the #115 snapshot columns join the
#114 new-contract columns in 2026-09-24-000000_add_true_utc_dates.
Pure ADD COLUMN either way — population is runtime (drains), so nothing
needs re-migrating. Column order is unchanged, so schema.rs stands.
cameron
merged commit 467abd41d5 into master2026-09-25 19:55:15 +00:00
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.
Carries the #114 foundation (schema + EXIF branch re-base + offset cascade + display zone, ADR-0001) and the #115 work built on it. Both tickets closed.
Summary
The exiftool branch of the date waterfall stops using
-d "%s"(which re-interpreted exiftool's rendered strings in the server's local zone — the "videos off by 4–5 h" bug) and parses raw strings + group prefixes in Rust:Manual date edit:
Evidence
photo_and_video_same_instant— photo2024:07:04 12:00:00(OffsetTimeOriginal −04:00) and video QuickTime2024:07:04 16:00:00both store1720108800(16:00Z).2024-07-04 12:00:00on an EDT server →1720108800, exactly what the old-d %sproduced (verified against a real file carrying an OffsetTime tag — the old path ignored it).cargo fmt --check+cargo clippy --all-targetsclean.Merge Danger
Door: two-way — additive nullable columns; legacy
date_takenstays byte-identical; no consumer reads the new columns yet (read-side cutover is #120). Rollback = revert; the migration has adown.sql.Blast Radius: ingest + per-tick date-backfill drain + the manual date endpoint only.