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.
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.