design(t-paliad-148): split project_teams.role into firm-level profession + project-level responsibility

Inventor design doc (kepler) for issue m/paliad#6. Splits the conflated
project_teams.role column into two axes:

- paliad.users.profession (firm-wide, drives t-138 approval ladder)
- paliad.project_teams.responsibility (per-project, lead/member/observer/external)

Approval ladder evaluated as tuple: profession_level if responsibility
opens the gate (lead/member), else 0. Policy grammar from t-138 stays
single-valued.

Verified live state: project_teams=3 rows (all 'lead'), partner_unit_members=20
rows (all default 'attorney'). Backfill is essentially trivial; risk is the
SQL rewiring (4 sites in approval_service.go, 2 in derivation_service.go,
2 in reminder_service.go) — all mechanical.

12 open questions from issue body answered with recommendations + rationale +
alternatives. Awaits m's go before any coder shift.

DESIGN READY FOR REVIEW.
This commit is contained in:
m
2026-05-07 20:45:07 +02:00
parent 99f08e3863
commit 1eb43ceb6b

View File

@@ -0,0 +1,841 @@
# Profession vs project responsibility — split `project_teams.role`
Inventor: kepler · Date: 2026-05-07 · Issue: m/paliad#6 (t-paliad-148)
Branch: `mai/kepler/inventor-profession-vs`
Status: **READY FOR REVIEW** — awaits m's go on the 12 open questions before any coder shift.
---
## §0 TL;DR
`paliad.project_teams.role` does two jobs at once: it labels a user's
**career tier at the firm** (PA, Associate, Of Counsel) **and** it labels
their **responsibility on this project** (Lead, Observer). m's bug report
(2026-05-06): you don't redefine someone's profession when staffing them
on a matter. The team-add dropdown should let you pick *responsibility*
only; profession should come from the firm record.
This design splits the column into two:
1. **`paliad.users.profession`** — firm-wide career tier
(`partner | of_counsel | associate | senior_pa | pa | paralegal | NULL`).
Drives the t-138 approval ladder. NULL means "no firm tier" (external).
2. **`paliad.project_teams.responsibility`** — per-project responsibility
(`lead | member | observer | external`). Default `member`. Drives a
simple gate — `lead` and `member` open authority; `observer` and
`external` close it. Replaces the team-add dropdown values m
complained about.
Approval ladder migrates from `project_teams.role` to a tuple
**(profession, responsibility)** evaluated as: *level = profession_level
if responsibility ∈ {lead, member} else 0*. Policy grammar
(`required_role` single-value) stays unchanged from t-138.
Single migration 057. Backfills profession from the highest legacy
`project_teams.role` per user. project_teams.role kept as a deprecated
shadow column for one release, dropped in 058.
---
## §1 Problem & locked premises
### What m said (2026-05-06)
> "The Role should not be definable there. Whether a team member is PA
> or Associate etc is not defined when adding existing members. Roles
> for the project, maybe. But not the 'profession'."
### Locked decisions (m, 2026-05-06)
- **Profession is not redefined per project.** It comes from the user's
firm-level record.
- **Project-level role is meaningful.** Stays editable per project, but
with a smaller value set focused on responsibility.
- **Approval ladder must keep working** — t-138 just shipped. Whatever
drives the ladder must still drive it.
### Three-axis principle (held since t-051)
> "Firm roles ≠ project roles ≠ tool roles."
Today's surfaces:
| Axis | Today's column | Today's values |
|---|---|---|
| Firm — display | `paliad.users.job_title` (free text) | "Counsel Knowledge Lawyer", "Junior Associate"… |
| Firm — tool admin | `paliad.users.global_role` | `standard \| global_admin` |
| Firm — partner-unit slot | `paliad.partner_unit_members.unit_role` | `lead \| attorney \| senior_pa \| pa \| paralegal` (per-unit, not firm-wide) |
| Project — staffing | `paliad.project_teams.role` | **mixed: profession + responsibility** ← the bug |
The split adds a fourth, missing axis — **firm career tier** as a
structured value that drives approval authority. It does not collapse
job_title (free-text label is still useful) or unit_role (per-unit slot
is still useful — see t-139 §11).
---
## §2 Verified live state (2026-05-07)
Probed `ydb` (paliad schema, port 11833) and current branch:
- `paliad.project_teams` CHECK on `role`: `lead, associate, pa,
of_counsel, local_counsel, expert, observer, senior_pa` (from
migration 054).
- `paliad.project_teams` row count: **3 rows, all `role='lead'`**.
- `paliad.partner_unit_members` CHECK on `unit_role`: `lead, attorney,
senior_pa, pa, paralegal`. Row count: **20 rows, all
`unit_role='attorney'`** (the default — nobody has overridden it
yet).
- `paliad.users` columns include `job_title` (text NULL), `global_role`
(text NOT NULL DEFAULT 'standard'). No profession column.
- `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
ladder helper, used in 4 SQL sites in `approval_service.go`.
- `paliad.approval_role_from_unit_role(text) RETURNS text IMMUTABLE` —
bridges unit_role → ladder values for derived authority.
- t-138 (commit `e2e1381`) and t-139 phases 13 all merged on `main`.
Migration tracker at 56 (next is **057**).
**Implication of the live data**: backfill is essentially trivial. Three
project_teams rows. Twenty partner_unit_members rows. The risk surface
of the migration is the SQL rewiring, not the data movement.
### Inventory of references to migrate
| File | Site | What it reads |
|---|---|---|
| `internal/services/team_service.go:53,93,103,122,159` | INSERT/SELECT/validate | `pt.role` for read+write of project membership. |
| `internal/services/derivation_service.go:118,127,314,383,403` | EffectiveProjectRole + manage gate | `pt.role` for ancestor walk + `RoleLead` for project-lead-can-manage check. |
| `internal/services/approval_service.go:103,411,751,854` | canApprove + ListPending + bell badge + deadlock check | `paliad.approval_role_level(pt.role)` — 4 SQL sites. |
| `internal/services/reminder_service.go:317,330` | reminder digest filter | `pt.role = 'lead'` — project-responsibility check. |
| `internal/services/deadline_service.go:695` | legacy authority check | `pt.role IN ('admin', 'lead')` — `'admin'` is dead since t-051; this is half-broken already. |
| `internal/services/project_service.go:486` | creator-as-lead INSERT | `INSERT … role='lead'`. |
| `internal/services/approval_levels.go:70` | Go-side `levelOf()` | Mirror of SQL ladder. Must change with the SQL. |
| `internal/services/project_service.go:57-66` | `RoleLead` etc. constants | Used in 14 places across services. |
| `internal/db/migrations/055_hierarchy_aggregation.up.sql:84,92` | can_see_project body | `pt.role = 'lead'`. |
| `frontend/src/projects-detail.tsx:124-132` | team-add dropdown | The 7 mixed options m complained about. |
| `frontend/src/client/projects-detail.ts:1665,1720,1772,1856` | render + read of role | i18n `projects.team.role.*`. |
| `frontend/src/client/i18n.ts:1139-1145, 2949-2955` | role translations | DE+EN keys. |
This is a wide rewrite but it's mechanical — the column boundary is
clean, the call sites are narrow, and the live data is small.
---
## §3 Sub-design A — Profession axis (Q1, Q2, Q3, Q12)
### Q1 — Where does profession live? Recommendation: **(a) new `paliad.users.profession` column**
Three candidates from issue body:
(a) New `paliad.users.profession` column (firm-wide, simple).
(b) Reuse `paliad.partner_unit_members.unit_role` (already added by
t-139 Phase 2; only set when the user is in a unit).
(c) New separate `paliad.user_professions(user_id, profession,
valid_from)` table for history.
**Recommend (a).**
Rationale:
- (b) breaks for users not in a partner unit. Today: 31 users, ~20 in
units. The other 11 (admins, externals, future hires) have no
unit_role. Profession needs to be defined for everyone or the
approval ladder gets gappy.
- (b) creates ambiguity if a user joins multiple units with different
unit_roles (legal under the t-139 schema). Picking "the highest" or
"the first" hides the data confusion. A firm-wide column is
unambiguous by construction.
- (b) re-couples the per-unit axis to the firm-wide axis. t-139 §11
explicitly kept `unit_role` per-unit to preserve the three-axis
principle. Reusing it for firm-wide authority breaks that invariant.
- (c) overengineered for v1. Profession changes when an HR promotion
fires — no audit, no time-slice. If history becomes a requirement,
add the table later (out-of-scope per issue body).
(a) is one column, one CHECK, no joins on the read path, no per-unit
ambiguity. Drop-in replacement for the slot in the approval ladder.
**Schema:**
```sql
ALTER TABLE paliad.users
ADD COLUMN profession text NULL
CHECK (profession IS NULL OR profession IN (
'partner', 'of_counsel', 'associate',
'senior_pa', 'pa', 'paralegal'
));
CREATE INDEX users_profession_idx ON paliad.users (profession);
```
NULL is a valid value: it means "no firm career tier" (e.g. external
local counsel signed up via invitation, or admin accounts that aren't
practicing lawyers). NULL → ladder level 0 → ineligible to approve.
`job_title` (free-text display) and `global_role` (tool admin) remain
untouched. Three firm-axis columns:
| Column | Purpose | Approval-relevant? |
|---|---|---|
| `users.job_title` | Free-text display label ("Counsel Knowledge Lawyer") | No |
| `users.profession` | Structured career tier (drives ladder) | **Yes** |
| `users.global_role` | Tool admin gate (`standard \| global_admin`) | Override only |
### Q2 — Profession values Recommendation: **`partner | of_counsel | associate | senior_pa | pa | paralegal`** (NULL = external)
The t-138 ladder defined 5 active levels. Today they are mixed
project-level + profession-level:
| Today | Level | Belongs on which axis? |
|---|---|---|
| `lead` | 5 | **project responsibility** (the lawyer in charge of THIS matter) |
| `of_counsel` | 4 | profession |
| `associate` | 3 | profession |
| `senior_pa` | 2 | profession |
| `pa` | 1 | profession |
| `local_counsel` | 0 | project responsibility (`external`) |
| `expert` | 0 | project responsibility (`external`) |
| `observer` | 0 | project responsibility |
Removing the project-axis values from the ladder leaves 4 profession
tiers (of_counsel, associate, senior_pa, pa). But "lead" was implicitly
"a partner is leading this matter", so profession needs **`partner`**
at level 5 to preserve the ceiling.
Add **`paralegal`** at level 0 (mirrors `partner_unit_members.unit_role`
which already has it; current `approval_role_from_unit_role` already
maps it to `observer`/level 0).
Final enum (6 values + NULL):
| Profession | Ladder level | Notes |
|---|---|---|
| `partner` | 5 | Replaces the project-level `lead` as the firm-tier ceiling. |
| `of_counsel` | 4 | unchanged |
| `associate` | 3 | unchanged; default for new firm members |
| `senior_pa` | 2 | unchanged |
| `pa` | 1 | unchanged |
| `paralegal` | 0 | New — present in unit_role; ineligible to approve. |
| NULL | 0 | "External / no firm tier." Approval-ineligible. |
**Why not include `senior_associate`, `counsel`, `trainee`, etc.** that
appear in the existing `i18n.team.role.*` keys (free-text user
directory): those values don't change the ladder level
(senior_associate = associate tier; counsel = of_counsel tier; trainee
= ineligible). Adding them inflates the enum without adding
authority-relevant distinctions. They live in `job_title` (free text)
where they belong. If HR later needs structured senior_associate vs
associate, the migration is one CHECK alter; the call sites are zero
because the ladder only sees levels.
**External roles (`local_counsel`, `expert`)** in the issue body are
project-only labels — they describe what a person *is on this matter*,
not a firm career tier. They land in §4 as `responsibility='external'`.
Their profession is NULL.
### Q3 — Onboarding flow Recommendation: **required-on-invite, default suggestion = `associate`, admin-editable later**
Three options:
- (i) Auto-default to `associate` with admin-edit later.
- (ii) Required-on-invite: inviting colleague picks profession.
- (iii) User picks own profession on first login.
**Recommend (ii) with default = `associate`.**
Rationale:
- (i) recreates the bug m just complained about, in slow motion. Every
PA invited gets shown as "associate" until someone notices and
edits. The whole point of this work is "profession is real, set it
honestly".
- (iii) is wrong: you don't redefine your own firm tier; HR/the firm
does. Self-pick also breaks the audit (anyone could promote
themselves).
- (ii) is one extra `<select>` on the existing invite form (already
rebuilt for t-paliad-141). The inviter is a colleague — they know
whether they're inviting a PA or an associate.
- Default `associate` makes the most common case one click. PAs and
Of Counsels are explicit choices, not silent demotions.
**External invitees** (local counsel, expert): inviter sets
`responsibility='external'` on the project; profession defaults to
NULL (not asked) — the form hides the profession field when
responsibility=external. Admin can fill profession later if the
external collaborator becomes a paliad-tracked firm member.
### Q12 — Bulk add / invite-new flow Recommendation: **profession capture on invite; NULL allowed; admin-edits later**
The existing invite-new-user flow (`team-user-invite-btn` →
`/api/team/invite-new`) accepts email + display_name today. After this
change:
- Invite form gains a profession `<select>` (6 values + "Extern (keine
Profession)").
- Default selected: `associate`.
- Submit creates `paliad.users` row with the picked profession +
`paliad.project_teams` row with the picked responsibility (default
`member`).
- "Extern" sets `responsibility='external'` on the project_teams row,
profession=NULL on users.
**No bulk-add UI exists today** — out of scope. If/when one ships, it
inherits the same per-row profession field.
**Admin re-edit**: `/admin/team` page (already shipped t-paliad-050)
gets a Profession column with inline-edit dropdown. Position next to
job_title. No last-admin guard needed (profession is not a tool gate).
---
## §4 Sub-design B — Project responsibility axis (Q4, Q5, Q6, Q11)
### Q4 — Value set Recommendation: **`lead | member | observer | external`**
Issue body suggests this set. Locking it.
| Value | Meaning | Edit/approve authority |
|---|---|---|
| `lead` | The responsible lawyer/partner for this matter. Also has project-management permissions (manage settings, attach partner units — already wired in derivation_service.go). | Full (subject to profession ceiling). |
| `member` | Staffed on this matter at their profession's level. | Full (subject to profession ceiling). |
| `observer` | Read-only awareness; no edit/approve authority. | None. |
| `external` | Non-firm collaborator (local counsel, expert witness). May edit per project policy, but cannot satisfy the firm-tier approval ladder. | Edit yes, approve no. |
**Why not collapse `external` into `observer`**: externals can actively
write (local counsel files briefs, experts upload reports). Observers
can't. The two are distinct read/write profiles and conflating them
loses information.
**Why not add `pa-on-this-project` etc.** — that's profession × project,
exactly what we just split. Once split, never re-mix.
### Q5 — Default value Recommendation: **`member`**
m's intuition is right. Lock.
The team-add form's default selection is `member`. The project creator
is auto-added as `lead` (already coded in `project_service.go:486` —
just rename the inserted column from `role='lead'` to
`responsibility='lead'`).
### Q6 — Display Recommendation: **3 columns: Name · Profession (read-only badge) · Responsibility (editable inline)**, plus the existing Herkunft column
Layout for the team table on `/projects/{id}` Tab:
```
| Name | Profession | Responsibility (edit) | Herkunft | Aktion |
| Anna Schmidt | [PA] | [Lead ▾] | direkt | 🗑 |
| Max Mustermann| [Associate] | [Member ▾] | über X-Unit | 🗑 |
| Carla Smith | (extern) | [External] | direkt | 🗑 |
```
- **Profession** column: read-only `.projekt-team-profession` pill (CSS
variant of existing `.projekt-team-role`). Click for global_admin
opens `/admin/team#user-{id}` for inline edit. For non-admins, a
hover tooltip explains "Profession wird im Firmenprofil gepflegt".
- **Responsibility** column: existing inline-edit pattern (`.entity-row
select`) — reuses the t-paliad-141 inline-edit affordance. Edit
permission = project lead OR global_admin.
- NULL profession renders as `(extern)` or `(keine Profession)`
depending on `responsibility`.
Inline prose elsewhere (Verlauf entries, inbox rows, email reminders):
**"Anna Schmidt (PA) — Lead"** — profession in parens, responsibility
after a dash. Explicit and parseable.
For the audit trail (`paliad.project_events`), emit
`team_member_added` with `metadata = {responsibility: 'member',
profession_at_time: 'pa'}` so historic rendering survives a profession
change.
### Q11 — Team table layout post-fix Recommendation: **3-column tabular layout above; tooltip-only profession is rejected**
Two alternatives the issue posed:
- **Hover-only profession** ("Anna Schmidt — Lead", profession in
tooltip badge): rejected. Profession is too important to hide. The
whole point of the split is to make profession honestly visible.
- **3-column tabular**: chosen. Matches the existing `.entity-table`
pattern; profession is glanceable.
Tooltip is still useful as *secondary* signal: hover the profession
badge → "PA — gesetzt im Firmenprofil. (Edit by global_admin only)".
The team-add form (the bug surface m complained about) loses the
mixed-axis dropdown. New form:
```
[ User autocomplete ▾ ] ← picks Anna Schmidt
Anna Schmidt (PA) ← shown beneath, read from users.profession
[ Responsibility: Member ▾ ] ← only dropdown left; default Member
[ Cancel ] [ Hinzufügen ]
```
**If the picked person has profession=NULL:** show a yellow warning
under the profession line: "*Anna hat keine Profession gesetzt — sie
kann keine 4-Augen-Genehmigungen erteilen. Admin im Firmenprofil
nachtragen.*" Doesn't block the add, just informs.
---
## §5 Sub-design C — Approval ladder rename + migration (Q7, Q8, Q9, Q10)
### Q7 vs Q8 — Tuple-gated ladder Recommendation: **Q7 (rename to profession) with project-responsibility as a binary gate**
Two views the issue posed:
- **Q7**: ladder migrates from `project_teams.role` → `users.profession`.
Project responsibility goes elsewhere; the ladder is purely
profession-driven.
- **Q8**: ladder becomes a tuple `(profession,
project_responsibility)` — finer policies, e.g. "associate-level
lawyer who is at least a member on this project".
**Recommend Q7-with-gate**: the ladder is profession-driven, and
project responsibility acts as a *binary gate* (open/closed) rather
than a separate dimension in the policy grammar.
Effective level for user U on project P:
```
profession_level = approval_role_level(U.profession) -- 0 if NULL
responsibility = project_teams.responsibility on P (direct or ancestor)
gate_open = responsibility IN ('lead', 'member')
effective_level = profession_level if gate_open else 0
```
For derived (partner-unit) authority (t-139):
```
derived_role = approval_role_from_unit_role(unit_role)
when ppu.derive_grants_authority = true
effective_level = max over all sources (direct, ancestor, derived)
```
(The "max" is operative because a user might be a `member` of one
project at profession=PA, AND derive-with-authority into the same
project via a partner-unit attachment with unit_role=senior_pa. Take
the higher.)
**Why not pure Q8?**
- Pure tuple-grammar means policies look like `required_role='associate'
AND required_responsibility='lead'`. Fine for power users; nobody
has asked. We can add the responsibility dimension to
`approval_policies` later (one new nullable column) if the firm
wants finer rules. v1 stays single-dimension, matching m's t-138
Q3 lock ("per-(project, entity_type, lifecycle_event)
required_role").
- Pure tuple also breaks Verlauf/audit phrasing — the audit currently
reads "Genehmigung erforderlich: Associate-Tier oder höher", which
stays clean under Q7-with-gate. Tuple grammar would need
"Associate-Tier UND mindestens Mitglied".
**Why not pure Q7 without the gate?**
- Without the gate, an `observer` who happens to be a Partner could
approve. That defeats the project-level call. The whole reason
someone is set as observer is "you're not authoritative here, even
though you're senior". The gate restores that semantics.
- `external` (local counsel) without a gate would also approve via
their own firm tier — except their profession is NULL, so they're
level 0 anyway. The gate is defense-in-depth there: if a future
external is given profession=of_counsel by mistake, the
responsibility=external still keeps them at level 0.
**Implementation site**: a new SQL function
`paliad.user_project_authority_level(_user_id uuid, _project_id uuid)
RETURNS int IMMUTABLE` encapsulates the (profession, responsibility,
derivation) computation. Replaces inline
`paliad.approval_role_level(pt.role)` at the 4
`approval_service.go` SQL sites. Plus a Go mirror
`UserProjectAuthorityLevel(ctx, userID, projectID) int` for callers
that need it without a SQL roundtrip (none today, but the
DerivationService.EffectiveProjectRole becomes a thin wrapper).
Policy grammar stays exactly as t-138 designed. `required_role` is a
profession value (`partner`, `of_counsel`, `associate`, `senior_pa`,
`pa`). The CHECK on `approval_policies.required_role` is updated to
the new enum (drop 'lead' — was the project-level value — and rename
nothing; the SQL ladder values are 1:1 except the ceiling). Existing
policy rows get backfilled `lead → partner` (the only mapping that
changes).
### Q9 — Backfill plan Recommendation: **highest-tier-observed per user; `lead/of_counsel/associate/senior_pa/pa → matching profession`; `local_counsel/expert/observer → NULL`**
Backfill rules:
**Profession** (firm-wide, one row per user):
For each user with at least one `paliad.project_teams` row:
```
profession = highest tier among all (direct) project_teams rows
where:
legacy 'lead' → 'partner' (level 5)
legacy 'of_counsel' → 'of_counsel'(level 4)
legacy 'associate' → 'associate' (level 3)
legacy 'senior_pa' → 'senior_pa' (level 2)
legacy 'pa' → 'pa' (level 1)
legacy 'local_counsel' → IGNORED
legacy 'expert' → IGNORED
legacy 'observer' → IGNORED
```
If after ignoring project-only labels the user has no firm-tier row →
profession = NULL.
For users with NO project_teams rows → profession = NULL too. Admin
edits at `/admin/team` if those users are firm members (the 11
unit-only users in current data).
**Tie-break**: pick the highest level. If a user is `lead` on Project A
and `pa` on Project B, profession = `partner` (level 5 > level 1).
This matches m's "highest-tier observed" rule from the issue body.
**Edge case — only `observer` rows**: the user has exactly one
`observer` row across all projects. Profession = NULL (no firm tier
inferable from the data). Admin will need to set it.
**Edge case — `local_counsel` rows only**: user is external. Profession
= NULL. Their project_teams.responsibility row will be 'external'
(see below).
**Responsibility** (per project_teams row):
```
legacy 'lead' → 'lead'
legacy 'observer' → 'observer'
legacy 'local_counsel' → 'external'
legacy 'expert' → 'external'
legacy 'associate' → 'member'
legacy 'pa' → 'member'
legacy 'of_counsel' → 'member'
legacy 'senior_pa' → 'member'
```
This preserves m's stated rules:
- `lead` → `lead`
- `observer` → `observer`
- everything else (firm tier) → `member` (their authority is now
encoded in their profession; the project row just says "they're
staffed")
External labels (`local_counsel`, `expert`) get
`responsibility='external'`. Their profession remains NULL (the
backfill above ignores them for profession purposes).
**Live data sanity check**: today there are 3 project_teams rows, all
`role='lead'`. Backfill produces:
- 3 users get `profession='partner'`.
- 3 project_teams rows get `responsibility='lead'`.
All other users (28 of 31) get `profession=NULL` until admin edits
them at `/admin/team`. Acceptable — the firm has known they need an
audit pass over user records since t-051; this surfaces it cleanly.
### Q10 — Down-migration safety Recommendation: **reversible with documented data loss on edge cases**
Down-migration steps (`057_down`):
1. Re-derive `project_teams.role` from `(responsibility, profession)`:
```sql
UPDATE paliad.project_teams pt
SET role = CASE
WHEN pt.responsibility = 'lead' THEN 'lead'
WHEN pt.responsibility = 'observer' THEN 'observer'
WHEN pt.responsibility = 'external' THEN 'local_counsel'
WHEN pt.responsibility = 'member' THEN COALESCE(
(SELECT u.profession FROM paliad.users u WHERE u.id = pt.user_id),
'associate'
)
END;
```
- `external` always maps back to `local_counsel` (most common
pre-split external label; `expert` is rarer and lossy).
- `member` with profession=`partner` maps back to… ambiguous.
Pre-split there was no firm-tier `partner` row in
`project_teams`. Document data loss: maps to `of_counsel` (next
highest legacy value). If the down is run, the partner re-appears
as of_counsel on that project. Acceptable for a rollback.
- `member` with profession=`paralegal` maps back to `pa` (closest
legacy fit; `paralegal` was never a `project_teams.role` value).
- `member` with profession=NULL maps back to `associate` (safe
default, matches the legacy `RoleAssociate` default).
2. DROP COLUMN `paliad.users.profession`.
3. DROP COLUMN `paliad.project_teams.responsibility`.
4. Drop `paliad.user_project_authority_level` function.
5. Restore `approval_service.go` SQL sites to inline
`approval_role_level(pt.role)`.
Down-migration is best-effort. Documented data loss in `057_down.sql`
comments. The Go code on `main` doesn't need to support both states
(paliad doesn't have multi-version-deployed history); a down is a
manual rollback path.
**Phasing**: `project_teams.role` stays on the table as a deprecated
shadow column for one release (migration 057 keeps it; migration 058 —
follow-up ticket — drops it after Go code is fully migrated). This
means even in the worst case, a fast down doesn't have to recompute
`role`; it just drops the new columns and keeps the old.
---
## §6 Migration plan — single migration 057
Filename: `internal/db/migrations/057_profession_vs_responsibility.up.sql`
Sections:
1. ADD `paliad.users.profession`.
2. ADD `paliad.project_teams.responsibility`.
3. CREATE `paliad.user_project_authority_level(user_id, project_id)`
function.
4. UPDATE `paliad.approval_policies.required_role` CHECK to add
`'partner'` and drop `'lead'`. Backfill `'lead'` → `'partner'` in
any existing rows.
5. Backfill `users.profession` per Q9.
6. Backfill `project_teams.responsibility` per Q9.
7. UPDATE `paliad.can_see_project` body — replace `pt.role = 'lead'`
with `pt.responsibility = 'lead'`. Function CASCADE-rebuild not
needed (only function body changes).
8. UPDATE the comment on `paliad.approval_role_level` to point at
`users.profession` instead of `project_teams.role`.
`project_teams.role` is **kept** in this migration (deprecated, not
read by any new code). Drop in follow-up migration 058 after Go code
fully migrates and is verified live.
### Service-layer migration (single PR alongside 057)
Files to edit:
- `internal/services/team_service.go` — INSERT/SELECT/validate the new
`responsibility` column. `isValidRole` becomes
`isValidResponsibility` with new enum.
- `internal/services/derivation_service.go` — `requireWritePermission`
reads `pt.responsibility = 'lead'` instead of `pt.role = 'lead'`.
`EffectiveProjectRole` (used by t-138 derived authority) replaced by
`UserProjectAuthorityLevel` (returns int from the SQL function +
source string). `ListAttachedUnits`, `ListDerivedMembers` unchanged
(they don't touch the ladder column).
- `internal/services/approval_service.go` — 4 SQL sites switch from
`paliad.approval_role_level(pt.role)` to
`paliad.user_project_authority_level(pt.user_id, $project_id)`.
Self-approval CHECK and policy lookup stay identical.
- `internal/services/approval_levels.go` — Go-side `levelOf()` becomes
`professionLevel()`; new helper `responsibilityOpensGate()`.
`RoleSeniorPA` constant stays (still a valid profession value,
reused). New constants `ProfessionPartner`, `ProfessionOfCounsel`,
`ProfessionAssociate`, `ProfessionSeniorPA`, `ProfessionPA`,
`ProfessionParalegal`. New constants `ResponsibilityLead`,
`ResponsibilityMember`, `ResponsibilityObserver`,
`ResponsibilityExternal`.
- `internal/services/project_service.go:486` — INSERT writes
`responsibility='lead'` (creator-as-lead). Old
`RoleLead`/`RoleAssociate`/etc constants stay as aliases for one
release to ease grep diffs; mark deprecated.
- `internal/services/reminder_service.go:317,330` —
`pt.role = 'lead'` → `pt.responsibility = 'lead'`.
- `internal/services/deadline_service.go:695` —
`pt.role IN ('admin', 'lead')` → `pt.responsibility = 'lead'`.
(`'admin'` was already dead since t-051; this is also a small
cleanup.)
- `internal/services/user_service.go` — onboarding/invite code
accepts a `profession` arg, stores on insert.
- `internal/handlers/team.go` (and friends) — JSON shape change:
`ProjectTeamMember` now exposes `responsibility` instead of `role`,
embeds `User.Profession`.
- `internal/models/models.go` — `ProjectTeamMember.Role` → `.Responsibility`;
`User` gains `.Profession *string`.
### Frontend migration (same PR)
- `frontend/src/projects-detail.tsx:124-132` — replace 7-option mixed
dropdown with 4-option responsibility-only dropdown
(`lead | member | observer | external`). Default `member`.
- `frontend/src/client/projects-detail.ts:1665,1720,1772,1856` — render
3-column team table. New `.projekt-team-profession` CSS pill +
i18n keys `projects.team.profession.partner` …
`projects.team.profession.paralegal`. New i18n keys
`projects.team.responsibility.lead` … `.external` (replace
`projects.team.role.*`).
- `frontend/src/client/team.ts` — `/team` directory page: respect new
profession column for grouping. Falls back to job_title when
profession=NULL (existing free-text behaviour preserved for
externals).
- `frontend/src/admin-team.tsx` + `client/admin-team.ts` — add
Profession column with inline-edit dropdown.
- `frontend/src/onboarding.tsx` — invite flow gains a profession
`<select>`.
- ~30 new i18n keys DE+EN.
### Tests
- `internal/services/team_service_test.go` — happy path on
AddMember/RemoveMember with new responsibility values; reject
invalid values.
- `internal/services/approval_service_test.go` — extend
table-driven ladder tests to cover the
(profession, responsibility) tuple. Cases: `partner+observer = 0`,
`pa+lead = 1`, `null+member = 0`, derived+responsibility=external
combinations.
- `internal/services/migration_057_test.go` — live-DB integration
test (skipped without `TEST_DATABASE_URL`): apply migration on a
seeded snapshot, assert backfill produces expected
(profession, responsibility) pairs.
---
## §7 Implementation phasing
**Single PR, 6 commits** — the schema + service + frontend are tightly
coupled. Splitting risks half-broken intermediate states (the bug
report itself is about a half-broken intermediate state).
1. Migration 057 (schema + backfill + new SQL function). No code
changes — server still reads `pt.role`. Verify backfill on live DB
via BEGIN/ROLLBACK.
2. ApprovalService + DerivationService rewire. Tests updated. Build +
test green. Server reads from new SQL function but writes still go
to `pt.role` (will fix in commit 3).
3. TeamService + UserService rewire. INSERT writes
`responsibility=...`. Reads return `responsibility`. Models
updated. JSON schema change.
4. Frontend rewire — team-add dropdown, team table, admin-team,
onboarding. New i18n keys.
5. Reminder + Deadline service touch-ups + can_see_project body
refresh.
6. Lint + grep sweep — kill any remaining `pt.role` references that
should have been migrated. Add a deprecation comment to the
`RoleLead`/`RoleAssociate` Go constants pointing at the new ones.
**Follow-up ticket (out of scope for this PR)**: t-paliad-149 —
migration 058 to DROP COLUMN `project_teams.role` after one release of
soak time on main. Trivial when the time comes; just keeps this PR
clean.
**Recommended implementer**: any pattern-fluent coder. **NOT cronus**
(retired from paliad per memory directive). Sonnet work — 70% of the
diff is mechanical rename, 30% is the new SQL function + 4 ladder-site
rewrites + the new team-table layout. The substrate is well-trodden
(t-051 split established the pattern; t-138/t-139 left clean call
sites to migrate from).
---
## §8 Trade-offs flagged
1. **One migration touches both axes at once.** A pure-additive
migration (add columns, leave `role`) would be safer-feeling, but
then the team-add dropdown bug stays open (the UX lie m hates is
still on screen until commit 4). I prefer one PR that ships the
fix end-to-end, with `project_teams.role` deprecated-shadow for
one release as the safety net.
2. **Profession=NULL semantics are load-bearing.** NULL means "no
firm tier" → ladder level 0 → ineligible. If a developer later
adds a fast-path that defaults NULL→`associate` for "convenience",
externals would silently gain approval rights. Mitigation: explicit
helper `professionLevel(*string) int` that returns 0 for NULL with
a comment naming the trap. Add a unit test `TestProfessionLevel_NilIsZero`.
3. **`partner` is the new ceiling but `lead` is no longer a profession**.
The mental jump for users: "Lead" was the highest in the dropdown;
now "Partner" is. Renaming is honest but a moment of surprise.
Mitigation: i18n keys carry over the lead-on-this-project sense via
`projects.team.responsibility.lead` so the word "Lead" stays
visible exactly where it should — the project axis. Profession's
"Partner" appears in firm-context surfaces (admin/team, tooltips).
4. **Tuple-gated ladder vs pure-tuple grammar.** Choosing
responsibility as a binary gate means a future "must be a member,
not just having visibility" rule is easy. A future "must be lead
AND of_counsel-tier or higher" rule needs a new dimension on
`approval_policies` (new nullable column). Acceptable: zero
policies today need it; cheap to add when one does.
5. **Backfill produces 28 NULL professions** out of 31 users (the
ones not in any project_teams row). After ship, `/admin/team` will
show a warning column "Profession nicht gesetzt" until admin
completes the audit. This is honest visibility of pre-existing data
debt rather than papering over with a guessed default.
6. **`approval_role_from_unit_role` doesn't change** but its callers
(the derived-authority SQL branches in approval_service.go) need to
move from "compare against `pt.role`" to "compare against
`users.profession` of the project_teams row's user". Mechanical;
listed in §6 file inventory.
---
## §9 Out of scope (v1)
- Replacing the partner-unit-derivation mechanism (t-139 Phase 2) —
derivation stays exactly as designed.
- A full firm-roles / hierarchy / org-chart feature — this design adds
one structured column (profession) and nothing more.
- Multi-profession (paralegal-turned-associate scenario). One
profession per user; admin edits when promoted.
- Time-sliced profession history (who was a PA in 2024). Out per
issue body.
- Adding a `responsibility` dimension to `approval_policies` (Q8 pure
tuple grammar). Deferred to a future ticket if a real policy
requires it.
- Bulk-add UI for project members. None exists today.
- Dropping `project_teams.role` itself. Deferred to follow-up
migration 058 after one release of soak time.
---
## §10 12 Questions — Recommendation summary
| # | Question | Recommendation | Locked? |
|---|---|---|---|
| Q1 | Where does profession live? | (a) New `paliad.users.profession` text column | open — m sign-off |
| Q2 | Profession values | `partner \| of_counsel \| associate \| senior_pa \| pa \| paralegal` (NULL = external) | open — m sign-off |
| Q3 | Onboarding flow | Required-on-invite, default `associate`, admin-editable | open — m sign-off |
| Q4 | Project responsibility values | `lead \| member \| observer \| external` | open — m hinted yes |
| Q5 | Default value | `member` | open — m hinted yes |
| Q6 | Display | 3 columns: Name · Profession (badge) · Responsibility (inline-edit), plus existing Herkunft | open — m sign-off |
| Q7 vs Q8 | Ladder migration | Q7 (rename to profession) WITH project-responsibility as a binary gate (`responsibility ∈ {lead, member}` opens the gate) | open — main architectural call |
| Q9 | Backfill | Profession = highest legacy tier per user (`lead → partner`, `of_counsel → of_counsel`, …, externals → NULL); responsibility per single-row mapping (`lead → lead`, `observer → observer`, externals → `external`, others → `member`) | open — m sign-off |
| Q10 | Down-migration | Reversible with documented best-effort data loss; `project_teams.role` kept as deprecated shadow until follow-up 058 | open — m sign-off |
| Q11 | Team table layout | 3-column tabular (rejecting tooltip-only profession); inline-edit responsibility; profession edits live on `/admin/team` | open — m sign-off |
| Q12 | Bulk add / invite | Profession capture on invite (default `associate`, "Extern" hides field). No bulk-add v1. Admin re-edits via `/admin/team` | open — m sign-off |
---
## §11 Coordination with sibling work
- **t-138 (approvals)**: shipped 2026-05-06 (commit `e2e1381`). Migration
054 sets up the ladder; this design extends it to read from
`users.profession` instead of `project_teams.role`. Policy grammar
unchanged. `required_role` enum gains `partner`, drops `lead`
(renamed in backfill).
- **t-139 (hierarchy + derivation)**: all 3 phases shipped. Migration
055 added `partner_unit_members.unit_role` and the
`approval_role_from_unit_role` bridge. This design leaves the
bridge untouched — `unit_role` values map 1:1 to the new profession
enum (`lead → partner`, `attorney → associate`, `senior_pa →
senior_pa`, `pa → pa`, `paralegal → paralegal`). Update the bridge's
`lead → lead` row to `lead → partner` in migration 057.
- **t-144 (Custom Views)**: shipped. ViewService.runApprovalRequests
uses ApprovalService.ListPendingForApprover, which reads the new
ladder. Inherits the change automatically.
- **t-paliad-145 (local chat)**: parked. Not relevant.
No siblings are blocked by this work, and this work doesn't block any
sibling. Independent migration, independent merge.
---
## §12 Inventor parking
Inventor (kepler) parks here. Awaits m's pass through the 12 questions
in §10 + any course-correction. After m signs off, this design locks
and a fresh coder shift can pick up the single PR. Branch:
`mai/kepler/inventor-profession-vs`.
DESIGN READY FOR REVIEW.