fix(projection): conditional label uses trigger_event_id, not parent_id
t-paliad-294 / m/paliad#126. knuth's #121 conditional-rendering defaulted the "abhängig von <parent>" chip to the rule's parent_id display name. For R.262(2) Erwiderung auf Vertraulichkeitsantrag the parent_id resolves to the SoC (Klageerhebung), but the rule's real semantic anchor is the opposing party's confidentiality application (paliad.trigger_events id=25). The chip read "abhängig von Klageerhebung", which is wrong. Fix: when a rule has a non-NULL trigger_event_id, the engine stamps ParentRuleCode / ParentRuleName / ParentRuleNameEN from the trigger_events catalog row instead of from the parent_id chain. The parent_id stays as the calc-time arithmetic anchor — only the user- facing dependency identity shifts. Generalises across every rule with a real trigger_event_id (2 rows in the live corpus today: confidentiality_response and translations_lodge — both relabel correctly). Touches both surfaces in one shot: verfahrensablauf-core's chip ("abhängig von …") and shape-timeline's "Folgt aus …" footer both read from ParentRule*, so no frontend change needed. Tests: extend TestUIDeadline_IsConditional_UncertainAnchors with a DE+EN string-pinning case for R.262(2) plus a generalisation guard for translations_lodge. Negative guard asserts the chip no longer leaks "Klageerhebung" / "Statement of Claim".
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user