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:
@@ -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);
|
||||
214
internal/db/migrations/062_approval_policy_unit_defaults.up.sql
Normal file
214
internal/db/migrations/062_approval_policy_unit_defaults.up.sql
Normal 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;
|
||||
Reference in New Issue
Block a user