Reconciliation report (sync-completeness signal)
IdentityMesh exposes an on-demand reconciliation endpoint that surfaces drift between what the mesh believes and what the most recent successful sync run reported. This is the auditable sync-completeness signal that satisfies SOC 2 Processing Integrity (PI1.x) and ISO 27001 Annex A 8.16: “yesterday’s run touched N objects; current count is M; here’s the difference.”
What it is
A read-only aggregation over existing tables (IM_MeshObjects,
IM_ManagementSpaceObjects, IM_RunHistory, IM_Connectors).
No new state is written. The endpoint is cheap enough to run
on demand against a healthy production database; an operator
running it after a DR restore, after a known out-of-band SQL
edit, or as a weekly scheduled health check is the supported
workflow.
What it detects (phase 1)
- Run-vs-mesh count drift — most recent successful run for a
connector reported
Nobjects touched (Added + Updated + Deleted); currentIM_ManagementSpaceObjectscount for that connector isM. The difference (M - N, signed) is the drift since the last sync. Healthy steady state is zero or close to it; non-zero means something changed without going through the engine (a partial subsequent run, an out-of-band SQL edit, manual cleanup) or the mesh was tampered with. - Orphan mesh objects — rows in
IM_MeshObjectswithErasedAtUtc IS NULLthat have noIM_ManagementSpaceObjectsrow referencing them. In a healthy deployment every live mesh object is owned by at least one connector-space object; an orphan indicates a partial delete or a broken import. - Dangling connector-space rows — rows in
IM_ManagementSpaceObjectswhoseMeshObjectIdis non-null but the referenced mesh object doesn’t exist (or is erased). A dangling row is a broken join — the projection engine will misbehave on it until the broken state is repaired. - Per-object-type breakdown — current counts grouped by
ObjectTypeso an operator can see “12,438 Users / 1,250 Groups” rather than a single number.
What it does NOT detect today (phase 1 limitation)
Drift between mesh state and the upstream source. The original “yesterday’s ServiceNow had 12,438 users; today it has 12,440 — here are the 2 deltas” requires every connector to expose a count-only query method against its source. That’s phase 2; it is documented as a follow-up and tracked separately.
In phase 1, the run-vs-mesh drift signal is a useful proxy: an out-of-band change to either side will show up as drift, even if we can’t yet attribute it to a specific source-system delta.
Endpoints
GET /api/admin/reconciliation — overall report
Returns the full reconciliation report: total counts, orphan + dangling counts, plus a per-connector summary.
curl -X GET https://identitymesh.example.com/api/admin/reconciliation \
-H "Authorization: Bearer $TOKEN"
Response shape:
{
"reportedAtUtc": "2026-04-26T14:33:21Z",
"totalMeshObjects": 13688,
"totalErasedMeshObjects": 4,
"orphanMeshObjectCount": 0,
"danglingConnectorSpaceCount": 0,
"connectors": [
{
"connectorId": "9c1c...",
"connectorName": "Entra-Production",
"connectorType": "EntraId",
"currentMeshObjectCount": 12438,
"lastRunObjectsTouched": 12438,
"lastSuccessfulRunUtc": "2026-04-26T03:15:00Z",
"runVsMeshDrift": 0
},
{
"connectorId": "a234...",
"connectorName": "AD-OnPrem",
"connectorType": "ActiveDirectory",
"currentMeshObjectCount": 1250,
"lastRunObjectsTouched": 1250,
"lastSuccessfulRunUtc": "2026-04-26T02:45:00Z",
"runVsMeshDrift": 0
}
]
}
A drift report (something out of sync) looks like:
{
"reportedAtUtc": "2026-04-26T14:33:21Z",
"totalMeshObjects": 13690,
"totalErasedMeshObjects": 4,
"orphanMeshObjectCount": 2,
"danglingConnectorSpaceCount": 1,
"connectors": [
{
"connectorName": "Entra-Production",
"currentMeshObjectCount": 12440,
"lastRunObjectsTouched": 12438,
"runVsMeshDrift": 2
},
{
"connectorName": "AD-OnPrem",
"currentMeshObjectCount": 1250,
"lastRunObjectsTouched": 1250,
"runVsMeshDrift": 0
}
]
}
The runVsMeshDrift: 2 on Entra-Production paired with the
orphanMeshObjectCount: 2 is the operator’s lead — there are two
mesh rows that exist on this side without a CS pairing on the other.
GET /api/admin/reconciliation/{connectorId} — per-connector detail
Returns the same summary plus a per-ObjectType breakdown and the
five most recent runs (any status — useful for spotting a string of
failed runs preceding the drift).
curl -X GET https://identitymesh.example.com/api/admin/reconciliation/9c1c... \
-H "Authorization: Bearer $TOKEN"
Response shape:
{
"summary": {
"connectorId": "9c1c...",
"connectorName": "Entra-Production",
"connectorType": "EntraId",
"currentMeshObjectCount": 12440,
"lastRunObjectsTouched": 12438,
"lastSuccessfulRunUtc": "2026-04-26T03:15:00Z",
"runVsMeshDrift": 2
},
"byObjectType": [
{ "objectType": "Group", "currentCount": 1250 },
{ "objectType": "User", "currentCount": 11190 }
],
"recentRuns": [
{ "runId": "...", "status": "Success", "added": 5, "updated": 12433, "deleted": 0 },
{ "runId": "...", "status": "Success", "added": 0, "updated": 12438, "deleted": 0 },
{ "runId": "...", "status": "Failed", "added": 0, "updated": 0, "deleted": 0 }
]
}
Operator workflow
Recommended cadence:
- Weekly — schedule a GET against
/api/admin/reconciliationand feed the response into your monitoring stack. Alert onorphanMeshObjectCount > 0,danglingConnectorSpaceCount > 0, or anyrunVsMeshDriftoutside an acceptable threshold (the threshold depends on natural change-rate for your tenant; start withabs(drift) > 5%of total). - Post-DR / post-restore — run immediately after a database restore to confirm row counts are coherent before letting sync runs resume.
- Post-incident — after any known out-of-band SQL edit (DBA cleanup, bulk attribute fix, etc.) run reconciliation to confirm no joins were left dangling.
When drift is detected:
- Pull the per-connector detail (
/api/admin/reconciliation/{id}). Cross-referencerecentRuns: a failed run preceding the drift often explains it (the engine started a delta, deleted some tracking rows, then crashed before repopulating them). - Pull the audit log (
/api/admin/audit) for the same window — anyErasureRequestrows account fortotalErasedMeshObjectsmoving up; any DBA-tagged actor entries account for out-of-band edits. - To clean up dangling CS rows safely: take a transactional backup, then run a targeted DELETE against the broken rows (the orphan / dangling counts in the report tell you the expected delete count — confirm before commit). Re-run the reconciliation report to confirm the counts dropped to zero.
- If drift persists across runs and can’t be attributed to out-of-band edits, treat it as a sync-engine bug — capture the reconciliation snapshot + run history and open a support ticket.
Import drift detection
The on-demand report above is the point-in-time snapshot. The
engine also reconciles continuously during import. On every
join-hit refresh — each time an incoming source object re-matches
an existing mesh object — the engine asks the shared
ExportProjectionBuilder a single question: “given the mesh
object’s full attribute set, would export write anything to the
target right now?” If the answer is yes, the object’s outbound
state has drifted from what the target holds, and the engine
auto-enqueues an ImportDrift export to close the gap.
The important property is that the drift detector and the
actual export share one code path — the same
ExportProjectionBuilder. They project from the same full mesh
attribute set, so they cannot disagree. This is what fixed the old
false-positive churn, where a detector projecting from a partial
import bag would flag “drift” that the exporter, projecting from
the full DB attributes, then declined to write — parking the queue
at thousands of Pending items that never produced a real export.
Now a detected drift always corresponds to a real pending write,
and a clean object is never enqueued.
Operationally this means import-driven exports appear in the queue tagged as ImportDrift without an explicit schedule triggering them — that is expected self-healing, not churn. If you see ImportDrift items that never drain, treat it as an export-side problem (target unreachable, connector disallowed under export staging), not a detector bug.
Composer membership reconciliation
Dynamic groups built by MeshComposer match members by criteria. A group can be exported to its target before all of its members are resolvable — a member’s mesh object may not yet carry the anchor the target needs (it joins on a later import), so the group lands at the target with an empty or partial membership and is not automatically revisited when the members become resolvable.
A periodic self-heal pass reconciles this: it re-queues dynamic groups whose members only became resolvable after the group was first exported, so the membership is re-projected and the target group fills in. This is a durable reconciliation pass — it runs on its own cadence and closes the gap without an operator re-running the composer rule by hand.
If a group’s membership at the target looks short right after onboarding, this pass is what corrects it on the next cycle; a membership that stays short across several cycles points at an unresolved member (a join that never landed), which the orphan / dangling signals above will surface.
Operations posture dashboard
The Admin UI surfaces all of the above in one place at
/identity/operations. Rather than curl the endpoints, an
operator gets a single red / amber / green posture view:
- Overall posture — a rolled-up health signal (green = coherent, amber = drift within threshold, red = orphans / dangling rows / failed chain) so an on-call operator can triage at a glance.
- License / grace status — current license tier and whether the deployment is inside its grace window, alongside the posture (a lapsed license is itself an operational condition).
- One-click chain verification — runs the hash-chain verifier
(see
audit-chain.md) and shows pass/fail without leaving the page. - Drift listings — the orphan mesh objects, dangling-management-space rows, and run-vs-mesh drift from the reconciliation report, rendered as actionable lists rather than raw counts.
The dashboard reads the same audit.read-gated reconciliation and
audit surfaces described here — it is a presentation over them, not
a separate data path.
Permissions
| Permission | Endpoints | Recommended role |
|---|---|---|
audit.read | GET /api/admin/reconciliation, GET /api/admin/reconciliation/{id} | Operator, Auditor |
The reconciliation endpoint is gated on the same audit.read
permission as the audit chain verifier (audit-chain.md) and the
audit log itself — same audience, same evidence-grade trust level.
Compliance mapping
| Standard | Control | What this satisfies |
|---|---|---|
| SOC 2 | PI1.1, PI1.2 (Processing Integrity) | Demonstrable sync-completeness signal with on-demand evidence |
| SOC 2 | PI1.4, PI1.5 | Detection of incomplete or inaccurate processing |
| ISO 27001:2022 | Annex A 8.16 (Monitoring activities) | Continuous monitoring of data-flow integrity |
| ISO 27001:2022 | Annex A 8.34 (Protection of information systems during audit testing) | Read-only signal safe to invoke during audit |
The reconciliation response is itself ephemeral — it’s recomputed
on each call and not persisted. For a long-term record, fold the
response into your monitoring system’s metrics history (the
reportedAtUtc field is the timestamp authoritative for the
snapshot).
Related
audit-chain.md— the hash-chained audit log that records every state change. Reconciliation reads only; audit-chain is the write-side evidence.audit-retention.md— how long historical run / audit rows survive (relevant when investigating drift against an older window).dsar-erasure.md— thetotalErasedMeshObjectsfield counts subjects erased per ER-048; this is the link between the privacy-rights surface and the reconciliation signal.maintenance-operations.md— the operator actions (uniqueness re-resolve, duplicate-orphan cleanup, export staging) you run once reconciliation surfaces a problem.