One of my apps spent about three days on the App Store showing "Couldn't load plans" on every cold launch. The prices were right everywhere I knew how to check them. The source of truth, the billing dashboard, and App Store Connect all listed the same three tiers, and all three agreed with each other. The running app received none of them. The cause was a fix I had shipped a few days earlier for something else entirely.
What broke
Two days before that release went out, the app had a launch hang. The purchase service was doing keychain work and configuring itself synchronously during startup, and it blocked the first frame. The fix was to make that work asynchronous: do the keychain read and the configure call in the background, then set isConfigured on the main actor when it lands. The hang went away. That fix was correct, and it is still in place.
The root view asks for the product catalog from its first-frame .task. Once configuration moved to the background, that request always won the race, and it arrived at this:
func fetchOfferings() async {
guard isConfigured else { return } // fires on every cold launch
// ...fetch the catalog
}Read that guard on its own and it looks careful. The service is not ready, so do not call it, return. It is the kind of line written to avoid a crash and reviewed as defensive. What it does on a cold launch is decline to do the work and then report success by saying nothing.
Why one silent return became a dead paywall
If the guard had skipped a single attempt, the next one would have recovered. It did not, because the calling code read an empty return as a finished load. The paywall set productsLoaded = true and catalogLoadFailed = true and never asked again for the rest of the session. So this was not a race that sometimes lost. It was a race that lost once, at the earliest possible moment, and then latched.
Every cold launch hit the same ordering, so every cold launch produced the same result: a paywall with nothing on it, in a shipped release, for days.
Why nothing told me
Nothing crashed. There was no thrown error to catch and no non-zero exit, so there was nothing for the crash reporter to collect. The only trace the code left was a print statement compiled into debug builds, which is to say into exactly the builds where the bug did not matter. There was no analytics event for a failed catalog load either, so the funnel showed people reaching the paywall and not converting. That looks identical to people reaching the paywall and deciding not to buy.
The products themselves were configured correctly the entire time. I know that because the moment the race was fixed, the same build rendered all three tiers at the right prices without anything else changing. Source, the billing dashboard, and App Store Connect were all right. Nothing was comparing any of them to what the running app actually received, and at that point I did not have a check that would have.
Three sources of truth agreeing with each other is not evidence that the fourth one, the running app, ever heard them.
The immediate fix
Wait for the service instead of skipping the fetch, with a bound on the wait:
guard await awaitConfigured(timeout: .seconds(10)) else {
reportOfferingsUnavailable() // an event now, not a debug print
return
}Two details matter more than the wait itself. The bound, so a service that never configures fails loudly after ten seconds instead of hanging the launch again and recreating the original bug. And a cancellation check inside the wait loop, so backgrounding the app during those ten seconds does not leave a task spinning. An agent wrote the change and the adversarial review pass that followed added both, plus a regression test. On a fresh cold launch after it went out, the paywall renders all three tiers.
The fix that actually mattered
The wait handles this instance. It does nothing about the class, and the class is that nothing in my ship pipeline had ever launched the built app and asked it what it got.
Two checks came out of it. The first one exists and blocks today: it cross-checks the product catalog in source against the billing dashboard against App Store Connect, and a real drift stops the ship. That check exists because of this incident. It did not before.
The second one is the one that would actually have caught this, and I am still earning the right to trust it. It boots the built binary in a simulator, launches it with a flag that makes the app dump the offerings it actually received, and asserts the dump is non-empty and the launch events fired. It drives no UI, because the moment a gate needs an agent to interpret a screen, it has gone back to taking something else's word for it. It is wired in as advisory, report-only for its first couple of ship trains, and the app-side hook it reads is still a template I have not rolled out across the portfolio. So today it is a gate that can tell me things and cannot yet stop me. I would rather say that plainly than imply coverage I do not have.
The exit codes are the part I care about:
- 0: the binary received its tiers.
- 2: it did not. A real failure.
- 3: the simulator never booted, the lock was not obtained, or no dump appeared. An infrastructure failure.
Exit 3 never passes. That sounds obvious, and it is the entire point. The cheap version of this gate treats "I could not check" as "nothing was wrong," which is how you end up with a green check that has no evidence underneath it. That is the same shape as the bug it was built to catch: a step that quietly declined to do its work and returned as though it had.
I do not write the code and I do not read it. I direct it and I verify the output. That makes the honesty of the checks the whole job. A gate that can report success without evidence is worse than no gate at all, because it also convinces me to stop looking.