Raxx · internal docs

internal · gated

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


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


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.