Files
paliad/internal/db/migrations/066_approval_policy_split.up.sql

238 lines
10 KiB
PL/PgSQL

-- 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 <unit>").
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).';