-- 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';