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
80 lines
3.8 KiB
Go
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)
|
|
}
|