From 5cff38ff3cf86e2556092505e795390b2845dd6c Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 15:40:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(deadlines):=20mig=20138=20backfill=20appli?= =?UTF-8?q?es=5Fto=5Ftarget=20=E2=80=94=20Schadensbemessung=20(merits)=20+?= =?UTF-8?q?=20Bucheinsicht=20(order)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Slice B1's Berufung unification (mig 134), the picker exposed five appeal targets but only three carried rules. Schadensbemessung and Bucheinsicht returned empty timelines. m's 2026-05-26 decision (#134): R.224 is uniform across substantive R.118 decisions, and R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are uniform across the orders they appeal — so the existing merits-track and order-track rules can carry the missing targets via a non-destructive applies_to_target extension. Audit of live `paliad.deadline_rules` for upc.apl.unified (proceeding_type_id=160): - 7 endentscheidung rules → extend with 'schadensbemessung' - 7 anordnung rules → extend with 'bucheinsicht' - 2 kostenentscheidung rules — untouched (distinct leave-to-appeal track) Migration: - set_config('paliad.audit_reason', …) at top of UP and DOWN — required by the mig 079 deadline_rule_audit_trigger on every UPDATE. - Audit-first DO block lists every row to be touched (pre/post state) and RAISE EXCEPTIONs on pre-condition drift (missing proceeding_type, wrong rule counts, partial-run carry-over of the new targets). - Two narrow UPDATEs keyed off upc.apl.unified + existing target + absence of new target. - Post-sanity asserts schad=7, buch=7, end=7, anord=7, cost=2 — hard RAISE EXCEPTION on any drift. - DOWN strips both new targets via array_remove with the same WHERE. - No deadline_rules.updated_at writes; column exists but the migration is single-purpose and leaves it as-is. Dry-run via Supabase MCP confirmed: - UP yields {schad:7, buch:7, end:7, anord:7, cost:2} on prod. - DOWN restores {schad:0, buch:0, end:7, anord:7, cost:2}. - DB returned to pre-state; the real golang-migrate boot path will apply 138 cleanly at next deploy. Version bump 137→138: cronus's mig 137 (proceeding_role_labels, #132) merged to main while this branch was in flight. Rebased onto current main, renamed files, rewrote all "mig 137" references inside the SQL + test code. Test: - lookup_events_test.go: the schadensbemessung empty-result assertion becomes the inverse (rules expected). Adds a parallel bucheinsicht assertion. Same anchor-row shape check as the existing endentscheidung case (DepthFromAnchor=1, target ∈ AppliesToTarget, proceeding_type = upc.apl.unified). - `go test ./...` green post-rebase, including pkg/litigationplanner/ appeal_target_label_test.go added by cronus's mig 137. Refs: m/paliad#134, t-paliad-303. Lessons applied from mig 134 hotfixes: audit_reason set_config, no updated_at writes, audit live DB before drafting, RAISE EXCEPTION on integrity violations. --- ...peal_target_backfill_merits_order.down.sql | 71 ++++++ ...appeal_target_backfill_merits_order.up.sql | 232 ++++++++++++++++++ internal/services/lookup_events_test.go | 66 ++++- 3 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 internal/db/migrations/138_appeal_target_backfill_merits_order.down.sql create mode 100644 internal/db/migrations/138_appeal_target_backfill_merits_order.up.sql diff --git a/internal/db/migrations/138_appeal_target_backfill_merits_order.down.sql b/internal/db/migrations/138_appeal_target_backfill_merits_order.down.sql new file mode 100644 index 0000000..cbb4275 --- /dev/null +++ b/internal/db/migrations/138_appeal_target_backfill_merits_order.down.sql @@ -0,0 +1,71 @@ +-- 138_appeal_target_backfill_merits_order DOWN — t-paliad-303, m/paliad#134 +-- +-- Removes 'schadensbemessung' from the merits-track rules and +-- 'bucheinsicht' from the order-track rules, restoring the pre-137 +-- shape (endentscheidung-only / anordnung-only / kostenentscheidung-only). + +-- --------------------------------------------------------------- +-- 0. Audit reason (required by mig 079 trigger for any UPDATE on +-- paliad.deadline_rules). +-- --------------------------------------------------------------- + +SELECT set_config( + 'paliad.audit_reason', + 'mig 138 DOWN: t-paliad-303 — strip Schadensbemessung/Bucheinsicht from applies_to_target per m/paliad#134', + true); + +-- --------------------------------------------------------------- +-- 1. Strip new targets via array_remove. +-- +-- WHERE clauses pinned to upc.apl.unified to avoid touching unrelated +-- rules that might have been added later under other proceeding types. +-- --------------------------------------------------------------- + +-- 1a. Remove schadensbemessung from merits-track rows. +UPDATE paliad.deadline_rules dr + SET applies_to_target = array_remove(dr.applies_to_target, 'schadensbemessung') + FROM paliad.proceeding_types pt + WHERE pt.id = dr.proceeding_type_id + AND pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'schadensbemessung' = ANY(dr.applies_to_target); + +-- 1b. Remove bucheinsicht from order-track rows. +UPDATE paliad.deadline_rules dr + SET applies_to_target = array_remove(dr.applies_to_target, 'bucheinsicht') + FROM paliad.proceeding_types pt + WHERE pt.id = dr.proceeding_type_id + AND pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'bucheinsicht' = ANY(dr.applies_to_target); + +-- --------------------------------------------------------------- +-- 2. Sanity check — no row may carry the new targets after the down. +-- --------------------------------------------------------------- + +DO $$ +DECLARE + schad_left int; + buch_left int; +BEGIN + SELECT COUNT(*) INTO schad_left + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'schadensbemessung' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO buch_left + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'bucheinsicht' = ANY(dr.applies_to_target); + + IF schad_left > 0 THEN + RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry schadensbemessung', schad_left; + END IF; + IF buch_left > 0 THEN + RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry bucheinsicht', buch_left; + END IF; + RAISE NOTICE '[mig 138 DOWN] stripped schadensbemessung + bucheinsicht from upc.apl.unified rules'; +END $$; diff --git a/internal/db/migrations/138_appeal_target_backfill_merits_order.up.sql b/internal/db/migrations/138_appeal_target_backfill_merits_order.up.sql new file mode 100644 index 0000000..d6cb811 --- /dev/null +++ b/internal/db/migrations/138_appeal_target_backfill_merits_order.up.sql @@ -0,0 +1,232 @@ +-- 138_appeal_target_backfill_merits_order — t-paliad-303, m/paliad#134 +-- +-- Slice B1 (mig 134) introduced the unified upc.apl.unified proceeding type +-- with 5 appeal_target enum values: endentscheidung, kostenentscheidung, +-- anordnung, schadensbemessung, bucheinsicht. The first three each carry +-- rules; schadensbemessung and bucheinsicht returned an empty timeline +-- because no rules referenced them yet. +-- +-- m's 2026-05-26 decision (#134): extend applies_to_target on the existing +-- rules — Schadensbemessung := merits track (R.224 anchored on R.118 +-- substantive decisions), Bucheinsicht := order track (R.220.2 + +-- R.224.2.b + R.235.2 + R.237 + R.238.2 etc.). Legal premise verified +-- against the 16 live rules — every endentscheidung rule is a generic +-- R.224 merits step, every anordnung rule is a generic R.220/224/235/237/ +-- 238 order step. No rule carries content specific to a particular kind +-- of underlying decision/order. Audit on the comment trail of #134. + +-- --------------------------------------------------------------- +-- 0. Audit reason (required by mig 079 trigger for any UPDATE on +-- paliad.deadline_rules — both UPDATEs below trigger it). +-- --------------------------------------------------------------- + +SELECT set_config( + 'paliad.audit_reason', + 'mig 138: t-paliad-303 — extend applies_to_target for Schadensbemessung (merits) + Bucheinsicht (order) per m/paliad#134', + true); + +-- --------------------------------------------------------------- +-- 1. Audit-first DO block. +-- +-- Resolve upc.apl.unified, count the rows we are about to touch, and +-- RAISE EXCEPTION if anything looks wrong (proceeding type missing, +-- merits/order rule counts off, or a rule already carries the new +-- target — which would mean an earlier partial run). +-- --------------------------------------------------------------- + +DO $$ +DECLARE + rec record; + upc_apl_id int; + merits_count int; + order_count int; + schad_already int; + buch_already int; +BEGIN + SELECT id INTO upc_apl_id + FROM paliad.proceeding_types + WHERE code = 'upc.apl.unified'; + IF upc_apl_id IS NULL THEN + RAISE EXCEPTION '[mig 138] upc.apl.unified proceeding_type not found — mig 134 must run first'; + END IF; + RAISE NOTICE '[mig 138] upc.apl.unified proceeding_type_id = %', upc_apl_id; + + SELECT COUNT(*) INTO merits_count + FROM paliad.deadline_rules + WHERE proceeding_type_id = upc_apl_id + AND is_active = true + AND 'endentscheidung' = ANY(applies_to_target); + SELECT COUNT(*) INTO order_count + FROM paliad.deadline_rules + WHERE proceeding_type_id = upc_apl_id + AND is_active = true + AND 'anordnung' = ANY(applies_to_target); + + RAISE NOTICE '[mig 138] live counts: endentscheidung=% anordnung=%', merits_count, order_count; + IF merits_count <> 7 THEN + RAISE EXCEPTION '[mig 138] expected 7 endentscheidung rules under upc.apl.unified, got %', merits_count; + END IF; + IF order_count <> 7 THEN + RAISE EXCEPTION '[mig 138] expected 7 anordnung rules under upc.apl.unified, got %', order_count; + END IF; + + SELECT COUNT(*) INTO schad_already + FROM paliad.deadline_rules + WHERE proceeding_type_id = upc_apl_id + AND is_active = true + AND 'schadensbemessung' = ANY(applies_to_target); + SELECT COUNT(*) INTO buch_already + FROM paliad.deadline_rules + WHERE proceeding_type_id = upc_apl_id + AND is_active = true + AND 'bucheinsicht' = ANY(applies_to_target); + IF schad_already > 0 THEN + RAISE EXCEPTION '[mig 138] % rules already carry schadensbemessung — partial run?', schad_already; + END IF; + IF buch_already > 0 THEN + RAISE EXCEPTION '[mig 138] % rules already carry bucheinsicht — partial run?', buch_already; + END IF; + + RAISE NOTICE '[mig 138] rules to extend with schadensbemessung (merits track):'; + FOR rec IN + SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target + FROM paliad.deadline_rules dr + WHERE dr.proceeding_type_id = upc_apl_id + AND dr.is_active = true + AND 'endentscheidung' = ANY(dr.applies_to_target) + ORDER BY dr.sequence_order, dr.rule_code NULLS LAST + LOOP + RAISE NOTICE '[mig 138] merits % % % pre=% → post=%', + COALESCE(rec.rule_code, '(no-code)'), + COALESCE(rec.legal_source, '(no-source)'), + rec.name, + rec.applies_to_target, + rec.applies_to_target || 'schadensbemessung'::text; + END LOOP; + + RAISE NOTICE '[mig 138] rules to extend with bucheinsicht (order track):'; + FOR rec IN + SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target + FROM paliad.deadline_rules dr + WHERE dr.proceeding_type_id = upc_apl_id + AND dr.is_active = true + AND 'anordnung' = ANY(dr.applies_to_target) + ORDER BY dr.sequence_order, dr.rule_code NULLS LAST + LOOP + RAISE NOTICE '[mig 138] order % % % pre=% → post=%', + COALESCE(rec.rule_code, '(no-code)'), + COALESCE(rec.legal_source, '(no-source)'), + rec.name, + rec.applies_to_target, + rec.applies_to_target || 'bucheinsicht'::text; + END LOOP; +END $$; + +-- --------------------------------------------------------------- +-- 2. Extend applies_to_target. +-- +-- Narrow WHERE clauses key off upc.apl.unified + existing target + +-- absence of new target, so the UPDATEs are idempotent in spirit +-- (the audit block above already RAISE EXCEPTIONed if any row +-- already had the new value). +-- --------------------------------------------------------------- + +-- 2a. Schadensbemessung := merits track (7 rules expected). +UPDATE paliad.deadline_rules dr + SET applies_to_target = applies_to_target || 'schadensbemessung'::text + FROM paliad.proceeding_types pt + WHERE pt.id = dr.proceeding_type_id + AND pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'endentscheidung' = ANY(dr.applies_to_target) + AND NOT ('schadensbemessung' = ANY(dr.applies_to_target)); + +-- 2b. Bucheinsicht := order track (7 rules expected). +UPDATE paliad.deadline_rules dr + SET applies_to_target = applies_to_target || 'bucheinsicht'::text + FROM paliad.proceeding_types pt + WHERE pt.id = dr.proceeding_type_id + AND pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'anordnung' = ANY(dr.applies_to_target) + AND NOT ('bucheinsicht' = ANY(dr.applies_to_target)); + +-- --------------------------------------------------------------- +-- 3. Post-migration sanity check. +-- +-- Hard-fail on any divergence: the two new targets must each cover +-- 7 rules, the original three targets must be unchanged in count, +-- and no rule has lost its prior target. +-- --------------------------------------------------------------- + +DO $$ +DECLARE + schad_post int; + buch_post int; + end_post int; + anord_post int; + cost_post int; + target_distribution record; +BEGIN + SELECT COUNT(*) INTO schad_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'schadensbemessung' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO buch_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'bucheinsicht' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO end_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'endentscheidung' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO anord_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'anordnung' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO cost_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'kostenentscheidung' = ANY(dr.applies_to_target); + + RAISE NOTICE '[mig 138] post: schadensbemessung=% bucheinsicht=% endentscheidung=% anordnung=% kostenentscheidung=%', + schad_post, buch_post, end_post, anord_post, cost_post; + + IF schad_post <> 7 THEN + RAISE EXCEPTION '[mig 138] FAILED — expected 7 schadensbemessung rules, got %', schad_post; + END IF; + IF buch_post <> 7 THEN + RAISE EXCEPTION '[mig 138] FAILED — expected 7 bucheinsicht rules, got %', buch_post; + END IF; + IF end_post <> 7 THEN + RAISE EXCEPTION '[mig 138] FAILED — endentscheidung count drifted: expected 7, got %', end_post; + END IF; + IF anord_post <> 7 THEN + RAISE EXCEPTION '[mig 138] FAILED — anordnung count drifted: expected 7, got %', anord_post; + END IF; + IF cost_post <> 2 THEN + RAISE EXCEPTION '[mig 138] FAILED — kostenentscheidung count drifted: expected 2, got %', cost_post; + 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.unified' AND dr.is_active = true + GROUP BY unnest(applies_to_target) + ORDER BY 1 + LOOP + RAISE NOTICE '[mig 138] post: applies_to_target=% count=%', + target_distribution.target, target_distribution.n; + END LOOP; +END $$; diff --git a/internal/services/lookup_events_test.go b/internal/services/lookup_events_test.go index 5c66aa5..a4f1dd3 100644 --- a/internal/services/lookup_events_test.go +++ b/internal/services/lookup_events_test.go @@ -144,7 +144,7 @@ func TestLookupEvents(t *testing.T) { } }) - t.Run("appeal_target=schadensbemessung returns empty (no rules seeded yet)", func(t *testing.T) { + t.Run("appeal_target=schadensbemessung returns upc.apl merits rules (mig 138 backfill)", func(t *testing.T) { matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{ Jurisdiction: "UPC", AppealTarget: lp.AppealTargetSchadensbemessung, @@ -152,8 +152,68 @@ func TestLookupEvents(t *testing.T) { if err != nil { t.Fatalf("LookupEvents: %v", err) } - if len(matches) != 0 { - t.Errorf("schadensbemessung should be empty until rules seeded; got %d rows", len(matches)) + // mig 138 (t-paliad-303, m/paliad#134) extends the 7 merits-track + // rules under upc.apl.unified with applies_to_target ⊇ {schadensbemessung} + // because R.224 is uniform across substantive R.118 decisions. + if len(matches) == 0 { + t.Fatal("expected upc.apl schadensbemessung rules after mig 138 backfill") + } + for _, m := range matches { + if m.DepthFromAnchor != 1 { + continue + } + found := false + for _, t := range m.Rule.AppliesToTarget { + if t == lp.AppealTargetSchadensbemessung { + found = true + break + } + } + if !found { + t.Errorf("anchor row %s missing schadensbemessung target: %v", + m.Rule.Name, m.Rule.AppliesToTarget) + } + if m.ProceedingType.Code != "upc.apl.unified" { + t.Errorf("anchor row %s came from %s, want upc.apl.unified", + m.Rule.Name, m.ProceedingType.Code) + } + } + }) + + t.Run("appeal_target=bucheinsicht returns upc.apl order rules (mig 138 backfill)", func(t *testing.T) { + matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{ + Jurisdiction: "UPC", + AppealTarget: lp.AppealTargetBucheinsicht, + }, lp.EventLookupDepthAllFollowing) + if err != nil { + t.Fatalf("LookupEvents: %v", err) + } + // mig 138 (t-paliad-303, m/paliad#134) extends the 7 order-track + // rules under upc.apl.unified with applies_to_target ⊇ {bucheinsicht} + // because R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are + // uniform across the orders they appeal. + if len(matches) == 0 { + t.Fatal("expected upc.apl bucheinsicht rules after mig 138 backfill") + } + for _, m := range matches { + if m.DepthFromAnchor != 1 { + continue + } + found := false + for _, t := range m.Rule.AppliesToTarget { + if t == lp.AppealTargetBucheinsicht { + found = true + break + } + } + if !found { + t.Errorf("anchor row %s missing bucheinsicht target: %v", + m.Rule.Name, m.Rule.AppliesToTarget) + } + if m.ProceedingType.Code != "upc.apl.unified" { + t.Errorf("anchor row %s came from %s, want upc.apl.unified", + m.Rule.Name, m.ProceedingType.Code) + } } }) }