Files
paliad/docs/design-profession-vs-project-role-2026-05-07.md
m 1eb43ceb6b 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.
2026-05-07 20:45:07 +02:00

38 KiB
Raw Blame History

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:

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.roleusers.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:

  • leadlead
  • observerobserver
  • 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):

    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.gorequireWritePermission 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,330pt.role = 'lead'pt.responsibility = 'lead'.
  • internal/services/deadline_service.go:695pt.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.goProjectTeamMember.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.partnerprojects.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.