Objects and handles
A bound C# class (not static) becomes a Kotlin class that wraps an opaque handle to the real C# object. Every crossing of the bridge allocates a fresh handle and a fresh Kotlin wrapper; there is no identity caching, and every bridgeable public instance constructor maps to a Kotlin secondary constructor. These decisions are ADR-051 and ADR-052.
The handle mechanism
The C# side allocates a GCHandle.Normal for the object and hands its IntPtr value across the ABI as a blittable pointer. The Kotlin wrapper stores that pointer, unopened, in an internal holder and never interprets it; every method call passes it straight back through a thunk. This is the mirror image of the forward direction's StableRef-backed opaque pointers, with the roles of the two runtimes swapped: GCHandle.Alloc ↔ StableRef.create, GCHandle.ToIntPtr ↔ .asCPointer(), GCHandle.FromIntPtr(ptr).Target ↔ ptr.asStableRef<T>().get(), handle.Free() ↔ stableRef.dispose().
Freeing a handle from Kotlin needs a registered thunk, because Kotlin has no way to call directly into managed code (see Consuming C# in Kotlin). A single shared export, nuget_runtime_register, is emitted once and reused by every bound class:
Lifetime: Cleaner-primary, close() as the escape hatch
The generated wrapper implements kotlin.AutoCloseable and releases the handle automatically once the wrapper becomes unreachable, via kotlin.native.ref.Cleaner. close() releases it deterministically and is idempotent; calling it twice, or having the cleaner run after an explicit close(), is a safe no-op guarded by an atomic flag. Calling any member on an already-closed wrapper throws IllegalStateException.
use { } therefore works exactly as it would on any other AutoCloseable:
close() is optional. Forgetting it just means the C# object is released whenever Kotlin's GC happens to collect the wrapper, the way a Java or Objective-C object crossing into Kotlin behaves today, not a leak.
Constructors
Every bridgeable public instance constructor per bound class maps to a Kotlin secondary constructor. Kotlin's constructor(...) : this(...) delegation can only be an expression, not a statement block, so the generated constructor delegates through a file-private helper that runs the bridge call and requireNotNulls the returned handle (a C# constructor never legitimately returns null, unlike a factory method):
Because a constructor's return is unconditionally the class's own type (never null), it is a distinct RIR node (RirConstructor) rather than a RirMethod with a mandatory return type.
Multiple constructors keep their parameter types. The generated OverloadLab wrapper contains:
The hexadecimal suffixes are internal bridge identities derived from canonical managed signatures. They do not appear in the public constructor calls.
Object parameters and returns: nullability follows NullableAttribute
An object return, parameter, or property's nullability is read straight from the bound C# assembly's NullableAttribute/NullableContextAttribute metadata (ADR-053), not hardcoded per position:
Foo?(annotated nullable):IntPtr.Zerofrom C# maps to Kotlinnull, no wrapper allocated.Foo(annotated non-null, or un-annotated/oblivious): the generated stubrequireNotNulls the pointer and throwsIllegalStateExceptionnaming the member if a null slips through anyway. An oblivious (pre-C#-8, or#nullable disable) assembly binds this way too, with oneinfo_oblivious_nullabilitybuild warning (see The bridgeable subset).
TestDependency's Test.Nullability.NicknameBook exercises every combination:
The C# side behind describe guards the null sentinel explicitly, because GCHandle.FromIntPtr on a zero pointer throws:
Before ADR-053, this was a blanket policy instead: every object return was unconditionally Foo? and every object parameter unconditionally non-null Foo, regardless of what the C# API actually annotated. Template.parse(...) and template.clone() are annotated non-null in the fixture, so they dropped their ? (fun clone(): Template, not Template?; see Instance members for the real generated output). Any hand-written call site still wrapping such a call in requireNotNull(...) is now redundant, though harmless, since the return is already non-null.
template.handle.require("Template") is how a wrapper's raw pointer gets unwrapped for a call: it also guards against use-after-close, throwing IllegalStateException if the handle was already freed.
New wrapper per crossing: no identity, no caching
Every time a C# object crosses into Kotlin, a new GCHandle and a new Kotlin wrapper are created, even if the same underlying C# object crossed a moment ago. Two wrappers around the same C# object are entirely independent: each holds its own handle, each is freed independently, and neither one's close() can invalidate the other. equals, hashCode, and toString are left as Kotlin's defaults (reference identity on the wrapper, not the underlying C# object), so:
This is a deliberate mirror of the forward direction's own choice (ADR-005): simple, stateless, no cross-runtime cache invalidation to get wrong.
Contrast with the forward direction
The forward direction (Kotlin objects surfacing in C#) chose IDisposable-first, because C# consumers expect deterministic disposal as the default and treat automatic cleanup as a safety net. The reverse direction inverts that choice deliberately: Kotlin consumers of a foreign object expect the experience they already have with Java or Objective-C interop, where objects clean themselves up and disposal is an opt-in for cases that need it. ADR-051 walks through why this inversion is correct for each direction rather than a stylistic accident.
Limitations
No identity caching: two wrappers of the same C# object are unequal and independently closeable. Tracked as a possible future optimization only if profiling shows it's needed.
equals/hashCode/toStringare never delegated to the C# object's ownEquals/GetHashCode/ToString.Nullable<T>value types (int?,CatMood?) are a separate, deferred feature: they carry noNullableAttribute(see ADR-053 Decision 3). The wire-format decision that feature was waiting on is now settled: it reuses the out-pointer convention from ADR-056 (see C# structs) and needs no new ADR, only the reader/generator work.A throwing C# constructor or factory still fast-fails the host process; see The bridgeable subset for the exception policy.