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
208 lines
6.2 KiB
Go
208 lines
6.2 KiB
Go
package litigationplanner
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestParseSpec_Roundtrip pins the spec-decoder contract: well-formed
|
|
// jsonb with version=1 parses; unknown versions and malformed JSON
|
|
// surface ErrInvalidScenario.
|
|
func TestParseSpec_Roundtrip(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
spec string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
"v1 primary-only",
|
|
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[{"code":"upc.inf.cfi","role":"primary"}]}`,
|
|
false,
|
|
},
|
|
{
|
|
"v1 with full primary entry",
|
|
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[
|
|
{"code":"upc.inf.cfi","role":"primary","flags":["with_ccr"],
|
|
"anchor_overrides":{"inf.reply":"2026-08-15"},
|
|
"skip_rules":["inf.r30_amend"]}
|
|
]}`,
|
|
false,
|
|
},
|
|
{
|
|
"v2 spec rejected — unknown version",
|
|
`{"version":2,"proceedings":[]}`,
|
|
true,
|
|
},
|
|
{
|
|
"empty spec",
|
|
``,
|
|
true,
|
|
},
|
|
{
|
|
"malformed json",
|
|
`{"version":1,"proceedings":[}`,
|
|
true,
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
_, err := ParseSpec(NullableJSON(c.spec))
|
|
if c.wantErr && err == nil {
|
|
t.Errorf("ParseSpec(%s): want error, got nil", c.spec)
|
|
}
|
|
if !c.wantErr && err != nil {
|
|
t.Errorf("ParseSpec(%s): unexpected error %v", c.spec, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestScenarioSpec_PrimaryProceeding pins the "exactly one primary"
|
|
// invariant: zero → ErrScenarioNoPrimary; multiple → ErrInvalidScenario.
|
|
func TestScenarioSpec_PrimaryProceeding(t *testing.T) {
|
|
t.Run("zero primary → ErrScenarioNoPrimary", func(t *testing.T) {
|
|
s := &ScenarioSpec{
|
|
Version: 1,
|
|
Proceedings: []ScenarioProceeding{
|
|
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
|
|
},
|
|
}
|
|
_, err := s.PrimaryProceeding()
|
|
if err != ErrScenarioNoPrimary {
|
|
t.Errorf("want ErrScenarioNoPrimary, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("two primaries rejected", func(t *testing.T) {
|
|
s := &ScenarioSpec{
|
|
Version: 1,
|
|
Proceedings: []ScenarioProceeding{
|
|
{Code: "upc.inf.cfi", Role: ScenarioRolePrimary},
|
|
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary},
|
|
},
|
|
}
|
|
_, err := s.PrimaryProceeding()
|
|
if err == nil || !strings.Contains(err.Error(), "multiple proceedings with role='primary'") {
|
|
t.Errorf("want multi-primary error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("single primary picked", func(t *testing.T) {
|
|
s := &ScenarioSpec{
|
|
Version: 1,
|
|
Proceedings: []ScenarioProceeding{
|
|
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
|
|
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary, Flags: []string{"with_amend"}},
|
|
},
|
|
}
|
|
p, err := s.PrimaryProceeding()
|
|
if err != nil {
|
|
t.Fatalf("PrimaryProceeding: %v", err)
|
|
}
|
|
if p.Code != "upc.rev.cfi" {
|
|
t.Errorf("primary code = %q, want upc.rev.cfi", p.Code)
|
|
}
|
|
if len(p.Flags) != 1 || p.Flags[0] != "with_amend" {
|
|
t.Errorf("primary.Flags = %v, want [with_amend]", p.Flags)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestScenarioSpec_CalcOptionsFromSpec covers the unpack from spec
|
|
// jsonb into the CalcOptions the engine consumes. Pins:
|
|
// - base_trigger_date used when no per-proceeding override
|
|
// - trigger_date_override wins when set
|
|
// - flags + anchor_overrides + appeal_target passed through verbatim
|
|
// - per_card_choices unpacked into PerCardAppellant / SkipRules /
|
|
// IncludeCCRFor maps
|
|
func TestScenarioSpec_CalcOptionsFromSpec(t *testing.T) {
|
|
includeTrue := true
|
|
skipTrue := true
|
|
s := &ScenarioSpec{
|
|
Version: 1,
|
|
BaseTriggerDate: "2026-05-26",
|
|
Proceedings: []ScenarioProceeding{{
|
|
Code: "upc.inf.cfi",
|
|
Role: ScenarioRolePrimary,
|
|
Flags: []string{"with_ccr"},
|
|
AnchorOverrides: map[string]string{"inf.reply": "2026-08-15"},
|
|
AppealTarget: "endentscheidung",
|
|
SkipRules: []string{"explicit_skip_code"},
|
|
PerCardChoices: map[string]ScenarioCardChoice{
|
|
"inf.r30_amend": {Appellant: "claimant"},
|
|
"inf.rejoin": {IncludeCCR: &includeTrue},
|
|
"inf.amend_other": {Skip: &skipTrue},
|
|
},
|
|
}},
|
|
}
|
|
code, td, opts, err := s.CalcOptionsFromSpec()
|
|
if err != nil {
|
|
t.Fatalf("CalcOptionsFromSpec: %v", err)
|
|
}
|
|
if code != "upc.inf.cfi" {
|
|
t.Errorf("code = %q, want upc.inf.cfi", code)
|
|
}
|
|
if td != "2026-05-26" {
|
|
t.Errorf("triggerDate = %q, want 2026-05-26", td)
|
|
}
|
|
if len(opts.Flags) != 1 || opts.Flags[0] != "with_ccr" {
|
|
t.Errorf("opts.Flags = %v, want [with_ccr]", opts.Flags)
|
|
}
|
|
if opts.AppealTarget != "endentscheidung" {
|
|
t.Errorf("opts.AppealTarget = %q, want endentscheidung", opts.AppealTarget)
|
|
}
|
|
if got := opts.AnchorOverrides["inf.reply"]; got != "2026-08-15" {
|
|
t.Errorf("opts.AnchorOverrides[inf.reply] = %q, want 2026-08-15", got)
|
|
}
|
|
if got := opts.PerCardAppellant["inf.r30_amend"]; got != "claimant" {
|
|
t.Errorf("opts.PerCardAppellant[inf.r30_amend] = %q, want claimant", got)
|
|
}
|
|
if _, ok := opts.IncludeCCRFor["inf.rejoin"]; !ok {
|
|
t.Error("opts.IncludeCCRFor missing inf.rejoin")
|
|
}
|
|
if _, ok := opts.SkipRules["inf.amend_other"]; !ok {
|
|
t.Error("opts.SkipRules missing inf.amend_other (from per_card_choices.skip)")
|
|
}
|
|
if _, ok := opts.SkipRules["explicit_skip_code"]; !ok {
|
|
t.Error("opts.SkipRules missing explicit_skip_code (from skip_rules[])")
|
|
}
|
|
}
|
|
|
|
// TestScenarioSpec_TriggerDateOverride pins the per-proceeding override
|
|
// path (v2-ready — primary entry honours trigger_date_override too).
|
|
func TestScenarioSpec_TriggerDateOverride(t *testing.T) {
|
|
s := &ScenarioSpec{
|
|
Version: 1,
|
|
BaseTriggerDate: "2026-05-26",
|
|
Proceedings: []ScenarioProceeding{{
|
|
Code: "upc.inf.cfi",
|
|
Role: ScenarioRolePrimary,
|
|
TriggerDateOverride: "2026-12-01",
|
|
}},
|
|
}
|
|
_, td, _, err := s.CalcOptionsFromSpec()
|
|
if err != nil {
|
|
t.Fatalf("CalcOptionsFromSpec: %v", err)
|
|
}
|
|
if td != "2026-12-01" {
|
|
t.Errorf("triggerDate = %q, want override 2026-12-01", td)
|
|
}
|
|
}
|
|
|
|
// TestScenarioSpec_NoBaseTrigger pins the safety check that a spec
|
|
// without base_trigger_date AND without per-proceeding override
|
|
// surfaces ErrInvalidScenario (the engine can't render without a date).
|
|
func TestScenarioSpec_NoBaseTrigger(t *testing.T) {
|
|
s := &ScenarioSpec{
|
|
Version: 1,
|
|
Proceedings: []ScenarioProceeding{{
|
|
Code: "upc.inf.cfi",
|
|
Role: ScenarioRolePrimary,
|
|
}},
|
|
}
|
|
_, _, _, err := s.CalcOptionsFromSpec()
|
|
if err == nil {
|
|
t.Fatal("want ErrInvalidScenario, got nil")
|
|
}
|
|
}
|