MVO Deletion and Grace Period¶
Last updated: 2026-08-02, JIM v0.14.0
This diagram shows the full lifecycle of Metaverse Object (MVO) deletion, from the trigger event (CSO disconnection) through deletion rule evaluation, grace period handling, mode-aware rejoin cancellation, and deferred housekeeping cleanup.
Deletion Rules¶
| Rule | Value | Trigger | Behaviour |
|---|---|---|---|
| Manual | 0 | Never | MVO is never automatically deleted. Requires admin intervention. |
| WhenLastConnectorDisconnected | 1 | All CSOs disconnected | MVO deleted when no CSOs remain joined. Default rule. |
| WhenAuthoritativeSourceDisconnected | 2 | Selected source system(s) disconnect | MVO deleted when the systems in DeletionTriggerConnectedSystemIds disconnect, per the configured trigger mode below, even if other CSOs remain. |
Authoritative Source Trigger Modes (#119)¶
WhenAuthoritativeSourceDisconnected carries an AuthoritativeSourceTriggerMode (MetaverseObjectType.DeletionTriggerMode) controlling how the selected sources trigger deletion:
| Mode | Value | Behaviour |
|---|---|---|
| SpecificSourcesDisconnect | 0 | Delete when ANY one of the selected sources disconnects, even if others remain connected. Pre-#119 behaviour; existing configurations read the column default and keep it. |
| AllSourcesDisconnect | 1 | Delete only once NO selected source retains a joined CSO. Non-source connectors (targets) never block or trigger deletion. Default for new configurations (the model property initialiser). |
The enum-numeric/property-initialiser split is deliberate: existing rows read the added column's default 0 (SpecificSourcesDisconnect), preserving #115 behaviour with no backfill, while entities newly constructed in code, the portal, or the API start at the safe default (AllSourcesDisconnect).
When a deletion is scheduled or executed, the MVO records DeletionTriggeredBySystemId and DeletionTriggeredBySystemName (a name snapshot that survives deletion of the system itself) plus DeletionPolicySnapshotJson, the decision-time policy snapshot (MvoDeletionPolicySnapshot: rule, trigger mode, selected sources, grace period, triggering system, and the listed sources still connected at decision time). The same snapshot is written to the outcome-bearing RPEI (ActivityRunProfileExecutionItems.DeletionPolicySnapshotJson) whenever a deletion rule evaluation records an outcome, including evaluated-but-not-triggered decisions, so decisions stay explainable after the configuration changes.
Trigger: CSO Disconnection During Sync¶
flowchart TD
Start([CSO becomes obsolete<br/>during sync]) --> Joined{CSO joined<br/>to MVO?}
Joined -->|No, NotJoined| QuietDelete[Delete CSO quietly<br/>Already disconnected]
Joined -->|No, other JoinType| DeleteWithRPEI[Create Deleted RPEI<br/>Queue CSO for deletion]
Joined -->|Yes| OosAction{InboundOutOfScope<br/>Action?}
OosAction -->|RemainJoined| KeepJoin[Delete CSO<br/>Preserve MVO join state<br/>Once managed always managed<br/>No deletion evaluation]
OosAction -->|Disconnect| RemoveAttrs{RemoveContributed<br/>AttributesOnObsoletion<br/>enabled on object type?}
RemoveAttrs -->|Yes| RecallAttrs[Attribute Recall + re-election:<br/>Mark MVO attributes where<br/>ContributedBySystemId = this system for removal<br/>Re-elect next-priority surviving contributor<br/>Attribute with no survivor is cleared,<br/>or frozen if a deletion grace period is active]
RemoveAttrs -->|No| BreakJoin
RecallAttrs --> QueueRecall[Queue MVO for export evaluation<br/>with recalled + re-elected values<br/>Targets receive removals or a<br/>change-of-value to the survivor]
QueueRecall --> BreakJoin[Break CSO-MVO join<br/>Set JoinType = NotJoined]
BreakJoin --> GetRemaining[Get joined Connected System ids<br/>one entry per remaining CSO,<br/>excluding the disconnecting CSO]
GetRemaining --> EvalDeletion[ISyncEngine.EvaluateMvoDeletionRule<br/>Pure decision on MVO fate]
Deletion Rule Evaluation¶
flowchart TD
Start([ISyncEngine.EvaluateMvoDeletionRule]) --> CheckOrigin{MVO Origin?}
CheckOrigin -->|Internal| Protected([Skip - internal MVOs<br/>protected from automatic deletion])
CheckOrigin -->|Projected| GetRule{Deletion<br/>rule?}
GetRule -->|Manual| NoAction([No automatic deletion])
GetRule -->|WhenLastConnector<br/>Disconnected| CheckRemaining{Remaining<br/>CSOs > 0?}
CheckRemaining -->|Yes| NoActionYet([Not yet - other CSOs<br/>still connected])
CheckRemaining -->|No| MarkForDeletion[MarkMvoForDeletionAsync]
GetRule -->|WhenAuthoritative<br/>SourceDisconnected| CheckTriggers{DeletionTrigger<br/>ConnectedSystemIds<br/>configured?}
CheckTriggers -->|Empty| FallbackRule[Fall back to<br/>WhenLastConnectorDisconnected<br/>behaviour]
FallbackRule --> CheckRemaining
CheckTriggers -->|Has entries| IsAuthSource{Disconnecting system<br/>in trigger list?}
IsAuthSource -->|No| NoAction2([Not an authoritative source<br/>No deletion])
IsAuthSource -->|Yes| CheckMode{Deletion<br/>trigger mode?}
CheckMode -->|SpecificSources<br/>Disconnect| MarkForDeletion
CheckMode -->|AllSources<br/>Disconnect| AnySourceLeft{Any listed source<br/>still holds a<br/>joined CSO?}
AnySourceLeft -->|Yes| NoAction3([Not yet - other sources<br/>remain connected])
AnySourceLeft -->|No| MarkForDeletion
The evaluation receives the Connected System id of every CSO still joined after the disconnection (GetJoinedConnectedSystemIdsByMetaverseObjectIdAsync, one raw SQL query replacing the previous count-only query), so All mode can test the remaining ids against the trigger list without an extra round trip. The decision-time policy snapshot is built from these same facts before the decision is applied.
Grace Period Decision¶
flowchart TD
Mark([MarkMvoForDeletionAsync]) --> RecordTrigger[Record triggering system:<br/>DeletionTriggeredBySystemId<br/>DeletionTriggeredBySystemName]
RecordTrigger --> CheckGrace{Grace period<br/>on MVO type?}
CheckGrace -->|Null or TimeSpan.Zero| DedupCheck{MVO already queued<br/>for deletion<br/>in this page?}
DedupCheck -->|Yes| Skip([Skip - prevent<br/>double-queueing])
DedupCheck -->|No| Immediate[Add MVO to<br/>pendingMvoDeletions batch<br/>Deleted at page boundary]
CheckGrace -->|> 0| Deferred[Set LastConnectorDisconnectedDate<br/>= DateTime.UtcNow<br/>Capture initiator info:<br/>DeletionInitiatedByType<br/>DeletionInitiatedById<br/>DeletionInitiatedByName<br/>Persist DeletionPolicySnapshotJson<br/>at mark-time<br/>Persist via UpdateMetaverseObjectAsync]
Deferred --> WaitForHousekeeping([Deferred to housekeeping<br/>Eligible after grace period expires])
Mode-Aware Rejoin Cancellation (#119)¶
A rejoin during the grace period only cancels a scheduled deletion when the rejoin falsifies the mode's trigger condition. ISyncEngine.ShouldCancelScheduledDeletion(mvo, rejoiningSystemId) is the pure decision, applied at both cancellation paths: EstablishJoinAsync (a CSO joins a marked MVO) and the same-page reconnect check in FlushPendingMvoDeletionsAsync (an immediate deletion queued earlier in the page).
flowchart TD
Start([ISyncEngine.ShouldCancelScheduledDeletion]) --> GetRule{Deletion<br/>rule?}
GetRule -->|Manual or<br/>WhenLastConnector<br/>Disconnected| Cancel([Cancel - a connector now exists,<br/>the condition no longer holds])
GetRule -->|WhenAuthoritative<br/>SourceDisconnected| CheckTriggers{Trigger list<br/>configured?}
CheckTriggers -->|Empty| Cancel
CheckTriggers -->|Has entries| HasTrigger{DeletionTriggeredBy<br/>SystemId recorded?}
HasTrigger -->|Null - marked<br/>pre-upgrade| Cancel
HasTrigger -->|Recorded| CheckMode{Deletion<br/>trigger mode?}
CheckMode -->|AllSources<br/>Disconnect| IsListed{Rejoining system<br/>in trigger list?}
IsListed -->|Yes| Cancel
IsListed -->|No| Keep([Keep scheduled - a non-source<br/>rejoin does not falsify<br/>the all-sources-gone condition])
CheckMode -->|SpecificSources<br/>Disconnect| IsTrigger{Rejoining system =<br/>recorded triggering<br/>system?}
IsTrigger -->|Yes| Cancel
IsTrigger -->|No| Keep2([Keep scheduled - the triggering<br/>disconnection has not been undone])
Cancellation clears every deletion marker together (ClearMvoDeletionMarkers): LastConnectorDisconnectedDate, the DeletionInitiatedBy* audit fields, DeletionTriggeredBySystemId/Name, and DeletionPolicySnapshotJson. The null-trigger fallback (cancel on any rejoin) covers rows marked before the triggering system was recorded, rather than stranding a scheduled deletion.
Immediate Deletion (Zero Grace Period)¶
flowchart TD
PageEnd([Page flush:<br/>FlushPendingMvoDeletionsAsync]) --> Capture[CaptureReferenceRecallContextAsync:<br/>Record who references the candidates and the<br/>candidates' per-system resolved reference values]
Capture --> Loop{More MVOs<br/>in batch?}
Loop -->|No| Recall[StageReferenceRecallExportsAsync:<br/>Stage membership-removal Pending Exports<br/>for objects that referenced the deleted MVOs]
Recall --> Done([Done])
Loop -->|Yes| EvalExports[EvaluateMvoDeletionAsync:<br/>Create delete Pending Exports for CSOs<br/>whose export rule action is Delete]
EvalExports --> DeleteMVO[DeleteMetaverseObjectAsync<br/>with initiator info]
DeleteMVO --> Success{Success?}
Success -->|Yes| Loop
Success -->|No| Fallback[Set LastConnectorDisconnectedDate<br/>as fallback so housekeeping<br/>can retry later]
Fallback --> Loop
Deferred Deletion (Housekeeping)¶
flowchart TD
Idle([Worker idle<br/>every 60 seconds]) --> Query[GetMetaverseObjectsEligibleForDeletionAsync<br/>Max 50 per cycle]
Query --> Criteria[Eligibility criteria:<br/>1. Origin = Projected<br/>2. LastConnectorDisconnectedDate != null<br/>3. Grace period expired<br/>4. Rule-specific checks]
Criteria --> RuleCheck{Deletion<br/>rule?}
RuleCheck -->|WhenLastConnector<br/>Disconnected| NoCSOs{No CSOs<br/>remaining?}
NoCSOs -->|Yes| Eligible
NoCSOs -->|No| NotEligible([Skip - CSOs reconnected<br/>during grace period])
RuleCheck -->|WhenAuthoritative<br/>SourceDisconnected| Eligible[Always eligible once marked<br/>May still have target CSOs]
Eligible --> Capture[CaptureReferenceRecallContextAsync:<br/>Record who references the candidates and the<br/>candidates' per-system resolved reference values]
Capture --> Loop{More eligible<br/>MVOs?}
Loop -->|No| Recall[StageReferenceRecallExportsAsync:<br/>Stage membership-removal Pending Exports<br/>for objects that referenced the deleted MVOs]
Recall --> Done([Done])
Loop -->|Yes| EvalExports[EvaluateMvoDeletionAsync:<br/>Create delete Pending Exports for remaining CSOs<br/>whose export rule action is Delete]
EvalExports --> DeleteMVO[DeleteMetaverseObjectAsync<br/>Uses ORIGINAL initiator info<br/>from when MVO was marked<br/>Copies the mark-time<br/>DeletionPolicySnapshotJson onto<br/>the deletion record's RPEI]
DeleteMVO --> Result{Success?}
Result -->|Yes| Loop
Result -->|No| LogError[Log error<br/>Continue with other MVOs<br/>Will retry next cycle]
LogError --> Loop
State Diagram¶
stateDiagram-v2
[*] --> Normal: MVO created via<br/>projection or internally
Normal --> MarkedForDeletion: CSO disconnects,<br/>deletion rule triggers,<br/>grace period > 0
Normal --> [*]: CSO disconnects,<br/>deletion rule triggers,<br/>grace period = 0<br/>(immediate deletion)
MarkedForDeletion --> Normal: Trigger-cancelling rejoin<br/>during grace period<br/>(ShouldCancelScheduledDeletion true,<br/>all deletion markers cleared)
MarkedForDeletion --> MarkedForDeletion: Non-cancelling rejoin<br/>(trigger condition still holds,<br/>markers unchanged)
MarkedForDeletion --> [*]: Grace period expires,<br/>housekeeping deletes MVO
note right of Normal
Origin = Projected or Internal.
Internal MVOs are protected
from automatic deletion.
end note
note left of MarkedForDeletion
LastConnectorDisconnectedDate set.
DeletionEligibleDate =
DisconnectedDate + GracePeriod.
Original initiator info preserved.
DeletionTriggeredBySystemId/Name
and DeletionPolicySnapshotJson
recorded at mark-time.
end note
Key Design Decisions¶
-
Internal MVO protection
MVOs withOrigin = Internal(admin accounts, service accounts created directly in JIM) are never subject to automatic deletion, regardless of the deletion rule configured on the object type. -
Mode-aware grace period reconnection (#119)
A CSO reconnecting to a marked MVO only cancels the scheduled deletion whenShouldCancelScheduledDeletionsays the mode's trigger condition no longer holds: any rejoin forWhenLastConnectorDisconnected; any listed source for All mode; only the recorded triggering system for Specific mode. Cancellation clears every deletion marker together; a non-cancelling rejoin leaves the MVO scheduled. Rows marked before the triggering system was recorded fall back to the pre-#119 cancel-on-any-rejoin behaviour. -
Trigger recording (#119)
Scheduling a deletion recordsDeletionTriggeredBySystemIdandDeletionTriggeredBySystemNameon the MVO. The id makes Specific-mode cancellation precise (re-deriving "is some source still disconnected" from current state is impossible without join history); the name snapshot survives deletion of the system itself and feeds the Pending Deletions page's "Triggered By" column. -
Decision-time policy snapshot (#119)
Every deletion rule evaluation that records an outcome (scheduled, deleted, or evaluated-but-not-triggered) writes anMvoDeletionPolicySnapshotto the RPEI, capturing the rule, trigger mode, selected sources, grace period, triggering system, and the sources still connected at decision time. For grace period deletions the snapshot is captured at mark-time on the MVO and copied onto the housekeeping deletion record at execution, so the final record reflects the policy that scheduled the deletion, not the configuration at execution time. The RPEI detail page renders deletion rule context from this snapshot; legacy records without one fall back to current configuration with a caveat. -
Connected System deletion path parity (#119)
MarkOrphanedMvosForDeletionAsync(invoked when a Connected System is deleted with "evaluate deletion rules" enabled) applies the same mode semantics when deciding which MVOs the system's removal orphans: in All mode, deleting one of two still-connected sources does not mark MVOs whose other source remains. Preview and execution share one query (QueryMvosOrphanedByConnectedSystemDeletion), soConnectedSystemDeletionPreviewcounts always agree with what execution does, and the marking records the deleted system as the trigger with a policy snapshot, exactly as the worker path does. -
Initiator preservation
When an MVO is marked for deferred deletion, the original initiator info (who/what caused the disconnection) is captured on the MVO. When housekeeping eventually deletes it, this original initiator is used in the audit trail, not "housekeeping" or "system". -
Export cleanup before deletion
Both immediate and housekeeping deletion paths callEvaluateMvoDeletionAsync()before the actual deletion. This creates delete Pending Exports for every CSO matched by an export Synchronisation Rule whoseOutboundDeprovisionActionisDelete, regardless of how the CSO was joined, ensuring the external system is cleaned up. CSOs with no matching rule, or whose rules sayDisconnect(the default), are disconnected and left in place in the target system. -
Reference recall after deletion (#908)
Both deletion paths also stage membership-removal Pending Exports for every Metaverse Object that referenced a deleted one (for example groups whose Static Members included a deleted leaver). The referencing linkage and the deleted objects' per-system resolved reference values (for example target DNs) are captured viaCaptureReferenceRecallContextAsync()before deletion, becauseDeleteMetaverseObjectAsync()nulls the reference FKs andEvaluateMvoDeletionAsync()disconnects the CSOs. After the deletions,StageReferenceRecallExportsAsync()evaluates each referencing object once with every reference it lost in the batch, staging Remove changes whose values are pre-resolved at staging time; export-time resolution walks MVO to joined CSO and can never succeed for a deleted object. Without this recall, targets without referential integrity would keep deleted users as group members forever, because the referencing groups' CSOs never change and the unchanged-skip means no sync re-evaluates them. -
Fallback on failure
If immediate deletion fails (e.g., database error), the system setsLastConnectorDisconnectedDateas a fallback. This ensures housekeeping will pick up the MVO for retry on the next cycle, rather than losing the deletion intent. -
Capped housekeeping
Housekeeping processes a maximum of 50 MVOs per cycle (every 60 seconds). This prevents large deletion backlogs from monopolising the worker during idle time. -
WhenAuthoritativeSourceDisconnected fallback
IfDeletionTriggerConnectedSystemIdsis empty, the rule falls back toWhenLastConnectorDisconnectedbehaviour in both trigger modes (evaluation and cancellation alike). This prevents misconfiguration from causing unexpected deletions. -
Dedup within page
Multiple CSOs from the same MVO can disconnect in the same sync page. The dedup check inMarkMvoForDeletionAsyncprevents the same MVO from being queued for immediate deletion twice. -
Attribute recall, re-election and hand-over via ContributedBySystemId
MVO attribute values contributed by the disconnecting system (identified byContributedBySystemId) are recalled when both of the following hold:RemoveContributedAttributesOnObsoletionis enabled on the CSO type, and the MVO is not slated for immediate deletion (the immediate-deletion check avoids nugatory work when the MVO is about to be deleted at page flush, per #390). A configured deletion grace period no longer skips recall wholesale (Attribute Priority, #91): before clearing, a still-joined next-priority contributor is re-elected for each recalled attribute where one survives, so an authoritative source leaving hands the attribute to the next source (a change-of-value) rather than blanking it. Only an attribute with no surviving contributor is affected by the grace period: it is frozen (preserved) for the grace window rather than cleared, so identity-critical single-source values are not lost mid-grace. The diagram shows only the first gate for clarity. Recalled and re-elected values are queued for export evaluation so target systems receive the removals or the change-of-value; the only skip is for MVOs pending immediate deletion, whose Delete Pending Exports are created byFlushPendingMvoDeletionsAsync. -
IsPendingDeletion
An MVO is considered pending deletion when it hasLastConnectorDisconnectedDateset, hasOrigin = Projected(notInternal), and its type's deletion rule is eitherWhenLastConnectorDisconnectedorWhenAuthoritativeSourceDisconnected.