feat(t-paliad-138): migration 054 — dual-control approvals schema

Schema-only commit (1 of 8) for the 4-Augen-Prüfung workflow per
docs/design-approvals-2026-05-06.md. No Go code reads these yet —
paliad behaves identically until commit 2 wires ApprovalService into
the mutation paths.

Migration 054 adds:

1. `senior_pa` to paliad.project_teams.role CHECK. Drops both the
   English `project_teams_role_check` and the German-legacy
   `projekt_teams_role_check` (live-DB constraint name carried over
   from migration 018's pre-rename era).

2. `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
   ladder helper: lead(5) > of_counsel(4) > associate(3) > senior_pa(2)
   > pa(1) > [local_counsel/expert/observer = 0 = ineligible]. Mirrors
   the upcoming Go `levelOf()`.

3. `paliad.approval_policies` (project_id, entity_type, lifecycle_event,
   required_role) — UNIQUE composite key gives at most 8 rows per
   project. RLS: SELECT via can_see_project; INSERT/UPDATE/DELETE only
   for global_admin (defence-in-depth; service-role pool bypasses RLS,
   so the actual gate is service-layer).

4. `paliad.approval_requests` — operational pending workflow.
   pre_image jsonb captures revert state; payload echoes the diff;
   required_role snapshots the policy at request time. CHECK
   `decided_by != requested_by` is the second layer of self-approval
   block. RLS = same can_see_project predicate as deadlines /
   appointments — anyone with project visibility sees pending requests.

5. `approval_status` (default 'approved'), `pending_request_id`,
   `approved_by`, `approved_at` columns on both deadlines and
   appointments. `appointments.completed_at` (new) lands the
   appointment:complete lifecycle event.

6. Backfill: every existing deadline + appointment row marked
   approval_status='legacy'. Per Q11, no retroactive approval; the
   next mutation on a legacy row that hits an active policy follows
   the normal flow.

Live-DB dry-run verified end-to-end: 20 deadlines + 5 appointments
backfill, both new tables instantiate cleanly, helper function returns
correct levels, self-approval CHECK fires on invalid INSERT, valid
pending insert succeeds.
This commit is contained in:
m
2026-05-06 15:13:26 +02:00
parent 7d1ddb9b84
commit b3401ec8ac
2 changed files with 279 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
-- t-paliad-138: rollback dual-control approvals.
--
-- Reverses 054_approvals.up.sql:
-- 1. Drop appointment + deadline approval columns.
-- 2. Drop paliad.approval_requests.
-- 3. Drop paliad.approval_policies.
-- 4. Drop paliad.approval_role_level().
-- 5. Restore project_teams.role CHECK without 'senior_pa'.
--
-- Step 5 will fail loudly if any user has been re-roled to 'senior_pa' —
-- intentional, mirrors the t-paliad-051 down strategy. Operator must
-- migrate those rows to another role before rolling back.
ALTER TABLE paliad.appointments
DROP COLUMN IF EXISTS completed_at,
DROP COLUMN IF EXISTS approved_at,
DROP COLUMN IF EXISTS approved_by,
DROP COLUMN IF EXISTS pending_request_id,
DROP COLUMN IF EXISTS approval_status;
ALTER TABLE paliad.deadlines
DROP COLUMN IF EXISTS approved_at,
DROP COLUMN IF EXISTS approved_by,
DROP COLUMN IF EXISTS pending_request_id,
DROP COLUMN IF EXISTS approval_status;
DROP INDEX IF EXISTS paliad.deadlines_approval_status_pending_idx;
DROP INDEX IF EXISTS paliad.appointments_approval_status_pending_idx;
DROP TABLE IF EXISTS paliad.approval_requests;
DROP TABLE IF EXISTS paliad.approval_policies;
DROP FUNCTION IF EXISTS paliad.approval_role_level(text);
-- Drop by both English and the German-legacy name (see up migration §1).
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS projekt_teams_role_check;
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
CHECK (role IN (
'lead', 'associate', 'pa', 'of_counsel',
'local_counsel', 'expert', 'observer'
));

View File

@@ -0,0 +1,237 @@
-- t-paliad-138: dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments.
--
-- Design: docs/design-approvals-2026-05-06.md (cronus, m-locked 2026-05-06).
--
-- Schema-only migration (commit 1 of 8). Adds the operational tables, the
-- strict-ladder helper, and the per-entity tracking columns. No Go code
-- reads these yet — paliad behaves identically until commit 2 wires the
-- ApprovalService into the mutation paths.
--
-- Sections:
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
-- 2. paliad.approval_role_level(text) — strict ladder helper.
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
-- 4. paliad.approval_requests — operational pending workflow.
-- 5. ALTER paliad.deadlines + paliad.appointments — approval columns
-- (approval_status, pending_request_id, approved_by, approved_at;
-- appointments also gains completed_at).
-- 6. Backfill: mark every existing row approval_status='legacy'.
--
-- ============================================================================
-- 1. Add 'senior_pa' to paliad.project_teams.role CHECK.
--
-- Live-DB finding (cronus, 2026-05-06): the existing constraint is named
-- `projekt_teams_role_check` (German leftover from migration 018, when the
-- table was `paliad.projekt_teams`; the table was renamed in 020 but the
-- constraint name was preserved by Postgres). Dropping by both names
-- defensively handles any future re-creation under the English name.
-- ============================================================================
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS projekt_teams_role_check;
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
CHECK (role IN (
'lead', 'associate', 'pa', 'of_counsel',
'local_counsel', 'expert', 'observer',
'senior_pa'
));
-- ============================================================================
-- 2. paliad.approval_role_level — strict ladder over project_teams.role.
--
-- Mirrors internal/services/approval_levels.go:levelOf. A user with
-- project_teams.role R can approve any request whose required_role has level
-- <= level(R). Roles outside the approval ladder (local_counsel, expert,
-- observer, anything new) return 0 and are ineligible to approve at any
-- level. Default required_role on policies is 'associate' (level 3).
-- ============================================================================
CREATE OR REPLACE FUNCTION paliad.approval_role_level(role text)
RETURNS int LANGUAGE SQL IMMUTABLE AS $$
SELECT CASE role
WHEN 'lead' THEN 5
WHEN 'of_counsel' THEN 4
WHEN 'associate' THEN 3
WHEN 'senior_pa' THEN 2
WHEN 'pa' THEN 1
ELSE 0
END
$$;
COMMENT ON FUNCTION paliad.approval_role_level(text) IS
'Strict-ladder level for approval gating (t-paliad-138). '
'Higher level always satisfies lower. Level 0 = ineligible. '
'Default policy required_role=associate (level 3) — eligible: lead, of_counsel, associate.';
-- ============================================================================
-- 3. paliad.approval_policies — per-(project, entity_type, lifecycle_event).
--
-- Up to 8 rows per project (deadline×4 + appointment×4). UNIQUE composite key
-- enforces this. No row = no approval needed for that event. Authoring is
-- gated to global_admin in the service layer; RLS lets project members read
-- their own project's policies (transparency: "do my edits need 4-eye?").
-- ============================================================================
CREATE TABLE paliad.approval_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
required_role text NOT NULL CHECK (required_role IN (
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
)),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
UNIQUE (project_id, entity_type, lifecycle_event)
);
CREATE INDEX approval_policies_project_idx
ON paliad.approval_policies (project_id);
ALTER TABLE paliad.approval_policies ENABLE ROW LEVEL SECURITY;
CREATE POLICY approval_policies_select ON paliad.approval_policies
FOR SELECT TO authenticated
USING (paliad.can_see_project(project_id));
-- Writes are restricted to global_admin in the application layer. The
-- service-role connection bypasses RLS, so these policies are
-- defence-in-depth for any future direct-DB access path.
CREATE POLICY approval_policies_write ON paliad.approval_policies
FOR ALL TO authenticated
USING (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
)
WITH CHECK (
EXISTS (SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
);
-- ============================================================================
-- 4. paliad.approval_requests — operational pending workflow.
--
-- One row per submitted state-change that needs 4-eye sign-off. The entity
-- being changed is referenced by (entity_type, entity_id) — polymorphic
-- across deadlines / appointments, so no FK constraint on entity_id.
--
-- pre_image carries the field values needed to revert on rejection
-- (NULL for 'create' since there's nothing to revert to). payload echoes
-- the diff or new values that were written, for audit display.
--
-- required_role is a snapshot of the policy at request time — even if the
-- policy changes mid-flight, the request honours the level it was submitted
-- under.
--
-- decision_kind discriminates 'peer' (normal in-team sign-off) from
-- 'admin_override' (global_admin used the escape-hatch path). Verlauf
-- chronology renders these distinctly.
--
-- The CHECK on (decided_by != requested_by) is defence-in-depth alongside
-- the service-layer self-approval block.
-- ============================================================================
CREATE TABLE paliad.approval_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('deadline', 'appointment')),
entity_id uuid NOT NULL,
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create', 'update', 'complete', 'delete')),
pre_image jsonb,
payload jsonb,
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
requested_at timestamptz NOT NULL DEFAULT now(),
required_role text NOT NULL CHECK (required_role IN (
'lead', 'of_counsel', 'associate', 'senior_pa', 'pa'
)),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'revoked', 'superseded')),
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
decided_at timestamptz,
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer', 'admin_override')),
decision_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT approval_requests_no_self_approval
CHECK (decided_by IS NULL OR decided_by <> requested_by)
);
CREATE INDEX approval_requests_project_status_idx
ON paliad.approval_requests (project_id, status);
CREATE INDEX approval_requests_entity_idx
ON paliad.approval_requests (entity_type, entity_id);
CREATE INDEX approval_requests_requested_by_idx
ON paliad.approval_requests (requested_by, status);
CREATE INDEX approval_requests_pending_idx
ON paliad.approval_requests (status, requested_at)
WHERE status = 'pending';
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
-- Visible to anyone with project visibility (mirrors deadlines / appointments).
-- The approve/reject action is gated at the service layer, not here.
CREATE POLICY approval_requests_all ON paliad.approval_requests
FOR ALL TO authenticated
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
-- ============================================================================
-- 5. Approval columns on paliad.deadlines + paliad.appointments.
--
-- approval_status:
-- 'approved' (default for new + existing-after-backfill-clears),
-- 'pending' (an approval_request is in flight; pending_request_id set),
-- 'legacy' (predates 4-eye; backfilled in §6 below).
--
-- pending_request_id: FK to the in-flight approval_requests row. NULL when
-- approval_status != 'pending'. ON DELETE SET NULL keeps the entity row
-- intact if an approval_requests row is ever pruned.
--
-- approved_by / approved_at: set on transition to approval_status='approved'
-- after a 4-eye approval. NULL for 'legacy' rows and rows that never went
-- through 4-eye (no policy applied).
--
-- appointments.completed_at: new column for the appointment:complete
-- lifecycle event. Nullable; NULL means "not yet marked done".
-- ============================================================================
ALTER TABLE paliad.deadlines
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
ADD COLUMN approved_at timestamptz;
CREATE INDEX deadlines_approval_status_pending_idx
ON paliad.deadlines (approval_status)
WHERE approval_status = 'pending';
ALTER TABLE paliad.appointments
ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
CHECK (approval_status IN ('approved', 'pending', 'legacy')),
ADD COLUMN pending_request_id uuid
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL,
ADD COLUMN approved_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
ADD COLUMN approved_at timestamptz,
ADD COLUMN completed_at timestamptz;
CREATE INDEX appointments_approval_status_pending_idx
ON paliad.appointments (approval_status)
WHERE approval_status = 'pending';
-- ============================================================================
-- 6. Backfill: mark every existing row legacy.
--
-- Per design §6.5 / m's Q11 answer: existing pre-4-eye rows are read-clean;
-- they don't need retroactive approval. The next mutation on a legacy row
-- that hits an active policy (none exist on day 1) will trigger normal flow
-- and lift the row to 'approved' (or 'pending' until signed off).
--
-- created_by is already populated since migration 005. approved_by stays
-- NULL on legacy rows.
-- ============================================================================
UPDATE paliad.deadlines SET approval_status = 'legacy';
UPDATE paliad.appointments SET approval_status = 'legacy';