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:

  • :agent depends 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 :agent or on the console. They’re leaves. The arrows only ever point toward the contract.
  • :protocol re-exports kotlinx-serialization-json as api, 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 by TYPESAFE_PROJECT_ACCESSORS in ../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 of ConcurrentHashMaps). 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.