signoff 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: daf22fb998df2e5bef0e6207a62b5a92706b25ae6d32c29b3be5ec813f5067aa
4
- data.tar.gz: bc00d77c7d53a2aa56883d637d0edcc984e431593acbfbf360ba3f01fe431940
3
+ metadata.gz: 733febbc93f96129a33e8a61d7c9a76e70ae48570ac2db24e244c4f2f494bbf5
4
+ data.tar.gz: 2fda8591c1e65d3fdbccea29174bc3e0a355bdadadf51287784e94ff80588ca1
5
5
  SHA512:
6
- metadata.gz: c7a29fac8c99c70171b22aac59c5b60522ff3bde67b3c81a3a58d8d66943e50d9e4f595012acdf29c7d692d8c638f8b7147af1dd397895c42d1e34f3043e3933
7
- data.tar.gz: 519e9fbfd372cddf725038124762ada8dc54a0fc280dd9c566228fa2e832e854aff5147767ab3b7173eb527417ddfd153536ccaa52e1aac63e0c329cb00fc6e3
6
+ metadata.gz: f23635f242f51897f94723519bfb4ed1edb96d652ff5fb2629cab3d20c9b1360f7366b85a643b580379c1abed316725f3ad8470639f885fd8ade0035f8688a1b
7
+ data.tar.gz: 149239b36bbcbe875e4732181a86605af121939ac1404f09adeddc4920f45c76926afb4fd66c34e015301049c892041c272d4a99e5850d9cda71735b5172bf88
data/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-06-23
11
+
12
+ ### Added
13
+
14
+ - **Custom action verbs.** Declare a named action inside the `signoff` block —
15
+ `action(name, to:, from: nil)` — to model any workflow verb beyond the built-in
16
+ `submit`/`approve`/`reject`. Each declared action generates a `name!` transition
17
+ method (accepting the usual `user:`, `comment:`, `metadata:`, `ip_address:`,
18
+ `user_agent:` keywords) and a non-raising `can_name?(user = nil)` predicate, and
19
+ records a `Signoff::Event` whose `action` is the verb name verbatim — so the
20
+ audit trail stays semantically truthful (e.g. `request_changes`, `cancel`,
21
+ `escalate`). Like `reject`, a custom action is a guarded *side transition*: its
22
+ target is never a forward edge, so it cannot make `approve!` ambiguous. `from:`
23
+ limits the permitted source states (single state or array) and defaults to every
24
+ non-finalized state except the action's own target. Custom actions reuse the
25
+ source state's `allow_transition` guard.
26
+ - **Multi-source forward transitions.** `transition` now accepts an array for
27
+ `from` as well as `to`, so several states can share one forward edge —
28
+ `transition [:draft, :rejected, :changes_requested], to: :review`.
29
+
30
+ ### Changed
31
+
32
+ - `approved?` / the `.approved` scope now exclude custom-action target states, and
33
+ `pending?` / the `.pending` scope treat a *terminal* custom-action target (e.g.
34
+ `cancelled`) as finalized while a *looping* target (e.g. `changes_requested`,
35
+ which re-submits) stays in flight. Workflows without custom actions are
36
+ unaffected — the finalized set remains exactly the approval terminals plus the
37
+ reject state.
38
+
10
39
  ## [0.1.0] - 2026-06-05
11
40
 
12
41
  Initial public release. `signoff` adds concurrency-safe approval workflows
@@ -75,5 +104,6 @@ and 8.x, backed by PostgreSQL — no external services required.
75
104
  `MissingColumnError`.
76
105
  - **Rails 7.x and 8.x support** on Ruby >= 3.2 with PostgreSQL.
77
106
 
78
- [Unreleased]: https://github.com/JijoBose/Signoff/compare/v0.1.0...HEAD
107
+ [Unreleased]: https://github.com/JijoBose/Signoff/compare/v0.2.0...HEAD
108
+ [0.2.0]: https://github.com/JijoBose/Signoff/compare/v0.1.0...v0.2.0
79
109
  [0.1.0]: https://github.com/JijoBose/Signoff/releases/tag/v0.1.0
data/README.md CHANGED
@@ -471,15 +471,71 @@ the state column for a single model (e.g. `signoff(column: :workflow_state) do`)
471
471
  | `state(name, initial: false)` | Declare a state. `initial: true` marks the start state. |
472
472
  | `states(*names)` | Declare several states at once. |
473
473
  | `initial_state(name)` | Set the start state explicitly (defaults to the first declared). |
474
- | `transition(from, to:)` | Declare a forward transition. `to:` accepts a symbol or an array. |
474
+ | `transition(from, to:)` | Declare a forward transition. Both `from` and `to:` accept a symbol or an array. |
475
475
  | `reject_to(state)` | The state `reject!` moves a record into. |
476
+ | `action(name, to:, from: nil)` | Declare a custom verb (e.g. `:request_changes`). Generates `name!` / `can_name?` and records `action` verbatim. A guarded side transition — never a forward edge. `from:` limits sources (defaults to any non-finalized state). |
476
477
  | `allow_transition(from) { \|user[, record]\| ... }` | Authorize transitions **out of** `from`. |
477
478
  | `before_transition { \|record, from, to\| ... }` | Run inside the transaction, before the state is written. |
478
479
  | `after_transition { \|record, event\| ... }` | Run after the transaction commits (great for jobs/mail). |
479
480
 
480
481
  Definitions are validated when the class loads. You'll get a descriptive
481
482
  `Signoff::DefinitionError` for duplicate states, transitions to/from
482
- undeclared states, a missing initial state, or an undeclared reject state.
483
+ undeclared states, a missing initial state, an undeclared reject state, or a
484
+ custom `action` that is named after a built-in verb or targets an undeclared
485
+ state.
486
+
487
+ ### Custom actions
488
+
489
+ `submit`, `approve`, and `reject` cover the common path, but real workflows have
490
+ more verbs — request changes, escalate, cancel, return-to-sender. Declare them
491
+ with `action` and the audit trail stays truthful (the recorded `action` is the
492
+ verb name, not a repurposed `"reject"`):
493
+
494
+ ```ruby
495
+ class Post < ApplicationRecord
496
+ include Signoff
497
+
498
+ signoff do
499
+ states :draft, :in_review, :approved, :rejected, :changes_requested, :cancelled
500
+
501
+ # Several states share one forward edge (re-submission re-enters review):
502
+ transition %i[draft rejected changes_requested], to: :in_review
503
+ transition :in_review, to: :approved
504
+
505
+ reject_to :rejected
506
+
507
+ # A looping off-ramp — re-submission brings it back into review:
508
+ action :request_changes, from: :in_review, to: :changes_requested
509
+ # A terminal off-ramp from any in-flight state (no :from given):
510
+ action :cancel, to: :cancelled
511
+
512
+ allow_transition :in_review do |user|
513
+ user.editor?
514
+ end
515
+ end
516
+ end
517
+
518
+ post.request_changes!(user: current_user, comment: "Tighten the intro")
519
+ post.current_state # => :changes_requested
520
+ post.workflow_history.last.action # => "request_changes" (truthful)
521
+ post.can_request_changes?(other) # => true / false (never raises)
522
+ post.submit! # changes_requested -> in_review again
523
+ post.cancel!(user: current_user) # -> cancelled
524
+ ```
525
+
526
+ A custom action behaves like a first-class `reject`:
527
+
528
+ - It is a **guarded side transition** — its target is never a forward edge, so a
529
+ state can offer `approve!` *and* `request_changes!`/`cancel!` without making
530
+ `approve!` ambiguous.
531
+ - It reuses the **source state's `allow_transition` guard**. Unauthorized calls
532
+ raise `Signoff::UnauthorizedError`; the generated `can_<name>?` predicate reports
533
+ permission without raising.
534
+ - `from:` limits the permitted source states (a symbol or array). Omit it and the
535
+ action is allowed from any non-finalized state except its own target.
536
+ - Targets that **loop back** into the flow (like `changes_requested`) count as
537
+ *in flight* (`pending?` / `.pending`); targets with no way out (like
538
+ `cancelled`) count as *finalized* — and neither is ever reported as `approved?`.
483
539
 
484
540
  ## Instance API
485
541
 
@@ -491,6 +547,9 @@ Including `Signoff` and declaring a workflow generates:
491
547
  report.submit!(user: nil, comment: nil, metadata: {}, to: nil)
492
548
  report.approve!(user: nil, comment: nil, metadata: {}, to: nil)
493
549
  report.reject!(user: nil, comment: nil, metadata: {})
550
+
551
+ # Plus one method per declared custom action, e.g.:
552
+ report.request_changes!(user: nil, comment: nil, metadata: {})
494
553
  ```
495
554
 
496
555
  - `submit!` and `approve!` both advance the **single forward transition** from the
@@ -499,6 +558,8 @@ report.reject!(user: nil, comment: nil, metadata: {})
499
558
  - When a state has **more than one** forward transition, pass `to:` to disambiguate
500
559
  (`report.approve!(to: :finance_review)`).
501
560
  - `reject!` moves the record to the `reject_to` state.
561
+ - Each custom `action :name` generates a `name!` method that records `"name"` as
562
+ the audit `action` (see [Custom actions](#custom-actions)).
502
563
  - All accept `ip_address:` and `user_agent:` keyword overrides (see
503
564
  [Configuration](#configuration)).
504
565
  - Each returns the created `Signoff::Event`.
@@ -521,11 +582,12 @@ report.finance_review?
521
582
  ### Authorization predicates
522
583
 
523
584
  ```ruby
524
- report.can_approve?(current_user) # => true / false (never raises)
525
- report.can_reject?(current_user) # => true / false
585
+ report.can_approve?(current_user) # => true / false (never raises)
586
+ report.can_reject?(current_user) # => true / false
587
+ report.can_request_changes?(current_user) # => one per custom action
526
588
  ```
527
589
 
528
- Both fall back to `Signoff::Current.user` when no argument is given.
590
+ All fall back to `Signoff::Current.user` when no argument is given.
529
591
 
530
592
  ### Audit helpers
531
593
 
@@ -6,14 +6,19 @@ module Signoff
6
6
  # lifecycle callbacks. Built by Signoff::DSL and stored on the model
7
7
  # class as +signoff_definition+.
8
8
  class Definition
9
+ # Built-in verbs that own their own transition methods on the model; a
10
+ # custom +action+ may not reuse these names.
11
+ RESERVED_ACTIONS = %i[submit approve reject].freeze
12
+
9
13
  attr_reader :states, :transitions, :authorizations,
10
- :before_callbacks, :after_callbacks, :state_column
14
+ :before_callbacks, :after_callbacks, :state_column, :actions
11
15
  attr_writer :initial_state
12
16
 
13
17
  def initialize(state_column: :approval_state)
14
18
  @states = []
15
19
  @transitions = {} # from(Symbol) => [to(Symbol), ...]
16
20
  @authorizations = {} # from(Symbol) => callable guard
21
+ @actions = {} # name(Symbol) => { to: Symbol, from: [Symbol]|nil }
17
22
  @before_callbacks = []
18
23
  @after_callbacks = []
19
24
  @initial_state = nil
@@ -31,11 +36,13 @@ module Signoff
31
36
  end
32
37
 
33
38
  def add_transition(from, to)
34
- from = from.to_sym
35
- Array(to).each do |target|
36
- target = target.to_sym
37
- @transitions[from] ||= []
38
- @transitions[from] << target unless @transitions[from].include?(target)
39
+ Array(from).each do |source|
40
+ source = source.to_sym
41
+ Array(to).each do |target|
42
+ target = target.to_sym
43
+ @transitions[source] ||= []
44
+ @transitions[source] << target unless @transitions[source].include?(target)
45
+ end
39
46
  end
40
47
  end
41
48
 
@@ -43,6 +50,22 @@ module Signoff
43
50
  @authorizations[from.to_sym] = guard
44
51
  end
45
52
 
53
+ # Register a named custom action: a guarded "side transition" that moves a
54
+ # record to +to+ and records an event whose +action+ is the name verbatim.
55
+ # Like +reject+, the target is NOT a forward transition, so it never makes
56
+ # +approve!+ ambiguous. +from+ limits the source states (defaults to every
57
+ # non-finalized state except the target itself).
58
+ def add_action(name, to:, from: nil)
59
+ name = name.to_sym
60
+ raise DefinitionError, "duplicate action #{name.inspect}" if @actions.key?(name)
61
+ raise DefinitionError, "action #{name.inspect} requires a :to state" if to.nil?
62
+
63
+ @actions[name] = {
64
+ to: to.to_sym,
65
+ from: from.nil? ? nil : Array(from).map(&:to_sym)
66
+ }
67
+ end
68
+
46
69
  def reject_state=(name)
47
70
  @reject_state = name&.to_sym
48
71
  end
@@ -65,10 +88,42 @@ module Signoff
65
88
  @states.reject { |s| forward_targets(s).any? }
66
89
  end
67
90
 
68
- # Terminal states that represent successful completion (everything that is
69
- # terminal except the reject state).
91
+ # Terminal states that represent successful completion: terminal via the
92
+ # forward flow and not reachable only as an off-ramp (reject or a custom
93
+ # action target). This keeps +approved?+ true for genuine end states only.
70
94
  def approval_terminal_states
71
- terminal_states - [reject_state].compact
95
+ terminal_states - [reject_state].compact - action_target_states
96
+ end
97
+
98
+ # Every state that ends the workflow: successful terminals, the reject
99
+ # state, and any custom-action target that has no forward transition out
100
+ # (e.g. a +cancel+ action's +cancelled+ state). Off-ramp targets that loop
101
+ # back (e.g. +changes_requested+ -> re-submit) stay in flight, not finalized.
102
+ def finalized_states
103
+ terminal_action_targets = action_target_states.select { |s| forward_targets(s).empty? }
104
+ (approval_terminal_states + [reject_state].compact + terminal_action_targets).uniq
105
+ end
106
+
107
+ # The distinct set of states reachable via custom actions.
108
+ def action_target_states
109
+ @actions.values.map { |spec| spec[:to] }.uniq
110
+ end
111
+
112
+ def action_names
113
+ @actions.keys
114
+ end
115
+
116
+ def action_for(name)
117
+ @actions[name.to_sym]
118
+ end
119
+
120
+ # States a custom action may be invoked from. An explicit +from:+ wins;
121
+ # otherwise any non-finalized state except the action's own target.
122
+ def action_allowed_from(name)
123
+ spec = @actions.fetch(name.to_sym)
124
+ return spec[:from] if spec[:from]
125
+
126
+ states - finalized_states - [spec[:to]]
72
127
  end
73
128
 
74
129
  def guard_for(state)
@@ -111,6 +166,7 @@ module Signoff
111
166
  validate_transitions!
112
167
  validate_reject_state!
113
168
  validate_authorizations!
169
+ validate_actions!
114
170
  self
115
171
  end
116
172
 
@@ -155,5 +211,28 @@ module Signoff
155
211
  "allow_transition references undeclared state #{state.inspect}"
156
212
  end
157
213
  end
214
+
215
+ def validate_actions!
216
+ @actions.each do |name, spec|
217
+ if RESERVED_ACTIONS.include?(name)
218
+ raise DefinitionError,
219
+ "action #{name.inspect} is reserved; #{name}! is a built-in verb"
220
+ end
221
+
222
+ unless @states.include?(spec[:to])
223
+ raise DefinitionError,
224
+ "action #{name.inspect} targets undeclared state #{spec[:to].inspect}"
225
+ end
226
+
227
+ next unless spec[:from]
228
+
229
+ spec[:from].each do |from|
230
+ next if @states.include?(from)
231
+
232
+ raise DefinitionError,
233
+ "action #{name.inspect} references undeclared from-state #{from.inspect}"
234
+ end
235
+ end
236
+ end
158
237
  end
159
238
  end
data/lib/signoff/dsl.rb CHANGED
@@ -47,7 +47,9 @@ module Signoff
47
47
  @definition.initial_state = name
48
48
  end
49
49
 
50
- # Declare a forward transition. +to+ accepts a single state or an array.
50
+ # Declare a forward transition. Both +from+ and +to+ accept a single state
51
+ # or an array, so several source states can share one forward edge
52
+ # (e.g. +transition [:draft, :rejected], to: :review+).
51
53
  def transition(from, to:)
52
54
  @definition.add_transition(from, to)
53
55
  end
@@ -57,6 +59,22 @@ module Signoff
57
59
  @definition.reject_state = state
58
60
  end
59
61
 
62
+ # Declare a named custom action. It generates a +name!+ transition method
63
+ # and a +can_name?+ predicate on the model, records an event whose +action+
64
+ # is the name verbatim, and is authorized by the same +allow_transition+
65
+ # guard as the source state. Like +reject+, the target is a side-transition
66
+ # (never a forward edge), so +approve!+ stays unambiguous. This is the
67
+ # extension point for modelling any workflow verb beyond submit/approve/reject.
68
+ #
69
+ # action :request_changes, from: :pending_review, to: :changes_requested
70
+ # action :cancel, to: :cancelled # from any in-flight state
71
+ #
72
+ # +from+ is optional and may be a single state or an array; when omitted the
73
+ # action is allowed from every non-finalized state except its own target.
74
+ def action(name, to:, from: nil)
75
+ @definition.add_action(name, to: to, from: from)
76
+ end
77
+
60
78
  # Authorize transitions out of +from_state+. The block receives the acting
61
79
  # user (and optionally the record) and must return a truthy value to allow
62
80
  # the transition.
data/lib/signoff/model.rb CHANGED
@@ -47,6 +47,7 @@ module Signoff
47
47
  dependent: Signoff.configuration.dependent
48
48
 
49
49
  _define_signoff_predicates(definition)
50
+ _define_signoff_actions(definition)
50
51
  definition
51
52
  end
52
53
 
@@ -84,11 +85,11 @@ module Signoff
84
85
  reject_state ? in_state(reject_state) : none
85
86
  end
86
87
 
87
- # Records that are neither approved nor rejected (still in flight).
88
+ # Records that are neither approved nor rejected (still in flight). A
89
+ # custom-action off-ramp that loops back (e.g. +changes_requested+) counts
90
+ # as in flight; one that terminates (e.g. +cancelled+) counts as finalized.
88
91
  def pending
89
- definition = signoff_definition!
90
- finalized = (definition.approval_terminal_states +
91
- [definition.reject_state].compact).map(&:to_s)
92
+ finalized = signoff_definition!.finalized_states.map(&:to_s)
92
93
  finalized.empty? ? all : where.not(signoff_column => finalized)
93
94
  end
94
95
 
@@ -103,6 +104,26 @@ module Signoff
103
104
  define_method(predicate) { current_state == state }
104
105
  end
105
106
  end
107
+
108
+ # For each declared custom action, generate a +name!+ transition method
109
+ # and a +can_name?+ predicate, mirroring the built-in approve!/can_approve?.
110
+ def _define_signoff_actions(definition)
111
+ definition.action_names.each do |name|
112
+ bang = "#{name}!"
113
+ unless method_defined?(bang) || private_method_defined?(bang)
114
+ define_method(bang) do |user: nil, comment: nil, metadata: {},
115
+ ip_address: nil, user_agent: nil|
116
+ _perform_action!(name, user: user, comment: comment, metadata: metadata,
117
+ ip_address: ip_address, user_agent: user_agent)
118
+ end
119
+ end
120
+
121
+ predicate = "can_#{name}?"
122
+ next if method_defined?(predicate) || private_method_defined?(predicate)
123
+
124
+ define_method(predicate) { |user = nil| _can_perform_action?(name, user) }
125
+ end
126
+ end
106
127
  end
107
128
 
108
129
  # --- state -----------------------------------------------------------
@@ -125,7 +146,7 @@ module Signoff
125
146
  end
126
147
 
127
148
  def pending?
128
- !approved? && !rejected?
149
+ !self.class.signoff_definition!.finalized_states.include?(current_state)
129
150
  end
130
151
 
131
152
  # --- transitions -----------------------------------------------------
@@ -241,6 +262,36 @@ module Signoff
241
262
  ip_address: ip_address, user_agent: user_agent)
242
263
  end
243
264
 
265
+ # Drive a declared custom action: validate the current state is a permitted
266
+ # source, then record an event whose action is the verb name verbatim.
267
+ def _perform_action!(name, user:, comment:, metadata:, ip_address:, user_agent:)
268
+ definition = self.class.signoff_definition!
269
+ spec = definition.action_for(name)
270
+ unless spec
271
+ raise InvalidTransitionError, "no action #{name.inspect} declared for #{self.class.name}"
272
+ end
273
+
274
+ from = current_state
275
+ allowed = definition.action_allowed_from(name)
276
+ unless allowed.include?(from)
277
+ raise InvalidTransitionError,
278
+ "cannot #{name} from #{from.inspect} " \
279
+ "(allowed from: #{allowed.inspect})"
280
+ end
281
+
282
+ _perform_transition!(action: name.to_s, from: from, to: spec[:to], user: user,
283
+ comment: comment, metadata: metadata,
284
+ ip_address: ip_address, user_agent: user_agent)
285
+ end
286
+
287
+ def _can_perform_action?(name, user)
288
+ definition = self.class.signoff_definition!
289
+ return false unless definition.action_for(name)
290
+ return false unless definition.action_allowed_from(name).include?(current_state)
291
+
292
+ _guard_satisfied?(current_state, user || Signoff::Current.user)
293
+ end
294
+
244
295
  def _perform_transition!(action:, from:, to:, user:, comment:, metadata:,
245
296
  ip_address:, user_agent:)
246
297
  if new_record?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Signoff
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: signoff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jijo Bose