plutonium 0.60.5 → 0.61.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +549 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +895 -193
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_resource_kanban.html.erb +1 -0
  25. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  26. data/docs/.vitepress/config.ts +24 -0
  27. data/docs/guides/index.md +2 -0
  28. data/docs/guides/kanban.md +447 -0
  29. data/docs/guides/wizards.md +447 -0
  30. data/docs/public/images/guides/kanban-after-move.png +0 -0
  31. data/docs/public/images/guides/kanban-board-light.png +0 -0
  32. data/docs/public/images/guides/kanban-board.png +0 -0
  33. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  34. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  35. data/docs/public/images/guides/wizards-chooser.png +0 -0
  36. data/docs/public/images/guides/wizards-completed.png +0 -0
  37. data/docs/public/images/guides/wizards-index-action.png +0 -0
  38. data/docs/public/images/guides/wizards-repeater.png +0 -0
  39. data/docs/public/images/guides/wizards-review.png +0 -0
  40. data/docs/public/images/guides/wizards-step.png +0 -0
  41. data/docs/reference/behavior/policies.md +1 -1
  42. data/docs/reference/index.md +14 -0
  43. data/docs/reference/kanban/authorization.md +62 -0
  44. data/docs/reference/kanban/dsl.md +293 -0
  45. data/docs/reference/kanban/index.md +40 -0
  46. data/docs/reference/kanban/positioning.md +162 -0
  47. data/docs/reference/resource/definition.md +16 -0
  48. data/docs/reference/ui/forms.md +36 -0
  49. data/docs/reference/ui/pages.md +2 -0
  50. data/docs/reference/wizard/anchoring-resume.md +194 -0
  51. data/docs/reference/wizard/dsl.md +332 -0
  52. data/docs/reference/wizard/index.md +33 -0
  53. data/docs/reference/wizard/one-time.md +129 -0
  54. data/docs/reference/wizard/registration-launch.md +177 -0
  55. data/docs/reference/wizard/storage-config.md +151 -0
  56. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  57. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  58. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  59. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  60. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  61. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  62. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  63. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  64. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  65. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  66. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  67. data/gemfiles/postgres.gemfile +8 -0
  68. data/gemfiles/postgres.gemfile.lock +321 -0
  69. data/gemfiles/rails_7.gemfile +1 -0
  70. data/gemfiles/rails_7.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.0.gemfile +1 -0
  72. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  73. data/gemfiles/rails_8.1.gemfile +1 -0
  74. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  75. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  76. data/lib/plutonium/action/base.rb +9 -0
  77. data/lib/plutonium/auth/rodauth.rb +1 -2
  78. data/lib/plutonium/configuration.rb +4 -0
  79. data/lib/plutonium/core/controller.rb +20 -1
  80. data/lib/plutonium/definition/base.rb +25 -0
  81. data/lib/plutonium/definition/form_layout.rb +54 -35
  82. data/lib/plutonium/definition/index_views.rb +54 -1
  83. data/lib/plutonium/definition/wizards.rb +209 -0
  84. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  85. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  86. data/lib/plutonium/invites/controller.rb +4 -1
  87. data/lib/plutonium/kanban/action.rb +7 -0
  88. data/lib/plutonium/kanban/board.rb +40 -0
  89. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  90. data/lib/plutonium/kanban/column.rb +69 -0
  91. data/lib/plutonium/kanban/context.rb +15 -0
  92. data/lib/plutonium/kanban/dsl.rb +71 -0
  93. data/lib/plutonium/kanban/grouping.rb +51 -0
  94. data/lib/plutonium/kanban/positioning.rb +75 -0
  95. data/lib/plutonium/kanban.rb +11 -0
  96. data/lib/plutonium/migrations.rb +40 -0
  97. data/lib/plutonium/positioning.rb +146 -0
  98. data/lib/plutonium/railtie.rb +33 -0
  99. data/lib/plutonium/resource/controller.rb +2 -0
  100. data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
  101. data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
  102. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  103. data/lib/plutonium/resource/policy.rb +8 -0
  104. data/lib/plutonium/routing/mapper_extensions.rb +44 -0
  105. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  106. data/lib/plutonium/ui/display/resource.rb +17 -12
  107. data/lib/plutonium/ui/form/base.rb +19 -5
  108. data/lib/plutonium/ui/form/components/password.rb +126 -0
  109. data/lib/plutonium/ui/form/components/uppy.rb +6 -3
  110. data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
  111. data/lib/plutonium/ui/form/resource.rb +1 -1
  112. data/lib/plutonium/ui/form/wizard.rb +63 -0
  113. data/lib/plutonium/ui/grid/card.rb +16 -5
  114. data/lib/plutonium/ui/kanban/card.rb +67 -0
  115. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  116. data/lib/plutonium/ui/kanban/column.rb +324 -0
  117. data/lib/plutonium/ui/kanban/resource.rb +212 -0
  118. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  119. data/lib/plutonium/ui/modal/base.rb +30 -3
  120. data/lib/plutonium/ui/modal/centered.rb +5 -2
  121. data/lib/plutonium/ui/page/index.rb +1 -0
  122. data/lib/plutonium/ui/page/show.rb +23 -0
  123. data/lib/plutonium/ui/page/wizard.rb +371 -0
  124. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  125. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  126. data/lib/plutonium/ui/table/base.rb +1 -1
  127. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  128. data/lib/plutonium/ui/wizard/review.rb +196 -0
  129. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  130. data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
  131. data/lib/plutonium/version.rb +1 -1
  132. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  133. data/lib/plutonium/wizard/attachments.rb +226 -0
  134. data/lib/plutonium/wizard/base.rb +216 -0
  135. data/lib/plutonium/wizard/base_controller.rb +31 -0
  136. data/lib/plutonium/wizard/configuration.rb +42 -0
  137. data/lib/plutonium/wizard/controller.rb +162 -0
  138. data/lib/plutonium/wizard/data.rb +134 -0
  139. data/lib/plutonium/wizard/driving.rb +639 -0
  140. data/lib/plutonium/wizard/dsl.rb +336 -0
  141. data/lib/plutonium/wizard/errors.rb +27 -0
  142. data/lib/plutonium/wizard/field_capture.rb +157 -0
  143. data/lib/plutonium/wizard/field_importer.rb +208 -0
  144. data/lib/plutonium/wizard/gate.rb +171 -0
  145. data/lib/plutonium/wizard/instance_key.rb +97 -0
  146. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  147. data/lib/plutonium/wizard/resume.rb +250 -0
  148. data/lib/plutonium/wizard/review_step.rb +48 -0
  149. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  150. data/lib/plutonium/wizard/runner.rb +684 -0
  151. data/lib/plutonium/wizard/session.rb +53 -0
  152. data/lib/plutonium/wizard/state.rb +35 -0
  153. data/lib/plutonium/wizard/step.rb +61 -0
  154. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  155. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  156. data/lib/plutonium/wizard/store/base.rb +42 -0
  157. data/lib/plutonium/wizard/store/memory.rb +44 -0
  158. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  159. data/lib/plutonium/wizard.rb +86 -0
  160. data/lib/plutonium.rb +5 -0
  161. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  162. data/lib/tasks/release.rake +144 -191
  163. data/package.json +3 -3
  164. data/src/css/components.css +132 -0
  165. data/src/js/controllers/attachment_input_controller.js +15 -1
  166. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  167. data/src/js/controllers/kanban_controller.js +330 -0
  168. data/src/js/controllers/password_sentinel_controller.js +39 -0
  169. data/src/js/controllers/register_controllers.js +6 -0
  170. data/src/js/controllers/remote_modal_controller.js +10 -0
  171. data/src/js/controllers/row_click_controller.js +14 -1
  172. data/src/js/controllers/wizard_controller.js +54 -0
  173. data/src/js/turbo/turbo_confirm.js +1 -1
  174. data/yarn.lock +271 -282
  175. metadata +100 -1
@@ -0,0 +1,1619 @@
1
+ # Wizard DSL 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:** Build the `Plutonium::Wizard` subsystem — a declarative, DB-backed, multi-step data-capture wizard — per `docs/superpowers/specs/2026-06-15-wizard-dsl-design.md`.
6
+
7
+ **Architecture:** A self-contained data-capture wizard. A wizard class declares ordered `step`s (own fields, or `using:` an interaction/definition), branches via `condition:`, stages typed `data` in one framework table (`plutonium_wizard_sessions`), and commits at the end via `execute` (or per-step `on_submit`/`persist`/`on_rollback`). A single controller drives all surfaces (record action, collection, standalone, one-time); identity is a derived `instance_key` digest; cleanup is TTL-swept.
8
+
9
+ **Tech Stack:** Ruby, Rails (7.2/8.0/8.1 via Appraisal), ActiveModel::Attributes, Phlex (Phlexi forms/display), Stimulus, Minitest. Reuses `Plutonium::Interaction`, `Plutonium::Definition` (DefineableProps/FormLayout/StructuredInputs), `Plutonium::Action`, `Plutonium::Routing`, `Plutonium::UI`.
10
+
11
+ **User Verification:** NO — the originating request ("explore a DSL for creating wizards") requires no human-in-the-loop validation of outcomes; correctness is verified by the test suite.
12
+
13
+ **Spec reference:** `docs/superpowers/specs/2026-06-15-wizard-dsl-design.md` — section numbers (§N) below point into it. Read it before starting.
14
+
15
+ **Conventions for every task:** TDD (write failing test → run red → implement → run green → commit). Run a focused test with `bundle exec appraisal rails-8.1 ruby -Itest <file>`; run the suite with `bundle exec appraisal rails-8.1 rake test`. Use `with_connection` for DB access; bang methods (`create!`) in examples; register Stimulus controllers; inline indexes in `create_table`.
16
+
17
+ ---
18
+
19
+ ## File Structure
20
+
21
+ ```
22
+ lib/plutonium/wizard.rb # namespace + autoloads + error classes
23
+ lib/plutonium/wizard/errors.rb # NotAnchoredError, StepError
24
+ lib/plutonium/wizard/configuration.rb # WizardConfiguration (enabled/cleanup_after/database)
25
+ lib/plutonium/migrations.rb # per-feature migration-path registry
26
+ db/migrate/wizard/<ts>_create_plutonium_wizard_sessions.rb
27
+ app/models/plutonium/wizard/session.rb # AR model (polymorphic owner/anchor/scope, instance_key, json, encrypts)
28
+ lib/plutonium/wizard/state.rb # value object: wizard, current_step, data, persisted, owner/anchor/scope/token
29
+ lib/plutonium/wizard/instance_key.rb # digest recipe
30
+ lib/plutonium/wizard/store/base.rb # port
31
+ lib/plutonium/wizard/store/active_record.rb # shipped store
32
+ lib/plutonium/wizard/store/memory.rb # test store
33
+ lib/plutonium/wizard/step.rb # step metadata value object
34
+ lib/plutonium/wizard/review_step.rb # terminal review step
35
+ lib/plutonium/wizard/data.rb # typed, dot-accessible snapshot builder
36
+ lib/plutonium/wizard/field_importer.rb # resolves using: (interaction/definition)
37
+ lib/plutonium/wizard/dsl.rb # step/review/anchored/navigation/cleanup_after/one_time/encrypt_data macros
38
+ lib/plutonium/wizard/base.rb # author class
39
+ lib/plutonium/wizard/runner.rb # path computation, validation, on_submit/execute, completeness/prune, lock, cleanup
40
+ lib/plutonium/wizard/sweep_job.rb # abandonment sweep
41
+ lib/plutonium/wizard/gate.rb # ensure_wizard_completed controller concern
42
+ lib/plutonium/definition/wizards.rb # `wizard` DSL macro (mixed into Definition::Base)
43
+ lib/plutonium/routing/wizard_registration.rb # register_wizard + per-resource wizard routes
44
+ app/controllers/plutonium/wizard/controller.rb # single controller mixin
45
+ lib/plutonium/ui/page/wizard.rb # page class
46
+ lib/plutonium/ui/wizard/stepper.rb # stepper component
47
+ lib/plutonium/ui/wizard/review.rb # review auto-summary component
48
+ test/plutonium/wizard/*_test.rb # unit tests (Memory store)
49
+ test/integration/.../wizard_*_test.rb # dummy-app integration tests
50
+ .claude/skills/plutonium-wizard/SKILL.md # skill
51
+ docs/guides/wizards.md + docs/reference/wizard/*.md
52
+ ```
53
+
54
+ Each task below is a coherent, committable unit. TDD cycles happen inside a task.
55
+
56
+ ---
57
+
58
+ ### Task 0: Namespace, errors, configuration, migrations registry
59
+
60
+ **Goal:** The plumbing every later task depends on — namespace + autoloading, error classes, namespaced `config.wizards`, and the per-feature migration registry + Railtie hook (no table yet).
61
+
62
+ **Files:**
63
+ - Create: `lib/plutonium/wizard.rb`, `lib/plutonium/wizard/errors.rb`, `lib/plutonium/wizard/configuration.rb`, `lib/plutonium/migrations.rb`
64
+ - Modify: `lib/plutonium/configuration.rb` (add `wizards` nested config), `lib/plutonium/railtie.rb` (migrations initializer), `lib/plutonium.rb` (require wizard namespace if not zeitwerk-autoloaded)
65
+ - Test: `test/plutonium/wizard/configuration_test.rb`, `test/plutonium/migrations_test.rb`
66
+
67
+ **Acceptance Criteria:**
68
+ - [ ] `Plutonium.configuration.wizards.enabled` defaults to `false`; `.cleanup_after` defaults to `30.days`; `.database` defaults to `:primary`.
69
+ - [ ] `Plutonium::Wizard::NotAnchoredError` and `Plutonium::Wizard::StepError` exist (both `< StandardError`).
70
+ - [ ] `Plutonium::Migrations.register(:wizard, path)` + `Plutonium::Migrations.enabled_paths` returns the wizard path only when `config.wizards.enabled`.
71
+ - [ ] The Railtie initializer is declared `after: :load_config_initializers`.
72
+
73
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/configuration_test.rb` → PASS
74
+
75
+ **Steps:**
76
+
77
+ - [ ] **Step 1: Failing test — config defaults + migrations gating**
78
+
79
+ ```ruby
80
+ # test/plutonium/wizard/configuration_test.rb
81
+ require "test_helper"
82
+
83
+ class Plutonium::Wizard::ConfigurationTest < Minitest::Test
84
+ def setup
85
+ @config = Plutonium::Wizard::Configuration.new
86
+ end
87
+
88
+ def test_defaults
89
+ refute @config.enabled
90
+ assert_equal 30.days, @config.cleanup_after
91
+ assert_equal :primary, @config.database
92
+ end
93
+
94
+ def test_error_classes
95
+ assert Plutonium::Wizard::NotAnchoredError < StandardError
96
+ assert Plutonium::Wizard::StepError < StandardError
97
+ end
98
+ end
99
+ ```
100
+
101
+ ```ruby
102
+ # test/plutonium/migrations_test.rb
103
+ require "test_helper"
104
+
105
+ class Plutonium::MigrationsTest < Minitest::Test
106
+ def setup
107
+ Plutonium::Migrations.reset!
108
+ Plutonium::Migrations.register(:wizard, "/gem/db/migrate/wizard")
109
+ end
110
+
111
+ def test_enabled_paths_gated_by_config
112
+ Plutonium.configuration.wizards.enabled = false
113
+ assert_empty Plutonium::Migrations.enabled_paths
114
+ Plutonium.configuration.wizards.enabled = true
115
+ assert_includes Plutonium::Migrations.enabled_paths, "/gem/db/migrate/wizard"
116
+ ensure
117
+ Plutonium.configuration.wizards.enabled = false
118
+ end
119
+ end
120
+ ```
121
+
122
+ - [ ] **Step 2: Run red** — `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/configuration_test.rb` → FAIL (NameError).
123
+
124
+ - [ ] **Step 3: Implement errors + namespace**
125
+
126
+ ```ruby
127
+ # lib/plutonium/wizard/errors.rb
128
+ module Plutonium
129
+ module Wizard
130
+ # Raised by `anchor` on a wizard that was not declared `anchored`.
131
+ class NotAnchoredError < StandardError; end
132
+
133
+ # Raise inside on_submit/execute (usually via `fail!`) for a custom,
134
+ # non-ActiveRecord::RecordInvalid step failure. `attribute` defaults to :base.
135
+ class StepError < StandardError
136
+ attr_reader :attribute
137
+
138
+ def initialize(message = nil, attribute: :base)
139
+ @attribute = attribute
140
+ super(message)
141
+ end
142
+ end
143
+ end
144
+ end
145
+ ```
146
+
147
+ ```ruby
148
+ # lib/plutonium/wizard.rb
149
+ module Plutonium
150
+ module Wizard
151
+ # Eager-required; the rest is zeitwerk-autoloaded by the host engine/app.
152
+ end
153
+ end
154
+ require_relative "wizard/errors"
155
+ require_relative "wizard/configuration"
156
+ ```
157
+
158
+ - [ ] **Step 4: Implement configuration + wire into Configuration**
159
+
160
+ ```ruby
161
+ # lib/plutonium/wizard/configuration.rb
162
+ module Plutonium
163
+ module Wizard
164
+ class Configuration
165
+ attr_accessor :enabled, :cleanup_after, :database
166
+
167
+ def initialize
168
+ @enabled = false
169
+ @cleanup_after = 30.days
170
+ @database = :primary
171
+ end
172
+ end
173
+ end
174
+ end
175
+ ```
176
+
177
+ In `lib/plutonium/configuration.rb`, add to the `Configuration` class (mirroring the `@assets` precedent at lines 102-122):
178
+
179
+ ```ruby
180
+ attr_reader :wizards
181
+
182
+ # inside initialize:
183
+ @wizards = Plutonium::Wizard::Configuration.new
184
+ ```
185
+
186
+ Ensure `require_relative "wizard"` (or the errors/configuration files) loads before `Configuration#initialize` runs.
187
+
188
+ - [ ] **Step 5: Implement migrations registry**
189
+
190
+ ```ruby
191
+ # lib/plutonium/migrations.rb
192
+ module Plutonium
193
+ # Registry mapping a feature → its gem-shipped migration directory.
194
+ # The Railtie appends only enabled features' paths (see railtie.rb).
195
+ module Migrations
196
+ @registry = {}
197
+
198
+ class << self
199
+ # feature → gem subdir path
200
+ def register(feature, path)
201
+ @registry[feature.to_sym] = path
202
+ end
203
+
204
+ def reset! = @registry = {}
205
+
206
+ # Paths whose feature flag is enabled. Each feature's flag lives under
207
+ # config.<feature> with an `.enabled` reader.
208
+ def enabled_paths
209
+ @registry.filter_map do |feature, path|
210
+ cfg = Plutonium.configuration.public_send(feature) if Plutonium.configuration.respond_to?(feature)
211
+ path if cfg&.respond_to?(:enabled) && cfg.enabled
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
217
+ ```
218
+
219
+ - [ ] **Step 6: Railtie initializer + register the wizard feature**
220
+
221
+ In `lib/plutonium/railtie.rb` add (per spec §10):
222
+
223
+ ```ruby
224
+ require "plutonium/migrations"
225
+
226
+ initializer "plutonium.register_migrations" do
227
+ Plutonium::Migrations.register(:wizards, Plutonium.root.join("db/migrate/wizard").to_s)
228
+ end
229
+
230
+ # Runs AFTER config/initializers/* so config.wizards.enabled is set (railtie inits run before app inits).
231
+ initializer "plutonium.migrations", after: :load_config_initializers do |app|
232
+ Plutonium::Migrations.enabled_paths.each do |path|
233
+ db = Plutonium.configuration.wizards.database
234
+ if db == :primary
235
+ app.config.paths["db/migrate"] << path
236
+ end
237
+ ActiveRecord::Migrator.migrations_paths << path unless
238
+ ActiveRecord::Migrator.migrations_paths.include?(path)
239
+ # Multi-db: also register on the named database's migrations_paths if not primary.
240
+ # (Resolved lazily; see spec §10. For :primary the global path above suffices.)
241
+ end
242
+ end
243
+ ```
244
+
245
+ > NOTE: the registry keys on the config method name — register under `:wizards` (matching `config.wizards`), not `:wizard`. Update the test's `register(:wizard, ...)` → `register(:wizards, ...)` and the assertion accordingly.
246
+
247
+ - [ ] **Step 7: Run green** — `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/configuration_test.rb test/plutonium/migrations_test.rb` → PASS
248
+
249
+ - [ ] **Step 8: Commit**
250
+
251
+ ```bash
252
+ git add lib/plutonium/wizard.rb lib/plutonium/wizard/errors.rb lib/plutonium/wizard/configuration.rb lib/plutonium/migrations.rb lib/plutonium/configuration.rb lib/plutonium/railtie.rb test/plutonium/wizard/configuration_test.rb test/plutonium/migrations_test.rb
253
+ git commit -m "feat(wizard): namespace, errors, config.wizards, migrations registry"
254
+ ```
255
+
256
+ ```json:metadata
257
+ {"files": ["lib/plutonium/wizard.rb", "lib/plutonium/wizard/errors.rb", "lib/plutonium/wizard/configuration.rb", "lib/plutonium/migrations.rb", "lib/plutonium/configuration.rb", "lib/plutonium/railtie.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/configuration_test.rb test/plutonium/migrations_test.rb", "acceptanceCriteria": ["config.wizards defaults (enabled=false, cleanup_after=30.days, database=:primary)", "NotAnchoredError + StepError exist", "Migrations.enabled_paths gated by config", "railtie initializer after :load_config_initializers"], "requiresUserVerification": false}
258
+ ```
259
+
260
+ ---
261
+
262
+ ### Task 1: Migration, Session model, instance_key, State, Store (Memory + ActiveRecord)
263
+
264
+ **Goal:** Persistence layer — the table, the AR model, the identity digest, the `State` value object, and the Store port with both adapters. This is the foundation the runner uses.
265
+
266
+ **Files:**
267
+ - Create: `db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb`, `app/models/plutonium/wizard/session.rb`, `lib/plutonium/wizard/instance_key.rb`, `lib/plutonium/wizard/state.rb`, `lib/plutonium/wizard/store/base.rb`, `lib/plutonium/wizard/store/memory.rb`, `lib/plutonium/wizard/store/active_record.rb`
268
+ - Test: `test/plutonium/wizard/instance_key_test.rb`, `test/plutonium/wizard/store/memory_test.rb`, `test/plutonium/wizard/store/active_record_test.rb`
269
+
270
+ **Acceptance Criteria:**
271
+ - [ ] Migration creates `plutonium_wizard_sessions` with all columns/indexes from spec §8.1 (polymorphic owner/anchor/scope, `instance_key` unique, `status`, `current_step`, `data`/`persisted` json, `expires_at`, `completed_at`, timestamps; sweep/listing/once-per indexes).
272
+ - [ ] `InstanceKey.for(wizard:, scope:, anchor:, token:, owner:)` == `SHA256("#{wizard}|#{scope_gid}|#{anchor_gid}|#{token.presence || owner_gid}")`, blanks for nils, **owner excluded when token present**.
273
+ - [ ] `Store::Memory` and `Store::ActiveRecord` both satisfy the port: `read/write/complete/clear/completed?/in_progress_for` with identical behavior (shared test module).
274
+ - [ ] `write` upserts by `instance_key`, sets owner/anchor/scope/token columns and `expires_at = now + cleanup_after`.
275
+ - [ ] `complete` sets `status: "completed"`, `completed_at`, nulls `data`/`persisted`.
276
+
277
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/store/active_record_test.rb` → PASS
278
+
279
+ **Steps:**
280
+
281
+ - [ ] **Step 1: Failing test — instance_key recipe**
282
+
283
+ ```ruby
284
+ # test/plutonium/wizard/instance_key_test.rb
285
+ require "test_helper"
286
+
287
+ class Plutonium::Wizard::InstanceKeyTest < Minitest::Test
288
+ def key(**kw) = Plutonium::Wizard::InstanceKey.for(**kw)
289
+
290
+ def test_token_excludes_owner
291
+ with_token = key(wizard: "W", scope: nil, anchor: nil, token: "abc", owner: nil)
292
+ after_auth = key(wizard: "W", scope: nil, anchor: nil, token: "abc", owner: gid("User", 1))
293
+ assert_equal with_token, after_auth, "owner must not change the digest when a token is present"
294
+ end
295
+
296
+ def test_owner_principal_without_token
297
+ a = key(wizard: "W", scope: nil, anchor: nil, token: nil, owner: gid("User", 1))
298
+ b = key(wizard: "W", scope: nil, anchor: nil, token: nil, owner: gid("User", 2))
299
+ refute_equal a, b
300
+ end
301
+
302
+ def test_scope_distinguishes
303
+ a = key(wizard: "W", scope: gid("Org", 1), anchor: nil, token: nil, owner: gid("User", 1))
304
+ b = key(wizard: "W", scope: gid("Org", 2), anchor: nil, token: nil, owner: gid("User", 1))
305
+ refute_equal a, b
306
+ end
307
+
308
+ def gid(type, id) = "gid://dummy/#{type}/#{id}"
309
+ end
310
+ ```
311
+
312
+ - [ ] **Step 2: Run red** → FAIL.
313
+
314
+ - [ ] **Step 3: Implement instance_key**
315
+
316
+ ```ruby
317
+ # lib/plutonium/wizard/instance_key.rb
318
+ require "digest"
319
+
320
+ module Plutonium
321
+ module Wizard
322
+ module InstanceKey
323
+ # Identity digest. Token is the principal when present (so pre-auth→auth
324
+ # doesn't rekey); otherwise the owner GID is the principal. Scope + anchor
325
+ # always participate when present. Spec §4 / §17.13.
326
+ def self.for(wizard:, scope:, anchor:, token:, owner:)
327
+ principal = token.presence || gid(owner)
328
+ Digest::SHA256.hexdigest([wizard, gid(scope), gid(anchor), principal].map(&:to_s).join("|"))
329
+ end
330
+
331
+ def self.gid(obj)
332
+ return obj if obj.nil? || obj.is_a?(String)
333
+ obj.to_global_id.to_s
334
+ end
335
+ end
336
+ end
337
+ end
338
+ ```
339
+
340
+ - [ ] **Step 4: Run green (instance_key)** → PASS. Commit-worthy checkpoint.
341
+
342
+ - [ ] **Step 5: Migration** (per spec §8.1 — inline indexes; `jsonb` on PG via `connection.adapter_name`)
343
+
344
+ ```ruby
345
+ # db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb
346
+ class CreatePlutoniumWizardSessions < ActiveRecord::Migration[7.2]
347
+ def change
348
+ json_type = (connection.adapter_name =~ /postgres/i) ? :jsonb : :json
349
+
350
+ create_table :plutonium_wizard_sessions do |t|
351
+ t.string :wizard, null: false
352
+ t.string :status, null: false, default: "in_progress" # in_progress | completing | completed
353
+ t.string :current_step
354
+
355
+ t.string :instance_key, null: false
356
+
357
+ t.string :owner_type
358
+ t.string :owner_id
359
+ t.string :anchor_type
360
+ t.string :anchor_id
361
+ t.string :scope_type
362
+ t.string :scope_id
363
+ t.string :token
364
+
365
+ t.public_send(json_type, :data, null: false, default: {})
366
+ t.public_send(json_type, :persisted, null: false, default: {})
367
+
368
+ t.datetime :expires_at
369
+ t.datetime :completed_at
370
+ t.timestamps
371
+
372
+ t.index :instance_key, unique: true
373
+ t.index [:status, :expires_at]
374
+ t.index [:owner_type, :owner_id, :status]
375
+ t.index [:scope_type, :scope_id, :status]
376
+ t.index [:wizard, :anchor_type, :anchor_id, :status]
377
+ end
378
+ end
379
+ end
380
+ ```
381
+
382
+ Add to a dummy-app/CI setup: ensure the dummy app enables `config.wizards.enabled = true` so the migration runs in the test DB (mirror how other features are toggled in `test/dummy`).
383
+
384
+ - [ ] **Step 6: Session model**
385
+
386
+ ```ruby
387
+ # app/models/plutonium/wizard/session.rb
388
+ module Plutonium
389
+ module Wizard
390
+ class Session < ActiveRecord::Base
391
+ self.table_name = "plutonium_wizard_sessions"
392
+
393
+ belongs_to :owner, polymorphic: true, optional: true
394
+ belongs_to :anchor, polymorphic: true, optional: true
395
+ belongs_to :scope, polymorphic: true, optional: true
396
+
397
+ enum :status, { in_progress: "in_progress", completing: "completing", completed: "completed" },
398
+ prefix: true
399
+
400
+ scope :sweepable, ->(now) {
401
+ where(status: %w[in_progress completing]).where.not(expires_at: nil).where(expires_at: ..now)
402
+ }
403
+ end
404
+ end
405
+ end
406
+ ```
407
+
408
+ > Encryption (`encrypt_data`) is applied conditionally by the wizard class, not statically here — see Task 2/4. The model stays plaintext by default.
409
+
410
+ - [ ] **Step 7: State value object**
411
+
412
+ ```ruby
413
+ # lib/plutonium/wizard/state.rb
414
+ module Plutonium
415
+ module Wizard
416
+ # In-memory snapshot of one wizard instance's stored state.
417
+ State = Struct.new(
418
+ :wizard, :instance_key, :current_step, :status,
419
+ :data, :persisted, :owner, :anchor, :scope, :token
420
+ ) do
421
+ def data = super || {}
422
+ def persisted = super || {}
423
+ end
424
+ end
425
+ end
426
+ ```
427
+
428
+ - [ ] **Step 8: Store port + Memory + ActiveRecord (shared behavior test)**
429
+
430
+ ```ruby
431
+ # lib/plutonium/wizard/store/base.rb
432
+ module Plutonium
433
+ module Wizard
434
+ module Store
435
+ class Base
436
+ def read(instance_key) = raise NotImplementedError
437
+ def write(instance_key, state, cleanup_after:) = raise NotImplementedError
438
+ def complete(instance_key) = raise NotImplementedError
439
+ def clear(instance_key) = raise NotImplementedError
440
+ def completed?(wizard:, owner: nil, anchor: nil) = raise NotImplementedError
441
+ def in_progress_for(owner) = raise NotImplementedError
442
+ end
443
+ end
444
+ end
445
+ end
446
+ ```
447
+
448
+ ```ruby
449
+ # lib/plutonium/wizard/store/active_record.rb
450
+ module Plutonium
451
+ module Wizard
452
+ module Store
453
+ class ActiveRecord < Base
454
+ def read(instance_key)
455
+ row = Session.find_by(instance_key:)
456
+ row && to_state(row)
457
+ end
458
+
459
+ def write(instance_key, state, cleanup_after:)
460
+ row = Session.find_or_initialize_by(instance_key:)
461
+ row.wizard = state.wizard
462
+ row.current_step = state.current_step
463
+ row.status ||= "in_progress"
464
+ row.data = state.data
465
+ row.persisted = state.persisted
466
+ row.owner = state.owner
467
+ row.anchor = state.anchor
468
+ row.scope = state.scope
469
+ row.token = state.token
470
+ row.expires_at = cleanup_after ? Time.current + cleanup_after : nil
471
+ row.save!
472
+ to_state(row)
473
+ end
474
+
475
+ def complete(instance_key)
476
+ row = Session.find_by!(instance_key:)
477
+ row.update!(status: "completed", completed_at: Time.current, data: {}, persisted: {})
478
+ end
479
+
480
+ def clear(instance_key) = Session.where(instance_key:).delete_all
481
+
482
+ def completed?(wizard:, owner: nil, anchor: nil)
483
+ scope = Session.status_completed.where(wizard: wizard.to_s)
484
+ scope = scope.where(owner:) if owner
485
+ scope = scope.where(anchor:) if anchor
486
+ scope.exists?
487
+ end
488
+
489
+ def in_progress_for(owner) = Session.status_in_progress.where(owner:).map { to_state(_1) }
490
+
491
+ private
492
+
493
+ def to_state(row)
494
+ State.new(
495
+ wizard: row.wizard, instance_key: row.instance_key, current_step: row.current_step,
496
+ status: row.status, data: row.data, persisted: row.persisted,
497
+ owner: row.owner, anchor: row.anchor, scope: row.scope, token: row.token
498
+ )
499
+ end
500
+ end
501
+ end
502
+ end
503
+ end
504
+ ```
505
+
506
+ ```ruby
507
+ # lib/plutonium/wizard/store/memory.rb
508
+ module Plutonium
509
+ module Wizard
510
+ module Store
511
+ class Memory < Base
512
+ def initialize = @rows = {}
513
+ def read(k) = @rows[k]&.dup
514
+ def write(k, state, cleanup_after:)
515
+ state = state.dup
516
+ state.instance_key = k
517
+ state.status ||= "in_progress"
518
+ @rows[k] = state
519
+ end
520
+ def complete(k)
521
+ s = @rows.fetch(k); s.status = "completed"; s.data = {}; s.persisted = {}; s
522
+ end
523
+ def clear(k) = @rows.delete(k)
524
+ def completed?(wizard:, owner: nil, anchor: nil)
525
+ @rows.values.any? { _1.status == "completed" && _1.wizard == wizard.to_s &&
526
+ (owner.nil? || _1.owner == owner) && (anchor.nil? || _1.anchor == anchor) }
527
+ end
528
+ def in_progress_for(owner) = @rows.values.select { _1.status == "in_progress" && _1.owner == owner }
529
+ end
530
+ end
531
+ end
532
+ end
533
+ ```
534
+
535
+ ```ruby
536
+ # test/plutonium/wizard/store/shared.rb (shared behavior)
537
+ module WizardStoreBehavior
538
+ def test_write_then_read_roundtrip
539
+ st = build_state(data: {"a" => 1})
540
+ @store.write(st.instance_key, st, cleanup_after: 1.day)
541
+ got = @store.read(st.instance_key)
542
+ assert_equal({"a" => 1}, got.data)
543
+ assert_equal "in_progress", got.status
544
+ end
545
+
546
+ def test_complete_nulls_payload
547
+ st = build_state(data: {"a" => 1})
548
+ @store.write(st.instance_key, st, cleanup_after: 1.day)
549
+ @store.complete(st.instance_key)
550
+ assert_equal "completed", @store.read(st.instance_key).status
551
+ assert_empty @store.read(st.instance_key).data
552
+ end
553
+
554
+ def test_completed_query
555
+ st = build_state
556
+ @store.write(st.instance_key, st, cleanup_after: 1.day)
557
+ @store.complete(st.instance_key)
558
+ assert @store.completed?(wizard: "W")
559
+ end
560
+
561
+ def build_state(data: {})
562
+ Plutonium::Wizard::State.new(wizard: "W", instance_key: "key-#{data.hash}",
563
+ current_step: "one", data: data, persisted: {})
564
+ end
565
+ end
566
+ ```
567
+
568
+ ```ruby
569
+ # test/plutonium/wizard/store/memory_test.rb
570
+ require "test_helper"; require_relative "shared"
571
+ class Plutonium::Wizard::Store::MemoryTest < Minitest::Test
572
+ include WizardStoreBehavior
573
+ def setup = @store = Plutonium::Wizard::Store::Memory.new
574
+ end
575
+ ```
576
+
577
+ ```ruby
578
+ # test/plutonium/wizard/store/active_record_test.rb
579
+ require "test_helper"; require_relative "shared"
580
+ class Plutonium::Wizard::Store::ActiveRecordTest < ActiveSupport::TestCase
581
+ include WizardStoreBehavior
582
+ setup { @store = Plutonium::Wizard::Store::ActiveRecord.new }
583
+ # NOTE: build_state in shared uses fixed instance_key strings; AR store keys on instance_key column — OK.
584
+ end
585
+ ```
586
+
587
+ - [ ] **Step 9: Run green** — both store tests + instance_key → PASS.
588
+
589
+ - [ ] **Step 10: Commit**
590
+
591
+ ```bash
592
+ git add db/migrate/wizard app/models/plutonium/wizard/session.rb lib/plutonium/wizard/instance_key.rb lib/plutonium/wizard/state.rb lib/plutonium/wizard/store test/plutonium/wizard/instance_key_test.rb test/plutonium/wizard/store
593
+ git commit -m "feat(wizard): sessions table, Session model, instance_key, State, Store (memory + AR)"
594
+ ```
595
+
596
+ ```json:metadata
597
+ {"files": ["db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb", "app/models/plutonium/wizard/session.rb", "lib/plutonium/wizard/instance_key.rb", "lib/plutonium/wizard/state.rb", "lib/plutonium/wizard/store/base.rb", "lib/plutonium/wizard/store/memory.rb", "lib/plutonium/wizard/store/active_record.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/store/active_record_test.rb test/plutonium/wizard/instance_key_test.rb", "acceptanceCriteria": ["table + indexes per spec 8.1", "instance_key recipe (token principal, owner excluded when token present)", "Memory + AR stores satisfy shared behavior", "write upserts + stamps expires_at", "complete nulls payload"], "requiresUserVerification": false}
598
+ ```
599
+
600
+ ---
601
+
602
+ ### Task 2: Wizard DSL — Base, Step, ReviewStep, typed `data`, anchoring, navigation, cleanup_after, one_time, encrypt_data
603
+
604
+ **Goal:** The author-facing class. Declaring `step`/`review`/`anchored`/`navigation`/`cleanup_after`/`one_time`/`encrypt_data`/`presents` works; a wizard exposes its ordered steps, union attribute schema, and a typed `data` snapshot; `anchor`/`persisted`/`fail!` accessors behave per spec. (No HTTP/runner yet — pure object behavior.)
605
+
606
+ **Files:**
607
+ - Create: `lib/plutonium/wizard/step.rb`, `lib/plutonium/wizard/review_step.rb`, `lib/plutonium/wizard/data.rb`, `lib/plutonium/wizard/dsl.rb`, `lib/plutonium/wizard/base.rb`
608
+ - Test: `test/plutonium/wizard/base_test.rb`, `test/plutonium/wizard/data_test.rb`
609
+
610
+ **Acceptance Criteria:**
611
+ - [ ] `step :k, label:, condition:` registers an ordered `Step`; the step block evaluates `attribute`/`input`/`validates`/`structured_input` into a per-step field surface (reusing `Definition::StructuredInputs::FieldsDefinition`-style capture).
612
+ - [ ] `review label:` registers a terminal `ReviewStep`; declaring any step after `review` raises at class-eval time (spec §2.5 terminality).
613
+ - [ ] `anchored with: T` / `with: [A, B]` / `anchored` / omit recorded; `anchor` accessor returns the bound record or raises `NotAnchoredError` when not anchored.
614
+ - [ ] `navigation` (default `:linear`), `cleanup_after` (default from config), `one_time once_per:`, `encrypt_data` recorded and readable.
615
+ - [ ] `data` is an ActiveModel::Attributes-backed snapshot over the **union** of all steps' attributes, cast to declared types, with uncollected fields `nil`; `data.foo` works; structured arrays yield typed sub-objects (`data.invites.first.email`).
616
+ - [ ] `fail!("m")` raises `StepError` (base); `fail!(:f, "m")` raises `StepError` with `attribute: :f`.
617
+
618
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/base_test.rb` → PASS
619
+
620
+ **Steps:**
621
+
622
+ - [ ] **Step 1: Failing test — DSL + data typing + anchor**
623
+
624
+ ```ruby
625
+ # test/plutonium/wizard/base_test.rb
626
+ require "test_helper"
627
+
628
+ class Plutonium::Wizard::BaseTest < Minitest::Test
629
+ class CreateCo < Plutonium::Wizard::Base
630
+ step :company do
631
+ attribute :name, :string
632
+ attribute :employees, :integer
633
+ input :name
634
+ validates :name, presence: true
635
+ end
636
+ step :plan, condition: -> { data.name.present? } do
637
+ attribute :plan, :string
638
+ input :plan
639
+ end
640
+ review label: "Review"
641
+ def execute = succeed(true)
642
+ end
643
+
644
+ def test_steps_ordered_and_terminal_review
645
+ keys = CreateCo.steps.map(&:key)
646
+ assert_equal %i[company plan review], keys
647
+ assert CreateCo.steps.last.review?
648
+ end
649
+
650
+ def test_union_attribute_schema_and_typed_data
651
+ w = CreateCo.new
652
+ w.data_attributes = {"name" => "Acme", "employees" => "12"}
653
+ assert_equal "Acme", w.data.name
654
+ assert_equal 12, w.data.employees # cast to Integer
655
+ assert_nil w.data.plan # uncollected → nil
656
+ end
657
+
658
+ def test_review_must_be_last
659
+ err = assert_raises(ArgumentError) do
660
+ Class.new(Plutonium::Wizard::Base) do
661
+ review label: "R"
662
+ step(:after) { attribute :x, :string }
663
+ end
664
+ end
665
+ assert_match(/review.*last/i, err.message)
666
+ end
667
+
668
+ def test_anchor_raises_when_not_anchored
669
+ assert_raises(Plutonium::Wizard::NotAnchoredError) { CreateCo.new.anchor }
670
+ end
671
+
672
+ def test_fail_bang
673
+ w = CreateCo.new
674
+ e = assert_raises(Plutonium::Wizard::StepError) { w.send(:fail!, "nope") }
675
+ assert_equal :base, e.attribute
676
+ e2 = assert_raises(Plutonium::Wizard::StepError) { w.send(:fail!, :name, "bad") }
677
+ assert_equal :name, e2.attribute
678
+ end
679
+ end
680
+ ```
681
+
682
+ - [ ] **Step 2: Run red** → FAIL.
683
+
684
+ - [ ] **Step 3: Step + ReviewStep value objects**
685
+
686
+ ```ruby
687
+ # lib/plutonium/wizard/step.rb
688
+ module Plutonium
689
+ module Wizard
690
+ class Step
691
+ attr_reader :key, :label, :condition, :fields, :on_submit, :on_rollback, :using_spec, :form_layout
692
+
693
+ def initialize(key:, label: nil, condition: nil, fields:, on_submit: nil,
694
+ on_rollback: nil, using_spec: nil, form_layout: nil)
695
+ @key = key
696
+ @label = label || key.to_s.humanize
697
+ @condition = condition
698
+ @fields = fields # FieldsDefinition-like: attributes/inputs/validations/structured
699
+ @on_submit = on_submit
700
+ @on_rollback = on_rollback
701
+ @using_spec = using_spec # FieldImporter::Spec or nil (Task 3)
702
+ @form_layout = form_layout
703
+ end
704
+
705
+ def review? = false
706
+ def attribute_schema = fields.attribute_schema # { name => type } (Task 2 fields capture)
707
+ end
708
+ end
709
+ end
710
+ ```
711
+
712
+ ```ruby
713
+ # lib/plutonium/wizard/review_step.rb
714
+ module Plutonium
715
+ module Wizard
716
+ class ReviewStep < Step
717
+ attr_reader :block
718
+ def initialize(key: :review, label: "Review", condition: nil, block: nil)
719
+ super(key:, label:, condition:, fields: EmptyFields.new)
720
+ @block = block
721
+ end
722
+ def review? = true
723
+
724
+ class EmptyFields
725
+ def attribute_schema = {}
726
+ end
727
+ end
728
+ end
729
+ end
730
+ ```
731
+
732
+ - [ ] **Step 4: Field capture + typed `data`**
733
+
734
+ The step block is evaluated against a capture object that records `attribute`/`input`/`validates`/`structured_input`. Reuse the existing `Plutonium::Definition::StructuredInputs::FieldsDefinition` shape (it already includes DefineableProps for `field`/`input`); extend a small capture that *also* records `attribute :name, :type` (for the schema) and `validates` (for inline validation). Build it as:
735
+
736
+ ```ruby
737
+ # lib/plutonium/wizard/data.rb
738
+ module Plutonium
739
+ module Wizard
740
+ # Builds a typed, dot-accessible snapshot class from a union attribute schema
741
+ # ({ name => type }). Backed by ActiveModel::Attributes so values are cast.
742
+ module Data
743
+ def self.class_for(schema)
744
+ Class.new do
745
+ include ActiveModel::Model
746
+ include ActiveModel::Attributes
747
+ schema.each { |name, type| attribute(name, type) }
748
+ end
749
+ end
750
+ end
751
+ end
752
+ end
753
+ ```
754
+
755
+ In `Base`, the union schema = merge of every step's `attribute_schema` (inline + imported). `data` builds (memoized) an instance of `Data.class_for(union_schema)` from the staged `data_attributes` hash. Structured inputs declared with `repeat:` are typed as arrays of nested snapshot objects — back them with an ActiveModel attribute whose type casts an array of hashes into nested `Data` instances (use a small custom `ActiveModel::Type` or map in the reader). For v1, implement structured arrays as: store raw array-of-hashes, and expose `data.invites` as an array of `OpenStruct`-like typed wrappers built from the structured_input's sub-schema. Keep the wrapper minimal but typed per the sub-field declarations.
756
+
757
+ - [ ] **Step 5: DSL module + Base**
758
+
759
+ ```ruby
760
+ # lib/plutonium/wizard/dsl.rb
761
+ module Plutonium
762
+ module Wizard
763
+ module DSL
764
+ extend ActiveSupport::Concern
765
+
766
+ class_methods do
767
+ def steps = @steps ||= []
768
+
769
+ def step(key, label: nil, condition: nil, using: nil, **using_opts, &block)
770
+ assert_not_after_review!(key)
771
+ fields = capture_fields(using:, using_opts:, &block)
772
+ on_submit = fields.delete_hook(:on_submit)
773
+ on_rollback = fields.delete_hook(:on_rollback)
774
+ steps << Step.new(key:, label:, condition:, fields:,
775
+ on_submit:, on_rollback:, using_spec: fields.using_spec, form_layout: fields.form_layout)
776
+ end
777
+
778
+ def review(label: "Review", condition: nil, &block)
779
+ steps << ReviewStep.new(label:, condition:, block:)
780
+ end
781
+
782
+ def anchored(with: nil, &resolver)
783
+ @anchored = true
784
+ @anchor_types = Array(with).presence
785
+ @anchor_resolver = resolver
786
+ end
787
+ def anchored? = !!@anchored
788
+ def anchor_types = @anchor_types
789
+ def anchor_resolver = @anchor_resolver
790
+
791
+ def navigation(mode = nil) = mode ? (@navigation = mode) : (@navigation || :linear)
792
+ def cleanup_after(ttl = :__read__)
793
+ return (@cleanup_after.nil? ? Plutonium.configuration.wizards.cleanup_after : @cleanup_after) if ttl == :__read__
794
+ @cleanup_after = (ttl == :never ? nil : ttl)
795
+ end
796
+ def one_time(once_per: :user) = @one_time = once_per
797
+ def one_time? = !@one_time.nil?
798
+ def one_time_scope = @one_time
799
+ def encrypt_data(flag = true) = @encrypt_data = flag
800
+ def encrypt_data? = !!@encrypt_data
801
+
802
+ private
803
+
804
+ def assert_not_after_review!(key)
805
+ if steps.any?(&:review?)
806
+ raise ArgumentError, "`review` must be the last step; cannot declare step :#{key} after it"
807
+ end
808
+ end
809
+
810
+ # Returns a fields-capture object; see Task 2 Step 4 + Task 3 for using:.
811
+ def capture_fields(using:, using_opts:, &block) = FieldCapture.build(using:, using_opts:, &block)
812
+ end
813
+ end
814
+ end
815
+ end
816
+ ```
817
+
818
+ ```ruby
819
+ # lib/plutonium/wizard/base.rb
820
+ module Plutonium
821
+ module Wizard
822
+ class Base
823
+ include ActiveModel::Model
824
+ include Plutonium::Definition::Presentable # presents label:/icon:/description:
825
+ include DSL
826
+
827
+ attr_accessor :data_attributes, :view_context
828
+ attr_writer :anchor, :scope, :token
829
+
830
+ def initialize(view_context: nil, **)
831
+ @view_context = view_context
832
+ @data_attributes = {}
833
+ super()
834
+ end
835
+
836
+ # Union schema across all (non-review) steps.
837
+ def self.union_attribute_schema
838
+ steps.reject(&:review?).reduce({}) { |acc, s| acc.merge(s.attribute_schema) }
839
+ end
840
+
841
+ def data
842
+ @data ||= Data.class_for(self.class.union_attribute_schema).new(data_attributes)
843
+ end
844
+
845
+ def anchor
846
+ raise NotAnchoredError, "#{self.class} is not `anchored`" unless self.class.anchored?
847
+ @anchor
848
+ end
849
+
850
+ def persisted = @persisted ||= {} # populated by the runner from on_submit/persist
851
+
852
+ def execute = raise NotImplementedError, "#{self.class} must implement #execute"
853
+
854
+ private
855
+
856
+ # Raise a StepError from on_submit/execute. fail!("msg") or fail!(:field, "msg").
857
+ def fail!(attribute_or_message, message = nil)
858
+ if message.nil?
859
+ raise StepError.new(attribute_or_message, attribute: :base)
860
+ else
861
+ raise StepError.new(message, attribute: attribute_or_message)
862
+ end
863
+ end
864
+
865
+ def succeed(value = nil) = Plutonium::Interaction::Outcome::Success.new(value:)
866
+ def failed(errors = nil, attribute = :base)
867
+ self.errors.add(attribute, errors.to_s) if errors.is_a?(String)
868
+ Plutonium::Interaction::Outcome::Failure.new(errors: self.errors)
869
+ end
870
+ end
871
+ end
872
+ end
873
+ ```
874
+
875
+ > The `FieldCapture` object (referenced above) wraps a `FieldsDefinition`-style recorder that supports `attribute`, `input`, `validates`, `structured_input`, `on_submit`, `on_rollback`, `using`, `form_layout`, and exposes `attribute_schema`, `delete_hook`, `using_spec`, `form_layout`. Implement it in this task for inline fields; the `using:` resolution is filled in Task 3 (here it can accept a spec and merge later). Keep `succeed`/`failed` aligned with `Plutonium::Interaction::Outcome` (verify the exact constructor — `Outcome::Success.new(value:)` per Task-research; adjust if the real signature differs).
876
+
877
+ - [ ] **Step 6: Run green** → PASS.
878
+
879
+ - [ ] **Step 7: Commit**
880
+
881
+ ```bash
882
+ git add lib/plutonium/wizard/step.rb lib/plutonium/wizard/review_step.rb lib/plutonium/wizard/data.rb lib/plutonium/wizard/dsl.rb lib/plutonium/wizard/base.rb test/plutonium/wizard/base_test.rb test/plutonium/wizard/data_test.rb
883
+ git commit -m "feat(wizard): Base DSL — step/review/anchored/navigation/one_time, typed data, fail!"
884
+ ```
885
+
886
+ ```json:metadata
887
+ {"files": ["lib/plutonium/wizard/step.rb", "lib/plutonium/wizard/review_step.rb", "lib/plutonium/wizard/data.rb", "lib/plutonium/wizard/dsl.rb", "lib/plutonium/wizard/base.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/base_test.rb test/plutonium/wizard/data_test.rb", "acceptanceCriteria": ["step/review/anchored/navigation/cleanup_after/one_time/encrypt_data DSL", "review terminality raises", "typed union data snapshot, nil for uncollected", "anchor raises NotAnchoredError", "fail! raises StepError with attribute"], "requiresUserVerification": false}
888
+ ```
889
+
890
+ ---
891
+
892
+ ### Task 3: FieldImporter — `using:` an interaction or resource definition
893
+
894
+ **Goal:** A step can `using:` an interaction or resource definition to import attributes (with types), inputs, validations (run-and-filter to imported fields + `:base`), and `form_layout` — with `fields:`/`only:`/`except:`, `validate: false`, `layout: false`, `validation_context:`. Definition targets read base from the record class and overlay the definition's customizations (spec §2.4).
895
+
896
+ **Files:**
897
+ - Create: `lib/plutonium/wizard/field_importer.rb`
898
+ - Modify: `lib/plutonium/wizard/dsl.rb` (wire `using:` into `capture_fields`/`FieldCapture`)
899
+ - Test: `test/plutonium/wizard/field_importer_test.rb`
900
+
901
+ **Acceptance Criteria:**
902
+ - [ ] `using: SomeInteraction, only: %i[a b]` imports those attributes (types from the interaction's `attribute` declarations), inputs, and validations.
903
+ - [ ] `using: SomeDefinition, fields: %i[a]` resolves the field's **type from the definition's record class** (`Model.attribute_types`), overlays the definition's input config, and validates via `Model.new(slice).valid?`.
904
+ - [ ] Imported validation keeps errors only on imported fields **+ `:base`**; errors on other attributes are dropped.
905
+ - [ ] `validate: false` skips validation reuse; `layout: false` skips form_layout inheritance; `validation_context:` is passed to `valid?`.
906
+ - [ ] Inline declarations in the same step compose with imported ones (inline wins on conflict).
907
+
908
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/field_importer_test.rb` → PASS
909
+
910
+ **Steps:**
911
+
912
+ - [ ] **Step 1: Failing test** — define a tiny interaction + a dummy resource/definition in the test, import subsets, assert schema/validation/error-filtering. (Use the dummy app's existing models/definitions for the definition-target case.)
913
+
914
+ ```ruby
915
+ # test/plutonium/wizard/field_importer_test.rb (sketch — fill concretely against dummy models)
916
+ require "test_helper"
917
+
918
+ class Plutonium::Wizard::FieldImporterTest < ActiveSupport::TestCase
919
+ class ContactInteraction < Plutonium::Interaction::Base
920
+ attribute :phone, :string
921
+ attribute :email, :string
922
+ validates :email, presence: true
923
+ private def execute = succeed(true)
924
+ end
925
+
926
+ test "interaction import: types + filtered validation" do
927
+ spec = Plutonium::Wizard::FieldImporter.resolve(using: ContactInteraction, opts: {only: %i[email]})
928
+ assert_equal({email: :string}, spec.attribute_schema)
929
+ errors = spec.validate({"email" => ""}) # runs ContactInteraction.new(email:"").valid?
930
+ assert errors.key?(:email)
931
+ refute errors.key?(:phone) # not imported → dropped
932
+ end
933
+
934
+ test "validate: false skips validation" do
935
+ spec = Plutonium::Wizard::FieldImporter.resolve(using: ContactInteraction, opts: {only: %i[email], validate: false})
936
+ assert_empty spec.validate({"email" => ""})
937
+ end
938
+ end
939
+ ```
940
+
941
+ - [ ] **Step 2: Run red** → FAIL.
942
+
943
+ - [ ] **Step 3: Implement FieldImporter**
944
+
945
+ ```ruby
946
+ # lib/plutonium/wizard/field_importer.rb
947
+ module Plutonium
948
+ module Wizard
949
+ module FieldImporter
950
+ Spec = Struct.new(:attribute_schema, :inputs, :form_layout, :validate_fn) do
951
+ def validate(data_slice) = validate_fn ? validate_fn.call(data_slice) : {}
952
+ end
953
+
954
+ def self.resolve(using:, opts:)
955
+ only = Array(opts[:fields] || opts[:only]).map(&:to_sym).presence
956
+ except = Array(opts[:except]).map(&:to_sym)
957
+ do_validate = opts.fetch(:validate, true)
958
+ do_layout = opts.fetch(:layout, true)
959
+ context = opts[:validation_context]
960
+
961
+ if interaction?(using)
962
+ from_interaction(using, only:, except:, do_validate:, do_layout:, context:)
963
+ else
964
+ from_definition(using, only:, except:, do_validate:, do_layout:, context:)
965
+ end
966
+ end
967
+
968
+ def self.interaction?(klass) = klass < Plutonium::Interaction::Base
969
+
970
+ def self.select(names, only:, except:)
971
+ names = names & only if only
972
+ names - except
973
+ end
974
+
975
+ def self.from_interaction(klass, only:, except:, do_validate:, do_layout:, context:)
976
+ names = select(klass.attribute_names.map(&:to_sym), only:, except:)
977
+ schema = names.index_with { |n| klass.attribute_types[n.to_s]&.type || :string }
978
+ validate_fn = build_validate(do_validate) do |slice|
979
+ obj = klass.new
980
+ obj.attributes = slice.slice(*names.map(&:to_s))
981
+ run_and_filter(obj, names, context)
982
+ end
983
+ Spec.new(attribute_schema: schema, inputs: klass.defined_inputs.slice(*names),
984
+ form_layout: (do_layout ? layout_for(klass, names) : nil), validate_fn:)
985
+ end
986
+
987
+ def self.from_definition(defn, only:, except:, do_validate:, do_layout:, context:)
988
+ model = defn.model_class # resolve the backing record class (verify exact accessor on Definition)
989
+ names = select(defn.defined_inputs.keys.map(&:to_sym), only:, except:)
990
+ # Type from record (base), input config overlaid by definition (handled at render time).
991
+ schema = names.index_with { |n| model.attribute_types[n.to_s]&.type || :string }
992
+ validate_fn = build_validate(do_validate) do |slice|
993
+ rec = model.new(slice.slice(*names.map(&:to_s)))
994
+ run_and_filter(rec, names, context)
995
+ end
996
+ Spec.new(attribute_schema: schema, inputs: defn.defined_inputs.slice(*names),
997
+ form_layout: (do_layout ? layout_for(defn, names) : nil), validate_fn:)
998
+ end
999
+
1000
+ def self.build_validate(do_validate)
1001
+ return nil unless do_validate
1002
+ ->(slice) { yield(slice) }
1003
+ end
1004
+
1005
+ # Run valid? and keep errors only on imported fields + :base.
1006
+ def self.run_and_filter(obj, names, context)
1007
+ context ? obj.valid?(context) : obj.valid?
1008
+ keep = names.map(&:to_s) << "base"
1009
+ obj.errors.group_by_attribute.slice(*keep.map(&:to_sym))
1010
+ end
1011
+
1012
+ def self.layout_for(source, names)
1013
+ layout = source.respond_to?(:defined_form_layout) ? source.defined_form_layout : nil
1014
+ return nil unless layout
1015
+ # Filter sections to imported fields (reuse Section resolution semantics).
1016
+ layout.map { |sec| Plutonium::Definition::FormLayout::ResolvedSection.new(sec, sec.fields & names) }
1017
+ .reject { |rs| rs.fields.empty? && !rs.section.ungrouped? }
1018
+ end
1019
+ end
1020
+ end
1021
+ end
1022
+ ```
1023
+
1024
+ > Verify exact accessors against the real codebase: `Interaction.attribute_types`/`attribute_names`/`defined_inputs`, definition's record-class accessor (likely `model_class` or similar — confirm), and `errors.group_by_attribute` availability (Rails 6.1+). Adjust names to match.
1025
+
1026
+ - [ ] **Step 4: Wire into the DSL** — in `FieldCapture`, when `using:` is given, call `FieldImporter.resolve` and merge its `attribute_schema`/`inputs`/`form_layout`/`validate_fn` with inline declarations (inline overrides imported). Expose `using_spec` on the Step so the runner (Task 4) and form (Task 6) can use it.
1027
+
1028
+ - [ ] **Step 5: Run green** → PASS.
1029
+
1030
+ - [ ] **Step 6: Commit**
1031
+
1032
+ ```bash
1033
+ git add lib/plutonium/wizard/field_importer.rb lib/plutonium/wizard/dsl.rb test/plutonium/wizard/field_importer_test.rb
1034
+ git commit -m "feat(wizard): using: import of fields/validations/form_layout from interaction or definition"
1035
+ ```
1036
+
1037
+ ```json:metadata
1038
+ {"files": ["lib/plutonium/wizard/field_importer.rb", "lib/plutonium/wizard/dsl.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/field_importer_test.rb", "acceptanceCriteria": ["interaction import: types+inputs+validations", "definition import: type from record class + overlay", "validation run-and-filter to imported fields + :base", "validate:false / layout:false / validation_context:", "inline composes with imported"], "requiresUserVerification": false}
1039
+ ```
1040
+
1041
+ ---
1042
+
1043
+ ### Task 4: Runner — navigation, validation, on_submit/persist/on_rollback, execute, completeness/prune, lock, cleanup, resume
1044
+
1045
+ **Goal:** The pure engine that, given a wizard + State + Store, computes the visible path, validates a step, advances/back/cancel, runs `on_submit` (tracking persisted GIDs) and `on_rollback`, finalizes via `execute` with completeness assertion + branch-hidden pruning + the locked `completing` transition, performs cleanup, and rehydrates `persisted` on resume. No HTTP yet — drive it directly in unit tests with the Memory store.
1046
+
1047
+ **Files:**
1048
+ - Create: `lib/plutonium/wizard/runner.rb`
1049
+ - Test: `test/plutonium/wizard/runner_test.rb`
1050
+
1051
+ **Acceptance Criteria:**
1052
+ - [ ] `visible_path` evaluates each step's `condition:` against `data` (subtractive); branch-hidden steps excluded; `review` always last.
1053
+ - [ ] `advance(step, params)` validates the step (inline + imported), stages `data`, runs `on_submit` (in a transaction), tracks records passed to `persist` as GIDs in `state.persisted[step_key]`, moves cursor to the next visible step; on validation/`on_submit` failure returns errors and does not advance.
1054
+ - [ ] `back` moves cursor without validating; never discards `data`.
1055
+ - [ ] `cancel` runs `on_rollback`/destroy of tracked records (reverse order) then clears the row.
1056
+ - [ ] `finalize` asserts every visible non-review step is visited+valid (else returns the first offending step), prunes `data` for branch-hidden steps, performs the locked `in_progress → completing` transition (bails if already moved), runs `execute`; on success `complete`s the row, on failure reverts to `in_progress`.
1057
+ - [ ] On load, `persisted` is rehydrated from stored GIDs (GlobalID.locate).
1058
+ - [ ] `on_submit` failure: `RecordInvalid` → field errors; `StepError` → `attribute` error; other `StandardError` re-raised.
1059
+
1060
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/runner_test.rb` → PASS
1061
+
1062
+ **Steps:**
1063
+
1064
+ - [ ] **Step 1: Failing tests** — branching path, advance happy/invalid, back-no-validate, on_submit persist+track, on_rollback on cancel, finalize completeness+prune, double-submit lock (simulate concurrent by calling finalize twice). Use a wizard with `condition:` and an `on_submit` that `persist`s a dummy AR record.
1065
+
1066
+ ```ruby
1067
+ # test/plutonium/wizard/runner_test.rb (key cases — expand)
1068
+ require "test_helper"
1069
+
1070
+ class Plutonium::Wizard::RunnerTest < ActiveSupport::TestCase
1071
+ class W < Plutonium::Wizard::Base
1072
+ step(:a) { attribute :go, :string; validates :go, presence: true }
1073
+ step(:b, condition: -> { data.go == "yes" }) { attribute :note, :string }
1074
+ review label: "R"
1075
+ def execute = succeed(:done)
1076
+ end
1077
+
1078
+ setup do
1079
+ @store = Plutonium::Wizard::Store::Memory.new
1080
+ @runner = Plutonium::Wizard::Runner.new(wizard_class: W, store: @store, instance_key: "k")
1081
+ end
1082
+
1083
+ test "branching hides b until go=yes" do
1084
+ assert_equal %i[a review], @runner.visible_path.map(&:key)
1085
+ @runner.advance(:a, {"go" => "yes"})
1086
+ assert_equal %i[a b review], @runner.visible_path.map(&:key)
1087
+ end
1088
+
1089
+ test "advance invalid does not move" do
1090
+ res = @runner.advance(:a, {"go" => ""})
1091
+ refute res.ok?
1092
+ assert res.errors.key?(:go)
1093
+ assert_equal :a, @runner.current_step.key
1094
+ end
1095
+
1096
+ test "finalize completeness redirects to first gap" do
1097
+ res = @runner.finalize
1098
+ refute res.completed?
1099
+ assert_equal :a, res.redirect_step
1100
+ end
1101
+
1102
+ test "concurrent finalize: loser bails" do
1103
+ @runner.advance(:a, {"go" => "no"}) # b hidden; only a + review
1104
+ first = @runner.finalize
1105
+ assert first.completed?
1106
+ second = Plutonium::Wizard::Runner.new(wizard_class: W, store: @store, instance_key: "k").finalize
1107
+ refute second.completed? # already completed/cleared
1108
+ end
1109
+ end
1110
+ ```
1111
+
1112
+ - [ ] **Step 2: Run red** → FAIL.
1113
+
1114
+ - [ ] **Step 3: Implement Runner** — core methods (concrete):
1115
+
1116
+ ```ruby
1117
+ # lib/plutonium/wizard/runner.rb
1118
+ module Plutonium
1119
+ module Wizard
1120
+ class Runner
1121
+ Result = Struct.new(:ok, :errors, :completed, :redirect_step, :value) do
1122
+ def ok? = !!ok
1123
+ def completed? = !!completed
1124
+ end
1125
+
1126
+ def initialize(wizard_class:, store:, instance_key:, view_context: nil, owner: nil, anchor: nil, scope: nil, token: nil)
1127
+ @wizard_class = wizard_class
1128
+ @store = store
1129
+ @instance_key = instance_key
1130
+ @state = store.read(instance_key) || new_state(owner:, anchor:, scope:, token:)
1131
+ @wizard = wizard_class.new(view_context:)
1132
+ @wizard.data_attributes = @state.data
1133
+ @wizard.anchor = (@state.anchor || anchor) if wizard_class.anchored?
1134
+ rehydrate_persisted
1135
+ end
1136
+
1137
+ def visible_path
1138
+ @wizard.data_attributes = @state.data
1139
+ @wizard_class.steps.select { |s| s.condition.nil? || @wizard.instance_exec(&s.condition) }
1140
+ end
1141
+
1142
+ def current_step = visible_path.find { _1.key.to_s == @state.current_step } || visible_path.first
1143
+
1144
+ def advance(step_key, params)
1145
+ step = step_for(step_key)
1146
+ errors = validate(step, params)
1147
+ return Result.new(ok: false, errors:) if errors.any?
1148
+ merge_data(params)
1149
+ run_on_submit(step) if step.on_submit
1150
+ @state.current_step = next_visible_after(step)&.key.to_s
1151
+ persist_state
1152
+ Result.new(ok: true)
1153
+ rescue ActiveRecord::RecordInvalid => e
1154
+ Result.new(ok: false, errors: e.record.errors.group_by_attribute)
1155
+ rescue StepError => e
1156
+ Result.new(ok: false, errors: {e.attribute => [e.message]})
1157
+ end
1158
+
1159
+ def back
1160
+ prev = previous_visible
1161
+ @state.current_step = prev&.key.to_s
1162
+ persist_state
1163
+ Result.new(ok: true)
1164
+ end
1165
+
1166
+ def cancel
1167
+ run_cleanup
1168
+ @store.clear(@instance_key)
1169
+ Result.new(ok: true)
1170
+ end
1171
+
1172
+ def finalize
1173
+ gap = first_incomplete_visible
1174
+ return Result.new(ok: false, redirect_step: gap.key) if gap
1175
+
1176
+ prune_hidden!
1177
+ return Result.new(ok: false, completed: false) unless lock_for_completion!
1178
+
1179
+ outcome = ActiveRecord::Base.transaction do
1180
+ run_deferred_nothing # (all on_submit already ran per-step; execute does at-end writes)
1181
+ @wizard.data_attributes = @state.data
1182
+ @wizard.execute
1183
+ end
1184
+
1185
+ if outcome.success?
1186
+ @store.complete(@instance_key)
1187
+ Result.new(ok: true, completed: true, value: outcome.value)
1188
+ else
1189
+ revert_completing!
1190
+ Result.new(ok: false, completed: false, errors: outcome_errors(outcome))
1191
+ end
1192
+ rescue ActiveRecord::RecordInvalid => e
1193
+ revert_completing!
1194
+ Result.new(ok: false, errors: e.record.errors.group_by_attribute)
1195
+ rescue StepError => e
1196
+ revert_completing!
1197
+ Result.new(ok: false, errors: {e.attribute => [e.message]})
1198
+ end
1199
+
1200
+ private
1201
+
1202
+ def lock_for_completion!
1203
+ row = Session.find_by(instance_key: @instance_key) or return true # memory store: no row
1204
+ row.with_lock do
1205
+ return false unless row.status_in_progress?
1206
+ row.update!(status: "completing")
1207
+ end
1208
+ true
1209
+ end
1210
+
1211
+ def revert_completing!
1212
+ Session.where(instance_key: @instance_key, status: "completing").update_all(status: "in_progress")
1213
+ end
1214
+
1215
+ def run_on_submit(step)
1216
+ ActiveRecord::Base.transaction do
1217
+ tracker = PersistTracker.new
1218
+ @wizard.data_attributes = @state.data
1219
+ @wizard.define_singleton_method(:persist) { |*recs| tracker.add(recs.flatten) }
1220
+ @wizard.instance_exec(&step.on_submit)
1221
+ @state.persisted[step.key.to_s] = tracker.gids
1222
+ @wizard.instance_variable_get(:@persisted)&.merge!(step.key => tracker.records)
1223
+ end
1224
+ end
1225
+
1226
+ def run_cleanup
1227
+ @wizard_class.steps.reverse_each do |step|
1228
+ recs = (@state.persisted[step.key.to_s] || []).filter_map { GlobalID.locate(_1) }
1229
+ next if recs.empty?
1230
+ if step.on_rollback
1231
+ @wizard.instance_variable_set(:@persisted, {step.key => recs})
1232
+ @wizard.instance_exec(&step.on_rollback)
1233
+ else
1234
+ recs.reverse_each(&:destroy!)
1235
+ end
1236
+ end
1237
+ end
1238
+
1239
+ def validate(step, params)
1240
+ merged = @state.data.merge(params)
1241
+ errors = {}
1242
+ # imported validation (run-and-filter)
1243
+ errors.merge!(step.using_spec.validate(merged)) if step.using_spec
1244
+ # inline validation: build a small ActiveModel from the step's inline attrs + validators
1245
+ errors.merge!(step.fields.validate_inline(merged)) if step.fields.respond_to?(:validate_inline)
1246
+ errors.reject { |_, msgs| msgs.blank? }
1247
+ end
1248
+
1249
+ def merge_data(params) = @state.data = @state.data.merge(params)
1250
+ def persist_state = @store.write(@instance_key, @state, cleanup_after: @wizard_class.cleanup_after)
1251
+ def step_for(key) = @wizard_class.steps.find { _1.key.to_s == key.to_s }
1252
+ def next_visible_after(step) = (vp = visible_path; vp[vp.index { _1.key == step.key }.to_i + 1])
1253
+ def previous_visible = (vp = visible_path; i = vp.index { _1.key.to_s == @state.current_step }.to_i; vp[[i - 1, 0].max])
1254
+ def first_incomplete_visible = visible_path.reject(&:review?).find { |s| !step_visited_and_valid?(s) }
1255
+ def step_visited_and_valid?(s) = validate(s, {}).empty? # visited rows have data staged; empty errors == valid
1256
+ def prune_hidden!
1257
+ visible = visible_path.flat_map { _1.attribute_schema.keys.map(&:to_s) }
1258
+ @state.data = @state.data.slice(*visible)
1259
+ end
1260
+ def rehydrate_persisted
1261
+ return unless @state.persisted.present?
1262
+ recs = @state.persisted.transform_values { |gids| Array(gids).filter_map { GlobalID.locate(_1) } }
1263
+ @wizard.instance_variable_set(:@persisted, recs.transform_keys(&:to_sym))
1264
+ end
1265
+ def new_state(owner:, anchor:, scope:, token:)
1266
+ State.new(wizard: @wizard_class.name, instance_key: @instance_key,
1267
+ current_step: @wizard_class.steps.first&.key.to_s, status: "in_progress",
1268
+ data: {}, persisted: {}, owner:, anchor:, scope:, token:)
1269
+ end
1270
+ def outcome_errors(o) = o.respond_to?(:errors) ? o.errors.group_by_attribute : {}
1271
+ def run_deferred_nothing = nil
1272
+
1273
+ class PersistTracker
1274
+ def initialize = (@records = [])
1275
+ def add(recs) = @records.concat(Array(recs))
1276
+ def records = @records
1277
+ def gids = @records.map { _1.to_global_id.to_s }
1278
+ end
1279
+ end
1280
+ end
1281
+ end
1282
+ ```
1283
+
1284
+ > This is the most intricate task — verify the `persist` macro binding (it must be available only inside `on_submit`/`execute`; the singleton-method approach above is one way; an alternative is a `PersistContext` the block is `instance_exec`'d against). Confirm `errors.group_by_attribute` shape and `Outcome` error access. Keep each behavior under its own test.
1285
+
1286
+ - [ ] **Step 4: Run green** → PASS.
1287
+
1288
+ - [ ] **Step 5: Commit**
1289
+
1290
+ ```bash
1291
+ git add lib/plutonium/wizard/runner.rb test/plutonium/wizard/runner_test.rb
1292
+ git commit -m "feat(wizard): runner — navigation, on_submit/persist/rollback, finalize lock + completeness/prune"
1293
+ ```
1294
+
1295
+ ```json:metadata
1296
+ {"files": ["lib/plutonium/wizard/runner.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/runner_test.rb", "acceptanceCriteria": ["visible_path subtractive branching", "advance validates+stages+on_submit+tracks GIDs", "back no-validate", "cancel runs rollback+clears", "finalize completeness+prune+lock+execute", "persisted rehydrated on resume", "failure mapping RecordInvalid/StepError"], "requiresUserVerification": false}
1297
+ ```
1298
+
1299
+ ---
1300
+
1301
+ ### Task 5: Controller, routing, and registration (`register_wizard` + `wizard` definition DSL) + `authorize?`
1302
+
1303
+ **Goal:** HTTP surface. One controller drives GET (render step), POST (`_direction` next/back/cancel, `pre_submit`), resolving the instance (scope/anchor/token/owner) and delegating to the Runner. Routes are synthesized two ways: standalone `register_wizard ... at:` and the in-definition `wizard` macro (which mirrors the action system). Entry checks `authorize?` (standalone) / the action policy (resource).
1304
+
1305
+ **Files:**
1306
+ - Create: `app/controllers/plutonium/wizard/controller.rb`, `lib/plutonium/routing/wizard_registration.rb`, `lib/plutonium/definition/wizards.rb`
1307
+ - Modify: `lib/plutonium/routing/mapper_extensions.rb` (draw per-resource wizard routes + `register_wizard`), `lib/plutonium/definition/base.rb` (include `Definition::Wizards`)
1308
+ - Test: `test/integration/.../wizard_flow_test.rb` (dummy app, all surfaces)
1309
+
1310
+ **Acceptance Criteria:**
1311
+ - [ ] `register_wizard OnboardingWizard, at: "/welcome"` draws `GET/POST /welcome/:step` (+ token variant) → the wizard controller; provides `welcome_wizard_path`.
1312
+ - [ ] `wizard :configure, ConfigureCompanyWizard` in a definition synthesizes a record action (anchored) / resource action (no anchor) that links to the wizard's GET route; placement mirrors interactions (record vs resource; **no bulk**).
1313
+ - [ ] GET renders the current step's form; POST `_direction=next` advances (or re-renders with errors `:unprocessable_content`), `back` goes back, `cancel` runs cleanup; `pre_submit` re-renders the form via turbo_stream (mirror interactive_actions.rb).
1314
+ - [ ] On finalize success → PRG redirect to the outcome target; one-time completion recorded.
1315
+ - [ ] Standalone entry calls `wizard.authorize?` (403 on false); resource entry uses the action's policy predicate.
1316
+ - [ ] Anchor injected from `:id` (record action); scope from the portal scoped entity; pre-auth token minted in a signed cookie.
1317
+
1318
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/<portal>/wizard_flow_test.rb` → PASS
1319
+
1320
+ **Steps:**
1321
+
1322
+ - [ ] **Step 1: Failing integration test** — in the dummy app, hand-write a small wizard (per project convention, dummy wizards are authored by hand like interactions), register it both standalone and on a resource definition, then drive: GET step 1 → POST next → GET step 2 → POST finish → assert redirect + records created; POST back; POST cancel → assert cleanup. Mirror `test/integration/org_portal/structured_input_interaction_test.rb` for request shape, `login_as`, and Turbo-Frame headers.
1323
+
1324
+ - [ ] **Step 2: Run red** → FAIL.
1325
+
1326
+ - [ ] **Step 3: Controller** — concrete skeleton (mirrors `interactive_actions.rb` flow §6):
1327
+
1328
+ ```ruby
1329
+ # app/controllers/plutonium/wizard/controller.rb
1330
+ module Plutonium
1331
+ module Wizard
1332
+ module Controller
1333
+ extend ActiveSupport::Concern
1334
+
1335
+ def show # GET .../:step
1336
+ runner = build_runner
1337
+ authorize_wizard!(runner)
1338
+ @wizard_view = runner # expose to the page
1339
+ render Plutonium::UI::Page::Wizard.new(runner:), **modal_render_options
1340
+ end
1341
+
1342
+ def update # POST .../:step
1343
+ runner = build_runner
1344
+ authorize_wizard!(runner)
1345
+ step_key = params[:step]
1346
+
1347
+ if params[:pre_submit]
1348
+ return render_pre_submit(runner, step_key)
1349
+ end
1350
+
1351
+ result =
1352
+ case params[:_direction]
1353
+ when "back" then runner.back
1354
+ when "cancel" then runner.cancel
1355
+ else
1356
+ adv = runner.advance(step_key, wizard_params)
1357
+ adv.ok? && runner.current_step&.review? == false && last_step?(runner) ? runner.finalize : adv
1358
+ end
1359
+
1360
+ respond_to_result(runner, result)
1361
+ end
1362
+
1363
+ private
1364
+
1365
+ def build_runner
1366
+ Plutonium::Wizard::Runner.new(
1367
+ wizard_class: current_wizard_class, store: wizard_store, instance_key: resolved_instance_key,
1368
+ view_context:, owner: current_user, anchor: resolved_anchor, scope: resolved_scope, token: resolved_token
1369
+ )
1370
+ end
1371
+
1372
+ def authorize_wizard!(runner)
1373
+ wiz = runner.wizard # expose reader on Runner
1374
+ if wiz.respond_to?(:authorize?) && !wiz.authorize?
1375
+ raise ActionPolicy::Unauthorized.new(nil, nil)
1376
+ end
1377
+ # resource-attached wizards additionally go through the action policy (mirror interactive actions)
1378
+ end
1379
+
1380
+ def wizard_store = Plutonium::Wizard::Store::ActiveRecord.new
1381
+ # resolved_instance_key/anchor/scope/token: from params + current scope + signed cookie (see spec §4)
1382
+ end
1383
+ end
1384
+ end
1385
+ ```
1386
+
1387
+ Implement: `respond_to_result` (success → `turbo_stream_redirect` / `redirect_to ..., status: :see_other`; failure → re-render step `:unprocessable_content`), `render_pre_submit` (turbo_stream replace of the form, mirroring interactive_actions.rb lines 37-60), `resolved_instance_key` (via `InstanceKey.for`), `resolved_scope` (portal `scoped_entity` if `scoped_to_entity?`), `resolved_token` (signed cookie, mint if absent for non-anchored), `last_step?`.
1388
+
1389
+ - [ ] **Step 4: Routing** — `register_wizard` + per-resource routes:
1390
+
1391
+ ```ruby
1392
+ # lib/plutonium/routing/wizard_registration.rb
1393
+ module Plutonium
1394
+ module Routing
1395
+ module WizardRegistration
1396
+ def register_wizard(wizard_class, at:)
1397
+ slug = wizard_class.name.demodulize.underscore.sub(/_wizard$/, "")
1398
+ scope path: at do
1399
+ get "(/:token)/:step", to: "plutonium/wizard#show", as: :"#{slug}_wizard", defaults: {wizard: wizard_class.name}
1400
+ post "(/:token)/:step", to: "plutonium/wizard#update", defaults: {wizard: wizard_class.name}
1401
+ end
1402
+ end
1403
+ end
1404
+ end
1405
+ end
1406
+ ```
1407
+
1408
+ Per-resource wizard routes mirror the interactive `record_actions`/`resource_actions` block in `mapper_extensions.rb` (lines 146-169): add `wizards/:wizard_slug(/:step)` GET/POST under member (anchored) and collection (non-anchored). Prepend `WizardRegistration` to `ActionDispatch::Routing::Mapper` in the Railtie alongside the existing `MapperExtensions`.
1409
+
1410
+ - [ ] **Step 5: `wizard` definition DSL** — synthesize actions:
1411
+
1412
+ ```ruby
1413
+ # lib/plutonium/definition/wizards.rb
1414
+ module Plutonium
1415
+ module Definition
1416
+ module Wizards
1417
+ extend ActiveSupport::Concern
1418
+ class_methods do
1419
+ def wizard(name, wizard_class, record_action: nil, collection: nil, **opts)
1420
+ anchored = wizard_class.anchored?
1421
+ is_record = record_action.nil? ? anchored : record_action
1422
+ action(name,
1423
+ route_options: Plutonium::Action::RouteOptions.new(method: :get, action: :show,
1424
+ url_resolver: wizard_url_resolver(wizard_class, is_record)),
1425
+ record_action: is_record, resource_action: !is_record,
1426
+ category: opts.fetch(:category, :primary),
1427
+ icon: opts[:icon], position: opts[:position],
1428
+ label: wizard_class.respond_to?(:presents) ? wizard_class.label : name.to_s.humanize)
1429
+ end
1430
+ end
1431
+ end
1432
+ end
1433
+ end
1434
+ ```
1435
+
1436
+ Include `Plutonium::Definition::Wizards` in `Definition::Base` (next to `Actions`). Implement `wizard_url_resolver` to build the wizard GET path for the subject. Confirm `Action::RouteOptions` constructor + `url_resolver` against `action/interactive.rb`.
1437
+
1438
+ - [ ] **Step 6: Run green** → PASS (iterate on routing/url helpers against the dummy app).
1439
+
1440
+ - [ ] **Step 7: Commit**
1441
+
1442
+ ```bash
1443
+ git add app/controllers/plutonium/wizard/controller.rb lib/plutonium/routing/wizard_registration.rb lib/plutonium/definition/wizards.rb lib/plutonium/routing/mapper_extensions.rb lib/plutonium/definition/base.rb lib/plutonium/railtie.rb test/integration
1444
+ git commit -m "feat(wizard): controller, register_wizard routing, wizard definition DSL, authorize?"
1445
+ ```
1446
+
1447
+ ```json:metadata
1448
+ {"files": ["app/controllers/plutonium/wizard/controller.rb", "lib/plutonium/routing/wizard_registration.rb", "lib/plutonium/definition/wizards.rb", "lib/plutonium/routing/mapper_extensions.rb", "lib/plutonium/definition/base.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration", "acceptanceCriteria": ["register_wizard draws routes + helper", "wizard DSL synthesizes record/resource action (no bulk)", "GET renders step; POST next/back/cancel; pre_submit", "PRG on finalize success", "authorize? gate (standalone) / policy (resource)", "anchor/scope/token resolution"], "requiresUserVerification": false}
1449
+ ```
1450
+
1451
+ ---
1452
+
1453
+ ### Task 6: UI — Page::Wizard, Stepper, nav buttons, review auto-summary, form rendering + repeater rehydration
1454
+
1455
+ **Goal:** Render a step (reusing the interaction form pipeline), the stepper (with disabled/branch-hidden behavior), Back/Next/Finish/Cancel buttons carrying `_direction`, and the review step's auto-summary (display components + outstanding-item jump links). Repeater rows rehydrate from staged `data` on GET.
1456
+
1457
+ **Files:**
1458
+ - Create: `lib/plutonium/ui/page/wizard.rb`, `lib/plutonium/ui/wizard/stepper.rb`, `lib/plutonium/ui/wizard/review.rb`, `lib/plutonium/ui/form/wizard_step.rb` (subclass of `UI::Form::Interaction`)
1459
+ - Test: `test/integration/.../wizard_rendering_test.rb`
1460
+ - Possibly: a small Stimulus controller for nav (mirror keystone's `wizard_nav_controller.js` intent — submit with `_direction`), registered per project convention.
1461
+
1462
+ **Acceptance Criteria:**
1463
+ - [ ] The current step renders its fields (inline + `using:`-imported, honoring inherited/inline `form_layout`) via a `UI::Form::Wizard` form posting to the step's POST route with `_direction`.
1464
+ - [ ] Stepper shows visible steps with completed/current/upcoming state; `:linear` allows clicking visited steps, disables upcoming; branch-hidden steps absent. `:free` allows any visited step.
1465
+ - [ ] Repeatable `structured_input` rows rehydrate from staged `data` on GET (not only on failed submit).
1466
+ - [ ] The `review` step renders an auto-summary of visible steps' `data` via display components + lists invalid/unvisited steps as jump links; Finish disabled until valid.
1467
+ - [ ] Resource-action wizards render in the modal/turbo-frame; standalone render full-page.
1468
+
1469
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/<portal>/wizard_rendering_test.rb` → PASS
1470
+
1471
+ **Steps:**
1472
+
1473
+ - [ ] **Step 1: Failing rendering test** — GET a step, assert the form fields + stepper markup; GET a wizard mid-flow with staged repeater data, assert N rows rendered; GET the review step, assert summary + outstanding links.
1474
+ - [ ] **Step 2: Run red** → FAIL.
1475
+ - [ ] **Step 3: Implement `UI::Form::Wizard`** subclassing `Plutonium::UI::Form::Interaction` — set `resource_fields` to the current step's field names, `resource_definition` to a per-step adapter exposing `defined_inputs`/`resolve_form_sections` from the step (inline + imported), `form_action` to the step's POST URL, and render a hidden `_direction` defaulting to `next`. Seed repeater values from `runner` staged data (override the value source so rows rehydrate on GET).
1476
+ - [ ] **Step 4: Implement `Page::Wizard`** — composes the stepper + the step form (or the review component for a review step) + nav buttons; chooses modal vs full-page from the surface (mirror `UI::Page::InteractiveAction` for modal).
1477
+ - [ ] **Step 5: Implement `Wizard::Stepper`** (Phlex) — renders visible path with state; clickable rules per `navigation`.
1478
+ - [ ] **Step 6: Implement `Wizard::Review`** (Phlex) — iterate visible non-review steps, render each field via the display pipeline (reuse `UI::Display`), and list `runner.first_incomplete_visible`-style gaps as links to each step's GET route; render Finish (disabled unless complete).
1479
+ - [ ] **Step 7: Stimulus nav** (if needed) — a controller that sets `_direction` and submits; register it.
1480
+ - [ ] **Step 8: Run green** → PASS.
1481
+ - [ ] **Step 9: Commit**
1482
+
1483
+ ```bash
1484
+ git add lib/plutonium/ui/page/wizard.rb lib/plutonium/ui/wizard lib/plutonium/ui/form/wizard_step.rb app/assets test/integration
1485
+ git commit -m "feat(wizard): UI — page, stepper, review auto-summary, step form + repeater rehydration"
1486
+ ```
1487
+
1488
+ ```json:metadata
1489
+ {"files": ["lib/plutonium/ui/page/wizard.rb", "lib/plutonium/ui/wizard/stepper.rb", "lib/plutonium/ui/wizard/review.rb", "lib/plutonium/ui/form/wizard_step.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration", "acceptanceCriteria": ["step renders fields + form_layout via wizard form", "stepper states + linear/free click rules", "repeater rows rehydrate on GET", "review auto-summary + outstanding jump links + gated finish", "modal vs full-page by surface"], "requiresUserVerification": false}
1490
+ ```
1491
+
1492
+ ---
1493
+
1494
+ ### Task 7: One-time wizards (gate + completion) and SweepJob
1495
+
1496
+ **Goal:** `one_time once_per: :user/:anchor` records a durable completion and an `ensure_wizard_completed` controller concern redirects un-completed users into the wizard and bounces completed ones. `SweepJob` reaps idle `in_progress`/`completing` rows (running cleanup) past `expires_at`.
1497
+
1498
+ **Files:**
1499
+ - Create: `lib/plutonium/wizard/gate.rb`, `lib/plutonium/wizard/sweep_job.rb`
1500
+ - Modify: controller finalize path to record one-time completion (already `complete`s the row; add the once-per assertion so a second run short-circuits).
1501
+ - Test: `test/plutonium/wizard/sweep_job_test.rb`, `test/integration/.../wizard_one_time_test.rb`
1502
+
1503
+ **Acceptance Criteria:**
1504
+ - [ ] A `one_time once_per: :user` wizard, once completed, is detected by `store.completed?(wizard:, owner:)`; `ensure_wizard_completed WizardClass` redirects to the wizard until done, then bounces to the original destination (PRG); completed users pass through.
1505
+ - [ ] `once_per: :anchor` keys completion on the anchor.
1506
+ - [ ] `SweepJob.perform_now` deletes idle `in_progress`/`completing` rows past `expires_at`, running each wizard's cleanup (`on_rollback`/destroy tracked records); never touches `completed`; skips null `expires_at`.
1507
+
1508
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/sweep_job_test.rb` → PASS
1509
+
1510
+ **Steps:**
1511
+
1512
+ - [ ] **Step 1: Failing tests** — sweep deletes expired in_progress + runs rollback on tracked records; leaves completed + null-expiry; gate concern redirects/bounces.
1513
+ - [ ] **Step 2: Run red** → FAIL.
1514
+ - [ ] **Step 3: SweepJob**
1515
+
1516
+ ```ruby
1517
+ # lib/plutonium/wizard/sweep_job.rb
1518
+ module Plutonium
1519
+ module Wizard
1520
+ class SweepJob < ActiveJob::Base
1521
+ def perform(now: Time.current)
1522
+ Session.sweepable(now).find_each do |row|
1523
+ wizard_class = row.wizard.safe_constantize
1524
+ Runner.new(wizard_class:, store: Store::ActiveRecord.new, instance_key: row.instance_key)
1525
+ .cancel if wizard_class # cancel runs cleanup + clears the row
1526
+ row.destroy! if Session.exists?(id: row.id)
1527
+ end
1528
+ end
1529
+ end
1530
+ end
1531
+ end
1532
+ ```
1533
+
1534
+ - [ ] **Step 4: Gate concern**
1535
+
1536
+ ```ruby
1537
+ # lib/plutonium/wizard/gate.rb
1538
+ module Plutonium
1539
+ module Wizard
1540
+ module Gate
1541
+ extend ActiveSupport::Concern
1542
+ class_methods do
1543
+ def ensure_wizard_completed(wizard_class, **opts)
1544
+ before_action(**opts) do
1545
+ store = Plutonium::Wizard::Store::ActiveRecord.new
1546
+ key = wizard_completion_key(wizard_class) # owner or anchor per once_per
1547
+ unless store.completed?(**key)
1548
+ session[:return_to] ||= request.fullpath
1549
+ redirect_to wizard_entry_path(wizard_class) and return
1550
+ end
1551
+ end
1552
+ end
1553
+ end
1554
+ end
1555
+ end
1556
+ end
1557
+ ```
1558
+
1559
+ Implement `wizard_completion_key` (owner: current_user for `:user`; anchor for `:anchor`) and `wizard_entry_path`. On finalize, after `complete`, redirect to `session.delete(:return_to)` if present.
1560
+
1561
+ - [ ] **Step 5: Run green** → PASS.
1562
+ - [ ] **Step 6: Commit**
1563
+
1564
+ ```bash
1565
+ git add lib/plutonium/wizard/gate.rb lib/plutonium/wizard/sweep_job.rb test/plutonium/wizard/sweep_job_test.rb test/integration
1566
+ git commit -m "feat(wizard): one-time gate + completion, SweepJob for abandoned cleanup"
1567
+ ```
1568
+
1569
+ ```json:metadata
1570
+ {"files": ["lib/plutonium/wizard/gate.rb", "lib/plutonium/wizard/sweep_job.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/sweep_job_test.rb", "acceptanceCriteria": ["one_time completion detected (user/anchor)", "ensure_wizard_completed redirect+bounce", "SweepJob reaps expired in_progress/completing with cleanup, keeps completed/null-expiry"], "requiresUserVerification": false}
1571
+ ```
1572
+
1573
+ ---
1574
+
1575
+ ### Task 8: Documentation and skill
1576
+
1577
+ **Goal:** A user guide, reference pages, and the `plutonium-wizard` AI skill, wired into nav and the umbrella skill map.
1578
+
1579
+ **Files:**
1580
+ - Create: `.claude/skills/plutonium-wizard/SKILL.md`, `docs/guides/wizards.md`, `docs/reference/wizard/{dsl,anchoring-resume,storage-config,registration-launch,one-time}.md`
1581
+ - Modify: VitePress nav config (`docs/.vitepress/config.*`), umbrella skill `.claude/skills/plutonium/SKILL.md` skill-map
1582
+ - Test: `yarn docs:build` (no broken links)
1583
+
1584
+ **Acceptance Criteria:**
1585
+ - [ ] Guide covers: a minimal `execute`-only wizard; branching with `condition:`; `using:` reuse; per-step `on_submit`/`persist`/`on_rollback`; one-time onboarding; registration (resource + standalone); config (`config.wizards.*`).
1586
+ - [ ] Skill mirrors other `plutonium-*` skills' frontmatter/structure and is added to the umbrella skill map.
1587
+ - [ ] `yarn docs:build` passes (no broken links).
1588
+
1589
+ **Verify:** `yarn docs:build` → success
1590
+
1591
+ **Steps:**
1592
+
1593
+ - [ ] **Step 1:** Write `docs/guides/wizards.md` (task-oriented, from spec §2–§9 examples).
1594
+ - [ ] **Step 2:** Write the `docs/reference/wizard/*.md` pages; add all to VitePress nav.
1595
+ - [ ] **Step 3:** Write `.claude/skills/plutonium-wizard/SKILL.md`; add to umbrella skill map.
1596
+ - [ ] **Step 4:** `yarn docs:build` → fix any broken links.
1597
+ - [ ] **Step 5: Commit**
1598
+
1599
+ ```bash
1600
+ git add .claude/skills/plutonium-wizard docs/guides/wizards.md docs/reference/wizard .claude/skills/plutonium/SKILL.md docs/.vitepress
1601
+ git commit -m "docs(wizard): guide, reference pages, plutonium-wizard skill"
1602
+ ```
1603
+
1604
+ ```json:metadata
1605
+ {"files": [".claude/skills/plutonium-wizard/SKILL.md", "docs/guides/wizards.md", "docs/reference/wizard/dsl.md"], "verifyCommand": "yarn docs:build", "acceptanceCriteria": ["guide covers core flows", "skill mirrors plutonium-* + added to umbrella map", "docs:build passes"], "requiresUserVerification": false}
1606
+ ```
1607
+
1608
+ ---
1609
+
1610
+ ## Self-Review notes
1611
+
1612
+ - **Spec coverage:** §2 (DSL) → Tasks 2–3, 6; §3 (anchoring) → Task 2,5; §4 (identity/resume) → Tasks 1,4,5; §5 (registration) → Task 5; §6 (runtime) → Task 4–5; §7 (UI) → Task 6; §8 (storage) → Task 1; §9 (one-time) → Task 7; §10 (migrations/config) → Task 0; §14 (testing) → every task; §15 (docs) → Task 8.
1613
+ - **Verification requirement scan:** the originating request requires no human-in-the-loop verification → **NO**; no `requiresUserVerification: true` tasks. (Confirmed in header.)
1614
+ - **Learnings from executed tasks (carry forward):**
1615
+ - **Plutonium is a Railtie, not an Engine** — the gem's Zeitwerk loader is rooted at `lib/`, so wizard classes live under `lib/plutonium/wizard/` (the `Session` AR model went to `lib/plutonium/wizard/session.rb`, NOT `app/models`). **Task 5 (controller) and Task 6 (UI components):** before placing files under `app/`, verify how existing Plutonium controllers/Phlex components are exposed to host apps (they may be base classes mixed into host controllers, or `app/` may be added to paths by the Railtie). Place wizard controller/components consistently with existing Plutonium controllers/UI — do not assume `app/` autoloads.
1616
+ - **Task 4 (runner):** the AR store's `find_or_initialize_by + save!` upsert has a TOCTOU window; the unique `instance_key` index is the backstop. The runner must **rescue `ActiveRecord::RecordNotUnique` on concurrent session creation** (re-read and proceed). Also: on cancel/sweep, run `on_rollback`/destroy of tracked records **before** `store.clear` (which is `delete_all`, no callbacks) — never rely on `dependent:`. Step validation must call `step.imported_validate_fn` (model-only now — `Model.new(slice).valid?`, returns `{attr => [msgs]}` filtered to imported + `:base`; **may be nil** when `validate: false` → nil-guard) and **merge** with inline `validates` errors. The earlier interaction/`view_context` validation concern is moot (interaction targets were dropped — `using:` is model-only). The corrected Outcome API: `succeed`→`Success.new(value)`, `failed`→`Failure.new` + ActiveModel errors. The `data` memo is invalidated on `data_attributes=` (fixed in Task 2).
1617
+ - Optional polish (non-blocking): make `Store#complete` return type consistent across adapters (document as void in `Base`).
1618
+ - **Open follow-up after Task 5:** per-resource **anchored member routes** (`/<resource>/:id/wizards/<name>/:step`) are NOT yet drawn in `register_resource` — only portal-level `register_wizard` routes exist. The `wizard` definition macro + controller support anchors, but the anchored-resource launch needs member-route nesting in `mapper_extensions.rb` + an anchored integration test. Fold into Task 6 or a small routing task. Also: Task 6 should route param extraction through the form's `extract_input` (Task 5 uses `params[:wizard].to_unsafe_h`, safe only because the typed `data` snapshot ignores undeclared keys) and flag for the final review: signed-cookie token handling, `authorize?` default-allow, and pre_submit turbo_stream.
1619
+ - **Known risk points flagged for the implementer (verify against real APIs before/while coding):** the `persist` macro binding inside `on_submit` (Task 4); `Outcome::Success.new`/`Failure.new` exact constructor + error access (Tasks 2,4); definition record-class accessor + `defined_inputs` (Task 3); `errors.group_by_attribute` (Tasks 3,4); `Action::RouteOptions`/`url_resolver` shape (Task 5); reusing `UI::Form::Interaction` internals for per-step forms (Task 6). These are integration seams — each has a test that will surface a mismatch immediately.