Engineering

A version catalog is a contract, and a transitive bump breaks it quietly

Declared versions are requests, not guarantees. Resolution picks the highest.

MobileByteSensei 8 min read
kmpgradlebuild

TL;DR

libs.versions.toml looks like a lockfile. It is not. Each entry is a requested version, and Gradle's default conflict resolution picks the highest requested version across your whole graph — including versions requested by your dependencies' dependencies.

So you declare kotlinx-coroutines = "1.8.0", a library you added last week transitively requests 1.9.0, and you ship 1.9.0. The catalog still says 1.8.0, your changelog says nothing, and the first symptom is a behavioural difference nobody can trace to a version.

The catalog records intent. ./gradlew dependencies records reality. If the two matter, pin with a constraint and verify in CI.

The case this post is about

A version catalog, read by every module, maintained carefully:

# gradle/libs.versions.toml
[versions]
kotlin = "2.0.21"
coroutines = "1.8.1"
ktor = "2.3.12"
serialization = "1.7.3"

[libraries]
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }

Then a new dependency for image loading, added in a normal pull request:

implementation(libs.coil.compose)        // 3.0.x

One line, reviewed, merged. Nothing else in the catalog changed.

The symptom

A behavioural change with no corresponding code change. A SharedFlow subscription in the sync layer started receiving an emission it previously did not, in a narrow case involving a WhileSubscribed restart. One test flipped from green to red:

rotationWithinGraceDoesNotRefetch   FAILED
    expected 1 fetch but was 2

The commit that broke it added an image loading library. Nothing in the diff touched coroutines, the sync layer, or that test.

The catalog was unchanged:

$ git log -1 --stat gradle/libs.versions.toml
(no commits touching this file in 3 weeks)

And then the actual state of the build:

$ ./gradlew :shared:dependencies --configuration commonMainImplementationDependenciesMetadata \
    | grep coroutines-core
+--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1 -> 1.9.0 (*)

-> 1.9.0. The catalog said 1.8.1, the build used 1.9.0, and the arrow in that output is the entire story. Three weeks of a version nobody had chosen.

Why the obvious fix failed

"The catalog must not be applied to that module." My first theory, and checking it cost an hour. The catalog was applied correctly — libs.coroutines.core resolved to the right module. The version is simply not the catalog's to decide once something else asks for more.

"Add strictly to the version in the catalog." This genuinely works and it is a bigger hammer than it looks:

coroutines-core = { module = "…", version = { strictly = "1.8.1" } }

A strictly constraint fails the build if anything requires a higher version — which is correct when you mean it, and turns every transitive bump into a hard stop that someone will "fix" by loosening the constraint. I applied it to five libraries and spent the next week fielding build failures that had no diagnosis attached.

"Pin with resolutionStrategy.force." Forces the version silently, which reproduces the original problem with the sign flipped: now you ship 1.8.1 while a library believes it has 1.9.0, and you find out through a NoSuchMethodError instead of a behaviour change.

"Use failOnVersionConflict()." Fails on every divergence, including dozens that are entirely benign patch differences. On a real multiplatform graph this is unusably noisy, and the noise is what makes people disable it.

The actual mechanism

Gradle resolution has three parts, and the catalog participates in only the first.

A catalog entry is a requested version. version.ref = "coroutines" means "I would like 1.8.1". It is one input to resolution, with the same standing as any other request.

Every dependency also requests versions, transitively. Gradle module metadata means a library publishes its own dependency requirements, and those are real requests in your graph — coil requesting coroutines 1.9.0 is indistinguishable, mechanically, from you requesting it.

Conflict resolution picks the highest. Gradle's default strategy for a module requested at multiple versions is newest wins. This is a deliberate and mostly-correct default: it assumes semantic versioning, and picking the lowest would break the library that needs the newer API.

So the upgrade is not a bug. It is the documented behaviour, doing the sensible thing — and the reason it hurts is that nothing surfaces it. The catalog file is where humans look, and it is the one place the resolved version does not appear.

A version catalog requesting coroutines 1.8.1 alongside a newly added library transitively requesting 1.9.0, both feeding Gradle's newest-wins conflict resolution, which selects 1.9.0 — while the catalog file, the pull request diff and the changelog all still read 1.8.1, so the only place the real version appears is the dependencies report nobody runs

The catalog is a statement of intent. The resolved graph is the artifact. A change to the second with no change to the first is invisible by default, and that invisibility is the defect — not the resolution.

The sub-case worth knowing before you try to fix it: a catalog version is not a pin, and most attempts to make it one also are not. Gradle distinguishes three strengths of request, and only the last one can lose politely or fail loudly:

coroutines = "1.8.1"                    // prefer-ish — loses silently to 1.9.0
implementation(libs.coroutines) { version { require("1.8.1") } }   // a minimum, not a ceiling
implementation(libs.coroutines) { version { strictly("1.8.1") } }  // conflict → BUILD FAILS

require is the one that catches people, because it reads like a demand. It means "at least this" — so a transitive request for 1.9.0 satisfies it, resolution picks 1.9.0, and you have written a pin that permits exactly the thing you were pinning against.

strictly is different in kind: it makes the conflict unresolvable rather than resolved, and the build stops with both requesters named. That is usually what you want when a version is load-bearing, and it is deliberately unpleasant everywhere else — a strict pin on a widely-used module means every transitive bump becomes your problem to adjudicate.

Which is why the ordering below is lockfile first, pins second. The lockfile makes the change visible at zero ongoing cost; a strict pin makes it impossible and hands you the maintenance. Visibility is what was missing, and it is the cheaper of the two fixes.

The fix

Make the resolved versions a committed artifact, so a transitive bump appears in a diff. Then pin deliberately, only where it matters.

A lockfile for the versions you care about. Gradle has dependency locking; for a multiplatform build the lighter version that worked for us is a committed snapshot:

// build.gradle.kts
tasks.register("resolvedVersions") {
    doLast {
        val out = StringBuilder()
        configurations
            .filter { it.isCanBeResolved && it.name.contains("CompileClasspath") }
            .forEach { cfg ->
                cfg.incoming.resolutionResult.allComponents
                    .map { it.moduleVersion }
                    .filterNotNull()
                    .filter { it.group.startsWith("org.jetbrains.kotlinx") ||
                              it.group.startsWith("io.ktor") }
                    .map { "${it.group}:${it.name}:${it.version}" }
                    .toSortedSet()
                    .forEach { out.appendLine(it) }
            }
        file("gradle/RESOLVED_VERSIONS.txt").writeText(
            out.lines().filter { it.isNotBlank() }.toSortedSet().joinToString("\n") + "\n"
        )
    }
}
$ ./gradlew -q resolvedVersions && git diff --stat gradle/RESOLVED_VERSIONS.txt
 gradle/RESOLVED_VERSIONS.txt | 2 +-
-org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1
+org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0

That diff is the whole fix. With a CI check:

$ git diff --exit-code gradle/RESOLVED_VERSIONS.txt \
  || { echo "❌ resolved dependency versions changed — review and commit"; exit 1; }

Now adding coil produces a pull request that says, in a line a reviewer will read, that coroutines went to 1.9.0.

Scope the snapshot deliberately. The filter to kotlinx and ktor is the thing that makes this usable — snapshotting the entire graph produces a 400-line file that churns on every patch release and gets rubber-stamped. Pick the libraries whose behaviour you depend on.

Then align the catalog, rather than fighting resolution. The right response to that diff is usually to accept the bump and record it:

coroutines = "1.9.0"      # raised by coil 3.0; adopted deliberately 2026-10-17

The catalog now tells the truth, and the next reader is not misled. strictly is reserved for the few cases where a higher version genuinely cannot be tolerated — and when you use it, say why:

# strictly: 2.4 changes the default redirect behaviour we depend on. See ADR-014.
ktor = { strictly = "2.3.12" }

How we proved it

That the resolved version diverged. The -> in the dependency report is already proof, and the committed snapshot turns it into something checkable:

before adding coil
  gradle/RESOLVED_VERSIONS.txt → kotlinx-coroutines-core:1.8.1
after adding coil
  gradle/RESOLVED_VERSIONS.txt → kotlinx-coroutines-core:1.9.0

That the guard fires. Verified by deliberately adding a library that forces a bump and running CI's own check:

$ ./gradlew -q resolvedVersions && git diff --exit-code gradle/RESOLVED_VERSIONS.txt
-io.ktor:ktor-client-core:2.3.12
+io.ktor:ktor-client-core:2.3.13
exit 1   ← PR blocked until reviewed and committed

And that the original test failure was the version, not the code. This was the step that mattered most, because "a transitive bump changed behaviour" was still a hypothesis:

coil present, coroutines resolved 1.9.0   rotationWithinGraceDoesNotRefetch  FAILED
coil present, coroutines forced 1.8.1     rotationWithinGraceDoesNotRefetch  PASSED
coil absent,  coroutines resolved 1.8.1   rotationWithinGraceDoesNotRefetch  PASSED

The middle row is the control: coil still present, only the coroutines version changed, and the test flips. That isolates the variable to the version rather than to anything else in the commit.

Two honest notes. The test turned out to be asserting on an implementation detail of WhileSubscribed restart timing, so the right fix was to rewrite the assertion — the version bump exposed a brittle test rather than a regression in coroutines. And the snapshot catches version changes in the libraries you listed; a bump in an unlisted dependency is still invisible, which is a deliberate trade for a file reviewers will actually read.

What this generalizes to

Declared and resolved are different things in every dependency system. npm's package.json versus package-lock.json, a requirements.txt versus a frozen pip freeze, a Go go.mod versus go.sum. Knowing which file your tooling actually obeys — and whether the other one is checked — is a question worth answering once per ecosystem.

An invisible change is the defect, not the behaviour that caused it. Newest-wins resolution is a good default. Shipping a version nobody chose, with no diff anywhere, is the failure. Most "surprising behaviour" incidents are really missing-visibility incidents.

Commit the derived artifact so changes appear in review. Resolved versions, an exported API surface, a generated schema, a permissions manifest. Snapshot plus equality check is the same cheap pattern each time, and it converts a silent change into a line someone reads.

Isolate one variable before believing a diagnosis. Forcing the old version with the new library still present is what turned "the bump did it" from plausible into demonstrated — and it also revealed the test was brittle, which the broad theory would have missed.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS