238 lines
10 KiB
PL/PgSQL
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).';
|