hubbado-sequence 0.5.0 → 0.6.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: 7f708335623135a67d05ecdf13e49c97f9d0a1d1a2c21c2fda731f83c69ecc65
4
- data.tar.gz: 011fcaa92a23f287b8800da473f37452c9305bd08a0e00833811817b50d858db
3
+ metadata.gz: cc373389ca48751e5d60ebc84c00dfd8740b437ec89a53b8d9a1b986ea5c2a48
4
+ data.tar.gz: 7053947aa597354f5b973832083ebc8cc9e60e4de3a1cee23524045f803a63cd
5
5
  SHA512:
6
- metadata.gz: 440e563ba5e86174e51c186ca365d30f7160a93ca4d4d79669d23e0461acfc1030261b63d109223e5a37129cefc4c5c0cfd6433219dd0edf3be2d7c9576048e5
7
- data.tar.gz: c5a884e0a781e0b9794a3997ba1eaadbd03eeceaf02289ee33cab2528686281d89016ef9f82e7e04d6b71be5ce4378dc81336c5c7e1866e32d6b0f0f01cdff54
6
+ metadata.gz: 240dbbf380bea6a9007872456c97d052a6cedf0f5b716fbffe63876362f7c66f89f18accbf51d1cd99f2ee0f3f377376c7161770e46d85f6314548bc07d6549b
7
+ data.tar.gz: ef08b3226afc5465ecac7030ca8e36ad9c3a2ecb967530144bb96aad79a670e2085eda9e74d80b4cc05e4617deab78ac0903150ff5ed6b78f4cf1b58a912d778
data/CHANGELOG.md CHANGED
@@ -4,6 +4,97 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ ## [0.6.0] - Result.failure flat kwargs; Dispatch delegates reads and exposes raise helpers
8
+
9
+ ### Changed (breaking)
10
+
11
+ - **`Result.failure` takes flat kwargs; the `error:` hash wrapper is
12
+ gone.** The previous shape — `Result.failure(ctx, error: { code:,
13
+ data:, ... })` — wrapped its keys in an `error:` hash for no reason
14
+ beyond convention. The fields are now first-class kwargs on
15
+ `Result.failure` and first-class attrs on `Result`:
16
+
17
+ ```ruby
18
+ # before
19
+ Result.failure(ctx, error: { code: :forbidden, data: { policy_result: pr } })
20
+ result.error[:code] # => :forbidden
21
+ result.error[:data][:policy_result]
22
+
23
+ # after
24
+ Result.failure(ctx, code: :forbidden, data: { policy_result: pr })
25
+ result.code # => :forbidden
26
+ result.data[:policy_result]
27
+ ```
28
+
29
+ `Result` exposes `code`, `data`, `step`, `message_override`,
30
+ `i18n_scope`, `i18n_key`, `i18n_args` as readers. `Result#error`
31
+ is removed.
32
+
33
+ Migration: in callers, replace `Result.failure(ctx, error: { code:
34
+ :X })` with `Result.failure(ctx, code: :X)`. Replace `result.error[:X]`
35
+ reads with `result.X`. The `Sequencer#failure(ctx, **error_attrs)`
36
+ helper is unchanged at the call site (it always took flat kwargs).
37
+ Macro substitutes' `fail_with(**error_attrs)` is unchanged at the call
38
+ site; arbitrary extra attrs that used to live in the error hash should
39
+ move into `data:` (`fail_with(code: :forbidden, data: { reason:
40
+ :not_owner })`).
41
+
42
+ - **The per-error `i18n_scope` override path is removed.** Previously a
43
+ caller could put `i18n_scope:` inside the `error:` hash *and* pass a
44
+ separate `i18n_scope:` to the surrounding wrapper, with the per-error
45
+ one winning. With flat kwargs there is one `i18n_scope` slot. The
46
+ `Sequencer#failure` helper still applies the sequencer's auto-derived
47
+ scope when the caller doesn't pass one (`error_attrs[:i18n_scope] ||=
48
+ i18n_scope`), preserving the "caller wins" semantics where it matters
49
+ in practice. `Result#with_i18n_scope` (used to apply a scope to an
50
+ already-built Result) is unchanged.
51
+
52
+ - **`Runner::Dispatch#result` is removed.** Master exposed the wrapped
53
+ Result via `attr_reader :result`, which was the source of the
54
+ `result.result.error.dig(...)` four-hop pattern. With the new
55
+ read-through delegations (`code`, `data`, `step`, `message`,
56
+ `successful_steps`, `ctx`) there's no reason for outcome blocks to
57
+ reach into the inner Result. Any caller still doing
58
+ `result.result.X` from inside a `run_sequence` block will now raise
59
+ `NoMethodError`; replace with the matching delegation on the
60
+ dispatch object itself.
61
+
62
+ - **The `message:` kwarg on `Result.failure` is removed.** It set a
63
+ literal-string fallback returned by `Result#message` when no
64
+ translation matched. No in-tree caller used it (the
65
+ i18n-translation chain plus `humanize_code` fallback covered every
66
+ real case), and the path was test-only. If a caller needs a custom
67
+ message they can supply `i18n_key:` and a matching translation, or
68
+ pass a humanizable `code:` symbol.
69
+
70
+ ### Added
71
+
72
+ - **`Runner::Dispatch` delegates reads to its wrapped `Result`.** Outcome
73
+ blocks can call `result.code`, `result.data`, `result.message`,
74
+ `result.step`, `result.successful_steps`, `result.ctx` on the
75
+ `Dispatch` object (the block argument) without hopping through an
76
+ inner `.result.` reference. The previous `result.result.error.dig(...)`
77
+ pattern collapses to one read.
78
+
79
+ ```ruby
80
+ result.policy_failed do |ctx|
81
+ if result.data[:policy_result].reason == :not_open
82
+ redirect_to public_path(ctx[:job])
83
+ else
84
+ result.raise_policy_failed
85
+ end
86
+ end
87
+ ```
88
+
89
+ - **Public raise helpers on `Runner::Dispatch`:** `raise_policy_failed`,
90
+ `raise_not_found`, and `raise_failed`. They produce the same exceptions
91
+ the safety net would raise, but can be called explicitly from inside an
92
+ outcome block — useful when a caller handles some failure cases inline
93
+ and wants the framework's standard escalation for the rest.
94
+ `enforce_safety_nets!` now delegates to the same helpers, so the
95
+ exception shapes stay aligned whether the caller invokes them directly
96
+ or the runner does it automatically.
97
+
7
98
  ## [0.5.0] - Result vocabulary renamed: success/failure and successful_steps
8
99
 
9
100
  ### Changed (breaking)
data/README.md CHANGED
@@ -292,14 +292,30 @@ p.invoke(:check_policy, Policies::User, :user, :update)
292
292
  ```
293
293
 
294
294
  The policy class must respond to `.build(current_user, record)`; the
295
- instance must respond to the action method and return an object with
296
- `permitted?`.
295
+ instance must respond to the action method and return a
296
+ `Hubbado::Policy::Result`-shaped object (`permitted?`, `denied?`,
297
+ `reason`, `message`).
297
298
 
298
299
  | | |
299
300
  |---|---|
300
301
  | **Reads** | `ctx[:current_user]`, `ctx[record_key]` |
301
302
  | **Writes** | nothing |
302
- | **Fails** | `:forbidden` when `permitted?` is false; `error[:data]` carries `{ policy:, policy_result: }` |
303
+ | **Fails** | `:forbidden` when `permitted?` is false; `result.data` carries `{ policy:, policy_result: }` |
304
+
305
+ A controller can branch on the denial reason via `data`:
306
+
307
+ ```ruby
308
+ result.policy_failed do |ctx|
309
+ if result.data[:policy_result].reason == :not_open
310
+ redirect_to public_path(ctx[:job])
311
+ else
312
+ result.raise_policy_failed
313
+ end
314
+ end
315
+ ```
316
+
317
+ See [Handling specific failure reasons inside an outcome block](#handling-specific-failure-reasons-inside-an-outcome-block)
318
+ for when `raise_policy_failed` and its siblings come in handy.
303
319
 
304
320
  ## Transactions
305
321
 
@@ -454,9 +470,50 @@ end
454
470
  ```
455
471
 
456
472
  `failure(ctx, ...)` is a sequencer helper that builds a failed `Result`
457
- with the sequencer's auto-derived i18n scope already applied. It takes the
458
- same error attrs as the underlying error hash (`code:`, `i18n_key:`,
459
- `i18n_args:`, `data:`, `message:`).
473
+ with the sequencer's auto-derived i18n scope already applied. It takes
474
+ the same kwargs as `Result.failure` (`code:`, `data:`, `step:`,
475
+ `i18n_scope:`, `i18n_key:`, `i18n_args:`).
476
+
477
+ ## Outcome blocks and safety nets
478
+
479
+ `run_sequence` enforces that serious failures are addressed. Forgetting to
480
+ handle them raises rather than silently swallowing:
481
+
482
+ - An unhandled `:forbidden` raises `Hubbado::Sequence::Errors::Unauthorized`.
483
+ - An unhandled `:not_found` raises `Hubbado::Sequence::Errors::NotFound`.
484
+ - An unhandled non-policy / non-not-found failure (and an `otherwise` block
485
+ isn't given) raises `Hubbado::Sequence::Errors::Failed`.
486
+
487
+ `otherwise` deliberately does *not* catch `:forbidden` or `:not_found` —
488
+ that's what prevents a generic `otherwise` accidentally rendering a form
489
+ when the policy denied access.
490
+
491
+ ### Handling specific failure reasons inside an outcome block
492
+
493
+ The dispatch object exposes the standard escalation paths as public
494
+ methods, so an outcome block can handle some cases inline and fall back to
495
+ the framework's exception for the rest:
496
+
497
+ ```ruby
498
+ result.policy_failed do |ctx|
499
+ if result.data[:policy_result].reason == :not_open
500
+ redirect_to public_path(ctx[:job])
501
+ else
502
+ result.raise_policy_failed
503
+ end
504
+ end
505
+ ```
506
+
507
+ The available helpers mirror the safety nets:
508
+
509
+ - `result.raise_policy_failed` — raises `Errors::Unauthorized` with the
510
+ standard message.
511
+ - `result.raise_not_found` — raises `Errors::NotFound`.
512
+ - `result.raise_failed` — raises `Errors::Failed`.
513
+
514
+ Use them when you genuinely want the framework's default escalation;
515
+ prefer plain Ruby control flow (`return`, `if`/`else`) for ordinary
516
+ branching.
460
517
 
461
518
  ## Testing
462
519
 
@@ -510,7 +567,7 @@ context "Seqs::UpdateUser::Present when the user is not found" do
510
567
  result = seq.(params: { id: 999 }, current_user: User.new)
511
568
 
512
569
  test "Fails with :not_found" do
513
- assert(result.error[:code] == :not_found)
570
+ assert(result.code == :not_found)
514
571
  end
515
572
 
516
573
  test "Does not build the contract" do
@@ -523,6 +580,15 @@ context "Seqs::UpdateUser::Present when the user is not found" do
523
580
  end
524
581
  ```
525
582
 
583
+ The `Policy::Check` substitute's `fail_with(**error)` accepts the same
584
+ attributes `Result.failure` does, so a test that needs an outcome block
585
+ to branch on `result.data[:policy_result].reason` can configure the data
586
+ payload directly:
587
+
588
+ ```ruby
589
+ seq.check_policy.fail_with(code: :forbidden, data: { policy_result: DeniedResult.new(:not_open) })
590
+ ```
591
+
526
592
  ### Substituting a nested sequencer
527
593
 
528
594
  Every sequencer ships a default `Substitute` module (installed by
@@ -567,7 +633,7 @@ context "Seqs::UpdateUser when Present denies access" do
567
633
  end
568
634
 
569
635
  test "Fails with :forbidden" do
570
- assert(result.error[:code] == :forbidden)
636
+ assert(result.code == :forbidden)
571
637
  end
572
638
 
573
639
  test "Does not validate" do
@@ -586,7 +652,7 @@ context "Seqs::UpdateUser when Present cannot find the record" do
586
652
  result = seq.(params: { id: 999, user: {} }, current_user: User.new)
587
653
 
588
654
  test "Fails with :not_found" do
589
- assert(result.error[:code] == :not_found)
655
+ assert(result.code == :not_found)
590
656
  end
591
657
 
592
658
  test "Does not validate" do
@@ -610,12 +676,12 @@ to exercise Find / Build / Policy::Check directly — those live in
610
676
 
611
677
  Every `Result` carries **successful_steps** — the list of step names that
612
678
  completed successfully, in order. On failure, the failing step is *not* in
613
- `successful_steps`; it's tagged on `error[:step]` instead.
679
+ `successful_steps`; it's tagged on `step` instead.
614
680
 
615
681
  ```ruby
616
682
  result.successful_steps # => [:find, :build_contract, :check_policy, :validate, :persist] # success
617
683
  result.successful_steps # => [:find, :build_contract] # failed at :check_policy
618
- result.error[:step] # => :check_policy
684
+ result.step # => :check_policy
619
685
  ```
620
686
 
621
687
  When invoked via `run_sequence`, the dispatcher logs a single line per
@@ -1,7 +1,7 @@
1
1
  # -*- encoding: utf-8 -*-
2
2
  Gem::Specification.new do |s|
3
3
  s.name = "hubbado-sequence"
4
- s.version = "0.5.0"
4
+ s.version = "0.6.0"
5
5
  s.summary = "A small framework for the short sequences of common steps that controller actions usually boil down to"
6
6
  s.description = "A sequencer takes input, runs an ordered sequence of steps, and returns a Result carrying a success-or-failure flag, a structured error, and the working context that was built up during execution. Built with Rails in mind but framework-agnostic."
7
7
 
@@ -31,7 +31,7 @@ module Hubbado
31
31
  end
32
32
 
33
33
  record def call(ctx, contract_class, attr_name = nil)
34
- return Result.failure(ctx, error: @configured_error) if @configured_error
34
+ return Result.failure(ctx, **@configured_error) if @configured_error
35
35
 
36
36
  ctx[:contract] = @return_value if @configured_success
37
37
  Result.success(ctx)
@@ -27,7 +27,7 @@ module Hubbado
27
27
  end
28
28
 
29
29
  record def call(ctx, from:)
30
- return Result.failure(ctx, error: @configured_error) if @configured_error
30
+ return Result.failure(ctx, **@configured_error) if @configured_error
31
31
 
32
32
  Result.success(ctx)
33
33
  end
@@ -16,7 +16,7 @@ module Hubbado
16
16
  if contract.save
17
17
  Result.success(ctx)
18
18
  else
19
- Result.failure(ctx, error: { code: :persist_failed })
19
+ Result.failure(ctx, code: :persist_failed)
20
20
  end
21
21
  end
22
22
 
@@ -34,7 +34,7 @@ module Hubbado
34
34
  end
35
35
 
36
36
  record def call(ctx)
37
- return Result.failure(ctx, error: @configured_error) if @configured_error
37
+ return Result.failure(ctx, **@configured_error) if @configured_error
38
38
 
39
39
  Result.success(ctx)
40
40
  end
@@ -19,7 +19,7 @@ module Hubbado
19
19
  if contract.errors.empty?
20
20
  Result.success(ctx)
21
21
  else
22
- Result.failure(ctx, error: { code: :validation_failed })
22
+ Result.failure(ctx, code: :validation_failed)
23
23
  end
24
24
  end
25
25
 
@@ -37,7 +37,7 @@ module Hubbado
37
37
  end
38
38
 
39
39
  record def call(ctx, from: nil)
40
- return Result.failure(ctx, error: @configured_error) if @configured_error
40
+ return Result.failure(ctx, **@configured_error) if @configured_error
41
41
 
42
42
  Result.success(ctx)
43
43
  end
@@ -40,7 +40,7 @@ module Hubbado
40
40
  "Macros::Model::Build substitute: #{model} does not respond to :new"
41
41
  end
42
42
 
43
- return Result.failure(ctx, error: @configured_error) if @configured_error
43
+ return Result.failure(ctx, **@configured_error) if @configured_error
44
44
 
45
45
  ctx[as] = @return_value if @configured_success
46
46
  Result.success(ctx)
@@ -18,7 +18,7 @@ module Hubbado
18
18
  ctx[as] = record
19
19
  Result.success(ctx)
20
20
  else
21
- Result.failure(ctx, error: { code: :not_found })
21
+ Result.failure(ctx, code: :not_found)
22
22
  end
23
23
  end
24
24
 
@@ -42,7 +42,7 @@ module Hubbado
42
42
  "Macros::Model::Find substitute: #{model} does not respond to :find_by"
43
43
  end
44
44
 
45
- return Result.failure(ctx, error: @configured_error) if @configured_error
45
+ return Result.failure(ctx, **@configured_error) if @configured_error
46
46
 
47
47
  ctx[as] = @return_value if @configured_success
48
48
  Result.success(ctx)
@@ -22,10 +22,8 @@ module Hubbado
22
22
  else
23
23
  Result.failure(
24
24
  ctx,
25
- error: {
26
- code: :forbidden,
27
- data: { policy: policy_instance, policy_result: policy_result }
28
- }
25
+ code: :forbidden,
26
+ data: { policy: policy_instance, policy_result: policy_result }
29
27
  )
30
28
  end
31
29
  end
@@ -49,7 +47,7 @@ module Hubbado
49
47
  "Macros::Policy::Check substitute: #{policy} does not declare action :#{action}"
50
48
  end
51
49
 
52
- return Result.failure(ctx, error: @configured_error) if @configured_error
50
+ return Result.failure(ctx, **@configured_error) if @configured_error
53
51
 
54
52
  Result.success(ctx)
55
53
  end
@@ -84,16 +84,13 @@ module Hubbado
84
84
 
85
85
  def record(name, return_value)
86
86
  if return_value.is_a?(Result) && return_value.failure?
87
- @failed_result = tag_failure(return_value, name)
87
+ @failed_result = return_value
88
+ .with_step(name)
89
+ .with_successful_steps(@successful_steps.dup)
88
90
  else
89
91
  @successful_steps << name
90
92
  end
91
93
  end
92
-
93
- def tag_failure(result, step_name)
94
- tagged_error = result.error.merge(step: step_name)
95
- Result.failure(result.ctx, error: tagged_error, successful_steps: @successful_steps.dup, i18n_scope: result.i18n_scope)
96
- end
97
94
  end
98
95
  end
99
96
  end
@@ -4,28 +4,52 @@ module Hubbado
4
4
  FRAMEWORK_I18N_SCOPE = "sequence.errors".freeze
5
5
 
6
6
  attr_reader :ctx
7
- attr_reader :error
7
+ attr_reader :code
8
+ attr_reader :data
9
+ attr_reader :step
8
10
  attr_reader :successful_steps
9
11
  attr_reader :i18n_scope
12
+ attr_reader :i18n_key
13
+ attr_reader :i18n_args
10
14
 
11
15
  def self.success(ctx, successful_steps: [], i18n_scope: nil)
12
- new(:success, ctx, error: nil, successful_steps: successful_steps, i18n_scope: i18n_scope)
16
+ new(
17
+ :success,
18
+ ctx: ctx,
19
+ successful_steps: successful_steps,
20
+ i18n_scope: i18n_scope
21
+ )
13
22
  end
14
23
 
15
- def self.failure(ctx, error:, successful_steps: [], i18n_scope: nil)
16
- unless error.is_a?(Hash) && error[:code]
17
- raise ArgumentError, "Result.failure requires error: { code: ... }"
18
- end
19
-
20
- new(:failure, ctx, error: error, successful_steps: successful_steps, i18n_scope: i18n_scope)
24
+ def self.failure(ctx, code:, data: nil, step: nil,
25
+ i18n_scope: nil, i18n_key: nil, i18n_args: nil, successful_steps: [])
26
+ raise ArgumentError, "Result.failure requires code:" unless code
27
+
28
+ new(
29
+ :failure,
30
+ ctx: ctx,
31
+ code: code,
32
+ data: data,
33
+ step: step,
34
+ successful_steps: successful_steps,
35
+ i18n_scope: i18n_scope,
36
+ i18n_key: i18n_key,
37
+ i18n_args: i18n_args
38
+ )
21
39
  end
22
40
 
23
- def initialize(status, ctx, error:, successful_steps:, i18n_scope:)
41
+ def initialize(status, ctx:, successful_steps:, i18n_scope:,
42
+ code: nil, data: nil, step: nil,
43
+ i18n_key: nil, i18n_args: nil)
24
44
  @status = status
25
45
  @ctx = ctx
26
- @error = error
46
+ @code = code
47
+ @data = data
48
+ @step = step
27
49
  @successful_steps = successful_steps
28
50
  @i18n_scope = i18n_scope
51
+ @i18n_key = i18n_key
52
+ @i18n_args = i18n_args
29
53
  end
30
54
 
31
55
  def success?
@@ -37,35 +61,50 @@ module Hubbado
37
61
  end
38
62
 
39
63
  def with_successful_steps(successful_steps)
40
- self.class.new(@status, @ctx, error: @error, successful_steps: successful_steps, i18n_scope: @i18n_scope)
64
+ copy(successful_steps: successful_steps)
41
65
  end
42
66
 
43
67
  def with_i18n_scope(scope)
44
68
  return self unless @i18n_scope.nil?
45
69
 
46
- self.class.new(@status, @ctx, error: @error, successful_steps: @successful_steps, i18n_scope: scope)
70
+ copy(i18n_scope: scope)
71
+ end
72
+
73
+ def with_step(step)
74
+ copy(step: step)
47
75
  end
48
76
 
49
77
  def message
50
78
  return nil if success?
51
79
 
52
- translation = translate_with_chain
53
- return translation if translation
54
-
55
- @error[:message] || humanize_code
80
+ translate_with_chain || humanize_code
56
81
  end
57
82
 
58
83
  private
59
84
 
85
+ def copy(**overrides)
86
+ self.class.new(
87
+ @status,
88
+ ctx: @ctx,
89
+ code: @code,
90
+ data: @data,
91
+ step: @step,
92
+ successful_steps: @successful_steps,
93
+ i18n_scope: @i18n_scope,
94
+ i18n_key: @i18n_key,
95
+ i18n_args: @i18n_args,
96
+ **overrides
97
+ )
98
+ end
99
+
60
100
  def translate_with_chain
61
101
  scopes = []
62
- scopes << @error[:i18n_scope] if @error[:i18n_scope]
63
102
  scopes << @i18n_scope if @i18n_scope
64
103
  scopes << FRAMEWORK_I18N_SCOPE
65
104
  scopes.uniq!
66
105
 
67
- key = @error[:i18n_key] || @error[:code]
68
- args = @error[:i18n_args] || {}
106
+ key = @i18n_key || @code
107
+ args = @i18n_args || {}
69
108
 
70
109
  scopes.each do |scope|
71
110
  translated = ::I18n.t("#{scope}.#{key}", default: nil, **args)
@@ -76,7 +115,7 @@ module Hubbado
76
115
  end
77
116
 
78
117
  def humanize_code
79
- @error[:code].to_s.tr("_", " ").capitalize
118
+ @code.to_s.tr("_", " ").capitalize
80
119
  end
81
120
  end
82
121
  end
@@ -25,7 +25,7 @@ module Hubbado
25
25
  class Dispatch
26
26
  include Hubbado::Log::Dependency
27
27
 
28
- attr_reader :returned, :result, :sequencer_class
28
+ attr_reader :returned, :sequencer_class
29
29
 
30
30
  def initialize(sequencer_class, result)
31
31
  @sequencer_class = sequencer_class
@@ -33,6 +33,16 @@ module Hubbado
33
33
  @handled = false
34
34
  end
35
35
 
36
+ # Read-throughs to the wrapped Result. Outcome blocks read these on
37
+ # the Dispatch object (the block argument) without hopping through
38
+ # an inner Result reference.
39
+ def code = @result.code
40
+ def data = @result.data
41
+ def step = @result.step
42
+ def message = @result.message
43
+ def successful_steps = @result.successful_steps
44
+ def ctx = @result.ctx
45
+
36
46
  def success
37
47
  return unless @result.success?
38
48
  execute { yield(@result.ctx) }
@@ -69,29 +79,38 @@ module Hubbado
69
79
  logger.info("Sequencer #{@sequencer_class.name} failed at #{step_label} (#{code}): #{steps_summary}")
70
80
  end
71
81
 
72
- def code
73
- @result.error&.[](:code)
74
- end
75
-
76
82
  def handled?
77
83
  @result.success? || @handled
78
84
  end
79
85
 
86
+ # Raise the standard policy-denial exception. Available inside an
87
+ # outcome block (e.g. for callers that handle some policy reasons
88
+ # inline and want the framework's standard escalation for the rest)
89
+ # and used internally by enforce_safety_nets! when no handler ran.
90
+ def raise_policy_failed
91
+ raise Errors::Unauthorized.new(
92
+ "#{@sequencer_class.name} denied: #{@result.message}",
93
+ @result
94
+ )
95
+ end
96
+
97
+ def raise_not_found
98
+ raise Errors::NotFound, "#{@sequencer_class.name} reported not_found"
99
+ end
100
+
101
+ def raise_failed
102
+ raise Errors::Failed, "#{@sequencer_class.name} failed (#{code}): #{@result.message}"
103
+ end
104
+
80
105
  def enforce_safety_nets!
81
106
  return if handled?
82
107
 
83
108
  log_unhandled
84
109
 
85
110
  case code
86
- when :forbidden
87
- raise Errors::Unauthorized.new(
88
- "#{@sequencer_class.name} denied: #{@result.message}",
89
- @result
90
- )
91
- when :not_found
92
- raise Errors::NotFound, "#{@sequencer_class.name} reported not_found"
93
- else
94
- raise Errors::Failed, "#{@sequencer_class.name} failed (#{code}): #{@result.message}"
111
+ when :forbidden then raise_policy_failed
112
+ when :not_found then raise_not_found
113
+ else raise_failed
95
114
  end
96
115
  end
97
116
 
@@ -107,11 +126,10 @@ module Hubbado
107
126
  end
108
127
 
109
128
  def steps_summary
110
- @result.successful_steps.empty? ? "(no steps)" : @result.successful_steps.map(&:to_s).join(" → ")
129
+ successful_steps.empty? ? "(no steps)" : successful_steps.map(&:to_s).join(" → ")
111
130
  end
112
131
 
113
132
  def step_label
114
- step = @result.error && @result.error[:step]
115
133
  step ? step.inspect : "(unknown step)"
116
134
  end
117
135
  end
@@ -162,7 +180,8 @@ module Hubbado
162
180
  def configure_failure(code, error_attrs)
163
181
  @configured_outcome = {
164
182
  kind: :failure,
165
- error: { code: code, **error_attrs }
183
+ code: code,
184
+ error_attrs: error_attrs
166
185
  }
167
186
  self
168
187
  end
@@ -175,7 +194,7 @@ module Hubbado
175
194
  outcome[:ctx_writes].each { |key, value| ctx[key] = value }
176
195
  Hubbado::Sequence::Result.success(ctx)
177
196
  else
178
- Hubbado::Sequence::Result.failure(ctx, error: outcome[:error])
197
+ Hubbado::Sequence::Result.failure(ctx, code: outcome[:code], **outcome[:error_attrs])
179
198
  end
180
199
  end
181
200
  end
@@ -34,7 +34,7 @@ module Hubbado
34
34
  end
35
35
 
36
36
  record def call(ctx)
37
- return ::Hubbado::Sequence::Result.failure(ctx, error: @configured_error) if @configured_error
37
+ return ::Hubbado::Sequence::Result.failure(ctx, **@configured_error) if @configured_error
38
38
 
39
39
  if @configured_writes
40
40
  @configured_writes.each { |k, v| ctx[k] = v }
@@ -83,7 +83,8 @@ module Hubbado
83
83
  end
84
84
 
85
85
  def failure(ctx, **error_attrs)
86
- Result.failure(ctx, error: error_attrs, i18n_scope: i18n_scope)
86
+ error_attrs[:i18n_scope] ||= i18n_scope
87
+ Result.failure(ctx, **error_attrs)
87
88
  end
88
89
 
89
90
  # Builds a Pipeline that auto-dispatches blockless `step(:foo)` calls to
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hubbado-sequence
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hubbado Devs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
11
+ date: 2026-05-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: evt-casing