Skip to content

Merging users

This page defines the operational procedure for executing user account merges on an OpenCTI platform. It is intended for platform administrators operating under maintenance conditions.


1. Overview & Guiding Principles

The User Merge capability reassigns data ownership, entity associations, collaborative links, and audit history from a source user onto a target user.

Guiding Principles

  • Manual, Unitary Execution (1-by-1): Merges are performed one pair at a time through GraphQL mutations. Batching is controlled externally by the operator (e.g. iterating over a CSV list), allowing human inspection of dry-run reports before each write.
  • Two-Pass Execution: Every merge runs an in-memory computation (dryRun: true) producing a full report before any write occurs. In a real pass (dryRun: false), the engine verifies that the platform state has not drifted before committing any update.
  • Platform at Rest: The merge engine relies on the platform being at rest. Ingestions, connectors, background workers and the scheduled platform tasks must all be stopped: a single concurrent write aborts the pass and leaves it partially applied.
  • Non-Destructive Merge, Gated Deletion: The merge operation itself disables the source account (account_status: Expired) and closes its access. Deleting the source account is a separate, explicitly gated operation requiring full coverage and zero pending references.
  • Never Delete the Source Account via Settings → Users: Merging an account disables it and marks it with merged_into. Deleting it through the standard web UI is rejected by the platform to avoid running cascades that destroy transferred dashboards and triggers. Source account deletion must be conducted exclusively through the dedicated, gated userMergeDeleteSource mutation.
  • Strict Idempotency: All merge handlers are strictly idempotent. If an execution is interrupted, re-running the merge on the same pair is a safe no-op on already applied data.

Warning

The platform must be at rest for the whole batch. A write that changes what the merge plans between its dry pass and its real pass (an ingestion, a worker, a scheduled task, a user action) makes the real pass refuse, with nothing written. A write on a document the real pass is rewriting aborts it half applied, and the merge has to be run again. Section 3.1 lists what to stop and how to check.


2. GraphQL API Reference & Parameter Specification

All queries and mutations require the BYPASS capability: * @auth(for: [BYPASS])


2.1 Mutations

Mutation userMerge

Executes or previews the merge of a source user into a target user.

mutation userMerge(
  $sourceId: ID!
  $targetId: ID!
  $options: UserMergeOptions
): UserMergeResult!
Arguments
Argument Type Required Default Description
sourceId ID! Yes — The internal ID or standard ID of the user account to merge away.
targetId ID! Yes — The internal ID or standard ID of the user account receiving data and ownership.
options UserMergeOptions No { dryRun: true, rightsStrategy: STRICT, acknowledgeExposureChange: false } Execution flags controlling dry-run mode, RBAC rights strategy, and safety acknowledgments.
Return Type: UserMergeResult
Field Type Description
id ID! Unique execution identifier (UUID) assigned to this merge run.
source_id ID! Resolved internal ID of the source user.
target_id ID! Resolved internal ID of the target user.
dry_run Boolean! true if this was a simulation run; false if writes were committed.
rights_strategy UserMergeRightsStrategy! The strategy applied (STRICT or UNION).
status UserMergeStatus! Outcome status: RUNNING, SUCCESS, or FAILED.
started_at DateTime! Timestamp when the merge run initiated.
completed_at DateTime Timestamp when the merge run terminated.
message String Error or diagnostic message if the execution failed or was blocked.
report UserMergeExecutionReport Detailed breakdown of changes per handler, alerts, and register coverage.
Sub-structure: UserMergeExecutionReport
  • merge_id: ID of the merge execution.
  • total_updated: Count of documents written (always 0 during a dry-run).
  • handlers: Array of UserMergeHandlerOutcome:
  • handler: Identifier of the handler (e.g. scalar-user-references, filter-user-references).
  • updated: Number of documents updated by this specific handler.
  • changes: Array of planned changes:
    • register_row_id: Register row answered for.
    • entity_type: Target entity type.
    • count: Number of entities or documents impacted.
    • exact: true if the count is strictly exact, false if estimated.
    • detail: Human-readable explanation of the change.
  • alerts: Array of UserMergeRightsAlert:
    • register_row_id: Register row raising the alert.
    • kind: Alert category (rights, marking, organization, exposure).
    • message: Detailed explanation of the alert.
    • blocking: true if this alert aborts the real pass unless acknowledgeExposureChange: true is provided.

Mutation userMergeDeleteSource

Permanently deletes the source user account document from the platform after a successful merge.

mutation userMergeDeleteSource(
  $sourceId: ID!
  $targetId: ID!
): ID!
Arguments
Argument Type Required Description
sourceId ID! Yes The internal ID of the merged-away source user account to delete.
targetId ID! Yes The internal ID of the target user that received the source's data.
Return Type
  • Returns ID! (the ID of the deleted source user account).
Safety Behavior
  1. Re-checks Readiness: Internally calls userMergeSourceDeletionReadiness. If allowed is false, throws a FunctionalError and aborts.
  2. Cascades Suppressed: Does not use userDelete, preventing accidental cascading deletions of Triggers, Dashboards, and Public Feeds that now belong to the target user.
  3. Session Purge & Audit: Kills residual Redis sessions and publishes an audit log event (deletes merged user <email>).

2.2 Queries

Query userMergeCoverage

Inspects the current completeness of the merge engine against the 100-row user reference register.

query userMergeCoverage($disposition: UserMergeDisposition): UserMergeCoverage!
Arguments
Argument Type Required Description
disposition UserMergeDisposition No Optional filter to view only rows with a specific disposition (TRANSFER, INVALIDATE, CONDITIONAL, RETAIN, OUT_OF_SCOPE). If omitted, returns all 100 rows.
Return Type: UserMergeCoverage
Field Type Description
total Int! Total number of rows in the register (100).
covered_count Int! Number of rows claimed by registered handlers.
uncovered_count Int! Total number of unclaimed rows (includes non-gating retained rows).
gating_uncovered_count Int! Number of unclaimed rows that gate source deletion (TRANSFER and CONDITIONAL). Must be 0 to allow source deletion.
is_complete Boolean! true when gating_uncovered_count === 0.
rows [UserMergeCoverageRow!]! Detailed inventory of register rows (row_id, label, path, disposition, covered, handler).

Query userMergeJournal

Retrieves execution logs recorded by handlers during merge runs.

query userMergeJournal(
  $mergeId: ID
  $first: Int
): [UserMergeJournalEntry!]!
Arguments
Argument Type Required Default Description
mergeId ID No null Target merge execution ID. If omitted, returns recent entries across all merge runs.
first Int No 20 Maximum number of entries to return (minimum: 1, maximum: 200).
Return Type: [UserMergeJournalEntry!]!
  • Array of log entries containing id, merge_id, source_id, target_id, handler, dry_run, status, started_at, completed_at, message.

Query userMergeSourceDeletionReadiness

Evaluates whether a source user account can be safely deleted.

query userMergeSourceDeletionReadiness(
  $sourceId: ID!
  $targetId: ID!
): UserMergeSourceDeletionReadiness!
Arguments
Argument Type Required Description
sourceId ID! Yes The internal ID of the source user account.
targetId ID! Yes The internal ID of the target user account.
Return Type: UserMergeSourceDeletionReadiness
Field Type Description
allowed Boolean! true if and only if all three deletion preconditions are satisfied.
coverage_complete Boolean! true if all gating register rows are claimed by handlers.
pending_change_count Int! Number of changes a live dry-run on the pair still plans. Not a count of references: a moved right counts once removed from the source and once granted to the target. Must be 0.
blockers [String!]! Array of human-readable explanations explaining why deletion is refused. Empty when allowed is true.

2.3 Input Options & Enumerations

Input UserMergeOptions

input UserMergeOptions {
  dryRun: Boolean = true
  rightsStrategy: UserMergeRightsStrategy = STRICT
  acknowledgeExposureChange: Boolean = false
}
  • dryRun (Boolean, default: true):
  • true: Executes a simulation pass. Calculates all planned changes and alerts without modifying platform data; only the merge journal is written, in Redis. total_updated in the report will be 0.
  • false: Executes the real merge. Recomputes and verifies the plan against current platform state, then commits all updates across platform indices, Redis, and internal caches.
  • rightsStrategy (UserMergeRightsStrategy, default: STRICT):
  • STRICT: The target user's RBAC rights (groups, roles, capabilities, markings) remain strictly unchanged. Source memberships and permissions are discarded.
  • UNION: The target user inherits the source user's group memberships, organization affiliations, and capabilities.
  • acknowledgeExposureChange (Boolean, default: false):
  • Must be explicitly passed as true in a real pass (dryRun: false) if the dry-run reported a blocking alert (blocking: true).
  • Two situations raise a blocking alert, under either strategy: a public feed, TAXII collection or live stream owned by the source would serve data with the target's access, and the target gains markings, organizations, groups or capabilities the source did not have; or elements list the source as an authorized member and move to a target without the KNOWLEDGE_KNUPDATE_KNMANAGEAUTHMEMBERS capability.

Enum UserMergeRightsStrategy

  • STRICT: Target permissions are kept intact; source permissions are dropped.
  • UNION: Target permissions are expanded by unioning source groups and organizations.

Enum UserMergeStatus

  • RUNNING: Merge pass is currently executing.
  • SUCCESS: Merge pass completed with zero fatal errors.
  • FAILED: Merge pass aborted due to pre-condition failure, unacknowledged alert, or database error.

Enum UserMergeDisposition

  • TRANSFER: Reference must be re-pointed to the target user.
  • INVALIDATE: Reference/access must be revoked, closed, or dropped.
  • CONDITIONAL: Dependent on lifecycle status or rights strategy.
  • RETAIN: Reference must remain untouched (historical record or own account identity).
  • OUT_OF_SCOPE: Not managed by user merge.

3. Mandatory Preconditions

Before initiating a merge batch, verify and fulfill every requirement in this checklist.

3.1 Platform at Rest

The merge rewrites documents in place. Elasticsearch refuses an update whose document version moved since the pass started, and the merge is deliberately configured to abort on that conflict rather than overwrite a concurrent change. A single object written by anything else during the window is therefore enough to stop the pass — and, because a bulk rewrite is not transactional, to leave it partially applied. Re-running is safe, but only once the platform is genuinely idle.

"At rest" means no writes at all, from any source: ingestion, background workers, scheduled platform tasks, and interactive users.

Order of operations

Stop the producers before the consumers, so the queues drain instead of growing.

  1. Stop ingestion. Stop every connector and feed pushing data into the platform. Managed connectors and ingestion feeds can be paused from the platform itself; external connectors are stopped wherever they are deployed.
  2. Wait for the queues to drain. Let the running workers finish what is already queued.
  3. Stop the workers. Once the queues are empty, stop every worker process.
  4. Disable the scheduled platform tasks. See below — these write with no connector and no worker involved, and a platform restart is required for the change to take effect.
  5. Prevent interactive writes. Notify users and confirm no session is still active. Any UI action writes an entity and a history record.

Scheduled platform tasks

These run inside the platform process. Stopping connectors and workers does not stop them. Disable them in the configuration, then restart the platform.

Configuration key What it writes during the window
RULE_ENGINE__ENABLED Inferred entities and relationships
TASK_SCHEDULER__ENABLED Bulk operations queued from the interface
SYNC_MANAGER__ENABLED Everything pulled from a remote platform
INGESTION_MANAGER__ENABLED Everything pulled from TAXII, RSS, CSV and JSON feeds
PLAYBOOK_MANAGER__ENABLED Whatever the playbooks create or patch
RETENTION_MANAGER__ENABLED Deletions of expired elements
GARBAGE_COLLECTION_MANAGER__ENABLED Purges of deleted objects
EXPIRATION_SCHEDULER__ENABLED Revocation of expired indicators
INDICATOR_DECAY_MANAGER__ENABLED Indicator score updates
PIR_MANAGER__ENABLED PIR scoring updates
FILE_INDEX_MANAGER__ENABLED Indexed file content
NOTIFICATION_MANAGER__ENABLED Notifications, which carry user references
PUBLISHER_MANAGER__ENABLED Notification deliveries and digests
CONNECTOR_MANAGER__ENABLED Closes and deletes connector works, which carry the user that ran them
IMPORT_CSV_BUILT_IN_CONNECTOR__ENABLED CSV imports still queued: works, draft workspaces and files for the user who asked
DATA_SANITY_MANAGER__ENABLED Merges of duplicated entities when a sanity operation runs, rewriting their creators
HUB_REGISTRATION_MANAGER__ENABLED The Settings document, hourly on a registered platform, and news feed items per user

EXCLUSION_LIST_CACHE_BUILD_MANAGER__ENABLED and PLATFORM_USAGE_METRICS_MANAGER__ENABLED can stay on: they only read Elasticsearch and write to Redis. CATALOG_MANAGER__ENABLED only writes when a new connector catalog ships, then patches managed connectors: disable it too for a strictly frozen window.

Warning

Leave HISTORY_MANAGER__ENABLED and ACTIVITY_MANAGER__ENABLED on.

Both only ever append to opencti_history; neither updates an existing record, so neither can conflict with the rewrites. And the activity manager is the sole writer of the audit trace the merge itself emits (merges user <source> into user <target>). Disabling it would run the batch with no record that it happened.

Records written during the window are not rewritten either: the history payload handler cuts at the start of the first real merge on the pair, so the traces the merge produces are left as they are instead of being erased by a later pass.

Verifying the platform is at rest

Check from the platform itself rather than from the infrastructure, so the answer reflects what the platform actually sees. rabbitMQMetrics requires the MODULES capability.

query PlatformAtRest {
  # Expect every count at "0": nothing queued, nothing being processed.
  rabbitMQMetrics {
    consumers # Connected workers. Expect "0" once the workers are stopped.
    overview {
      queue_totals {
        messages
        messages_ready
        messages_unacknowledged
      }
    }
  }
  # A running connector refreshes updated_at on every heartbeat. Expect it to stop moving.
  # `active` is not enough on its own: a connector killed without unregistering stays true.
  connectors {
    name
    active
    updated_at
  }
  # Expect completed: true on every entry, or an empty list.
  backgroundTasks(first: 50) {
    edges {
      node {
        id
        completed
        task_expected_number
        task_processed_number
      }
    }
  }
}

Run the query twice, a few minutes apart. Identical counts and unchanged updated_at values confirm the platform is idle rather than merely slow.

3.2 Operator Account Safeguards

  • Capability Requirement: The operator executing the GraphQL requests must hold the BYPASS capability (Platform Administrator).
  • Identity Guard: The operator account must NOT be one of the source accounts being merged. An operator cannot merge themselves away without terminating their own session mid-batch.

3.3 Mandatory Snapshot & Backup

Danger

OpenCTI does not have an automated rollback mechanism. Merges write partial documents across live Elasticsearch indices. A snapshot is your only recovery path in the event of an unrecoverable operational mistake.

Create a restorable snapshot of: 1. Elasticsearch / OpenSearch cluster indices (all indices, including opencti_internal_objects, opencti_history, opencti_deleted_objects). 2. Redis key-value store.

Verify that the snapshot completed successfully before launching the first merge.

3.4 Reverse Proxy & Network Timeout Settings

The dry-run only counts: it returns in seconds, even for an account named in millions of documents. The real run rewrites every document naming the source, so its duration follows the activity of the account, not the size of the platform. Estimated on a platform of 550M documents:

Source account Documents rewritten Real run
Ordinary user up to a few hundred thousand under a minute
Very active user about 5M 5 to 15 minutes
  • The real run is synchronous. A run longer than the reverse proxy read timeout (Nginx, AWS ALB, Cloudflare, often 60 seconds) answers a 504 while it keeps running server-side, and so does one longer than app:request_timeout (20 minutes by default). Raising the proxy timeout (proxy_read_timeout) avoids the error, but do not rely on the HTTP response: follow the run in the journal (section 4.4), and apply section 6.1 on a timeout.
  • The count values of the dry-run report give the order of magnitude in advance.

4. Per-User Merge Procedure

For each account pair (sourceId, targetId), execute the following 4-step loop.

4.1 Step 1: Execute Dry-Run

Run the userMerge mutation with dryRun: true:

mutation UserMergeDryRun($sourceId: ID!, $targetId: ID!) {
  userMerge(
    sourceId: $sourceId
    targetId: $targetId
    options: {
      dryRun: true
      rightsStrategy: STRICT
      acknowledgeExposureChange: false
    }
  ) {
    id
    status
    source_id
    target_id
    rights_strategy
    report {
      total_updated
      handlers {
        handler
        changes {
          register_row_id
          entity_type
          count
          exact
          detail
        }
        alerts {
          register_row_id
          kind
          message
          blocking
        }
      }
    }
  }
}

4.2 Step 2: Review and Interpret the Report

Inspect the returned report.handlers:

  1. Verify Handlers & Counts:
  2. source-deactivation: Count is 1 (or 0 if the source was already disabled and carrying merged_into === targetId). Note that an account manually expired before the merge will still count as 1 because merged_into must be written.
  3. scalar-user-references: Documents where the source was creator_id, user_id, etc.
  4. connector.user-id (under scalar-user-references): connectors registered under the source account. A non-zero count means connectors that authenticate as the source, and stop working once its tokens are revoked: see Step 3 and section 7.
  5. filter-user-references: Number of saved filters, triggers, or feeds containing the source user UUID.
  6. blob-user-references: Dashboards, playbooks, and draft update patches rewritten.
  7. history-attribution & history-context-data-payload: Past events and audit logs being re-attributed. The payload handler rewrites the subject ids and the recorded changes of a record, which the platform shows and resolves into names; the raw payload (input, list_params, filters) is retained as recorded (history.context-data-raw-payload).
  8. operational-relations: Assignee/Participant links being re-pointed or deduplicated.
  9. residual-references: Runs last. Claims the register rows no handler acts on, each with the reason, and reads and writes nothing (updated: 0). The merge answers for the references the register records: a reference it does not record is not searched for, and calls for a new register row and handler.
  10. Inspect RBAC Differences & Rights Strategy:
  11. STRICT (Default & Recommended): The target user retains strictly their own groups, roles, and markings. Source memberships are dropped.
  12. UNION: Source groups, organizations, and capabilities are added to the target. Use only when the target must inherit existing source clearances.
  13. Inspect Alerts (blocking: true vs blocking: false):
  14. Public Sharing Exposure Alerts: If the source owned a public feed, TAXII collection or live stream, an alert is raised. It is blocking when the target's access is wider than the source's, under STRICT as well as UNION: an organization the target belongs to is enough. If blocking: true, you must pass acknowledgeExposureChange: true during Step 3.
  15. Restricted Members Alert: If the source has restricted members on objects but the target lacks authorization management rights, a blocking alert is raised.
  16. Textual Mentions / Corrupt Filters: Informational alerts indicating that an unparsable filter or a free-text search UUID was found and left untouched.

4.3 Step 3: Execute Real Merge

Execute the merge with dryRun: false. Leave acknowledgeExposureChange: false: if the dry-run reported a blocking alert, the engine refuses the real run and nothing is written. Set it to true only once that alert has been reviewed in Step 2 and the exposure change it describes is accepted.

mutation UserMergeApply($sourceId: ID!, $targetId: ID!) {
  userMerge(
    sourceId: $sourceId
    targetId: $targetId
    options: {
      dryRun: false
      rightsStrategy: STRICT
      acknowledgeExposureChange: false
    }
  ) {
    id
    status
    message
    completed_at
    report {
      total_updated
    }
  }
}

The real run revokes every API token of the source. A connector, feed or script that authenticates with one of them stops working: give it a token of the target, or better of a dedicated service account, before restarting it (see section 7).

4.4 Step 4: Verify Completion via the Journal

Verify the outcome recorded in the journal:

query UserMergeJournalCheck($mergeId: ID!) {
  userMergeJournal(mergeId: $mergeId, first: 50) {
    id
    handler
    dry_run
    status
    message
    started_at
    completed_at
  }
}

Confirm that every entry with dry_run: false completed with status SUCCESS. The entries with dry_run: true are the internal dry pass the real run starts with. The source account is now Expired, its API tokens revoked, sessions terminated, and its data references transferred to the target.


5. Source Account Deletion Procedure

Deleting the source account is an irreversible operation and must only be executed after the merge has succeeded and all references have been cleared.

5.1 Check Deletion Readiness

Warning

Delete the source within 30 days of the merge. The deletion gate reads the start of the first real merge on the pair from the merge journal, which expires after 30 days. Past that, the merge's own traces count as pending and the gate refuses; the only way out is a new real run, which rewrites those traces.

Query the deletion gate before attempting deletion:

query UserMergeCheckReadiness($sourceId: ID!, $targetId: ID!) {
  userMergeSourceDeletionReadiness(sourceId: $sourceId, targetId: $targetId) {
    allowed
    coverage_complete
    pending_change_count
    blockers
  }
}

Gate Criteria

The deletion gate enforces three mandatory conditions: 1. coverage_complete === true: Every gating register row (transfer and conditional, 62 of the 100) is claimed by a handler — this is gating_uncovered_count === 0. covered_count is expected to stay below 100 and is not what the gate reads: invalidate, retain, and out-of-scope rows are outside the gate. 2. pending_change_count === 0: A live dry-run on the pair plans no change, so nothing still points to the source user. 3. merged_into === <targetId>: The source carries the mark a real merge into this target wrote on it.

If allowed is false, review blockers. Re-run the merge if changes are still planned.

Danger

Never delete a merged source account through Settings → Users.

The merge disables the source, it does not delete it, so the account remains listed with its ordinary delete button. That button runs four cascades — triggers and digests, workspaces, notifications, public dashboard sharing — which all select by a reference to the account being deleted. After a complete merge they find nothing. If the merge missed a reference, they delete the trigger or the dashboard that carries it, and those objects now belong to the target. A gap a re-run would repair becomes a permanent loss.

The platform refuses that button on any account carrying merged_into. Delete the source with userMergeDeleteSource (section 5.2), and do not clear merged_into to work around the refusal.

5.2 Execute Permanent Deletion

Once allowed: true, permanently delete the source account:

mutation UserMergeDeleteSource($sourceId: ID!, $targetId: ID!) {
  userMergeDeleteSource(sourceId: $sourceId, targetId: $targetId)
}

The mutation re-verifies readiness internally before deletion, deletes the User entity document, kills residual sessions, and emits an administrative audit log (deletes merged user <email>). It does not run the cascades, and it does not go through the ordinary deletion path, so the merged_into mark never has to be cleared.


6. Incident Response & Recovery Guide

6.1 Incident 1: HTTP 504 / Client Disconnection / Timeout

Warning

DO NOT RE-LAUNCH THE MERGE IMMEDIATELY. Neither Node.js nor Apollo cancels an in-flight mutation when a client disconnects. The merge is continuing server-side.

Procedure: 1. Retrieve the latest journal entries without specifying a mergeId:

query {
  userMergeJournal(first: 50) {
    merge_id
    source_id
    target_id
    handler
    dry_run
    status
    started_at
    completed_at
    message
  }
}
2. Identify the merge_id whose source_id and target_id match your pair. 3. Poll userMergeJournal(mergeId: "<merge_id>") until the residual-references entry with dry_run: false has completed. A real run journals its internal dry pass too, and that pass ends with the same handler before anything is written. 4. If all handlers succeeded, the merge is complete. Do not re-run.

6.2 Incident 2: Node Crash or Restart Mid-Merge

If the backend server process crashed or was restarted while a merge was writing:

  1. Safety Assessment: Writes are not atomic, but every handler is idempotent: re-running completes a partially applied merge.
  2. Procedure:
  3. Inspect userMergeJournal to find the last handler that completed.
  4. Re-run the merge with the exact same parameters (dryRun: false).
  5. The handlers that previously wrote will cleanly find 0 documents left to update (updated: 0), and the remaining handlers will complete.

6.3 Incident 3: Platform Divergence Error

Error Message: Platform state changed between the dry pass and the real pass, nothing was written

  • Root Cause: The engine recomputes all handler plans immediately before the first write. If any document count or plan fingerprint diverges from the dry pass, the engine refuses to write to prevent inconsistent state.
  • Typical Triggers: Ingestion workers were left running and modified entities; an active user logged in and created references.
  • Resolution:
  • Verify workers and connectors are stopped.
  • Run a fresh dry-run (dryRun: true).
  • Inspect the updated report and proceed with the real run.

6.4 Incident 4: Unacknowledged Blocking Alerts

Error Message: Merge blocked by unacknowledged alerts, nothing was written

  • Root Cause: A handler detected a change of exposure or of rights management (see section 2.3): a public endpoint served with a wider access, or authorized members moving to a target that cannot manage them.
  • Resolution: Review the dry-run alerts. If the security change is intended, pass acknowledgeExposureChange: true in options.

7. Closing the Batch & Restoring Traffic

Once all accounts in the batch have been processed:

  1. Post-Merge Verification Checks:
  2. Log in as the target user.
  3. Verify that the source user's dashboards, investigation workspaces, cases, and incidents are visible and editable.
  4. Check the Activity Log to confirm audit traces (merges user <source> into user <target>).
  5. Restore the platform: Reverse the steps of section 3.1, in the opposite order — re-enable the scheduled platform tasks and restart the platform, start the workers, then start the connectors and ingestion feeds last, so nothing is queued before there is a consumer for it. Before starting a connector or a feed, check that it does not authenticate with a token of a merged source account: those tokens are revoked, and the connector.user-id count of the dry-run named the connectors concerned. Re-run the verification query of that section: the worker count should be back to its nominal value.
  6. Re-open Platform Traffic: Re-enable user access through the reverse proxy.

8. GraphQL Operations Cheatsheet

Check Register Coverage

query CheckCoverage {
  userMergeCoverage {
    total
    covered_count
    uncovered_count
    gating_uncovered_count
    is_complete
  }
}

Full Merge Flow

# 1. Dry Run
mutation DryRun($src: ID!, $dst: ID!) {
  userMerge(sourceId: $src, targetId: $dst, options: { dryRun: true, rightsStrategy: STRICT }) {
    id
    status
    report { total_updated }
  }
}

# 2. Real Run — set acknowledgeExposureChange: true only after reviewing a blocking alert of the dry-run
mutation ApplyMerge($src: ID!, $dst: ID!) {
  userMerge(sourceId: $src, targetId: $dst, options: { dryRun: false, rightsStrategy: STRICT, acknowledgeExposureChange: false }) {
    id
    status
    report { total_updated }
  }
}

# 3. Check Deletion Readiness
query CheckReadiness($src: ID!, $dst: ID!) {
  userMergeSourceDeletionReadiness(sourceId: $src, targetId: $dst) {
    allowed
    blockers
    pending_change_count
  }
}

# 4. Delete Source User
mutation DeleteSource($src: ID!, $dst: ID!) {
  userMergeDeleteSource(sourceId: $src, targetId: $dst)
}