The JPEG that would have won
Pippa serves a frame by name, without its extension. The route /avatars/{who}/{preset}/{emotion} walks a fixed list, .jpeg, .jpg, .png, .webp, and serves the first file that exists. The soul route does the same, in the soul's root and its default folder. Now picture the obvious way to publish: copy the new default.png into the live folder. If the folder still holds the old default.jpg, Pippa keeps serving the old face. Nothing fails, nothing logs, and the publication looks applied. The architecture names the trap in one line: Old JPEG files must not shadow newly installed PNGs.
The answer is to never merge into a live folder. Pippa builds the complete new folder somewhere else and replaces the old one whole. Its revision function agrees with its server, too: frame_files walks the extensions in reverse, so when two files share a frame name, the one the route would serve is the one that gets hashed.
Check everything before touching anything
publish runs its checks in an order that keeps the live folder out of reach until all of them pass:
- The request id has a safe shape, and a known id with the same fingerprint goes straight to its receipt.
- The target resolves from the registry: a valid channel, a slug name, an active subject with an avatar root.
- The live revision equals the expected one.
- The set is complete enough: at least one frame, and
defaultamong them. - Every source path resolves inside Avatars' originals directory, is an image file, and still hashes to its recorded SHA-256.
- Every subject is active in the registry now, and a public soul may not receive a protected subject. Protection Follows the Pixels covers that rule.
A refusal at any step leaves Pippa's avatar folders exactly as they were. The Pippa test for protection checks precisely that: the refused publish raises, and the old file still holds its old bytes.
Back up first, and check the backup
The backup goes to avatar-backups/studio/<request_id>/previous, a tree beside Pippa's avatar folders, outside the routes that serve avatars. It is copied only if it is not already there, so a retry never overwrites the first backup with something newer. Then its revision must equal the live revision, or the publish stops with Backup verification failed. A backup nobody checked is a hope.
Stage a whole new directory
The stage is a fresh .avatars-stage-<uuid> in the same parent as the target. Each frame is copied in as its frame name with the original's extension, then hashed again: Staged original verification failed if a byte changed on the way. One older layout needs care. A soul whose default frames sit directly in its root keeps its other collections as subfolders there, so for that layout the stage first receives every subfolder and every non-image file, and none of the old images. The lineage file is written into the stage last, and then a receipt marked prepared records every path the swap will touch.
Two details make this safe. The stage shares the target's parent, so the rename that installs it stays on one filesystem, where Python documents os.replace as atomic. And the dot-prefixed names never match the slug pattern this module uses for frames and collections, which must start with a letter or digit, so its own discovery never mistakes a stage or a displaced folder for a collection.
Two renames and a receipt
os.replace cannot replace a directory that has files in it, so the swap takes two renames: the live folder moves aside to .avatars-old-<uuid>, then the stage moves into its place. Then the installed revision is checked against the receipt (Installed image verification failed otherwise), the receipt is marked applied in both places, and the displaced folder is removed, because the verified previous tree already sits in the backup.
For the instant between those two renames there is no live folder, and a request that landed exactly then would get a 404. The design does not hide that gap; it makes every crash inside it recoverable. finish_publication looks at the disk and continues from wherever it stopped:
| The process died | On disk | The same request again |
|---|---|---|
| after the prepared receipt, before any rename | old live folder, complete stage | moves the old aside, moves the stage in, verifies |
| between the two renames | no live folder; stage and displaced folder | moves the stage in, verifies |
| after both renames, before the receipt said applied | new live folder, displaced folder | sees the new revision, verifies, marks applied |
| and the stage was lost or damaged | no usable stage | puts the displaced folder back if the live one is gone, then refuses: the previous set was preserved |
One more guard sits at the top of that function. If the live folder holds a revision that is neither the old one nor the new one, someone else published in between, and the resume refuses with The interrupted publication conflicts with a newer live set instead of overwriting them. Pippa's own test kills the process right after the old folder moves aside, finds no live folder, runs the same publish again and gets an applied receipt.