# Design: Separate Job Title from Global Permissions

**Status:** Draft for review (m + head)
**Author:** cronus (mai inventor)
**Date:** 2026-04-27
**Task:** t-paliad-051

## Problem

Three orthogonal concepts share one column today:

1. **Job title** — Partner / Counsel / Associate / PA / Trainee / Sekretariat / "Counsel Knowledge Lawyer" / … . Free-text since migration 015. Display only.
2. **Global permissions** — currently "is the user a global admin?" piggy-backed on the same column with `paliad.users.role = 'admin'` checks across Go, SQL, and JS.
3. **Per-project role** — `paliad.project_teams.role` ∈ {lead, associate, pa, of_counsel, local_counsel, expert, observer, admin}. Already separated, fine, **out of scope**.

The collision bites whenever someone tries to record their real job title without losing admin access. Concrete trigger: m's job title is "Counsel Knowledge Lawyer". If he sets that as his `role`, he loses every `role='admin'` gate in the codebase. Today the admin-team page (t-paliad-050) actually hard-rejects setting `role='admin'` from the UI (`AdminUpdateUser` raises `ErrAdminBootstrapOnly`), so any UI-driven edit of m's row would silently demote him.

Live state confirmed 2026-04-27 14:25 against `100.99.98.201:11833`:

| email | role | display_name |
|---|---|---|
| matthias.siebels@hoganlovells.com | admin | Matthias |
| tester@hlc.de | admin | Test Tester |
| 29 stub colleagues | associate | … |

So: 31 rows total, 2 admins, 29 associates. No `partner` rows in production today even though the gate exists in code.

## Goal

Split `paliad.users.role` into:

- **`paliad.users.job_title`** — free text, display only. Replaces today's `role`.
- **`paliad.users.global_role`** — enum-via-CHECK, currently `'standard' | 'global_admin'`. New column, drives every `role='admin'`-style permission check.

Per-project `paliad.project_teams.role` is untouched.

After the change m can carry `job_title='Counsel Knowledge Lawyer'` AND `global_role='global_admin'` simultaneously.

## Decisions

### 1. Naming

- Rename `paliad.users.role` → `paliad.users.job_title`.
- Add `paliad.users.global_role text NOT NULL DEFAULT 'standard'` with `CHECK (global_role IN ('standard','global_admin'))`.

### 2. Why enum-via-CHECK over a `permissions text[]`

| | text-with-CHECK | text[] permissions |
|---|---|---|
| New permission | edit one CHECK constraint | none |
| Code surface | `u.global_role = 'global_admin'` | `'global_admin' = ANY(u.permissions)` |
| Multi-grant | impossible by construction | natural |
| Validation | DB-level | service-level only |
| Today's needs (1 permission) | trivial | over-engineered |

We have one permission today and m's brief says "possibly more later, design with that in mind". An enum keeps every call site short and DB-validated; growing the CHECK to add `billing_admin` is a one-line migration and `IN (...)` checks compose fine. If we ever genuinely need to grant 2+ permissions to one user, swap the column type to `text[]` in a future migration — call sites change from `=` to `ANY(...)`, mechanical. Nothing about today's choice forecloses that.

**Lean: enum-via-CHECK.** Matches m's stated lean. Ships smaller. Easy to widen later.

### 3. Why not "global_admin is just another job_title value"

Considered: keep `role` free-text, just normalize so `job_title='global_admin'` means both the title and the permission. Rejected because (a) the title `Counsel Knowledge Lawyer` and the permission `global_admin` are independent — a user can have one without the other (m wants both); (b) the admin-team UI restriction (`ErrAdminBootstrapOnly`) is exactly the symptom of trying to overload one column for two concerns. We're solving the conflation, not preserving it under a new name.

### 4. The "partner" gate — DROPPED entirely (m's three-axis principle)

Mid-implementation m clarified the principle: "firm roles are not project roles are not tool roles". Several places gated on `user.Role IN ('partner','admin')`:

- `internal/services/party_service.go:100` — delete party
- `internal/services/note_service.go:195` — note ops
- `internal/services/appointment_service.go:199` — appointment update/delete
- `internal/services/project_service.go:617` — project ops
- `internal/services/checklist_instance_service.go:301` — checklist ops
- `internal/services/deadline_service.go:437` — delete deadline
- migrations 018 / 021 RLS policies — `users.role IN ('partner','admin')` on `projects_delete`, `project_teams_delete`
- `frontend/src/client/projects-detail.ts:555,1206`, `deadlines-detail.ts:194,208`, `deadlines.ts:69`, `notes.ts:104` — UI gates

These conflate "Partner" (a firm role / job title) with permission-to-mutate (a tool role). m: firm role and tool role must be orthogonal; the firm role is **display only** and **must never gate ops**.

**Decision:** drop the partner half of every gate. Each of these checks becomes "global_admin only". Production impact is zero — no prod row has `role='partner'` today, so nobody loses a capability they actually had.

After-rename gate shape:
```go
// before
if user.Role != "partner" && user.Role != "admin" { return ErrForbidden }
// after
if user.GlobalRole != "global_admin" { return ErrForbidden }
```

If a future tool-role system grants partner-level mutations to specific users, it adds a fresh dimension cleanly (text[] permissions or named enums) without touching `job_title`. YAGNI for now — there are no rows that need it.

**Helper deleted:** the design's first draft kept an `IsPartnerOrGlobalAdmin(u)` helper. It's gone — the gate is just `user.GlobalRole == "global_admin"` everywhere.

### 5. Data migration

Single up migration (023). Idempotent. No backfill data shape worth keeping in code beyond:

```sql
-- 023_split_job_title_and_global_role.up.sql

BEGIN;

-- Add new column with default so existing rows pick up 'standard'.
ALTER TABLE paliad.users
    ADD COLUMN IF NOT EXISTS global_role text NOT NULL DEFAULT 'standard';

ALTER TABLE paliad.users
    DROP CONSTRAINT IF EXISTS users_global_role_check;

ALTER TABLE paliad.users
    ADD CONSTRAINT users_global_role_check
    CHECK (global_role IN ('standard','global_admin'));

-- Promote anyone who currently has role='admin'.
UPDATE paliad.users SET global_role = 'global_admin' WHERE role = 'admin';

-- Wipe role='admin' to NULL (admins no longer carry a job title — they didn't
-- pick one, the column was overloaded). Real job titles for the 2 current
-- admins (m + tester) get fixed up by a separate manual UPDATE inside the
-- same transaction, since we know them and the migration ran end-to-end is
-- the right place to do it.
UPDATE paliad.users SET role = NULL WHERE role = 'admin';

UPDATE paliad.users
   SET role = 'Counsel Knowledge Lawyer'
 WHERE email = 'matthias.siebels@hoganlovells.com';
-- tester@hlc.de stays role=NULL — it's a synthetic admin account, no real
-- job title. Admin-team UI will render NULL as "—".

-- Rename the column. Doing this last so the explicit UPDATEs above stay
-- readable; if the rename were first, every UPDATE would refer to job_title
-- and the diff is harder to review.
ALTER TABLE paliad.users
    RENAME COLUMN role TO job_title;

-- The CHECK (role <> '') from migration 015 must come along to job_title,
-- but with a tweak: NULL is now allowed (admins without a job title).
ALTER TABLE paliad.users
    DROP CONSTRAINT IF EXISTS users_role_check;

ALTER TABLE paliad.users
    ADD CONSTRAINT users_job_title_check
    CHECK (job_title IS NULL OR job_title <> '');

-- can_see_project must follow.
DROP FUNCTION IF EXISTS paliad.can_see_project(uuid) CASCADE;

CREATE FUNCTION paliad.can_see_project(_project_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = paliad, public
AS $$
    SELECT EXISTS (
        SELECT 1 FROM paliad.users u
         WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
    )
    OR EXISTS (
        SELECT 1
          FROM paliad.projects      target
          JOIN paliad.project_teams pt
            ON pt.user_id = auth.uid()
           AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
         WHERE target.id = _project_id
    );
$$;

-- Rebuild RLS policies that the CASCADE drops. Identical to migration 021
-- except every `u.role = 'admin'` becomes `u.global_role = 'global_admin'`
-- and every `u.role IN ('partner','admin')` becomes
-- `(u.job_title = 'partner' OR u.global_role = 'global_admin')`.
-- (Full body in the migration file; not reprinted here for brevity.)

COMMIT;
```

Down migration is the symmetric reverse: rename `job_title` → `role`, copy `global_admin` rows back to `role='admin'`, drop `global_role`, restore the original `can_see_project` body. Ugly but tractable; ~31 rows, no realistic reason to roll back.

### 6. Code surface — where every `'admin'` lives

Inventoried `grep -rn "'admin'\\|\\\"admin\\\"\\|user.Role" --include="*.go" --include="*.sql" --include="*.ts" --include="*.tsx"` and bucketed:

#### A. Global-admin gates (these MUST migrate)

**Go services**
- `internal/services/dashboard_service.go:154,211,241,270` — SQL `$2 = 'admin'`, callers pass `user.Role` as `$2` at lines 186, 219, 250, 279
- `internal/services/agenda_service.go:135,201` — same pattern
- `internal/services/appointment_service.go:487` — same pattern, caller line 492
- `internal/services/project_service.go:725,736,747` — three `visibilityPredicate*` helpers
- `internal/services/deadline_service.go:405` — `if user.Role == "admin"` short-circuit
- `internal/services/department_service.go:304` — `requireAdmin`
- `internal/services/user_service.go:149,232,346,388,500,638,641` — bootstrap + IsAdmin + assignment guards

**Go handlers**
- `internal/handlers/onboarding.go:63` — error message
- `internal/handlers/users.go:91` — error message
- `internal/handlers/admin_users.go:75,113` — error message (twice)

**Auth**
- `internal/auth/require_admin.go:19` — comment only

**SQL migrations (existing — for awareness; only migration 023 touches these)**
- `internal/db/migrations/006_visibility.up.sql:33` — historical, superseded
- `internal/db/migrations/007_rls_policies.up.sql:44,58` — historical, superseded
- `internal/db/migrations/018_projects_v2.up.sql:414,497,530,544,548,560,565` — historical, superseded
- `internal/db/migrations/021_fix_function_bodies_after_rename.up.sql:46,126,155` — current live state of `can_see_project` + RLS; migration 023 replaces

**Frontend**
- `frontend/src/client/sidebar.ts:299,309` — sidebar admin-section reveal
- `frontend/src/client/settings.ts:572,582` — admin-only controls on settings page
- `frontend/src/client/admin-team.ts:117,156,177,179,220,221` — table render + sort
- `frontend/src/client/deadlines-detail.ts:190,193,207` — admin/partner gate on UI
- `frontend/src/client/projects-detail.ts:555,1206` — admin/partner gate on UI

#### B. Job-title labels (these stay as `job_title`)

- `frontend/src/admin-team.tsx:74,121` — table header / form label "Rolle"
- `frontend/src/admin-team.tsx:122` — direct-add input still says "role" (rename to `job_title` server-side, label stays "Rolle / Job title")
- `frontend/src/onboarding.tsx:48,52,53,57` — onboarding form label / field name
- `frontend/src/client/admin-team.ts:170,179,309,365,372` — datalist + form handling
- `frontend/src/client/i18n.ts` — every `admin.team.col.role` / `onboarding.role.*` string

The form FIELD NAMES on the wire (`{display_name, office, role, ...}`) become `job_title` after the rename. Both client and server change in one commit.

#### C. Per-project role (NOT touched)

- `internal/services/deadline_service.go:417` — `pt.role IN ('admin', 'lead')` — this is `project_teams.role`, unrelated.
- All other `pt.role` references in handlers/services.

### 7. API surface

`/api/me` payload before:
```json
{ "id": "…", "email": "…", "role": "admin", … }
```

After:
```json
{ "id": "…", "email": "…", "job_title": "Counsel Knowledge Lawyer", "global_role": "global_admin", … }
```

`/api/admin/users` and `/api/admin/users/{id}` similarly expose both fields. `PATCH /api/me` accepts `{job_title}` (no `role`); `PATCH /api/admin/users/{id}` accepts `{job_title, global_role}` — server enforces that only existing global_admins can change `global_role`, and refuses to demote the last global_admin (mirror of the existing last-admin protection in `AdminDeleteUser`).

`POST /api/admin/users` accepts `{email, display_name, office, job_title, dezernat, lang}` only — `global_role` defaults to `'standard'`. Promotion is a separate `PATCH` action so it can't be smuggled into create.

Self-service `POST /api/onboarding` accepts `{display_name, office, job_title, dezernat}` — `global_role` defaults to `'standard'`. The bootstrap path (first row of `paliad.users`) flips `global_role='global_admin'` instead of setting `role='admin'`. Same `pg_advisory_xact_lock(7346298141)` guard.

### 8. UI surface

#### Onboarding (`/onboarding`)
- Field label: "Berufsbezeichnung / Job title" (was "Rolle")
- Field name in DOM: `job_title`
- Datalist suggestions stay (Partner / Associate / PA / Of Counsel / Referendar/in / Trainee / wiss. Mitarbeiter/in / Sekretariat). Add: Counsel, Knowledge Lawyer, Counsel Knowledge Lawyer.
- No `global_role` field — that defaults to 'standard'.

#### Settings (`/einstellungen`)
- "Rolle" → "Berufsbezeichnung / Job title", same input.
- New read-only "Berechtigung / Permission" line below: shows `Standard` or `Global Admin`. Not editable from settings (must use admin page).

#### Admin team (`/admin/team`)
- New column header (after Office, before Dezernat): "Berechtigung / Permission".
- Cell content: badge — `Standard` (neutral) or `Global Admin` (lime, the brand accent).
- Cell behavior: click toggles a dropdown with the two enum values. Saving issues `PATCH /api/admin/users/{id}` with `{global_role}`.
- "Rolle" column heading + cell content stays — but the cell now renders `job_title` (free text, may be NULL → render as "—").
- Direct-add modal: rename "Rolle" input to "Berufsbezeichnung / Job title", drop the special "Associate" default (keep the placeholder), bind `name="job_title"`.
- Sort: existing "admins first" sort key flips to `global_role='global_admin'` first.
- Last-global_admin protection: dropdown disabled (with tooltip) when the row is the last surviving global_admin.

#### Sidebar
- The admin-section reveal in `sidebar.ts:initAdminGroup()` flips the predicate from `me.role === "admin"` to `me.global_role === "global_admin"`.

#### Deadline / project detail pages
- The `me.role === "admin" || me.role === "partner"` gates become `me.global_role === "global_admin" || me.job_title === "partner"` (preserving today's broken-but-harmless behavior; see §4).

### 9. Bootstrap rule

Today: first `paliad.users` row may self-assign `role='admin'`, guarded by `pg_advisory_xact_lock(7346298141)`.

After: first `paliad.users` row may set `global_role='global_admin'`. Same lock, same constant, new column. Onboarding payload includes no `global_role` — the service decides based on the row count and overrides the default 'standard' for the first inserter.

### 10. Backwards compat

**Decision: clean rename, no compat shim.** Justification:

- 31 production rows, all in our control.
- Wire format changes (`role` → `job_title`, new `global_role`) cross client + server simultaneously in one merge. No staged rollout needed for an internal tool.
- Old session cookies with cached `me.role` values get refreshed on the next `/api/me` call, which the client makes on every page load.
- The `paliad.users.role` column stops existing after migration 023. Any ad-hoc query / report keyed on `role='admin'` breaks loudly — that's the point.

If future me wants compat-during-deploy: add a generated column `role text GENERATED ALWAYS AS (job_title) STORED` for one release, drop in the next. Not doing that now.

### 11. Test plan

**Unit**
- `internal/services/user_service_test.go`
  - `Create` with `count=0` → `global_role='global_admin'` AND `job_title=<input>` (or NULL if empty)
  - `Create` with `count>0` → `global_role='standard'`
  - `UpdateProfile` cannot set `global_role` (field absent from `UpdateProfileInput`)
  - `AdminUpdateUser` can set `global_role`; rejects when caller is not global_admin (handler-level test); rejects demotion of last global_admin (service-level test, mirror of `AdminDeleteUser`'s last-admin protection)
  - `IsAdmin` reads `global_role`
- `internal/auth/require_admin_test.go` — already covers the `IsAdmin` surface; no changes needed beyond the swap of test fixture's seeded column.

**Integration / smoke**
- Manual: log in as tester@hlc.de — confirm sidebar `/admin/team` entry appears, page loads, table shows m as global_admin + "Counsel Knowledge Lawyer" job title.
- Manual: set m's `job_title` via admin page to something else, confirm `global_role` is unchanged.
- Manual: try to demote tester (last global_admin in this case if you've already demoted m) — expect rejection.
- DB-level: `SELECT email, job_title, global_role FROM paliad.users` after migration. Expected:
  - 2 rows global_admin (m, tester), m.job_title='Counsel Knowledge Lawyer', tester.job_title IS NULL
  - 29 rows standard with job_title='associate'
- `go build ./... && go vet ./... && go test ./...` clean.
- `cd frontend && bun run build` clean.

## Out of scope (recap)

- Fine-grained permissions (`partner`, `billing_admin`) — design leaves room (CHECK can grow; or migrate to `text[]` later) but ships only `global_admin`.
- Cleaning up the "partner" gate conflation (§4) — gate stays job-title-driven for now. File follow-up.
- Permission inheritance from `project_teams` to global — explicitly orthogonal.
- Role-based UI customization beyond hide/show — defer.

## Open questions for m

1. **m's `job_title` value** — task brief says "Counsel Knowledge Lawyer". Confirmed? (Migration writes that exact string.)
2. **tester's `job_title`** — migration sets NULL. Alternative: 'Admin' literal. Lean: NULL — tester is a synthetic admin without a real title; "Admin" as a job title perpetuates the conflation we're solving.
3. **Default `global_role` on new sign-ups beyond the bootstrap** — confirmed `'standard'`.
4. **The 'partner' job-title gate** — leave as-is for this PR? (My recommendation: yes, file follow-up.)

## Implementation phase plan (after greenlight)

Single mai/cronus/separate-job-title-from branch, single PR, single merge to main.

1. `internal/db/migrations/023_split_job_title_and_global_role.{up,down}.sql` — schema + data + can_see_project + RLS rebuild.
2. `internal/models/models.go` — `User.Role` → `User.JobTitle` (keep `db:"job_title"` `json:"job_title"`); add `User.GlobalRole string \`db:"global_role" json:"global_role"\``. Make `JobTitle` a `*string` since admins may have NULL.
3. `internal/services/user_service.go` — every `role` reference, including `userColumns`, the bootstrap branch (assign `global_role`), `IsAdmin` (reads `global_role`), `UpdateProfileInput`/`AdminUpdateInput` (drop `Role`, add `JobTitle *string` and `GlobalRole *string` for admin-only).
4. `internal/services/{dashboard,agenda,appointment,project,deadline,department,party,note,checklist_instance}_service.go` — swap the SQL `$N = 'admin'` → `$N = 'global_admin'` and the call sites pass `user.GlobalRole` instead of `user.Role`. Partner gates change to `user.JobTitle != "partner" && user.GlobalRole != "global_admin"` (per §4).
5. `internal/handlers/{onboarding,users,admin_users}.go` — update error messages; payload field renames.
6. `internal/auth/require_admin.go` — comment update only (the `AdminLookup.IsAdmin` interface is unchanged because it abstracts behind the boolean).
7. `frontend/src/admin-team.tsx`, `frontend/src/onboarding.tsx` — labels + field names (`role` → `job_title`); add Permission column on admin-team.
8. `frontend/src/client/admin-team.ts`, `frontend/src/client/onboarding.ts`, `frontend/src/client/sidebar.ts`, `frontend/src/client/settings.ts`, `frontend/src/client/deadlines-detail.ts`, `frontend/src/client/projects-detail.ts` — every `me.role === "admin"` → `me.global_role === "global_admin"`; every form-field `role` → `job_title`; add the global_role dropdown widget.
9. `frontend/src/client/i18n.ts` — DE+EN strings for "Berufsbezeichnung", "Berechtigung", "Standard", "Global Admin".
10. Tests — update fixtures + add the cases in §11.

Self-merge to main authorized once `go build/vet/test ./...` and `bun run build` are clean and a smoke pass against ydb confirms acceptance §1–§9 of the task brief.
