plutonium 0.68.0 → 0.69.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
  3. data/CHANGELOG.md +20 -0
  4. data/SECURITY.md +1 -1
  5. data/app/assets/plutonium.css +3 -1
  6. data/config/locales/en/async.yml +11 -1
  7. data/docs/public/images/reference/async-progress-page.png +0 -0
  8. data/docs/public/images/reference/async-running-banner.png +0 -0
  9. data/docs/reference/behavior/async-interactions.md +62 -5
  10. data/docs/reference/generators/lite.md +30 -6
  11. data/docs/reference/i18n.md +1 -1
  12. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
  13. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
  14. data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
  15. data/gemfiles/rails_7.gemfile.lock +5 -5
  16. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  17. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  18. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
  19. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
  20. data/lib/plutonium/definition/presentable.rb +4 -1
  21. data/lib/plutonium/interaction/async/configuration.rb +15 -0
  22. data/lib/plutonium/interaction/async/context.rb +12 -2
  23. data/lib/plutonium/interaction/async/executor.rb +54 -0
  24. data/lib/plutonium/interaction/async/run.rb +128 -14
  25. data/lib/plutonium/interaction/async/run_definition.rb +5 -4
  26. data/lib/plutonium/interaction/async/run_policy.rb +5 -5
  27. data/lib/plutonium/interaction/response/redirect.rb +19 -7
  28. data/lib/plutonium/ui/breadcrumbs.rb +26 -8
  29. data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
  30. data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
  31. data/lib/plutonium/ui/layout/base.rb +0 -9
  32. data/lib/plutonium/ui/page/index.rb +23 -4
  33. data/lib/plutonium/version.rb +1 -1
  34. data/package.json +1 -1
  35. data/src/css/components.css +9 -0
  36. data/src/css/font_awesome.css +2328 -0
  37. data/src/css/plutonium.css +1 -0
  38. metadata +6 -2
@@ -0,0 +1,1056 @@
1
+ # Async Run pre_check Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Give async runs an optional `pre_check` stage that can reject the run's input, reporting every problem at once, before any work is done.
6
+
7
+ **Architecture:** `Run` gains a no-op `pre_check`, a `reject` collector that is only live while the check runs, and `reject_run!`, which writes tagged `errors_log` entries and `failed` in one update. `Executor` calls the check after the claim (and, for targeted runs, after target resolution) and stops if anything was rejected. `outcome` reads `"rejected"` from the tagged entries; the progress page and badges render it.
8
+
9
+ **Tech Stack:** Ruby, Rails (ActiveRecord, I18n), Phlex, Minitest via Appraisal.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md`
12
+
13
+ **User Verification:** NO. No user verification required.
14
+
15
+ ---
16
+
17
+ ## Ground rules for every task
18
+
19
+ - Run tests through Appraisal from the repo root:
20
+ `bundle exec appraisal rails-8.1 ruby -Itest <file>` (add `-n "/pattern/"` to pick tests).
21
+ - **Never stage or commit** unless the user has asked for commits. Each task ends with a suggested commit message for when they do.
22
+ - Test files have no transactional rollback (`test_helper` does not load `rails/test_help`); every suite deletes the rows it creates in `setup` / `teardown`. Follow that.
23
+ - Appraisal rewrites `gemfiles/*.gemfile.lock`. Do not stage those.
24
+ - Ruby style: no defensive `respond_to?` checks; comments explain *why*, matching the density of the surrounding code; no em dashes in docs.
25
+
26
+ ## File map
27
+
28
+ | File | Change |
29
+ |---|---|
30
+ | `lib/plutonium/interaction/async/configuration.rb` | `max_rejections` setting |
31
+ | `lib/plutonium/interaction/async/run.rb` | `PRE_CHECK_STAGE`, `pre_check`, `reject`, `collect_rejections`, `reject_run!`, `rejected?`, `outcome` |
32
+ | `lib/plutonium/interaction/async/executor.rb` | call the check, arity guard |
33
+ | `lib/plutonium/interaction/async/run_definition.rb` | `rejected: :warning` badge colour |
34
+ | `lib/plutonium/ui/interaction/async/run_progress.rb` | badge variant, "Nothing was processed" line |
35
+ | `config/locales/en/async.yml` | `executor.rejections_truncated`, `executor.pre_check_arity`, `run.reject_outside_pre_check`, `progress.rejected` |
36
+ | `test/plutonium/interaction/async/run_test.rb` | model tests |
37
+ | `test/plutonium/interaction/async/executor_test.rb` | executor tests |
38
+ | `test/integration/admin_portal/async_run_progress_test.rb` | display tests |
39
+ | `test/dummy/app/interactions/import_tasks_interaction.rb` | new: CSV import with a pre_check |
40
+ | `test/dummy/app/policies/task_policy.rb` | `import_tasks?` |
41
+ | `test/dummy/app/definitions/task_definition.rb` | `action :import_tasks` |
42
+ | `test/integration/admin_portal/async_import_pre_check_test.rb` | new: end-to-end |
43
+ | `docs/reference/behavior/async-interactions.md` | new section, config line, outcome, heartbeat |
44
+ | `.claude/skills/plutonium-async-interactions/SKILL.md` | matching section, Critical note |
45
+
46
+ ---
47
+
48
+ ### Task 1: Run model: reject, reject_run!, rejected outcome, max_rejections
49
+
50
+ **Goal:** `Run` can collect rejections during a check, persist them in one write, and report `outcome == "rejected"`.
51
+
52
+ **Files:**
53
+ - Modify: `lib/plutonium/interaction/async/configuration.rb`
54
+ - Modify: `lib/plutonium/interaction/async/run.rb`
55
+ - Modify: `config/locales/en/async.yml`
56
+ - Test: `test/plutonium/interaction/async/run_test.rb`
57
+
58
+ **Acceptance Criteria:**
59
+ - [ ] `config.async_interactions.max_rejections` defaults to 100.
60
+ - [ ] `reject` raises outside `collect_rejections`; inside, it stores up to `max_rejections` messages and counts the rest.
61
+ - [ ] `reject_run!(messages, overflow:)` writes entries tagged `"stage" => "pre_check"`, a truncation entry when `overflow > 0`, and `state: "failed"` in a single UPDATE.
62
+ - [ ] `outcome` is `"rejected"` for a failed run with a tagged entry; untagged failed runs still read `"failed"`.
63
+
64
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb` → 0 failures, 0 errors
65
+
66
+ **Steps:**
67
+
68
+ - [ ] **Step 1: Write the failing tests**
69
+
70
+ Append inside `class Plutonium::Interaction::RunTest` in `test/plutonium/interaction/async/run_test.rb` (before its final `end`):
71
+
72
+ ```ruby
73
+ # --- pre_check rejections -------------------------------------------------
74
+
75
+ def with_max_rejections(limit)
76
+ config = Plutonium.configuration.async_interactions
77
+ previous = config.max_rejections
78
+ config.max_rejections = limit
79
+ yield
80
+ ensure
81
+ config.max_rejections = previous
82
+ end
83
+
84
+ test "max_rejections defaults to 100" do
85
+ assert_equal 100, Plutonium::Interaction::Async::Configuration.new.max_rejections
86
+ end
87
+
88
+ # A rejection outside the check would be collected by nothing and lost.
89
+ test "reject outside a check raises" do
90
+ error = assert_raises(RuntimeError) { build.reject("bad row") }
91
+ assert_match(/only available inside pre_check/, error.message)
92
+ end
93
+
94
+ test "collect_rejections returns the messages and how many went past the cap" do
95
+ run = build
96
+
97
+ messages, overflow = with_max_rejections(2) do
98
+ run.collect_rejections { 5.times { |i| run.reject("row #{i}") } }
99
+ end
100
+
101
+ assert_equal ["row 0", "row 1"], messages
102
+ assert_equal 3, overflow
103
+ end
104
+
105
+ test "reject is closed again once the check is over" do
106
+ run = build
107
+ run.collect_rejections { run.reject("inside") }
108
+
109
+ assert_raises(RuntimeError) { run.reject("after") }
110
+ end
111
+
112
+ test "reject_run! records tagged entries and fails the run in one write" do
113
+ run = build(TestOpaqueRun)
114
+ run.save!
115
+
116
+ statements = []
117
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
118
+ statements << payload[:sql] if payload[:sql].start_with?("UPDATE")
119
+ end
120
+ run.reject_run!(["line 2: missing email", "line 9: missing email"], overflow: 0)
121
+ ActiveSupport::Notifications.unsubscribe(subscriber)
122
+
123
+ assert_equal 1, statements.size, "entries and state must land together"
124
+ run.reload
125
+ assert_equal "failed", run.state
126
+ refute_nil run.finished_at
127
+ assert_equal ["line 2: missing email", "line 9: missing email"], run.errors_log.map { |e| e["message"] }
128
+ assert(run.errors_log.all? { |e| e["stage"] == "pre_check" && e["target_id"].nil? })
129
+ assert_equal 0, run.progress_done
130
+ end
131
+
132
+ test "reject_run! adds one entry for the problems past the cap" do
133
+ run = build(TestOpaqueRun)
134
+ run.save!
135
+
136
+ run.reject_run!(["line 2: missing email"], overflow: 4_900)
137
+
138
+ last = run.reload.errors_log.last
139
+ assert_equal "and 4900 more problems were found", last["message"]
140
+ assert_equal "pre_check", last["stage"]
141
+ end
142
+
143
+ test "a run rejected by its check reads rejected, not failed" do
144
+ run = build(TestOpaqueRun, state: "failed",
145
+ errors_log: [{"target_id" => nil, "message" => "bad header", "stage" => "pre_check"}])
146
+
147
+ assert_equal "rejected", run.outcome
148
+ assert run.rejected?
149
+ end
150
+
151
+ test "a failed run without check entries still reads failed" do
152
+ run = build(TestOpaqueRun, state: "failed",
153
+ errors_log: [{"target_id" => nil, "message" => "boom"}])
154
+
155
+ assert_equal "failed", run.outcome
156
+ refute run.rejected?
157
+ end
158
+ ```
159
+
160
+ - [ ] **Step 2: Run the tests to verify they fail**
161
+
162
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb -n "/reject|max_rejections/"`
163
+ Expected: FAIL / ERROR (`undefined method 'max_rejections'`, `undefined method 'reject'`, ...)
164
+
165
+ - [ ] **Step 3: Add the setting**
166
+
167
+ In `lib/plutonium/interaction/async/configuration.rb`, after the `attachment_backend` accessor:
168
+
169
+ ```ruby
170
+ # @return [Integer] how many pre_check rejections a run stores. The rest
171
+ # are counted and reported as one "and N more" entry, so a file that is
172
+ # bad on every row cannot write tens of thousands of entries into the
173
+ # errors_log column (and onto the progress page).
174
+ attr_accessor :max_rejections
175
+ ```
176
+
177
+ and in `initialize`, after `@stall_after = 1.hour`:
178
+
179
+ ```ruby
180
+ @max_rejections = 100
181
+ ```
182
+
183
+ - [ ] **Step 4: Add the locale keys**
184
+
185
+ In `config/locales/en/async.yml`, under `plutonium.async.run` add:
186
+
187
+ ```yaml
188
+ reject_outside_pre_check: "%{run_class}#reject is only available inside pre_check"
189
+ ```
190
+
191
+ and under `plutonium.async.executor` add:
192
+
193
+ ```yaml
194
+ rejections_truncated:
195
+ one: "and %{count} more problem was found"
196
+ other: "and %{count} more problems were found"
197
+ ```
198
+
199
+ - [ ] **Step 5: Implement on Run**
200
+
201
+ In `lib/plutonium/interaction/async/run.rb`, after `FAILURE_POLICIES = ...`:
202
+
203
+ ```ruby
204
+ # Tags the errors_log entries a pre_check rejection writes, which is what
205
+ # #outcome reads "rejected" from. A key in the existing JSON rather than a
206
+ # column: older entries simply lack it.
207
+ PRE_CHECK_STAGE = "pre_check"
208
+ ```
209
+
210
+ Replace `#outcome` with:
211
+
212
+ ```ruby
213
+ def outcome
214
+ return "rejected" if rejected?
215
+
216
+ (state == "completed" && errors_log.any?) ? "completed_with_errors" : state
217
+ end
218
+
219
+ # Failed because its pre_check refused the input, as opposed to failing
220
+ # while doing the work. Nothing was processed. state stays "failed", so
221
+ # every reader keyed on state (ReapJob, in_progress, the banner) is
222
+ # unaffected.
223
+ def rejected? = state == "failed" && errors_log.any? { |entry| entry["stage"] == PRE_CHECK_STAGE }
224
+ ```
225
+
226
+ After `#record_target_failures!`, add:
227
+
228
+ ```ruby
229
+ # Fails the run with its pre_check's rejections, in ONE write, for the
230
+ # reason record_target_failures! gives: a crash between two writes must
231
+ # not leave a failed run without its reasons, or reasons on a run still
232
+ # marked running. Entries carry the run-level nil target_id, so progress
233
+ # and handled_target_ids are untouched: nothing was processed.
234
+ #
235
+ # @param messages [Array<String>] the rejections that were stored
236
+ # @param overflow [Integer] how many more were rejected past max_rejections
237
+ def reject_run!(messages, overflow: 0)
238
+ entries = messages.map { |message| pre_check_entry(message) }
239
+ entries << pre_check_entry(I18n.t("plutonium.async.executor.rejections_truncated", count: overflow)) if overflow.positive?
240
+
241
+ now = Time.current
242
+ update!(errors_log: errors_log + entries, state: "failed", finished_at: now, last_activity_at: now)
243
+ end
244
+
245
+ # Runs the block with #reject live, and closes it again afterwards.
246
+ # Called by Async::Executor around pre_check.
247
+ #
248
+ # @return [Array(Array<String>, Integer)] the stored messages, and how
249
+ # many more were rejected past max_rejections
250
+ def collect_rejections
251
+ @rejections = []
252
+ @rejection_count = 0
253
+ yield
254
+ [@rejections, @rejection_count - @rejections.size]
255
+ ensure
256
+ @rejections = nil
257
+ end
258
+
259
+ # Records one problem with the run's input and returns, so a check can
260
+ # report every bad row in one pass. Only live inside pre_check: a
261
+ # rejection from perform would be collected by nothing.
262
+ #
263
+ # Counts past max_rejections but stores no more than that, so memory
264
+ # stays bounded and the "and N more" count is exact.
265
+ #
266
+ # @param message [String]
267
+ # @return [void]
268
+ def reject(message)
269
+ raise I18n.t("plutonium.async.run.reject_outside_pre_check", run_class: self.class) if @rejections.nil?
270
+
271
+ @rejection_count += 1
272
+ @rejections << message.to_s if @rejections.size < Plutonium.configuration.async_interactions.max_rejections
273
+ nil
274
+ end
275
+ ```
276
+
277
+ After the base `#perform` (the one raising `NotImplementedError`), add:
278
+
279
+ ```ruby
280
+ # Checks the run's input before any work, and refuses it with #reject.
281
+ # Optional: the default checks nothing. Override it as
282
+ # +pre_check(records)+ in a run with +perform_on+ (it receives the
283
+ # resolved, authorized records), or +pre_check+ for opaque work.
284
+ # See Async::Executor#pre_check_passed?.
285
+ def pre_check(*) = nil
286
+ ```
287
+
288
+ In the `private` section, add:
289
+
290
+ ```ruby
291
+ def pre_check_entry(message) = {"target_id" => nil, "message" => message, "stage" => PRE_CHECK_STAGE}
292
+ ```
293
+
294
+ - [ ] **Step 6: Run the tests to verify they pass**
295
+
296
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb`
297
+ Expected: 0 failures, 0 errors
298
+
299
+ - [ ] **Step 7: Suggested commit (only when the user asks for commits)**
300
+
301
+ `feat(async): let a run reject its input with reject / reject_run!`
302
+
303
+ ```json:metadata
304
+ {"files": ["lib/plutonium/interaction/async/configuration.rb", "lib/plutonium/interaction/async/run.rb", "config/locales/en/async.yml", "test/plutonium/interaction/async/run_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb", "acceptanceCriteria": ["max_rejections defaults to 100", "reject raises outside collect_rejections and caps stored messages", "reject_run! writes tagged entries and failed in one UPDATE", "outcome reads rejected only for tagged failed runs"], "requiresUserVerification": false}
305
+ ```
306
+
307
+ ---
308
+
309
+ ### Task 2: Executor runs the check before the work
310
+
311
+ **Goal:** The executor calls `pre_check` with the right arguments for the run's shape and stops before any work when it rejects.
312
+
313
+ **Files:**
314
+ - Modify: `lib/plutonium/interaction/async/executor.rb` (`perform_opaque`, `perform_targets`, new private methods)
315
+ - Modify: `config/locales/en/async.yml`
316
+ - Test: `test/plutonium/interaction/async/executor_test.rb`
317
+
318
+ **Acceptance Criteria:**
319
+ - [ ] Opaque and targeted runs whose check rejects nothing perform as before.
320
+ - [ ] A rejection stops `perform` / `perform_on`; `progress_done` stays 0; `outcome == "rejected"`.
321
+ - [ ] Every rejection is recorded; past `max_rejections`, one "and N more" entry with the exact remainder.
322
+ - [ ] An exception raised in `pre_check` reads `failed`, not `rejected`.
323
+ - [ ] A wrong-arity `pre_check` fails the run with a message naming the class and expected signature.
324
+ - [ ] A resumed targeted run's check receives only the unhandled records.
325
+ - [ ] A `:transactional` run is rejected without opening a transaction.
326
+
327
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb` → 0 failures, 0 errors
328
+
329
+ **Steps:**
330
+
331
+ - [ ] **Step 1: Write the failing tests**
332
+
333
+ In `test/plutonium/interaction/async/executor_test.rb`, add these fixture classes after `class UndefinedWorkRun ... end` (top level, before the test class):
334
+
335
+ ```ruby
336
+ # A check whose behaviour each test scripts. `checked` records what the check
337
+ # was handed, so a test can assert which records a targeted check saw.
338
+ class CheckedRun < ScriptedRun
339
+ on_failure :continue
340
+ cattr_accessor :check
341
+ cattr_accessor :checked
342
+
343
+ def pre_check(records)
344
+ self.class.checked = records.map(&:id)
345
+ self.class.check&.call(self, records)
346
+ end
347
+ end
348
+
349
+ # Records whether the batch transaction opened. Executor#perform_all_or_nothing
350
+ # is the only caller of Run.transaction; update! goes through the connection.
351
+ class TransactionalCheckedRun < CheckedRun
352
+ on_failure :transactional
353
+ cattr_accessor :batch_opened
354
+
355
+ def self.transaction(...)
356
+ self.batch_opened = true
357
+ super
358
+ end
359
+ end
360
+
361
+ class CheckedOpaqueRun < Plutonium::Interaction::Async::Run
362
+ cattr_accessor :check
363
+ cattr_accessor :calls
364
+
365
+ def pre_check = self.class.check&.call(self)
366
+
367
+ def perform = self.class.calls += 1
368
+ end
369
+
370
+ # Opaque-shaped check declared on a targeted run: the arity mistake an author
371
+ # makes when copying an opaque example.
372
+ class MisdeclaredCheckRun < ScriptedRun
373
+ def pre_check = nil
374
+ end
375
+ ```
376
+
377
+ In the test class `setup`, after `OpaqueScriptedRun.calls = 0`, add:
378
+
379
+ ```ruby
380
+ CheckedRun.check = nil
381
+ CheckedRun.checked = nil
382
+ TransactionalCheckedRun.batch_opened = false
383
+ CheckedOpaqueRun.check = nil
384
+ CheckedOpaqueRun.calls = 0
385
+ ```
386
+
387
+ Append these tests inside the test class (before its final `end`):
388
+
389
+ ```ruby
390
+ # --- pre_check ------------------------------------------------------------
391
+
392
+ test "an opaque run whose check rejects nothing performs" do
393
+ run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
394
+
395
+ assert_equal 1, CheckedOpaqueRun.calls
396
+ assert_equal "completed", run.state
397
+ end
398
+
399
+ test "an opaque run whose check rejects is not performed" do
400
+ CheckedOpaqueRun.check = ->(run) { run.reject("missing header: email") }
401
+
402
+ run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
403
+
404
+ assert_equal 0, CheckedOpaqueRun.calls
405
+ assert_equal "rejected", run.outcome
406
+ assert_equal ["missing header: email"], messages(run)
407
+ end
408
+
409
+ test "a targeted check receives the resolved records and gates the work" do
410
+ first = create_post!(user: @user, organization: @org, title: "first")
411
+ second = create_post!(user: @user, organization: @org, title: "second")
412
+ CheckedRun.check = ->(run, records) { run.reject("mixed") if records.size > 1 }
413
+
414
+ run = execute!(create_run!(CheckedRun, target_ids: [first.id, second.id]))
415
+
416
+ assert_equal [first.id, second.id].sort, CheckedRun.checked.sort
417
+ assert_empty ScriptedRun.performed, "a rejected run must not touch a target"
418
+ assert_equal "rejected", run.outcome
419
+ assert_equal 0, run.progress_done
420
+ assert_equal "first", first.reload.title
421
+ end
422
+
423
+ test "a targeted run whose check passes performs every target" do
424
+ post = create_post!(user: @user, organization: @org)
425
+
426
+ run = execute!(create_run!(CheckedRun, target_ids: [post.id]))
427
+
428
+ assert_equal [post.id], ScriptedRun.performed
429
+ assert_equal "completed", run.state
430
+ end
431
+
432
+ test "every rejection is recorded, in order" do
433
+ CheckedOpaqueRun.check = ->(run) { 3.times { |i| run.reject("line #{i + 2}: missing email") } }
434
+
435
+ run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
436
+
437
+ assert_equal ["line 2: missing email", "line 3: missing email", "line 4: missing email"], messages(run)
438
+ end
439
+
440
+ test "rejections past max_rejections are summarized with the exact remainder" do
441
+ config = Plutonium.configuration.async_interactions
442
+ previous = config.max_rejections
443
+ config.max_rejections = 2
444
+ CheckedOpaqueRun.check = ->(run) { 7.times { |i| run.reject("line #{i}") } }
445
+
446
+ run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
447
+
448
+ assert_equal ["line 0", "line 1", "and 5 more problems were found"], messages(run)
449
+ ensure
450
+ config.max_rejections = previous
451
+ end
452
+
453
+ # "Your input is wrong" and "the check itself broke" are different reports.
454
+ test "an exception raised in the check fails the run rather than rejecting it" do
455
+ CheckedOpaqueRun.check = ->(_run) { raise "unreadable file" }
456
+
457
+ run = execute!(CheckedOpaqueRun.create!(initiator: @user, scoped_entity: @org))
458
+
459
+ assert_equal "failed", run.outcome
460
+ assert_equal ["unreadable file"], messages(run)
461
+ assert_equal 0, CheckedOpaqueRun.calls
462
+ end
463
+
464
+ test "a check declared with the wrong arguments names the expected signature" do
465
+ post = create_post!(user: @user, organization: @org)
466
+
467
+ run = execute!(create_run!(MisdeclaredCheckRun, target_ids: [post.id]))
468
+
469
+ assert_equal "failed", run.state
470
+ assert_match(/MisdeclaredCheckRun#pre_check must take the records/, messages(run).first)
471
+ assert_empty ScriptedRun.performed
472
+ end
473
+
474
+ test "a resumed targeted run checks only the records it has left" do
475
+ done = create_post!(user: @user, organization: @org, title: "done")
476
+ remaining = create_post!(user: @user, organization: @org, title: "remaining")
477
+ run = create_run!(CheckedRun, target_ids: [done.id, remaining.id])
478
+
479
+ # Simulate an interruption after `done` was applied, then a reap.
480
+ Executor.new(run).send(:advance!, done.id)
481
+ run.update!(state: "pending")
482
+
483
+ execute!(run)
484
+
485
+ assert_equal [remaining.id], CheckedRun.checked
486
+ end
487
+
488
+ test "a transactional run is rejected without opening a transaction" do
489
+ post = create_post!(user: @user, organization: @org)
490
+ CheckedRun.check = ->(run, _records) { run.reject("no") }
491
+
492
+ run = execute!(create_run!(TransactionalCheckedRun, target_ids: [post.id]))
493
+
494
+ assert_equal "rejected", run.outcome
495
+ refute TransactionalCheckedRun.batch_opened, "the batch transaction must never open"
496
+ assert_empty ScriptedRun.performed
497
+ end
498
+
499
+ # The control for the test above: a passing check does reach the batch.
500
+ test "a transactional run whose check passes opens its batch" do
501
+ post = create_post!(user: @user, organization: @org)
502
+
503
+ run = execute!(create_run!(TransactionalCheckedRun, target_ids: [post.id]))
504
+
505
+ assert_equal "completed", run.state
506
+ assert TransactionalCheckedRun.batch_opened
507
+ end
508
+ ```
509
+
510
+ - [ ] **Step 2: Run the tests to verify they fail**
511
+
512
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb -n "/check|rejection/"`
513
+ Expected: FAIL (rejected runs still perform; outcome reads `completed`)
514
+
515
+ - [ ] **Step 3: Add the arity message**
516
+
517
+ In `config/locales/en/async.yml`, under `plutonium.async.executor`:
518
+
519
+ ```yaml
520
+ pre_check_arity:
521
+ targeted: "%{run_class}#pre_check must take the records: define pre_check(records) on a run with perform_on"
522
+ opaque: "%{run_class}#pre_check must take no arguments: define pre_check on a run with perform"
523
+ ```
524
+
525
+ - [ ] **Step 4: Wire the check into the executor**
526
+
527
+ In `lib/plutonium/interaction/async/executor.rb`, replace `perform_opaque` with:
528
+
529
+ ```ruby
530
+ def perform_opaque
531
+ return unless pre_check_passed?
532
+
533
+ run.send(:perform)
534
+ run.finish!
535
+ end
536
+ ```
537
+
538
+ In `perform_targets`, after the line `return refuse_partial_batch(resolved) if unresolved?(resolved) && !continue?`, add:
539
+
540
+ ```ruby
541
+ return unless pre_check_passed?(resolved.records)
542
+ ```
543
+
544
+ Add these private methods after `perform_targets`:
545
+
546
+ ```ruby
547
+ # Runs the run's pre_check with #reject live, and fails the run with
548
+ # whatever it rejected. Before any work: a rejected run touches no
549
+ # target. Outside the :transactional batch, which never opens for a
550
+ # rejected run.
551
+ #
552
+ # An exception from the check is NOT a rejection. It propagates to #call
553
+ # and fails the run the ordinary way, so "your input is wrong" and "the
554
+ # check itself broke" stay distinguishable (Run#outcome).
555
+ #
556
+ # send, for the reason #perform_opaque gives.
557
+ #
558
+ # @return [Boolean] whether the work may go ahead
559
+ def pre_check_passed?(*args)
560
+ verify_pre_check_arity!(args.size)
561
+
562
+ messages, overflow = run.collect_rejections { run.send(:pre_check, *args) }
563
+ return true if messages.empty? && overflow.zero?
564
+
565
+ run.reject_run!(messages, overflow: overflow)
566
+ false
567
+ end
568
+
569
+ # A targeted check declared opaque-style (or the reverse) would raise a
570
+ # bare "wrong number of arguments" from inside the executor, naming
571
+ # neither the class nor the fix.
572
+ #
573
+ # Raised as NotImplementedError ("the run class is written wrong"), which
574
+ # #call records like any other failure.
575
+ def verify_pre_check_arity!(given)
576
+ arity = run.method(:pre_check).arity
577
+ return if arity.negative? ? given >= arity.abs - 1 : arity == given
578
+
579
+ raise NotImplementedError, I18n.t(
580
+ "plutonium.async.executor.pre_check_arity.#{run.targeted? ? "targeted" : "opaque"}",
581
+ run_class: run.class
582
+ )
583
+ end
584
+ ```
585
+
586
+ - [ ] **Step 5: Run the tests to verify they pass**
587
+
588
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb`
589
+ Expected: 0 failures, 0 errors (all pre-existing executor tests still pass, since the base `pre_check(*)` accepts any arity and rejects nothing)
590
+
591
+ - [ ] **Step 6: Run the rest of the async suite**
592
+
593
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest -e 'Dir["test/plutonium/interaction/async/*_test.rb"].each { |f| require File.expand_path(f) }'`
594
+ Expected: 0 failures, 0 errors
595
+
596
+ - [ ] **Step 7: Suggested commit (only when the user asks for commits)**
597
+
598
+ `feat(async): run a pre_check before the work and stop on rejection`
599
+
600
+ ```json:metadata
601
+ {"files": ["lib/plutonium/interaction/async/executor.rb", "config/locales/en/async.yml", "test/plutonium/interaction/async/executor_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb", "acceptanceCriteria": ["passing checks perform as before", "rejection stops work, progress_done 0, outcome rejected", "cap with exact remainder", "exception in check reads failed", "wrong arity names expected signature", "resumed run checks remaining records only", "transactional rejection opens no batch transaction"], "requiresUserVerification": false}
602
+ ```
603
+
604
+ ---
605
+
606
+ ### Task 3: Show rejected runs on the progress page and index
607
+
608
+ **Goal:** A rejected run shows a warning "Rejected" badge, a "Nothing was processed" line, and its messages.
609
+
610
+ **Files:**
611
+ - Modify: `lib/plutonium/ui/interaction/async/run_progress.rb` (`BADGE_VARIANTS`, `render_panel`, new `render_rejected`)
612
+ - Modify: `lib/plutonium/interaction/async/run_definition.rb` (both `colors:` hashes)
613
+ - Modify: `config/locales/en/async.yml`
614
+ - Test: `test/integration/admin_portal/async_run_progress_test.rb`
615
+
616
+ **Acceptance Criteria:**
617
+ - [ ] The show page renders a `pu-badge-warning` badge reading "Rejected", the line "Nothing was processed", and every rejection message.
618
+ - [ ] A plain failed run does not show "Nothing was processed".
619
+ - [ ] The index renders "Rejected" for the run.
620
+
621
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb` → 0 failures, 0 errors
622
+
623
+ **Steps:**
624
+
625
+ - [ ] **Step 1: Write the failing tests**
626
+
627
+ Append inside `AdminPortal::AsyncRunProgressTest` (before its final `end`):
628
+
629
+ ```ruby
630
+ def rejection(message) = {"target_id" => nil, "message" => message, "stage" => "pre_check"}
631
+
632
+ test "a rejected run says so, says nothing was processed, and lists every problem" do
633
+ run = create_run!(state: "failed", progress_total: 3, progress_done: 0,
634
+ errors_log: [rejection("line 2: missing email"), rejection("line 7: missing email")])
635
+
636
+ get run_path(run)
637
+ assert_response :success
638
+
639
+ assert_match(/pu-badge-warning[^>]*>\s*Rejected\s*</, response.body)
640
+ assert_match(/Nothing was processed/, response.body)
641
+ assert_match(/line 2: missing email/, response.body)
642
+ assert_match(/line 7: missing email/, response.body)
643
+ end
644
+
645
+ test "a run that failed while working does not claim nothing was processed" do
646
+ run = create_run!(state: "failed", errors_log: [{"target_id" => nil, "message" => "boom"}])
647
+
648
+ get run_path(run)
649
+
650
+ refute_match(/Nothing was processed/, response.body)
651
+ end
652
+
653
+ test "the index shows a rejected run as rejected" do
654
+ create_run!(state: "failed", errors_log: [rejection("bad header")])
655
+
656
+ get "/admin/async_runs"
657
+
658
+ assert_match(/Rejected/, response.body)
659
+ end
660
+ ```
661
+
662
+ - [ ] **Step 2: Run the tests to verify they fail**
663
+
664
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb -n "/rejected|nothing was processed/"`
665
+ Expected: FAIL (no "Nothing was processed"; badge variant is `neutral`)
666
+
667
+ - [ ] **Step 3: Add the locale key**
668
+
669
+ In `config/locales/en/async.yml`, under `plutonium.async.progress`:
670
+
671
+ ```yaml
672
+ rejected: "Nothing was processed"
673
+ ```
674
+
675
+ - [ ] **Step 4: Render it**
676
+
677
+ In `lib/plutonium/ui/interaction/async/run_progress.rb`, add to `BADGE_VARIANTS` after `"completed_with_errors" => "warning",`:
678
+
679
+ ```ruby
680
+ # The input was refused, nothing broke: see Run#rejected?.
681
+ "rejected" => "warning",
682
+ ```
683
+
684
+ Replace `render_panel` with:
685
+
686
+ ```ruby
687
+ def render_panel
688
+ div(class: "pu-card pu-run-progress", **poll_attributes) do
689
+ div(class: "pu-card-body space-y-4") do
690
+ render_status
691
+ render_progress
692
+ render_rejected if run.rejected?
693
+ render_failures if run.errors_log.any?
694
+ end
695
+ end
696
+ end
697
+ ```
698
+
699
+ Add after `render_status`:
700
+
701
+ ```ruby
702
+ # "0 of 50 targets" under a red list reads as a run that died at its
703
+ # first target. A rejected run never started, and says so.
704
+ def render_rejected
705
+ div(class: "text-sm text-[var(--pu-text-muted)]") do
706
+ plain t("plutonium.async.progress.rejected")
707
+ end
708
+ end
709
+ ```
710
+
711
+ In `lib/plutonium/interaction/async/run_definition.rb`, change both `colors:` hashes (on `display :outcome` and `column :outcome`) to:
712
+
713
+ ```ruby
714
+ colors: {running: :info, completed_with_errors: :warning, rejected: :warning}
715
+ ```
716
+
717
+ and add `rejected` to the comment above them: "`running`, `completed_with_errors` and `rejected` are coloured explicitly".
718
+
719
+ - [ ] **Step 5: Run the tests to verify they pass**
720
+
721
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb`
722
+ Expected: 0 failures, 0 errors
723
+
724
+ - [ ] **Step 6: Suggested commit (only when the user asks for commits)**
725
+
726
+ `feat(async): show rejected runs as rejected on the progress page and index`
727
+
728
+ ```json:metadata
729
+ {"files": ["lib/plutonium/ui/interaction/async/run_progress.rb", "lib/plutonium/interaction/async/run_definition.rb", "config/locales/en/async.yml", "test/integration/admin_portal/async_run_progress_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb", "acceptanceCriteria": ["warning Rejected badge, Nothing was processed, all messages on show page", "plain failed run lacks the line", "index shows Rejected"], "requiresUserVerification": false}
730
+ ```
731
+
732
+ ---
733
+
734
+ ### Task 4: Dummy CSV import with a pre_check, end to end
735
+
736
+ **Goal:** A real interaction in the dummy app exercises dispatch → staged file → pre_check → rejected or completed, and backs the docs example.
737
+
738
+ **Files:**
739
+ - Create: `test/dummy/app/interactions/import_tasks_interaction.rb`
740
+ - Modify: `test/dummy/app/policies/task_policy.rb` (after `archive_all_async?`)
741
+ - Modify: `test/dummy/app/definitions/task_definition.rb` (after `action :archive_all_async`)
742
+ - Create: `test/integration/admin_portal/async_import_pre_check_test.rb`
743
+
744
+ **Acceptance Criteria:**
745
+ - [ ] Posting a CSV with bad rows creates a run that, once executed, reads `rejected`, lists one message per bad row, and creates no tasks.
746
+ - [ ] Posting a valid CSV creates the tasks and the run reads `completed`.
747
+ - [ ] The rejected run's progress page shows the badge and messages.
748
+
749
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb` → 0 failures, 0 errors
750
+
751
+ **Steps:**
752
+
753
+ - [ ] **Step 1: Write the failing test**
754
+
755
+ Create `test/integration/admin_portal/async_import_pre_check_test.rb`:
756
+
757
+ ```ruby
758
+ # frozen_string_literal: true
759
+
760
+ require "test_helper"
761
+
762
+ # A CSV import through the real dispatch path: the file is staged at dispatch,
763
+ # revived in the job, checked whole before a single task is created.
764
+ class AdminPortal::AsyncImportPreCheckTest < ActionDispatch::IntegrationTest
765
+ include IntegrationTestHelper
766
+
767
+ Executor = Plutonium::Interaction::Async::Executor
768
+
769
+ setup do
770
+ Plutonium::Interaction::Async::Run.delete_all
771
+ Task.delete_all
772
+ @admin = create_admin!
773
+ login_as_admin(@admin)
774
+ end
775
+
776
+ teardown do
777
+ Task.delete_all
778
+ Plutonium::Interaction::Async::Run.delete_all
779
+ end
780
+
781
+ def import(csv)
782
+ file = Rack::Test::UploadedFile.new(StringIO.new(csv), "text/csv", original_filename: "tasks.csv")
783
+ post "/admin/tasks/resource_actions/import_tasks", params: {interaction: {import_file: file}}
784
+
785
+ # The test queue adapter never performs; drive the job's work directly.
786
+ run = ImportTasksInteraction::Run.sole
787
+ Executor.new(run).call
788
+ run.reload
789
+ end
790
+
791
+ test "a file with bad rows is rejected whole, every problem listed, nothing imported" do
792
+ run = import("title,status\nShip it,todo\n,doing\nWrite docs,someday\n")
793
+
794
+ assert_equal "rejected", run.outcome
795
+ assert_equal ["line 3: title is missing", "line 4: unknown status \"someday\""],
796
+ run.errors_log.map { |entry| entry["message"] }
797
+ assert_equal 0, Task.count, "a rejected import must not create anything"
798
+
799
+ get "/admin/async_runs/#{run.to_param}"
800
+ assert_match(/Rejected/, response.body)
801
+ assert_match(/Nothing was processed/, response.body)
802
+ assert_match(/line 3: title is missing/, response.body)
803
+ end
804
+
805
+ test "a valid file is imported" do
806
+ run = import("title,status\nShip it,todo\nWrite docs,\n")
807
+
808
+ assert_equal "completed", run.state
809
+ assert_equal [["Ship it", "todo"], ["Write docs", "todo"]], Task.order(:id).pluck(:title, :status)
810
+ end
811
+ end
812
+ ```
813
+
814
+ - [ ] **Step 2: Run it to verify it fails**
815
+
816
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb`
817
+ Expected: ERROR, `uninitialized constant ImportTasksInteraction`
818
+
819
+ - [ ] **Step 3: Write the interaction**
820
+
821
+ Create `test/dummy/app/interactions/import_tasks_interaction.rb`:
822
+
823
+ ```ruby
824
+ # frozen_string_literal: true
825
+
826
+ require "csv"
827
+
828
+ # A CSV import that checks the WHOLE file before creating anything: the
829
+ # browser-drivable example of an async run's pre_check. A file with a bad row
830
+ # is rejected with every problem listed, and no task is created.
831
+ class ImportTasksInteraction < ::ResourceInteraction
832
+ presents label: "Import tasks (async)",
833
+ icon: Phlex::TablerIcons::Upload
834
+
835
+ attribute :import_file
836
+ input :import_file, as: :file
837
+
838
+ validates :import_file, presence: true
839
+
840
+ async do
841
+ def pre_check
842
+ each_row do |row, line|
843
+ reject "line #{line}: title is missing" if row["title"].blank?
844
+ unless row["status"].blank? || Task::IMPORTABLE_STATUSES.include?(row["status"])
845
+ reject "line #{line}: unknown status #{row["status"].inspect}"
846
+ end
847
+ end
848
+ end
849
+
850
+ def perform
851
+ each_row { |row, _line| Task.create!(title: row["title"], status: row["status"].presence || "todo") }
852
+ end
853
+
854
+ private
855
+
856
+ # Line numbers as a spreadsheet shows them: the header is line 1.
857
+ def each_row(&)
858
+ attachment(:import_file).open do |file|
859
+ CSV.new(file, headers: true).each.with_index(2, &)
860
+ end
861
+ end
862
+ end
863
+ end
864
+ ```
865
+
866
+ Add the constant to `test/dummy/app/models/task.rb`, under `# add constants above.` (put it above that marker comment):
867
+
868
+ ```ruby
869
+ IMPORTABLE_STATUSES = %w[todo doing done review].freeze
870
+ ```
871
+
872
+ - [ ] **Step 4: Gate and declare the action**
873
+
874
+ In `test/dummy/app/policies/task_policy.rb`, after `archive_all_async?`:
875
+
876
+ ```ruby
877
+ # Resource action: a CSV import dispatched to a run whose pre_check checks
878
+ # the whole file first.
879
+ def import_tasks?
880
+ create?
881
+ end
882
+ ```
883
+
884
+ In `test/dummy/app/definitions/task_definition.rb`, after `action :archive_all_async, ...`:
885
+
886
+ ```ruby
887
+ # Resource action, dispatched to a run that checks the whole file before
888
+ # importing any of it (see ImportTasksInteraction#pre_check).
889
+ action :import_tasks, interaction: ImportTasksInteraction
890
+ ```
891
+
892
+ - [ ] **Step 5: Run it to verify it passes**
893
+
894
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb`
895
+ Expected: 0 failures, 0 errors
896
+
897
+ If `post` answers with a non-redirect (422), print `response.body` and check the form param key; the interactive action form posts `interaction[...]` (see `test/integration/admin_portal/async_dispatch_response_test.rb`).
898
+
899
+ - [ ] **Step 6: Run the full suite**
900
+
901
+ Run: `bundle exec appraisal rails-8.1 rake test`
902
+ Expected: 0 failures, 0 errors. (Task actions appear in other Task integration tests; a new resource-action button must not break their assertions.)
903
+
904
+ - [ ] **Step 7: Suggested commit (only when the user asks for commits)**
905
+
906
+ `test(async): CSV import in the dummy app exercising pre_check end to end`
907
+
908
+ ```json:metadata
909
+ {"files": ["test/dummy/app/interactions/import_tasks_interaction.rb", "test/dummy/app/models/task.rb", "test/dummy/app/policies/task_policy.rb", "test/dummy/app/definitions/task_definition.rb", "test/integration/admin_portal/async_import_pre_check_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb", "acceptanceCriteria": ["bad CSV rejected whole with one message per bad row and no tasks", "valid CSV imported and completed", "progress page shows Rejected and messages"], "requiresUserVerification": false}
910
+ ```
911
+
912
+ ---
913
+
914
+ ### Task 5: Docs and skill
915
+
916
+ **Goal:** The reference doc and the shipped skill explain `pre_check`, `reject`, the `rejected` outcome, `max_rejections`, and the heartbeat requirement, with samples that match the dummy app.
917
+
918
+ **Files:**
919
+ - Modify: `docs/reference/behavior/async-interactions.md`
920
+ - Modify: `.claude/skills/plutonium-async-interactions/SKILL.md`
921
+
922
+ **Acceptance Criteria:**
923
+ - [ ] A "Checking input before processing" section after "### Files" in the doc, and after "## Attributes and files" in the skill.
924
+ - [ ] `max_rejections` in the doc's Enabling config block.
925
+ - [ ] "Outcome vs state" documents `rejected`.
926
+ - [ ] Both heartbeat warnings mention `pre_check`.
927
+ - [ ] No em dashes; samples match `ImportTasksInteraction`.
928
+ - [ ] `yarn docs:build` passes.
929
+
930
+ **Verify:** `yarn docs:build` → completes without dead-link errors
931
+
932
+ **Steps:**
933
+
934
+ - [ ] **Step 1: Doc, config block**
935
+
936
+ In `docs/reference/behavior/async-interactions.md`, in the Enabling block, after the `stall_after` line:
937
+
938
+ ```ruby
939
+ config.async_interactions.max_rejections = 100 # pre_check problems stored per run
940
+ ```
941
+
942
+ - [ ] **Step 2: Doc, new section**
943
+
944
+ Insert before `### Sharing one run across interactions`:
945
+
946
+ ````markdown
947
+ ### Checking input before processing
948
+
949
+ Validations on the interaction run in the request, so they suit cheap checks (an uploader's size and type rules already fail the form there). A check too slow for a request, like reading every row of a large file, goes in the run's `pre_check`. It runs in the job, before any work, and refuses the input with `reject`:
950
+
951
+ ```ruby
952
+ async do
953
+ def pre_check
954
+ each_row do |row, line|
955
+ reject "line #{line}: title is missing" if row["title"].blank?
956
+ end
957
+ end
958
+
959
+ def perform
960
+ each_row { |row, _line| Task.create!(title: row["title"]) }
961
+ end
962
+
963
+ private
964
+
965
+ def each_row(&)
966
+ attachment(:import_file).open do |file|
967
+ CSV.new(file, headers: true).each.with_index(2, &)
968
+ end
969
+ end
970
+ end
971
+ ```
972
+
973
+ - `reject` records a problem and returns, so one pass reports every bad row.
974
+ - If anything was rejected, the run ends `failed` with `outcome == "rejected"`, the problems listed on its progress page, and `perform` / `perform_on` never runs.
975
+ - A run with `perform_on` declares `pre_check(records)` and receives the resolved, authorized records the loop is about to process.
976
+ - An exception raised inside `pre_check` fails the run the ordinary way (`outcome == "failed"`): the check broke, the input was not judged.
977
+ - Only the first `config.async_interactions.max_rejections` problems (default 100) are stored, followed by one "and N more problems were found" entry.
978
+ - The check runs again when a stalled run is resumed, so it must only read. A resumed targeted run checks the records it has left, unless an earlier pass already applied a target (that pass's check passed, so it is not repeated).
979
+ - A targeted check receives the records as loaded when the run resolved its targets. `perform_on` gets each record re-read just before it runs, so a verdict based on record state can be stale by then; re-check anything that must hold at the moment of the work inside `perform_on`.
980
+ - A long check must call `heartbeat!` (see [Long work must say it is alive](#long-work-must-say-it-is-alive)).
981
+ ````
982
+
983
+ - [ ] **Step 3: Doc, outcome section**
984
+
985
+ In "## Outcome vs state", after the code block, add:
986
+
987
+ ```markdown
988
+ A run whose `pre_check` rejected its input reads `outcome == "rejected"` (its `state` is `"failed"`, and nothing was processed). A run that failed while working reads `"failed"`.
989
+ ```
990
+
991
+ - [ ] **Step 4: Doc, heartbeat section**
992
+
993
+ In "### Long work must say it is alive", change the first bullet's opening to:
994
+ "**Opaque work, and any `pre_check`.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count."
995
+
996
+ - [ ] **Step 5: Skill**
997
+
998
+ In `.claude/skills/plutonium-async-interactions/SKILL.md`:
999
+
1000
+ In the Critical bullet "**Long work must call `heartbeat!`.**", after "so opaque `perform` writes nothing at all between claim and finish." add: "The same holds for `pre_check`."
1001
+
1002
+ Insert a section before `## Registering the Run resource`:
1003
+
1004
+ ````markdown
1005
+ ## Checking input before processing (`pre_check`)
1006
+
1007
+ For checks too slow for the request (every row of a large file, lookups per row), define `pre_check` in the run. It runs in the job before any work; `reject` records a problem and keeps going:
1008
+
1009
+ ```ruby
1010
+ async do
1011
+ def pre_check # targeted runs: def pre_check(records)
1012
+ each_row do |row, line|
1013
+ reject "line #{line}: title is missing" if row["title"].blank?
1014
+ end
1015
+ end
1016
+
1017
+ def perform
1018
+ each_row { |row, _line| Task.create!(title: row["title"]) }
1019
+ end
1020
+
1021
+ private
1022
+
1023
+ def each_row(&)
1024
+ attachment(:import_file).open { |f| CSV.new(f, headers: true).each.with_index(2, &) }
1025
+ end
1026
+ end
1027
+ ```
1028
+
1029
+ - Any rejection: run ends `failed`, `outcome == "rejected"`, problems on the progress page, no work done.
1030
+ - An exception in `pre_check` is a plain `failed`, not `rejected`.
1031
+ - `config.async_interactions.max_rejections` (default 100) caps stored problems; the rest become one "and N more" entry.
1032
+ - It re-runs on resume, so it must only read. Long checks must `heartbeat!`.
1033
+ - `reject` outside `pre_check` raises.
1034
+ ````
1035
+
1036
+ - [ ] **Step 6: Build the docs**
1037
+
1038
+ Run: `yarn docs:build`
1039
+ Expected: build completes; no dead-link errors.
1040
+
1041
+ - [ ] **Step 7: Suggested commit (only when the user asks for commits)**
1042
+
1043
+ `docs(async): document pre_check, reject and the rejected outcome`
1044
+
1045
+ ```json:metadata
1046
+ {"files": ["docs/reference/behavior/async-interactions.md", ".claude/skills/plutonium-async-interactions/SKILL.md"], "verifyCommand": "yarn docs:build", "acceptanceCriteria": ["new section in doc and skill", "max_rejections in config block", "rejected in Outcome vs state", "heartbeat notes mention pre_check", "no em dashes", "docs build passes"], "requiresUserVerification": false}
1047
+ ```
1048
+
1049
+ ---
1050
+
1051
+ ## Final verification
1052
+
1053
+ - [ ] `bundle exec appraisal rails-8.1 rake test` → 0 failures, 0 errors
1054
+ - [ ] `bundle exec appraisal rails-7 ruby -Itest -e 'Dir["test/plutonium/interaction/async/*_test.rb", "test/integration/admin_portal/async_*_test.rb"].each { |f| require File.expand_path(f) }'` → 0 failures, 0 errors
1055
+ - [ ] `yarn docs:build` passes
1056
+ - [ ] `git status` shows no staged `gemfiles/*.gemfile.lock`