· 9 min
What Apple, Postgres and 32 languages taught me
Rejected for asking permission, one trigger that stopped every family write, invented data under a real WHO citation, and a Ukrainian column that was Russian.
Part 5 of 7 in the Building ProbaBaby series.
The modal saga in Part 3 was the longest fight. It wasn’t the only one. Across a year of ProbaBaby, the bugs that hurt most had one thing in common: almost none of them crashed. They failed silently, while a build, a preview or a green check said everything was fine. These are the ones I learned the most from, grouped by who taught me.
Apple
Rejected for asking permission
The first submission asked for camera and photo access at launch, with an “Open Settings” button for anyone who said no. Apple rejected version 1.0 twice. One guideline forbids steering people to Settings after they decline. Under another, the reviewer kept being sent back to the privacy notice whenever they uploaded a photo: the photo picker briefly made the app inactive, coming back re-ran the permission check, and a second full-screen cover collided with the prediction flow, which restarted at step one. That wasn’t the only cause of the reset; Part 3 has the rest.
The first fix asked for the camera just before opening the flow, so the system dialog couldn’t reset it. Apple flagged that too: it asked before the person had chosen to use the camera. The final version asks only when someone picks the 3D face scan, and photo uploads use Apple’s own picker, which needs no photo permission at all. Ask in context, and distrust any workaround whose side effect is itself a rule violation.
Things you can’t take back
In June the Watch app “would not install” for a reviewer. The first guess was that, marked as able to run without the iPhone, it had been installed on its own and that path had failed. Making it companion-only was refused at upload, because an approved version had already shipped a standalone Watch app, and that can’t be removed. The build went back with a note that its setup matched the approved version, and went live the same day.
At the end of August, two coding sessions set the app and its widgets to iPhone-only to postpone iPad work. On 2 September App Store Connect refused build 108: an update may never support fewer device types than the version already in the store. Every device family and capability you ship becomes a promise. The built app’s device list is now checked before every archive.
The crash fix that wasn’t in the build
Users kept crashing in the Planning calendar after the crash had been fixed. The fixes were real, but they weren’t ancestors of the commit that produced the App Store build. Every audit now starts by asking what the shipped build actually runs, with git merge-base against the build-number commit.
Widgets that worked everywhere except on a phone
After a redesign in August, the Home Screen widgets compiled cleanly and showed up blank or black on my phone. Two rounds on a real device found the rules no build reveals. On iOS 26 our Home Screen widgets were rendered from a stored snapshot, and any self-updating time text, like “12 min ago” or a running timer, came out blank, along with everything drawn after it. There was no crash and no memory kill in the logs; comparing broken and working widgets piece by piece found it. Durations are now frozen into each update, with fresh updates every minute or every fifteen, depending on the widget’s size. Colours built from UIKit’s adaptive colours were resolved once, in light mode, so they come from the asset catalog now.
Days later, on a Mac, the baby clock became a flat grey disc. With an app window in front, macOS shows iPhone widgets in a monochrome mode where only opacity survives, so categories are now told apart by opacity and shape. For widgets, only a device tells the truth.
Push and Postgres
The notifications that never arrived
Notifications broke three separate ways, each one silently. From late March to 1 May, no server push left the building: the platform’s gateway rejected the backend’s internal calls to its own push function, and the only symptom was a log table that stopped growing. In May, Apple accepted pushes and delivered nothing, because the app chose between Apple’s test and production servers from a build flag instead of from the profile it was actually signed with. It now reads the profile, and Live Activity and family pushes retry the other environment once. In September it turned out that everything sent through the main push function had failed since early May: the platform had moved to a new key format the code didn’t handle, and one stored “key” was a literal placeholder string.
- the gateway refuses the backend’s own calls late March – 1 May
- Apple accepts, and delivers nothing in May: the wrong server for the signing profile
- the main push function fails from early May, found in September
The diagram, step by step:
- Late March to 1 May: the gateway rejects the backend’s calls to its own push function. The only symptom: a log table stops growing.
- May: Apple accepts the pushes and delivers nothing. The app picked Apple’s server from a build flag, not its signing profile.
- From early May: everything sent through the main push function fails on a new key format. It turns up in September.
- Now: one internal authentication scheme, errors with their real status, and a “Send test notification” button.
There is now one internal authentication scheme, errors carry the real status, and there’s a “Send test notification” button. A pipeline with no failure alarm must be assumed broken.
One trigger stopped every family write
On 14 June 2026 a small change went to production: send the “session ended” push only when a session actually ends. So a trigger function began comparing the old and new values of ended_at. But that trigger was shared by the tables for feeds, sleeps, supplements, appointments, medical tests and timed sessions, and most of them have no such column. For those tables Postgres raised an error, and because the trigger runs inside the write, the error cancelled the write. Feeds stopped reaching the server.
- six tablesfeeds, sleeps, supplements, appointments, medical tests, timed sessions
- every writeone shared triggernow compares the old and new ended_at
- most tablesno such column: an errorit runs inside the write, so the write is cancelled
- thenthe hotfix, the same dayread the field from a JSON copy of the row: a missing field is null
The diagram, step by step:
- Feeds, sleeps, supplements, appointments, medical tests and timed sessions share one trigger function.
- 14 Jun: it starts comparing the old and new ended_at, to send “session ended” only when a session ends.
- Most of those tables have no such column. The error cancels the write, and feeds stop reaching the server.
- The hotfix, the same day, reads the field from a JSON copy of the row, where a missing field returns null.
The hotfix, the same day, read the field from a JSON copy of the row, where a missing field returns null instead of failing. A shared trigger is a shared blast radius. Since late September, every non-trivial migration runs on a throwaway local Postgres first, twice, against deliberately messy data.
The feed that vanished
In late May, starting a feed with the big Day Mode buttons made it vanish almost instantly. The first fix, a guard against double taps, was real but wasn’t the cause. Starting a feed wrote to the cloud and to memory, but not to the phone’s local database, and many background events reload the screen from that database. It only happened to people with history, which made it hard to reproduce.
In August, “Mom logged a feed offline, Dad never received it” had three stacked causes, among them a reflection-based lookup that could never see a stored record’s id, because SwiftData’s macro rewrites the property. It had been a silent no-op since day one. The same month, a first login on a busy account kept only the newest 1,000 rows per table, which for feeds is about three months of a newborn’s life. In an offline-first app the local store is the truth, and every write lands there first.
The previous user’s baby
After one person signed out and another signed in on the same phone, the new account showed the previous person’s baby. The wipe of local data sat behind the network call that ends the session, so if that call failed, the wipe never ran. The screen only replaced its baby when new data was non-empty, which a new account’s never is. And the hand-kept list of record types to clear had drifted from the schema; six kinds survived. Isolation is now a property of signing in, not just signing out: every sign-in compares the cached user with the incoming one and wipes first. Never put a security wipe behind a network call that can fail.
| before | now | |
|---|---|---|
| isolation is | a job for signing out | a property of signing in, too |
| the wipe runs | behind the network call that ends the session | first, when the cached user isn’t the incoming one |
| if that call fails | no wipe at all | the next sign-in wipes anyway |
The diagram, step by step:
- Before: keeping accounts apart was a job for signing out.
- The wipe of local data sat behind the network call that ends the session.
- If that call failed, the wipe never ran: one of three reasons the new account showed the previous person’s baby.
- Now every sign-in compares the cached user with the incoming one, and wipes first.
New constraints land on old builds
A plan in September called for database checks that the code on main already satisfied. The live App Store build didn’t: some of its ordinary writes would have been refused, parked in its queue and pruned after seven days. That is silent data loss for everyone who hasn’t updated. New rules now repair bad values in a trigger and keep the check only as a backstop, and new columns stay nullable for old builds.
Data that looked researched
241 countries, generated
In February the app gained crisis hotlines and postpartum support contacts for “241 countries & territories”, and the number went into the App Store description. In August an audit found that 24 countries shared the same generic entries, and 92 of their 96 links didn’t resolve. The phone numbers came from the same generator and showed it: one Pacific nation’s four “hotlines” ran in sequence. 213 entries were removed and 100 more demoted.
A second bug made it worse. Each country’s list was decoded all-or-nothing, so one malformed entry blanked the whole country, and 23 regions had shown no crisis hotlines at all since April. Re-checked entries now record their source, date and method, and the commit hook refuses data changes without the rebuilt database. Life-safety data needs an independent check of every single contact.
A real citation under an invented number
The baby growth charts carried a “WHO Child Growth Standards” citation. Underneath, four of the five growth datasets were invented: a ±15% band around a hand-typed median, a percentile that could only take six values, the same 4.0 kg reference weight at birth and at age two, and a sex setting that was never read. They were replaced with WHO’s own per-day tables, checked to reproduce WHO’s published values.
The calculators had the same problem. The iron tool recommended liver to pregnant readers. The caffeine gauge stopped at 100%, so 210 mg and 900 mg looked the same. The weight-gain tool told twin mothers to aim below their own recommended total. A citation tells you where a number should have come from, not where it did. Where no guideline exists, the tools now say so instead of inventing a multiplier.
911 for everyone
Emergency guidance had “911” written into all 32 languages, so Turkey saw 911 instead of 112 and the UK saw 911 instead of 999. Emergency numbers now come from the region, never from a string.
Thirty-two languages
Nobody checked which language
ProbaBaby’s gates checked that every translation was filled in. None checked that it was the right language. In August the Ukrainian column turned out to be the Russian one run through find-and-replace: a blind audit of 240 cells found 37% real Ukrainian, 30% plain Russian and 32% valid in neither. The cruellest detail was that the corruption often produced the very Ukrainian-only letter a detector looks for, so the worst cells passed as clean. The whole column, about 9,500 cells, was regenerated from English.
240 cells, read blind
- 37% real Ukrainian
- 30% plain Russian
- 32% valid in neither
each square is 1% of the 240 cells; the reported shares add up to 99%
The diagram, step by step:
- August: a blind audit reads 240 cells of the Ukrainian column.
- 37% are real Ukrainian.
- 30% are plain Russian.
- 32% are valid in neither. The whole column, about 9,500 cells, is regenerated from English.
A September repair fixed about 7,600 more cells. Spanish and Japanese week-by-week pregnancy content were placeholders like “Información médica semana 26 - 2”. Simplified Chinese was a character-by-character conversion of Traditional. Postpartum warning-sign lists had slipped out of line with English, and the warning about a C-section incision opening was missing in 13 languages. “Translated” is a status, not a correctness check. A commit gate now catches the wrong script and a sibling language’s marker words, and the repair tools check format specifiers and whether text fits its slot.
Raw keys on screen
The app routed string keys to their tables by prefix, and 34 fully translated keys sat in tables the lookup never reached, among them postpartum red-flag warnings, so the screen showed the raw key instead. The lookup doesn’t crash or log; it just returns the key. There’s a routing gate for it now.
What they have in common
- Silent failure is the normal failure. Almost nothing here crashed.
- Builds and previews lie. Devices, live data and the shipped build tell the truth.
- Two sources of truth drift. Every time.
- A citation, a “translated” flag or a green gate isn’t correctness.
- Some things ship forever. Device families, standalone Watch apps, and database rules that meet old builds.
The series goes on with the users I didn’t plan for. ProbaBaby is on the App Store as Pregnancy & Baby Tracker - PB, and the case study has the architecture and the numbers.
The series
- Four platforms in four months
- Rules born from mistakes
- The sheet that closed itself
- A newborn app, with a newborn
- What Apple, Postgres and 32 languages taught me
- Twins found us
- A spotless website nobody could find