Deciding Is Separated From Doing
In Photo Sweep, a verdict is just a database row until one specific action executes it. You can judge a thousand photos, change your mind about any of them, and close the tab having changed nothing.
Photo Sweep's core interaction is a deck of photos and two buttons: keep, delete. It sounds like an app that deletes photos as you swipe. It doesn't. Swiping "delete" on a photo does one thing — it writes a row into a decisions table with applied = false. That's the entire effect. The file itself is untouched, and stays untouched, until a separate, explicit action runs.
A thousand photos judged and never confirmed leaves the library completely untouched.
What record() actually does
public function record(string $userId, int $fileId, string $verdict): Decision {
// ...
$decision = new Decision();
$decision->setUserId($userId);
$decision->setFileId($fileId);
$decision->setVerdict($verdict);
$decision->setApplied(false);
// Captured now, while the file is still where it belongs. After it moves
// there is nothing left to read the original location from, and an undo
// needs it.
$decision->setOriginPath($media->getPath());
return $this->decisionMapper->upsert($decision);
}No file handle is opened. No move, no delete, no trash call. The method even captures the file's current path in originPath, purely as insurance for a restore that might happen much later — proof that the design already assumes the verdict and the action are separated by arbitrary time, not just by a function call boundary.
Undoing a pending verdict is symmetrically cheap: undo() deletes the row if applied is still false, and refuses if it's already true — "that row is history, and the way back from there is a restore, not an undo," as the comment in DecisionService puts it.
Why bother with this indirection
The obvious alternative — delete on swipe — is a worse product, not just a riskier one. Going through a photo library is not a linear decision process; people back out of individual choices constantly, want to look at a whole month's worth of "marked for deletion" before committing to any of them, and occasionally close the app mid-review and come back later with a different mood about last July. All of that is free if a verdict is inert data. None of it is free if a verdict is an action.
It also means the one screen that changes files — the review-and-confirm screen — can afford to be paranoid, because it's the only place that needs to be. It shows every pending deletion, lets you pull any of them back out individually, and only then calls apply(). Everywhere else in the app — the deck, the month grid, the API's /decisions/pending endpoint — can be read-heavy and side-effect-free by construction, because there's no code path from "look at this" to "delete this" that skips the confirm screen.
The property this buys you
decisionMapper->pending() deliberately does not reconcile against the filesystem before returning results:
// Deliberately not reconciled against the filesystem. A pending row is a
// verdict the user gave and has not confirmed, and an external storage that
// is briefly unreachable looks exactly like a file that has been removed —
// so checking here would sometimes throw away real work to tidy a list.
return $this->decisionMapper->findPending($userId, Verdict::DELETE);That comment is the payoff of the whole design. Because a pending decision has no side effects, there's no urgency to keep it perfectly in sync with external reality — an unreachable network mount looking momentarily like a deleted file is a cosmetic annoyance in a list, not a correctness bug, because nothing has acted on that list yet. applied(), the list that does reflect real actions taken, does reconcile — it has to, because it's downstream of something that actually happened. The separation between deciding and doing isn't just a UX nicety; it's what lets each half of the codebase be as careful, or as relaxed, as its actual stakes require.
Series: Photo Sweep.