# Shape — admin-dashboard scope, purpose, and design

Approved 2026-05-08. Durable handoff for `/impeccable craft <section>` work.

## 1. Feature Summary

The CFX-internal **operator dashboard** for the platform — a single keyboard-first surface for authoring and operating the runtime behind the customer-facing and bank-facing dashboards. Its functional surface area overlaps heavily with what a bank core covers: Account applications, Customers, Accounts, Transactions, Cards, Payments across every rail, Compliance (AML / KYC / sanctions), Products, Reports, and Admin (CFX runtime config — rules, routes, banks, vendors, custody) live here as co-equal first-class domains, _except lending_.

The regulatory baggage of a chartered bank core (call reports, capital-adequacy filings, charter-level compliance) is deliberately not in scope. CFX operates through partner banks, and the partner banks own that side. The dashboard inherits the **operator-surface anatomy** that bank cores converged on — left nav by domain, top search, work queues, entity-with-tabs, audit trail on every entity — without inheriting their regulatory posture or their visual cliches (see §3). The dashboard serves engineers, payments ops, the OTC desk, compliance, and support without role-switching.

What the dashboard _does_ inherit, with no compromise, is the **seriousness of the operations**. Every Confirm here moves real customer money, mutates live routing, edits real KYC state, or burns a token. **Dual-control / maker-checker on high-risk writes is a fact of life in v1**, not a v2 nice-to-have — the curated list lives in §11. So is audit-everything, diff-before-activate, and simulation-as-gate. Treat the brief's discipline floor as non-negotiable even though the regulatory ceiling is delegated to partner banks.

It absorbs the Interval-powered `internal-dashboard` over time and replaces it surface by surface (see §12 Absorption map for the explicit migration). Today's six sections (Routes / Rules / Simulator / Quote history / Products / Banks, plus orphaned Vendors) are a starting slice — the destination is the bank-core-_shaped_ scope, drawn against this brief.

## 2. Primary User Action

Find the resource you came for, then act on it. Most arrivals are mixed-mode across the five operator hats. The single most important affordance is getting from "I have a thought about X" to "I'm looking at X" in under three seconds — through ⌘K resource search, the bank-core-domain sidebar, the home work queues, the watchlist, or recent items. Acting on what's found (configure / activate / annotate / approve / drill into history) is the second-order action.

## 3. Design Direction

**Color strategy:** Restrained. Same family as `customer-dashboard` and `bank-dashboard`. No per-surface override. Color carries meaning (status, role, computed-amount sign), never decoration.

**Theme scene sentence:** _A CFX engineer at 11pm with a 27-inch monitor split four ways, reviewing three rule diffs while a payments-ops teammate pings about a stuck ACH file and a compliance officer next to them is auditing yesterday's vendor change._ Forces dark canvas, dense layout, low-chroma neutrals tinted toward the brand hue. Light mode ships at parity via `packages/ui` tokens; not a separate visual contract.

**Anchor references (four lanes):**

- **Linear** — Investigate. High-signal-density lists, ⌘K as a primary primitive, side panel preserves the queue, status-driven navigation, flat sidebar with thin dividers (no group-heading labels).
- **Stripe Sigma / Radar** — Configure. Rule-editing UI with simulation peers, fee/limit clip surfaces, audit trail on every write.
- **Modern Treasury operator surface** — Move. B2B money-movement seen from the platform side, ledger-as-product framing, rail-shaped subsections.
- **Mercury / Stripe Dashboard** — Entity-detail depth. Customer / Account / Card as full-page entities with horizontal tabs, related entities surfaced inline, history-as-tab, drill-down without losing the entity.

**Anti-references (bank-core specific, beyond PRODUCT.md's existing list):**

- **FIS / Fiserv / Jack Henry-style bank cores.** Concrete rejections: corporate teal/green chrome header, nested sidebar-inside-sidebar (two columns of nav at once), donut/pie charts as decoration, `…` overflow columns, vertically-stacked enterprise chrome, mid-2000s visual posture. Bank-core _anatomy_ (left nav by domain, top search, work queues, entity-with-tabs, audit trail) is adopted; bank-core _visual_ is rejected wholesale.
- **Baseella-style modern operator dashboards.** Closer to aspirational, still rejected: hero-metric template (big numbers + sparklines + drill arrows), brand-colored status (orange "AML review"), light-theme default, large circular icon glyphs as section anchors, marketing-y operator dashboard. Adopt the IA shape (left nav by domain, semantic drill-arrows); reject the visual posture.

PRODUCT.md anchors (inherited verbatim, not re-litigated): shadcn primitives via `packages/ui`, GT Pressura / GT Pressura Mono, OKLCH neutrals, cents-as-smaller-figure, plain-text status (no badges), SidePanel-vs-modal-vs-full-page detail surfaces.

## 4. Scope

- **Fidelity:** mid-fi at the IA level (full destination nav, write/audit/detail patterns committed). Sketch-quality on individual screens.
- **Breadth:** the whole eventual surface. Six existing sections + the migration target from `internal-dashboard`.
- **Interactivity:** shipping-quality patterns (the section template, the write flow, the side panel, ⌘K). Sketch interactions on individual screens.
- **Time intent:** anchor for sequenced execution. Each section migrates against the brief; the brief is durable until the destination IA changes.

## 5. Layout Strategy

### Sidebar (bank-core domains semantically; flat visually)

```text
[⌘K  Search customers, accounts, transactions, rules…]

Home

Account applications
Customers
Accounts
Transactions
Cards
─────
ACH files
Wires
RTP
Redemptions
Mints
Cash deposits
Reconciliation
─────
AML cases
Identities
Sanctions
─────
Products
Routes
Rules
Banks
Vendors
Custody
─────
Simulator
Audit log
Reports
─────
Admin
```

No group labels in the visual nav. Items grouped by spacing and thin dividers (Linear-style). The semantic clusters in this brief — Entities / Payments / Compliance / Configure / Tools / Admin — drive IA decisions, but the rendered sidebar reads as a single flat list with separators.

`Quote history` folds into `Audit log` (every quote is an audit event; per-resource Quote history stays as a tab on the Simulator and on Customer detail). `Identities` moves from Investigate → Compliance. `Vendors` lands in nav. `Custody` is new (CFX-side wallets and on-chain ops — see §12).

No workspace switcher. Single global view. Environments are per-host (no in-app env switcher).

Drop-the-headings rule extends to repeated section headers inside dense detail surfaces: prefer sub-section spacing or thin rules over shouty headings unless the section genuinely needs a name.

### Home (hybrid: search + work queues + watchlist + recent)

```text
┌─ ⌘K  Search customers, accounts, transactions, rules… ─────────────┐
└─────────────────────────────────────────────────────────────────────┘

WORK QUEUES                                          (static set in v1; role-aware in v2)
  Account applications pending review                     4 →
  AML cases needing review                                3 →
  ACH files pending submission                            12 →
  Wires awaiting approval                                 5 →
  Redemptions in pending                                  8 →
  Unreconciled bank transactions                          47 →

WATCHLIST                                            (operator-pinned entities)
  Customer  cust_4Bz9…    Acme Treasury
  Case      case_9Lk2…    Sanctions hit — pending decision
  Wire      w_7Pq3…       Drawdown — return window closing

RECENT ACTIVITY
  2026-05-08 14:22  rule  fee  Updated "ACH-pull standard"   alex
  2026-05-08 14:09  ops   ach  Submitted file batch_8281     ops bot
  …
```

No fake metrics. No hero-metric template. Queue counts are plain text colored by urgency (warning role when SLA-risked).

### Section template

```
┌─ Section header  [filters bar with chips]              [+ New …] ─┐
├─ Table (dense rows; ID column first, monospace; status as plain ─┤
│   text colored by role; no badges; pivot-aware columns)          │
│   Hover row → cursor: pointer                                    │
│   Click row → open detail SidePanel (read-mode)                  │
│   Click "Edit" in panel → navigate to /<section>/<id>/edit       │
└──────────────────────────────────────────────────────────────────┘
```

Detail SidePanel inherits customer-dashboard's posture: list narrowed but visible behind, header titled by the resource itself ("Activation rule", not "Rule details"), verbatim ID(s) copyable, deep-links out to related resources, History tab.

Edit is full page. Forms-first; JSON tab is the engineer's escape hatch (per PRODUCT.md principle 5). For matcher-bearing resources, the Builder tab is the default and the JSON tab is secondary (matchers must render visually, not be left as raw JSON).

### Entity detail page (first-class entities)

For first-class entities — Account application, Customer, Account, Card, AML case, Transaction — the detail surface is a **full page with horizontal tabs**, not a SidePanel. The SidePanel pattern stays for shallow resources (Rules, Routes, Banks, Vendors, Products).

```text
/customers/<id>
┌─ Acme Treasury  cust_4Bz9k2hQp7n  [copy]              [⋯ actions] ┐
│  Org · KYB approved · 2 accounts · 3 active cards                  │
├─ Overview  Accounts  Transactions  Cards  Payment Instr.          │
│  Cases  Rewards  Notes  History  Raw                               │
├─                                                                   │
│  [tab content: scoped section template — filter chips +            │
│   dense table + row-click → SidePanel for the child entity]        │
└────────────────────────────────────────────────────────────────────┘
```

- **Header line:** entity title + verbatim ID (monospace, copyable) + role-line of computed status as plain text colored.
- **Tabs:** read-mostly. Each renders its own scoped section template; row-click opens a child-entity SidePanel.
- **`[⋯ actions]` menu:** holds entity-level writes — destructive ones are dual-control-gated (v1). For Customer: bypass terms / reset terms / update KYC template / update Victor config / upload pricing schedule / create fee / freeze / unfreeze.
- **Edit** for entity-level scalar fields opens its own full-page form, same write flow as today's resources.
- **History tab** is the per-entity audit feed; same row component as the global Audit log scoped to this entity.

## 6. Key States

| State                    | What the operator sees                                                                                                                         |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **Default (populated)**  | Dense table, filter chips reflecting URL state, pagination at bottom.                                                                          |
| **Empty (zero results)** | Plain-text "No rules match these filters." with a "Clear filters" button if any active. No illustration. No mascot.                            |
| **First-run (no data)**  | Plain-text describing what this section is for, plus a single "+ New …" CTA. No multi-step onboarding.                                         |
| **Loading**              | Skeleton rows matching the table layout; never a centered spinner.                                                                             |
| **Error (load)**         | Plain-text error with the verbatim cause and a "Retry" button. Sentry receives the error in the background.                                    |
| **Detail SidePanel**     | Read-only. History tab present. "Edit" button at top right.                                                                                    |
| **Edit page**            | Form fields with inline validation; "Save" button disabled until dirty; "Cancel" navigates back.                                               |
| **Diff preview**         | After save, modal shows verbatim diff (current live vs draft). "Confirm" and "Back to edit" buttons. ESC = back, not dismiss.                  |
| **Activated**            | Diff modal closes; toast confirms; user lands back on the detail SidePanel showing the new live state. New audit entry visible in History tab. |

**Permission-denied** state ships with v2 auth (see §11). v1 has no in-dashboard permission gating.

## 7. Interaction Model

### Write flow

Single shape, with one conditional terminal step:

1. **Edit** — full-page form. Drafts persisted to URL state (refresh / share works).
2. **Diff preview** — on save, modal shows side-by-side or unified diff against current live. Verbatim, no abstraction.
3. **Confirm** — explicit "Confirm" button. ESC backs to edit (not dismiss). Confirm writes to DB and adds an audit entry.
4. **Activate** — _conditional_. For versioned resources (rules: activation / fee / limit), activation is a separate explicit action on the detail surface, with its own audit entry. For unversioned resources (routes / banks / vendors / products), save = live; step 4 does not exist.

### ⌘K (search + navigate)

- Fuzzy search across rules / routes / customers / transactions / quotes / vendors / banks / products by name and ID. Top 8 results, grouped by resource type.
- Type "go to" or use a leading slash for direct navigation: `/routes` jumps directly. Navigation entries always present below resource hits.
- No commands ("New rule", "Activate") in v1. Linear-grade search-and-nav, not Raycast-grade execution.
- Backing index: user-owned, built separately; UI assumes <300ms response from typing to results.

### Detail SidePanel

- Opens on row click. Closes on ESC, on outside-click, or on URL change to a non-detail page.
- URL state: `?detail=<id>` so deep-link / share / browser-back works.
- Header: resource title + verbatim ID (copyable, monospace, muted).
- Tabs: Overview / History / (per-resource: Matcher / Schedule / Audit / etc.) / Raw JSON.
- "Edit" routes to `/<section>/<id>/edit` full page; SidePanel closes.
- "View in Simulator" / "View in Audit log" deep-links sit at the top of the panel for fast pivots.

### Audit log (global)

- New section under Investigate. Reverse-chronological feed of every write across the platform.
- Per row: timestamp, actor, resource type, resource ID (deep-link), action (created / updated / activated / deactivated), short summary, "view diff" link.
- Filters: actor, resource type, date range, action. URL-state filters (deep-links carry).
- Per-resource detail surfaces have a History tab that re-uses the same row component, scoped to that resource. Two views, same data shape.

### Fast pivot primitive

`cmd+.` on any row, or right-click, opens a related-entity menu. Customer row → its open Cases / recent Transactions / linked Cards / matched Rules / Simulator-with-its-context. Universal across every list. Replaces ad-hoc "View in X" links scattered through SidePanels.

### Universal explain

Every computed value (a fee, a limit clip, a status, a rejection reason, an applied rule) carries a small `?` affordance that pre-pivots to Simulator with that row's exact inputs. Simulate-as-primitive, not Simulate-as-destination. v1 ships on a curated set: route-resolved fee, limit clip, activation status, transaction rejection reason.

### Recent items

Persistent dropdown next to ⌘K. Last 10 entities the operator touched, cross-section. Clicked from any surface; jumps directly. Local to the host (environments are per-host). v1.

### Watchlist

Pin any entity to home from its detail page (`pin` action). Distinct from queues (work to do) and recent (automatic). Persisted server-side per operator. v1 ships pin/unpin and home rendering; v2 adds shared watchlists.

### Saved views

Every list section supports saved filter+sort+columns combos. Section-default views ship in v1; user-saved views land with v2 auth. Pinable to sidebar.

### Bulk actions

Multi-select in tables → bulk action menu. "Approve N wires", "tag N transactions", "export N selected." Read-y variants (export, tag) ship without dual control; destructive variants (approve N, freeze N) inherit the v1 maker-checker gate on the whole batch (one second-operator confirm covers the batch).

### Maker-checker (dual control) — v1

Two-operator rule for high-risk writes; load-bearing in v1 because the operations themselves are load-bearing, regardless of regulatory framing (see §1). Maker drafts the write + diff; a second operator confirms.

- **Seam:** today's Diff modal is the maker step. v1 adds the second-operator gate at Confirm — the modal does not commit on the first operator's click. The drafted write becomes a first-class entity (a change request) with its own URL.
- **Identity requirement:** dual control needs distinct operator identities, even before full permission gating ships in v2. The host-level SSO that gets operators into the dashboard provides that identity, materialized as `admin.dashboard_user.id` (the actor surfaced on every audit row); v1 rejects same-operator confirm at the Confirm gate.
- **Curated v1 list** (also in §11): manual ledger adjustments, customer freeze / unfreeze, vendor or route changes affecting routing, manual mints, redemption commits, KYC overrides, identity deletes, card status changes for lost / stolen, account-application approvals.
- **Bulk variants** of the curated list inherit the same gate: "approve N wires" and "freeze N customers" require a second-operator confirm on the whole batch, not per-row.
- **Out of scope for v1:** role-based gating ("only this role can be the checker for this write"). v2 layers that on top via the v2 auth model. v1 ships any-two-operators dual control.
- **Detailed brief:** §13 expands this bullet into the data model, service interface, state machine, page tree, and migration plan. The customer-dashboard's [transaction approvals design](https://www.notion.so/cfxlabs/Customer-dashboard-transaction-approvals-33a5388dab68815e9904ff3397f21c50) is mirrored 1:1 here; admin-dashboard ships first and feeds back what it learns.

### Time-travel (v2)

Every entity surface gains a date scrubber: "Show this customer (or rule, or route) as of 2026-04-12 14:00 UTC." Audit log is the data source. v2.

### Operator notes

Sticky free-text notes attached to Customer / Account / Card / AML case / Transaction / Account application. Searchable via ⌘K. Audit-trailed (note creation/edit/delete is itself an audit event). Renders as a `Notes` tab on the entity detail page. v1.

## 8. Content Requirements

- **Sidebar:** no group labels (drop-the-headings). Items separated by thin dividers and spacing only.
- **Section page headers:** the resource plural ("Rules", "Routes", "Customers"). No subtitle. No breadcrumb if the section is top-level.
- **Empty states:** plain text describing what lives here. Examples: "No rules match these filters." / "No customers in this environment yet." / "No writes recorded in the selected window."
- **Error states:** "Couldn't load <resource>. <verbatim cause>." Plus retry. Never "Something went wrong."
- **Diff modal copy:** "<n> field(s) changed in <resource>. Confirm to write." with explicit list. Activation copy: "Activate <resource>? This will take effect immediately."
- **Audit-row copy:** verbs. "Created", "Updated", "Activated", "Deactivated", "Deleted" — never "made changes to" / "modified".
- **No celebratory copy.** No "Nice work!", no "All set!", no exclamation marks in confirmations. Toast says "Rule activated" — that's the whole sentence.
- **ID display:** monospace `font-mono text-xs`, muted. Truncated to 16 chars with ellipsis where space-constrained; full + copy-button on detail surfaces.
- **Timestamps:** absolute ISO ("2026-05-08 14:22 UTC") in tables. Relative ("3 minutes ago") on home recent-activity only.

## 9. Per-Section Decisions

Two layers: **9.A** is the destination shape for the bank-core modules (forward-looking; some are designed-for slots in v1). **9.B** preserves and evolves the existing six sections shipped today.

## 9.A Bank-core-shaped modules (destination shape)

### Account applications (new top-level entity; exemplar for queue + creation flow)

The prospect-to-customer (and existing-customer-to-new-account) entry point. Universal across operator dashboards in this functional space; new functionality beyond what `internal-dashboard` exposes — today, customer creation is a one-step `createCustomer` Interval action; here, the application becomes an entity with its own lifecycle.

- **Entity:** an Application. Status flow: Draft → Sent → In Progress → Submitted → Under Review → Approved / Denied. Approval spawns a Customer + at least one Account; the application stays as the audit record. Applies to net-new prospects and existing customers adding accounts.
- **List (queue-shaped):** filter chips by status / type (new customer vs new account on existing customer) / age / assigned reviewer. Plain-text status colored by role (Submitted = warning, Under Review = foreground, Approved = success, Denied = destructive).
- **Detail page (full page, horizontal tabs):** Overview / Documents / Checklist / Decision / Audit / Raw. Decision tab holds the verdict + reason + actor + timestamp; the Approve / Deny actions are dual-control-gated (v1).
- **Creation flow ("+ New application"):** operator fills basic info (prospect contact, intended product / account type, expected volume), submits → system generates an invite link the prospect uses to complete the rest. Tracks invite state (sent / opened / submitted) on the entity.
- **Internal-dashboard ports:** `platform/customer/createCustomer` and `platform/customer/checklistCustomer` move here from the Customer detail page.

### Customers (exemplar — shipped-quality)

Canonical entity-detail surface. Drives the entity-detail-page pattern every other first-class entity inherits.

- **List:** filter chips (status / type / KYC state / created window / pricing tier), dense table, ID column first, status as plain-text colored by role.
- **Detail page tabs:**
  - **Overview** — computed snapshot (status, KYB/KYC state, account count, recent activity), no sparklines, no hero metrics.
  - **Accounts / Transactions / Cards / Payment Instruments** — scoped section templates, row-click opens SidePanel for the child entity.
  - **Cases** — open AML / sanctions / support cases linked to this customer.
  - **Rewards** — administering this customer's reward program: event configs (create / update / delete), per-customer reward search and issuance. Per-customer concept; not a CFX-level admin module.
  - **Notes** — sticky operator notes (per §7 Operator notes).
  - **History** — cross-cutting audit feed scoped to this customer (KYC overrides, term resets, pricing changes, freezes, application history).
  - **Raw** — JSON.
- **Actions menu (`[⋯]`):** Bypass terms · Reset terms · Update KYC template (AiPrise) · Update Victor config · Upload pricing schedule · Create fee · Manage documents · Freeze / unfreeze. KYC overrides and freeze / unfreeze are dual-control-gated (v1).
- **Sub-types:** Organizations get the same shape with org-specific fields (legal entity, registered jurisdiction, beneficial owners). The org-vs-identity distinction is a tab (Members) on the Org detail.

### Compliance (peer module)

- **AML cases:** queue-shaped list (open / in-review / decided). Detail page (full-page entity): Timeline (events) / Evidence (linked transactions and docs) / Decision log (verdict + reason + actor + timestamp) / Notes / Linked entities. Decisions are dual-control-gated (v1).
- **Identities:** identity search → identity detail; doc upload review; KYC decision logging. Imports `internal-dashboard`'s `searchIdentity` / `viewIdentity` / `deleteIdentity` (deletion is dual-control-gated, v1).
- **Sanctions:** screening hits + dispositions. Designed-for slot in v1; full implementation later.

### Payments (rail-shaped subsections)

Each rail is its own sidebar item, each follows the section template, each gets a queue-shaped list with rail-appropriate columns. See §12 for which `internal-dashboard` actions land where.

- **ACH files / batches:** queue (pending → submitted → settled). File detail with line-items, returns, archive controls. Per-direction sub-views (deposit / withdrawal).
- **Wires (deposit / withdrawal / drawdown):** per-direction queue; beneficiary lookup integrated; Fed Wire directory inline on relevant forms.
- **RTP:** deposit queue.
- **Redemptions:** stage-based table (pending / committed / void / failed); commit / void actions are dual-control-gated (v1). Two tracks: redemption + withdrawal-redemption.
- **Mints:** operational queue (mints in flight). Token-authority side lives in Admin → Custody.
- **Cash deposits:** retailer-driven barcode flow; queue + per-barcode detail. Retailer registry lives in Admin → Retailers.
- **Reconciliation:** reconciled / unreconciled bank-side credit and debit views; manual transaction creation. Today's `bank/transaction/*` set.

### Cards (Girasol)

- **Cards list:** all issued cards; filters by customer, status, last-4.
- **Card detail:** full-page entity surface (per the standard). Tabs: Overview / Transactions / Status history / Notes / Audit / Raw. Lost / stolen status changes are dual-control-gated (v1); freeze / unfreeze are not.
- **Clearing batches:** queue (pending / processed). Process-clearing action.
- **Card accounts:** create / manage / reconcile / sync. Card-account detail surface follows the standard.

### Reports

New module. Three concerns:

- **Regulatory exports:** Dart-style packs (balance report, monthly transaction report, ad-hoc transaction report). Existing `internal-dashboard` preview flows port directly. Each export has a saved definition + scheduling (v2).
- **Ad-hoc queries:** query-builder for transactions / balances. Replaces today's `transaction_base_query` and `balance_timeseries_query` Interval actions. Fielded form, results table, CSV/XLSX export.
- **List exports:** every section table ships CSV/XLSX export from its filter+sort state. Universal affordance; not per-section copy.

### Admin (CFX runtime config + platform plumbing)

Each is a list → SidePanel → full-page edit (existing pattern):

- **Products / Routes / Rules / Banks / Vendors** — the existing six (covered in §9.B).
- **Custody** _(new)_ — CFX-side wallets (Utila, Solana accounts), token authorities (mintfx authorities, hot authorities, token metadata), program accounts, manual-mint authoring. Operations are sensitive; default-collapsed under Admin. Writes are dual-control-gated (v1).
- **API** _(new)_ — API keys + usage plans. `internal/api_keys` and `internal/api_usage_plans` ports.
- **Retailers** _(new)_ — cash-deposit retailer registrations. `cash_deposit_retailers` port.
- **Interest** _(new)_ — periodic interest accrual job. `calculate_moveusd_interest` port. Audit-trailed; dual-control-gated (v1).
- **Users** _(new — this work)_ — admin / role / permission tables in `admin` pgSchema. List + role-change form + matrix editor at `/admin/users`, `/admin/roles`, `/admin/permissions`. Single role per user; permissions resolved from DB per request. Mirrors PropelAuth's data shape so customer-dashboard ports the pattern later.
- **Tools** — Fed Wire directory lookup, payment-instrument upload, recipient upload — utility forms that don't fit a list. Live as small flat-form pages.

## 9.B Existing slice (visual evolution notes)

### Simulator (preserve function; evolve visual)

**Functional invariants — DO NOT CHANGE:**

- Form inputs: `productName`, `customerId`, `entityType` (IDENTITY / ORGANIZATION) required; `bankId` optional post-filter; `countryCode`, `regionCode`, `cashDepositRetailerId`, `hasIdentityDocument` as criteria; tri-state `amountMode` (none / source / target) with `amount` and `decimalPlaces`.
- Output shape: per-route metadata, provider fees, rule-applied fee, 8-row limit comparison (rule clip vs provider clip), effective fee template (CFX-tagged rule fee + provider fee only when `feeVisible`), activation block, computed quote when amount mode is source/target.
- Reason → rule deep-links: `SET_BY_CUSTOMER:<id>`, `SET_BY_ADMIN_FOR_CUSTOMER:<id>`, plain admin form. All link to `/rules/activation/<id>`. Fee rule → `/rules/fee/<id>`. Limit rule → `/rules/limit/<id>`. Route → `/routes/<id>`.
- "No audit row is written" disclaimer stays visible in the page header.
- BigNumber values stringified at the action boundary.

**Visual evolution (apply now, function untouched):**

- Replace `<Card>` group containers with section spacing + thin dividers. Form sections (Required / Route narrowing / Criteria / Amount) become semantic sub-sections, not card titles. Drop the headings where the section is implied by the fields.
- Replace ACTIVE / INACTIVE `<Badge>` with plain-text status colored by role: `text-foreground` for ACTIVE, `text-warning` for INACTIVE.
- Replace Provider / CFX / Customer receiver `<Badge>` (outline variant) with plain-text labels in a fixed-width column. Receiver becomes a column, not a chip.
- Per-route result Card → section with horizontal-rule separators between routes. Header line: `<product name> · <bank>  ·  ACTIVE  ·  Priority N` as plain text, monospace where it's an identifier.
- Computed-quote section keeps its inset (lighter background) but loses the rounded card feel; it's a sub-block of the route, not a card.
- Limit comparison stays as a 3-column grid (label / rule clip / provider clip). Column headers go from `text-xs uppercase` to plain small-caps muted matching the rest of the dashboard.

### Rules (activation / fee / limit)

- Three peer subsections (`/rules/activation`, `/rules/fee`, `/rules/limit`); existing tabs work.
- Detail SidePanel for read; full-page edit. Matcher gets a Matcher tab in the SidePanel.
- Editor uses the existing `MatcherEditor` (Builder + JSON tabs already implemented in `src/components/MatcherEditor/`). Builder is the default; JSON is the engineer's escape hatch. Matchers must always render visually.
- "Test in simulator" deep-link at top of every rule SidePanel. Pre-fills the simulator with that rule's matcher domain.
- Rules are versioned: write flow includes the conditional Activate step (Save → Diff → Confirm → Activate).

### Routes

- List has filters for product, bank, status, vendor.
- Detail SidePanel shows the full record + "Resolved by" panel with a sample of recent quotes that hit it.
- Edit page covers fee / limit / vendor wiring as a single form with section dividers.
- Save = live; no separate Activate step.

### Quote history (folded into Audit log)

- Quote history folds into the global Audit log (every quote is an audit event). Customer-scoped quote history surfaces as a tab on Customer detail; rule-scoped surfaces as a panel on the rule SidePanel.
- Row click → SidePanel with: inputs, resolved routes, rules that matched, computed quote.
- "Re-simulate this" button at top → opens simulator with this row's inputs pre-filled.

### Products / Banks / Vendors

- Vendors lands in the sidebar (currently missing).
- Each is a list → SidePanel → full-page edit. Vendor edit is the densest of the three; plan for matcher-style form complexity.
- All three: save = live; no separate Activate step.

## 10. Recommended References

- [`docs/AUTH.md`](../../docs/AUTH.md) — admin-dashboard RBAC: CF Access identity flow, the three services, the bootstrap allowlist, and the `lib/auth.ts` surface (`<ShowIfPermission />`, `mustHaveAuthPermissions`, `serverAction({ permissions })`). This is the canonical reference for the auth model shipped in step 1.5 below.
- [`docs/DASHBOARD.md`](../../docs/DASHBOARD.md) — customer-dashboard's server-action wrapper patterns and page conventions. The exported `lib/auth.ts` surface is identical to admin-dashboard's; the backing store (PropelAuth vs DB) is what differs.
- [`rules/dashboard-auth.md`](../../rules/dashboard-auth.md) — scoped to customer-/bank-dashboard files. Informational here; the admin-side story lives in `docs/AUTH.md`.
- [`rules/shadcn.md`](../../rules/shadcn.md) — auto-loads on `packages/ui`; CLI import-rewrite + dep-revert workflow.
- [`packages/customer-dashboard/PRODUCT.md`](../customer-dashboard/PRODUCT.md) — the section-template / SidePanel / status-as-plain-text canonical patterns admin-dashboard inherits.
- [`packages/bank-dashboard/PRODUCT.md`](../bank-dashboard/PRODUCT.md) — closest neighbor for admin-dashboard's posture.
- [`packages/admin-dashboard/PRODUCT.md`](./PRODUCT.md) — strategic anchor.
- `dashboard-dev` subagent — spawn for non-trivial dashboard work; rules auto-load there too.

## 11. Resolved Decisions and Forward Notes

- **Environments:** per-host. No in-app env switcher.
- **Auth (v1):** no in-dashboard permission gating. PropelAuth not used in admin-dashboard.
- **Auth (v2):** custom RBAC mirroring PropelAuth (admin / role / permission tables in the `admin` schema). Single role per user, single tenant — no orgs/subscriptions. JWT carries identity only; permissions resolved from DB at request time. See the RBAC implementation plan in `agents/plans/` for the full design.
- **Bootstrap allowlist (owner):** the initial `owner` cohort is granted lazily on first sight. `AdminDashboardUserService.upsert` consults a comma-separated email allowlist on **INSERT only** (the INSERT vs UPDATE branch of the upsert is discriminated by Postgres `xmax = 0`) and, when the new user's email matches, sets `role_id` to the `owner` role in the same transaction and writes a `user.roleAssigned` activity row with `data.source = "bootstrap-allowlist"`. `role_assigned_by_user_id` stays NULL (no actor). The UPDATE branch never touches role columns — the allowlist never re-grants or revokes on existing users. The allowlist is plumbed in via the `ADMIN_DASHBOARD_OWNER_EMAILS` env var; in Pulumi it lives under the `ownerEmails` config key on each stack (`packages/admin-dashboard/pulumi/Pulumi.{dev,prod}.yaml`). In dev (`NODE_ENV !== "production"`) it defaults to `dev@cfx.to` (matching `DEV_FAKE_CF_ACCESS_EMAIL` in `proxy.ts`) so a fresh `nx serve admin-dashboard` lands straight into an `owner`-roled user. Audit trail lives in the Pulumi config's git history plus `admin.dashboard_user_activity`.
- **Activation semantics:** rules version (have Activate step). Routes / Banks / Vendors / Products do not (save = live).
- **Matcher rendering:** must render visually via the existing `MatcherEditor`. JSON is the escape hatch tab.
- **⌘K backing index:** user-owned; UI assumes <300ms.
- **Role-aware home:** static work-queue set in v1; role-aware in v2 once the permission model exists.
- **Recent items:** v1. Persistent dropdown next to ⌘K. Last 10 entities the operator touched. Local to host.
- **Watchlist:** v1 (pin/unpin + home rendering); shared watchlists in v2.
- **Saved views:** section-default views in v1; user-saved with v2 auth.
- **Bulk actions:** v1. Read-y variants (export, tag) ship without dual control; destructive variants (approve N wires, freeze N customers, etc.) inherit the v1 maker-checker gate on the whole batch.
- **Universal explain:** v1 on a curated set — route-resolved fee, limit clip, activation status, transaction rejection reason.
- **Operator notes:** v1. Tab on every first-class entity. Searchable via ⌘K. Audit-trailed.
- **Maker-checker (dual control):** v1. Load-bearing because the operations are load-bearing (see §1), not because of regulatory framing. Applied at the Confirm step on a curated v1 list — manual ledger adjustments, customer freeze / unfreeze, vendor or route changes that affect routing, manual mints, redemption commits, KYC overrides, identity deletes, card status (lost / stolen) changes, account-application approvals. Requires distinct operator identities (host-level SSO satisfies this); rejects same-operator confirm. Role-based gating of who-can-be-checker is v2. **Detailed brief in §13** — change-requests-as-entities, `ChangeApproval*` services, the state machine, the page tree, and the migration plan. Mirrors the customer-dashboard's transaction approvals design 1:1 so we pilot before customer-dashboard build starts.
- **Time-travel views:** v2. Per-entity date scrubber backed by audit log.
- **Custody module:** new top-level Admin sub-section; Solana mintfx + Utila wallets + manual mint authoring land here, not in Payments.
- **Compliance + Reports:** promoted to peer modules, separate from Investigate / Configure.
- **Rewards scoping:** per-customer concept (a Customer detail tab covering event configs / issuance / search). Not a top-level CFX module and not an Admin sub-section.
- **Account applications:** new top-level entity covering net-new prospects and existing customers adding accounts. Universal across operator dashboards in this functional space; new functionality beyond what `internal-dashboard` exposes.
- **Migration sequencing:**
  1. Polish the existing six (routes / rules-{activation,fee,limit} / products / banks / vendors / quote history / simulator visual evolution) to this brief. **Lands ASAP.**
     1.5. **Admin user service + RBAC (this work).** New `admin` pgSchema with 5 tables (`admin.dashboard_user`, `admin.dashboard_role`, `admin.dashboard_permission`, `admin.dashboard_role_permission`, `admin.dashboard_user_activity`). Cloudflare Access JWT → DB upsert. Three services (`AdminDashboardUserService`, `AdminDashboardRoleService`, `AdminDashboardPermissionService`). `lib/auth.ts` mirroring customer-dashboard's exported surface. `<ShowIfPermission />`. New `/admin/users` and `/admin/roles` pages (matrix editor for role × permission grants per the CFX Permission Matrix shape). Existing admin-dashboard pages get broad permission gates. Maker-checker (§13) FKs into this. **Ships in parallel with step 1.**
  2. Audit log infrastructure (per-resource History tab + global feed) **+ v1 maker-checker scaffolding** (operator identity surfaced on writes; second-operator gate at Confirm). The two ship together — audit-trail without dual control is half a discipline. Sidebar reframe to bank-core-shaped domains.
  3. Customers + Transactions detail pages (full-page entity pattern); Notes + Watchlist + Recent items.
  4. Account applications (entity + creation flow + invite link). First consumer of dual control on a destructive write (approval).
  5. Compliance / AML cases + Identities. Identity delete + KYC override are dual-control-gated.
  6. Payments / ACH files.
  7. Payments / Wires.
  8. Cards (Girasol). Card status changes for lost / stolen are dual-control-gated.
  9. Remainder of Payments (Redemptions, Mints, Cash deposits, Reconciliation, RTP). Manual mint, redemption commit, manual ledger adjustment all dual-control-gated.
  10. Reports module (Regulatory exports, Ad-hoc queries, list exports).
  11. Admin / Custody, API, Retailers, Interest, Tools. Custody writes are dual-control-gated.
  12. v2 auth follow-ons (role-aware checker constraints) + time-travel + user-saved views + shared watchlists.

## 12. Internal-dashboard absorption map

Every `internal-dashboard` subdirectory has a home in the bank-core SHAPE. Source paths are relative to `packages/internal-dashboard/src/routes/`.

| internal-dashboard surface                                                                                                            | admin-dashboard home                                                                        |
| ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `backoffice/ach/{file,batch,deposit,withdrawal}`                                                                                      | Payments → ACH files (queue + per-file detail)                                              |
| `backoffice/wire/{deposit,withdrawal,beneficiary,drawdown,batch,file}`                                                                | Payments → Wires (per-direction queues + beneficiary lookup)                                |
| `backoffice/rtp/deposit`                                                                                                              | Payments → RTP                                                                              |
| `backoffice/redemption/{*,wallets,withdrawal}`                                                                                        | Payments → Redemptions; wallet creation under Admin → Custody                               |
| `backoffice/manual_mint`, `backoffice/bank/mint`                                                                                      | Payments → Mints (queue) + Admin → Custody (token-authority side)                           |
| `backoffice/bank/{transaction,internal_transfer}`                                                                                     | Payments → Reconciliation                                                                   |
| `backoffice/bank/{commit_redemption,manageDocuments}`                                                                                 | Payments → Redemptions actions; Customer detail → Documents                                 |
| `backoffice/dart/preview_*`                                                                                                           | Reports → Regulatory exports                                                                |
| `backoffice/fed-directory/lookup`                                                                                                     | Admin → Tools (also inline on wire/ACH forms)                                               |
| `backoffice/interest/calculate_moveusd_interest`                                                                                      | Admin → Interest                                                                            |
| `backoffice/utila/*`                                                                                                                  | Admin → Custody (Utila wallets)                                                             |
| `backoffice/virtual_account/*`                                                                                                        | Customer detail → Virtual accounts tab + Admin → Custody (Victor admin)                     |
| `backoffice/solana/*`                                                                                                                 | Admin → Custody (on-chain ops)                                                              |
| `backoffice/withdrawal/*`                                                                                                             | Transactions → Withdrawals filter                                                           |
| `internal/api_keys`, `internal/api_usage_plans`                                                                                       | Admin → API                                                                                 |
| `internal/cash_deposit_{retailers,barcode_scanner}`, `internal/view_cash_deposit_barcode`                                             | Payments → Cash deposits + Admin → Retailers                                                |
| `internal/balance_timeseries_query`                                                                                                   | Reports → Ad-hoc queries                                                                    |
| `internal/transaction_base_query{,_customer}`                                                                                         | Reports → Ad-hoc queries (customer-scoped variant pre-fills from Customer detail)           |
| `platform/customer/{createCustomer,checklistCustomer}`                                                                                | Account applications → creation flow + Checklist tab                                        |
| `platform/customer/{manageCustomer,manageAccount,manageDocuments}`                                                                    | Customer detail → Overview / Accounts / Documents                                           |
| `platform/customer/{bypassTerms,resetTerms,createFee,updateCustomerAiPriseTemplate,updateCustomerVictorConfig,uploadPricingSchedule}` | Customer detail → `[⋯ actions]` menu                                                        |
| `platform/identity/*`                                                                                                                 | Compliance → Identities                                                                     |
| `platform/organization/*`                                                                                                             | Customers → Organizations sub-type                                                          |
| `platform/transaction/*`                                                                                                              | Transactions (top-level) + Customer detail → Transactions tab                               |
| `platform/transfers/{adminTransfer,view}`                                                                                             | Transactions → Manual transfers (admin op, maker-checker v2)                                |
| `platform/paymentInstrument/*`                                                                                                        | Customer detail → Payment Instruments tab + Admin → Tools (recipient upload)                |
| `platform/product/productUsage`                                                                                                       | Products → detail page (Usage tab)                                                          |
| `platform/cards/{account,card,clearing,transactions}`                                                                                 | Cards (top-level)                                                                           |
| `platform/deposit`, `platform/withdrawal`                                                                                             | Transactions → Deposits / Withdrawals filters                                               |
| `platform/search/search`                                                                                                              | Universal ⌘K                                                                                |
| `reward/*`                                                                                                                            | Customer detail → Rewards tab (per-customer program admin: event configs, issuance, search) |
| `solana/mintfx/*`, `solana/view_moveusd_*`                                                                                            | Admin → Custody (token authorities, mints, burns, balance changes)                          |

### Surfaces that need explicit design attention

The bank-core taxonomy is awkward for these — call out in implementation, not silently absorb:

- **Custody.** Not in the canonical bank-core list. Real banks don't custody crypto; CFX does. Lives under Admin as a defensive default; promotable to peer module if scope grows. Sensitive — maker-checker on writes (v2).
- **Virtual accounts (Victor).** Half-customer-data, half-custody-config. Split: per-customer view on Customer detail; admin-side Victor config under Admin → Custody.
- **Reconciliation.** Not in the canonical bank-core list but core to running rails. Lives under Payments because it's where the reconciled side of every rail surfaces. Promotable to peer module if surfaces grow large.
- **Cash deposits.** Customer-facing channel that's neither a pure "Payment rail" nor pure "Customer config". Split: operational queue under Payments → Cash deposits; retailer registry under Admin → Retailers.

## 13. Change approvals (v1 maker-checker implementation)

This section expands §7's `Maker-checker (dual control)` and §11's curated-list bullet into a buildable brief. It is the detailed shape for one of the v1 commitments, not a separate document — sequencing for the section lands in §11 migration step 2.

### 13.1 What this is

A first-class change-request entity, sitting between the form and the underlying `*Service.update()`. Every form submit on a versioned-or-curated-list write surface (RouteForm, RuleForm, VendorForm, ProductForm, and every subsequent write surface in §12 migration steps 4–11) creates a `change_approval_request`, redirects to its URL, and gates the apply step behind RBAC + (when required by config) approver sign-off.

The design is a 1:1 mirror of the [customer-dashboard transaction approvals](https://www.notion.so/cfxlabs/Customer-dashboard-transaction-approvals-33a5388dab68815e9904ff3397f21c50) Notion design — same table shape, same state machine, same service surface, same status vocabulary. Names are admin-flavored (`ChangeApproval*`, not `TransactionApproval*`) because the domain is config-changes not transactions, but every method signature, every table column role, every status value, and every page route is the customer-dashboard's design unchanged. **Admin-dashboard ships first**; the customer-dashboard team picks up the implementation as a rename pass once we've shaken out the UX.

One deliberate divergence: every form submit drafts, no auto-accept paths, the detail page is the universal confirmation screen. Rationale + recommendation back to customer-dashboard in §13.10.

### 13.2 Scope

Form pages that today call `update*` server actions on canonical state:

- [`routes/_components/RouteForm.tsx`](./src/app/routes/_components/RouteForm.tsx) → `updateRoute`
- [`vendors/_components/VendorForm.tsx`](./src/app/vendors/_components/VendorForm.tsx) → `updateVendor`
- [`products/_components/ProductForm.tsx`](./src/app/products/_components/ProductForm.tsx) → `updateProduct`
- [`rules/_components/RuleForm.tsx`](./src/app/rules/_components/RuleForm.tsx) → `updateActivationRule` / `updateFeeRule` / `updateLimitRule`
- Future: every write surface in the §12 migration as it lands.

`create*` actions stay direct-write for v1 (same scoping decision as the customer-dashboard — "the diff is X → Y, a new row has no X"). Easy to extend later; out of v1.

### 13.3 Requirements (from customer-dashboard, translated)

- A subset of users can approve / execute change requests, by group or by individual user(s).
- Approvals are available to all relevant resource changes.
- CFX admins can decide which resource changes require approval (matchers: resource type + payload criteria — limit-rule `MAX_USD > $1M`, route with non-default `holdBusinessDays`, etc.).
- Drafters have to request approval if their change requires it.
- Drafters and approvers can see a list of requests: pending, cancelled, executed.
- Drafters can edit and cancel their own change requests (edit resets approvals).
- Approvers can approve and execute (if all approval conditions are met).
- CFX admins can cancel any change request.

### 13.4 Data model

```mermaid
erDiagram
  change_approval_config {
    int id PK
    text status
    jsonb config
    timestamp created_at
    timestamp updated_at
  }

  change_approval_request {
    int id PK
    text external_id
    text status
    int requester_id FK
    jsonb data
    text resource_id
    timestamp created_at
    timestamp executed_at
    timestamp updated_at
  }

  change_approval {
    int id PK
    text status
    int request_id FK
    int approver_id FK
    timestamp created_at
    timestamp updated_at
  }

  admin_dashboard_user ||--o| change_approval_request: requester
  admin_dashboard_user ||--o{ change_approval: approver

  change_approval_request ||--o{ change_approval: approves
```

- `change_approval_config` holds the rules CFX admins author. `status`: `ACTIVE` / `DISABLED`. Single global row (customer-dashboard's `customer_id FK` omitted — admin is single-tenant).
- `change_approval_request` captures the candidate change payload. `status`: `PENDING` / `EXECUTED` / `CANCELLED`. `resource_id` is the external_id of the resource being changed; populated at draft time so the queue page can group by resource. `data` carries the form's submit payload plus a `resourceType` discriminator.
- `change_approval` captures the approvals granted against the request. `status`: `PENDING` / `APPROVED` / `DECLINED`. `PENDING` is the back-flip state an approver flips to if they want to undo a prior approval / decline.
- External-id prefix: `drft_*` (same prefix family as the customer-dashboard; distinct entities).

### 13.5 Services

```mermaid
classDiagram
  class ChangeApprovalConfigService {
    upsert(rules)
    search()
    delete()
    getApprovers(resourceType, payload)
  }

  class ChangeApprovalService {
    create(requesterId, input)
    search(...)
    get(requestId)
    update(requestId, input)
    approve(requestId, approverId, status)
    cancel(requestId)
    canExecute(requestId)
    execute(requestId)
  }

  ChangeApprovalService --> ChangeApprovalConfigService: uses
```

- All admin-service methods are global (no `customerId` parameter).
- `ChangeApprovalService.update()` resets the approvals.
- `ChangeApprovalService.execute()` is heavy and dispatches to the right per-resource admin service (`ProductRouteAdminService.update` etc.) inside one DB transaction with the request status flip.
- `ChangeApprovalConfigService.getApprovers()` returns either a list of approvers or an empty list (= no approval required).
- `ChangeApprovalConfigService` is aware of `admin.dashboard_user.status = DISABLED` and renders disabled users in disabled state in the rule editor; never returns them from `getApprovers()`. Group names resolve to `admin.dashboard_role.name`.

Service objects:

```ts
type ChangeApprovalConfig = {
  status: "ACTIVE" | "DISABLED";
  rules: {
    approvers: { groups: string[]; users: string[] }[]; // AND between array items
    status: "ACTIVE" | "DISABLED";
    matcher: object; // criteria: resourceType, payload predicates
  }[];
};

type ChangeApprovalRequest = ({ status: "PENDING" | "READY" | "CANCELLED" } | { status: "EXECUTED"; versionId: number }) & {
  id: string; // drft_
  status: "PENDING" | "READY" | "EXECUTED" | "CANCELLED";
  changeInput: object;
  resourceType: "product_route" | "product_activation_rule" | "product_fee_rule" | "product_limit_rule" | "product_vendor" | "product";
  resourceId: string;
  approvers: { userId: string }[]; // who CAN approve
  approvals: { userId: string; approvedAt: Date }[]; // who HAS approved
};
```

`READY` is a service-only status, not in the DB. Readiness is re-evaluated at execution time, never cached.

### 13.6 State machine

```mermaid
stateDiagram-v2
  [*] --> PENDING: create()
  state if_ready <<choice>>
  PENDING --> if_ready: approve()
  if_ready --> READY: Fully approved
  if_ready --> PENDING: Outstanding approvers
  state if_success <<choice>>
  READY --> if_success: execute()
  if_success --> EXECUTED: Success
  if_success --> READY: Failed
  EXECUTED --> [*]

  PENDING --> CANCELLED: cancel()
  READY --> CANCELLED: cancel()
  CANCELLED --> [*]
```

Identical to the customer-dashboard's state machine. `EXECUTED` is kept as the terminal-success vocabulary (rather than something admin-flavored like `APPLIED`) so both systems read the same state names once the customer-dashboard ships.

**No-approvers-required edge:** when `getApprovers()` returns an empty list at `create()` time, the request has no required approvers and is service-derived as `READY` immediately. `canExecute()` returns true from creation. The detail page renders a `Confirm and execute` CTA right away. This is the case that, in the customer-dashboard, would auto-execute on the quote-confirm page; here we force the click-through so the diff is always seen before apply.

### 13.7 M-of-N approvals

The `change_approval` table is keyed on `(request_id, approver_id)`. A request's approval set is:

- **Required approvers**: the union of `users` and members of `groups` resolved from each `rule.approvers` entry. Multiple `approvers` entries in a single rule are AND-combined (a rule like `[{groups: ["compliance"]}, {users: ["alice"]}]` means "one compliance person AND alice"). Customer-dashboard's "AND between array items" convention, preserved.
- **Granted approvals**: the rows in `change_approval` with `status = APPROVED`.

A request is `READY` iff every required approver has at least one `APPROVED` row. `canExecute()` recomputes this at execute time against the live config + live `change_approval` rows (the source of truth, not a cached flag).

Supported shapes:

- Single-approver (1-of-1): one rule, one `approvers` entry, one user / group of size one.
- M-of-N within a group: one rule, one `approvers` entry naming a group. Any one member satisfies (the customer-dashboard convention; preserved).
- M-of-N across groups: multiple `approvers` entries in one rule (AND across entries).
- Decline kills the request: any `DECLINED` row blocks `READY` and surfaces `Declined by X` on the detail page; the drafter must cancel + re-draft.

### 13.8 Page tree

| Route                            | Purpose                                                                                                                                                                                                                                                                |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/settings/approvals`            | CRUD over `ChangeApprovalConfigService` rules. Lists each rule with matcher + required approvers + status. Sits in a new `Settings` sidebar group at the bottom of the nav. Gated by `admin/approvals-config:w` (one or two owners).                                   |
| `/routes/[id]/edit` (etc.)       | Existing form. On submit, the server action **always** calls `ChangeApprovalService.create()` — no `getApprovers()` upstream of create, no auto-accept branch. Redirect always lands on `/changes/approvals/[requestId]`.                                              |
| `/changes/approvals`             | Queue. Fetches `PENDING` requests where the current user is requester or approver. Lives in the `Investigate` sidebar group (count badge for pending count).                                                                                                           |
| `/changes/approvals/[requestId]` | Universal detail page. Same URL is post-submit landing, queue-item detail, and confirmation screen. Renders the diff (baseline vs. proposed) + approval state. RBAC gates the apply / approve CTAs — non-permitted users see read-only diff. Behavior matrix in §13.9. |

### 13.9 Interaction model

Drafter actions on `/changes/approvals/[requestId]`:

- **Cancel request** → `ChangeApprovalService.cancel()`.
- **Edit request** → opens the original form pre-populated with the request payload. On submit: `ChangeApprovalService.update()` (resets approvals); stay on the detail page.

Approver actions:

- **Decline** → `ChangeApprovalService.approve(DECLINED)`.
- **Approve** → `ChangeApprovalService.approve(APPROVED)`; recheck live config via `getApprovers()`; if no more approvers required, `ChangeApprovalService.execute()`. If execute fails (e.g. baseline drifted against the live resource), display the cause and leave the request in `READY` for retry. On success, redirect to the resource's detail page.

CTA matrix on the detail page:

| Drafter is...           | Approvers required by config? | Primary CTA                                                                                       |
| ----------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------- |
| Anyone with resource:w  | None                          | `Confirm and execute` (visible immediately; `canExecute()` is true from creation).                |
| In the approver set     | Yes                           | `Approve and execute` (one button; service composes `approve()` + `execute()`).                   |
| Not in the approver set | Yes                           | Read-only diff + `Waiting on approval.` line + list of who can approve. Cancel + Edit still show. |

Permissions:

- **Approval gating** is NOT RBAC; it's driven by `ChangeApprovalConfigService` (mirrors customer-dashboard exactly).
- **Resource write permissions** (`route:w`, `rule:w`, etc.) are checked on BOTH requester and approver — same convention as customer-dashboard checking payment-submit on both parties.
- **`/settings/approvals`** is gated by a separate `admin/approvals-config:w` permission so only a small set of owners can author approval rules.

User-activity events (same family as customer-dashboard, renamed):

- `changeApprovalConfig.upserted`
- `changeApproval.created`
- `changeApproval.updated`
- `changeApproval.approved`
- `changeApproval.cancelled`
- `changeApproval.executed`

### 13.10 Visual treatment

Inherits the global rules from §3 and §8. Section-specific overlays:

- **No green/red diff cells.** Old values are `line-through text-muted-foreground`; new values are `text-foreground`. Diff is typographic per `Two-Tier Ink`.
- **Mono for IDs and structured values.** Matcher diffs render in mono; raw JSON shows inside an expandable "View raw" disclosure (engineer escape hatch). Builder-side diff is the canonical view.
- **Sentence case** for page titles, button labels, and field rows.
- **Status text in `Two-Tier Ink`.** `PENDING` in `text-foreground`; `EXECUTED` in `text-success` (success-green earns its place here because EXECUTED is the rare terminal happy state, not the unmarked default); `READY` in `text-foreground` with a `Ready to execute.` helper line; `CANCELLED` / `DECLINED` in `text-muted-foreground`.
- **Inline preview for Timing edits.** Where the diff touches `holdBusinessDays` / `settlementBusinessDays`, render `Funds available Friday, May 15 → Friday, May 22.` style downstream-effect previews. Reuse the helper from `RouteForm`.
- **New sidebar entries**:
  - `Approvals` under `Investigate` (links to `/changes/approvals`). Count badge for pending count.
  - `Approval rules` under a new `Settings` group at the bottom of the nav.

### 13.11 Deliberate divergence + feedback to customer-dashboard

Customer-dashboard auto-accepts when (a) no approval is required by config or (b) the drafter is in the approver set for their own change. Admin-dashboard always drafts. Reasons we're piloting the all-draft variant here:

- **The detail page becomes the universal confirmation screen.** Same URL, same diff render, same audit row, every change. Customer-dashboard has two confirm UIs (one inline on the quote-confirm page, one on the request detail page); all-draft collapses to one.
- **RBAC naturally gates the apply step.** A `Confirm and execute` CTA can be wrapped in `<ShowIfPermission>` without form-vs-request branching anywhere upstream.
- **Self-approvers still get to see the diff.** The customer-dashboard's self-bypass skips the visual confirmation step entirely for the most authorized users — exactly the population most likely to be moving fast and benefit most from a forced pause. Forced click-through costs one click, recovers one mid-air typo per quarter.
- **One write path, one audit trail.** Every change has a request row regardless of approver configuration; the version history never has a "applied via the form" branch alongside an "applied via the approval flow" branch.

**Recommendation back to customer-dashboard, pending operator feedback**: keep the all-draft flow in customer-dashboard too. We'll update this with empirical findings after the admin pilot has run for a week or two with real operator use.

### 13.12 Resolved decisions (from customer-dashboard "Outstanding")

The customer-dashboard's Notion doc lists two open questions; we resolve them here first since we ship first.

1. **What happens to metadata and attachments?** Admin equivalent: matcher payloads can reference resources that get edited / deleted between draft and execute. **Resolution:** `canExecute()` re-validates resource references at execute time. If a referenced resource is missing / soft-deleted / disabled, surface a clear error on the request detail page and let the drafter cancel + re-draft. No deeper snapshot — defers the problem and confuses the audit chain.
2. **Who should execute the request?** **Resolution:** the last approver. They're in the page, have most context on the approval decision, and the audit row records both their identity and the request's drafter. If execute fails (e.g. baseline drifted), the request drops back to `READY` and either party can retry.

### 13.13 Migration plan

Lands resource-by-resource behind a feature flag (GrowthBook or similar):

1. **Infrastructure first** — three tables, two services, queue pages, settings page, sidebar entries. No form changes yet; the system exists in the codebase but isn't used. ~4 days.
2. **RouteForm pilot wiring.** Existing direct-`updateRoute` path stays; flag off = current behavior, flag on = drafts route through approval requests when the config matches. ~1 day.
3. **Operator feedback round.** Payments-ops + compliance + engineering each run real edits on staging. Adjust copy, diff renderer, matcher-rule UX, approver assignment. ~2 days.
4. **Roll out to Rule × 3 / Vendor / Product forms** one at a time, each behind the same flag. ~0.5 day each.
5. **Remove the flag** once every form is on the new path. The underlying `*Service.update()` methods should only get called from `ChangeApprovalService.execute()` — enforce with a service-internal annotation that lints against direct calls.
6. **Port to customer-dashboard.** Once the admin pilot has shaken out the UX, port the shape over. Renames only: `ChangeApprovalService` → `TransactionApprovalService`, `change_approval_*` → `transaction_approval_*`, the `/settings/approvals` page lifts from admin to per-customer in the customer dashboard, and so on. ~1 day for the rename pass, plus customer-side dashboard wiring (separate effort).

Total to first admin pilot ship: ~7 days. Full admin rollout: ~10 days.

### 13.14 Open questions

- **`/settings/approvals` matcher-rule UX.** Matchers are deeply-nested per resource type (a route matcher and a limit-rule matcher have different fields). Decide whether to (a) render per-resource-type editors, (b) leverage the existing `MatcherEditor` from §11, or (c) ship JSON-only for v1 and revisit. Recommend (b) once `MatcherEditor` is generic enough; otherwise (c).
- **Notifications.** When a request is drafted, who gets pinged? Slack channel? Email? In-dashboard inbox? v1 = nobody is pinged, approvers refresh the queue. v2 = wire to existing notification infra once we know operators want it.
- **Diff fidelity for matchers.** Matchers are deeply-nested JSON. Decide whether to (a) render the Builder view side-by-side, (b) render only changed leaf conditions, or (c) defer matcher diffing to v2 and say `Matcher changed.` with a "View raw" disclosure. v1 ships (c).
