A scenario is a named composition of existing proceedings + flags +
per-card choices + anchor dates. Users compose, they don't author —
spec references existing rules by submission_code; never creates new
rules. Per m's 2026-05-26 AskUserQuestion picks (doc commit 6e58595):
Q1 composition: primary + spawned (v1); multi-proceeding peer
compose is the v2 goal (spec.proceedings[] array)
Q2 scope: per-project + abstract (project_id NULL = abstract)
Q3 trigger: per-anchor overrides over one base date
Q4 storage: NEW paliad.scenarios table with jsonb spec
(NOT a project_event_choices column extension)
Migration 145 — additive only. Pre-flight coordination check:
- On-disk max: 138 (Berufung backfill, just merged).
- Live DB tracker: 106 (significantly behind — many migs pending
deploy).
- curie's #93 B.2-B.6 migs not pushed yet — reserved 139-143 + 144
as buffer; claimed 145 as the safe minimum that won't collide.
- paliad.scenarios has audit_reason NOT applicable (no audit
trigger on the table); updated_at trigger added on the table
itself.
- paliad.projects gains active_scenario_id uuid NULL FK with ON
DELETE SET NULL (mig 134 lesson — no updated_at clauses on
proceeding_types-style assumptions).
Schema:
paliad.scenarios (
id uuid pk,
project_id uuid NULL FK → projects(id) ON DELETE CASCADE,
name text NOT NULL CHECK char_length > 0,
description text NULL,
spec jsonb NOT NULL CHECK jsonb_typeof = 'object',
created_by uuid NULL FK → users(id) ON DELETE SET NULL,
created_at + updated_at timestamptz,
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name)
);
paliad.projects.active_scenario_id uuid NULL FK;
RLS: project-scoped → can_see_project; abstract → created_by = auth.uid();
Trigger: scenarios_touch_updated_at_trg.
pkg/litigationplanner additions:
- Scenario struct (db + json tags)
- ScenarioSpec / ScenarioProceeding / ScenarioCardChoice — parsed
view of the jsonb (version-1 today, v2 multi-peer-ready)
- ParseSpec(raw) + ScenarioSpec.PrimaryProceeding() + CalcOptionsFromSpec()
- ScenarioFilter + Catalog.LoadScenarios + Catalog.MatchScenario
- CalculateFromScenario(scenario, catalog, holidays, courts) — high-
level engine entry: parses spec → builds CalcOptions → delegates
to Calculate
- Sentinel errors: ErrUnknownScenario, ErrInvalidScenario,
ErrScenarioNoPrimary
paliadCatalog impl:
- LoadScenarios with progressively-built WHERE clauses (project-id
filter, abstract-for-user filter, or all)
- MatchScenario by id — returns ErrUnknownScenario on not-found
- Services connection bypasses RLS; ScenarioService enforces
visibility at the application layer (mirrors EventChoiceService
pattern from t-paliad-265)
SnapshotCatalog impl (embedded/upc):
- LoadScenarios returns empty slice (no scenarios in the snapshot)
- MatchScenario returns ErrUnknownScenario
internal/services/scenario_service.go:
- Create / Get / ListForProject / ListAbstractForUser / Patch /
SetActive / Delete with visibility checks
- validateSpec checks version, base_trigger_date format, every
proceedings[*].code resolves to an active paliad.proceeding_types
row, every appeal_target is valid, every anchor_overrides date
parses, every role ∈ {primary, peer}
- SetActive validates the scenario belongs to the requested project
(a scenario from a different project can't be active here)
- Returns ErrScenarioNotVisible for failed visibility checks
REST endpoints (registered in handlers.go):
GET /api/scenarios?project=<id> — list project's
GET /api/scenarios?abstract=true — list user's abstract
GET /api/scenarios/{id} — one
POST /api/scenarios — create
PATCH /api/scenarios/{id} — partial update
DELETE /api/scenarios/{id} — remove
PUT /api/projects/{id}/active-scenario — set / clear active
Handler error mapping:
- ErrUnknownScenario / ErrScenarioNotVisible → 404
- ErrInvalidInput / ErrInvalidScenario / ErrScenarioNoPrimary → 400
- everything else → 500
Tests:
- pkg/litigationplanner/scenarios_test.go: ParseSpec roundtrip
(well-formed + unknown version + malformed json),
PrimaryProceeding zero/multi/single, CalcOptionsFromSpec full
unpack, trigger_date_override path, no-base-trigger safety check.
8 cases total, all DB-free.
Wired in cmd/server/main.go alongside EventChoice — same pattern,
nil-safe when DATABASE_URL is unset (handlers 503 in that mode).
Acceptance:
- go build ./... clean
- go test ./... all green (incl. new scenarios tests)
- Pre-flight audit confirmed mig 145 number is safe vs curie's
pending B.2-B.6 range
171 lines
7.5 KiB
PL/PgSQL
171 lines
7.5 KiB
PL/PgSQL
-- 145_scenarios — Slice D, m/paliad#124 §5 (revised)
|
|
--
|
|
-- Creates paliad.scenarios + paliad.projects.active_scenario_id FK.
|
|
-- A scenario is a named composition of existing proceedings + flags
|
|
-- + per-card choices + anchor dates the user can switch between for
|
|
-- a project (project_id NOT NULL) OR save as an abstract template on
|
|
-- /tools/verfahrensablauf (project_id IS NULL).
|
|
--
|
|
-- m's 2026-05-26 picks (AskUserQuestion round, doc commit 6e58595):
|
|
-- Q1: composition shape → primary+spawned (v1); multi-proceeding
|
|
-- peer compose is the v2 goal. spec.jsonb
|
|
-- architected for N entries from day 1.
|
|
-- Q2: scope → per-project + abstract.
|
|
-- Q3: trigger dates → per-anchor overrides over one base date.
|
|
-- Q4: storage → NEW paliad.scenarios table with jsonb
|
|
-- spec (NOT a project_event_choices column
|
|
-- extension).
|
|
--
|
|
-- "users should not add their own rules" (m, t-paliad-301) — scenarios
|
|
-- compose existing rules, never author new ones. spec.proceedings[*].code
|
|
-- must resolve to an existing active paliad.proceeding_types row;
|
|
-- spec.proceedings[*].anchor_overrides keys must resolve to existing
|
|
-- submission_codes. Validation happens at the application layer
|
|
-- (ScenarioService.validateSpec) — not in DB CHECK constraints (too
|
|
-- expensive to express in pure SQL).
|
|
--
|
|
-- Migration number: 145. Coordination check 2026-05-26 17:38: curie's
|
|
-- B.2-B.6 migrations land in the 139-143 range. 144 reserved as buffer.
|
|
-- 145 is the next safe claim.
|
|
--
|
|
-- ADDITIVE ONLY: CREATE TABLE, ALTER ADD COLUMN, indexes, RLS policies.
|
|
-- Down drops everything. No backfill (zero existing scenarios on day 1).
|
|
--
|
|
-- See docs/design-litigation-planner-2026-05-26.md §5 + §18.4 for the
|
|
-- design.
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 1. The scenarios table
|
|
-- ---------------------------------------------------------------
|
|
|
|
CREATE TABLE paliad.scenarios (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
-- project_id NULL = abstract scenario (saved Verfahrensablauf
|
|
-- template, no Akte). project_id NOT NULL = scenario attached to
|
|
-- a real Akte.
|
|
project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
|
name text NOT NULL,
|
|
description text NULL,
|
|
-- spec carries the full composition. Shape documented in the
|
|
-- design doc §5; the application validates structure before write.
|
|
spec jsonb NOT NULL,
|
|
created_by uuid NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
|
|
-- Within a single project, scenario names are unique. Abstract
|
|
-- scenarios are unique per (created_by, name) so two users can
|
|
-- each keep a "with_ccr" template without colliding. NULLS NOT
|
|
-- DISTINCT means a single user can have one "name" per
|
|
-- (project_id, created_by) tuple, where NULL project_id +
|
|
-- NULL created_by is a single global namespace (used only by
|
|
-- seed / system scenarios — none today).
|
|
CONSTRAINT scenarios_unique_per_scope
|
|
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name),
|
|
|
|
-- Non-empty name.
|
|
CONSTRAINT scenarios_name_nonempty CHECK (char_length(name) > 0),
|
|
|
|
-- Non-empty spec — at least an object. The application checks
|
|
-- structure (version, proceedings[], base_trigger_date format).
|
|
CONSTRAINT scenarios_spec_object CHECK (jsonb_typeof(spec) = 'object')
|
|
);
|
|
|
|
CREATE INDEX scenarios_project_id_idx
|
|
ON paliad.scenarios(project_id) WHERE project_id IS NOT NULL;
|
|
|
|
CREATE INDEX scenarios_abstract_user_idx
|
|
ON paliad.scenarios(created_by) WHERE project_id IS NULL;
|
|
|
|
COMMENT ON TABLE paliad.scenarios IS
|
|
'Named compositions of existing proceedings + flags + per-card '
|
|
'choices + anchor dates. project_id NULL = abstract template; '
|
|
'project_id NOT NULL = attached to an Akte. Design: '
|
|
'docs/design-litigation-planner-2026-05-26.md §5. (Slice D)';
|
|
|
|
COMMENT ON COLUMN paliad.scenarios.spec IS
|
|
'jsonb composition spec. Shape: {version: int, base_trigger_date: '
|
|
'ISO date, proceedings: [{code, role, flags[], per_card_choices, '
|
|
'anchor_overrides, skip_rules[]}, ...]}. Validated at write-time '
|
|
'by ScenarioService.validateSpec.';
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 2. paliad.projects.active_scenario_id FK
|
|
--
|
|
-- NULL = use today's ad-hoc per-card choice state from
|
|
-- paliad.project_event_choices (pre-scenario behaviour preserved).
|
|
-- Non-NULL = the project's current SmartTimeline / Akte-Fristenrechner
|
|
-- render reads from this scenario's spec instead.
|
|
-- ---------------------------------------------------------------
|
|
|
|
ALTER TABLE paliad.projects
|
|
ADD COLUMN active_scenario_id uuid NULL
|
|
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
|
|
|
|
COMMENT ON COLUMN paliad.projects.active_scenario_id IS
|
|
'FK to paliad.scenarios. NULL = read choices from '
|
|
'paliad.project_event_choices (legacy). Non-NULL = read from the '
|
|
'pointed scenario.spec.';
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 3. RLS — mirror paliad.project_event_choices's pattern (mig 129).
|
|
--
|
|
-- Project-scoped scenarios (project_id NOT NULL) inherit team visibility
|
|
-- via paliad.can_see_project. Abstract scenarios (project_id IS NULL)
|
|
-- are private to created_by — only the author can read / write them.
|
|
-- ---------------------------------------------------------------
|
|
|
|
ALTER TABLE paliad.scenarios ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Project-scoped: team visibility.
|
|
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
|
|
CREATE POLICY scenarios_project_select ON paliad.scenarios
|
|
FOR SELECT
|
|
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
|
|
|
|
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
|
|
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
|
|
FOR ALL
|
|
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
|
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
|
|
|
|
-- Abstract: owner-only.
|
|
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
|
|
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
|
|
FOR SELECT
|
|
USING (project_id IS NULL AND created_by = auth.uid());
|
|
|
|
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
|
|
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
|
|
FOR ALL
|
|
USING (project_id IS NULL AND created_by = auth.uid())
|
|
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 4. updated_at trigger (mirrors other paliad tables that carry
|
|
-- updated_at — keep it in lockstep with row mutations).
|
|
-- ---------------------------------------------------------------
|
|
|
|
CREATE OR REPLACE FUNCTION paliad.scenarios_touch_updated_at()
|
|
RETURNS trigger AS $$
|
|
BEGIN
|
|
NEW.updated_at = now();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER scenarios_touch_updated_at_trg
|
|
BEFORE UPDATE ON paliad.scenarios
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 5. Informational NOTICE — schema-only migration, zero rows added.
|
|
-- ---------------------------------------------------------------
|
|
|
|
DO $$
|
|
BEGIN
|
|
RAISE NOTICE '[mig 145] paliad.scenarios created (0 rows; awaits API usage)';
|
|
RAISE NOTICE '[mig 145] paliad.projects.active_scenario_id added (all rows NULL initially)';
|
|
END $$;
|