-- t-paliad-148: split paliad.project_teams.role into firm-level profession -- and project-level responsibility. -- -- Design: docs/design-profession-vs-project-role-2026-05-07.md (kepler, -- m-locked 2026-05-07 21:35). -- -- The legacy column did two jobs at once: -- - career tier at the firm (PA, Associate, Of Counsel, …) -- - responsibility on this matter (Lead, Member, Observer) -- This migration introduces two clean axes and backfills both from the -- legacy column. The legacy column is kept as a deprecated shadow for one -- release; a follow-up migration drops it after Go code has fully -- migrated and the production data is verified clean. -- -- Day-1 deploy = zero behaviour change because the new code paths read -- the new columns and the backfill is run inside this migration. -- -- Sections: -- 1. ALTER paliad.users ADD COLUMN profession. -- 2. ALTER paliad.project_teams ADD COLUMN responsibility. -- 3. Backfill profession from highest legacy project_teams.role per user. -- 4. Backfill responsibility from legacy project_teams.role. -- 5. UPDATE paliad.approval_policies.required_role CHECK + 'lead' → 'partner'. -- 6. UPDATE paliad.approval_requests.required_role CHECK + 'lead' → 'partner'. -- 7. UPDATE paliad.approval_role_from_unit_role: lead → partner. -- 8. CREATE paliad.user_project_authority_level — tuple-with-gate ladder. -- 9. CASCADE-rebuild paliad.project_partner_units RLS policies that -- reference pt.role = 'lead'. -- 10. UPDATE COMMENT on paliad.approval_role_level pointing at users.profession. -- ============================================================================ -- 1. paliad.users.profession — firm-wide career tier. -- -- NULL means "no firm tier" (external local counsel, expert, admin -- accounts that aren't practicing lawyers). NULL → ladder level 0 → -- ineligible to approve. Required-on-invite for firm members; admin -- editable on /admin/team. -- ============================================================================ 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); COMMENT ON COLUMN paliad.users.profession IS 'Firm-wide career tier driving the t-paliad-138 approval ladder. ' 'NULL = no firm tier (external collaborators, admin accounts). ' 'Distinct from job_title (free-text display) and global_role (tool admin gate).'; -- ============================================================================ -- 2. paliad.project_teams.responsibility — per-project responsibility. -- -- Replaces the project-axis values that were mixed into project_teams.role. -- Default 'member'. 'lead' has additional manage-project privileges (already -- wired in derivation_service.go). 'observer' and 'external' close the -- approval gate (level 0 regardless of profession). -- -- The legacy `role` column is kept on the table as a deprecated shadow for -- one release. New code reads .responsibility; old code paths that still -- read .role continue working until the follow-up migration drops the -- column. -- ============================================================================ ALTER TABLE paliad.project_teams ADD COLUMN responsibility text NOT NULL DEFAULT 'member' CHECK (responsibility IN ('lead', 'member', 'observer', 'external')); CREATE INDEX project_teams_responsibility_idx ON paliad.project_teams (project_id, responsibility); COMMENT ON COLUMN paliad.project_teams.responsibility IS 'Per-project responsibility on this matter. lead/member open the ' 'approval gate; observer/external close it. Profession provides the ' 'level (paliad.users.profession).'; COMMENT ON COLUMN paliad.project_teams.role IS 'DEPRECATED — split into users.profession + project_teams.responsibility ' 'in migration 057 (t-paliad-148). Kept as a shadow column for one release. ' 'Drop in follow-up migration 058.'; -- ============================================================================ -- 3. Backfill paliad.users.profession from highest legacy tier per user. -- -- Mapping (legacy role → profession): -- lead → partner -- of_counsel → of_counsel -- associate → associate -- senior_pa → senior_pa -- pa → pa -- local_counsel/expert/observer → IGNORED (no firm tier inferable) -- -- For each user with at least one project_teams row carrying a firm-tier -- value, take the HIGHEST tier (per the t-138 ladder). Ties at same tier -- collapse trivially (same value). Users with only project-only labels -- (observer / local_counsel / expert) get profession=NULL — admin will -- need to fill them in via /admin/team. -- ============================================================================ WITH legacy_to_profession AS ( SELECT pt.user_id, CASE pt.role WHEN 'lead' THEN 'partner' WHEN 'of_counsel' THEN 'of_counsel' WHEN 'associate' THEN 'associate' WHEN 'senior_pa' THEN 'senior_pa' WHEN 'pa' THEN 'pa' -- observer / local_counsel / expert → NULL (filtered below) END AS profession, CASE pt.role WHEN 'lead' THEN 5 WHEN 'of_counsel' THEN 4 WHEN 'associate' THEN 3 WHEN 'senior_pa' THEN 2 WHEN 'pa' THEN 1 ELSE 0 END AS lvl FROM paliad.project_teams pt ), ranked AS ( SELECT user_id, profession, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY lvl DESC) AS rn FROM legacy_to_profession WHERE profession IS NOT NULL ) UPDATE paliad.users u SET profession = r.profession FROM ranked r WHERE u.id = r.user_id AND r.rn = 1 AND u.profession IS NULL; -- ============================================================================ -- 4. Backfill paliad.project_teams.responsibility from legacy role. -- -- Per-row mapping: -- lead → lead -- observer → observer -- local_counsel → external -- expert → external -- associate / pa / of_counsel / senior_pa → member -- -- Authority for "member" rows now comes from the user's profession. -- ============================================================================ UPDATE paliad.project_teams SET responsibility = CASE role WHEN 'lead' THEN 'lead' WHEN 'observer' THEN 'observer' WHEN 'local_counsel' THEN 'external' WHEN 'expert' THEN 'external' ELSE 'member' END; -- ============================================================================ -- 5. paliad.approval_policies.required_role — drop 'lead', add 'partner'. -- -- Legacy 'lead' was the project-level value at the ladder ceiling; under the -- new model the ceiling is profession='partner'. Backfill any existing -- policy rows from 'lead' to 'partner', then tighten the CHECK. -- ============================================================================ UPDATE paliad.approval_policies SET required_role = 'partner' WHERE required_role = 'lead'; ALTER TABLE paliad.approval_policies DROP CONSTRAINT IF EXISTS approval_policies_required_role_check; ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_required_role_check CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa')); -- ============================================================================ -- 6. paliad.approval_requests.required_role — same rename for snapshots. -- -- Each request snapshots the policy's required_role at submission time so -- mid-flight policy edits don't change the bar. Backfill 'lead' → 'partner' -- for parity with the new policy enum, then tighten the CHECK. -- ============================================================================ UPDATE paliad.approval_requests SET required_role = 'partner' WHERE required_role = 'lead'; ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_required_role_check; ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_required_role_check CHECK (required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa')); -- ============================================================================ -- 7. paliad.approval_role_from_unit_role — bridge maps lead → partner now. -- -- Derived authority via partner-unit attachments (t-paliad-139) bridges -- unit_role to the project-tier ladder. Under the new ladder, the highest -- tier is 'partner' (was 'lead'). Update the lead → lead row to lead → -- partner; the rest of the bridge mapping stays unchanged. -- ============================================================================ CREATE OR REPLACE FUNCTION paliad.approval_role_from_unit_role(unit_role text) RETURNS text LANGUAGE SQL IMMUTABLE AS $$ SELECT CASE unit_role WHEN 'lead' THEN 'partner' WHEN 'attorney' THEN 'associate' WHEN 'senior_pa' THEN 'senior_pa' WHEN 'pa' THEN 'pa' ELSE 'observer' END $$; -- Update the level helper too: 'partner' replaces 'lead' as the ceiling -- value that the function recognises. The numeric ladder is identical; -- only the named tier shifts. CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text) RETURNS int LANGUAGE SQL IMMUTABLE AS $$ SELECT CASE role WHEN 'partner' THEN 5 WHEN 'of_counsel' THEN 4 WHEN 'associate' THEN 3 WHEN 'senior_pa' THEN 2 WHEN 'pa' THEN 1 WHEN 'paralegal' THEN 0 -- Legacy 'lead' kept at level 5 for the deprecated-shadow window: -- old call sites that still read pt.role would otherwise return -- level 0 and break authority for projects where the migration -- has run but the Go redirect hasn't. Removed in migration 058. WHEN 'lead' THEN 5 ELSE 0 END $$; COMMENT ON FUNCTION paliad.approval_role_level(text) IS 'Strict-ladder level for the t-paliad-138 / t-paliad-148 approval gate. ' 'Reads paliad.users.profession; legacy project_teams.role values still ' 'recognised via the lead→5 shadow row until migration 058 retires the ' 'column. Higher level always satisfies lower; level 0 = ineligible.'; -- ============================================================================ -- 8. paliad.user_project_authority_level — tuple-with-gate ladder. -- -- effective_level for user U on project P: -- -- profession_level = approval_role_level(U.profession) -- 0 if NULL -- responsibility = direct or ancestor on project P -- gate_open = responsibility IN ('lead', 'member') -- derived_role = approval_role_from_unit_role(unit_role) -- when project_partner_units.derive_grants_authority -- effective_level = MAX over sources, gated as above -- -- Direct/ancestor responsibility opens the gate, profession provides the -- level. Derivation is its own source — derived authority always opens -- its own gate (the unit attachment's grants_authority flag is the gate). -- A user can hit this function via direct membership AND derivation; the -- result is the max of both sources. -- ============================================================================ CREATE OR REPLACE FUNCTION paliad.user_project_authority_level( _user_id uuid, _project_id uuid ) RETURNS int LANGUAGE sql STABLE AS $$ WITH path AS ( SELECT string_to_array(p.path, '.')::uuid[] AS ids FROM paliad.projects p WHERE p.id = _project_id ), direct_or_ancestor AS ( SELECT pt.responsibility FROM paliad.project_teams pt JOIN path ON pt.project_id = ANY(path.ids) WHERE pt.user_id = _user_id ), profession_level AS ( SELECT paliad.approval_role_level(u.profession) AS lvl FROM paliad.users u WHERE u.id = _user_id ), direct_level AS ( -- Profession-level if any membership row opens the gate, else 0. SELECT CASE WHEN EXISTS ( SELECT 1 FROM direct_or_ancestor doa WHERE doa.responsibility IN ('lead', 'member') ) THEN COALESCE((SELECT lvl FROM profession_level), 0) ELSE 0 END AS lvl ), derived_level AS ( SELECT COALESCE(MAX(paliad.approval_role_level( paliad.approval_role_from_unit_role(pum.unit_role) )), 0) AS lvl FROM paliad.project_partner_units ppu JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id AND pum.user_id = _user_id AND pum.unit_role = ANY(ppu.derive_unit_roles) JOIN path ON ppu.project_id = ANY(path.ids) WHERE ppu.derive_grants_authority = true ) SELECT GREATEST( (SELECT lvl FROM direct_level), (SELECT lvl FROM derived_level) ); $$; COMMENT ON FUNCTION paliad.user_project_authority_level(uuid, uuid) IS 'Effective approval-ladder level for user U on project P, evaluated as ' 'a tuple-with-gate: profession_level if responsibility ∈ {lead,member} ' 'else 0; max with derived authority (partner-unit attachment with ' 'grants_authority=true). t-paliad-148.'; -- ============================================================================ -- 9. paliad.project_partner_units RLS — switch lead-gate to .responsibility. -- -- Migration 055 wrote two policies that gate writes on pt.role = 'lead'. -- Under the new model, lead is a project responsibility, not a profession. -- Drop and rewrite both policies to read .responsibility. -- ============================================================================ DROP POLICY IF EXISTS project_partner_units_write ON paliad.project_partner_units; CREATE POLICY project_partner_units_write ON paliad.project_partner_units FOR ALL USING ( EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin') OR EXISTS (SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = auth.uid() AND pt.project_id = project_partner_units.project_id AND pt.responsibility = 'lead') ) WITH CHECK ( EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin') OR EXISTS (SELECT 1 FROM paliad.project_teams pt WHERE pt.user_id = auth.uid() AND pt.project_id = project_partner_units.project_id AND pt.responsibility = 'lead') );