With print conversion on (as it must be, since parse_gps_dms parses "74 deg 0' 21.60\""), exiftool renders the refs as "West" / "South", not "W" / "S". So the flags are never set, every western longitude and southern latitude comes out positive, and the camera-offset cascade's GPS step looks up the mirrored point. Northeast Ohio (41.4, −81.8) becomes (41.4, +81.8) in China, and the row gets UTC+8.
The existing test parse_payload_groups_dates_and_evidence feeds "GPS:GPSLongitudeRef": "W", which isn't what exiftool prints, so it can't catch this. The kamadak-exif path (exif.rs::extract_gps_coordinate) checks starts_with('S' | 'W') and is correct; only the exiftool path is wrong. That's also why the stored gps_latitude / gps_longitude columns are correctly signed: only the offset evidence is affected.
Evidence
exiftool output (12.76, ImageApi's flags -j -q -G1, on a JPEG written with -GPSLongitudeRef=W -GPSLatitudeRef=N):
Production (2026-10-10): all 280 rows with date_taken_tz_source = 'gps' and a negative gps_longitude have camera_offset_minutes = 480 (NSFW 62, archive 78, Phone Backup 140). The one eastern-hemisphere gps row is also 480 and may be genuine.
Against the location track: for GPS-tagged photos, the track position at the photo's true_instant should sit near its EXIF GPS. For these rows it's a median 1,020 m away, versus 38 m when the same wall clock is read in America/New_York; the old reading is closer for 114 of 165 comparable rows. Other sources agree with the track (assumed_server: 5 m vs 6 m). The cameras are phones that follow local time (LG VS980, iPhone 6, OnePlus A6013), so this isn't a camera left on home time.
Impact
These rows' date_utc is 12–13 h off, so photo↔SMS / photo↔location matching and day grouping are wrong for them. Apollo #37 (matching on true_instant) is blocked on this: today's Apollo ignores the offset and pins them correctly by accident; after the switch they'd move 12–13 h.
Fix
Hemisphere check on the first letter, case-insensitive, accepting both "W" and "West" (same rule as exif.rs).
Test with real exiftool strings ("West" / "South"), including a southern-hemisphere case.
Re-resolving existing rows
The drain only picks date_utc IS NULL rows. Its cursor wraps to 0 at the end of the table, which happens every tick once only floor rows remain. So after deploying the fix:
All gps rows went through the same cascade step, so resetting all of them is the simplest safe set. The next ticks re-resolve them with the fixed parser. Verify: gps rows with negative longitude no longer carry +480, and the track-vs-EXIF-GPS check above drops to the same tens of metres as the other sources.
## Problem
The exiftool branch of the date resolver reads the GPS hemisphere refs with an exact one-letter match (`src/date_resolver.rs:843-847`):
```rust
"GPSLatitudeRef" => data.evidence.gps_lat_south = v.trim().eq_ignore_ascii_case("S"),
"GPSLongitudeRef" => data.evidence.gps_lon_west = v.trim().eq_ignore_ascii_case("W"),
```
With print conversion on (as it must be, since `parse_gps_dms` parses `"74 deg 0' 21.60\""`), exiftool renders the refs as **`"West"` / `"South"`**, not `"W"` / `"S"`. So the flags are never set, every western longitude and southern latitude comes out positive, and the camera-offset cascade's GPS step looks up the mirrored point. Northeast Ohio (41.4, −81.8) becomes (41.4, +81.8) in China, and the row gets **UTC+8**.
The existing test `parse_payload_groups_dates_and_evidence` feeds `"GPS:GPSLongitudeRef": "W"`, which isn't what exiftool prints, so it can't catch this. The kamadak-exif path (`exif.rs::extract_gps_coordinate`) checks `starts_with('S' | 'W')` and is correct; only the exiftool path is wrong. That's also why the stored `gps_latitude` / `gps_longitude` columns are correctly signed: only the offset evidence is affected.
## Evidence
**exiftool output** (12.76, ImageApi's flags `-j -q -G1`, on a JPEG written with `-GPSLongitudeRef=W -GPSLatitudeRef=N`):
```json
"GPS:GPSLongitudeRef": "West",
"GPS:GPSLatitudeRef": "North",
"GPS:GPSLongitude": "81 deg 50' 34.80\""
```
**Production (2026-10-10):** all 280 rows with `date_taken_tz_source = 'gps'` and a negative `gps_longitude` have `camera_offset_minutes = 480` (NSFW 62, archive 78, Phone Backup 140). The one eastern-hemisphere `gps` row is also 480 and may be genuine.
**Against the location track:** for GPS-tagged photos, the track position at the photo's `true_instant` should sit near its EXIF GPS. For these rows it's a median **1,020 m** away, versus **38 m** when the same wall clock is read in America/New_York; the old reading is closer for 114 of 165 comparable rows. Other sources agree with the track (`assumed_server`: 5 m vs 6 m). The cameras are phones that follow local time (LG VS980, iPhone 6, OnePlus A6013), so this isn't a camera left on home time.
## Impact
These rows' `date_utc` is 12–13 h off, so photo↔SMS / photo↔location matching and day grouping are wrong for them. Apollo #37 (matching on `true_instant`) is blocked on this: today's Apollo ignores the offset and pins them correctly by accident; after the switch they'd move 12–13 h.
## Fix
- Hemisphere check on the first letter, case-insensitive, accepting both `"W"` and `"West"` (same rule as `exif.rs`).
- Test with real exiftool strings (`"West"` / `"South"`), including a southern-hemisphere case.
## Re-resolving existing rows
The drain only picks `date_utc IS NULL` rows. Its cursor wraps to 0 at the end of the table, which happens every tick once only floor rows remain. So after deploying the fix:
```sql
UPDATE image_exif
SET date_utc = NULL, camera_offset_minutes = NULL, date_taken_tz_source = NULL
WHERE date_taken_tz_source = 'gps';
```
All `gps` rows went through the same cascade step, so resetting all of them is the simplest safe set. The next ticks re-resolve them with the fixed parser. Verify: `gps` rows with negative longitude no longer carry +480, and the track-vs-EXIF-GPS check above drops to the same tens of metres as the other sources.
Related: #113 / ADR-0001, #120, Apollo #37.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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
The exiftool branch of the date resolver reads the GPS hemisphere refs with an exact one-letter match (
src/date_resolver.rs:843-847):With print conversion on (as it must be, since
parse_gps_dmsparses"74 deg 0' 21.60\""), exiftool renders the refs as"West"/"South", not"W"/"S". So the flags are never set, every western longitude and southern latitude comes out positive, and the camera-offset cascade's GPS step looks up the mirrored point. Northeast Ohio (41.4, −81.8) becomes (41.4, +81.8) in China, and the row gets UTC+8.The existing test
parse_payload_groups_dates_and_evidencefeeds"GPS:GPSLongitudeRef": "W", which isn't what exiftool prints, so it can't catch this. The kamadak-exif path (exif.rs::extract_gps_coordinate) checksstarts_with('S' | 'W')and is correct; only the exiftool path is wrong. That's also why the storedgps_latitude/gps_longitudecolumns are correctly signed: only the offset evidence is affected.Evidence
exiftool output (12.76, ImageApi's flags
-j -q -G1, on a JPEG written with-GPSLongitudeRef=W -GPSLatitudeRef=N):Production (2026-10-10): all 280 rows with
date_taken_tz_source = 'gps'and a negativegps_longitudehavecamera_offset_minutes = 480(NSFW 62, archive 78, Phone Backup 140). The one eastern-hemispheregpsrow is also 480 and may be genuine.Against the location track: for GPS-tagged photos, the track position at the photo's
true_instantshould sit near its EXIF GPS. For these rows it's a median 1,020 m away, versus 38 m when the same wall clock is read in America/New_York; the old reading is closer for 114 of 165 comparable rows. Other sources agree with the track (assumed_server: 5 m vs 6 m). The cameras are phones that follow local time (LG VS980, iPhone 6, OnePlus A6013), so this isn't a camera left on home time.Impact
These rows'
date_utcis 12–13 h off, so photo↔SMS / photo↔location matching and day grouping are wrong for them. Apollo #37 (matching ontrue_instant) is blocked on this: today's Apollo ignores the offset and pins them correctly by accident; after the switch they'd move 12–13 h.Fix
"W"and"West"(same rule asexif.rs)."West"/"South"), including a southern-hemisphere case.Re-resolving existing rows
The drain only picks
date_utc IS NULLrows. Its cursor wraps to 0 at the end of the table, which happens every tick once only floor rows remain. So after deploying the fix:All
gpsrows went through the same cascade step, so resetting all of them is the simplest safe set. The next ticks re-resolve them with the fixed parser. Verify:gpsrows with negative longitude no longer carry +480, and the track-vs-EXIF-GPS check above drops to the same tens of metres as the other sources.Related: #113 / ADR-0001, #120, Apollo #37.
🤖 Generated with Claude Code