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
316 lines
9.4 KiB
Go
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")
|