Reconciliation: Expected versus Actual
Most identity governance products treat reconciliation as a report: something that runs overnight, produces a list of differences, and leaves a human to act on it. Here it is the motor. Access that arrives because a rule said so is not written to a target by the feature that made the rule — it is written to the Expected state, and a reconciliation run converges the target towards it. One engine performs the comparison, and every safety limit on writing to a customer's systems sits on that one path.
This page is what that means in practice: where each side of the comparison comes from, what a verdict looks like, what deliberately does not wait for a run, and what stops a bad run from doing catastrophic damage.
The two sides
Expected is computed, not stored. For a given identity, a run recalculates it from live governance state every time:
- Context-role bindings — roles scoped to an organisational context the identity currently belongs to.
- Access grant policies — attribute-matched rules. Birthright access is a policy match, not a separate mechanism; a policy can also grant a bare account on a system without granting any entitlement on it.
- Role inheritance — the leaves reached by expanding a role the identity holds.
- SSO derivation — an assignment on an identity provider implied by access to the application it fronts.
- Explicit keeps — an approved access request, a direct administrative grant, a certification attestation, a materialised provisioning claim. These are one-time human or process decisions, so they are re-asserted from the grant's own record each run and persist until something explicit removes them.
- Lifecycle windows — a leaver's grace period, a mover's retention window.
The difference between derived and explicit sources is load-bearing. Derived sources are recomputed from scratch, so when a policy stops matching or a coupling is switched off, the source is pruned on the next run and the access is revoked. Explicit sources are never pruned that way — an approved request stays approved until something revokes it, it expires, or a review rejects it. A grant carries every source that justifies it, so "held because of a policy and because someone requested it" is representable, and stays correct when one of the two lapses.
Actual is not computed at all. It is the imported model — the accounts, entitlements and grants that connected systems reported at their last sync, together with the attribute values that came with them. A run reads that model; it does not query the target. This matters more than it sounds: reconciliation can only ever be as current as the imports feeding it. A verdict is a statement about what the last import saw, and the honest question to ask of any drift figure is when that import ran.
What a verdict looks like
A run classifies each account-and-entitlement pairing into exactly one of eight kinds of drift, in a fixed precedence — the most operationally urgent wins, and "in sync" is the absence of a kind rather than a ninth one:
orphan account (the account correlates to no identity) → disabled account (the account cannot carry access at all) → cardinality conflict (more assignments of a type than the type permits) → provision blocked (Expected wants access but there is no suitable account, or a failed job is in the way) → shadow (would be unapproved, but the identity already reaches it transitively — informational) → unapproved (in the target, not expected) → missing (expected, not in the target) → attribute drift (otherwise compliant, but a writable attribute differs).
Each item is then mapped onto the action the next run would take, from a closed
set: provision, revoke, write_back, wait_for_decision, keep. That mapping
is a function of the drift kind and the system's own policies — it classifies the
queue rather than simulating the engine. Four of the eight kinds always resolve to
wait_for_decision, because correlating an orphan, fixing an account, picking a
cardinality winner and clearing a blocker are human calls.
A worked verdict. Take one grant on an Active Directory connector configured
with unapproved_action = auto_remove:
| Grant | Finance-Reporting-RW on account ADAMS_J |
| Actual says | the account is a member of the group — the last import saw it |
| Expected says | nothing. The policy that used to grant it no longer matches this identity, and the policy source was pruned on this run |
| Sources remaining | none |
| Verdict | unapproved — in the target, expected by nothing |
| Planned action | revoke (from the system's auto_remove setting) |
| What the run did | buffered the revoke, evaluated the breaker across the whole run, executed it |
Change one input and the answer changes honestly. Had the grant also carried an
approved-request source, pruning the policy source would leave that one standing,
the verdict would be compliant, and nothing would be revoked. Had the system
been left on the default keep, the verdict would still read unapproved — the
drift is real and stays visible — but the planned action would be keep and no
write would happen. A verdict describes; the policy decides.
Why one engine rather than several
There are two shapes of provisioning. Convergence is "the rules say this should be true, make it true" — a policy match, a role expansion, an SSO coupling, a lifecycle event. Imperative is "do this now" — an administrator revoking a grant by hand, an emergency lockout.
Convergence-shaped changes are routed through reconciliation rather than each feature calling a connector on its own. The argument is not elegance:
- One answer to "why does this person have this?" The Expected state is the answer, and one engine put it there. Access that appears with no run behind it is invisible to every rollup that reads runs.
- One place the gates live. Batch approval, do-it-at-source sync ownership, the failed-jobs queue, account bootstrapping, the engine pause switch and the mass-revoke breaker are honoured because they sit on the write path a run uses. A second eager path honours whichever of them its author remembered.
The one convergence-shaped path that historically wrote eagerly is access-request approval. It can now be switched, per tenant, to write the approved item as an Expected entry with a request provenance and let a per-identity run provision it. The default is still the eager path — the recon-driven mode is opt-in, and a deployment that has not enabled it provisions approved requests inline. In recon mode, a request landing on a batch-approval-gated connector correctly ends in awaiting provisioning rather than reporting itself provisioned.
What deliberately does not wait for a run
Reconciliation is not the only way anything is ever provisioned, and it should not be:
- Emergency lockout revokes every grant and disables every account immediately, regardless of each connector's drift policy — overriding policy is the point, and waiting on a tick would defeat it. Lifting the lockout is a convergence: access returns by recomputing Expected and letting a run re-provision it.
- Break-glass calls the provisioning engine directly in both modes, because the panic button has to work when reconciliation itself is degraded. Its expiry can be handed back to reconciliation.
- Just-in-time elevation is a time-boxed activation, not convergence towards a steady state. How an elevation reaches the target is a per-tenant setting whose default is conservative; see Privileged access.
- Direct administrative grants and revokes, including the fix action on a drift item, are explicit imperatives.
All of these still go through the single provisioning engine and its gates. They simply do not queue behind a run.
The mass-revoke circuit breaker
A connector set to auto_remove revokes access that Expected no longer asserts.
That is correct, and also the most dangerous thing the product does — because the
failure mode is not one wrong revoke. It is a corruption that drops a large share
of Expected at once (a reset that orphaned policy references, a half-completed
sync, a bad configuration change), after which every affected grant reads as
unapproved and a run would revoke the lot.
The breaker exists for exactly that, and its shape matters:
- It is evaluated across the whole run, before any revoke executes. A bulk run
arms a guard and every
auto_removerevoke is buffered rather than performed. After the loop the guard knows the run's total projected revokes per connector, compares that with the connector's active in-target grant population, and only then decides. A breaker that latched after N revokes would stop the run partway through the damage; this one aborts before the first one. - Fail-safe means keep. On a trip the buffered revokes are discarded and the grants stay exactly as they are. Nothing is deleted, so nothing has to be restored — the run's decision is simply not to act. A critical, immutable audit event, an advisory finding and an administrator notification fire, and the trip is recorded on the run.
- The threshold is a percentage with an absolute floor. By default a run trips when the projected revokes both number at least 10 and exceed 25% of that connector's active in-target grants. The floor keeps small tenants and routine cleanups from being blocked; the percentage is what catches a systemic fault. Both are tenant-configurable, and an individual connector can carry a tighter percentage than the tenant default.
- Errors trip it. If the breaker cannot evaluate a connector — a failed count, an unexpected exception — that connector is treated as tripped and its grants are kept. A missing or zero baseline while revokes are projected is a data inconsistency, and is likewise treated as a trip. There is no path where an evaluation failure lets revokes through.
- A genuine bulk cleanup can be forced. An administrator can run with an explicit override; the breaker still evaluates and still records what it saw, it just does not abort. The override is per run, never a stored setting.
What the breaker is not: a guarantee that no wrong revoke happens. It bounds the blast radius of a single run. A revoke that is genuinely wrong but small — under the floor, or under the percentage — executes, and is corrected the way any other wrong revoke is. Nor is it armed on single-identity paths: a per-identity recompute, break-glass or an administrator's fix action revokes inline, unchanged, because a run over one identity has no aggregate for the breaker to reason about.
Run guards
Two overlapping bulk runs for the same tenant walk the same rows in different orders, which is a textbook database deadlock — reproduced by retrying the run button while a previous run was still in flight. So:
- One bulk run per tenant. A manual trigger while a run is in flight is refused outright (HTTP 409). The scheduler's daily safety-net run skips with a log line instead of stacking a second run on top.
- One sync run per connector, on the same pattern one level down.
- Stale runs are reconciled first. A crashed worker leaves a run marked running forever, which — with a guard in place — would block every future run. So before the concurrency check, any bulk run still unfinished after two hours (thirty minutes for a sync) is marked failed with an explaining message. The window is generous on purpose: a full-tenant applying run makes real connector calls, and there is no heartbeat, so the queue time is the only anchor available.
The baseline exemption
A connector's first sync cannot trip a circuit breaker. This concerns the sync-diff breakers — the ones watching for an implausible share of changes, deletions or creations in a single import — rather than the mass-revoke breaker above.
Those breakers compare an import against prior state, and on a first import there is none. Every record is new, which is exactly what a hundred percent change rate means and exactly what an initial import is; evaluating it would guarantee a trip on the one occasion when nothing is wrong. The exemption keys on that connector's own first successful sync, not on the tenant having no data — adding a connector to an established tenant is still a first import, and its wholly-new population must not read as an anomaly in the tenant's totals.
What happens to unapproved access
Each connected system decides, and the default is not to delete:
keep(default) — the drift is recorded and visible; nothing is written.auto_certify— a review is opened and a human decides.auto_remove— the access is revoked in the target, subject to the breaker above.
Attribute drift is governed separately and defaults to auto_fix — write the
expected value back — with review (never write, always raise a job) and keep
(visible only) as the alternatives. Even under auto_fix a write only happens if
the attribute passes three independent allow-lists: the tenant's propagation list,
the system's own mappings, and the account type's writable attributes. An attribute
missing from any one is skipped silently, by design, so a narrow allow-list
looks identical to no drift.
Finally, a tenant-wide pause switch stops the reconciliation engine writing anything at all — on every apply path, not some of them.
Known edges, published rather than discovered
- Actual is an import, not a live read. Every verdict is relative to the last sync of the system in question. A connector that has not synced recently produces confident, stale drift.
- Recon-driven access-request provisioning is opt-in. The shipped default still provisions an approved request eagerly. Ask which mode a deployment runs before reading its provenance.
- Imperative paths bypass the run by design, listed above. They share the provisioning engine and its gates, not the run's aggregate protections — which means the mass-revoke breaker does not cover them.
- The breaker bounds a run, not correctness. Under the floor or under the percentage, revokes proceed. It is a limit on blast radius, not a review.
- Silent skips in attribute propagation. An attribute not present in all three allow-lists is not written and not reported as blocked.
- Four drift kinds only ever wait for a human — orphan accounts, disabled accounts, cardinality conflicts and blocked provisioning. Runs will keep reporting them until somebody acts; no amount of running resolves them.
Further reading: