feat(t-paliad-154) commit 1/5: migration 062 — approval_policies unit-defaults + 'none' sentinel + resolver + seed

Schema:
- ALTER paliad.approval_policies: project_id nullable, ADD partner_unit_id
  uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE.
- XOR check: exactly one of (project_id, partner_unit_id) is set.
- Replace UNIQUE composite with two partial unique indexes (one per scope).
- Extend required_role CHECK with 'none' sentinel.
- approval_role_level('none') already returns 0 via existing ELSE branch
  in 059_profession_vs_responsibility.up.sql:218 — no function update.

Resolver paliad.approval_policy_effective(project, entity_type, lifecycle):
- Step 1: project-specific row wins outright (any value, including 'none').
- Step 2: MAX(approval_role_level) across ancestor rows on project's path
  + unit-default rows for partner units attached to project. Tied levels
  break alphabetically ('ancestor' beats 'unit_default') for stable
  attribution.
- Step 3: zero rows (no candidates) — caller treats as 'no policy applies'.

Returns (required_role, source, source_id) — source ∈ {project, ancestor,
unit_default}; source_id is project_id or partner_unit_id depending.

Seed:
- 8 rows × every existing partner_unit (currently 11): deadline+appointment
  × create/update/delete = associate; complete = none.
- ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
  WHERE partner_unit_id IS NOT NULL DO NOTHING — idempotent on re-run
  (verified live: 11 units → 88 seed rows, second run is no-op).
- Safe on a DB with 0 partner_units (SELECT returns no rows).

Down migration: reverse-order. Coerces 'none' rows to 'associate' before
restoring CHECK so rollback works without data loss. Drops seeded unit
rows; preserves project rows that pre-date 062.

Validated end-to-end against the live DB inside BEGIN ... ROLLBACK; the
existing project policy (deadline:create=partner) is preserved by the
DO NOTHING clause and the partial-index scope.

Design: docs/design-approval-policy-ui-2026-05-07.md §3.1.

No RAISE EXCEPTION. No bare CSS tokens (no CSS in this commit).
This commit is contained in:
m
2026-05-08 02:11:23 +02:00
parent 01fa4b1287
commit f7908f03ad
2 changed files with 269 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
-- t-paliad-154 down migration. Reverses 062_approval_policy_unit_defaults.up.sql.
--
-- Order is the reverse of up:
-- 1. Drop seeded unit-default rows (anything where partner_unit_id IS NOT NULL).
-- 2. Drop the resolver function.
-- 3. Restore required_role CHECK without 'none'.
-- 4. Drop the two partial unique indexes + restore the original UNIQUE composite.
-- 5. Drop XOR check + partner_unit_id column.
-- 6. Restore project_id NOT NULL.
--
-- Best-effort reversibility: any project-specific row with required_role='none'
-- will fail the CHECK restoration. We coerce those to 'associate' before
-- restoring the CHECK so the migration can roll back without data loss.
-- 1. Drop unit-default rows. Keep project rows intact (they pre-date 062).
DELETE FROM paliad.approval_policies WHERE partner_unit_id IS NOT NULL;
-- 2. Drop resolver.
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
-- 3. Coerce 'none' rows to 'associate' so the restored CHECK passes.
UPDATE paliad.approval_policies
SET required_role = 'associate'
WHERE required_role = 'none';
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'
));
-- 4. Drop partial unique indexes; restore composite UNIQUE on project rows
-- (down migration leaves the column NOT NULL so the unique-on-project_id
-- composite is sound again).
DROP INDEX IF EXISTS paliad.approval_policies_project_unique;
DROP INDEX IF EXISTS paliad.approval_policies_unit_unique;
DROP INDEX IF EXISTS paliad.approval_policies_unit_idx;
-- 5. Drop XOR check + partner_unit_id column.
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_scope_xor;
ALTER TABLE paliad.approval_policies
DROP COLUMN IF EXISTS partner_unit_id;
-- 6. Restore NOT NULL on project_id (no rows should be NULL by now since
-- step 1 deleted every unit-default row).
ALTER TABLE paliad.approval_policies
ALTER COLUMN project_id SET NOT NULL;
-- Restore composite UNIQUE constraint name to match migration 054.
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_project_id_entity_type_lifecycle_event_key
UNIQUE (project_id, entity_type, lifecycle_event);

View File

@@ -0,0 +1,214 @@
-- t-paliad-154: approval-policy authoring UI substrate.
--
-- Design: docs/design-approval-policy-ui-2026-05-07.md (hilbert, m-locked
-- 2026-05-07). Surfaces the dormant t-138 4-eye system by adding:
--
-- 1. partner_unit_id column on paliad.approval_policies — XOR with project_id
-- so a row applies to either one project or one partner unit (firm-wide
-- default for projects attached to that unit). Existing rows
-- (project_id IS NOT NULL) keep their meaning unchanged.
-- 2. 'none' sentinel value for required_role — explicit "no approval needed"
-- override at project-row level. Suppresses inherited defaults.
-- 3. paliad.approval_policy_effective() resolver — most-restrictive across
-- project row | ancestor rows | unit defaults. Project-specific row
-- wins outright (any value including 'none'). Otherwise MAX(level) across
-- ancestor + unit_default candidates.
-- 4. Conservative seed defaults for every existing partner_unit:
-- deadline+appointment × create/update/delete=associate, complete=none.
--
-- Idempotent on re-run (ON CONFLICT DO NOTHING on the seed). Safe on a DB
-- with 0 partner_units (the seed simply inserts zero rows).
--
-- Sections:
-- 1. Make project_id nullable + ADD partner_unit_id + XOR check.
-- 2. Replace UNIQUE composite with two partial unique indexes.
-- 3. Extend required_role CHECK with 'none' sentinel.
-- 4. CREATE FUNCTION paliad.approval_policy_effective(uuid, text, text).
-- 5. Seed conservative defaults for every existing partner_unit.
-- ============================================================================
-- 1. project_id becomes nullable; add partner_unit_id; XOR check.
-- ============================================================================
ALTER TABLE paliad.approval_policies
ALTER COLUMN project_id DROP NOT NULL;
ALTER TABLE paliad.approval_policies
ADD COLUMN partner_unit_id uuid
REFERENCES paliad.partner_units(id) ON DELETE CASCADE;
-- Exactly one of (project_id, partner_unit_id) must be set. NEVER both NULL,
-- NEVER both set. Defence against orphaned rows.
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_scope_xor CHECK (
(project_id IS NOT NULL AND partner_unit_id IS NULL) OR
(project_id IS NULL AND partner_unit_id IS NOT NULL)
);
-- ============================================================================
-- 2. Replace UNIQUE (project_id, ...) with two partial unique indexes.
--
-- The original UNIQUE composite assumed project_id NOT NULL. Now that
-- project_id is nullable, we split: one partial unique index per scope
-- (project rows | unit rows). Cells stay 1:1 within their scope.
-- ============================================================================
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_project_id_entity_type_lifecycle_event_key;
CREATE UNIQUE INDEX approval_policies_project_unique
ON paliad.approval_policies (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL;
CREATE UNIQUE INDEX approval_policies_unit_unique
ON paliad.approval_policies (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL;
CREATE INDEX approval_policies_unit_idx
ON paliad.approval_policies (partner_unit_id)
WHERE partner_unit_id IS NOT NULL;
-- ============================================================================
-- 3. 'none' sentinel value for required_role.
--
-- Project row with required_role='none' suppresses inherited defaults
-- explicitly (caller-side: LookupPolicy returns nil when the resolver
-- yields 'none'). Unit-default rows can also carry 'none' but are
-- structurally invisible to the MAX(level) computation since
-- approval_role_level('none')=0 (loses to any non-none).
--
-- approval_role_level('none') already returns 0 via the existing ELSE
-- branch in migration 059 §7. No function update needed.
-- ============================================================================
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', 'none'
));
-- ============================================================================
-- 4. paliad.approval_policy_effective(project, entity_type, lifecycle).
--
-- Returns at most one row, or zero rows when no policy applies.
--
-- Resolution order:
-- Step 1: if a project-specific row exists for (project, entity, lifecycle),
-- return it outright. Any value (including 'none') wins.
-- Step 2: else MAX(approval_role_level) across:
-- - ancestor project rows on this project's ltree path
-- - unit-default rows for partner units attached to this project
-- Tied levels: 'ancestor' beats 'unit_default' alphabetically for
-- stable attribution.
-- Step 3: else (no candidates) — return zero rows. Caller treats as
-- "no policy applies".
--
-- Returned columns:
-- required_role text — one of partner|of_counsel|associate|senior_pa|pa|none
-- source text — 'project' | 'ancestor' | 'unit_default'
-- source_id uuid — project_id for project/ancestor, partner_unit_id for unit_default
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
p_project_id uuid,
p_entity_type text,
p_lifecycle text
) RETURNS TABLE (
required_role text,
source text,
source_id uuid
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
-- Step 1: project-specific row wins outright.
RETURN QUERY
SELECT ap.required_role, 'project'::text AS source, ap.project_id AS source_id
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;
IF FOUND THEN
RETURN;
END IF;
-- Step 2: MAX(level) across ancestor rows + unit defaults attached to project.
RETURN QUERY
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = p_project_id
),
ancestor_rows AS (
SELECT ap.required_role,
'ancestor'::text AS src,
ap.project_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
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.required_role,
'unit_default'::text AS src,
ap.partner_unit_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
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
)
SELECT a.required_role, a.src, a.sid
FROM (
SELECT * FROM ancestor_rows
UNION ALL
SELECT * FROM unit_rows
) AS a
ORDER BY a.lvl DESC, a.src ASC
LIMIT 1;
END;
$$;
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
'Effective approval policy resolver (t-paliad-154). '
'Project-specific row wins outright; else MAX(level) across ancestor '
'rows on the project path and unit-default rows for attached partner '
'units; else no row. Caller treats 0 rows or required_role=''none'' as '
'"no policy applies".';
-- ============================================================================
-- 5. Seed conservative defaults for every existing partner_unit.
--
-- 8 rows per unit: deadline+appointment × create/update/delete=associate,
-- complete=none. Marking-as-done is low-risk; planning operations need 4-eye.
--
-- ON CONFLICT DO NOTHING on (partner_unit_id, entity_type, lifecycle_event)
-- via the partial unique index so re-running the migration (or topping up
-- after a partner_unit is added) is a no-op for existing rows.
--
-- Safe on a DB with 0 partner_units — the SELECT returns no rows.
-- ============================================================================
INSERT INTO paliad.approval_policies (
project_id, partner_unit_id, entity_type, lifecycle_event, required_role
)
SELECT NULL, pu.id, t.entity_type, t.lifecycle_event, t.required_role
FROM paliad.partner_units pu
CROSS JOIN (
VALUES
('deadline', 'create', 'associate'),
('deadline', 'update', 'associate'),
('deadline', 'delete', 'associate'),
('deadline', 'complete', 'none'),
('appointment', 'create', 'associate'),
('appointment', 'update', 'associate'),
('appointment', 'delete', 'associate'),
('appointment', 'complete', 'none')
) AS t(entity_type, lifecycle_event, required_role)
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL
DO NOTHING;