fix(deadline-rules): pick rule's jurisdiction-aware event_type default

m's 2026-05-08 22:08 dogfood: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung'
(DE) auto-filled to 'Klageerwiderung' label but the chosen event_type was
upc_statement_of_defence (UPC). Both render as 'Klageerwiderung' in the
UI, but they are different legal events in different jurisdictions.

Migration 074 adds a jurisdiction column to
paliad.deadline_concept_event_types and swaps the unique-default index
from per-concept to per-(concept, jurisdiction). Backfills jurisdiction
from each event_type's own column, then re-elects DE / DPMA / EPO
defaults where a non-UPC event_type genuinely exists. Idempotent: uses
ADD COLUMN IF NOT EXISTS, ON CONFLICT DO UPDATE, partial unique index.

DeadlineRuleService.hydrateConceptDefaultEventTypes now JOINs
paliad.proceeding_types and matches on (rule.concept, rule.jurisdiction)
with EPA→EPO canonicalisation. Rules whose (concept, jurisdiction) has
no default stay NULL — silent no-op on the form, better than a wrong
jurisdictional default. UPC rules unchanged; DE rules now resolve to
de_klageerwiderung when concept = statement-of-defence, else no autofill.

Live audit confirms: every active rule now resolves to a same-
jurisdiction event_type or no event_type at all. No more cross-
jurisdiction matches in the seed.
This commit is contained in:
m
2026-05-08 22:16:55 +02:00
parent 52caba51ec
commit 6058d21ce6
3 changed files with 187 additions and 22 deletions

View File

@@ -0,0 +1,13 @@
-- t-paliad-165 follow-up down: remove jurisdiction column + restore the
-- old one-default-per-concept index. The added jurisdictional default
-- rows are kept (harmless without the index), but this isn't an
-- expected operation in production.
DROP INDEX IF EXISTS paliad.deadline_concept_event_types_one_default_per_jur;
ALTER TABLE paliad.deadline_concept_event_types
DROP COLUMN IF EXISTS jurisdiction;
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default
ON paliad.deadline_concept_event_types (concept_id)
WHERE is_default = true;

View File

@@ -0,0 +1,143 @@
-- t-paliad-165 follow-up (m's 2026-05-08 22:08 dogfood): add jurisdiction
-- to paliad.deadline_concept_event_types so DE rules don't auto-fill a
-- UPC event_type and vice versa.
--
-- Bug being fixed
-- ---------------
-- m: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung' (DE) auto-filled to
-- 'Klageerwiderung' but the chosen event_type was upc_statement_of_defence
-- (UPC). Both render as 'Klageerwiderung' in the UI, but they are
-- different legal events in different jurisdictions — the auto-link is
-- technically wrong even though the label looks right.
--
-- Root cause
-- ----------
-- Migration 073 made the junction one-default-per-concept. The same
-- legal concept ('statement-of-defence' = Klageerwiderung) has several
-- jurisdictional flavours (upc_statement_of_defence, de_klageerwiderung,
-- DPMA Erwiderung, EPA Patentinhaber-Erwiderung). The default was
-- jurisdiction-blind — it always picked the UPC variant.
--
-- Fix
-- ---
-- 1. Add a jurisdiction text column to the junction.
-- 2. Backfill from each event_type's own jurisdiction.
-- 3. Replace the unique-default index with a (concept_id, jurisdiction)
-- pair so each concept can carry one default per jurisdiction.
-- 4. Add jurisdictional defaults where a non-UPC event_type genuinely
-- exists (DE Klageerwiderung, DPMA / EPO opposition + appeal).
--
-- Lookup contract (consumed by the rule-service hydrator)
-- -------------------------------------------------------
-- For a rule with proceeding_types.jurisdiction = J, the auto-fill
-- looks up the row WHERE is_default AND jurisdiction = J. EPA→EPO
-- canonicalisation lives in the Go service (proceeding_types use 'EPA'
-- but event_types use 'EPO' — the two columns disagreed before this
-- mapping table existed). When NO row matches the rule's jurisdiction,
-- the auto-fill silently no-ops; better than a wrong default.
ALTER TABLE paliad.deadline_concept_event_types
ADD COLUMN IF NOT EXISTS jurisdiction text;
COMMENT ON COLUMN paliad.deadline_concept_event_types.jurisdiction IS
'Which jurisdiction this default applies to. Matches the rule''s '
'proceeding_types.jurisdiction (UPC / DE / DPMA / EPO). EPA→EPO '
'canonicalisation is done service-side. NULL = applies to any '
'jurisdiction (the catch-all fallback — currently unused).';
-- Backfill jurisdiction from the event_type's own column.
UPDATE paliad.deadline_concept_event_types j
SET jurisdiction = et.jurisdiction
FROM paliad.event_types et
WHERE j.event_type_id = et.id
AND j.jurisdiction IS NULL;
-- Replace the old unique-default index (one default per concept) with
-- one default per (concept, jurisdiction). We DROP IF EXISTS so the
-- migration is rerunnable against a freshly-rebuilt schema.
DROP INDEX IF EXISTS paliad.deadline_concept_event_types_one_default;
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default_per_jur
ON paliad.deadline_concept_event_types (concept_id, jurisdiction)
WHERE is_default = true;
-- ============================================================================
-- Demote then re-elect defaults so each (concept, jurisdiction) pair is
-- correctly anchored. Rows that never had jurisdiction picked stay as
-- non-defaults until a curated row beats them.
-- ============================================================================
-- statement-of-defence DE: de_klageerwiderung becomes the DE default.
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
SELECT dc.id, et.id, true, 10, 'DE'
FROM paliad.deadline_concepts dc
JOIN paliad.event_types et ON et.slug = 'de_klageerwiderung' AND et.archived_at IS NULL
WHERE dc.slug = 'statement-of-defence'
ON CONFLICT (concept_id, event_type_id) DO UPDATE
SET is_default = true, jurisdiction = 'DE', sort_order = 10;
-- opposition: split per jurisdiction. EPO is the canonical EU-wide
-- pre-grant Einspruch, DPMA is the German national variant.
UPDATE paliad.deadline_concept_event_types j
SET is_default = true, jurisdiction = 'EPO', sort_order = 10
FROM paliad.deadline_concepts dc, paliad.event_types et
WHERE j.concept_id = dc.id
AND j.event_type_id = et.id
AND dc.slug = 'opposition'
AND et.slug = 'epo_opposition_filing';
UPDATE paliad.deadline_concept_event_types j
SET is_default = true, jurisdiction = 'DPMA', sort_order = 20
FROM paliad.deadline_concepts dc, paliad.event_types et
WHERE j.concept_id = dc.id
AND j.event_type_id = et.id
AND dc.slug = 'opposition'
AND et.slug = 'dpma_opposition';
-- request-for-examination: DPMA is the only jurisdiction with an
-- event_type counterpart (EP-grant exam request has no event_type yet).
UPDATE paliad.deadline_concept_event_types j
SET is_default = true, jurisdiction = 'DPMA'
FROM paliad.deadline_concepts dc, paliad.event_types et
WHERE j.concept_id = dc.id
AND j.event_type_id = et.id
AND dc.slug = 'request-for-examination'
AND et.slug = 'dpma_examination_request';
-- notice-of-appeal: keep the UPC default (already set by mig 073) AND
-- add EPO + DPMA jurisdictional variants for non-UPC rules.
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
SELECT dc.id, et.id, true, 10, 'EPO'
FROM paliad.deadline_concepts dc
JOIN paliad.event_types et ON et.slug = 'epo_appeal_notice' AND et.archived_at IS NULL
WHERE dc.slug = 'notice-of-appeal'
ON CONFLICT (concept_id, event_type_id) DO UPDATE
SET is_default = true, jurisdiction = 'EPO', sort_order = 10;
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
SELECT dc.id, et.id, true, 10, 'DPMA'
FROM paliad.deadline_concepts dc
JOIN paliad.event_types et ON et.slug = 'dpma_appeal' AND et.archived_at IS NULL
WHERE dc.slug = 'notice-of-appeal'
ON CONFLICT (concept_id, event_type_id) DO UPDATE
SET is_default = true, jurisdiction = 'DPMA', sort_order = 10;
-- statement-of-grounds-of-appeal: keep UPC default; add EPO variant.
UPDATE paliad.deadline_concept_event_types j
SET is_default = true, jurisdiction = 'EPO', sort_order = 10
FROM paliad.deadline_concepts dc, paliad.event_types et
WHERE j.concept_id = dc.id
AND j.event_type_id = et.id
AND dc.slug = 'statement-of-grounds-of-appeal'
AND et.slug = 'epo_appeal_grounds';
-- ============================================================================
-- Final pass: any junction row that still has NULL jurisdiction (none
-- expected after the backfill, but defensive) gets its event_type's
-- jurisdiction copied so the partial-unique index is well-defined.
-- ============================================================================
UPDATE paliad.deadline_concept_event_types j
SET jurisdiction = et.jurisdiction
FROM paliad.event_types et
WHERE j.event_type_id = et.id
AND j.jurisdiction IS NULL;

View File

@@ -61,49 +61,58 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) (
return rules, nil
}
// hydrateConceptDefaultEventTypes resolves rule.ConceptID →
// paliad.deadline_concept_event_types.event_type_id (where is_default)
// for every rule with a non-nil ConceptID, and assigns the result.
// One round-trip; rules whose concept has no default mapping stay NULL.
// hydrateConceptDefaultEventTypes resolves each rule's (concept_id,
// proceeding_type.jurisdiction) pair to the canonical paliad.event_types
// row from paliad.deadline_concept_event_types (where is_default and
// jurisdiction matches), and assigns it to ConceptDefaultEventTypeID.
//
// One round-trip via JOIN to paliad.proceeding_types so we can match on
// the rule's jurisdiction without a per-rule second query. EPA→EPO
// canonicalisation is done in SQL because event_types use 'EPO' but
// proceeding_types use 'EPA' — the two columns disagreed before this
// mapping table existed (mig 074).
//
// Rules whose (concept, jurisdiction) has no default stay NULL —
// silent no-op on the form, better than a wrong-jurisdiction default.
func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Context, rules []models.DeadlineRule) error {
conceptIDs := make([]uuid.UUID, 0, len(rules))
seen := make(map[uuid.UUID]bool, len(rules))
ruleIDs := make([]uuid.UUID, 0, len(rules))
for _, r := range rules {
if r.ConceptID == nil || seen[*r.ConceptID] {
if r.ConceptID == nil {
continue
}
seen[*r.ConceptID] = true
conceptIDs = append(conceptIDs, *r.ConceptID)
ruleIDs = append(ruleIDs, r.ID)
}
if len(conceptIDs) == 0 {
if len(ruleIDs) == 0 {
return nil
}
query, args, err := sqlx.In(
`SELECT concept_id, event_type_id
FROM paliad.deadline_concept_event_types
WHERE is_default = true AND concept_id IN (?)`, conceptIDs)
`SELECT dr.id AS rule_id, j.event_type_id
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concept_event_types j
ON j.concept_id = dr.concept_id
AND j.is_default = true
AND j.jurisdiction = CASE WHEN pt.jurisdiction = 'EPA' THEN 'EPO' ELSE pt.jurisdiction END
WHERE dr.id IN (?)`, ruleIDs)
if err != nil {
return fmt.Errorf("build concept→event_type IN query: %w", err)
return fmt.Errorf("build rule→event_type IN query: %w", err)
}
query = s.db.Rebind(query)
type row struct {
ConceptID uuid.UUID `db:"concept_id"`
RuleID uuid.UUID `db:"rule_id"`
EventTypeID uuid.UUID `db:"event_type_id"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return fmt.Errorf("load concept→event_type defaults: %w", err)
return fmt.Errorf("load rule→event_type defaults: %w", err)
}
defaultByConcept := make(map[uuid.UUID]uuid.UUID, len(rows))
defaultByRule := make(map[uuid.UUID]uuid.UUID, len(rows))
for _, r := range rows {
defaultByConcept[r.ConceptID] = r.EventTypeID
defaultByRule[r.RuleID] = r.EventTypeID
}
for i := range rules {
if rules[i].ConceptID == nil {
continue
}
if et, ok := defaultByConcept[*rules[i].ConceptID]; ok {
if et, ok := defaultByRule[rules[i].ID]; ok {
etCopy := et
rules[i].ConceptDefaultEventTypeID = &etCopy
}