# Tenant modes (workspace profiles)

**Status:** planned → building (v0.14). **Spec owner:** ADR #020.

Each tenant can look and navigate like its own app — different nav,
different table columns, different *theme and shell* — while every mode
runs on the same data and the same components. PlusROI mode is matched to
the **host.bowden.works** design language (Rob's Flywheel taste); Bowden
Works keeps its current look.

## The principle: the UI is DATA, not code

One declarative **WorkspaceProfile** per mode describes the interface.
Generic components render from it. The single rule that keeps this from
becoming N apps:

> **No component ever contains `if (tenant === …)`. Every per-mode
> difference is a field in the profile object.**

Because the app renders *from* the profile, the eventual "human-editable
modes" UI is just moving the profile from a TS registry into a DB table +
editor — it touches ONE resolver, not the pages. That is the rip-out
guarantee.

## The profile shape

```ts
type WorkspaceProfile = {
  name: string                 // "plusroi" | "bowden-works" | "tingang"
  shell: 'sidebar' | 'topnav'  // chrome template (B layer)
  theme: string                // token set name → data-theme on <root> (A layer)
  nav: NavGroup[]              // groups → items {label, route, show}
  tables: Record<TableKey, {   // 'invoices'|'expenses'|'projects'|'entries'|…
    columns: ColumnKey[]       // pick + order from the table's canonical catalog
    labels?: Record<ColumnKey, string>
  }>
  terms?: Record<string, string>  // 'client'→'customer', 'name'→'official name'…
}
```

**Resolution:** `tenants.settings.workspace_profile` (a name; default
`"bowden-works"`) → registry lookup → shipped on `GET /api/me` as
`me.workspace` → the SPA renders nav + shell + theme + columns from it.
One resolver (`features/workspace/resolveProfile`).

**Three layers, three costs** (ADR #020):
- **A — skin** (colors, type, spacing, radius, density): a token set,
  flipped by `data-theme` on the root. Cheap; vary freely.
- **B — shell** (sidebar vs topnav, page framing): a small set of layout
  templates the profile names. Moderate; keep to ≤3.
- **C — bespoke pages**: allowed as an *exception*, flagged — never the
  default, or the "one codebase" win is lost.

**Columns = Airtable's "fields vs views".** The data endpoints stay
tenant-agnostic (they return the full row); the profile picks/orders/
relabels which columns render. A `columnCatalog` per table lists every
possible column + how to render it; the profile names a subset.

## The PlusROI theme (matched to host.bowden.works /dev/ui)

A `[data-theme="plusroi"]` block overriding `with`'s existing token
variables. Exact host values:

```css
:root[data-theme="plusroi"] {
  --color-accent:        #0aa5b8;  /* teal — chrome accent, bg / large   */
  --color-accent-strong: #087f8e;  /* teal-dark — hover · teal TEXT (AA)  */
  --color-accent-tint:   #dfeef0;  /* teal-tint — tinted fills            */
  --color-text-primary:  #1f2b33;  /* ink                                 */
  --color-text-secondary:#64757f;  /* muted — secondary / micro-labels    */
  --color-surface-0:     #eef1f3;  /* bg — app canvas                     */
  --color-surface-1:     #ffffff;  /* card                                */
  --color-border:        #e4e9ec;  /* hairlines                           */
  --color-code-bg:       #f3f6f7;
  --radius-card:         14px;
  --focus-ring:          2px solid #0aa5b8;  /* 2px offset, keyboard-vis  */
  /* status: ok green / warn amber / down red / info teal — tint fill +
     AA-dark text (keep with's semantic tokens, retuned to these hues) */
}
```

Type scale (system-ui, no web fonts): `--text-hero` 30/800, `--text-title`
16/700, `--text-body` 15/400, `--text-micro` 13/600 (uppercase micro-
label), `--text-meta` 12. Cards: white, soft shadow, hairline, 14px
radius, uppercase muted micro-label. Tables: sticky uppercase header,
hover rows, right-aligned `tabular-nums`, monospace refs, scroll-in-card.
Pills: tint fill + AA-dark text, optional status dot.

**Map, don't fork:** the theme overrides `with`'s EXISTING token variable
names so every shared component (DataTable, MoneyCell, forms, pills)
restyles automatically. No component is duplicated for PlusROI.

## Nav — PlusROI vs Bowden Works

- **PlusROI** (books-centric, sidebar): Dashboard · **Clients** ·
  **Contacts** · **Projects** · **Invoices** · **Expenses** · **Vendors**
  · **Settlement** · Reports. Time-tracking hidden.
- **Bowden Works** (time-centric, topnav — current): Entries · Import ·
  Invoices · CC · Projects · Team. Books/Settlement hidden.

## Data-ownership model (the dual-entry consequence — ADR #020)

Replaces the conflated `parallel_run` flag with an explicit
`tenants.settings.books_owner`:

- **`"sheet"`** — the nightly rebuilds this tenant's books from the sheet
  export (TRUNCATE + reimport) AND blocks in-app invoice/expense writes.
  (What PlusROI was.)
- **`"app"`** — the nightly LEAVES this tenant untouched; in-app writes are
  ALLOWED. The app is the system of record.

**The flip (chosen 2026-07-11, EXECUTED 2026-07-12 — ADR #021): PlusROI →
`"app"`.** One fresh final import brought it current (2,391 invoices /
4,724 expenses / 403 projects), then
`scripts/flip_books_owner.py --project-tenant plusroi --to app` set
`books_owner="app"` — that seed is frozen and writes are unlocked. rian
dual-enters from the sheet weekly; Danielle's sheet stays the safety net.
Bowden Works is unaffected (still legacy-mirrored nightly — a separate
pipeline; no flag = writable + rebuilt). The sheet importer skips any
tenant whose effective owner is `"app"` (checked BEFORE the
missing-export fatal, so a stale export can never fail an app-owned
nightly); the invoice/expense write-block
(`_require_writable_books`, 409 `SHEET_OWNED_READ_ONLY`) keys on
`books_owner == "sheet"`; a legacy `parallel_run: true` still reads as
sheet-owned (`app/services/tenancy.py::effective_books_owner`).
Reverting `--to sheet` requires `--confirm-wipe` — the next nightly then
wipes in-app rows (that is what sheet ownership means).

**⚠ DEPENDENCY discovered 2026-07-11 — stable party identity — RESOLVED
2026-07-12 (ADR #021).** The flip was NOT just a flag. The nightly BW
rebuild did `DELETE FROM parties` and recreated every party with a FRESH
uuid (ADR #003 #4). PlusROI SHARES parties with BW (Bowden Works, Adi,
Heather — the sheet importer matches them by name; the shared-entity
model is deliberate). So an app-owned PlusROI, whose invoices/expenses/
projects reference those party ids, would have been orphaned the next
time BW rebuilt. The transform now keeps party identities STABLE across
rebuilds — upsert-by-slug preserving uuids (harness-neutral: the harness
keys on party *names*, not ids) — and the rebuild's row-clears are
tenant-scoped so app-owned tenants' rows (books, engagements, audit
trail, partner-user party links) survive; with no app-owned tenant the
reset degenerates to the historical TRUNCATE (harness byte-identical by
construction). The harness extractor (`harness/extract_new.py`) is also
scoped to legacy-mirrored tenants, so books tenants are invisible to the
parity comparison by construction. Proven end-to-end by
`tests/migrate/test_app_owned_survival.py` (the phase-2 gate) and a live
import → flip → simulated-nightly run with an sha256-identical PlusROI
snapshot.

## Phasing

1. **Scaffold + PlusROI theme/shell + nav** (this is the see-and-feel).
2. **The app-owned flip** (books_owner; nightly gating; write-block; fresh
   import → flip PlusROI).
3. **Write forms, one entity end-to-end:** Client (+Contacts) → Vendor →
   Project (+ billing + the recurring model) → Expense → Invoice (+ status
   workflow) → settlement-month "apply". Then the recurring-invoice
   generator.

## Changelog
- 2026-07-11 — doc created; architecture + PlusROI theme spec locked
  (ADR #020). Build starting.
- 2026-07-12 — Phase 2 shipped (ADR #021): stable party identity
  (upsert-by-slug, uuids preserved across rebuilds), tenant-scoped
  rebuild reset, `books_owner` flag superseding `parallel_run`
  (`app/services/tenancy.py`), the sheet-step app-owned skip, the
  `SHEET_OWNED_READ_ONLY` write-block rename,
  `scripts/flip_books_owner.py`, harness extractor scoped to
  legacy-mirrored tenants, and the survival gate
  (`tests/migrate/test_app_owned_survival.py`). PlusROI imported fresh
  and flipped app-owned on the live sidecar; settlement gate 32/32.
- 2026-07-12 — Phase 1 shipped (presentation-only): the WorkspaceProfile
  registry (`frontend/src/features/workspace/profiles.ts` — `bowden-works`
  topnav/default + `plusroi` sidebar/theme, `resolveProfile`/`resolveNav`/
  `resolveThemeAttribute`), `useWorkspace()`, the PlusROI `[data-theme=
  "plusroi"]` token block in `tokens.css`, the sidebar shell (B) alongside the
  unchanged topnav in `AppShell.tsx`, config-driven nav, and config-driven
  columns proven on the Books invoices + expenses tables (`ledgerColumns.ts` +
  `ledgerCatalog.tsx`). Backend: `/api/me` now carries `workspace_profile`
  (name only) read from `tenants.settings.workspace_profile`, defaulting by
  slug. No data-model / money / migration changes. Phase 2 (the app-owned
  `books_owner` flip) and phase 3 (write forms) remain.
