The governance model rapidvalue.eu →

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:

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:

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:

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:

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:

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:

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


Further reading: