---
status: shipped
---

# A dashboard workspace is its own row

> **Status: built (2026-10-03).** Migrations `20261003190000` to `20261003190007` in
> [`packages/db/migrations`](../../packages/db/migrations), and `20261003190008`, which moves a
> customer's logo and notification preferences onto its workspace. They fill the workspace rows once from
> the old columns, with no trigger behind them: nothing writes the old columns again (§3), and
> dropping them is a later change (§6); `customer.workspace_external_id` went on 2026-10-07 in `20261010040000_drop_legacy_auth_columns.sql`. The dashboard child tables' tenant columns are dropped
> here, so those tables reference only the workspace (§2). The change reverses
> [SHARED_DASHBOARD_AUTH.md](SHARED_DASHBOARD_AUTH.md)'s "no workspace table" decision and its
> host-picks-the-bank rule.

## 1. Why

A workspace was implicit: a customer's was `b2b.customer` itself, a bank's was `b2b.bank`, and
dashboard-only configuration sat on those rows beside ledger and rail configuration. Three things
followed from that:

- **Bank-dashboard wrote `b2b.bank`.** Its role, `bank_dashboard_rw`, held a column-level
  `UPDATE (theme, updated_at)` there so a bank could save its own theme. On the workspace table
  it holds ordinary DML, as on every other `bank` table.
- **"Has a dashboard domain" meant "dashboard enabled."** A bank could not keep its host and be
  switched off, or be enabled before it had a host.
- **The request's host chose the bank.** A member of several banks saw the host's bank, or on the
  apex every bank at once, with a per-page `?bank=` chooser. The tenant came from a header, and
  each page had to handle a set of banks.

## 2. The tables

| Table                              | Holds                                                                                                              |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `b2b.customer_dashboard_workspace` | `external_id`, `role_set_id`, `logo_path`, `notifications`                                                         |
| `bank.bank_dashboard_workspace`    | `external_id` (`bws_…`), `role_set_id`, `dashboard_domain` (unique, with a pattern CHECK), `theme`, `enabled`      |
| `b2b.customer`, `b2b.bank`         | `workspace_id`: an integer, NOT NULL, unique foreign key to the workspace. One workspace per customer and per bank |

A customer workspace's `external_id` is what services already call `workspaceId`. Existing
customers kept the value they had in `customer.workspace_external_id` (since dropped), so links carrying it keep
resolving; a new one gets the table's `cws_…` default. `dashboard_domain` must be one label under
`cfx.to` or `dev.cfx.to`: the CHECK and `BANK_DASHBOARD_DOMAIN_PATTERN` in
[`bank-services`](../../packages/bank-services/src/dashboardDomain.ts) hold the same pattern.
`enabled` is its own column and does not require a domain.

Memberships, invites, SSO connections and activity on both dashboards reference only their
workspace, through a NOT NULL `workspace_id`, and a dashboard user names the workspace they last
used in `last_workspace_id`. Their `customer_id` / `bank_id` and `last_customer_id` /
`last_bank_id` are dropped. A tenant reaches its rows through its own `workspace_id`, which is
unique, and a query that reports a tenant joins it back the same way.

Each table keeps one unique index, moved onto the workspace with the columns and predicate the
tenant one had:

| Index (`customer_dashboard_…` / `bank_dashboard_…`) | Columns                                                                        |
| --------------------------------------------------- | ------------------------------------------------------------------------------ |
| `membership_workspace_id_user_id_key`               | `(workspace_id, user_id)`                                                      |
| `invite_workspace_id_email_pending_key`             | `(workspace_id, lower(email)) WHERE deleted_at IS NULL AND status = 'PENDING'` |
| `sso_connection_workspace_id_live_key`              | `(workspace_id) WHERE deleted_at IS NULL`                                      |
| `user_activity_workspace_id_object_id_index`        | `(workspace_id, object_id)`, not unique                                        |

The upserts name these as their conflict targets. With one unique index per table, a racing
upsert's loser has nothing else to collide with and takes its `ON CONFLICT` branch; the race
that kept the earlier `workspace_id` indexes plain needed a second unique index beside the tenant
one, and that index is gone.

## 3. A one-time backfill

The migrations fill every workspace row and every `workspace_id` once, then hold them with
constraints. No trigger or function stands behind them.

- **Tenants.** `20261003190001` and `20261003190002` create one workspace per customer and per
  bank from the old columns and make the tenant's `workspace_id` NOT NULL and unique. A bank's
  `enabled` starts as `dashboard_domain IS NOT NULL`, which is what an enabled bank dashboard
  meant until then.
- **Customer dashboard child tables.** `20261003190003` adds the columns nullable, with NOT VALID
  foreign keys and a NOT VALID `workspace_id IS NOT NULL` check on memberships, invites, SSO
  connections and activity, so no NULL row can arrive after it. `20261003190004` backfills,
  `20261003190005` builds the activity index concurrently, and `20261003190006` validates, sets
  NOT NULL and drops the checks, then drops `customer_id` from the four tables and
  `last_customer_id` from the user, with their foreign keys and indexes, and builds the unique
  indexes on `workspace_id` (§2). A user's `last_workspace_id` stays nullable and has no check.
  `b2b.dashboard_customer_user`, the compatibility view over the membership's `customer_id` from
  `20260921180000`, goes in the same file; nothing read it.
- **Bank dashboard child tables.** `20261003190007` does the same in one transaction, because the
  tables are small, dropping `bank_id` and `last_bank_id`.
- **Customer logo and notification preferences.** `20261003190008` copies each customer's
  `data ->> 'logoPath'` and `notifications` onto its workspace's `logo_path` and `notifications`,
  then removes `logoPath` from the customer's `data`. The logo is moved, not copied.

The down blocks of `20261003190006` and `20261003190007` put the tenant columns back, filled from
`customer.workspace_id` / `bank.workspace_id`, with their original NOT NULL, foreign keys,
indexes and names, and re-create the view.

**The old columns are frozen.** `customer.dashboard_role_set_id`, `customer.notifications`,
`bank.dashboard_role_set_id`, `bank.dashboard_domain` and `bank.theme` keep the values they held
when the migrations ran. The services read and write the workspace row only (§4), so nothing
writes them again. `customer.workspace_external_id` was frozen the same way and is dropped as of
2026-10-07 (`20261010040000_drop_legacy_auth_columns.sql`); the workspace's `external_id` is the
only copy of that key. SQL that inserts a
customer or a bank directly has to insert its workspace first, as the seed files that create them
do (`20230807000000_customer.sql`, `20230620062301_compaction_reference_data.sql`).

**The deploy window.** Migrations run before every code deploy, so for a few minutes the
previously deployed code runs against this schema. It knows neither the tables nor the columns:

| The previously deployed code                                          | In the window                                                                                                                                                                 |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Creates a customer or a bank                                          | Refused, `23502`: the insert names no `workspace_id`                                                                                                                          |
| Customer dashboard: sign-in membership upsert, invite, activity write | Refused: `23514` from the NOT VALID check while the customer-side migrations run, `42703` once `customer_id` is dropped                                                       |
| Bank dashboard: sign-in membership upsert, invite, activity write     | Refused, `42703`: `bank_id` is gone                                                                                                                                           |
| Reads memberships, invites, SSO connections or activity               | Fails, `42703`: its queries on these tables name `customer_id` / `bank_id`. Sign-in and the pages that read members, invites, SSO or activity error until the new code is out |
| Stamps a user as last seen                                            | Fails, `42703`: `last_customer_id` / `last_bank_id` are gone                                                                                                                  |
| Saves a role set, a bank's dashboard domain or a theme                | Succeeds on the old column only. The workspace row does not get the value, and nothing carries it over afterward                                                              |
| Saves a customer's notification preferences                           | The same: it lands on `customer.notifications` only                                                                                                                           |
| Saves a customer's logo                                               | Writes `data.logoPath` back onto the customer. Nothing reads it, and the workspace keeps its logo                                                                             |
| Reads a customer's logo                                               | Finds none, since `data.logoPath` is gone: customers show initials, and replacing or removing a logo leaves the previous file in the bucket                                   |

**Rollback.** Rolling the code back means rolling the migrations back with it. The old columns
are stale after the first write by the new code, and the previous code reads them. The down
blocks of `20261003190001` and `20261003190002` copy the workspace's values onto the old columns
before dropping `workspace_id`; `enabled` has no old column and is lost. Those of
`20261003190006` and `20261003190007` restore the child tables' tenant columns from the
workspace (§3), and `20261003190008`'s does the same for the logo and notification preferences.

## 4. Services

- **`CustomerService.create`** inserts the workspace and the customer in one transaction, so a
  duplicate customer leaves no orphan workspace. `Customer.workspaceId` is unchanged for callers:
  it is the workspace's external id.
- **`CustomerService.update`** writes `logoPath` to the workspace row, and `Customer.logoPath`
  reads it from there; `CustomerNotificationPreferencesService` reads and writes the workspace's
  `notifications`. Both keep their signatures.
- **`BankAdminService`** reads and writes the workspace row with the bank. `Bank` gains
  `dashboardEnabled` and `workspaceId`. A domain that fails the pattern is
  `BANK_DASHBOARD_DOMAIN_INVALID` and one another bank holds is `BANK_DASHBOARD_DOMAIN_TAKEN`.
  Giving a bank its first domain opens its dashboard and clearing the domain closes it, unless
  `dashboardEnabled` is passed.
- **`dashboard-auth-services`** read a workspace's role set from the workspace row, and a role-set
  switch locks and writes that row instead of the tenant's. Every membership, invite, SSO
  connection and activity row they insert names only `workspace_id`, taken from the tenant row,
  and every upsert's conflict target is the unique index on it (§2). A caller's membership
  carries its workspace row, which their filters use; a result that reports a customer or bank
  joins it on `workspace_id`. A last-seen write moves `last_workspace_id`, and
  `CustomerDashboardUser.lastCustomerId` / `BankDashboardUser.lastBankId` are read back through
  it. No public service signature or result shape changed.
- **Admin-dashboard's bank form** has a "Dashboard enabled" switch, shows the two domain errors on
  the field, and the bank page shows the workspace id.

## 5. Bank dashboard: the tenant comes from membership

The session's bank is no longer read from the host.

1. **Grants.** `getAuthMemberGrants` reads the caller's ACTIVE memberships of enabled workspaces
   from one `searchOwn`. The host plays no part.
2. **Narrowing.** On a bank's own host, `narrowGrantsToHost` keeps only that bank's grant. A
   disabled bank's host keeps none, since no grant names a disabled workspace, so a member of
   other banks gets `/forbidden` there rather than another bank on its URL. The host can remove
   grants and never adds one.
3. **One active bank.** `chooseActiveGrant` picks, among what is left: the workspace the
   `cfx_workspace` cookie names while it is still granted, else the one last used, else the only
   one. With several and none chosen the caller goes to `/workspaces`; with none, `/forbidden`.
   `/switch?workspaceId=` sets the cookie, and only for a grant the caller holds on a host that
   may act in it.

Every page and action then acts in that one bank. The all-banks aggregation and the per-page
`?bank=` chooser are removed: a view across banks is admin-dashboard's job. A mutating action is
a `workspaceAction`: the page binds the workspace id it rendered as the first argument, and the
action runs only while that is still the session's active workspace, otherwise returning
`workspaceSwitched` and writing nothing.

**Branding** is display only (`chooseBranding`): the active workspace's row once signed in, else
the enabled workspace whose own host this is, else CFX's. The host read is
`BankDashboardHostService.getDashboardHost({ host })`, which answers for a disabled workspace's
host too, with `enabled: false`. It is a reader of its own rather than a method of the admin-only
`BankAdminService`, because it runs before anyone signs in. It is cached per host under the
`dashboardHost` key prefix of the `bank-search` namespace, and every `BankAdminService` write busts
it through the reader it holds ([CACHING.md §9](../CACHING.md#9-per-service-cache-summary)).
Beyond that the host supplies only the OAuth redirect and logout origins.

## 6. Deferred

- **Dropping `bank.theme`**, the last old column. `customer.dashboard_role_set_id`,
  `customer.notifications`, `bank.dashboard_role_set_id` and `bank.dashboard_domain` went in
  `20261008182814_drop_frozen_dashboard_columns.sql`, and `customer.workspace_external_id` in
  `20261010040000_drop_legacy_auth_columns.sql`. `bank.theme` still carries the Pulumi column grant
  `bank-dashboard-rw-bank-theme-update` (`packages/aurora/pulumi/workloadIdentities.ts`), so that
  grant has to be removed and applied before the column can drop.
- **Service-level bank-id isolation of the query services outside auth.** The dashboard passes
  them the one active bank; the services themselves are not changed here.
