mAi: #97 - t-paliad-266 — event-type modal: narrow cross-cutting trigger pills by court system

Cross-cutting Wiedereinsetzung sub-rows (PatG §123 / ZPO §233 /
EPC Art.122 / DPMA PatG §123 / UPC R.320) used to bypass the
forum-bucket chip selection by design — every chip combination
returned all five rows. m/paliad#97: chip the chips through
to triggers via legal_source inference.

  - mig 123 backfills the missing deadline_rules row for trigger
    207 (UPC R.320 Wiedereinsetzung, orphaned by mig 063 because
    mig 092 dropped event_deadlines before that path was seeded)
    and rebuilds paliad.deadline_search with a LEFT JOIN on
    deadline_rules so cross-cutting trigger pills carry their
    structured legal_source.
  - DeadlineSearchService gains ForumToLegalSourcePrefixes (10
    buckets → UPC. / DE.ZPO. / DE.PatG. / EU.EPC + EU.EPÜ)
    paralleling ForumToProceedingCodes. Rule pills still narrow
    by proceeding_code; trigger pills now narrow by legal_source
    LIKE prefix. Multiple chips union the prefix allow-list as
    expected.
  - Live golden-table test gains a Wiedereinsetzung×forum matrix
    plus a multi-chip union case, and the existing 4-pill assertion
    is updated to the now-5-pill state (mig 063 added trigger 207).

Branch: mai/hermes/gitster-event-type-modal.
This commit is contained in:
mAi
2026-05-25 15:36:08 +02:00
parent f55648944c
commit 24f3baf61f
4 changed files with 552 additions and 20 deletions

View File

@@ -0,0 +1,103 @@
-- Down migration for 123_cross_cutting_filter_legal_source.up.sql.
--
-- Rebuilds the mig 098 matview shape (NULL legal_source on trigger
-- rows) and removes the trigger-207 backfill row. Two steps in
-- forward-reverse order so the matview drop doesn't trip on the
-- deadline_rules delete.
SELECT set_config(
'paliad.audit_reason',
'mig 123 down: revert cross-cutting filter legal_source (drop trigger-207 backfill + rebuild matview without LEFT JOIN to deadline_rules).',
true);
-- 1. Drop the matview before pulling rows underneath it.
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- 2. Delete the trigger 207 backfill row.
DELETE FROM paliad.deadline_rules
WHERE trigger_event_id = 207
AND sequence_order = 1207;
-- 3. Recreate the mig 098 matview verbatim (NULL legal_source on
-- trigger rows).
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);

View File

@@ -0,0 +1,222 @@
-- t-paliad-266 / m/paliad#97 — make cross-cutting trigger pills filter
-- by court system in the event-type / Fristen search modal.
--
-- Two things land here:
--
-- 1. DATA — backfill the missing deadline_rules row for trigger 207
-- (Wegfall des Hindernisses, UPC R.320). Mig 063 added the
-- trigger_event but never seeded its event_deadlines counterpart;
-- mig 092 then dropped event_deadlines after copying the four
-- sibling Wiedereinsetzungen (ids 200..203) into deadline_rules,
-- so trigger 207 stayed orphaned with no duration / legal_source.
-- Adding the row makes UPC R.320 Wiedereinsetzung calculable on
-- par with the four siblings (2 months from removal of obstacle,
-- legal_source = 'UPC.RoP.320', party = 'both') and gives the
-- matview a legal_source to surface for the UPC trigger pill.
-- Pattern mirrors the four sibling rows mig 085 inserted.
--
-- 2. MATVIEW — rebuild paliad.deadline_search with a LEFT JOIN on
-- paliad.deadline_rules for trigger pills, exposing the trigger's
-- legal_source on the row. The cross-cutting concept card pills
-- then carry a structured citation prefix (UPC.* / DE.ZPO.* /
-- DE.PatG.* / EU.EPC* / EU.EPÜ.*) that the search service can
-- match against the active forum-bucket filter — see
-- DeadlineSearchService.translateForums + ForumToLegalSourcePrefixes
-- (added in this same change). Without the matview surfacing
-- legal_source for trigger rows, every cross-cutting sub-row
-- ignored the court-system chip selection (the bug m reported).
--
-- The materialised view paliad.deadline_search refreshes on the next
-- server boot via services.RefreshSearchView (cmd/server/main.go), so
-- the new legal_source column for triggers becomes searchable as soon
-- as the deploy restarts the process. No matview refresh from the
-- migration itself.
SELECT set_config(
'paliad.audit_reason',
'mig 123: t-paliad-266 — backfill missing deadline_rules row for trigger 207 (UPC R.320 Wiedereinsetzung) and rebuild deadline_search matview so trigger pills carry legal_source (cross-cutting court-system filter, m/paliad#97).',
true);
-- =============================================================================
-- 1. Backfill: deadline_rules row for trigger 207.
--
-- Idempotency: gated on NOT EXISTS by (trigger_event_id, name). Mirrors
-- mig 085's guard so re-runs are no-ops once the row is present.
-- =============================================================================
INSERT INTO paliad.deadline_rules (
id,
proceeding_type_id,
parent_id,
trigger_event_id,
spawn_proceeding_type_id,
submission_code,
name,
name_en,
primary_party,
event_type,
is_mandatory,
is_optional,
is_court_set,
is_spawn,
duration_value,
duration_unit,
timing,
alt_duration_value,
alt_duration_unit,
combine_op,
rule_code,
deadline_notes,
deadline_notes_en,
legal_source,
condition_expr,
condition_flag,
sequence_order,
is_active,
priority,
lifecycle_state,
draft_of,
published_at,
concept_id
)
SELECT
gen_random_uuid(),
NULL::integer,
NULL::uuid,
207,
NULL::integer,
NULL::text,
'Wiedereinsetzungsantrag (UPC R.320)',
'Petition for re-establishment of rights (UPC R.320)',
NULL::text,
NULL::text,
true,
false,
false,
false,
2,
'months',
'after',
NULL::integer,
NULL::text,
NULL::text,
NULL::text,
'Frist beträgt 2 Monate ab Wegfall des Hindernisses (R.320 RoP). Spätestens 12 Monate nach Ablauf der versäumten Frist.',
'Period is 2 months from removal of the obstacle (UPC R.320 RoP). Latest 12 months after expiry of the missed deadline.',
'UPC.RoP.320',
NULL::jsonb,
NULL::text[],
1207,
true,
'mandatory',
'published',
NULL::uuid,
now(),
(SELECT id FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung')
WHERE NOT EXISTS (
SELECT 1
FROM paliad.deadline_rules dr
WHERE dr.trigger_event_id = 207
);
-- =============================================================================
-- 2. Matview rebuild — LEFT JOIN deadline_rules on trigger_event_id so
-- cross-cutting trigger pills carry legal_source. Indexes reproduced
-- verbatim from mig 098 §5.
--
-- The trigger-row JOIN matches the Pipeline-C convention (mig 085 §2.5 /
-- mig 092 §2): each cross-cutting trigger has a single deadline_rules
-- row with proceeding_type_id IS NULL. A trigger event without that
-- row leaves legal_source NULL and the trigger pill keeps its current
-- "no jurisdiction filter match" semantics — same shape as before this
-- migration, just structurally surfaceable.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
dr_trig.legal_source AS legal_source,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
LEFT JOIN paliad.deadline_rules dr_trig
ON dr_trig.trigger_event_id = te.id
AND dr_trig.proceeding_type_id IS NULL
AND dr_trig.is_active
AND dr_trig.lifecycle_state = 'published'
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);

View File

@@ -33,7 +33,12 @@ import (
// tree alone is enough to produce a candidate concept set.
// - Forums: a list of forum slugs from the v3 bucket map. Translated
// to proceeding_type_codes by the search service; trigger-event
// pills bypass the forum filter (cross-cutting by design).
// pills carry a structured legal_source citation (via mig 123)
// and narrow by the per-forum legal-source prefix set instead of
// by proceeding_code — see ForumToLegalSourcePrefixes. Before mig
// 123 trigger pills bypassed the forum filter unconditionally;
// m/paliad#97 (t-paliad-266) requires the cross-cutting sub-rows
// to narrow with the active court-system chip.
//
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
@@ -74,6 +79,40 @@ var ForumToProceedingCodes = map[string][]string{
"dpma": {CodeDPMAOpposition},
}
// ForumToLegalSourcePrefixes maps the v3 forum buckets to the
// structured legal_source prefixes that cross-cutting trigger pills
// must match against (t-paliad-266 / m/paliad#97). Rule pills already
// narrow by proceeding_code via ForumToProceedingCodes; trigger pills
// have no proceeding context, so the narrowing key is the citation
// body itself.
//
// Mapping mirrors m's spec on the issue:
//
// - UPC chips → UPC.* (UPC RoP / UPC Agreement / UPC Statute)
// - DE LG/OLG/BGH chips → DE.ZPO.* (civil-procedure path)
// - DE BPatG chip → DE.PatG.* (national patent path)
// - DPMA chip → DE.PatG.* (national patent path)
// - EPA chips → EU.EPC* / EU.EPÜ* (EPC / EPÜ citations)
//
// Two forums (de_bgh, de_bpatg) intentionally collapse: BGH hears
// both civil-patent and nullity appeals; PatG covers DPMA + BPatG
// patent jurisdiction. The matching SQL uses startsWith against the
// union of the active forums' prefixes, so a chip combination like
// "DPMA + de_bgh" surfaces every trigger whose legal_source starts
// with DE.PatG.* OR DE.ZPO.* — exactly the user's union expectation.
var ForumToLegalSourcePrefixes = map[string][]string{
"upc_cfi": {"UPC."},
"upc_coa": {"UPC."},
"de_lg": {"DE.ZPO."},
"de_olg": {"DE.ZPO."},
"de_bgh": {"DE.ZPO."},
"de_bpatg": {"DE.PatG."},
"epa_grant": {"EU.EPC", "EU.EPÜ"},
"epa_opp": {"EU.EPC", "EU.EPÜ"},
"epa_appeal": {"EU.EPC", "EU.EPÜ"},
"dpma": {"DE.PatG."},
}
// SearchOptions carries the optional facet filters from the URL query
// string. Empty strings / empty slices mean "no filter on this facet".
type SearchOptions struct {
@@ -279,8 +318,12 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
subtree = newSubtreeFilter(outcomes)
}
// v3: translate forum slugs to proceeding_code allow-list.
// v3: translate forum slugs to proceeding_code allow-list (rule
// pills) and t-paliad-266: parallel legal_source prefix allow-list
// for trigger pills. Empty slice for either axis = no narrowing on
// that pill kind.
forumCodes := translateForums(opts.Forums)
forumLegalPrefixes := translateForumsToLegalSourcePrefixes(opts.Forums)
if !browseMode && qNorm == "" {
return resp, nil
@@ -293,11 +336,11 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
var ranks []rankRow
if browseMode {
// Browse mode: synthesize ranks from the allow-list directly.
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, limit)
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, forumLegalPrefixes, limit)
} else {
qLow := strings.ToLower(qNorm)
var err error
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, limit)
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, forumLegalPrefixes, limit)
if err != nil {
return nil, err
}
@@ -310,7 +353,7 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
for i, r := range ranks {
conceptIDs[i] = r.ConceptID
}
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes)
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes, forumLegalPrefixes)
if err != nil {
return nil, err
}
@@ -418,6 +461,33 @@ func translateForums(slugs []string) []string {
return out
}
// translateForumsToLegalSourcePrefixes maps a list of forum slugs to
// the union of legal_source prefixes those forums admit for trigger
// pills (t-paliad-266). Empty when no slug carries a prefix mapping —
// callers must treat empty as "no trigger narrowing applies" rather
// than "match nothing", mirroring translateForums.
func translateForumsToLegalSourcePrefixes(slugs []string) []string {
if len(slugs) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, slug := range slugs {
prefixes, ok := ForumToLegalSourcePrefixes[slug]
if !ok {
continue
}
for _, p := range prefixes {
if seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
}
return out
}
// browseRanks synthesizes a rank list from a subtree-filter tuple set
// (v3 B1 browse mode). No trigram scoring — order is by concept
// sort_order then name. Forum filter applies post-hoc to keep concepts
@@ -430,6 +500,7 @@ func (s *DeadlineSearchService) browseRanks(
subtree *subtreeFilter,
party, proc, source *string,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) []rankRow {
const sqlText = `
@@ -452,8 +523,18 @@ SELECT DISTINCT
AND (
$6::text[] IS NULL
OR cardinality($6::text[]) = 0
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($6::text[])
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($6::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
LIMIT $7
@@ -465,6 +546,7 @@ SELECT DISTINCT
party, proc, source,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
// Browse mode failures degrade to empty (taxonomy-driven UX
// shouldn't crash on a malformed slug); log via the caller.
@@ -490,11 +572,12 @@ func (s *DeadlineSearchService) rankConcepts(
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) ([]rankRow, error) {
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
// $6 subtree_cids uuid[]? · $7 subtree_procs text[]? ·
// $8 forum_codes text[]? · $9 limit
// $8 forum_codes text[]? · $9 limit · $10 forum_legal_prefixes text[]?
const sqlText = `
WITH matched AS (
SELECT
@@ -544,8 +627,18 @@ WITH matched AS (
AND (
$8::text[] IS NULL
OR cardinality($8::text[]) = 0
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($8::text[])
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($8::text[])
)
OR (
s.kind = 'trigger'
AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($10::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
)
SELECT
@@ -569,6 +662,7 @@ SELECT
cidArg, procArg,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("rank concepts: %w", err)
}
@@ -581,10 +675,11 @@ func (s *DeadlineSearchService) loadPills(
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
) ([]pillRow, error) {
// $1 concept_ids uuid[] · $2 party · $3 proc · $4 source ·
// $5 subtree_cids uuid[]? · $6 subtree_procs text[]? ·
// $7 forum_codes text[]?
// $7 forum_codes text[]? · $8 forum_legal_prefixes text[]?
const sqlText = `
SELECT
s.kind,
@@ -627,8 +722,18 @@ SELECT
AND (
$7::text[] IS NULL
OR cardinality($7::text[]) = 0
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($7::text[])
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($7::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
)
ORDER BY s.concept_id, s.kind, s.proceeding_display_order, s.proceeding_code NULLS LAST, s.rule_local_code
`
@@ -638,6 +743,7 @@ SELECT
pq.Array(conceptIDs), party, proc, source,
cidArg, procArg,
nullableArray(forumCodes),
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("load pills: %w", err)
}

View File

@@ -166,15 +166,15 @@ func TestDeadlineSearch(t *testing.T) {
mustHaveLegalSource(t, card, "DE.PatG.82.1")
})
t.Run("Wiedereinsetzung returns the cross-cutting concept with 4 trigger pills", func(t *testing.T) {
t.Run("Wiedereinsetzung returns the cross-cutting concept with 5 trigger pills", func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
// Exactly 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123 — corresponding to trigger_event ids
// 200..203 from migration 046.
// Exactly 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids
// 200..203 from mig 046 plus 207 from mig 063.
triggerIDs := []int64{}
for _, p := range card.Pills {
if p.Kind != "trigger" {
@@ -184,9 +184,9 @@ func TestDeadlineSearch(t *testing.T) {
triggerIDs = append(triggerIDs, *p.TriggerEventID)
}
}
want := map[int64]bool{200: true, 201: true, 202: true, 203: true}
if len(triggerIDs) != 4 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs))
want := map[int64]bool{200: true, 201: true, 202: true, 203: true, 207: true}
if len(triggerIDs) != 5 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs))
}
for _, id := range triggerIDs {
if !want[id] {
@@ -195,6 +195,107 @@ func TestDeadlineSearch(t *testing.T) {
}
})
// t-paliad-266 / m/paliad#97 — court-system filter narrows
// cross-cutting trigger pills via legal_source inference.
t.Run("forum filter narrows Wiedereinsetzung trigger pills by court system", func(t *testing.T) {
// Each pair is (forum slug, expected trigger_event_ids).
cases := []struct {
name string
forum string
wantTrigIDs []int64
}{
{"upc_cfi shows only UPC R.320", "upc_cfi", []int64{207}},
{"upc_coa shows only UPC R.320", "upc_coa", []int64{207}},
{"de_lg shows only ZPO §233", "de_lg", []int64{201}},
{"de_olg shows only ZPO §233", "de_olg", []int64{201}},
{"de_bgh shows only ZPO §233", "de_bgh", []int64{201}},
{"de_bpatg shows only PatG §123 (DE national)", "de_bpatg", []int64{200, 203}},
{"dpma shows only PatG §123 (DPMA)", "dpma", []int64{200, 203}},
{"epa_grant shows only EPC Art.122", "epa_grant", []int64{202}},
{"epa_opp shows only EPC Art.122", "epa_opp", []int64{202}},
{"epa_appeal shows only EPC Art.122", "epa_appeal", []int64{202}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{tc.forum},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{}
for _, id := range tc.wantTrigIDs {
want[id] = true
}
for id := range got {
if !want[id] {
t.Errorf("forum=%s leaked trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
for id := range want {
if !got[id] {
t.Errorf("forum=%s missing expected trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
})
}
})
t.Run("multiple forum chips union the legal_source allow-list for triggers", func(t *testing.T) {
// upc_cfi + de_lg → UPC.* OR DE.ZPO.* → trigger ids 201 + 207.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{"upc_cfi", "de_lg"},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{201: true, 207: true}
for id := range got {
if !want[id] {
t.Errorf("union forum upc_cfi+de_lg leaked trigger id %d", id)
}
}
for id := range want {
if !got[id] {
t.Errorf("union forum upc_cfi+de_lg missing trigger id %d", id)
}
}
})
t.Run("empty forum filter leaves cross-cutting pills untouched", func(t *testing.T) {
// No forum chips = all 5 triggers stay visible.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
count := 0
for _, p := range card.Pills {
if p.Kind == "trigger" {
count++
}
}
if count != 5 {
t.Errorf("empty forum filter dropped a trigger pill: got %d, want 5", count)
}
})
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
if err != nil {