Files
paliad/docs/design-litigation-planner-2026-05-26.md
mAi ce28ea972e
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(litigationplanner): embedded UPC snapshot + generator (Slice C, m/paliad#124 §19)
Lays the foundation for youpc.org's cross-repo integration: an
in-package UPC subset of paliad's deadline corpus, embedded as JSON,
that any consumer can use to run the litigationplanner engine without
DB access.

Generator (cmd/gen-upc-snapshot):
  - Reads paliad's live DB (DATABASE_URL), applies pending migrations
    to match schema HEAD, SELECTs the UPC subset
    (proceeding_types WHERE jurisdiction='UPC' AND is_active=true,
    deadline_rules WHERE lifecycle_state='published' AND is_active=true
    on those proceedings, referenced trigger_events, DE+UPC holidays,
    UPC courts).
  - Writes pretty-printed JSON to
    pkg/litigationplanner/embedded/upc/{proceeding_types, rules,
    trigger_events, holidays, courts, meta}.json.
  - Idempotent — same DB state → same output (modulo
    meta.generated_at + auto-versioned suffix).
  - Date-stamped versioning (YYYY-MM-DD-N) with same-day suffix bump.
  - Operator runbook in cmd/gen-upc-snapshot/README.md.

Embedded subpackage (pkg/litigationplanner/embedded/upc/):
  - embed.go    — //go:embed *.json + LoadMeta()
  - snapshot.go — SnapshotCatalog (full lp.Catalog impl: LoadProceeding
    / LoadProceedingByID / LoadRuleByID / LoadRuleByCode /
    LoadRulesByTriggerEvent / LoadTriggerEventsByIDs / LookupEvents);
    O(1) map lookups; LookupEvents linear over the < 100-row UPC corpus.
  - holidays.go — SnapshotHolidayCalendar implementing lp.HolidayCalendar
    (IsNonWorkingDay / Adjust* with structured AdjustmentReason).
  - courts.go   — SnapshotCourtRegistry implementing lp.CourtRegistry.
  - Compile-time assertions (_ lp.X = (*Snapshot*)(nil)) catch
    interface drift.

Wire-up for consumers:
  cat, _ := upc.NewCatalog()
  hc, _  := upc.NewHolidayCalendar()
  cr, _  := upc.NewCourtRegistry()
  timeline, _ := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
                              lp.CalcOptions{}, cat, hc, cr)

Tests (snapshot_test.go, all DB-free):
  - meta parses cleanly, non-zero counts
  - LoadProceeding(upc.inf.cfi) returns expected proc + rules
  - LoadProceeding(unknown) returns ErrUnknownProceedingType
  - LookupEvents(Jurisdiction:UPC, all-following) covers corpus
  - LookupEvents(party=defendant, next) scopes anchors correctly
  - engine end-to-end via lp.Calculate against the embedded snapshot
  - holiday calendar (weekends, DE closures, UPC vacation block)
  - court registry (empty courtID fallback, known + unknown court)

Placeholder data shipped (2 proceedings, 2 rules, 5 holidays, 2
courts) so tests run without a live DB. Operator regenerates against
prod via `make snapshot-upc` once migrations 134 (B1) and 135 (B3)
have landed on prod — see cmd/gen-upc-snapshot/README.md for the
runbook. The placeholder's meta.version is suffixed `-placeholder`
to make the regeneration delta obvious.

Makefile target:
  make snapshot-upc — wraps the generator + reruns the snapshot tests

Design (§19 of docs/design-litigation-planner-2026-05-26.md):
  - Embedding format: go:embed JSON (diff-friendly, no compile coupling)
  - Generator entry: cmd/gen-upc-snapshot/main.go (idiomatic Go cmd path)
  - Versioning: meta.json carries semver + generated_at + paliad_commit
  - Regeneration: manual via Make target or `go generate`; no CI cron in v1
  - Out of scope: snapshot signing, DE/EPA/DPMA snapshots, snapshot
    diff tooling

Acceptance:
  - go build clean, go test all green (incl. 6 new tests in
    pkg/litigationplanner/embedded/upc, all DB-free)
  - SnapshotCatalog passes the compile-time lp.Catalog assertion
  - Generator binary builds + runs (Idempotence verified by re-running
    against the same source data)
2026-05-26 15:11:07 +02:00

1619 lines
102 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.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.
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 (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** — `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 locked decisions (latest = §0.5):
- **Package within paliad** (option 1, not a separate repo). ✓
- **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.** ✓
---
## §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 (m's picks — REVISED 2026-05-26)
### 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. […] users should not add their own rules. I want them to setup a scenario with different existing proceeding types / submissions that trigger sequences.
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.
### Recommended design — `paliad.scenarios` table with jsonb spec
#### Schema
```sql
-- Slice D migration (paliad-side, NOT in pkg/litigationplanner).
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)
);
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_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
-- 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.
```
`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 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 surfaces
**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.
**/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.
#### Library-side neutrality
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.
#### What the spec is NOT
- 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.
### Multi-proceeding peer compose — v2 path (m's "goal")
m: *"Usually it would be 1 — but 2 is the goal. So people can create more complex scenarios."*
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~~ — REMOVED by m's 2026-05-26 decision
> 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
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=<id>` → 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 270780) | `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: per-project (REVISED per m's 2026-05-26 picks)
**Scope** `paliad.scenarios` table + jsonb spec + project-page UI.
**Files**:
- `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 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 — abstract scenarios on /tools/verfahrensablauf (NEW, replaces ex-Slice E)
**Scope** abstract scenarios surface; reuses the same `paliad.scenarios` table with `project_id IS NULL`.
**Files**:
- `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**:
- /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)
**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@<tag>` 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 (revised)
```
A → B → C → F (youpc integration on C)
D → E (paliad-only; E builds on D's table + schema)
```
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.
---
## §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 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.
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 — RESOLVED 2026-05-26
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) — ANSWERED §0.5-Q4
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.
### Q2 — User-authored rules scope (material) — REJECTED §0.5
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
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.
---
## §18 Slice B — Catalog Interface + Unifications (2026-05-26)
Slice A landed atomically at `d1d0cf9`. Before Slice B's coder shift begins, three additional decisions m confirmed today need to be folded into the package design:
- §18.1 **Berufung unification**. Collapse the 3 active UPC appeal proceeding_types (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) into ONE `upc.apl` proceeding type + an `appeal_target` discriminator.
- §18.2 **Multi-axis catalog query API**. New `Catalog.LookupEvents` method taking any subset of `{jurisdiction, proceeding_type_id, party, event_category_id}` axes + a depth control (`next` / `all-following`).
- §18.3 **`primary_party` enum tightening**. Convert the free-text `paliad.deadline_rules.primary_party` column to a CHECK constraint matching the four-value vocabulary `claimant / defendant / court / both`.
Each subsection follows the same shape: motivation schema impact API shape acceptance criteria.
### §18.0 Live state on main (audit summary)
Confirmed via Supabase before drafting (`mai/cronus/inventor-litigation-slice-b` branch off `main`):
- **9 active UPC proceeding_types**: `upc.inf.cfi` (25 rules), `upc.rev.cfi` (17), `upc.pi.cfi` (7), `upc.dmgs.cfi` (8), `upc.disc.cfi` (4), `upc.ccr.cfi` (0 sub-track), `upc.apl.merits` (7), `upc.apl.cost` (2), `upc.apl.order` (7).
- **3 appeal-flavoured proceeding_types** = 16 rules across 3 codes. Schadensbemessung + Bucheinsicht are SEPARATE first-instance proceedings today (`upc.dmgs.cfi`, `upc.disc.cfi`), NOT appeal sub-tracks.
- **`paliad.deadline_rules.primary_party`** value distribution: `claimant=26`, `defendant=26`, `court=38`, `both=63`, `NULL=78`. The 78 NULL rows are ALL `proceeding_type_id IS NULL` orphans (cross-cutting concept seeds: Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung 8 distinct concepts × N rules). Every proceeding-bound rule already has a four-value `primary_party`.
- **`paliad.event_categories.party`** column shape: `text[]` (array). Live distinct values: `{claimant}`, `{defendant}`, NULL. No `court` or `both` in event_categories.party today. The semantic is "from whose perspective is this event triggered?" narrower than `primary_party` which is "who files this submission".
### §18.1 Berufung unification
#### Motivation
m's framing (2026-05-26 09:55, t-paliad-298 instructions): *"the Verfahrensablauf event picker has 4-5 separate proceeding_types … plus Berufung Schadensbemessung and Berufung Bucheinsicht variants. m doesn't like the pre-separation. He wants ONE 'Berufung' entry in the picker, and the user then picks what the appeal is directed AT … the system derives the correct frist sequence from that target."*
Today's 3 codes (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) are a leaky abstraction of "appeal" the user has to know whether it's a merits/cost/order appeal BEFORE they enter the picker, even though that branching question is "what's being appealed?" not "what kind of appeal?". Schadensbemessung + Bucheinsicht aren't in the appeal taxonomy at all today; appeals against those decisions silently fall into `upc.apl.merits`, blurring the rule sequence (RoP.220 vs RoP.221 vs RoP.224 timing).
The five appeal-target kinds are:
| Target | Source decision | Typical RoP track | Current proceeding code |
|---|---|---|---|
| Endentscheidung | Final merits decision (UPC.RoP.118.1 / 219) | 2-month notice + 4-month grounds (R.224.1.a / R.224.2.a) | `upc.apl.merits` |
| Kostenentscheidung | Cost decision (R.150 / R.221.1) | 15-day leave-to-appeal (R.221.1) | `upc.apl.cost` |
| Anordnung | Order during proceedings (R.220) | 15-day track (R.220.2 / R.220.3 / R.224.2.b) | `upc.apl.order` |
| Schadensbemessung | Damages-determination decision (R.118.4 + R.140.2.b damages award) | Same merits track (2/4 month), but conceptually distinct anchor | (today maps to `upc.apl.merits`, silently) |
| Bucheinsicht | Lay-open-books decision (R.142) | 15-day track (R.220.2 order-flavoured) OR merits track depending on the underlying decision shape | (today maps to `upc.apl.merits`, silently) |
#### Schema impact
**Migration plan (single `134_berufung_unification.up.sql`)**:
1. **Add column** `paliad.proceeding_types.appeal_target text NULL` discriminator on the unified `upc.apl` row.
2. **Add CHECK** on `appeal_target`: NULL OR one of `endentscheidung | kostenentscheidung | anordnung | schadensbemessung | bucheinsicht`. Slugged in English-lowercase to match the package's English-identifier rule; the user-facing label is i18n'd in frontend.
3. **Insert** a new unified row `upc.apl` (name="Berufungsverfahren", name_en="Appeal", jurisdiction="UPC", category="fristenrechner", `appeal_target=NULL`).
4. **Re-target rule rows** by `appeal_target`:
- Today's 7 `upc.apl.merits` rules keep `proceeding_type_id` pointing at the new `upc.apl` row, set a NEW column on `paliad.deadline_rules` called `applies_to_target text NULL` (CHECK matching the five-value vocab) to `'endentscheidung'`.
- Today's 2 `upc.apl.cost` rules `applies_to_target='kostenentscheidung'`.
- Today's 7 `upc.apl.order` rules `applies_to_target='anordnung'`.
- The 7 merits rules ALSO carry the implicit "applies to Schadensbemessung" semantic (the merits track is shared) explicit duplication or a multi-value applies_to_target array? See §18.1 "Open question" below.
5. **Archive** the 3 old proceeding_types set `category='archived'`, `is_active=false`. Keep the rows for FK integrity (project_event_choices, etc. may reference them historically; the archive flag stops them surfacing in the picker).
6. **Add 5 stable proceeding-type alias rows** OR **just emit one chip per appeal_target in the frontend**. Recommended (see API shape below): emit chips from the package's catalog, no DB row per target.
**Two new columns added by this migration:**
- `paliad.proceeding_types.appeal_target text NULL` (CHECK on 5 slugs OR NULL NULL means "not an appeal").
- `paliad.deadline_rules.applies_to_target text[] NULL` (CHECK each element the 5 slugs array because the merits track applies to BOTH endentscheidung AND schadensbemessung today).
**Migration audit pass first**: before running step 4 the migration should `RAISE NOTICE` for any rule row whose `applies_to_target` derivation is ambiguous (e.g. an old `upc.apl.merits` rule that has a `condition_flag` that doesn't fit any target). In practice the 16 rules all map cleanly, but the audit pattern matches Phase 2 Step E discipline (see `docs/design-fristen-phase2-2026-05-15.md` §3.E).
**Down-migration**: re-insert the 3 archived proceeding_types, restore `proceeding_type_id` on rules from the saved `applies_to_target`, drop the two new columns. Standard down-symmetry per `docs/design-fristen-phase2-2026-05-15.md`.
#### API shape
The package's existing `Catalog.LoadProceeding(ctx, code, hint)` already returns a `ProceedingType` + `[]Rule`. The Berufung unification fits cleanly:
- `LoadProceeding(ctx, "upc.apl", hint)` returns the unified Berufung proceeding + ALL appeal rules across the 5 targets.
- A new optional field on the request narrows by target: extend `CalcOptions` with `AppealTarget string`. When non-empty, the engine filters the returned rule list to rules whose `applies_to_target` contains the requested target.
- The package exposes the 5 target slugs as constants:
```go
const (
AppealTargetEndentscheidung = "endentscheidung"
AppealTargetKostenentscheidung = "kostenentscheidung"
AppealTargetAnordnung = "anordnung"
AppealTargetSchadensbemessung = "schadensbemessung"
AppealTargetBucheinsicht = "bucheinsicht"
)
// AppealTargets is the canonical ordered list for UI chip rendering.
var AppealTargets = []string{
AppealTargetEndentscheidung,
AppealTargetKostenentscheidung,
AppealTargetAnordnung,
AppealTargetSchadensbemessung,
AppealTargetBucheinsicht,
}
```
- `ProceedingType` gains a field: `AppealTarget *string ` db:"appeal_target" json:"appealTarget,omitempty"`` (per-row tag for clarity; redundant with the unified row's `code='upc.apl'` but useful for non-appeal proceedings that may carry NULL).
- `Rule` gains a field: `AppliesToTarget []string ` db:"applies_to_target" json:"appliesToTarget,omitempty"`` (per-row applies-to set).
Frontend logic:
- Verfahrensablauf picker shows one "Berufung" entry (the `upc.apl` proceeding).
- After picking Berufung, a chip group renders the 5 `AppealTargets` slugs (i18n labels in `frontend/src/client/i18n.ts`).
- Selecting a target sets `?target=<slug>` query param backend includes `opts.AppealTarget=<slug>` in the request engine filters.
#### Acceptance criteria (Slice B sub-tasks for this fold-in)
1. Migration `134_berufung_unification.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB.
2. After migration, `SELECT code FROM paliad.proceeding_types WHERE jurisdiction='UPC' AND is_active=true AND category='fristenrechner'` returns one less row (the 3 old appeal codes collapsed to 1 new code).
3. `Catalog.LoadProceeding(ctx, "upc.apl", hint)` returns the merged 16-rule set; with `opts.AppealTarget="endentscheidung"` it returns exactly 7 rules.
4. Verfahrensablauf renders one "Berufung" picker entry. The 5 target chips render below it post-pick; switching chips re-renders the timeline.
5. Existing project rows that referenced the old `upc.apl.merits` / `upc.apl.cost` / `upc.apl.order` codes still load (the FK integrity is preserved via the archived old rows).
6. The `paliad.proceeding_type_history` follow-up (not in scope here) can later migrate those project FKs to the new `upc.apl` + `appeal_target` field that's a follow-up.
#### m's answer on Q18.1.1 (2026-05-26 13:40)
> Schadensbemessung-as-appeal is a NEW appeal target. Model it as a first-class entry in the appeal_target enum with its own rule set (no shared inheritance from upc.apl.merits). The rules don't exist yet in the catalog; for now the appeal_target value is defined and `CalcOptions.AppealTarget="schadensbemessung"` returns an empty sequence until rules are seeded.
**Option B wins (m overrode inventor's R=A).** Migration 134 still ships the schema + the 5 enum values + the chip group + the engine filter. Existing rules:
- 7 `upc.apl.merits` rules `applies_to_target=['endentscheidung']` (Endentscheidung only NOT also Schadensbemessung).
- 2 `upc.apl.cost` rules `applies_to_target=['kostenentscheidung']`.
- 7 `upc.apl.order` rules `applies_to_target=['anordnung']`.
- **Schadensbemessung + Bucheinsicht get NO rules in this migration.** `applies_to_target='schadensbemessung'` and `'bucheinsicht'` are valid enum values but no rule row carries them yet.
**Frontend behaviour with empty rule sets:**
- All 5 target chips still render (the picker promises the user a complete vocabulary).
- Picking Schadensbemessung or Bucheinsicht returns an empty timeline with a banner: "Frist-Sequenz für diesen Berufungstyp ist noch nicht hinterlegt bitte über /admin/rules einpflegen oder Migrations-Follow-up abwarten."
- Picking the 3 populated targets renders normally.
**Rule-seeding follow-up (TODO, separate slice):**
- Schadensbemessung-appeal rules: anchor on R.118.4 (Folgeentscheidung Schadensbemessung) decision; conjecture 2/4-month track but distinct legal basis.
- Bucheinsicht-appeal rules: anchor on R.142 (Lay-open-books decision); conjecture 15-day track per R.220.2 + R.224.2.b.
- Can pair with `t-paliad-193` orphan-concept-seed if m wants a combined seeding pass.
- Either path: editorial via `/admin/rules` (rule-editor service, Slice 11a) so the lawyer team can author + audit.
**Q18.1.2 — User-facing label for "appeal_target": "Worauf richtet sich die Berufung?" (DE) / "Appeal against:" (EN)?**
Recommendation: yes, those exact strings; defer i18n decisions to the coder shift.
### §18.2 Multi-axis catalog query API
#### Motivation
The current `Catalog` interface (added in Slice A) supports proceeding-code lookups only. The new scenarios surface (Slice D) + the Determinator cascade (t-paliad-166) + a future "show me all next-step events when I'm in state X" need a generalised query that takes any subset of axes and returns matching events.
m's brief (2026-05-26 13:33): *"any subset of these axes (all optional): jurisdiction, proceeding_type_id, party, event_category_id. Returns matching events with the priority flag and a sequence-depth control: caller picks 'next' (1 hop downstream) or 'all-following' (full chain)."*
Today the cascade reconstructs this client-side via fanned-out calls to `/api/tools/fristenrechner` etc. fragile + duplicated logic. The new method centralises the graph walk in the package.
#### Schema impact
**None new.** The query reads existing tables:
- `paliad.proceeding_types` (jurisdiction, id, code)
- `paliad.deadline_rules` (parent_id, sequence_order, primary_party, priority, event_category_id via concept_id event_category_concepts)
- `paliad.event_categories` (id, party, parent_id for the cascade hierarchy)
- `paliad.event_category_concepts` (junction; concept_id event_category_id)
The depth control is a runtime graph walk `next` returns one hop from the matched parent, `all-following` walks `parent_id` recursively until leaves.
The audit found one schema gap worth flagging but NOT changing in Slice B:
- `paliad.deadline_rules` has no direct `event_category_id` column it goes through `concept_id → deadline_concepts → event_category_concepts → event_categories`. The join is well-trodden but introduces an extra hop. v1 of the catalog API uses the join; a future denormalisation (`paliad.deadline_rules.event_category_id` cached column) is out of scope.
#### API shape
```go
// EventLookupAxes carries the optional filter axes for LookupEvents. All
// fields are optional; the empty value is "no filter on this axis". When
// multiple axes are set the engine applies them as AND (a rule must
// match ALL non-zero axes).
type EventLookupAxes struct {
Jurisdiction string // "UPC" | "DE" | "EPA" | "DPMA" — empty = any
ProceedingTypeID *int // narrow to one proceeding — nil = any
Party string // "claimant" | "defendant" | "court" | "both" — empty = any
EventCategoryID *uuid.UUID // narrow to one event_categories row — nil = any
AppealTarget string // §18.1 fold-in — empty = any
}
// EventLookupDepth controls the sequence-depth of the returned events.
type EventLookupDepth string
const (
// EventLookupDepthNext returns immediate children of the matched
// anchor (1 hop downstream). Default for "what comes next from
// this point?" queries.
EventLookupDepthNext EventLookupDepth = "next"
// EventLookupDepthAllFollowing returns the entire downstream
// chain (parent_id walk to leaves). Default for "show me the
// whole sequence from here onward" queries.
EventLookupDepthAllFollowing EventLookupDepth = "all-following"
)
// EventMatch is one result row from LookupEvents.
type EventMatch struct {
Rule Rule `json:"rule"` // full rule row
ProceedingType ProceedingType `json:"proceedingType"` // owning proceeding
Priority string `json:"priority"` // mandatory|recommended|optional|informational
DepthFromAnchor int `json:"depthFromAnchor"` // 1 = next, 2+ = deeper
// ParentRuleID populated when the match has a parent_id in the
// returned set (so the frontend can render a tree).
ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
}
// LookupEvents on the Catalog interface returns events matching any
// subset of axes, at the requested sequence depth. Returns an empty
// slice (NOT an error) when no events match.
//
// Implementation must respect the catalog's "published + active" rule
// gate that LoadProceeding already enforces.
type Catalog interface {
// ... existing methods (LoadProceeding, LoadProceedingByID, ...)
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
}
```
paliad's `paliadCatalog` impl builds one SQL query with optional WHERE clauses + the existing `deadline_concept_event_types` JOIN for the event_category_id axis. youpc.org's embedded snapshot impl runs the same axis-filter pass on the in-memory rule slice.
#### Acceptance criteria (Slice B sub-tasks)
1. `Catalog.LookupEvents` exists on the interface + has both a paliad-side impl (SQL) and a stub for the future embedded/upc snapshot impl.
2. Round-trip test: `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all 77 UPC rules (matches the count from §0).
3. Combined-axis test: `EventLookupAxes{Jurisdiction:"UPC", Party:"claimant"}` returns the claimant-perspective subset.
4. Depth test: with a specific `ProceedingTypeID` + `Party:"defendant"`, `EventLookupDepthNext` returns only 1-hop children of the proceeding's root; `EventLookupDepthAllFollowing` returns the full chain.
5. New axis-driven endpoint at `GET /api/tools/lookup-events?…` proxies the call (separate slice out of scope for the package-side acceptance, but listed for the coder).
### §18.3 `primary_party` enum tightening
#### Motivation
Today `paliad.deadline_rules.primary_party` is free-text. The live values confirm a stable four-value vocabulary (`claimant=26, defendant=26, court=38, both=63, NULL=78`) but nothing prevents a future rule editor from typing `clamant` or `Court` and silently breaking the appellant-context propagation in `engine.go`.
m's brief: *"Tighten to a check constraint matching event_categories.party's allowed values: claimant/defendant/court/both. Migration must audit + clean existing rows first; surface dirty rows to m if any don't fit the four-value vocabulary."*
Note: `event_categories.party` is a `text[]` (array) with current live values `{claimant}`, `{defendant}`, NULL. It does NOT carry `court` or `both` today. The brief's "matching event_categories.party's allowed values" is taken to mean the SEMANTIC vocabulary (claimant/defendant/court/both), not the literal current rows of event_categories.party. The package owns the canonical list.
#### Schema impact
**Migration `135_primary_party_check.up.sql`**:
1. **Audit pass** (DO $$ block): COUNT rules where `proceeding_type_id IS NOT NULL AND primary_party NOT IN ('claimant', 'defendant', 'court', 'both', NULL)`. RAISE NOTICE for each non-matching row's `(id, name, primary_party)`. If COUNT > 0, RAISE EXCEPTION 'dirty rows — see notice; manual cleanup required'.
2. **Add CHECK constraint**: `ALTER TABLE paliad.deadline_rules ADD CONSTRAINT deadline_rules_primary_party_chk CHECK (primary_party IS NULL OR primary_party IN ('claimant', 'defendant', 'court', 'both'))`.
3. **No data change** — every proceeding-bound rule already has a valid four-value value; the 78 NULL rows are orphan concept seeds and stay NULL.
4. **Down-migration**: `ALTER TABLE paliad.deadline_rules DROP CONSTRAINT deadline_rules_primary_party_chk`. No data revert needed.
**Why NULL stays valid:**
- The 78 NULL rows are cross-cutting concept seeds (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) that have NO proceeding binding. They're not in the calculator's path; loosening the CHECK to `IS NULL OR IN (...)` keeps them valid without further schema gymnastics.
- A stricter "NOT NULL when proceeding_type_id is NOT NULL" partial constraint would be cleaner but adds a multi-column rule that's harder to maintain. The simpler form suffices given today's invariant.
**Should the same vocabulary be propagated to `paliad.event_categories.party`?**
Recommendation: **NO, not in this migration**. event_categories.party is array-shaped (a category can apply to multiple perspectives) and today carries only `{claimant}` / `{defendant}` per its narrower semantic ("from whose perspective is this category triggered?"). Tightening it to require court/both would force backfill of rows where neither perspective is the trigger. Out of scope for Slice B; flag as a follow-up.
#### API shape
The package's `Rule.PrimaryParty` field (already `*string`) stays as-is — the type doesn't change. A new package-level set of constants:
```go
const (
PrimaryPartyClaimant = "claimant"
PrimaryPartyDefendant = "defendant"
PrimaryPartyCourt = "court"
PrimaryPartyBoth = "both"
)
// PrimaryParties is the canonical ordered list for validation +
// admin-UI rendering.
var PrimaryParties = []string{
PrimaryPartyClaimant,
PrimaryPartyDefendant,
PrimaryPartyCourt,
PrimaryPartyBoth,
}
// IsValidPrimaryParty returns true for empty (NULL-equivalent) or any
// of the four canonical values. Used by the rule-editor (Slice E2 if
// ever revisited) to validate writes before they hit the CHECK.
func IsValidPrimaryParty(s string) bool { }
```
The rule-editor service (`internal/services/rule_editor_service.go`) gains a validation call against `lp.IsValidPrimaryParty` before any UPDATE — surfaces a user-friendly 400 before the DB CHECK fires with the less-pretty error.
#### Acceptance criteria (Slice B sub-tasks)
1. Migration `135_primary_party_check.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB.
2. Pre-migration audit pass surfaces zero dirty rows on the current live corpus (verified via Supabase audit before migration drafted).
3. Post-migration, attempting to UPDATE a rule's primary_party to `'foo'` raises a DB CHECK violation.
4. The package exposes the four constants + the `PrimaryParties` slice + the `IsValidPrimaryParty` predicate.
5. Rule-editor service surfaces 400 with a clear message when a write violates the constraint (instead of leaking the raw PG error).
### §18 Summary table
| § | Topic | Schema delta | API delta | Migration |
|---|---|---|---|---|
| 18.1 | Berufung unification | +2 columns (proceeding_types.appeal_target + deadline_rules.applies_to_target[]); collapse 3→1 active appeal codes | `Catalog` returns merged proceeding + Rule.AppliesToTarget; CalcOptions.AppealTarget filter; AppealTargets[] constants | `134_berufung_unification.up.sql` |
| 18.2 | Multi-axis catalog query | None new (uses existing joins) | `Catalog.LookupEvents(axes, depth)` new method + EventLookupAxes / EventMatch types | None |
| 18.3 | `primary_party` enum | +CHECK constraint on deadline_rules.primary_party | `PrimaryParties[]` constants + `IsValidPrimaryParty()` predicate | `135_primary_party_check.up.sql` |
### §18.4 Slice plan refinement (revises §10 for Slice B)
The original §10 listed Slice B as "Catalog / HolidayCalendar / CourtRegistry interfaces + paliad's default loaders." Slice A already folded those interfaces in (the engine.Calculate signature accepts them). Slice B's revised scope:
1. **Slice B1 — Berufung unification** (§18.1): migration 134 + package constants + `appeal_target` field on ProceedingType + `applies_to_target[]` on Rule + `CalcOptions.AppealTarget` filter. Frontend updates (verfahrensablauf chip group) follow in the same PR.
2. **Slice B2 — Multi-axis catalog query API** (§18.2): `Catalog.LookupEvents` method + paliad impl + tests. New `GET /api/tools/lookup-events` endpoint optional (slice C may want it earlier).
3. **Slice B3 — primary_party enum tightening** (§18.3): migration 135 + package constants + rule-editor validation hook.
B1 / B2 / B3 are independently shippable and can land in any order. B1 has the most user-facing impact (the picker change is what m flagged); B3 is the smallest hardening; B2 is the largest API surface.
### §18.5 Open questions escalated to head
- §18.1 Q1 — Schadensbemessung-as-appeal: shared vs distinct vs deferred (R: shared, multi-valued `applies_to_target`).
- §18.1 Q2 — i18n label for "Worauf richtet sich die Berufung?" (R: yes, defer to coder).
- §18.3 — Should event_categories.party be tightened in the same migration? (R: no, separate follow-up.)
No `AskUserQuestion` per inventor protocol; head escalates to m if material.
---
## §19 Slice C — embedded UPC snapshot + generator (2026-05-26)
Slice A landed the package, Slice B added the catalog API surface. Slice C lays the foundation for the youpc.org cross-repo integration: an in-package UPC subset of paliad's deadline corpus, embedded as JSON, that youpc.org can use to run the engine without any paliad DB access.
### §19.1 Goals
1. **Zero DB dependency for snapshot consumers.** youpc.org imports `pkg/litigationplanner/embedded/upc` and gets a working Catalog / HolidayCalendar / CourtRegistry without ever touching paliad's Postgres.
2. **Reproducible regeneration.** A generator binary (`cmd/gen-upc-snapshot`) reads paliad's live DB and produces the JSON. Idempotent — same DB state in, same JSON out.
3. **Versioned snapshots.** Each snapshot carries a `version` + `generated_at` so consumers can detect regeneration and decide whether to bump their go.mod.
4. **Stays in lockstep with paliad's engine.** The embedded data conforms to the same `Rule` / `ProceedingType` Go types the engine consumes — no schema drift, no parallel-vocab risk.
### §19.2 Embedding format
**Pick: `//go:embed` of JSON.**
Three candidates considered:
- A. **`//go:embed` of JSON files** — generator emits human-readable JSON; package reads at boot via `embed.FS`. Diff-friendly in git; youpc.org sees the bytes change in code review.
- B. **Generated Go const literals** — generator emits a `.go` file with the rule slice inlined. Type-safe at compile; harder to diff (big generated files); pollutes `git log -p` with mechanical changes.
- C. **External resource fetched at runtime** — youpc.org would HTTP-GET the snapshot from a paliad endpoint. Adds runtime coupling between the two services; defeats the "zero DB dependency" goal.
**(R) = A**. JSON is the wire shape paliad's API already serves; the package's `Rule` struct already has compatible `json:` tags from Slice A. The generated bytes survive `git diff` cleanly. youpc.org can also vendor the JSON via go-module if they want fully reproducible builds.
### §19.3 File layout
```
pkg/litigationplanner/embedded/upc/
embed.go ← //go:embed *.json + package metadata
snapshot.go ← SnapshotCatalog struct + Load() helper
snapshot_test.go ← unit tests against the embedded data
rules.json ← generator output: all UPC rules
proceeding_types.json ← generator output: all UPC proceeding types
trigger_events.json ← generator output: UPC-referenced trigger events
holidays.json ← generator output: DE + UPC regime holidays
courts.json ← generator output: UPC courts
meta.json ← generator output: {version, generated_at, paliad_commit, source_db_label}
cmd/gen-upc-snapshot/
main.go ← generator entry point
README.md ← operator runbook
```
`pkg/litigationplanner/embedded/upc` is the public consumer surface. youpc.org imports it as:
```go
import upc "mgit.msbls.de/m/paliad/pkg/litigationplanner/embedded/upc"
cat, _ := upc.NewCatalog()
hc, _ := upc.NewHolidayCalendar()
cr, _ := upc.NewCourtRegistry()
timeline, err := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26", lp.CalcOptions{...}, cat, hc, cr)
```
### §19.4 Snapshot data shape
The five data files (`rules.json`, `proceeding_types.json`, `trigger_events.json`, `holidays.json`, `courts.json`) are each a top-level JSON array of the corresponding type. The package's `Rule` / `ProceedingType` / `TriggerEvent` structs deserialise directly (their `json:` tags align with paliad's wire shape).
`holidays.json` and `courts.json` use minimal structures defined in the embedded sub-package (the package's core API only requires `HolidayCalendar` / `CourtRegistry` interfaces — no struct contract).
`meta.json` carries the versioning block:
```json
{
"version": "2026-05-26-1",
"generated_at": "2026-05-26T15:01:00Z",
"paliad_commit": "932b177",
"source_db_label": "paliad-dev-supabase",
"rule_count": 81,
"proceeding_count": 9,
"trigger_event_count": 2,
"holiday_count": 142,
"court_count": 18
}
```
`version` uses a date-stamped scheme (`YYYY-MM-DD-N` where N starts at 1 and increments for same-day regenerations) — simple, sortable, no merge conflicts on regen.
### §19.5 Generator
`cmd/gen-upc-snapshot/main.go` runs as:
```sh
DATABASE_URL=postgres://... \
go run ./cmd/gen-upc-snapshot \
-output ./pkg/litigationplanner/embedded/upc
```
Flow:
1. Connect to `DATABASE_URL` (paliad's live DB).
2. Apply migrations first (`db.ApplyMigrations(url)`) — ensures the snapshot matches schema HEAD.
3. SELECT all `paliad.proceeding_types` WHERE `jurisdiction = 'UPC'` AND `is_active = true`. (After B1 the unified `upc.apl` is the only appeal proceeding — the 3 archived old codes are filtered out.)
4. SELECT all `paliad.deadline_rules` for those proceeding ids WHERE `lifecycle_state = 'published'` AND `is_active = true`.
5. SELECT `paliad.trigger_events` referenced by any rule's `trigger_event_id`.
6. SELECT `paliad.holidays` filtered to `country = 'DE' OR regime = 'UPC'` (the union UPC procedures need).
7. SELECT `paliad.courts` filtered to `regime = 'UPC' OR court_type LIKE 'upc%'` (UPC court hierarchy).
8. Write each result set to `<output>/<name>.json` (pretty-printed for diff-friendliness).
9. Compute meta — current paliad commit (via `git rev-parse --short HEAD`), timestamp, row counts.
10. Write `meta.json`.
**Versioning rule**: the generator never overwrites a meta.json with `version` equal to an existing one. If today's date is already used (suffix `-1`), the generator bumps to `-2`. This keeps regenerations within a day distinguishable. Operator can pass `-version <string>` to override.
### §19.6 Regeneration trigger
Manual. Three entry points:
- **`make snapshot-upc`** — Make target invokes the generator with `DATABASE_URL` from env. Documented in `cmd/gen-upc-snapshot/README.md`.
- **`go generate ./pkg/litigationplanner/embedded/upc`** — `//go:generate` directive on a stub in the package. Same effect; lets contributors discover the regen path from the package they're modifying.
- **Operator runs the command directly** — power-user path.
**No CI regeneration in v1.** The snapshot is operator-controlled. Future slice can add a nightly CI job that opens a PR with the regenerated snapshot if drift is detected (out of scope here).
### §19.7 SnapshotCatalog implementation
In `pkg/litigationplanner/embedded/upc/snapshot.go`:
```go
type SnapshotCatalog struct {
proceedings []litigationplanner.ProceedingType
rules []litigationplanner.Rule
triggerEvents map[int64]litigationplanner.TriggerEvent
rulesByProc map[int][]litigationplanner.Rule // for LoadProceeding
rulesByID map[uuid.UUID]litigationplanner.Rule
procByID map[int]litigationplanner.ProceedingType
procByCode map[string]litigationplanner.ProceedingType
}
func NewCatalog() (*SnapshotCatalog, error) // parses embedded JSON
```
All 7 Catalog interface methods (`LoadProceeding`, `LoadProceedingByID`, `LoadRuleByID`, `LoadRuleByCode`, `LoadRulesByTriggerEvent`, `LoadTriggerEventsByIDs`, `LookupEvents`) implemented against the in-memory maps. Lookup methods are O(1) on the indexed maps; `LookupEvents` does a linear scan of `rules` (the UPC subset is < 100 rows; no index needed).
`ProjectHint` is ignored on the snapshot side (youpc.org has no projects). `applies_to_target` filter for B1 works identically the rules carry the same array.
`HolidayCalendar` impl mirrors paliad's `HolidayService` but reads from the embedded holiday slice instead of paliad.holidays. Same `AdjustForNonWorkingDaysWithReason` semantics.
`CourtRegistry` impl mirrors `CourtService.CountryRegime`. UPC courts only.
### §19.8 Tests
`snapshot_test.go` exercises:
- Snapshot loads without error
- `meta.json` parses + has non-zero counts
- `LoadProceeding(ctx, "upc.inf.cfi", ProjectHint{})` returns the expected proceeding + > 0 rules
- `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all rules
- A golden compute: `Calculate(ctx, "upc.inf.cfi", "2026-01-15", CalcOptions{}, cat, hc, cr)` produces a non-empty timeline with a known root rule (Klageerhebung)
All tests run without a DB (zero `os.Getenv("TEST_DATABASE_URL")` checks).
### §19.9 Acceptance criteria
1. `cmd/gen-upc-snapshot` exists + builds + runs against the live paliad DB.
2. `pkg/litigationplanner/embedded/upc/*.json` checked in with the first generated snapshot.
3. `embedded/upc.NewCatalog()` (+ `NewHolidayCalendar` + `NewCourtRegistry`) return ready-to-use implementations of the package interfaces.
4. Unit tests in `embedded/upc` pass without `TEST_DATABASE_URL` (no DB roundtrip).
5. `make snapshot-upc` regenerates the snapshot.
6. `go build ./...` + `go test ./...` all green.
### §19.10 Out of scope (deferred to follow-up)
- Snapshot signing / integrity attestation. v1 is plain JSON; future slice can ship a `meta.sig` next to `meta.json` for tamper detection.
- DE/EPA/DPMA snapshots. v1 only ships the UPC subset (matches youpc.org's scope). Future jurisdictions add as sibling packages: `embedded/de`, `embedded/epa`, etc.
- CI regeneration cron. Operator-driven only in v1.
- Snapshot diff tooling. v1 relies on `git diff` of the JSON files.
---
*End of design doc.*