Architecture
MDMCore is built from four moving parts plus a shared contract. The contract is the hub; everything else is a spoke.
The four parts
| Part | Module(s) | Stack | Role |
|---|---|---|---|
| Server | :server | Ktor (Netty), JVM | The brain. Holds the desired policy per device and the latest compliance report. Authoritative on what “compliant” means. Sends FCM wake-ups. |
| Agent | :agent | Android app, Device Owner / DPC | Pulls the desired policy, enforces it via DevicePolicyManager, and reports the actual observed state back. |
| Console | :app:shared, :app:desktopApp, :app:webApp | Compose Multiplatform (desktop + web; iOS parked) | Views device status and edits the desired policy. |
| Protocol | :protocol | Pure Kotlin Multiplatform | The contract: every @Serializable message type, the one compliance rule, and the canonical JSON config. |
| Core | :core | Pure Kotlin Multiplatform | Non-wire shared domain logic. Currently minimal. |
iOS is parked: the console’s shared module compiles for iOS and there’s an Xcode entry point (app/iosApp), but on-device iOS MDM is a server-side protocol, not an on-device DPC, so the iOS app is a viewer at most. See ../app/iosApp.
Module dependency star
Every part that touches the wire depends on :protocol, and on nothing else of each other. That’s the whole point: because the server, the agent, and the console all import the same Kotlin types, the compiler refuses to let them disagree about the wire format.
flowchart TD
subgraph consoleFamily["Console family (Compose Multiplatform)"]
desktop[":app:desktopApp"]
web[":app:webApp"]
shared[":app:shared"]
end
agent[":agent<br/>(Android Device Owner / DPC)"]
server[":server<br/>(Ktor brain)"]
core[":core"]
protocol[":protocol<br/>— the contract —"]
desktop -->|implementation| shared
web -->|implementation| shared
shared -->|api| core
shared -->|implementation| protocol
server -->|api| core
server -->|implementation| protocol
agent -->|implementation| protocol
protocol -->|api| serialization["kotlinx-serialization-json"]
style protocol fill:#cde4ff,stroke:#4a90d9,stroke-width:3px
Things worth noticing in that graph:
:agentdepends only on:protocol. It is deliberately not placed under:app(the console family) — the agent and the console share the wire contract and nothing else. The agent doesn’t even use:core.- Nothing depends on
:agentor on the console. They’re leaves. The arrows only ever point toward the contract. :protocolre-exportskotlinx-serialization-jsonasapi, so every consumer gets the serialization runtime transitively and serializes through the same configuration (see protocol.md).- Project dependencies use type-safe project accessors (
projects.protocol,projects.core,projects.app.shared), enabled byTYPESAFE_PROJECT_ACCESSORSin../settings.gradle.kts.
Design principles
These five ideas explain most of the code.
1. The protocol is the single contract
All wire types live once, in :protocol’s commonMain (so they compile for every target). There is no “server’s idea of a policy” and “agent’s idea of a policy” — there is one DevicePolicy. A breaking change to the wire is a compile error somewhere, not a silent runtime mismatch. The serialization plugin is applied only in :protocol (the one place @Serializable is declared); consumers just need the types on their classpath. See protocol.md.
2. The server is authoritative
The agent reports what it observes and its own compliant opinion — but the server ignores that opinion and recomputes compliance itself, from the reported actual state, using the shared rule. The device proposes; the server decides. The same stance applies to policy edits: a console sends only the editable fields (PolicyUpdate), and the server owns and bumps the version. See server.md.
3. Policy is versioned, so “behind” is distinguishable from “wrong”
Every DevicePolicy carries a version the server bumps on each change. The agent echoes back the version it last enforced. That single integer lets the server tell STALE (“the device hasn’t caught up to the latest policy yet”) apart from NON_COMPLIANT (“the device is on the current policy but has drifted”). Without versioning those two look identical. See mdm-loop.md.
4. Push wakes; HTTP moves the data
A policy change rings a doorbell: a data-only FCM message that simply tells the device “come check”. The push carries no policy — the actual data always moves over HTTP on the device’s next sync. This keeps the wake path tiny and reliable and keeps a single code path (runMdmSync) for moving data, whether it was triggered by a push, the app launching, or a retry. See fcm-doorbell.md.
5. Platform specifics use expect/actual
Shared Kotlin declares an expect and each target supplies an actual. The canonical example is getPlatform() in :app:shared (Platform.kt in commonMain, with Platform.android.kt, Platform.jvm.kt, Platform.js.kt, Platform.wasmJs.kt, Platform.ios.kt). Declare the expect once and provide an actual for every target source set, or the build fails. See console.md.
Where the data lives
- Desired policy + last report + FCM token: in the server, in memory (
DeviceStore, a set ofConcurrentHashMaps). Not persisted — restart the server and it forgets. Fine for the current scope; a real deployment would back this with a database. See server.md. - Device identity: on the agent, in
SharedPreferences(DeviceIdStore), so a device keeps its server-assigned id across restarts and re-enrolls only when it has none or the server 404s. See agent.md.
Tech stack
Versions are centralized in ../gradle/libs.versions.toml and referenced via libs.*. The headline versions (Kotlin, AGP, Compose Multiplatform, Ktor, kotlinx-serialization, Firebase, WorkManager) and the SDK/JVM targets are tabulated in development.md.