package services import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/paliad/internal/models" lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // RuleEditorService owns the admin-only rule lifecycle for Phase 3 // Slice 11a (t-paliad-191). m's Q5 option C ruling: "C please — I need // to see these things. Admin only, ofc." // // Lifecycle (mig 078 lifecycle_state enum): // // - draft — admin work-in-progress. Calculator does NOT include // these in any user-facing surface (the SELECT filters // lifecycle_state='published' or the equivalent). The // admin previewer is the only reader. // - published — live, calculator-visible, the corpus the rest of // Paliad runs on. // - archived — historical, kept for audit. The Restore op flips // archived → published; the Publish flow archives // the cloned-from source so each rule_code has at // most one live row. // // All writes set paliad.audit_reason via set_config in the same tx // before the UPDATE so the mig 079 audit trigger captures the // rationale forever. The reason is mandatory on every write. // // Spawn cycle guard: edits that change spawn_proceeding_type_id are // pre-validated against the global rule graph. A draft that would // create a cycle when published returns ErrCyclicSpawn rather than // allowing the write — the guard fires server-side before the row // hits the DB. type RuleEditorService struct { db *sqlx.DB rules *DeadlineRuleService } // NewRuleEditorService wires the service to its dependencies. func NewRuleEditorService(db *sqlx.DB, rules *DeadlineRuleService) *RuleEditorService { return &RuleEditorService{db: db, rules: rules} } // Typed errors surfaced to handlers (mapped to HTTP statuses). var ( // ErrRuleNotFound — UUID didn't resolve to an existing row. ErrRuleNotFound = errors.New("rule not found") // ErrInvalidLifecycleState — caller asked for a transition that // the current lifecycle_state doesn't allow (e.g. PATCH a // published row, Publish a non-draft row, Restore a non-archived // row, etc.). 409 Conflict in the handler. ErrInvalidLifecycleState = errors.New("invalid lifecycle state for this operation") // ErrAuditReasonRequired — write came in without a non-empty // reason. 400 in the handler. ErrAuditReasonRequired = errors.New("audit_reason required for rule-editor writes") ) // RulePatch is the partial-update payload for UpdateDraft. // Only fields the editor allows to change are exposed; system-managed // fields (id, created_at, lifecycle_state itself, draft_of, // published_at) are NOT in this struct — lifecycle transitions go // through the dedicated methods. type RulePatch struct { Name *string `json:"name,omitempty"` NameEN *string `json:"name_en,omitempty"` Description *string `json:"description,omitempty"` PrimaryParty *string `json:"primary_party,omitempty"` EventType *string `json:"event_type,omitempty"` DurationValue *int `json:"duration_value,omitempty"` DurationUnit *string `json:"duration_unit,omitempty"` Timing *string `json:"timing,omitempty"` AltDurationValue *int `json:"alt_duration_value,omitempty"` AltDurationUnit *string `json:"alt_duration_unit,omitempty"` AltRuleCode *string `json:"alt_rule_code,omitempty"` AnchorAlt *string `json:"anchor_alt,omitempty"` CombineOp *string `json:"combine_op,omitempty"` RuleCode *string `json:"rule_code,omitempty"` LegalSource *string `json:"legal_source,omitempty"` DeadlineNotes *string `json:"deadline_notes,omitempty"` DeadlineNotesEn *string `json:"deadline_notes_en,omitempty"` Priority *string `json:"priority,omitempty"` IsCourtSet *bool `json:"is_court_set,omitempty"` IsSpawn *bool `json:"is_spawn,omitempty"` SpawnLabel *string `json:"spawn_label,omitempty"` SpawnProceedingTypeID *int `json:"spawn_proceeding_type_id,omitempty"` TriggerEventID *int64 `json:"trigger_event_id,omitempty"` ConditionExpr json.RawMessage `json:"condition_expr,omitempty"` SequenceOrder *int `json:"sequence_order,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"` ConceptID *uuid.UUID `json:"concept_id,omitempty"` } // CreateRuleInput is the create payload — a full rule row in draft // state. Required fields enforce schema NOT-NULL on insert (name, // name_en, duration_value, duration_unit). type CreateRuleInput struct { Name string `json:"name"` NameEN string `json:"name_en"` ProceedingTypeID *int `json:"proceeding_type_id,omitempty"` TriggerEventID *int64 `json:"trigger_event_id,omitempty"` ParentID *uuid.UUID `json:"parent_id,omitempty"` ConceptID *uuid.UUID `json:"concept_id,omitempty"` SubmissionCode *string `json:"submission_code,omitempty"` PrimaryParty *string `json:"primary_party,omitempty"` EventType *string `json:"event_type,omitempty"` DurationValue int `json:"duration_value"` DurationUnit string `json:"duration_unit"` Timing *string `json:"timing,omitempty"` AltDurationValue *int `json:"alt_duration_value,omitempty"` AltDurationUnit *string `json:"alt_duration_unit,omitempty"` AltRuleCode *string `json:"alt_rule_code,omitempty"` AnchorAlt *string `json:"anchor_alt,omitempty"` CombineOp *string `json:"combine_op,omitempty"` RuleCode *string `json:"rule_code,omitempty"` LegalSource *string `json:"legal_source,omitempty"` DeadlineNotes *string `json:"deadline_notes,omitempty"` DeadlineNotesEn *string `json:"deadline_notes_en,omitempty"` Priority string `json:"priority"` IsCourtSet bool `json:"is_court_set"` IsSpawn bool `json:"is_spawn"` SpawnLabel *string `json:"spawn_label,omitempty"` SpawnProceedingTypeID *int `json:"spawn_proceeding_type_id,omitempty"` ConditionExpr json.RawMessage `json:"condition_expr,omitempty"` SequenceOrder int `json:"sequence_order"` } // Create inserts a new rule as lifecycle_state='draft' with // published_at=NULL. The caller's reason is set on the session BEFORE // the INSERT so the mig 079 trigger writes an audit row with the // rationale. func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, reason string) (*models.DeadlineRule, error) { if strings.TrimSpace(reason) == "" { return nil, ErrAuditReasonRequired } if strings.TrimSpace(input.Name) == "" || strings.TrimSpace(input.NameEN) == "" { return nil, fmt.Errorf("%w: name + name_en required on create", ErrInvalidInput) } if strings.TrimSpace(input.Priority) == "" { input.Priority = "mandatory" } // Slice B3 (m/paliad#124 §18.3, mig 135): canonical four-value // primary_party vocab. Pre-validate so the user gets a // user-friendly error before the DB CHECK fires with the raw // constraint-violation message. if input.PrimaryParty != nil && !lp.IsValidPrimaryParty(*input.PrimaryParty) { return nil, fmt.Errorf( "%w: primary_party=%q is not one of %v", ErrInvalidInput, *input.PrimaryParty, lp.PrimaryParties, ) } if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil { return nil, err } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() if err := setAuditReasonTx(ctx, tx, reason); err != nil { return nil, err } id := uuid.New() // Slice 9 (t-paliad-195) dropped is_mandatory / is_optional / // condition_flag / condition_rule_id from the schema. The INSERT // here writes the live shape only — priority + condition_expr // + is_court_set are the new gates. if _, err := tx.ExecContext(ctx, `INSERT INTO paliad.deadline_rules (id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code, name, name_en, description, primary_party, event_type, duration_value, duration_unit, timing, alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op, rule_code, legal_source, deadline_notes, deadline_notes_en, priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id, condition_expr, sequence_order, is_active, lifecycle_state, draft_of, published_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, true, 'draft', NULL, NULL, now(), now())`, id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.SubmissionCode, input.Name, input.NameEN, input.PrimaryParty, input.EventType, input.DurationValue, input.DurationUnit, input.Timing, input.AltDurationValue, input.AltDurationUnit, input.AltRuleCode, input.AnchorAlt, input.CombineOp, input.RuleCode, input.LegalSource, input.DeadlineNotes, input.DeadlineNotesEn, input.Priority, input.IsCourtSet, input.IsSpawn, input.SpawnLabel, input.SpawnProceedingTypeID, nullableJSON(input.ConditionExpr), input.SequenceOrder, ); err != nil { return nil, fmt.Errorf("insert rule: %w", err) } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit create: %w", err) } return s.getByID(ctx, id) } // UpdateDraft applies a partial patch to a rule in lifecycle_state= // 'draft'. Published or archived rows cannot be patched directly — // the caller must CloneAsDraft first. func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch RulePatch, reason string) (*models.DeadlineRule, error) { if strings.TrimSpace(reason) == "" { return nil, ErrAuditReasonRequired } current, err := s.getByID(ctx, id) if err != nil { return nil, err } if current.LifecycleState != "draft" { return nil, fmt.Errorf("%w: rule %s is %s, must be draft to patch (clone first)", ErrInvalidLifecycleState, id, current.LifecycleState) } // Slice B3 (m/paliad#124 §18.3, mig 135): pre-validate the // patch's primary_party so the user gets a user-friendly error // before the DB CHECK fires with the raw constraint-violation // message. Patch field is *string — nil means "don't change", // dereferenced empty string means "set to NULL" (handled below // in buildPatchSets). if patch.PrimaryParty != nil && !lp.IsValidPrimaryParty(*patch.PrimaryParty) { return nil, fmt.Errorf( "%w: primary_party=%q is not one of %v", ErrInvalidInput, *patch.PrimaryParty, lp.PrimaryParties, ) } // Spawn cycle guard: if the patch sets spawn_proceeding_type_id, // validate against the global graph BEFORE the UPDATE so we can // surface the cycle clearly instead of relying on a runtime // projection failure. if patch.SpawnProceedingTypeID != nil { if err := s.validateSpawnNoCycle(ctx, &id, patch.SpawnProceedingTypeID, current.ProceedingTypeID); err != nil { return nil, err } } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() if err := setAuditReasonTx(ctx, tx, reason); err != nil { return nil, err } sets, args := buildPatchSets(patch) if len(sets) == 0 { return current, nil // no-op patch; don't fire the audit trigger } sets = append(sets, fmt.Sprintf("updated_at = $%d", len(args)+1)) args = append(args, time.Now().UTC()) args = append(args, id) q := fmt.Sprintf( `UPDATE paliad.deadline_rules SET %s WHERE id = $%d AND lifecycle_state = 'draft'`, strings.Join(sets, ", "), len(args)) if _, err := tx.ExecContext(ctx, q, args...); err != nil { return nil, fmt.Errorf("update rule draft: %w", err) } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit update: %w", err) } return s.getByID(ctx, id) } // CloneAsDraft creates a new lifecycle_state='draft' row that's a // deep-copy of the source rule (published or archived), with draft_of // pointing back at the source. Lets editors propose changes to live // rules without mutating the live row. func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) { if strings.TrimSpace(reason) == "" { return nil, ErrAuditReasonRequired } src, err := s.getByID(ctx, id) if err != nil { return nil, err } if src.LifecycleState == "draft" { return nil, fmt.Errorf("%w: rule %s is already a draft", ErrInvalidLifecycleState, id) } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() if err := setAuditReasonTx(ctx, tx, reason); err != nil { return nil, err } newID := uuid.New() if _, err := tx.ExecContext(ctx, `INSERT INTO paliad.deadline_rules (id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code, name, name_en, description, primary_party, event_type, duration_value, duration_unit, timing, alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op, rule_code, legal_source, deadline_notes, deadline_notes_en, priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id, condition_expr, sequence_order, is_active, lifecycle_state, draft_of, published_at, created_at, updated_at) SELECT $1, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code, name, name_en, description, primary_party, event_type, duration_value, duration_unit, timing, alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op, rule_code, legal_source, deadline_notes, deadline_notes_en, priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id, condition_expr, sequence_order, is_active, 'draft', $2, NULL, now(), now() FROM paliad.deadline_rules WHERE id = $2`, newID, id, ); err != nil { return nil, fmt.Errorf("clone rule as draft: %w", err) } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit clone: %w", err) } return s.getByID(ctx, newID) } // Publish flips a draft to published, sets published_at=now(), and — // if the draft was cloned from a published peer — archives that peer // so each rule_code has at most one live row. func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) { if strings.TrimSpace(reason) == "" { return nil, ErrAuditReasonRequired } current, err := s.getByID(ctx, id) if err != nil { return nil, err } if current.LifecycleState != "draft" { return nil, fmt.Errorf("%w: only drafts can be published (rule %s is %s)", ErrInvalidLifecycleState, id, current.LifecycleState) } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() if err := setAuditReasonTx(ctx, tx, reason); err != nil { return nil, err } now := time.Now().UTC() if _, err := tx.ExecContext(ctx, `UPDATE paliad.deadline_rules SET lifecycle_state = 'published', published_at = $1, updated_at = $1 WHERE id = $2 AND lifecycle_state = 'draft'`, now, id, ); err != nil { return nil, fmt.Errorf("publish draft: %w", err) } // Archive the peer this draft was cloned from, if any. if current.DraftOf != nil { if _, err := tx.ExecContext(ctx, `UPDATE paliad.deadline_rules SET lifecycle_state = 'archived', updated_at = $1 WHERE id = $2 AND lifecycle_state = 'published'`, now, *current.DraftOf, ); err != nil { return nil, fmt.Errorf("archive cloned-from source: %w", err) } } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit publish: %w", err) } return s.getByID(ctx, id) } // Archive flips lifecycle_state to 'archived'. Both published and // draft rules can be archived (a draft might be abandoned without // publishing). func (s *RuleEditorService) Archive(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) { return s.flipLifecycle(ctx, id, "archived", []string{"published", "draft"}, reason) } // Restore flips lifecycle_state from 'archived' to 'published'. Used // when an editor undoes a previous archive. func (s *RuleEditorService) Restore(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) { return s.flipLifecycle(ctx, id, "published", []string{"archived"}, reason) } func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, target string, allowed []string, reason string) (*models.DeadlineRule, error) { if strings.TrimSpace(reason) == "" { return nil, ErrAuditReasonRequired } current, err := s.getByID(ctx, id) if err != nil { return nil, err } if !containsString(allowed, current.LifecycleState) { return nil, fmt.Errorf("%w: rule %s is %s, cannot flip to %s (allowed: %v)", ErrInvalidLifecycleState, id, current.LifecycleState, target, allowed) } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() if err := setAuditReasonTx(ctx, tx, reason); err != nil { return nil, err } now := time.Now().UTC() // published_at is set on the published flip (Restore from archived) // but NOT touched on Archive — preserving the original publication // timestamp helps audit reads ("when was this rule first live?"). if target == "published" { if _, err := tx.ExecContext(ctx, `UPDATE paliad.deadline_rules SET lifecycle_state = $1, published_at = COALESCE(published_at, $2), updated_at = $2 WHERE id = $3`, target, now, id, ); err != nil { return nil, fmt.Errorf("flip lifecycle to %s: %w", target, err) } } else { if _, err := tx.ExecContext(ctx, `UPDATE paliad.deadline_rules SET lifecycle_state = $1, updated_at = $2 WHERE id = $3`, target, now, id, ); err != nil { return nil, fmt.Errorf("flip lifecycle to %s: %w", target, err) } } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit flip: %w", err) } return s.getByID(ctx, id) } // Preview runs the unified calculator with the given draft rule // substituted for its published peer (or appended if it's a net-new // draft with no peer). No DB write, no audit log; pure simulation // for the editor's "what would this rule do on date X?" affordance. // // Implements design §4.5 + Q-H-4 option (a): in-memory override // passed to Calculate. The peer-discovery walks draft_of → published // chain; if the draft has no peer, the rule is appended so its // effect lights up against the rest of the proceeding's rules. func (s *RuleEditorService) Preview(ctx context.Context, fristen *FristenrechnerService, id uuid.UUID, triggerDate string, flags []string, courtID string) (*UIResponse, error) { draft, err := s.getByID(ctx, id) if err != nil { return nil, err } if draft.LifecycleState != "draft" { return nil, fmt.Errorf("%w: preview only operates on drafts (rule %s is %s)", ErrInvalidLifecycleState, id, draft.LifecycleState) } if draft.ProceedingTypeID == nil { return nil, fmt.Errorf("%w: draft has no proceeding_type_id — preview needs a proceeding context", ErrInvalidInput) } // Resolve proceeding code for the Calculate call. var proceedingCode string if err := s.db.GetContext(ctx, &proceedingCode, `SELECT code FROM paliad.proceeding_types WHERE id = $1 AND is_active = true`, *draft.ProceedingTypeID); err != nil { return nil, fmt.Errorf("resolve proceeding code: %w", err) } // The override slice carries the draft itself; Calculate substitutes // any rule with matching .ID in the proceeding's rule list. If the // draft is cloned-from a published row (draft_of != NULL), the // override replaces THAT row's effect — Calculate sees the draft's // fields in place of the published row, but the draft's own ID is // what shows up in the result. Net-new drafts (draft_of NULL) get // appended so they take effect as new rules. overrides := []models.DeadlineRule{*draft} if draft.DraftOf != nil { // Make the draft's ID match the peer's so the override // substitutes in place. Saves a callback into Calculate // changing the rule_id seen in the response. dup := *draft dup.ID = *draft.DraftOf overrides[0] = dup } return fristen.Calculate(ctx, proceedingCode, triggerDate, CalcOptions{ Flags: flags, CourtID: courtID, RuleOverrides: overrides, }) } // RuleAuditEntry mirrors the paliad.deadline_rule_audit row + a friendly // changed_by display name from paliad.users (NULL on system writes). // Distinct from services.AuditEntry (the cross-source union for the // site-wide audit panel) — this one is rule-editor-specific. type RuleAuditEntry struct { models.DeadlineRuleAudit ChangedByDisplayName *string `db:"changed_by_display_name" json:"changed_by_display_name,omitempty"` } // ListAudit returns paliad.deadline_rule_audit rows for a single rule, // newest first, with optional offset/limit pagination. func (s *RuleEditorService) ListAudit(ctx context.Context, ruleID uuid.UUID, offset, limit int) ([]RuleAuditEntry, error) { if limit <= 0 || limit > 200 { limit = 50 } if offset < 0 { offset = 0 } var rows []RuleAuditEntry if err := s.db.SelectContext(ctx, &rows, ` SELECT a.id, a.rule_id, a.changed_by, a.changed_at, a.action, a.before_json, a.after_json, a.reason, a.migration_exported, u.display_name AS changed_by_display_name FROM paliad.deadline_rule_audit a LEFT JOIN paliad.users u ON u.id = a.changed_by WHERE a.rule_id = $1 ORDER BY a.changed_at DESC LIMIT $2 OFFSET $3`, ruleID, limit, offset); err != nil { return nil, fmt.Errorf("list audit for rule %s: %w", ruleID, err) } return rows, nil } // ListRules returns paginated rules for the admin list view, with // optional filters: proceeding_type_id, lifecycle_state, trigger_event_id, // and a fuzzy "q" (matches name OR name_en OR rule_code, ILIKE). type ListRulesFilter struct { ProceedingTypeID *int TriggerEventID *int64 LifecycleState string Query string Offset int Limit int } func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([]models.DeadlineRule, error) { if f.Limit <= 0 || f.Limit > 500 { f.Limit = 100 } if f.Offset < 0 { f.Offset = 0 } var ( conds []string args []any ) addArg := func(v any) string { args = append(args, v) return fmt.Sprintf("$%d", len(args)) } if f.ProceedingTypeID != nil { conds = append(conds, "proceeding_type_id = "+addArg(*f.ProceedingTypeID)) } if f.TriggerEventID != nil { conds = append(conds, "trigger_event_id = "+addArg(*f.TriggerEventID)) } if f.LifecycleState != "" { conds = append(conds, "lifecycle_state = "+addArg(f.LifecycleState)) } if strings.TrimSpace(f.Query) != "" { q := "%" + f.Query + "%" conds = append(conds, "(name ILIKE "+addArg(q)+" OR name_en ILIKE "+addArg(q)+" OR rule_code ILIKE "+addArg(q)+")") } where := "" if len(conds) > 0 { where = "WHERE " + strings.Join(conds, " AND ") } query := `SELECT ` + ruleColumns + ` FROM paliad.deadline_rules ` + where + ` ORDER BY proceeding_type_id NULLS LAST, sequence_order LIMIT ` + addArg(f.Limit) + ` OFFSET ` + addArg(f.Offset) var rows []models.DeadlineRule if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil { return nil, fmt.Errorf("list rules: %w", err) } return rows, nil } // GetByID returns a single rule. Exported so the handler can call it // directly without round-tripping through ListRules. func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) { return s.getByID(ctx, id) } func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) { var r models.DeadlineRule err := s.db.GetContext(ctx, &r, `SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE id = $1`, id) if errors.Is(err, sql.ErrNoRows) { return nil, ErrRuleNotFound } if err != nil { return nil, fmt.Errorf("get rule %s: %w", id, err) } return &r, nil } // ============================================================================= // Internal helpers // ============================================================================= // setAuditReasonTx writes the audit reason into the session-local // paliad.audit_reason setting via set_config(name, value, is_local=true). // The mig 079 trigger reads it via current_setting('paliad.audit_reason', true). func setAuditReasonTx(ctx context.Context, tx *sqlx.Tx, reason string) error { if _, err := tx.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', $1, true)`, reason); err != nil { return fmt.Errorf("set audit_reason: %w", err) } return nil } // validateSpawnNoCycle checks that spawning from `sourceProceedingID` // (the rule's proceeding) into `targetProceedingID` doesn't create a // cycle in the global rule graph. Reuses the design §6 cycle-guard // semantics: walk the target's spawn rules transitively; if any of // them spawn back to sourceProceedingID (or to a proceeding already in // the chain), refuse. // // Skipped when either side is nil (no spawn intent or no source // context). The ruleID parameter is used to exclude the rule itself // from the walk so an edit that already had a spawn doesn't see // itself as the cycle source. func (s *RuleEditorService) validateSpawnNoCycle(ctx context.Context, ruleID *uuid.UUID, target *int, source *int) error { if target == nil || source == nil { return nil } if *target == *source { return fmt.Errorf("%w: cannot spawn into the same proceeding", ErrCyclicSpawn) } // Walk the target proceeding's spawn rules. If any of them have a // spawn_proceeding_type_id equal to source, that's the cycle. visited := map[int]bool{*source: true} queue := []int{*target} maxHops := maxSpawnDepth for len(queue) > 0 && maxHops > 0 { maxHops-- current := queue[0] queue = queue[1:] if visited[current] { return fmt.Errorf("%w: edit would create a cycle through proceeding %d", ErrCyclicSpawn, current) } visited[current] = true var nexts []sql.NullInt64 q := `SELECT DISTINCT spawn_proceeding_type_id::bigint FROM paliad.deadline_rules WHERE proceeding_type_id = $1 AND is_spawn = true AND spawn_proceeding_type_id IS NOT NULL AND is_active = true AND lifecycle_state IN ('published', 'draft')` args := []any{current} if ruleID != nil { q += " AND id <> $2" args = append(args, *ruleID) } if err := s.db.SelectContext(ctx, &nexts, q, args...); err != nil { return fmt.Errorf("walk spawn graph from %d: %w", current, err) } for _, n := range nexts { if !n.Valid { continue } queue = append(queue, int(n.Int64)) } } if maxHops == 0 { return fmt.Errorf("%w: spawn graph walk exceeded max depth %d", ErrCyclicSpawn, maxSpawnDepth) } return nil } // buildPatchSets walks the RulePatch and produces (SET clauses, args) // for the UPDATE statement. Order is stable (per-field) so the // generated SQL stays diff-friendly. Returns empty slices when the // patch is empty (caller short-circuits without writing). func buildPatchSets(p RulePatch) (sets []string, args []any) { add := func(col string, val any) { args = append(args, val) sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args))) } if p.Name != nil { add("name", *p.Name) } if p.NameEN != nil { add("name_en", *p.NameEN) } if p.Description != nil { add("description", *p.Description) } if p.PrimaryParty != nil { add("primary_party", *p.PrimaryParty) } if p.EventType != nil { add("event_type", *p.EventType) } if p.DurationValue != nil { add("duration_value", *p.DurationValue) } if p.DurationUnit != nil { add("duration_unit", *p.DurationUnit) } if p.Timing != nil { add("timing", *p.Timing) } if p.AltDurationValue != nil { add("alt_duration_value", *p.AltDurationValue) } if p.AltDurationUnit != nil { add("alt_duration_unit", *p.AltDurationUnit) } if p.AltRuleCode != nil { add("alt_rule_code", *p.AltRuleCode) } if p.AnchorAlt != nil { add("anchor_alt", *p.AnchorAlt) } if p.CombineOp != nil { add("combine_op", *p.CombineOp) } if p.RuleCode != nil { add("rule_code", *p.RuleCode) } if p.LegalSource != nil { add("legal_source", *p.LegalSource) } if p.DeadlineNotes != nil { add("deadline_notes", *p.DeadlineNotes) } if p.DeadlineNotesEn != nil { add("deadline_notes_en", *p.DeadlineNotesEn) } if p.Priority != nil { add("priority", *p.Priority) } if p.IsCourtSet != nil { add("is_court_set", *p.IsCourtSet) } if p.IsSpawn != nil { add("is_spawn", *p.IsSpawn) } if p.SpawnLabel != nil { add("spawn_label", *p.SpawnLabel) } if p.SpawnProceedingTypeID != nil { add("spawn_proceeding_type_id", *p.SpawnProceedingTypeID) } if p.TriggerEventID != nil { add("trigger_event_id", *p.TriggerEventID) } if p.ConditionExpr != nil { add("condition_expr", nullableJSON(p.ConditionExpr)) } if p.SequenceOrder != nil { add("sequence_order", *p.SequenceOrder) } if p.ParentID != nil { add("parent_id", *p.ParentID) } if p.ConceptID != nil { add("concept_id", *p.ConceptID) } return sets, args } // nullableJSON returns nil for empty / "null" raw so the SQL driver // writes NULL into the jsonb column, otherwise the byte slice itself. func nullableJSON(b json.RawMessage) any { if len(b) == 0 || string(b) == "null" { return nil } return []byte(b) }