Desired Sync (Property Convergence Shadow)
The provider SDK exposes a top-level desiredSync field on each device twin that carries per-property synchronization state — the answer to "did the upstream/device actually accept this desired write, and is the device reporting it back?".
The shadow tree is parallel to (not interleaved with) the modelled desired and reported trees.
Why a shadow tree?
DTDL v4 — the model layer — explicitly does not model property synchronization state. From the spec:
"Every digital twin Property has synchronization information behind it … since this synchronization information is the same for all Properties, it is not included in the model definition."
That means there is no DTDL-blessed place to put "this property is in flight", "the upstream rejected with 409", or "the device has not yet acked the desired version". Different platforms invent their own conventions:
- Azure IoT Hub Plug-and-Play wraps each writable property's reported value in an envelope:
reported.X = { value, ac, av, ad }. Compatible with PnP tooling but forces every reader to parse a tagged-union and changes the wire shape ofreported. - BeaconTower keeps the modelled trees clean and adds a separate top-level field called
desiredSync. Path keys mirror thedesiredtree exactly. Readers that do not care about sync state ignore the field; readers that do care (UIs, ops dashboards, automation) read it directly.
This is purely additive — no breaking change to existing consumers, and merging the trees into a PnP-style envelope later is a mechanical transformation if it ever becomes useful.
Twin shape
{
"deviceId": "enode-vehicle-ad623e31",
"desired": { "chargingMode": "START", "config": { "interval": 30 } },
"desiredVersion": 7,
"reported": { "chargingMode": "STOP" },
"reportedVersion": 12,
"desiredSync": {
"chargingMode": { "ac": 202, "av": 7, "ad": "Pending", "lastAttemptAt": "2026-05-01T11:42:01.123Z", "attempts": 2 },
"config": { "interval": { "ac": 200, "av": 6, "ad": "Converged", "lastAttemptAt": "2026-05-01T11:30:14.001Z", "attempts": 0 } }
}
}
| Field | Meaning |
|---|---|
ac | HTTP-style status code: 200 converged, 202 pending, 4xx upstream rejected (definitive), 5xx/504 upstream error or timeout. |
av | Desired version this envelope acknowledges. Stale when av < desiredVersion. |
ad | Human-readable description (used by UIs and logs). |
lastAttemptAt | UTC timestamp of the most recent push attempt. |
attempts | Counter of attempts so far (drives backoff display). |
Reading desiredSync
The field is exposed on three surfaces:
- HTTP —
GET /api/devices/{id}returns the twin includingdesiredSync. - Bus-api —
bt.provider.{providerId}.cmd.get-desired.{deviceId}includesdesiredSyncalongsidedesiredin the response. - Database — the field lives inside the existing twin JSONB document column; no new column is required.
// Inside an adapter or hosted service:
var twin = await context.Twin.GetAsync(deviceId, ct);
var sync = twin?.DesiredSync;
// sync is JsonDocument? — null when nothing has ever populated it.
Writing desiredSync
Adapters do not write desiredSync directly. The SDK's convergence scheduler owns the field. Writes happen automatically when:
- A desired delivery succeeds → an envelope with
ac=202(pending) orac=200(already converged) is seeded. - The convergence scheduler observes reported matches desired →
ac=200. - An upstream call throws
UpstreamRejectedException(4xx)→ac=<4xx>. - The configured timeout elapses without convergence →
ac=504. - A path is removed from
desired(RFC 7396 null patch) → its envelope is cleared.
If you have a use case that requires manual writes (e.g. an out-of-band reconciliation job), the repository exposes:
public Task<bool> MergeDesiredSyncAsync(string deviceId, JsonDocument patch, CancellationToken ct);
public Task<bool> ReplaceDesiredSyncAsync(string deviceId, JsonDocument? newDesiredSync, CancellationToken ct);
Both follow the same semantics as the desired merge: a null value at any path removes that path; nested objects merge recursively.
Stale envelopes
The scheduler does not aggressively prune stale entries. If desiredVersion = 7 and desiredSync.X.av = 5, the envelope is reflective of an older write. Readers must check av against the current desiredVersion before drawing conclusions.
The scheduler reseeds the envelope on the next convergence event for the affected path, so stale entries are short-lived in practice.
What the field is not
- Not a place to put device-side state. That is
reported. If the device's firmware version changes, it goes inreported.firmwareVersion—desiredSynconly describes the cloud→device convergence direction. - Not validated against any DTDL model. The spec deliberately omits this state, so neither does the SDK.
- Not part of the
reportedtree. Frontends that readreported.chargingModecontinue to see plain values; they opt into status awareness by readingdesiredSyncseparately.
Spec reference
The behavior is captured in the twin-management spec under "Requirement: Desired Property Synchronization Shadow" (see openspec/specs/twin-management/spec.md).