Files
paliad/pkg/litigationplanner/catalog.go
mAi cd5f752a0e
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
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
2026-05-26 17:48:56 +02:00

80 lines
3.8 KiB
Go

package litigationplanner
import (
"context"
"github.com/google/uuid"
)
// Catalog supplies proceeding-type metadata + rules for the calculator.
//
// Implementations:
// - paliad: reads paliad.deadline_rules + paliad.proceeding_types,
// filtered to lifecycle_state='published' AND is_active=true.
// ProjectHint scopes future per-project rule merges.
// - embedded/upc (Slice C): in-memory map keyed by code, populated
// once at init from the embedded JSON snapshot.
//
// All methods return ErrUnknownProceedingType / ErrUnknownRule when the
// caller asks for a code/id that doesn't exist in the catalog.
type Catalog interface {
// LoadProceeding returns the proceeding-type metadata + the full
// rule list (sorted by sequence_order). Caller passes the user-
// facing proceeding code (e.g. "upc.inf.cfi"). The hint scopes a
// future per-project rule merge — implementations that don't
// support projects ignore it.
LoadProceeding(ctx context.Context, code string, hint ProjectHint) (*ProceedingType, []Rule, error)
// LoadProceedingByID is the resolver used by CalculateRule when it
// has a rule + needs the rule's parent proceeding metadata.
LoadProceedingByID(ctx context.Context, id int) (*ProceedingType, error)
// LoadRuleByID resolves a rule UUID to the rule row. Used by
// CalculateRule when the caller supplies CalcRuleParams.RuleID.
LoadRuleByID(ctx context.Context, ruleID string) (*Rule, error)
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode)
// + returns the parent proceeding for use in the response identity.
// Used by CalculateRule when the caller supplies the (code, local)
// pair from a concept-card pill.
LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*Rule, *ProceedingType, error)
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted
// rules (rules whose trigger_event_id matches). Used by
// EventDeadlineService → Calculate via CalcOptions.TriggerEventIDFilter.
LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]Rule, error)
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows
// for the conditional-label override (t-paliad-294 /
// m/paliad#126). Returns a map keyed by event id; missing ids
// are simply absent (caller treats absence as "no override").
// Empty input returns an empty map without a DB roundtrip.
LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]TriggerEvent, error)
// LookupEvents returns deadline rules matching any subset of the
// requested axes, at the requested sequence depth (Slice B2,
// m/paliad#124 §18.2). Used by the Determinator cascade, the
// scenarios surface (Slice D), and any future "show me events
// matching X" query. Empty result is NOT an error.
//
// Implementations must respect the catalog's "published + active"
// rule gate (rules with lifecycle_state='draft' or is_active=false
// must NEVER appear in the result). Sort order is
// (proceeding_type_id, sequence_order) so the frontend can render
// without re-sorting.
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
// LoadScenarios lists scenarios visible to the caller, narrowed by
// the filter (Slice D, m/paliad#124 §5). Returns an empty slice
// (NOT an error) when no scenarios match. paliad-side impl applies
// RLS (paliad.can_see_project for project-scoped, created_by for
// abstract); snapshot-backed catalogs return an empty list.
LoadScenarios(ctx context.Context, filter ScenarioFilter) ([]Scenario, error)
// MatchScenario returns the scenario with the given id, or
// ErrUnknownScenario if not found / not visible. The engine adapter
// (CalculateFromScenario) calls this to fetch a scenario by id and
// then unpacks its spec via ParseSpec.
MatchScenario(ctx context.Context, id uuid.UUID) (*Scenario, error)
}