API tiers and opt-in annotations
What @ExperimentalStoreApi, @DelicateStoreApi, and @InternalStoreApi mean, and where the stability tiers actually live.
When the compiler refuses an API and names an annotation, it is enforcing the stability tiers.
Store 6 marks every API that carries less than the full stable
guarantee with one of three opt-in markers, and all three are declared at
RequiresOptIn.Level.ERROR. Using a marked API without opting in is a compile error, not a
warning.
The markers are real annotations in store6-core, in the
org.mobilenativefoundation.store6.core package, and each one's meaning is carried in its own
KDoc. The sections below restate them and show where you will meet each one. The full surface is
browsable in the store6-core API reference.
@ExperimentalStoreApi: may change in any release
Marks API that is under active development and may change or be removed in any release. Experimental API ships in separate artifacts wherever possible. This marker exists for the cases where an experimental member must live beside stable API. The compiler says:
This Store API is experimental and may change or be removed in any release.
Inside store6-core you will hit it in exactly two places:
- Every type in the seam package: the interfaces you implement to plug in your own infrastructure. There is one deliberate exception, covered below.
- Every expert knob on the builder: the regular-interface
fetcher(Fetcher)overload,persistence,telemetry,overlay,bookkeeper,wallClock, andfreshnessValidator.
The zero-config path needs no opt-in. store { } itself, the lambda fetcher { },
fetcherOfResult, and maxIdleKeys are all stable-track: you can build and run a complete store
without a single annotation. The opt-in appears at the moment you reach past the defaults:
import org.mobilenativefoundation.store6.core.ExperimentalStoreApi
import org.mobilenativefoundation.store6.core.store
@OptIn(ExperimentalStoreApi::class)
val users = store<UserKey, User> {
fetcher { key -> api.getUser(key.id) } // stable — needs no opt-in
persistence(userSourceOfTruth) // experimental knob — the reason for @OptIn
}@OptIn(...) at the use site is the form to prefer. Kotlin also accepts a module-wide
-opt-in=org.mobilenativefoundation.store6.core.ExperimentalStoreApi compiler argument, but that
blankets every file in the module and hides which call sites actually depend on unstable surface.
Keep the acknowledgment next to the code that makes it.
@DelicateStoreApi: stable but easy to misuse
Marks API that is stable but easy to misuse. This is not a stability warning. The shape will not change out from under you. Opting in asserts something about you: that you uphold the documented contract of the marked declaration. The compiler says:
This is a delicate Store API. Read the contract documentation of the declaration before opting in. Implementations must uphold every documented semantic.
The canonical case is implementing Store directly instead of building one through the
store { } DSL:
@SubclassOptInRequired(DelicateStoreApi::class)
public interface Store<K : StoreKey, out V : Any>@SubclassOptInRequired gates only implementation, not use. Calling stream or get on a store
you built through the DSL requires no opt-in at all. Writing class MyStore : Store<K, V> does.
Implementing the interface yourself is a deliberate act, not a default, and what the act declares
is that your implementation honors every semantic in the interface's KDoc, including the
one-failure-channel rule described in the read contract.
The seam interfaces you implement carry the same subclass gate. FreshnessValidator, for
example, is @ExperimentalStoreApi on the type and @SubclassOptInRequired(DelicateStoreApi::class)
on implementation. Plugging in your own seam implementation is therefore a double opt-in:
experimental because the seam is not frozen yet, delicate because the contract is yours to uphold.
@InternalStoreApi: never yours
Marks API that is internal to the Store libraries. It may change or disappear without notice
even in patch releases, and it must never be used outside org.mobilenativefoundation.store
artifacts. The compiler says:
This API is internal to Store and must not be used outside Store artifacts.
There is no supported reason to opt into this one. If you find yourself wanting an internal type, usually to change how fetching, persistence, freshness, or observation works, the supported route is the seam: the extension surface exists precisely so that nobody needs the internals. The extending guide covers that seam.
The tier is on the artifact
Experimental code lives in separate artifacts, never annotation-gated inside a stable one. When a capability needs its own release rhythm, it gets its own artifact, and the tier is stated on the artifact you depend on rather than buried in an annotation on a member you have already built against.
The artifact placement scopes the compatibility promise. SemVer applies to the stable tier. A
breaking change to an @ExperimentalStoreApi surface in a minor release is not a SemVer
violation, because that surface never claimed the guarantee.
As of 6.0.0-alpha01 the split is:
store6-core: stable-track. The API is not frozen until the beta01 freeze candidate.- Everything else with an API surface (
store6-testing, thestore6-sqldelight,store6-room, andstore6-composeadapters,store6-mutations, and the devtools artifacts) is experimental (@ExperimentalStoreApi).store6-mutationsis an entire experimental artifact: every public symbol carries the marker, and its graduation criteria are stated in the stability policy. The mutations overview introduces its journalled write model.
The stability policy carries the full artifact table, release targets, and the deprecation cycle. It is the synchronized authority and this page deliberately does not duplicate it.
The seam: freeze candidate, not frozen
Inside store6-core, the org.mobilenativefoundation.store6.core.seam package (the 13 files you
implement to plug in your own fetcher, source of truth, bookkeeper, clock, telemetry, or overlay)
is a freeze candidate, not frozen. Today those types are @ExperimentalStoreApi, so
implementing one is an explicit opt-in. This is the stated exception to the
separate-artifacts rule above, and it is why the seam sits inside a stable-track artifact rather
than shipping separately.
The candidate-versus-frozen distinction is deliberate and staged. A real producer has to exercise
a seam end to end before it is called a candidate. The Overlay and StoreWriteHandle surfaces
become frozen only once the ack-path atomicity work and its test matrix are green. If that work
misses beta01, those two ship @ExperimentalStoreApi outside the frozen tier and the rest of core
freezes on schedule. The freeze timeline lives on the roadmap.
CI enforces the 13-file list on every pull request, so the seam cannot grow quietly.
FetcherResult is the one seam type that carries no experimental
marker. The stable fetcherOfResult builder member takes a lambda returning FetcherResult<V>,
and a stable signature cannot require an experimental type, so FetcherResult is the one seam
type reachable without any opt-in.
What opting in commits you to
@ExperimentalStoreApi: expect shapes to change between alphas, and pin your versions
accordingly. An experimental surface changing under you in a minor release is the deal you
accepted, not a bug report.
@DelicateStoreApi: read the contract KDoc of the declaration you are opting into, and uphold
every documented semantic. The KDoc on these declarations is the contract, not commentary.
Custom source-of-truth and bookkeeper implementations should run the contract kits that ship in
store6-testing (SourceOfTruthContractKit, BookkeeperContractKit). The
testing guide and extending guide
cover that workflow.
Source recorded 2026-08-12 ·main@539614c0· pre-6.0.0-alpha01