Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/developers/operations/identity-dedup-reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Identity dedup — duplicate-email reconciliation runbook (#2651)

**Status:** NOT auto-executed. Human-operator procedure. The dry-run numbers are reviewed with the maintainer before any mutation runs.

## Background

Pre-#2552 (and, until #2661, for IAS logins), the same human could get a second `Users` row — the login resolver minted a new row instead of matching the existing one on email. `reconcileDuplicateEmails` (`srv/jobs/identity-dedup-job.js`) walks duplicate-email `Users` clusters, picks the canonical row per cluster (`pickCanonicalRow`), and folds the others into it via `mergeAccounts`. ~349 clusters in PROD at the time of #2661.

## Why this is a manual step (not a scheduled/admin job)

`reconcileDuplicateEmails` is deliberately **NOT** registered in `JOB_REGISTRY` (`srv/jobs/scheduler.js`). Registry entries get BOTH a cron schedule AND a one-click admin `runJob` button — either path would let a stray trigger execute merges across all clusters with no dry-run gate. A mutating reconciliation must stay invocation-only, with the operator explicitly choosing `dryRun`.

## Invocation path

Run against the target environment's HANA via the documented hybrid/cf-env read path (same binding method used for PROD probes — move `.cdsrc-private.json` aside in the primary tree, write `default-env.json` from `cf env tutorials-srv`, confirm the connection authenticates as the intended prod container, restore afterward).

From a `cds repl` bound to the environment:
```js
const { reconcileDuplicateEmails } = await import('./srv/jobs/identity-dedup-job.js');

// STEP 1 — DRY RUN (no mutation). Capture the report.
const dry = await reconcileDuplicateEmails({ dryRun: true });
console.log(JSON.stringify(dry, null, 2));
// Expect roughly: { clusters, merges, pointsBefore, pointsAfter, capTrims, executed: false }
```

## STEP 2 — Review with maintainer (gate)

- `clusters` — matches the PROD dup-email count established during root-cause probing.
- `merges` — number of secondary rows to fold in.
- `pointsBefore` vs `pointsAfter` — the delta is points lost to the daily-5 / event-100 caps on collision. Some trim is expected and correct (ruling: trim to 100); a LARGE unexpected gap is a red flag — stop and investigate.
- `capTrims` — total points trimmed by the per-event 100-cap. Should be modest.

Only proceed to Step 3 after the maintainer approves the numbers.

## STEP 3 — EXECUTE (mutating)

Confirm `cf target` is the intended environment first. Then:
```js
const live = await reconcileDuplicateEmails({ dryRun: false });
console.log(JSON.stringify(live, null, 2)); // executed: true
```
Run `{ dryRun: false, limit: 1 }` first as a canary — collapse ONE cluster, verify it (below), then run the full batch.

## STEP 4 — Re-verify (close the loop)

1. **The reporter (see #2651 for the account):** previously-forked rows now resolve to a single consolidated row. `SELECT` their `Users` rows by email → one canonical row carrying the real sapId.
2. **Counter == leaderboard:** stranded `CatGameAwards` points now join a `GAMEBOARD_PARTICIPANT_V1` row (the inner-join to `EventRegistrations` was why stranded awards were invisible). `GAMEBOARD_BONUS_V1` points for that user now appear on the leaderboard and match the in-game counter.
3. **Sample a few other reconciled clusters** to confirm no over-merge (two distinct humans wrongly collapsed) — spot-check that merged clusters shared a real verified email, not a GitHub-synthetic noreply address (filtered by `NOREPLY_GITHUB_RE`, but confirm).

## Rollback note

`mergeAccounts` records every merge in `SecondaryAccounts` (status MERGED, `mergedAt`, `primaryAccount_ID`) — the audit trail of what was folded where, and the basis for any manual unwind if an over-merge is discovered. There is no automated un-merge; investigate via `SecondaryAccounts` before executing.
Loading