Account Merge — Console Admin UI Design (Layer-1 RBAC Proxy + UI + Antlers Verify)
Date: 2026-06-21 UTC
Status: Active — answers three groomer-flagged open questions; drives #3249 sub-cards
Author: software-architect agent
Scope: Console (Flask, Layer-1 RBAC proxy + admin UI), Antlers (raxx-next, customer verify page)
Parent design: docs/architecture/account-merge-2026-06-05.md
Parent card: #3249 (account-merge: Console admin UI)
Blocked cards unblocked: #3249 splits into #3768, #3769, #3770 (see §8)
1. Context
The merge engine (#3248), internal Raptor routes (#3253 / PR #3760), schema (#3246), and email flow (#3247) are built or in-flight. What remains is:
- The Console Layer-1 RBAC proxy — the gateway that verifies Console-session RBAC
before injecting
X-Admin-Service-Token+X-Merge-Permission-Grantedheaders and proxying to Raptor's/api/internal/merges/*routes. - The Console admin UI pages (list, detail, initiate, reversal flow) — all CE-skinned.
- The Antlers (raxx-next) customer-facing verify page.
The groomer flagged three open questions before #3249 could be claimed. This document answers all three and specifies every surface the developer needs.
2. Mandatory Invariants (applied to this sub-design)
From the parent design (account-merge-2026-06-05.md §2 and §3):
- M1 — No CS bypass to
verifiedstate. Console never calls/merges/{id}/verify. Console only calls/api/internal/merges/*routes. - M2 — CS cannot call the customer-facing verify endpoint. The Console proxy routes
do not include
/merges/{id}/verify. Structural separation enforced. - M3 — No swap-primary affordance anywhere in Console or Antlers. No button, no route, no API call. The customer verify page in Antlers has no mechanism to request a primary swap.
- M4 — All state mutations in Raptor use
UPDATE ... WHERE ... RETURNING. Console is not responsible for atomicity — it is Raptor's invariant. - M5 — Audit events are written in Raptor's same transaction as state changes.
Console writes its own
console_audit_eventsrow separately and non-fatally. - Hide, don't gray — Actions the operator's role lacks are absent from the page, not disabled. This prevents role-leakage via greyed controls.
- No broker names in copy — Customer-facing copy never mentions Alpaca, SnapTrade, Stripe by name. CS-facing copy may use operational names internally.
- FLAG_ACCOUNT_MERGE — All Console merge routes return 404 when the flag is off. Antlers verify page is absent (not rendered) when the flag is off.
- No stored credentials —
ADMIN_SERVICE_TOKENis read from env at request time, never written to Console DB or logs.
3. Answers to the Three Open Questions
Q1 — Where does the Console Layer-1 RBAC proxy live?
Decision: New blueprint console/app/blueprints/merges.py, registered at
url_prefix="/console/api/merges".
Not middleware. Not an extension of existing middleware.
Rationale: The existing rbac.py middleware provides decorators (@require_rbac_permission,
@require_rbac_role). Those decorators are the right tool — they check rbac_user_groups →
rbac_group_roles → rbac_roles/rbac_permissions exactly as designed. The proxy logic
(HTTP call to Raptor with injected headers, response forwarding) is per-route business logic,
not cross-cutting middleware concern. A dedicated blueprint follows the existing Console pattern:
customers.py, billing.py, flags.py are all standalone blueprints, not middleware
extensions.
Request flow:
Console session (cookie) → validate_session() → admin.id
→ _admin_has_rbac_permission(admin.id, "customers:merge:<X>")
[via rbac_user_groups → rbac_group_roles → rbac_roles → rbac_permissions]
→ 403 if denied (hide, don't gray — button not rendered if role absent)
→ inject headers: X-Admin-Service-Token, X-Merge-Permission-Granted,
X-Merge-CS-Actor-Id (SHA-256 of admin.email)
→ forward to RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<path>
→ return Raptor response body + status to Console UI / API caller
Module path: console/app/blueprints/merges.py
Blueprint registration:
# console/app/__init__.py (in create_app)
from app.blueprints.merges import bp as merges_bp
app.register_blueprint(merges_bp)
The blueprint defines two URL families:
- /console/merges and /console/merges/<id> — HTML UI routes (Jinja templates)
- /console/api/merges and /console/api/merges/<id>/* — JSON API proxy routes
called by the UI via fetch()
There is no url_prefix on the blueprint itself; routes declare their full path to
keep the distinction between HTML and JSON routes explicit.
Q2 — What value does X-Merge-Permission-Granted carry?
Decision: The exact customers:merge:* permission string that Console verified.
Specifically, one of:
- customers:merge:read
- customers:merge:initiate
- customers:merge:cancel
- customers:merge:reverse
- customers:merge:approve_reversal
Rationale: Reading internal_merges.py _check_merge_permission() (line 138–174):
Raptor compares X-Merge-Permission-Granted against the permission string the route
requires using _hmac.compare_digest(granted.encode(), permission.encode()). This is
a plain string equality check (constant-time). The value must therefore be the exact
permission string — not a role enum, not a signed token.
A signed token would require a shared secret and a decode step in Raptor; that would
couple Raptor to Console's signing key rotation and add latency. A role enum would
require a role-to-permission mapping in Raptor, but Raptor explicitly does not query
rbac_* tables (those tables live in the Console DB). The plain permission string
is the correct, minimal, and already-implemented choice.
Security note: X-Merge-Permission-Granted is only trusted by Raptor when
X-Admin-Service-Token also matches ADMIN_SERVICE_TOKEN (via _hmac.compare_digest).
Without the service token, the permission header is ignored and the request returns 401.
Since only Console holds ADMIN_SERVICE_TOKEN, only Console can forge a trusted
X-Merge-Permission-Granted value.
Q3 — Does Raptor enforce X-Merge-Permission-Granted, or is that #3249's job?
Decision: Raptor already enforces this. #3249's job is Console-side injection only.
Raptor's _require_merge_permission(permission) decorator (lines 177–195 of
internal_merges.py) is applied to every /api/internal/merges/* route. It:
1. Returns 404 if FLAG_ACCOUNT_MERGE is off.
2. Returns 401 if X-Admin-Service-Token is missing or invalid.
3. Returns 403 if X-Merge-Permission-Granted does not equal the required permission.
This is already implemented and merged in PR #3760. #3249 (Console) is purely responsible
for:
- Checking the Console session + RBAC tables before forwarding.
- Injecting X-Admin-Service-Token (from ADMIN_SERVICE_TOKEN env var) and
X-Merge-Permission-Granted (the permission string Console just verified) and
X-Merge-CS-Actor-Id (SHA-256 of admin.email for Raptor's audit hashing).
- Rendering the UI and forwarding Raptor responses.
No Raptor code changes are needed from #3249.
4. Data Model
4.1 Console DB — No new tables required
The Console DB already has (from migration 0215):
- rbac_roles rows for all five customers:merge:* permissions
- rbac_permissions rows for all five permission strings
- rbac_role_permissions join rows
- Group assignments for raxx-support-team, raxx-platform-admins, etc.
No Console migration 0216 or 0217 is needed for #3249. All RBAC rows were seeded in migration 0215. The Console proxy blueprint reads Console DB only for session validation and RBAC resolution.
Note on migration numbering: The next available Console migration slot is 0216. It is explicitly NOT consumed by #3249. Reserve 0216 for whatever ships next.
4.2 Raptor DB — No new tables
All merge state lives in Raptor's account_merges table (migration 0046).
Console reads and mutates it exclusively via Raptor's internal API.
5. APIs / Contracts
5.1 Console JSON API proxy routes
All routes in console/app/blueprints/merges.py. All return 404 when
FLAG_ACCOUNT_MERGE is off.
GET /console/api/merges
@require_rbac_permission("customers:merge:read")
Proxies: GET RAPTOR_INTERNAL_BASE_URL/api/internal/merges
Query params forwarded: page, per_page, status
Response: Raptor response body, status code forwarded
GET /console/api/merges/<merge_id>
@require_rbac_permission("customers:merge:read")
Proxies: GET RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>
POST /console/api/merges
@require_rbac_permission("customers:merge:initiate")
Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges
Body forwarded: { primary_user_id, secondary_user_id, freescout_ticket_id? }
X-Merge-Permission-Granted: "customers:merge:initiate"
POST /console/api/merges/<merge_id>/cancel
@require_rbac_permission("customers:merge:cancel")
Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/cancel
X-Merge-Permission-Granted: "customers:merge:cancel"
POST /console/api/merges/<merge_id>/reversal/initiate
@require_rbac_permission("customers:merge:reverse")
Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/reversal/initiate
X-Merge-Permission-Granted: "customers:merge:reverse"
POST /console/api/merges/<merge_id>/reversal/approve
@require_rbac_permission("customers:merge:approve_reversal")
Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/reversal/approve
X-Merge-Permission-Granted: "customers:merge:approve_reversal"
POST /console/api/merges/<merge_id>/reversal/abort
@require_rbac_permission("customers:merge:approve_reversal")
Proxies: POST RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/reversal/abort
X-Merge-Permission-Granted: "customers:merge:approve_reversal"
GET /console/api/merges/<merge_id>/events
@require_rbac_permission("customers:merge:read")
Proxies: GET RAPTOR_INTERNAL_BASE_URL/api/internal/merges/<merge_id>/events
Returns: Raptor audit event timeline for this merge (filtered merge.* events)
Headers injected on every proxy call:
X-Admin-Service-Token: <ADMIN_SERVICE_TOKEN env var>
X-Merge-Permission-Granted: <permission string verified by Console RBAC check>
X-Merge-CS-Actor-Id: <SHA-256 hex of g.admin.email>
Error forwarding: If Raptor returns 4xx/5xx, Console forwards the status code
and JSON body verbatim. The UI handles Raptor error codes (conflict, not_found,
four_eyes_violation, hold_window_active, reversal_window_expired, dsr_pending).
5.2 Console HTML UI routes
GET /console/merges
@require_rbac_permission("customers:merge:read")
Renders: templates/merges/list.html
Template context: merges list (from proxy), can_initiate (bool), flag_on (bool)
GET /console/merges/<merge_id>
@require_rbac_permission("customers:merge:read")
Renders: templates/merges/detail.html
Template context: merge record, events, can_cancel, can_reverse,
can_approve_reversal (all bools derived from actor role)
Template boolean helpers are derived from _admin_has_rbac_permission(admin.id, perm),
called once per render. A CS operator lacking customers:merge:cancel sees no Cancel
button. An operator lacking customers:merge:approve_reversal sees no Approve/Abort
buttons. (Hide, don't gray.)
5.3 Antlers customer-facing verify page
In frontend/raxx-next/:
GET /merge/verify/<merge_id>
Auth: customer session (raxx.app customer JWT, NOT Console session)
Renders: Antlers page component — MergeVerifyPage
Guard: calls Raptor's GET /merges/<merge_id> to confirm merge status and
that session.user_id is primary_user_id or secondary_user_id
Hidden (not rendered) when FLAG_ACCOUNT_MERGE is off
POST /merge/verify/<merge_id>
Body: { "code": "<8-char code>" }
Calls: Raptor's POST /merges/<merge_id>/verify (customer-facing route, no service token)
On success with both_verified: renders billing-choice prompt inline
GET/POST /merge/verify/<merge_id>/billing-choice
Body: { "choice": "apply_to_primary" | "stripe_refund" }
Calls: Raptor's POST /merges/<merge_id>/billing-choice
On success: renders "Merge is underway" confirmation
Verify page content (hide-don't-gray rules):
- Shows the submitting user's display name or email for confirmation.
- Shows "Enter the 8-character code from the email you received."
- No merge details (primary/secondary user IDs, other account's email) exposed.
- On expired code: "This code has expired. Contact support@raxx.app to request a new one."
- On rate-limit (10 attempts): "Too many attempts. Contact support@raxx.app."
- On wrong code: generic "Incorrect code." (no hint about remaining attempts — avoids
timing oracle for brute-force enumeration).
- No swap-primary affordance anywhere on this page.
- Hidden (route returns 404) when FLAG_ACCOUNT_MERGE is off.
6. Sequence Diagrams
6.1 Console Proxy — Initiate Merge
sequenceDiagram
participant UI as Console UI (browser)
participant C as Console Flask (merges.py)
participant RDB as Console DB (rbac_*)
participant R as Raptor /api/internal/merges
UI->>C: POST /console/api/merges {primary_user_id, secondary_user_id}
C->>C: validate_session(cookie) → admin
C->>RDB: _admin_has_rbac_permission(admin.id, "customers:merge:initiate")
alt permission denied
C-->>UI: 403 {error: "forbidden"}
else permission granted
C->>R: POST /api/internal/merges\n X-Admin-Service-Token: <token>\n X-Merge-Permission-Granted: customers:merge:initiate\n X-Merge-CS-Actor-Id: <SHA-256(admin.email)>
R->>R: _check_service_token() OK\n_check_merge_permission("customers:merge:initiate") OK
R->>R: INSERT account_merges; emit audit merge.initiated (M5)
R-->>C: 201 {merge_id, status: "initiated"}
C-->>UI: 201 {merge_id, status: "initiated"}
end
6.2 Console Proxy — Request Reversal (four-eyes)
sequenceDiagram
participant CS1 as CS Operator (browser)
participant C as Console (merges.py)
participant R as Raptor
CS1->>C: POST /console/api/merges/42/reversal/initiate
C->>C: RBAC check: customers:merge:reverse
C->>R: POST /api/internal/merges/42/reversal/initiate\n X-Merge-Permission-Granted: customers:merge:reverse\n X-Merge-CS-Actor-Id: <SHA-256(CS1.email)>
R->>R: four-eyes check: SHA-256(CS1.email) != initiated_by_cs
alt four-eyes violation
R-->>C: 403 {error: "four_eyes_violation"}
C-->>CS1: 403 — "A different team member must initiate the reversal."
else OK
R->>R: status=reversal_pending; audit merge.reversal_initiated (M5)
R-->>C: 200 {status: "reversal_pending", hold_expires_at: ...}
C-->>CS1: 200
end
6.3 Antlers Customer Verify
sequenceDiagram
participant A as Customer (browser, primary account)
participant ANT as Antlers (raxx-next)
participant R as Raptor /merges/<id>/verify
A->>ANT: GET /merge/verify/<merge_id>
ANT->>R: GET /api/merges/<merge_id> (customer session)
R-->>ANT: 200 {status, primary_user_id, secondary_user_id}
ANT->>ANT: confirm session.user_id matches one of the two IDs
ANT-->>A: render verify form
A->>ANT: POST /merge/verify/<merge_id> {code: "ABCD1234"}
ANT->>R: POST /api/merges/<merge_id>/verify {code: "ABCD1234"}\n (customer session cookie)
R->>R: argon2 verify code; set primary_verified_at (M4, M5)
alt secondary not yet verified
R-->>ANT: 200 {status: "waiting_for_other_account"}
ANT-->>A: "Waiting for the other account to verify."
else both verified
R-->>ANT: 200 {status: "verified", billing_choice_required: true}
ANT-->>A: render billing-choice prompt
end
7. Console UI Specification (CE-Skinned)
All UI uses the Confidence Engine look and feel (Direction C). No raw react-bootstrap chrome. No broker names in customer-facing copy.
7.1 Merge List View (/console/merges)
- Navigation: "Account Merges" under the Customers section of the Console nav.
- Table columns: Merge ID, Primary account (user_id), Secondary account (user_id), Status badge (colour-coded), Initiated at (UTC), FreeScout ticket (link if set).
- Status filter dropdown (all / initiated / verified / in_progress / completed / reversal_pending / reversed / cancelled / failed).
- "Initiate New Merge" button — visible only if
customers:merge:initiateis held. Opens initiate modal (inline on same page, not a new route). - Empty state: "No merges found." (no error, just empty.)
- Pagination: 20 per page.
7.2 Initiate Modal (inline, visible to initiate role only)
- Field: "Primary account — user ID or email search" (autocomplete against
Raptor's customer search endpoint, existing
/api/internal/customersif available, or plain integer user_id input). - Field: "Secondary account" (same).
- Field: "Support ticket ID" (optional text field, FreeScout ticket number).
- Warning block: "Both account holders will receive an email with a verification code. The merge will not proceed until both codes are entered."
- Submit disabled if primary == secondary (client-side guard; Raptor also enforces).
- On 201: closes modal, reloads list, shows success toast.
- On 409 (dsr_pending): toast "This account has a pending data request. Resolve it first."
- On 409 (conflict): toast "A merge already exists for these accounts."
7.3 Merge Detail View (/console/merges/<id>)
Header: Status badge + merge ID + FreeScout ticket link (if set).
Timeline section: Ordered list of audit events from /console/api/merges/<id>/events
showing: event name, timestamp (UTC), actor hash prefix (first 8 chars — never full hash),
key fields. Events with dimension: operator_interaction show an actor column.
Verification status:
- Per-account row: "Primary (user_id: N) — verified / awaiting code"
- Per-account row: "Secondary (user_id: N) — verified / awaiting code"
- If initiated status and either code not verified: "Resend code" button per account
(visible to customers:merge:read; triggers POST /api/internal/merges/{id}/resend
which Raptor exposes — see note below).
Billing choice display (D4):
- Shown only after billing_choice is set on the merge record.
- Label: "Billing disposition" — value: "Credit applied to primary account" or
"Refund to payment method on file."
Actions panel (visibility gated on role):
| Action | Required permission | Visible when |
|---|---|---|
| Cancel merge | customers:merge:cancel |
status = 'initiated' |
| Request Reversal | customers:merge:reverse |
status = 'completed' AND within 14-day window |
| Approve Reversal | customers:merge:approve_reversal |
status = 'reversal_pending' AND hold window elapsed |
| Abort Reversal | customers:merge:approve_reversal |
status = 'reversal_pending' |
"Approve Reversal" button additionally shows the reversal_hold_expires_at timestamp.
If the hold window has not yet elapsed, the button is absent and a countdown is shown:
"24-hour customer hold window expires at
No swap-primary button exists anywhere. No disabled/grayed swap button. It does not exist.
Error states:
- error_detail = 'dsr_pending' (status='failed'): "Merge was blocked by a pending
data request on one of the accounts."
- status = 'failed': red banner with error_detail text (sanitized before render).
7.4 Antlers Customer Verify Page
Component: frontend/raxx-next/app/merge/verify/[mergeId]/page.tsx
CE-skinned. Minimal — no merge metadata exposed.
Heading: "Verify account merge"
Sub-line: "You're signed in as <email>."
Input: "Enter the 8-character code from the email you received." [text, maxlength=8]
Submit: "Verify"
Success (waiting): "Verification received. Waiting for the other account."
Success (billing choice): show billing-choice form (two radio options):
- "Apply remaining subscription balance to my account"
- "Refund balance to payment method on file"
[Submit: "Confirm"]
Success (all done): "You're all set. Your accounts are being merged."
Error — wrong code: "Incorrect code."
Error — expired: "This code has expired. Contact support@raxx.app to request a new one."
Error — rate limited: "Too many attempts. Contact support@raxx.app."
Error — already consumed: "This code has already been used."
Error — FLAG off: 404 (route does not render).
Note on billing copy: "subscription balance" and "payment method on file" are the permitted operational terms. No mention of Stripe or any payment processor.
8. Migrations
No Console migration is needed for #3249. All RBAC seed data for merge permissions landed in migration 0215. The Console proxy blueprint uses only existing tables.
No Raptor migration is needed for #3249. account_merges and related tables
landed in Alembic migration 0046.
The next available Console migration slot is 0216. It is not consumed here.
9. Rollout Plan
Phase 1 — Dark (FLAG_ACCOUNT_MERGE=off) All merge routes return 404. Blueprint registered but all routes guarded. No Console navigation item shown. Antlers verify page route returns 404. CI passes.
Phase 2 — CS-Restricted (FLAG_ACCOUNT_MERGE=on, staging) Console "Account Merges" section live on staging. Internal team performs 2-3 synthetic merge round-trips: - Initiate → verify both accounts → billing choice → completion. - Reversal: initiate → 24h hold → approve. - Cancel flow. FreeScout auto-comment tested. Antlers verify page tested.
Phase 3 — Limited GA (FLAG_ACCOUNT_MERGE=on, prod) Console merge UI live. CS can initiate real customer merges on request. No customer-facing self-serve affordance exists.
Phase 4 — Docs GA Privacy Policy update + public docs page describing the merge process and customer rights.
10. Security Considerations
- ADMIN_SERVICE_TOKEN never logged. Console proxy reads it from env; it must not
appear in any log line. The
X-Admin-Service-Tokenheader is never echoed in Console responses. - Actor hash on forward. Console sends
X-Merge-CS-Actor-Idas SHA-256 ofadmin.email. This is the same hash Raptor stores ininitiated_by_cs/reversal_initiated_byfor four-eyes enforcement. Consistent across the system. - No Console-side state for merge records. Console never writes to or caches
account_merges. All state lives in Raptor. Console is a stateless proxy with an RBAC gate. This prevents Console-side state divergence from Raptor truth. - CSRF. All Console POST routes are protected by Flask-WTF CSRF (
SESSION_COOKIE_SAMESITE=Strict). The@csrf.exemptdecorator is not applied to any merge route. Antlers POST routes use Next.js Server Actions or standardcredentials: 'include'fetch with the raxx.app session cookie; CSRF is mitigated by SameSite=Strict on that domain. - Operator role check before proxy. Console never proxies to Raptor before
completing the full
_admin_has_rbac_permissionresolution. If the DB is unreachable,_admin_has_rbac_permissionreturns False (fail-safe) and the request returns 403, not forwarded to Raptor. - Antlers: no merge metadata exposed on verify page. The page shows only the
submitting user's own email.
primary_user_id,secondary_user_id, and the other account's email are never rendered on the verify page. - Rate limiting on verify. Raptor enforces 10 attempts per merge record. Antlers surfaces the rate-limit error gracefully without exposing attempt counts.
- Kill-switch.
FLAG_ACCOUNT_MERGE=offsilences all surfaces instantly. Console routes return 404. Antlers verify page route returns 404. No in-flight merge can be initiated or advanced from the UI.
11. Security / GDPR Checklist
- PII collected by Console UI: None. Console is a stateless proxy. No merge data
written to Console DB. The
X-Merge-CS-Actor-Idheader carries a SHA-256 hash of the operator email — not PII-recoverable. - PII on Antlers verify page: The page renders the submitting user's own email address (already known to them). No cross-account PII rendered.
- Retention: Covered by parent design §13. No new retention requirements in #3249.
- DSR: Console UI does not hold DSR-relevant data. Merge records in Raptor are covered by parent design §13.
- Audit trail: Console writes
console_audit_eventsrows for UI-triggered actions (console.merge.initiate,console.merge.cancel,console.merge.reversal_initiate,console.merge.reversal_approve,console.merge.reversal_abort). These are non-fatal parallel writes — Console audit failure does not block the Raptor operation. Raptor's M5-gated audit is the authoritative trail. - No stored credentials:
ADMIN_SERVICE_TOKENread from env at request time, never written to Console DB or logs. - Breach notification: Console UI handles no PII. Not in scope for breach notification beyond the parent design's Raptor-side coverage.
- Kill-switch:
FLAG_ACCOUNT_MERGE(see §9). - Secrets location + rotation:
ADMIN_SERVICE_TOKENin Infisical/SSM. Rotatable without redeploy (same env-var reload path as other service tokens).
12. Open Questions
None that block implementation. All three groomer questions answered above.
One forward-looking note for the operator:
Resend code button: The card spec mentions "Resend code" buttons in the Console
detail view (per account, up to 5 each). Raptor's /api/internal/merges/<id>/resend
is referenced in the design doc §6.1 but the stub in internal_merges.py does not
implement a resend endpoint yet. Feature-developer on #3769 (detail view sub-card)
should confirm whether the resend endpoint is available from #3247's scope or needs
a Raptor patch sub-card. If the endpoint is missing, the resend button is
implementation-blocked (not design-blocked). Flag it at claim time.