Skip to content

feat(merge-users): dry-run/run framework, handler registry and coverage manifest (#17494) - #17535

Draft
Ludovic Bouges (ludovic) wants to merge 7 commits into
issue/17493from
issue/17494
Draft

feat(merge-users): dry-run/run framework, handler registry and coverage manifest (#17494)#17535
Ludovic Bouges (ludovic) wants to merge 7 commits into
issue/17493from
issue/17494

Conversation

@ludovic

@ludovic Ludovic Bouges (ludovic) commented Aug 5, 2026

Copy link
Copy Markdown
Member

Proposed changes

  • Adds the user reference register as a versioned constant: 100 rows, each with a stable id, a disposition, an entity and a field path. Row ids are never renumbered. The per-row analysis stays in the specification.
  • Adds the MergeHandler contract. A handler exposes compute() (reads only) and apply() (writes only); the engine derives both execution modes from them, so dry-run is compute and the real run is compute then apply. A handler has no second code path that could drift from the first.
  • Adds the handler registry. A handler declares the register rows it covers, the register version it was written against, and its read and write field sets. Registration rejects an unknown row id, a stale register version, a row already claimed by another handler, and a read set intersecting another handler's write set.
  • Adds the two-pass engine. Every handler computes, the full report is produced, then every handler writes. Handlers are never interleaved, so a handler's computation always observes the same platform state in both modes. Before writing, each handler recomputes and its plan fingerprint is compared against the dry pass; a mismatch aborts without writing, since the platform is required to be at rest during a merge. Read/write disjointness is re-checked before each run, not only at registration. A failure returns a FAILED result carrying the merge id rather than throwing, because what was applied is only readable from the journal.
  • Adds the coverage manifest, derived from the register rather than from the registered handlers, so an uncovered row is named rather than absent. is_complete is true only when every row is claimed. A disposition filter narrows the returned rows but never the counts. Exposed as userMergeCoverage and attached to every execution report.
  • Adds the bulk write primitive: conflicts: 'abort', wait_for_completion: true, refresh: true, throwing when failures is non-empty or version_conflicts is greater than zero, and returning the updated count.
  • Adds the execution journal: one entry per handler and per pass, opened before the handler runs and closed with its outcome, so a handler that throws leaves a FAILED entry. Stored in Redis with a 30 day TTL, keyed per entry and indexed in a sorted set globally and per merge id, following the existing playbook-execution pattern.

No handler is registered in this chunk, so a merge still performs no write. Handlers land from #17495 onward.

Related issues

How to test this PR

Stacked on #17507, so review the last 7 commits only. Enable the flag and start the platform:

export APP__ENABLED_DEV_FEATURES='["MERGE_USERS"]'
yarn start

With no handler registered, the coverage manifest must list all 100 register rows as uncovered, by name:

curl -s http://localhost:4000/graphql \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $OPENCTI_TOKEN" \
  -d '{"query":"{ userMergeCoverage { covered_count uncovered_count is_complete uncovered_rows { id disposition entity path } } }"}'

Filtering by disposition must narrow the returned rows while leaving the counts on the whole register:

curl -s http://localhost:4000/graphql \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $OPENCTI_TOKEN" \
  -d '{"query":"{ userMergeCoverage(disposition: TRANSFER) { covered_count uncovered_count uncovered_rows { id } } }"}'

Run a dry merge and read the journal back with the returned id:

curl -s http://localhost:4000/graphql \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $OPENCTI_TOKEN" \
  -d '{"query":"mutation { userMerge(sourceId:\"<source>\", targetId:\"<target>\", options:{dryRun:true}) { merge_id status report { coverage { is_complete } } } }"}'

curl -s http://localhost:4000/graphql \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $OPENCTI_TOKEN" \
  -d '{"query":"{ userMergeJournal(mergeId:\"<merge_id>\") { handler pass status updated } }"}'

The journal entries live in Redis and survive a platform restart for 30 days.

Automated coverage:

npx vitest run --config vitest.config.ci-unit.ts tests/01-unit/modules/userMerge
npx vitest run --config vitest.config.dev.ts tests/03-integration/10-modules/userMerge

Checklist

  • I consider the submitted work as finished
  • I tested the code for its functionality
  • I wrote test cases for the relevant use cases (coverage and e2e)
  • I added/updated the relevant documentation (either on GitHub or on Notion)
  • Where necessary, I refactored code to improve the overall quality

Further comments

48 unit tests and 21 integration tests. Notable cases: with zero handler registered the manifest lists all 100 register rows as uncovered by name; a filtered manifest keeps its counts on the whole register; a real pass whose recomputation diverges from the dry pass writes nothing; per-disposition register counts are asserted as literals rather than recomputed from the array.

Commit by commit:

Commit Contents
fc4278de user reference register, as a versioned constant (100 rows, v2)
c8fa927b MergeHandler contract and handler registry
5e539e76 bulk write primitive
68b90751 execution journal
422eeca9 two-pass engine
60558cc6 coverage manifest and its API
63ccabcb journal storage moved to Redis

The precedent in the platform, SanityOperation, exposes dryRun() and run() as two methods each implementation writes, relying on both happening to call the same helper. That is a convention, and conventions drift the day someone fixes a bug in one branch and forgets the other. Here there is a single selection function, so "dry-run equals real impact" cannot drift.

The engine cannot name what no handler covers unless it holds the full list
independently of the handlers. Without it a report can only show what is done,
never what is missing — and the blind spots go invisible exactly when they are
most numerous, at MVP delivery.

The register is the questions, the handlers are the answers. This list is fixed:
it describes the existing codebase, not what the merge can do. It does not grow
chunk by chunk; what grows is the set of rows claimed by a handler.

100 rows transcribed from register v2: transfer 39, invalidate 22, conditional 21,
retain 12, out-of-scope 6. Row ids are stable and are what handlers declare
coverage against.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Deviation from the SanityOperation precedent, deliberate: a handler does not
expose dryRun() and run(). It exposes one read-only compute() and one write-only
apply(), and the engine derives both modes — dry is compute, real is compute then
apply.

The precedent exposes both methods and relies on each implementation happening to
call the same helper. That is a convention, and conventions drift the day someone
fixes a bug in one branch and forgets the other. With a single selection function
a handler has no second code path to drift into, so "dry-run == real impact" is
structural rather than declarative.

Registration validates at import time, so a mistake surfaces when the platform
boots rather than when a merge is launched:
- coverage on a register row that does not exist
- a handler written against an older register version, since a row can be
  requalified without its id changing (v1 to v2 requalified 12 rows that way)
- two handlers claiming the same row
- read/write disjointness across handlers

Index scope is defined once at this level: platform indices plus the trash, which
is restorable and would otherwise re-inject source ids into live data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
None of the platform's existing bulk paths is usable for a merge, and this is
what the coverage manifest's real counts depend on.

elOperationForMigration polls the task until completed and logs the duration, but
never inspects failures or version_conflicts, and returns nothing: a task that
updates 3 000 documents out of 10 000 and conflicts on the rest is reported as a
success. The other paths run with conflicts: 'proceed' and silently skip
conflicting documents; one is fire-and-forget with no task follow-up.

This wrapper surfaces updated, total, failures and version_conflicts, and throws
on any non-empty failure or conflict. The platform is supposed to be at rest
during a merge, so a conflict means the execution precondition was violated —
precisely when to stop rather than carry on.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An interrupted merge must stay diagnosable. On a batch, a failure on the 57th user
leaves 56 completed merges, one partial and the rest untouched, and without a journal
nothing in the database says so.

The entry is opened before the handler runs, so a process killed mid-handler still
names where it stopped, and a handler that throws leaves a FAILED entry rather than
one stuck in RUNNING.

The journal is excluded from the handlers' own scope: it carries a creator_id in a
live index, and later chunks rewrite exactly that field, so a merge would otherwise
rewrite its own trace.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Every handler computes first, the complete report is produced, and only then does
any handler write. Interleaving -- compute A, write A, compute B -- would let B
observe what A wrote, so B's dry figure and B's real figure would describe
different platform states and dry-run == real impact would stop holding at the
second handler.

Before writing, each handler recomputes and the plan fingerprint is compared with
what the dry pass reported. The platform is required to be at rest during a merge,
so a divergence is not a race to retry: it means the premise of the operation is
false, and writing anyway would apply changes the operator never reviewed.

A failure returns a FAILED result rather than throwing, because what was and was
not applied is only readable from the journal, and that needs the merge id.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The manifest is derived from the register rather than from the handlers, so an
uncovered row is named instead of being absent. Built from the handlers alone, a
report can only show what was done: with a partial handler set -- the state of
every intermediate build -- it would look complete while leaving most of the
register untouched.

The coverage is attached to every execution report, not offered as a separate
opt-in query only, because three handlers succeeding reads as a complete merge
unless the report also says what the register still holds.

is_complete is what a later chunk reads to decide whether deleting the source
account is legitimate; a filtered view deliberately keeps its counts on the whole
register so it cannot claim completeness by narrowing the question.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the filigran team Item from the Filigran team. label Aug 5, 2026
@Filigran-Automation Filigran Automation (Filigran-Automation) changed the title [backend] Merge users — dry-run/run framework, handler registry and coverage manifest feat(backend): merge users — dry-run/run framework, handler registry and coverage manifest (#17494) Aug 5, 2026
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.86096% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 34.18%. Comparing base (319ad5d) to head (63ccabc).

Files with missing lines Patch % Lines
...graphql/src/modules/userMerge/userMerge-journal.ts 91.66% 2 Missing ⚠️
...raphql/src/modules/userMerge/userMerge-coverage.ts 96.55% 1 Missing ⚠️
...graphql/src/modules/userMerge/userMerge-handler.ts 90.90% 1 Missing ⚠️
Additional details and impacted files
@@               Coverage Diff               @@
##           issue/17493   #17535      +/-   ##
===============================================
- Coverage        34.39%   34.18%   -0.22%     
===============================================
  Files             3381     3386       +5     
  Lines           138611   137814     -797     
  Branches         37741    37256     -485     
===============================================
- Hits             47676    47109     -567     
+ Misses           90935    90705     -230     
Flag Coverage Δ
opencti-client-python 48.37% <ø> (ø)
opencti-front 10.98% <ø> (+0.01%) ⬆️
opencti-graphql 69.26% <97.86%> (-0.20%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The journal was an indexed internal object. Measured, that costs ~15 fields on a
mapping shared by every index and already at 2312/3000, permanently -- fields
cannot be removed without a reindex -- for a feature meant to run once.

It does not need to be indexed: what authorizes deleting the source account is the
coverage manifest, derived from the register and the registered handlers. The
journal is diagnostic, not evidential. It now follows the playbook-execution
precedent: a key per entry with a 30 day TTL, indexed in a sorted set globally and
per merge id.

This also removes a self-reference. As an indexed entity the journal carried a
creator_id in a live index -- the very field later chunks rewrite -- which forced
an explicit exclusion of its own entity type from the handlers' scope. That
exclusion is gone with it.

The GraphQL contract is unchanged, and reads are now immediate rather than
refresh-dependent, which is what the follow-up query needs during a run.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ludovic Ludovic Bouges (ludovic) changed the title feat(backend): merge users — dry-run/run framework, handler registry and coverage manifest (#17494) feat(merge-users): dry-run/run framework, handler registry and coverage manifest (#17494) Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

filigran team Item from the Filigran team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PR2 — Merge engine: handler registry + dry-run/run framework + coverage manifest

1 participant