
"6.7" < "6.11" is false: comparing Remote Config versions as strings froze content updates
Kotlin's string < says "6.7" is greater than "6.11". Once the content version in Remote Config reached 6.10, that comparison stopped updates on every device with 6.2 to 6.9 stored.
The drawer of SugarSpeller, our Korean spelling app, showed content version (6.7/2.2) while production Remote Config held 6.11/2.3. The operator read both numbers off a device on September 25, 2026. The app compared the two versions as strings, so devices with 6.2 to 6.9 stored never received the quiz bank and notice updates sent through info_version after that. A commit at 12:14 the same day (7b997f8) added VersionCompare, which compares each dot-separated part as an integer, plus four unit tests.
Sources: the app repository's git history (d5ac5e2, 6d0f4f1, 5f082f8, 71c31ae, a4d5b9a, e9d6b9c, 079feab, 7b997f8, 31c082d, 844c828), CHANGELOG entries 1.6.019 and 1.6.023, the quiz bank review document and the monitoring script, rechecked on 2026-10-29. Remote Config template numbers and project identifiers are left out.
Contents (9)
- The drawer said 6.7; Remote Config said 6.11
- With one digit after the dot, both orders agree
- Three agent commits, none of them fixed the content check
- One VersionCompare for both content and app versions
- The force flag in the same JSON went on before adoption
- One git grep, a test with the failing values, a check on an old install
- Builds already installed still compare strings
- The old rule is still in CLAUDE.md
- FAQ
The drawer said 6.7; Remote Config said 6.11
The app ships its quiz bank, notices and FAQ as one JSON value in Firebase Remote Config (RC below). On the splash screen, ActivityEmpty compares RC's info_version and clip_version with the version stored in the device's Room database. If RC is newer, it deletes the local content and inserts the new set. The (6.7/2.2) at the bottom of the drawer is that stored version.
One suspect was the build. Debug builds of this app read the RC of a separate development Firebase project, so a debug build on the device could show numbers that differ from production. Production RC really was 6.11/2.3, which ruled that out and left the comparison code.
// ActivityEmpty, just before 7b997f8
if (res[0].infoVersion < infoVer || res[0].clipVersion < clipVer) {
repo.setVersion(VersionT(infoVer, clipVer))
// delete and re-insert content
}
infoVersion is a String field. Kotlin compares strings lexicographically, character by character, so "6.7" and "6.11" first differ at the third character: '7' against '1'. '7' is larger, "6.7" < "6.11" evaluates to false, and the app concluded that RC held the older version.

With one digit after the dot, both orders agree
String comparison has been there since the app first connected to Remote Config in 2020. The current form, infoVersion < infoVer, appeared on May 6, 2023 (6d0f4f1), when a commit flipped the comparison to the right direction; the clip condition was added two days later. While the minor part ran from 6.1 to 6.9, lexicographic order and numeric order were the same, and the check always gave the right answer. It broke when RC moved to 6.10. We recomputed what the old expression returns for each stored value:
| Stored on the device | RC at 6.10 | RC at 6.11 |
|---|---|---|
| 6.1 | updates | updates |
| 6.2 to 6.9 | skips | skips |
| 6.10 | equal | updates |
"6.1" is a prefix of "6.10", so the shorter string sorts first; 6.2 through 6.9 all win at the third character. From the day RC reached 6.10, a device stuck in that range ignored every info_version bump (unless clip_version also went up). The quiz bank review document also records that notices changed on September 13, 20 and 22 without any change to info_version, which stayed at 6.10. Those three notices reached no existing user, string comparison or not.
Fresh installs never showed the bug. On first run there is no stored version row, so the app inserts the RC content without comparing anything and starts on the latest set.
Three agent commits, none of them fixed the content check
Counting only the times an agent touched or reasoned about the version check itself, the git history shows three. On August 9, 2025 at 15:32, a commit generated with Claude Code (5f082f8) blamed a "persistent update notice" on the string comparison of app_version and switched major, minor and patch to integer comparison. The app was at 1.5.031, so major and minor were one character each and the string comparison gave the same answer anyway. At 17:51 that day a commit whose message just says "fix" (71c31ae) put the string comparison back. No reason was recorded. Neither commit touched the content version check.
On December 11, 2025, an RC sync refactor (a4d5b9a, co-authored by Claude Opus 4.5) added 11 tests (its message says 13). One of them, versionComparison_isCorrect, asserts that "5.0" < "5.1". It uses single-digit values and calls no app code, only Kotlin's String operator, so it passes whatever the production check does. How we find tests that stay green when the guarded code is deleted is in 3,066 green tests hid a line that breaks opening the database.
On the morning of September 25, 2026, the agent reviewing all 124 quiz items (Claude Opus 5.5) wrote into the publishing steps of its 11:08 commit (e9d6b9c): the splash screen compares versions as strings, so bump info_version to 7.0. Fifteen minutes later the value that went up was 6.11, and the document now said that 6.10 to 6.11 is larger even as a string, so 6.10 users would update. The document still said a 6.9 to 6.10 bump is not recognized, but said nothing about what to do for devices stuck below 6.10. The 11:23 commit (079feab) also added a rule to the project's CLAUDE.md, that a bump which changes the number of digits is not seen as an update, and left the comparison code as it was.
Devices below 6.10 surfaced only when the operator put the drawer's 6.7/2.2 next to production RC. The 12:14 fix (7b997f8) was also written by an agent (Claude Fable 5.1), this time after the operator had pointed out that the device and RC values differed. How this incident shows up in the commit messages is covered, along with other cases, in A year of AI coding in 701 commits.

One VersionCompare for both content and app versions
Setting RC to 7.0 would have unblocked 6.x devices for now, since "6.x" < "7.0" is true. It would break again at 7.10, and everyone editing content would have to remember never to make a bump that adds a digit, so we dropped it as a permanent rule.
Comparing all three parts as integers, as in August 2025, did not fit either. This app looks only at the major and minor parts of app_version and ignores the patch on purpose; comparing the patch would send every user on an older patch to the store after each patch release. 7b997f8 keeps one function and routes both the content version check and the major/minor checks of app_version through it.
object VersionCompare {
fun compare(a: String, b: String): Int {
val pa = parts(a); val pb = parts(b)
for (i in 0 until maxOf(pa.size, pb.size)) {
val x = pa.getOrElse(i) { 0 }; val y = pb.getOrElse(i) { 0 }
if (x != y) return x.compareTo(y)
}
return 0
}
fun isNewer(remote: String, local: String) = compare(remote, local) > 0
private fun parts(v: String) = v.trim().split('.').map { it.trim().toIntOrNull() ?: 0 }
}
The four tests in VersionCompareTest cover the values that failed ("6.11" against "6.7"), equal values, parts of different lengths or counts ("1.10.0" against "1.9.9", "6.11" against "6.11.0") and non-numeric input. The first test also asserts that "6.7" < "6.11" is false. 112 unit tests passed at that commit. Do not write a new version check with a string <; that rule is in CLAUDE.md now.
The force flag in the same JSON went on before adoption
The RC version JSON that holds the content versions also holds app_version and force, and since 7b997f8 the major/minor check of app_version goes through VersionCompare too. The splash screen decides in this order: if the installed version matches app_version character for character, let the user in; otherwise compare major and minor only; finally, if force is true, override the result and show the update dialog.
A September 18 commit (31c082d) records that a device with 1.6.017 showed "A new version has been released!", and the code was the first suspect. The real cause was an RC app_version that did not match the shipped version. A patch bump (1.6.017 to 1.6.018) is not rescued by the major/minor check, so the dialog goes away only when the RC value matches the shipped version exactly.
On that same September 18, force went on while few users had the new version, and sessions for the day were down 25% five hours later. A September 13 case is left out here: a note written that day corrected an earlier reading as GA4 standard-report lag rather than a lockout, while later documents list it as a lockout again. Since 1.6.019 (September 19), monitor.py --only rc reads the RC version JSON and prints, from GA4 for yesterday and today, the share of active users on exactly the app_version string. If force is true and that share is under 90%, it warns that the rest are stuck behind the splash dialog right now. A separate rollout incident, where a staged percentage went out wrong, is in A 20% staged rollout went out at 100%.
One git grep, a test with the failing values, a check on an old install
- Find lines that compare version-like names with < or >. In your own repository, drop the revision. In ours, with the revision just before 7b997f8 as below, the command finds the two lines in
ActivityEmpty(248 and 270) and three lines in the a4d5b9a test (run on 2026-10-29).git grep -nE '[Vv]er[A-Za-z]*(\[[0-9]\])?\s*[<>]=?\s*[A-Za-z.\[]*[Vv]er' 7b997f8^ -- '*.kt' '*.java' - Move every hit into one function and test it with the values that failed. At minimum: "6.11" against "6.7" and "1.10.0" against "1.9.9".
- Do not verify on a fresh install. Install the build from before the fix and run it once with RC at 6.9 so the stored version is 6.9. Install the fixed build over it without clearing data, raise RC to 6.10, and check that the stored version shown on screen becomes 6.10. A bump that keeps the digit count, such as 6.15 to 6.16, passes under the old comparison too and proves nothing.
- When content changes, bump
info_version. - Before turning
forceon, check in GA4 the share of active users whose appVersion equals the RCapp_version. Under 90%, leave it off.
Builds already installed still compare strings
The comparison runs on the device, so a device that has not updated to the fixed build keeps comparing strings. RC has since gone to 6.12 (October 1), 6.13 (October 9), 6.14 (October 10) and 6.15 (October 24), but on an old build with 6.2 to 6.9 stored, "6.7" < "6.15" is still false.
One server-side way to reach those devices is a one-time bump of the RC value to 7.0. On old builds "6.7" < "7.0" is true, and on new builds VersionCompare also sees 7.0 as newer than 6.15, so both paths update. We dropped 7.0 as a permanent rule, but as a one-time migration while old builds remain it is the right tool; after it, bumps should stay within 7.1 to 7.9 until old builds are rare enough. As of October 24 the RC value is 6.15, and there is no record of this app moving to 7.0. Because the old condition is an OR, raising clip_version would also make it true, but we do not know why those devices did not update at clip 2.3, so we do not count that path as a confirmed fix.
VersionCompare reads any non-numeric part as 0. A typo such as "6.l1" in RC would read as 6.0 and the update would be skipped silently; the test only confirms that "6.7" is newer than "abc" and raises no warning. "6.11" and "6.11.0" count as the same version.
For 1.6.023 a release APK on an API 34 emulator showed 6.11/2.3 in the drawer, but the record does not say whether that was a fresh install. If it was, the check never ran. clip_version is an open question too: stored 2.2, RC 2.3. "2.2" < "2.3" is true even as strings and the old condition was an OR, so the clip side should have triggered an update. The history does not say when clip became 2.3, so string comparison alone does not fully explain why this device stayed at 6.7/2.2.
Writing the version row before the content was another defect on the same update path. On September 28, on a physical S21, a first run while offline left the quiz bank empty until the next info_version; commit ce024cd now refills the bank when it is empty even if the version matches.
The old rule is still in CLAUDE.md
As of October 29, 2026, lines 206 and 207 of CLAUDE.md at HEAD still say the comparison is a string comparison and a bump that adds a digit is not seen as an update, while line 734 says versions are compared numerically with VersionCompare. An agent reading the first rule could believe the code still compares strings and pick bump values to suit that. The text from "the comparison is a string" on line 206 to the end of line 207 should become the one-time 7.0 migration rule for old builds; the "always bump info_version" rule on lines 205 and 206 stays. versionComparison_isCorrect, which calls no app code, is also still in the test suite; it should either be deleted or call VersionCompare.
FAQ
Why is "6.7" < "6.11" false in Kotlin?
String comparison is lexicographic, character by character. At the third character '7' is greater than '1', so "6.7" sorts after "6.11".
Wouldn't bumping the version to 7.0 work with string comparison?
For now, yes. It breaks again at 7.10, and everyone editing content has to avoid bumps that add a digit.
When is it safe to turn on Remote Config's force flag?
Once at least 90% of active users run exactly the app_version string. This app turned it on with low adoption and lost 25% of the day's sessions.
This note is written from values SugarMount measured itself; an AI coding agent was used to draft it.
More AX notes
- Pre-gzipping Android assets, measured: zero APK savings, and .pack.gz became .pack with a FileNotFoundException
- A market:// link opens a store chooser on SKT-sold Samsung phones: set the Play package first
- A splash that never moved on: addOnCompleteListener(this) and onStop
- R8 kept 5 AdMob test IDs in our release build: one listOf().any line and a dex count
- A reverse-construction generator and the gate behind 1,100 arrow-puzzle levels
- Daily users rose after the redesign while organic D1 retention fell from 15.2% to 11.2%