feat(workflows): mutating actions on transition triggers + per-tenant cycle cap (PMS-467) #345
No reviewers
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
psa-systems/mokosh-server!345
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-467-workflow-mutating-transitions"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PMS-448 phase 1 + phase 2 left a half-built workflow surface:
ticket.createdran mutating actions (assign / set_priority / add_tag / add_internal_note) in-transaction, whileticket.status_changed/ticket.priority_changedonly wroteworkflow_rule_runsaudit rows and refused to mutate. The deferral was intentional because a status-mutating status_changed rule can re-trigger itself indefinitely; phase 3 needed a story for the loop.This change promotes the transition triggers to mutating. An action that itself moves
status_idorpriority_idre-fires the matching trigger at depth + 1; the per-tenantworkflows/rule_max_depthcap (default 3, clamped 1..=10) refuses to fire any rule whose call depth has reached the ceiling and instead writes aworkflow_rule_runsrow whoseerrorquotes the cap so the operator's audit trail captures why the cascade stopped.What landed:
072_workflow_rule_max_depth.sqldocuments the well-known settings key (category='workflows', key='rule_max_depth') and seeds the default for every existing tenant so the row is browseable from the SPA immediately after upgrade. The reader falls back to 3 when the row is absent for new tenants created after this migration.validate_setting_value("workflows", "rule_max_depth")accepts integers in 1..=10. The lower bound (1) is "no cascade beyond the originating transition"; the upper bound (10) prevents a typo from letting a tenant DOS itself with a status-flipping rule.settings::read_workflow_rule_max_depth(tx, tenant_id)reads the value off a rawPgConnectionso the workflow executor can call it from inside its own transaction without nesting abegin_with_tenant(which would deadlock on theapp.current_tenantGUC SET).executor.rssplits the previousapply_actionsinto two flavours.apply_create_actionsruns forticket.createdand does NOT cascade (the create is the originating event; the next mutation is the operator's).apply_transition_actionsruns forticket.status_changed/ticket.priority_changed, applies the non-cascading actions first so they are visible to nested rules, then handlesset_priority_id/set_status_idlast, UPDATEs the column, and re-fires the matching transition trigger viaBox::pin(run_*_at_depth(.., depth + 1, max_depth)). TheBox::pinis the standard escape hatch for direct async recursion (the future would otherwise be infinitely sized).run_ticket_status_changed/run_ticket_priority_changedkeep their public signature; both now read the per-tenant cap at the top of the call and delegate to the internal*_at_depthhelpers. Depth 0 is the operator-originated transition; depthmax_depthis the level at which matching rules are refused.set_status_idaction mirrors the existingset_priority_id. On the create path it's applied without cascade (no status_changed trigger fires from a create); on the transition path it cascades into the status_changed trigger.Integration tests at
tests/workflow_rules_phase3.rs:status_changed_mutating_rule_fires_note: ato_status_idrule withadd_internal_notefires when the matching transition lands and the note row appears inticket_notes.status_changed_self_cascade_hits_depth_cap: two cross-firing rules (A->B and B->A) trigger the cascade, which walks depth 0, 1, 2 and refuses at depth 3 witherror = 'cycle cap reached at depth 3'.priority_changed_non_mutating_rule_fires_once: a tag-only rule onpriority_changedfires exactly once with no depth-cap rows, confirming the non-cascading path is unaffected.Phase 3 follow-ups that did NOT land here, deferred to their own tickets:
set_status_idaction and theworkflows/rule_max_depthknob (no client work in scope on PMS-467).time_entry.created,invoice.paid, richer LIKE / range / NOT IN condition operators) tracked under their own backlog items.#PMS-467
eb19c633a474e08a061b