-- t-paliad-139: hierarchy aggregation — partner-unit derivation schema. -- -- Design: docs/design-hierarchy-aggregation-2026-05-06.md (noether, m-locked 2026-05-06). -- -- This is the Phase 2 schema migration. Day-1 deploy = zero behaviour change -- because: -- - Every existing partner_unit_members row defaults to unit_role='attorney'. -- - The default derive_unit_roles on the new junction is {'pa','senior_pa'}. -- - No project_partner_units rows exist yet; admins opt-in by attaching -- units to projects. -- Until those two conditions diverge, no derivation happens and visibility -- behaves identically to the pre-055 world. -- -- Sections: -- 1. ALTER paliad.partner_unit_members ADD COLUMN unit_role. -- 2. CREATE paliad.project_partner_units junction (with RLS). -- 3. CREATE paliad.approval_role_from_unit_role helper. -- 4. CREATE OR REPLACE paliad.can_see_project — extended with derivation -- branch. -- ============================================================================ -- 1. unit_role on paliad.partner_unit_members. -- -- Per-unit role distinction so derivation can target specific tiers (default -- {pa, senior_pa}) without re-introducing a firm-wide rank column. The same -- user can have a different unit_role in different units; in practice most -- users belong to one unit so this is effectively a firm-rank, but the per- -- unit framing preserves the t-paliad-051/-138 three-axis principle on the -- user side (job_title remains free-text display, global_role stays -- standard|global_admin). -- ============================================================================ ALTER TABLE paliad.partner_unit_members ADD COLUMN unit_role text NOT NULL DEFAULT 'attorney' CHECK (unit_role IN ('lead', 'attorney', 'senior_pa', 'pa', 'paralegal')); -- ============================================================================ -- 2. paliad.project_partner_units — project ↔ unit involvement. -- -- A row here means "this unit is involved on this project, and the listed -- unit_roles auto-derive onto the project team". Authority defaults to off -- (visibility-only): set derive_grants_authority=true to let derived members -- count as approvers (per t-paliad-139 §3.4). Composite PK enforces "one -- attachment per (project, unit)". -- ============================================================================ CREATE TABLE paliad.project_partner_units ( project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE, partner_unit_id uuid NOT NULL REFERENCES paliad.partner_units(id) ON DELETE CASCADE, -- Roles in the unit that auto-derive onto the project team. Defaults -- target PAs only; a project can widen to ['pa','senior_pa','attorney'] -- to pull the whole unit, or narrow to ['pa'] to exclude senior_pa. derive_unit_roles text[] NOT NULL DEFAULT ARRAY['pa', 'senior_pa'], -- Strict default: derived members are visibility-only. Flipping this on -- lets them be eligible approvers per the t-138 ladder via the mapping -- in paliad.approval_role_from_unit_role. derive_grants_authority boolean NOT NULL DEFAULT false, attached_at timestamptz NOT NULL DEFAULT now(), attached_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL, PRIMARY KEY (project_id, partner_unit_id) ); CREATE INDEX project_partner_units_unit_idx ON paliad.project_partner_units (partner_unit_id, project_id); ALTER TABLE paliad.project_partner_units ENABLE ROW LEVEL SECURITY; -- Anyone who can see the project can see the unit attachment. Mirrors the -- approval_requests / deadlines / appointments policy. CREATE POLICY project_partner_units_select ON paliad.project_partner_units FOR SELECT USING (paliad.can_see_project(project_id)); -- Writes gated to global_admin OR project lead. Same pattern as -- /admin/team and /admin/partner-units precedent. 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.role = '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.role = 'lead') ); -- ============================================================================ -- 3. paliad.approval_role_from_unit_role — unit_role → project_role mapping. -- -- Used when a derived member's authority is evaluated by the t-138 strict -- ladder. The mapping is intentional: -- lead → lead (the unit's lead, matches project lead tier) -- attorney → associate (default for working lawyers) -- senior_pa → senior_pa (1:1) -- pa → pa (1:1) -- paralegal → observer (level 0 — ineligible to approve) -- The ApprovalService (t-138) reads project_teams.role first; only when that -- has no row does it fall back to derived authority via this mapping (and -- only when the project_partner_units row has derive_grants_authority=true). -- ============================================================================ 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 'lead' WHEN 'attorney' THEN 'associate' WHEN 'senior_pa' THEN 'senior_pa' WHEN 'pa' THEN 'pa' ELSE 'observer' END $$; -- ============================================================================ -- 4. Extend paliad.approval_requests.decision_kind CHECK to allow -- 'derived_peer' — a derived (partner-unit) member with authority who -- signed off via the t-paliad-138 inbox path. Distinct from plain -- 'peer' so the audit trail discloses the derivation chain. -- ============================================================================ ALTER TABLE paliad.approval_requests DROP CONSTRAINT IF EXISTS approval_requests_decision_kind_check; ALTER TABLE paliad.approval_requests ADD CONSTRAINT approval_requests_decision_kind_check CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override', 'derived_peer')); -- ============================================================================ -- 5. paliad.can_see_project — extended with derivation branch. -- -- Same shape as the migration-023 body, plus one EXISTS branch: a user is -- visible on a project if there is any (ancestor of project) attached to a -- partner_unit they are a member of, AND their unit_role is in the derive -- set for that attachment. Read-cost is small (project_partner_units + -- partner_unit_members are tiny). -- -- t-paliad-139 §3.3 Option B: compute on read, no materialised state. -- ============================================================================ CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = paliad, public AS $$ SELECT EXISTS ( SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin' ) OR EXISTS ( SELECT 1 FROM paliad.projects target JOIN paliad.project_teams pt ON pt.user_id = auth.uid() AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[]) WHERE target.id = _project_id ) OR EXISTS ( SELECT 1 FROM paliad.projects target JOIN paliad.project_partner_units ppu ON ppu.project_id = ANY(string_to_array(target.path, '.')::uuid[]) JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id AND pum.user_id = auth.uid() AND pum.unit_role = ANY(ppu.derive_unit_roles) WHERE target.id = _project_id ); $$;