Protocol — the wire contract
:protocol is the hub of the dependency star. It holds every message that travels between the server, the agent, and the console — defined once, in commonMain so it compiles for every target (Android, iOS, JVM, JS, Wasm). Because all three parts import the same Kotlin types, they cannot disagree about the wire format: a breaking change is a compile error, not a runtime surprise.
All types are in protocol/src/commonMain/kotlin/com/arganaemre/mdmcore/protocol/.
The types at a glance
classDiagram
class DevicePolicy {
+Int version
+Boolean cameraDisabled
}
class PolicyUpdate {
+Boolean cameraDisabled
}
class DeviceId {
+String value
}
class EnrollRequest {
+String model
+String osVersion
+String agentVersion
}
class EnrollResponse {
+DeviceId deviceId
+DevicePolicy policy
}
class TokenRegistration {
+String token
}
class ComplianceReport {
+DeviceId deviceId
+Int appliedPolicyVersion
+Boolean cameraDisabled
+Boolean compliant
}
class DeviceStatus {
+DeviceId deviceId
+DevicePolicy desired
+ComplianceReport lastReport
+ComplianceState state
}
class ComplianceState {
<<enumeration>>
UNKNOWN
STALE
NON_COMPLIANT
COMPLIANT
}
EnrollResponse --> DeviceId
EnrollResponse --> DevicePolicy
ComplianceReport --> DeviceId
DeviceStatus --> DeviceId
DeviceStatus --> DevicePolicy
DeviceStatus --> ComplianceReport : lastReport (nullable)
DeviceStatus --> ComplianceState
Policy types — Policy.kt
DevicePolicy — the desired state
The state the server wants a device to be in. The server is its sole author.
version: Int— increases on every change. The agent echoes back the version it last enforced (inComplianceReport.appliedPolicyVersion), which is what makes STALE detectable (see the loop).cameraDisabled: Boolean = false— the one enforced policy. Has a default, so an older or minimal payload missing the key still decodes.
PolicyUpdate — what a console may change
The editable fields only — deliberately no version. A console proposes new values; the server bumps the version itself. Same “server is authoritative” stance as compliance evaluation. Today that’s just cameraDisabled: Boolean.
Device + enrollment types — Device.kt
DeviceId
A stable, server-assigned identity wrapped in its own type — data class DeviceId(val value: String) — so the compiler stops you passing, say, a model name where a device id belongs.
On the wire it’s an object, not a bare string. Because it’s a
data class(not a value class),DeviceIdserializes as{"value":"device-1"}. In a URL path the bare string is used (/devices/device-1/policy); in a JSON body the wrapped object appears. See http-api.md.
EnrollRequest
Agent → server on first contact: model (Build.MODEL), osVersion (e.g. "Android 34"), agentVersion (the agent app’s versionName). Just enough to register.
EnrollResponse
Server → agent: the assigned deviceId plus the first policy to apply.
TokenRegistration
Agent → server: the device’s current FCM registration token, so the server can ring its doorbell. Sent on every sync — re-registering is idempotent and tokens rotate.
Compliance types — Compliance.kt
ComplianceReport — the actual state
Agent → server. The device is its sole author. Drift is the server comparing this against the current DevicePolicy.
deviceId— who is reporting (the report carries it, so the server keys on it).appliedPolicyVersion— theDevicePolicy.versionthe agent last enforced.cameraDisabled— the observed camera state on the device.compliant— the agent’s self-assessment. The server ignores this and recomputes.
The one compliance rule
The single source of truth for “what does compliant mean”, shared by agent and server so they can never disagree:
fun DevicePolicy.isSatisfiedBy(cameraDisabled: Boolean): Boolean =
cameraDisabled == this.cameraDisabled
fun DevicePolicy.isSatisfiedBy(report: ComplianceReport): Boolean =
isSatisfiedBy(report.cameraDisabled)
It’s exact equality on the camera state today. Extend this function when you add a policy.
Status types — Status.kt
ComplianceState
UNKNOWN | STALE | NON_COMPLIANT | COMPLIANT. The server’s authoritative verdict. The STALE vs NON_COMPLIANT distinction exists only because the policy is versioned. Full semantics in the loop.
DeviceStatus
A device’s desired policy, its lastReport (nullable — null until the first report), and the server-derived state. This is what the console lists and renders.
Serialization — Serialization.kt
One canonical JSON config, defined in the contract so the server and agent serialize identically:
val MdmJson: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
ignoreUnknownKeys = true— an older peer can decode a payload a newer peer added fields to (forward compatibility). Pair this with always giving new fields a default.encodeDefaults = true— write default-valued fields explicitly (kotlinx omits them by default). We want the wire to carry the full picture, e.g. an explicit"cameraDisabled": false, rather than relying on the reader’s defaults.
MdmJson is exported transitively: :protocol declares api(libs.kotlinx.serialization.json), so every consumer gets both the types and the same JSON runtime.
Evolving the contract
The rules that keep old and new peers interoperable:
- Add new fields with a default. A missing key then decodes to the default instead of failing.
- Don’t rename or retype existing fields — that’s a breaking wire change. Add a new field and migrate.
- Extend
isSatisfiedBywhen a new field is policy-relevant, so both sides judge compliance the same way. - Because the types are shared, the compiler will point at every call site that needs updating — lean on it.