The emergency starts before the server knows about it
The problem
Every feature in this app is built on a handful of quiet assumptions. There is a network. The app is running. The user is looking at the screen. The user has time. If something goes wrong, we can show them a spinner and try again.
A dog goes missing at eleven at night on a road with no signal. The owner has one hand on a torch. Every one of those assumptions is false at once.
SOS is the feature built without them. Almost every default in the codebase is inverted somewhere in it, and each inversion comes with a cost somewhere else. The interesting part isn't the inversions — it's the bills.
The emergency happens before the network
Normal order: call the server, get an ID, render the result.
SOS reverses it. The client mints the incident ID itself — a UUIDv7 — opens the emergency chat, puts the ongoing alert on the lock screen, and only then sends the write:
final incidentId = ensureIncidentId(petId, kind);
openSosChat(
petId: petId,
petName: petName,
incidentId: incidentId,
...
);
await liveActivity.startSosAlert(
incidentId: incidentId,
...
);
final saved = await SosApi(api).raise(
incidentId: incidentId,
...
);
The thing that makes this safe rather than reckless is that the ID belongs to the client. PUT /sos/incidents/{id} is an upsert using an ID the caller already chose, so the request is idempotent. If the write fails, it can be retried without creating another emergency.
If the write never reaches the server at all, the alert still exists and the ID is remembered. The next launch can reconcile it.
There is no state where the owner has an emergency on screen and the system has simply forgotten that it ever happened.
The bill: the ID is provisional.
The server may dedupe against an incident that was already opened from another device and return a different ID. The client then has to adopt the server's ID, and anything already holding the local one — especially the lock-screen notification whose buttons deep-link back into the app — has to be recreated under the new name.
Local-first gives you an emergency that can start offline. The price is an identity-reconciliation problem.
The alert is not the source of truth
The ongoing alert is an iOS Live Activity or an Android live update. It survives the app being killed. It does not survive everything: a reboot, a reinstall, or iOS deciding the Live Activity has run long enough can take it away.
So the alert can't own the state.
The server owns whether an emergency is actually live, and the client reconciles against it on every launch and session start. When the alert is restored, it is anchored to the incident's real raise time, so the timer doesn't reset to zero for a dog that has been missing since morning.
The bill: "nothing is open" and "I couldn't ask" can no longer mean the same thing.
Future<List<SosIncident>?> openIncidents()
null means the read failed.
An empty list means the server successfully answered and says there are no open emergencies.
That distinction matters. Collapse both into [] and a dropped request can become an instruction to tear down an alert for a search that is still active.
The same distinction exists one level up. Reconciliation needs to tell us whether it actually ran, not just what it found. A boot that couldn't reconcile because a dependency wasn't ready is different from a boot that reconciled successfully and found nothing.
And boot waits for the real answer rather than guessing.
There is deliberately no timeout here. A cold launch is exactly when a round trip is most likely to take longer — token refresh, DNS warming up, network coming back. "We'll reconcile in the background" isn't much comfort to someone staring at the home screen while their dog is missing.
The screen you cannot leave
Tapping SOS doesn't open another screen on top of the app. It replaces the route stack:
pushAndRemoveUntil(route, (_) => false)
The triage screen becomes the only route.
The navbar and floating button aren't hidden. They disappear because the shell that owned them is gone. There is no isEmergency flag threaded through the UI deciding what to suppress, because the mode itself is represented by the stack.
The bill: a mode is something you have to be able to leave, and Android and iOS disagree about what "leave" means.
On Android, if the current route doesn't handle the back gesture, it can reach the OS and close the app. That's zero emergency-specific code.
On iOS, there is no root back gesture, and apps aren't supposed to quit themselves programmatically.
So Cancel exists, and it exists for a very specific reason: there has to be an explicit way out on iOS.
Leaving the screen never ends the alert.
Closing a screen was never the same decision as calling off a search.
The permission you cannot ask for at a bad moment
The health-emergency path needs location. An emergency vet run that can't tell anyone where you are has very little to announce.
The obvious implementation is:
Ask for location → wait for the answer → raise the emergency.
That version had a problem. It posted an undismissable "emergency vet care in progress" notification before the location prompt had even been shown, let alone answered.
So the gate checks first rather than immediately asking. If the permission isn't there, nothing is raised. The user gets the primer, and the primer re-emits the original intent once permission is granted.
Then comes the subtler part:
if (!await isLocationGranted()) {
if (!await requestLocation()) return;
if (stillWanted != null && !stillWanted()) return;
}
The OS permission prompt is the one await that can outlive the original tap by minutes.
The owner can background the app and come back later. Or they can grant location from system settings an hour later while doing something completely unrelated.
If the app resumes straight into raise(), it can create an emergency over whatever screen the user happens to be on.
That's how you get a report like:
"SOS opens randomly while changing permissions."
The bill: an async permission result has to carry the question it belongs to.
stillWanted asks whether the action that caused this permission request is still active.
A permission being granted is not a new request to start an emergency.
Nothing on a public poster may be a placeholder
Everywhere else in the app, a missing field is mostly a UI problem.
Here it's a public one.
The poster goes to strangers.
So the report is gated twice, in order, and nothing is written until both steps pass.
First comes the last-seen sheet. It can use GPS, but a typed place is equally valid. Coordinates are only included when they actually exist.
Then comes the read-back: the photo, the breed, the place — exactly what the alert will carry.
This is the owner's one chance to see the broadcast before it goes out rather than after.
Back out of either step and nothing exists.
No incident. No alert. No half-created report.
The same rule applies to the QR code.
If the finder link hasn't arrived yet, the poster draws no QR code rather than one that scans to an error. The client isn't allowed to construct that URL locally, however obvious the format might look, because the URL is printed onto physical tags and belongs to the server.
The bill: two screens between the tap and the alert, in an emergency.
That's a real cost, and it was a real argument.
It was paid because a poster with an invented address is worse than a poster that appears thirty seconds later with the right one.
The one surface a stranger sees
Every backend module is tenant-scoped by construction.
The finder page — the page a scanned tag resolves to — is the exception. It writes to a global partition.
It has to.
A person standing in the street holding somebody's animal has no account and cannot be given one.
That missing session shapes the data.
Fields that only the owner could have entered — the last-seen location, reward, phone number — are snapshotted when the report is published.
The public read has no tenant and no principal, so it has nowhere else to get those values from.
Fields that belong to the pet itself — breed, age, photograph — are read live. A new photo can therefore appear without republishing the report.
Two storage strategies in the same record, both because the public page has no authenticated session.
And the page has a floor it can fall back to without failing:
name and phone number.
A finder with those can still get the animal home.
A page that fails completely can't.
The bill: nothing enforces this for you.
The normal isolation guarantees provided by tenancy aren't available on this surface. Every call has to perform its own checks.
Consent is staged
Raising an emergency is one action.
Telling strangers where the pet was last seen and what number to call is another.
So the public information is opt-in, afterwards, with a composer showing the owner exactly what will be shared.
The backend models those as separate actions, and the client mirrors that instead of collapsing them into one tap.
An emergency is not a reason to skip asking.
Key takeaways
Local-first is an identity problem in disguise. Letting the client mint the ID is what makes an emergency appear offline. It also means two parties can temporarily name the same thing differently, and everything holding the old name has to be updated when they disagree.
Durable state needs three answers, not two. Yes, no, and I couldn't ask. Anything that folds the third into one of the first two will eventually act with confidence on a request that actually failed.
A mode is a stack shape, not a flag. If leaving a mode means hiding a collection of things, you now have two mechanisms representing the same state. They will eventually disagree. Replacing the stack means the chrome disappears because the thing that owned it disappeared.
An async permission answer must carry its question. The gap between "may I?" and "yes" can be minutes long and span a backgrounded app. A yes is not a fresh request to do the thing.
"No data" is a design decision on a private screen and a correctness issue on a public artefact. Placeholder text that a stranger might act on is worse than no text at all.
The surface with no session enforces nothing for you. Every guarantee the rest of the system gets from its tenant boundary has to be re-derived, per call, by hand.
Author's note
I've now written up two defects in this feature — duplicate incidents from a read-then-write race, and a notification whose buttons addressed a row that was never stored.
Reading them back beside this, they don't really look like mistakes in spite of the design.
They're the bill for it.
A client-minted ID is what makes the alert appear on a dead connection, and it is exactly what lets the lock screen and the server temporarily disagree about a name.
A server that owns liveness is what makes an alert survive an app restart, and it is exactly what makes "I couldn't reach it" and "there's nothing there" dangerous to confuse.
That doesn't make the design wrong.
The alternative — an emergency that waits for the network, an alert that dies with the process — fails in the one moment the feature exists for, and it fails silently.
These bugs were loud.
And every one of them was reported by someone who could see that something was wrong.
The thing I'd tell myself earlier is that the inversions are the cheap part.
Deciding that the alert should lead the write takes an afternoon.
The costs show up across the codebase for months afterwards, in places that don't look like SOS at all — a map key, a null check, a permission callback resuming into the wrong screen.
When you invert a default, the bill doesn't arrive where you signed for it.