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
216 lines
7.8 KiB
Go
216 lines
7.8 KiB
Go
package litigationplanner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Slice D scenarios — m/paliad#124 §5 (revised), mig 145.
|
|
//
|
|
// A Scenario is a named composition of existing proceedings + flags +
|
|
// per-card choices + anchor dates. v1 ships with one primary proceeding
|
|
// per scenario; the spec.proceedings[] array is architected to absorb
|
|
// multi-peer compose (v2) without a schema migration.
|
|
//
|
|
// "users should not add their own rules" (m, t-paliad-301) — the spec
|
|
// references existing rules by submission_code; it never creates new
|
|
// ones. ValidateSpec checks every code/submission resolves against the
|
|
// current catalog before a save is accepted.
|
|
|
|
// Scenario is one row of paliad.scenarios. Wire shape doubles as the
|
|
// API request/response payload for /api/scenarios.
|
|
type Scenario struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
|
Name string `db:"name" json:"name"`
|
|
Description *string `db:"description" json:"description,omitempty"`
|
|
// Spec carries the jsonb composition. Stored raw so we can ship
|
|
// shape evolutions without schema churn; ParseSpec gives the
|
|
// structured view.
|
|
Spec NullableJSON `db:"spec" json:"spec"`
|
|
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
|
}
|
|
|
|
// ScenarioSpec is the parsed view of Scenario.Spec. v1 = version 1.
|
|
// Future shape changes bump the version; ParseSpec rejects unknown
|
|
// versions so an old client doesn't silently misread a future-shape
|
|
// scenario.
|
|
type ScenarioSpec struct {
|
|
Version int `json:"version"`
|
|
BaseTriggerDate string `json:"base_trigger_date"`
|
|
Proceedings []ScenarioProceeding `json:"proceedings"`
|
|
}
|
|
|
|
// ScenarioProceeding is one entry under spec.proceedings[]. v1 honours
|
|
// exactly one with role="primary" (additional entries with role="peer"
|
|
// are reserved for v2 multi-proceeding compose and silently ignored
|
|
// by the engine today).
|
|
type ScenarioProceeding struct {
|
|
Code string `json:"code"`
|
|
Role string `json:"role"` // "primary" | "peer" (v2)
|
|
TriggerDateOverride string `json:"trigger_date_override,omitempty"`
|
|
Flags []string `json:"flags,omitempty"`
|
|
PerCardChoices map[string]ScenarioCardChoice `json:"per_card_choices,omitempty"`
|
|
AnchorOverrides map[string]string `json:"anchor_overrides,omitempty"`
|
|
SkipRules []string `json:"skip_rules,omitempty"`
|
|
AppealTarget string `json:"appeal_target,omitempty"`
|
|
}
|
|
|
|
// ScenarioCardChoice is one entry under
|
|
// spec.proceedings[*].per_card_choices. Mirrors the t-paliad-265 choice
|
|
// kinds; not every kind is populated on every card.
|
|
type ScenarioCardChoice struct {
|
|
Appellant string `json:"appellant,omitempty"`
|
|
IncludeCCR *bool `json:"include_ccr,omitempty"`
|
|
Skip *bool `json:"skip,omitempty"`
|
|
}
|
|
|
|
// Spec version constant.
|
|
const ScenarioSpecVersion = 1
|
|
|
|
// Sentinel errors for scenarios.
|
|
var (
|
|
ErrUnknownScenario = errors.New("unknown scenario")
|
|
ErrInvalidScenario = errors.New("invalid scenario spec")
|
|
ErrScenarioNoPrimary = errors.New("scenario spec has no proceeding with role='primary'")
|
|
)
|
|
|
|
// ScenarioRole* are the canonical role slugs for ScenarioProceeding.Role.
|
|
const (
|
|
ScenarioRolePrimary = "primary"
|
|
ScenarioRolePeer = "peer"
|
|
)
|
|
|
|
// ParseSpec decodes Scenario.Spec into a structured ScenarioSpec. Used
|
|
// by the engine adapter + the rule-editor preview. Surfaces a friendly
|
|
// error wrapping ErrInvalidScenario on malformed JSON / unknown version
|
|
// so the handler can map to a 400.
|
|
func ParseSpec(raw NullableJSON) (*ScenarioSpec, error) {
|
|
if len(raw) == 0 {
|
|
return nil, fmt.Errorf("%w: spec is empty", ErrInvalidScenario)
|
|
}
|
|
var s ScenarioSpec
|
|
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
|
return nil, fmt.Errorf("%w: decode spec: %v", ErrInvalidScenario, err)
|
|
}
|
|
if s.Version != ScenarioSpecVersion {
|
|
return nil, fmt.Errorf("%w: spec.version=%d, want %d",
|
|
ErrInvalidScenario, s.Version, ScenarioSpecVersion)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// PrimaryProceeding returns the entry from spec.proceedings[] with
|
|
// role="primary". Returns ErrScenarioNoPrimary if absent — every spec
|
|
// must carry exactly one primary entry. (Multiple primaries are also
|
|
// rejected: the engine consumes one.)
|
|
func (s *ScenarioSpec) PrimaryProceeding() (*ScenarioProceeding, error) {
|
|
var primary *ScenarioProceeding
|
|
for i := range s.Proceedings {
|
|
if s.Proceedings[i].Role == ScenarioRolePrimary {
|
|
if primary != nil {
|
|
return nil, fmt.Errorf("%w: multiple proceedings with role='primary'", ErrInvalidScenario)
|
|
}
|
|
primary = &s.Proceedings[i]
|
|
}
|
|
}
|
|
if primary == nil {
|
|
return nil, ErrScenarioNoPrimary
|
|
}
|
|
return primary, nil
|
|
}
|
|
|
|
// CalcOptionsFromSpec builds a CalcOptions from the scenario's primary
|
|
// entry. The caller still needs the proceeding code + the trigger date,
|
|
// both returned alongside.
|
|
//
|
|
// v1: only the primary entry is honoured. v2 will iterate over peer
|
|
// entries; the multi-peer merge lives in the paliad-side
|
|
// ProjectionService (one Calculate call per entry, merged + sorted by
|
|
// date).
|
|
func (s *ScenarioSpec) CalcOptionsFromSpec() (proceedingCode, triggerDate string, opts CalcOptions, err error) {
|
|
primary, err := s.PrimaryProceeding()
|
|
if err != nil {
|
|
return "", "", CalcOptions{}, err
|
|
}
|
|
td := s.BaseTriggerDate
|
|
if primary.TriggerDateOverride != "" {
|
|
td = primary.TriggerDateOverride
|
|
}
|
|
if td == "" {
|
|
return "", "", CalcOptions{}, fmt.Errorf("%w: no base_trigger_date and no per-proceeding override", ErrInvalidScenario)
|
|
}
|
|
|
|
perCardAppellant := make(map[string]string, len(primary.PerCardChoices))
|
|
skipRules := make(map[string]struct{}, len(primary.SkipRules))
|
|
includeCCRFor := make(map[string]struct{}, len(primary.PerCardChoices))
|
|
for code, choice := range primary.PerCardChoices {
|
|
if choice.Appellant != "" {
|
|
perCardAppellant[code] = choice.Appellant
|
|
}
|
|
if choice.IncludeCCR != nil && *choice.IncludeCCR {
|
|
includeCCRFor[code] = struct{}{}
|
|
}
|
|
if choice.Skip != nil && *choice.Skip {
|
|
skipRules[code] = struct{}{}
|
|
}
|
|
}
|
|
for _, code := range primary.SkipRules {
|
|
skipRules[code] = struct{}{}
|
|
}
|
|
|
|
return primary.Code, td, CalcOptions{
|
|
Flags: primary.Flags,
|
|
AnchorOverrides: primary.AnchorOverrides,
|
|
AppealTarget: primary.AppealTarget,
|
|
PerCardAppellant: perCardAppellant,
|
|
SkipRules: skipRules,
|
|
IncludeCCRFor: includeCCRFor,
|
|
}, nil
|
|
}
|
|
|
|
// ScenarioFilter narrows Catalog.LoadScenarios. All fields optional:
|
|
//
|
|
// - ProjectID non-nil: only scenarios attached to that project
|
|
// (project_id = filter.ProjectID).
|
|
// - AbstractForUser non-nil: only abstract scenarios (project_id IS
|
|
// NULL) created by that user.
|
|
// - Both nil: list every scenario the caller can see (RLS-gated).
|
|
type ScenarioFilter struct {
|
|
ProjectID *uuid.UUID
|
|
AbstractForUser *uuid.UUID
|
|
}
|
|
|
|
// CalculateFromScenario is the high-level engine entry for scenario-
|
|
// driven rendering. Unpacks the spec, builds CalcOptions, and delegates
|
|
// to Calculate.
|
|
//
|
|
// v1: surfaces only the primary proceeding's timeline. v2 multi-peer
|
|
// expansion lives on the paliad-side ProjectionService (per-entry
|
|
// Calculate + client-side merge); the package doesn't own that
|
|
// orchestration.
|
|
func CalculateFromScenario(
|
|
ctx context.Context,
|
|
scenario *Scenario,
|
|
catalog Catalog,
|
|
holidays HolidayCalendar,
|
|
courts CourtRegistry,
|
|
) (*Timeline, error) {
|
|
spec, err := ParseSpec(scenario.Spec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
code, triggerDate, opts, err := spec.CalcOptionsFromSpec()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return Calculate(ctx, code, triggerDate, opts, catalog, holidays, courts)
|
|
}
|