ADR 0113 — Account Merge Architecture
Status: Accepted Date: 2026-06-05 UTC (locked 2026-06-05; ADR updated 2026-06-10 to reflect D1–D4 operator decisions) Deciders: software-architect agent + operator (D1–D6 confirmed 2026-06-05) Scope: Raptor (identity + data merge engine), Console (CS UI), Antlers (no customer-facing UI), Queue (identity adapter)
Context
Customers occasionally create duplicate Raxx accounts. CS needs a safe, audited, cryptographically cross-verified flow to merge two accounts into one without exposing either account to unilateral hijack. The merge must handle every table in Raptor's schema with a deterministic policy (MERGE, PREFER_PRIMARY, ASK_USER, or SKIP), preserve the audit trail, respect GDPR Art. 17, and leave a tombstone for email-uniqueness enforcement after PII nulling.
Key constraints: no self-serve merge affordance for customers, mandatory cross-verification from both email addresses, CS-initiated and CS-reversible within 14 days.
Security-agent threat model (PR #3262) and detection-engineer behavioral-detection memo (PR #3261) both informed the locked decisions below. Full v2 design spec: docs/architecture/account-merge-2026-06-05.md.
Decision
Implement account merge as a new Raptor subsystem backed by three new tables (account_merges, user_redirects, tombstoned_emails) and a soft-delete extension to users. Cross-verification uses 8-character argon2id one-time codes (t=2, m=65536, p=2) with a 24-hour TTL sent to each account's verified email via Postmark. The merge engine executes as a single Postgres transaction keyed by account_merges.id. Console gains an "Account Merges" admin section gated behind fine-grained RBAC roles (see §Locked Decisions D1 below). The customer-facing surface is limited to the email verification links and a signed-cancel-token path; there is no self-serve affordance in Antlers. The feature ships behind FLAG_ACCOUNT_MERGE (default OFF). Post-Queue-cutover, the identity read path swaps to Queue via a MergeIdentityAdapter protocol without redesigning the merge engine.
Locked Decisions (D1–D4)
D1 — Primary designation is CS-only. No customer-callable swap.
Source: Operator decision 2026-06-05. Security-agent threat model PR #3262 §2.1, §5. Detection-engineer memo PR #3261 §5.
v1 proposal: Hybrid — CS nominates, customer may request one swap before first code is consumed.
Locked posture: CS-only. The POST /merges/{id}/swap-primary customer-facing route does not exist. Primary designation is immutable after initiation except by cancellation and recreation by CS.
Rationale: Security-agent identified that a customer-callable swap endpoint enables a one-inbox-compromise primary-flip attack (PR #3262 §2.1): an adversary who controls the secondary account's email can authenticate, call swap-primary before any code is consumed, elect themselves the surviving primary, then verify when the legitimate primary user also verifies. Making the swap CS-only eliminates this attack class. Detection-engineer independently ranked CS-only as the cleanest detection surface (PR #3261 §5): all anomaly detection concentrates on CS-actor signals where signal quality is higher.
RBAC decomposition (security memo §4.1): customers:merge:write from v1 is replaced with minimum-privilege separate permissions: customers:merge:read, customers:merge:initiate, customers:merge:cancel, customers:merge:reverse, customers:merge:approve_reversal. The last permission is operator-level and is distinct from the CS-level reverse permission.
D2 — Secondary account tombstoned immediately at merge completion. tombstoned_emails table enforces email uniqueness post-PII-nulling.
Source: Operator decision 2026-06-05. Security-agent threat model PR #3262 §2.7, Issue D2-1 (MEDIUM).
v1 proposal: Soft-delete secondary account for 90 days, then tombstone with PII nulling.
Locked posture: tombstoned_at set on the secondary users row at merge completion (not 90 days later). Tombstone nightly job nulls PII columns 15 days post-completion (after the 14-day reversal window closes + 1-day buffer). At the same time PII is nulled, an argon2id hash of the email is written to tombstoned_emails. Registration flow checks tombstoned_emails and surfaces "This email was previously used. Contact support." if matched. The 90-day soft-delete window is removed.
Rationale: After 90 days with a null email column, the uniqueness constraint no longer prevents re-registration at the same address (PR #3262 §2.7). A user whose secondary account was merged could register a new account at the same address and lose all merge context. tombstoned_emails maintains email uniqueness enforcement permanently without retaining PII.
D3 — 14-day reversal window with four-eyes requirement and pre-execution 24-hour customer notification hold.
Source: Operator decision 2026-06-05. Security-agent threat model PR #3262 §2.5, §3.4, Issues D3-1 (HIGH) + D3-2 (MEDIUM).
v1 proposal: 14-day window, CS-only, audit-logged — no four-eyes, no customer notification before execution.
Locked posture: CS initiates reversal (must not be the original initiating CS user — 403 if same). Operator-level approval required (customers:merge:approve_reversal, different user from initiator). Both account holders emailed with a 24-hour hold window before execution. If no objection, operator approves and reversal executes. Status: reversal_pending → (24h elapsed + operator approves) → reversed.
Rationale: D3-1 (HIGH): without four-eyes, a rogue CS user who initiates a fraudulent merge can reverse it using the same account — the reversal audit trail looks like a self-correcting error and covers the primary observable trace. D3-2 (MEDIUM): without pre-execution notification, a CS social engineering attacker can trigger reversal of a legitimate merge without either account holder's knowledge. The 24-hour hold window gives customers time to object via support.
D4 — Customer chooses billing disposition at merge time: apply secondary subscription credit to primary, or refund to payment method.
Source: Operator decision 2026-06-05.
v1 proposal: Refund unused days from secondary subscription and extend primary subscription by equivalent duration.
Locked posture: During the verification flow, after both codes are consumed, the customer is prompted to choose: (A) apply secondary subscription balance as Stripe credit to primary account balance, or (B) refund as Stripe credit to payment method on file. First submission wins; choice is immutable after being set. Merge engine does not execute until billing_choice is recorded.
Rationale: Stripe subscription extension is not a native operation and proration math creates edge cases. Customer-choice eliminates all proration logic and reduces the implementation surface.
Mandatory Security Invariants (M1–M5)
These invariants apply regardless of any future design change. Each has an enforcement mechanism and a required integration test. Full enforcement detail in docs/architecture/account-merge-2026-06-05.md §2.
| # | Invariant | Enforcement | Integration test |
|---|---|---|---|
| M1 | Both codes required; no CS bypass to verified state |
Merge engine dispatch: WHERE primary_verified_at IS NOT NULL AND secondary_verified_at IS NOT NULL |
test_merge_requires_both_verifications |
| M2 | CS cannot call the customer-facing verify endpoint | Session middleware checks users table only; console_admins sessions receive 403 |
test_cs_session_cannot_call_verify |
| M3 | Primary designation is CS-only; no customer-callable swap | POST /merges/{id}/swap-primary does not exist as a customer route |
test_no_customer_swap_primary_endpoint |
| M4 | Atomic row-level locking on all mutating state transitions | UPDATE ... WHERE ... RETURNING pattern; no TOCTOU gap |
test_concurrent_verify_atomic |
| M5 | Audit events written in same transaction as state changes | Audit insert failure rolls back state change; no fire-and-forget | test_audit_write_failure_rolls_back_state |
Language choice rationale
Service: No new service introduced. Account merge is a subsystem of Raptor (Python) and Console (Python). No new process or daemon.
Language tier: Tier 2 — Python.
Rationale for this classification: The merge operation runs asynchronously (not on a hot path), has no sub-5ms latency requirement, and does not handle auth credentials or billing PII in a way that exceeds Raptor's existing security posture. Python is the correct choice per docs/architecture/language-tier-policy.md. A future Tier 1 promotion is not anticipated for this subsystem — merge is an infrequent CS operation, not a throughput-sensitive path.
API contract portability (Tier 2): The MergeIdentityAdapter protocol is defined as a Python Protocol class. The public REST contract (POST /internal/merges, POST /merges/{id}/verify) uses plain JSON with typed fields — portable to any language without redesign.
Consequences
Positive
- CS can resolve duplicate-account tickets without engineering involvement.
- Cross-verification prevents unilateral account takeover via merge.
- D3 four-eyes + 24h hold eliminates both the insider-fraud coverage risk and the CS-social-engineering reversal risk.
- D2
tombstoned_emailsprevents permanent re-registration confusion post-PII-nulling. - D4 customer billing choice eliminates all proration math and Stripe subscription-extension edge cases.
- Per-table merge policy matrix makes the merge deterministic and auditable.
- Queue-aware adapter design ensures the cutover does not require a feature rework.
Negative / risks
- D4 Stripe credit note application. Applying secondary subscription balance as a Stripe credit note requires a credit-note write. Risk: Stripe API quirks on credit note application timing and refund-as-credit-note tax implications. Mitigation: smoke test with Stripe test mode before Phase 3 GA; confirm with legal on credit-note vs. direct-refund implications.
- Mid-flight transaction failure. If the merge transaction fails after some rows have been re-foreign-keyed (before commit), Postgres rolls back cleanly — but the
statusupdate toin_progresshas already committed in a preceding transaction. The engine must handle thein_progress→failedtransition idempotently on restart. - Passkey FK rebinding. Updating
webauthn_credentials.user_idfor a credential that is actively in use is safe (the credential itself is unchanged) but must be done atomically within the merge transaction to avoid a window where the credential is temporarily unowned. - Large account merge time. A user with tens of thousands of paper orders will have a slow merge transaction. Mitigation: batch re-foreign-key updates in chunks within the transaction; add a progress column to
account_merges.
Neutral
user_redirectsadds a middleware lookup on every session validation request. At current user scale, this is a simple indexed lookup with negligible latency. At scale it may warrant a Redis cache layer — flagged as an open question for Queue Phase 2.- The detection-engineer's 8 behavioral monitoring rules (D1-D8 in PR #3261) are designed as human-review signals per
feedback_deterministic_execution_ai_augments. They are not deployed rules;feature-developerimplementing the merge engine must confirm all 16 audit events (design doc §8.2) are emitted before closing any sub-card.
Alternatives considered
Alternative A: CS performs manual SQL operations
Rejected: no audit trail, requires DB access for every merge, error-prone, no customer verification step, bypasses the cross-verification invariant.
Alternative B: Customer-self-serve merge (no CS involvement)
Rejected: social engineering attack surface. CS involvement as initiator is a hard requirement.
Alternative C: Hard-delete secondary immediately
Rejected: no reversal possible; FK references break; audit trail orphaned.
Alternative D: Store collision items in a separate merge_collisions table
Rejected: ASK_USER items per merge are bounded and small (< 10 fields). JSONB column avoids a join without sacrificing queryability.
Alternative E (D1): Hybrid — CS nominates, customer may request one swap before first code consumed
Rejected per security-agent threat model PR #3262 §2.1: customer-callable swap enables one-inbox-compromise primary-flip. Detection-engineer memo PR #3261 §5 independently ranked CS-only as cleanest detection surface. Hybrid adds a swap-window event requiring sub-60s detection latency without any design benefit over CS-only.
Alternative F (D2): 90-day soft-delete before tombstone
Rejected per security-agent threat model PR #3262 §2.7 (Issue D2-1 MEDIUM): null email column post-tombstone allows re-registration at the same address.
Alternative G (D3): CS-only reversal, no four-eyes, no customer hold
Rejected per security-agent threat model PR #3262 §2.5, §3.4 (Issues D3-1 HIGH and D3-2 MEDIUM): enables insider-fraud coverage via same-user initiate-and-reverse, and enables CS social engineering to trigger reversal without affected users' knowledge.
Alternative H (D4): Proration math — refund unused days + extend primary subscription
Rejected: Stripe subscription extension is not a native operation; proration edge cases are complex and introduce legal ambiguity.
Security / GDPR checklist
- PII collected:
account_merges.collision_dataJSONB may contain display name, alt email.primary_user_id/secondary_user_idare indirect PII. Actor fields (initiated_by_cs,reversal_initiated_by,reversal_approved_by) stored as argon2id one-way hashes — not recoverable to plaintext.tombstoned_emails.email_hashis argon2id one-way. - Retention period:
account_mergesrows: indefinitely (7-year audit policy).collision_data: nulled 15 days post-completion.tombstoned_emails: forever. PII columns on secondaryusersrow: nulled 15 days post-completion. - Deletion on DSR: DSR on either account blocks merge initiation. Post-merge DSR on primary covers the merged-away
user_id(linked viauser_redirects). Tombstone job writesdsr_logentry when PII is cleared. - Audit trail: 16 audit events across 13 namespaces (design doc §8.2) in
customer_audit_events. Retained per existing 7-year policy. Every state change and every row re-foreign-keyed writes an event in the same transaction. - Stored credentials: Merge verification codes are 8-character random tokens stored argon2id (t=2, m=65536, p=2). Never stored plain. One-time; consumed code returns 409 on re-use. Not credentials per ADR-0002 (cannot authenticate).
- Breach notification path:
account_mergesandtombstoned_emailsboth in-scope for breach notification. Both primary and secondary email addresses are notification targets. Automated breach notification (ADR-0003) must include these tables. - Secrets location + rotation: Postmark API key, Stripe API key, FreeScout API key all in secret store (Infisical / SSM). No secrets in application code. Rotatable without redeploy.
- Kill-switch:
FLAG_ACCOUNT_MERGEdisables all merge initiation and verification endpoints. In-flightin_progressmerges complete;initiated/verifiedmerges frozen until flag is re-enabled.
Sub-cards
| Card | Scope | Status |
|---|---|---|
| #3246 | Schema — migration 0028 + 0145 (v2: tombstoned_emails, D3/D4 columns) | Open |
| #3247 | Email flow — Postmark templates + argon2 code hashing (v2: billing-choice + cancel token + reversal-pending) | Open, blocked on #3246 |
| #3248 | Merge engine — transactional re-FK + two-step reversal + D4 billing Stripe calls | Open, blocked on #3246 |
| #3249 | Console admin UI — initiate + cancel + two-step reversal + billing-choice display | Open, blocked on #3246 |
| #3253 | RBAC fine-grained permissions + tombstone nightly job + audit allowlist | Open, blocked on #3246 |
| #3254 | E2E smoke — full round-trip + M1-M5 invariant tests | Open, blocked on all above |
| #3265 | ASN infrastructure — GeoLite2-ASN bundle + session middleware hook | Open, blocked on #3246 |
| #3266 | Audit-action allowlist additions — 13 merge.* namespaces | Open |
Revisit when
- Queue Phase 2 ships and Queue owns the customer source-of-truth. Swap
MergeIdentityAdapterto call Queue's API (Queue Phase 2 epic sub-card, not here). - User volume exceeds 10,000 such that
user_redirectsmiddleware lookup requires a Redis cache layer. - Stripe changes its subscription credit API in a way that affects the D4 credit-note logic.
- The detection-engineer's 8 behavioral monitoring rules are promoted to automated signals (currently human-review per
feedback_deterministic_execution_ai_augments).