From 8240717b5aebb2abb4f0fecf2fa7305c49c9e341 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 10:05:50 +0200 Subject: [PATCH 1/3] docs(litigation-planner): pkg/litigationplanner design for paliad + youpc.org reuse (t-paliad-292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inventor design for m/paliad#124. Atomic extract of FristenrechnerService / DeadlineCalculator / proceeding_mapping / SubTrackRoutings / legal-source helpers into pkg/litigationplanner with Catalog / HolidayCalendar / CourtRegistry interfaces. youpc.org reuse via embedded UPC snapshot (catalog.json + holidays.json + courts.json) shipped inside the package. 6 slices: A extract, B catalog interface, C embedded snapshot + generator, D scenarios persistence (project_event_choices.scenario_name), E user-authored rules (deadline_rules.project_id), F youpc-side PR. Q1 + Q2 (material) escalated to head per inventor protocol — NOT AskUserQuestion. Q3-Q5 locked. Decision picks (R) noted; doc holds together under any answer to the open Qs because pkg shape is decoupled from persistence choices. --- docs/design-litigation-planner-2026-05-26.md | 1034 ++++++++++++++++++ 1 file changed, 1034 insertions(+) create mode 100644 docs/design-litigation-planner-2026-05-26.md diff --git a/docs/design-litigation-planner-2026-05-26.md b/docs/design-litigation-planner-2026-05-26.md new file mode 100644 index 0000000..9c61205 --- /dev/null +++ b/docs/design-litigation-planner-2026-05-26.md @@ -0,0 +1,1034 @@ +# Litigation Planner suite — extract Fristenrechner/Verfahrensablauf into a Go package for paliad + youpc.org reuse + +**Task:** t-paliad-292 (m/paliad#124) · inventor design phase · read-only · NO code, NO migrations +**Branch:** `mai/cronus/inventor-litigation` +**Author:** cronus (inventor) +**Date:** 2026-05-26 +**Issue:** https://mgit.msbls.de/m/paliad/issues/124 + +--- + +## §0 TL;DR + +> **paliad + youpc.org both import a single Go package — `pkg/litigationplanner`** — that owns the deadline-rule model, the calendar arithmetic, the condition-expression gate, the sub-track routing, and the timeline composer. Persistence stays at the call-site: paliad's `internal/services/*` implements the `Catalog` / `HolidayCalendar` / `CourtRegistry` interfaces against Postgres; youpc.org imports `embedded/upc.Catalog()` + friends and runs the same engine against a generator-produced JSON snapshot of paliad's UPC subset. + +The convergence: + +1. Today the calc is 1505 LoC in `internal/services/fristenrechner.go` + 175 LoC in `deadline_calculator.go` + helpers, all paliad-internal. +2. Phase 2 unification (t-paliad-181 / Slices 1-10, all shipped) collapsed Pipelines A + B + C onto a single `paliad.deadline_rules` table — 231 rows, 20 active proceeding types, lifecycle_state-gated, condition_expr-gated, 77 UPC rules. +3. The body to extract is already structurally a library; the work is **moving it across the package boundary atomically and replacing the DB-coupled imports with abstract `Catalog` / `HolidayCalendar` / `CourtRegistry` interfaces**. +4. youpc.org's UPC-restricted reuse rides on an embedded JSON snapshot (UPC subset + UPC/EU holidays + UPC courts) shipped inside the package — `import "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"` is the entire integration surface on the youpc side. +5. Scenarios + user-authored rules are paliad-side persistence concerns that the package's request shape (`CalcRequest`) already supports via `RuleOverrides` + per-card `Choices`. The pkg need not grow new contracts for either; only paliad's catalog impl + paliad-side tables grow. + +Six slices, each independently shippable on the paliad side; one paired PR on the youpc side at the end: + +- **Slice A** — atomic extract of calc + types + condition_expr + sub-track + legal-source helpers into `pkg/litigationplanner`. No behaviour change. paliad's `internal/services/fristenrechner.go` becomes a 60-line shell. +- **Slice B** — `Catalog` / `HolidayCalendar` / `CourtRegistry` interfaces + paliad's default loaders implementing them. +- **Slice C** — embedded UPC snapshot + `scripts/snapshot-upc/main.go` generator. +- **Slice D** — scenarios persistence (`paliad.project_event_choices.scenario_name` + `paliad.projects.active_scenario`). +- **Slice E** — user-authored rules (`paliad.deadline_rules.project_id` nullable column + catalog merge). +- **Slice F** — youpc.org integration (separate PR on the youpc repo). + +m's two locked decisions: +- **Package within paliad** (option 1, not a separate repo). ✓ +- **Inventor must escalate Q1+Q2 to head via `mai instruct`, not AskUserQuestion.** ✓ + +--- + +## §1 Today's surfaces converging + +Three surfaces share the same compute core today. The package crystallises that: + +### 1. Fristenrechner (`/tools/fristenrechner`) +Knowledge-tool surface. Manual deadline calculation: user picks proceeding type + trigger date + optional flags + per-card choices, gets a flat list of computed deadlines. Backed by `FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions) (*UIResponse, error)`. Implemented at `internal/services/fristenrechner.go:270` and consumed by `internal/handlers/fristenrechner.go`. + +Path-A (abstract browse via `?path=a`) and Path-B (Determinator concept-cascade via `?path=b`, t-paliad-166) feed the same Calculate. + +### 2. Verfahrensablauf (`/tools/verfahrensablauf`) +Knowledge-tool surface. Same compute, different chrome — abstract browse-a-proceeding with variant chips + consolidated-vs-lane view + side-by-side compare (t-paliad-178/179, shipped). The frontend lifts a shared `client/views/verfahrensablauf-core.ts` module; both routes call the SAME `Calculate` endpoint, just with different UI framing. + +### 3. SmartTimeline (`/projects/{id}` Verlauf) +Per-project read view. `ProjectionService.For(ctx, projectID, opts) (*ResponseEnvelope, error)` at `internal/services/projection_service.go:1` builds the merged stream: +- Past actuals from `paliad.deadlines ∪ appointments ∪ project_events` (filtered to opted-in `timeline_kind`) +- Future-projected rows from `FristenrechnerService.Calculate(...)` driven by the project's `proceeding_type_id` + anchor map built from already-completed actuals +- Off-script events (counterclaim_created, scope_change, custom_milestone) + +`ProjectionService` is paliad-only — it speaks Postgres directly (loads project + counterclaim children + actuals, builds the anchor override map, merges + sorts the events, applies levelPolicy at Patent/Litigation/Client). The **compute step** inside it (the `FristenrechnerService.Calculate` call at projection_service.go:813) is the only part that crosses into the library. + +### What converges, what doesn't + +| Concern | Goes into `pkg/litigationplanner` | Stays in `internal/services` | +|---|---|---| +| Rule model (`Rule`, `ProceedingType`) | ✓ | — | +| Calendar arithmetic + working-day walker | ✓ | — | +| condition_expr jsonb evaluator | ✓ | — | +| Sub-track routing | ✓ | — | +| Legal-source display/URL formatting | ✓ | — | +| `MapLitigationToFristenrechner` | ✓ | — | +| `CalcOptions` (flags, anchor overrides, per-card choices, skip rules) | ✓ | — | +| `Calculate` + `CalculateRule` (the pure entry points) | ✓ | — | +| `Catalog` / `HolidayCalendar` / `CourtRegistry` interfaces | ✓ | — | +| paliad-specific Catalog impl (reads `paliad.deadline_rules`) | — | ✓ (slim) | +| paliad-specific HolidayCalendar impl (reads `paliad.holidays`) | — | ✓ | +| paliad-specific CourtRegistry impl (reads `paliad.courts`) | — | ✓ | +| `ProjectionService` (per-project SmartTimeline) | — | ✓ (no change) | +| Scenarios persistence (table writes) | — | ✓ | +| User-authored rule writes (insert into `paliad.deadline_rules`) | — | ✓ (rule editor) | +| Audit log + RLS | — | ✓ | +| HTTP handlers + UI response JSON tags | — | ✓ (`UIResponse` shape kept byte-identical) | + +Verfahrensablauf and `/tools/fristenrechner` become thin wrappers over the package; SmartTimeline still owns its merge logic but delegates the future-projection step to the package. + +--- + +## §2 Target package layout + +``` +pkg/litigationplanner/ + doc.go — package docstring, reuse manifesto, version banner + types.go — Rule, ProceedingType, Court, Holiday, CalcOptions, + Timeline, TimelineEntry, RuleCalculation, ConditionExpr + catalog.go — Catalog interface + ProjectHint + holidays.go — HolidayCalendar interface + AdjustForNonWorkingDays + + AdjustForNonWorkingDaysBackward + courts.go — CourtRegistry interface + DefaultsForJurisdiction + expr.go — condition_expr jsonb evaluator (pure) + durations.go — applyDuration + addWorkingDays (pure, takes a + HolidayCalendar) + subtrack.go — SubTrackRouting registry (statically embedded — + the 1 entry today is upc.ccr.cfi → upc.inf.cfi) + legal_source.go — FormatLegalSourceDisplay + BuildLegalSourceURL + proceeding_mapping.go — MapLitigationToFristenrechner + code constants + engine.go — Calculate(ctx, req CalcRequest, cat Catalog, + hol HolidayCalendar, crt CourtRegistry) (*Timeline, error) + + CalculateRule(...) for the single-rule v4 surface + embedded/ + upc/ + catalog.go — //go:embed catalog.json + NewCatalog() Catalog + catalog.json — 9 proceeding types × 77 rules (generator output) + holidays.go — //go:embed holidays.json + NewHolidayCalendar() + holidays.json — UPC summer vacation + UPC public holidays + EU + public holidays (regime='UPC' + country='DE'+'FR'+ + 'NL'+'IT' subsets paliad already carries) + courts.go — //go:embed courts.json + NewCourtRegistry() + courts.json — UPC LDs + CFI + CoA + LDC, country/regime tagged + version.go — //go:embed VERSION (paliad release tag the + snapshot was generated against) + snapshot_test.go — golden test: every rule's compute matches the + reference fixture compute + scripts/ + snapshot/ + main.go — reads paliad.deadline_rules etc. via $DATABASE_URL, + writes embedded/upc/{catalog,holidays,courts}.json + + bumps VERSION + README.md — "How to regenerate the UPC snapshot" + testdata/ + fixtures/ — input + expected-output JSON pairs (per proceeding + type, per scenario) for golden tests + README.md — "How to import this package" + CHANGELOG.md — package-internal changelog (semver beats) +``` + +### Why a sub-package per snapshot + +`embedded/upc/` (and any future `embedded/de/`, `embedded/epa/`, …) sits below `pkg/litigationplanner/` so the snapshot is opt-in: youpc.org imports the snapshot, paliad does **not** (paliad runs against its live DB). Importing the snapshot pulls the JSON into the binary; consumers who only want the engine pay zero binary bloat. + +### Why no `scenarios/` sub-package + +m's framing — "create scenarios" — is **paliad-side persistence**: name a state, store it, switch active. The package's `CalcRequest` already accepts the only field that varies per scenario (`Choices` + `AnchorOverrides`). Scenarios are a paliad table; the package needs no new contract. See §5. + +--- + +## §3 Public API + types + +### Rule + +```go +// Rule is the canonical deadline rule shape. JSON tags match the +// paliad.deadline_rules columns 1:1 so the generator can dump rows +// straight into the snapshot. +type Rule struct { + ID uuid.UUID `json:"id"` + ProceedingTypeID int `json:"proceeding_type_id"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + SubmissionCode *string `json:"submission_code,omitempty"` + Name string `json:"name"` + NameEN string `json:"name_en"` + Description *string `json:"description,omitempty"` + PrimaryParty *string `json:"primary_party,omitempty"` + EventType *string `json:"event_type,omitempty"` + + DurationValue int `json:"duration_value"` + DurationUnit string `json:"duration_unit"` // days|weeks|months|working_days + Timing *string `json:"timing,omitempty"` // after|before + AltDurationValue *int `json:"alt_duration_value,omitempty"` + AltDurationUnit *string `json:"alt_duration_unit,omitempty"` + AltRuleCode *string `json:"alt_rule_code,omitempty"` + CombineOp *string `json:"combine_op,omitempty"` // min|max + AnchorAlt *string `json:"anchor_alt,omitempty"` // priority_date + + RuleCode *string `json:"rule_code,omitempty"` + DeadlineNotes *string `json:"deadline_notes,omitempty"` + DeadlineNotesEN *string `json:"deadline_notes_en,omitempty"` + SequenceOrder int `json:"sequence_order"` + + ConditionExpr json.RawMessage `json:"condition_expr,omitempty"` + Priority string `json:"priority"` // mandatory|recommended|optional|informational + IsCourtSet bool `json:"is_court_set"` + IsSpawn bool `json:"is_spawn"` + SpawnLabel *string `json:"spawn_label,omitempty"` + SpawnProceedingTypeID *int `json:"spawn_proceeding_type_id,omitempty"` + LegalSource *string `json:"legal_source,omitempty"` + ConceptID *uuid.UUID `json:"concept_id,omitempty"` + IsBilateral bool `json:"is_bilateral"` + TriggerEventID *int64 `json:"trigger_event_id,omitempty"` + ChoicesOffered json.RawMessage `json:"choices_offered,omitempty"` + + // Lifecycle fields are part of the schema but irrelevant to the + // calculator (a non-published rule never reaches the engine). They + // ship in the snapshot for traceability but the engine ignores them. + LifecycleState string `json:"lifecycle_state"` + DraftOf *uuid.UUID `json:"draft_of,omitempty"` + PublishedAt *time.Time `json:"published_at,omitempty"` +} +``` + +### ProceedingType + +```go +type ProceedingType struct { + ID int `json:"id"` + Code string `json:"code"` // upc.inf.cfi + Name string `json:"name"` + NameEN string `json:"name_en"` + Description *string `json:"description,omitempty"` + Jurisdiction *string `json:"jurisdiction,omitempty"` // UPC|DE|EPA|DPMA + DefaultColor string `json:"default_color"` + SortOrder int `json:"sort_order"` + DisplayOrder int `json:"display_order"` + TriggerEventLabelDE *string `json:"trigger_event_label_de,omitempty"` + TriggerEventLabelEN *string `json:"trigger_event_label_en,omitempty"` +} +``` + +### Catalog interface + +```go +// Catalog supplies proceeding-type metadata + rules. Implementations: +// - paliad: SELECTs from paliad.deadline_rules + paliad.proceeding_types, +// filtered to lifecycle_state='published' AND is_active=true. Optional +// ProjectHint (Slice E) merges in project-scoped rules. +// - embedded/upc: in-memory map keyed by code, populated once at init +// from the embedded JSON. +type Catalog interface { + Proceeding(ctx context.Context, code string, hint ProjectHint) (*ProceedingType, []Rule, error) + SubTrackRouting(code string) (SubTrackRouting, bool) +} + +// ProjectHint scopes a Catalog call to a specific project. paliad's +// catalog uses ProjectID to merge in rules with project_id = hint.ProjectID +// (Slice E). youpc's catalog ignores the hint (no projects exist). +// +// Zero value = no project context (the abstract Verfahrensablauf / +// public Fristenrechner case). +type ProjectHint struct { + ProjectID uuid.UUID +} + +var ErrUnknownProceedingType = errors.New("unknown proceeding type") +``` + +### HolidayCalendar interface + +```go +// HolidayCalendar adjusts dates onto working days for a given +// (country, regime) pair. The package's calc only needs three primitives: +// +// - IsNonWorkingDay — used by addWorkingDays walker +// - AdjustForNonWorkingDays — forward snap (timing='after') +// - AdjustForNonWorkingDaysBackward — backward snap (timing='before') +// +// Implementations: +// - paliad: reads paliad.holidays, caches per-year, merges DE federal +// fallback. Existing HolidayService at internal/services/holidays.go +// already does this; gains a thin satisfying-the-interface shim. +// - embedded/upc: in-memory year-keyed map populated from the embedded +// JSON snapshot. Covers UPC summer vacation, UPC public holidays, +// and the country-tagged subsets paliad ships for UPC LDs (DE, FR, +// NL, IT — the countries the LDs sit in). +type HolidayCalendar interface { + IsNonWorkingDay(date time.Time, country, regime string) bool + AdjustForNonWorkingDays(date time.Time, country, regime string) (time.Time, bool) + AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (time.Time, bool) + AdjustmentReason(date time.Time, country, regime string) *AdjustmentReason +} + +type AdjustmentReason struct { + Country string `json:"country,omitempty"` + Regime string `json:"regime,omitempty"` + Name string `json:"name,omitempty"` + IsVacation bool `json:"isVacation,omitempty"` + IsClosure bool `json:"isClosure,omitempty"` +} +``` + +### CourtRegistry interface + +```go +// CourtRegistry maps a court id (e.g. "upc-ld-paris", "de-bgh") to its +// (country, regime) tuple, which drives non-working-day adjustment. +// +// Implementations: +// - paliad: reads paliad.courts. Existing CourtService.CountryRegime +// already does this lookup with a fallback to defaults. +// - embedded/upc: in-memory map populated from the embedded JSON. +// Carries every UPC LD + CFI + CoA, country-tagged. +type CourtRegistry interface { + CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error) +} + +// DefaultsForJurisdiction is the fallback used when CourtID is empty. +// Identical to the existing helper in internal/services. Pure function, +// no I/O — lives directly in pkg/litigationplanner. +func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) +``` + +### CalcRequest + CalcOptions + +```go +type CalcRequest struct { + ProceedingCode string + TriggerDate time.Time + Options CalcOptions +} + +// CalcOptions carries the optional knobs. Same shape as today's +// internal/services.CalcOptions — verbatim, no field rename. +type CalcOptions struct { + PriorityDate *time.Time + Flags []string + AnchorOverrides map[string]time.Time + CourtID string + + // Catalog hint — only paliad-side catalogs consume this. + ProjectHint ProjectHint + + // Event-driven branch (event_trigger_service callers) + TriggerEventIDFilter *int64 + + // Editor preview / sandbox + RuleOverrides []Rule + + // Per-card choices (t-paliad-265) + PerCardAppellant map[string]string + SkipRules map[string]struct{} + IncludeCCRFor map[string]struct{} + IncludeHidden bool +} +``` + +### Timeline (result) + +```go +// Timeline is the package's structured return. Aligns with paliad's +// internal/services.UIResponse field-for-field so paliad's HTTP +// handlers serve it directly with no shim. +type Timeline struct { + ProceedingType string `json:"proceedingType"` + ProceedingName string `json:"proceedingName"` + ProceedingNameEN string `json:"proceedingNameEN,omitempty"` + TriggerDate string `json:"triggerDate"` + Entries []TimelineEntry `json:"deadlines"` + ContextualNote string `json:"contextualNote,omitempty"` + ContextualNoteEN string `json:"contextualNoteEN,omitempty"` + TriggerEventLabel string `json:"triggerEventLabel,omitempty"` + TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"` + HiddenCount int `json:"hiddenCount"` +} + +// TimelineEntry == paliad's UIDeadline. Same JSON tags. +type TimelineEntry struct { /* identical to current UIDeadline */ } +``` + +The package emits `Timeline`; paliad's handler aliases `Timeline → UIResponse` and `TimelineEntry → UIDeadline` (type aliases keep call-sites byte-identical): + +```go +// internal/services/fristenrechner.go (post-Slice-A, ~60 lines) +type ( + UIResponse = litigationplanner.Timeline + UIDeadline = litigationplanner.TimelineEntry + CalcOptions = litigationplanner.CalcOptions + AdjustmentReason = litigationplanner.AdjustmentReason +) + +func (s *FristenrechnerService) Calculate(ctx context.Context, code, dateStr string, opts CalcOptions) (*UIResponse, error) { + td, err := time.Parse("2006-01-02", dateStr) + if err != nil { return nil, fmt.Errorf("invalid trigger date %q: %w", dateStr, err) } + return litigationplanner.Calculate(ctx, litigationplanner.CalcRequest{ + ProceedingCode: code, TriggerDate: td, Options: opts, + }, s.catalog, s.holidays, s.courts) +} +``` + +paliad's handlers, projection_service, event_deadline_service all call `FristenrechnerService.Calculate(...)` exactly as today. + +--- + +## §4 Catalog interface — embedded snapshot design (Q3.A.1) + +### Snapshot generation + +`pkg/litigationplanner/scripts/snapshot/main.go` is run **inside paliad's repo against paliad's DB** (it's a paliad-side generator, paliad-only `$DATABASE_URL` requirement). It: + +1. Connects to `$DATABASE_URL` (paliad's Postgres). +2. Reads `paliad.proceeding_types` filtered to `is_active=true` AND `category='fristenrechner'` AND `jurisdiction IN ('UPC')`. (One generator-level flag selects the jurisdiction subset; today only `UPC` is exported. Future generator runs can produce `embedded/de/` or `embedded/epa/`.) +3. For each proceeding, reads `paliad.deadline_rules` filtered to `proceeding_type_id IN (...) AND is_active=true AND lifecycle_state='published' AND project_id IS NULL`. (project_id filter ensures user-authored rules from Slice E don't leak into the snapshot.) +4. Reads `paliad.holidays` for the past 5 + next 10 years (configurable), filtered to entries that apply to a UPC court (`regime='UPC' OR country IN ('DE','FR','NL','IT')`). +5. Reads `paliad.courts` for the UPC subset (`regime='UPC'`). +6. Writes `embedded/upc/catalog.json` + `embedded/upc/holidays.json` + `embedded/upc/courts.json` + `embedded/upc/VERSION` (= current paliad git tag, e.g. `v0.42.0`). +7. Re-renders the snapshot test's golden fixtures (or fails if the snapshot diverges from the live calc in an unexpected way — the test compute-loop guards against silent corruption). +8. Exits non-zero on any DB integrity check failure (orphan rules, dangling FKs, etc.). + +```bash +# Regenerate the UPC snapshot. +DATABASE_URL=$PALIAD_DB go run ./pkg/litigationplanner/scripts/snapshot \ + --jurisdiction=UPC \ + --output=./pkg/litigationplanner/embedded/upc \ + --tag=$(git describe --tags --always) +go test ./pkg/litigationplanner/embedded/upc/... +``` + +### Snapshot consumer (youpc.org) + +youpc.org imports the snapshot once at boot: + +```go +import ( + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" + upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc" +) + +var ( + catalog lp.Catalog = upc.NewCatalog() + holidays lp.HolidayCalendar = upc.NewHolidayCalendar() + courts lp.CourtRegistry = upc.NewCourtRegistry() +) + +func handleFristenrechner(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("proceeding") + dateStr := r.URL.Query().Get("trigger_date") + td, _ := time.Parse("2006-01-02", dateStr) + timeline, err := lp.Calculate(r.Context(), lp.CalcRequest{ + ProceedingCode: code, + TriggerDate: td, + // No project hint (youpc has no projects), no flags by default. + }, catalog, holidays, courts) + // ... render JSON ... +} +``` + +The snapshot constructors are zero-arg, the in-memory maps populate on first call (sync.Once), and the binary footprint of the embedded JSON is small (77 rules × ~1KB per rule + ~5KB courts + ~25KB holidays ≈ 110KB total). + +### Why the JSON shape mirrors the schema + +The snapshot stores rows in close to their DB shape (snake_case JSON tags, same field names) so a future schema addition (a new column on `paliad.deadline_rules`) is a one-line update: add the field to `Rule`, regenerate the snapshot, ship a minor-version bump. The package itself never needs to know what the columns ARE — it just consumes the `Rule` struct. + +### What the snapshot does NOT include + +- `paliad.deadline_concepts` (rule grouping for cascade) — not needed for compute. The cascade UI is paliad-only. +- `paliad.event_categories` (Determinator B1 taxonomy) — paliad-only UI. +- `paliad.event_types` (concept default event-type hydration) — not needed for compute. youpc rendering can re-derive from rule metadata. +- `paliad.trigger_events` (event-driven branch) — out of scope for v1 youpc; if needed later, generator gains a `--with-events` flag. +- All paliad-state tables (projects, deadlines, project_event_choices, audit log, …). + +--- + +## §5 Scenarios design (Q1) + +### m's framing + +> Where we can add proceeding types or specific submissions and then get the whole sequence with options for the steps so we can create scenarios etc as well. + +"Scenarios" = named "what if" projections for a project. The user has a UPC INF case; they want to compare: +- "what if we accept the offer to amend" (with_amend flag) +- "what if we file a CCR" (with_ccr flag) +- "what if both" (with_amend + with_ccr) +- "court-extended replik by 1 month" (anchor override on inf.reply) + +Each of these is a complete set of choices. Today they're URL state (`?with_ccr&with_amend&choices=...`). Scenarios make them named + persisted + switchable from the project page. + +### Recommended (R) — Option A from the brief, refined + +Extend `paliad.project_event_choices` with a `scenario_name` column; add `paliad.projects.active_scenario` to track the user's current pick. No new table. + +#### Schema + +```sql +-- Slice D migration (paliad-side, NOT in pkg/litigationplanner). +ALTER TABLE paliad.project_event_choices + ADD COLUMN scenario_name text NOT NULL DEFAULT 'default'; + +-- Composite PK now includes scenario_name so a project can have N parallel +-- scenarios each with their own choice set. +ALTER TABLE paliad.project_event_choices + DROP CONSTRAINT IF EXISTS project_event_choices_pkey; +ALTER TABLE paliad.project_event_choices + ADD PRIMARY KEY (project_id, scenario_name, submission_code, choice_kind); + +ALTER TABLE paliad.projects + ADD COLUMN active_scenario text NOT NULL DEFAULT 'default'; + +CREATE INDEX project_event_choices_scenario_idx + ON paliad.project_event_choices(project_id, scenario_name); +``` + +The default scenario (`'default'`) is automatic — every existing row backfills there, every new project starts there. The user is never *required* to name a scenario. + +#### Endpoints + +- `GET /api/projects/{id}/scenarios` — list scenario names + which is active. +- `POST /api/projects/{id}/scenarios` — create a new named scenario (clones the choices of the source scenario name or empty if first). +- `PUT /api/projects/{id}/scenarios/{name}/active` — set as active scenario. +- `DELETE /api/projects/{id}/scenarios/{name}` — remove (cannot delete `'default'`; cannot delete the active one without picking another first). +- The existing `POST /api/projects/{id}/event-choices` learns to write against the active scenario (or accept an explicit `scenario_name` query param). + +#### UI + +- Project page sub-header gains a "Szenario" chip group above SmartTimeline: `[default ▾] + Neu`. +- Click chip → switch active → SmartTimeline re-renders against new choice set. +- "+ Neu" → modal: name + base ("Leer" or clone-from-current). +- SmartTimeline + Akte-mode Fristenrechner both read the active scenario's choices. + +#### Why this design + +- **No new table** — the existing `project_event_choices` table already keys on `(project_id, submission_code, choice_kind)`. Adding `scenario_name` to the PK partitions naturally. +- **Backward compat** — every existing row gets `scenario_name='default'`; nothing breaks. +- **Library-side neutrality** — the package never sees scenarios. paliad's catalog/handler reads which scenario is active, builds the `CalcOptions` for it, and calls `Calculate`. youpc.org doesn't need scenarios at all (no projects). +- **Symmetry with Verfahrensablauf** — Verfahrensablauf stays URL-only (no projects, no persistence) — that surface is for abstract exploration, where URL-state IS the "scenario" (shareable, ephemeral, no name). + +#### Rejected: Option B (new `paliad.scenarios` table) + +Cleaner separation but duplicates the (project_id, choices) relationship. The composite-PK extension on `project_event_choices` is one column + one DEFAULT + one PK swap — strictly less work, no duplication. + +#### Rejected: Option C (URL-only, no persistence) + +Loses the "name it, switch to it" affordance m's framing implies. URL-only scenarios already exist on Verfahrensablauf; the project-page version needs more. + +### Escalation to head (Q1) + +The package design works for any of A / B / C. The schema choice is paliad-side. **Recommendation = A** as designed above. If m flags a need for cross-project scenario sharing later (firm-shared templates), B becomes the cleaner base; A → B is a one-migration upgrade. + +--- + +## §6 User-authored rules design (Q2) + +### m's framing + +> we can add proceeding types or specific submissions and then get the whole sequence with options for the steps + +Two scopes hidden in one sentence: + +**(a) Per-project rule additions** — "on this specific case, we filed an unusual motion X with deadline Y for response". Common enough that a lawyer wants to type it once and have it ride the timeline. + +**(b) New proceeding TYPES** — entirely new flows (e.g. Spanish patent litigation, Australian Federal Court). Much bigger surface; would need an editor for proceeding-type metadata too. + +### Recommended (R) — (a) for v1, defer (b) + +#### Schema + +```sql +-- Slice E migration (paliad-side). +ALTER TABLE paliad.deadline_rules + ADD COLUMN project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE CASCADE; + +CREATE INDEX deadline_rules_project_id_idx + ON paliad.deadline_rules(project_id, proceeding_type_id, sequence_order) + WHERE project_id IS NOT NULL; + +-- Existing rules (231 rows) all have project_id IS NULL → global. +-- New project-scoped rules have project_id NOT NULL. +-- Pre-existing UNIQUE constraints on (proceeding_type_id, submission_code) +-- continue to apply because they don't include project_id; we add a +-- supplementary constraint that allows project-scoped rules to use any +-- submission_code namespace independent of global rules: +ALTER TABLE paliad.deadline_rules + DROP CONSTRAINT IF EXISTS deadline_rules_unique_proceeding_submission; +ALTER TABLE paliad.deadline_rules + ADD CONSTRAINT deadline_rules_unique_proceeding_submission + UNIQUE NULLS NOT DISTINCT (proceeding_type_id, submission_code, project_id); +``` + +#### Catalog merge + +paliad's `DeadlineRuleService.List` learns the project hint: + +```go +func (s *DeadlineRuleService) ListForProceeding(ctx context.Context, ptID int, hint litigationplanner.ProjectHint) ([]models.DeadlineRule, error) { + if hint.ProjectID == uuid.Nil { + // Knowledge-tool / abstract surface: global rules only. + return s.list(ctx, ptID, nil) + } + // Per-project surface (SmartTimeline + Akte-mode Fristenrechner): + // global rules + this project's scoped rules, merged on sequence_order. + return s.list(ctx, ptID, &hint.ProjectID) +} +``` + +The merge ordering uses `sequence_order` — project-scoped rules pick their own slot via the rule-editor UI. Parent-child chains across the boundary work (a project-scoped rule can have `parent_id` pointing at a global rule, but not vice-versa — DB CHECK enforces). + +#### Rule-editor UI + +Slice 11 of t-paliad-181 (the admin rule editor, partially shipped) is the natural anchor. Extend it with: +- A "Add project-scoped rule" affordance on the project page: opens the same editor scoped to `?project=` → writes with `project_id` set + `created_by` = current user. +- Visibility: project-scoped rules show with a "Akte-spezifisch" badge in admin views; non-admin users only see them on the project they belong to. +- Reuses the rule editor's lifecycle (`draft → published → archived`) so users can iterate. + +#### Why (a) for v1, not (b) + +- (a) adds ONE column. (b) would need user-authored proceeding_types (5 new columns), code-namespace coordination, fristenrechner-vs-archived category routing, and the cascade taxonomy (event_categories) backfilling for the new type. Much bigger. +- m's strong-signal use case is "filed an unusual motion" — that's (a), not (b). +- (b) can be added on top of (a) when the demand surfaces (e.g. a Spanish jurisdiction expansion). No design dead-end. + +#### Library-side neutrality + +The package's `Catalog.Proceeding(ctx, code, hint)` signature already passes `ProjectHint`. paliad's catalog respects it; youpc's catalog ignores it. No package change beyond making the hint field exist (which Slice B does as part of the interface). + +#### User-authored rules NEVER leak to youpc.org + +The snapshot generator filters on `project_id IS NULL` (§4). Per-project rules are paliad-owned business data; youpc.org gets the public-knowledge UPC subset only. + +### Escalation to head (Q2) + +The package design works for any of (a) / (a)+(b) / (c) defer. **Recommendation = (a)**. If m wants (b) in scope, we add a Slice E2 for user-authored proceeding types — but I'd argue defer until a real demand surfaces. + +--- + +## §7 youpc.org integration plan + +### Repo split discipline + +- **paliad repo** (`m/paliad`) — owns the package, owns the generator, owns the snapshot. Every paliad release tag is a snapshot vintage. +- **youpc repo** (`m/youpc.org`) — owns the youpc-side UI, imports the package via Go module. +- **No cross-repo coupling at runtime.** youpc.org talks to its own DB; never reaches into paliad's DB. + +### Import path + +```go +import ( + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" + upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc" +) +``` + +youpc's `go.mod` pins a specific paliad version: + +``` +require mgit.msbls.de/m/paliad v0.42.0 +``` + +Updates are explicit — `go get -u mgit.msbls.de/m/paliad@v0.43.0` is the only way to pick up new rules. + +### UPC-only restriction + +The restriction is **structural**: youpc imports `embedded/upc` and only `embedded/upc`. The catalog only knows about UPC proceeding codes; asking for `de.inf.lg` returns `ErrUnknownProceedingType`. The restriction isn't an enforcement flag in the engine; it's a property of the snapshot binding. + +If youpc.org ever needs EPA, a separate `embedded/epa` ships and youpc imports it explicitly. Two snapshots, two imports, two surface scopes. + +### UX on youpc.org + +Scope: +- A new public route, e.g. `/laws/upc/fristenrechner`, that renders the same Fristenrechner UX (variant chips + flat result list + per-card choices). +- A new public route, e.g. `/laws/upc/verfahrensablauf`, that renders the abstract Verfahrensablauf (compare two timelines side-by-side, variant chips). + +Both routes share the package's `Calculate` entry; only the chrome differs. youpc.org's existing visual language drives the rendering — design lift from paliad/frontend is OUT OF SCOPE for this design (covered by a separate task on the youpc repo, Slice F). + +### Persistence + +youpc.org doesn't persist anything related to the planner — no projects, no scenarios, no user-authored rules. URL state IS the state. This matches the existing youpc model where /laws is a knowledge surface, not a workspace. + +### Auth + +The Catalog API is anonymous. youpc.org can put the route behind a login if it wants (premium-beta gate, etc.), but the package itself doesn't gate. + +### Telemetry + +youpc.org can wrap `lp.Calculate` in its own telemetry shim. The package doesn't emit telemetry directly — keeps the dependency chain clean. + +--- + +## §8 Migration plan (Q4: atomic move) + +### Why atomic, not duplicate-then-DRY + +The current `internal/services/fristenrechner.go` body IS the package's reason to exist. Duplicating its logic into `pkg/litigationplanner` while leaving the original in place creates two compute paths that will drift the moment a Wave 3 rule shape lands. The risk of drift outweighs the risk of a single bigger PR. + +Slice A is atomic. After Slice A: +- `pkg/litigationplanner/*.go` — full body, exported. +- `internal/services/fristenrechner.go` — ~60 lines, the shell. +- `internal/services/deadline_calculator.go` — ~30 lines, the shell (or deleted entirely once `DeadlineCalculator` is just `litigationplanner.Calculator`). +- `internal/services/event_deadline_service.go` — uses `litigationplanner.Calculate(...)` with `TriggerEventIDFilter` set. +- All tests still pass. + +### File-move map (Slice A, illustrative — coder will adjust) + +| From | To | Notes | +|---|---|---| +| `internal/services/deadline_calculator.go` | `pkg/litigationplanner/durations.go` | Plus `addWorkingDays` + `applyDuration` from `fristenrechner.go`. | +| `internal/services/fristenrechner.go` Calculate body (lines 270–780) | `pkg/litigationplanner/engine.go` | Renamed `litigationplanner.Calculate`. | +| `internal/services/fristenrechner.go` CalculateRule body | `pkg/litigationplanner/engine.go` | Renamed `litigationplanner.CalculateRule`. | +| `internal/services/fristenrechner.go` evalConditionExpr | `pkg/litigationplanner/expr.go` | Already pure. | +| `internal/services/proceeding_mapping.go` (entirety) | `pkg/litigationplanner/proceeding_mapping.go` | Pure. | +| `internal/services/proceeding_mapping.go` SubTrackRoutings | `pkg/litigationplanner/subtrack.go` | Pure registry. | +| `internal/services/deadline_search_service.go` FormatLegalSourceDisplay + BuildLegalSourceURL | `pkg/litigationplanner/legal_source.go` | Both pure. | +| `internal/services/fristenrechner.go` UIResponse, UIDeadline, CalcOptions, CalcRuleParams, AdjustmentReason | `pkg/litigationplanner/types.go` | Renamed Timeline / TimelineEntry / CalcOptions / RuleCalcParams / AdjustmentReason. paliad keeps type aliases for back-compat. | +| `internal/services/holidays.go` HolidayService | (stays, becomes Catalog impl) | Plus the package gets the interface. | +| `internal/services/courts.go` CourtService | (stays, becomes Catalog impl) | Same. | +| `internal/services/deadline_rule_service.go` DeadlineRuleService | (stays, becomes Catalog impl) | Same. | + +### Test discipline + +- All current tests under `internal/services/fristenrechner_test.go`, `deadline_calculator_test.go`, `projection_service_test.go`, `event_deadline_service_test.go` continue to run **unchanged** in paliad — they test the paliad-side wrappers which delegate to the package. +- The package gets its own `pkg/litigationplanner/*_test.go` with fixture-driven tests: input JSON → expected output JSON, covering every rule shape (composite, alt-swap, anchor-override, sub-track, condition_expr, court-set indirect, choice-skip, choice-include-CCR, …). +- `pkg/litigationplanner/embedded/upc/snapshot_test.go` is a golden test: run the engine against the snapshot for every (proceeding, trigger_date) fixture, assert byte-equality with the expected output. This is the regression net for the snapshot generator. + +### Rollback + +Slice A is one PR. Revert = revert. Once Slice C lands and youpc.org takes a dep on the snapshot (Slice F), revert means coordinating the youpc.org module pin — but that's a normal multi-repo coordination, not a one-way door. + +### Slice ordering hard constraints + +- **A blocks B**: B introduces the Catalog interface; A must put types into the package first. +- **B blocks C**: C populates the snapshot via the Catalog interface. +- **D + E are independent of A-C** but should land after at least Slice B so paliad's catalog is the merge point. +- **F (youpc) blocks on C** at minimum. Ideally also on D + E so the snapshot is stable. + +--- + +## §9 Versioning + release process (Q5) + +### Semver discipline on the package + +- `v0.x.y` — pre-1.0. The package is intentionally below the 1.0 stability bar until paliad-internal + youpc-internal usage is settled (probably 2-3 months of co-existence). +- `v1.0.0` — declare API frozen; new fields are additive only; removals require a major bump. + +### What counts as a breaking change? + +- Rename a public function / type / field → breaking. +- Change the JSON tags of `Rule` / `ProceedingType` / `Timeline` → breaking (the snapshot file shape changes too). +- Change the meaning of a function (e.g. `CountryRegime` now returns an error on unknown court) → breaking. +- Drop a `Catalog` / `HolidayCalendar` / `CourtRegistry` method → breaking. + +### What is additive (non-breaking)? + +- Add a new field to `Rule` / `Timeline` / `CalcOptions` → additive (struct fields are open). +- Add a new constant / helper / sub-package → additive. +- Add a new optional field on `CalcRequest` → additive if zero-value is back-compat. +- Add a new entry to `SubTrackRoutings` → additive (data, not API). +- Add a new proceeding type to the snapshot → additive (data). +- Add new rules to existing proceeding types in the snapshot → additive (data). + +### Snapshot versioning vs API versioning + +Two clocks: + +- **API version** — bumped on package API change. Lives in `pkg/litigationplanner/doc.go` (`const Version = "..."`). +- **Snapshot vintage** — bumped on every regeneration. Lives in `pkg/litigationplanner/embedded/upc/VERSION` (auto-set to the paliad git tag at generator time). + +A breaking schema addition (e.g. a new column on `paliad.deadline_rules` that the snapshot ships) is API-version-additive (struct gains a field) but snapshot-vintage-bumping. youpc.org sees it on its next `go get -u`. + +A deletion of a column (rare) is API-version-breaking — requires a major bump. + +### Release flow + +1. paliad merge to main bumps the package version when it touches `pkg/litigationplanner/`. +2. Snapshot regeneration is triggered MANUALLY by the operator after material rule changes (rule editor Slice 11b, batch migrations, etc.). Process: `go run ./pkg/litigationplanner/scripts/snapshot` → review diff → commit → tag → push. +3. youpc.org's CI runs `go get -u` weekly (or on-demand) and opens a PR with the new dep version. Human reviews + merges. + +### Drift detection + +A test on paliad's side compares the snapshot at HEAD against the live DB and fails if the rule set has diverged without a snapshot bump: + +```bash +# pkg/litigationplanner/scripts/snapshot/check.sh — runs in paliad CI +DATABASE_URL=$PALIAD_DB go run ./pkg/litigationplanner/scripts/snapshot \ + --jurisdiction=UPC --dry-run --diff-against=./pkg/litigationplanner/embedded/upc +``` + +If the diff is non-empty, the build fails with "snapshot drift — regenerate after reviewing". This catches accidental forgetting; nothing prevents intentional drift. + +### Why semver, not date-stamped versions + +Go modules need semver. `v0.42.0` slots into `require`; `v2026-05-26` doesn't. The snapshot VERSION file can carry both (semver + date) for human readability. + +--- + +## §10 Slice plan + +Each slice is independently shippable on the paliad side. Slice F is a separate PR on the youpc.org repo. + +### Slice A — extract calc + types into `pkg/litigationplanner` (no behaviour change) + +**Scope** — paliad-side extraction. Move: + +- `Rule` / `ProceedingType` / `Court` / `Holiday` types into `pkg/litigationplanner/types.go`. +- `applyDuration`, `addWorkingDays`, `DeadlineCalculator` body into `pkg/litigationplanner/durations.go`. +- `evalConditionExpr` + `hasConditionExpr` into `pkg/litigationplanner/expr.go`. +- `MapLitigationToFristenrechner` + code constants into `pkg/litigationplanner/proceeding_mapping.go`. +- `SubTrackRoutings` + `LookupSubTrackRouting` into `pkg/litigationplanner/subtrack.go`. +- `FormatLegalSourceDisplay` + `BuildLegalSourceURL` into `pkg/litigationplanner/legal_source.go`. +- `DefaultsForJurisdiction` into `pkg/litigationplanner/courts.go`. +- `Calculate` + `CalculateRule` bodies into `pkg/litigationplanner/engine.go` — using the Catalog / HolidayCalendar / CourtRegistry interfaces. +- Type aliases in `internal/services/fristenrechner.go` so call-sites continue to import `services.UIResponse` etc. + +**Test discipline** — all existing tests under `internal/services/*_test.go` continue to pass unchanged. Add package-side tests for the pure functions (which are mostly already there as table-driven tests; move them across). + +**Exit criteria**: +- `go build ./...` clean. +- `go test ./...` green, no regressions. +- `internal/services/fristenrechner.go` is < 100 lines and consists of the type aliases + thin `Calculate`/`CalculateRule` shells. + +**~1700 LoC moved, ~60 LoC remaining on the paliad side.** + +### Slice B — Catalog / HolidayCalendar / CourtRegistry interfaces + paliad's default loaders + +**Scope** — define the three interfaces in the package; have paliad's existing `DeadlineRuleService`, `HolidayService`, `CourtService` implement them with shim methods. Slice A already calls Calculate against the interfaces, so this is purely about wiring the production paths. + +**Files**: +- `pkg/litigationplanner/catalog.go` — `Catalog` + `ProjectHint`. +- `pkg/litigationplanner/holidays.go` — `HolidayCalendar`. +- `pkg/litigationplanner/courts.go` — `CourtRegistry`. +- `internal/services/deadline_rule_service.go` — add `Proceeding(ctx, code, hint)` method satisfying `Catalog`. +- `internal/services/holidays.go` — already has `IsNonWorkingDay` / `AdjustForNonWorkingDays`; add `AdjustForNonWorkingDaysBackward` shim if not exposed. +- `internal/services/courts.go` — `CountryRegime` already exists, satisfies `CourtRegistry`. + +**Exit criteria**: paliad's wire-up uses the interfaces; no behaviour change. + +### Slice C — embedded UPC snapshot + generator script + +**Scope** — write the generator + emit the first snapshot + the snapshot consumer. + +**Files**: +- `pkg/litigationplanner/scripts/snapshot/main.go` — generator (CLI binary). +- `pkg/litigationplanner/scripts/snapshot/README.md` — operator runbook. +- `pkg/litigationplanner/embedded/upc/catalog.go` + `.json` + `holidays.go` + `.json` + `courts.go` + `.json` + `VERSION`. +- `pkg/litigationplanner/embedded/upc/snapshot_test.go` — golden tests. + +**Exit criteria**: +- `go run ./pkg/litigationplanner/scripts/snapshot --jurisdiction=UPC` emits a non-empty snapshot. +- `lp.Calculate(ctx, req, upc.NewCatalog(), upc.NewHolidayCalendar(), upc.NewCourtRegistry())` returns the same Timeline for every fixture as the paliad-side Catalog does. +- A snapshot-drift test fails on intentional drift to prove the detector works. + +### Slice D — scenarios persistence (Q1) + +**Scope** — paliad-side schema + endpoints + UI. + +**Files**: +- `internal/db/migrations/134_project_event_choices_scenario.up.sql` + `.down.sql` — add `scenario_name` + new PK + `active_scenario` on `paliad.projects`. +- `internal/services/scenario_service.go` (new) — Create/List/SetActive/Delete. +- `internal/handlers/scenarios.go` (new) — wire endpoints. +- `internal/services/event_choice_service.go` — learn to write/read against active scenario. +- `internal/services/projection_service.go` — consume active scenario's choices when building CalcOptions. +- `frontend/src/components/ScenarioChips.tsx` (new) + `frontend/src/client/projects-detail.ts` — UI surface. + +**Exit criteria**: +- Project page shows scenario chips; create/switch/delete works. +- SmartTimeline + Akte-mode Fristenrechner respect active scenario. +- Existing projects function unchanged (everything in 'default'). + +### Slice E — user-authored rules (Q2) + +**Scope** — paliad-side schema + rule-editor extension. + +**Files**: +- `internal/db/migrations/135_deadline_rules_project_id.up.sql` + `.down.sql` — nullable `project_id` column + composite uniqueness adjustment. +- `internal/services/deadline_rule_service.go` — learn `ListForProceeding(ctx, code, hint)` with merge semantics. +- `internal/services/rule_editor_service.go` — extend to scope writes to a project_id when set. +- `internal/handlers/rule_editor.go` — accept `?project=` scope param. +- `frontend/src/projects-detail.tsx` + `client/projects-detail.ts` — "Akte-spezifische Frist hinzufügen" affordance. + +**Exit criteria**: +- A project-scoped rule appears in that project's SmartTimeline + Akte-Fristenrechner. +- The same rule does NOT appear in the public `/tools/fristenrechner` or in any other project. +- The snapshot generator's filter (`project_id IS NULL`) prevents leakage to youpc. + +### Slice F — youpc.org integration (separate repo) + +**Scope** — youpc-side import + UI + routes. This is a youpc repo PR; out-of-scope for this paliad task. Document the integration contract here so the youpc-side worker has a clean handover. + +**Contract**: +- `go get mgit.msbls.de/m/paliad@` in youpc.org. +- Boot-time wire: `catalog = upc.NewCatalog()` (sync.Once-protected init). +- One handler per knowledge surface (Fristenrechner, Verfahrensablauf). Each calls `lp.Calculate(...)`. +- UX lift from paliad's frontend out of scope of the package; youpc.org's design system drives the UI. +- youpc's CI sets up a weekly Renovate-style PR for paliad version bumps. + +**Out of scope of this design** — youpc's UX, youpc's auth gate, youpc's analytics, youpc's i18n. Those belong to a sibling task on the youpc.org repo. + +### Slice ordering + +``` +A → B → C → F (youpc integration on C) + ↘ + D + E (parallel, paliad-only, depend on B for catalog merge points) +``` + +A is the prerequisite for everything. B is the prerequisite for C, D, E. C is the prerequisite for F. D and E are parallel after B. + +--- + +## §11 Risk assessment + rollback + +### Risks + +1. **Slice A regression risk.** Moving 1700 LoC across a package boundary while preserving behaviour is the biggest single-PR risk in this work. **Mitigation**: every existing test must keep passing; add a regression suite at the package boundary before moving anything; the type-alias bridge means call-sites need no edits. + +2. **Snapshot drift between paliad and youpc.** A paliad-side rule change that the operator forgets to snapshot leaves youpc stale. **Mitigation**: snapshot-drift CI check (§9) fails the build on un-snapshotted drift. Operator regenerates manually; one-line command. + +3. **API churn during pre-1.0.** Sub-1.0 means breaking changes are allowed, and youpc.org will pick them up on `go get -u`. **Mitigation**: every breaking change is called out in `pkg/litigationplanner/CHANGELOG.md`; youpc.org pins exact versions during pre-1.0. + +4. **Catalog merge ambiguity (Slice E).** A project-scoped rule with the same `submission_code` as a global rule — does it override or coexist? **Mitigation**: the unique constraint `(proceeding_type_id, submission_code, project_id) NULLS NOT DISTINCT` allows ONE global + N project-scoped to coexist. The catalog returns both; the engine sees both rows. Ordering by `sequence_order` decides which renders first. Document this explicitly in the rule-editor UX (no silent override). + +5. **Scenarios PK migration is destructive.** Slice D drops the existing PK and recreates it. **Mitigation**: small table (~50 rows in live data), standard `DROP CONSTRAINT` + `ADD CONSTRAINT` is atomic in a single transaction; down-migration restores. + +6. **youpc.org snapshot binary footprint.** ~110KB embedded JSON in youpc's binary. **Mitigation**: trivial. Not a risk in practice. + +7. **Spawn cycle guard ownership.** Today `ProjectionService.expandCrossProceedingSpawns` carries the visited-set + maxSpawnDepth cycle guard. After the move, the guard lives in the package (it's part of `Calculate`). **Mitigation**: lift the test fixtures along with the code; the cycle-guard test stays green by construction. + +### Rollback + +- **Slice A**: revert the PR. paliad runs against the pre-move code; no DB change, no consumer impact. +- **Slice B**: revert the PR. Calls to interfaces fall back to direct calls on services. +- **Slice C**: revert the PR. paliad unaffected; youpc.org not yet integrated. +- **Slice D**: revert + run the down-migration. project_event_choices loses `scenario_name`, projects loses `active_scenario`; user data preserved (one row per choice). +- **Slice E**: revert + run the down-migration. Project-scoped rules become orphaned (column drop wipes them — coder should snapshot to a sidecar table before drop if any user has authored rules). Alternatively roll forward with bug fix. +- **Slice F**: youpc.org pins a previous paliad version. Trivial. + +--- + +## §12 Out of scope + +- youpc.org UX, layout, design-system lift — separate task on the youpc repo (Slice F's content). +- Cross-firm rule sharing (one firm's user-authored rules visible to another firm). Defer until the firm-multitenancy story is real. +- Multi-jurisdiction snapshots (`embedded/de/`, `embedded/epa/`). The generator gains a `--jurisdiction` flag in Slice C; the additional snapshots are a follow-up task per jurisdiction when demand surfaces. +- Re-architecting `ProjectionService` to live in the package. It speaks Postgres directly to load project + counterclaim children + actuals, which IS paliad-specific business logic. Only its single `Calculate(...)` call crosses the boundary. +- Replacing the existing rule editor UX. Slice E extends it for project-scoped rules; no redesign. +- Live cross-repo CI integration (auto-PR on paliad release tagging youpc.org). Manual `go get -u` workflow for now; automation deferred. +- Anthropic/Paliadin AI integration with the planner (e.g. natural-language scenario creation). Out of scope. +- Frontend type generation from the package's Go types. The TS interfaces in `frontend/src/types.ts` already match the JSON shape; preserve manually until a generator is worth the effort. +- Snapshot signing / provenance attestation. youpc.org trusts paliad's release tag; cryptographic integrity is a follow-up. + +--- + +## §13 Open questions for m (escalated via `mai instruct head`) + +Per the inventor → head gate, the inventor does NOT call AskUserQuestion. The questions below are forwarded to `paliad/head`; m's answers come back via head's reply. + +### Q1 — Scenarios storage model (material) + +Recommendation: **Option A — extend `paliad.project_event_choices` with `scenario_name` text NOT NULL DEFAULT 'default' + add `paliad.projects.active_scenario`**. Reasoning §5. Alternatives: +- B: new `paliad.scenarios` table. +- C: URL-only scenarios, no persistence. + +m's call gates Slice D. The library's shape doesn't depend on which is picked. + +### Q2 — User-authored rules scope (material) + +Recommendation: **Option (a) — per-project rule additions via `paliad.deadline_rules.project_id` nullable column**. Reasoning §6. Alternatives: +- (a)+(b): also support new user-authored proceeding types. +- (c): defer entirely. + +m's call gates Slice E. If (b) is in scope, an additional Slice E2 is needed. + +### Q3 — Locked by m + +Package-within-project. Embedded snapshot with generator. **Recommendation honoured.** + +### Q4 — Locked by inventor (matches brief default) + +Atomic move from `internal/services` into `pkg/litigationplanner`. **No m gate needed**; head approves the engineering plan. + +### Q5 — Locked by inventor (matches brief default) + +Semver discipline, additive-by-default API, snapshot-vintage separate from API version. **No m gate needed**; head approves. + +### Q6 — Snapshot regeneration cadence (tactical) + +Manual operator command vs CI cron? Recommendation: **manual** during pre-1.0; revisit after 3 months of usage. **Head decides.** + +### Q7 — Snapshot drift policy on paliad CI (tactical) + +Fail the build on un-snapshotted drift, or warn? Recommendation: **warn during pre-1.0** so paliad-side rule edits don't block development; fail starting at v1.0. **Head decides.** + +### Q8 — Package version cadence (tactical) + +Bump on every PR that touches `pkg/litigationplanner/`, or batch into milestone releases? Recommendation: **bump on every PR**, since semver is cheap and the operator burden is one line. **Head decides.** + +### Q9 — Rule-editor extension for Slice E (tactical, depends on Q2) + +If Q2 = (a), where does the "Akte-spezifische Frist" affordance live in the project page? Recommendation: **a small "+ Frist hinzufügen" button under the SmartTimeline header**, opens the existing rule editor scoped to the project. **Head decides** (UX placement). + +### Q10 — youpc.org module pin strategy (tactical, advisory) + +`require mgit.msbls.de/m/paliad v0.x.y` — exact, ^-ranged, or auto-updating? Recommendation: **exact pin** through pre-1.0; tilde-ranged at v1.x. **Head decides + flags to the youpc-side worker.** + +### Q11 — Snapshot-bound proceeding scope (tactical) + +The brief says "youpc UPC-restricted". The 9 UPC proceeding types are explicit. But: should the snapshot also include `epa.grant.exa` (EP grant publication is UPC-relevant for priority-date math)? Recommendation: **start with UPC only**; if a UPC visitor's deadline depends on EPA rules, surface a stub error that points them at paliad.de. **Head decides.** + +--- + +## §14 m's decisions (placeholder — will be filled after head returns answers) + +This section will be added when `paliad/head` relays m's answers to Q1, Q2, and any escalated tactical Qs. Per inventor protocol, the design doc gets an addendum here; the rest of the doc is the historical record. + +For now, the inventor's picks (R) stand as proposed; the design holds together with any of the alternative pickings on Q1 / Q2 because the package shape is decoupled from the persistence choices. + +--- + +## §15 Recommended implementer + +Slice A is the biggest mechanical lift; recommend a **pattern-fluent Sonnet coder** with deep paliad-go-services familiarity. Cronus (this inventor) has carried t-paliad-133, t-paliad-291, and the design context here, so a same-worker shift to coder mode is one of the head's options for Slice A. + +Slices B + C + D + E are smaller and parallel-friendly. Different coders can pick them up after A lands. + +Slice F is a youpc-side task; it needs a worker with youpc-go familiarity (a separate hire on the youpc.org project). + +--- + +## §16 Trade-offs flagged + +- **Atomic Slice A vs incremental** — atomic carries one large-PR regression risk; incremental carries drift risk. Picked atomic. §8 justifies. +- **Embedded snapshot vs read-replica access from youpc** — snapshot wins on independence + offline use, loses on freshness. Picked snapshot. §4 justifies. +- **`scenario_name` PK extension vs new `scenarios` table** — column extension wins on simplicity + back-compat. Picked column. §5 justifies. +- **Per-project rules nullable `project_id` vs separate table** — nullable column wins on merge simplicity (one SELECT, two WHERE clauses). Picked nullable. §6 justifies. +- **Type aliases (`UIResponse = Timeline`) vs full rename** — aliases keep call-sites byte-identical; rename forces a separate noise-PR. Picked aliases. §3 justifies. +- **Snapshot in package vs separate `paliad-snapshots` repo** — in-package wins on simplicity; separate repo wins on release independence. Picked in-package per m's lock. §4 justifies. +- **Public surface (UPC subset only) vs full multi-jurisdiction snapshot** — UPC-only matches the brief; multi-jurisdiction is opt-in via generator flag. Compatible. + +--- + +## §17 Files of note for future workers + +**Anchor files (read these before touching anything):** +- `internal/services/fristenrechner.go` (1505 LoC) — the body to extract. Slice A moves most of it. +- `internal/services/deadline_calculator.go` (175 LoC) — already library-shaped. +- `internal/services/projection_service.go` (2214 LoC) — STAYS in `internal/services` (paliad-specific). Slice A makes it import from the package. +- `internal/services/deadline_rule_service.go` (352 LoC) — Slice B turns this into a Catalog impl. +- `internal/services/holidays.go` (413 LoC) — Slice B turns this into a HolidayCalendar impl. +- `internal/services/courts.go` (~150 LoC) — Slice B turns this into a CourtRegistry impl. +- `internal/services/proceeding_mapping.go` (191 LoC) — Slice A moves it. +- `internal/services/event_deadline_service.go` — uses `Calculate(... TriggerEventIDFilter ...)`. Slice A updates the import path. +- `internal/services/rule_editor_service.go` — Slice E extends with `project_id` writes. +- `internal/services/event_choice_service.go` — Slice D extends with `scenario_name` reads/writes. + +**Design doc cross-references:** +- `docs/audit-fristen-logic-2026-05-13.md` (pauli, t-paliad-157) — the audit that justified the Phase 2 unification. +- `docs/design-fristen-phase2-2026-05-15.md` (pauli, t-paliad-181) — the unified rule model, partially / largely shipped. +- `docs/design-smart-timeline-2026-05-08.md` (lagrange, t-paliad-169) — `ProjectionService` design. +- `docs/design-tools-cleanup-2026-05-12.md` (kelvin, t-paliad-178) — Fristenrechner vs Verfahrensablauf split. +- `docs/design-determinator-row-cascade-2026-05-13.md` (pauli, t-paliad-166) — Determinator cascade — paliad-side UI, unaffected by this design. +- `docs/design-event-card-choices-2026-05-25.md` — per-card choice surface; the `CalcOptions` extension that this design preserves verbatim. + +--- + +*End of design doc.* From 6e585951ee23b747272f5f71fc6ca5cdbf0269d9 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 10:21:23 +0200 Subject: [PATCH 2/3] =?UTF-8?q?docs(litigation-planner):=20fold=20m's=20As?= =?UTF-8?q?kUserQuestion=20picks=20=E2=80=94=20new=20paliad.scenarios=20ta?= =?UTF-8?q?ble=20+=20jsonb=20spec,=20no=20user-authored=20rules=20(t-palia?= =?UTF-8?q?d-292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m's 2026-05-26 decisions: - Q1 composition: primary+spawned (v1) with multi-proceeding peer compose as v2 goal — jsonb spec architected for N entries from day 1 - Q2 scope: per-project + abstract (project_id NULL = abstract saved templates) - Q3 dates: per-anchor overrides over one base date (matches today's compute) - Q4 storage: new paliad.scenarios table with jsonb spec (NOT project_event_choices column extension) - "users should not add their own rules" — original Slice E (user-authored rules) DROPPED, replaced with abstract scenarios surface on /tools/verfahrensablauf §5 rewritten with new schema (paliad.scenarios + active_scenario_id FK), jsonb spec shape (proceedings[] array, version-tagged), validate-on-load discipline, multi-peer v2 path. §6 struck-through with original body preserved as historical context. §10 slice plan revised: Slice E = abstract scenarios surface, not user-authored rules. §0.5 added with decision matrix; §13 marked resolved. Package shape (§2 §3) unchanged — library was decoupled from persistence/UI choices by design. --- docs/design-litigation-planner-2026-05-26.md | 294 +++++++++++++------ 1 file changed, 202 insertions(+), 92 deletions(-) diff --git a/docs/design-litigation-planner-2026-05-26.md b/docs/design-litigation-planner-2026-05-26.md index 9c61205..dc43a13 100644 --- a/docs/design-litigation-planner-2026-05-26.md +++ b/docs/design-litigation-planner-2026-05-26.md @@ -8,6 +8,27 @@ --- +## §0.5 m's decisions (2026-05-26, AskUserQuestion round) + +After Q1+Q2 escalation, m chose AskUserQuestion-mode + revised Q2 framing. Four-question round answered live: + +| Q | Topic | m's pick | Note | +|---|---|---|---| +| §0.5-Q1 | Composition shape | **Primary + spawned (v1)** with **multi-proceeding peer compose as the goal (v2)** | "Usually it would be 1 — but 2 is the goal. So people can create more complex scenarios." The jsonb spec is architected for N entries from day 1; v1 ships with N=1; v2 adds peers without a schema migration. | +| §0.5-Q2 | Scope | **Per-project + abstract** | Scenarios attach to a real Akte (project_id non-NULL) AND can be created standalone on /tools/verfahrensablauf (project_id NULL = abstract saved templates). | +| §0.5-Q3 | Trigger dates | **Per-anchor overrides over one base date** | Matches today's compute model — one trigger date + AnchorOverrides for known court-set / extended dates. Per-proceeding dates inside the spawn graph derive from base + parent chain. | +| §0.5-Q4 | Storage | **New `paliad.scenarios` table with jsonb spec** | Beats the column-extension option because the spec carries multi-proceeding compose, abstract-vs-project distinction, and absorbs future schema churn inside jsonb. | + +**Additional clarification (m's text):** *"users should not add their own rules. I want them to setup a scenario with different existing proceeding types / submissions that trigger sequences."* + +→ **Original Slice E (user-authored rules) is DROPPED.** `paliad.deadline_rules.project_id` column is OUT. Scenarios are pure compositions of existing rules/proceedings/submissions — never authored corpus. §6 below is preserved as historical context; the slice plan in §10 is revised. + +→ **New Slice E** = abstract scenarios surface on `/tools/verfahrensablauf` (Speichern affordance + per-user saved-template list). Different chrome, same scenarios table, same engine. + +§5 has been rewritten to match these picks; §6 is preserved struck-through; §10 slice plan is revised. The package layout in §2 and the public API in §3 are unchanged by these decisions — the library shape was decoupled by design from persistence + UI choices. + +--- + ## §0 TL;DR > **paliad + youpc.org both import a single Go package — `pkg/litigationplanner`** — that owns the deadline-rule model, the calendar arithmetic, the condition-expression gate, the sub-track routing, and the timeline composer. Persistence stays at the call-site: paliad's `internal/services/*` implements the `Catalog` / `HolidayCalendar` / `CourtRegistry` interfaces against Postgres; youpc.org imports `embedded/upc.Catalog()` + friends and runs the same engine against a generator-produced JSON snapshot of paliad's UPC subset. @@ -20,18 +41,20 @@ The convergence: 4. youpc.org's UPC-restricted reuse rides on an embedded JSON snapshot (UPC subset + UPC/EU holidays + UPC courts) shipped inside the package — `import "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"` is the entire integration surface on the youpc side. 5. Scenarios + user-authored rules are paliad-side persistence concerns that the package's request shape (`CalcRequest`) already supports via `RuleOverrides` + per-card `Choices`. The pkg need not grow new contracts for either; only paliad's catalog impl + paliad-side tables grow. -Six slices, each independently shippable on the paliad side; one paired PR on the youpc side at the end: +Six slices, each independently shippable on the paliad side; one paired PR on the youpc side at the end (revised per §0.5): - **Slice A** — atomic extract of calc + types + condition_expr + sub-track + legal-source helpers into `pkg/litigationplanner`. No behaviour change. paliad's `internal/services/fristenrechner.go` becomes a 60-line shell. - **Slice B** — `Catalog` / `HolidayCalendar` / `CourtRegistry` interfaces + paliad's default loaders implementing them. - **Slice C** — embedded UPC snapshot + `scripts/snapshot-upc/main.go` generator. -- **Slice D** — scenarios persistence (`paliad.project_event_choices.scenario_name` + `paliad.projects.active_scenario`). -- **Slice E** — user-authored rules (`paliad.deadline_rules.project_id` nullable column + catalog merge). +- **Slice D** — `paliad.scenarios` table + jsonb spec + per-project chip group. Composition-of-existing-rules (NOT user-authored rules — §6 retracted). +- **Slice E** — abstract scenarios on `/tools/verfahrensablauf` (Speichern als Vorlage + Meine Vorlagen list). Same table, `project_id IS NULL`. - **Slice F** — youpc.org integration (separate PR on the youpc repo). -m's two locked decisions: +m's locked decisions (latest = §0.5): - **Package within paliad** (option 1, not a separate repo). ✓ -- **Inventor must escalate Q1+Q2 to head via `mai instruct`, not AskUserQuestion.** ✓ +- **AskUserQuestion permitted this session** — m flipped the protocol mid-session. ✓ +- **Scenarios = jsonb spec, per-project + abstract, primary+spawned for v1, multi-peer for v2.** ✓ +- **No user-authored rules. Composition only.** ✓ --- @@ -443,86 +466,175 @@ The snapshot stores rows in close to their DB shape (snake_case JSON tags, same --- -## §5 Scenarios design (Q1) +## §5 Scenarios design (m's picks — REVISED 2026-05-26) -### m's framing +### m's framing (verbatim) -> Where we can add proceeding types or specific submissions and then get the whole sequence with options for the steps so we can create scenarios etc as well. +> Where we can add proceeding types or specific submissions and then get the whole sequence with options for the steps so we can create scenarios etc as well. […] users should not add their own rules. I want them to setup a scenario with different existing proceeding types / submissions that trigger sequences. -"Scenarios" = named "what if" projections for a project. The user has a UPC INF case; they want to compare: -- "what if we accept the offer to amend" (with_amend flag) -- "what if we file a CCR" (with_ccr flag) -- "what if both" (with_amend + with_ccr) -- "court-extended replik by 1 month" (anchor override on inf.reply) +A scenario is a **named composition** built from existing proceedings, existing submissions, existing flags, and anchor dates. It is not authored corpus — every rule it references is already in `paliad.deadline_rules`. The scenario glues them together with the user's choices + dates and produces a complete timeline. -Each of these is a complete set of choices. Today they're URL state (`?with_ccr&with_amend&choices=...`). Scenarios make them named + persisted + switchable from the project page. - -### Recommended (R) — Option A from the brief, refined - -Extend `paliad.project_event_choices` with a `scenario_name` column; add `paliad.projects.active_scenario` to track the user's current pick. No new table. +### Recommended design — `paliad.scenarios` table with jsonb spec #### Schema ```sql -- Slice D migration (paliad-side, NOT in pkg/litigationplanner). -ALTER TABLE paliad.project_event_choices - ADD COLUMN scenario_name text NOT NULL DEFAULT 'default'; +CREATE TABLE paliad.scenarios ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE CASCADE, + -- project_id NULL = abstract scenario (saved Verfahrensablauf template). + -- project_id NOT NULL = attached to an Akte (the common case). + name text NOT NULL, + description text NULL, + spec jsonb NOT NULL, + -- spec carries the full composition; see §5.1. + created_by uuid NULL REFERENCES paliad.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + -- Within a single project, names are unique. Abstract scenarios are + -- unique per (created_by, name) so two users can each have a "with_ccr" + -- saved template without colliding. + CONSTRAINT scenarios_unique_per_scope UNIQUE NULLS NOT DISTINCT + (project_id, created_by, name) +); --- Composite PK now includes scenario_name so a project can have N parallel --- scenarios each with their own choice set. -ALTER TABLE paliad.project_event_choices - DROP CONSTRAINT IF EXISTS project_event_choices_pkey; -ALTER TABLE paliad.project_event_choices - ADD PRIMARY KEY (project_id, scenario_name, submission_code, choice_kind); +CREATE INDEX scenarios_project_id_idx ON paliad.scenarios(project_id); +CREATE INDEX scenarios_abstract_user_idx + ON paliad.scenarios(created_by) WHERE project_id IS NULL; +-- Active scenario per project. NULL = use today's ad-hoc choice state +-- (paliad.project_event_choices) — pre-scenario behaviour preserved. ALTER TABLE paliad.projects - ADD COLUMN active_scenario text NOT NULL DEFAULT 'default'; + ADD COLUMN active_scenario_id uuid NULL + REFERENCES paliad.scenarios(id) ON DELETE SET NULL; -CREATE INDEX project_event_choices_scenario_idx - ON paliad.project_event_choices(project_id, scenario_name); +-- RLS: project-scoped scenarios inherit team visibility via +-- paliad.can_see_project(project_id). Abstract scenarios are +-- created_by-owner-only (private) for v1; later we can add a shared flag. ``` -The default scenario (`'default'`) is automatic — every existing row backfills there, every new project starts there. The user is never *required* to name a scenario. +`paliad.project_event_choices` is **NOT modified** by this design. It continues to store the un-named "current" choice state for back-compat; once a project switches to a saved scenario, the engine reads from `scenarios.spec` instead. + +#### Spec shape (jsonb) + +Architected for multi-proceeding compose from day 1 — v1 ships with N=1 entries under `proceedings[]`; v2 lifts to N=peer. + +```json +{ + "version": 1, + "base_trigger_date": "2026-05-26", + "proceedings": [ + { + "code": "upc.inf.cfi", + "role": "primary", + "flags": ["with_ccr", "with_amend"], + "per_card_choices": { + "inf.r30_amend": {"appellant": "claimant"}, + "inf.rejoin": {"include_ccr": true} + }, + "anchor_overrides": { + "inf.reply": "2026-08-15", + "inf.urteil": "2027-02-01" + }, + "skip_rules": ["inf.r30_amend_response"] + } + // v2 (future): additional peer proceeding entries here, each with + // its own trigger_date override + flags + choices + anchors. + ] +} +``` + +Field semantics: + +- `version` — schema version of the spec format. v1. Bumped when the spec shape changes incompatibly. +- `base_trigger_date` — the case's anchor date (e.g. when the Klage was filed). Every proceeding without its own `trigger_date_override` uses this. +- `proceedings[]` — array of proceeding entries: + - `code` — proceeding type code (e.g. `upc.inf.cfi`). + - `role` — `"primary"` (the main proceeding) or `"peer"` (v2 extension; ignored in v1). + - `trigger_date_override` — optional ISO date, defaults to `base_trigger_date`. v1 uses the base; v2 enables per-proceeding offsets. + - `flags[]` — array of flag strings (`with_ccr`, `with_amend`, `with_cci`). + - `per_card_choices` — map of `submission_code → {appellant?, include_ccr?, …}` (today's PerCardAppellant + IncludeCCRFor merged into one shape). + - `anchor_overrides` — map of `submission_code → ISO date` (today's AnchorOverrides — court-extended deadlines, court-set decision dates known retroactively). + - `skip_rules[]` — array of submission_codes the user has opted out of (today's SkipRules). + +Cross-proceeding spawns (the existing `is_spawn` graph — AMD / APP / CCR spawned from primary INF) are handled **automatically by the engine inside the primary entry** — the user does not list spawn outcomes in the spec; the engine expands them via `expandCrossProceedingSpawns`. The spec only carries primary picks + (later) peer picks. + +#### Why jsonb spec + +- **Multi-proceeding-ready from day 1.** v1 has 1 entry in `proceedings[]`; v2 has N. No schema migration needed for the lift. +- **Absorbs future per-card-choice keys** without ALTER TABLE. The choices vocabulary (`appellant`, `include_ccr`, …) is already extension-friendly inside the calculator; the spec inherits that. +- **Single round-trip** to load a scenario — no joins. +- **Validatable at write time** — a JSON-schema check in the service layer asserts every `code` resolves to an active proceeding, every `submission_code` resolves to a rule, every flag is known. Bad scenarios cannot be saved; the user gets a clear error. + +#### Engine integration + +paliad's `FristenrechnerService.Calculate` is unchanged. The new code path is the **request builder**: when a project has `active_scenario_id` non-NULL, paliad reads the scenario's spec, builds `CalcOptions` from the primary proceeding entry, and calls `Calculate(ctx, primary.code, scenario.base_trigger_date, opts)`. The package gets the same `CalcOptions` shape it's always gotten. + +v2 multi-peer expansion: iterate `proceedings[]`, call `Calculate` per entry, merge results client-side in `ProjectionService.For` (paliad-side). Still no library API change. #### Endpoints -- `GET /api/projects/{id}/scenarios` — list scenario names + which is active. -- `POST /api/projects/{id}/scenarios` — create a new named scenario (clones the choices of the source scenario name or empty if first). -- `PUT /api/projects/{id}/scenarios/{name}/active` — set as active scenario. -- `DELETE /api/projects/{id}/scenarios/{name}` — remove (cannot delete `'default'`; cannot delete the active one without picking another first). -- The existing `POST /api/projects/{id}/event-choices` learns to write against the active scenario (or accept an explicit `scenario_name` query param). +- `GET /api/projects/{id}/scenarios` — list project's scenarios + active. +- `GET /api/scenarios?abstract=true` — list current user's abstract scenarios. +- `POST /api/projects/{id}/scenarios` — create scoped scenario (body: name, spec). +- `POST /api/scenarios` — create abstract scenario (body: name, spec). +- `PATCH /api/scenarios/{id}` — rename / edit spec. +- `PUT /api/projects/{id}/active-scenario` — set active (body: scenario_id, or null to clear). +- `DELETE /api/scenarios/{id}` — remove (also clears any project.active_scenario_id pointing at it). +- `POST /api/scenarios/{id}/clone` — copy with new name. Supports cloning abstract → project-scoped and vice versa (the clone source gets `project_id` re-targeted). -#### UI +#### UI surfaces -- Project page sub-header gains a "Szenario" chip group above SmartTimeline: `[default ▾] + Neu`. -- Click chip → switch active → SmartTimeline re-renders against new choice set. -- "+ Neu" → modal: name + base ("Leer" or clone-from-current). -- SmartTimeline + Akte-mode Fristenrechner both read the active scenario's choices. +**Project page** (Akte-mode): +- "Szenarien" chip group above SmartTimeline: `[Aktuell] [mit CCR] [+ Neu]`. +- "Aktuell" = the live `project_event_choices` state (no active scenario). +- Click chip → set as active → SmartTimeline + Akte-Fristenrechner re-render. +- "+ Neu" modal: name + base (Leer / vom aktuellen Stand / abstrakte Vorlage importieren). +- Per-scenario "Bearbeiten" affordance opens the same per-card choice surface filtered to that scenario's spec. -#### Why this design +**/tools/verfahrensablauf** (abstract): +- Existing variant chips + per-card choices continue to drive a "live" abstract scenario carried in URL state. +- New "Speichern als Vorlage" button → modal: name → POST /api/scenarios (project_id=NULL). +- Sidebar list "Meine Vorlagen" lets the user reload a saved abstract scenario into the URL state. +- Existing share-via-URL stays — abstract scenarios are *also* shareable URLs; saving is opt-in for stickiness. -- **No new table** — the existing `project_event_choices` table already keys on `(project_id, submission_code, choice_kind)`. Adding `scenario_name` to the PK partitions naturally. -- **Backward compat** — every existing row gets `scenario_name='default'`; nothing breaks. -- **Library-side neutrality** — the package never sees scenarios. paliad's catalog/handler reads which scenario is active, builds the `CalcOptions` for it, and calls `Calculate`. youpc.org doesn't need scenarios at all (no projects). -- **Symmetry with Verfahrensablauf** — Verfahrensablauf stays URL-only (no projects, no persistence) — that surface is for abstract exploration, where URL-state IS the "scenario" (shareable, ephemeral, no name). +#### Library-side neutrality -#### Rejected: Option B (new `paliad.scenarios` table) +The package never sees the scenario. paliad's request builder unpacks the spec and calls `Calculate` with vanilla `CalcOptions`. youpc.org doesn't need scenarios at all (it stays on URL state). The scenarios feature is 100% paliad-side; the package stays clean. -Cleaner separation but duplicates the (project_id, choices) relationship. The composite-PK extension on `project_event_choices` is one column + one DEFAULT + one PK swap — strictly less work, no duplication. +#### What the spec is NOT -#### Rejected: Option C (URL-only, no persistence) +- Not a place to inject user-authored rules. m's clarification: "users should not add their own rules." The spec references existing rules by `submission_code`; it cannot create new ones. +- Not a place to add user-authored proceeding types. Same logic — the `code` field must resolve to an existing active `paliad.proceeding_types` row. +- Not a SmartTimeline event store. SmartTimeline reads actuals from `paliad.deadlines` / `appointments` / `project_events` as today; scenarios only influence the *projected* future. -Loses the "name it, switch to it" affordance m's framing implies. URL-only scenarios already exist on Verfahrensablauf; the project-page version needs more. +### Multi-proceeding peer compose — v2 path (m's "goal") -### Escalation to head (Q1) +m: *"Usually it would be 1 — but 2 is the goal. So people can create more complex scenarios."* -The package design works for any of A / B / C. The schema choice is paliad-side. **Recommendation = A** as designed above. If m flags a need for cross-project scenario sharing later (firm-shared templates), B becomes the cleaner base; A → B is a one-migration upgrade. +v2 lifts the engine to multi-peer: + +1. Frontend: the "+ Verfahren hinzufügen" button on the scenario editor adds a new entry to `proceedings[]` with `role: "peer"`, picks a proceeding code, optionally sets a `trigger_date_override`. +2. Backend: `ProjectionService.For` (or the request builder) iterates `proceedings[]`, calls `Calculate` per entry, tags each timeline event with its origin proceeding (new `Origin` field on `TimelineEntry`), and merges + sorts by date. +3. SmartTimeline renders peers as parallel lanes (reuses the existing lane infrastructure from t-paliad-175 Slice 4 — `LaneInfo` already supports parallel tracks). +4. No package change — the package processes one proceeding per call as today; multi-peer is a paliad-side orchestration. + +v2 is OUT OF SCOPE for v1 implementation but IN SCOPE for the spec/storage design. v1 silently ignores any `proceedings[]` entry with `role: "peer"`; v2 honours them. --- -## §6 User-authored rules design (Q2) +## §6 ~~User-authored rules design~~ — REMOVED by m's 2026-05-26 decision -### m's framing +> m's clarification: *"users should not add their own rules. I want them to setup a scenario with different existing proceeding types / submissions that trigger sequences."* + +The original Q2 (a) per-project rule additions via `paliad.deadline_rules.project_id` is **dropped**. No `project_id` column lands on `paliad.deadline_rules`. The corpus stays admin-curated (Slice 11 rule editor) — users compose, they don't author. §5 above replaces this design pillar: the scenario spec is the user's expression surface. + +The original §6 body is preserved below as historical context (struck-through) for future readers who want to understand what was considered and why it was rejected. + +--- + +~~### m's framing~~ > we can add proceeding types or specific submissions and then get the whole sequence with options for the steps @@ -818,38 +930,42 @@ Each slice is independently shippable on the paliad side. Slice F is a separate - `lp.Calculate(ctx, req, upc.NewCatalog(), upc.NewHolidayCalendar(), upc.NewCourtRegistry())` returns the same Timeline for every fixture as the paliad-side Catalog does. - A snapshot-drift test fails on intentional drift to prove the detector works. -### Slice D — scenarios persistence (Q1) +### Slice D — scenarios persistence: per-project (REVISED per m's 2026-05-26 picks) -**Scope** — paliad-side schema + endpoints + UI. +**Scope** — `paliad.scenarios` table + jsonb spec + project-page UI. **Files**: -- `internal/db/migrations/134_project_event_choices_scenario.up.sql` + `.down.sql` — add `scenario_name` + new PK + `active_scenario` on `paliad.projects`. -- `internal/services/scenario_service.go` (new) — Create/List/SetActive/Delete. -- `internal/handlers/scenarios.go` (new) — wire endpoints. -- `internal/services/event_choice_service.go` — learn to write/read against active scenario. -- `internal/services/projection_service.go` — consume active scenario's choices when building CalcOptions. -- `frontend/src/components/ScenarioChips.tsx` (new) + `frontend/src/client/projects-detail.ts` — UI surface. +- `internal/db/migrations/134_scenarios.up.sql` + `.down.sql` — create `paliad.scenarios` (project_id, name, spec jsonb, created_by, …) + `paliad.projects.active_scenario_id` FK + RLS via `paliad.can_see_project`. +- `internal/services/scenario_service.go` (new) — Create/List/Get/Patch/Clone/SetActive/Delete + JSON-schema validation of `spec` (every code/submission resolves; flags known). +- `internal/handlers/scenarios.go` (new) — wire the REST endpoints from §5. +- `internal/services/projection_service.go` — when `project.active_scenario_id` is set, build `CalcOptions` from the scenario's spec.primary entry instead of `paliad.project_event_choices`. +- `internal/services/event_choice_service.go` — unchanged (still owns the un-named "current" state); a TODO comment notes the v2 collapse if scenarios fully replace ad-hoc choices. +- `frontend/src/components/ScenarioChips.tsx` (new) + `frontend/src/client/projects-detail.ts` — chip group + create/edit modal + active switch. +- `frontend/src/components/ScenarioEditor.tsx` (new) — per-card choices + flags + anchor-overrides editor scoped to the scenario. **Exit criteria**: -- Project page shows scenario chips; create/switch/delete works. -- SmartTimeline + Akte-mode Fristenrechner respect active scenario. -- Existing projects function unchanged (everything in 'default'). +- Project page renders scenario chips; create/switch/delete works. +- SmartTimeline + Akte-Fristenrechner respect the active scenario. +- "Aktuell" (no active scenario) preserves today's behaviour reading from `project_event_choices`. +- Per-card choice editor writes into the scenario's spec.anchor_overrides / per_card_choices / skip_rules / flags. -### Slice E — user-authored rules (Q2) +### Slice E — abstract scenarios on /tools/verfahrensablauf (NEW, replaces ex-Slice E) -**Scope** — paliad-side schema + rule-editor extension. +**Scope** — abstract scenarios surface; reuses the same `paliad.scenarios` table with `project_id IS NULL`. **Files**: -- `internal/db/migrations/135_deadline_rules_project_id.up.sql` + `.down.sql` — nullable `project_id` column + composite uniqueness adjustment. -- `internal/services/deadline_rule_service.go` — learn `ListForProceeding(ctx, code, hint)` with merge semantics. -- `internal/services/rule_editor_service.go` — extend to scope writes to a project_id when set. -- `internal/handlers/rule_editor.go` — accept `?project=` scope param. -- `frontend/src/projects-detail.tsx` + `client/projects-detail.ts` — "Akte-spezifische Frist hinzufügen" affordance. +- `internal/services/scenario_service.go` — Slice D code already supports abstract scenarios; Slice E adds the `created_by`-scoped list endpoint + the clone-abstract-to-project / clone-project-to-abstract flows. +- `internal/handlers/scenarios.go` — extend with `?abstract=true` filter on List. +- `frontend/src/verfahrensablauf.tsx` + `client/verfahrensablauf.ts` — "Speichern als Vorlage" button + sidebar list "Meine Vorlagen" + load-saved-template → URL state. **Exit criteria**: -- A project-scoped rule appears in that project's SmartTimeline + Akte-Fristenrechner. -- The same rule does NOT appear in the public `/tools/fristenrechner` or in any other project. -- The snapshot generator's filter (`project_id IS NULL`) prevents leakage to youpc. +- /tools/verfahrensablauf gains save + list-my-templates affordances. +- A saved abstract scenario reloaded into URL state reproduces the live timeline byte-identically. +- Project page "+ Neu" modal supports "abstrakte Vorlage importieren" — copies the abstract scenario into the project's scope. + +### v2 — multi-proceeding peer compose (DEFERRED, no slice in this design) + +m's "goal": *"2 is the goal. So people can create more complex scenarios."* The spec already carries `proceedings[]` as an array; v2 lifts the engine + UI to honour `role: "peer"` entries. Out of scope for v1 — but the design holds because the schema doesn't need migration to support it. ### Slice F — youpc.org integration (separate repo) @@ -864,15 +980,17 @@ Each slice is independently shippable on the paliad side. Slice F is a separate **Out of scope of this design** — youpc's UX, youpc's auth gate, youpc's analytics, youpc's i18n. Those belong to a sibling task on the youpc.org repo. -### Slice ordering +### Slice ordering (revised) ``` A → B → C → F (youpc integration on C) ↘ - D + E (parallel, paliad-only, depend on B for catalog merge points) + D → E (paliad-only; E builds on D's table + schema) ``` -A is the prerequisite for everything. B is the prerequisite for C, D, E. C is the prerequisite for F. D and E are parallel after B. +A is the prerequisite for everything. B is the prerequisite for C and D. C is the prerequisite for F. D + E are sequential (E reuses D's table) but can ship as one PR if a worker prefers. + +Old "Slice E user-authored rules" is dropped per m's 2026-05-26 clarification (§6). New Slice E is the abstract scenarios surface on /tools/verfahrensablauf. --- @@ -888,7 +1006,7 @@ A is the prerequisite for everything. B is the prerequisite for C, D, E. C is th 4. **Catalog merge ambiguity (Slice E).** A project-scoped rule with the same `submission_code` as a global rule — does it override or coexist? **Mitigation**: the unique constraint `(proceeding_type_id, submission_code, project_id) NULLS NOT DISTINCT` allows ONE global + N project-scoped to coexist. The catalog returns both; the engine sees both rows. Ordering by `sequence_order` decides which renders first. Document this explicitly in the rule-editor UX (no silent override). -5. **Scenarios PK migration is destructive.** Slice D drops the existing PK and recreates it. **Mitigation**: small table (~50 rows in live data), standard `DROP CONSTRAINT` + `ADD CONSTRAINT` is atomic in a single transaction; down-migration restores. +5. **Scenarios spec validation drift.** Per m's revised design (§5), the `spec` jsonb references `proceeding_type.code` + `submission_code` + flag strings — all of which can be renamed by future migrations. A scenario saved against `upc.inf.cfi.inf.reply` would silently break if the submission_code changes. **Mitigation**: validate-on-load (not just on-write): when paliad reads a scenario, the service resolves every code/submission against the current corpus and surfaces a "Vorlage benötigt Aktualisierung" banner if anything's gone stale. The user can edit + re-save against the current corpus; the old spec is preserved until they do. 6. **youpc.org snapshot binary footprint.** ~110KB embedded JSON in youpc's binary. **Mitigation**: trivial. Not a risk in practice. @@ -919,25 +1037,17 @@ A is the prerequisite for everything. B is the prerequisite for C, D, E. C is th --- -## §13 Open questions for m (escalated via `mai instruct head`) +## §13 Open questions — RESOLVED 2026-05-26 -Per the inventor → head gate, the inventor does NOT call AskUserQuestion. The questions below are forwarded to `paliad/head`; m's answers come back via head's reply. +Originally Q1 + Q2 were escalated via `mai instruct head`. m switched to AskUserQuestion-mode mid-session; answers folded into §0.5. Original Q1+Q2 framings preserved below as historical context. -### Q1 — Scenarios storage model (material) +### Q1 — Scenarios storage model (material) — ANSWERED §0.5-Q4 -Recommendation: **Option A — extend `paliad.project_event_choices` with `scenario_name` text NOT NULL DEFAULT 'default' + add `paliad.projects.active_scenario`**. Reasoning §5. Alternatives: -- B: new `paliad.scenarios` table. -- C: URL-only scenarios, no persistence. +m picked **new `paliad.scenarios` table with jsonb spec** (option B in the original framing). Original recommendation (column extension) was retracted in favour of jsonb spec because m's Q1-Q3 picks (multi-proceeding peer compose as goal, per-project + abstract scope) made the jsonb shape strictly better. -m's call gates Slice D. The library's shape doesn't depend on which is picked. +### Q2 — User-authored rules scope (material) — REJECTED §0.5 -### Q2 — User-authored rules scope (material) - -Recommendation: **Option (a) — per-project rule additions via `paliad.deadline_rules.project_id` nullable column**. Reasoning §6. Alternatives: -- (a)+(b): also support new user-authored proceeding types. -- (c): defer entirely. - -m's call gates Slice E. If (b) is in scope, an additional Slice E2 is needed. +m clarified: *"users should not add their own rules."* Original Slice E dropped. New Slice E = abstract scenarios surface on /tools/verfahrensablauf. Composition replaces authoring. ### Q3 — Locked by m From 5f0a85fa835fcc091e3e8b9bf46db29581633bad Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 12:52:59 +0200 Subject: [PATCH 3/3] refactor(litigationplanner): extract Fristen/Verfahrensablauf calc into pkg/litigationplanner (Slice A, t-paliad-298 / m/paliad#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atomic extraction of the deadline-rule compute engine + types from internal/services into a new pkg/litigationplanner package that paliad + youpc.org can both import. No behaviour change — every existing test passes against the post-move shape. Package contents (~1850 LoC): - doc.go package docstring + reuse manifesto - types.go Rule, ProceedingType, NullableJSON, AdjustmentReason, HolidayDTO, CalcOptions, CalcRuleParams, Timeline, TimelineEntry, RuleCalculation*, FristenrechnerType, ProjectHint, sentinel errors - catalog.go Catalog interface (proceeding + rule lookups) - holidays.go HolidayCalendar interface - courts.go CourtRegistry interface + DefaultsForJurisdiction + country/regime constants - expr.go EvalConditionExpr + HasConditionExpr + ExtractFlagsFromExpr (jsonb gate evaluator) - durations.go ApplyDuration + AddWorkingDays (pure compute) - subtrack.go SubTrackRouting + LookupSubTrackRouting registry - legal_source.go FormatLegalSourceDisplay + BuildLegalSourceURL - proceeding_mapping.go MapLitigationToFristenrechner + code constants (CodeUPCInfringement, CodeDEInfringementLG, ...) - engine.go Calculate + CalculateRule + the trigger-event branch + applyRuleOverrides (the big move) paliad side (~1900 LoC net deletion): - internal/services/fristenrechner.go shrinks from 1505 → ~290 lines (thin paliad Catalog adapter + type aliases for back-compat). - internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON become type aliases to litigationplanner.* — every sqlx scan and every projection_service caller compiles unchanged. - internal/services/holidays.go: AdjustmentReason + HolidayDTO become aliases to lp.* (canonical definitions now in the package). - internal/services/proceeding_mapping.go: rewritten as thin re-exports of lp constants + helpers. - internal/services/deadline_search_service.go: FormatLegalSourceDisplay + BuildLegalSourceURL replaced with delegating wrappers to lp. Catalog interface satisfaction: - DeadlineRuleService → paliadCatalog adapter (wraps the existing service, replicates the original SELECT shapes). - HolidayService → satisfies lp.HolidayCalendar directly (compile- time assertion at end of fristenrechner.go). - CourtService → satisfies lp.CourtRegistry directly. Wire shape is byte-identical. JSON tags on Rule / ProceedingType / Timeline / TimelineEntry / RuleCalculation match the historical UIResponse / UIDeadline shape; the frontend reads the same bytes. Slice B (Catalog interface + paliad loader cleanup) is folded into this commit since Slice A already needs the interfaces to call Calculate across the boundary. Slice C (embedded UPC snapshot + generator) is the next coder shift; the Berufung unification m called out lands in Slice B/C per head's brief. Refs: docs/design-litigation-planner-2026-05-26.md --- internal/models/models.go | 215 +- internal/services/deadline_rule_service.go | 3 +- internal/services/deadline_search_service.go | 127 +- internal/services/fristenrechner.go | 1985 +++--------------- internal/services/holidays.go | 42 +- internal/services/proceeding_mapping.go | 218 +- pkg/litigationplanner/catalog.go | 49 + pkg/litigationplanner/courts.go | 49 + pkg/litigationplanner/doc.go | 17 + pkg/litigationplanner/durations.go | 76 + pkg/litigationplanner/engine.go | 908 ++++++++ pkg/litigationplanner/expr.go | 145 ++ pkg/litigationplanner/holidays.go | 25 + pkg/litigationplanner/legal_source.go | 123 ++ pkg/litigationplanner/proceeding_mapping.go | 139 ++ pkg/litigationplanner/sort.go | 151 ++ pkg/litigationplanner/subtrack.go | 53 + pkg/litigationplanner/types.go | 428 ++++ 18 files changed, 2491 insertions(+), 2262 deletions(-) create mode 100644 pkg/litigationplanner/catalog.go create mode 100644 pkg/litigationplanner/courts.go create mode 100644 pkg/litigationplanner/doc.go create mode 100644 pkg/litigationplanner/durations.go create mode 100644 pkg/litigationplanner/engine.go create mode 100644 pkg/litigationplanner/expr.go create mode 100644 pkg/litigationplanner/holidays.go create mode 100644 pkg/litigationplanner/legal_source.go create mode 100644 pkg/litigationplanner/proceeding_mapping.go create mode 100644 pkg/litigationplanner/sort.go create mode 100644 pkg/litigationplanner/subtrack.go create mode 100644 pkg/litigationplanner/types.go diff --git a/internal/models/models.go b/internal/models/models.go index 87933d4..80786bf 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -4,63 +4,20 @@ package models import ( - "database/sql/driver" "encoding/json" - "fmt" "time" "github.com/google/uuid" "github.com/lib/pq" + + "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) -// NullableJSON is a jsonb column that may be NULL. json.RawMessage -// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value -// from Postgres breaks the row scan with "unsupported Scan, storing -// driver.Value type into type *json.RawMessage" — exactly the -// error that hid every approval_request from the inbox when m's first -// "create" lifecycle row arrived with NULL pre_image (m's dogfood -// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column -// fixes the scan and preserves inline JSON output (no base64 cast). -type NullableJSON []byte - -func (n *NullableJSON) Scan(value any) error { - if value == nil { - *n = nil - return nil - } - switch v := value.(type) { - case []byte: - *n = append((*n)[:0], v...) - return nil - case string: - *n = []byte(v) - return nil - } - return fmt.Errorf("NullableJSON: unsupported scan type %T", value) -} - -func (n NullableJSON) Value() (driver.Value, error) { - if len(n) == 0 { - return nil, nil - } - return []byte(n), nil -} - -func (n NullableJSON) MarshalJSON() ([]byte, error) { - if len(n) == 0 { - return []byte("null"), nil - } - return []byte(n), nil -} - -func (n *NullableJSON) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - *n = nil - return nil - } - *n = append((*n)[:0], data...) - return nil -} +// NullableJSON is a jsonb column that may be NULL. Canonical definition +// (with sql.Scanner / driver.Valuer / json.Marshaler / json.Unmarshaler) +// lives in pkg/litigationplanner — kept here as a type alias so every +// existing models.NullableJSON reference continues to compile. +type NullableJSON = litigationplanner.NullableJSON // User extends auth.users with firm-specific profile fields. Created by the // Phase D onboarding flow; without a row here, the user can't see any Projects. @@ -584,112 +541,10 @@ type Party struct { } // DeadlineRule is one rule in the proceeding-rule tree (UPC R.023, etc.). -type DeadlineRule struct { - ID uuid.UUID `db:"id" json:"id"` - ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"` - ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"` - SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"` - Name string `db:"name" json:"name"` - NameEN string `db:"name_en" json:"name_en"` - Description *string `db:"description" json:"description,omitempty"` - PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"` - EventType *string `db:"event_type" json:"event_type,omitempty"` - DurationValue int `db:"duration_value" json:"duration_value"` - DurationUnit string `db:"duration_unit" json:"duration_unit"` - Timing *string `db:"timing" json:"timing,omitempty"` - RuleCode *string `db:"rule_code" json:"rule_code,omitempty"` - DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"` - DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"` - SequenceOrder int `db:"sequence_order" json:"sequence_order"` - AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"` - AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"` - AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"` - AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"` - ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"` - // ConceptDefaultEventTypeID is the canonical paliad.event_types row for - // this rule's concept (joined via paliad.deadline_concept_event_types - // where is_default = true). Lets the deadline create form auto-populate - // the Typ chip when the user picks this rule. Hydrated by the service - // layer; not a column. NULL when the concept has no mapped event_type. - ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"` - LegalSource *string `db:"legal_source" json:"legal_source,omitempty"` - IsSpawn bool `db:"is_spawn" json:"is_spawn"` - SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"` - IsActive bool `db:"is_active" json:"is_active"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - - // --------------------------------------------------------------- - // Phase 3 unified-rule columns (mig 078, t-paliad-182). - // Slice 9 (t-paliad-195) dropped the legacy IsMandatory / - // IsOptional / ConditionFlag / ConditionRuleID fields — they - // were superseded by Priority / ConditionExpr / IsCourtSet and - // the unified calculator no longer reads them. - // --------------------------------------------------------------- - - // TriggerEventID points at paliad.trigger_events when this rule is - // event-rooted (Pipeline C unification, design §2.5). NULL on - // proceeding-rooted rules. Exactly one of (proceeding_type_id, - // trigger_event_id) is set after Slice 3. - TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"` - - // SpawnProceedingTypeID is the cross-proceeding spawn target — - // when is_spawn=true and this is non-NULL, the calculator follows - // the FK and emits the target proceeding's root rule chain. Slice - // 7 backfills the 8 live is_spawn=true rows. - SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"` - - // CombineOp is 'max' or 'min' for composite-rule arithmetic - // (R.198 / R.213: "31d OR 20 working_days, whichever is longer"). - // NULL = single-anchor arithmetic. - CombineOp *string `db:"combine_op" json:"combine_op,omitempty"` - - // ConditionExpr is the jsonb gating expression replacing - // ConditionFlag (design §2.4). Grammar: - // {"flag": ""} - // {"op":"and"|"or", "args":[, ...]} - // {"op":"not", "args":[]} - // NULL or {} = unconditional. NullableJSON so a NULL column scans - // cleanly (the row mishap that hid approval rows from the inbox - // must not recur on rule rows). - ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"` - - // Priority is the 4-way unified enum replacing - // (IsMandatory, IsOptional). Values: 'mandatory' (default), - // 'recommended', 'optional', 'informational'. Backfilled in - // Slice 2; legacy callers read IsMandatory + IsOptional until - // Slice 4 cuts them over. - Priority string `db:"priority" json:"priority"` - - // IsCourtSet replaces the runtime heuristic - // (primary_party='court' OR event_type IN ('hearing','decision', - // 'order')). Backfilled in Slice 2; legacy callers read the - // heuristic until Slice 4. - IsCourtSet bool `db:"is_court_set" json:"is_court_set"` - - // LifecycleState drives the rule-editor flow (design §4.2): - // 'draft' (admin work-in-progress) | 'published' (live, calculator- - // visible) | 'archived' (historical, retained for audit). Every - // pre-Slice-1 row defaults to 'published' via the migration. - LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"` - - // DraftOf points at the published rule this draft will replace on - // publish. NULL on published / archived rows. NULL also on net- - // new drafts that have no prior published peer. - DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"` - - // PublishedAt records when the row entered LifecycleState='published'. - // NULL while draft, set on publish, retained through archive. - // Distinct from UpdatedAt (moves on every edit). - PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"` - - // ChoicesOffered declares which per-event-card choice-kinds this - // rule offers on the Verfahrensablauf timeline (mig 129, - // t-paliad-265). NULL = no caret affordance (default). See the - // COMMENT on paliad.deadline_rules.choices_offered for the value - // shape. The engine and the frontend both read this column. - ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"` -} +// Canonical definition lives in pkg/litigationplanner.Rule — kept here +// as a type alias so every existing models.DeadlineRule reference (sqlx +// scans, hydration, projection service) continues to compile. +type DeadlineRule = litigationplanner.Rule // DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the // append-only audit log for every change to paliad.deadline_rules. @@ -721,43 +576,19 @@ type DeadlineRuleAudit struct { MigrationExported bool `db:"migration_exported" json:"migration_exported"` } -// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter -// management) or the lowercase dot-separated fristenrechner codes -// (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see -// docs/design-proceeding-code-taxonomy-2026-05-18.md. -type ProceedingType struct { - ID int `db:"id" json:"id"` - Code string `db:"code" json:"code"` - Name string `db:"name" json:"name"` - NameEN string `db:"name_en" json:"name_en"` - Description *string `db:"description" json:"description,omitempty"` - Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"` - Category *string `db:"category" json:"category,omitempty"` - DefaultColor string `db:"default_color" json:"default_color"` - SortOrder int `db:"sort_order" json:"sort_order"` - IsActive bool `db:"is_active" json:"is_active"` - // TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf - // "Auslösendes Ereignis". When set, overrides the proceedingName fallback - // that fires when no rule has IsRootEvent=true. Populated for UPC Appeal - // (mig 121) so the caption reads "Anfechtbare Entscheidung" / - // "Appealable Decision" instead of "Berufungsverfahren" / "Appeal". - // NULL on most proceedings — they already carry a root rule. - TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"` - TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"` -} +// ProceedingType is one of the litigation conceptual codes (INF / REV / +// CCR / APM / APP / AMD / ZPO_CIVIL) or the lowercase dot-separated +// fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see +// docs/design-proceeding-code-taxonomy-2026-05-18.md. Canonical +// definition lives in pkg/litigationplanner.ProceedingType — kept here +// as a type alias so every existing models.ProceedingType reference +// continues to compile. +type ProceedingType = litigationplanner.ProceedingType -// TriggerEvent is a UPC procedural event that can start one or more deadlines -// running. Powers the "Was kommt nach…" Fristenrechner mode (event-driven -// lookup, mirrored from youpc data.events). -type TriggerEvent struct { - ID int64 `db:"id" json:"id"` - Code string `db:"code" json:"code"` - Name string `db:"name" json:"name"` - NameDE string `db:"name_de" json:"name_de"` - Description string `db:"description" json:"description"` - IsActive bool `db:"is_active" json:"is_active"` - CreatedAt time.Time `db:"created_at" json:"created_at"` -} +// TriggerEvent is a UPC procedural event referenced by deadline rules +// whose semantic anchor is an event rather than a parent rule. +// Canonical definition lives in pkg/litigationplanner.TriggerEvent. +type TriggerEvent = litigationplanner.TriggerEvent // EventDeadline is a single deadline that flows from a TriggerEvent. Mirrors // youpc data.deadlines + the trigger half of data.deadline_events. diff --git a/internal/services/deadline_rule_service.go b/internal/services/deadline_rule_service.go index 381da34..6ff3a40 100644 --- a/internal/services/deadline_rule_service.go +++ b/internal/services/deadline_rule_service.go @@ -38,7 +38,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n choices_offered` const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction, - category, default_color, sort_order, is_active` + category, default_color, sort_order, is_active, + trigger_event_label_de, trigger_event_label_en` // List returns active rules, optionally filtered by proceeding type. // Each row has ConceptDefaultEventTypeID hydrated from diff --git a/internal/services/deadline_search_service.go b/internal/services/deadline_search_service.go index 6a0f749..92acb85 100644 --- a/internal/services/deadline_search_service.go +++ b/internal/services/deadline_search_service.go @@ -9,6 +9,8 @@ import ( "github.com/jmoiron/sqlx" "github.com/lib/pq" + + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // DeadlineSearchService backs the unified Fristenrechner search bar @@ -921,130 +923,15 @@ func roundScore(v float64) float64 { return float64(int(v*10000+0.5)) / 10000 } -// FormatLegalSourceDisplay renders a structured legal_source code into -// the form HLC users read in pleadings: -// -// UPC.RoP.23.1 → "UPC RoP R.23(1)" -// UPC.RoP.139 → "UPC RoP R.139" -// DE.PatG.82.1 → "PatG §82(1)" -// DE.ZPO.276.1 → "ZPO §276(1)" -// EU.EPÜ.108 → "EPÜ Art.108" -// EU.EPC-R.79.1 → "EPC R.79(1)" -// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)" -// -// Returns the empty string for an empty input. Unknown jurisdictions -// fall through with the structured form preserved (caller decides -// whether to display). +// FormatLegalSourceDisplay + BuildLegalSourceURL are canonically +// defined in pkg/litigationplanner — kept here as thin re-exports so +// the existing in-package + handler call-sites compile unchanged. func FormatLegalSourceDisplay(src string) string { - src = strings.TrimSpace(src) - if src == "" { - return "" - } - parts := strings.Split(src, ".") - if len(parts) < 3 { - // Malformed — return as-is so the caller still has something. - return src - } - code := parts[1] - rest := parts[2:] - var prefix string - switch code { - case "RoP": - prefix = "UPC RoP R." - case "PatG": - prefix = "PatG §" - case "ZPO": - prefix = "ZPO §" - case "EPÜ": - prefix = "EPÜ Art." - case "EPC-R": - prefix = "EPC R." - case "RPBA": - prefix = "RPBA Art." - default: - prefix = code + " " - } - var b strings.Builder - b.Grow(len(prefix) + len(src)) - b.WriteString(prefix) - b.WriteString(rest[0]) - for _, p := range rest[1:] { - b.WriteByte('(') - b.WriteString(p) - b.WriteByte(')') - } - return b.String() + return lp.FormatLegalSourceDisplay(src) } -// BuildLegalSourceURL maps a structured legal_source code to a -// youpc.org/laws permalink when the cited body is hosted there. Today -// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national -// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc -// home yet, so the helper returns the empty string for those and the -// caller renders the display string as plain text. -// -// Inputs mirror FormatLegalSourceDisplay — structured dot-separated -// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond -// the law-number position are dropped; youpc resolves the page at -// . granularity. The law-number is zero-padded to 3 -// digits to match how youpc stores law_number (laws-data.json carries -// "001" / "023" / "220" forms). -// -// URL shape uses the hash-fragment form that youpc itself emits from -// its laws-page redirect (handlers/laws.go:215+229) — the canonical -// in-app deep link target. The `/laws/:type/:number` pretty route also -// resolves the same page but redirects to the hash form anyway. -// -// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023 -// UPC.RoP.139 → https://youpc.org/laws#UPCRoP.139 -// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220 -// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029 -// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083 -// DE.ZPO.276.1 → "" (no youpc home — render display text plain) func BuildLegalSourceURL(src string) string { - src = strings.TrimSpace(src) - if src == "" { - return "" - } - parts := strings.Split(src, ".") - if len(parts) < 3 { - return "" - } - var lawType string - switch parts[0] + "." + parts[1] { - case "UPC.RoP": - lawType = "UPCRoP" - case "UPC.UPCA": - lawType = "UPCA" - case "UPC.UPCS": - lawType = "UPCS" - default: - return "" - } - number := padLawNumber(parts[2]) - if number == "" { - return "" - } - return "https://youpc.org/laws#" + lawType + "." + number -} - -// padLawNumber zero-pads a pure-digit law-number segment to 3 digits. -// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art. -// 112a) pass through unchanged so the URL still resolves. Empty input -// returns the empty string. -func padLawNumber(s string) string { - if s == "" { - return "" - } - for _, c := range s { - if c < '0' || c > '9' { - return s - } - } - if len(s) >= 3 { - return s - } - return strings.Repeat("0", 3-len(s)) + s + return lp.BuildLegalSourceURL(src) } // RefreshSearchView re-populates the materialised view. Safe to call on diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index 1288b14..8fea562 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -3,1365 +3,82 @@ package services import ( "context" "database/sql" - "encoding/json" "errors" "fmt" - "sort" "time" "github.com/google/uuid" "mgit.msbls.de/m/paliad/internal/models" + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) -// FristenrechnerService renders the Paliad public Fristenrechner's response -// shape from DB-stored rules. It sits on top of DeadlineRuleService and -// HolidayService and produces the bilingual, rule-code + notes-rich payload -// that /tools/fristenrechner's client expects. +// FristenrechnerService renders the Paliad public Fristenrechner's +// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it +// is a thin adapter: the compute engine + types live in +// pkg/litigationplanner, and FristenrechnerService just wires the +// Postgres-backed Catalog + HolidayCalendar + CourtRegistry +// implementations and delegates Calculate / CalculateRule across the +// boundary. // -// The UI-facing response is distinct from the plain calculator in -// DeadlineCalculator: it adds IsRootEvent, IsCourtSet, RuleRef, Notes, -// party color classes, and keeps the result ordered by sequence_order -// within each proceeding type. +// The package owns the wire shape (Timeline / TimelineEntry); paliad's +// historical aliases (UIResponse / UIDeadline) keep call-sites +// unchanged. type FristenrechnerService struct { rules *DeadlineRuleService holidays *HolidayService courts *CourtService + + catalog lp.Catalog } // NewFristenrechnerService wires the service to its dependencies. func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService { - return &FristenrechnerService{rules: rules, holidays: holidays, courts: courts} + s := &FristenrechnerService{rules: rules, holidays: holidays, courts: courts} + s.catalog = &paliadCatalog{rules: rules} + return s } -// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface -// (camelCase JSON to keep /tools/fristenrechner byte-identical). -// -// Phase 3 Slice 9 (t-paliad-195) dropped the legacy IsMandatory + -// IsOptional fields — Priority is the canonical wire signal. The -// frontend reads priorityRendering(d) which since Slice 8 has -// priority as the primary input; Slice 9 removes the legacy fallback -// branch from the frontend too. -type UIDeadline struct { - RuleID string `json:"ruleId,omitempty"` - Code string `json:"code"` - Name string `json:"name"` - NameEN string `json:"nameEN"` - Party string `json:"party"` - // Priority is the 4-way enum the rule-editor + save-modal logic - // reads: 'mandatory' | 'recommended' | 'optional' | 'informational'. - // Informational rules render as notice cards (no save button, no - // checkbox) — the visible UX win of Phase 3 on today's F/F rules. - Priority string `json:"priority"` - RuleRef string `json:"ruleRef"` - LegalSource string `json:"legalSource,omitempty"` - // LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)") - // of LegalSource, produced by FormatLegalSourceDisplay. Frontend - // renders this in the deadline card meta line; falls back to - // RuleRef when LegalSource is empty. - LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"` - // LegalSourceURL is the youpc.org/laws permalink when the cited - // body is hosted there (UPCRoP / UPCA / UPCS today). Empty for - // DE/EPA/EU bodies — the renderer shows display text without a link. - LegalSourceURL string `json:"legalSourceURL,omitempty"` - Notes string `json:"notes,omitempty"` - NotesEN string `json:"notesEN,omitempty"` - DueDate string `json:"dueDate"` - OriginalDate string `json:"originalDate"` - WasAdjusted bool `json:"wasAdjusted"` - AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"` - IsRootEvent bool `json:"isRootEvent"` - IsCourtSet bool `json:"isCourtSet"` - // ConditionExpr is the jsonb gate predicate (design §2.4 long - // form) emitted verbatim so the rule editor (Slice 11) + admin - // surfaces can show the rule's gating shape. NULL / empty when - // the rule is unconditional. Frontend reads this to render the - // "Mit Nichtigkeitswiderklage" hint chips. - ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"` - // IsCourtSetIndirect is true when IsCourtSet is true because the - // rule chains off a court-determined parent (e.g. RoP.151 - // Kostenentscheidung is "1 Monat ab Hauptentscheidung", and the - // Hauptentscheidung itself is the court-set anchor). Direct - // court-determined rules (Urteil / Beschluss / Anordnung - // themselves) keep IsCourtSet=true with IsCourtSetIndirect=false. - // The frontend uses this to render "unbestimmt" for indirect - // cases instead of "wird vom Gericht bestimmt", which is only - // strictly correct for the direct ones — the indirect deadline - // is computed off a parent date that the COURT sets, not by the - // court itself. - IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"` - // IsConditional signals the rule's anchor is uncertain — no - // concrete date can be projected. Set when the rule depends on: - // - a court-set ancestor whose date isn't anchored - // (overlaps with IsCourtSetIndirect; the two are kept - // distinct because IsCourtSet wraps a specific UX message - // "wird vom Gericht bestimmt", whereas IsConditional is - // the broader "render as 'abhängig von '" signal) - // - timing='before' rules whose forward anchor isn't set - // (e.g. R.109(1) Antrag auf Simultanübersetzung 1 month - // before the oral hearing — without the hearing date, the - // backward arithmetic against the trigger date is meaningless) - // - optional opposing-side rules whose true triggering event - // hasn't been recorded for this project (e.g. R.262(2) - // Erwiderung auf Vertraulichkeitsantrag — the data-model - // parent is the SoC, but the real trigger is the opposing - // party's confidentiality motion which may never happen) - // When true, DueDate and OriginalDate are empty and the frontend - // renders an "abhängig von " chip in place of a - // date. Suppressed by an explicit user anchor (IsOverridden wins). - // (t-paliad-289) - IsConditional bool `json:"isConditional,omitempty"` - // ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the - // parent's identity so the frontend can render - // "abhängig von " when IsConditional=true. - // Populated whenever the rule has a parent_id, not only when - // conditional — keeps the wire shape stable. Empty for root rules. - ParentRuleCode string `json:"parentRuleCode,omitempty"` - ParentRuleName string `json:"parentRuleName,omitempty"` - ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"` - IsOverridden bool `json:"isOverridden,omitempty"` - // ChoicesOffered surfaces paliad.deadline_rules.choices_offered for - // the rule so the frontend knows whether to render the per-event-card - // caret affordance, and which choice-kinds to populate the popover - // with. NULL / empty for rules with no choices. (t-paliad-265) - ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"` - // AppellantContext is the per-decision appellant pick that applies - // to descendants of the closest ancestor decision card with a - // PerCardAppellant set. Empty when no per-card override is in - // effect (page-level ?appellant= still applies in that case). - // Frontend bucketer prefers this over the page-level appellant when - // non-empty. (t-paliad-265) - AppellantContext string `json:"appellantContext,omitempty"` - // IsHidden marks a card the user has previously hidden via a - // skip choice. Only ever true when CalcOptions.IncludeHidden is - // set — the toggle re-surfaces these rows so the user can either - // keep them faded for context or un-hide them via the inline - // "Wieder einblenden" chip. (t-paliad-290 / m/paliad#122) - IsHidden bool `json:"isHidden,omitempty"` -} +// Type aliases keep call-sites byte-identical with the pre-Slice-A +// shape. The wire JSON tags are owned by the package. +// (AdjustmentReason + HolidayDTO are aliased in holidays.go.) +type ( + UIResponse = lp.Timeline + UIDeadline = lp.TimelineEntry + CalcOptions = lp.CalcOptions + CalcRuleParams = lp.CalcRuleParams + RuleCalculation = lp.RuleCalculation + RuleCalculationRule = lp.RuleCalculationRule + RuleCalculationProceeding = lp.RuleCalculationProceeding + SubTrackRouting = lp.SubTrackRouting +) -// UIResponse matches the frontend's DeadlineResponse TypeScript interface. -type UIResponse struct { - ProceedingType string `json:"proceedingType"` - ProceedingName string `json:"proceedingName"` - // ProceedingNameEN carries the English label of the proceeding so - // the frontend can switch on lang. Empty when the proceeding has no - // English label populated; the frontend falls back to ProceedingName. - // Added 2026-05-20 (m/paliad#58) — previously the verfahrensablauf - // "Trigger event" label fell back to the DE proceedingName whenever - // the timeline had no root rule (e.g. for sub-track proceedings like - // upc.ccr.cfi that have no native rules). - ProceedingNameEN string `json:"proceedingNameEN,omitempty"` - TriggerDate string `json:"triggerDate"` - Deadlines []UIDeadline `json:"deadlines"` - // ContextualNote / ContextualNoteEN surface a banner above the - // timeline. Populated by sub-track routing (m/paliad#58): when the - // user picks a proceeding that is normally a sub-track of another - // proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with - // with_ccr), the renderer routes to the parent's rules but keeps - // the user-picked code/name as the response identity and surfaces a - // note explaining the framing. - ContextualNote string `json:"contextualNote,omitempty"` - ContextualNoteEN string `json:"contextualNoteEN,omitempty"` - // TriggerEventLabel / TriggerEventLabelEN: optional caption for the - // /tools/verfahrensablauf "Auslösendes Ereignis" field. Populated - // from paliad.proceeding_types.trigger_event_label_{de,en} (mig 121). - // The frontend prefers this over the proceedingName fallback that - // fires when no rule has IsRootEvent=true — UPC Appeal needed it - // because all its rules carry a non-zero duration off the trigger - // date so no rule is the "anchor". The trigger event for UPC Appeal - // is the appealable first-instance decision (m/paliad#81). - TriggerEventLabel string `json:"triggerEventLabel,omitempty"` - TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"` - // HiddenCount is the number of rules whose submission_code is in - // CalcOptions.SkipRules AND whose condition_expr gate passes — - // i.e. how many rows the user has hidden in this projection - // regardless of the IncludeHidden toggle state. The frontend uses - // this to render the "Ausgeblendete (N)" badge on the toggle even - // when the toggle is OFF (so users know there's something to - // re-surface). (t-paliad-290 / m/paliad#122) - HiddenCount int `json:"hiddenCount"` -} +// Sentinel errors. Re-exported as package-level vars so handlers that +// errors.Is(..., services.ErrUnknownProceedingType) continue to work. +var ( + ErrUnknownProceedingType = lp.ErrUnknownProceedingType + ErrUnknownRule = lp.ErrUnknownRule +) -// ErrUnknownProceedingType is returned when the UI sends an unrecognised code. -var ErrUnknownProceedingType = errors.New("unknown proceeding type") - -// CalcOptions carries optional inputs for Calculate. Callers can leave fields -// empty/nil for the legacy behaviour. -// -// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with anchor_alt = -// 'priority_date' (e.g. epa.grant.exa.ep_grant.publish per Art. 93 EPÜ) use -// this date as their base instead of the parent's adjusted date / the -// trigger date. -// - Flags: lowercase string flags from the UI (e.g. "with_ccr", -// "with_amend", "with_cci"). A rule with a non-empty condition_flag -// array renders iff EVERY element of that array is in Flags. When all -// are present AND alt_duration_value is non-NULL, the calculator -// swaps to alt_*; when set + flags not satisfied, the rule is -// suppressed entirely (skipped from the result list). -// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides -// of the computed deadline date. When a child rule chains off a -// parent whose code is in AnchorOverrides, the override date is -// used as the anchor instead of the parent's calculated date. Lets -// the user set a real court-extended deadline, or a court-set -// decision date once known, and have downstream rules re-flow. -type CalcOptions struct { - PriorityDateStr string - Flags []string - AnchorOverrides map[string]string - // CourtID picks the forum the proceeding is filed in (e.g. "upc-ld-paris", - // "de-bgh"). The calculator resolves it to (country, regime) for non- - // working-day computation. Empty falls back to UPC München (DE/UPC) for - // UPC-flavoured proceedings, DE for everything else — preserves legacy - // behaviour for callers that don't yet send a court. - CourtID string - // TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C - // rules: when non-nil, the proceedingCode argument is ignored and the - // service selects rules WHERE trigger_event_id = *TriggerEventIDFilter - // instead of WHERE proceeding_type_id = .... Set by - // EventDeadlineService.Calculate so the unified backend can serve the - // "Was kommt nach…" surface after Phase 3 Slice 3. The pointer width - // matches paliad.trigger_events.id (bigint, mig 028). See design - // §3.D (calculator unification). - TriggerEventIDFilter *int64 - // RuleOverrides substitutes specific rules in the calculator's - // rule list with caller-supplied in-memory rows. Used by the - // rule-editor preview (Slice 11a, t-paliad-191): the admin's - // draft replaces its published peer (matched by rule.ID) so the - // editor sees "what would this rule do?" without writing to the - // DB. Net-new drafts (no draft_of peer) get appended to the rule - // list so their effect lights up on a fresh evaluation. - // - // Empty / nil = no override (default). Overrides apply equally to - // the proceeding-tree and trigger-event branches. - RuleOverrides []models.DeadlineRule - - // Per-event-card choice overlays (t-paliad-265 / m/paliad#96). - // Keyed by paliad.deadline_rules.submission_code — same key - // AnchorOverrides uses. - // - // - PerCardAppellant: maps a decision-card's submission_code to the - // user-picked appellant ("claimant"|"defendant"|"both"|"none"). - // The engine walks the parent chain of each rule and stamps the - // resulting UIDeadline.AppellantContext from the closest ancestor - // decision with a pick. The frontend bucketer then prefers the - // per-rule context over the page-level appellant. - // - SkipRules: set of submission_code values whose rules (and any - // descendants) the user has opted out of for this projection. - // Same suppression path as a failed condition_expr gate. - // - IncludeCCRFor: set of submission_code values for rules where - // the user opted in to the include-CCR choice (Klageerwiderung - // cards). v1 simplification (design §4.2 #2): if non-empty, - // "with_ccr" is appended to the flag set before gate - // evaluation. Correct for single-CCR-entry-point proceedings - // (UPC INF + DE LG today). Multi-CCR scope is a future expansion. - PerCardAppellant map[string]string - SkipRules map[string]struct{} - IncludeCCRFor map[string]struct{} - - // IncludeHidden re-surfaces rules whose submission_code is in - // SkipRules (t-paliad-290 / m/paliad#122). When true: - // - Skipped rules are NOT dropped from the result; they render - // with UIDeadline.IsHidden=true so the frontend can fade them. - // - Descendant suppression is bypassed (the skipped parent is - // present in the result, so children compute their dates off - // it as if the user had never hidden it). - // Default false preserves the original skip semantic (drop rule + - // suppress descendants). HiddenCount on UIResponse is independent - // of this flag — it always reflects the number of hide-eligible - // rows so the toggle's count badge stays accurate. - IncludeHidden bool -} - -// Calculate renders the full UI timeline for a proceeding type + trigger date. -// Preserves the pre-Phase-C in-memory calculator's classification: -// -// - Rules with duration_value = 0 and no parent_id → IsRootEvent -// (due date = trigger date) -// - Rules with duration_value = 0 and a parent_id → IsCourtSet -// (due date empty, UI shows "court-set" placeholder) -// - All other rules → calculate from either the trigger date (no parent) -// or the previously-computed date for their parent rule. -// -// Audit-driven extensions: -// -// - opts.Flags can flip flag-conditioned rules onto their alt_* values -// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr"). When a -// rule's condition_flag array is non-empty, the rule renders iff -// EVERY element is in opts.Flags; rules that fail this gate are -// suppressed entirely (used by Phase B1 cross-flow rules that should -// only appear with their flag). -// - opts.PriorityDateStr overrides the anchor for rules with anchor_alt -// set (e.g. epa.grant.exa publication date is 18mo from priority, not filing). -// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the -// caller redirect a downstream rule's parent anchor to a user-set -// date. Used for court-extended deadlines and for entering -// court-set decision dates post-hoc. +// Calculate delegates to litigationplanner.Calculate with paliad's +// Postgres-backed Catalog / HolidayCalendar / CourtRegistry implementations. func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) { - // Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven - // branch (Pipeline-C unified rules; mig 085 moved 77 rows out of - // paliad.event_deadlines into paliad.deadline_rules carrying a - // non-NULL trigger_event_id). proceedingCode is ignored on this - // path. EventDeadlineService.Calculate is the sole caller today; - // future "event-trigger" surfaces (design §5) plug in here too. - if opts.TriggerEventIDFilter != nil { - return s.calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts) - } - - triggerDate, err := time.Parse("2006-01-02", triggerDateStr) - if err != nil { - return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) - } - - var priorityDate *time.Time - if opts.PriorityDateStr != "" { - pd, err := time.Parse("2006-01-02", opts.PriorityDateStr) - if err != nil { - return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err) - } - priorityDate = &pd - } - flagSet := make(map[string]struct{}, len(opts.Flags)) - for _, f := range opts.Flags { - flagSet[f] = struct{}{} - } - // v1 simplification (design §4.2 #2, t-paliad-265): when any - // IncludeCCRFor entry exists, we treat with_ccr as set in the flag - // context. Correct for single-CCR-entry-point proceedings (UPC INF + - // DE LG today). Multi-CCR scope is a future expansion that would - // thread the include set through the gate evaluator per-rule. - if len(opts.IncludeCCRFor) > 0 { - flagSet["with_ccr"] = struct{}{} - } - - // Parse anchor overrides up-front so a malformed date errors out - // before we start walking rules. - overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides)) - for code, dateStr := range opts.AnchorOverrides { - od, err := time.Parse("2006-01-02", dateStr) - if err != nil { - return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err) - } - overrideDates[code] = od - } - - // Look up proceeding type metadata. - var pt struct { - ID int `db:"id"` - Code string `db:"code"` - Name string `db:"name"` - NameEN string `db:"name_en"` - Jurisdiction *string `db:"jurisdiction"` - TriggerEventLabelDE *string `db:"trigger_event_label_de"` - TriggerEventLabelEN *string `db:"trigger_event_label_en"` - } - err = s.rules.db.GetContext(ctx, &pt, - `SELECT id, code, name, name_en, jurisdiction, - trigger_event_label_de, trigger_event_label_en - FROM paliad.proceeding_types - WHERE code = $1 AND is_active = true`, proceedingCode) - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrUnknownProceedingType - } - if err != nil { - return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err) - } - - // Sub-track routing (m/paliad#58). When the user picks a proceeding - // that has no native rules and is normally a sub-track of another - // proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route - // rule lookup to the parent and merge the default flags into the - // user's flag set. The response identity (Code/Name/NameEN) stays - // on the user-picked proceeding so the page header still reads - // "Counterclaim for Revocation", but the timeline body is the - // parent's full flow with the sub-track flag enabled. A note - // surfaces the framing. - var pickedProceeding = pt - var subTrackNote SubTrackRouting - var hasSubTrackNote bool - if route, ok := LookupSubTrackRouting(proceedingCode); ok { - subTrackNote = route - hasSubTrackNote = true - // Re-resolve to the parent proceeding for rule lookup. - err = s.rules.db.GetContext(ctx, &pt, - `SELECT id, code, name, name_en, jurisdiction, - trigger_event_label_de, trigger_event_label_en - FROM paliad.proceeding_types - WHERE code = $1 AND is_active = true`, route.ParentCode) - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, ErrUnknownProceedingType) - } - if err != nil { - return nil, fmt.Errorf("resolve sub-track parent %q: %w", route.ParentCode, err) - } - // Merge default flags into the user's flag set so the gated - // rules render. User-supplied flags win on conflict (they're - // already in flagSet); default flags only add what's missing. - for _, f := range route.DefaultFlags { - if _, exists := flagSet[f]; !exists { - flagSet[f] = struct{}{} - } - } - } - - // Resolve (country, regime) for non-working-day adjustment. Court wins - // when supplied; otherwise default by proceeding regime. UPC proceedings - // default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA / - // DE proceedings default to DE (no supranational regime). - defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) - country, regime, err := s.courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime) - if err != nil { - return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) - } - - rules, err := s.rules.List(ctx, &pt.ID) - if err != nil { - return nil, err - } - if len(opts.RuleOverrides) > 0 { - rules = applyRuleOverrides(rules, opts.RuleOverrides) - } - - // Walk the rule list in sequence_order (already sorted by the query) and - // compute each entry, keeping a code→date map so RelativeTo / parent_id - // references resolve to the adjusted predecessor date. - computed := make(map[string]time.Time, len(rules)) - courtSet := make(map[uuid.UUID]bool, len(rules)) - deadlines := make([]UIDeadline, 0, len(rules)) - - // Pre-pass: identify rules flagged is_court_set=true in the data so - // order-of-evaluation in sequence_order doesn't matter for the - // parent-court-set check below. Without this, a rule processed - // earlier than its court-set parent (e.g. R.109(1) Antrag auf - // Simultanübersetzung sequence_order=45 vs. Mündliche Verhandlung - // sequence_order=50 in upc.inf.cfi) misses the court-set propagation - // and computes a meaningless date — for timing='before' rules, that - // produces a backward offset from the trigger date, which has no - // semantic relationship to the rule. (t-paliad-289) - for _, r := range rules { - if r.IsCourtSet { - courtSet[r.ID] = true - } - } - - // ruleByID lets the conditional-rendering branches resolve a parent - // rule's display fields (submission_code, name, name_en) for the - // "abhängig von " chip without re-scanning the - // rules slice on every iteration. - ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules)) - for _, r := range rules { - ruleByID[r.ID] = r - } - - // triggerEventByID powers the trigger-event override on the - // conditional-label chip (m/paliad#126 / t-paliad-294). When a - // rule carries a real paliad.trigger_events row, that catalog - // event — not the rule's parent_id — is the rule's actual - // semantic anchor. The override fires below when stamping - // ParentRule* on the wire so the chip reads e.g. - // abhängig von Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit - // for R.262(2) Erwiderung auf Vertraulichkeitsantrag — instead of - // the (misleading) parent_id-derived "abhängig von Klageerhebung". - // - // Bulk-loaded in one round-trip; trees in the live corpus carry at - // most a handful of trigger_event_id-bearing rules (2 today on - // upc.inf.cfi), so the IN(...) is small. - var triggerIDs []int64 - seenTrigger := make(map[int64]struct{}, len(rules)) - for _, r := range rules { - if r.TriggerEventID == nil { - continue - } - if _, ok := seenTrigger[*r.TriggerEventID]; ok { - continue - } - seenTrigger[*r.TriggerEventID] = struct{}{} - triggerIDs = append(triggerIDs, *r.TriggerEventID) - } - triggerEventByID, err := s.rules.LoadTriggerEventsByIDs(ctx, triggerIDs) - if err != nil { - return nil, fmt.Errorf("load trigger events for conditional labels: %w", err) - } - - // Per-event-card overlays (t-paliad-265). Empty/nil maps are safe - // for membership tests; the engine reads them but doesn't mutate. - skipRules := opts.SkipRules - perCardAppellant := opts.PerCardAppellant - // skippedIDs accumulates the set of rule UUIDs whose timeline entry - // the user has opted out of. Walking in sequence_order means a - // child rule's parent has already been classified — so descendant - // suppression is a one-pass parent_id lookup. - skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules)) - // hiddenCount counts rows whose submission_code is in skipRules - // AND that pass the condition_expr gate — i.e. rows the user has - // hidden in this projection. Surfaced on UIResponse.HiddenCount so - // the frontend's "Ausgeblendete (N)" badge stays accurate even when - // IncludeHidden is off and the rows aren't in the result list. - // (t-paliad-290 / m/paliad#122) - hiddenCount := 0 - // appellantContext maps a rule UUID to the appellant value that - // applies to its descendants. A rule that has its own PerCardAppellant - // pick stamps itself with that value; a rule whose parent has a - // context inherits it. - appellantContext := make(map[uuid.UUID]string, len(rules)) - - for _, r := range rules { - // Phase-3 unified gate: evaluate condition_expr (jsonb). - // Suppression semantic preserved: when the gate fires false AND - // no alt_* values exist, the rule is dropped from the timeline - // entirely (purely conditional). When alt_* values exist, the - // gate-false branch still renders, just without the alt-swap - // (legacy "swap-on-flag" pattern, e.g. with_ccr). - gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet) - if !gateMet && r.AltDurationValue == nil { - continue - } - - // SkipRules suppression (t-paliad-265): the user has marked - // this rule (or one of its ancestors) as "don't consider for - // this case". Drop the row entirely AND record the rule ID so - // descendants suppress too. - // - // t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set, - // we re-surface the directly-skipped row (faded via IsHidden) - // instead of dropping it. Descendants are NOT cascade-suppressed - // in that mode either — the un-suppressed parent computes its - // date normally, so children compute off it as usual. Either - // way we count the hide for the toggle's badge. - var isHidden bool - if r.SubmissionCode != nil { - if _, skipped := skipRules[*r.SubmissionCode]; skipped { - hiddenCount++ - if !opts.IncludeHidden { - skippedIDs[r.ID] = struct{}{} - continue - } - isHidden = true - } - } - if r.ParentID != nil { - if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped { - skippedIDs[r.ID] = struct{}{} - continue - } - } - - // AppellantContext propagation. A rule with its own PerCardAppellant - // pick stamps its UUID with that value. Otherwise inherit from - // parent if the parent had a context. - var ctxVal string - if r.SubmissionCode != nil { - if v, ok := perCardAppellant[*r.SubmissionCode]; ok { - ctxVal = v - } - } - if ctxVal == "" && r.ParentID != nil { - if v, ok := appellantContext[*r.ParentID]; ok { - ctxVal = v - } - } - if ctxVal != "" { - appellantContext[r.ID] = ctxVal - } - - d := UIDeadline{ - RuleID: r.ID.String(), - Name: r.Name, - NameEN: r.NameEN, - Priority: r.Priority, - ConditionExpr: json.RawMessage(r.ConditionExpr), - AppellantContext: ctxVal, - ChoicesOffered: json.RawMessage(r.ChoicesOffered), - IsHidden: isHidden, - } - if r.SubmissionCode != nil { - d.Code = *r.SubmissionCode - } - if r.PrimaryParty != nil { - d.Party = *r.PrimaryParty - } - if r.RuleCode != nil { - d.RuleRef = *r.RuleCode - } - if r.LegalSource != nil { - d.LegalSource = *r.LegalSource - d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) - d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) - } - if r.DeadlineNotes != nil { - d.Notes = *r.DeadlineNotes - } - if r.DeadlineNotesEn != nil { - d.NotesEN = *r.DeadlineNotesEn - } - - // Resolve the parent rule once so every conditional-rendering - // branch (incl. the optional-not-recorded path below) can stamp - // ParentRule* on the wire without re-scanning. Populated even - // for non-conditional rows — the frontend dependency-footer - // ("Folgt aus …") already consumes this on regular projected - // rows. (t-paliad-289) - var parentRule *models.DeadlineRule - if r.ParentID != nil { - if pr, ok := ruleByID[*r.ParentID]; ok { - parentRule = &pr - if pr.SubmissionCode != nil { - d.ParentRuleCode = *pr.SubmissionCode - } - d.ParentRuleName = pr.Name - d.ParentRuleNameEN = pr.NameEN - } - } - - // Trigger-event override on the user-facing dependency identity - // (m/paliad#126 / t-paliad-294). When a rule has a real - // trigger_event_id, that catalog event is the actual semantic - // anchor — not the parent_id node, which is only the calc-time - // arithmetic anchor. R.262(2) Erwiderung auf Vertraulichkeits- - // antrag is the canonical case: parent_id resolves to the SoC - // ("Klageerhebung"), but the real triggering event is the - // opposing party's confidentiality application. Generalises to - // any rule whose trigger_event_id is set (e.g. R.6(2) - // translations_lodge → judge-rapporteur's order). - // - // Only the user-facing wire fields shift; parentRule (and the - // parent_id chain that feeds parentIsCourtSet / the calc-time - // date arithmetic below) stays anchored on the rule tree — - // that's still the right calc semantic. parentRule is NOT - // reassigned here. - if r.TriggerEventID != nil { - if te, ok := triggerEventByID[*r.TriggerEventID]; ok { - d.ParentRuleCode = te.Code - d.ParentRuleName = te.NameDE - d.ParentRuleNameEN = te.Name - } - } - - // Propagate court-set status from a parent rule whose date the - // court determines: if the anchor itself has no real date, - // nothing downstream can be computed either — UNLESS the user - // has supplied an override date for the parent (which they can - // once they know the real decision date). - parentOverridden := false - if r.ParentID != nil && courtSet[*r.ParentID] && parentRule != nil { - if parentRule.SubmissionCode != nil { - if _, ok := overrideDates[*parentRule.SubmissionCode]; ok { - parentOverridden = true - } - } - } - parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden - - // Zero-duration rules fall into one of four buckets: - // 1. parent=nil, not court-determined → IsRootEvent (trigger anchor) - // 2. parent=nil, court-determined → IsCourtSet (Zwischenverfahren / - // Mündliche Verhandlung / Entscheidung etc.) - // 3. parent set, court-determined → IsCourtSet (waypoint) - // 4. parent set, NOT court-determined → "filed-with-parent" - // semantic: rule is filed AT THE SAME TIME as its parent - // (e.g. upc.rev.cfi.rev.app_to_amend, rev.cc_inf — R.49(2) says - // Application to amend / Counterclaim for infringement are - // INCLUDED in the Defence to revocation). Use the parent's - // computed date. - // - // AnchorOverrides: when the user has set a date for any - // zero-duration rule, that override wins over both the - // court-set placeholder and the parent-inheritance. - if r.DurationValue == 0 { - // User override always wins. - if r.SubmissionCode != nil { - if ov, ok := overrideDates[*r.SubmissionCode]; ok { - d.DueDate = ov.Format("2006-01-02") - d.OriginalDate = d.DueDate - d.IsOverridden = true - computed[*r.SubmissionCode] = ov - deadlines = append(deadlines, d) - continue - } - } - - if r.ParentID == nil && !r.IsCourtSet { - // Bucket 1: timeline anchor. - d.IsRootEvent = true - d.DueDate = triggerDateStr - d.OriginalDate = triggerDateStr - if r.SubmissionCode != nil { - computed[*r.SubmissionCode] = triggerDate - } - } else if r.ParentID != nil && !r.IsCourtSet { - // Bucket 4: filed-with-parent. Inherit parent's date. - // If parent is court-set, we have nothing to inherit — - // fall through to court-set marking. - if parentIsCourtSet { - // Indirect: this rule isn't itself court-determined, - // it's blocked because its parent is. UI should say - // "unbestimmt", not "wird vom Gericht bestimmt". - d.IsCourtSet = true - d.IsCourtSetIndirect = true - d.IsConditional = true - d.DueDate = "" - d.OriginalDate = "" - courtSet[r.ID] = true - } else { - var parentDate time.Time - var haveParentDate bool - for _, prev := range rules { - if prev.ID == *r.ParentID { - if prev.SubmissionCode != nil { - if ov, ok := overrideDates[*prev.SubmissionCode]; ok { - parentDate = ov - haveParentDate = true - } else if ref, ok := computed[*prev.SubmissionCode]; ok { - parentDate = ref - haveParentDate = true - } - } - break - } - } - if haveParentDate { - d.DueDate = parentDate.Format("2006-01-02") - d.OriginalDate = d.DueDate - if r.SubmissionCode != nil { - computed[*r.SubmissionCode] = parentDate - } - } else { - // Parent not yet computed (defensive — shouldn't - // happen given sequence_order). Treat as indirect - // court-set: the date is unknown but the rule - // itself isn't a court action. - d.IsCourtSet = true - d.IsCourtSetIndirect = true - d.IsConditional = true - d.DueDate = "" - d.OriginalDate = "" - courtSet[r.ID] = true - } - } - } else { - // Buckets 2 + 3: court-determined directly (the rule - // itself is a hearing / decision / order or has - // primary_party='court'). The label "wird vom Gericht - // bestimmt" is strictly correct here — keep - // IsCourtSetIndirect=false. - d.IsCourtSet = true - d.DueDate = "" - d.OriginalDate = "" - courtSet[r.ID] = true - } - deadlines = append(deadlines, d) - continue - } - - // If the parent is court-determined and not overridden we have no - // real anchor date; surface this rule as court-set too rather - // than fabricating one off the trigger date. The user can re-run - // with the actual decision date once the court issues it (or - // supplied via AnchorOverrides). - // - // This is the RoP.151 case (Antrag auf Kostenentscheidung is - // "1 Monat ab Hauptentscheidung") — the rule has a real - // duration but its anchor is the court-set parent. The UI - // should say "unbestimmt", not "wird vom Gericht bestimmt": - // the date isn't directly determined by the court, it's - // derived from a date the court sets. - // - // timing='before' rules end up here too — a rule with - // "1 Monat VOR der mündlichen Verhandlung" (R.109(1)) has the - // oral hearing as its parent; if the hearing date isn't set, - // the backward arithmetic against the trigger date is - // meaningless. The pre-pass above ensures courtSet[oral.ID] - // is true even when the oral hearing rule is processed later - // in sequence_order. IsConditional surfaces the "abhängig - // von " UX. (t-paliad-289) - if parentIsCourtSet { - d.IsCourtSet = true - d.IsCourtSetIndirect = true - d.IsConditional = true - d.DueDate = "" - d.OriginalDate = "" - courtSet[r.ID] = true - deadlines = append(deadlines, d) - continue - } - - // Anchor: prefer alt-anchor (e.g. priority_date for epa.grant.exa publish) - // when supplied, then parent's computed date (or user override), - // then trigger date. - baseDate := triggerDate - if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil { - baseDate = *priorityDate - } else if r.ParentID != nil { - // Linear scan is fine — rule trees are < 20 entries. - for _, prev := range rules { - if prev.ID == *r.ParentID { - if prev.SubmissionCode != nil { - // User override on the parent rule wins over - // the calculated date — lets the user redirect - // downstream from a real (court-extended, - // court-set) date. - if ov, ok := overrideDates[*prev.SubmissionCode]; ok { - baseDate = ov - } else if ref, ok := computed[*prev.SubmissionCode]; ok { - baseDate = ref - } - } - break - } - } - } - - // Flag-conditioned alt-swap (legacy with_ccr pattern): when the - // gate fires AND alt_* values exist, swap the primary duration - // to the alt values. This is distinct from combine_op below — - // alt-swap is a one-or-the-other choice keyed on flags, whereas - // combine_op computes both legs and picks max/min. Mutually - // exclusive in the live corpus today (no rule sets both). - durationValue := r.DurationValue - durationUnit := r.DurationUnit - timing := "" - if r.Timing != nil { - timing = *r.Timing - } - if r.CombineOp == nil && gateMet && hasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil { - durationValue = *r.AltDurationValue - if r.AltDurationUnit != nil { - durationUnit = *r.AltDurationUnit - } - if r.AltRuleCode != nil { - d.RuleRef = *r.AltRuleCode - } - } - - // User override on this rule: replace the calculated date with - // the user's date. Skip holiday rollover — the user's date is - // authoritative. Downstream rules that chain off this rule will - // see the override via the parent-anchor lookup above. - if r.SubmissionCode != nil { - if ov, ok := overrideDates[*r.SubmissionCode]; ok { - d.OriginalDate = ov.Format("2006-01-02") - d.DueDate = ov.Format("2006-01-02") - d.WasAdjusted = false - d.AdjustmentReason = nil - d.IsOverridden = true - computed[*r.SubmissionCode] = ov - deadlines = append(deadlines, d) - continue - } - } - - origDate, adjusted, wasAdj, reason := applyDuration( - baseDate, durationValue, durationUnit, timing, country, regime, s.holidays, - ) - - // combine_op composite: compute the alt leg too, apply max/min. - // No proceeding-tree rules carry combine_op today (it's a - // future-friendly column the rule editor will surface). When - // present, the gate-met / alt-swap branch above has been - // skipped, so the comparison is between the unmodified base - // (durationValue/Unit) and the alt (AltDurationValue/Unit). - if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { - altOrig, altAdj, altWasAdj, altReason := applyDuration( - baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays, - ) - switch *r.CombineOp { - case "max": - if altAdj.After(adjusted) { - origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason - } - case "min": - if altAdj.Before(adjusted) { - origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason - } - } - } - - d.OriginalDate = origDate.Format("2006-01-02") - d.DueDate = adjusted.Format("2006-01-02") - d.WasAdjusted = wasAdj - d.AdjustmentReason = reason - - // Optional-on-the-other-side detection (t-paliad-289 Symptom B). - // Rules with priority='optional' AND primary_party='both' whose - // data-model parent is the proceeding's trigger anchor (parent - // has parent_id=NULL and is not court-set, i.e. the SoC root - // rule) represent a rule whose REAL triggering event sits - // outside the rule data — e.g. R.262(2) Erwiderung auf - // Vertraulichkeitsantrag anchors on SoC in the data, but the - // real trigger is the opposing party's confidentiality motion - // which may never happen. Without an explicit anchor on the - // rule itself (user clicks "Datum setzen" after the motion - // arrives), the projection must NOT claim a concrete date. - // - // In the live corpus this catches confidentiality_response; - // every other optional+both rule has a court-set ancestor and - // is already caught by the parentIsCourtSet branches above. - // Suppressed when IsOverridden (the user has anchored the rule - // — the date is real) or when the rule has already been marked - // IsConditional by an earlier branch. - if !d.IsOverridden && !d.IsConditional && - r.Priority == "optional" && - r.PrimaryParty != nil && *r.PrimaryParty == "both" && - parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet { - d.IsConditional = true - d.DueDate = "" - d.OriginalDate = "" - d.WasAdjusted = false - d.AdjustmentReason = nil - // Mark this rule's ID as having an uncertain anchor so - // rules chaining off it also surface conditional via the - // parentIsCourtSet path (no rule currently chains off - // confidentiality_response in the live corpus, but the - // extension keeps the propagation semantics consistent). - courtSet[r.ID] = true - } - - if r.SubmissionCode != nil { - computed[*r.SubmissionCode] = adjusted - } - deadlines = append(deadlines, d) - } - - // t-paliad-296: within consecutive runs of rules sharing the same - // trigger group (parent_id + trigger_event_id), reorder by duration - // ascending so optional events following the same anchor render in - // their likely-sequence order (a 1-month rule before a 2-month rule - // chained off the same decision). Different trigger groups keep - // their proceeding-sequence position — the chunk walk only sorts - // adjacent same-group rows. Court-set / conditional rows whose - // date isn't in the duration ladder sort LAST within their group. - sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) - - resp := &UIResponse{ - ProceedingType: pickedProceeding.Code, - ProceedingName: pickedProceeding.Name, - ProceedingNameEN: pickedProceeding.NameEN, - TriggerDate: triggerDateStr, - Deadlines: deadlines, - HiddenCount: hiddenCount, - } - // Sub-track routing keeps the user-picked proceeding's identity, - // so the trigger-event label rides on `pickedProceeding` (e.g. - // upc.ccr.cfi inherits whatever upc.inf.cfi's caption is, not - // upc.ccr.cfi's own — which is fine: the sub-track note already - // explains the framing). - if pickedProceeding.TriggerEventLabelDE != nil { - resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE - } - if pickedProceeding.TriggerEventLabelEN != nil { - resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN - } - if hasSubTrackNote { - resp.ContextualNote = subTrackNote.NoteDE - resp.ContextualNoteEN = subTrackNote.NoteEN - } - return resp, nil + return lp.Calculate(ctx, proceedingCode, triggerDateStr, opts, s.catalog, s.holidays, s.courts) } -// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of -// deadlines whose underlying rule shares the same trigger group -// (parent_id + trigger_event_id) and reorders each run in place by -// duration ascending. Different trigger groups keep their original -// proceeding-sequence position — the walk only ever permutes adjacent -// same-group rows. -// -// Sort key (within a run): -// 1. Conditional / court-set rows (no concrete date in the duration -// ladder) sort LAST, tiebroken by submission_code. -// 2. duration_unit weight ASC: days/working_days < weeks < months < years -// 3. duration_value ASC -// 4. submission_code ASC (deterministic tiebreak) -// -// Issue: m/paliad#128 — post-decision optional events (R.151/R.353 -// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog -// order instead of likely-sequence order. (t-paliad-296) -func sortDeadlinesByDurationWithinTriggerGroup( - deadlines []UIDeadline, - ruleByID map[uuid.UUID]models.DeadlineRule, -) { - if len(deadlines) < 2 { - return - } - n := len(deadlines) - i := 0 - for i < n { - gid := triggerGroupKey(deadlines[i], ruleByID) - j := i + 1 - for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid { - j++ - } - // Root rules (no parent and no trigger_event) get gid="root" - // and would otherwise collapse into one big run. Skip the sort - // for the "root" pseudo-group — each root rule represents its - // own anchor (SoC, oral hearing, decision …) and the - // proceeding-sequence order between them must be preserved. - if j-i > 1 && gid != "" { - chunk := deadlines[i:j] - sort.SliceStable(chunk, func(a, b int) bool { - return durationLessForSort(chunk[a], chunk[b], ruleByID) - }) - } - i = j - } -} - -// triggerGroupKey returns a string key identifying which trigger group -// a deadline belongs to. Same key = same group = candidates for sort. -// Empty string means "root" (no parent, no trigger_event) — used as a -// sentinel by the caller to skip sorting roots against each other. -func triggerGroupKey(d UIDeadline, ruleByID map[uuid.UUID]models.DeadlineRule) string { - rid, err := uuid.Parse(d.RuleID) - if err != nil { - return "" - } - r, ok := ruleByID[rid] - if !ok { - return "" - } - if r.ParentID != nil { - return "p:" + r.ParentID.String() - } - if r.TriggerEventID != nil { - return fmt.Sprintf("t:%d", *r.TriggerEventID) - } - return "" -} - -// durationLessForSort compares two deadlines for the duration-ascending -// sort. Court-set / conditional rows (no concrete date) sort LAST -// regardless of duration — they don't fit the duration ladder. -func durationLessForSort( - a, b UIDeadline, - ruleByID map[uuid.UUID]models.DeadlineRule, -) bool { - aLast := a.IsCourtSet || a.IsConditional - bLast := b.IsCourtSet || b.IsConditional - if aLast != bLast { - return !aLast - } - if aLast && bLast { - return a.Code < b.Code - } - - ra := lookupRuleFromDeadline(a, ruleByID) - rb := lookupRuleFromDeadline(b, ruleByID) - - wa := durationUnitWeight(ra.DurationUnit) - wb := durationUnitWeight(rb.DurationUnit) - if wa != wb { - return wa < wb - } - if ra.DurationValue != rb.DurationValue { - return ra.DurationValue < rb.DurationValue - } - return a.Code < b.Code -} - -func lookupRuleFromDeadline( - d UIDeadline, - ruleByID map[uuid.UUID]models.DeadlineRule, -) models.DeadlineRule { - if d.RuleID == "" { - return models.DeadlineRule{} - } - rid, err := uuid.Parse(d.RuleID) - if err != nil { - return models.DeadlineRule{} - } - return ruleByID[rid] -} - -// durationUnitWeight maps a duration unit to its sort weight so the -// trigger-group sort can order shorter durations first. days and -// working_days share weight 0 (both are sub-week granularities); -// unknown units sort to the end so they're visible as a tail rather -// than silently winning. -func durationUnitWeight(unit string) int { - switch unit { - case "days", "working_days": - return 0 - case "weeks": - return 1 - case "months": - return 2 - case "years": - return 3 - } - return 4 -} - -// ErrUnknownRule is returned when CalculateRule can't resolve the -// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule. -var ErrUnknownRule = errors.New("unknown rule") - -// CalcRuleParams identifies a single rule and the inputs needed to -// compute one deadline from it. Caller supplies either RuleID OR the -// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on -// hand from the concept-card pill it just received a click on. -type CalcRuleParams struct { - RuleID string // optional — UUID - ProceedingCode string // optional — used with RuleLocalCode - RuleLocalCode string // optional — paliad.deadline_rules.code - TriggerDate string // required — YYYY-MM-DD - Flags []string // optional — condition_flag inputs - CourtID string // optional — selects holiday calendar; defaults via proceeding's jurisdiction -} - -// RuleCalculation is the v4 (t-paliad-136 Phase B) single-rule calc -// response that backs the result-card click → calc-panel flow. Distinct -// from UIDeadline (which represents one rendered timeline row inside a -// full-proceeding response): RuleCalculation is self-contained — caller -// gets the rule metadata + the computed date in one payload, no separate -// proceeding-types lookup needed. -// -// Trigger semantics: TriggerDate is the immediate parent event's -// effective date — i.e. when the user clicks "Duplik" in the card and -// types "2026-05-05", they mean "I received the Replik on 2026-05-05". -// We do NOT walk the parent chain; callers wanting the full timeline -// for a proceeding still go through Calculate. -type RuleCalculation struct { - Rule RuleCalculationRule `json:"rule"` - Proceeding RuleCalculationProceeding `json:"proceeding"` - TriggerDate string `json:"triggerDate"` - OriginalDate string `json:"originalDate"` - DueDate string `json:"dueDate"` - WasAdjusted bool `json:"wasAdjusted"` - AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"` - IsCourtSet bool `json:"isCourtSet"` - // FlagsApplied lists the condition_flag values from the rule that - // the caller's Flags satisfied. Empty when the rule has no - // condition_flag, OR when the caller didn't satisfy the gate. Lets - // the frontend show "Mit Nichtigkeitswiderklage angewandt" hints. - FlagsApplied []string `json:"flagsApplied,omitempty"` - // FlagsRequired is the rule's condition_flag in canonical order so - // the frontend can render checkboxes for each flag the rule gates on. - FlagsRequired []string `json:"flagsRequired,omitempty"` -} - -// RuleCalculationRule mirrors the small subset of DeadlineRule the -// frontend needs to render the calc panel. -type RuleCalculationRule struct { - ID string `json:"id"` - LocalCode string `json:"localCode,omitempty"` - NameDE string `json:"nameDE"` - NameEN string `json:"nameEN"` - RuleRef string `json:"ruleRef,omitempty"` - LegalSource string `json:"legalSource,omitempty"` - LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"` - LegalSourceURL string `json:"legalSourceURL,omitempty"` - DurationValue int `json:"durationValue"` - DurationUnit string `json:"durationUnit"` - Party string `json:"party,omitempty"` - IsMandatory bool `json:"isMandatory"` - NotesDE string `json:"notesDE,omitempty"` - NotesEN string `json:"notesEN,omitempty"` -} - -// RuleCalculationProceeding identifies the proceeding context for the -// rule. Used by the frontend for display + by the add-to-project flow. -type RuleCalculationProceeding struct { - Code string `json:"code"` - NameDE string `json:"nameDE"` - NameEN string `json:"nameEN"` -} - -// CalculateRule computes a single deadline from a rule + trigger date. -// Used by the v4 result-card click flow. Distinct from Calculate: no -// parent-chain walk, no full-timeline rendering — just one date out. -// -// When the rule is court-determined (primary_party='court' or event_type -// ∈ {hearing, decision, order}), DueDate is empty and IsCourtSet=true; -// the caller should disable the "Add to project" CTA in that case. -// -// When the rule has condition_flag and the caller's Flags satisfy every -// element AND alt_duration_value is non-NULL, the calc swaps to alt_* -// (matches the existing flag-conditional semantics in Calculate). -// -// When the rule has condition_flag and the caller's Flags do NOT satisfy -// every element, the calc still proceeds with the base duration_value -// and surfaces FlagsRequired so the frontend can render the gating -// checkboxes. The result IS the date the rule would be due if the user -// confirmed the flag — letting the user toggle the checkbox and see the -// duration change live. +// CalculateRule delegates to litigationplanner.CalculateRule. Distinct +// from Calculate: no parent-chain walk, no full-timeline rendering — +// just one date out. func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*RuleCalculation, error) { - triggerDate, err := time.Parse("2006-01-02", params.TriggerDate) - if err != nil { - return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err) - } - - rule, pt, err := s.resolveRule(ctx, params) - if err != nil { - return nil, err - } - - mandWire, _ := wireFlagsFromPriority(rule.Priority) - out := &RuleCalculation{ - Rule: RuleCalculationRule{ - ID: rule.ID.String(), - NameDE: rule.Name, - NameEN: rule.NameEN, - DurationValue: rule.DurationValue, - DurationUnit: rule.DurationUnit, - IsMandatory: mandWire, - }, - Proceeding: RuleCalculationProceeding{ - Code: pt.Code, - NameDE: pt.Name, - NameEN: pt.NameEN, - }, - TriggerDate: params.TriggerDate, - } - if rule.SubmissionCode != nil { - out.Rule.LocalCode = *rule.SubmissionCode - } - if rule.RuleCode != nil { - out.Rule.RuleRef = *rule.RuleCode - } - if rule.LegalSource != nil { - out.Rule.LegalSource = *rule.LegalSource - out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource) - out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource) - } - if rule.PrimaryParty != nil { - out.Rule.Party = *rule.PrimaryParty - } - if rule.DeadlineNotes != nil { - out.Rule.NotesDE = *rule.DeadlineNotes - } - if rule.DeadlineNotesEn != nil { - out.Rule.NotesEN = *rule.DeadlineNotesEn - } - // Slice 9 (t-paliad-195) replacement for the dropped condition_flag - // text[] enumeration: walk the jsonb gate to pull out flag-leaf - // names. Returns nil on an unconditional rule. - out.FlagsRequired = extractFlagsFromExpr(rule.ConditionExpr) - - // Court-determined: no calculable date. - if rule.IsCourtSet { - out.IsCourtSet = true - return out, nil - } - - // Resolve flag-conditional duration via the unified condition_expr - // evaluator (Slice 4). Same semantics as Calculate: gate met + alt - // values present → swap to alt; otherwise use base values. - flagSet := make(map[string]struct{}, len(params.Flags)) - for _, f := range params.Flags { - flagSet[f] = struct{}{} - } - durationValue := rule.DurationValue - durationUnit := rule.DurationUnit - gateMet := evalConditionExpr([]byte(rule.ConditionExpr), flagSet) - if gateMet && hasConditionExpr(rule.ConditionExpr) { - out.FlagsApplied = out.FlagsRequired - if rule.AltDurationValue != nil { - durationValue = *rule.AltDurationValue - } - if rule.AltDurationUnit != nil { - durationUnit = *rule.AltDurationUnit - } - if rule.AltRuleCode != nil { - out.Rule.RuleRef = *rule.AltRuleCode - } - } - - // Zero-duration non-court-determined rules are "filed at the same - // time as parent" markers (upc.rev.cfi.app_to_amend, upc.rev.cfi.cc_inf): - // effectively mean "due on the trigger date itself". The card-click - // flow doesn't need to surface those as a calc panel — but if it - // does, returning the trigger date is the right answer. - if durationValue == 0 { - out.OriginalDate = params.TriggerDate - out.DueDate = params.TriggerDate - return out, nil - } - - defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) - country, regime, err := s.courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime) - if err != nil { - return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err) - } - - timing := "" - if rule.Timing != nil { - timing = *rule.Timing - } - endDate, adjusted, wasAdj, reason := applyDuration( - triggerDate, durationValue, durationUnit, timing, country, regime, s.holidays, - ) - out.OriginalDate = endDate.Format("2006-01-02") - out.DueDate = adjusted.Format("2006-01-02") - out.WasAdjusted = wasAdj - out.AdjustmentReason = reason - - return out, nil + return lp.CalculateRule(ctx, params, s.catalog, s.holidays, s.courts) } -// resolveRule resolves CalcRuleParams to a rule + its proceeding type. -// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The -// frontend uses the latter form (it has the pill context) and the -// programmatic / test caller can use the former. -func (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRuleParams) (*models.DeadlineRule, *models.ProceedingType, error) { - if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") { - return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required") - } - - const ptCols = `id, code, name, name_en, description, jurisdiction, - category, default_color, sort_order, is_active` - - var rule models.DeadlineRule - var pt models.ProceedingType - if params.RuleID != "" { - err := s.rules.db.GetContext(ctx, &rule, - `SELECT `+ruleColumns+` - FROM paliad.deadline_rules - WHERE id = $1 AND is_active = true`, params.RuleID) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil, ErrUnknownRule - } - if err != nil { - return nil, nil, fmt.Errorf("resolve rule by id %q: %w", params.RuleID, err) - } - if rule.ProceedingTypeID == nil { - return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID) - } - err = s.rules.db.GetContext(ctx, &pt, - `SELECT `+ptCols+` - FROM paliad.proceeding_types - WHERE id = $1`, *rule.ProceedingTypeID) - if err != nil { - return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err) - } - return &rule, &pt, nil - } - - err := s.rules.db.GetContext(ctx, &pt, - `SELECT `+ptCols+` - FROM paliad.proceeding_types - WHERE code = $1 AND is_active = true`, params.ProceedingCode) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil, ErrUnknownProceedingType - } - if err != nil { - return nil, nil, fmt.Errorf("resolve proceeding %q: %w", params.ProceedingCode, err) - } - err = s.rules.db.GetContext(ctx, &rule, - `SELECT `+ruleColumns+` - FROM paliad.deadline_rules - WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`, - pt.ID, params.RuleLocalCode) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil, ErrUnknownRule - } - if err != nil { - return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", params.RuleLocalCode, params.ProceedingCode, err) - } - return &rule, &pt, nil -} - -// ListFristenrechnerTypes returns the proceeding types that populate the -// Fristenrechner UI (category = 'fristenrechner'), ordered by sort_order. -func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]FristenrechnerType, error) { +// ListFristenrechnerTypes returns the proceeding types that populate +// the Fristenrechner UI (category='fristenrechner'), ordered by +// sort_order. Stays on the service because the response is a paliad- +// specific surface (the wire shape FristenrechnerType is owned by the +// package but the SQL filter is paliad-side). +func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]lp.FristenrechnerType, error) { rows, err := s.rules.db.QueryxContext(ctx, ` SELECT code, name, name_en, jurisdiction FROM paliad.proceeding_types @@ -1372,9 +89,9 @@ func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([] } defer rows.Close() - var out []FristenrechnerType + var out []lp.FristenrechnerType for rows.Next() { - var t FristenrechnerType + var t lp.FristenrechnerType var juris sql.NullString if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil { return nil, err @@ -1387,430 +104,210 @@ func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([] return out, rows.Err() } -// FristenrechnerType mirrors the /api/tools/proceeding-types response metadata. -type FristenrechnerType struct { - Code string `json:"code"` - Name string `json:"name"` - NameEN string `json:"nameEN"` - Group string `json:"group"` +// FristenrechnerType is paliad's local alias for lp.FristenrechnerType +// so historical call-sites (services.FristenrechnerType) keep working. +type FristenrechnerType = lp.FristenrechnerType + +// --------------------------------------------------------------------- +// paliadCatalog is the paliad-side litigationplanner.Catalog adapter. +// Wraps DeadlineRuleService to expose proceeding + rule lookups against +// paliad.proceeding_types + paliad.deadline_rules. +// --------------------------------------------------------------------- + +type paliadCatalog struct { + rules *DeadlineRuleService } -// allFlagsSet returns true when every element of `required` is present in -// `set`. Empty `required` returns true (no condition). Retained as the -// fallback predicate used by evalConditionExpr when condition_expr is -// NULL but the legacy condition_flag text[] is set — preserves -// transition-window behaviour for any row Slice 2 missed (it shouldn't, -// but defensive). -func allFlagsSet(required []string, set map[string]struct{}) bool { - for _, f := range required { - if _, ok := set[f]; !ok { - return false - } - } - return true -} +// proceedingTypeColumns is canonically defined in +// deadline_rule_service.go; the catalog adapter reuses it via the +// shared package-level const. -// evalConditionExpr returns true iff the rule's gate predicate is -// satisfied for the caller's flag set. Drives flag-conditional rendering -// + flag-conditional alt-swap throughout the calculator. -// -// Grammar (design §2.4 long form, mig 084 backfill): -// -// {"flag": ""} — leaf: true iff ∈ flags -// {"op": "and", "args": [...]} — true iff every arg evaluates true -// {"op": "or", "args": [...]} — true iff any arg evaluates true -// {"op": "not", "args": []} — true iff the single arg is false -// -// NULL / empty / "null" expression → true (unconditional). Malformed -// JSON → true (defensive: the rule still renders, the lawyer sees -// it even if the gate is broken). -// -// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag -// text[] column; the fallback that AND'd over it is gone. Any future -// row needing array-of-flags semantics writes the equivalent -// {"op":"and","args":[{"flag":""},...]} jsonb directly. -func evalConditionExpr(expr []byte, flags map[string]struct{}) bool { - if len(expr) == 0 || string(expr) == "null" { - return true +// LoadProceeding returns the proceeding-type metadata + rules. The +// ProjectHint is currently ignored on paliad's side (per m's 2026-05-26 +// decision dropping the Slice E user-authored rules); kept on the +// interface for forward-compat. +func (c *paliadCatalog) LoadProceeding(ctx context.Context, code string, _ lp.ProjectHint) (*models.ProceedingType, []models.DeadlineRule, error) { + var pt models.ProceedingType + err := c.rules.db.GetContext(ctx, &pt, + `SELECT `+proceedingTypeColumns+` + FROM paliad.proceeding_types + WHERE code = $1 AND is_active = true`, code) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, lp.ErrUnknownProceedingType } - return evalConditionExprNode(expr, flags) -} - -// evalConditionExprNode walks one node of the condition_expr jsonb -// tree. Recursion depth is bounded by the editor (Slice 11 caps tree -// depth + arg count); pre-Slice-11 backfilled rows have at most a -// 2-arg AND (mig 084). -func evalConditionExprNode(raw []byte, flags map[string]struct{}) bool { - var node struct { - Flag string `json:"flag"` - Op string `json:"op"` - Args []json.RawMessage `json:"args"` - } - if err := json.Unmarshal(raw, &node); err != nil { - // Malformed → unconditional. The Slice 11 editor's validation - // will block such writes; in the live corpus today mig 084's - // jsonb_build_object output is well-formed by construction. - return true - } - if node.Flag != "" { - _, ok := flags[node.Flag] - return ok - } - switch node.Op { - case "and": - for _, a := range node.Args { - if !evalConditionExprNode(a, flags) { - return false - } - } - return true - case "or": - for _, a := range node.Args { - if evalConditionExprNode(a, flags) { - return true - } - } - return false - case "not": - if len(node.Args) != 1 { - // Malformed NOT — fall through to unconditional rather - // than risk suppressing a rule the lawyer expects to see. - return true - } - return !evalConditionExprNode(node.Args[0], flags) - } - // Unknown op (forward-compat with editor extensions): treat as - // unconditional so the rule still renders. - return true -} - -// hasConditionExpr returns true when the rule carries a non-empty, -// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the -// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the -// flag-keyed alt-swap branch. Same intent: "this rule has a gate; -// when the gate flips to met, swap to alt". -func hasConditionExpr(expr models.NullableJSON) bool { - if len(expr) == 0 { - return false - } - s := string(expr) - return s != "null" && s != "{}" -} - -// extractFlagsFromExpr walks the jsonb gate and returns the unique -// flag names referenced as {"flag":""} leaves. Used by -// CalculateRule's response (FlagsRequired) so the result-card calc -// panel can render flag checkboxes for each gate input. Replaces the -// dropped condition_flag text[] enumeration. Returns nil on a NULL -// expression or one that contains no flag leaves. -func extractFlagsFromExpr(expr models.NullableJSON) []string { - if !hasConditionExpr(expr) { - return nil - } - seen := make(map[string]struct{}) - walkFlagLeaves([]byte(expr), seen) - if len(seen) == 0 { - return nil - } - out := make([]string, 0, len(seen)) - for f := range seen { - out = append(out, f) - } - return out -} - -func walkFlagLeaves(raw []byte, into map[string]struct{}) { - var node struct { - Flag string `json:"flag"` - Op string `json:"op"` - Args []json.RawMessage `json:"args"` - } - if err := json.Unmarshal(raw, &node); err != nil { - return - } - if node.Flag != "" { - into[node.Flag] = struct{}{} - return - } - for _, a := range node.Args { - walkFlagLeaves(a, into) - } -} - -// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional) -// pair from the unified priority enum so the wire shape stays -// pixel-identical through Slice 4. Slice 8 will swap the wire to -// emit priority directly. Mapping is the exact reverse of mig 083's -// backfill (per design §2.3): -// -// 'mandatory' → (true, false) — statutory must, ☑ pre-checked -// 'optional' → (true, true) — RoP.151 case: strict but opt-in, -// ☐ pre-unchecked save modal -// 'recommended' → (false, false) — situational filing, save by default -// with override (legacy F/F semantic) -// 'informational' → (false, false) — never saves; today no live rows -// carry it. Future: surfaces as a -// notice card in the timeline. -// (unknown) → (true, false) — safe default; treat as mandatory -// so we never silently drop a rule. -func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { - switch priority { - case "mandatory": - return true, false - case "optional": - return true, true - case "recommended": - return false, false - case "informational": - return false, false - default: - return true, false - } -} - -// applyRuleOverrides replaces rules whose ID appears in `overrides` -// with the override row, and appends any override whose ID isn't in -// the source list (net-new drafts the rule editor wants to preview). -// -// Used by the Slice 11a (t-paliad-191) preview endpoint: the editor -// passes the draft as an override so Calculate runs against the -// proposed shape without writing to the DB. Empty overrides slice = -// pass-through (Calculate's existing behaviour for non-preview -// callers). The override slice is small (1 row in practice — the -// draft being previewed) so the linear scan is fine. -func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule { - if len(overrides) == 0 { - return src - } - byID := make(map[uuid.UUID]models.DeadlineRule, len(overrides)) - for _, o := range overrides { - byID[o.ID] = o - } - out := make([]models.DeadlineRule, 0, len(src)+len(overrides)) - seen := make(map[uuid.UUID]bool, len(overrides)) - for _, r := range src { - if ov, ok := byID[r.ID]; ok { - out = append(out, ov) - seen[ov.ID] = true - continue - } - out = append(out, r) - } - for _, o := range overrides { - if seen[o.ID] { - continue - } - out = append(out, o) - } - return out -} - -// applyDuration is the unified date-arithmetic helper used by every -// calculator path (Pipeline-A proceeding-tree, Pipeline-C trigger-event, -// CalculateRule single-rule). Phase 3 Slice 4 (t-paliad-185) replaces -// the prior split between addDuration (proceeding-tree, no timing / -// working_days) and applyDurationOnCalendar (Pipeline-C, full support). -// -// Returns (raw, adjusted, didAdjust, reason): -// -// - raw: the date strictly implied by the rule before rollover. -// - adjusted: post-rollover for calendar units. 'working_days' lands -// on a working day by construction so raw == adjusted there. -// - didAdjust: true iff rollover moved the date. -// - reason: populated when didAdjust is true; nil otherwise. -// -// timing='before' negates the sign. timing='after' (or any other value -// including the empty string) keeps it positive — preserves the -// pre-Slice-4 behaviour for proceeding-tree rules whose Timing field -// is sometimes NULL (mig 003 defaults to 'after' but legacy callers -// pass r.Timing dereferenced). -func applyDuration( - base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService, -) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) { - sign := 1 - if timing == "before" { - sign = -1 - } - switch unit { - case "days": - raw = base.AddDate(0, 0, sign*value) - case "weeks": - raw = base.AddDate(0, 0, sign*value*7) - case "months": - raw = base.AddDate(0, sign*value, 0) - case "working_days": - raw = addWorkingDays(base, sign*value, country, regime, holidays) - // Working-day arithmetic lands on a working day by construction - // — the per-step skip loop in addWorkingDays already passes over - // weekends and holidays. No post-rollover required. - return raw, raw, false, nil - default: - raw = base - } - adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime) - return raw, adjusted, didAdjust, reason -} - -// addWorkingDays advances from `from` by `n` working days, skipping -// weekends and holidays applicable to the given country/regime. Negative -// n walks backward. n=0 keeps the input date as-is (caller decides -// whether to roll forward via AdjustForNonWorkingDays). -// -// Bounded by an inner 30-step skip per advance — vacation runs in our -// holiday tables are < 14 consecutive days, so 30 is a safety margin. -func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time { - if n == 0 { - return from - } - step := 1 - if n < 0 { - step = -1 - n = -n - } - cur := from - for i := 0; i < n; i++ { - cur = cur.AddDate(0, 0, step) - for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ { - cur = cur.AddDate(0, 0, step) - } - } - return cur -} - -// calculateByTriggerEvent renders the Pipeline-C timeline for an event -// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id -// chains), have no flag gating, no priority_date alt-anchor, no party -// classification, and no IsRootEvent / IsCourtSet semantics. The math -// is just: base + (timing-signed) duration → optional alt-leg combine -// → optional weekend/holiday rollover for calendar units. -// -// UIResponse.ProceedingType / ProceedingName stay empty — EventDeadlineService -// owns the trigger-event metadata (it's the caller that needed it -// pre-Slice-3 and continues to load it for the legacy CalculateResponse -// shape). Callers that don't need those fields can ignore them. -func (s *FristenrechnerService) calculateByTriggerEvent( - ctx context.Context, triggerEventID int64, triggerDateStr string, opts CalcOptions, -) (*UIResponse, error) { - triggerDate, err := time.Parse("2006-01-02", triggerDateStr) if err != nil { - return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) + return nil, nil, fmt.Errorf("resolve proceeding %q: %w", code, err) } - - // Pipeline-C rules originate from youpc's UPC-flavoured deadline - // corpus — DE / UPC defaults match the legacy EventDeadlineService. - country, regime, err := s.courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC) + rules, err := c.rules.List(ctx, &pt.ID) if err != nil { - return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) + return nil, nil, err } + return &pt, rules, nil +} - rules, err := s.rules.ListByTriggerEvent(ctx, triggerEventID) +// LoadProceedingByID is the resolver for a rule's parent proceeding. +func (c *paliadCatalog) LoadProceedingByID(ctx context.Context, id int) (*models.ProceedingType, error) { + var pt models.ProceedingType + err := c.rules.db.GetContext(ctx, &pt, + `SELECT `+proceedingTypeColumns+` + FROM paliad.proceeding_types + WHERE id = $1`, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, lp.ErrUnknownProceedingType + } if err != nil { + return nil, fmt.Errorf("resolve proceeding by id %d: %w", id, err) + } + return &pt, nil +} + +// LoadRuleByID resolves a rule UUID to the rule row. +func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*models.DeadlineRule, error) { + var rule models.DeadlineRule + err := c.rules.db.GetContext(ctx, &rule, + `SELECT `+ruleColumns+` + FROM paliad.deadline_rules + WHERE id = $1 AND is_active = true`, ruleID) + if errors.Is(err, sql.ErrNoRows) { + return nil, lp.ErrUnknownRule + } + if err != nil { + return nil, fmt.Errorf("resolve rule by id %q: %w", ruleID, err) + } + if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil { return nil, err } - if len(opts.RuleOverrides) > 0 { - rules = applyRuleOverrides(rules, opts.RuleOverrides) - } - - deadlines := make([]UIDeadline, 0, len(rules)) - for _, r := range rules { - timing := "" - if r.Timing != nil { - timing = *r.Timing - } - baseRaw, baseAdj, baseChanged, baseReason := applyDuration( - triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, s.holidays, - ) - picked := baseAdj - original := baseRaw - wasAdj := baseChanged - reason := baseReason - - if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { - altRaw, altAdj, altChanged, altReason := applyDuration( - triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays, - ) - switch *r.CombineOp { - case "max": - if altAdj.After(baseAdj) { - picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason - } - case "min": - if altAdj.Before(baseAdj) { - picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason - } - } - } - - // Slice 9 (t-paliad-195) wire-shape cleanup: trigger-event - // path emits Priority + ConditionExpr directly. The legacy - // IsMandatory/IsOptional pair was retired with the column - // drop; frontend reads priorityRendering(d) which now branches - // on priority alone. - d := UIDeadline{ - RuleID: r.ID.String(), - Name: r.Name, - NameEN: r.NameEN, - Priority: r.Priority, - ConditionExpr: json.RawMessage(r.ConditionExpr), - DueDate: picked.Format("2006-01-02"), - OriginalDate: original.Format("2006-01-02"), - WasAdjusted: wasAdj, - AdjustmentReason: reason, - } - if r.SubmissionCode != nil { - d.Code = *r.SubmissionCode - } - if r.PrimaryParty != nil { - d.Party = *r.PrimaryParty - } - if r.RuleCode != nil { - d.RuleRef = *r.RuleCode - } - if r.LegalSource != nil { - d.LegalSource = *r.LegalSource - d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) - d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) - } - if r.DeadlineNotes != nil { - d.Notes = *r.DeadlineNotes - } - if r.DeadlineNotesEn != nil { - d.NotesEN = *r.DeadlineNotesEn - } - deadlines = append(deadlines, d) - } - - return &UIResponse{ - // Trigger-event responses don't carry proceeding metadata — - // EventDeadlineService.Calculate fills the trigger fields in the - // legacy CalculateResponse shape. Leaving these empty is the - // stable contract. - ProceedingType: "", - ProceedingName: "", - TriggerDate: triggerDateStr, - Deadlines: deadlines, - }, nil + return &rule, nil } -// DefaultsForJurisdiction maps the proceeding-type jurisdiction text -// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple a -// holiday lookup should default to when the caller didn't pass an explicit -// CourtID. UPC proceedings get DE+UPC (München LD is HLC's most common -// venue, German federal holidays plus UPC vacations apply); DE / DPMA / EPA -// get DE-only (German federal). Future EPA-specific closures will require -// callers to pick an EPA court explicitly so the EPO regime kicks in. +// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode) +// + returns the parent proceeding for use in the response identity. +func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*models.DeadlineRule, *models.ProceedingType, error) { + var pt models.ProceedingType + err := c.rules.db.GetContext(ctx, &pt, + `SELECT `+proceedingTypeColumns+` + FROM paliad.proceeding_types + WHERE code = $1 AND is_active = true`, proceedingCode) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, lp.ErrUnknownProceedingType + } + if err != nil { + return nil, nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err) + } + var rule models.DeadlineRule + err = c.rules.db.GetContext(ctx, &rule, + `SELECT `+ruleColumns+` + FROM paliad.deadline_rules + WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`, + pt.ID, submissionCode) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, lp.ErrUnknownRule + } + if err != nil { + return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", submissionCode, proceedingCode, err) + } + if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil { + return nil, nil, err + } + return &rule, &pt, nil +} + +// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules. +func (c *paliadCatalog) LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) { + return c.rules.ListByTriggerEvent(ctx, triggerEventID) +} + +// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for +// the conditional-label override (t-paliad-294 / m/paliad#126). +func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) { + return c.rules.LoadTriggerEventsByIDs(ctx, ids) +} + +// _ proves paliadCatalog satisfies lp.Catalog at compile time. +var _ lp.Catalog = (*paliadCatalog)(nil) + +// Ensure HolidayService satisfies lp.HolidayCalendar at compile time. +// HolidayService.AdjustForNonWorkingDaysWithReason returns the +// AdjustmentReason via paliad's internal type — since lp.AdjustmentReason +// is now the canonical definition and AdjustmentReason inside services +// is aliased to it, the signatures align verbatim. +var _ lp.HolidayCalendar = (*HolidayService)(nil) + +// Ensure CourtService satisfies lp.CourtRegistry at compile time. +var _ lp.CourtRegistry = (*CourtService)(nil) + +// --------------------------------------------------------------------- +// Helpers used by sibling services (event_trigger_service, +// event_deadline_service). Re-exported as thin wrappers so the existing +// call-sites in those services continue to compile without an import +// rewrite. A future slice can collapse them onto direct lp.* imports. +// --------------------------------------------------------------------- + +// applyDuration delegates to litigationplanner.ApplyDuration. +func applyDuration(base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) { + return lp.ApplyDuration(base, value, unit, timing, country, regime, holidays) +} + +// addWorkingDays delegates to litigationplanner.AddWorkingDays. // -// Helper kept tiny and stateless — when a caller passes a real CourtID, -// these defaults are bypassed entirely and the court's actual country + -// regime are used. -func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) { - if jurisdiction == nil { - return CountryDE, "" - } - switch *jurisdiction { - case "UPC": - return CountryDE, RegimeUPC - default: - return CountryDE, "" - } +//nolint:unused // referenced for forward-compat with sibling services +func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time { + return lp.AddWorkingDays(from, n, country, regime, holidays) +} + +// evalConditionExpr delegates to litigationplanner.EvalConditionExpr. +func evalConditionExpr(expr []byte, flags map[string]struct{}) bool { + return lp.EvalConditionExpr(expr, flags) +} + +// hasConditionExpr delegates to litigationplanner.HasConditionExpr. +func hasConditionExpr(expr models.NullableJSON) bool { + return lp.HasConditionExpr(expr) +} + +// extractFlagsFromExpr delegates to litigationplanner.ExtractFlagsFromExpr. +// +//nolint:unused // retained for sibling services that may want it +func extractFlagsFromExpr(expr models.NullableJSON) []string { + return lp.ExtractFlagsFromExpr(expr) +} + +// allFlagsSet delegates to litigationplanner.AllFlagsSet. Retained for +// the paliad-side test suite that asserts the helper's contract. +func allFlagsSet(required []string, set map[string]struct{}) bool { + return lp.AllFlagsSet(required, set) +} + +// wireFlagsFromPriority delegates to +// litigationplanner.WireFlagsFromPriority. Retained for the paliad-side +// test suite that asserts the priority → (isMandatory, isOptional) +// mapping. +func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { + return lp.WireFlagsFromPriority(priority) +} + +// sortDeadlinesByDurationWithinTriggerGroup is the paliad-side wrapper +// retained for the t-paliad-296 sort tests. Delegates to the +// package-internal sort over the lp.TimelineEntry shape — which is +// just an alias for UIDeadline, so callers pass []UIDeadline directly. +func sortDeadlinesByDurationWithinTriggerGroup( + deadlines []UIDeadline, + ruleByID map[uuid.UUID]models.DeadlineRule, +) { + lp.SortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) +} + +// DefaultsForJurisdiction delegates to +// litigationplanner.DefaultsForJurisdiction. Public re-export so +// handlers (deadline_rules_db.go) can keep using +// services.DefaultsForJurisdiction without an import-rewrite. +func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) { + return lp.DefaultsForJurisdiction(jurisdiction) +} + +// applyRuleOverrides delegates to litigationplanner.ApplyRuleOverrides. +// +//nolint:unused // retained for sibling services that may want it +func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule { + return lp.ApplyRuleOverrides(src, overrides) } diff --git a/internal/services/holidays.go b/internal/services/holidays.go index c74895d..b346ff7 100644 --- a/internal/services/holidays.go +++ b/internal/services/holidays.go @@ -8,6 +8,8 @@ import ( "time" "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // Country and regime constants — keep in sync with the paliad.countries @@ -229,38 +231,14 @@ func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, country, regime // Feiertag" — so a 27-day shift across UPC vacation no longer looks like a // math bug. See t-paliad-119. // -// Date fields are JSON-serialised as YYYY-MM-DD strings (the same convention -// as UIDeadline.DueDate / OriginalDate) so the frontend doesn't need a -// separate RFC3339 parser. Holidays carries the same string-date shape. -type AdjustmentReason struct { - // Kind is the dominant cause; longest cause wins when several apply - // (vacation > public_holiday > weekend). - Kind string `json:"kind"` - // Holidays collects every named holiday encountered while walking past - // the non-working run, deduped by (date, name). May be empty when the - // only cause is a weekend. - Holidays []HolidayDTO `json:"holidays,omitempty"` - // VacationName, VacationStart and VacationEnd describe the contiguous - // vacation block the original date sits in. Populated only when Kind - // == "vacation". Span boundaries are the first/last vacation day in - // the block (excludes the weekends that pad it). - VacationName string `json:"vacationName,omitempty"` - VacationStart string `json:"vacationStart,omitempty"` - VacationEnd string `json:"vacationEnd,omitempty"` - // OriginalWeekday is the English weekday name of the original date — - // "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI - // can localise it. - OriginalWeekday string `json:"originalWeekday,omitempty"` -} - -// HolidayDTO is the JSON shape for a holiday emitted in AdjustmentReason — -// distinct from Holiday so dates serialise as YYYY-MM-DD strings. -type HolidayDTO struct { - Date string `json:"date"` - Name string `json:"name"` - IsVacation bool `json:"isVacation,omitempty"` - IsClosure bool `json:"isClosure,omitempty"` -} +// Canonical AdjustmentReason + HolidayDTO definitions live in +// pkg/litigationplanner — kept here as type aliases so every existing +// reference (HolidayService methods, JSON serialisation, projection +// service) continues to compile. +type ( + AdjustmentReason = litigationplanner.AdjustmentReason + HolidayDTO = litigationplanner.HolidayDTO +) // AdjustForNonWorkingDaysWithReason is AdjustForNonWorkingDays plus an // explanation. Reason is nil when wasAdjusted is false. diff --git a/internal/services/proceeding_mapping.go b/internal/services/proceeding_mapping.go index 1174d5c..7938804 100644 --- a/internal/services/proceeding_mapping.go +++ b/internal/services/proceeding_mapping.go @@ -1,191 +1,63 @@ package services -// proceeding_mapping bridges the two proceeding-type vocabularies in the -// codebase: the **litigation** conceptual category (INF / REV / APP / -// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding -// + Pipeline-A rules, and the **fristenrechner** code category -// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator -// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects -// bind to fristenrechner codes directly, but the litigation→fristenrechner -// mapping is still needed for the ~40 Pipeline-A rules that remain on -// litigation proceedings and for any other surface that thinks in -// litigation terms. -// -// The mapping table here is the single source of truth — see -// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the -// design rationale + ambiguity notes, and -// docs/design-proceeding-code-taxonomy-2026-05-18.md for the -// lowercase dot-separated naming convention applied by mig 096 -// (t-paliad-206). **Never silent FK promotion**: every ambiguous case -// returns ok=false so callers can degrade gracefully ("no narrowing") -// instead of guessing. +import lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" -// Stable code constants — the strings landed by mig 096. Use these -// throughout the codebase so a future rename only needs to touch this -// file. The id-anchored FKs (deadline_rules.proceeding_type_id, -// projects.proceeding_type_id) are unaffected by the rename. +// proceeding_mapping bridges the two proceeding-type vocabularies in +// the codebase. The canonical implementations now live in +// pkg/litigationplanner — this file keeps the existing service-level +// names alive as re-exports so the rest of internal/services + tests +// compile without an import-rewrite. +// +// See pkg/litigationplanner/proceeding_mapping.go for the logic + +// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the +// design rationale. + +// Stable code constants — re-exported from the package so existing +// services / handlers can keep using the bare names. const ( - CodeUPCInfringement = "upc.inf.cfi" - CodeUPCRevocation = "upc.rev.cfi" - CodeUPCCounterclaim = "upc.ccr.cfi" - CodeUPCPreliminary = "upc.pi.cfi" - CodeUPCDamages = "upc.dmgs.cfi" - CodeUPCDiscovery = "upc.disc.cfi" - CodeUPCAppealMerits = "upc.apl.merits" - CodeUPCAppealOrder = "upc.apl.order" - CodeUPCAppealCost = "upc.apl.cost" - CodeDEInfringementLG = "de.inf.lg" - CodeDEInfringementOLG = "de.inf.olg" - CodeDEInfringementBGH = "de.inf.bgh" - CodeDENullityBPatG = "de.null.bpatg" - CodeDENullityBGH = "de.null.bgh" - CodeEPAGrant = "epa.grant.exa" - CodeEPAOpposition = "epa.opp.opd" - CodeEPAOppositionAppeal = "epa.opp.boa" - CodeDPMAOpposition = "dpma.opp.dpma" - CodeDPMAAppealBPatG = "dpma.appeal.bpatg" - CodeDPMAAppealBGH = "dpma.appeal.bgh" + CodeUPCInfringement = lp.CodeUPCInfringement + CodeUPCRevocation = lp.CodeUPCRevocation + CodeUPCCounterclaim = lp.CodeUPCCounterclaim + CodeUPCPreliminary = lp.CodeUPCPreliminary + CodeUPCDamages = lp.CodeUPCDamages + CodeUPCDiscovery = lp.CodeUPCDiscovery + CodeUPCAppealMerits = lp.CodeUPCAppealMerits + CodeUPCAppealOrder = lp.CodeUPCAppealOrder + CodeUPCAppealCost = lp.CodeUPCAppealCost + CodeDEInfringementLG = lp.CodeDEInfringementLG + CodeDEInfringementOLG = lp.CodeDEInfringementOLG + CodeDEInfringementBGH = lp.CodeDEInfringementBGH + CodeDENullityBPatG = lp.CodeDENullityBPatG + CodeDENullityBGH = lp.CodeDENullityBGH + CodeEPAGrant = lp.CodeEPAGrant + CodeEPAOpposition = lp.CodeEPAOpposition + CodeEPAOppositionAppeal = lp.CodeEPAOppositionAppeal + CodeDPMAOpposition = lp.CodeDPMAOpposition + CodeDPMAAppealBPatG = lp.CodeDPMAAppealBPatG + CodeDPMAAppealBGH = lp.CodeDPMAAppealBGH ) // MapLitigationToFristenrechner returns the fristenrechner code + // condition flags implied by a (litigationCode, jurisdiction) pair. -// -// Inputs are case-sensitive — pass the canonical upper-snake form -// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous -// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero -// fristenrechner code; callers should treat that as "no narrowing" -// and leave the cascade wide-open rather than auto-pick. -// -// Condition flags are returned as a slice so callers can apply them -// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr, -// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag -// context applies. +// Delegates to litigationplanner.MapLitigationToFristenrechner. func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) { - switch litigationCode { - case "INF": - switch jurisdiction { - case "UPC": - return CodeUPCInfringement, nil, true - case "DE": - return CodeDEInfringementLG, nil, true - } - case "REV": - switch jurisdiction { - case "UPC": - return CodeUPCRevocation, nil, true - case "DE": - return CodeDENullityBPatG, nil, true - } - case "CCR": - // Counterclaim revocation — UPC fold-in is structural (the - // counterclaim lives inside an upc.inf.cfi proceeding with the - // with_ccr flag). DE Nichtigkeit is conceptually the same - // adversarial-validity test, no separate flag. - switch jurisdiction { - case "UPC": - return CodeUPCInfringement, []string{"with_ccr"}, true - case "DE": - return CodeDENullityBPatG, nil, true - } - case "AMD": - // Amendment-application bundled into upc.inf.cfi via with_amend. - // No DE / EPA / DPMA analogue today. - if jurisdiction == "UPC" { - return CodeUPCInfringement, []string{"with_amend"}, true - } - case "APP": - // Appeal is ambiguous in DE (OLG vs BGH) and the project - // model doesn't carry the instance hint we'd need to - // disambiguate. UPC is unambiguous — upc.apl.merits covers - // the merits appeal track for inf/rev/ccr/damages. - if jurisdiction == "UPC" { - return CodeUPCAppealMerits, nil, true - } - case "APM": - // Preliminary injunction / urgency procedure — UPC-only - // concept in the fristenrechner taxonomy. - if jurisdiction == "UPC" { - return CodeUPCPreliminary, nil, true - } - case "OPP": - // Opposition — primarily EPA. DPMA has dpma.opp.dpma but it - // doesn't surface from the litigation vocabulary today. - if jurisdiction == "EPA" { - return CodeEPAOpposition, nil, true - } - } - return "", nil, false + return lp.MapLitigationToFristenrechner(litigationCode, jurisdiction) } -// ResolveCounterclaimRouting handles the determinator's -// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown -// for taxonomic completeness, but no rules are attached to it. When the -// cascade resolves to upc.ccr.cfi we route the rule lookup back to -// upc.inf.cfi with a default with_ccr=true flag — see -// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1. -// -// `code` is the proceeding code the cascade resolved to. If it's -// upc.ccr.cfi, the function returns (CodeUPCInfringement, -// []string{"with_ccr"}, true). For any other code the function returns -// (code, nil, false) and callers proceed with the code unchanged. The -// boolean signals "routing was applied"; the caller can surface the hint -// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin -// weiter." in the UI. +// ResolveCounterclaimRouting handles the determinator's upc.ccr.cfi +// illustrative-peer route. Delegates to +// litigationplanner.ResolveCounterclaimRouting. func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) { - if route, ok := SubTrackRoutings[code]; ok { - return route.ParentCode, route.DefaultFlags, true - } - return code, nil, false + return lp.ResolveCounterclaimRouting(code) } -// SubTrackRouting describes a proceeding type that has no native rules -// of its own and is normally rendered inside a parent proceeding's flow -// with one or more condition flags enabled. The Procedure Roadmap -// (verfahrensablauf) routes calc requests for these codes to the parent -// proceeding + default flags, but preserves the user-picked code/name -// in the response identity and surfaces a contextual note explaining -// the framing — see m/paliad#58 and the design doc cited above. -// -// Adding a new sub-track is a data-only change here: extend -// SubTrackRoutings with the (code, parent, flags, note) tuple and the -// renderer picks it up automatically. The note copy lives in this file -// because it's semantic to the routing, not UI chrome. -type SubTrackRouting struct { - // Code is the user-picked proceeding code (e.g. "upc.ccr.cfi"). - Code string - // ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi"). - ParentCode string - // DefaultFlags are merged into the user's flag set so the - // gated rules render. Order is preserved. - DefaultFlags []string - // NoteDE / NoteEN are the contextual banner above the timeline, - // explaining that the proceeding type is normally a sub-track. - // Plain text — the frontend renders them as a banner. - NoteDE string - NoteEN string -} - -// SubTrackRoutings — single-source-of-truth registry. Today: just CCR. -// The pattern generalises to other "sub-track" proceeding types (e.g. -// R.30 application to amend the patent as a standalone roadmap, R.46 -// preliminary objection) once they have a proceeding-type code of their -// own. New entries here are picked up by the spawn-as-standalone -// renderer in FristenrechnerService.Calculate without further wiring. -var SubTrackRoutings = map[string]SubTrackRouting{ - CodeUPCCounterclaim: { - Code: CodeUPCCounterclaim, - ParentCode: CodeUPCInfringement, - DefaultFlags: []string{"with_ccr"}, - NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.", - NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.", - }, -} +// SubTrackRoutings exposes the sub-track routing registry. SubTrackRouting +// is aliased in fristenrechner.go. +var SubTrackRoutings = lp.SubTrackRoutings // LookupSubTrackRouting returns the sub-track routing for a proceeding -// code, or (zero, false) if the code is not a sub-track. Used by the -// fristenrechner Calculate path to spawn the parent flow with the sub- -// track's default flags. +// code, or (zero, false) if the code is not a sub-track. Delegates to +// litigationplanner.LookupSubTrackRouting. func LookupSubTrackRouting(code string) (SubTrackRouting, bool) { - r, ok := SubTrackRoutings[code] - return r, ok + return lp.LookupSubTrackRouting(code) } diff --git a/pkg/litigationplanner/catalog.go b/pkg/litigationplanner/catalog.go new file mode 100644 index 0000000..e5755e3 --- /dev/null +++ b/pkg/litigationplanner/catalog.go @@ -0,0 +1,49 @@ +package litigationplanner + +import "context" + +// 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) +} diff --git a/pkg/litigationplanner/courts.go b/pkg/litigationplanner/courts.go new file mode 100644 index 0000000..f584ce9 --- /dev/null +++ b/pkg/litigationplanner/courts.go @@ -0,0 +1,49 @@ +package litigationplanner + +// CourtRegistry maps a court id (e.g. "upc-ld-paris", "de-bgh") to its +// (country, regime) tuple, which drives non-working-day adjustment. +// +// Implementations: +// - paliad: reads paliad.courts (CourtService.CountryRegime). +// - embedded/upc (Slice C): in-memory map populated from the embedded +// JSON snapshot. +// +// Empty courtID falls back to (defaultCountry, defaultRegime) so callers +// without a court_id (the abstract Verfahrensablauf path) still get +// sensible behaviour. Returns an error when courtID is non-empty and +// not in the registry. +type CourtRegistry interface { + CountryRegime(courtID, defaultCountry, defaultRegime string) (country, regime string, err error) +} + +// Country and regime constants — keep in sync with the paliad.countries +// seed list and the holidays_regime_chk / courts_regime_chk constraints. +const ( + CountryDE = "DE" + RegimeUPC = "UPC" + RegimeEPO = "EPO" +) + +// DefaultsForJurisdiction maps the proceeding-type jurisdiction text +// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple +// a holiday lookup should default to when the caller didn't pass an +// explicit CourtID. UPC proceedings get DE+UPC (München LD is HLC's +// most common venue, German federal holidays plus UPC vacations apply); +// DE / DPMA / EPA get DE-only (German federal). Future EPA-specific +// closures will require callers to pick an EPA court explicitly so the +// EPO regime kicks in. +// +// Helper kept tiny and stateless — when a caller passes a real CourtID, +// these defaults are bypassed entirely and the court's actual country + +// regime are used. +func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) { + if jurisdiction == nil { + return CountryDE, "" + } + switch *jurisdiction { + case "UPC": + return CountryDE, RegimeUPC + default: + return CountryDE, "" + } +} diff --git a/pkg/litigationplanner/doc.go b/pkg/litigationplanner/doc.go new file mode 100644 index 0000000..58b5a18 --- /dev/null +++ b/pkg/litigationplanner/doc.go @@ -0,0 +1,17 @@ +// Package litigationplanner is the canonical Fristen / Verfahrensablauf +// compute engine — the deadline-rule model, the calendar arithmetic, the +// condition-expression gate, the sub-track routing, and the timeline +// composer that drives Paliad's /tools/fristenrechner, +// /tools/verfahrensablauf, and the per-project SmartTimeline. +// +// The package owns its types (Rule, ProceedingType, Timeline, +// TimelineEntry, CalcOptions, …) and exposes three interfaces for the +// stateful inputs: Catalog (proceeding + rule lookup), HolidayCalendar +// (non-working-day adjustment), and CourtRegistry (court → country/regime +// resolution). Paliad implements them against its Postgres database; +// downstream consumers (youpc.org) implement them against an embedded +// JSON snapshot of the UPC subset. +// +// See docs/design-litigation-planner-2026-05-26.md (t-paliad-292 / +// m/paliad#124) for the full design. +package litigationplanner diff --git a/pkg/litigationplanner/durations.go b/pkg/litigationplanner/durations.go new file mode 100644 index 0000000..c4c93ea --- /dev/null +++ b/pkg/litigationplanner/durations.go @@ -0,0 +1,76 @@ +package litigationplanner + +import "time" + +// ApplyDuration is the unified date-arithmetic helper used by every +// calculator path (proceeding-tree, trigger-event, CalculateRule single- +// rule). Phase 3 Slice 4 (t-paliad-185) replaced the prior split +// between addDuration (proceeding-tree, no timing / working_days) and +// ApplyDurationOnCalendar (Pipeline-C, full support) with this single +// helper. +// +// Returns (raw, adjusted, didAdjust, reason): +// +// - raw: the date strictly implied by the rule before rollover. +// - adjusted: post-rollover for calendar units. 'working_days' lands +// on a working day by construction so raw == adjusted there. +// - didAdjust: true iff rollover moved the date. +// - reason: populated when didAdjust is true; nil otherwise. +// +// timing='before' negates the sign. timing='after' (or any other value +// including the empty string) keeps it positive — preserves the pre- +// Slice-4 behaviour for proceeding-tree rules whose Timing field is +// sometimes NULL (mig 003 defaults to 'after' but legacy callers pass +// r.Timing dereferenced). +func ApplyDuration( + base time.Time, value int, unit, timing, country, regime string, holidays HolidayCalendar, +) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) { + sign := 1 + if timing == "before" { + sign = -1 + } + switch unit { + case "days": + raw = base.AddDate(0, 0, sign*value) + case "weeks": + raw = base.AddDate(0, 0, sign*value*7) + case "months": + raw = base.AddDate(0, sign*value, 0) + case "working_days": + raw = AddWorkingDays(base, sign*value, country, regime, holidays) + // Working-day arithmetic lands on a working day by construction + // — the per-step skip loop in AddWorkingDays already passes over + // weekends and holidays. No post-rollover required. + return raw, raw, false, nil + default: + raw = base + } + adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime) + return raw, adjusted, didAdjust, reason +} + +// AddWorkingDays advances from `from` by `n` working days, skipping +// weekends and holidays applicable to the given country/regime. Negative +// n walks backward. n=0 keeps the input date as-is (caller decides +// whether to roll forward via AdjustForNonWorkingDays). +// +// Bounded by an inner 30-step skip per advance — vacation runs in our +// holiday tables are < 14 consecutive days, so 30 is a safety margin. +func AddWorkingDays(from time.Time, n int, country, regime string, holidays HolidayCalendar) time.Time { + if n == 0 { + return from + } + step := 1 + if n < 0 { + step = -1 + n = -n + } + cur := from + for i := 0; i < n; i++ { + cur = cur.AddDate(0, 0, step) + for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ { + cur = cur.AddDate(0, 0, step) + } + } + return cur +} diff --git a/pkg/litigationplanner/engine.go b/pkg/litigationplanner/engine.go new file mode 100644 index 0000000..3ee06e1 --- /dev/null +++ b/pkg/litigationplanner/engine.go @@ -0,0 +1,908 @@ +package litigationplanner + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Calculate renders the full UI timeline for a proceeding type + trigger date. +// Preserves the pre-Phase-C in-memory calculator's classification: +// +// - Rules with duration_value = 0 and no parent_id → IsRootEvent +// (due date = trigger date) +// - Rules with duration_value = 0 and a parent_id → IsCourtSet +// (due date empty, UI shows "court-set" placeholder) +// - All other rules → calculate from either the trigger date (no parent) +// or the previously-computed date for their parent rule. +// +// Audit-driven extensions: +// +// - opts.Flags can flip flag-conditioned rules onto their alt_* values +// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr"). +// - opts.PriorityDateStr overrides the anchor for rules with +// anchor_alt='priority_date' (e.g. epa.grant.exa publication date +// is 18mo from priority, not filing). +// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the +// caller redirect a downstream rule's parent anchor to a user-set +// date. +func Calculate( + ctx context.Context, + proceedingCode string, + triggerDateStr string, + opts CalcOptions, + catalog Catalog, + holidays HolidayCalendar, + courts CourtRegistry, +) (*Timeline, error) { + // Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven + // branch (Pipeline-C unified rules). proceedingCode is ignored on + // this path. + if opts.TriggerEventIDFilter != nil { + return calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts, catalog, holidays, courts) + } + + triggerDate, err := time.Parse("2006-01-02", triggerDateStr) + if err != nil { + return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) + } + + var priorityDate *time.Time + if opts.PriorityDateStr != "" { + pd, err := time.Parse("2006-01-02", opts.PriorityDateStr) + if err != nil { + return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err) + } + priorityDate = &pd + } + flagSet := make(map[string]struct{}, len(opts.Flags)) + for _, f := range opts.Flags { + flagSet[f] = struct{}{} + } + // v1 simplification (t-paliad-265): when any IncludeCCRFor entry + // exists, we treat with_ccr as set in the flag context. + if len(opts.IncludeCCRFor) > 0 { + flagSet["with_ccr"] = struct{}{} + } + + // Parse anchor overrides up-front so a malformed date errors out + // before we start walking rules. + overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides)) + for code, dateStr := range opts.AnchorOverrides { + od, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err) + } + overrideDates[code] = od + } + + // Look up proceeding type metadata. + pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint) + if err != nil { + return nil, err + } + + // Sub-track routing (m/paliad#58). When the user picks a proceeding + // that has no native rules and is normally a sub-track of another + // proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route + // rule lookup to the parent and merge the default flags into the + // user's flag set. The response identity stays on the user-picked + // proceeding so the page header still reads "Counterclaim for + // Revocation", but the timeline body is the parent's full flow with + // the sub-track flag enabled. + var subTrackNote SubTrackRouting + var hasSubTrackNote bool + pt := pickedProceeding + if route, ok := LookupSubTrackRouting(proceedingCode); ok { + subTrackNote = route + hasSubTrackNote = true + parentPt, parentRules, err := catalog.LoadProceeding(ctx, route.ParentCode, opts.ProjectHint) + if err != nil { + return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, err) + } + pt = parentPt + rules = parentRules + // Merge default flags into the user's flag set so the gated + // rules render. User-supplied flags win on conflict. + for _, f := range route.DefaultFlags { + if _, exists := flagSet[f]; !exists { + flagSet[f] = struct{}{} + } + } + } + + // Resolve (country, regime) for non-working-day adjustment. Court + // wins when supplied; otherwise default by proceeding regime. + defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) + country, regime, err := courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime) + if err != nil { + return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) + } + + if len(opts.RuleOverrides) > 0 { + rules = ApplyRuleOverrides(rules, opts.RuleOverrides) + } + + // ruleByID lets the conditional-rendering branches resolve a parent + // rule's display fields (submission_code, name, name_en) for the + // "abhängig von " chip without re-scanning the rules + // slice on every iteration. (t-paliad-289) + ruleByID := make(map[uuid.UUID]Rule, len(rules)) + for _, r := range rules { + ruleByID[r.ID] = r + } + + // triggerEventByID powers the trigger-event override on the + // conditional-label chip (m/paliad#126 / t-paliad-294). When a rule + // carries a real paliad.trigger_events row, that catalog event — + // not the rule's parent_id — is the rule's actual semantic anchor. + // The override fires below when stamping ParentRule* on the wire so + // the chip reads e.g. "abhängig von Antrag auf Vertraulichkeit + // gegenüber der Öffentlichkeit" for R.262(2) — instead of the + // (misleading) parent_id-derived "abhängig von Klageerhebung". + // + // Bulk-loaded in one round-trip; trees in the live corpus carry at + // most a handful of trigger_event_id-bearing rules (2 today on + // upc.inf.cfi), so the IN(...) is small. + var triggerIDs []int64 + seenTrigger := make(map[int64]struct{}, len(rules)) + for _, r := range rules { + if r.TriggerEventID == nil { + continue + } + if _, ok := seenTrigger[*r.TriggerEventID]; ok { + continue + } + seenTrigger[*r.TriggerEventID] = struct{}{} + triggerIDs = append(triggerIDs, *r.TriggerEventID) + } + triggerEventByID, err := catalog.LoadTriggerEventsByIDs(ctx, triggerIDs) + if err != nil { + return nil, fmt.Errorf("load trigger events for conditional labels: %w", err) + } + + // Walk the rule list in sequence_order (already sorted by the + // catalog query) and compute each entry, keeping a code→date map so + // RelativeTo / parent_id references resolve to the adjusted + // predecessor date. + computed := make(map[string]time.Time, len(rules)) + courtSet := make(map[uuid.UUID]bool, len(rules)) + deadlines := make([]TimelineEntry, 0, len(rules)) + + skipRules := opts.SkipRules + perCardAppellant := opts.PerCardAppellant + skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules)) + hiddenCount := 0 + appellantContext := make(map[uuid.UUID]string, len(rules)) + + for _, r := range rules { + // Phase-3 unified gate: evaluate condition_expr (jsonb). + // Suppression semantic preserved: when the gate fires false + // AND no alt_* values exist, the rule is dropped from the + // timeline entirely (purely conditional). When alt_* values + // exist, the gate-false branch still renders, just without + // the alt-swap. + gateMet := EvalConditionExpr([]byte(r.ConditionExpr), flagSet) + if !gateMet && r.AltDurationValue == nil { + continue + } + + // SkipRules suppression (t-paliad-265). + // t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set, + // we re-surface the directly-skipped row (faded via IsHidden) + // instead of dropping it. + var isHidden bool + if r.SubmissionCode != nil { + if _, skipped := skipRules[*r.SubmissionCode]; skipped { + hiddenCount++ + if !opts.IncludeHidden { + skippedIDs[r.ID] = struct{}{} + continue + } + isHidden = true + } + } + if r.ParentID != nil { + if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped { + skippedIDs[r.ID] = struct{}{} + continue + } + } + + // AppellantContext propagation. A rule with its own + // PerCardAppellant pick stamps its UUID with that value. + // Otherwise inherit from parent if the parent had a context. + var ctxVal string + if r.SubmissionCode != nil { + if v, ok := perCardAppellant[*r.SubmissionCode]; ok { + ctxVal = v + } + } + if ctxVal == "" && r.ParentID != nil { + if v, ok := appellantContext[*r.ParentID]; ok { + ctxVal = v + } + } + if ctxVal != "" { + appellantContext[r.ID] = ctxVal + } + + d := TimelineEntry{ + RuleID: r.ID.String(), + Name: r.Name, + NameEN: r.NameEN, + Priority: r.Priority, + ConditionExpr: json.RawMessage(r.ConditionExpr), + AppellantContext: ctxVal, + ChoicesOffered: json.RawMessage(r.ChoicesOffered), + IsHidden: isHidden, + } + if r.SubmissionCode != nil { + d.Code = *r.SubmissionCode + } + if r.PrimaryParty != nil { + d.Party = *r.PrimaryParty + } + if r.RuleCode != nil { + d.RuleRef = *r.RuleCode + } + if r.LegalSource != nil { + d.LegalSource = *r.LegalSource + d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) + d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) + } + if r.DeadlineNotes != nil { + d.Notes = *r.DeadlineNotes + } + if r.DeadlineNotesEn != nil { + d.NotesEN = *r.DeadlineNotesEn + } + + // Resolve the parent rule once so every conditional-rendering + // branch (incl. the optional-not-recorded path below) can stamp + // ParentRule* on the wire without re-scanning. Populated even + // for non-conditional rows — the frontend dependency-footer + // ("Folgt aus …") already consumes this on regular projected + // rows. (t-paliad-289) + var parentRule *Rule + if r.ParentID != nil { + if pr, ok := ruleByID[*r.ParentID]; ok { + parentRule = &pr + if pr.SubmissionCode != nil { + d.ParentRuleCode = *pr.SubmissionCode + } + d.ParentRuleName = pr.Name + d.ParentRuleNameEN = pr.NameEN + } + } + + // Trigger-event override on the user-facing dependency identity + // (m/paliad#126 / t-paliad-294). When a rule has a real + // trigger_event_id, that catalog event is the actual semantic + // anchor — not the parent_id node, which is only the calc-time + // arithmetic anchor. Only the user-facing wire fields shift; + // parentRule (and the parent_id chain feeding parentIsCourtSet + // and the calc-time arithmetic below) stays anchored on the + // rule tree. + if r.TriggerEventID != nil { + if te, ok := triggerEventByID[*r.TriggerEventID]; ok { + d.ParentRuleCode = te.Code + d.ParentRuleName = te.NameDE + d.ParentRuleNameEN = te.Name + } + } + + // Propagate court-set status from a parent rule whose date the + // court determines: if the anchor itself has no real date, + // nothing downstream can be computed either — UNLESS the user + // has supplied an override date for the parent. + parentOverridden := false + if r.ParentID != nil && courtSet[*r.ParentID] { + for _, prev := range rules { + if prev.ID == *r.ParentID { + if prev.SubmissionCode != nil { + if _, ok := overrideDates[*prev.SubmissionCode]; ok { + parentOverridden = true + } + } + break + } + } + } + parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden + + // Zero-duration rules fall into one of four buckets: + // 1. parent=nil, not court-determined → IsRootEvent (trigger anchor) + // 2. parent=nil, court-determined → IsCourtSet + // 3. parent set, court-determined → IsCourtSet (waypoint) + // 4. parent set, NOT court-determined → "filed-with-parent" + // + // AnchorOverrides: when the user has set a date for any zero- + // duration rule, that override wins over both the court-set + // placeholder and the parent-inheritance. + if r.DurationValue == 0 { + if r.SubmissionCode != nil { + if ov, ok := overrideDates[*r.SubmissionCode]; ok { + d.DueDate = ov.Format("2006-01-02") + d.OriginalDate = d.DueDate + d.IsOverridden = true + computed[*r.SubmissionCode] = ov + deadlines = append(deadlines, d) + continue + } + } + + if r.ParentID == nil && !r.IsCourtSet { + // Bucket 1: timeline anchor. + d.IsRootEvent = true + d.DueDate = triggerDateStr + d.OriginalDate = triggerDateStr + if r.SubmissionCode != nil { + computed[*r.SubmissionCode] = triggerDate + } + } else if r.ParentID != nil && !r.IsCourtSet { + // Bucket 4: filed-with-parent. Inherit parent's date. + if parentIsCourtSet { + // Indirect: rule isn't itself court-determined, + // it's blocked because its parent is. + d.IsCourtSet = true + d.IsCourtSetIndirect = true + d.IsConditional = true + d.DueDate = "" + d.OriginalDate = "" + courtSet[r.ID] = true + } else { + var parentDate time.Time + var haveParentDate bool + for _, prev := range rules { + if prev.ID == *r.ParentID { + if prev.SubmissionCode != nil { + if ov, ok := overrideDates[*prev.SubmissionCode]; ok { + parentDate = ov + haveParentDate = true + } else if ref, ok := computed[*prev.SubmissionCode]; ok { + parentDate = ref + haveParentDate = true + } + } + break + } + } + if haveParentDate { + d.DueDate = parentDate.Format("2006-01-02") + d.OriginalDate = d.DueDate + if r.SubmissionCode != nil { + computed[*r.SubmissionCode] = parentDate + } + } else { + // Parent not yet computed (defensive). + d.IsCourtSet = true + d.IsCourtSetIndirect = true + d.IsConditional = true + d.DueDate = "" + d.OriginalDate = "" + courtSet[r.ID] = true + } + } + } else { + // Buckets 2 + 3: court-determined directly. + d.IsCourtSet = true + d.DueDate = "" + d.OriginalDate = "" + courtSet[r.ID] = true + } + deadlines = append(deadlines, d) + continue + } + + // If the parent is court-determined and not overridden we have + // no real anchor date; surface this rule as court-set too + // rather than fabricating one off the trigger date. IsConditional + // surfaces the "abhängig von " UX (t-paliad-289). + if parentIsCourtSet { + d.IsCourtSet = true + d.IsCourtSetIndirect = true + d.IsConditional = true + d.DueDate = "" + d.OriginalDate = "" + courtSet[r.ID] = true + deadlines = append(deadlines, d) + continue + } + + // Anchor: prefer alt-anchor (e.g. priority_date for + // epa.grant.exa publish) when supplied, then parent's computed + // date (or user override), then trigger date. + baseDate := triggerDate + if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil { + baseDate = *priorityDate + } else if r.ParentID != nil { + for _, prev := range rules { + if prev.ID == *r.ParentID { + if prev.SubmissionCode != nil { + if ov, ok := overrideDates[*prev.SubmissionCode]; ok { + baseDate = ov + } else if ref, ok := computed[*prev.SubmissionCode]; ok { + baseDate = ref + } + } + break + } + } + } + + // Flag-conditioned alt-swap (legacy with_ccr pattern): when the + // gate fires AND alt_* values exist, swap the primary duration + // to the alt values. This is distinct from combine_op below — + // alt-swap is a one-or-the-other choice keyed on flags, whereas + // combine_op computes both legs and picks max/min. + durationValue := r.DurationValue + durationUnit := r.DurationUnit + timing := "" + if r.Timing != nil { + timing = *r.Timing + } + if r.CombineOp == nil && gateMet && HasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil { + durationValue = *r.AltDurationValue + if r.AltDurationUnit != nil { + durationUnit = *r.AltDurationUnit + } + if r.AltRuleCode != nil { + d.RuleRef = *r.AltRuleCode + } + } + + // User override on this rule: replace the calculated date with + // the user's date. Skip holiday rollover — the user's date is + // authoritative. + if r.SubmissionCode != nil { + if ov, ok := overrideDates[*r.SubmissionCode]; ok { + d.OriginalDate = ov.Format("2006-01-02") + d.DueDate = ov.Format("2006-01-02") + d.WasAdjusted = false + d.AdjustmentReason = nil + d.IsOverridden = true + computed[*r.SubmissionCode] = ov + deadlines = append(deadlines, d) + continue + } + } + + origDate, adjusted, wasAdj, reason := ApplyDuration( + baseDate, durationValue, durationUnit, timing, country, regime, holidays, + ) + + // combine_op composite: compute the alt leg too, apply max/min. + if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { + altOrig, altAdj, altWasAdj, altReason := ApplyDuration( + baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays, + ) + switch *r.CombineOp { + case "max": + if altAdj.After(adjusted) { + origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason + } + case "min": + if altAdj.Before(adjusted) { + origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason + } + } + } + + d.OriginalDate = origDate.Format("2006-01-02") + d.DueDate = adjusted.Format("2006-01-02") + d.WasAdjusted = wasAdj + d.AdjustmentReason = reason + + // Optional-on-the-other-side detection (t-paliad-289 Symptom B). + // Rules with priority='optional' AND primary_party='both' whose + // data-model parent is the proceeding's trigger anchor (parent + // has parent_id=NULL and is not court-set, i.e. the SoC root + // rule) represent a rule whose REAL triggering event sits + // outside the rule data — e.g. R.262(2) Erwiderung auf + // Vertraulichkeitsantrag anchors on SoC in the data, but the + // real trigger is the opposing party's confidentiality motion + // which may never happen. Without an explicit anchor on the + // rule itself, the projection must NOT claim a concrete date. + if !d.IsOverridden && !d.IsConditional && + r.Priority == "optional" && + r.PrimaryParty != nil && *r.PrimaryParty == "both" && + parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet { + d.IsConditional = true + d.DueDate = "" + d.OriginalDate = "" + d.WasAdjusted = false + d.AdjustmentReason = nil + // Mark this rule's ID as having an uncertain anchor so + // rules chaining off it also surface conditional via the + // parentIsCourtSet path. + courtSet[r.ID] = true + } + + if r.SubmissionCode != nil { + computed[*r.SubmissionCode] = adjusted + } + deadlines = append(deadlines, d) + } + + // t-paliad-296: within consecutive runs of rules sharing the same + // trigger group (parent_id + trigger_event_id), reorder by duration + // ascending so optional events following the same anchor render in + // their likely-sequence order. Different trigger groups keep their + // proceeding-sequence position — the chunk walk only sorts adjacent + // same-group rows. Court-set / conditional rows sort LAST. + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) + + resp := &Timeline{ + ProceedingType: pickedProceeding.Code, + ProceedingName: pickedProceeding.Name, + ProceedingNameEN: pickedProceeding.NameEN, + TriggerDate: triggerDateStr, + Deadlines: deadlines, + HiddenCount: hiddenCount, + } + // Sub-track routing keeps the user-picked proceeding's identity, + // so the trigger-event label rides on `pickedProceeding`. + if pickedProceeding.TriggerEventLabelDE != nil { + resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE + } + if pickedProceeding.TriggerEventLabelEN != nil { + resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN + } + if hasSubTrackNote { + resp.ContextualNote = subTrackNote.NoteDE + resp.ContextualNoteEN = subTrackNote.NoteEN + } + return resp, nil +} + +// calculateByTriggerEvent renders the Pipeline-C timeline for an event +// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id +// chains), have no flag gating, no priority_date alt-anchor, no party +// classification, and no IsRootEvent / IsCourtSet semantics. The math +// is just: base + (timing-signed) duration → optional alt-leg combine +// → optional weekend/holiday rollover for calendar units. +// +// Timeline.ProceedingType / ProceedingName stay empty — +// EventDeadlineService owns the trigger-event metadata. +func calculateByTriggerEvent( + ctx context.Context, + triggerEventID int64, + triggerDateStr string, + opts CalcOptions, + catalog Catalog, + holidays HolidayCalendar, + courts CourtRegistry, +) (*Timeline, error) { + triggerDate, err := time.Parse("2006-01-02", triggerDateStr) + if err != nil { + return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) + } + + // Pipeline-C rules originate from youpc's UPC-flavoured deadline + // corpus — DE / UPC defaults match the legacy EventDeadlineService. + country, regime, err := courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC) + if err != nil { + return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) + } + + rules, err := catalog.LoadRulesByTriggerEvent(ctx, triggerEventID) + if err != nil { + return nil, err + } + if len(opts.RuleOverrides) > 0 { + rules = ApplyRuleOverrides(rules, opts.RuleOverrides) + } + + deadlines := make([]TimelineEntry, 0, len(rules)) + for _, r := range rules { + timing := "" + if r.Timing != nil { + timing = *r.Timing + } + baseRaw, baseAdj, baseChanged, baseReason := ApplyDuration( + triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, holidays, + ) + picked := baseAdj + original := baseRaw + wasAdj := baseChanged + reason := baseReason + + if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { + altRaw, altAdj, altChanged, altReason := ApplyDuration( + triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays, + ) + switch *r.CombineOp { + case "max": + if altAdj.After(baseAdj) { + picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason + } + case "min": + if altAdj.Before(baseAdj) { + picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason + } + } + } + + d := TimelineEntry{ + RuleID: r.ID.String(), + Name: r.Name, + NameEN: r.NameEN, + Priority: r.Priority, + ConditionExpr: json.RawMessage(r.ConditionExpr), + DueDate: picked.Format("2006-01-02"), + OriginalDate: original.Format("2006-01-02"), + WasAdjusted: wasAdj, + AdjustmentReason: reason, + } + if r.SubmissionCode != nil { + d.Code = *r.SubmissionCode + } + if r.PrimaryParty != nil { + d.Party = *r.PrimaryParty + } + if r.RuleCode != nil { + d.RuleRef = *r.RuleCode + } + if r.LegalSource != nil { + d.LegalSource = *r.LegalSource + d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) + d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) + } + if r.DeadlineNotes != nil { + d.Notes = *r.DeadlineNotes + } + if r.DeadlineNotesEn != nil { + d.NotesEN = *r.DeadlineNotesEn + } + deadlines = append(deadlines, d) + } + + return &Timeline{ + // Trigger-event responses don't carry proceeding metadata — + // EventDeadlineService.Calculate fills the trigger fields in + // the legacy CalculateResponse shape. Leaving these empty is + // the stable contract. + ProceedingType: "", + ProceedingName: "", + TriggerDate: triggerDateStr, + Deadlines: deadlines, + }, nil +} + +// CalculateRule computes a single deadline from a rule + trigger date. +// Used by the v4 result-card click flow. Distinct from Calculate: no +// parent-chain walk, no full-timeline rendering — just one date out. +// +// When the rule is court-determined, DueDate is empty and +// IsCourtSet=true; the caller should disable the "Add to project" CTA. +// +// When the rule has a condition_expr gate and the caller's Flags +// satisfy it AND alt_duration_value is non-NULL, the calc swaps to +// alt_*. When the gate is not satisfied, the calc still proceeds with +// the base duration_value and surfaces FlagsRequired. +func CalculateRule( + ctx context.Context, + params CalcRuleParams, + catalog Catalog, + holidays HolidayCalendar, + courts CourtRegistry, +) (*RuleCalculation, error) { + triggerDate, err := time.Parse("2006-01-02", params.TriggerDate) + if err != nil { + return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err) + } + + rule, pt, err := resolveRule(ctx, params, catalog) + if err != nil { + return nil, err + } + + mandWire, _ := wireFlagsFromPriority(rule.Priority) + out := &RuleCalculation{ + Rule: RuleCalculationRule{ + ID: rule.ID.String(), + NameDE: rule.Name, + NameEN: rule.NameEN, + DurationValue: rule.DurationValue, + DurationUnit: rule.DurationUnit, + IsMandatory: mandWire, + }, + Proceeding: RuleCalculationProceeding{ + Code: pt.Code, + NameDE: pt.Name, + NameEN: pt.NameEN, + }, + TriggerDate: params.TriggerDate, + } + if rule.SubmissionCode != nil { + out.Rule.LocalCode = *rule.SubmissionCode + } + if rule.RuleCode != nil { + out.Rule.RuleRef = *rule.RuleCode + } + if rule.LegalSource != nil { + out.Rule.LegalSource = *rule.LegalSource + out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource) + out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource) + } + if rule.PrimaryParty != nil { + out.Rule.Party = *rule.PrimaryParty + } + if rule.DeadlineNotes != nil { + out.Rule.NotesDE = *rule.DeadlineNotes + } + if rule.DeadlineNotesEn != nil { + out.Rule.NotesEN = *rule.DeadlineNotesEn + } + // Slice 9 (t-paliad-195) replacement for the dropped condition_flag + // text[] enumeration: walk the jsonb gate to pull out flag-leaf + // names. Returns nil on an unconditional rule. + out.FlagsRequired = ExtractFlagsFromExpr(rule.ConditionExpr) + + // Court-determined: no calculable date. + if rule.IsCourtSet { + out.IsCourtSet = true + return out, nil + } + + // Resolve flag-conditional duration via the unified condition_expr + // evaluator. + flagSet := make(map[string]struct{}, len(params.Flags)) + for _, f := range params.Flags { + flagSet[f] = struct{}{} + } + durationValue := rule.DurationValue + durationUnit := rule.DurationUnit + gateMet := EvalConditionExpr([]byte(rule.ConditionExpr), flagSet) + if gateMet && HasConditionExpr(rule.ConditionExpr) { + out.FlagsApplied = out.FlagsRequired + if rule.AltDurationValue != nil { + durationValue = *rule.AltDurationValue + } + if rule.AltDurationUnit != nil { + durationUnit = *rule.AltDurationUnit + } + if rule.AltRuleCode != nil { + out.Rule.RuleRef = *rule.AltRuleCode + } + } + + // Zero-duration non-court-determined rules are "filed at the same + // time as parent" markers: effectively mean "due on the trigger + // date itself". + if durationValue == 0 { + out.OriginalDate = params.TriggerDate + out.DueDate = params.TriggerDate + return out, nil + } + + defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) + country, regime, err := courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime) + if err != nil { + return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err) + } + + timing := "" + if rule.Timing != nil { + timing = *rule.Timing + } + endDate, adjusted, wasAdj, reason := ApplyDuration( + triggerDate, durationValue, durationUnit, timing, country, regime, holidays, + ) + out.OriginalDate = endDate.Format("2006-01-02") + out.DueDate = adjusted.Format("2006-01-02") + out.WasAdjusted = wasAdj + out.AdjustmentReason = reason + + return out, nil +} + +// resolveRule resolves CalcRuleParams to a rule + its proceeding type. +// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The +// frontend uses the latter form (it has the pill context) and the +// programmatic / test caller can use the former. +func resolveRule(ctx context.Context, params CalcRuleParams, catalog Catalog) (*Rule, *ProceedingType, error) { + if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") { + return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required") + } + + if params.RuleID != "" { + rule, err := catalog.LoadRuleByID(ctx, params.RuleID) + if err != nil { + return nil, nil, err + } + if rule.ProceedingTypeID == nil { + return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID) + } + pt, err := catalog.LoadProceedingByID(ctx, *rule.ProceedingTypeID) + if err != nil { + return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err) + } + return rule, pt, nil + } + + rule, pt, err := catalog.LoadRuleByCode(ctx, params.ProceedingCode, params.RuleLocalCode) + if err != nil { + return nil, nil, err + } + return rule, pt, nil +} + +// ApplyRuleOverrides replaces rules whose ID appears in `overrides` +// with the override row, and appends any override whose ID isn't in +// the source list (net-new drafts the rule editor wants to preview). +// +// Used by the Slice 11a (t-paliad-191) preview endpoint: the editor +// passes the draft as an override so Calculate runs against the +// proposed shape without writing to the DB. Empty overrides slice = +// pass-through. +func ApplyRuleOverrides(src, overrides []Rule) []Rule { + if len(overrides) == 0 { + return src + } + byID := make(map[uuid.UUID]Rule, len(overrides)) + for _, o := range overrides { + byID[o.ID] = o + } + out := make([]Rule, 0, len(src)+len(overrides)) + seen := make(map[uuid.UUID]bool, len(overrides)) + for _, r := range src { + if ov, ok := byID[r.ID]; ok { + out = append(out, ov) + seen[ov.ID] = true + continue + } + out = append(out, r) + } + for _, o := range overrides { + if seen[o.ID] { + continue + } + out = append(out, o) + } + return out +} + +// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional) +// pair from the unified priority enum so the wire shape stays +// pixel-identical. Mapping mirrors mig 083's backfill (per design §2.3): +// +// 'mandatory' → (true, false) +// 'optional' → (true, true) +// 'recommended' → (false, false) +// 'informational' → (false, false) +// (unknown) → (true, false) +func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { + switch priority { + case "mandatory": + return true, false + case "optional": + return true, true + case "recommended": + return false, false + case "informational": + return false, false + default: + return true, false + } +} + +// AllFlagsSet is retained as a tiny utility for callers that have a +// flat list of flag strings + a flag-set lookup. The new condition_expr +// gate is the canonical evaluator; this helper exists for forward- +// compat with any future caller that wants the legacy AND-over-list +// semantic without rebuilding the jsonb. +func AllFlagsSet(required []string, set map[string]struct{}) bool { + return allFlagsSet(required, set) +} + +// WireFlagsFromPriority is the public form of wireFlagsFromPriority so +// the paliad-side test suite (which historically asserted the mapping +// directly) can still test the contract. +func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { + return wireFlagsFromPriority(priority) +} diff --git a/pkg/litigationplanner/expr.go b/pkg/litigationplanner/expr.go new file mode 100644 index 0000000..9012c55 --- /dev/null +++ b/pkg/litigationplanner/expr.go @@ -0,0 +1,145 @@ +package litigationplanner + +import "encoding/json" + +// allFlagsSet returns true when every element of `required` is present in +// `set`. Empty `required` returns true (no condition). Retained as the +// fallback predicate used by EvalConditionExpr when condition_expr is +// NULL but the legacy condition_flag text[] is set — preserves +// transition-window behaviour for any row Slice 2 missed (it shouldn't, +// but defensive). +func allFlagsSet(required []string, set map[string]struct{}) bool { + for _, f := range required { + if _, ok := set[f]; !ok { + return false + } + } + return true +} + +// EvalConditionExpr returns true iff the rule's gate predicate is +// satisfied for the caller's flag set. Drives flag-conditional rendering +// + flag-conditional alt-swap throughout the calculator. +// +// Grammar (design §2.4 long form, mig 084 backfill): +// +// {"flag": ""} — leaf: true iff ∈ flags +// {"op": "and", "args": [...]} — true iff every arg evaluates true +// {"op": "or", "args": [...]} — true iff any arg evaluates true +// {"op": "not", "args": []} — true iff the single arg is false +// +// NULL / empty / "null" expression → true (unconditional). Malformed +// JSON → true (defensive: the rule still renders, the lawyer sees +// it even if the gate is broken). +// +// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag +// text[] column; the fallback that AND'd over it is gone. Any future +// row needing array-of-flags semantics writes the equivalent +// {"op":"and","args":[{"flag":""},...]} jsonb directly. +func EvalConditionExpr(expr []byte, flags map[string]struct{}) bool { + if len(expr) == 0 || string(expr) == "null" { + return true + } + return EvalConditionExprNode(expr, flags) +} + +// EvalConditionExprNode walks one node of the condition_expr jsonb +// tree. Recursion depth is bounded by the editor (Slice 11 caps tree +// depth + arg count); pre-Slice-11 backfilled rows have at most a +// 2-arg AND (mig 084). +func EvalConditionExprNode(raw []byte, flags map[string]struct{}) bool { + var node struct { + Flag string `json:"flag"` + Op string `json:"op"` + Args []json.RawMessage `json:"args"` + } + if err := json.Unmarshal(raw, &node); err != nil { + // Malformed → unconditional. The Slice 11 editor's validation + // will block such writes; in the live corpus today mig 084's + // jsonb_build_object output is well-formed by construction. + return true + } + if node.Flag != "" { + _, ok := flags[node.Flag] + return ok + } + switch node.Op { + case "and": + for _, a := range node.Args { + if !EvalConditionExprNode(a, flags) { + return false + } + } + return true + case "or": + for _, a := range node.Args { + if EvalConditionExprNode(a, flags) { + return true + } + } + return false + case "not": + if len(node.Args) != 1 { + // Malformed NOT — fall through to unconditional rather + // than risk suppressing a rule the lawyer expects to see. + return true + } + return !EvalConditionExprNode(node.Args[0], flags) + } + // Unknown op (forward-compat with editor extensions): treat as + // unconditional so the rule still renders. + return true +} + +// HasConditionExpr returns true when the rule carries a non-empty, +// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the +// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the +// flag-keyed alt-swap branch. Same intent: "this rule has a gate; +// when the gate flips to met, swap to alt". +func HasConditionExpr(expr NullableJSON) bool { + if len(expr) == 0 { + return false + } + s := string(expr) + return s != "null" && s != "{}" +} + +// ExtractFlagsFromExpr walks the jsonb gate and returns the unique +// flag names referenced as {"flag":""} leaves. Used by +// CalculateRule's response (FlagsRequired) so the result-card calc +// panel can render flag checkboxes for each gate input. Replaces the +// dropped condition_flag text[] enumeration. Returns nil on a NULL +// expression or one that contains no flag leaves. +func ExtractFlagsFromExpr(expr NullableJSON) []string { + if !HasConditionExpr(expr) { + return nil + } + seen := make(map[string]struct{}) + walkFlagLeaves([]byte(expr), seen) + if len(seen) == 0 { + return nil + } + out := make([]string, 0, len(seen)) + for f := range seen { + out = append(out, f) + } + return out +} + +func walkFlagLeaves(raw []byte, into map[string]struct{}) { + var node struct { + Flag string `json:"flag"` + Op string `json:"op"` + Args []json.RawMessage `json:"args"` + } + if err := json.Unmarshal(raw, &node); err != nil { + return + } + if node.Flag != "" { + into[node.Flag] = struct{}{} + return + } + for _, a := range node.Args { + walkFlagLeaves(a, into) + } +} diff --git a/pkg/litigationplanner/holidays.go b/pkg/litigationplanner/holidays.go new file mode 100644 index 0000000..d725d8c --- /dev/null +++ b/pkg/litigationplanner/holidays.go @@ -0,0 +1,25 @@ +package litigationplanner + +import "time" + +// HolidayCalendar adjusts dates onto working days for a given +// (country, regime) pair. The calculator only needs three primitives: +// +// - IsNonWorkingDay — used by the addWorkingDays walker +// - AdjustForNonWorkingDays — forward snap (timing='after') +// - AdjustForNonWorkingDaysBackward — backward snap (timing='before') +// - AdjustForNonWorkingDaysWithReason — like the forward snap but +// also returns *AdjustmentReason so the timeline can render the +// "rolled past holiday X" footer in TimelineEntry.AdjustmentReason. +// +// Implementations: +// - paliad: reads paliad.holidays, caches per-year, merges DE +// federal fallback. +// - embedded/upc (Slice C): in-memory year-keyed map populated from +// the embedded JSON snapshot. +type HolidayCalendar interface { + IsNonWorkingDay(date time.Time, country, regime string) bool + AdjustForNonWorkingDays(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) + AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) + AdjustForNonWorkingDaysWithReason(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool, reason *AdjustmentReason) +} diff --git a/pkg/litigationplanner/legal_source.go b/pkg/litigationplanner/legal_source.go new file mode 100644 index 0000000..eac4de3 --- /dev/null +++ b/pkg/litigationplanner/legal_source.go @@ -0,0 +1,123 @@ +package litigationplanner + +import "strings" + +// FormatLegalSourceDisplay renders a structured legal_source code into +// the form HLC users read in pleadings: +// +// UPC.RoP.23.1 → "UPC RoP R.23(1)" +// UPC.RoP.139 → "UPC RoP R.139" +// DE.PatG.82.1 → "PatG §82(1)" +// DE.ZPO.276.1 → "ZPO §276(1)" +// EU.EPÜ.108 → "EPÜ Art.108" +// EU.EPC-R.79.1 → "EPC R.79(1)" +// EU.RPBA.12.1.c → "RPBA Art.12(1)(c)" +// +// Returns the empty string for an empty input. Unknown jurisdictions +// fall through with the structured form preserved (caller decides +// whether to display). +func FormatLegalSourceDisplay(src string) string { + src = strings.TrimSpace(src) + if src == "" { + return "" + } + parts := strings.Split(src, ".") + if len(parts) < 3 { + // Malformed — return as-is so the caller still has something. + return src + } + code := parts[1] + rest := parts[2:] + var prefix string + switch code { + case "RoP": + prefix = "UPC RoP R." + case "PatG": + prefix = "PatG §" + case "ZPO": + prefix = "ZPO §" + case "EPÜ": + prefix = "EPÜ Art." + case "EPC-R": + prefix = "EPC R." + case "RPBA": + prefix = "RPBA Art." + default: + prefix = code + " " + } + var b strings.Builder + b.Grow(len(prefix) + len(src)) + b.WriteString(prefix) + b.WriteString(rest[0]) + for _, p := range rest[1:] { + b.WriteByte('(') + b.WriteString(p) + b.WriteByte(')') + } + return b.String() +} + +// BuildLegalSourceURL maps a structured legal_source code to a +// youpc.org/laws permalink when the cited body is hosted there. Today +// youpc only carries the UPC corpus (UPCA, UPCS, UPCRoP); DE national +// codes (PatG, ZPO) and EPO bodies (EPÜ, EPC-R, RPBA) have no youpc +// home yet, so the helper returns the empty string for those and the +// caller renders the display string as plain text. +// +// Inputs mirror FormatLegalSourceDisplay — structured dot-separated +// codes like UPC.RoP.23.1, UPC.UPCA.83. Sub-paragraph segments beyond +// the law-number position are dropped; youpc resolves the page at +// . granularity. The law-number is zero-padded to 3 +// digits to match how youpc stores law_number (laws-data.json carries +// "001" / "023" / "220" forms). +// +// UPC.RoP.23.1 → https://youpc.org/laws#UPCRoP.023 +// UPC.RoP.220.1 → https://youpc.org/laws#UPCRoP.220 +// UPC.RoP.29.a → https://youpc.org/laws#UPCRoP.029 +// UPC.UPCA.83 → https://youpc.org/laws#UPCA.083 +// DE.ZPO.276.1 → "" (no youpc home — render display text plain) +func BuildLegalSourceURL(src string) string { + src = strings.TrimSpace(src) + if src == "" { + return "" + } + parts := strings.Split(src, ".") + if len(parts) < 3 { + return "" + } + var lawType string + switch parts[0] + "." + parts[1] { + case "UPC.RoP": + lawType = "UPCRoP" + case "UPC.UPCA": + lawType = "UPCA" + case "UPC.UPCS": + lawType = "UPCS" + default: + return "" + } + number := padLawNumber(parts[2]) + if number == "" { + return "" + } + return "https://youpc.org/laws#" + lawType + "." + number +} + +// padLawNumber zero-pads a pure-digit law-number segment to 3 digits. +// Non-digit-only inputs (e.g. "112a" if youpc ever ingests EPÜ Art. +// 112a) pass through unchanged so the URL still resolves. Empty input +// returns the empty string. +func padLawNumber(s string) string { + if s == "" { + return "" + } + for _, c := range s { + if c < '0' || c > '9' { + return s + } + } + if len(s) >= 3 { + return s + } + return strings.Repeat("0", 3-len(s)) + s +} diff --git a/pkg/litigationplanner/proceeding_mapping.go b/pkg/litigationplanner/proceeding_mapping.go new file mode 100644 index 0000000..2ffcc0c --- /dev/null +++ b/pkg/litigationplanner/proceeding_mapping.go @@ -0,0 +1,139 @@ +package litigationplanner + +// proceeding_mapping bridges the two proceeding-type vocabularies in the +// codebase: the **litigation** conceptual category (INF / REV / APP / +// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding +// + Pipeline-A rules, and the **fristenrechner** code category +// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator +// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects +// bind to fristenrechner codes directly, but the litigation→fristenrechner +// mapping is still needed for the ~40 Pipeline-A rules that remain on +// litigation proceedings and for any other surface that thinks in +// litigation terms. +// +// The mapping table here is the single source of truth — see +// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the +// design rationale + ambiguity notes, and +// docs/design-proceeding-code-taxonomy-2026-05-18.md for the +// lowercase dot-separated naming convention applied by mig 096 +// (t-paliad-206). **Never silent FK promotion**: every ambiguous case +// returns ok=false so callers can degrade gracefully ("no narrowing") +// instead of guessing. + +// Stable code constants — the strings landed by mig 096. Use these +// throughout the codebase so a future rename only needs to touch this +// file. The id-anchored FKs (deadline_rules.proceeding_type_id, +// projects.proceeding_type_id) are unaffected by the rename. +const ( + CodeUPCInfringement = "upc.inf.cfi" + CodeUPCRevocation = "upc.rev.cfi" + CodeUPCCounterclaim = "upc.ccr.cfi" + CodeUPCPreliminary = "upc.pi.cfi" + CodeUPCDamages = "upc.dmgs.cfi" + CodeUPCDiscovery = "upc.disc.cfi" + CodeUPCAppealMerits = "upc.apl.merits" + CodeUPCAppealOrder = "upc.apl.order" + CodeUPCAppealCost = "upc.apl.cost" + CodeDEInfringementLG = "de.inf.lg" + CodeDEInfringementOLG = "de.inf.olg" + CodeDEInfringementBGH = "de.inf.bgh" + CodeDENullityBPatG = "de.null.bpatg" + CodeDENullityBGH = "de.null.bgh" + CodeEPAGrant = "epa.grant.exa" + CodeEPAOpposition = "epa.opp.opd" + CodeEPAOppositionAppeal = "epa.opp.boa" + CodeDPMAOpposition = "dpma.opp.dpma" + CodeDPMAAppealBPatG = "dpma.appeal.bpatg" + CodeDPMAAppealBGH = "dpma.appeal.bgh" +) + +// MapLitigationToFristenrechner returns the fristenrechner code + +// condition flags implied by a (litigationCode, jurisdiction) pair. +// +// Inputs are case-sensitive — pass the canonical upper-snake form +// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous +// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero +// fristenrechner code; callers should treat that as "no narrowing" +// and leave the cascade wide-open rather than auto-pick. +// +// Condition flags are returned as a slice so callers can apply them +// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr, +// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag +// context applies. +func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) { + switch litigationCode { + case "INF": + switch jurisdiction { + case "UPC": + return CodeUPCInfringement, nil, true + case "DE": + return CodeDEInfringementLG, nil, true + } + case "REV": + switch jurisdiction { + case "UPC": + return CodeUPCRevocation, nil, true + case "DE": + return CodeDENullityBPatG, nil, true + } + case "CCR": + // Counterclaim revocation — UPC fold-in is structural (the + // counterclaim lives inside an upc.inf.cfi proceeding with the + // with_ccr flag). DE Nichtigkeit is conceptually the same + // adversarial-validity test, no separate flag. + switch jurisdiction { + case "UPC": + return CodeUPCInfringement, []string{"with_ccr"}, true + case "DE": + return CodeDENullityBPatG, nil, true + } + case "AMD": + // Amendment-application bundled into upc.inf.cfi via with_amend. + // No DE / EPA / DPMA analogue today. + if jurisdiction == "UPC" { + return CodeUPCInfringement, []string{"with_amend"}, true + } + case "APP": + // Appeal is ambiguous in DE (OLG vs BGH) and the project + // model doesn't carry the instance hint we'd need to + // disambiguate. UPC is unambiguous — upc.apl.merits covers + // the merits appeal track for inf/rev/ccr/damages. + if jurisdiction == "UPC" { + return CodeUPCAppealMerits, nil, true + } + case "APM": + // Preliminary injunction / urgency procedure — UPC-only + // concept in the fristenrechner taxonomy. + if jurisdiction == "UPC" { + return CodeUPCPreliminary, nil, true + } + case "OPP": + // Opposition — primarily EPA. DPMA has dpma.opp.dpma but it + // doesn't surface from the litigation vocabulary today. + if jurisdiction == "EPA" { + return CodeEPAOpposition, nil, true + } + } + return "", nil, false +} + +// ResolveCounterclaimRouting handles the determinator's +// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown +// for taxonomic completeness, but no rules are attached to it. When the +// cascade resolves to upc.ccr.cfi we route the rule lookup back to +// upc.inf.cfi with a default with_ccr=true flag — see +// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1. +// +// `code` is the proceeding code the cascade resolved to. If it's +// upc.ccr.cfi, the function returns (CodeUPCInfringement, +// []string{"with_ccr"}, true). For any other code the function returns +// (code, nil, false) and callers proceed with the code unchanged. The +// boolean signals "routing was applied"; the caller can surface the hint +// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin +// weiter." in the UI. +func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) { + if route, ok := SubTrackRoutings[code]; ok { + return route.ParentCode, route.DefaultFlags, true + } + return code, nil, false +} diff --git a/pkg/litigationplanner/sort.go b/pkg/litigationplanner/sort.go new file mode 100644 index 0000000..a1ecd08 --- /dev/null +++ b/pkg/litigationplanner/sort.go @@ -0,0 +1,151 @@ +package litigationplanner + +import ( + "fmt" + "sort" + + "github.com/google/uuid" +) + +// SortDeadlinesByDurationWithinTriggerGroup is the public form of +// sortDeadlinesByDurationWithinTriggerGroup. Exported so paliad's +// test suite (which historically reached the helper directly) can +// keep invoking it via a tiny wrapper. +func SortDeadlinesByDurationWithinTriggerGroup( + deadlines []TimelineEntry, + ruleByID map[uuid.UUID]Rule, +) { + sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) +} + +// sortDeadlinesByDurationWithinTriggerGroup walks consecutive runs of +// deadlines whose underlying rule shares the same trigger group +// (parent_id + trigger_event_id) and reorders each run in place by +// duration ascending. Different trigger groups keep their original +// proceeding-sequence position — the walk only ever permutes adjacent +// same-group rows. +// +// Sort key (within a run): +// 1. Conditional / court-set rows (no concrete date in the duration +// ladder) sort LAST, tiebroken by submission_code. +// 2. duration_unit weight ASC: days/working_days < weeks < months < years +// 3. duration_value ASC +// 4. submission_code ASC (deterministic tiebreak) +// +// Issue: m/paliad#128 — post-decision optional events (R.151/R.353 +// 1-month before R.118.4/R.220.1 2-month) were rendering in catalog +// order instead of likely-sequence order. (t-paliad-296) +func sortDeadlinesByDurationWithinTriggerGroup( + deadlines []TimelineEntry, + ruleByID map[uuid.UUID]Rule, +) { + if len(deadlines) < 2 { + return + } + n := len(deadlines) + i := 0 + for i < n { + gid := triggerGroupKey(deadlines[i], ruleByID) + j := i + 1 + for j < n && triggerGroupKey(deadlines[j], ruleByID) == gid { + j++ + } + // Root rules (no parent and no trigger_event) get gid="" and + // would otherwise collapse into one big run. Skip the sort for + // the "root" pseudo-group — each root rule represents its own + // anchor (SoC, oral hearing, decision …) and the proceeding- + // sequence order between them must be preserved. + if j-i > 1 && gid != "" { + chunk := deadlines[i:j] + sort.SliceStable(chunk, func(a, b int) bool { + return durationLessForSort(chunk[a], chunk[b], ruleByID) + }) + } + i = j + } +} + +// triggerGroupKey returns a string key identifying which trigger group +// a deadline belongs to. Same key = same group = candidates for sort. +// Empty string means "root" (no parent, no trigger_event) — used as a +// sentinel by the caller to skip sorting roots against each other. +func triggerGroupKey(d TimelineEntry, ruleByID map[uuid.UUID]Rule) string { + rid, err := uuid.Parse(d.RuleID) + if err != nil { + return "" + } + r, ok := ruleByID[rid] + if !ok { + return "" + } + if r.ParentID != nil { + return "p:" + r.ParentID.String() + } + if r.TriggerEventID != nil { + return fmt.Sprintf("t:%d", *r.TriggerEventID) + } + return "" +} + +// durationLessForSort compares two deadlines for the duration-ascending +// sort. Court-set / conditional rows (no concrete date) sort LAST +// regardless of duration — they don't fit the duration ladder. +func durationLessForSort( + a, b TimelineEntry, + ruleByID map[uuid.UUID]Rule, +) bool { + aLast := a.IsCourtSet || a.IsConditional + bLast := b.IsCourtSet || b.IsConditional + if aLast != bLast { + return !aLast + } + if aLast && bLast { + return a.Code < b.Code + } + + ra := lookupRuleFromDeadline(a, ruleByID) + rb := lookupRuleFromDeadline(b, ruleByID) + + wa := durationUnitWeight(ra.DurationUnit) + wb := durationUnitWeight(rb.DurationUnit) + if wa != wb { + return wa < wb + } + if ra.DurationValue != rb.DurationValue { + return ra.DurationValue < rb.DurationValue + } + return a.Code < b.Code +} + +func lookupRuleFromDeadline( + d TimelineEntry, + ruleByID map[uuid.UUID]Rule, +) Rule { + if d.RuleID == "" { + return Rule{} + } + rid, err := uuid.Parse(d.RuleID) + if err != nil { + return Rule{} + } + return ruleByID[rid] +} + +// durationUnitWeight maps a duration unit to its sort weight so the +// trigger-group sort can order shorter durations first. days and +// working_days share weight 0 (both are sub-week granularities); +// unknown units sort to the end so they're visible as a tail rather +// than silently winning. +func durationUnitWeight(unit string) int { + switch unit { + case "days", "working_days": + return 0 + case "weeks": + return 1 + case "months": + return 2 + case "years": + return 3 + } + return 4 +} diff --git a/pkg/litigationplanner/subtrack.go b/pkg/litigationplanner/subtrack.go new file mode 100644 index 0000000..ac8c379 --- /dev/null +++ b/pkg/litigationplanner/subtrack.go @@ -0,0 +1,53 @@ +package litigationplanner + +// SubTrackRouting describes a proceeding type that has no native rules +// of its own and is normally rendered inside a parent proceeding's flow +// with one or more condition flags enabled. The Procedure Roadmap +// (verfahrensablauf) routes calc requests for these codes to the parent +// proceeding + default flags, but preserves the user-picked code/name +// in the response identity and surfaces a contextual note explaining +// the framing — see m/paliad#58 and the design doc cited above. +// +// Adding a new sub-track is a data-only change here: extend +// SubTrackRoutings with the (code, parent, flags, note) tuple and the +// renderer picks it up automatically. The note copy lives in this file +// because it's semantic to the routing, not UI chrome. +type SubTrackRouting struct { + // Code is the user-picked proceeding code (e.g. "upc.ccr.cfi"). + Code string + // ParentCode is the proceeding whose rules to use (e.g. "upc.inf.cfi"). + ParentCode string + // DefaultFlags are merged into the user's flag set so the + // gated rules render. Order is preserved. + DefaultFlags []string + // NoteDE / NoteEN are the contextual banner above the timeline, + // explaining that the proceeding type is normally a sub-track. + // Plain text — the frontend renders them as a banner. + NoteDE string + NoteEN string +} + +// SubTrackRoutings — single-source-of-truth registry. Today: just CCR. +// The pattern generalises to other "sub-track" proceeding types (e.g. +// R.30 application to amend the patent as a standalone roadmap, R.46 +// preliminary objection) once they have a proceeding-type code of their +// own. New entries here are picked up by the spawn-as-standalone +// renderer in Calculate without further wiring. +var SubTrackRoutings = map[string]SubTrackRouting{ + CodeUPCCounterclaim: { + Code: CodeUPCCounterclaim, + ParentCode: CodeUPCInfringement, + DefaultFlags: []string{"with_ccr"}, + NoteDE: "Die Nichtigkeitswiderklage läuft normalerweise innerhalb eines UPC-Verletzungsverfahrens mit aktiver Nichtigkeitswiderklage. Diese Zeitleiste zeigt das Verletzungsverfahren mit gesetztem with_ccr-Flag.", + NoteEN: "The counterclaim for revocation normally runs inside a UPC infringement action with the counterclaim flag set. This timeline shows the infringement action with with_ccr automatically enabled.", + }, +} + +// LookupSubTrackRouting returns the sub-track routing for a proceeding +// code, or (zero, false) if the code is not a sub-track. Used by the +// fristenrechner Calculate path to spawn the parent flow with the sub- +// track's default flags. +func LookupSubTrackRouting(code string) (SubTrackRouting, bool) { + r, ok := SubTrackRoutings[code] + return r, ok +} diff --git a/pkg/litigationplanner/types.go b/pkg/litigationplanner/types.go new file mode 100644 index 0000000..01eca82 --- /dev/null +++ b/pkg/litigationplanner/types.go @@ -0,0 +1,428 @@ +package litigationplanner + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +// NullableJSON is a jsonb column that may be NULL. json.RawMessage +// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value +// from Postgres breaks the row scan with "unsupported Scan, storing +// driver.Value type into type *json.RawMessage" — exactly the +// error that hid every approval_request from the inbox when m's first +// "create" lifecycle row arrived with NULL pre_image (m's dogfood +// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column +// fixes the scan and preserves inline JSON output (no base64 cast). +type NullableJSON []byte + +// Scan implements sql.Scanner. +func (n *NullableJSON) Scan(value any) error { + if value == nil { + *n = nil + return nil + } + switch v := value.(type) { + case []byte: + *n = append((*n)[:0], v...) + return nil + case string: + *n = []byte(v) + return nil + } + return fmt.Errorf("NullableJSON: unsupported scan type %T", value) +} + +// Value implements driver.Valuer. +func (n NullableJSON) Value() (driver.Value, error) { + if len(n) == 0 { + return nil, nil + } + return []byte(n), nil +} + +// MarshalJSON emits the raw JSON bytes (or "null"). +func (n NullableJSON) MarshalJSON() ([]byte, error) { + if len(n) == 0 { + return []byte("null"), nil + } + return []byte(n), nil +} + +// UnmarshalJSON consumes raw JSON bytes (literal "null" maps to nil). +func (n *NullableJSON) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *n = nil + return nil + } + *n = append((*n)[:0], data...) + return nil +} + +// Rule is one rule in the proceeding-rule tree (UPC R.023, etc.). +// +// JSON + db tags are intentionally identical to the historical +// paliad.deadline_rules row shape — sqlx scans onto Rule directly and +// the wire bytes the frontend reads are unchanged from the pre-extract +// shape. +type Rule struct { + ID uuid.UUID `db:"id" json:"id"` + ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"` + ParentID *uuid.UUID `db:"parent_id" json:"parent_id,omitempty"` + SubmissionCode *string `db:"submission_code" json:"submission_code,omitempty"` + Name string `db:"name" json:"name"` + NameEN string `db:"name_en" json:"name_en"` + Description *string `db:"description" json:"description,omitempty"` + PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"` + EventType *string `db:"event_type" json:"event_type,omitempty"` + DurationValue int `db:"duration_value" json:"duration_value"` + DurationUnit string `db:"duration_unit" json:"duration_unit"` + Timing *string `db:"timing" json:"timing,omitempty"` + RuleCode *string `db:"rule_code" json:"rule_code,omitempty"` + DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"` + DeadlineNotesEn *string `db:"deadline_notes_en" json:"deadline_notes_en,omitempty"` + SequenceOrder int `db:"sequence_order" json:"sequence_order"` + AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"` + AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"` + AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"` + AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"` + ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"` + // ConceptDefaultEventTypeID is the canonical paliad.event_types row for + // this rule's concept (joined via paliad.deadline_concept_event_types + // where is_default = true). Lets the deadline create form auto-populate + // the Typ chip when the user picks this rule. Hydrated by the service + // layer; not a column. NULL when the concept has no mapped event_type. + ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"` + LegalSource *string `db:"legal_source" json:"legal_source,omitempty"` + IsSpawn bool `db:"is_spawn" json:"is_spawn"` + SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"` + IsActive bool `db:"is_active" json:"is_active"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + + // TriggerEventID points at paliad.trigger_events when this rule is + // event-rooted (Pipeline C unification, design §2.5). NULL on + // proceeding-rooted rules. + TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"` + + // SpawnProceedingTypeID is the cross-proceeding spawn target — + // when is_spawn=true and this is non-NULL, the calculator follows + // the FK and emits the target proceeding's root rule chain. + SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"` + + // CombineOp is 'max' or 'min' for composite-rule arithmetic + // (R.198 / R.213: "31d OR 20 working_days, whichever is longer"). + // NULL = single-anchor arithmetic. + CombineOp *string `db:"combine_op" json:"combine_op,omitempty"` + + // ConditionExpr is the jsonb gating expression. Grammar: + // {"flag": ""} + // {"op":"and"|"or", "args":[, ...]} + // {"op":"not", "args":[]} + // NULL or {} = unconditional. + ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"` + + // Priority is the 4-way unified enum: 'mandatory' (default), + // 'recommended', 'optional', 'informational'. + Priority string `db:"priority" json:"priority"` + + // IsCourtSet replaces the runtime heuristic (primary_party='court' + // OR event_type IN ('hearing','decision','order')). + IsCourtSet bool `db:"is_court_set" json:"is_court_set"` + + // LifecycleState drives the rule-editor flow: + // 'draft' | 'published' | 'archived'. + LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"` + + // DraftOf points at the published rule this draft will replace on + // publish. NULL on published / archived rows. + DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"` + + // PublishedAt records when the row entered LifecycleState='published'. + PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"` + + // ChoicesOffered declares which per-event-card choice-kinds this + // rule offers on the Verfahrensablauf timeline (mig 129, + // t-paliad-265). NULL = no caret affordance (default). + ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"` +} + +// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR +// /APM/APP/AMD/ZPO_CIVIL — matter management) or the lowercase dot- +// separated fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — +// see docs/design-proceeding-code-taxonomy-2026-05-18.md. +type ProceedingType struct { + ID int `db:"id" json:"id"` + Code string `db:"code" json:"code"` + Name string `db:"name" json:"name"` + NameEN string `db:"name_en" json:"name_en"` + Description *string `db:"description" json:"description,omitempty"` + Jurisdiction *string `db:"jurisdiction" json:"jurisdiction,omitempty"` + Category *string `db:"category" json:"category,omitempty"` + DefaultColor string `db:"default_color" json:"default_color"` + SortOrder int `db:"sort_order" json:"sort_order"` + IsActive bool `db:"is_active" json:"is_active"` + // TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf + // "Auslösendes Ereignis". When set, overrides the proceedingName fallback + // that fires when no rule has IsRootEvent=true. + TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"` + TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"` +} + +// AdjustmentReason describes why a date was rolled forward / backward +// off a non-working day. Populated by HolidayCalendar implementations +// when AdjustForNonWorkingDaysWithReason moves the date. +// +// Date fields are JSON-serialised as YYYY-MM-DD strings (matching +// TimelineEntry.DueDate / OriginalDate) so the frontend doesn't need a +// separate RFC3339 parser. +type AdjustmentReason struct { + // Kind is the dominant cause; longest cause wins when several apply + // (vacation > public_holiday > weekend). + Kind string `json:"kind"` + // Holidays collects every named holiday encountered while walking + // past the non-working run, deduped by (date, name). May be empty + // when the only cause is a weekend. + Holidays []HolidayDTO `json:"holidays,omitempty"` + // VacationName, VacationStart and VacationEnd describe the + // contiguous vacation block the original date sits in. Populated + // only when Kind == "vacation". Span boundaries are the first/last + // vacation day in the block (excludes the weekends that pad it). + VacationName string `json:"vacationName,omitempty"` + VacationStart string `json:"vacationStart,omitempty"` + VacationEnd string `json:"vacationEnd,omitempty"` + // OriginalWeekday is the English weekday name of the original date — + // "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI + // can localise it. + OriginalWeekday string `json:"originalWeekday,omitempty"` +} + +// HolidayDTO is the JSON shape for a holiday emitted in +// AdjustmentReason — distinct from a DB-level Holiday row so dates +// serialise as YYYY-MM-DD strings. +type HolidayDTO struct { + Date string `json:"date"` + Name string `json:"name"` + IsVacation bool `json:"isVacation,omitempty"` + IsClosure bool `json:"isClosure,omitempty"` +} + +// CalcOptions carries optional inputs for Calculate. Callers can leave +// fields empty/nil for the legacy behaviour. +// +// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with +// anchor_alt='priority_date' (e.g. epa.grant.exa.ep_grant.publish +// per Art. 93 EPÜ) use this date as their base instead of the +// parent's adjusted date / the trigger date. +// - Flags: lowercase string flags from the UI (e.g. "with_ccr", +// "with_amend"). Drive condition_expr evaluation + flag-keyed +// alt-swap. +// - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides +// of the computed deadline date. When a child rule chains off a +// parent whose code is in AnchorOverrides, the override date is +// used as the anchor instead of the parent's calculated date. +// - CourtID picks the forum the proceeding is filed in (e.g. +// "upc-ld-paris", "de-bgh"). The calculator resolves it to +// (country, regime) for non-working-day computation. +// - TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C +// rules: when non-nil, the proceedingCode argument is ignored and +// the engine selects rules WHERE trigger_event_id = *filter. +// - RuleOverrides substitutes specific rules in the calculator's +// rule list with caller-supplied in-memory rows. Used by the +// rule-editor preview. +// - PerCardAppellant / SkipRules / IncludeCCRFor / IncludeHidden +// drive per-event-card choice overlays (t-paliad-265, t-paliad-290). +// - ProjectHint scopes the catalog lookup to a project context +// (paliad's catalog uses this to merge in project-scoped rules +// in future slices; v1 catalogs may ignore it). +type CalcOptions struct { + PriorityDateStr string + Flags []string + AnchorOverrides map[string]string + CourtID string + TriggerEventIDFilter *int64 + RuleOverrides []Rule + + PerCardAppellant map[string]string + SkipRules map[string]struct{} + IncludeCCRFor map[string]struct{} + IncludeHidden bool + + ProjectHint ProjectHint +} + +// ProjectHint scopes a Catalog call to a specific project. Paliad's +// catalog uses ProjectID to merge in project-scoped rules in a future +// slice (m/paliad#124 §6 — currently dropped per m's 2026-05-26 +// decision; the field stays for forward-compat). Other catalogs (the +// embedded UPC snapshot used by youpc.org) ignore the hint. +// +// Zero value = no project context (the abstract Verfahrensablauf / +// public Fristenrechner case). +type ProjectHint struct { + ProjectID uuid.UUID +} + +// CalcRuleParams identifies a single rule and the inputs needed to +// compute one deadline from it. Caller supplies either RuleID OR the +// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on +// hand from the concept-card pill it just received a click on. +type CalcRuleParams struct { + RuleID string // optional — UUID + ProceedingCode string // optional — used with RuleLocalCode + RuleLocalCode string // optional — paliad.deadline_rules.submission_code + TriggerDate string // required — YYYY-MM-DD + Flags []string // optional — condition_flag inputs + CourtID string // optional — selects holiday calendar +} + +// Timeline is the package's structured return for Calculate. JSON tags +// are aligned with paliad's historical UIResponse so handlers can serve +// it directly — the wire bytes the frontend reads are unchanged. +type Timeline struct { + ProceedingType string `json:"proceedingType"` + ProceedingName string `json:"proceedingName"` + ProceedingNameEN string `json:"proceedingNameEN,omitempty"` + TriggerDate string `json:"triggerDate"` + Deadlines []TimelineEntry `json:"deadlines"` + ContextualNote string `json:"contextualNote,omitempty"` + ContextualNoteEN string `json:"contextualNoteEN,omitempty"` + TriggerEventLabel string `json:"triggerEventLabel,omitempty"` + TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"` + HiddenCount int `json:"hiddenCount"` +} + +// TimelineEntry matches the frontend's CalculatedDeadline TypeScript +// interface (camelCase JSON to keep /tools/fristenrechner byte-identical). +type TimelineEntry struct { + RuleID string `json:"ruleId,omitempty"` + Code string `json:"code"` + Name string `json:"name"` + NameEN string `json:"nameEN"` + Party string `json:"party"` + Priority string `json:"priority"` + RuleRef string `json:"ruleRef"` + LegalSource string `json:"legalSource,omitempty"` + LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"` + LegalSourceURL string `json:"legalSourceURL,omitempty"` + Notes string `json:"notes,omitempty"` + NotesEN string `json:"notesEN,omitempty"` + DueDate string `json:"dueDate"` + OriginalDate string `json:"originalDate"` + WasAdjusted bool `json:"wasAdjusted"` + AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"` + IsRootEvent bool `json:"isRootEvent"` + IsCourtSet bool `json:"isCourtSet"` + ConditionExpr json.RawMessage `json:"conditionExpr,omitempty"` + IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"` + // IsConditional signals the rule's anchor is uncertain — no + // concrete date can be projected. Set when the rule depends on: + // - a court-set ancestor whose date isn't anchored (overlaps + // with IsCourtSetIndirect; the two are kept distinct because + // IsCourtSet wraps a specific UX message "wird vom Gericht + // bestimmt", whereas IsConditional is the broader "render as + // 'abhängig von '" signal) + // - timing='before' rules whose forward anchor isn't set + // - optional opposing-side rules whose true triggering event + // hasn't been recorded for this project (e.g. R.262(2) + // Erwiderung auf Vertraulichkeitsantrag) + // When true, DueDate and OriginalDate are empty and the frontend + // renders an "abhängig von " chip in place of a + // date. Suppressed by an explicit user anchor. (t-paliad-289) + IsConditional bool `json:"isConditional,omitempty"` + // ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the + // parent's identity so the frontend can render + // "abhängig von " when IsConditional=true. + // Populated whenever the rule has a parent_id, not only when + // conditional — keeps the wire shape stable. Empty for root rules. + // When a rule has a real trigger_event_id, these fields are + // overridden to point at the trigger_events catalog row instead of + // the parent_id chain (t-paliad-294 / m/paliad#126). + ParentRuleCode string `json:"parentRuleCode,omitempty"` + ParentRuleName string `json:"parentRuleName,omitempty"` + ParentRuleNameEN string `json:"parentRuleNameEN,omitempty"` + IsOverridden bool `json:"isOverridden,omitempty"` + ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"` + AppellantContext string `json:"appellantContext,omitempty"` + IsHidden bool `json:"isHidden,omitempty"` +} + +// RuleCalculation is the single-rule calc response that backs the +// result-card click → calc-panel flow. Distinct from TimelineEntry +// (which represents one rendered row inside a full-proceeding +// response): RuleCalculation is self-contained. +type RuleCalculation struct { + Rule RuleCalculationRule `json:"rule"` + Proceeding RuleCalculationProceeding `json:"proceeding"` + TriggerDate string `json:"triggerDate"` + OriginalDate string `json:"originalDate"` + DueDate string `json:"dueDate"` + WasAdjusted bool `json:"wasAdjusted"` + AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"` + IsCourtSet bool `json:"isCourtSet"` + FlagsApplied []string `json:"flagsApplied,omitempty"` + FlagsRequired []string `json:"flagsRequired,omitempty"` +} + +// RuleCalculationRule mirrors the small subset of Rule the +// frontend needs to render the calc panel. +type RuleCalculationRule struct { + ID string `json:"id"` + LocalCode string `json:"localCode,omitempty"` + NameDE string `json:"nameDE"` + NameEN string `json:"nameEN"` + RuleRef string `json:"ruleRef,omitempty"` + LegalSource string `json:"legalSource,omitempty"` + LegalSourceDisplay string `json:"legalSourceDisplay,omitempty"` + LegalSourceURL string `json:"legalSourceURL,omitempty"` + DurationValue int `json:"durationValue"` + DurationUnit string `json:"durationUnit"` + Party string `json:"party,omitempty"` + IsMandatory bool `json:"isMandatory"` + NotesDE string `json:"notesDE,omitempty"` + NotesEN string `json:"notesEN,omitempty"` +} + +// RuleCalculationProceeding identifies the proceeding context for the +// rule. Used by the frontend for display + by the add-to-project flow. +type RuleCalculationProceeding struct { + Code string `json:"code"` + NameDE string `json:"nameDE"` + NameEN string `json:"nameEN"` +} + +// FristenrechnerType mirrors the /api/tools/proceeding-types response +// metadata. +type FristenrechnerType struct { + Code string `json:"code"` + Name string `json:"name"` + NameEN string `json:"nameEN"` + Group string `json:"group"` +} + +// TriggerEvent is a UPC procedural event referenced by deadline rules +// whose semantic anchor is an event rather than a parent rule (the +// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is +// triggered by the opposing party's confidentiality application, not +// by the SoC parent rule). The conditional-rendering branch reads +// this when stamping ParentRule* on the wire. +type TriggerEvent struct { + ID int64 `db:"id" json:"id"` + Code string `db:"code" json:"code"` + Name string `db:"name" json:"name"` + NameDE string `db:"name_de" json:"name_de"` + Description string `db:"description" json:"description"` + IsActive bool `db:"is_active" json:"is_active"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +// Sentinel errors surfaced by Calculate / CalculateRule / Catalog +// implementations. Handlers map these to HTTP statuses. +var ( + ErrUnknownProceedingType = errors.New("unknown proceeding type") + ErrUnknownRule = errors.New("unknown rule") +)