axn 0.1.0.pre.alpha.4.2 → 0.1.0.pre.alpha.4.3

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: bbed8a136915346d270fb287bd513684ddfd9b70692fa8a06384924a34198d6b
4
- data.tar.gz: a9f8bb70324642e6da0ecba6522eaa8587f53259ad3488ddc72ed20927a73ad3
3
+ metadata.gz: a911da94d085860df8fc6aed9bbe869edae27c2ff0e5821b2c07a043644c4fb5
4
+ data.tar.gz: 0d56247c256cc52efc8258a1716b0c4274114411948b084fe872fc63285907c3
5
5
  SHA512:
6
- metadata.gz: 260b77024b257ad90ac69ae67deb6468a6c30d4bd47d877eefb709879e8e1a16a6f0cf519607951f3de3d9fe2663bb84cab37a58383846f495ddc17eed053614
7
- data.tar.gz: 583c4c638a5bdc2c9d5686551e305972756d7737450c3ec8a02f3a5b236582a59e41e2290532ed8d69b9fc4c145364db0ac8f0a4a8cbda83736bf10cd7a5093f
6
+ metadata.gz: d39402f76d8df64e831007e046f0d5c8ac7fcd009246bbee9e7296db789b3afa270f5e63987eb23c88821f3f0ee2fecf3e7ec576b5dc9ce0f9e0972ba1811f93
7
+ data.tar.gz: 2347b225abbab7c3e705ca071b661e104c18c4d6333d5eb8563eadf26559035485f79682f4e4a23f3c6e3dabf30209475570fd07ab511a9b75f9d5537d320f41
data/CHANGELOG.md CHANGED
@@ -1,7 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
4
- * N/A
3
+ ## 0.1.0-alpha.4.3
4
+ * [FEAT] Plain namespace **modules** can now host mounted actions: `include Axn::Mountable` on a module (not just a class) exposes `mount_axn` / `mount_axn_method` / `step`. Class hosts keep the existing `class_attribute` + `inherited` behavior; module hosts use singleton accessors and skip the `inherited` hook (modules have no subclasses). Also fixes a `.name` clobber so passing an already-named class to `mount_axn` preserves its original name instead of rewriting it to the `Axns` namespace path.
5
+ * [BUGFIX] Fixed an off-by-one in `async.attempt` reported from the Sidekiq death handler: Sidekiq increments `retry_count` before invoking death handlers, so an exhausted `retry: 3` job (4 executions) reported attempt `5` instead of `4`. The bug was metadata-only — control flow (`retries_exhausted?`, `first_attempt?`, `should_trigger_on_exception?`) was unaffected. Also documents why framework-native integrations (e.g. Honeybadger's Sidekiq plugin) can produce duplicate async error reports, with a tag-and-filter suppression recipe.
6
+ * [BUGFIX] Subfield names that collide with a method on the parent value (e.g. `zip`, `count`, `first` — any `Hash`/`Enumerable` method) are now read as keys instead of being dispatched as method calls. Previously `expects :zip, on: :address` extracted `address.zip` (`Enumerable#zip`) and failed with a bogus error; `FieldResolvers::Extract` now digs the key first for Hash-like sources and only falls back to a reader method for non-diggable objects (e.g. `Data` instances).
7
+ * [FEAT] `expects … on:` now accepts a **dotted path** to reach a deeply-nested parent (e.g. `expects :zip, on: "address.billing", type: String` validates `address[:billing][:zip]` and defines a flat `zip` reader). The root segment must be a declared field/subfield. `default:`/`preprocess:`/`sensitive:` combined with a dotted `on:` raise `ArgumentError` (writing into — and redacting — an arbitrary nested path isn't supported yet); single-key `on:` is unchanged.
8
+ * [FEAT] Add block syntax for declaring the per-member shape of a structured field on `expects`/`exposes`. On a `type: Array`, `type: Hash`, or class-typed field, a block declares member contracts: `expects :items, type: Array do field :status, type: String, inclusion: { in: %w[a b] } end`. Members accept the same options as top-level fields (`type`, `inclusion`, `optional`, `description`, …) and recurse via nested blocks. For arrays each element is validated with indexed errors (`element at index 2: status …`); for a single Hash/object value its members are validated directly. The block requires a single structured `type:` (raises `ArgumentError` on scalars, unions, or no type), composes with `of:` (which still checks element class), and — unlike `on:` subfields — defines **no** reader methods. Downstream tooling reads members from `config.validations[:shape][:members]`.
9
+ * [FEAT] Add `of:` array-element validation for `expects`/`exposes`. On a `type: Array` field, `of:` validates each element: a single class (`of: String`), a union (`of: [String, Numeric]` — an element passes if it matches *any*), the `:boolean`/`:uuid`/`:params` symbols, or a `Data.define` class. Only valid alongside `type: Array` (raises `ArgumentError` otherwise, including for unions like `type: [Array, String]`). Error messages report the failing element's index (e.g. `element at index 2 is not a String`) and honor a custom `message:`. `optional`/`allow_blank`/`allow_nil` govern whether the whole field may be absent — they do not make individual elements blank-able. Downstream tooling can read the element type from `config.validations[:of][:klass]`.
10
+ * [FEAT] `exposes`-declared fields that are also `expects`-declared are now auto-copied from the input into the result on **all** outcome paths — success, `done!`, `fail!`, and unhandled exception. Previously, the auto-copy only ran on success/`done!` paths, leaving `result.field` as `nil` after `fail!` or an exception even when the field was provided as input. This is particularly useful for re-exposing mutated ActiveRecord objects (e.g. inspecting `user.errors` after a failed save). Explicit `expose` calls before a failure continue to work and take precedence.
11
+ * [FEAT] Execution log messages now display elapsed time in human-readable units (milliseconds, seconds, minutes, or hours) instead of always showing milliseconds
12
+ * [BREAKING] `exposes` no longer defines a direct reader method on the action instance. Exposed fields must be accessed via `result.field` (e.g., `result.greeting`). `expects` readers are unaffected. User-defined methods with the same name as an exposed field are preserved (DefaultCall still calls them). Use `result.field` in `success`/`error` message callables and `sensitive:` procs to access output values.
13
+ * [FEAT] Add boolean predicate readers for `expects` and `exposes`: `expects :enabled, type: :boolean` defines `enabled?` on the action instance; `exposes :enabled, type: :boolean` defines `result.enabled?`.
14
+ * [BUGFIX] `ExceptionContext.build` no longer raises `URI::GID::MissingModelIdError` when an exposed or expected value is an unpersisted ActiveRecord record. The formatter now renders such values as `#<ClassName (unpersisted)>` and the optional retry command falls back to `inspect` instead of generating `Model.find(nil)`.
15
+ * [FEAT] Add dynamic `sensitive:` option support for `expects` and `exposes` fields - accepts procs or symbols that are evaluated at runtime against the action instance, allowing conditional sensitivity based on input values (e.g., `exposes :data, sensitive: -> { redact_mode }`)
5
16
 
6
17
  ## 0.1.0-alpha.4.2
7
18
  * [FEAT] Add extensible field metadata support for `expects`/`exposes`:
data/Rakefile CHANGED
@@ -151,3 +151,71 @@ Rake::Task["release"].enhance do
151
151
  Rake::Task["benchmark:release"].reenable
152
152
  Rake::Task["benchmark:release"].invoke
153
153
  end
154
+
155
+ # Downstream gem compatibility check
156
+ DOWNSTREAM_GEMS = {
157
+ "slack_sender" => File.expand_path("../slack_sender/slack_sender.gemspec", __dir__),
158
+ "data_shifter" => File.expand_path("../data_shifter/data_shifter.gemspec", __dir__),
159
+ "axn-mcp" => File.expand_path("../axn-mcp/axn-mcp.gemspec", __dir__),
160
+ "axn-ruby_llm" => File.expand_path("../axn-ruby_llm/axn-ruby_llm.gemspec", __dir__),
161
+ }.freeze
162
+
163
+ PARSE_AXN_REQUIREMENT = lambda { |gemspec_path|
164
+ content = File.read(gemspec_path)
165
+ match = content.match(/add_dependency\s+["']axn["']\s*,\s*(.+)$/)
166
+ return nil unless match
167
+
168
+ constraints = match[1].scan(/["']([^"']+)["']/).flatten
169
+ Gem::Requirement.new(constraints)
170
+ }
171
+
172
+ namespace :downstream do
173
+ desc "Check whether downstream gems support the current axn version"
174
+ task :check do
175
+ require "rubygems"
176
+ require_relative "lib/axn/version"
177
+
178
+ current_version = Gem::Version.new(Axn::VERSION)
179
+ warnings = []
180
+
181
+ puts "Downstream gem compatibility with axn #{current_version}:"
182
+ puts ""
183
+
184
+ DOWNSTREAM_GEMS.each do |name, gemspec_path|
185
+ unless File.exist?(gemspec_path)
186
+ puts " #{name}: gemspec not found at #{gemspec_path}"
187
+ next
188
+ end
189
+
190
+ requirement = PARSE_AXN_REQUIREMENT.call(gemspec_path)
191
+
192
+ unless requirement
193
+ puts " #{name}: no axn dependency found in gemspec"
194
+ next
195
+ end
196
+
197
+ if requirement.satisfied_by?(current_version)
198
+ puts " #{name}: OK (#{requirement})"
199
+ else
200
+ puts " #{name}: NEEDS UPDATE (#{requirement} excludes #{current_version})"
201
+ warnings << " - #{name}: update axn constraint (currently #{requirement}) to include #{current_version}"
202
+ end
203
+ end
204
+
205
+ puts ""
206
+
207
+ if warnings.any?
208
+ puts "WARNING: These downstream gems need axn version constraint updates before"
209
+ puts " they can use axn #{current_version}:"
210
+ warnings.each { |w| puts w }
211
+ else
212
+ puts "All downstream gems support axn #{current_version}."
213
+ end
214
+ end
215
+ end
216
+
217
+ # Warn (but don't block) about downstream gems that need updating before release
218
+ Rake::Task["release"].enhance do
219
+ Rake::Task["downstream:check"].reenable
220
+ Rake::Task["downstream:check"].invoke
221
+ end
@@ -58,6 +58,7 @@ export default defineConfig({
58
58
  { text: 'Validating User Input', link: '/recipes/validating-user-input' },
59
59
  { text: 'Testing Actions', link: '/recipes/testing' },
60
60
  { text: 'RuboCop Integration', link: '/recipes/rubocop-integration' },
61
+ { text: 'Suppressing Duplicate Async Reports', link: '/recipes/suppressing-duplicate-async-reports' },
61
62
  ]
62
63
  },
63
64
  {
@@ -17,6 +17,39 @@ When you attach an action to a class, you get multiple ways to access it:
17
17
  1. **Direct method calls** on the class (e.g., `SomeClass.foo`), which depend on how you told it to mount
18
18
  3. **Namespace method calls** (e.g., `SomeClass::Axns.foo`) which always call the underlying axn directly (i.e. returning Axn::Result like a normal SomeAxn.call)
19
19
 
20
+ ## Host Types
21
+
22
+ ### Classes (`include Axn`)
23
+
24
+ The typical host is a class that also `include Axn`. It gets the full mounting DSL, inheritable descriptors (subclasses receive mounts from their parent), and the `::Axns` namespace:
25
+
26
+ ```ruby
27
+ class UserService
28
+ include Axn
29
+
30
+ mount_axn(:create_user) { |email:| User.create!(email:) }
31
+ end
32
+ ```
33
+
34
+ ### Plain namespace modules (`include Axn::Mountable`)
35
+
36
+ If your host is a namespace module — not itself an action — include `Axn::Mountable` directly instead:
37
+
38
+ ```ruby
39
+ module MyGem
40
+ include Axn::Mountable
41
+
42
+ mount_axn :process, MyGem::ProcessAction
43
+ end
44
+
45
+ MyGem.process(...) # => Axn::Result
46
+ MyGem.process!(...) # raises on failure
47
+ MyGem.process_async(...)
48
+ MyGem::Axns.process(...)
49
+ ```
50
+
51
+ Mounting an existing named class on any host preserves the class's original `.name` — it will not be overwritten with the `::Axns::` namespace path.
52
+
20
53
  ## Attachment Strategies
21
54
 
22
55
  ### `axn` Strategy
@@ -28,15 +61,14 @@ class UserService
28
61
  include Axn
29
62
 
30
63
  mount_axn(:create_user) do |email:, name:|
31
- user = User.create!(email: email, name: name)
32
- expose :user_id, user.id
64
+ User.create!(email: email, name: name)
33
65
  end
34
66
  end
35
67
 
36
68
  # Usage
37
69
  result = UserService.create_user(email: "user@example.com", name: "John")
38
70
  if result.ok?
39
- puts "User created with ID: #{result.user_id}"
71
+ puts "User created: #{result.value.inspect}"
40
72
  else
41
73
  puts "Error: #{result.error}"
42
74
  end
@@ -130,8 +162,8 @@ class DataProcessor
130
162
  async :sidekiq
131
163
 
132
164
  mount_axn(:process_data, async: :sidekiq) do |data:|
133
- # Processing logic
134
- expose :processed_count, data.count
165
+ # Processing logic; return value is auto-exposed as result.value
166
+ data.count
135
167
  end
136
168
  end
137
169
 
@@ -185,13 +217,12 @@ class UserService
185
217
  # Inherits lifecycle (hooks, callbacks, messages, async) but not fields
186
218
  mount_axn :create_user do
187
219
  # Will run log_start before and track_success after
188
- expose :user_id, 123
220
+ User.create!(email: "example@example.com")
189
221
  end
190
222
 
191
223
  # Completely independent - no inheritance
192
224
  step :validate_user do
193
225
  # Will NOT run log_start or track_success
194
- expose :valid, true
195
226
  end
196
227
  end
197
228
  ```
@@ -427,8 +458,7 @@ class UserService
427
458
  include Axn
428
459
 
429
460
  mount_axn(:create) do |email:, name:|
430
- user = User.create!(email: email, name: name)
431
- expose :user_id, user.id
461
+ User.create!(email: email, name: name)
432
462
  end
433
463
 
434
464
  mount_axn_method(:find_by_email) do |email:|
@@ -0,0 +1,65 @@
1
+ # Suppressing Duplicate Async Error Reports
2
+
3
+ When an Axn async job fails, your error monitoring integration may report the exception **twice**: once via Axn's `on_exception` path, and once natively from the background job framework's own error integration.
4
+
5
+ For example, with Honeybadger + Sidekiq: Honeybadger's Sidekiq plugin independently catches the re-raised exception on every execution, producing a separate raw fault in Honeybadger regardless of your `async_exception_reporting` setting. If you've configured `:first_and_exhausted`, you'll still see a raw Sidekiq `RuntimeError` fault with one notice per retry — which makes the reporting look broken.
6
+
7
+ ## General Approach
8
+
9
+ The fix belongs in your error reporter, not in Axn. Suppress framework-native reports for Axn actions (since Axn is already handling reporting via `on_exception`) while leaving non-Axn jobs unaffected.
10
+
11
+ The two signals you need:
12
+
13
+ 1. **Is this an Axn action?** Check whether the job class includes `Axn::Core`.
14
+ 2. **Was this notice sent by Axn or by the framework natively?** Tag Axn-authored notices in your `on_exception` handler so they can be distinguished from the native ones.
15
+
16
+ Add a known key when calling your error reporter from `on_exception`:
17
+
18
+ ```ruby
19
+ Axn.configure do |c|
20
+ c.on_exception = proc do |e, action:, context:|
21
+ # Tag this notice as Axn-authored so we can identify it in before_notify filters
22
+ Honeybadger.notify(e, context: context.merge(axn: true))
23
+ end
24
+ end
25
+ ```
26
+
27
+ ## Honeybadger + Sidekiq Example
28
+
29
+ Honeybadger's `before_notify` hook lets you inspect and halt notices before they're sent:
30
+
31
+ ```ruby
32
+ # config/initializers/honeybadger.rb
33
+ Honeybadger.configure do |config|
34
+ config.before_notify do |notice|
35
+ # Axn-authored notices (tagged via on_exception) always pass through
36
+ next if notice.context[:axn] || notice.context["axn"]
37
+
38
+ # Halt native Sidekiq/ActiveJob notices for Axn actions —
39
+ # Axn's on_exception is handling reporting for these.
40
+ component = notice.component.to_s
41
+
42
+ # Direct Sidekiq worker: component is the Axn action class itself
43
+ klass = component.safe_constantize
44
+ next notice.halt! if klass&.include?(Axn::Core)
45
+
46
+ # ActiveJob proxy: component is "MyAction::ActiveJobProxy"
47
+ if component.end_with?("::ActiveJobProxy")
48
+ action_klass = component.delete_suffix("::ActiveJobProxy").safe_constantize
49
+ next notice.halt! if action_klass&.include?(Axn::Core)
50
+ end
51
+ end
52
+ end
53
+ ```
54
+
55
+ **What this does:**
56
+
57
+ - Axn-authored notices (tagged `axn: true`) pass through unchanged
58
+ - Native Sidekiq/ActiveJob notices for Axn actions are halted
59
+ - Notices for non-Axn workers are unaffected
60
+ - Multiple `before_notify` hooks compose — this doesn't interfere with other app-specific filters
61
+
62
+ ## Notes
63
+
64
+ - The exact implementation varies by error reporter and adapter. The pattern is the same: detect Axn ownership at the filter layer and suppress native reports, passing through Axn-authored ones.
65
+ - This fix is typically applied in your application or framework layer rather than in Axn itself, since it depends on which error reporter you're using.
@@ -212,6 +212,14 @@ Axn::Async::Adapters::Sidekiq::AutoConfigure.death_handler_registered?
212
212
  # => should be true
213
213
  ```
214
214
 
215
+ ### Duplicate error reports from Honeybadger or other error integrations
216
+
217
+ If you're seeing duplicate faults in your error tracker — for example, both an Axn-authored notice and a raw `RuntimeError` fault with one entry per retry — this is caused by your error monitoring integration reporting the re-raised exception independently of Axn's `on_exception` path.
218
+
219
+ Axn re-raises unexpected exceptions after reporting so that Sidekiq can retry them. Integrations like Honeybadger's Sidekiq plugin intercept that re-raised exception directly, bypassing your `async_exception_reporting` setting entirely.
220
+
221
+ See [Suppressing Duplicate Async Error Reports](/recipes/suppressing-duplicate-async-reports) for an explanation and a Honeybadger + Sidekiq filter example.
222
+
215
223
  ### Jobs not retrying on fail!
216
224
 
217
225
  This is expected behavior. `fail!` indicates a business decision, not a transient error. If you need retries for a specific failure case, raise an exception instead:
@@ -12,7 +12,7 @@ Every `call` invocation on an Axn will return an `Axn::Result` instance, which p
12
12
  | `outcome` | The execution outcome as a string inquirer (`success?`, `failure?`, `exception?`)
13
13
  | `elapsed_time` | Execution time in milliseconds (Float)
14
14
  | `finalized?` | `true` if the result has completed execution (either successfully or with an exception), `false` if still in progress
15
- | any `expose`d values | guaranteed to be set if `ok?` (since they have outgoing presence validations by default; any missing would have failed the action)
15
+ | any `expose`d values | guaranteed to be set if `ok?`; fields that are also `expects`-declared are auto-copied and available on `fail!` and exception paths too (see [Re-exposing an expected field](/usage/writing#re-exposing-an-expected-field-auto-copy))
16
16
 
17
17
  NOTE: `success` and `error` (and so implicitly `message`) can be configured per-action via [the `success` and `error` declarations](/reference/class#success-and-error).
18
18
 
@@ -20,6 +20,58 @@ Both `expects` and `exposes` support the same core options:
20
20
  | `type` | `expects :foo, type: String` | Custom type validation -- fail unless `name.is_a?(String)`
21
21
  | anything else | `expects :foo, inclusion: { in: [:apple, :peach] }` | Any other arguments will be processed [as ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html) (i.e. as if passed to `validates :foo, <...>` on an ActiveRecord model)
22
22
 
23
+ ### Dynamic `sensitive` fields
24
+
25
+ The `sensitive` option can accept a proc or symbol in addition to a boolean, allowing you to conditionally filter fields based on runtime values:
26
+
27
+ ```ruby
28
+ class MyAction
29
+ include Axn
30
+
31
+ expects :include_pii, type: :boolean
32
+ expects :ssn, sensitive: -> { !include_pii }
33
+
34
+ exposes :api_response, sensitive: :should_redact?
35
+
36
+ def call
37
+ expose api_response: fetch_data
38
+ end
39
+
40
+ private
41
+
42
+ def should_redact?
43
+ !include_pii || result.api_response[:contains_secrets]
44
+ end
45
+ end
46
+
47
+ # When include_pii is false, ssn is filtered
48
+ MyAction.call(include_pii: false, ssn: "123-45-6789")
49
+ #=> inputs: { ssn: [FILTERED], include_pii: false }
50
+
51
+ # When include_pii is true, ssn is visible
52
+ MyAction.call(include_pii: true, ssn: "123-45-6789")
53
+ #=> inputs: { ssn: "123-45-6789", include_pii: true }
54
+ ```
55
+
56
+ The callable receives no arguments and is evaluated via `instance_exec`, so it has access to:
57
+ - All `expects` field values (via their reader methods, e.g., `include_pii`)
58
+ - Exposed values via `result.field` (e.g., `result.api_response`) — bare field names are **not** available for `exposes`-only fields
59
+ - Any instance methods defined on the action
60
+
61
+ ::: warning Timing: sensitive evaluated before defaults
62
+ For `expects` fields, the `sensitive` callable is evaluated **before** defaults are applied. This means if your sensitivity logic depends on another field's value, that field should either be required or you should handle `nil` explicitly:
63
+
64
+ ```ruby
65
+ # CAUTION: mode may be nil if caller doesn't provide it
66
+ expects :mode, default: "public"
67
+ expects :api_key, sensitive: -> { mode != "debug" } # mode could be nil here!
68
+
69
+ # SAFER: handle nil explicitly
70
+ expects :api_key, sensitive: -> { mode.nil? || mode != "debug" }
71
+ ```
72
+
73
+ This is because automatic logging of inputs happens before defaults are applied in the execution flow. For `exposes` fields, this is not a concern since output logging happens after the action completes.
74
+ :::
23
75
 
24
76
  ### Validation details
25
77
 
@@ -27,11 +79,16 @@ Both `expects` and `exposes` support the same core options:
27
79
  While we _support_ complex interface validations, in practice you usually just want a `type`, if anything. Remember this is your validation about how the action is called, _not_ pretty user-facing errors (there's [a different pattern for that](/recipes/validating-user-input)).
28
80
  :::
29
81
 
30
- In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support four additional custom validators:
82
+ In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support five additional custom validators:
31
83
  * `type: Foo` - fails unless the provided value `.is_a?(Foo)`
32
84
  * Edge case: use `type: :boolean` to handle a boolean field (since ruby doesn't have a Boolean class to pass in directly)
85
+ * Boolean `expects` fields also define a predicate reader, so `expects :enabled, type: :boolean` provides both `enabled` and `enabled?` on the action instance. The same applies to subfield readers unless `readers: false` is set. Boolean `exposes` fields provide predicate readers on the result, so `exposes :enabled, type: :boolean` provides `result.enabled?`.
33
86
  * Edge case: use `type: :uuid` to handle a confirming given string is a UUID (with or without `-` chars)
34
87
  * Edge case: use `type: :params` to accept either a Hash or ActionController::Parameters (Rails-compatible)
88
+ * `of: Foo` - for `type: Array` fields, validates each element (fails unless every element `.is_a?(Foo)`)
89
+ * Accepts the same forms as `type:`: a single class (`of: String`), a union array (`of: [String, Numeric]` — an element passes if it matches *any*), the `:boolean`/`:uuid`/`:params` symbols, or a `Data.define` class
90
+ * Only valid alongside `type: Array` (exactly) — using it on any other type, including a union like `type: [Array, String]`, raises `ArgumentError` at declaration time
91
+ * Error messages report the failing element's index (e.g. `element at index 2 is not a String`). Pass `of: { klass: Foo, message: "..." }` to override the type description while still reporting the index
35
92
  * `validate: [callable]` - Support custom validations (fails if any string is returned OR if it raises an exception)
36
93
  * Example:
37
94
  ```ruby
@@ -61,13 +118,38 @@ In addition to the [standard ActiveModel validations](https://guides.rubyonrails
61
118
  * For external APIs, you can pass a `Method` object as the finder
62
119
  :::
63
120
 
121
+ #### Describing the shape of structured fields (block syntax)
122
+
123
+ For a structured field — `type: Array`, `type: Hash`, or a class such as a `Data.define` — you can pass a block to declare per-member contracts (types, enums, descriptions, nesting). This works on both `expects` and `exposes`:
124
+
125
+ ```ruby
126
+ exposes :integrations, type: Array, of: IntegrationRecord do
127
+ field :source, type: String
128
+ field :status, type: String, inclusion: { in: %w[connected connected_with_issues needs_reconnect incomplete error] }
129
+
130
+ field :config, type: Hash do # nested object
131
+ field :region, type: String
132
+ end
133
+ field :endpoints, type: Array do # nested array of objects
134
+ field :url, type: String
135
+ end
136
+ end
137
+ ```
138
+
139
+ * The block requires a single, **structured** `type:` (Array, Hash, or a class). Declaring it on a scalar type (`String`, `Integer`, `:boolean`, …), a union (`type: [Array, String]`), or with no `type:` raises `ArgumentError` at declaration time.
140
+ * For `type: Array`, each element is validated and errors report the element's index (e.g. `element at index 2: status is not included in the list`). For a `type: Hash`/class, the single value's members are validated directly.
141
+ * Members accept validations (`type`, `inclusion`, …), `optional`/`allow_blank`/`allow_nil`, and `description`, and **recurse** — a member with its own block validates its nested members at any depth. Members are validation/schema-only, so `default:`, `preprocess:`, and `sensitive:` are **not** supported on a member (they raise at declaration time).
142
+ * Unlike `expects … on:` subfields, a shape block does **not** define reader methods — there is no single value to bind (an array has many elements). It is a contract on structure only.
143
+ * Composes with `of:`: `of:` checks each element's *class*, while the block describes the element's *fields*. `of:` is optional.
144
+
64
145
  #### How `optional`, `allow_blank` and `allow_nil` work with validators
65
146
 
66
147
  When you specify `optional: true`, `allow_blank: true`, or `allow_nil: true` on a field, these options are automatically passed through to **all validators** applied to that field. This means:
67
148
 
68
149
  - **ActiveModel validations** (like `inclusion`, `length`, etc.) will respect these options
69
- - **Custom validators** (`type`, `validate`, `model`) will also respect these options
150
+ - **Custom validators** (`type`, `validate`, `model`, `of`) will also respect these options
70
151
  - **Type validator edge case**: Note passing `allow_blank` is nonsensical for type: :params and type: :boolean
152
+ - **`of` validator note**: these options govern whether the whole Array field may be absent — they do **not** make individual elements optional. A `nil` (or blank) element is still validated against `of:` regardless.
71
153
 
72
154
  **Recommended approach**: Use `optional: true` instead of `allow_blank: true` for better clarity. The `optional` parameter is equivalent to `allow_blank: true` and makes the intent clearer.
73
155
 
@@ -75,7 +157,7 @@ If neither `optional`, `allow_blank` nor `allow_nil` is specified, a default pre
75
157
 
76
158
  ### Details specific to `.exposes`
77
159
 
78
- Remember that you'll need [a corresponding `expose` call](/reference/instance#expose) for every variable you declare via `exposes`.
160
+ For fields you declare via `exposes`, you'll need [a corresponding `expose` call](/reference/instance#expose) unless the field is also declared via `expects`, in which case axn auto-copies it from the input into the result on all outcome paths (success, `fail!`, and exception). See [Re-exposing an expected field](/usage/writing#re-exposing-an-expected-field-auto-copy).
79
161
 
80
162
 
81
163
  ### Details specific to `.expects`
@@ -101,6 +183,21 @@ end
101
183
  Defaults work the same way for subfields as they do for top-level fields - they are applied when the subfield is missing or explicitly `nil`, but not for blank values.
102
184
  :::
103
185
 
186
+ #### Reaching into nested parents
187
+
188
+ `on:` accepts a **dotted path** to declare a subfield of a deeply-nested parent, with a clean flat reader named after the field:
189
+
190
+ ```ruby
191
+ expects :address, type: Hash
192
+ expects :zip, on: "address.billing", type: String # validates address[:billing][:zip]; defines a `zip` reader
193
+ ```
194
+
195
+ The **root** segment (`address`) must be a declared field (or subfield); intermediate segments are assumed to be hashes. The reader is named after the subfield (`zip`) — there's no ambiguity, since the field name itself has no dots.
196
+
197
+ ::: warning
198
+ `default:`, `preprocess:`, and `sensitive:` are **not** supported on a dotted `on:` (they raise at declaration time) — `default:`/`preprocess:` write into the parent, and `sensitive:` relies on the log filter matching a top-level field, neither of which handles an arbitrary nested path yet. Use them on a single-key `on:`, or declare the intermediate levels explicitly.
199
+ :::
200
+
104
201
  #### Disabling subfield readers
105
202
 
106
203
  By default, subfields create top-level reader methods (e.g., `random` in the example above). You can disable this with `readers: false`:
@@ -47,7 +47,7 @@ To abort execution with a specific error message, call `fail!`. You can also pro
47
47
 
48
48
  To complete execution early with a success result, call `done!` with an optional success message and exposures as keyword arguments.
49
49
 
50
- If you declare that your action `exposes` anything, you need to actually `expose` it.
50
+ If you declare that your action `exposes` anything, you need to actually `expose` it — unless you're re-exposing a field you also `expects`, in which case axn auto-copies it for you (see below).
51
51
 
52
52
  ```ruby
53
53
  class Foo
@@ -67,6 +67,29 @@ end
67
67
 
68
68
  See [the reference doc](/reference/instance) for a few more handy helper methods (e.g. `#log`).
69
69
 
70
+ ### Re-exposing an expected field (auto-copy)
71
+
72
+ When a field is declared with both `expects` and `exposes`, axn automatically copies it from the input into the result — no manual `expose` call needed. This works on **all outcome paths**: success, `done!`, `fail!`, and unhandled exceptions.
73
+
74
+ This is particularly useful when an action mutates an ActiveRecord object in-place (e.g. `user.valid?` populates `user.errors`) and the caller needs to inspect the object after a failure:
75
+
76
+ ```ruby
77
+ class UpdateUser
78
+ include Axn
79
+
80
+ expects :user, model: true
81
+ exposes :user # auto-copied — no expose call needed
82
+
83
+ def call
84
+ user.assign_attributes(params)
85
+ fail! unless user.save # user.errors is available on result.user even on failure
86
+ end
87
+ end
88
+
89
+ result = UpdateUser.call(user:, params:)
90
+ result.user.errors.full_messages # populated on both ok? and !ok?
91
+ ```
92
+
70
93
  ### Convenient failure with context
71
94
 
72
95
  Both `fail!` and `done!` can accept keyword arguments to expose data before halting execution:
@@ -27,7 +27,7 @@ module Axn
27
27
  config_mode = klass.try(:_async_exception_reporting) || Axn.config.async_exception_reporting
28
28
  return if config_mode == :every_attempt # Already reported on each attempt
29
29
 
30
- retry_context = RetryHelpers.build_retry_context(job)
30
+ retry_context = RetryHelpers.build_retry_context(job, from_death_handler: true)
31
31
 
32
32
  # For :first_and_exhausted, we need to report now (exhausted)
33
33
  # For :only_exhausted, we need to report now (only time)
@@ -43,10 +43,21 @@ module Axn
43
43
 
44
44
  # Builds an Axn::Async::RetryContext from a Sidekiq job hash.
45
45
  # Used by both middleware and death handler to ensure consistent context.
46
- def build_retry_context(job)
46
+ #
47
+ # @param from_death_handler [Boolean] When true, subtracts 1 from the computed attempt
48
+ # because Sidekiq increments retry_count before calling death handlers, so the value
49
+ # in the job hash is one higher than the retry_count present during the last execution.
50
+ def build_retry_context(job, from_death_handler: false)
51
+ attempt = extract_attempt_number(job)
52
+ # Sidekiq increments retry_count before calling retries_exhausted/death handlers,
53
+ # so the job hash has retry_count = last_execution_retry_count + 1. Subtract 1 to
54
+ # recover the actual last execution's attempt number. Guard on non-nil: when Sidekiq
55
+ # calls death handlers directly (retry: false), retry_count is absent and attempt is
56
+ # already correct.
57
+ attempt -= 1 if from_death_handler && !job["retry_count"].nil?
47
58
  RetryContext.new(
48
59
  adapter: :sidekiq,
49
- attempt: extract_attempt_number(job),
60
+ attempt:,
50
61
  max_retries: extract_max_retries(job),
51
62
  job_id: job["jid"],
52
63
  )
@@ -65,19 +65,25 @@ module Axn
65
65
  inspection_filter.filter_param(field, inspected_value)
66
66
  end
67
67
 
68
- def inspection_filter = action.class.inspection_filter
68
+ def inspection_filter
69
+ @inspection_filter ||= if action.class._has_dynamic_sensitive_fields?
70
+ action.class._build_instance_filter(action)
71
+ else
72
+ action.class.inspection_filter
73
+ end
74
+ end
69
75
 
70
76
  def sensitive_subfields?(field)
71
- action.subfield_configs.any? { |config| config.on == field && config.sensitive }
77
+ action.subfield_configs.any? do |config|
78
+ config.on == field && action.class._resolve_sensitive_value(config.sensitive, action)
79
+ end
72
80
  end
73
81
 
74
82
  def filter_subfields(field, value)
75
- # Build a nested structure with subfield paths for filtering
76
83
  nested_data = { field => value }
77
84
 
78
- # Create a filter with the subfield paths
79
85
  sensitive_subfield_paths = action.subfield_configs
80
- .select { |config| config.on == field && config.sensitive }
86
+ .select { |config| config.on == field && action.class._resolve_sensitive_value(config.sensitive, action) }
81
87
  .map { |config| "#{field}.#{config.field}" }
82
88
 
83
89
  return value if sensitive_subfield_paths.empty?