Raxx · internal docs

internal · gated

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

Negative / risks

Neutral


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


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