Invariant 1
A plate gets photographed where the food is, and that is often where the signal is not. Sizzle's first product invariant answers that directly: a photo is saved on the device and queued before any network call and before any call to Pippa, so losing the signal at the table loses nothing. The doc names its owner too. The phone's outbox holds the plate, and the engine makes the send safe to repeat by keying it on the client's request id. The same rule runs through the family's other capture apps; vesta-quest's Capture in Motion track and the outbox lesson in swift-quest's Talking to the Engine at Home teach it in general. This lesson is Sizzle's version, line by line.
Save is two steps
On the phone, Save in the New plate sheet does not send anything. It calls commit, which is quick, and only then starts a drain in its own task. If the commit fails, the sheet says "Not saved on this phone" with the reason, and nothing has left the device. If it succeeds, the sheet switches from Cancel to Close and shows the upload as it goes (lesson 4).
commit builds the payload: the kind, the note with its whitespace trimmed, the moment of capture, the device's time-zone identifier, and a place. The moment is the first photo's own camera clock when it has one, and the current time otherwise. The place is the first photo that carries GPS. Then it enqueues the plate under a fresh id minted with the prefix plate-. That outbox id is also the request_id the engine will see, which is what makes every later send idempotent.
The enqueue itself writes each JPEG into the app container under a new file name, with file protection that unlocks after the first unlock since boot, and then commits the kit's outbox document. If that document cannot be written, the files it just wrote are removed again. That cleanup is narrower than it looks. It covers only the outbox write: a photo write that throws halfway leaves the plate's earlier photos behind, because the loop runs before the cleanup is in place, and a process killed between the writes and the commit runs no cleanup at all. What the order does guarantee is the other direction. The record is written last, so it never names a file that is not there, and a leftover is at worst an unreferenced JPEG in the photo folder, never half a plate. Its comment puts the rule in four words: it exists from here on.
Time and place come from the original
Photos are prepared off the main actor. PreparedPhoto.prepare asks the kit's ImageDownscale for a JPEG whose long edge is at most 2048 pixels, and reads the metadata from the original: the capture time with its offset, and the GPS position with the signs that south and west need. Since kit todo #548 the downscaled JPEG also keeps the original's EXIF. Two older lines still say the upload has none, a comment in Outbox.swift and a sentence in the architecture doc. The code and a hosted test say otherwise.
The engine then decides. It takes the camera's own clock first, then the client's captured_at, then the current time, so a photo picked from the library days later keeps the evening it was eaten. A photo's own GPS wins over the place the client sent. The copies the engine derives for display and thumbnails are saved without EXIF or XMP, so the camera's GPS stays with the original, and the display copy is what Pippa sees. A JPEG comment is the one block that survives into both copies (track 5, lesson 5).
The drain
The drain is where sending happens, and its rules come from the kit's OutboxDrain, composed from three family outboxes that each got one part subtly wrong. A pass is serial, in capture order. A success counts as synced. A permanent failure moves that one plate to needs-attention and the pass continues. A transient failure puts the plate back and stops the pass, because the rest of the queue would fail the same way and a hundred pointless attempts cost battery on a bad link. Only some 4xx answers are permanent: 408, 425 and 429 ask to be retried, and 401 and 403 mean the session is wrong, not the plate.
Sizzle's own drain adds two things. It recovers sends that an earlier run left half-done, and it coalesces: a call that arrives while a pass is running asks for one more pass. Without that, a plate saved during a pass would wait for the next refresh, because the kit's pass takes its list when it starts. Drains start from Save, from pull to refresh on Plates, from a queued row's Send now, and from the sync loop that runs every 60 seconds while the app is active.
A parked plate is Dad's call
A plate the engine refused stays on the phone. Its row on Plates shows the reason after "Refused:", and a menu offers Send now and Discard this plate. That is the one visible way to act, with no swipe actions. A discard removes the outbox record first and the photo bytes second. A plate that is only waiting says "Waiting for the engine", with the last error when there was one.
The web has no outbox
The web's capture card keeps the picked files, the note and the request id in memory. The plate exists only once the engine has answered. A failure keeps all of it for Retry, but closing the tab loses it. The web sends the current time as captured_at and sends the originals as picked, so the engine still finds the camera's clock in the files themselves. The outbox lives on the side that carries the risk: the phone, at the table.