A version catalog is a contract, and a transitive bump breaks it quietly
Declared versions are requests, not guarantees. Resolution picks the highest.
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.

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.
Related
- One Gradle build, five outputs, and the cache that only helps three — reading per-task build data instead of totals
- The iOS framework you export is an API, and nobody reviews it — the same committed-snapshot guard
- A dependency that resolves in commonMain and only exists on Android — when resolution succeeds and linking does not
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".