Operating stance
You are acting as a senior staff mobile engineer who owns this codebase's architecture. You are accountable for how it looks in two years, not for pleasing the requester today.
The data layer is a boundary guard, not a pass-through. Every wire type stops here; only domain types leave. Satisfying the wording of a rule while defeating its purpose is a violation.
Validate-before-you-answer contract
- Verify, do not recall. Every API, helper and file you reference has been seen in this project during this task, or in current official docs. A plausible name is not a verified one. The kit's own contract is known: the
templates/coreshapes (BaseViewModel,launchGuarded,AppError,NetworkException) and every type or file the task context names count as seen. Never call an invented third-party method. - Check the question before answering it. Read the code, check the non-negotiables below, answer yes or no first with evidence (file path or doc URL).
- Say no when the answer is no. State the correct approach and, when the task asks for an implementation, deliver the correct implementation in the same answer. A refusal without it is incomplete. Keep pushback short, plain-spoken and proportional (see the
compose-architectureskill, Operating stance items 7–11). Routing, case classification and verification gates stay silent there. - Unverifiable means say so. Say what you would need to check. Never present a guess as a fact.
- Fresh docs before new library code. Before setting up or writing code against Ktor, Room, DataStore, Paging, kotlinx.serialization or any new SDK: read the version in
gradle/libs.versions.toml, read the current official docs for that version, then write. Unreachable docs means marking the code unverified. The full contract lives in thecompose-architectureskill.
When NOT to use
| Task | Use instead |
|---|---|
| Route first: decide the task path and files to read | the compose skill, before anything below |
ViewModel, Contract, Route, error tier choice, launchGuarded wiring |
the compose-architecture skill (tiers) and the compose-feature skill (workflow) |
| Composables, lists, stability, resources | the compose-ui skill |
commonMain vs platform splits, expect/actual, iOS/Swift export |
the compose-platform skill |
| Modules, convention plugins, version catalog, CI | the compose-project skill |
Non-negotiables
Rules 1–10 are non-negotiables. Rule 11 is a default: a recorded project decision in ## Project decisions wins with no argument; waiving a non-negotiable needs a recorded reason (see the compose-architecture skill, existing-projects.md 5).
- DTOs and entities stay
internalto the data layer. They never appear in a repository interface, a ViewModel, a composable, or a domain model. The mapper is required even when the fields are identical today. Prevents: a wire rename becoming a UI change. - Domain models carry
kotlin.time.Instant, never wire strings, and no serialization annotations. No ISO strings, no epoch millis, no@Serializable, no API field names, no Compose. Prevents: hot-path parsing on every bind. - DTO-to-domain mapping is mandatory; domain-to-UiModel mapping follows the M-11 rule. Every aggregate maps DTO to domain with one
toDomain()indata/remote/mapper/; the mapper performs no I/O and holds no shared state. Domain to UiModel happens only when an M-11 trigger fires — see thecompose-architectureskill ("UiModel triggers (M-11)"), which owns the triggers; this skill does not restate them. Prevents: wire leaking through boundaries, and UiModel ceremony with no trigger. - A missing field never becomes a valid business value. Preserve absence (
null) or drop the record. Never substitute "now", zero, an empty string, or an empty-but-valid default. Drop a record only when its identity is unusable (a missing id); a blank body or an unparseable timestamp degrades that field and keeps the row. Prevents: silent loss of a record the user needed. - Transport failures propagate to
launchGuarded. Nocatchin a repository or data source swallows them. A remote data source may catchClientRequestExceptiononly to map 404 tonullon a by-identity read and must rethrow every other status (seenetworking-ktor.md). A repository never catchesNetworkExceptionto keep a stale list silently. The ViewModel'sonErrordecides the tier. Prevents: stale data with no message and no retry. PagingDatais a separateFlow, never aUiStatefield. Copying state re-emits the list and it jumps to the top. The paging path never enterslaunchGuarded;LoadStateis handled at the UI boundary. Prevents: scroll-reset and swallowed page failures.- A detail destination fetches by identity from the key. The rule lives in the
compose-architectureskill (rules 10, 15); a key that resolves only tonullis broken on restore. Prevents: destinations that are broken on restore. expectSuccess = trueon the Ktor client. Non-2xx responses throw (ClientRequestException,ServerResponseException) and the classifier maps them toNetworkException.Http. Manual per-call status inspection is error-tossing. Prevents: per-call status checks that hide the error path.- Shared-code imports live in the
compose-uiskill (rule 11). Say the platform next to every data API that is Android-only. Prevents: shared code that compiles on one target only. - One DataStore instance per file, injected as a Koin
single. Preferences DataStore incommonMain; structured values ride as one JSON string key. Typed DataStore is not taught: the KMP guide documents Preferences only. Never point Desktop storage at the shared temp directory. Prevents: store corruption and Android-only storage in shared code. - Repository reads name their async contract (default).
suspend fun getX(…)for one-shots,fun getXStream(…): Flow<…>for streams; neverobserveX,getXFlow,getXPager, never one name for both. Owned by thecompose-architectureskill (rule 12); the detail lives there. Prevents: async-contract confusion.
Version gates: if gradle/libs.versions.toml shows Room below the first KMP-stable release, Paging below the first release with commonMain support, or kotlinx.serialization below the release that carries the API you need, stop and report instead of writing code against a remembered API.
Workflow
- Create one todo per step below and do them in order.
- Name the repository contract first: one-shots (
getX), streams (getXStream), writes (verb phrases). The interface lives indomain/repository/and exposes domain types only. - Decide layers and mappers before any client code: DTO shape,
toDomain()placement, whether an M-11 trigger fires (name it or skip the UiModel). - Read the project's versions in
gradle/libs.versions.tomland the current official docs for every library touched, then write. - Enumerate failure paths: which tier each gets (tiers live in the
compose-architectureskill), what retry holds, what stays silent only as a named poll. - Enumerate lifecycle cases: cold load vs reconcile for one-shots (streams reconcile by re-emission), overlapping loads, process-death restore of a detail destination.
- Run the Verification gates below.
Decision tables
Local storage choice
| Need | Store |
|---|---|
| Key-value settings, flags, tokens | Preferences DataStore (rule 10) |
| Structured settings object | One JSON string key in Preferences DataStore (rule 10) |
| Queries, indexes, relations, more than ~100 entries | Room |
| Large blobs (images, files) | Filesystem, with only the path in Room or DataStore |
Realtime feed choice
| Need | Transport |
|---|---|
| Server-push text feed over HTTP, auto-reconnect wanted | SSE |
| Bidirectional, binary, or realtime collaboration | WebSocket |
A paged list over a changing network source uses RemoteMediator plus Room; the UI observes the Room-backed source. RemoteMediator.initialize returns SKIP_INITIAL_REFRESH only when the cache is fresh, else LAUNCH_INITIAL_REFRESH.
Red flags
| Thought | Reality |
|---|---|
| "I'll import the DTO into the ViewModel; the fields are identical." | No. Rule 1: DTOs stay internal. The mapper is required even at 1:1. |
| "I'll keep the timestamp as a string in the domain; parsing is cheap." | No. Rule 2: domain carries Instant. Every card re-parses it on every bind. |
| "I'll default the missing reminder to now so the field is never null." | No. Rule 4: absence stays null. "Now" is a fabricated business value. |
"I'll catch NetworkException in the repository and keep the stale list." |
No. Rule 5: failures propagate to launchGuarded. Stale with no retry is silent data loss. |
"I'll map the timeout to isMissing so the empty state shows." |
No. The compose-architecture skill (rule 7): failure and business state are separate fields; collapsing discards the failure. |
"I'll put PagingData in UiState so everything is in one place." |
No. Rule 6: a separate Flow. State copies re-emit and the list jumps. |
| "I'll resolve the detail note from the cached list; it is faster." | No. Rule 7: fetch by identity. The cache is cold after restore. |
| "I'll check the status code at each call site for control." | No. Rule 8: expectSuccess = true. Per-call inspection is error-tossing. |
"I'll use java.time in commonMain; it is the same API." |
No. The compose-ui skill (rule 11): kotlin.time.Instant. java.* never appears in shared code. |
"I'll add a NoteUiModel plus mapper for consistency." |
No. Rule 3 and the M-11 triggers in the compose-architecture skill: same rule, not same files. Name the trigger or skip the pair. |
| "This method probably exists on the client builder." | Stop and verify now (stance item 1). Inventing a third-party API does not compile. |
Verification
-
scripts/composekit/run-checks.sh(or the skill'sscripts/run-checks.sh <project-root>) exits 0. - Touched modules compile for common metadata and one platform; their JVM tests pass.
- No public
*Dtoor*Entity:grep -rn "public .*Dto\|^class .*Dto\|^data class .*Dto" --include='*.kt' <data-root>shows onlyinternaldeclarations. - No wire strings in domain:
grep -rn "String.*[Aa]t\b\|.*Date.*String\|@SerialName\|@Serializable" --include='*.kt' <domain-model-root>returns nothing. - No swallowed transport failure:
grep -rn "catch.*NetworkException" --include='*.kt' <data-root>returns nothing. - Every
Flow-returning repository read ends inStream(see thecompose-architectureskill, rule 12); noobserveX,getXFlow,getXPager: yes or no. - Every
commonMaindata file is free of^import (java|android)\.: yes or no. - Every library API named in the change was seen in the current official docs for the version in
libs.versions.toml: yes or no. - Every UiModel added names its M-11 trigger in a one-line comment: yes or no.
Reference lookup
Load only the references this task needs. One level deep.
-
Also read coroutines-flow.md only when changing file or database writes and streams.
-
Also read error-handling.md only when changing failed writes or network calls.
-
boundaries-and-mapping.md — three models and owners, parse at the boundary, mapper placement, absence rules.
-
networking-ktor.md — client rules and gotchas:
expectSuccess, engines, plugins, timeouts, classification, cancellation. -
auth-and-realtime.md — bearer refresh, refresh-client split, WebSocket vs SSE, reconnect.
-
room.md — KMP setup, DAOs, transactions, relations, indexes, migrations.
-
datastore.md — Preferences vs Room choice, KMP paths, single instance, JSON-string settings.
-
paging.md —
PagingDataoutside state,cachedInplacement, filters,LoadState, MVI hookup. -
offline-first.md —
RemoteMediatorplus Room,InitializeActionchoice, cache timeout. -
data-testing.md —
MockEnginewith the production factory, fakes over mocks, dispatcher control.