The MDM loop
This is the whole system. Desired policy lives on the server; the agent makes reality match it and reports back; the server compares the two and flags any gap. Everything else is detail.
enroll → pull desired → enforce → report actual → server detects drift
The steps
| Step | Who → who | How | Result |
|---|---|---|---|
| enroll | agent → server | POST /enroll with EnrollRequest | server assigns a DeviceId and returns the first DevicePolicy (EnrollResponse) |
| register token | agent → server | POST /devices/{id}/token with TokenRegistration | server can now ring this device’s FCM doorbell |
| pull desired | agent → server | GET /devices/{id}/policy | agent learns the desired DevicePolicy |
| enforce | agent (local) | PolicyEnforcer → DevicePolicyManager | the device’s camera is set to match cameraDisabled |
| report actual | agent → server | POST /devices/{id}/report with ComplianceReport | server stores the observed state + the policy version the agent applied |
| detect drift | server (local) | evaluate() on read of GET /devices or GET /devices/{id}/status | server derives an authoritative ComplianceState |
The agent runs steps 1–5 as one routine, runMdmSync (see agent.md). It runs on app launch, on a WorkManager job, and whenever the FCM doorbell rings.
Sequence
sequenceDiagram
autonumber
participant C as Console
participant S as Server
participant A as Agent (Device Owner)
Note over A,S: First contact (only when the agent has no id, or the server 404s)
A->>S: POST /enroll (model, osVersion, agentVersion)
S-->>A: EnrollResponse (deviceId, policy v1)
A->>A: persist deviceId (DeviceIdStore)
Note over A,S: Every sync (runMdmSync)
A->>S: POST /devices/{id}/token (current FCM token)
A->>S: GET /devices/{id}/policy
S-->>A: DevicePolicy (desired)
A->>A: enforce via DevicePolicyManager, then read back actual state
A->>S: POST /devices/{id}/report (observed cameraDisabled + appliedPolicyVersion)
S->>S: evaluate() — recompute compliance from the report
Note over C,S: Operator changes desired policy
C->>S: PUT /devices/{id}/policy (PolicyUpdate)
S->>S: bump version
S-->>C: DevicePolicy (new version)
S--)A: FCM doorbell — data-only "policy_changed" (no policy inside)
A->>S: GET /devices/{id}/policy (re-syncs, enforces, re-reports)
The dashed arrow from server to agent is the doorbell: it only wakes the agent. The policy itself always travels over HTTP, on the very next line. See fcm-doorbell.md.
Compliance: how the server decides
The agent includes its own compliant opinion in every report, but the server does not trust it. On every read, the server recomputes the verdict from the reported actual state using the single shared rule (DevicePolicy.isSatisfiedBy, see protocol.md). The device proposes; the server decides.
The verdict is one of four ComplianceState values, computed in this exact order (server/src/main/kotlin/com/arganaemre/mdmcore/Application.kt, evaluate):
when {
report == null -> UNKNOWN
report.appliedPolicyVersion < desired.version -> STALE
desired.isSatisfiedBy(report) -> COMPLIANT
else -> NON_COMPLIANT
}
| State | Meaning |
|---|---|
| UNKNOWN | The device is enrolled but has never reported. |
| STALE | The device’s last report was for an older policy version — it hasn’t caught up to the latest desired policy yet. Checked before content, so a report can match the camera setting and still be STALE if it’s a version behind. |
| NON_COMPLIANT | On the current policy version, but the observed state doesn’t satisfy it — real drift. |
| COMPLIANT | On the current version and the observed state satisfies the desired policy. |
Why STALE is its own state — versioning earns it
STALE and NON_COMPLIANT would be indistinguishable without a version number. The agent echoes the appliedPolicyVersion it last enforced; comparing that to the server’s current version is the only way to tell “the device hasn’t synced the new policy yet” from “the device synced it and is genuinely violating it”. That’s why DevicePolicy carries a version the server bumps on every edit, and why PolicyUpdate (what a console sends) carries no version — the server owns it.
State transitions
The state is derived on every read, not stored as a field — but the transitions a single device walks through look like this:
stateDiagram-v2
[*] --> UNKNOWN: enrolled
UNKNOWN --> COMPLIANT: report matches current policy
UNKNOWN --> NON_COMPLIANT: report on current version, but drifted
COMPLIANT --> NON_COMPLIANT: device drifts, re-reports
NON_COMPLIANT --> COMPLIANT: device fixed, re-reports
COMPLIANT --> STALE: operator edits policy (version bumps)
NON_COMPLIANT --> STALE: operator edits policy (version bumps)
STALE --> COMPLIANT: agent re-applies new policy, report satisfies it
STALE --> NON_COMPLIANT: agent on new version but still drifted
A typical happy path after an edit: COMPLIANT → (edit) → STALE → (doorbell wakes the agent, it pulls, enforces, reports) → COMPLIANT.
What’s enforced today
Exactly one policy: the camera (cameraDisabled). Compliance is an exact-equality check on that one boolean. Adding a second policy touches the contract, the enforcer, and the device-admin declaration together — see the checklist in agent.md.