-- t-paliad-160 (M1, slice 1): split approval_policies.required_role into -- two columns — requires_approval (the gate) + min_role (the seniority -- threshold). The legacy required_role='none' sentinel conflated two -- concepts: "approval applies at all" vs "who can approve". This -- migration introduces the split and backfills. -- -- M1 = additive + dual-read. New code paths read both old and new columns -- so a rollback to pre-deploy code keeps working. M2 (follow-up -- migration) will drop required_role once everything writes the new -- shape exclusively. -- -- Resolver semantics also change with this split: when both a project- -- level row and a partner-unit-default row resolve for the same -- (entity_type, lifecycle_event), most-strict-wins now applies on BOTH -- axes: -- - requires_approval: OR (true if either side says true). -- - min_role: MAX along approval_role_level(). -- That update lives in the paliad.approval_policy_effective() rewrite -- in §4 below. -- -- Sections: -- 1. ALTER paliad.approval_policies ADD COLUMN requires_approval + min_role. -- 2. Backfill: required_role='none' → (false, NULL); else → (true, role). -- 3. Constraint: (requires_approval=false) OR (min_role IS NOT NULL). -- 4. Replace paliad.approval_policy_effective() with most-strict-wins -- across the new columns. Returns (requires_approval, min_role, -- source, source_id) — back-compat shim required_role column kept -- in result type so callers reading the old column don't break -- until they cut over. -- ============================================================================ -- 1. New columns. Both nullable in the schema; the constraint in §3 -- enforces the relationship instead of a NOT NULL on requires_approval -- (we want Postgres to keep the row out cleanly when min_role is NULL -- and requires_approval = false). -- ============================================================================ ALTER TABLE paliad.approval_policies ADD COLUMN requires_approval boolean, ADD COLUMN min_role text; -- ============================================================================ -- 2. Backfill from required_role. -- 'none' → (false, NULL) -- else (any of partner/of_counsel/associate/senior_pa/pa) → (true, role) -- ============================================================================ UPDATE paliad.approval_policies SET requires_approval = false, min_role = NULL WHERE required_role = 'none'; UPDATE paliad.approval_policies SET requires_approval = true, min_role = required_role WHERE required_role <> 'none'; -- After backfill every row has a non-NULL requires_approval. Tighten. ALTER TABLE paliad.approval_policies ALTER COLUMN requires_approval SET NOT NULL; -- ============================================================================ -- 3. The split-grammar invariant: a row that demands approval must name -- a min_role; a row that does not demand approval has min_role NULL. -- ============================================================================ ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_min_role_xor_required CHECK ( (requires_approval = false AND min_role IS NULL) OR (requires_approval = true AND min_role IS NOT NULL) ); -- min_role values mirror the approval ladder. NULL is allowed (the -- requires_approval=false branch); any other value must be on the ladder. ALTER TABLE paliad.approval_policies ADD CONSTRAINT approval_policies_min_role_check CHECK ( min_role IS NULL OR min_role IN ( 'partner', 'of_counsel', 'associate', 'senior_pa', 'pa' ) ); -- ============================================================================ -- 4. paliad.approval_policy_effective — most-strict-wins resolver. -- -- Returns at most one row, or zero rows when no policy applies. The result -- shape adds two columns (requires_approval, min_role) while keeping the -- legacy required_role column for back-compat dual-read. Old callers that -- still read required_role keep working; new callers branch on -- requires_approval/min_role. -- -- Resolution: -- Step 1 — collect candidates for (project, entity, lifecycle): -- a) project-specific row (project_id = p_project_id). -- b) ancestor rows on the project's ltree path (excluding self). -- c) unit-default rows for partner units attached to this project. -- Step 2 — most-strict-wins over the union: -- requires_approval := bool_or(c.requires_approval) -- true if any says true -- min_role := role with MAX(approval_role_level) among the -- candidates whose requires_approval=true. -- NULL if no candidate demands approval. -- Step 3 — the project-specific row no longer wins outright. The 'none' -- sentinel is gone; suppression is now expressed as an explicit -- requires_approval=false at project level, which loses to any -- ancestor / unit_default with requires_approval=true under -- most-strict-wins. This is intentional: the user-locked semantics -- is "tighten only, never loosen by inheritance" and the project -- row that wants to relax inherited rules has to be authored at the -- ancestor / unit level instead. (See t-paliad-160 §A resolver lock.) -- -- Returned columns: -- requires_approval boolean — the gate -- min_role text — the threshold (NULL when gate is off) -- required_role text — back-compat: NULL when gate is off, -- else equals min_role. Old callers that -- read required_role keep working until -- M2 drops the column. -- source text — 'project' | 'ancestor' | 'unit_default' -- (the source of the WINNING min_role; for -- a pure requires_approval=false result, -- the source of the highest-priority -- 'false' row in the order project > -- ancestor > unit_default). -- source_id uuid — project_id or partner_unit_id of source. -- ============================================================================ DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text); CREATE FUNCTION paliad.approval_policy_effective( p_project_id uuid, p_entity_type text, p_lifecycle text ) RETURNS TABLE ( requires_approval boolean, min_role text, required_role text, source text, source_id uuid ) LANGUAGE plpgsql STABLE AS $$ BEGIN RETURN QUERY WITH path AS ( SELECT string_to_array(p.path, '.')::uuid[] AS ids FROM paliad.projects p WHERE p.id = p_project_id ), project_rows AS ( SELECT ap.requires_approval, ap.min_role, 'project'::text AS src, ap.project_id AS sid, 1 AS src_priority FROM paliad.approval_policies ap WHERE ap.project_id = p_project_id AND ap.entity_type = p_entity_type AND ap.lifecycle_event = p_lifecycle ), ancestor_rows AS ( SELECT ap.requires_approval, ap.min_role, 'ancestor'::text AS src, ap.project_id AS sid, 2 AS src_priority FROM paliad.approval_policies ap, path WHERE ap.project_id = ANY(path.ids) AND ap.project_id <> p_project_id AND ap.entity_type = p_entity_type AND ap.lifecycle_event = p_lifecycle ), unit_rows AS ( SELECT ap.requires_approval, ap.min_role, 'unit_default'::text AS src, ap.partner_unit_id AS sid, 3 AS src_priority FROM paliad.approval_policies ap JOIN paliad.project_partner_units ppu ON ppu.partner_unit_id = ap.partner_unit_id WHERE ppu.project_id = p_project_id AND ap.entity_type = p_entity_type AND ap.lifecycle_event = p_lifecycle ), candidates AS ( SELECT * FROM project_rows UNION ALL SELECT * FROM ancestor_rows UNION ALL SELECT * FROM unit_rows ), -- Pick the strictest min_role: highest approval_role_level among the -- requires_approval=true candidates. Tie-break: project > ancestor > -- unit_default for stable attribution. strictest_role AS ( SELECT c.min_role, c.src AS source, c.sid AS source_id FROM candidates c WHERE c.requires_approval = true AND c.min_role IS NOT NULL ORDER BY paliad.approval_role_level(c.min_role) DESC, c.src_priority ASC LIMIT 1 ), -- If nothing demands approval, surface the project row's "no approval" -- if present, else any (false) row with stable tie-break, so attribution -- still works for the UI ("inherited from "). no_approval_attribution AS ( SELECT c.src AS source, c.sid AS source_id FROM candidates c WHERE c.requires_approval = false ORDER BY c.src_priority ASC LIMIT 1 ), summary AS ( SELECT bool_or(c.requires_approval) AS req FROM candidates c ) SELECT COALESCE(s.req, false) AS requires_approval, sr.min_role AS min_role, sr.min_role AS required_role, COALESCE(sr.source, na.source) AS source, COALESCE(sr.source_id, na.source_id) AS source_id FROM summary s LEFT JOIN strictest_role sr ON true LEFT JOIN no_approval_attribution na ON true WHERE EXISTS (SELECT 1 FROM candidates); -- zero rows when no policy applies END; $$; COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS 'Effective approval policy resolver (t-paliad-160 most-strict-wins). ' 'Returns requires_approval (OR across candidates), min_role (MAX along ' 'the role ladder among requires_approval=true candidates), and the ' 'source attribution. required_role mirrors min_role for back-compat ' 'dual-read with code that hasn''t cut over yet. Zero rows when no ' 'policy candidates exist for the (project, entity_type, lifecycle).';