Two stores with opposite rules
The phone keeps two kinds of data, and they obey opposite rules. The outbox from lesson 3 is authored data: plates Dad made that the engine has not acknowledged yet. Nothing may drop them silently. The offline shelf holds disposable copies of what the engine already said: the plate list, each plate's detail, photo bytes, the tree, the classes and the threads. The shelf's own doc comment states the order between them. The copies are disposable, the outbox stays authored data, and only a successful copy receipt lets the outbox retire bytes the engine has acknowledged.
Each copy is one file holding the response body and the moment it was saved, so a screen that is showing a copy can say so: "Saved on device" with the date and time. A broken file reads as missing, and the next good read replaces it.
Scoped to one engine
The shelf's directory is named from the engine's base URL. Copies taken from one engine can never appear while the phone talks to another. When Dad changes the engine address in Settings, the app retires the old shelf before it saves the new address, opens a new shelf for the new engine, clears the image cache, and restores the plates and the tree from whatever that shelf already holds. For an engine it has never visited, that is nothing. A hosted test switches to a second engine and finds an empty screen, then switches back and finds the first engine's plate again.
Tickets and generations
The hard case is time. A read can be slow, and while it is in flight Dad can change the very record it will bring back. If the late reply were written as it arrived, the device copy would quietly undo his change. The shelf prevents that with two counters and a lock.
begin(path)records a fresh id as the latest read of that path and returns a ticket holding that id and the shelf's current generation.didMutate()bumps the generation.retire()marks the shelf retired and bumps it too.isCurrentis true only if the shelf is not retired, the generation has not moved, and this ticket is still the latest read of its path.- A write that carries a ticket lands only if that ticket is current.
EngineClient applies the rule in two places. Every read of a mirrored path (the plate list, a plate, the tree, a class, a thread) takes a ticket before the request goes out. When the answer arrives and decodes, a stale ticket throws a cancellation, so the screen keeps what it had, and a current one writes the copy. Every mutation does the opposite: after the engine answers a correction, an adoption or a question, the client bumps the generation and writes the mutation's own answer straight onto the shelf. A changed plate also updates its row in the saved list.
So the freshest truth is always the answer to the change, and any read that started before it is refused. A later read of the same path also supersedes an earlier one, because each begin replaces the path's read id. Two hosted tests pin both cases: a slow list read that finishes after a write cannot erase the device record, and a late reply from the old engine cannot fill the new engine's screen.
Sync reads, never asks
While the app is active, a loop runs syncOffline every 60 seconds. It drains the outbox first. If the plate list read succeeded, it reads the tree, then every listed plate's detail, and the thumbnail and display bytes of each photo. Next come the classes reachable from the tree and from the plates' links, following parents and children until none is new, and finally the threads: the kitchen, every plate's and every visited class's. Each landed unit is kept even if a later one fails.
Every one of those calls is a GET. The test that seeds a cold offline launch asserts exactly that, because a sync must never ask Pippa for a new reading or a new answer. Photos never change once stored, so a saved image is returned without a request as long as its bytes still decode as an image.
Pages that cannot lie either
The plate list is paged, 500 at a time. The client walks pages until one comes back short. If a later page only repeats ids it already has, it stops with a message saying the saved copy was kept. The whole list is written to the shelf only after every page has arrived, and only with a current ticket. A second page that fails halfway leaves the previous complete copy in place; the test uses 501 plates to prove it.
The outbox retires after the copy
When the engine acknowledges a new plate, the drain's send bumps the generation, saves the plate's detail, and writes every uploaded photo to the shelf, under the capture's own path and as the thumbnail and display copy of the engine's photo ids. Those are the phone's own downscaled bytes, so the new plate shows offline at once. If any of those writes fails, the send reports a transient failure saying the engine has the plate but the device copy could not be saved. The plate stays queued, and the next pass is a replay the engine answers from its stored plate. The web keeps none of this. It shows only live answers, and its local storage holds only the Sidekick's preferences: whether it was open, its mode, and the frame's three sizes (side width, dock height and floating size). No plate and no reading is kept there.