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) }