Conflation: paliad.users.role was simultaneously job title (display only)
and global permission ('role=admin' checks across Go/SQL/JS). m wanted
to set his real job title ('Counsel Knowledge Lawyer') without losing
admin access — the t-paliad-050 admin-team UI even rejected role='admin'
on edit, so any UI-driven update silently demoted m.
Per m's three-axis principle ("firm roles are not project roles are not
tool roles"), this lands TWO orthogonal columns:
* paliad.users.job_title — free text, NULL allowed, display only.
NEVER gates anything in code or SQL.
* paliad.users.global_role — CHECK ('standard'|'global_admin'),
default 'standard'. The only thing that gates ops.
Migration 023:
* Drops NOT NULL + 'associate' default off the legacy role column
* Promotes role='admin' rows to global_role='global_admin'; clears
their role text; sets m's job_title='Counsel Knowledge Lawyer'
* Renames role -> job_title with CHECK (job_title IS NULL OR <> '')
* Replaces can_see_project body with global_role='global_admin'
* CASCADE-rebuilds every RLS policy under canonical English names —
with the historic u.role IN ('partner','admin') gates simplified
to u.global_role='global_admin' only (job_title NEVER gates)
Code surface:
* internal/models/models.go: User.Role -> User.JobTitle (*string) +
User.GlobalRole (string)
* internal/services/user_service.go: bootstrap (first row promoted to
global_admin via pg_advisory_xact_lock(7346298141), unchanged constant);
UpdateProfile drops role, accepts job_title only; AdminUpdateUser adds
global_role with last-admin demotion guard (ErrLastGlobalAdmin);
IsAdmin reads global_role
* Other services (dashboard/agenda/appointment/project/deadline/
department/party/note/checklist_instance): pass user.GlobalRole into
visibility predicates; partner-or-admin gates simplified to
global_admin only
* Handlers: drop now-impossible ErrAdminBootstrapOnly cases;
admin_users handles ErrLastGlobalAdmin -> 409
* department_service: SQL u.role -> u.job_title, DepartmentMember.Role
-> JobTitle (*string)
Frontend:
* /api/me + Me interfaces ship {job_title, global_role}
* Onboarding form: 'Berufsbezeichnung / Job title' (job_title)
* Settings + admin-team forms: same renames + i18n updates
* Admin-team: new 'Berechtigung / Permission' column with
'Standard'|'Global Admin' badge + dropdown editor; last-admin
demotion guard at the UI layer
* Sidebar admin-section reveal: me.global_role==='global_admin'
* deadlines/deadlines-detail/projects-detail/notes: partner-as-permission
gates dropped, only global_admin grants those operations
Tests:
* user_service_test: bootstrap promotes first user to global_admin,
subsequent default to standard; AdminUpdateUser refuses to demote
the last global_admin; IsAdmin reads global_role
Migration applied to ydb 2026-04-27. Live state verified:
* m: job_title='Counsel Knowledge Lawyer', global_role='global_admin'
* tester: job_title=NULL, global_role='global_admin'
* 29 stub colleagues: job_title='associate', global_role='standard'
20 KiB
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:
- Job title — Partner / Counsel / Associate / PA / Trainee / Sekretariat / "Counsel Knowledge Lawyer" / … . Free-text since migration 015. Display only.
- 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. - 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:
| 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'srole.paliad.users.global_role— enum-via-CHECK, currently'standard' | 'global_admin'. New column, drives everyrole='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'withCHECK (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 partyinternal/services/note_service.go:195— note opsinternal/services/appointment_service.go:199— appointment update/deleteinternal/services/project_service.go:617— project opsinternal/services/checklist_instance_service.go:301— checklist opsinternal/services/deadline_service.go:437— delete deadline- migrations 018 / 021 RLS policies —
users.role IN ('partner','admin')onprojects_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:
// 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:
-- 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 passuser.Roleas$2at lines 186, 219, 250, 279internal/services/agenda_service.go:135,201— same patterninternal/services/appointment_service.go:487— same pattern, caller line 492internal/services/project_service.go:725,736,747— threevisibilityPredicate*helpersinternal/services/deadline_service.go:405—if user.Role == "admin"short-circuitinternal/services/department_service.go:304—requireAdmininternal/services/user_service.go:149,232,346,388,500,638,641— bootstrap + IsAdmin + assignment guards
Go handlers
internal/handlers/onboarding.go:63— error messageinternal/handlers/users.go:91— error messageinternal/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, supersededinternal/db/migrations/007_rls_policies.up.sql:44,58— historical, supersededinternal/db/migrations/018_projects_v2.up.sql:414,497,530,544,548,560,565— historical, supersededinternal/db/migrations/021_fix_function_bodies_after_rename.up.sql:46,126,155— current live state ofcan_see_project+ RLS; migration 023 replaces
Frontend
frontend/src/client/sidebar.ts:299,309— sidebar admin-section revealfrontend/src/client/settings.ts:572,582— admin-only controls on settings pagefrontend/src/client/admin-team.ts:117,156,177,179,220,221— table render + sortfrontend/src/client/deadlines-detail.ts:190,193,207— admin/partner gate on UIfrontend/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 tojob_titleserver-side, label stays "Rolle / Job title")frontend/src/onboarding.tsx:48,52,53,57— onboarding form label / field namefrontend/src/client/admin-team.ts:170,179,309,365,372— datalist + form handlingfrontend/src/client/i18n.ts— everyadmin.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 isproject_teams.role, unrelated.- All other
pt.rolereferences in handlers/services.
7. API surface
/api/me payload before:
{ "id": "…", "email": "…", "role": "admin", … }
After:
{ "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_rolefield — that defaults to 'standard'.
Settings (/einstellungen)
- "Rolle" → "Berufsbezeichnung / Job title", same input.
- New read-only "Berechtigung / Permission" line below: shows
StandardorGlobal 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) orGlobal 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 fromme.role === "admin"tome.global_role === "global_admin".
Deadline / project detail pages
- The
me.role === "admin" || me.role === "partner"gates becomeme.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, newglobal_role) cross client + server simultaneously in one merge. No staged rollout needed for an internal tool. - Old session cookies with cached
me.rolevalues get refreshed on the next/api/mecall, which the client makes on every page load. - The
paliad.users.rolecolumn stops existing after migration 023. Any ad-hoc query / report keyed onrole='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.goCreatewithcount=0→global_role='global_admin'ANDjob_title=<input>(or NULL if empty)Createwithcount>0→global_role='standard'UpdateProfilecannot setglobal_role(field absent fromUpdateProfileInput)AdminUpdateUsercan setglobal_role; rejects when caller is not global_admin (handler-level test); rejects demotion of last global_admin (service-level test, mirror ofAdminDeleteUser's last-admin protection)IsAdminreadsglobal_role
internal/auth/require_admin_test.go— already covers theIsAdminsurface; no changes needed beyond the swap of test fixture's seeded column.
Integration / smoke
- Manual: log in as tester@hlc.de — confirm sidebar
/admin/teamentry appears, page loads, table shows m as global_admin + "Counsel Knowledge Lawyer" job title. - Manual: set m's
job_titlevia admin page to something else, confirmglobal_roleis 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.usersafter 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 buildclean.
Out of scope (recap)
- Fine-grained permissions (
partner,billing_admin) — design leaves room (CHECK can grow; or migrate totext[]later) but ships onlyglobal_admin. - Cleaning up the "partner" gate conflation (§4) — gate stays job-title-driven for now. File follow-up.
- Permission inheritance from
project_teamsto global — explicitly orthogonal. - Role-based UI customization beyond hide/show — defer.
Open questions for m
- m's
job_titlevalue — task brief says "Counsel Knowledge Lawyer". Confirmed? (Migration writes that exact string.) - 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. - Default
global_roleon new sign-ups beyond the bootstrap — confirmed'standard'. - 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.
internal/db/migrations/023_split_job_title_and_global_role.{up,down}.sql— schema + data + can_see_project + RLS rebuild.internal/models/models.go—User.Role→User.JobTitle(keepdb:"job_title"json:"job_title"); addUser.GlobalRole string \db:"global_role" json:"global_role"`. MakeJobTitlea*string` since admins may have NULL.internal/services/user_service.go— everyrolereference, includinguserColumns, the bootstrap branch (assignglobal_role),IsAdmin(readsglobal_role),UpdateProfileInput/AdminUpdateInput(dropRole, addJobTitle *stringandGlobalRole *stringfor admin-only).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 passuser.GlobalRoleinstead ofuser.Role. Partner gates change touser.JobTitle != "partner" && user.GlobalRole != "global_admin"(per §4).internal/handlers/{onboarding,users,admin_users}.go— update error messages; payload field renames.internal/auth/require_admin.go— comment update only (theAdminLookup.IsAdmininterface is unchanged because it abstracts behind the boolean).frontend/src/admin-team.tsx,frontend/src/onboarding.tsx— labels + field names (role→job_title); add Permission column on admin-team.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— everyme.role === "admin"→me.global_role === "global_admin"; every form-fieldrole→job_title; add the global_role dropdown widget.frontend/src/client/i18n.ts— DE+EN strings for "Berufsbezeichnung", "Berechtigung", "Standard", "Global Admin".- 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.