# Organizations & Team — current-system spec (parity bar for the rebuild)

> This feature is the app's identity + roster layer: a hardcoded single tenant
> ("Bowden Works"), org membership roles (owner/manager/member), per-user billing
> **teams**, and **team_members** — the workers (no logins) whose Clockify hours are
> matched by email at import time and whose `cost_rate_usd` / `billout_rate_usd`
> get stamped onto every time entry. It is the source of truth for "who is this
> hour by, what does it cost Rian, and what does Rian bill for it." In the rebuild
> this whole area dissolves into parties + engagements + compensation_terms
> (02-domain-model.md §1–2); this doc is the exact behavior that mapping must
> reproduce.

## User workflows

- **Rian (owner, super admin)** maintains his own team's roster — himself
  (`rian@rian.ca`), Matthew Monti-Cooper (`matmoncoo@gmail.com`), and **Gary
  (`gary@rian.ca`), an AI team member with cost $0 and billout $25/hr**. He sets
  every member's cost rate (what he pays) and billout rate (what he charges
  PlusROI/clients). Billout is his private number — Adi must never see it.
- **Adi (manager, owns his own team)** maintains the roster of his Tingang
  developers (~18 seeded from the old "Rate Lookup" sheet). The one thing that
  must be right is the **email**: it has to match the `Email` column of the
  Clockify CSV he uploads, or his import rows come in unmatched (no member, no
  rates). He sets `consolidate_as = "Tingang"` on his devs so exports/summaries
  group them under one label; his own row leaves it blank.
- **Org membership itself has no UI.** Rian and Adi were seeded once
  (`scripts/seed.ts`); roles never change in practice. The org is the invisible
  scope every query filters on.
- **View-as**: Rian (super admin) switches into "view as Adi" to verify Adi sees
  only his own team and no billout column.

## Complete behavior

### Organization resolution (lib/org.ts)

- **B1.** The app is scoped to exactly one org, looked up by the hardcoded
  constant `APP_ORG_SLUG = 'bowden-works'` (`lib/org.ts`). `getAppOrg(supabase)`
  runs `organizations.select('id, name, default_hourly_rate_usd')
  .eq('slug', APP_ORG_SLUG).maybeSingle()`; on error or no row it returns
  `null`.
- **B2.** `getAppOrg` coerces `default_hourly_rate_usd` with
  `Number(data.default_hourly_rate_usd ?? FALLBACK_BASE_RATE)` where
  `FALLBACK_BASE_RATE = 14` (exported from `lib/org.ts`). Post ADR #034 this
  column is **dead** for money math; the fallback exists only as a legacy guard.
- **B3.** Every page begins with `getAppOrg`; a `null` result renders the inline
  error `Bowden Works organization not found.` (no redirect). A logged-in user
  who is not a Bowden Works member sees this on every page (RLS returns zero
  rows for the slug lookup).
- **B4.** `organization_members.role` is the Postgres enum `org_role` with values
  `'owner' | 'manager' | 'member'`. Current data: Rian = owner of Bowden Works;
  Adi = manager of Bowden Works AND owner of a second org "Tingang" which holds
  no data. Adi's Tingang membership has zero effect on app behavior.
- **B5.** There is **no org CRUD UI** and no org-switcher. Orgs and memberships
  are created via the seed script / Supabase service role only (RLS has no
  INSERT/UPDATE/DELETE policies on `organizations` or `organization_members`).
- **B6.** `profiles` extends `auth.users` 1:1 (`id` FK ON DELETE CASCADE); holds
  `email` (unique), `full_name`, `is_super_admin` (global, cross-org flag,
  default false), `must_change_password` (default true; see auth spec).

### Teams (lib/teams.ts + migration 20260523030001, ADR #032)

- **B7.** A **team** is one-per-(org, owner): `teams` has UNIQUE
  `(org_id, owner_user_id)` (`teams_org_id_owner_user_id_key`). `name` is
  nullable (UI shows `(unnamed team)`). Current rows: "Adi's team", "Rian's
  team".
- **B8.** `teams.base_rate_usd` (numeric(10,2), NOT NULL, default 50) is
  **vestigial** since ADR #034 — retained in schema, never used for cost or
  billout. The /team "Base rate panel" was deliberately removed (comment in
  `app/(app)/team/page.tsx`). `lib/teams.ts` still exports
  `getRateForTeamMember(supabase, teamMemberId)` returning the member's team
  `base_rate_usd` — legacy helper, do not carry forward.
- **B9.** There is **no team CRUD UI**. Teams were created by
  migration/owner-side ops. RLS `teams_modify_admin` allows insert/update/delete
  for org owner or super admin; `teams_update_self` additionally lets a team
  owner update their own team row (name/base_rate) — unused by any UI.
- **B10.** Team visibility (RLS `teams_select`): a team row is readable iff
  `owner_user_id = auth.uid()` OR `is_org_owner(org_id)` OR
  `is_super_admin_caller()`. Helper `listVisibleTeams(supabase, orgId)` returns
  all RLS-visible teams in the org ordered by `name` ASC;
  `getTeamForUser(supabase, orgId, userId)` returns the team **owned by**
  `userId` (maybeSingle).
- **B11.** The team is the **unit of importer scoping**: the Clockify import
  fails fast if `getTeamForUser(org.id, eu.effective_user_id)` returns null
  ("You don't have a team in this organization yet. An org owner needs to create
  one for you before you can import."), and email→member resolution at commit is
  restricted to the importer's own team's members.

### Team members — data semantics

- **B12.** `team_members` are **workers, not users**: no login, no invite flow,
  no link to `auth.users`. Disjoint from `organization_members`.
- **B13.** Email uniqueness is **per team**: UNIQUE `(team_id, email)`
  (`team_members_team_id_email_key`). The same email may exist in two teams
  (e.g. `rian@rian.ca` historically). A non-unique index
  `idx_team_members_org_email` on `(org_id, lower(email))` supports lookups.
  (Note: `docs/features/team.md` §Constraints still says `unique (org_id,
  email)` — stale; the migration and 23505 error message "already in this
  team" are authoritative.)
- **B14.** Emails are stored **trimmed + lowercased** (server action does
  `.trim().toLowerCase()`); import matching keys a `TeamLookup` Map on
  `email.toLowerCase()` from the CSV's Email column, so matching is
  case-insensitive end-to-end.
- **B15.** `cost_rate_usd` (numeric(10,2), nullable) is the **canonical cost
  basis**: USD per SOURCE hour the team owner pays this person (ADR #034,
  migration 20260524000013). At import/manual-entry/re-resolve time it is
  stamped onto `time_entries.billout_cost_usd = source hours × cost_rate_usd`
  rounded to 2 dp. **Locked at write** — changing the rate later affects only
  new entries; explicit re-stamp actions exist elsewhere (entries feature).
- **B16.** `cost_rate_usd = NULL` means "cost unknown": entries stamp NULL
  `billout_cost_usd` and project margin displays them as cost-unknown until the
  rate is filled in. **NULL ≠ 0**: Gary's rate is explicitly `0`, which stamps
  `$0.00` cost — a zero-cost billable worker is a supported, deliberate case
  (Gary is an AI; billout stays $25).
- **B17.** `billout_rate_usd` (numeric(10,2), nullable) is **owner-only**: USD
  per SOURCE hour Rian charges the client for this member's work. It is the
  **fallback** in the effective-billout precedence implemented by SQL function
  `effective_billout_rate(p_team_member_id, p_project_id)` (wrapped by
  `getEffectiveBilloutRate` in `lib/teams.ts`):
  1. user-specific `project_rate_overrides` row (project + team_member),
  2. project-wide override (`team_member_id IS NULL`),
  3. `team_members.billout_rate_usd`.
  NULL result → `time_entries.billout_amount_usd` stays NULL.
- **B18.** `rate_proportion` (numeric(6,5), NOT NULL, CHECK 0..1) is **legacy**:
  since ADR #034 it feeds ONLY the Toggl CSV export's "converted hours" —
  `converted_duration_seconds = Math.round(duration_seconds × rate_proportion)`
  stamped at import. It no longer affects cost or billout.
- **B19.** `consolidate_as` (text, nullable): if set (e.g. `"Tingang"`), it
  replaces `display_name` as the entry's `converted_user` at import
  (`converted_user = consolidate_as ?? display_name`). NULL renders as
  `(own name)` in the UI. Display-label only, no auth meaning.
- **B20.** `is_active` (boolean, NOT NULL default true) is a status flag only —
  inactive members still resolve on import (no filtering by `is_active` in the
  lookup); it drives sort order and a badge.
- **B21.** `hourly_rate_usd` (numeric(8,2), nullable) is **dead** (pre-team-era
  proxy for the org base rate; value moved to
  `organizations.default_hourly_rate_usd` in migration 20260521170001; never
  dropped). Do not rebuild.
- **B22.** Deleting a member is **permanent**; `time_entries.team_member_id` FK
  is **ON DELETE SET NULL**, so historical entries keep their stamped
  cost/billout but lose the member link. `project_rate_overrides.team_member_id`
  FK is ON DELETE CASCADE (member-specific overrides die with the member).
- **B23.** Import behavior on no email match: the entry inserts with all five
  conversion fields NULL (`team_member_id`, `converted_user`,
  `converted_duration_seconds`, and the two rate stamps); the import success
  panel lists the distinct unknown emails with guidance. Fixing requires adding
  the member and re-resolving (entries feature).
- **B24.** `listVisibleTeamMembers(supabase, orgId)` (lib/teams.ts) feeds the
  /entries member pickers: selects `id, team_id, email, display_name,
  consolidate_as, is_active, team:teams(name)` filtered by `org_id`, ordered
  `display_name` ASC, mapping `team_name = team?.name ?? null`. RLS gates rows.

### /team page (app/(app)/team/page.tsx)

- **B25.** Route `/team`; searchParams `edit` (member id in inline-edit mode)
  and `error` (flash message rendered in `<p class="error">`).
- **B26.** Teams are loaded under the **real JWT** via `listVisibleTeams`, then
  narrowed with `filterTeamsForViewAs(allTeams, eu)` (lib/effective-user.ts):
  not viewing-as → all; effective super admin or `org_role === 'owner'` → all;
  else only teams with `owner_user_id === eu.effective_user_id`.
- **B27.** `canSeeBillout = canTransfer(eu)` gates BOTH the "Billout rate" table
  column AND the billout input in add/edit forms (input not rendered at all for
  non-owners).
- **B28.** Members query selects the 9 fields `id, team_id, email, display_name,
  rate_proportion, consolidate_as, is_active, notes, cost_rate_usd,
  billout_rate_usd` with `.in('team_id', teamIds)`; when `teamIds` is empty it
  passes the sentinel `['00000000-0000-0000-0000-000000000000']` (all-zero
  uuid, cf. `NO_MATCH_UUID`) so `.in()` isn't empty. Order: `is_active` DESC,
  then `display_name` ASC. Members are grouped into a
  `Map<team_id, TeamMember[]>` and rendered as one panel per visible team.
- **B29.** Page heading "Teams"; subtitle (semantics verbatim): "Cost rate and
  billout rate are per-source-hour. Cost is locked at import time — changing
  the rate later only affects new entries. Proportion only feeds the Toggl CSV
  export (legacy 'converted hours' column); it no longer affects cost or
  billout."
- **B30.** Zero visible teams → empty state "No teams visible. Ask an org owner
  to create one for you." Team with zero members → "No members in this team
  yet."
- **B31.** Each team panel: heading `team.name || '(unnamed team)'`; an "Add
  team member to {teamLabel}" form (posts `createTeamMember` with hidden
  `team_id`) shown **only when no row in that team is being edited**; a members
  table titled "Members ({count})".
- **B32.** Table columns: Email · Display name · Cost rate (numeric, header
  tooltip "Per-source-hour cost. Locked onto each entry at import time.") ·
  Billout rate (owner-only; tooltip "Owner-only: USD/hr Rian charges the client
  for this person's hours.") · Proportion (tooltip "Toggl-export only. Source
  hours × proportion = exported hours.") · Consolidate as · Status · Notes ·
  actions.
- **B33.** Rendering: cost/billout via `fmtUsd(Number(v))`, NULL → muted `—`;
  proportion via `formatPct(p)` = `p*100`, integer → `toFixed(0)` else
  `toFixed(3)`, suffixed `%` (0.5 → `50%`, 0.12345 → `12.345%`);
  `consolidate_as` NULL → muted `(own name)`; status badge `Active`
  (`badge-ok`) / `Inactive` (`badge-muted`); notes in muted 0.85em.
- **B34.** Row actions: `Edit` link → `/team?edit=<id>`; Delete via `DeleteForm`
  with confirm text `Delete <email>? This is permanent. Historical time entries
  referencing them will remain but lose the link.`
- **B35.** Edit mode replaces the row with a full-width
  `<td colSpan={canSeeBillout ? 9 : 8}>` on background `#fff8e1` containing the
  same field grid pre-filled + hidden `id`, Save (posts `updateTeamMember`),
  Cancel link to `/team`.
- **B36.** Form fields (`TeamMemberFields`, 4-column grid): `email`
  (type=email, required, autoComplete off) · `display_name` (text, required) ·
  `cost_rate_usd` (number, step 0.01, min 0, placeholder `e.g. 7.00`, label
  "Cost rate (USD per source hour)", default rendered `toFixed(2)`) ·
  `billout_rate_usd` (owner-only; number, step 0.01, min 0, placeholder
  `e.g. 25.00`, label "Billout rate (owner-only, USD per source hour)") ·
  `rate_proportion` (**text** input, required, placeholder `0.5`, label
  "Proportion (Toggl export only, 0–1 or 0–100%)") · `consolidate_as` (text,
  placeholder `Tingang`, label "Consolidate as (blank = use own name)") ·
  `notes` (text, spans 2 columns, optional) · `is_active` (checkbox, default
  checked for new members).

### Server actions (app/(app)/team/actions.ts)

- **B37.** Error pattern: `fail(msg, editId?)` redirects to
  `/team?error=<msg>[&edit=<id>]` — validation failures during an edit keep the
  edit row open. Success: `revalidatePath('/team')` then `redirect('/team')`.
- **B38.** `parseProportion(raw, editId?)`: required ("Proportion is
  required."); trims and strips one trailing `%`; `Number()` — NaN →
  "Proportion must be a number."; **if n > 1 divide by 100** (`50` or `50%` →
  0.5); range 0..1 else "Proportion must be between 0 and 1 (or 0% and
  100%)."; rounds to 5 dp (`Math.round(p * 100000) / 100000`, matching
  numeric(6,5)).
- **B39.** `emailLooksValid` regex: `/^[^\s@]+@[^\s@]+\.[^\s@]+$/`. Invalid or
  empty → "A valid email is required." Empty display_name → "Display name is
  required." `consolidate_as` / `notes`: trimmed, empty → NULL. `is_active` =
  `formData.get('is_active') === 'on'`.
- **B40.** `billout_rate_usd` is included in the update payload **only if the
  form field was present** (`formData.get(...) !== null`) — a manager-rendered
  form (no billout input) must not null out the owner's stored value. When
  present: blank → NULL; else must be finite and ≥ 0 ("Billout rate must be a
  non-negative number or blank.") and is rounded to cents
  (`Math.round(n*100)/100`). `cost_rate_usd` follows the identical
  present-check / blank→NULL / non-negative / round-to-cents pattern with
  message "Cost rate must be a non-negative number or blank."
- **B41.** `createTeamMember`: requires `team_id` ("Missing team id."); runs
  `guardTeam(supabase, eu, org.id, teamId)`; inserts
  `{ org_id, team_id, ...payload }`. Postgres `23505` →
  `"<email> is already in this team."`; other DB errors surface raw
  `error.message`.
- **B42.** `updateTeamMember`: requires `id` ("Missing team member id."); runs
  `guardTeamMember`; updates with `.eq('id', id).eq('org_id', org.id)`.
  **Deliberately no team move** — `team_id` is never in the update payload
  (comment: a move would be a separate action). Same 23505 message; failures
  redirect with `edit=<id>` preserved.
- **B43.** `deleteTeamMember`: requires `id`; runs `guardTeamMember`; hard
  `.delete().eq('id', id).eq('org_id', org.id)`.
- **B44.** View-as guards (lib/view-as-guards.ts): `guardTeam` allows when not
  viewing-as, or effective user is super admin / org owner; otherwise loads
  `teams.owner_user_id` (not found → `'Team not found.'`) and denies with
  `"That row isn't visible to the user you're viewing as. Exit view-as to make
  this change."` unless the effective user owns the team. `guardTeamMember`
  does the same via embedded join
  `team_members.select('team:teams(owner_user_id)')` (not found → `'Team
  member not found.'`).

### RLS enforcement (the actual write gate)

- **B45.** `team_members` RLS: `team_members_team_select` (SELECT) and
  `team_members_team_modify` (ALL) both require an owning-team row where
  `t.owner_user_id = auth.uid()` OR `is_org_owner(t.org_id)` OR
  `is_super_admin_caller()`. So the real write rule is **team owner, org
  owner, or super admin** — an org `manager` who owns no team could not manage
  anyone (Adi can because he owns a team). Neither the page nor the actions
  call `canManageTeam(eu)`; that function exists in `lib/permissions.ts` but is
  **dead code** (see Permissions).
- **B46.** `organization_members` read policies: `members_self_select` (own
  row), `members_co_member_select` (co-members via SECURITY DEFINER helper
  `current_user_org_ids()` — the fix for the recursive-policy bug, ADR #008),
  `members_super_admin_select` (needed for the view-as switcher). No write
  policies.

## Data model

```
organizations
  id uuid PK default gen_random_uuid()
  name text NOT NULL UNIQUE
  slug text NOT NULL UNIQUE            -- app hardcodes 'bowden-works'
  default_hourly_rate_usd numeric(8,2) NULL   -- DEAD (superseded twice; ADR #034)
  created_at timestamptz NOT NULL default now()

profiles                                -- 1:1 with auth.users
  id uuid PK, FK → auth.users(id) ON DELETE CASCADE
  email text NOT NULL UNIQUE
  full_name text NULL
  is_super_admin boolean NOT NULL default false   -- global flag (rian)
  must_change_password boolean NOT NULL default true
  created_at / updated_at (trigger profiles_set_updated_at → set_updated_at())

organization_members
  organization_id uuid FK → organizations ON DELETE CASCADE  ┐ PK
  user_id uuid FK → auth.users ON DELETE CASCADE             ┘ (composite)
  role org_role NOT NULL               -- enum: owner | manager | member
  created_at; index idx_org_members_user(user_id)

teams                                   -- ADR #032, one per (org, owner)
  id uuid PK
  org_id uuid FK → organizations ON DELETE CASCADE
  owner_user_id uuid FK → auth.users ON DELETE CASCADE
  name text NULL
  base_rate_usd numeric(10,2) NOT NULL default 50   -- VESTIGIAL post ADR #034
  created_at / updated_at (trigger teams_set_updated_at)
  UNIQUE (org_id, owner_user_id)

team_members                            -- workers; NO auth accounts
  id uuid PK
  org_id uuid FK → organizations ON DELETE CASCADE
  team_id uuid NOT NULL FK → teams ON DELETE CASCADE
  email text NOT NULL                   -- stored lowercased
  display_name text NOT NULL
  rate_proportion numeric(6,5) NOT NULL CHECK (>= 0 AND <= 1)  -- Toggl-export legacy
  hourly_rate_usd numeric(8,2) NULL     -- DEAD
  cost_rate_usd numeric(10,2) NULL      -- canonical cost, USD/source-hour (ADR #034)
  billout_rate_usd numeric(10,2) NULL   -- owner-only, USD/source-hour
  consolidate_as text NULL              -- e.g. 'Tingang'
  is_active boolean NOT NULL default true
  notes text NULL
  created_at / updated_at (trigger team_members_set_updated_at)
  UNIQUE (team_id, email); index idx_team_members_org_email(org_id, lower(email))
```

Downstream reads/writes that matter:

- `time_entries.team_member_id` FK → team_members **ON DELETE SET NULL**;
  stamps at write: `billout_cost_usd` (from `cost_rate_usd`),
  `billout_amount_usd` (from `effective_billout_rate`), `converted_user`
  (from `consolidate_as ?? display_name`), `converted_duration_seconds`
  (from `rate_proportion`).
- `project_rate_overrides.team_member_id` FK → team_members ON DELETE CASCADE
  (NULL = project-wide rule; see adjustments feature).
- SQL function `effective_billout_rate(p_team_member_id, p_project_id)` —
  precedence in B17.
- Seeded magic rows: Adi `info@adipramono.com`; `rian@rian.ca` (proportion 1.0,
  Rian's team); `matmoncoo@gmail.com` (0.7, Rian's team); `gary@rian.ca` (AI,
  cost 0, billout 25). Constant `ADI_EMAIL = 'info@adipramono.com'` lives in
  `lib/toggl.ts` and is used only to stamp the Email column of the Toggl CSV
  export (phase-out).

## Permissions

- **See /team, read teams/members:** RLS `teams_select` /
  `team_members_team_select` — team owner (`owner_user_id = auth.uid()`), org
  owner (`is_org_owner(org_id)`), or super admin (`is_super_admin_caller()`).
  An org `member` with no team sees the "No teams visible" empty state.
- **Add/edit/delete team members:** enforced by RLS `team_members_team_modify`
  (same three-way rule) plus app-layer view-as guards `guardTeam` /
  `guardTeamMember`. **Note:** `canManageTeam(eu)` in `lib/permissions.ts`
  (super admin not-viewing-as, else `org_role in ('owner','manager')`) is
  defined but **never called anywhere** — its truth table differs from the RLS
  rule (a team-less manager passes `canManageTeam` but fails RLS). The rebuild
  must pick one rule; the RLS rule is what actually ships.
- **See/edit billout rate:** `canTransfer(eu)` (lib/permissions.ts) — true iff
  `eu.is_super_admin && !eu.is_viewing_as`, else `eu.org_role === 'owner'`.
  Reused app-wide as the "is owner" predicate (income/margin columns, /tools,
  /invoices nav).
- **Create/delete teams, org CRUD:** owner/super-admin via RLS
  (`teams_modify_admin`); orgs/memberships service-role only. No UI.
- **View-as:** `getEffectiveUser(supabase, orgId)` resolves the effective user
  (real JWT sub read from the `sb-*-auth-token` cookie; `view_as_user_id`
  cookie for the target); `filterTeamsForViewAs` narrows the team list;
  `canEnterViewAs(eu)` = super admin, no nesting. While viewing-as a manager,
  billout is hidden (canTransfer false) and mutations outside the target's own
  team are refused by the guards.

## Edge cases & error behavior

- Duplicate email in the same team → Postgres 23505 →
  `"<email> is already in this team."` (create and update paths).
- All validation failures redirect to `/team?error=…` (plus `&edit=<id>` when
  editing) — messages listed in B37–B40; unknown DB errors surface raw.
- View-as denial message (exact): `That row isn't visible to the user you're
  viewing as. Exit view-as to make this change.`; missing rows → `Team not
  found.` / `Team member not found.`.
- Zero visible teams: members query still runs, using the all-zero-uuid
  sentinel array (wart — rebuild should just skip the query).
- Member delete is a hard delete with FK SET NULL on entries; stamped money is
  untouched. No soft-delete path despite `is_active` existing (wart; rebuild
  uses soft archive — see deltas).
- Moving a member between teams is impossible via the UI (update never writes
  `team_id`); the workaround is delete + re-add, which orphans entry links.
- `cost_rate_usd` NULL vs 0 distinction (B16) — a rebuild that coalesces NULL
  to 0 silently converts "cost unknown" into "free work". Gary is the reason 0
  must be first-class.
- The billout present-check (B40) means a manager saving a member cannot
  clobber the billout rate — but also cannot see whether one exists.
- Manager-permission mismatch: `canManageTeam` (dead) vs RLS (live) — see
  Permissions. Also `docs/features/team.md` §2.5 says "Managers and Owners can
  manage team", which is only true because Adi happens to own a team.
- Stale docs found while writing this spec: team.md's `unique (org_id, email)`
  claim (actual: `(team_id, email)`); team.md's claim that the Summary page
  looks up Adi's rate by `info@adipramono.com` with a `$14/hr` fallback —
  in current code `ADI_EMAIL` is only used in the Toggl CSV export and
  `FALLBACK_BASE_RATE = 14` only backstops the dead
  `organizations.default_hourly_rate_usd`; tests-catalog TEAM-003/TEAM-004
  still describe the retired base-rate × proportion model.
- Backfill quirks (historical, don't rebuild): `derive_name(email)` heuristic
  produced rough display names, fixed by hand; Adi's team seeded at legacy org
  rate (fallback 35), Rian's at 50.

## UI notes

- Current UI: one section per team; inline add-form above the members table;
  inline row edit on amber background `#fff8e1`; Active/Inactive badges;
  muted `—` for unset money; `(own name)` for blank consolidate_as; tooltips on
  the three rate column headers carry the semantics (per-source-hour, locked at
  import, Toggl-only).
- Rian's taste (transcript-requirements.md §17), applied here:
  - **Liked/asked for:** compact rows; detail-on-hover (the header tooltips fit
    this); hidden-but-real owner-only columns (billout column disappearing for
    Adi is the pattern he wants); sensible defaults (Active pre-checked);
    inline edit that doesn't jump the page (he explicitly hated pages jumping
    to top on inline edit — the `/team?edit=` full-page navigation is exactly
    that wart; rebuild should edit in place); searchable comboboxes "like
    Google Sheet data validation" for any picker.
  - **Disliked:** redundant fields and noise dominating signal — the lingering
    Proportion column/input (legacy, Toggl-only) is precisely the "redundant
    field" smell he complained about; it disappears with the Toggl phase-out.
    Also disliked actions with no progress feedback — team saves are silent
    full-page round-trips today.
  - The proportion input being type=text (accepts `50%`) while rates are
    type=number is an inconsistency nobody asked for; moot after phase-out.

## Rebuild deltas

Per `docs/rebuild/00-vision-and-scope.md` (phase-outs), `02-domain-model.md`
§1–2 & §7 mapping, `04-architecture.md` (authz service), and judgment call #12:

- **organizations + organization_members → `tenants` + engagement-derived
  grants.** No role enum; rian = official_partnership/owner engagement on
  Bowden Works, Adi = subcontract/worker engagement with a
  `can_manage_imports`-grade capability. The six current permission functions
  (incl. dead `canManageTeam`) become named capabilities with **identical
  truth tables in R1** (02 §4) — resolve the canManageTeam-vs-RLS mismatch
  explicitly when porting.
- **profiles/auth.users → `users` (idauth_username-keyed, Pattern B id-auth) +
  person `parties`.** `must_change_password` and password login are dropped.
- **teams → dissolved.** Team scoping becomes engagement scoping;
  `teams.base_rate_usd` is not carried.
- **team_members → person parties + engagements + `compensation_terms`,**
  field-by-field:
  - `email` → `parties.email` (still the import-matching key; `users.email`
    for login identities).
  - `display_name` → `parties.name`.
  - `cost_rate_usd` → compensation_term (model `hourly`, rate_usd) on the
    **worker→Bowden Works subcontract/freelance engagement** (the cost hop).
  - `billout_rate_usd` → compensation_term on the **BW→PlusROI partnership
    engagement** (the billout hop). `project_rate_overrides` → the dedicated
    `rate_overrides` table, near-verbatim — NOT flattened into
    compensation_terms in R1 (02 §2; judgment call #25).
  - `consolidate_as` → import-mapping config on the engagement (02 §7).
  - `is_active` → `parties.is_active` — **soft archive replaces hard delete;
    parties are never hard-deleted** (fixes B22's orphaning wart).
  - `notes` → `parties.notes`.
  - `rate_proportion`, `hourly_rate_usd`, `teams.base_rate_usd`,
    `organizations.default_hourly_rate_usd` → **dropped entirely** (Toggl
    phase-out confirmed 2026-07-06; ADR #034).
  - `time_entries.team_member_id` → `worker_party_id` +
    `via_engagement_id`.
- **Gary is a first-class case** (judgment call #12): a $0-rate hourly comp
  term with a $25 billout term — supported natively and **tested explicitly**.
  The 140h Tingang target becomes an engagement setting, not a hardcode.
- **Single-org hardcode dies:** `APP_ORG_SLUG` → tenant context; RLS is
  deleted wholesale in favor of the `services/authz/` engine
  (`can()`/`scope()`, 04-architecture.md); view-as becomes an app-layer
  context swap (no JWT machinery).
- Migration seeding (05-migration-plan.md): engagements + comp terms created
  for every team_member, **including Gary (cost 0, billout 25)**.

## Test specs

Carried IDs (fix stale wording where noted):

- T-ORG-01 (carries ORG-001): given a Bowden Works member, when `getAppOrg`
  runs, then exactly one org row (slug `bowden-works`) returns.
- T-ORG-02 (carries ORG-002): given a user with no Bowden Works membership,
  when any page resolves the org, then `getAppOrg` → null and the page shows
  "Bowden Works organization not found."
- T-TEAM-01 (carries TEAM-001, **reworded to team scope**): given an existing
  member email in team X, when creating the same email in team X, then 23505 →
  "<email> is already in this team."
- T-TEAM-02 (carries TEAM-002): given a member whose rate/proportion changes,
  then historical entries' stamps are unchanged (only future imports /
  explicit re-resolve).
- TEAM-003 / TEAM-004: **retired** — they test the eliminated base-rate ×
  proportion model; do not port.

New:

- T-TEAM-03: given the same email in team X, when created in team Y, then it
  succeeds (uniqueness is per-team).
- T-TEAM-04: given input `RIAN@Rian.CA `, when saved, then stored email is
  `rian@rian.ca` (trim + lowercase) and import matching hits it
  case-insensitively.
- T-TEAM-05: proportion parsing — `50` → 0.5, `50%` → 0.5, `0.5` → 0.5,
  `abc` → "must be a number", `150` → 1.5 → range error, result rounded to
  5 dp.
- T-TEAM-06: given a form without a `billout_rate_usd` field (manager render),
  when updating a member with an existing billout rate, then the stored rate
  is unchanged (column absent from payload).
- T-TEAM-07: given blank `cost_rate_usd`, when saved, then NULL is stored and
  subsequently imported entries stamp NULL `billout_cost_usd` (margin shows
  cost-unknown).
- T-TEAM-08: given Gary (`cost_rate_usd = 0`, `billout_rate_usd = 25`), when
  10h import, then stamped cost = 0.00 (not NULL) and billout = 250.00 —
  zero-cost billable worker.
- T-TEAM-09: given a member with entries, when deleted, then
  `time_entries.team_member_id` → NULL and stamped money unchanged; the
  member's `project_rate_overrides` rows are gone.
- T-TEAM-10: given `consolidate_as = 'Tingang'`, when an entry resolves, then
  `converted_user = 'Tingang'`; given NULL, then `display_name`.
- T-TEAM-11: effective billout precedence — user-specific override beats
  project-wide override beats `billout_rate_usd`; all NULL → billout stamp
  NULL.
- T-TEAM-12: given a non-owner (`canTransfer` false), when /team renders, then
  no Billout column and no billout input (8-col edit row, not 9).
- T-TEAM-13: given super admin viewing-as Adi, when /team renders, then only
  Adi's team panel appears (`filterTeamsForViewAs`); when mutating a member of
  Rian's team, then the guard fails with the exact out-of-scope message.
- T-TEAM-14: given an effective user with zero visible teams, when /team
  renders, then "No teams visible…" and the members query returns zero rows
  (sentinel uuid today; rebuild: skip query).
- T-TEAM-15: given an update form, when submitted, then `team_id` is never
  modified (no team move via edit).
- T-TEAM-16: given an importer who owns no team, when uploading a CSV, then
  fail-fast "You don't have a team in this organization yet…" before the
  resolver.
- T-TEAM-17: given a CSV row whose email matches no member of the importer's
  team, when committed, then the entry inserts with NULL team_member_id /
  converted_user / rate stamps and the unknown email is listed post-import.
- T-ORG-03: capability parity grid — port `canTransfer` / `canManageImports` /
  `canEnterViewAs` / `canEditEntry` truth tables (and the resolved
  team-manage rule) against the seeded engagement grants; identical outcomes
  required before any new semantics (02 §4).
