hubbado-sequence 0.6.0 → 0.8.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: cc373389ca48751e5d60ebc84c00dfd8740b437ec89a53b8d9a1b986ea5c2a48
4
- data.tar.gz: 7053947aa597354f5b973832083ebc8cc9e60e4de3a1cee23524045f803a63cd
3
+ metadata.gz: bae3a73329cdbf0194b5074fae10bd5e2d4035e773ee70f238ca802a5d0f2ae7
4
+ data.tar.gz: 8ff2d5bc1088333c8480695d411000f7b361efb22e296057c0111f88c0886113
5
5
  SHA512:
6
- metadata.gz: 240dbbf380bea6a9007872456c97d052a6cedf0f5b716fbffe63876362f7c66f89f18accbf51d1cd99f2ee0f3f377376c7161770e46d85f6314548bc07d6549b
7
- data.tar.gz: ef08b3226afc5465ecac7030ca8e36ad9c3a2ecb967530144bb96aad79a670e2085eda9e74d80b4cc05e4617deab78ac0903150ff5ed6b78f4cf1b58a912d778
6
+ metadata.gz: 16069e3f557800e6c0c18e2f6b52c5bec482d2d8610d244e2679403879bf81ab742bbcf4da74d63a03fbaa12614103e51ffe53a6889d45b6cc3a73509ca3ecd5
7
+ data.tar.gz: c92c360d5fe1995cf05eefb330ab1949d0870d6ede7fcc308f350e7c23f98118544cda4d218580ba62d12793e7bbf6329af7f2b33a4c0adc6b5d87db2bc85dea
data/CHANGELOG.md CHANGED
@@ -4,6 +4,117 @@ 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.8.0] - Instance calls take only a Ctx; chained pipeline carries the i18n scope
8
+
9
+ ### Changed (breaking)
10
+
11
+ - **`pipeline` rejects anything but a `Ctx`.** A sequencer's instance
12
+ `call` takes a `Ctx`, as `docs/design.md` already stated, and
13
+ `pipeline` now raises `ArgumentError` when it gets keyword arguments or
14
+ a plain Hash. A spec that calls an instance
15
+ (`seq = Seq.new; seq.(params: ...)`) must pass
16
+ `Hubbado::Sequence::Ctx.build(params: ...)`. On a plain Hash, a read of a
17
+ missing ctx key returned `nil` in the spec while it raised `KeyError` in
18
+ production through the class-level `.()`, so specs could not find that
19
+ fault. The class-level `.()` still accepts keyword arguments.
20
+
21
+ ### Fixed
22
+
23
+ - **A chained `pipeline(ctx).step(...).result` carries the sequencer's
24
+ i18n scope.** Only the block form tagged its Result, so a sequencer
25
+ using the chained form got no scope on an instance call, and its
26
+ parent's scope when nested through `p.invoke`. The pipeline now applies
27
+ the scope in both forms. The README states that a failure returned
28
+ without `pipeline` should be built with the `failure` helper.
29
+
30
+ ## [0.7.0] - Macros::Policy::Check record-less policies; Sequencer i18n_scope applied at boundary
31
+
32
+ ### Changed (breaking)
33
+
34
+ - **`Macros::Policy::Check#call` signature changed** from `(ctx, policy,
35
+ record_key, action)` to `(ctx, policy, action, record_key = nil)`.
36
+ `record_key` is now a trailing optional positional; omitting it
37
+ builds the policy with `nil` as the record, the shape required by
38
+ plural / collection policies (e.g. `Policies::Jobs`) that authorise
39
+ on a non-record subject rather than gating on a specific record:
40
+
41
+ ```ruby
42
+ # before
43
+ p.invoke(:check_policy, Policies::User, :user, :update)
44
+
45
+ # after
46
+ p.invoke(:check_policy, Policies::User, :update, :user) # singular
47
+ p.invoke(:check_policy, Policies::Jobs, :list) # record-less
48
+ ```
49
+
50
+ Migration: at every `p.invoke(:check_policy, ...)` call site, swap
51
+ the third and fourth positional arguments. Substitutes and the
52
+ underlying `policy.method_defined?(action)` typo-catch are
53
+ unchanged in behaviour; the parameter order on the substitute's
54
+ `call` is migrated to match.
55
+
56
+ See `docs/design.md` "Resolved Through Iteration" for the rationale
57
+ and the alternatives considered.
58
+
59
+ ### Added
60
+
61
+ - **`Macros::Policy::Check.failure(ctx, policy, policy_result)`** class
62
+ helper. Returns `Result.failure(ctx, code: :forbidden, data: { policy:,
63
+ policy_result: })` — the same failure shape the macro produces. Lets
64
+ hand-rolled policy-check steps (for policy actions that take arguments,
65
+ or for compound logic the macro doesn't cover) produce the standard
66
+ failure shape without duplicating framework knowledge.
67
+
68
+ - **`Macros::Policy::Check` now stores the built policy on `ctx[:policy]`.**
69
+ After building the policy instance and before invoking the action, the
70
+ macro writes it to ctx under `:policy` by default. Downstream steps (e.g.
71
+ contract construction that needs the policy injected) can read it
72
+ directly without re-building. Pass `as:` to store under a different key
73
+ when a sequencer runs multiple policy checks:
74
+
75
+ ```ruby
76
+ p.invoke(:check_policy, Policies::Document, :update, :document)
77
+ # ctx[:policy] is now the built Policies::Document instance
78
+
79
+ p.invoke(:check_policy, Policies::User, :show, :user, as: :user_policy)
80
+ # ctx[:user_policy] is the built Policies::User instance
81
+ ```
82
+
83
+ The substitute's `succeed_with` now accepts an optional policy instance;
84
+ passing one mirrors the production write to `ctx[as]` so substituted
85
+ specs can drive the same downstream paths.
86
+
87
+ ### Changed
88
+
89
+ - **`Macros::Contract::Build`'s second parameter renamed** from
90
+ `attr_name` to `model`. The positional shape is unchanged — this is
91
+ an internal rename only — and the name now describes what the
92
+ parameter is (the ctx key/path for the model the contract wraps)
93
+ rather than what it isn't (an "attribute name" on anything). Callers
94
+ passing the value positionally (the only in-tree shape) are
95
+ unaffected.
96
+
97
+ ### Fixed
98
+
99
+ - **`Sequencer#pipeline` and `Sequencer.()` now apply the sequencer's
100
+ auto-derived `i18n_scope` to the returned Result.** Closes a
101
+ documented-but-unimplemented step in the `Result#message`
102
+ translation fallback chain. Previously only `Sequencer#failure` (the
103
+ explicit helper) tagged a result with the sequencer's scope; macros
104
+ call `Result.failure` directly with no scope, so a sequencer body
105
+ that returned a macro's failure unchanged produced an unscoped
106
+ Result and `Result#message` fell through to the framework default
107
+ (`sequence.errors.<code>`) instead of the per-sequencer scoped
108
+ translation. Pure-macro sequencers (e.g. a body that's just
109
+ `pipeline(ctx) { |p| p.invoke(:check_policy, ...) }`) could never
110
+ produce a message translated under their own namespace. Tagging at
111
+ the boundary (`pipeline.result` and `Sequencer.()`) via
112
+ `Result#with_i18n_scope` preserves nested-sequencer "innermost scope
113
+ wins" semantics — `with_i18n_scope` is a no-op when the scope is
114
+ already set, so an inner sequencer's scope survives the outer
115
+ wrapper. See `docs/design.md` "Resolved Through Iteration" for the
116
+ rationale.
117
+
7
118
  ## [0.6.0] - Result.failure flat kwargs; Dispatch delegates reads and exposes raise helpers
8
119
 
9
120
  ### Changed (breaking)
data/README.md CHANGED
@@ -126,7 +126,7 @@ class Seqs::UpdateUser
126
126
  pipeline(ctx) do |p|
127
127
  p.invoke(:find, User, as: :user)
128
128
  p.invoke(:build_contract, Contracts::UpdateUser, :user)
129
- p.invoke(:check_policy, Policies::User, :user, :update)
129
+ p.invoke(:check_policy, Policies::User, :update, :user)
130
130
 
131
131
  p.transaction do |t|
132
132
  t.invoke(:validate, from: %i[params user])
@@ -230,7 +230,7 @@ p.invoke(:build_contract, Contracts::CreateUser) # no model
230
230
 
231
231
  | | |
232
232
  |---|---|
233
- | **Reads** | `ctx[attr_name]` for the model (optional) |
233
+ | **Reads** | `ctx` at `model` for the model (optional) |
234
234
  | **Writes** | `ctx[:contract]` |
235
235
  | **Fails** | never |
236
236
 
@@ -288,20 +288,50 @@ Designed to work with the
288
288
  Builds a policy and calls the named action to authorise the operation.
289
289
 
290
290
  ```ruby
291
- p.invoke(:check_policy, Policies::User, :user, :update)
291
+ p.invoke(:check_policy, Policies::User, :update, :user) # policy on a record
292
+ p.invoke(:check_policy, Policies::Jobs, :list) # plural / record-less
292
293
  ```
293
294
 
294
295
  The policy class must respond to `.build(current_user, record)`; the
295
296
  instance must respond to the action method and return a
296
297
  `Hubbado::Policy::Result`-shaped object (`permitted?`, `denied?`,
297
- `reason`, `message`).
298
+ `reason`, `message`). When `record_key` is omitted the policy is built
299
+ with `nil` as the record — the shape for plural / collection policies
300
+ that authorise against a non-record subject (e.g. a company id read
301
+ from `current_user`).
302
+
303
+ The built policy instance is written to `ctx[:policy]` so downstream
304
+ steps (e.g. contract construction that needs the policy injected) can
305
+ read it directly. Pass `as:` to store under a different key when a
306
+ sequencer runs more than one policy check:
307
+
308
+ ```ruby
309
+ p.invoke(:check_policy, Policies::User, :show, :user, as: :user_policy)
310
+ # ctx[:user_policy] — the built Policies::User instance
311
+ ```
298
312
 
299
313
  | | |
300
314
  |---|---|
301
- | **Reads** | `ctx[:current_user]`, `ctx[record_key]` |
302
- | **Writes** | nothing |
315
+ | **Reads** | `ctx[:current_user]`, `ctx[record_key]` when `record_key` is supplied |
316
+ | **Writes** | `ctx[as]` — the built policy instance (`as:` defaults to `:policy`) |
303
317
  | **Fails** | `:forbidden` when `permitted?` is false; `result.data` carries `{ policy:, policy_result: }` |
304
318
 
319
+ The macro only covers zero-arg policy actions. For actions that take
320
+ arguments (e.g. `Policies::Jobs#create(company_id)`), hand-roll a step
321
+ and use `Macros::Policy::Check.failure(ctx, policy, policy_result)` to
322
+ produce the standard failure shape:
323
+
324
+ ```ruby
325
+ def check_create_policy(ctx)
326
+ policy = Policies::Jobs.build(ctx[:current_user], nil)
327
+ result = policy.create(ctx[:company_id])
328
+
329
+ return Macros::Policy::Check.failure(ctx, policy, result) unless result.permitted?
330
+
331
+ Result.success(ctx)
332
+ end
333
+ ```
334
+
305
335
  A controller can branch on the denial reason via `data`:
306
336
 
307
337
  ```ruby
@@ -328,7 +358,7 @@ def call(ctx)
328
358
  pipeline(ctx) do |p|
329
359
  p.invoke(:find, User, as: :user)
330
360
  p.invoke(:build_contract, Contracts::UpdateUser, :user)
331
- p.invoke(:check_policy, Policies::User, :user, :update)
361
+ p.invoke(:check_policy, Policies::User, :update, :user)
332
362
 
333
363
  p.transaction do |t|
334
364
  t.invoke(:validate, from: %i[params user])
@@ -383,7 +413,7 @@ class Seqs::UpdateUser
383
413
  pipeline(ctx) do |p|
384
414
  p.invoke(:find, User, as: :user)
385
415
  p.invoke(:build_contract, Contracts::UpdateUser, :user)
386
- p.invoke(:check_policy, Policies::User, :user, :update)
416
+ p.invoke(:check_policy, Policies::User, :update, :user)
387
417
  end
388
418
  end
389
419
  end
@@ -474,6 +504,81 @@ with the sequencer's auto-derived i18n scope already applied. It takes
474
504
  the same kwargs as `Result.failure` (`code:`, `data:`, `step:`,
475
505
  `i18n_scope:`, `i18n_key:`, `i18n_args:`).
476
506
 
507
+ ## Translations
508
+
509
+ `Result#message` translates the failure `code` through a fallback chain:
510
+
511
+ 1. **Per-error scope** — whatever the failure set as `i18n_scope:` (or
512
+ `i18n_key:` for an explicit key override).
513
+ 2. **Sequencer's auto-derived scope** — the class name underscored, with
514
+ `/` → `.`. `Seqs::UpdateUser` becomes `seqs.update_user`;
515
+ `Jobadder::Seqs::AuthorizationCallback` becomes
516
+ `jobadder.seqs.authorization_callback`.
517
+ 3. **Framework default** — `sequence.errors.<code>` (the gem ships
518
+ translations for the standard codes; see "Standard error codes" below).
519
+ 4. **Humanized code** — `:not_found` → `"Not found"`.
520
+
521
+ The sequencer's scope is applied automatically. Both the `failure(ctx, ...)`
522
+ helper *and* the boundary itself (the result of `pipeline(ctx)`, in block
523
+ and chained form, and `Sequencer.()`) tag the returned `Result` with
524
+ `i18n_scope` via `Result#with_i18n_scope`. That means an unscoped failure
525
+ produced inside a macro, a hand-rolled step, or anywhere else in the
526
+ pipeline picks up the sequencer's scope when the Result bubbles out — no
527
+ `failure` call required.
528
+
529
+ A sequencer that returns a failure without `pipeline` must build it with
530
+ `failure(ctx, ...)`, not `Result.failure(...)`. An instance call (a test,
531
+ or a nested sequencer run through `p.invoke`) reaches the sequencer's
532
+ `call` directly, so only the helper gives that failure the sequencer's
533
+ scope there.
534
+
535
+ ### Defining translations for a sequencer
536
+
537
+ Drop translations under the sequencer's auto-derived scope in your locale
538
+ file:
539
+
540
+ ```yaml
541
+ en:
542
+ jobadder:
543
+ seqs:
544
+ authorization_callback:
545
+ forbidden: "You are not allowed to connect this company to JobAdder"
546
+ authorization_failed: "Could not authorize with JobAdder"
547
+ ```
548
+
549
+ Now `result.message` returns the scoped string when the sequencer fails
550
+ with `code: :forbidden` or `code: :authorization_failed`. Missing
551
+ translations fall through to the framework default, then to the humanized
552
+ code — so a fresh app gets sensible behaviour with zero config.
553
+
554
+ ### Per-error overrides
555
+
556
+ A specific failure can override the scope or key. The error's own scope
557
+ beats the sequencer's:
558
+
559
+ ```ruby
560
+ failure(
561
+ ctx,
562
+ code: :not_shippable,
563
+ i18n_scope: "checkout.errors", # used instead of seqs.place_order
564
+ i18n_key: :address_invalid, # used instead of :not_shippable
565
+ i18n_args: { region: ctx[:country] }
566
+ )
567
+ ```
568
+
569
+ Resolves `checkout.errors.address_invalid` with the `%{region}`
570
+ interpolation supplied.
571
+
572
+ ### Nested sequencers: innermost scope wins
573
+
574
+ `Result#with_i18n_scope` is a no-op when the scope is already set, so a
575
+ nested sequencer's scope sticks. If `UpdateUser` calls `Present` and
576
+ Present's `Model::Find` macro fails, the failure is tagged with
577
+ `seqs.present` first (Present's boundary); `UpdateUser`'s boundary tries
578
+ to retag with `seqs.update_user` but the no-op preserves the inner scope.
579
+ Messages resolve under the namespace of the sequencer that actually
580
+ produced the failure, not the outermost wrapper.
581
+
477
582
  ## Outcome blocks and safety nets
478
583
 
479
584
  `run_sequence` enforces that serious failures are addressed. Forgetting to
@@ -530,6 +635,13 @@ as substitutes. Tests configure the substitutes for the scenario at hand.
530
635
  Substitutes default to pass-through `Result.success(ctx)` so a test only
531
636
  configures the ones whose return matters.
532
637
 
638
+ An instance call takes a `Ctx`, the same strict ctx that the class-level
639
+ `.()` builds for production, so a test passes one:
640
+ `seq.(Hubbado::Sequence::Ctx.build(params: ..., current_user: ...))`.
641
+ `pipeline` raises `ArgumentError` for keyword arguments or a plain Hash,
642
+ because on a Hash a read of a missing key would return `nil` in the test
643
+ and raise `KeyError` in production.
644
+
533
645
  ### Substituting macros directly
534
646
 
535
647
  ```ruby
@@ -541,7 +653,7 @@ context "Seqs::UpdateUser::Present happy path" do
541
653
  seq.find.succeed_with(user)
542
654
  seq.build_contract.succeed_with(contract)
543
655
 
544
- result = seq.(params: { id: 1 }, current_user: User.new)
656
+ result = seq.(Hubbado::Sequence::Ctx.build(params: { id: 1 }, current_user: User.new))
545
657
 
546
658
  test "Is success" do
547
659
  assert(result.success?)
@@ -564,7 +676,7 @@ context "Seqs::UpdateUser::Present when the user is not found" do
564
676
  seq = Seqs::UpdateUser::Present.new
565
677
  seq.find.fail_with(code: :not_found)
566
678
 
567
- result = seq.(params: { id: 999 }, current_user: User.new)
679
+ result = seq.(Hubbado::Sequence::Ctx.build(params: { id: 999 }, current_user: User.new))
568
680
 
569
681
  test "Fails with :not_found" do
570
682
  assert(result.code == :not_found)
@@ -605,8 +717,10 @@ context "Seqs::UpdateUser happy path" do
605
717
  seq.present.succeed_with(user: user, contract: contract)
606
718
 
607
719
  result = seq.(
608
- params: { user: { email: "new@example.com" } },
609
- current_user: User.new
720
+ Hubbado::Sequence::Ctx.build(
721
+ params: { user: { email: "new@example.com" } },
722
+ current_user: User.new
723
+ )
610
724
  )
611
725
 
612
726
  test "Is success" do
@@ -626,7 +740,7 @@ context "Seqs::UpdateUser when Present denies access" do
626
740
  seq = Seqs::UpdateUser.new
627
741
  seq.present.fail_with(code: :forbidden)
628
742
 
629
- result = seq.(params: { user: {} }, current_user: User.new)
743
+ result = seq.(Hubbado::Sequence::Ctx.build(params: { user: {} }, current_user: User.new))
630
744
 
631
745
  test "Fails" do
632
746
  assert(result.failure?)
@@ -649,7 +763,7 @@ context "Seqs::UpdateUser when Present cannot find the record" do
649
763
  seq = Seqs::UpdateUser.new
650
764
  seq.present.fail_with(code: :not_found)
651
765
 
652
- result = seq.(params: { id: 999, user: {} }, current_user: User.new)
766
+ result = seq.(Hubbado::Sequence::Ctx.build(params: { id: 999, user: {} }, current_user: User.new))
653
767
 
654
768
  test "Fails with :not_found" do
655
769
  assert(result.code == :not_found)
@@ -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.6.0"
4
+ s.version = "0.8.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
 
@@ -10,9 +10,9 @@ module Hubbado
10
10
  new
11
11
  end
12
12
 
13
- def call(ctx, contract_class, attr_name = nil)
14
- model = attr_name && Path.resolve(ctx, attr_name)
15
- ctx[:contract] = contract_class.new(model)
13
+ def call(ctx, contract_class, model = nil)
14
+ resolved_model = model && Path.resolve(ctx, model)
15
+ ctx[:contract] = contract_class.new(resolved_model)
16
16
  Result.success(ctx)
17
17
  end
18
18
 
@@ -30,7 +30,7 @@ module Hubbado
30
30
  self
31
31
  end
32
32
 
33
- record def call(ctx, contract_class, attr_name = nil)
33
+ record def call(ctx, contract_class, model = nil)
34
34
  return Result.failure(ctx, **@configured_error) if @configured_error
35
35
 
36
36
  ctx[:contract] = @return_value if @configured_success
@@ -10,29 +10,36 @@ module Hubbado
10
10
  new
11
11
  end
12
12
 
13
- def call(ctx, policy, record_key, action)
13
+ def self.failure(ctx, policy, policy_result)
14
+ Result.failure(
15
+ ctx,
16
+ code: :forbidden,
17
+ data: { policy: policy, policy_result: policy_result }
18
+ )
19
+ end
20
+
21
+ def call(ctx, policy, action, record_key = nil, as: nil)
22
+ as ||= :policy
14
23
  current_user = ctx[:current_user]
15
- record = ctx[record_key]
24
+ record = record_key && ctx[record_key]
16
25
 
17
26
  policy_instance = policy.build(current_user, record)
27
+ ctx[as] = policy_instance
18
28
  policy_result = policy_instance.public_send(action)
19
29
 
20
30
  if policy_result.permitted?
21
31
  Result.success(ctx)
22
32
  else
23
- Result.failure(
24
- ctx,
25
- code: :forbidden,
26
- data: { policy: policy_instance, policy_result: policy_result }
27
- )
33
+ self.class.failure(ctx, policy_instance, policy_result)
28
34
  end
29
35
  end
30
36
 
31
37
  module Substitute
32
38
  include ::RecordInvocation
33
39
 
34
- def succeed_with
40
+ def succeed_with(policy_instance = nil)
35
41
  @configured_success = true
42
+ @return_policy = policy_instance
36
43
  self
37
44
  end
38
45
 
@@ -41,7 +48,9 @@ module Hubbado
41
48
  self
42
49
  end
43
50
 
44
- record def call(ctx, policy, record_key, action)
51
+ record def call(ctx, policy, action, record_key = nil, as: nil)
52
+ as ||= :policy
53
+
45
54
  unless policy.method_defined?(action)
46
55
  raise ArgumentError,
47
56
  "Macros::Policy::Check substitute: #{policy} does not declare action :#{action}"
@@ -49,6 +58,7 @@ module Hubbado
49
58
 
50
59
  return Result.failure(ctx, **@configured_error) if @configured_error
51
60
 
61
+ ctx[as] = @return_policy if @return_policy
52
62
  Result.success(ctx)
53
63
  end
54
64
 
@@ -4,11 +4,12 @@ module Hubbado
4
4
  # `pipeline(ctx)` helper. Not part of the public API — sequencers reach
5
5
  # it through the helper.
6
6
  class Pipeline
7
- def initialize(ctx, dispatcher:)
7
+ def initialize(ctx, dispatcher:, i18n_scope: nil)
8
8
  @ctx = ctx
9
9
  @successful_steps = []
10
10
  @failed_result = nil
11
11
  @dispatcher = dispatcher
12
+ @i18n_scope = i18n_scope
12
13
  end
13
14
 
14
15
  # `step(:name)` dispatches to `dispatcher.send(name, ctx)`. The method
@@ -55,11 +56,9 @@ module Hubbado
55
56
  end
56
57
 
57
58
  def result
58
- if @failed_result
59
- @failed_result
60
- else
61
- Result.success(@ctx, successful_steps: @successful_steps.dup)
62
- end
59
+ result = @failed_result || Result.success(@ctx, successful_steps: @successful_steps.dup)
60
+
61
+ result.with_i18n_scope(@i18n_scope)
63
62
  end
64
63
 
65
64
  private
@@ -62,7 +62,7 @@ module Hubbado
62
62
  ctx = Ctx.build(ctx)
63
63
  end
64
64
 
65
- build.call(ctx)
65
+ build.call(ctx).with_i18n_scope(i18n_scope)
66
66
  end
67
67
 
68
68
  # Default factory: a sequencer with no configurable dependencies needs
@@ -95,8 +95,18 @@ module Hubbado
95
95
  # block, and returns the final Result — no trailing `.result` needed. The
96
96
  # non-block form returns the Pipeline so chained `.step(...)...result`
97
97
  # calls still work.
98
+ #
99
+ # Anything but a Ctx means an instance call skipped the Ctx that the
100
+ # class-level `.()` builds; run on a plain Hash, a missing key would
101
+ # read as nil rather than raise.
98
102
  def pipeline(ctx, &block)
99
- pipe = Pipeline.new(ctx, dispatcher: self)
103
+ unless ctx.is_a?(Ctx)
104
+ raise ArgumentError,
105
+ "#{self.class.name}#pipeline expects a Hubbado::Sequence::Ctx, got #{ctx.class}; " \
106
+ "pass Hubbado::Sequence::Ctx.build(...) to an instance call"
107
+ end
108
+
109
+ pipe = Pipeline.new(ctx, dispatcher: self, i18n_scope: i18n_scope)
100
110
 
101
111
  if block
102
112
  block.call(pipe)
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.6.0
4
+ version: 0.8.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-16 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: evt-casing