feat(litigationplanner): primary_party CHECK constraint + IsValidPrimaryParty helper (Slice B3, m/paliad#124 §18.3)
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

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
This commit is contained in:
mAi
2026-05-26 13:58:33 +02:00
parent db8e8ba6fd
commit 989941c648
6 changed files with 237 additions and 11 deletions

View File

@@ -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;

View File

@@ -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, '<orphan>'),
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, '<NULL>') 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 $$;

View File

@@ -15,16 +15,6 @@ import (
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" 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 // FristenrechnerService renders the Paliad public Fristenrechner's
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it // 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 = "" jurisdiction = ""
} }
party := axes.Party party := axes.Party
if party != "" && !isValidPartyForLookup(party) { if party != "" && !lp.IsValidPrimaryParty(party) {
party = "" party = ""
} }
appealTarget := axes.AppealTarget appealTarget := axes.AppealTarget

View File

@@ -13,6 +13,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models" "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 // 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) == "" { if strings.TrimSpace(input.Priority) == "" {
input.Priority = "mandatory" 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 { if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil {
return nil, err return nil, err
} }
@@ -220,6 +231,19 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
ErrInvalidLifecycleState, id, current.LifecycleState) 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, // Spawn cycle guard: if the patch sets spawn_proceeding_type_id,
// validate against the global graph BEFORE the UPDATE so we can // validate against the global graph BEFORE the UPDATE so we can
// surface the cycle clearly instead of relying on a runtime // surface the cycle clearly instead of relying on a runtime

View File

@@ -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)
}
}
}

View File

@@ -551,6 +551,50 @@ var AppealTargets = []string{
AppealTargetBucheinsicht, 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 // IsValidAppealTarget returns true for empty (no filter requested) or
// any of the five canonical slugs. The engine uses this to gate the // any of the five canonical slugs. The engine uses this to gate the
// CalcOptions.AppealTarget filter — an unknown slug is silently // CalcOptions.AppealTarget filter — an unknown slug is silently