Three files per photo
Photos are the one part of the record too big for the journal. The journal holds a photo's facts; the bytes live under photos/<encounter>/, three files per photo, all written by PhotoStore.ingest:
| File | What it is | Who sees it |
|---|---|---|
<id>.orig | The uploaded bytes, untouched, EXIF and GPS included | The engine. The photo route can serve it, and neither client asks for it |
<id>.display.jpg | Upright, at most 1600 px on the long edge, JPEG quality 85, no EXIF | The plate pages, and Pippa |
<id>.thumb.jpg | The same at most 480 px, quality 80 | Lists and cards |
Each file goes through the kit's atomic_write_bytes, so a crash leaves either the old bytes or the new ones, never half a file. Before any of that, ingest refuses what it cannot safely hold: an empty file, more than 60 MB, more than 120 million pixels, or anything Pillow cannot open. HEIC straight from an iPhone works when pillow_heif is installed. Because a stored photo never changes, the route serves it with a year-long immutable cache header, and clients may keep it forever.
Read the facts before the pixels change
The order inside ingest is the important part. It opens the image and reads its EXIF first: DateTimeOriginal with its offset when the camera wrote one, and GPS turned into a latitude and longitude rounded to six decimals. Only then does it call ImageOps.exif_transpose to stand the picture upright and convert it to RGB. The facts go into the photo's record, the record goes into encounter.captured, and the mirror's photos table keeps taken_at and gps for each one.
Those two facts are what lesson 4's capture path uses. _capture_moment prefers the camera's own clock, so a photo picked from the library days later still lands on the evening it was taken. And the first photo carrying its own GPS becomes the plate's place, ahead of whatever the client sent.
What the copies carry, and what they do not
_jpeg makes each derived copy: it copies the upright image, shrinks it with Lanczos, and saves a JPEG without passing any EXIF, so Pillow writes none. I checked it against the engine's pinned Pillow, 12.1.1, by feeding it a photo carrying a camera tag, a GPS block, an XMP packet, an ICC profile and a JPEG comment. The derived JPEG came back with an empty EXIF, no XMP and no ICC profile. The comment came back intact. Pillow keeps a JPEG's comment in the image's info, the upright copy inherits it, and the JPEG writer saves whatever comment it finds there unless it is told otherwise. Phone cameras rarely write one, but an edited file can, and whatever it says rides into both copies. So the honest description is no EXIF, not no metadata. The engine's own test checks the part that matters most: the display copy has no GPS, and the original still does.
Those copies are what leaves the engine. The reading worker sends Pippa the display copies, and conversation turns attach the same. The text Pippa gets about a plate carries its time, kind and Dad's note, and no place field. So the GPS the camera recorded never reaches her. That promise is narrower than it sounds. It covers EXIF and XMP, the blocks where cameras write location, and the JPEG comment above sits outside it: if an editing app wrote a place into the comment, that place rides along in both copies. Dad's note and his corrections travel exactly as he typed them, so a place he writes there goes along, and the pixels themselves can show a shop sign or a printed menu. The engine drops EXIF, XMP and ICC from what travels, and leaves the comment, the picture and Dad's words as they are. The camera's GPS stays in two places: the original file and the plate's own record.
The phone's upload keeps its EXIF now
The phone does not send the camera file. It downscales each photo first with the kit's ImageDownscale, to 2048 px on the long edge, because that size is measured to keep a vision turn fast. Until kit todo #548 the re-encode dropped all metadata on purpose, so the phone read the time and place from the original and sent them as form fields. On 2026-10-19 Dad overruled that for the whole family:
CWK 패밀리 앱에서 Public Facing 부분을 빼고는 모든 게 아빠 개인 앱이야. 그런데 Forge도 마찬가지고 그 메타데이터를 날려버리면 복구할 방법이 없는데 왜 미리 날려버리냐고. Public Facing으로 공개할 때나 제거를 하는 거지. 날려버린 메타데이터는 복구를 못 해.
Outside the public-facing parts, every CWK family app is Dad's own app, Forge included. Metadata thrown away early can never be recovered, so why throw it away in advance? You remove it when something goes public, and not before.
Since kit commit 71a29b9, which Sizzle followed in e5d76ff, the kit's carriedMetadata copies the EXIF, GPS, TIFF and IPTC groups into the downscaled JPEG, resetting the orientation to 1 and the pixel dimensions to the new ones. So the .orig of a phone plate is a 2048 px JPEG that still knows when and where it was taken, and the engine reads those facts itself. The phone still sends captured_at and lat/lon from the original as well. The web sends the picked file as it is, so a web plate's .orig is the camera file.
Code wins over comments here. Four places still describe the old behaviour: the docstring of Sizzle.capture, the place comment in the phone's Outbox.swift, a comment in test_capture.py, and the architecture doc's phone-capture paragraph. CaptureMetadata.swift and its test are current. What never changed is the engine's side, the house sign's invariant 6: derived copies carry no metadata. In code that holds for EXIF, XMP and ICC, with the JPEG comment above as its one exception.
Staged, claimed, released
Since kit todo #545 a photo travels on its own request before the plate does. POST /api/staged-uploads takes a client_key and one file. The staged id is the first 32 hex characters of the key's SHA-256, so a retry after an engine restart finds the same file, and the same key a second time answers with the first copy and writes nothing. Bytes and metadata are written to .part files and moved into place with os.replace, and the route enforces the 60 MB cap while it reads, a megabyte at a time.
The plate then names its staged ids. take reads them without deleting anything. Only after the plate has committed does the route call release, so a crash between the two leaves a retry that still works. Anything nobody claims for seven days is swept on the next upload. A plate naming an id that is gone is refused with a message saying it is not here, expired or never sent, and the phone's outbox catches exactly that refusal, forgets its staged ids, and sends the photos again on its next pass.