WizusLabs Engineering · Studio

The update prompt you cannot fix by shipping an update

Every other defect in a shipped app has the same remedy: cut a new build. The dialog that announces a new build is the exception — the client holding the bug is the client that has to read the fix, and the fix arrives through the release the dialog exists to advertise.

By WizusLabs Engineering · 2026-08-14 · ~11 min read

Ship a crash and you fix the crash, cut a build, promote it, and the users who were crashing stop crashing. That loop is so reliable that it quietly underwrites every other decision a small studio makes. The in-app update prompt is the one place the loop does not close: the code that decides whether to nag a player lives in the binary the player already has, and the corrected code can only reach them through a store release — the exact release the prompt is there to point at. So a wrong prompt is unreachable by the normal remedy, and the only channel left is the server-side manifest the prompt reads. Which means the manifest is not free to say whatever is true. It is constrained to say only what the most constrained installed client can act on safely. Those are different values, and on 2026-08-14 they differ by five builds in our own file.

The one repair channel that does not run through the client

Start with what makes this feature unlike the others. An update prompt has three failure modes worth naming: it can point at a version the store cannot hand over, it can block the app behind a floor nobody can reach, or it can go silent when it should have spoken. In each case the population affected is defined as everyone running the build that got it wrong — and a store release does not reach them, because reaching them is the thing the prompt was supposed to accomplish. This is not a hard bug. It is a bug with no ordinary path to the people it is hurting.

What is left is the manifest. Our client fetches a small JSON document from wizuslabs.com, compares it against the running build, and decides. The URL is a hardcoded constant on purpose — no key, no environment variation, one configuration surface. We can rewrite that file and have it live in minutes, with no review queue and no adoption curve. That asymmetry is the whole subject of this post: the manifest is simultaneously the only thing that can repair a bad prompt and the only thing that can cause one. A channel with both properties does not get to be merely accurate. It has to be safe.

One pair of numbers, two stores, no branch

The comparator is about thirty lines and it is worth stating exactly, because every constraint later in this piece falls out of it. Three rules, in order. If the installed build is below the floor, the decision is mandatory — a blocking dialog. Otherwise, if the installed build is below the latest, the decision is optional — a dismissible one. Otherwise nothing. The comparison itself is lexicographic over a four-element tuple, [major, minor, patch, build], so the build number is the final tiebreaker after the three version parts. That separation of a version string from a build integer is deliberate and we have argued for it at length: one is a claim about the product, the other a claim about the bytes.

Now the part that surprises people. The decision function reads one latestVersion and one latestBuild. There is no platform branch in it — not a fallback, not a default; the string Platform. does not occur in the file. And the value it compares against is a two-field object carrying a version string and a build integer, and nothing else. The installed-build snapshot structurally cannot know which operating system it is running on, because that fact was never put in it. So the answer to “is this player behind?” is computed from information that contains no notion of a store.

The manifest looks like it disagrees, and this is the false tell that costs people an afternoon. It carries both an iosStoreUrl and an androidStoreUrl, and there is a resolver that keys on the running platform exactly once to choose between them. That reads like platform-awareness. It is not: the resolver runs after the decision, and all it decides is which store the Update button opens. The manifest is per-platform on destination and unified on version. Everything difficult about this feature lives in that sentence.

One unified version field read against two stores at different release states A single manifest field carries the pair 1.0.0 build 110, with no platform branch in the comparator. It is read by installs served from two different stores: Google Play, which is serving 1.0.1 build 115, and the App Store, which is serving 1.0.0 build 110 and is the lagging store. With the field at 110, a Play player on build 114 gets a dismissible prompt toward a build that exists, and an App Store player on build 110 correctly gets no prompt. The safe value is the minimum of the two released builds, 110. Raising the same field to 1.0.1 build 115 would instead produce a dead-end prompt for the App Store player, pointing at a build that store cannot serve. The manifest field latest 1.0.0+110 one pair · no platform branch Google Play serving 1.0.1+115 the store that is ahead App Store serving 1.0.0+110 the lagging store prompt → build exists a player on +114 can update no prompt a player on +110 is current min(110, 115) = 110 what no released store is behind set the field to 1.0.1+115 → a prompt this store cannot fulfil one field cannot say two things
The field has one slot and the stores have two answers. Whatever is true, the only value that harms nobody is the one the slower store can already hand over — everything above it is a prompt with nowhere to go.

Two ways to be wrong; only one of them announces itself

Set the floor above what a store can serve and the failure is loud and immediate. The decision comes back mandatory, the dialog is blocking, and the skip mechanism is deliberately ignored in that branch — a forced update is the one the app refuses to run without, so honouring a dismissal would defeat it. The result is an app that will not open, with a button that leads to a build the store does not have. Nobody argues about the severity of that one.

Set latest above what a store can serve and the failure is quiet, which is why it is the one that actually happens to us. The decision is optional, the dialog is dismissible, and the player can get on with their game. What they get is a dead end: a recommendation toward a build that does not exist for them, offered again on the next cold start. And the dismissal is weaker protection than it looks. A skip is stored as the build number it was taken against, and honoured only while that stored number is at or above the current latestBuild — so the moment the field moves, the prompt is back. More to the point, the manual “check for updates” path clears the stored skip before it re-fetches. That is correct behaviour for its purpose: a player who explicitly asks should not be silently answered by an old dismissal. It also means the escape hatch does not durably protect anyone from a wrong value.

So the invariant applies to both fields, and for a long time our own written rule said otherwise. It described the floor as the dangerous one and treated latest* as safe to raise on any routine promote. That is true only when both platforms promote together, which for us is not the normal case — it is the lucky case. The correct statement is flatter and harder: with a unified comparator, neither field may exceed what the lagging store can serve. The safe value is the minimum across platforms, computed on the full version-and-build tuple, and it is a property of the slowest store rather than of the newest build.

The obvious fix is gated behind the thing it fixes

Everyone who hears this reaches the same answer within about ten seconds, and it is the right answer in the wrong order: give the manifest per-platform blocks, teach the client to read the block for the platform it is on, bump the schema integer so old clients know they are looking at something new. The trap is in that last clause. Our clients hard-reject an unrecognised schema version — anything greater than the highest they understand parses to nothing, and there is a unit test asserting that a manifest declaring version 99 yields null. A null manifest propagates all the way out as the decision none. Fail-closed, which is the right default nine times in ten and precisely the wrong one here: bumping the writer takes the prompt dark for every client that has not yet learned to read it, and a client that has learned is itself a store release. You would be shipping the fix through the channel you are trying to repair.

Checking that claim turned up a complication that makes the argument sharper rather than weaker, so it belongs here rather than in a footnote. The blackout is not total, because the client has a fallback: when the master manifest fails for any reason — unreachable, malformed, or carrying a schema it refuses — it retries against a legacy per-app mirror published at a second URL, still declaring schema version 1. So a bumped master would not silence the prompt. It would silently demote every shipped client onto that mirror. And the mirror is flat: one latestVersion, one latestBuild, no per-platform block, no way to express one. The escape hatch leads back into the same room. To keep the prompt alive through a schema bump you must maintain, indefinitely, an artifact that can only carry the unified field you were trying to escape — and that artifact then becomes the operative one for exactly the population you cannot measure.

Additive keys, not a new schema

What we shipped instead turns on an asymmetry in our own parser that is easy to miss and does all the work. Unknown keys are ignored, because the parser reads the fields it names and never enumerates what else is present. Unknown version integers are rejected outright. One of those is a tolerance and the other is a wall, so the licence is specific: you may add anything you like to the document, as long as you do not renumber it.

Concretely, in the master manifest today: manifestVersion stays at 2, and each app entry has gained a platforms object with ios and android blocks sitting alongside the flat pair that was already there. For one of our board-game apps the blocks read 1.0.1 / 115 for Play and 1.0.0 / 110 for the App Store, while the flat pair every shipped client actually reads stays at 1.0.0 / 110 — the minimum of the two, and the reason the figure above uses those numbers. For Sudoku the flat pair and both blocks agree at 1.0.6 / 154, and the legacy mirror carries the same pair in lockstep. The document now records the truth and advertises the minimum, in two different places, on purpose. That is not elegant. It is what a compatibility shim looks like when you write it down honestly instead of pretending the new field is live.

Two details in the same file are worth keeping for the same reason. One app entry has an android block and no ios block at all, because there is no iOS record to describe yet — an absence left visible rather than filled with a placeholder that would later read as a fact. And two entries carry null where the floor fields go. In the client we read, a null in a required field makes the whole entry unparseable, which means an app that is not yet registered gets no prompt rather than a wrong one. Both are the same instinct: when the channel cannot be corrected in the field, the failure mode you choose in advance is silence.

One number we were right about by accident, and one we were wrong about for days

The version history of this one file is more candid than anything we would have written about it. Three days ago a commit landed titled docs(manifest): HOLD sudoku latest* at 1.0.5+131 — unified field, iOS still at 131: a deliberate refusal to advertise a build one store had and the other did not. Correct at the time. Then a sibling app’s field was raised from build 110 to 114 on the strength of a Play rollout reaching 100%, and that commit was reverted 633 seconds later — a little over ten minutes. The bump was right about Play and wrong about the field, and the revert is the shape of someone realising which of those mattered. The per-platform blocks arrived the next day, with the flat pair pointedly unchanged.

And then the commit at the head of the branch as this post goes out: fix(manifest): sudoku latest 1.0.5+131 -> 1.0.6+154, a release live on both stores for days. That is our own commit message telling on us. The hold was a correct decision that expired the moment the lagging store caught up, and nothing in our process noticed the expiry — not for hours, for days, on the one field that decides whether a player is told their app is out of date. A hold is a decision with an expiry date, and we wrote down the decision without the date.

The less comfortable half is the one that would be easiest to leave out. On an earlier promote the mandated manifest bump was simply skipped, and skipping it happened to leave the correct value in place — because raising the field would have pointed at a build only one store could serve. We were right by accident. From the outside, being right by accident and being right look identical: same file, same value, same green checks. Only one of them repeats, and the tell is that nobody can say afterwards which one they had. That is a worse position than a caught mistake, and it is why the rule we now carry is stated as an invariant on the value rather than as a step in a checklist.

A store can move a number you wrote down

One more failure from the same family, because it is the reason a written rule is not enough. An internal note of ours recorded which build an App Store version record was attached to. It was accurate when written. When someone later queried the store API at the moment they needed the answer, the record resolved to build 154; the note said 140. Fourteen builds of drift, in a document whose whole purpose was to be consulted before a decision about exactly that number.

A build attachment is mutable store state. A document recording it is a cache with no invalidation, and nothing about reading the document tells you which it is. So the rule is unglamorous and absolute: query the store at the moment you need the number, never read it out of a document — including the document that tells you not to. It is the same posture as reading back what you published instead of trusting the response that said it worked, and the same reason a build being processed and valid is not a build that is ready. A stored number and a measured number are not two grades of the same evidence. One of them is evidence.

What the client cannot re-read, the server must not overstate

None of this is exotic. Any system with a server-side value read by clients that cannot be recalled has the same shape — a feature flag, a minimum-supported API level, a deprecation date, a remote config that gates a screen. The moment the correction has to travel through the population that got it wrong, the field stops being a place to record what is true and becomes a place to record what is survivable. Our version manifest happens to make this vivid because the harm is so concrete: a button that opens a store page and offers nothing.

The practical rule we ended up with is one line long. A shared field must be set to the minimum its most constrained reader can act on, and the truth goes in a field nobody is reading yet. That is not a compromise waiting to be cleaned up; it is what forward compatibility costs when your only deploy channel to the reader is the store. The game in this account is Sudoku by WizusLabs — free and ad-supported, with a single optional Pro purchase that removes the ads, which also means the update prompt is very nearly the only message we ever push to a player, and a good argument for not wasting it on a dead end. More of these small shipping disciplines, and the ways they compound, are on the WizusLabs Engineering blog. An update prompt is a promise made on someone else’s behalf. Make only the promise the slower store can keep.

Notes

This is a first-hand account from our own code, our own published manifest, and our own commit history, all read on 2026-08-14. Every number in it — the build numbers 110, 114, 115, 131, 140 and 154, the schema integers 1 and 2, the rejected test value 99, and the 633 seconds between a bump and its revert — was read out of a file or a git log rather than recalled, and the commit titles are quoted as written. There are no adoption, acceptance, conversion or install figures anywhere in this post, because we have none we would stand behind: we do not measure how many players accept an update prompt, and manufacturing a rate to make the argument look rigorous would be the exact failure the piece is about. Two scope limits worth stating plainly. The comparator, parser and fallback behaviour described here were read in one app’s client; the pattern is shared across our apps by design, but we are not asserting byte-identical behaviour in every shipped binary, and a client already in the field cannot be re-inspected — which is the post’s own thesis applied to its evidence. And store state is mutable: the release states, attached builds and manifest values quoted here are dated, not current, and the only correct way to use any of them is to re-query at the moment you need the answer.

Keep reading: all posts on the WizusLabs Engineering blog.

← Back to the Blog