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
329 lines
11 KiB
Go
329 lines
11 KiB
Go
package litigationplanner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Regression test for t-paliad-304 / m/paliad#135.
|
|
//
|
|
// Reproduces the R.109.1 / R.109.4 anchor bug on upc.inf.cfi:
|
|
// - Trigger (Klageerhebung): parent_id=nil, duration=0, !IsCourtSet, sequence_order=0
|
|
// - Translation request: parent_id=oral, duration=1mo before, sequence_order=45
|
|
// - Interpreter cost: parent_id=oral, duration=2w before, sequence_order=46
|
|
// - Oral hearing: parent_id=nil, duration=0, IsCourtSet, sequence_order=50
|
|
//
|
|
// The "before" children are listed BEFORE the oral hearing in sequence
|
|
// order (because chronologically they happen before it). The engine walks
|
|
// rules in sequence_order, so when it processes the translation/
|
|
// interpreter rows, the oral hearing has not yet been processed →
|
|
// courtSet[oral.ID] is not yet set → parentIsCourtSet is false → the
|
|
// engine falls back to the trigger date as the base. Result: the timing=
|
|
// 'before' arithmetic produces 27.04.2026 (1mo before SoC) instead of
|
|
// the conditional-no-date treatment that a court-set parent should
|
|
// trigger.
|
|
//
|
|
// Expected post-fix: translation_request + interpreter_cost render as
|
|
// IsConditional (no concrete date) because their parent's date is
|
|
// court-set and the proceeding does not yet have an explicit override.
|
|
|
|
// stubCatalog implements lp.Catalog backed by an in-memory rule slice.
|
|
// Only LoadProceeding is needed for the engine path under test; the
|
|
// other interface methods return errors so an unintended call surfaces
|
|
// immediately.
|
|
type stubCatalog struct {
|
|
pt ProceedingType
|
|
rules []Rule
|
|
}
|
|
|
|
func (s *stubCatalog) LoadProceeding(_ context.Context, code string, _ ProjectHint) (*ProceedingType, []Rule, error) {
|
|
if code != s.pt.Code {
|
|
return nil, nil, ErrUnknownProceedingType
|
|
}
|
|
rules := make([]Rule, len(s.rules))
|
|
copy(rules, s.rules)
|
|
pt := s.pt
|
|
return &pt, rules, nil
|
|
}
|
|
func (s *stubCatalog) LoadProceedingByID(_ context.Context, _ int) (*ProceedingType, error) {
|
|
return nil, errors.New("stubCatalog.LoadProceedingByID: not implemented")
|
|
}
|
|
func (s *stubCatalog) LoadRuleByID(_ context.Context, _ string) (*Rule, error) {
|
|
return nil, errors.New("stubCatalog.LoadRuleByID: not implemented")
|
|
}
|
|
func (s *stubCatalog) LoadRuleByCode(_ context.Context, _, _ string) (*Rule, *ProceedingType, error) {
|
|
return nil, nil, errors.New("stubCatalog.LoadRuleByCode: not implemented")
|
|
}
|
|
func (s *stubCatalog) LoadRulesByTriggerEvent(_ context.Context, _ int64) ([]Rule, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[int64]TriggerEvent, error) {
|
|
return map[int64]TriggerEvent{}, nil
|
|
}
|
|
func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubCatalog) LoadScenarios(_ context.Context, _ ScenarioFilter) ([]Scenario, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*Scenario, error) {
|
|
return nil, ErrUnknownScenario
|
|
}
|
|
|
|
// noOpHolidays never adjusts dates — the test fixture doesn't care about
|
|
// weekends or holidays, only about which base date the engine resolves.
|
|
type noOpHolidays struct{}
|
|
|
|
func (noOpHolidays) IsNonWorkingDay(_ time.Time, _, _ string) bool { return false }
|
|
func (noOpHolidays) AdjustForNonWorkingDays(d time.Time, _, _ string) (time.Time, time.Time, bool) {
|
|
return d, d, false
|
|
}
|
|
func (noOpHolidays) AdjustForNonWorkingDaysBackward(d time.Time, _, _ string) (time.Time, time.Time, bool) {
|
|
return d, d, false
|
|
}
|
|
func (noOpHolidays) AdjustForNonWorkingDaysWithReason(d time.Time, _, _ string) (time.Time, time.Time, bool, *AdjustmentReason) {
|
|
return d, d, false, nil
|
|
}
|
|
|
|
type fixedCourts struct{}
|
|
|
|
func (fixedCourts) CountryRegime(_, _, _ string) (string, string, error) {
|
|
return CountryDE, RegimeUPC, nil
|
|
}
|
|
|
|
func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// proceeding metadata
|
|
jurisdiction := "UPC"
|
|
procID := 1
|
|
pt := ProceedingType{
|
|
ID: procID,
|
|
Code: "upc.inf.cfi",
|
|
Name: "Verletzungsverfahren",
|
|
NameEN: "Infringement",
|
|
Jurisdiction: &jurisdiction,
|
|
IsActive: true,
|
|
}
|
|
|
|
mkID := func() uuid.UUID {
|
|
id, _ := uuid.NewRandom()
|
|
return id
|
|
}
|
|
str := func(s string) *string { return &s }
|
|
procIDPtr := &procID
|
|
|
|
socID := mkID()
|
|
oralID := mkID()
|
|
transID := mkID()
|
|
interpID := mkID()
|
|
|
|
socCode := "upc.inf.cfi.soc"
|
|
oralCode := "upc.inf.cfi.oral"
|
|
transCode := "upc.inf.cfi.translation_request"
|
|
interpCode := "upc.inf.cfi.interpreter_cost"
|
|
|
|
rules := []Rule{
|
|
{
|
|
ID: socID,
|
|
ProceedingTypeID: procIDPtr,
|
|
ParentID: nil,
|
|
SubmissionCode: &socCode,
|
|
Name: "Klageerhebung",
|
|
NameEN: "Statement of Claim",
|
|
PrimaryParty: str("claimant"),
|
|
DurationValue: 0,
|
|
DurationUnit: "months",
|
|
Timing: str("after"),
|
|
SequenceOrder: 0,
|
|
IsCourtSet: false,
|
|
IsActive: true,
|
|
LifecycleState: "published",
|
|
Priority: "mandatory",
|
|
},
|
|
// Translation request: sequence_order BEFORE the oral hearing.
|
|
// Reproduces the real corpus ordering (DB rows 45 < 50).
|
|
{
|
|
ID: transID,
|
|
ProceedingTypeID: procIDPtr,
|
|
ParentID: &oralID,
|
|
SubmissionCode: &transCode,
|
|
Name: "Antrag auf Simultanübersetzung",
|
|
NameEN: "Translation request",
|
|
PrimaryParty: str("both"),
|
|
DurationValue: 1,
|
|
DurationUnit: "months",
|
|
Timing: str("before"),
|
|
SequenceOrder: 45,
|
|
IsCourtSet: false,
|
|
IsActive: true,
|
|
LifecycleState: "published",
|
|
Priority: "optional",
|
|
},
|
|
// Interpreter cost notice: sequence_order BEFORE the oral hearing.
|
|
{
|
|
ID: interpID,
|
|
ProceedingTypeID: procIDPtr,
|
|
ParentID: &oralID,
|
|
SubmissionCode: &interpCode,
|
|
Name: "Mitteilung Dolmetscherkosten",
|
|
NameEN: "Interpreter cost notice",
|
|
PrimaryParty: str("court"),
|
|
DurationValue: 2,
|
|
DurationUnit: "weeks",
|
|
Timing: str("before"),
|
|
SequenceOrder: 46,
|
|
IsCourtSet: false,
|
|
IsActive: true,
|
|
LifecycleState: "published",
|
|
Priority: "mandatory",
|
|
},
|
|
// Oral hearing: court-set, no calculable date. Listed AFTER its
|
|
// "before"-timed children in sequence_order.
|
|
{
|
|
ID: oralID,
|
|
ProceedingTypeID: procIDPtr,
|
|
ParentID: nil,
|
|
SubmissionCode: &oralCode,
|
|
Name: "Mündliche Verhandlung",
|
|
NameEN: "Oral hearing",
|
|
PrimaryParty: str("court"),
|
|
DurationValue: 0,
|
|
DurationUnit: "months",
|
|
Timing: str("after"),
|
|
SequenceOrder: 50,
|
|
IsCourtSet: true,
|
|
IsActive: true,
|
|
LifecycleState: "published",
|
|
Priority: "mandatory",
|
|
},
|
|
}
|
|
|
|
cat := &stubCatalog{pt: pt, rules: rules}
|
|
|
|
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
|
|
if err != nil {
|
|
t.Fatalf("Calculate: %v", err)
|
|
}
|
|
|
|
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
|
for _, d := range timeline.Deadlines {
|
|
byCode[d.Code] = d
|
|
}
|
|
|
|
// The trigger event itself is unambiguous.
|
|
if got := byCode[socCode]; got.DueDate != "2026-05-26" || !got.IsRootEvent {
|
|
t.Errorf("SoC: DueDate=%q IsRootEvent=%v, want 2026-05-26 + IsRootEvent=true", got.DueDate, got.IsRootEvent)
|
|
}
|
|
|
|
// Oral hearing must surface as IsCourtSet (no date).
|
|
oral := byCode[oralCode]
|
|
if oral.DueDate != "" || !oral.IsCourtSet {
|
|
t.Errorf("oral: DueDate=%q IsCourtSet=%v, want empty + IsCourtSet=true", oral.DueDate, oral.IsCourtSet)
|
|
}
|
|
|
|
// The two "before" children of the court-set oral hearing MUST surface
|
|
// as conditional rows (no date, no fabricated arithmetic off the
|
|
// trigger date). The buggy behaviour produces 2026-04-27 and 2026-05-12.
|
|
trans := byCode[transCode]
|
|
if trans.DueDate != "" {
|
|
t.Errorf("translation_request: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", trans.DueDate)
|
|
}
|
|
if !trans.IsConditional && !trans.IsCourtSet {
|
|
t.Errorf("translation_request: IsConditional=%v IsCourtSet=%v, want at least one true", trans.IsConditional, trans.IsCourtSet)
|
|
}
|
|
|
|
interp := byCode[interpCode]
|
|
if interp.DueDate != "" {
|
|
t.Errorf("interpreter_cost: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", interp.DueDate)
|
|
}
|
|
if !interp.IsConditional && !interp.IsCourtSet {
|
|
t.Errorf("interpreter_cost: IsConditional=%v IsCourtSet=%v, want at least one true", interp.IsConditional, interp.IsCourtSet)
|
|
}
|
|
}
|
|
|
|
// TestCalculate_BeforeChildOfCourtSetParent_WithOverride pins the
|
|
// override semantics: when the user supplies an anchor override for
|
|
// the court-set parent, the "before" children should compute against
|
|
// that override date instead of remaining conditional.
|
|
func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
jurisdiction := "UPC"
|
|
procID := 1
|
|
pt := ProceedingType{
|
|
ID: procID,
|
|
Code: "upc.inf.cfi",
|
|
Name: "Verletzungsverfahren",
|
|
Jurisdiction: &jurisdiction,
|
|
IsActive: true,
|
|
}
|
|
|
|
mkID := func() uuid.UUID {
|
|
id, _ := uuid.NewRandom()
|
|
return id
|
|
}
|
|
str := func(s string) *string { return &s }
|
|
procIDPtr := &procID
|
|
|
|
socID := mkID()
|
|
oralID := mkID()
|
|
transID := mkID()
|
|
|
|
socCode := "upc.inf.cfi.soc"
|
|
oralCode := "upc.inf.cfi.oral"
|
|
transCode := "upc.inf.cfi.translation_request"
|
|
|
|
rules := []Rule{
|
|
{
|
|
ID: socID, ProceedingTypeID: procIDPtr, ParentID: nil,
|
|
SubmissionCode: &socCode, Name: "Klageerhebung", NameEN: "SoC",
|
|
PrimaryParty: str("claimant"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
|
|
SequenceOrder: 0, IsActive: true, LifecycleState: "published", Priority: "mandatory",
|
|
},
|
|
{
|
|
ID: transID, ProceedingTypeID: procIDPtr, ParentID: &oralID,
|
|
SubmissionCode: &transCode, Name: "Antrag auf Simultanübersetzung", NameEN: "Translation request",
|
|
PrimaryParty: str("both"), DurationValue: 1, DurationUnit: "months", Timing: str("before"),
|
|
SequenceOrder: 45, IsActive: true, LifecycleState: "published", Priority: "optional",
|
|
},
|
|
{
|
|
ID: oralID, ProceedingTypeID: procIDPtr, ParentID: nil,
|
|
SubmissionCode: &oralCode, Name: "Mündliche Verhandlung", NameEN: "Oral hearing",
|
|
PrimaryParty: str("court"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
|
|
SequenceOrder: 50, IsCourtSet: true, IsActive: true, LifecycleState: "published", Priority: "mandatory",
|
|
},
|
|
}
|
|
|
|
cat := &stubCatalog{pt: pt, rules: rules}
|
|
|
|
// User pins the oral hearing to 2026-10-15.
|
|
opts := CalcOptions{
|
|
AnchorOverrides: map[string]string{
|
|
oralCode: "2026-10-15",
|
|
},
|
|
}
|
|
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
|
|
if err != nil {
|
|
t.Fatalf("Calculate: %v", err)
|
|
}
|
|
|
|
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
|
|
for _, d := range timeline.Deadlines {
|
|
byCode[d.Code] = d
|
|
}
|
|
|
|
if got := byCode[oralCode].DueDate; got != "2026-10-15" {
|
|
t.Errorf("oral: DueDate=%q, want 2026-10-15 (user override)", got)
|
|
}
|
|
|
|
// 1 month before 2026-10-15 = 2026-09-15
|
|
if got := byCode[transCode].DueDate; got != "2026-09-15" {
|
|
t.Errorf("translation_request: DueDate=%q, want 2026-09-15 (1 month before oral override)", got)
|
|
}
|
|
}
|