Merge: t-paliad-294 — conditional label uses trigger_event name (R.262(2) → Vertraulichkeitsantrag) (m/paliad#126)
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

This commit is contained in:
mAi
2026-05-26 11:19:40 +02:00
3 changed files with 149 additions and 5 deletions

View File

@@ -207,6 +207,44 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
return rules, nil
}
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for the
// given id set, keyed by id. Returns nil, nil for an empty input set so
// callers can blindly forward whatever they accumulated. Inactive rows
// are included — the conditional-label resolution in fristenrechner.go
// surfaces the trigger event's display name even when the catalog row
// has been retired, which is preferable to silently falling back to
// the (wrong) parent_id name.
//
// Used by FristenrechnerService.Calculate to redirect a conditional
// rule's "abhängig von …" chip from parent_id to trigger_event_id —
// the actual semantic anchor for rules whose data-model parent is the
// proceeding root but whose real trigger sits in the trigger_events
// catalog (e.g. R.262(2) Erwiderung auf Vertraulichkeitsantrag → the
// opposing party's confidentiality application). See m/paliad#126.
func (s *DeadlineRuleService) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
if len(ids) == 0 {
return nil, nil
}
query, args, err := sqlx.In(
`SELECT id, code, name, name_de, description, is_active, created_at
FROM paliad.trigger_events
WHERE id IN (?)`, ids)
if err != nil {
return nil, fmt.Errorf("build trigger_events IN query: %w", err)
}
query = s.db.Rebind(query)
var rows []models.TriggerEvent
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("load trigger_events by ids %v: %w", ids, err)
}
out := make(map[int64]models.TriggerEvent, len(rows))
for _, r := range rows {
out[r.ID] = r
}
return out, nil
}
// ListByTriggerEvent returns active rules scoped to a single trigger
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
// These rules carry proceeding_type_id IS NULL (event-rooted) and have

View File

@@ -452,6 +452,36 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
ruleByID[r.ID] = r
}
// triggerEventByID powers the trigger-event override on the
// conditional-label chip (m/paliad#126 / t-paliad-294). When a
// rule carries a real paliad.trigger_events row, that catalog
// event — not the rule's parent_id — is the rule's actual
// semantic anchor. The override fires below when stamping
// ParentRule* on the wire so the chip reads e.g.
// abhängig von Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit
// for R.262(2) Erwiderung auf Vertraulichkeitsantrag — instead of
// the (misleading) parent_id-derived "abhängig von Klageerhebung".
//
// Bulk-loaded in one round-trip; trees in the live corpus carry at
// most a handful of trigger_event_id-bearing rules (2 today on
// upc.inf.cfi), so the IN(...) is small.
var triggerIDs []int64
seenTrigger := make(map[int64]struct{}, len(rules))
for _, r := range rules {
if r.TriggerEventID == nil {
continue
}
if _, ok := seenTrigger[*r.TriggerEventID]; ok {
continue
}
seenTrigger[*r.TriggerEventID] = struct{}{}
triggerIDs = append(triggerIDs, *r.TriggerEventID)
}
triggerEventByID, err := s.rules.LoadTriggerEventsByIDs(ctx, triggerIDs)
if err != nil {
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
}
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
// for membership tests; the engine reads them but doesn't mutate.
skipRules := opts.SkipRules
@@ -582,6 +612,30 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
}
}
// Trigger-event override on the user-facing dependency identity
// (m/paliad#126 / t-paliad-294). When a rule has a real
// trigger_event_id, that catalog event is the actual semantic
// anchor — not the parent_id node, which is only the calc-time
// arithmetic anchor. R.262(2) Erwiderung auf Vertraulichkeits-
// antrag is the canonical case: parent_id resolves to the SoC
// ("Klageerhebung"), but the real triggering event is the
// opposing party's confidentiality application. Generalises to
// any rule whose trigger_event_id is set (e.g. R.6(2)
// translations_lodge → judge-rapporteur's order).
//
// Only the user-facing wire fields shift; parentRule (and the
// parent_id chain that feeds parentIsCourtSet / the calc-time
// date arithmetic below) stays anchored on the rule tree —
// that's still the right calc semantic. parentRule is NOT
// reassigned here.
if r.TriggerEventID != nil {
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
d.ParentRuleCode = te.Code
d.ParentRuleName = te.NameDE
d.ParentRuleNameEN = te.Name
}
}
// Propagate court-set status from a parent rule whose date the
// court determines: if the anchor itself has no real date,
// nothing downstream can be computed either — UNLESS the user

View File

@@ -507,15 +507,21 @@ func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
wantParentCode string
}{
// Symptom A — backward-anchored on the court-set oral hearing.
// Pre-pass fix: order-of-evaluation no longer matters.
// Pre-pass fix: order-of-evaluation no longer matters. These
// rules have no trigger_event_id, so ParentRuleCode stays on
// the parent_id-derived value.
{"upc.inf.cfi.translation_request", true, "upc.inf.cfi.oral"},
{"upc.inf.cfi.interpreter_cost", true, "upc.inf.cfi.oral"},
// R.118(4) chain — parent=decision (court-set).
// R.118(4) chain — parent=decision (court-set). No trigger_event_id.
{"upc.inf.cfi.cons_orders", true, "upc.inf.cfi.decision"},
// Symptom B — optional + both anchored on SoC (trigger anchor).
{"upc.inf.cfi.confidentiality_response", true, "upc.inf.cfi.soc"},
// Symptom B — optional + both, data-model parent is SoC but the
// real trigger is the opposing party's confidentiality application.
// m/paliad#126 / t-paliad-294: ParentRuleCode now reflects the
// trigger_events catalog row (id=25), NOT the parent_id chain.
{"upc.inf.cfi.confidentiality_response", true, "application_to_request_confidentiality_from_the_public"},
// Negative control — mandatory rule anchored on SoC must keep
// its concrete date (no IsConditional, real DueDate).
// its concrete date (no IsConditional, real DueDate). No
// trigger_event_id, so parent_id-derived code stays.
{"upc.inf.cfi.sod", false, "upc.inf.cfi.soc"},
}
@@ -546,6 +552,52 @@ func TestUIDeadline_IsConditional_UncertainAnchors(t *testing.T) {
})
}
// m/paliad#126 / t-paliad-294: the conditional chip for R.262(2)
// reads from the trigger_events catalog (id=25), so the user sees
// the actual semantic anchor instead of the parent_id-derived
// "Klageerhebung". Pin the exact DE + EN strings so a future
// rename of the catalog row surfaces here.
t.Run("R.262(2) conditional label uses trigger_event_id, not parent_id", func(t *testing.T) {
d, ok := byCode["upc.inf.cfi.confidentiality_response"]
if !ok {
t.Fatalf("confidentiality_response missing from response")
}
const wantNameDE = "Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit"
const wantNameEN = "Application to request confidentiality from the public"
if d.ParentRuleName != wantNameDE {
t.Errorf("ParentRuleName = %q, want %q (trigger_events.name_de for id=25)", d.ParentRuleName, wantNameDE)
}
if d.ParentRuleNameEN != wantNameEN {
t.Errorf("ParentRuleNameEN = %q, want %q (trigger_events.name for id=25)", d.ParentRuleNameEN, wantNameEN)
}
// Negative guard — neither label should leak the SoC ("Klageerhebung"),
// which is the regression the fix exists to prevent.
if d.ParentRuleName == "Klageerhebung" || d.ParentRuleNameEN == "Statement of Claim" {
t.Errorf("conditional label still resolves via parent_id (SoC); fix regressed")
}
})
// Generalisation guard — translations_lodge also carries a real
// trigger_event_id (113 = judge-rapporteur's order). Its
// conditional chip should reference the order, not its parent_id
// (Zwischenverfahren). Locks in the "any rule with trigger_event_id
// uses THAT, not parent_id" contract from m/paliad#126.
t.Run("translations_lodge conditional label uses trigger_event_id", func(t *testing.T) {
d, ok := byCode["upc.inf.cfi.translations_lodge"]
if !ok {
t.Skip("upc.inf.cfi.translations_lodge missing from response — data drift?")
}
if !d.IsConditional {
t.Skipf("translations_lodge IsConditional=false in current corpus; trigger-event override is only user-visible on conditional rows. Skip but keep the generalisation guard.")
}
if d.ParentRuleName == "Zwischenverfahren" {
t.Errorf("translations_lodge still labelled via parent_id (Zwischenverfahren); should follow trigger_event_id=113")
}
if d.ParentRuleCode != "order_of_the_judge_rapporteur_to_lodge_translations" {
t.Errorf("ParentRuleCode = %q, want trigger_events.code for id=113", d.ParentRuleCode)
}
})
// Override path: when the user anchors the oral hearing, the
// backward-anchored R.109(1) flips back to a concrete date and
// IsConditional clears. This is the click-to-edit unblock.