Publishing Kotlin to C#
The forward direction takes a Kotlin/Native library and generates a C# API for it. You write Kotlin, the plugin ships a .nupkg a C# consumer can reference directly, no code generation step on their side.
Pipeline
At build time:
KSP discovers public declarations. The KSP processor (
nuget-processor/) walks every public class, function, and property in the compiled Kotlin/Native source set.Ordinary sync callables are planned once. Each ordinary synchronous function, method, constructor, property, companion, object method, extension, and value-class member is classified into a
BridgeTypeand validated as aForwardCallablePlanorForwardPropertyPlan(ADR-062). Specialized protocols (suspend,Flow, lambda/callback, sealed helpers, generic declaration families) stay on named legacy routes; a sealed helper is the one exception at a property position, where the property planner now plans it directly instead (ADR-105): a sealed return still rides the legacy route unchanged. See Interfaces, abstract classes, and sealed classes: Limitations for exactly which sealed positions still skip.Dual projection from the plan. The same plan projects to CIR for C# (ADR-004) and to KotlinPoet
@CNameexports. A generation-time ABI contract check (ADR-055) compares both halves to the plan. The same check also covers the specialized legacy routes (ADR-078): since theirDllImportdeclarations are raw renderer text rather than plan output, they are collected straight from the renderedInterop.csand normalized the same way before being compared against their Kotlin@CNameexports.CirRendereremitsInterop.cs. The C# source is generated once, at Kotlin build time, and shipped inside the package. There is no consumer-side codegen step, unlike theClangSharpPInvokeGenerator-based approach from earlier phases (see ADR-001).KotlinPoet emits
Bridges.kt. Kotlin-side@CNameexport wrappers are generated so every bridged declaration has a stable C ABI entry point.Kotlin/Native compiles and links shared libraries for each target platform.
packNugetpackages the generated C#, the native binaries, and metadata into a.nupkg.
Memory model
Kotlin/Native's GC and the .NET GC know nothing about each other. Every object that crosses the bridge needs an explicit ownership story:
Primitives are copied by value. No ownership concern.
Strings cross as UTF-8
const char*, copied immediately into a managedstringviaMarshal.PtrToStringUTF8. The pointer is never cached.Objects are pinned Kotlin-side with
StableRef.create(...), which returns an opaqueCOpaquePointer. The generated C# wrapper stores that pointer as_handleand implementsIDisposable; disposing releases theStableRef. See ADR-003.
Every time an object-typed property or return value crosses the bridge, the generated code creates a new wrapper around a new StableRef rather than caching or reusing an existing one. This mirrors how Kotlin/Native's ObjC and Swift exports behave. Identity is not preserved (cat.Brother != cat.Brother even when both point at the same Kotlin object), and disposing one wrapper never cascades to another. See Classes and objects and ADR-005 for the concrete generated shape.
What ships in the .nupkg
Running packNuget for test-library produces this layout:
contentFiles/cs/any/Interop.cs: the generated C# source, compiled directly into the consumer's own project (not a separate assembly). This is why the generated code has no external dependency beyond the .NET BCL: it becomes part of the consumer's compilation unit.runtimes/{rid}/native/: the compiled Kotlin/Native shared libraries, one per supported target (osx-arm64,win-x64, ...). The .NET runtime resolves the correct native asset for the host RID automatically via[DllImport].
No consumer-side build step, SDK, or tool is required beyond referencing the package.
Package layout and namespace mapping
The Gradle DSL configures the root of the generated namespace tree:
Kotlin sub-packages map relative to rootPackage, and the C# namespace root is the package's packageId. In test-library, rootPackage = "io.github.xxfast.kotlin.native.nuget.test", so:
Kotlin package | C# namespace |
|---|---|
|
|
|
|
|
|
|
|
Every generated declaration lands under its mapped namespace inside the single Interop.cs file.
The mapping has three cases (ADR-066 §5's 2026-09-13 amendment):
Kotlin package | C# namespace |
|---|---|
|
|
under |
|
outside |
|
With rootPackage unset, every package collapses to <packageId> regardless, since there is no prefix to strip or compare against. The full-package case reaches an admitted dependency-module type the same way an in-root type does: Billboards, declared under rootPackage, returns a Billboard from dev.other.admitted, a package outside rootPackage that test-library/build.gradle.kts admits with include("io.github.xxfast.kotlin.native.nuget.test", "dev.other.admitted"):
Billboard itself is declared at namespace TestLibrary.Dev.Other.Admitted, the full Kotlin package PascalCased under the assembly's root namespace, not the bare Dev.Other.Admitted a package outside a module's own files might otherwise suggest: one NuGet package is one assembly, so every namespace under its packageId stays collision-free against any other assembly a consumer references. See the ADR for the two rejected alternatives.
With no include(...) set, the default scope is rootPackage itself, when one is configured, or every public declaration in the module when it isn't. publish { include(...); exclude(...) } narrows that to an explicit package-prefix allowlist. publish { exportMarkers(...) } is a separate, orthogonal escape list: it names @RequiresOptIn markers whose declarations keep exporting instead of being dropped, see Opt-in-marked declarations skip named.
The export set is not limited to the module's own files, either: it is a reachability closure that also walks into types declared in a dependency Gradle module (return types, parameter types, property types, type arguments of Flow<T> /collections, sealed subclasses, primary-constructor parameters), admitting each discovered type through the same include/exclude/rootPackage predicate. See The nuget {} DSL for the full predicate and the cross-module closure rules.
Diagnostics
Not every Kotlin construct can be expressed as C#. When the generator meets one it cannot bridge, it names the member and the reason, at the author's own Kotlin source, rather than emitting invalid Kotlin or a C# API whose signature lies about its contract. A member the compiler itself wrote, not the author, is filtered out before any of this reporting runs and never appears as a diagnostic: that covers equals/hashCode/toString, a data class's copy/componentN, a hidden-deprecated member, and a compiler plugin's synthesized surface such as kotlinx.serialization's Companion.serializer() (#235). Every diagnostic carries a ForwardDiagnosticKind whose name encodes its severity:
SKIPPED_*: the member is warned about and omitted entirely from the generated C# API. Generation continues. This is the default for a construct the forward direction cannot express (an unsupported type, aMap/Setparameter, an unsupported generic/suspend combination, a value-class member a supertype declares, whether inherited, delegated or overridden). ASequence<T>parameter or return is one of these:kotlin.sequences.Sequenceis a namedUnsupportedstdlib type, so a callable using it at either position skips asSKIPPED_UNSUPPORTED_TYPEnaming the callable, instead of vanishing from the generated API with no diagnostic at all. Any otherkotlin.*/kotlinx.*stdlib type with no first-class C# mapping (see Primitives and strings and Collections for what is mapped) skips the same way, naming the type itself; a stdlib type wants a first-class mapping, not an export-scope change, so it gets noinclude(...)suggestion, unlikeSKIPPED_UNEXPORTED_DEPENDENCY_TYPEbelow.INFO_*: the member still binds, under a documented assumption (for example,out/invariance on a class type parameter is dropped, but the member still generates).ERROR_*: generation fails andCNameExports.kt(the Kotlin@CNameexport file) is never written, sopackNugetnever runs. Cases include two constructors, or two methods on one class, that render an identical C# signature (ADR-034), namedERROR_CSHARP_SIGNATURE_COLLISION. This also catches two constructors that differ only in reference-type nullability (constructor(from: Patient)next toconstructor(from: Patient?)): C# does not treat a nullable reference annotation as part of a method's signature, so both would otherwise render, correctly but uncompilably, asReferral(Patient from)andReferral(Patient? from)(CS0111). Nullable value types are unaffected and keep working:constructor(n: Int)next toconstructor(n: Int?)render genuinely distinct signatures and are not treated as a collision. A second case is a top-levelvalandfunthat PascalCase to the same C# name (ERROR_CSHARP_NAME_COLLISION, CS0102); see Top-level declarations for that one. This page covers the third:ERROR_C_ENTRY_POINT_COLLISION, two different Kotlin declarations deriving the same underlying C entry point; see Two declarations can't share one C entry point below.
A List/Map/Set parameter with an unsupported element/key/value type (see Collections) is skipped like this, naming the component that failed rather than the collection kind:
A property whose declared type the property planner cannot bridge is skipped the same way, naming the property's own type, the same sentence a callable route prints for the same refusal. Cat.unsupported: Sequence<String> is the fixture:
A collection property is skipped the same way when one of its components (the element, or a map key or value) has no C# spelling. A sealed class component no longer falls into this bucket: since ADR-105 it binds, materialised through the ADR-009 FromHandle discriminator (see Sealed types as property types). A sealed interface component still has no C# spelling to bind against, because only a sealed class gets a FromHandle discriminator, so it still skips. Since ADR-064's 2026-09-11 amendment the message comes from the element's own SEALED_POSITION reason, naming the sealed interface itself rather than the outer collection shape:
SKIPPED_UNSUPPORTED_PROPERTY never fires for a property whose type is a lambda, suspend lambda, Flow, or StateFlow (nullable or not): those are unplannable by design and still bind through a named legacy route, so warning would tell a consumer a working property had vanished.
Unrouted positions for a lambda, Flow, or a generic declaration
The three legacy routes above (Flow/StateFlow, a lambda, a generic declaration) each bind at a handful of specific (owner, position) pairs, a class-method return or parameter, a top-level function return, and nowhere else: an object, an interface default, an extension, a secondary constructor, a collection element, or the same reason at the other position on a class method, used to vanish from both the Kotlin and C# output with no diagnostic at all. Since ADR-064's 2026-09-13 amendment every one of those positions is a named skip instead:
A callable's own type parameter (a class or object method declared fun <T> f(value: T): T, as opposed to a top-level generic function) is one of these positions too, and skips SKIPPED_UNSUPPORTED_COMBINATION naming the structural mismatch rather than the position:
A top-level fun f(): Flow<T> used to be worse than silent: it passed the generic-return route's own gate on both halves, so the Kotlin side exported a handle and the C# side rendered a return type declared nowhere in the generated file (CS0246 in the consumer). The route now refuses it ahead of that gate, so it is a named skip and no member, the same as every other position above, rather than a build that only fails downstream in the consumer's own project.
Two positions are exempt on purpose and stay silent, because a legacy route genuinely re-emits them elsewhere: an interface default with a Flow/StateFlow return or a lambda parameter still re-emits on every class that implements the interface (just not on the generated C# interface itself, see ROADMAP.md), and a top-level generic return in the same Kotlin package as the generic type still binds (a cross-package one does not, and is not yet named either, see ROADMAP.md).
The same kind also fires when an extension property's receiver type, not its declared type, is what the planner can't wire. String, a primitive, ObjectHandle classes, an eligible sealed base (see Extensions: Sealed receivers), and a value class over any of the four underlyings admitted at ordinary positions (String, a primitive, an enum, or ObjectHandle) are the supported receivers; anything else warns and the property is dropped entirely, naming the receiver rather than the property's own (usually fine) type:
The message names the declaration, the reason, an actionable hint, and, when KSP can resolve it, the file and line of the Kotlin declaration that was skipped, something the reverse direction's RirDiagnostic cannot carry, since it works from compiled metadata rather than source. See each forward page's own Limitations section for which named diagnostic fires where.
A nested class, object, interface, enum class, or value class under a non-generic, non-inner class or object owner, an interface owner, or a sealed base/arm owner is declared as a real C# nested type, Outer.Nested, at any depth (ADR-133, ADR-134); see Classes and objects: Nested types. A nested declaration under a still-deferred owner shape (an inner class, a generic, or an enum class owner) still skips named, SKIPPED_NESTED_DECLARATION at the declaration, and a member typed with it skips SKIPPED_UNSUPPORTED_TYPE naming UNDECLARED_CLASS/UNDECLARED_ENUM/UNDECLARED_INTERFACE instead of being spelled as a dangling reference; see Enums: Nested enums and Interfaces, abstract and sealed classes: Nested interfaces.
A member positioned with a Kotlin object type, nested or top-level, skips named too, with the same SKIPPED_UNSUPPORTED_TYPE kind naming a new OBJECT_POSITION reason: an object renders as a C# static class, and C# forbids a static type at a parameter or return position (CS0722). This is not a nesting limitation; it applies to a top-level object the same way. See Classes and objects: An object at a member position stays CS0722.
Three more kinds cover the cross-module export closure (ADR-066; see The nuget {} DSL for the closure's own rules). A reachable dependency-module type outside the effective include/rootPackage scope is skipped, naming the exact fix, from test-library's Newsroom.sponsor(): Advertisement (dev.other.core.Advertisement sits outside rootPackage):
The hint names the whole include(...) line, the current scope first, because an explicit include replaces the rootPackage default rather than adding to it (#55).
include(...) is only ever the right fix for a type the closure simply never included. Three other reasons the closure can refuse a dependency type all fold into the same SKIPPED_UNEXPORTED_DEPENDENCY_TYPE kind but each get their own hint, since include(...) would be wrong advice for any of them. Following ADR-109's own exclude("<pkg>") remedy for a duplicated type lands here:
With neither rootPackage nor include set, so the closure never crosses the module boundary at all, the hint names the setting that turns cross-module admission on instead:
And a dependency's own expect declaration says its actualization lives in that module and cannot be reached with include(...) at all, naming the type instead of suggesting a scope change that cannot fix it. See The nuget {} DSL for the full set of refusal reasons.
When the scope admits none of the module's public declarations, the processor warns once with SKIPPED_ALL_DECLARATIONS, naming the scope and the packages it dropped, instead of returning silently with no Interop.cs in the package:
When at least one dependency-module type is admitted, the closure also emits one aggregate INFO_EXPORTED_FROM_DEPENDENCY line per KSP run rather than one line per type, naming the whole admitted set.
The closure follows two more edges through a nested type, closing a gap ADR-133 left when it shipped nested-type declaration with no closure change of its own. A member returning or taking a nested type climbs to its owner first, so a dependency member naming only Almanac.Page admits Almanac even when nothing anywhere returns Almanac itself; and once a nested type is declared under an admitted owner, the closure also walks its own member types, so Broadcast.Schedule.timetable(): Timetable admits the top-level dependency type Timetable on the strength of a member declared two levels down. Neither edge gives the nested type its own admission record: the owner is what the manifest and the generated C# name, exactly as Classes and objects: Nested types describes.
From Interop.cs, Almanac is declared at namespace level with Page nested inside it even though no member anywhere returns Almanac, and Timetable is declared at namespace level even though the only member naming it is two levels down, on Broadcast.Schedule:
Duplicate-type hazard across two published packages
Two Gradle modules can each publish forward and each independently admit the same dependency-module type through their own reachability closure. Neither KSP run can see the other's export scope, so each package would silently declare its own unrelated C# copy of the same Kotlin type (ADR-109). The plugin closes that visibility gap: every forward publisher's include/exclude/rootPackage predicate is lowered into a nuget.publishedScopes KSP option (see The nuget {} DSL), and the processor matches every admitted dependency type against every other publisher's scope, by package, since a cross-module declaration carries no module identity. A match warns with WARNING_DUPLICATED_DEPENDENCY_TYPE. Nothing is skipped and the generated output does not change, so the message says "Duplicating", not the severity-keyed "Skipping" every other warning gets:
The two workable remedies are the two the hint names: publish a single umbrella module that depends on both instead of two separate publishers, or exclude("<pkg>") from one of them so only the other declares it (the excluded module's own callables reaching that type then skip named with SKIPPED_UNEXPORTED_DEPENDENCY_TYPE). A shared "models" NuGet package both publishers depend on is not a remedy: two published packages are two separate Kotlin/Native runtimes in the consumer's process, so a handle minted in one is meaningless to the other's exports.
A related but distinct case: the reachability closure never walks a class's supertypes at all, so an exported class implementing an interface declared outside the export set, the Koin KoinComponent shape, used to render a base-list entry (" : IKoinComponent ") that nothing ever generates. Issue42Api : Issue42Component is the fixture: Issue42Component lives in a separate Gradle module the export set never admits.
Unlike SKIPPED_UNEXPORTED_DEPENDENCY_TYPE, the hint does not point at include(...): the closure has no edge for supertypes, so admitting the dependency package changes nothing. The interface is dropped from the generated base list; the class's own members, and any defaulted member the interface declares, still export:
An unexported base class dangles the same way, and is covered too: class Issue42Derived : UnexportedBase(), where UnexportedBase lives outside the export set, used to render public class Issue42Derived : UnexportedBase and fail CS0246. A base class carries real callable members, unlike a dropped interface, so the fix is a little different: the base is dropped from the C# base list entirely (Issue42Derived gets no base at all, just IDisposable, INugetHandle), and UnexportedBase's own public members are bound directly on Issue42Derived, with no override.
Farewell overrides UnexportedBase.farewell(name, warmly: Boolean = false): Kotlin forbids the override from restating the default, so the = false lives only on the dropped base. With no C# base to inherit the omitting overload from, Issue42Derived synthesizes it itself, reading the default flag off the root of the override chain, the same ADR-096 rule described under Method default parameters. An override of an exported C# base still synthesizes nothing: the base carries the overload, and a generated subclass reaches it through ordinary C# inheritance.
The hint picks the clause that is true for the base at hand instead of hedging: a same-module base gets told to add include("...") alongside the existing rootPackage/include(...) scope, since that genuinely admits it, while a dependency base gets told include(...) alone will not, since the ADR-066 reachability closure never walks supertypes.
The base walk also follows a chain of unexported links to the nearest exported base, rather than stopping after one hop: class Dinghy : Skiff("Dinghy") with Skiff : Vessel(name), only Skiff outside the export set, renders public class Dinghy : Vessel, not base-less. Skiff's own public members (oars, row()) re-home onto Dinghy the same way UnexportedBase's did above; Vessel's members are inherited normally, with no re-homing. The diagnostic fires once per dropped link and its middle clause names the kept base instead of saying there is no base at all:
The quoted Issue42Derived block above is unchanged: a single dropped base with no exported base above it still reads "generated with no base at all", byte for byte. The clause only changes when a grand-base survives the walk.
Annotation classes skip named
A public annotation class has no route in the forward direction at all: there is no C# projection of a Kotlin annotation worth generating, so it always vanishes from the generated C#. Until ADR-064's 2026-09-07 amendment that vanishing was silent; now it skips named with SKIPPED_ANNOTATION_CLASS, once per public top-level annotation class:
Applying the annotation to an exported declaration costs that declaration nothing: the forward pipeline reads no annotation but kotlin.native.CName, so @Tagged("plaything") data class Toy(...) still generates its constructor, properties, Copy, Equals, HashCode and ToString exactly as if the annotation weren't there. An internal/private annotation class stays silent, matching every other bucket's visibility gate. An expect annotation class fires the same kind once, on the actual: the isExpect filter drops the expect half one line earlier, so only the actual reaches the bucket, with symbol pointing at the actual's own file.
Opt-in-marked declarations skip named
A declaration carrying its own @RequiresOptIn-meta-annotated marker is not part of the forward-exported surface, at any RequiresOptIn.Level (ADR-115). C# has no way to honour a Kotlin opt-in requirement: a Kotlin consumer of a marked declaration must acknowledge it with @OptIn or a compiler flag, while a C# consumer of the generated binding would see a plain public member with no signal at all. Exporting a marked declaration always erases the marker's purpose, so it is dropped instead and named with SKIPPED_OPT_IN_MARKER, once per dropped declaration, regardless of whether the marker is ERROR - or WARNING-level.
A marked class, object, interface, enum, or value class is never declared at all, refused at the same place a package-scope refusal is, so nothing in the generated Interop.cs names it. A marked member of an exported class, or a marked top-level function or property, skips per-callable instead, so the owning type still generates with everything else intact. A member whose type is a marked class carries its own reason, blaming the type rather than the member, since no include(...) change can ever bring a marked type into scope.
From test-library/.../issue113/Issue113Sample.kt:
HouseRules never appears in the generated C# at all. Litter.Extra, Litter.Other, and Litter.ViaSetter (the whole property, not just its setter) are all absent, while Litter.Name generates normally:
@set:Marker is the only accessor position that compiles at all (@get: and @field: are frontend errors, Opt-in requirement marker annotation cannot be used on getter/... on field, so there is nothing to bridge there in the first place); it is invisible on the property declaration itself and readable only off the setter, so it drops the whole property rather than exporting it get-only.
A member whose type is marked blames the type instead, since no include(...) change can ever bring a marked type into scope. Shelter.rules() returns HouseRules, and the marker sits on HouseRules itself, not on rules():
A marked primary-constructor val follows one invariant: the marked declaration never appears in a C# signature. A trailing marked parameter with a default keeps the shorter constructor overload that already omits it (ADR-091's own omitting-overload machinery); an undefaulted or non-trailing one has no such overload, so the constructor itself is dropped, copy along with it, and the class stays reachable only through a Kotlin factory, with WARNING_NO_PUBLIC_CONSTRUCTOR naming OPT_IN_MARKER among the reasons. See Classes and objects: No public constructor.
A marker declared one Gradle module away resolves the same way: Cattery.crossModuleName, marked with CatteryInternalApi from :test-models, is absent from the generated C# exactly like a module-local marker would be. @OptIn(InternalApi::class) on a declaration is a marker consumer, not a marker member, and stays exported; @SubclassOptInRequired is not itself @RequiresOptIn-meta-annotated, so it does not match and stays exported either.
An escape list for markers meant to stay public
Not every @RequiresOptIn marker means "internal, keep this out of C#". publish { exportMarkers("com.example.ExperimentalFooApi") } names a marker whose declarations keep exporting through the ordinary route instead, with no SKIPPED_OPT_IN_MARKER diagnostic, as if the marker weren't there at all. Every marker not named keeps being dropped exactly as described above; this is an escape list, not a change to the default.
From test-library/.../issue113/ExportMarkersSample.kt:
test-library/build.gradle.kts waives exactly one marker:
DietName() generates normally; LedgerName, behind the unlisted LedgerApi marker, does not:
An ERROR-level marker's waived declarations only compile inside the generated file because every configured marker is also appended to its own @OptIn list:
Entries are trusted, not validated against the resolver: a misspelt marker name waives nothing, and the SKIPPED_OPT_IN_MARKER diagnostic printed for the declaration still names the marker's real FQN. A waived marker also waives every type marked with it, since both reads go through the same optInMarkerName() choke point; there is no separate list for that case. The marker annotation class itself is never exported, only the declarations that use it.
An opt-in-marked parameter type takes every arity with it
A trailing defaulted parameter whose own type is opt-in-marked is a stronger case than a marked property or default target: it makes every arity of the constructor or function illegal, not only the declared one, so the trailing-omitting overload above cannot repair it either. Kotlin propagates the opt-in requirement from a callee's declared parameter types, at every arity, and never from what a default expression happens to read, so a data class whose sole parameter is a marked-typed default reduces to zero callable arities. Every arity is skipped OPT_IN_MARKER_TYPE (still rendered as SKIPPED_OPT_IN_MARKER), and the class ends up factory-only; see Classes and objects: No public constructor.
From test-library/.../issue128/Issue128Sample.kt, where Grooming is an opt-in-marked enum declared one module away in :test-models:
GroomingPlan still generates, keeps Name, and carries only its internal handle constructor. The class line also carries the same detail as an XML-escaped <remarks> doc comment, so a consumer sees it as an IDE tooltip (ADR-064 2026-09-10 amendment):
The same check applies to a function's trailing defaulted parameters, not only constructors: a top-level fun schedule(name: String = "Oreo", grooming: Grooming = Grooming.DAILY) synthesizes no omitting overload either, and schedule is absent from the generated C# at every arity.
A class with no reachable constructor stays, and says so
A class whose every public constructor is skipped, for any reason, still generates a C# type, exportedTypes admits a class by declaration, not by constructor outcome, and a Kotlin factory returning the class hands C# a usable instance regardless. What used to be silent is the class carrying only its internal Foo(IntPtr handle) constructor, with nothing in the build log explaining why. Since ADR-064's 2026-09-07 amendment, this warns once per class with WARNING_NO_PUBLIC_CONSTRUCTOR, its verb "Keeping" rather than the usual "Skipping" since the class itself is not skipped:
The warning still only reaches the library author's Gradle log and NugetDiagnostics.json; on its own, the 2026-09-07 amendment left a consumer opening Issue56Failure with no explanation anywhere in the assembly or IntelliSense. ADR-064's 2026-09-10 amendment closes that: the same skipped- constructor detail the warning names is also emitted as an XML-escaped /// <remarks> doc comment directly above the class line, using consumer-facing wording rather than the diagnostic's author-facing hint, so the constraint is visible as an IDE tooltip too. See Classes and objects: No public constructor for the rendered block.
Fires for every skip reason a constructor can go for, including a legacy-route deferral that never reaches droppedCallables on its own (no legacy route re-emits a constructor, so that family was silent in every channel before this amendment). A sealed type at a parameter position used to be one such reason, see A sealed type at a parameter position now binds below. Also fires for a sealed subclass arm of kind class whose every constructor is refused, the same reasons and the same <remarks> twin as a non-subclass type (ADR-148), see Interfaces, abstract classes, and sealed classes: Sealed classes and interfaces. Not fired for an abstract class or the interface-return backing wrapper, neither of which is handle-less by accident. See Classes and objects: No public constructor for the full Issue56Failure shape.
A sealed type at a parameter position now binds
A sealed type at a bare, nullable, or collection-component parameter position (including a constructor parameter) used to be dropped completely silently: the ForwardPlanSkipReason that covered it, SEALED_PROTOCOL, was a droppedFromCSharp = false legacy-route deferral on the assumption some other route re-emitted it, but no route ever did for a parameter. ADR-064's 2026-09-07 amendment first renamed the reason SEALED_POSITION, flipped droppedFromCSharp to true, and added SKIPPED_SEALED_POSITION so the skip was at least named; then ADR-105's 2026-09-07 amendment bridged the position itself, sharing the same sealedAsHandle() rewrite the property planner and the sealed-return route use. SKIPPED_SEALED_POSITION still fires, but only for a sealed type with no generated FromHandle discriminator at all: a sealed interface, or a sealed class outside the export scope. See Interfaces, abstract classes, and sealed classes: A sealed type at a parameter position.
Two declarations can't share one C entry point
The native ABI is one flat namespace of @CName-exported C functions, and the export symbol is derived from the Kotlin declaration's own (unqualified) name. Two declarations that resolve to the same symbol used to abort packNuget with a raw IllegalArgumentException naming only the mangled symbol (radio_play_collect), not which Kotlin declarations were fighting over it. ADR-117 (issue #106) replaced that with a named ERROR_C_ENTRY_POINT_COLLISION, naming every owning declaration. Two same-simple-name classes in different packages is the plainest trigger:
The shape below is reconstructed from ForwardDiagnostic.format() ([nuget:<kind>] <verb> <location>: <reason>. <hint><at>) applied to the actual ForwardAbiCollision.reason/.hint text Tier1EntryPointCollisionTest asserts against, with the temp-file paths elided:
(The constructor's C# import carries the ADR-031 error out-parameter alongside the name argument, the same out IntPtr error shape Constructor default parameters shows for carrier_create, which is why the bracketed signature pair reads (in string, out pointer) rather than just (in string).) The trailing at line echoes the first owner's own location again, the same location logger.error attaches the diagnostic to; it is not a third declaration.
Every @CName export, on every route, names its exact owning declaration; there is no route left that can only point at a class-level range. An ordinary declaration (a constructor, a top-level function, a class method or property, a suspend method) names itself, with parameter types and file:line, as above; this includes a generic class's own declared methods and constructor (ADR-147), which collide the same way an ordinary class's do. A generated member, one Kotlin itself never declares, such as the IDisposable.Dispose() every class gets for free, a sealed discriminator, or a data-class equals/hashCode/toString, names the class (or sealed arm) that owns it plus a role in parentheses, since several such members can share one class or arm. fun dispose() on an exported class is this shape: it collides with the always-generated Dispose, and the message names the method plus the generated member's role (ADR-117's 2026-09-13 amendment):
The hint is always the same: rename one of the colliding declarations. The prefix scheme itself (unqualified simple name, no package, no namespace) is unchanged; naming the collision is the interim remedy, not a fix for it. See the open backlog item for the structural fix (qualifying the export prefix by package) that would close the collision itself rather than only naming it.
A nullable parameter names itself, instead of the return
A callable dropped because one of its parameters is a nullable type with no wire used to render as SKIPPED_UNSUPPORTED_RETURN, the same kind a nullable return with nowhere to put the absence gets, and the hint said "at this position" without saying which one. An author reading that message went looking at the return type, which was perfectly exportable, before finding the actual problem was a parameter. ADR-064's 2026-09-09 amendment (issue #131) gives the skip its own position: an input-position NULLABLE now renders SKIPPED_UNSUPPORTED_INPUT and names the parameter, a return-position one keeps SKIPPED_UNSUPPORTED_RETURN and its unnamed hint (there is no BridgeType-to-Kotlin-spelling renderer to name a return's type):
That skip is the arity that still carries events. The shorter overloads that omit it still bind: HubWithEvents() and HubWithEvents(Settings). See Function default parameters.
An extension receiver counts as an input position too, unnamed, since it has no author-written parameter name. A nullable exported class handle at a parameter position was never actually unsupported, on any route (null rides IntPtr.Zero); see Classes and objects: A nullable class handle parameter for the shape that does bind.
Where these messages appear
A diagnostic computed at generation time is only useful if it reaches the console. The processor writes every accumulated diagnostic to NugetDiagnostics.json, a declared KSP task output (ADR-100). Being a declared output, not just something printed during the task action, is what makes it survive an incremental build: it is restored on a cache hit and present after an UP-TO-DATE run, the two outcomes a normal, unchanged packNuget produces on every run after the first. A NugetReportDiagnosticsTask ahead of packNuget reads that file and re-emits every message through Gradle's own warning logger, so a skip is visible on every packNuget, cached or not, not only the build where KSP happened to run. This is a real packNuget --console=plain run against this repository's own fixture, KSP task UP-TO-DATE:
Limitations
AOT and trimming
The generated bindings are AOT-safe.
The generics bridge's reflection, the first blocker (ADR-038, Deferred), is fixed: ADR-094 (Accepted) replaced the type-erased generics bridge's GetField/Activator.CreateInstance reflection with a static factory registry. A build against a pre-ADR-094 version of these bindings (plugin 0.2.0) could still trigger the .NET trimmer's IL2075 on that GetField, mitigated with <TrimmerRootAssembly> on the project compiling Interop.cs; on the current generator this should no longer fire for that reason.
The second blocker, the forward callback surface, is fixed too (ADR-102, Accepted). Every place C# calls back into Kotlin, a per-call lambda parameter, a stored callback, a C#-implemented interface bridge, Flow/StateFlow collection, and suspend continuation resumption, used to hand Kotlin a pointer obtained from Marshal.GetFunctionPointerForDelegate, which needs the runtime to JIT a native-to-managed thunk the first time it is invoked from native code. A fully AOT-compiled runtime has none, which is what threw ExecutionEngineException on a Mono full-AOT Mac Catalyst build the first time a generated Flow was collected. The generated C# now dispatches every one of those shapes through a static [UnmanagedCallersOnly] thunk (one per delegate shape, NugetThunks), keyed off a GCHandle ctx pointer every callback ABI already threaded through unused. Kotlin needs no change: