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) PolicyEnforcerDevicePolicyManager 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.