feat(litigationplanner): scenarios — paliad.scenarios jsonb table + Catalog API + engine adapter (Slice D, t-paliad-306, m/paliad#124 §5)
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
This commit is contained in:
13
internal/db/migrations/145_scenarios.down.sql
Normal file
13
internal/db/migrations/145_scenarios.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 145_scenarios — DOWN
|
||||
--
|
||||
-- Reverses mig 145. Drops the FK on paliad.projects, the table, the
|
||||
-- trigger function, and the RLS policies (CASCADE on table drop kills
|
||||
-- policies). Any data in paliad.scenarios is lost on down.
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP COLUMN IF EXISTS active_scenario_id;
|
||||
|
||||
DROP TRIGGER IF EXISTS scenarios_touch_updated_at_trg ON paliad.scenarios;
|
||||
DROP FUNCTION IF EXISTS paliad.scenarios_touch_updated_at();
|
||||
|
||||
DROP TABLE IF EXISTS paliad.scenarios CASCADE;
|
||||
170
internal/db/migrations/145_scenarios.up.sql
Normal file
170
internal/db/migrations/145_scenarios.up.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
-- 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 $$;
|
||||
Reference in New Issue
Block a user