package litigationplanner import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" ) // Calculate renders the full UI timeline for a proceeding type + trigger date. // Preserves the pre-Phase-C in-memory calculator's classification: // // - Rules with duration_value = 0 and no parent_id → IsRootEvent // (due date = trigger date) // - Rules with duration_value = 0 and a parent_id → IsCourtSet // (due date empty, UI shows "court-set" placeholder) // - All other rules → calculate from either the trigger date (no parent) // or the previously-computed date for their parent rule. // // Audit-driven extensions: // // - opts.Flags can flip flag-conditioned rules onto their alt_* values // (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr"). // - opts.PriorityDateStr overrides the anchor for rules with // anchor_alt='priority_date' (e.g. epa.grant.exa publication date // is 18mo from priority, not filing). // - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the // caller redirect a downstream rule's parent anchor to a user-set // date. func Calculate( ctx context.Context, proceedingCode string, triggerDateStr string, opts CalcOptions, catalog Catalog, holidays HolidayCalendar, courts CourtRegistry, ) (*Timeline, error) { // Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven // branch (Pipeline-C unified rules). proceedingCode is ignored on // this path. if opts.TriggerEventIDFilter != nil { return calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts, catalog, holidays, courts) } triggerDate, err := time.Parse("2006-01-02", triggerDateStr) if err != nil { return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) } var priorityDate *time.Time if opts.PriorityDateStr != "" { pd, err := time.Parse("2006-01-02", opts.PriorityDateStr) if err != nil { return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err) } priorityDate = &pd } flagSet := make(map[string]struct{}, len(opts.Flags)) for _, f := range opts.Flags { flagSet[f] = struct{}{} } // v1 simplification (t-paliad-265): when any IncludeCCRFor entry // exists, we treat with_ccr as set in the flag context. if len(opts.IncludeCCRFor) > 0 { flagSet["with_ccr"] = struct{}{} } // Parse anchor overrides up-front so a malformed date errors out // before we start walking rules. overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides)) for code, dateStr := range opts.AnchorOverrides { od, err := time.Parse("2006-01-02", dateStr) if err != nil { return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err) } overrideDates[code] = od } // Look up proceeding type metadata. pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint) if err != nil { return nil, err } // Sub-track routing (m/paliad#58). When the user picks a proceeding // that has no native rules and is normally a sub-track of another // proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route // rule lookup to the parent and merge the default flags into the // user's flag set. The response identity stays on the user-picked // proceeding so the page header still reads "Counterclaim for // Revocation", but the timeline body is the parent's full flow with // the sub-track flag enabled. var subTrackNote SubTrackRouting var hasSubTrackNote bool pt := pickedProceeding if route, ok := LookupSubTrackRouting(proceedingCode); ok { subTrackNote = route hasSubTrackNote = true parentPt, parentRules, err := catalog.LoadProceeding(ctx, route.ParentCode, opts.ProjectHint) if err != nil { return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, err) } pt = parentPt rules = parentRules // Merge default flags into the user's flag set so the gated // rules render. User-supplied flags win on conflict. for _, f := range route.DefaultFlags { if _, exists := flagSet[f]; !exists { flagSet[f] = struct{}{} } } } // Resolve (country, regime) for non-working-day adjustment. Court // wins when supplied; otherwise default by proceeding regime. defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) country, regime, err := courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime) if err != nil { return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) } if len(opts.RuleOverrides) > 0 { rules = ApplyRuleOverrides(rules, opts.RuleOverrides) } // ruleByID lets the conditional-rendering branches resolve a parent // rule's display fields (submission_code, name, name_en) for the // "abhängig von " chip without re-scanning the rules // slice on every iteration. (t-paliad-289) ruleByID := make(map[uuid.UUID]Rule, len(rules)) for _, r := range rules { 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) — 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 := catalog.LoadTriggerEventsByIDs(ctx, triggerIDs) if err != nil { return nil, fmt.Errorf("load trigger events for conditional labels: %w", err) } // Walk the rule list in sequence_order (already sorted by the // catalog query) and compute each entry, keeping a code→date map so // RelativeTo / parent_id references resolve to the adjusted // predecessor date. computed := make(map[string]time.Time, len(rules)) courtSet := make(map[uuid.UUID]bool, len(rules)) deadlines := make([]TimelineEntry, 0, len(rules)) skipRules := opts.SkipRules perCardAppellant := opts.PerCardAppellant skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules)) hiddenCount := 0 appellantContext := make(map[uuid.UUID]string, len(rules)) for _, r := range rules { // Phase-3 unified gate: evaluate condition_expr (jsonb). // Suppression semantic preserved: when the gate fires false // AND no alt_* values exist, the rule is dropped from the // timeline entirely (purely conditional). When alt_* values // exist, the gate-false branch still renders, just without // the alt-swap. gateMet := EvalConditionExpr([]byte(r.ConditionExpr), flagSet) if !gateMet && r.AltDurationValue == nil { continue } // SkipRules suppression (t-paliad-265). // t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set, // we re-surface the directly-skipped row (faded via IsHidden) // instead of dropping it. var isHidden bool if r.SubmissionCode != nil { if _, skipped := skipRules[*r.SubmissionCode]; skipped { hiddenCount++ if !opts.IncludeHidden { skippedIDs[r.ID] = struct{}{} continue } isHidden = true } } if r.ParentID != nil { if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped { skippedIDs[r.ID] = struct{}{} continue } } // AppellantContext propagation. A rule with its own // PerCardAppellant pick stamps its UUID with that value. // Otherwise inherit from parent if the parent had a context. var ctxVal string if r.SubmissionCode != nil { if v, ok := perCardAppellant[*r.SubmissionCode]; ok { ctxVal = v } } if ctxVal == "" && r.ParentID != nil { if v, ok := appellantContext[*r.ParentID]; ok { ctxVal = v } } if ctxVal != "" { appellantContext[r.ID] = ctxVal } d := TimelineEntry{ RuleID: r.ID.String(), Name: r.Name, NameEN: r.NameEN, Priority: r.Priority, ConditionExpr: json.RawMessage(r.ConditionExpr), AppellantContext: ctxVal, ChoicesOffered: json.RawMessage(r.ChoicesOffered), IsHidden: isHidden, } if r.SubmissionCode != nil { d.Code = *r.SubmissionCode } if r.PrimaryParty != nil { d.Party = *r.PrimaryParty } if r.RuleCode != nil { d.RuleRef = *r.RuleCode } if r.LegalSource != nil { d.LegalSource = *r.LegalSource d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) } if r.DeadlineNotes != nil { d.Notes = *r.DeadlineNotes } if r.DeadlineNotesEn != nil { d.NotesEN = *r.DeadlineNotesEn } // Resolve the parent rule once so every conditional-rendering // branch (incl. the optional-not-recorded path below) can stamp // ParentRule* on the wire without re-scanning. Populated even // for non-conditional rows — the frontend dependency-footer // ("Folgt aus …") already consumes this on regular projected // rows. (t-paliad-289) var parentRule *Rule if r.ParentID != nil { if pr, ok := ruleByID[*r.ParentID]; ok { parentRule = &pr if pr.SubmissionCode != nil { d.ParentRuleCode = *pr.SubmissionCode } d.ParentRuleName = pr.Name d.ParentRuleNameEN = pr.NameEN } } // 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. Only the user-facing wire fields shift; // parentRule (and the parent_id chain feeding parentIsCourtSet // and the calc-time arithmetic below) stays anchored on the // rule tree. 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 // has supplied an override date for the parent. parentOverridden := false if r.ParentID != nil && courtSet[*r.ParentID] { for _, prev := range rules { if prev.ID == *r.ParentID { if prev.SubmissionCode != nil { if _, ok := overrideDates[*prev.SubmissionCode]; ok { parentOverridden = true } } break } } } parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden // Zero-duration rules fall into one of four buckets: // 1. parent=nil, not court-determined → IsRootEvent (trigger anchor) // 2. parent=nil, court-determined → IsCourtSet // 3. parent set, court-determined → IsCourtSet (waypoint) // 4. parent set, NOT court-determined → "filed-with-parent" // // AnchorOverrides: when the user has set a date for any zero- // duration rule, that override wins over both the court-set // placeholder and the parent-inheritance. if r.DurationValue == 0 { if r.SubmissionCode != nil { if ov, ok := overrideDates[*r.SubmissionCode]; ok { d.DueDate = ov.Format("2006-01-02") d.OriginalDate = d.DueDate d.IsOverridden = true computed[*r.SubmissionCode] = ov deadlines = append(deadlines, d) continue } } if r.ParentID == nil && !r.IsCourtSet { // Bucket 1: timeline anchor. d.IsRootEvent = true d.DueDate = triggerDateStr d.OriginalDate = triggerDateStr if r.SubmissionCode != nil { computed[*r.SubmissionCode] = triggerDate } } else if r.ParentID != nil && !r.IsCourtSet { // Bucket 4: filed-with-parent. Inherit parent's date. if parentIsCourtSet { // Indirect: rule isn't itself court-determined, // it's blocked because its parent is. d.IsCourtSet = true d.IsCourtSetIndirect = true d.IsConditional = true d.DueDate = "" d.OriginalDate = "" courtSet[r.ID] = true } else { var parentDate time.Time var haveParentDate bool for _, prev := range rules { if prev.ID == *r.ParentID { if prev.SubmissionCode != nil { if ov, ok := overrideDates[*prev.SubmissionCode]; ok { parentDate = ov haveParentDate = true } else if ref, ok := computed[*prev.SubmissionCode]; ok { parentDate = ref haveParentDate = true } } break } } if haveParentDate { d.DueDate = parentDate.Format("2006-01-02") d.OriginalDate = d.DueDate if r.SubmissionCode != nil { computed[*r.SubmissionCode] = parentDate } } else { // Parent not yet computed (defensive). d.IsCourtSet = true d.IsCourtSetIndirect = true d.IsConditional = true d.DueDate = "" d.OriginalDate = "" courtSet[r.ID] = true } } } else { // Buckets 2 + 3: court-determined directly. d.IsCourtSet = true d.DueDate = "" d.OriginalDate = "" courtSet[r.ID] = true } deadlines = append(deadlines, d) continue } // If the parent is court-determined and not overridden we have // no real anchor date; surface this rule as court-set too // rather than fabricating one off the trigger date. IsConditional // surfaces the "abhängig von " UX (t-paliad-289). if parentIsCourtSet { d.IsCourtSet = true d.IsCourtSetIndirect = true d.IsConditional = true d.DueDate = "" d.OriginalDate = "" courtSet[r.ID] = true deadlines = append(deadlines, d) continue } // Anchor: prefer alt-anchor (e.g. priority_date for // epa.grant.exa publish) when supplied, then parent's computed // date (or user override), then trigger date. baseDate := triggerDate if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil { baseDate = *priorityDate } else if r.ParentID != nil { for _, prev := range rules { if prev.ID == *r.ParentID { if prev.SubmissionCode != nil { if ov, ok := overrideDates[*prev.SubmissionCode]; ok { baseDate = ov } else if ref, ok := computed[*prev.SubmissionCode]; ok { baseDate = ref } } break } } } // Flag-conditioned alt-swap (legacy with_ccr pattern): when the // gate fires AND alt_* values exist, swap the primary duration // to the alt values. This is distinct from combine_op below — // alt-swap is a one-or-the-other choice keyed on flags, whereas // combine_op computes both legs and picks max/min. durationValue := r.DurationValue durationUnit := r.DurationUnit timing := "" if r.Timing != nil { timing = *r.Timing } if r.CombineOp == nil && gateMet && HasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil { durationValue = *r.AltDurationValue if r.AltDurationUnit != nil { durationUnit = *r.AltDurationUnit } if r.AltRuleCode != nil { d.RuleRef = *r.AltRuleCode } } // User override on this rule: replace the calculated date with // the user's date. Skip holiday rollover — the user's date is // authoritative. if r.SubmissionCode != nil { if ov, ok := overrideDates[*r.SubmissionCode]; ok { d.OriginalDate = ov.Format("2006-01-02") d.DueDate = ov.Format("2006-01-02") d.WasAdjusted = false d.AdjustmentReason = nil d.IsOverridden = true computed[*r.SubmissionCode] = ov deadlines = append(deadlines, d) continue } } origDate, adjusted, wasAdj, reason := ApplyDuration( baseDate, durationValue, durationUnit, timing, country, regime, holidays, ) // combine_op composite: compute the alt leg too, apply max/min. if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { altOrig, altAdj, altWasAdj, altReason := ApplyDuration( baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays, ) switch *r.CombineOp { case "max": if altAdj.After(adjusted) { origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason } case "min": if altAdj.Before(adjusted) { origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason } } } d.OriginalDate = origDate.Format("2006-01-02") d.DueDate = adjusted.Format("2006-01-02") d.WasAdjusted = wasAdj d.AdjustmentReason = reason // Optional-on-the-other-side detection (t-paliad-289 Symptom B). // Rules with priority='optional' AND primary_party='both' whose // data-model parent is the proceeding's trigger anchor (parent // has parent_id=NULL and is not court-set, i.e. the SoC root // rule) represent a rule whose REAL triggering event sits // outside the rule data — e.g. R.262(2) Erwiderung auf // Vertraulichkeitsantrag anchors on SoC in the data, but the // real trigger is the opposing party's confidentiality motion // which may never happen. Without an explicit anchor on the // rule itself, the projection must NOT claim a concrete date. if !d.IsOverridden && !d.IsConditional && r.Priority == "optional" && r.PrimaryParty != nil && *r.PrimaryParty == "both" && parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet { d.IsConditional = true d.DueDate = "" d.OriginalDate = "" d.WasAdjusted = false d.AdjustmentReason = nil // Mark this rule's ID as having an uncertain anchor so // rules chaining off it also surface conditional via the // parentIsCourtSet path. courtSet[r.ID] = true } if r.SubmissionCode != nil { computed[*r.SubmissionCode] = adjusted } deadlines = append(deadlines, d) } // t-paliad-296: within consecutive runs of rules sharing the same // trigger group (parent_id + trigger_event_id), reorder by duration // ascending so optional events following the same anchor render in // their likely-sequence order. Different trigger groups keep their // proceeding-sequence position — the chunk walk only sorts adjacent // same-group rows. Court-set / conditional rows sort LAST. sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) resp := &Timeline{ ProceedingType: pickedProceeding.Code, ProceedingName: pickedProceeding.Name, ProceedingNameEN: pickedProceeding.NameEN, TriggerDate: triggerDateStr, Deadlines: deadlines, HiddenCount: hiddenCount, } // Sub-track routing keeps the user-picked proceeding's identity, // so the trigger-event label rides on `pickedProceeding`. if pickedProceeding.TriggerEventLabelDE != nil { resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE } if pickedProceeding.TriggerEventLabelEN != nil { resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN } if hasSubTrackNote { resp.ContextualNote = subTrackNote.NoteDE resp.ContextualNoteEN = subTrackNote.NoteEN } return resp, nil } // calculateByTriggerEvent renders the Pipeline-C timeline for an event // trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id // chains), have no flag gating, no priority_date alt-anchor, no party // classification, and no IsRootEvent / IsCourtSet semantics. The math // is just: base + (timing-signed) duration → optional alt-leg combine // → optional weekend/holiday rollover for calendar units. // // Timeline.ProceedingType / ProceedingName stay empty — // EventDeadlineService owns the trigger-event metadata. func calculateByTriggerEvent( ctx context.Context, triggerEventID int64, triggerDateStr string, opts CalcOptions, catalog Catalog, holidays HolidayCalendar, courts CourtRegistry, ) (*Timeline, error) { triggerDate, err := time.Parse("2006-01-02", triggerDateStr) if err != nil { return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err) } // Pipeline-C rules originate from youpc's UPC-flavoured deadline // corpus — DE / UPC defaults match the legacy EventDeadlineService. country, regime, err := courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC) if err != nil { return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err) } rules, err := catalog.LoadRulesByTriggerEvent(ctx, triggerEventID) if err != nil { return nil, err } if len(opts.RuleOverrides) > 0 { rules = ApplyRuleOverrides(rules, opts.RuleOverrides) } deadlines := make([]TimelineEntry, 0, len(rules)) for _, r := range rules { timing := "" if r.Timing != nil { timing = *r.Timing } baseRaw, baseAdj, baseChanged, baseReason := ApplyDuration( triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, holidays, ) picked := baseAdj original := baseRaw wasAdj := baseChanged reason := baseReason if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil { altRaw, altAdj, altChanged, altReason := ApplyDuration( triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays, ) switch *r.CombineOp { case "max": if altAdj.After(baseAdj) { picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason } case "min": if altAdj.Before(baseAdj) { picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason } } } d := TimelineEntry{ RuleID: r.ID.String(), Name: r.Name, NameEN: r.NameEN, Priority: r.Priority, ConditionExpr: json.RawMessage(r.ConditionExpr), DueDate: picked.Format("2006-01-02"), OriginalDate: original.Format("2006-01-02"), WasAdjusted: wasAdj, AdjustmentReason: reason, } if r.SubmissionCode != nil { d.Code = *r.SubmissionCode } if r.PrimaryParty != nil { d.Party = *r.PrimaryParty } if r.RuleCode != nil { d.RuleRef = *r.RuleCode } if r.LegalSource != nil { d.LegalSource = *r.LegalSource d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource) d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource) } if r.DeadlineNotes != nil { d.Notes = *r.DeadlineNotes } if r.DeadlineNotesEn != nil { d.NotesEN = *r.DeadlineNotesEn } deadlines = append(deadlines, d) } return &Timeline{ // Trigger-event responses don't carry proceeding metadata — // EventDeadlineService.Calculate fills the trigger fields in // the legacy CalculateResponse shape. Leaving these empty is // the stable contract. ProceedingType: "", ProceedingName: "", TriggerDate: triggerDateStr, Deadlines: deadlines, }, nil } // CalculateRule computes a single deadline from a rule + trigger date. // Used by the v4 result-card click flow. Distinct from Calculate: no // parent-chain walk, no full-timeline rendering — just one date out. // // When the rule is court-determined, DueDate is empty and // IsCourtSet=true; the caller should disable the "Add to project" CTA. // // When the rule has a condition_expr gate and the caller's Flags // satisfy it AND alt_duration_value is non-NULL, the calc swaps to // alt_*. When the gate is not satisfied, the calc still proceeds with // the base duration_value and surfaces FlagsRequired. func CalculateRule( ctx context.Context, params CalcRuleParams, catalog Catalog, holidays HolidayCalendar, courts CourtRegistry, ) (*RuleCalculation, error) { triggerDate, err := time.Parse("2006-01-02", params.TriggerDate) if err != nil { return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err) } rule, pt, err := resolveRule(ctx, params, catalog) if err != nil { return nil, err } mandWire, _ := wireFlagsFromPriority(rule.Priority) out := &RuleCalculation{ Rule: RuleCalculationRule{ ID: rule.ID.String(), NameDE: rule.Name, NameEN: rule.NameEN, DurationValue: rule.DurationValue, DurationUnit: rule.DurationUnit, IsMandatory: mandWire, }, Proceeding: RuleCalculationProceeding{ Code: pt.Code, NameDE: pt.Name, NameEN: pt.NameEN, }, TriggerDate: params.TriggerDate, } if rule.SubmissionCode != nil { out.Rule.LocalCode = *rule.SubmissionCode } if rule.RuleCode != nil { out.Rule.RuleRef = *rule.RuleCode } if rule.LegalSource != nil { out.Rule.LegalSource = *rule.LegalSource out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource) out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource) } if rule.PrimaryParty != nil { out.Rule.Party = *rule.PrimaryParty } if rule.DeadlineNotes != nil { out.Rule.NotesDE = *rule.DeadlineNotes } if rule.DeadlineNotesEn != nil { out.Rule.NotesEN = *rule.DeadlineNotesEn } // Slice 9 (t-paliad-195) replacement for the dropped condition_flag // text[] enumeration: walk the jsonb gate to pull out flag-leaf // names. Returns nil on an unconditional rule. out.FlagsRequired = ExtractFlagsFromExpr(rule.ConditionExpr) // Court-determined: no calculable date. if rule.IsCourtSet { out.IsCourtSet = true return out, nil } // Resolve flag-conditional duration via the unified condition_expr // evaluator. flagSet := make(map[string]struct{}, len(params.Flags)) for _, f := range params.Flags { flagSet[f] = struct{}{} } durationValue := rule.DurationValue durationUnit := rule.DurationUnit gateMet := EvalConditionExpr([]byte(rule.ConditionExpr), flagSet) if gateMet && HasConditionExpr(rule.ConditionExpr) { out.FlagsApplied = out.FlagsRequired if rule.AltDurationValue != nil { durationValue = *rule.AltDurationValue } if rule.AltDurationUnit != nil { durationUnit = *rule.AltDurationUnit } if rule.AltRuleCode != nil { out.Rule.RuleRef = *rule.AltRuleCode } } // Zero-duration non-court-determined rules are "filed at the same // time as parent" markers: effectively mean "due on the trigger // date itself". if durationValue == 0 { out.OriginalDate = params.TriggerDate out.DueDate = params.TriggerDate return out, nil } defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction) country, regime, err := courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime) if err != nil { return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err) } timing := "" if rule.Timing != nil { timing = *rule.Timing } endDate, adjusted, wasAdj, reason := ApplyDuration( triggerDate, durationValue, durationUnit, timing, country, regime, holidays, ) out.OriginalDate = endDate.Format("2006-01-02") out.DueDate = adjusted.Format("2006-01-02") out.WasAdjusted = wasAdj out.AdjustmentReason = reason return out, nil } // resolveRule resolves CalcRuleParams to a rule + its proceeding type. // Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The // frontend uses the latter form (it has the pill context) and the // programmatic / test caller can use the former. func resolveRule(ctx context.Context, params CalcRuleParams, catalog Catalog) (*Rule, *ProceedingType, error) { if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") { return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required") } if params.RuleID != "" { rule, err := catalog.LoadRuleByID(ctx, params.RuleID) if err != nil { return nil, nil, err } if rule.ProceedingTypeID == nil { return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID) } pt, err := catalog.LoadProceedingByID(ctx, *rule.ProceedingTypeID) if err != nil { return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err) } return rule, pt, nil } rule, pt, err := catalog.LoadRuleByCode(ctx, params.ProceedingCode, params.RuleLocalCode) if err != nil { return nil, nil, err } return rule, pt, nil } // ApplyRuleOverrides replaces rules whose ID appears in `overrides` // with the override row, and appends any override whose ID isn't in // the source list (net-new drafts the rule editor wants to preview). // // Used by the Slice 11a (t-paliad-191) preview endpoint: the editor // passes the draft as an override so Calculate runs against the // proposed shape without writing to the DB. Empty overrides slice = // pass-through. func ApplyRuleOverrides(src, overrides []Rule) []Rule { if len(overrides) == 0 { return src } byID := make(map[uuid.UUID]Rule, len(overrides)) for _, o := range overrides { byID[o.ID] = o } out := make([]Rule, 0, len(src)+len(overrides)) seen := make(map[uuid.UUID]bool, len(overrides)) for _, r := range src { if ov, ok := byID[r.ID]; ok { out = append(out, ov) seen[ov.ID] = true continue } out = append(out, r) } for _, o := range overrides { if seen[o.ID] { continue } out = append(out, o) } return out } // wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional) // pair from the unified priority enum so the wire shape stays // pixel-identical. Mapping mirrors mig 083's backfill (per design §2.3): // // 'mandatory' → (true, false) // 'optional' → (true, true) // 'recommended' → (false, false) // 'informational' → (false, false) // (unknown) → (true, false) func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { switch priority { case "mandatory": return true, false case "optional": return true, true case "recommended": return false, false case "informational": return false, false default: return true, false } } // AllFlagsSet is retained as a tiny utility for callers that have a // flat list of flag strings + a flag-set lookup. The new condition_expr // gate is the canonical evaluator; this helper exists for forward- // compat with any future caller that wants the legacy AND-over-list // semantic without rebuilding the jsonb. func AllFlagsSet(required []string, set map[string]struct{}) bool { return allFlagsSet(required, set) } // WireFlagsFromPriority is the public form of wireFlagsFromPriority so // the paliad-side test suite (which historically asserted the mapping // directly) can still test the contract. func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { return wireFlagsFromPriority(priority) }