A klib is not a jar, and that changes how dependencies resolve
Metadata, klibs and platform artifacts resolve through different Gradle variants.
TL;DR
- A date-formatting dependency that resolved for
jvm,androidandwasmJscarried the symbol in those artifacts and not in either iOS one — and the build was green, because nothing in commonMain had used the missing part yet. - A multiplatform dependency is not one artifact. It is a metadata module that points at one artifact per target, each published as its own Gradle variant with its own attributes.
- So "the dependency resolved" is a per-target claim. The question that has an answer is which
variant did each compilation get, and
dependencyInsightanswers it per source set. - What reading on buys: the one command that answers "which variant did this target get", and why the two-of-five split was build-cache state rather than a platform gap — a distinction that decides whether you fix the library or ship a workaround.
The case this post is about
A shared module with five targets and one new dependency — a small date-formatting library, published as a multiplatform artifact:
// shared/build.gradle.kts
kotlin {
androidTarget(); iosArm64(); iosSimulatorArm64(); jvm("desktop"); wasmJs { browser() }
sourceSets {
commonMain.dependencies {
implementation("com.example.time:pretty-duration:1.4.0")
}
}
}
./gradlew :shared:build was green. The formatter worked on Android, on desktop and in the
browser. Then one line was added to commonMain that touched a part of the library the earlier
code had not:
// shared/src/commonMain/kotlin/Format.kt
fun relative(d: Duration): String = d.prettyRelative(Locale.current) // ← the new call
e: Unresolved reference: prettyRelative
(iosArm64, iosSimulatorArm64)
Two targets could not see a function the other three could, from a dependency all five had declared in one place.
The symptom
The error is a compile failure on exactly two of five targets, and the build file has no per-target dependency anywhere in it. So the first reading is that the library does not support iOS — which is wrong, and its own published metadata says so:
$ ./gradlew :shared:dependencies --configuration iosArm64CompileKlibraries | grep pretty
\--- com.example.time:pretty-duration:1.4.0
\--- com.example.time:pretty-duration-iosarm64:1.4.0
The iOS artifact resolved. It is on the compile classpath. And the symbol is not in it.

Why the obvious fix failed
The obvious fix is to declare the dependency per source set, on the theory that commonMain's declaration was not reaching the native compilations:
iosMain.dependencies {
implementation("com.example.time:pretty-duration:1.4.0")
}
That changed nothing, and it could not have: the resolution report above already showed the artifact present. Declaring it twice resolves the same variant twice.
The second guess was a version skew — that iosMain was pinning an older release through some
transitive path. Also wrong:
$ ./gradlew :shared:dependencyInsight \
--configuration iosArm64CompileKlibraries --dependency pretty-duration
com.example.time:pretty-duration:1.4.0
variant "iosArm64ApiElements" [
org.gradle.usage = kotlin-api
org.jetbrains.kotlin.platform.type = native
org.jetbrains.kotlin.native.target = ios_arm64
]
One version, the right variant, the right target attribute. Everything the resolution machinery is responsible for had worked. The absence was inside the artifact, which is where I had not looked — because a jar that resolves contains the classes the POM advertises, and I was reasoning about jars.
The actual mechanism
A multiplatform library publishes one module per target plus a metadata module, and they are not copies of each other.
The metadata module is the thing you depend on. com.example.time:pretty-duration:1.4.0 is a
Gradle Module Metadata document listing variants. It carries the common .klib — the declarations
visible to commonMain — and a pointer to one real artifact per target.
Each target's artifact is compiled separately, from its own source set. pretty-duration-iosarm64
is produced by the library's own iosMain compilation. If the library author put prettyRelative
in their jvmMain and androidMain and never in iosMain, the iOS klib does not contain it —
and nothing about that is a resolution failure. The right artifact arrived; it was built from
different sources.
And the common klib is what commonMain checks against. This is the part that decides whether
you find out at build time or not. A declaration present in the common klib is visible to
commonMain and must have an actual in every target's klib. A declaration present only in the
platform klibs is visible to that platform's source set and invisible to common. The library had
the second shape: prettyRelative was declared in three platform modules and in none of the
common metadata, so:
commonMain sees: the common klib → prettyRelative absent
androidMain sees: common + android klib → prettyRelative present
iosArm64Main sees: common + ios klib → prettyRelative absent
My call was in commonMain, so it should have failed on all five. It failed on two because
Gradle compiles the metadata compilation and the platform compilations as separate tasks, and the
JVM-family ones had a stale metadata klib from the build cache that still contained an older
shape. The native ones had no cache entry and compiled fresh.
So the two-of-five split was not a property of the dependency at all. It was cache state, and it is why the failure looked like a platform problem.

The fix
Two parts: resolve the actual question with a command rather than a guess, and make the shape of the dependency explicit so the next reader does not have to.
The command, as a Gradle task you can keep. dependencyInsight answers per configuration,
and a KMP module has one compile configuration per target — so the useful form loops over them and
reports which variant each one got:
// build-logic/src/main/kotlin/kmp-dependency-audit.gradle.kts
//
// Resolves the SAME coordinate against every target's compile configuration and prints the
// variant each one selected. A dependency missing for one target shows up as a row, not as a
// compile error three weeks later in whichever source set first touches it.
abstract class AuditKmpDependency : DefaultTask() {
@get:Input abstract val coordinate: Property<String>
/** Configuration name → the resolved variant for `coordinate`, or null if absent. */
@get:Internal abstract val configurations: MapProperty<String, ResolvedComponentResult>
@TaskAction
fun audit() {
val coord = coordinate.get()
val rows = configurations.get().map { (name, root) ->
val match = root.dependencies
.filterIsInstance<ResolvedDependencyResult>()
.firstOrNull { it.selected.moduleVersion?.module?.name?.startsWith(coord) == true }
// The two cases that matter are DIFFERENT, and a null-coalesce hides it:
// absent → this target has no artifact for the coordinate at all
// present → an artifact arrived; what it CONTAINS is a separate question
when (match) {
null -> name to "ABSENT"
else -> name to (match.resolvedVariant.displayName
?: error("$name resolved $coord with no variant — report this"))
}
}
val absent = rows.filter { it.second == "ABSENT" }
rows.sortedBy { it.first }.forEach { (cfg, variant) ->
logger.lifecycle(" %-34s %s".format(cfg, variant))
}
// Fail on a PARTIAL set rather than on zero. All-absent means nobody declared it, which
// is a different mistake and usually deliberate. Some-absent means one target is about
// to be missing a symbol nothing has used yet.
if (absent.isNotEmpty() && absent.size < rows.size) {
throw GradleException(
"$coord resolved for ${rows.size - absent.size} of ${rows.size} targets. " +
"Missing: ${absent.joinToString { it.first }}. " +
"A partial multiplatform dependency compiles until the first common call site."
)
}
}
}
Wired to the module's own targets, so it needs no configuration:
tasks.register<AuditKmpDependency>("auditPrettyDuration") {
coordinate.set("pretty-duration")
configurations.set(provider {
project.configurations
.filter { it.name.endsWith("CompileKlibraries") || it.name.endsWith("CompileClasspath") }
.filter { it.isCanBeResolved }
.associate { it.name to it.incoming.resolutionResult.root }
})
}
And the output it produced on the case above, before the dependency was corrected:
$ ./gradlew :shared:auditPrettyDuration
androidDebugCompileClasspath com.example.time:pretty-duration-android:1.4.0
desktopCompileClasspath com.example.time:pretty-duration-jvm:1.4.0
iosArm64CompileKlibraries com.example.time:pretty-duration-iosarm64:1.4.0
iosSimulatorArm64CompileKlibraries com.example.time:pretty-duration-iossimulatorarm64:1.4.0
metadataCompileClasspath com.example.time:pretty-duration-metadata:1.4.0
wasmJsCompileKlibraries com.example.time:pretty-duration-wasm-js:1.4.0
BUILD SUCCESSFUL
Six variants, none absent — which is the useful finding, because it says the problem is not
resolution and sends you to the artifact's contents instead of to the build file. The actual
repair was then upstream: prettyRelative moved into the library's commonMain, and 1.4.1
carries it in the metadata klib where a common call site can see it.
The local half, for the days before that release landed, was an expect/actual in our own code —
declaring the capability in common and implementing it per target, including a native
implementation the library had not shipped.
How we proved it
That the artifact resolved and the symbol was still missing — the two facts the first hypothesis could not hold together:
$ ./gradlew :shared:auditPrettyDuration # all six variants present
$ unzip -l ~/.gradle/.../pretty-duration-iosarm64-1.4.0.klib | grep -c prettyRelative
0
$ unzip -l ~/.gradle/.../pretty-duration-android-1.4.0.aar | grep -c prettyRelative
1
Resolution was never broken. One artifact contained the symbol and one did not.
That the two-of-five split was cache state, not platform support. The test is to invalidate the metadata compilation alone and re-run, which is the only move that distinguishes a stale klib from a real platform gap:
$ ./gradlew :shared:compileKotlinMetadata --rerun-tasks
e: Unresolved reference: prettyRelative (metadata)
$ ./gradlew :shared:build
e: Unresolved reference: prettyRelative (metadata, android, desktop, iosArm64,
iosSimulatorArm64, wasmJs)
Five of five, once nothing was cached. The original two-of-five report was an artifact of the build cache, and had I "fixed" it per-platform I would have shipped a workaround for a cache state.
That the audit task fails on the partial case rather than only on the empty one. The gate ships with a deliberately-broken fixture — a module declaring the coordinate for four targets and not for the native pair — run before the task was trusted:
$ ./gradlew :fixtures:partial-dep:auditPrettyDuration
…
iosArm64CompileKlibraries ABSENT
iosSimulatorArm64CompileKlibraries ABSENT
FAILURE: pretty-duration resolved for 4 of 6 targets. Missing:
iosArm64CompileKlibraries, iosSimulatorArm64CompileKlibraries.
A partial multiplatform dependency compiles until the first common call site.
And the all-absent fixture passes, which is the branch worth defending: a coordinate nobody declared is not a finding, and a task that failed on it would be turned off within a week.
What I cannot tell you is how often the "symbol in platform klibs only" shape occurs in published libraries. I hit it once, in one library, and fixed it upstream. The audit task has since caught two genuinely-partial dependencies in our own modules, both of which were our own declarations rather than a publisher's mistake — so the mechanism generalises and the frequency in this post is a sample of one.

What this generalizes to
The resolution machinery answers a narrower question than its success implies: it tells you an artifact matching the attributes was found, not that the artifact contains what the call site needs. That gap exists wherever one coordinate fans out to several build products — a multiplatform library, a BOM, a platform-specific native bundle — and the reliable move is the same each time: ask the build which variant each compilation actually selected, before reasoning about why a symbol is missing. The command is cheap, the inference it replaces is not, and the two-of-five split that looked like a platform limitation was a cache entry.
Related
- Source sets are a graph, and iosMain is not where you think it is — where an
actualhas to live for a given target to see it, which is the same visibility question one level down. - Why the build cache helps the JVM target and almost never the native ones — the cache behaviour that produced the misleading two-of-five split here, measured per target.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".