Account Merge — Design (v2)
Date: 2026-06-05 UTC Status: Active — operator decisions locked Author: software-architect agent Scope: Raptor (identity + data), Antlers (no customer-facing affordance), Console (CS UI), Queue (identity source-of-truth, Queue-aware stub)
Revisions 2026-06-05 (post-operator-decisions)
This section records the deltas from v1 (initial design) introduced after the security-agent threat model (PR #3262), detection-engineer memo (PR #3261), and operator decision session on 2026-06-05.
| Decision | v1 proposal | v2 locked state |
|---|---|---|
| D1 — Who picks primary? | Hybrid (CS nominates; customer may request one swap before first code consumed) | CS-only. No customer-callable swap. Primary is locked by CS at initiation and cannot be changed after that. POST /merges/{id}/swap-primary customer route removed from spec. Closes security-agent finding: one-inbox-compromise primary-flip attack (§2.1 of threat model). |
| D2 — Secondary account fate? | Soft-delete 90 days, then tombstone with PII nulling | Tombstone immediately + tombstoned_emails table. Email-uniqueness enforcement continues after PII nulling via argon2-hashed email stored in tombstoned_emails. Registration flow checks this table and surfaces "This email was previously used. Contact support." Closes security-agent issue D2-1 (MEDIUM). |
| D3 — Reversal window? | 14 days, CS-only, audit-logged — no four-eyes, no customer notification before execution | 14-day window, operator-escalation required, pre-execution customer notification. CS initiates reversal request in Console → operator-level approval required (different user from initiator, satisfying four-eyes) → both account holders emailed with 24-hour hold window → operator confirms or aborts at 24h → reversal executes. Closes security-agent issues D3-1 (HIGH four-eyes gap) + D3-2 (MEDIUM customer notification gap). |
| D4 — Billing collision? | Refund secondary unused time + extend primary subscription; Founders pricing wins | Customer-choice at merge time. During the verification flow, customer is prompted: (A) apply secondary subscription credit to primary account balance, or (B) refund as Stripe credit to payment method on file. Eliminates proration math entirely. No subscription extension logic required. |
| D5 — CS trigger surface? | Console admin UI + FreeScout auto-comment | Confirmed — no change. Console is the action layer. FreeScout ticket auto-links. |
| D6 — History merge? | Data always merged; history_visible toggle controls display |
Confirmed — no change. Data merge always performed. history_visible on the merge record controls display. |
| ASN infra | Not in scope (flagged as new infra requirement by detection-engineer) | In scope. MaxMind GeoLite2-ASN offline DB bundled at deploy (~10 MB free), monthly cron refresh, in-process resolution helper. Wires detection events merge.primary_verified / merge.secondary_verified (ASN fields) and merge.post_merge_session_new_ip. |
Audit-event reconciliation: Detection-engineer specified 9 action namespaces; security-agent specified 13 (finer-grained splits). Design adopts the security-agent superset — the detection layer composes from these granular events. The security-agent splits enforce invariants that aggregate events would obscure (e.g., per-role verified events enable sub-90-second detection per account side independently). Mapping documented in §8.
Decisions for operator section cleared. All six decisions confirmed. No open operator-pending decisions remain.
1. Context
Raxx customers occasionally create two accounts — most commonly when signing up with a different email address or after a device change. CS identifies the duplicate and needs a safe, audited path to merge both accounts into one. The feature is CS-initiated and requires cryptographic cross-verification by both email addresses so that neither account can be merged without the other party's explicit acknowledgment. All historical data migrates to the surviving account. Primary designation is CS-only — customers have no mechanism to influence which account survives.
2. Mandatory Invariants
The following five invariants are non-negotiable. Each has an enforcement mechanism, an integration-test name, and a cross-reference to the security memo (PR #3262).
M1 — Both codes required; no CS bypass to verified state
Rule: No internal endpoint may set status = 'verified' or transition to in_progress without both primary_verified_at and secondary_verified_at having been set by the customer-facing verify route. No CS "force-complete" capability exists.
Enforcement: Merge-engine dispatch query enforces: WHERE status = 'initiated' AND primary_verified_at IS NOT NULL AND secondary_verified_at IS NOT NULL. Any looser check is exploitable.
Integration test: test_merge_requires_both_verifications — asserts that a merge in initiated status with only one verified_at set cannot advance to in_progress.
Security memo ref: §3.1, §6 (M1) of PR #3262.
M2 — CS cannot call the customer-facing verify endpoint
Rule: Session middleware for POST /merges/{id}/verify checks user_id IN (primary_user_id, secondary_user_id) against the users table only — not console_admins. A CS console session token must receive 403.
Enforcement: Session middleware user-table check; structural separation of Console routes (proxy to internal endpoints only) from customer-facing routes.
Integration test: test_cs_session_cannot_call_verify — sends a POST /merges/{id}/verify with a valid Console session token; asserts 403.
Security memo ref: §3.2, §4.2, §6 (M2) of PR #3262.
M3 — Primary designation is CS-only; no customer-callable swap
Rule: There is no customer-callable endpoint to change the primary designation. The only way to alter which account is primary is for CS to cancel the merge and create a new one with the desired primary.
Enforcement: POST /merges/{id}/swap-primary does not exist as a customer-facing route. Primary designation fields (primary_user_id, secondary_user_id) are immutable after initiation except by merge cancellation.
Integration test: test_no_customer_swap_primary_endpoint — asserts that any authenticated customer session calling a swap-primary path receives 404 (route does not exist).
Security memo ref: §5, §6 (M3) of PR #3262. The specific one-inbox-compromise primary-flip attack documented in §2.1 is structurally impossible because this endpoint does not exist.
M4 — Atomic row-level locking on all mutating state transitions
Rule: Every state-changing operation on account_merges (verify, cancel, reversal initiation, reversal approval) uses UPDATE ... WHERE ... RETURNING with all guard conditions in the WHERE clause. No time-of-check/time-of-use gap between read and write.
Enforcement: All state-mutation functions use the pattern:
UPDATE account_merges
SET <field> = <value>
WHERE id = $merge_id AND status = $expected_status AND <guard conditions>
RETURNING id
If RETURNING yields no row, the operation is rejected with 409.
Integration test: test_concurrent_verify_atomic — two concurrent verify calls on the same merge record; asserts exactly one succeeds and one returns 409.
Security memo ref: §2.4, §3.2, §6 (M4) of PR #3262.
M5 — Audit events written in the same transaction as state changes
Rule: Every customer_audit_events insert for a merge event is in the same Postgres transaction as the state change it records. If the audit insert fails, the transaction rolls back and the state change is rejected (500). No state change is committed without an audit trail.
Enforcement: All merge state-change functions wrap the account_merges UPDATE and customer_audit_events INSERT in a single BEGIN/COMMIT block. No fire-and-forget audit writes.
Integration test: test_audit_write_failure_rolls_back_state — mocks write_customer_audit_event to raise; asserts the state change is not committed.
Security memo ref: §4.3, §6 (M5) of PR #3262.
3. Additional System Invariants
- No stored credentials. Merge verification codes are 8-character random tokens stored as argon2id (t≥2, m≥65536, p≥2). Plaintext codes exist only in memory at generation time and in the Postmark email body. Never logged, never stored plain.
- Passkeys / WebAuthn only. No new auth flows introduced. Merge codes are supplemental consent tokens, not an auth path.
- Email is the single contact channel. Four merge-related transactional emails per flow (initiated, billing-choice prompt, reversal-pending, completed/failed) via Postmark to the verified email on each account.
- GDPR. If either account has a pending or active DSR (erasure / rectification), the merge is BLOCKED until the DSR is resolved.
- Paper-first gating. Not applicable — account-merge is a CS operation, not a live-trading path.
- No credentials in code. Postmark API key, Stripe key, and FreeScout API key stay in the secret store.
- Queue-aware but not Queue-blocked. Pre-Queue-cutover, the merge engine reads customer data from Raptor. Post-cutover it calls Queue. Design uses a
MergeIdentityAdapterinterface so the cutover is a one-file swap.
4. Data Model
4.1 New Tables
account_merges
id BIGSERIAL PRIMARY KEY
primary_user_id BIGINT NOT NULL REFERENCES users(id)
secondary_user_id BIGINT NOT NULL REFERENCES users(id)
initiated_by_cs TEXT NOT NULL
-- argon2id hash of initiating CS operator email (never stored plain)
initiated_at TIMESTAMPTZ NOT NULL DEFAULT now()
primary_code_hash TEXT NOT NULL -- argon2id hash of 8-char code
secondary_code_hash TEXT NOT NULL -- argon2id hash of 8-char code
primary_code_expires TIMESTAMPTZ NOT NULL -- initiated_at + 24h
secondary_code_expires TIMESTAMPTZ NOT NULL -- initiated_at + 24h
primary_verified_at TIMESTAMPTZ NULL
secondary_verified_at TIMESTAMPTZ NULL
primary_resend_count INT NOT NULL DEFAULT 0
secondary_resend_count INT NOT NULL DEFAULT 0
status TEXT NOT NULL DEFAULT 'initiated'
CHECK (status IN ('initiated','verified','in_progress','completed',
'failed','reversal_pending','reversed','cancelled'))
freescout_ticket_id TEXT NULL
history_visible BOOLEAN NOT NULL DEFAULT TRUE
collision_data JSONB NOT NULL DEFAULT '{}'
-- stores ASK_USER collision items + resolved values
billing_choice TEXT NULL
CHECK (billing_choice IS NULL OR
billing_choice IN ('apply_to_primary','stripe_refund'))
-- D4: set during verification flow before merge engine executes
reversal_initiated_by TEXT NULL
-- argon2id hash of reversing CS operator email
reversal_initiated_at TIMESTAMPTZ NULL
reversal_approved_by TEXT NULL
-- argon2id hash of approving operator email (four-eyes)
reversal_approved_at TIMESTAMPTZ NULL
reversal_hold_expires_at TIMESTAMPTZ NULL
-- reversal_initiated_at + 24h; customer objection window
reversed_at TIMESTAMPTZ NULL
merge_completed_at TIMESTAMPTZ NULL
error_detail TEXT NULL
schema_version INT NOT NULL DEFAULT 2
UNIQUE (primary_user_id, secondary_user_id)
CHECK (primary_user_id <> secondary_user_id)
Indexes:
- (status, initiated_at) — CS list view
- (primary_user_id, status)
- (secondary_user_id, status)
- (freescout_ticket_id) partial WHERE NOT NULL
Note: primary_swap_requested column from the v1 design does not exist. D1 is CS-only; no swap mechanism.
user_redirects
from_user_id BIGINT PRIMARY KEY -- the merged-away secondary
to_user_id BIGINT NOT NULL REFERENCES users(id)
merged_at TIMESTAMPTZ NOT NULL
merge_id BIGINT NOT NULL REFERENCES account_merges(id)
Persists forever. Any incoming request bearing a session token for from_user_id is intercepted by session middleware and redirected/invalidated.
tombstoned_emails
id BIGSERIAL PRIMARY KEY
email_hash TEXT NOT NULL UNIQUE
-- argon2id hash of the email address; one-way, not recoverable
original_user_id BIGINT NOT NULL
-- the merged-away secondary user_id; for CS lookup only, not surfaced to registrants
tombstoned_at TIMESTAMPTZ NOT NULL DEFAULT now()
merge_id BIGINT NOT NULL REFERENCES account_merges(id)
Persists forever. The registration flow checks this table on new account creation. If argon2.verify(email_hash, submitted_email) matches, registration returns: "This email was previously used. Contact support." The original_user_id is only accessible to CS with customers:merge:read.
4.2 Modified Tables
users
Add columns:
deleted_at TIMESTAMPTZ NULL -- set on merge completion for secondary
deleted_reason TEXT NULL -- 'merged' | 'gdpr_erasure' | ...
tombstoned_at TIMESTAMPTZ NULL -- set on merge completion (D2: immediate)
D2 timing (v2): deleted_at and tombstoned_at are both set at merge completion for the secondary account. PII columns (email, phone, display_name, address fields) are nulled 15 days post-completion — after the reversal window closes with one day buffer. The email hash is written to tombstoned_emails at the same time PII is nulled. The tombstone nightly job handles the 15-day deferred PII nulling; it does not run a 90-day soft-delete window (that window is removed from v2).
5. Per-Table Merge Policy Matrix
| Table | Policy | Rationale |
|---|---|---|
users |
PREFER_PRIMARY + ASK_USER | Primary row survives. Collision fields: display_name, alt_email. These are ASK_USER items stored in collision_data JSONB and resolved before merge executes. |
webauthn_credentials |
MERGE | All passkeys from secondary re-bound to primary user_id. WebAuthn RP ID stays raxx.app; credential objects are valid for the RP regardless of user binding. |
customer_sessions |
SKIP (invalidate secondary) | All active sessions for secondary revoked (revoked_at = now()) at merge start. Primary sessions unaffected. |
customer_preferences |
PREFER_PRIMARY | Primary's preferences win. Secondary's row archived to collision_data for reference during reversal window. |
onboarding_state |
PREFER_PRIMARY | Primary row survives; secondary's archived. If primary has not completed onboarding, prefer the more-complete row (compare completed_steps array length). |
strategies |
MERGE | All secondary strategies re-foreign-keyed to primary. Name collision: secondary strategy renamed <name> (imported). |
backtest_runs |
MERGE | All secondary backtests re-foreign-keyed to primary. |
paper_accounts |
MERGE (balance sum) | One row per user. Credit primary's cash_balance with secondary's. Add secondary's total_pl to primary's. |
paper_positions |
MERGE | Secondary positions re-foreign-keyed to primary. Symbol collision: quantities summed; secondary row deleted. |
paper_orders (MBT, migration 0022) |
MERGE | All secondary MBT paper orders re-foreign-keyed to primary. |
paper_orders (original, migration 0002) |
MERGE | All secondary paper orders re-foreign-keyed to primary. |
order_strategy_links |
MERGE | Re-foreign-keyed as part of paper_orders merge. |
customer_notifications |
SKIP | Ephemeral display data; no cross-account value. |
customer_reminders |
MERGE | User-created; merge preserves them. Dedup by content hash. |
signup_reminders |
SKIP | Pre-signup state; irrelevant post-merge. |
email_tokens |
SKIP + invalidate | All pending email tokens for secondary invalidated at merge start. |
email_verifications |
SKIP + invalidate | All pending verification codes for secondary invalidated at merge start. |
customer_trading_mode_overrides |
PREFER_PRIMARY | Live trading mode overrides are administrative; primary wins. |
backup_codes |
SKIP (invalidate secondary) | Secondary's backup codes invalidated at merge start. |
support_customer_map |
MERGE + dedup | Both FreeScout customer links merged to primary user_id. Duplicate rows deduplicated. |
dsr_log |
SKIP (keep on original) | DSR history stays attributed to original user_id for compliance. BLOCK check happens before merge executes. |
audit_log |
SKIP (keep on original) | Historical audit events are immutable and stay on their original actor_user_id. |
customer_audit_events |
SKIP (keep on original) | Immutable audit chain; events retain original customer_id. |
trace_events / trace_workflows |
SKIP | Immutable observability chain. |
freescout_ticket_cache |
MERGE | Secondary's cached ticket rows re-attributed to primary user_id. |
waf_events |
SKIP | Security telemetry; no cross-account value. |
*_history shadow tables |
SKIP | Immutable audit records; retain original primary_id. |
waitlist_signups |
SKIP | Pre-signup; irrelevant. |
quebec_waitlist |
SKIP | Pre-signup; irrelevant. |
login_challenges |
SKIP + invalidate | Any pending challenge for secondary invalidated. |
bootstrap_tokens_* |
SKIP | Auth bootstrap tokens; not user-data. |
strategy_template_definitions |
SKIP | System-level data, no user_id FK. |
postmark_delivery_events |
SKIP | System telemetry, no user_id FK. |
6. APIs / Contracts
6.1 Raptor Merge Engine (Internal — CS-only)
POST /internal/merges
role: customers:merge:initiate
GET /internal/merges
GET /internal/merges/{id}
role: customers:merge:read
POST /internal/merges/{id}/cancel
role: customers:merge:cancel
guard: status must be 'initiated'
POST /internal/merges/{id}/reversal/initiate
role: customers:merge:reverse
guard: status = 'completed', within 14-day window
guard: initiating CS operator != original initiated_by_cs (four-eyes)
action: sets status='reversal_pending', reversal_hold_expires_at=now()+24h
emails both account holders with 24h hold window
POST /internal/merges/{id}/reversal/approve
role: customers:merge:approve_reversal (operator-level)
guard: approver != reversal_initiated_by (four-eyes on approve side)
guard: reversal_hold_expires_at has passed; no objection received
action: executes reversal transaction
POST /internal/merges/{id}/reversal/abort
role: customers:merge:approve_reversal
action: returns status to 'completed'
Note: POST /merges/{id}/swap-primary does not exist. D1 is CS-only.
6.2 Raptor Customer-Facing Routes
POST /merges/{id}/verify
auth: session must belong to primary_user_id or secondary_user_id (users table only)
body: { "code": "<8-char>" }
directionality: primary session verifies against secondary_code_hash; vice versa
rate limit: 10 attempts per merge record
POST /merges/{id}/billing-choice
auth: same session constraint as /verify
body: { "choice": "apply_to_primary" | "stripe_refund" }
guard: both verifications complete; billing_choice IS NULL
action: sets billing_choice; enqueues merge job
note: first submission wins; idempotent if same choice repeated
POST /merges/{id}/resend
auth: same session constraint
rate limit: max 5 per account side
guard: code for that account not yet consumed (verified_at IS NULL)
POST /merges/{id}/cancel
auth: HMAC-signed cancel token from email (no session required)
guard: token not expired (24h TTL, same as code expiry)
action: status → 'cancelled'; both accounts notified
note: available to either account holder; first cancellation wins
6.3 RBAC Permission Set (v2)
| Permission | Level | Operation |
|---|---|---|
customers:merge:read |
CS | View merge records |
customers:merge:initiate |
CS | Start a merge |
customers:merge:cancel |
CS | Cancel an initiated merge |
customers:merge:reverse |
CS | Initiate a reversal request (not approve it) |
customers:merge:approve_reversal |
Operator | Approve or abort a pending reversal |
customers:merge:write from v1 is decomposed into separate permissions for minimum privilege (per security memo §4.1, PR #3262). customers:merge:approve_reversal is distinct from customers:merge:reverse — a CS rep who can request a reversal must not also be able to approve it.
6.4 Console Admin Endpoints (Console Python)
GET /console/api/merges
POST /console/api/merges
GET /console/api/merges/{id}
POST /console/api/merges/{id}/cancel
POST /console/api/merges/{id}/reversal/initiate
POST /console/api/merges/{id}/reversal/approve
POST /console/api/merges/{id}/reversal/abort
GET /console/api/merges/{id}/events -- audit timeline
Console proxies to Raptor internal endpoints. Console UI includes: - Billing-choice display (which option the customer selected, D4) - Reversal flow UI: "Request Reversal" button (CS role) and "Approve/Abort Reversal" button (operator role) - No customer-facing swap affordance (removed per D1)
6.5 Queue Cutover Contract (MergeIdentityAdapter)
class MergeIdentityAdapter(Protocol):
def get_user(self, user_id: int) -> UserRecord: ...
def list_dsr_pending(self, user_id: int) -> list[DSRRecord]: ...
def invalidate_sessions(self, user_id: int) -> int: ... # returns count
Pre-Queue-cutover: reads from Raptor's users table. Post-cutover: calls Queue's /v1/customers/{id}.
7. State Machine / Sequences
7.1 Merge Status State Machine
stateDiagram-v2
[*] --> initiated : CS initiates (POST /internal/merges)
initiated --> cancelled : CS cancels OR customer uses signed cancel token
initiated --> initiated : Resend code (max 5 per account)
initiated --> verified : Both codes consumed (either order)
verified --> in_progress : billing_choice set; merge engine begins transaction
in_progress --> completed : All rows migrated, emails sent
in_progress --> failed : Transaction error or mid-flight exception
failed --> [*] : Terminal
completed --> reversal_pending : CS initiates reversal (within 14-day window)
reversal_pending --> reversed : Operator approves after 24h customer hold window
reversal_pending --> completed : Operator aborts pending reversal
reversed --> [*] : Terminal
cancelled --> [*] : Terminal
7.2 Cross-Verification Sequence (D1: CS-only; D4: customer billing choice)
sequenceDiagram
participant CS as CS (Console)
participant R as Raptor
participant PM as Postmark
participant A as Account A (primary)
participant B as Account B (secondary)
CS->>R: POST /internal/merges {primary_user_id, secondary_user_id, ticket_id}
R->>R: Generate code_A, code_B; store argon2id hashes; status=initiated
R->>PM: merge-initiated email to A (code_A + signed cancel token)
R->>PM: merge-initiated email to B (code_B + signed cancel token)
Note over A,B: Both emails include "I did not request this" cancel CTA
A->>R: POST /merges/{id}/verify { code: code_B }
Note right of R: Primary session must supply secondary's code
R->>R: argon2 verify code_B; set primary_verified_at; emit merge.primary_verified
R-->>A: 200 "Waiting for other account"
B->>R: POST /merges/{id}/verify { code: code_A }
Note right of R: Secondary session must supply primary's code
R->>R: argon2 verify code_A; set secondary_verified_at; emit merge.secondary_verified + merge.both_verified; status=verified
R-->>B: 200 + billing choice prompt (apply_to_primary OR stripe_refund)
R->>PM: billing-choice email to A (also surfaces the prompt)
B->>R: POST /merges/{id}/billing-choice { choice: "apply_to_primary" }
R->>R: Set billing_choice; emit billing choice event; enqueue merge job
R->>R: Execute merge transaction; emit merge.engine_started
R->>R: Per-table re-keying; emit merge.row_rekeyed per table
R->>R: Apply billing choice via Stripe API
R->>R: Set deleted_at + tombstoned_at on secondary; status=completed
R->>R: emit merge.engine_completed
R->>PM: merge-completed email to A
R->>PM: merge-completed email to B
7.3 Reversal Sequence (D3: four-eyes + 24h customer hold)
sequenceDiagram
participant CS1 as CS (not the original initiator)
participant OP as Operator (approve_reversal role)
participant R as Raptor
participant PM as Postmark
CS1->>R: POST /internal/merges/{id}/reversal/initiate
R->>R: Check: CS1 != initiated_by_cs; within 14d window
R->>R: status=reversal_pending; reversal_hold_expires_at=now()+24h
R->>R: emit merge.reversal_initiated
R->>PM: "reversal pending" email to primary (24h window + object CTA)
R->>PM: "reversal pending" email to former secondary (24h window + object CTA)
Note over R: 24h customer hold window
OP->>R: POST /internal/merges/{id}/reversal/approve
R->>R: Check: approver != reversal_initiated_by; hold window elapsed
R->>R: Execute reversal transaction; rows re-keyed to secondary
R->>R: emit merge.reversal_approved + merge.reversal_completed
R->>PM: reversal-completed emails to both accounts
7.4 Failure Modes
| Failure | Detection | Recovery |
|---|---|---|
| Merge transaction fails mid-flight | Transaction rolls back; status = failed; error_detail set | CS creates a new merge record. |
| One email fails delivery | Postmark delivery webhook records bounce | CS uses Console "Resend" (max 5 per account). |
| Code expires (24h TTL) | *_code_expires checked on each verify call |
CS resends; new code minted, old hash discarded, TTL reset. |
| No billing_choice after both verifications | billing_choice NULL blocks merge engine dispatch | 12h reminder email. CS can check status in Console. |
| Customer objects during reversal hold window | Customer emails support; CS aborts via reversal/abort | status returns to 'completed'. |
| Reversal after 14-day window | Endpoint returns 409 | CS files manual data correction ticket (out of scope). |
| DSR pending on either account | BLOCK check at initiation | error_detail = 'dsr_pending'. Resolve DSR first. |
in_progress on server restart |
Merge engine handles in_progress → idempotent re-run |
Detected at startup; re-runs from verified state using per-table upsert. |
8. Audit Events
8.1 Reconciliation: Security-Agent (13) vs. Detection-Engineer (9) Event Sets
The security-agent defined 13 discrete events (PR #3262). The detection-engineer defined 9 namespaces (PR #3261). The security-agent superset is adopted because their finer splits enforce mandatory invariants that aggregate events would obscure. Specifically: merge.primary_verified and merge.secondary_verified as discrete events (rather than a single rolled-up merge.code_verified) allow the sub-90-second detection rule (Detection 1) to fire independently per account side, and enable M5 enforcement to be verified per-event.
| Security-agent event | Detection-engineer namespace | Resolution |
|---|---|---|
merge.initiated |
merge.initiated |
1:1 — SA name and DE field spec both used |
merge.code_sent |
(part of merge.initiated in DE spec) |
SA discrete event retained — Postmark send is a separate observable |
merge.primary_verified |
merge.code_verified (verifying_account_role=primary) |
SA split adopted — per-role events needed for Detection 1 |
merge.secondary_verified |
merge.code_verified (verifying_account_role=secondary) |
SA split adopted |
merge.both_verified |
(implicit in DE spec) | SA explicit event retained — detection anchor for D3 |
merge.verification_failed |
merge.code_verify_failed |
1:1 — DE field spec used |
merge.primary_swapped |
merge.swap_primary_requested |
Moot — D1 locked CS-only; no swap event exists or is emitted |
merge.code_resent |
merge.resend_requested |
SA name adopted; DE field spec used |
merge.engine_started |
(part of merge.completed in DE spec) |
SA discrete event retained — needed to compute merge engine duration independently |
merge.row_rekeyed |
(not in DE spec) | SA event retained — per-table audit required for security review |
merge.engine_completed |
merge.completed |
1:1 — SA name adopted; DE field spec used |
merge.engine_failed |
merge.failed |
1:1 — SA name adopted |
merge.cancelled |
merge.cancelled |
1:1 |
merge.reversal_initiated |
merge.reversed (single event in DE spec) |
SA splits into initiated/approved/completed; adopted — four-eyes enforcement requires discrete attribution events |
merge.reversal_approved |
(implicit) | SA discrete event retained |
merge.reversal_completed |
part of merge.reversed |
SA discrete event retained |
merge.post_merge_session_new_ip |
merge.post_merge_session_new_ip |
1:1 — requires ASN infra |
Total events registered in allowlist: 16 (13 from SA superset + 3 further splits: merge.primary_verified / merge.secondary_verified / merge.both_verified split out from the SA's merge.verification_attempt).
8.2 Audit-Action Allowlist Additions
The following 13 action namespace entries (covering 16 event names after the per-role split) must be added to audit_action_allowlist.yaml before the merge feature ships. Without them, the audit writer service returns 422 on every merge event.
merge.initiated:
allowed_fields:
- merge_id
- primary_user_id
- secondary_user_id
- cs_actor_hash
- freescout_ticket_id
- dsr_block_checked
dimension: operator_interaction
merge.code_sent:
allowed_fields:
- merge_id
- account_side # "primary" | "secondary"
- postmark_message_id
- sent_at
dimension: system_automated
merge.primary_verified:
allowed_fields:
- merge_id
- verifying_session_user_id
- request_ip_class # /24 prefix only — not full IP (PII floor)
- request_asn # autonomous system number
- seconds_since_initiation
- timestamp
dimension: customer_self
merge.secondary_verified:
allowed_fields:
- merge_id
- verifying_session_user_id
- request_ip_class
- request_asn
- seconds_since_initiation
- timestamp
dimension: customer_self
merge.both_verified:
allowed_fields:
- merge_id
- primary_verified_at
- secondary_verified_at
- timestamp
dimension: system_automated
merge.code_verify_failed:
allowed_fields:
- merge_id
- verifying_account_role # "primary" | "secondary"
- failure_reason # "wrong_code" | "expired" | "already_consumed" | "rate_limited"
- attempt_number # 1..10
dimension: customer_self
merge.resend_requested:
allowed_fields:
- merge_id
- account_role # "primary" | "secondary"
- resend_sequence # 1..5
- cs_actor_hash
dimension: operator_interaction
merge.cancelled:
allowed_fields:
- merge_id
- cancelled_by # "cs" | "customer_token" | "system"
- cs_actor_hash # null if customer or system
- reason
dimension: operator_interaction
merge.engine_started:
allowed_fields:
- merge_id
- timestamp
dimension: system_automated
merge.row_rekeyed:
allowed_fields:
- merge_id
- table_name
- row_count
- policy # "MERGE" | "PREFER_PRIMARY" | "SKIP"
- timestamp
dimension: system_automated
merge.engine_completed:
allowed_fields:
- merge_id
- tables_touched_count
- rows_rekeyed_total
- billing_action # "apply_to_primary" | "stripe_refund" | "none"
- duration_seconds
- timestamp
dimension: system_automated
merge.engine_failed:
allowed_fields:
- merge_id
- failure_stage # "pre_flight" | "mid_transaction" | "post_transaction"
- error_category # "dsr_block" | "db_error" | "passkey_rebind_error" | "billing_error" | "other"
- timestamp
dimension: system_automated
merge.reversal_initiated:
allowed_fields:
- merge_id
- reversing_cs_actor_hash
- original_initiator_hash
- is_four_eyes_satisfied # boolean — API enforces this is always true; event confirms
- days_since_completion
- timestamp
dimension: operator_interaction
merge.reversal_approved:
allowed_fields:
- merge_id
- approving_operator_hash
- is_four_eyes_satisfied
- timestamp
dimension: operator_interaction
merge.reversal_completed:
allowed_fields:
- merge_id
- rows_restored_count
- timestamp
dimension: operator_interaction
merge.post_merge_session_new_ip:
allowed_fields:
- merge_id
- user_id # primary_user_id post-merge
- session_ip_class # /24 prefix
- session_asn
- is_new_asn # boolean — ASN absent from 30d pre-merge session history
- hours_since_completion
dimension: system_automated
console_audit_events parallel write: Every operator_interaction-dimensioned event above must also produce a parallel write to console_audit_events for CS-side anomaly detection. The console-side action allowlist requires the same namespace entries.
8.3 Detection Signal Mapping
| Detection | Trigger events | Condition |
|---|---|---|
| D1 — Sub-90s verify (CRITICAL) | merge.primary_verified, merge.secondary_verified |
seconds_since_initiation < 90 on either event |
| D2 — Swap + immediate verify | N/A | Removed — D1 locked CS-only, no swap event |
| D3 — Per-CS initiation rate (HIGH) | merge.initiated |
>99.9th percentile Poisson per cs_actor_hash per 24h |
| D4 — Cross-session ASN distance (HIGH) | merge.primary_verified + merge.secondary_verified |
Different country-code ASNs on same merge_id |
| D5 — Post-merge new-ASN session (HIGH) | merge.post_merge_session_new_ip |
is_new_asn = true within 2h of merge.engine_completed |
| D6 — Per-CS reversal rate (MEDIUM) | merge.reversal_initiated |
ratio > 0.30 over 14d with ≥3 completions per cs_actor_hash |
| D7 — High-resend no-verify (MEDIUM) | account_merges direct query |
resend_count ≥ 4, status = 'initiated', age > 12h |
| D8 — Short-lifecycle complete+reverse (HIGH) | merge.engine_completed + merge.reversal_completed |
reversal within 2h of completion |
9. ASN Infrastructure
Decision: MaxMind GeoLite2-ASN offline DB bundled at deploy time (~10 MB, free license). Monthly cron refresh via GH Actions updates the bundle. In-process resolution helper in Raptor: resolve_asn(ip: str) -> int | None.
Usage: Called at request time inside POST /merges/{id}/verify and inside the session-middleware post-merge hook. No external network call; no IP exported to a third party.
Session-middleware post-merge hook: After a session is validated, if account_merges has a completed merge for session.user_id within the last 48h, the middleware compares the incoming session IP's ASN against the user's 30-day session ASN history (queried from customer_sessions). If the ASN is new, it writes merge.post_merge_session_new_ip to customer_audit_events.
Monthly refresh: GH Actions cron (0 03 1 * * UTC) downloads the updated GeoLite2-ASN.mmdb and opens a PR to update the bundled asset. The asset lives at backend_v2/data/GeoLite2-ASN.mmdb (excluded from diff review by .gitattributes as a binary asset).
10. Migrations
Raptor (Alembic)
Migration 0028 — Creates account_merges (v2 schema: billing_choice, reversal_* columns; no primary_swap_requested), user_redirects, tombstoned_emails. Adds deleted_at, deleted_reason, tombstoned_at to users. Includes FLAG_ACCOUNT_MERGE B1 promotion record.
Single migration, single PR (with #3246). Pure DDL; no PL/pgSQL blocks; no -- POSTGRES-ONLY sentinel required.
Rollback (downgrade): drop account_merges, user_redirects, tombstoned_emails; drop columns deleted_at, deleted_reason, tombstoned_at from users.
Console (Flask-Migrate)
Migration 0145 — B1 flag promotion entry for FLAG_ACCOUNT_MERGE. Ships in the same PR as migration 0028 per feedback_new_flag_needs_b1_migration_same_pr.
11. Rollout Plan
Phase 1 — Dark (FLAG_ACCOUNT_MERGE=off) Schema migrations deployed. No code path reachable. Smoke tests pass in CI.
Phase 2 — CS-Restricted (FLAG_ACCOUNT_MERGE=on, staging) Console "Account Merges" UI live on staging. Internal team performs 2-3 synthetic merge round-trips. Reversal flow (initiate → 24h hold → approve) tested. Billing-choice both branches tested. FreeScout auto-comment tested.
Phase 3 — Limited GA (FLAG_ACCOUNT_MERGE=on, prod) Feature goes live. CS can initiate merges for real customer requests. No customer-facing self-serve affordance exists.
Phase 4 — Docs GA Public docs page and Privacy Policy update go live.
12. Security Considerations
- No customer-influenceable primary designation. D1 is CS-only. The specific one-inbox-compromise primary-flip attack documented in security memo §2.1 is structurally impossible.
- Cross-verification codes are consent tokens, not auth credentials. An 8-char argon2id code signals consent to a specific merge; it does not authenticate as the other account.
- Argon2id parameters are explicit. t=2, m=65536, p=2. Not library defaults. Covers an 8-char alphanumeric space of ~2.8 trillion combinations.
- Rate limiting.
/merges/{id}/verifycapped at 10 attempts per merge record. - Session invalidation. Secondary sessions revoked atomically within the merge transaction.
- Passkey rebinding. Rebinding
webauthn_credentials.user_idis safe — private key never leaves the authenticator; RP IDraxx.appis unchanged. - No credential replay. Codes are one-time; consumed code returns 409 on re-use.
- Billing info. Stripe customer IDs not stored in Raptor. No payment instrument numbers touch Raptor storage.
- GDPR Art. 17 block. DSR check at initiation prevents merging data into a deletion-requested account.
- Audit retention.
account_mergesrows retained forever.customer_audit_eventsentries retained per existing 7-year policy. - Kill-switch.
FLAG_ACCOUNT_MERGEdisables all merge initiation endpoints. - Cancel CTA in initiation email. HMAC-signed cancel token in both initiation emails. Customer can cancel without a console session. Token TTL = 24h (same as code expiry). Remains active after one party has verified (per security memo §7.3).
- Four-eyes on reversal.
reversal_initiated_by != initiated_by_csenforced at API layer (403 if same). Approver must also differ from reversal initiator. - Pre-execution customer notification. Reversal emails fire to both account holders before execution; 24h hold window gives customers time to object.
- Tombstone email preservation.
tombstoned_emailstable prevents re-registration at the same address after PII nulling. - ASN resolution offline. MaxMind GeoLite2-ASN bundled; no runtime lookup call that could export IP to a third party.
13. Privacy / Compliance
PII collected: account_merges stores primary_user_id and secondary_user_id (indirect PII). collision_data JSONB may store display name, alt email for ASK_USER items. All actor hashes (initiated_by_cs, reversal_initiated_by, etc.) are argon2id one-way hashes — not recoverable to plaintext. tombstoned_emails stores a one-way argon2id hash of the email address.
Retention:
- account_merges rows: retained indefinitely (7-year audit requirement for money + permissions state changes).
- collision_data: nulled 15 days post-completion (after reversal window closes).
- tombstoned_emails: retained forever (email-uniqueness enforcement).
- PII columns on secondary users row: nulled 15 days post-completion.
DSR interaction:
- DSR on primary during merge → merge blocked.
- DSR on primary post-merge → covers both the primary and the merged-away user_id (linked via user_redirects).
- DSR portability export → includes merge record (redacted codes, timestamps, role designation).
Breach notification: If account_merges or tombstoned_emails data is breached, both primary and secondary email addresses are notification targets. Breach automation (ADR-0003) must include these tables in its PII scope.
CCPA: Merge data touches Sensitive PI. Console access gated behind merge RBAC roles. No third party receives merge data. MaxMind DB is offline; no lookup call leaves the process.
14. Open Questions
None. All decisions (D1–D6, ASN infra) are locked. Security memo issues D3-1 (HIGH) and D3-2 (MEDIUM) addressed in v2 design above. No open operator-pending decisions remain.
15. Queue Cutover Note
Pre-cutover: MergeIdentityAdapter reads from Raptor's users table. Post-Queue-cutover: adapter calls Queue's /v1/customers/{id}. The merge engine itself does not change. This swap is a sub-card for the Queue Phase 2 epic, not this epic.