Merge: t-paliad-292 — Slice B1: Berufung unification (one upc.apl + 5 appeal_target chips, mig 134 additive) (m/paliad#124 §18.1)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-05-26 13:49:52 +02:00
12 changed files with 896 additions and 8 deletions

View File

@@ -1141,4 +1141,312 @@ Slice F is a youpc-side task; it needs a worker with youpc-go familiarity (a sep
--- ---
## §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.
---
*End of design doc.* *End of design doc.*

View File

@@ -237,6 +237,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.upc.disc.cfi": "Bucheinsicht", "deadlines.upc.disc.cfi": "Bucheinsicht",
"deadlines.upc.apl.cost": "Berufung Kosten", "deadlines.upc.apl.cost": "Berufung Kosten",
"deadlines.upc.apl.order": "Berufung Anordnungen", "deadlines.upc.apl.order": "Berufung Anordnungen",
"deadlines.upc.apl": "Berufung",
"deadlines.appeal_target.label": "Worauf richtet sich die Berufung?",
"deadlines.appeal_target.endentscheidung": "Endentscheidung",
"deadlines.appeal_target.kostenentscheidung": "Kostenentscheidung",
"deadlines.appeal_target.anordnung": "Anordnung",
"deadlines.appeal_target.schadensbemessung": "Schadensbemessung",
"deadlines.appeal_target.bucheinsicht": "Bucheinsicht",
"deadlines.de.group.inf": "Verletzungsverfahren", "deadlines.de.group.inf": "Verletzungsverfahren",
"deadlines.de.group.null": "Nichtigkeitsverfahren", "deadlines.de.group.null": "Nichtigkeitsverfahren",
"deadlines.de.inf.lg": "LG (1. Instanz)", "deadlines.de.inf.lg": "LG (1. Instanz)",
@@ -3327,6 +3334,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.upc.dmgs.cfi": "Damages Determination", "deadlines.upc.dmgs.cfi": "Damages Determination",
"deadlines.upc.disc.cfi": "Lay-open Books", "deadlines.upc.disc.cfi": "Lay-open Books",
"deadlines.upc.apl.cost": "Cost-Decision Appeal", "deadlines.upc.apl.cost": "Cost-Decision Appeal",
"deadlines.upc.apl": "Appeal",
"deadlines.appeal_target.label": "Appeal against:",
"deadlines.appeal_target.endentscheidung": "Final Decision",
"deadlines.appeal_target.kostenentscheidung": "Cost Decision",
"deadlines.appeal_target.anordnung": "Order",
"deadlines.appeal_target.schadensbemessung": "Damages Determination",
"deadlines.appeal_target.bucheinsicht": "Lay-open Books",
"deadlines.upc.apl.order": "Order Appeal (15-day)", "deadlines.upc.apl.order": "Order Appeal (15-day)",
"deadlines.de.group.inf": "Infringement proceedings", "deadlines.de.group.inf": "Infringement proceedings",
"deadlines.de.group.null": "Nullity proceedings", "deadlines.de.group.null": "Nullity proceedings",

View File

@@ -64,9 +64,7 @@ let sidePrefilledFromProject = false;
// Conservative — false negatives just hide a control; false positives // Conservative — false negatives just hide a control; false positives
// would show an irrelevant control. // would show an irrelevant control.
const APPELLANT_AXIS_PROCEEDINGS = new Set([ const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl.merits", "upc.apl",
"upc.apl.cost",
"upc.apl.order",
"de.inf.olg", "de.inf.olg",
"de.inf.bgh", "de.inf.bgh",
"de.null.bgh", "de.null.bgh",
@@ -75,6 +73,29 @@ const APPELLANT_AXIS_PROCEEDINGS = new Set([
"epa.opp.boa", "epa.opp.boa",
]); ]);
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
// Proceedings that surface the appeal-target chip group. Currently
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
// can opt in by adding the code here.
const APPEAL_TARGET_PROCEEDINGS = new Set([
"upc.apl",
]);
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
// in sync with pkg/litigationplanner/types.go AppealTargets).
const APPEAL_TARGETS = [
"endentscheidung",
"kostenentscheidung",
"anordnung",
"schadensbemessung",
"bucheinsicht",
] as const;
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
function hasAppealTarget(proceedingType: string): boolean {
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
}
function hasAppellantAxis(proceedingType: string): boolean { function hasAppellantAxis(proceedingType: string): boolean {
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType); return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
} }
@@ -103,6 +124,32 @@ function writeAppellantToURL(a: Side) {
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash); window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
} }
// Slice B1 — appeal-target URL state. Empty string = no target picked
// (the row is hidden because the proceeding isn't an appeal). Any
// other value must be one of APPEAL_TARGETS; unknown values are
// rejected by readAppealTargetFromURL so a stale link can't break
// the engine filter.
function readAppealTargetFromURL(): AppealTarget {
const raw = new URLSearchParams(window.location.search).get("target") || "";
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
return raw as AppealTarget;
}
return "";
}
function writeAppealTargetToURL(t: AppealTarget) {
const url = new URL(window.location.href);
if (t === "") url.searchParams.delete("target");
else url.searchParams.set("target", t);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Default target on first picker entry into upc.apl. m: Endentscheidung
// is the most-common appeal target; the chip group also defaults
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
// sync so the URL-less default render hits the same code path.
let currentAppealTarget: AppealTarget = "";
// Per-rule anchor overrides set by the click-to-edit affordance on // Per-rule anchor overrides set by the click-to-edit affordance on
// timeline / column date cells. Posted as `anchorOverrides` to the // timeline / column date cells. Posted as `anchorOverrides` to the
// /api/tools/fristenrechner calc so downstream rules re-anchor off the // /api/tools/fristenrechner calc so downstream rules re-anchor off the
@@ -268,6 +315,13 @@ async function doCalc() {
const overrides: Record<string, string> = {}; const overrides: Record<string, string> = {};
for (const [code, date] of anchorOverrides) overrides[code] = date; for (const [code, date] of anchorOverrides) overrides[code] = date;
// Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung,
// default to "endentscheidung" when no chip pick is stored in URL.
// For non-appeal proceedings the engine ignores opts.AppealTarget.
const appealTarget = hasAppealTarget(selectedType)
? (currentAppealTarget || "endentscheidung")
: "";
const data = await calculateDeadlines({ const data = await calculateDeadlines({
proceedingType: selectedType, proceedingType: selectedType,
triggerDate, triggerDate,
@@ -276,6 +330,7 @@ async function doCalc() {
courtId, courtId,
perCardChoices, perCardChoices,
includeHidden: showHidden, includeHidden: showHidden,
appealTarget,
}); });
if (seq !== calcSeq) return; if (seq !== calcSeq) return;
if (!data) return; if (!data) return;
@@ -447,6 +502,7 @@ function selectProceeding(btn: HTMLButtonElement) {
void populateCourtPicker("court-picker-row", "court-picker", selectedType); void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows(); syncFlagRows();
syncAppellantRowVisibility(); syncAppellantRowVisibility();
syncAppealTargetRowVisibility();
setProceedingPickerCollapsed(true, proceedingDisplayName(btn)); setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
@@ -471,6 +527,23 @@ function syncAppellantRowVisibility() {
} }
} }
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
// syncAppealTargetRowVisibility shows the appeal-target chip group
// when the unified upc.apl Berufung tile is selected, hides it
// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears
// state + URL when hiding so a stale ?target= can't leak.
function syncAppealTargetRowVisibility() {
const row = document.getElementById("appeal-target-row");
if (!row) return;
const visible = hasAppealTarget(selectedType);
row.style.display = visible ? "" : "none";
if (!visible && currentAppealTarget !== "") {
currentAppealTarget = "";
writeAppealTargetToURL("");
syncRadioGroup("appeal-target", "endentscheidung");
}
}
function syncRadioGroup(name: string, value: string) { function syncRadioGroup(name: string, value: string) {
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => { document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
input.checked = input.value === value; input.checked = input.value === value;
@@ -655,8 +728,10 @@ function initViewToggle() {
function initPerspectiveControls() { function initPerspectiveControls() {
currentSide = readSideFromURL(); currentSide = readSideFromURL();
currentAppellant = readAppellantFromURL(); currentAppellant = readAppellantFromURL();
currentAppealTarget = readAppealTargetFromURL();
syncRadioGroup("side", currentSide ?? ""); syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? ""); syncRadioGroup("appellant", currentAppellant ?? "");
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
syncSideHintVisibility(); syncSideHintVisibility();
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => { document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
@@ -679,6 +754,23 @@ function initPerspectiveControls() {
if (lastResponse) renderResults(lastResponse); if (lastResponse) renderResults(lastResponse);
}); });
}); });
// Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
// Each chip change re-fetches with the new target slug so the
// timeline re-renders against the matching rule subset.
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appeal-target]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
if ((APPEAL_TARGETS as readonly string[]).includes(v)) {
currentAppealTarget = v as AppealTarget;
} else {
currentAppealTarget = "";
}
writeAppealTargetToURL(currentAppealTarget);
scheduleCalc(0);
});
});
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {

View File

@@ -195,6 +195,12 @@ export interface CalcParams {
// Sent only when the page-level "Ausgeblendete anzeigen" toggle is // Sent only when the page-level "Ausgeblendete anzeigen" toggle is
// ON. // ON.
includeHidden?: boolean; includeHidden?: boolean;
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
// (upc.apl) timeline to the rule subset whose applies_to_target
// contains the requested slug. Empty = no filter. Valid values:
// endentscheidung | kostenentscheidung | anordnung |
// schadensbemessung | bucheinsicht.
appealTarget?: string;
} }
const PARTY_CLASS: Record<string, string> = { const PARTY_CLASS: Record<string, string> = {
@@ -811,6 +817,7 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
? params.perCardChoices ? params.perCardChoices
: undefined, : undefined,
includeHidden: params.includeHidden ? true : undefined, includeHidden: params.includeHidden ? true : undefined,
appealTarget: params.appealTarget || undefined,
}), }),
}); });
if (!resp.ok) { if (!resp.ok) {

View File

@@ -1184,6 +1184,12 @@ export type I18nKey =
| "deadlines.adjusted.weekend" | "deadlines.adjusted.weekend"
| "deadlines.adjusted.weekend.saturday" | "deadlines.adjusted.weekend.saturday"
| "deadlines.adjusted.weekend.sunday" | "deadlines.adjusted.weekend.sunday"
| "deadlines.appeal_target.anordnung"
| "deadlines.appeal_target.bucheinsicht"
| "deadlines.appeal_target.endentscheidung"
| "deadlines.appeal_target.kostenentscheidung"
| "deadlines.appeal_target.label"
| "deadlines.appeal_target.schadensbemessung"
| "deadlines.appellant.claimant" | "deadlines.appellant.claimant"
| "deadlines.appellant.defendant" | "deadlines.appellant.defendant"
| "deadlines.appellant.label" | "deadlines.appellant.label"
@@ -1511,6 +1517,7 @@ export type I18nKey =
| "deadlines.trigger.label" | "deadlines.trigger.label"
| "deadlines.unavailable" | "deadlines.unavailable"
| "deadlines.upc" | "deadlines.upc"
| "deadlines.upc.apl"
| "deadlines.upc.apl.cost" | "deadlines.upc.apl.cost"
| "deadlines.upc.apl.merits" | "deadlines.upc.apl.merits"
| "deadlines.upc.apl.order" | "deadlines.upc.apl.order"

View File

@@ -28,16 +28,20 @@ function proceedingBtn(p: ProceedingDef): string {
); );
} }
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
// unified "Berufung" tile (upc.apl). After picking it, the user
// selects which decision the appeal is directed AT via the
// .appeal-target-row chip group below — the engine then filters
// rules whose applies_to_target contains the picked slug.
const UPC_TYPES: ProceedingDef[] = [ const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" }, { code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" }, { code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" }, { code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" }, { code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" }, { code: "upc.apl", i18nKey: "deadlines.upc.apl", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" }, { code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" }, { code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
]; ];
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's // DE proceedings split by type (Verletzung / Nichtigkeit) per m's
@@ -216,6 +220,36 @@ export function renderVerfahrensablauf(): string {
</button> </button>
</div> </div>
</div> </div>
{/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1).
Shown only when the unified upc.apl Berufung tile is
selected; lets the user narrow the timeline to the
rules whose applies_to_target contains the picked
decision kind. URL state ?target=<slug>. */}
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="endentscheidung" checked />
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="kostenentscheidung" />
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="anordnung" />
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="schadensbemessung" />
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="bucheinsicht" />
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none"> <div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span> <span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant"> <div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">

View File

@@ -0,0 +1,63 @@
-- 134_berufung_unification — DOWN
--
-- Reverses the Berufung unification: un-archives the 3 old appeal
-- proceeding_types, points the 16 rules back at their original
-- proceeding by their applies_to_target stamp, drops the new
-- upc.apl row, drops the two columns + their CHECK constraints.
--
-- The 3 old proceeding_types are recovered by code (we archived them,
-- never deleted them — that's what makes this down-migration safe).
-- ---------------------------------------------------------------
-- ---------------------------------------------------------------
-- 1. Un-archive the 3 old appeal proceeding_types.
-- ---------------------------------------------------------------
UPDATE paliad.proceeding_types
SET is_active = true,
updated_at = now()
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
-- ---------------------------------------------------------------
-- 2. Point rules back at their original proceeding_type by stamp.
-- ---------------------------------------------------------------
UPDATE paliad.deadline_rules dr
SET proceeding_type_id = (
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.merits'
)
WHERE dr.applies_to_target = ARRAY['endentscheidung']::text[];
UPDATE paliad.deadline_rules dr
SET proceeding_type_id = (
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.cost'
)
WHERE dr.applies_to_target = ARRAY['kostenentscheidung']::text[];
UPDATE paliad.deadline_rules dr
SET proceeding_type_id = (
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl.order'
)
WHERE dr.applies_to_target = ARRAY['anordnung']::text[];
-- ---------------------------------------------------------------
-- 3. Drop the unified upc.apl row (now orphaned).
-- ---------------------------------------------------------------
DELETE FROM paliad.proceeding_types WHERE code = 'upc.apl';
-- ---------------------------------------------------------------
-- 4. Drop the new columns + their CHECK constraints.
-- ---------------------------------------------------------------
ALTER TABLE paliad.deadline_rules
DROP CONSTRAINT IF EXISTS deadline_rules_applies_to_target_chk;
ALTER TABLE paliad.deadline_rules
DROP COLUMN IF EXISTS applies_to_target;
ALTER TABLE paliad.proceeding_types
DROP CONSTRAINT IF EXISTS proceeding_types_appeal_target_chk;
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS appeal_target;

View File

@@ -0,0 +1,263 @@
-- 134_berufung_unification — Slice B1, m/paliad#124, t-paliad-298+
--
-- Collapses the 3 active UPC appeal proceeding_types (upc.apl.merits,
-- upc.apl.cost, upc.apl.order — 16 rules across 3 codes) into ONE
-- unified upc.apl proceeding type + an `appeal_target` discriminator on
-- both proceeding_types (top-level marker) and deadline_rules
-- (per-row applies-to set, text[] for multi-target rules).
--
-- ADDITIVE ONLY. The migration:
-- 1. Adds the two columns + check constraints.
-- 2. Inserts the new upc.apl proceeding type.
-- 3. Audit-first: NOTICES every row about to be touched.
-- 4. Reassigns rule rows from the 3 old types to upc.apl, stamping
-- applies_to_target by source proceeding code.
-- 5. Archives (is_active=false) the 3 old proceeding_types — NEVER
-- deletes them, so any historical project_event_choices / FK
-- references stay intact.
--
-- Schadensbemessung + Bucheinsicht get NO rule rows in this migration
-- (m's 2026-05-26 decision: distinct rule sets, not shared with
-- merits). Their appeal_target enum values are defined and addressable
-- by CalcOptions.AppealTarget; the engine returns an empty timeline
-- until rules are seeded in a follow-up slice (likely via
-- /admin/rules, pairing with t-paliad-193 orphan-concept-seed).
--
-- See docs/design-litigation-planner-2026-05-26.md §18.1.
-- ---------------------------------------------------------------
-- 1. Schema additions
-- ---------------------------------------------------------------
ALTER TABLE paliad.proceeding_types
ADD COLUMN appeal_target text NULL;
ALTER TABLE paliad.proceeding_types
ADD CONSTRAINT proceeding_types_appeal_target_chk
CHECK (appeal_target IS NULL OR appeal_target IN (
'endentscheidung',
'kostenentscheidung',
'anordnung',
'schadensbemessung',
'bucheinsicht'
));
COMMENT ON COLUMN paliad.proceeding_types.appeal_target IS
'Top-level appeal-target marker. NULL on non-appeal proceedings. '
'Reserved for future variants — today only the unified upc.apl row '
'has this NULL (the actual per-rule target set lives on '
'paliad.deadline_rules.applies_to_target).';
ALTER TABLE paliad.deadline_rules
ADD COLUMN applies_to_target text[] NULL;
ALTER TABLE paliad.deadline_rules
ADD CONSTRAINT deadline_rules_applies_to_target_chk
CHECK (
applies_to_target IS NULL
OR applies_to_target <@ ARRAY[
'endentscheidung',
'kostenentscheidung',
'anordnung',
'schadensbemessung',
'bucheinsicht'
]::text[]
);
COMMENT ON COLUMN paliad.deadline_rules.applies_to_target IS
'Set of appeal_target slugs this rule applies to. NULL on rules '
'that don''t belong to an appeal proceeding. The engine filters '
'by CalcOptions.AppealTarget — rules whose applies_to_target '
'contains the requested slug are emitted; others are suppressed.';
-- ---------------------------------------------------------------
-- 2. Insert the unified upc.apl row.
--
-- Inherits default_color from the merits row (the most-used appeal
-- track today). sort_order follows the cluster of UPC proceedings;
-- placed just before upc.apl.merits's old slot so the chip-grouped
-- picker UI lands Berufung in a sensible position. Tweakable later
-- without a migration.
-- ---------------------------------------------------------------
INSERT INTO paliad.proceeding_types (
code, name, name_en, description, jurisdiction, category,
default_color, sort_order, is_active, display_order,
appeal_target
)
SELECT
'upc.apl',
'Berufungsverfahren',
'Appeal',
'Vereinheitlichtes Berufungsverfahren — wählen Sie anschließend, '
'worauf die Berufung sich richtet (Endentscheidung, '
'Kostenentscheidung, Anordnung, Schadensbemessung, Bucheinsicht).',
'UPC',
'fristenrechner',
default_color,
sort_order,
true,
display_order,
NULL
FROM paliad.proceeding_types
WHERE code = 'upc.apl.merits';
-- ---------------------------------------------------------------
-- 3. Audit-first RAISE NOTICE pass.
--
-- Lists every rule row that will be reassigned + every proceeding_type
-- row that will be archived. The migration runs to completion either
-- way; the operator reads the notices to confirm scope before the
-- next migration in the chain.
-- ---------------------------------------------------------------
DO $$
DECLARE
rec record;
upc_apl_id int;
rules_touched int := 0;
procs_archived int := 0;
BEGIN
SELECT id INTO upc_apl_id
FROM paliad.proceeding_types
WHERE code = 'upc.apl';
RAISE NOTICE '[mig 134] new upc.apl proceeding_type_id = %', upc_apl_id;
RAISE NOTICE '[mig 134] Rules to reassign to upc.apl with applies_to_target:';
FOR rec IN
SELECT dr.id AS rule_id,
pt.code AS old_proceeding,
dr.submission_code,
dr.name
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
AND dr.is_active = true
ORDER BY pt.code, dr.sequence_order
LOOP
RAISE NOTICE '[mig 134] % % % (%)',
rec.old_proceeding, rec.submission_code, rec.name, rec.rule_id;
rules_touched := rules_touched + 1;
END LOOP;
RAISE NOTICE '[mig 134] Total rules to reassign: %', rules_touched;
RAISE NOTICE '[mig 134] Proceeding_types to archive (is_active=false):';
FOR rec IN
SELECT id, code, name
FROM paliad.proceeding_types
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
ORDER BY sort_order
LOOP
RAISE NOTICE '[mig 134] % % (id=%)', rec.code, rec.name, rec.id;
procs_archived := procs_archived + 1;
END LOOP;
RAISE NOTICE '[mig 134] Total proceeding_types to archive: %', procs_archived;
END $$;
-- ---------------------------------------------------------------
-- 4. Reassign rule rows.
--
-- Stamp applies_to_target by source proceeding code, then point all
-- 16 rules at the new upc.apl row.
-- ---------------------------------------------------------------
-- 4a. upc.apl.merits → applies_to_target = {endentscheidung}
UPDATE paliad.deadline_rules dr
SET applies_to_target = ARRAY['endentscheidung']::text[]
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.code = 'upc.apl.merits'
AND dr.is_active = true;
-- 4b. upc.apl.cost → applies_to_target = {kostenentscheidung}
UPDATE paliad.deadline_rules dr
SET applies_to_target = ARRAY['kostenentscheidung']::text[]
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.code = 'upc.apl.cost'
AND dr.is_active = true;
-- 4c. upc.apl.order → applies_to_target = {anordnung}
UPDATE paliad.deadline_rules dr
SET applies_to_target = ARRAY['anordnung']::text[]
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.code = 'upc.apl.order'
AND dr.is_active = true;
-- 4d. Reassign all 16 rules to the new upc.apl proceeding_type row.
UPDATE paliad.deadline_rules dr
SET proceeding_type_id = (
SELECT id FROM paliad.proceeding_types WHERE code = 'upc.apl'
)
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND pt.code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
-- ---------------------------------------------------------------
-- 5. Archive the 3 old proceeding_types.
--
-- NEVER DELETE — historical project_event_choices and project FKs
-- (paliad.projects.proceeding_type_id) may still reference these IDs.
-- The is_active=false flag stops them appearing in the picker but
-- preserves FK integrity for historical reads.
-- ---------------------------------------------------------------
UPDATE paliad.proceeding_types
SET is_active = false,
updated_at = now()
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order');
-- ---------------------------------------------------------------
-- 6. Post-migration sanity check.
-- ---------------------------------------------------------------
DO $$
DECLARE
unified_count int;
archived_count int;
target_distribution record;
BEGIN
SELECT COUNT(*) INTO unified_count
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl' AND dr.is_active = true;
RAISE NOTICE '[mig 134] post: rules on unified upc.apl = % (expected 16)', unified_count;
IF unified_count <> 16 THEN
RAISE EXCEPTION '[mig 134] FAILED — expected 16 rules on upc.apl, got %', unified_count;
END IF;
SELECT COUNT(*) INTO archived_count
FROM paliad.proceeding_types
WHERE code IN ('upc.apl.merits', 'upc.apl.cost', 'upc.apl.order')
AND is_active = false;
RAISE NOTICE '[mig 134] post: archived old appeal proceeding_types = % (expected 3)', archived_count;
IF archived_count <> 3 THEN
RAISE EXCEPTION '[mig 134] FAILED — expected 3 archived types, got %', archived_count;
END IF;
FOR target_distribution IN
SELECT unnest(applies_to_target) AS target, COUNT(*) AS n
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE pt.code = 'upc.apl' AND dr.is_active = true
GROUP BY unnest(applies_to_target)
ORDER BY 1
LOOP
RAISE NOTICE '[mig 134] post: applies_to_target=% count=%',
target_distribution.target, target_distribution.n;
END LOOP;
END $$;
-- ---------------------------------------------------------------
-- TODO (follow-up slice, not in 134):
--
-- Seed rules for Schadensbemessung-as-appeal + Bucheinsicht-as-appeal.
-- m's 2026-05-26 decision: distinct rule sets, NOT shared with merits.
-- - Schadensbemessung: anchor on R.118.4 decision; conjecture 2/4-month
-- merits-style track but distinct legal basis.
-- - Bucheinsicht: 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
-- editorial pass via /admin/rules.
-- ---------------------------------------------------------------

View File

@@ -69,6 +69,14 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
// stay in the result list. Default false preserves the legacy // stay in the result list. Default false preserves the legacy
// suppression. HiddenCount on the response is independent. // suppression. HiddenCount on the response is independent.
IncludeHidden bool `json:"includeHidden,omitempty"` IncludeHidden bool `json:"includeHidden,omitempty"`
// Slice B1 / m/paliad#124 §18.1: narrows the unified UPC
// Berufung (upc.apl) timeline to the rule subset whose
// applies_to_target contains the requested slug. Empty = no
// filter. Valid values: endentscheidung | kostenentscheidung
// | anordnung | schadensbemessung | bucheinsicht. Unknown
// slugs are silently dropped (no filter) so a stale frontend
// chip doesn't 400 the request.
AppealTarget string `json:"appealTarget,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -116,6 +124,7 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
SkipRules: addendum.SkipRules, SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor, IncludeCCRFor: addendum.IncludeCCRFor,
IncludeHidden: req.IncludeHidden, IncludeHidden: req.IncludeHidden,
AppealTarget: req.AppealTarget,
}) })
if err != nil { if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) { if errors.Is(err, services.ErrUnknownProceedingType) {

View File

@@ -35,11 +35,12 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
created_at, updated_at, created_at, updated_at,
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr, trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
priority, is_court_set, lifecycle_state, draft_of, published_at, priority, is_court_set, lifecycle_state, draft_of, published_at,
choices_offered` choices_offered, applies_to_target`
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction, const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active, category, default_color, sort_order, is_active,
trigger_event_label_de, trigger_event_label_en` trigger_event_label_de, trigger_event_label_en,
appeal_target`
// List returns active rules, optionally filtered by proceeding type. // List returns active rules, optionally filtered by proceeding type.
// Each row has ConceptDefaultEventTypeID hydrated from // Each row has ConceptDefaultEventTypeID hydrated from

View File

@@ -126,6 +126,25 @@ func Calculate(
rules = ApplyRuleOverrides(rules, opts.RuleOverrides) rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
} }
// AppealTarget filter (Slice B1, m/paliad#124 §18.1). When set,
// keep only rules whose AppliesToTarget contains the requested
// slug. Unknown slugs short-circuit to no-op (defensive: a stale
// frontend chip shouldn't break the render). Empty AppliesToTarget
// on a rule means "doesn't belong to an appeal target" — such a
// rule is suppressed under any non-empty AppealTarget filter.
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
filtered := make([]Rule, 0, len(rules))
for _, r := range rules {
for _, t := range r.AppliesToTarget {
if t == opts.AppealTarget {
filtered = append(filtered, r)
break
}
}
}
rules = filtered
}
// ruleByID lets the conditional-rendering branches resolve a parent // ruleByID lets the conditional-rendering branches resolve a parent
// rule's display fields (submission_code, name, name_en) for the // rule's display fields (submission_code, name, name_en) for the
// "abhängig von <ParentRuleName>" chip without re-scanning the rules // "abhängig von <ParentRuleName>" chip without re-scanning the rules

View File

@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq"
) )
// NullableJSON is a jsonb column that may be NULL. json.RawMessage // NullableJSON is a jsonb column that may be NULL. json.RawMessage
@@ -149,6 +150,13 @@ type Rule struct {
// rule offers on the Verfahrensablauf timeline (mig 129, // rule offers on the Verfahrensablauf timeline (mig 129,
// t-paliad-265). NULL = no caret affordance (default). // t-paliad-265). NULL = no caret affordance (default).
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"` ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
// AppliesToTarget is the per-rule applies-to set for the unified
// UPC Berufung proceeding type (Slice B1, mig 134, m/paliad#124
// §18.1). Each element ∈ AppealTargets. NULL on rules outside
// the appeal proceeding. The engine filters by this when
// CalcOptions.AppealTarget is set.
AppliesToTarget pq.StringArray `db:"applies_to_target" json:"appliesToTarget,omitempty"`
} }
// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR // ProceedingType is one of the litigation conceptual codes (INF/REV/CCR
@@ -171,6 +179,12 @@ type ProceedingType struct {
// that fires when no rule has IsRootEvent=true. // that fires when no rule has IsRootEvent=true.
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"` TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"` TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
// AppealTarget is the top-level appeal-target marker (Slice B1, mig
// 134). NULL on non-appeal proceedings. Reserved for future variants
// — today the unified upc.apl row has this NULL (per-rule targets
// live on Rule.AppliesToTarget).
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
} }
// AdjustmentReason describes why a date was rolled forward / backward // AdjustmentReason describes why a date was rolled forward / backward
@@ -253,6 +267,14 @@ type CalcOptions struct {
IncludeHidden bool IncludeHidden bool
ProjectHint ProjectHint ProjectHint ProjectHint
// AppealTarget narrows the timeline to rules whose AppliesToTarget
// contains the requested slug. Empty = no filter. Set to one of
// AppealTargets for the unified UPC Berufung picker (Slice B1,
// m/paliad#124 §18.1). Unknown slugs are silently dropped (no
// filter applied) so a stale frontend chip doesn't break the
// timeline render — see IsValidAppealTarget.
AppealTarget string
} }
// ProjectHint scopes a Catalog call to a specific project. Paliad's // ProjectHint scopes a Catalog call to a specific project. Paliad's
@@ -426,3 +448,52 @@ var (
ErrUnknownProceedingType = errors.New("unknown proceeding type") ErrUnknownProceedingType = errors.New("unknown proceeding type")
ErrUnknownRule = errors.New("unknown rule") ErrUnknownRule = errors.New("unknown rule")
) )
// AppealTarget* are the canonical slugs for the unified UPC Berufung
// proceeding type's appeal-target discriminator (Slice B1, m/paliad#124
// §18.1). The verfahrensablauf picker renders one "Berufung" entry;
// the user then picks one of these five targets and the engine filters
// rules whose AppliesToTarget contains the requested slug.
//
// Schadensbemessung + Bucheinsicht have no rule rows in migration 134;
// per m's 2026-05-26 decision they are distinct from the merits track
// and their rule sets will be seeded in a follow-up slice (paired with
// t-paliad-193 orphan-concept-seed or editorial via /admin/rules).
// CalcOptions.AppealTarget="schadensbemessung" or "bucheinsicht"
// currently returns an empty timeline.
const (
AppealTargetEndentscheidung = "endentscheidung"
AppealTargetKostenentscheidung = "kostenentscheidung"
AppealTargetAnordnung = "anordnung"
AppealTargetSchadensbemessung = "schadensbemessung"
AppealTargetBucheinsicht = "bucheinsicht"
)
// AppealTargets is the canonical ordered list for UI chip rendering +
// validation. Order matches the design doc + the frontend's i18n key
// ordering — do not reorder without coordinating with the chip-group
// renderer.
var AppealTargets = []string{
AppealTargetEndentscheidung,
AppealTargetKostenentscheidung,
AppealTargetAnordnung,
AppealTargetSchadensbemessung,
AppealTargetBucheinsicht,
}
// IsValidAppealTarget returns true for empty (no filter requested) or
// any of the five canonical slugs. The engine uses this to gate the
// CalcOptions.AppealTarget filter — an unknown slug is silently
// dropped (no filter applied) rather than producing an error, so a
// stale frontend chip doesn't break the timeline render.
func IsValidAppealTarget(s string) bool {
if s == "" {
return true
}
for _, t := range AppealTargets {
if t == s {
return true
}
}
return false
}