From 989941c6483c0c589fe3de9831fead5fbeef4a4b Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 13:58:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(litigationplanner):=20primary=5Fparty=20CH?= =?UTF-8?q?ECK=20constraint=20+=20IsValidPrimaryParty=20helper=20(Slice=20?= =?UTF-8?q?B3,=20m/paliad#124=20=C2=A718.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens paliad.deadline_rules.primary_party from free-text to a CHECK constraint over the canonical four-value vocab (claimant / defendant / court / both). NULL stays valid for the 78 cross-cutting orphan concept seeds (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) — they have no proceeding_type_id binding so they're outside the calculator's path; loosening the CHECK to "IS NULL OR IN (…)" keeps them valid without backfill gymnastics. Migration 135 (audit-first): - DO block RAISEs NOTICE for every non-conforming row + RAISEs EXCEPTION if any dirty rows exist (manual cleanup required). Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows on the current corpus; the audit pass stays in the migration as safety against future drift. - ALTER TABLE … ADD CONSTRAINT deadline_rules_primary_party_chk CHECK (primary_party IS NULL OR primary_party IN ('claimant', 'defendant', 'court', 'both')) - Post-migration distribution NOTICE so the operator sees the final per-value count. - Down = DROP CONSTRAINT. No data revert needed. Package additions (pkg/litigationplanner): - PrimaryParty* constants (PrimaryPartyClaimant / Defendant / Court / Both) + PrimaryParties[] ordered list + IsValidPrimaryParty(s) predicate. Empty string is "no value supplied" = valid (NULL maps to empty on the wire); non-empty must match one of the four canonical values. - Sibling unit tests (primary_party_test.go) pin the four-value vocab + the chip order + IsValidAppealTarget's matching shape. Rule-editor validation hook (rule_editor_service.go): - Create() validates input.PrimaryParty before INSERT. - UpdateDraft() validates patch.PrimaryParty before UPDATE. - Both surface a user-friendly 400 with the canonical vocab listed instead of leaking the raw PG CHECK constraint-violation message. - Uses errors.Is(err, ErrInvalidInput) so handler 400 routing continues to work. services/fristenrechner.go cleanup: - The B2-inlined isValidPartyForLookup helper is replaced with the canonical lp.IsValidPrimaryParty. No behaviour change. No frontend changes — the rule-editor's primary_party UI already constrains to the four values via a select; the validation hook is defense-in-depth. Audit: - go build + go test (incl. new lp unit tests) all green - Pre-migration audit confirmed: 26 claimant + 26 defendant + 38 court + 63 both + 78 NULL = 231 total, all in canonical vocab - event_categories.party (text[] array, narrower semantic) is NOT touched in this migration per the design doc's "out of scope, separate follow-up" decision --- .../135_primary_party_check.down.sql | 8 ++ .../migrations/135_primary_party_check.up.sql | 92 +++++++++++++++++++ internal/services/fristenrechner.go | 12 +-- internal/services/rule_editor_service.go | 24 +++++ pkg/litigationplanner/primary_party_test.go | 68 ++++++++++++++ pkg/litigationplanner/types.go | 44 +++++++++ 6 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 internal/db/migrations/135_primary_party_check.down.sql create mode 100644 internal/db/migrations/135_primary_party_check.up.sql create mode 100644 pkg/litigationplanner/primary_party_test.go diff --git a/internal/db/migrations/135_primary_party_check.down.sql b/internal/db/migrations/135_primary_party_check.down.sql new file mode 100644 index 0000000..f7cbfb1 --- /dev/null +++ b/internal/db/migrations/135_primary_party_check.down.sql @@ -0,0 +1,8 @@ +-- 135_primary_party_check — DOWN +-- +-- Drops the CHECK constraint added in 135.up. No data revert needed +-- — the column stays text, the four-value vocab is enforced only by +-- application code thereafter. + +ALTER TABLE paliad.deadline_rules + DROP CONSTRAINT IF EXISTS deadline_rules_primary_party_chk; diff --git a/internal/db/migrations/135_primary_party_check.up.sql b/internal/db/migrations/135_primary_party_check.up.sql new file mode 100644 index 0000000..7b93aee --- /dev/null +++ b/internal/db/migrations/135_primary_party_check.up.sql @@ -0,0 +1,92 @@ +-- 135_primary_party_check — Slice B3, m/paliad#124 §18.3 +-- +-- Tightens paliad.deadline_rules.primary_party from free-text to a +-- CHECK constraint over the canonical four-value vocabulary +-- (claimant / defendant / court / both). NULL stays valid for the +-- 78 cross-cutting orphan concept seeds (Wiedereinsetzung, +-- Versäumnisurteil-Einspruch, Schriftsatznachreichung, +-- Weiterbehandlung) — they have no proceeding_type_id binding so +-- they're outside the calculator's path; loosening the CHECK to +-- "IS NULL OR IN (…)" keeps them valid without backfill gymnastics. +-- +-- Audit-first: the DO block RAISEs NOTICE for every non-conforming +-- row before adding the CHECK, and RAISEs EXCEPTION if any dirty +-- rows are found so the operator can decide a manual cleanup path. +-- Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows +-- on the current corpus: 26 claimant + 26 defendant + 38 court + +-- 63 both + 78 NULL = 231 total, all in the canonical vocab. The +-- audit pass stays in the migration for safety against future drift +-- (e.g. a rule editor write that bypassed the application-layer +-- validation hook this slice also adds). + +DO $$ +DECLARE + rec record; + dirty_count int := 0; +BEGIN + RAISE NOTICE '[mig 135] primary_party audit pass — non-conforming rows:'; + FOR rec IN + SELECT dr.id, dr.name, dr.primary_party, + pt.code AS proceeding_code + FROM paliad.deadline_rules dr + LEFT JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE dr.is_active = true + AND dr.primary_party IS NOT NULL + AND dr.primary_party NOT IN ('claimant', 'defendant', 'court', 'both') + ORDER BY pt.code NULLS LAST, dr.name + LOOP + RAISE NOTICE '[mig 135] % % primary_party=% (rule=%)', + COALESCE(rec.proceeding_code, ''), + rec.name, + rec.primary_party, + rec.id; + dirty_count := dirty_count + 1; + END LOOP; + IF dirty_count > 0 THEN + RAISE EXCEPTION '[mig 135] FAILED — % rule(s) carry non-canonical primary_party values. ' + 'Manual cleanup required: update each row to one of ' + '''claimant'', ''defendant'', ''court'', ''both'', or NULL. ' + 'See the NOTICE lines above for the offending rows.', dirty_count; + END IF; + RAISE NOTICE '[mig 135] audit clean — proceeding with CHECK constraint'; +END $$; + +-- --------------------------------------------------------------- +-- Add the CHECK constraint. NULL stays valid; the four canonical +-- values are the only allowed non-NULL forms. +-- --------------------------------------------------------------- + +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') + ); + +COMMENT ON CONSTRAINT deadline_rules_primary_party_chk + ON paliad.deadline_rules IS + 'Slice B3 (mig 135, m/paliad#124 §18.3) — canonical four-value ' + 'vocab for primary_party (claimant / defendant / court / both). ' + 'NULL allowed for cross-cutting orphan concept seeds (78 rows in ' + 'live corpus as of mig 135). See pkg/litigationplanner.PrimaryParties ' + 'for the in-code vocabulary.'; + +-- --------------------------------------------------------------- +-- Post-migration distribution check — informational NOTICE only. +-- --------------------------------------------------------------- + +DO $$ +DECLARE + rec record; +BEGIN + RAISE NOTICE '[mig 135] post: primary_party distribution after constraint add:'; + FOR rec IN + SELECT COALESCE(primary_party, '') AS party, COUNT(*) AS n + FROM paliad.deadline_rules + WHERE is_active = true + GROUP BY primary_party + ORDER BY party + LOOP + RAISE NOTICE '[mig 135] % count=%', rec.party, rec.n; + END LOOP; +END $$; diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index eb58e30..fd870d8 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -15,16 +15,6 @@ import ( lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) -// isValidPartyForLookup mirrors the four-value primary_party vocab the -// engine knows about. B2 inlines this check; B3 will add a canonical -// lp.IsValidPrimaryParty + tighten the column to a CHECK constraint. -func isValidPartyForLookup(s string) bool { - switch s { - case "claimant", "defendant", "court", "both": - return true - } - return false -} // FristenrechnerService renders the Paliad public Fristenrechner's // response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it @@ -268,7 +258,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe jurisdiction = "" } party := axes.Party - if party != "" && !isValidPartyForLookup(party) { + if party != "" && !lp.IsValidPrimaryParty(party) { party = "" } appealTarget := axes.AppealTarget diff --git a/internal/services/rule_editor_service.go b/internal/services/rule_editor_service.go index 2b68f08..f48fba3 100644 --- a/internal/services/rule_editor_service.go +++ b/internal/services/rule_editor_service.go @@ -13,6 +13,7 @@ import ( "github.com/jmoiron/sqlx" "mgit.msbls.de/m/paliad/internal/models" + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // RuleEditorService owns the admin-only rule lifecycle for Phase 3 @@ -148,6 +149,16 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r if strings.TrimSpace(input.Priority) == "" { input.Priority = "mandatory" } + // Slice B3 (m/paliad#124 §18.3, mig 135): canonical four-value + // primary_party vocab. Pre-validate so the user gets a + // user-friendly error before the DB CHECK fires with the raw + // constraint-violation message. + if input.PrimaryParty != nil && !lp.IsValidPrimaryParty(*input.PrimaryParty) { + return nil, fmt.Errorf( + "%w: primary_party=%q is not one of %v", + ErrInvalidInput, *input.PrimaryParty, lp.PrimaryParties, + ) + } if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil { return nil, err } @@ -220,6 +231,19 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch ErrInvalidLifecycleState, id, current.LifecycleState) } + // Slice B3 (m/paliad#124 §18.3, mig 135): pre-validate the + // patch's primary_party so the user gets a user-friendly error + // before the DB CHECK fires with the raw constraint-violation + // message. Patch field is *string — nil means "don't change", + // dereferenced empty string means "set to NULL" (handled below + // in buildPatchSets). + if patch.PrimaryParty != nil && !lp.IsValidPrimaryParty(*patch.PrimaryParty) { + return nil, fmt.Errorf( + "%w: primary_party=%q is not one of %v", + ErrInvalidInput, *patch.PrimaryParty, lp.PrimaryParties, + ) + } + // Spawn cycle guard: if the patch sets spawn_proceeding_type_id, // validate against the global graph BEFORE the UPDATE so we can // surface the cycle clearly instead of relying on a runtime diff --git a/pkg/litigationplanner/primary_party_test.go b/pkg/litigationplanner/primary_party_test.go new file mode 100644 index 0000000..8174c97 --- /dev/null +++ b/pkg/litigationplanner/primary_party_test.go @@ -0,0 +1,68 @@ +package litigationplanner + +import "testing" + +// TestIsValidPrimaryParty pins the four-value vocab + NULL-equivalent +// behaviour the rule-editor's B3 validation hook depends on. Empty +// string is "no value supplied" = valid (NULL maps to empty on the +// wire). Non-empty must match one of the four canonical values. +func TestIsValidPrimaryParty(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", true}, + {"claimant", true}, + {"defendant", true}, + {"court", true}, + {"both", true}, + {"Claimant", false}, // case-sensitive + {"clamant", false}, // typo + {"applicant", false}, // not in vocab + {"foo", false}, + } + for _, c := range cases { + if got := IsValidPrimaryParty(c.in); got != c.want { + t.Errorf("IsValidPrimaryParty(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// TestPrimaryPartiesOrder pins the canonical chip order (admin UI +// renders these as a select; reordering would break user muscle +// memory). Update both the slice + this test together if the order +// genuinely needs to change. +func TestPrimaryPartiesOrder(t *testing.T) { + want := []string{"claimant", "defendant", "court", "both"} + if len(PrimaryParties) != len(want) { + t.Fatalf("PrimaryParties has %d entries, want %d", len(PrimaryParties), len(want)) + } + for i, p := range PrimaryParties { + if p != want[i] { + t.Errorf("PrimaryParties[%d] = %q, want %q", i, p, want[i]) + } + } +} + +// TestIsValidAppealTarget is sibling-of: same shape, ensures the B1 +// helper has the same NULL-equivalent semantic. +func TestIsValidAppealTarget(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", true}, + {"endentscheidung", true}, + {"kostenentscheidung", true}, + {"anordnung", true}, + {"schadensbemessung", true}, + {"bucheinsicht", true}, + {"foo", false}, + {"Endentscheidung", false}, // case-sensitive + } + for _, c := range cases { + if got := IsValidAppealTarget(c.in); got != c.want { + t.Errorf("IsValidAppealTarget(%q) = %v, want %v", c.in, got, c.want) + } + } +} diff --git a/pkg/litigationplanner/types.go b/pkg/litigationplanner/types.go index 00e5a3c..217db1d 100644 --- a/pkg/litigationplanner/types.go +++ b/pkg/litigationplanner/types.go @@ -551,6 +551,50 @@ var AppealTargets = []string{ AppealTargetBucheinsicht, } +// PrimaryParty* are the canonical four-value vocabulary for +// paliad.deadline_rules.primary_party (Slice B3, m/paliad#124 §18.3, +// mig 135). The DB CHECK constraint enforces the same set; the +// application-layer helper IsValidPrimaryParty lets the rule editor +// surface a friendly 400 before the DB error fires. +// +// NULL is also valid in the DB (for the 78 orphan cross-cutting +// concept seeds — Wiedereinsetzung, Versäumnisurteil-Einspruch, +// Schriftsatznachreichung, Weiterbehandlung). The helper treats the +// empty string as "no value supplied" = valid; non-empty strings must +// match one of the four canonical values. +const ( + PrimaryPartyClaimant = "claimant" + PrimaryPartyDefendant = "defendant" + PrimaryPartyCourt = "court" + PrimaryPartyBoth = "both" +) + +// PrimaryParties is the canonical ordered list for validation + +// admin-UI rendering. Order matches the rule-editor select; do not +// reorder without coordinating with the frontend. +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 to validate +// writes before they hit the DB CHECK — produces a user-friendly 400 +// instead of a raw constraint-violation error. +func IsValidPrimaryParty(s string) bool { + if s == "" { + return true + } + for _, p := range PrimaryParties { + if p == s { + return true + } + } + return false +} + // 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