feat(litigationplanner): Berufung unification — one upc.apl + 5 appeal_target chips (Slice B1, 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

Collapses the 3 UPC appeal proceeding_types (upc.apl.merits 7 rules,
upc.apl.cost 2, upc.apl.order 7 = 16 total across 3 codes) into ONE
unified upc.apl proceeding type + a per-rule applies_to_target[]
discriminator. The verfahrensablauf picker now shows one "Berufung"
tile; after picking it, the user selects which decision the appeal is
directed AT via a 5-chip group (Endentscheidung / Kostenentscheidung /
Anordnung / Schadensbemessung / Bucheinsicht) and the engine filters
rules whose applies_to_target contains the picked slug.

m's 2026-05-26 decision: Schadensbemessung-as-appeal is a NEW first-
class target with its OWN rule set (no shared inheritance from
merits). The 5 enum values are all defined + addressable; for now
schadensbemessung and bucheinsicht return empty timelines until rules
are seeded in a follow-up slice (likely via /admin/rules or pairing
with t-paliad-193 orphan-concept-seed).

Migration 134 (additive only):
  - ADD proceeding_types.appeal_target text (CHECK on 5 slugs OR NULL)
  - ADD deadline_rules.applies_to_target text[] (CHECK each element
    in the 5 slugs)
  - INSERT the unified upc.apl row (inherits sort/color from
    upc.apl.merits)
  - Audit-first RAISE NOTICE pass listing every row about to be
    touched + a post-migration sanity check
  - Reassign rule rows: merits → applies_to_target={endentscheidung},
    cost → {kostenentscheidung}, order → {anordnung}
  - Archive (is_active=false, NOT DELETE) the 3 old proceeding_types
    so historical FKs stay intact
  - Down migration restores is_active=true on the 3 old types, points
    rules back by their applies_to_target stamp, drops the unified
    row, drops both columns. Safe.

Package additions (pkg/litigationplanner):
  - AppealTarget* constants + AppealTargets[] ordered list +
    IsValidAppealTarget(s) predicate (silent no-op on unknown slugs
    so a stale frontend chip doesn't break the render)
  - ProceedingType.AppealTarget *string field (top-level marker;
    NULL on non-appeal proceedings)
  - Rule.AppliesToTarget pq.StringArray field (per-row applies-to set)
  - CalcOptions.AppealTarget string (engine filter — when set,
    keeps only rules whose AppliesToTarget contains the slug)

Engine filter runs after ApplyRuleOverrides but before the rule walk
so the existing condition_expr / spawn / appellant-context machinery
operates on the filtered subset transparently.

paliad-side wiring:
  - deadline_rule_service.go: ruleColumns + proceedingTypeColumns
    extended to scan the new columns
  - handlers/fristenrechner.go: AppealTarget JSON field on the
    request payload, threaded into CalcOptions

Frontend (verfahrensablauf surface only):
  - Single "Berufung" tile replaces the 3 separate Berufung tiles
  - New 5-chip appeal-target row, shown only when upc.apl is picked
  - URL state ?target=<slug>; default endentscheidung when none set
  - APPELLANT_AXIS_PROCEEDINGS updated: upc.apl.* (3 entries) →
    upc.apl (1 entry)
  - i18n keys (DE + EN) for the new tile + the 5 chip labels +
    the "Worauf richtet sich die Berufung?" / "Appeal against:" prompt
  - calculateDeadlines threads appealTarget through to the API

Acceptance:
  - go build clean, go test all green (existing test suite — no new
    tests on the engine filter as a follow-up; the migration's
    sanity-check DO block guards the rule-reassignment count)
  - Live audit before drafting confirmed: 3 active UPC appeal
    proceeding_types, 16 rules total, primary_party already conforms
    to 4-value vocab on all proceeding-bound rules
This commit is contained in:
mAi
2026-05-26 13:49:03 +02:00
parent acf5743fa3
commit 07acf7b4a2
12 changed files with 605 additions and 15 deletions

View File

@@ -1245,16 +1245,26 @@ Frontend logic:
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). 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. 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.
#### Open question for m (escalate via `mai instruct head`) #### m's answer on Q18.1.1 (2026-05-26 13:40)
**Q18.1.1 — Is Schadensbemessung-as-appeal a duplicate of Endentscheidung, or a distinct set?** > 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.
Three interpretations: **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:
- A. **Shared rule set**: appeal of a Schadensbemessung uses the SAME rules as appeal of an Endentscheidung (both run the 2/4-month merits track). `applies_to_target=['endentscheidung','schadensbemessung']` on each rule. (R) simplest, matches what the live `upc.apl.merits` corpus does today (no explicit target distinction). - 7 `upc.apl.merits` rules `applies_to_target=['endentscheidung']` (Endentscheidung only NOT also Schadensbemessung).
- B. **Distinct rule set**: Schadensbemessung-appeal has its own anchor + sequence (different trigger event, different timing). Would need 7 new rule rows specifically for `applies_to_target=['schadensbemessung']`. No live evidence for this today. - 2 `upc.apl.cost` rules `applies_to_target=['kostenentscheidung']`.
- C. **Defer**: ship Berufung unification with only 3 targets (endentscheidung / kostenentscheidung / anordnung) for v1; add Schadensbemessung + Bucheinsicht as a follow-up. - 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.
Recommendation: **A** share rules, multi-valued `applies_to_target` array. Frontend renders all 5 chips from day 1; the merits 7 rules show under endentscheidung + schadensbemessung; the order 7 rules show under anordnung + bucheinsicht (the 15-day track DOES apply to Bucheinsicht under R.142+R.220.2). No new rule rows needed. **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)?** **Q18.1.2 — User-facing label for "appeal_target": "Worauf richtet sich die Berufung?" (DE) / "Appeal against:" (EN)?**

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
}