Files
paliad/pkg/litigationplanner/embedded/upc/snapshot.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

316 lines
9.4 KiB
Go

package upc
import (
"context"
"fmt"
"github.com/google/uuid"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// SnapshotCatalog is the embedded-JSON implementation of lp.Catalog.
// All lookups are O(1) on indexed in-memory maps; LookupEvents does a
// linear scan of the rule slice (< 100 rows in the UPC corpus, no
// index needed).
//
// ProjectHint is ignored — the snapshot has no project-scoped rules.
// applies_to_target (B1) and condition_expr (Phase 2) ride along on
// each Rule as ordinary fields; the engine consumes them identically
// whether the catalog is paliad-backed or snapshot-backed.
type SnapshotCatalog struct {
procs []lp.ProceedingType
rules []lp.Rule
triggerByID map[int64]lp.TriggerEvent
rulesByProc map[int][]lp.Rule
ruleByID map[uuid.UUID]lp.Rule
procByID map[int]lp.ProceedingType
procByCode map[string]lp.ProceedingType
rulesByTriggr map[int64][]lp.Rule
}
// NewCatalog parses the embedded snapshot and returns a ready-to-use
// Catalog. Returns an error when the JSON is missing or malformed
// (e.g. snapshot never generated, or stale relative to the package
// types).
func NewCatalog() (*SnapshotCatalog, error) {
var procs []lp.ProceedingType
if err := readJSON("proceeding_types.json", &procs); err != nil {
return nil, err
}
var rules []lp.Rule
if err := readJSON("rules.json", &rules); err != nil {
return nil, err
}
var triggers []lp.TriggerEvent
if err := readJSON("trigger_events.json", &triggers); err != nil {
return nil, err
}
c := &SnapshotCatalog{
procs: procs,
rules: rules,
triggerByID: make(map[int64]lp.TriggerEvent, len(triggers)),
rulesByProc: make(map[int][]lp.Rule),
ruleByID: make(map[uuid.UUID]lp.Rule, len(rules)),
procByID: make(map[int]lp.ProceedingType, len(procs)),
procByCode: make(map[string]lp.ProceedingType, len(procs)),
rulesByTriggr: make(map[int64][]lp.Rule),
}
for _, p := range procs {
c.procByID[p.ID] = p
c.procByCode[p.Code] = p
}
for _, r := range rules {
c.ruleByID[r.ID] = r
if r.ProceedingTypeID != nil {
c.rulesByProc[*r.ProceedingTypeID] = append(c.rulesByProc[*r.ProceedingTypeID], r)
}
if r.TriggerEventID != nil {
c.rulesByTriggr[*r.TriggerEventID] = append(c.rulesByTriggr[*r.TriggerEventID], r)
}
}
for _, t := range triggers {
c.triggerByID[t.ID] = t
}
return c, nil
}
// LoadProceeding returns the proceeding-type metadata + rules. The
// ProjectHint is ignored on the snapshot side (no projects).
func (c *SnapshotCatalog) LoadProceeding(_ context.Context, code string, _ lp.ProjectHint) (*lp.ProceedingType, []lp.Rule, error) {
p, ok := c.procByCode[code]
if !ok {
return nil, nil, lp.ErrUnknownProceedingType
}
// Return a defensive copy of the rule slice so callers can sort /
// mutate without leaking back into the cache.
src := c.rulesByProc[p.ID]
dst := make([]lp.Rule, len(src))
copy(dst, src)
return &p, dst, nil
}
// LoadProceedingByID is the resolver used by CalculateRule.
func (c *SnapshotCatalog) LoadProceedingByID(_ context.Context, id int) (*lp.ProceedingType, error) {
p, ok := c.procByID[id]
if !ok {
return nil, lp.ErrUnknownProceedingType
}
return &p, nil
}
// LoadRuleByID resolves a rule UUID to the rule row.
func (c *SnapshotCatalog) LoadRuleByID(_ context.Context, ruleID string) (*lp.Rule, error) {
id, err := uuid.Parse(ruleID)
if err != nil {
return nil, lp.ErrUnknownRule
}
r, ok := c.ruleByID[id]
if !ok {
return nil, lp.ErrUnknownRule
}
return &r, nil
}
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode).
func (c *SnapshotCatalog) LoadRuleByCode(_ context.Context, proceedingCode, submissionCode string) (*lp.Rule, *lp.ProceedingType, error) {
p, ok := c.procByCode[proceedingCode]
if !ok {
return nil, nil, lp.ErrUnknownProceedingType
}
for _, r := range c.rulesByProc[p.ID] {
if r.SubmissionCode != nil && *r.SubmissionCode == submissionCode {
rr := r
pp := p
return &rr, &pp, nil
}
}
return nil, nil, lp.ErrUnknownRule
}
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules.
func (c *SnapshotCatalog) LoadRulesByTriggerEvent(_ context.Context, triggerEventID int64) ([]lp.Rule, error) {
src := c.rulesByTriggr[triggerEventID]
dst := make([]lp.Rule, len(src))
copy(dst, src)
return dst, nil
}
// LoadTriggerEventsByIDs returns trigger-event rows for the given IDs.
func (c *SnapshotCatalog) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]lp.TriggerEvent, error) {
out := make(map[int64]lp.TriggerEvent, len(ids))
for _, id := range ids {
if t, ok := c.triggerByID[id]; ok {
out[id] = t
}
}
return out, nil
}
// LookupEvents runs the multi-axis filter + depth walk against the
// in-memory rule slice. Mirrors the paliad-side semantics: unknown
// axis values fall through as "no filter on this axis"; anchors are
// depth=1, walked-in children are depth=2+; results ordered by
// (proceeding_type_id, sequence_order).
func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
// Validate axes; unknown values reset to empty (no filter).
jurisdiction := axes.Jurisdiction
if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" &&
jurisdiction != "EPA" && jurisdiction != "DPMA" {
jurisdiction = ""
}
party := axes.Party
if party != "" && !lp.IsValidPrimaryParty(party) {
party = ""
}
appealTarget := axes.AppealTarget
if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) {
appealTarget = ""
}
// First pass: find anchor matches (rules that satisfy every
// non-zero axis directly).
anchors := make(map[uuid.UUID]bool, len(c.rules))
for _, r := range c.rules {
if r.ProceedingTypeID == nil {
continue
}
p := c.procByID[*r.ProceedingTypeID]
if jurisdiction != "" && (p.Jurisdiction == nil || *p.Jurisdiction != jurisdiction) {
continue
}
if axes.ProceedingTypeID != nil && *r.ProceedingTypeID != *axes.ProceedingTypeID {
continue
}
if party != "" && (r.PrimaryParty == nil || *r.PrimaryParty != party) {
continue
}
// EventCategoryID axis: the embedded snapshot doesn't carry
// the deadline_concept_event_types junction (only paliad has
// it). When EventCategoryID is set, we conservatively return
// no matches — youpc.org doesn't use this axis today. Future
// snapshot generations can add a concept→category index if
// needed.
if axes.EventCategoryID != nil {
continue
}
if appealTarget != "" {
found := false
for _, t := range r.AppliesToTarget {
if t == appealTarget {
found = true
break
}
}
if !found {
continue
}
}
anchors[r.ID] = true
}
// Second pass: depth walk. Expand anchors → their immediate
// children (parent_id ∈ matched). Iterate to fixpoint for
// EventLookupDepthAllFollowing; stop after one pass for
// EventLookupDepthNext.
matched := make(map[uuid.UUID]bool, len(anchors))
for id := range anchors {
matched[id] = true
}
if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
for {
grew := false
for _, r := range c.rules {
if matched[r.ID] {
continue
}
if r.ParentID == nil {
continue
}
if matched[*r.ParentID] {
matched[r.ID] = true
grew = true
}
}
if !grew || depth == lp.EventLookupDepthNext {
break
}
}
}
// Compute depth from anchor: walk parent_id chain until we hit
// an anchor.
depths := make(map[uuid.UUID]int, len(matched))
for id := range matched {
if anchors[id] {
depths[id] = 1
continue
}
// Walk up.
d := 1
cur := id
maxIter := len(matched) + 1
for i := 0; i < maxIter; i++ {
r, ok := c.ruleByID[cur]
if !ok || r.ParentID == nil {
break
}
d++
cur = *r.ParentID
if anchors[cur] {
break
}
}
depths[id] = d
}
// Compose output, ordered by (proceeding_type_id, sequence_order)
// via the catalog's rule slice ordering.
out := make([]lp.EventMatch, 0, len(matched))
for _, r := range c.rules {
if !matched[r.ID] {
continue
}
var parentRuleID *uuid.UUID
if r.ParentID != nil && matched[*r.ParentID] {
p := *r.ParentID
parentRuleID = &p
}
proc := lp.ProceedingType{}
if r.ProceedingTypeID != nil {
proc = c.procByID[*r.ProceedingTypeID]
}
out = append(out, lp.EventMatch{
Rule: r,
ProceedingType: proc,
Priority: r.Priority,
DepthFromAnchor: depths[r.ID],
ParentRuleID: parentRuleID,
})
}
return out, nil
}
// LoadScenarios returns an empty slice. The snapshot catalog has no
// scenarios — youpc.org (the consumer today) doesn't carry a project /
// user model. Future snapshot variants could ship demo scenarios, but
// v1 returns nothing.
func (c *SnapshotCatalog) LoadScenarios(_ context.Context, _ lp.ScenarioFilter) ([]lp.Scenario, error) {
return []lp.Scenario{}, nil
}
// MatchScenario always returns ErrUnknownScenario — the snapshot has
// no scenarios to match against.
func (c *SnapshotCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*lp.Scenario, error) {
return nil, lp.ErrUnknownScenario
}
// Compile-time assertion that SnapshotCatalog satisfies lp.Catalog.
var _ lp.Catalog = (*SnapshotCatalog)(nil)
// ErrSnapshotEmpty is returned by NewCatalog when the embedded files
// parse but the corpus is empty (zero proceedings) — almost always a
// sign that the snapshot has never been generated.
var ErrSnapshotEmpty = fmt.Errorf("upc snapshot is empty — run cmd/gen-upc-snapshot")