understanding-stability-inference — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited understanding-stability-inference (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Stability is decided by a 12-phase algorithm baked into the Compose compiler. This skill teaches Claude how the algorithm walks a type so it can explain why a report says what it says, and predict classifications before the report is even generated. Pair this with ../diagnosing-compose-stability/SKILL.md (which generates the report) and ../stabilizing-compose-types/SKILL.md (which fixes obvious unstable types). Reach for this skill when the simpler skills produce a verdict that surprises the developer.
Foo classified as runtime stable and not stable?"runtime or unknown for a class that "looks fine".Box<T>, Wrapper<A, B>, Pair<String, Int>, ImmutableList<User>..class/.kotlin_metadata artifact.$stable: Int field, @StabilityInferred, or cross-module classification.class Node(val children: List<Node>)) is unstable for non-obvious reasons.var → val, List → ImmutableList, Flow parameter removal). Use ../stabilizing-compose-types/SKILL.md.../diagnosing-compose-stability/SKILL.md first.../enforcing-stability-in-ci/SKILL.md.<module>-classes.txt and <module>-composables.txt available in build/compose_compiler/.stable, unstable, skippable, restartable, @Stable, @Immutable.Walk the type through the same 12 phases the compiler does. For each call site, ask the questions in order; the first matching phase wins.
The canonical phase order (used everywhere in this skill and matching references/twelve-phase-algorithm.md):
The compiler returns immediately. No field analysis runs. Mention the fast path so the developer knows nothing else was inspected.
A bare type variable T becomes Stability.Parameter(T); resolution is deferred to the call site that substitutes it.
Nullability does not change stability; the algorithm strips the ? and recurses.
value class Wrapper(val raw: T) is exactly as stable as T.
The algorithm bails on cycles to guarantee termination. The escape hatch is @Stable/@Immutable on the recursive class, which fires in phase 6 before phase 5 is reached.
Yes → Stability.Certain (stable). @Immutable enables additional optimizations beyond @Stable because the compiler can promote reads of properties to static expressions and elide equality checks; @Stable only promises change notification.
Pair / Triple / Result / ImmutableList / dagger.Lazy / ClosedRange / etc.).Returns Stability.Parameter with the registry's bitmask. See references/bitmask-encoding.md for the full registry.
stability_config.conf).Returns Stability.Parameter with the bitmask declared in the config file.
Returns Stability.Runtime. The compiler emits a $stable: Int field on the JVM (a mangled top-level property on Native/JS) that the runtime queries via Composer.changed. Tell the developer this is not a bug — runtime is the compiler saying "I cannot prove this at compile time, so I will check at runtime".
Java final fields look like var to the inference because the algorithm has no Kotlin metadata to read. Fix via stabilityConfigurationFiles, not by editing the Java source.
The compiler cannot enumerate implementations from a single call site; the runtime falls back to identity (===) for the equality probe.
var property → Unstable (mutation observed without Snapshot integration).Unstable → Unstable (Combined dominates).Stable (Combined of all-stable fields collapses to Stable).For full pseudocode of all 12 phases plus the field-by-field loop, see references/twelve-phase-algorithm.md.
Box<String> show as runtime stable?"// Source — the developer's class, in a library module
class Box<T>(val value: T)
// Call site, in app module
@Composable fun BoxRow(box: Box<String>) { Text(box.value) }The compiler walks Box<T>:
value: T; recursion on T hits phase 2 (type parameter) → Stability.Parameter. Combined collapses to Stability.Parameter with bitmask 0b1 (the single type parameter affects stability).Box is consumed from a different module, the compiler emits @StabilityInferred(parameters = 0b1) on Box and a $stable: Int field initialized from T's stability at runtime. Downstream call sites pick this up via phase 9.T = String → String is Certain Stable → bit 0 satisfied.runtime stable class Box<T> and at the call site BoxRow is skippable.// WRONG mental model
// "runtime stable means there is a runtime cost on every recomposition" — partly true but misleading
// WRONG because: the cost is one Int field load and a bitwise AND, performed once when the runtime
// computes the call-site stability. It is far cheaper than the unskipped recomposition it prevents.// RIGHT mental model
// runtime stable = "the compiler proved stability conditional on the type arguments, and emitted
// a $stable: Int field whose bits the runtime ANDs against the substituted argument stabilities".
// The skip decision is still made; it is just made at runtime instead of compile time.@Immutable data class Person(val name: String) enable more optimizations than @Stable?"@Stable is a contract: "I will notify Compose of changes". @Immutable is a stronger contract: "I will never change". With @Immutable the compiler may promote reads of Person.name to static expressions and elide equality probes for nested usages; with @Stable it must still emit equality checks. Both classify as Stability.Certain, but the downstream optimizer treats @Immutable more aggressively.
// WRONG
@Stable data class Coordinates(val lat: Double, val lng: Double)
// WRONG because: Coordinates never mutates after construction. @Stable understates the contract
// and forfeits static-expression promotion at every read site.// RIGHT
@Immutable data class Coordinates(val lat: Double, val lng: Double)data class Node(val id: String, val children: List<Node>)Phase 5 (cycle detection) bails. The compiler does not attempt fixed-point analysis because it would have to assume the answer to prove the answer. The conservative verdict is Unstable. Even if every field is otherwise stable, the recursion through children returns Unstable to the parent call.
// WRONG — adds @Stable to "force" stability
@Stable data class Node(val id: String, val children: List<Node>)
// WRONG because: List<Node> is a mutable interface backed by ArrayList in practice. The @Stable
// annotation tells the compiler to trust the contract, but the actual List instance can mutate
// between recompositions without notifying Compose, producing silent missed recompositions.// RIGHT
import kotlinx.collections.immutable.ImmutableList
@Immutable data class Node(val id: String, val children: ImmutableList<Node>)ImmutableList is in the Known Stable Constructs registry (phase 7) with bitmask 0b1, so the recursion through children is permitted: cycle detection still triggers in phase 5, but the registry hit short-circuits the conservative verdict.
Set<String> block skipping but ImmutableSet<String> doesn't?"kotlin.collections.Set is an interface (phase 11 → Unknown) backed in practice by LinkedHashSet, which mutates. kotlinx.collections.immutable.ImmutableSet is in the Known Stable Constructs registry with bitmask 0b1, so it is Stability.Parameter and resolves to stable when the element type is stable.
// WRONG
@Composable fun TagRow(tags: Set<String>) { /* ... */ }
// WRONG because: Set is an interface — phase 11 returns Unknown, the call site is non-skippable.// RIGHT
@Composable fun TagRow(tags: ImmutableSet<String>) { /* ... */ }runtime stable even when it has only vals?"Separate compilation. At the time the call site compiles, the compiler does not have the full source AST of the dependency, only its .class files plus the metadata in @StabilityInferred(parameters = ...). Phase 9 reads that annotation; the runtime resolves the bitmask against actual type arguments via the generated $stable: Int field. The classification is correct — there is no extra work to do — but it must be deferred to runtime because cross-module compile-time analysis is impossible without the source.
Cite these by name when answering "why" questions. The compiler stores stability as one of:
@Stable/@Immutable-annotated classes. Decision is final and compile-time.$stable: Int field and @StabilityInferred; runtime ANDs the bits against actual type arguments.=== identity for the equality probe.Container<T1, T2, T3> uses an Int bitmask where bit i set means Ti participates in stability:
| Type | Bitmask | Reading |
|---|---|---|
kotlin.Pair<A, B> | 0b11 | both A and B affect stability |
kotlin.Triple<A, B, C> | 0b111 | all three affect stability |
kotlinx.collections.immutable.ImmutableList<E> | 0b1 | only E affects stability |
java.math.BigInteger | 0b0 | no parameters; classified as stable regardless of erased type arguments |
The full rules — including how @StabilityInferred(parameters = ...) is generated for separately-compiled types and how the $stable: Int field is laid out on the JVM versus the mangled top-level property used on Kotlin/Native and Kotlin/JS — are in references/bitmask-encoding.md.
runtime stable is not a bug or an unstable verdict — it is the compiler's way of saying "stability is conditional on type arguments and will be checked once at runtime via the $stable: Int field".var to val, swapping collection types) before explaining why the current structure is unstable. Diagnosis before treatment.Stability.Unknown from Stability.Unstable when answering — Unknown means "cannot tell" and falls back to identity equality, Unstable means "proven unstable" and disables skipping outright.@Stable to a type whose contract they cannot guarantee. A stability annotation is a contract; breaking it produces silent missed recompositions, which is worse than a non-skippable composable.Stability.kt (the algebraic data type), KnownStableConstructs.kt (the registry), ClassStabilityTransformer.kt (the $stable field emission), and StabilityConfigParser.kt (the config-file reader).Pair's bitmask is set, A=String is Certain Stable, satisfied; bit 1 is set, B=List is Unstable, fails — Combined collapses to Unstable".Certain, Runtime, Unknown, Parameter, or Combined.runtime stable class, Claude can explain that the compiler emitted @StabilityInferred(parameters = ...) on the class declaration and a $stable: Int field that the runtime ANDs against substituted type-argument stabilities.@Stable or @Immutable on a type whose mutation contract is not guaranteed.Set<T>, List<T>, Map<K, V> as Unknown interfaces (not Unstable) when explaining why they block skipping.references/twelve-phase-algorithm.md — pseudocode walkthrough of all 12 phases plus the field-by-field analysis pseudocode.references/bitmask-encoding.md — generic stability bitmask rules, the Known Stable Constructs registry, the @StabilityInferred(parameters = 0b1) annotation generated by the compiler, the runtime $stable: Int field on JVM, and the mangled top-level property approach on Native and JS.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.