# 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 270–780) | `pkg/litigationplanner/engine.go` | Renamed `litigationplanner.Calculate`. |
| `internal/services/fristenrechner.go` CalculateRule body | `pkg/litigationplanner/engine.go` | Renamed `litigationplanner.CalculateRule`. |
| `internal/services/fristenrechner.go` evalConditionExpr | `pkg/litigationplanner/expr.go` | Already pure. |
| `internal/services/proceeding_mapping.go` (entirety) | `pkg/litigationplanner/proceeding_mapping.go` | Pure. |
| `internal/services/proceeding_mapping.go` SubTrackRoutings | `pkg/litigationplanner/subtrack.go` | Pure registry. |
| `internal/services/deadline_search_service.go` FormatLegalSourceDisplay + BuildLegalSourceURL | `pkg/litigationplanner/legal_source.go` | Both pure. |
| `internal/services/fristenrechner.go` UIResponse, UIDeadline, CalcOptions, CalcRuleParams, AdjustmentReason | `pkg/litigationplanner/types.go` | Renamed Timeline / TimelineEntry / CalcOptions / RuleCalcParams / AdjustmentReason. paliad keeps type aliases for back-compat. |
| `internal/services/holidays.go` HolidayService | (stays, becomes Catalog impl) | Plus the package gets the interface. |
| `internal/services/courts.go` CourtService | (stays, becomes Catalog impl) | Same. |
| `internal/services/deadline_rule_service.go` DeadlineRuleService | (stays, becomes Catalog impl) | Same. |

### Test discipline

- All current tests under `internal/services/fristenrechner_test.go`, `deadline_calculator_test.go`, `projection_service_test.go`, `event_deadline_service_test.go` continue to run **unchanged** in paliad — they test the paliad-side wrappers which delegate to the package.
- The package gets its own `pkg/litigationplanner/*_test.go` with fixture-driven tests: input JSON → expected output JSON, covering every rule shape (composite, alt-swap, anchor-override, sub-track, condition_expr, court-set indirect, choice-skip, choice-include-CCR, …).
- `pkg/litigationplanner/embedded/upc/snapshot_test.go` is a golden test: run the engine against the snapshot for every (proceeding, trigger_date) fixture, assert byte-equality with the expected output. This is the regression net for the snapshot generator.

### Rollback

Slice A is one PR. Revert = revert. Once Slice C lands and youpc.org takes a dep on the snapshot (Slice F), revert means coordinating the youpc.org module pin — but that's a normal multi-repo coordination, not a one-way door.

### Slice ordering hard constraints

- **A blocks B**: B introduces the Catalog interface; A must put types into the package first.
- **B blocks C**: C populates the snapshot via the Catalog interface.
- **D + E are independent of A-C** but should land after at least Slice B so paliad's catalog is the merge point.
- **F (youpc) blocks on C** at minimum. Ideally also on D + E so the snapshot is stable.

---

## §9 Versioning + release process (Q5)

### Semver discipline on the package

- `v0.x.y` — pre-1.0. The package is intentionally below the 1.0 stability bar until paliad-internal + youpc-internal usage is settled (probably 2-3 months of co-existence).
- `v1.0.0` — declare API frozen; new fields are additive only; removals require a major bump.

### What counts as a breaking change?

- Rename a public function / type / field → breaking.
- Change the JSON tags of `Rule` / `ProceedingType` / `Timeline` → breaking (the snapshot file shape changes too).
- Change the meaning of a function (e.g. `CountryRegime` now returns an error on unknown court) → breaking.
- Drop a `Catalog` / `HolidayCalendar` / `CourtRegistry` method → breaking.

### What is additive (non-breaking)?

- Add a new field to `Rule` / `Timeline` / `CalcOptions` → additive (struct fields are open).
- Add a new constant / helper / sub-package → additive.
- Add a new optional field on `CalcRequest` → additive if zero-value is back-compat.
- Add a new entry to `SubTrackRoutings` → additive (data, not API).
- Add a new proceeding type to the snapshot → additive (data).
- Add new rules to existing proceeding types in the snapshot → additive (data).

### Snapshot versioning vs API versioning

Two clocks:

- **API version** — bumped on package API change. Lives in `pkg/litigationplanner/doc.go` (`const Version = "..."`).
- **Snapshot vintage** — bumped on every regeneration. Lives in `pkg/litigationplanner/embedded/upc/VERSION` (auto-set to the paliad git tag at generator time).

A breaking schema addition (e.g. a new column on `paliad.deadline_rules` that the snapshot ships) is API-version-additive (struct gains a field) but snapshot-vintage-bumping. youpc.org sees it on its next `go get -u`.

A deletion of a column (rare) is API-version-breaking — requires a major bump.

### Release flow

1. paliad merge to main bumps the package version when it touches `pkg/litigationplanner/`.
2. Snapshot regeneration is triggered MANUALLY by the operator after material rule changes (rule editor Slice 11b, batch migrations, etc.). Process: `go run ./pkg/litigationplanner/scripts/snapshot` → review diff → commit → tag → push.
3. youpc.org's CI runs `go get -u` weekly (or on-demand) and opens a PR with the new dep version. Human reviews + merges.

### Drift detection

A test on paliad's side compares the snapshot at HEAD against the live DB and fails if the rule set has diverged without a snapshot bump:

```bash
# pkg/litigationplanner/scripts/snapshot/check.sh — runs in paliad CI
DATABASE_URL=$PALIAD_DB go run ./pkg/litigationplanner/scripts/snapshot \
    --jurisdiction=UPC --dry-run --diff-against=./pkg/litigationplanner/embedded/upc
```

If the diff is non-empty, the build fails with "snapshot drift — regenerate after reviewing". This catches accidental forgetting; nothing prevents intentional drift.

### Why semver, not date-stamped versions

Go modules need semver. `v0.42.0` slots into `require`; `v2026-05-26` doesn't. The snapshot VERSION file can carry both (semver + date) for human readability.

---

## §10 Slice plan

Each slice is independently shippable on the paliad side. Slice F is a separate PR on the youpc.org repo.

### Slice A — extract calc + types into `pkg/litigationplanner` (no behaviour change)

**Scope** — paliad-side extraction. Move:

- `Rule` / `ProceedingType` / `Court` / `Holiday` types into `pkg/litigationplanner/types.go`.
- `applyDuration`, `addWorkingDays`, `DeadlineCalculator` body into `pkg/litigationplanner/durations.go`.
- `evalConditionExpr` + `hasConditionExpr` into `pkg/litigationplanner/expr.go`.
- `MapLitigationToFristenrechner` + code constants into `pkg/litigationplanner/proceeding_mapping.go`.
- `SubTrackRoutings` + `LookupSubTrackRouting` into `pkg/litigationplanner/subtrack.go`.
- `FormatLegalSourceDisplay` + `BuildLegalSourceURL` into `pkg/litigationplanner/legal_source.go`.
- `DefaultsForJurisdiction` into `pkg/litigationplanner/courts.go`.
- `Calculate` + `CalculateRule` bodies into `pkg/litigationplanner/engine.go` — using the Catalog / HolidayCalendar / CourtRegistry interfaces.
- Type aliases in `internal/services/fristenrechner.go` so call-sites continue to import `services.UIResponse` etc.

**Test discipline** — all existing tests under `internal/services/*_test.go` continue to pass unchanged. Add package-side tests for the pure functions (which are mostly already there as table-driven tests; move them across).

**Exit criteria**:
- `go build ./...` clean.
- `go test ./...` green, no regressions.
- `internal/services/fristenrechner.go` is < 100 lines and consists of the type aliases + thin `Calculate`/`CalculateRule` shells.

**~1700 LoC moved, ~60 LoC remaining on the paliad side.**

### Slice B — Catalog / HolidayCalendar / CourtRegistry interfaces + paliad's default loaders

**Scope** — define the three interfaces in the package; have paliad's existing `DeadlineRuleService`, `HolidayService`, `CourtService` implement them with shim methods. Slice A already calls Calculate against the interfaces, so this is purely about wiring the production paths.

**Files**:
- `pkg/litigationplanner/catalog.go` — `Catalog` + `ProjectHint`.
- `pkg/litigationplanner/holidays.go` — `HolidayCalendar`.
- `pkg/litigationplanner/courts.go` — `CourtRegistry`.
- `internal/services/deadline_rule_service.go` — add `Proceeding(ctx, code, hint)` method satisfying `Catalog`.
- `internal/services/holidays.go` — already has `IsNonWorkingDay` / `AdjustForNonWorkingDays`; add `AdjustForNonWorkingDaysBackward` shim if not exposed.
- `internal/services/courts.go` — `CountryRegime` already exists, satisfies `CourtRegistry`.

**Exit criteria**: paliad's wire-up uses the interfaces; no behaviour change.

### Slice C — embedded UPC snapshot + generator script

**Scope** — write the generator + emit the first snapshot + the snapshot consumer.

**Files**:
- `pkg/litigationplanner/scripts/snapshot/main.go` — generator (CLI binary).
- `pkg/litigationplanner/scripts/snapshot/README.md` — operator runbook.
- `pkg/litigationplanner/embedded/upc/catalog.go` + `.json` + `holidays.go` + `.json` + `courts.go` + `.json` + `VERSION`.
- `pkg/litigationplanner/embedded/upc/snapshot_test.go` — golden tests.

**Exit criteria**:
- `go run ./pkg/litigationplanner/scripts/snapshot --jurisdiction=UPC` emits a non-empty snapshot.
- `lp.Calculate(ctx, req, upc.NewCatalog(), upc.NewHolidayCalendar(), upc.NewCourtRegistry())` returns the same Timeline for every fixture as the paliad-side Catalog does.
- A snapshot-drift test fails on intentional drift to prove the detector works.

### Slice D — scenarios persistence: 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.*
