t-paliad-148: split project_teams.role — profession vs project responsibility #11

Open
mAi wants to merge 0 commits from mai/kepler/inventor-profession-vs into main
Collaborator

Closes m/paliad#6 (when merged).

Summary

Splits paliad.project_teams.role into two clean axes:

  • paliad.users.profession — firm-wide career tier (partner | of_counsel | associate | senior_pa | pa | paralegal | NULL). Drives the t-138 approval ladder.
  • paliad.project_teams.responsibility — per-project (lead | member | observer | external). Default member. Replaces the team-add dropdown values m complained about.

Approval ladder is now a tuple-with-gate: effective_level = profession_level IF responsibility ∈ {lead, member} ELSE 0. Policy grammar from t-138 (required_role single value) stays unchanged.

Design locked by m on 2026-05-07 21:35: "lets go with those - but if you are fine, go for shift". All 12 §10 recommendations approved verbatim.

Commits (6)

  1. Migration 057 (ab2530f) — schema + backfill + user_project_authority_level SQL function. project_teams.role kept as deprecated shadow.
  2. ApprovalService + DerivationService (6506864) — 4 SQL ladder sites switch to tuple-with-gate; levelOf → professionLevel; new responsibilityOpensGate. NULL trap pinned by TestProfessionLevel_NilIsZero.
  3. TeamService + UserService + Models + Handlers (e6937d2) — write profession + responsibility. JSON shape extends with responsibility (preferred) + user_profession; legacy role accepted as fallback for one release.
  4. Reminder + Deadline + Derivation cleanup (9184e9b) — pt.role → pt.responsibility. Drops dead 'admin' reference in deadline reopen check.
  5. Frontend (2af4bf1) — team-add dropdown 7 mixed → 4 responsibility-only. 3-column team table (Name · Profession · Responsibility · Herkunft). admin-team gains Profession column. Onboarding gains Profession select. ~30 new i18n keys DE+EN. CSS pill variants.
  6. Deprecation notes + grep sweep (0b4de1c).

Live data impact

Backfill is essentially trivial:

  • 3 project_teams rows (all lead) → 2 users get profession='partner', 3 rows get responsibility='lead'.
  • 20 partner_unit_members rows (all default attorney) — bridge unchanged.
  • 45 of 47 users get profession=NULL and will need admin pass via /admin/team (honest visibility of pre-existing data debt).

Live-DB BEGIN/ROLLBACK dry-run verified during commit 1.

Verification

  • go build ./...
  • go vet ./...
  • go test ./... ✓ — all 7 test packages pass; new tests for profession ladder + responsibility gate + NULL trap + new validators.
  • bun build.ts ✓ — 1723 i18n keys, all referenced.

Test plan

  • Merge to main; Dokploy deploys; migration 057 applies on boot.
  • /projects/{id} Team tab renders 3-column layout (Name · Profession · Rolle · Herkunft · ✕).
  • Team-add dropdown shows 4 options (Lead/Mitglied/Beobachter/Extern), default Mitglied.
  • Picking a person without profession shows the warning hint.
  • /admin/team renders Profession column; inline-edit dropdown saves.
  • /onboarding form has the profession select; submits the new field.
  • /inbox + bell badge keep working — partner-tier users still satisfy associate-required policies.
  • observer/external rows excluded from approval queue even if user.profession=partner.
  • Admin pass over the 45 NULL-profession users.

Follow-up

File t-paliad-149 to drop paliad.project_teams.role after one release of soak time on main. Trivial migration 058.

Closes m/paliad#6 (when merged). ## Summary Splits `paliad.project_teams.role` into two clean axes: - **`paliad.users.profession`** — firm-wide career tier (`partner | of_counsel | associate | senior_pa | pa | paralegal | NULL`). Drives the t-138 approval ladder. - **`paliad.project_teams.responsibility`** — per-project (`lead | member | observer | external`). Default `member`. Replaces the team-add dropdown values m complained about. Approval ladder is now a tuple-with-gate: `effective_level = profession_level IF responsibility ∈ {lead, member} ELSE 0`. Policy grammar from t-138 (`required_role` single value) stays unchanged. Design locked by m on 2026-05-07 21:35: *"lets go with those - but if you are fine, go for shift"*. All 12 §10 recommendations approved verbatim. ## Commits (6) 1. **Migration 057** (`ab2530f`) — schema + backfill + `user_project_authority_level` SQL function. project_teams.role kept as deprecated shadow. 2. **ApprovalService + DerivationService** (`6506864`) — 4 SQL ladder sites switch to tuple-with-gate; `levelOf → professionLevel`; new `responsibilityOpensGate`. NULL trap pinned by `TestProfessionLevel_NilIsZero`. 3. **TeamService + UserService + Models + Handlers** (`e6937d2`) — write profession + responsibility. JSON shape extends with `responsibility` (preferred) + `user_profession`; legacy `role` accepted as fallback for one release. 4. **Reminder + Deadline + Derivation cleanup** (`9184e9b`) — pt.role → pt.responsibility. Drops dead `'admin'` reference in deadline reopen check. 5. **Frontend** (`2af4bf1`) — team-add dropdown 7 mixed → 4 responsibility-only. 3-column team table (Name · Profession · Responsibility · Herkunft). admin-team gains Profession column. Onboarding gains Profession select. ~30 new i18n keys DE+EN. CSS pill variants. 6. **Deprecation notes + grep sweep** (`0b4de1c`). ## Live data impact Backfill is essentially trivial: - 3 `project_teams` rows (all `lead`) → 2 users get `profession='partner'`, 3 rows get `responsibility='lead'`. - 20 `partner_unit_members` rows (all default `attorney`) — bridge unchanged. - 45 of 47 users get `profession=NULL` and will need admin pass via `/admin/team` (honest visibility of pre-existing data debt). Live-DB BEGIN/ROLLBACK dry-run verified during commit 1. ## Verification - `go build ./...` ✓ - `go vet ./...` ✓ - `go test ./...` ✓ — all 7 test packages pass; new tests for profession ladder + responsibility gate + NULL trap + new validators. - `bun build.ts` ✓ — 1723 i18n keys, all referenced. ## Test plan - [ ] Merge to main; Dokploy deploys; migration 057 applies on boot. - [ ] /projects/{id} Team tab renders 3-column layout (Name · Profession · Rolle · Herkunft · ✕). - [ ] Team-add dropdown shows 4 options (Lead/Mitglied/Beobachter/Extern), default Mitglied. - [ ] Picking a person without profession shows the warning hint. - [ ] /admin/team renders Profession column; inline-edit dropdown saves. - [ ] /onboarding form has the profession select; submits the new field. - [ ] /inbox + bell badge keep working — partner-tier users still satisfy associate-required policies. - [ ] observer/external rows excluded from approval queue even if user.profession=partner. - [ ] Admin pass over the 45 NULL-profession users. ## Follow-up File **t-paliad-149** to drop `paliad.project_teams.role` after one release of soak time on main. Trivial migration 058.
mAi added 7 commits 2026-05-07 19:58:10 +00:00
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.
Adds paliad.users.profession (firm-wide career tier) and paliad.project_teams.responsibility
(per-project responsibility, default 'member'). Backfills both from the legacy
project_teams.role column — highest-tier-per-user for profession, single-row map
for responsibility (lead→lead, observer→observer, local_counsel/expert→external,
others→member).

Updates paliad.approval_role_level to recognise 'partner' as the new ceiling
(replaces 'lead' as the firm-tier ceiling), keeping 'lead' at level 5 as a
deprecated-shadow row until follow-up migration 058 retires project_teams.role.

Updates paliad.approval_role_from_unit_role: lead → partner.

Creates paliad.user_project_authority_level(user_id, project_id) — the
tuple-with-gate ladder. Returns profession_level if responsibility ∈ {lead,member}
else 0; max with derived authority via partner-unit attachments where
derive_grants_authority=true.

Updates approval_policies.required_role + approval_requests.required_role CHECK
constraints (drop 'lead', add 'partner'); backfills any existing rows.

Rewrites project_partner_units write RLS policy to read pt.responsibility='lead'
instead of pt.role='lead'.

Live-DB BEGIN/ROLLBACK dry-run verified: 2 users get profession='partner'
(matthias.siebels, tester@hlc.de — the only users currently on project_teams),
45 users get profession=NULL (admin fills via /admin/team).

project_teams.role kept as deprecated shadow column. Drop in follow-up migration 058.
Rewires the 4 SQL ladder sites in approval_service.go (canApprove,
hasQualifiedApprover, ListPendingForApprover, PendingCountForUser) to read
the new tuple: project_teams.responsibility ∈ {lead, member} AND
users.profession at or above the threshold. observer/external rows close
the gate even if the user's profession would otherwise qualify — that's
the project-level call.

approval_levels.go renamed levelOf → professionLevel and added
responsibilityOpensGate helper. New constants: ProfessionPartner /
ProfessionOfCounsel / … and ResponsibilityLead / ResponsibilityMember /
ResponsibilityObserver / ResponsibilityExternal. New validators
IsValidProfession + IsValidResponsibility. RoleSeniorPA kept as legacy
alias for the one remaining call site that hasn't migrated yet.

CRITICAL trap pinned by TestProfessionLevel_NilIsZero: NULL profession
returns 0, never silently defaults to associate. External collaborators
must stay ineligible.

derivation_service.go: requireWritePermission switches from pt.role='lead'
to pt.responsibility='lead' — project-management writes gate on the
project responsibility, not the firm tier. EffectiveProjectRole replaced
by UserProjectAuthorityLevel (thin wrapper over the SQL function in
migration 057). The legacy method was unused dead code despite t-139
design intent.

Tests extended: profession ladder, responsibility gate, NULL trap,
new validators. Build + vet clean.
Models:
- ProjectTeamMember.Responsibility (new) + .Role (kept as deprecated shadow). JSON exposes both during the deprecation window.
- ProjectTeamMemberWithUser.UserProfession — populated by reads so the team-tab UI can render the firm-tier badge.
- User.Profession (*string) — structured firm-tier driving the approval ladder. Distinct from JobTitle (display) and GlobalRole (tool admin).

TeamService:
- AddMember signature kept as (callerID, projectID, userID, responsibility) — third arg renamed conceptually. Accepts the new responsibility enum and writes both legacy `role` (via legacyRoleFromResponsibility helper) and `responsibility` to keep the deprecated shadow consistent.
- ListDirectMembers + ListEffectiveMembers SELECT both `pt.role`, `pt.responsibility`, and `u.profession`. ORDER BY switches from pt.role to pt.responsibility.
- legacy isValidRole removed (unused after switch to IsValidResponsibility).

UserService:
- CreateUserInput + AdminCreateInput + AdminUpdateInput accept Profession. Self-service onboarding defaults to 'associate' when empty. AdminCreate likewise. AdminUpdate empty-string clears to NULL (external collaborator). Invalid values rejected with ErrInvalidInput.
- INSERT statements write the new column on both Create paths.

ProjectService.Create:
- Auto-add-creator INSERT writes responsibility='lead' alongside legacy role='lead'.

Handlers:
- POST /api/projects/{id}/team accepts `responsibility` (preferred) and falls back to legacy `role` for one release while frontend migrates.

Build + vet clean. Pure-Go tests pass.
reminder_service.go: BuildDigest audience predicate switches the
"project lead anywhere on the path" branch from `pt.role = 'lead'` to
`pt.responsibility = 'lead'`. Two SQL sites + comment updated.

deadline_service.go: assertCanAdminProject (Reopen permission) switches
from `pt.role IN ('admin','lead')` to `pt.responsibility = 'lead'`.
The legacy 'admin' was already dead since t-paliad-051 — never present
in project_teams.role to begin with — so this also drops a slow leak.
Doc comments + error message updated.

derivation_service.go: ListDescendantStaffed SELECTs both `pt.role` and
`pt.responsibility`, returns the new column to the team-tab "from
descendants" subsection (so the firm-tier badge + responsibility pill
both render). ORDER BY switches to responsibility.

Build + vet clean. Pure-Go tests pass.
projects-detail.tsx (the bug surface):
- Team-add dropdown switches from 7 mixed values (lead/associate/pa/of_counsel/local_counsel/expert/observer) to 4 responsibility-only values (lead/member/observer/external). Default 'member'. Closes m's bug — staffing a person no longer pretends to define their firm tier.
- Team table gains a Profession column (between Name and Responsibility), so the firm-tier badge is glanceable at staffing time.
- form.team-profession-hint surfaces the picked person's profession or warns when none is set ("kann keine 4-Augen-Genehmigungen erteilen").

projects-detail.ts:
- ProjectTeamMember type gains responsibility + user_profession. Legacy .role field kept readable for the deprecation window but UI no longer uses it.
- renderTeam renders 3-column tabular layout. Profession pill is read-only (.projekt-team-profession[--none]); responsibility is visible inline (inline-edit deferred to follow-up).
- canManagePartnerUnits switches from m.role==="lead" to m.responsibility==="lead".
- Team-add submit posts {responsibility} instead of {role}.

admin-team.tsx + client/admin-team.ts:
- New Profession column with inline-edit dropdown (6 values + "(extern)" NULL option). User type extends with profession?: string|null.
- Read-only cell uses .projekt-team-profession pill with "(extern)" placeholder for NULL.

onboarding.tsx + client/onboarding.ts:
- New required profession <select> with default 'associate'. Six values match the new enum. Hint copy explains the difference from job_title.
- POST /api/onboarding payload gains profession field.

i18n.ts: ~30 new keys DE+EN — projects.team.profession.* / .responsibility.* / projects.detail.team.col.profession / .responsibility / .form.responsibility / .form.profession.* / admin.team.col.profession.* / onboarding.profession.* / projects.team.profession.none + .hint variants.

CSS:
- .projekt-team-profession pill (firm-tier, read-only).
- .projekt-team-profession--none italic-dashed for NULL professions.
- .projekt-team-responsibility pill (per-project).
- .form-hint--warning for the team-add no-profession warning.

Build: bun build.ts clean (1723 i18n keys, all referenced). go build + go vet + go test (pure-Go) clean.
Mark the legacy Role* constants in project_service.go as DEPRECATED.
They stay defined for one release because team_service.go still writes
the deprecated shadow column via legacyRoleFromResponsibility; follow-up
migration 058 (t-paliad-149) retires both the column and the constants.

Final grep sweep clean: no live-code call sites remaining for
project_teams.role outside of:
  - the deprecated legacyRoleFromResponsibility mapper (intentional)
  - team_service.go RETURNING + SELECT (reads the shadow column for
    the JSON .role field still surfaced for the deprecation window)
  - migrations 018/023/054/055 (historical, not modified)

Test suite green across all packages: auth, branding, calc, changelog,
handlers, offices, services. Frontend bun build clean (1723 i18n keys).
Checking for merge conflicts ...
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin mai/kepler/inventor-profession-vs:mai/kepler/inventor-profession-vs
git checkout mai/kepler/inventor-profession-vs
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/paliad#11
No description provided.