-- 157_scenario_builder_foundation — t-paliad-340 / m/paliad#153 B0 -- -- Schema foundation for the Litigation Builder (PRD -- docs/plans/prd-procedures-litigation-planner-2026-05-27.md §5.1 + §5.2). -- Phase B0 of the 7-slice train described in PRD §7.1. DB-only — no UI -- depends on these tables yet; B1 wires the builder shell on top. -- -- What this migration adds: -- -- 1. Six new columns on paliad.scenarios for the builder shape: -- owner_id, status, origin_project_id, promoted_project_id, -- stichtag, notes. -- Two relaxations on existing columns: -- - spec NOT NULL → NULL (the builder normalises spec contents -- into scenario_proceedings / scenario_events; new rows skip -- spec entirely. Legacy callers from mig 145 still provide it -- explicitly, so they keep inserting valid rows.) -- - DROP CONSTRAINT scenarios_unique_per_scope (the builder -- allows multiple "Unbenanntes Szenario" + multiple scratch -- scenarios per user — uniqueness on (project_id, created_by, -- name) blocks that. The legacy service treated the constraint -- as UX collision avoidance, not correctness.) -- -- 2. Three new tables for the normalised builder shape: -- - paliad.scenario_proceedings (one row per proceeding in a -- scenario; multi-proceeding constellations + spawned children) -- - paliad.scenario_events (one row per event card on the -- canvas; planned / filed / skipped state + actual_date + notes -- + per-card optional horizon) -- - paliad.scenario_shares (read-only team shares; owner is -- the sole editor) -- -- 3. One new column on paliad.projects: -- - origin_scenario_id — audit trail for promote-to-project -- (B5; the column lands now so the FK is in place when the -- wizard arrives). -- -- 4. New helper function paliad.can_see_scenario(_scenario_id) that -- mirrors paliad.can_see_project's STABLE SECURITY DEFINER shape. -- Visibility logic: -- - global_admin sees everything, -- - owner_id = auth.uid() (builder-owned scenarios), -- - scenario_shares.shared_with_user_id = auth.uid() -- (read-only shared scenarios), -- - legacy project-scoped scenarios (owner_id IS NULL AND -- project_id IS NOT NULL) follow can_see_project(project_id), -- - legacy abstract scenarios (owner_id IS NULL AND project_id -- IS NULL) follow created_by = auth.uid(). -- -- 5. Replacement RLS policies on paliad.scenarios that fold builder -- visibility together with the legacy shape. The legacy -- project_* / abstract_* policies are dropped (they covered only -- legacy paths) and rewritten as a single pair of policies that -- treats owner_id, scenario_shares, and the legacy paths uniformly. -- -- Builder-only RLS for the three new tables: read = scenario -- visibility; write = scenario owner (or legacy editor) only. -- -- PRD §5.1 deviations called out for the reader: -- -- - PRD specs `proceeding_type_id uuid REFERENCES paliad.proceeding_types(id)`. -- The live column is `integer` (see paliad.proceeding_types.id); -- scenario_proceedings.proceeding_type_id is integer here to match -- the real FK target. PRD authors did not check the column type; -- this migration uses the truth on disk. -- -- - PRD references `auth.users(id)` for owner_id and share columns; -- the established paliad convention (see paliad.projects.created_by, -- paliad.scenarios.created_by) uses `paliad.users(id)`. Same UUIDs -- either way (paliad.users.id == auth.users.id), but the FK targets -- paliad.users to stay consistent with project tables. -- -- Audit-first: all DDL ran clean against a BEGIN/ROLLBACK probe on the -- live DB before this file was committed. paliad.scenarios has 0 rows -- (verified pre-mig), so the column additions and constraint relaxations -- have no data impact. BEGIN; SELECT set_config( 'paliad.audit_reason', 'mig 157: Scenario builder foundation (t-paliad-340 / m/paliad#153 B0)', true ); -- ---------------------------------------------------------------- -- 1. paliad.scenarios — additive columns + constraint relaxations -- ---------------------------------------------------------------- ALTER TABLE paliad.scenarios ADD COLUMN owner_id uuid NULL REFERENCES paliad.users(id) ON DELETE CASCADE, ADD COLUMN status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived','promoted')), ADD COLUMN origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL, ADD COLUMN promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL, ADD COLUMN stichtag date NULL, ADD COLUMN notes text NULL; ALTER TABLE paliad.scenarios ALTER COLUMN spec DROP NOT NULL; ALTER TABLE paliad.scenarios DROP CONSTRAINT IF EXISTS scenarios_unique_per_scope; CREATE INDEX scenarios_owner_status_idx ON paliad.scenarios(owner_id, status) WHERE owner_id IS NOT NULL; CREATE INDEX scenarios_updated_idx ON paliad.scenarios(owner_id, updated_at DESC) WHERE owner_id IS NOT NULL; COMMENT ON COLUMN paliad.scenarios.owner_id IS 'Litigation Builder owner (PRD §5.1). NULL = legacy composition-spec ' 'scenario from m/paliad#124 Slice D (mig 145). Builder rows MUST have ' 'owner_id set; the application enforces it via ScenarioBuilderService.'; COMMENT ON COLUMN paliad.scenarios.status IS 'Lifecycle: active (default; user-editable) / archived (soft-deleted, ' 'still visible in side panel) / promoted (converted to project via ' 'B5 wizard; read-only). Legacy mig-145 rows default to active.'; COMMENT ON COLUMN paliad.scenarios.origin_project_id IS 'Set when the scenario was exported from an existing project ' '("Im Builder öffnen" — Akte mode, PRD §2.3).'; COMMENT ON COLUMN paliad.scenarios.promoted_project_id IS 'Set after the scenario was promoted to a real project via the 3-step ' 'wizard (PRD §5.4). Together with paliad.projects.origin_scenario_id, ' 'forms the bidirectional audit link.'; COMMENT ON COLUMN paliad.scenarios.stichtag IS 'Scenario-level default Stichtag; per-proceeding overrides in ' 'paliad.scenario_proceedings.stichtag take precedence.'; -- ---------------------------------------------------------------- -- 2. paliad.scenario_proceedings — one proceeding per scenario row -- ---------------------------------------------------------------- CREATE TABLE paliad.scenario_proceedings ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE, proceeding_type_id integer NOT NULL REFERENCES paliad.proceeding_types(id), primary_party text NULL CHECK (primary_party IN ('claimant','defendant')), scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(scenario_flags) = 'object'), parent_scenario_proceeding_id uuid NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE, spawn_anchor_event_id uuid NULL REFERENCES paliad.sequencing_rules(id), ordinal int NOT NULL DEFAULT 0, stichtag date NULL, detailgrad text NOT NULL DEFAULT 'selected' CHECK (detailgrad IN ('selected','all_options')), appeal_target text NULL, collapsed boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX scenario_proceedings_scenario_idx ON paliad.scenario_proceedings(scenario_id, ordinal); CREATE INDEX scenario_proceedings_parent_idx ON paliad.scenario_proceedings(parent_scenario_proceeding_id) WHERE parent_scenario_proceeding_id IS NOT NULL; COMMENT ON TABLE paliad.scenario_proceedings IS 'One proceeding inside a Litigation Builder scenario. Multiple rows ' 'per scenario for multi-proceeding constellations. ' 'parent_scenario_proceeding_id self-refs for spawned children ' '(e.g. upc.ccr.cfi spawned by with_ccr on upc.inf.cfi). ' 'PRD §5.1, m/paliad#153 B0.'; COMMENT ON COLUMN paliad.scenario_proceedings.primary_party IS 'Per-proceeding perspective ("our side"). NULL = no perspective ' 'picked yet (both party columns render with natural labels). ' 'Per-proceeding so multi-jurisdiction constellations can flip side ' 'independently (PRD §3.3).'; COMMENT ON COLUMN paliad.scenario_proceedings.scenario_flags IS 'Per-proceeding flags (e.g. {"with_ccr": true, "with_amend": false}). ' 'Mirrors paliad.projects.scenario_flags shape but lives per-proceeding-' 'per-scenario. Validated by the application against ' 'paliad.scenario_flag_catalog at write time.'; COMMENT ON COLUMN paliad.scenario_proceedings.spawn_anchor_event_id IS 'Which sequencing_rule of the parent proceeding caused this spawn. ' 'NULL for root proceedings. Used by the UI to place the spawned child ' 'triplet directly below the parent at the spawn node (PRD §3.6).'; COMMENT ON COLUMN paliad.scenario_proceedings.ordinal IS 'Stack order on canvas (top to bottom). Siblings under the same ' 'parent (or top-level) are ordered by ordinal asc, then created_at.'; COMMENT ON COLUMN paliad.scenario_proceedings.detailgrad IS 'Per-proceeding optional-detail toggle: selected (only explicitly ' 'chosen optionals + mandatories) or all_options (every optional ' 'sequencing_rule surfaces). Matches today''s Verfahrensablauf pattern.'; -- ---------------------------------------------------------------- -- 3. paliad.scenario_events — one event card on the canvas -- ---------------------------------------------------------------- CREATE TABLE paliad.scenario_events ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), scenario_proceeding_id uuid NOT NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE, sequencing_rule_id uuid NULL REFERENCES paliad.sequencing_rules(id), procedural_event_id uuid NULL REFERENCES paliad.procedural_events(id), custom_label text NULL, state text NOT NULL DEFAULT 'planned' CHECK (state IN ('planned','filed','skipped')), actual_date date NULL, skip_reason text NULL, notes text NULL, horizon_optional int NOT NULL DEFAULT 0 CHECK (horizon_optional >= 0), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT scenario_events_one_anchor CHECK ( (sequencing_rule_id IS NOT NULL)::int + (procedural_event_id IS NOT NULL)::int + (custom_label IS NOT NULL)::int >= 1 ) ); CREATE INDEX scenario_events_proceeding_idx ON paliad.scenario_events(scenario_proceeding_id); -- A single proceeding can't carry two cards for the same sequencing rule -- (each rule maps to one card). Free-form / procedural_event-only cards -- skip this uniqueness — multiple custom cards per proceeding are OK. CREATE UNIQUE INDEX scenario_events_rule_uniq_idx ON paliad.scenario_events(scenario_proceeding_id, sequencing_rule_id) WHERE sequencing_rule_id IS NOT NULL; COMMENT ON TABLE paliad.scenario_events IS 'One event card on the Litigation Builder canvas. Captures state ' '(planned/filed/skipped), actual_date, notes, skip_reason, and the ' 'per-card optional-horizon setting. At least one of ' '(sequencing_rule_id, procedural_event_id, custom_label) must be ' 'set — sequencing-rule-backed cards are the common case; free-form ' 'cards exist for events the catalog doesn''t cover yet. ' 'PRD §3.4 / §5.1.'; COMMENT ON COLUMN paliad.scenario_events.state IS '3-state machine: planned (default, future event with computed date) ' '/ filed (past event, actual_date set) / skipped (user chose not to ' 'file; optional skip_reason). No "overdue" enum — that''s derived ' '(date < today AND state=planned), not stored. PRD Q10 / §3.4.'; COMMENT ON COLUMN paliad.scenario_events.actual_date IS 'Set when state=filed (real-world filing date) OR when state=planned ' 'and the user overrode the computed date (court-set events, manual ' 'tweaks). NULL when the computed date is canonical.'; COMMENT ON COLUMN paliad.scenario_events.horizon_optional IS 'Per-card "show N more optional follow-ups" affordance. Default 0 ' '(hidden). PRD Q4 / §3.4.'; -- ---------------------------------------------------------------- -- 4. paliad.scenario_shares — read-only team shares -- ---------------------------------------------------------------- CREATE TABLE paliad.scenario_shares ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE, shared_with_user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now(), created_by uuid NOT NULL REFERENCES paliad.users(id), UNIQUE (scenario_id, shared_with_user_id) ); CREATE INDEX scenario_shares_user_idx ON paliad.scenario_shares(shared_with_user_id); COMMENT ON TABLE paliad.scenario_shares IS 'Read-only team shares for Litigation Builder scenarios. Owner ' '(paliad.scenarios.owner_id) is the sole editor; rows here grant ' 'view-only access to other paliad users. PRD Q12 / §5.1.'; -- ---------------------------------------------------------------- -- 5. paliad.projects.origin_scenario_id — promote-to-project trail -- ---------------------------------------------------------------- ALTER TABLE paliad.projects ADD COLUMN origin_scenario_id uuid NULL REFERENCES paliad.scenarios(id) ON DELETE SET NULL; CREATE INDEX projects_origin_scenario_idx ON paliad.projects(origin_scenario_id) WHERE origin_scenario_id IS NOT NULL; COMMENT ON COLUMN paliad.projects.origin_scenario_id IS 'FK to the scenario this project was promoted from (B5 wizard). ' 'NULL = project was created directly, not via Builder. Together with ' 'paliad.scenarios.promoted_project_id, forms the bidirectional audit ' 'link. PRD §5.2.'; -- ---------------------------------------------------------------- -- 6. paliad.can_see_scenario — visibility helper -- ---------------------------------------------------------------- CREATE OR REPLACE FUNCTION paliad.can_see_scenario(_scenario_id uuid) RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'paliad', 'public' AS $func$ SELECT EXISTS ( SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin' ) OR EXISTS ( SELECT 1 FROM paliad.scenarios s WHERE s.id = _scenario_id AND s.owner_id = auth.uid() ) OR EXISTS ( SELECT 1 FROM paliad.scenario_shares sh WHERE sh.scenario_id = _scenario_id AND sh.shared_with_user_id = auth.uid() ) -- Legacy project-scoped scenarios (mig 145) — visible via project -- team membership. OR EXISTS ( SELECT 1 FROM paliad.scenarios s WHERE s.id = _scenario_id AND s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id) ) -- Legacy abstract scenarios (mig 145) — owner-only via created_by. OR EXISTS ( SELECT 1 FROM paliad.scenarios s WHERE s.id = _scenario_id AND s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid() ); $func$; COMMENT ON FUNCTION paliad.can_see_scenario(uuid) IS 'Returns true if the caller (auth.uid()) can see the given scenario. ' 'Mirrors paliad.can_see_project. Covers builder-owned scenarios ' '(owner_id), read-only shares (scenario_shares), and the two legacy ' 'paths from mig 145 (project-scoped via can_see_project, abstract ' 'via created_by). Used by RLS on all four scenario_* tables.'; -- ---------------------------------------------------------------- -- 7. RLS — replace legacy scenarios policies + new tables -- ---------------------------------------------------------------- -- Replace mig-145's four policies with a single pair that handles -- builder + legacy shapes together. DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios; DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios; DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios; DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios; CREATE POLICY scenarios_select ON paliad.scenarios FOR SELECT USING (paliad.can_see_scenario(id)); -- Write rule: builder owner, legacy project team member (if no owner), -- or legacy abstract creator (if no owner + no project). Shares are -- read-only — they don't grant mutate. CREATE POLICY scenarios_owner_mutate ON paliad.scenarios FOR ALL USING ( owner_id = auth.uid() OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id)) OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid()) ) WITH CHECK ( owner_id = auth.uid() OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id)) OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid()) ); -- scenario_proceedings — visibility piggybacks on the parent scenario. ALTER TABLE paliad.scenario_proceedings ENABLE ROW LEVEL SECURITY; CREATE POLICY scenario_proceedings_select ON paliad.scenario_proceedings FOR SELECT USING (paliad.can_see_scenario(scenario_id)); CREATE POLICY scenario_proceedings_mutate ON paliad.scenario_proceedings FOR ALL USING (EXISTS ( SELECT 1 FROM paliad.scenarios s WHERE s.id = scenario_id AND (s.owner_id = auth.uid() OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) )) WITH CHECK (EXISTS ( SELECT 1 FROM paliad.scenarios s WHERE s.id = scenario_id AND (s.owner_id = auth.uid() OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) )); -- scenario_events — visibility piggybacks on the parent scenario via -- the proceeding row. ALTER TABLE paliad.scenario_events ENABLE ROW LEVEL SECURITY; CREATE POLICY scenario_events_select ON paliad.scenario_events FOR SELECT USING (EXISTS ( SELECT 1 FROM paliad.scenario_proceedings sp WHERE sp.id = scenario_proceeding_id AND paliad.can_see_scenario(sp.scenario_id) )); CREATE POLICY scenario_events_mutate ON paliad.scenario_events FOR ALL USING (EXISTS ( SELECT 1 FROM paliad.scenario_proceedings sp JOIN paliad.scenarios s ON s.id = sp.scenario_id WHERE sp.id = scenario_proceeding_id AND (s.owner_id = auth.uid() OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) )) WITH CHECK (EXISTS ( SELECT 1 FROM paliad.scenario_proceedings sp JOIN paliad.scenarios s ON s.id = sp.scenario_id WHERE sp.id = scenario_proceeding_id AND (s.owner_id = auth.uid() OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) )); -- scenario_shares — recipient can see their share rows; the scenario -- owner (or legacy editor) can manage them. ALTER TABLE paliad.scenario_shares ENABLE ROW LEVEL SECURITY; CREATE POLICY scenario_shares_select ON paliad.scenario_shares FOR SELECT USING ( shared_with_user_id = auth.uid() OR EXISTS ( SELECT 1 FROM paliad.scenarios s WHERE s.id = scenario_id AND (s.owner_id = auth.uid() OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) ) ); CREATE POLICY scenario_shares_mutate ON paliad.scenario_shares FOR ALL USING (EXISTS ( SELECT 1 FROM paliad.scenarios s WHERE s.id = scenario_id AND (s.owner_id = auth.uid() OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) )) WITH CHECK (EXISTS ( SELECT 1 FROM paliad.scenarios s WHERE s.id = scenario_id AND (s.owner_id = auth.uid() OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) )); -- ---------------------------------------------------------------- -- 8. updated_at triggers on the new tables (reuse the function mig 145 -- already created for paliad.scenarios). -- ---------------------------------------------------------------- CREATE TRIGGER scenario_proceedings_touch_updated_at_trg BEFORE UPDATE ON paliad.scenario_proceedings FOR EACH ROW EXECUTE FUNCTION paliad.scenarios_touch_updated_at(); CREATE TRIGGER scenario_events_touch_updated_at_trg BEFORE UPDATE ON paliad.scenario_events FOR EACH ROW EXECUTE FUNCTION paliad.scenarios_touch_updated_at(); -- ---------------------------------------------------------------- -- 9. Informational NOTICE. -- ---------------------------------------------------------------- DO $$ BEGIN RAISE NOTICE '[mig 157] paliad.scenarios extended with builder columns (0 legacy rows affected)'; RAISE NOTICE '[mig 157] paliad.scenario_proceedings created'; RAISE NOTICE '[mig 157] paliad.scenario_events created'; RAISE NOTICE '[mig 157] paliad.scenario_shares created'; RAISE NOTICE '[mig 157] paliad.projects.origin_scenario_id added'; RAISE NOTICE '[mig 157] paliad.can_see_scenario(uuid) created'; END $$; COMMIT;