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,194 @@
1
+ # Anchoring & resume
2
+
3
+ How a wizard binds to an existing record (anchoring), how a running wizard is identified, and how a user resumes where they left off.
4
+
5
+ ## Anchoring
6
+
7
+ An **anchored** wizard runs against an existing record — the analogue of `attribute :resource` on an interaction. The anchor is read-only context, available from any step (and `condition:`/`on_submit`/`execute`) via the `anchor` accessor.
8
+
9
+ ```ruby
10
+ class ConfigureCompanyWizard < Plutonium::Wizard::Base
11
+ anchored with: Company # operate on a Company
12
+
13
+ step :branding, label: "Branding" do
14
+ attribute :logo, :string
15
+ input :logo
16
+ on_submit { anchor.update!(logo: data.branding.logo) } # mutate the anchor
17
+ end
18
+
19
+ def execute
20
+ anchor.update!(configured_at: Time.current)
21
+ succeed(anchor)
22
+ end
23
+ end
24
+ ```
25
+
26
+ ### Forms of `anchored`
27
+
28
+ | Declaration | Meaning |
29
+ |---|---|
30
+ | `anchored with: Company` | A single concrete type. |
31
+ | `anchored with: [Company, Organization]` | Polymorphic — accepts any listed type. |
32
+ | `anchored` (no `with:`) | Generic — the type binds at registration to whichever resource hosts it (shareable library wizard). |
33
+ | *(omit `anchored`)* | No anchor — a pure data → create flow. |
34
+
35
+ ```ruby
36
+ # A generic, shareable wizard — bound to a concrete type at registration.
37
+ class ArchiveWithReasonWizard < Plutonium::Wizard::Base
38
+ anchored
39
+
40
+ step :reason do
41
+ attribute :reason, :string
42
+ input :reason, as: :textarea
43
+ validates :reason, presence: true
44
+ end
45
+
46
+ def execute
47
+ anchor.update!(archived_at: Time.current, archive_reason: data.reason.reason)
48
+ succeed(anchor)
49
+ end
50
+ end
51
+ ```
52
+
53
+ ### `anchor` raises when absent
54
+
55
+ ```ruby
56
+ anchor # => the record, for an anchored wizard
57
+ # => raises Plutonium::Wizard::NotAnchoredError, for a non-anchored one
58
+ ```
59
+
60
+ `anchor` never returns `nil`. Anchored-vs-not is a static property of the wizard, so reaching for `anchor` when the wizard isn't `anchored` is a programming error, not a runtime condition to guard.
61
+
62
+ The anchor is **not** part of `persisted` — `persisted` holds only records the wizard creates. The anchor is an input the wizard was launched against.
63
+
64
+ ### Anchor resolution per surface
65
+
66
+ The wizard body never cares where the anchor came from; the launch surface resolves it:
67
+
68
+ - **Record action** (`wizard :configure, ...` on a definition for a `with:`-anchored wizard) — auto-mounted as a **member route** (`/companies/:id/wizards/configure/:step`) on the resource controller. The anchor is resolved through that controller's scoped, policy-gated `resource_record!` — never an unscoped `find_by`, so a record outside the portal's authorized scope (or a non-existent id) 404s instead of leaking another tenant's record.
69
+ - **Context anchor** (`anchored via: :method`) — mounted **portal-level** with `register_wizard`; the anchor is resolved by calling that method on the controller (e.g. `via: :current_scoped_entity` for the tenant). No URL `:id`, IDOR-safe (trusted context). An optional `with:` type-asserts the result.
70
+ - **Collection action / create flow** — no anchor.
71
+
72
+ ::: tip Anchored member routes are IDOR-safe by construction
73
+ Because the anchor comes from `resource_record!` (the same scoped lookup CRUD and interactive record actions use), a `with:`-anchored wizard can only ever operate on a record the current user is authorized to see in this portal. A `via:`-anchored wizard is IDOR-safe by trusting the resolved context.
74
+ :::
75
+
76
+ ## Instance identity
77
+
78
+ Every running wizard has a deterministic **instance key** — a digest the session row is uniquely keyed by. There are two recipes, by identity axis ([see Identity, concurrency & repeatability](/reference/wizard/dsl)):
79
+
80
+ ```
81
+ # concurrency_key set:
82
+ instance_key = SHA256(JSON([secret_key_base, "concurrency", wizard, serialized(concurrency_key)]))
83
+ # no concurrency_key:
84
+ instance_key = SHA256(JSON([secret_key_base, "tokened", wizard, wizard_token]))
85
+ ```
86
+
87
+ The digest hashes the **JSON of a structured array**, not a flat-joined string, and is **salted with the app's `secret_key_base`**:
88
+
89
+ - **`concurrency_key`** is serialized records → GID, scalars → string, arrays kept as a **nested structure (not joined)** — so two distinct keys can never collide into one row (`["a", "b"]` ≠ `"a|b"`). The **tenant (`current_scoped_entity`) is folded in automatically**, so the same user running the same keyed wizard in two tenant portals gets two distinct rows.
90
+ - **The `secret_key_base` salt** makes the digest a MAC over otherwise-public identifiers (the wizard name + key GIDs), so a run's existence/state can't be probed by recomputing the digest off-app.
91
+ - **`wizard_token`** is the **per-run id** for runs with no `concurrency_key` — a fresh, unguessable token per launch makes each run distinct and repeatable. Its source depends on the run identity: an **authenticated** repeatable run carries it in the URL `:token` segment (guarded by [owner-scoping](#authentication)); a **guest (`anonymous`)** run keys off the **Rails session** (never the URL — no leak surface). It is **not** a pre-auth principal that survives login, and a wizard never crosses the auth boundary mid-flow.
92
+
93
+ The owner, anchor, and scope are also stored as plain polymorphic columns (`owner_type`/`owner_id`, etc.) for listing and querying — but identity is the digest.
94
+
95
+ ::: warning Rotating `secret_key_base` invalidates in-progress runs
96
+ Because the salt is `secret_key_base`, rotating it changes every instance-key digest. In-progress runs become unresumable (their rows no longer match the recomputed key) and **one-time gates re-open** (the retained `completed` marker no longer matches). This only affects rows live at rotation time; new runs key off the new secret. Drain or accept the reset when rotating.
97
+ :::
98
+
99
+ ## Resume
100
+
101
+ A `concurrency_key`-keyed wizard's `in_progress` row **is the lock**: a second launch at the same key resumes it instead of forking. Look up the row by `instance_key`; if one exists, the user continues where they left off. A tokened (no `concurrency_key`) wizard resumes via its per-run id (carried in the URL `:token` segment for an authenticated run, or the Rails session for a guest run) and starts a fresh run otherwise.
102
+
103
+ For a non-`anonymous` (authenticated) wizard, **every resume is owner-scoped**: a row may only be resumed by the user that owns it. A run id leaked in a URL can't be picked up by another logged-in user — the engine treats a foreign row as not-found (404). See [Authentication](#authentication).
104
+
105
+ ### Listing in-progress wizards
106
+
107
+ ```ruby
108
+ Plutonium::Wizard.in_progress_for(view_context, anchor: nil, wizard: nil)
109
+ # → Array<Resume::Entry>, newest-first (delegates to Resume.entries_for)
110
+ ```
111
+
112
+ Like interactions, it takes the `view_context` and derives everything from it — the run **owner** (`current_user`), the **tenant scope** (`current_scoped_entity` when `scoped_to_entity?`, else `nil`), and the **portal** — returning that owner's in-progress runs **for the current portal**.
113
+
114
+ **Each `entry` exposes:** `label`, `icon`, `current_step` (+ `current_step_label`), `updated_at`, `resume_url` (or `nil`), `resume_unresolved_reason`, `wizard_class`, and the raw `session` row.
115
+
116
+ **Portal scoping.** A run is only ever listed (and linked) by the portal it was launched in — a non-scoped portal lists only unscoped runs; a scoped portal narrows to the current tenant. Two portals can share an entity scope, so the launching portal (the `engine` column) is recorded per-run because scope alone can't identify it.
117
+
118
+ **`resume_url`** is built through the current portal's routes:
119
+ - `wizard`-macro **anchored** mount → `resource_url_for(record, wizard:, step:)`.
120
+ - `register_wizard` mount → the named route (with the scope segment, and the `:token` for tokened runs).
121
+ - Unresolvable here (e.g. a non-anchored `wizard`-macro run, whose resource identity isn't on the row) → `resume_url: nil` + a `resume_unresolved_reason` string. Render those without a link rather than guessing.
122
+
123
+ **`anchor:` / `wizard:` filters** (for a per-record resume widget — "does this record have an unfinished draft of wizard X?"):
124
+ - They narrow **in the query, before enrichment**, so discarded rows are never resume-URL-resolved or anchor-loaded — cheaper than filtering the returned array.
125
+ - They compose, and the `wizard + anchor` pair is index-covered: `in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: record).first`. `wizard:` takes the wizard **class**.
126
+ - For ad-hoc post-filtering the array still works (`select { |e| e.wizard_class == X }` is free — the class is on each entry). Avoid filtering on `e.session.anchor` (a polymorphic load per row) — use `anchor:`.
127
+
128
+ See the [guide](/guides/wizards#listing-in-progress-wizards) for a worked dashboard example.
129
+
130
+ ### The implied anchored key
131
+
132
+ An `anchored` wizard with **no explicit `concurrency_key`** is keyed by default — `{ [anchor, current_user] }`, with the tenant folded in. So an anchored wizard, out of the box, is **one in-progress draft per user per record**: re-launching it for the same record resumes your draft, and two users editing the same record get **independent** runs (no collision).
133
+
134
+ This is the right default because anchoring without keying is a footgun in both directions: a *tokened* anchored wizard forks a new run on every launch, while `{ anchor }` (record-only) keys across users — so a second user editing the same record collides with the first and is owner-scoped out (a 404). The implied key threads the needle. The anchor's GlobalID is already globally unique (and pins the tenant for a tenant-scoped record), so `[anchor, current_user]` is the full identity; the auto-folded tenant is redundant there but load-bearing for non-anchor keys.
135
+
136
+ Override when you want different semantics:
137
+ - `concurrency_key { anchor }` — a true singleton: **one run per record, any user** ("configure this once, by anyone"). A concurrent second user is blocked (owner-scoped) until the first finishes.
138
+ - `concurrency_key { wizard_token }` — make the anchored wizard **repeatable** (a fresh run per launch).
139
+
140
+ Anonymous (guest) anchored wizards are exempt — a guest has no real user to key by, so they stay session-tokened.
141
+
142
+ ### Relaunching a tokened wizard
143
+
144
+ A keyed wizard auto-resumes (its keyed row is the lock), so a bare launch always continues the single in-progress run. A **tokened** wizard has no such single run — each bare launch could mint a fresh one. By default it doesn't silently fork: it prompts. Opt out for flows that should always start clean:
145
+
146
+ ```ruby
147
+ on_relaunch :new
148
+ ```
149
+
150
+ With the default (`on_relaunch :prompt`), a bare launch (e.g. `GET /onboarding`) checks the user's pending runs (owner- and tenant-scoped, via the same listing as above). If any exist, it renders a **"resume or start new" page** (each pending run with a Resume link, plus a **Start new** button) instead of silently discarding that in-progress work. With no pending runs it starts fresh as usual, and **Start new** (the bare launch URL with `?new=1`) always forces a fresh run. Because the chooser only appears when a pending run exists, `:prompt` is a safe superset of `:new`. Use `on_relaunch :new` to opt out (always fork a fresh run) for flows meant to be run repeatedly from scratch.
151
+
152
+ This only applies to authenticated tokened wizards: keyed wizards already auto-resume, and `anonymous` (guest) runs are session-keyed to a single run — `on_relaunch` is a no-op for both.
153
+
154
+ On resume the engine:
155
+
156
+ - Restores the step cursor and `data` (typed snapshot rehydrated from the JSON column).
157
+ - Re-renders the current step's form seeded from staged `data` — including repeater rows (a `structured_input ..., repeat:` step re-renders the right number of filled rows, not one blank row).
158
+ - Lazily rehydrates `persisted[:key]` from stored GlobalIDs on first access (memoized per request), so a per-step `on_submit` create flow returning later still sees records made by earlier steps — without paying a `GlobalID.locate` on requests that never read `persisted`.
159
+
160
+ Navigation never loses data: **Back** moves the cursor without validating and never discards `data`. A step whose answer is later **un-chosen** (its `condition:` flips false) leaves the visible path and is **fully pruned**: its staged `data` is dropped, and if its `on_submit` had **persisted records** (save-as-you-go) those records are **rolled back**: its `on_rollback` runs first if declared (additive side-effect cleanup), then the engine **always** destroys them, so nothing is orphaned. The step's `persisted` / `data` / `visited` state is cleared, so re-entering that branch re-runs its `on_submit` from scratch. Pruning fires as soon as the branch is hidden (during the advance that flips it) and again as a safety net at finalize.
161
+
162
+ ## Authentication
163
+
164
+ **Wizards require authentication by default.** Entry without a `current_user` is rejected (redirect to login / 401). A wizard never crosses the auth boundary mid-flow.
165
+
166
+ - **Default (authenticated).** `current_user` is required throughout. Every session lookup/resume is **owner-scoped** (`where(owner: current_user)` + an owner check), so a run id leaked in a URL can't be resumed by another logged-in user. `current_user` comes from the host: a portal mount inherits the portal's auth concern, while an **authenticated main-app** mount requires an app-defined `::WizardsController` carrying the auth concern (see [the override hook](/reference/wizard/registration-launch#hosting-the-controller-override-hook)).
167
+ - **`anonymous` (guest).** Opt in with the `anonymous` macro. The wizard may run with no `current_user`; its identity is the server-minted, unguessable `wizard_token` held in the **Rails session** (`session["plutonium_wizards"][<wizard_key>]`): **not a cookie, no TTL** (the row's `cleanup_after` → sweep is the authoritative lifetime; the session id is just a pointer). Session storage gives browser-close ephemerality, **auto-clear on login/logout** (Rodauth's `clear_session` → `reset_session`), and clearing on completion; the id never appears in a URL. It guards only the user's own in-progress data. Its terminal `execute` **may** authenticate (e.g. a signup flow that creates the account and logs in); that login goes through Rodauth, which rotates the Rails session. There is **no** mid-flow owner-stamping, token-survives-login, or instance_key rekey.
168
+
169
+ ```ruby
170
+ class GuestSignupWizard < Plutonium::Wizard::Base
171
+ anonymous # may run pre-login
172
+
173
+ step :account do
174
+ attribute :email, :string
175
+ input :email, as: :email
176
+ validates :email, presence: true
177
+ end
178
+ review label: "Review"
179
+
180
+ def execute # the ONE boundary a guest wizard may cross
181
+ account = Account.create!(email: data.account.email)
182
+ # sign the account in here (the host calls Rodauth) — no special framework handling
183
+ succeed(account)
184
+ end
185
+ end
186
+ ```
187
+
188
+ An `anonymous` wizard must be [mounted on a public route](/reference/wizard/registration-launch#public-mount-for-anonymous-wizards).
189
+
190
+ ## Related
191
+
192
+ - [DSL reference](/reference/wizard/dsl) — `anchored`, `anchor`, `persisted`.
193
+ - [Storage & config](/reference/wizard/storage-config) — the session table + columns.
194
+ - [One-time wizards](/reference/wizard/one-time) — durable completion + the gate.
@@ -0,0 +1,332 @@
1
+ # Wizard DSL
2
+
3
+ ::: warning Experimental
4
+ Wizards are experimental — the DSL and behavior may change in a future release.
5
+ :::
6
+
7
+ A wizard is a Ruby class — `class X < Plutonium::Wizard::Base`. It declares ordered `step`s, an optional terminal `review` step, wizard-level options, and an `execute` commit hook. This page is the full reference for the author-facing DSL.
8
+
9
+ For task-oriented walkthroughs, start with the [Wizards guide](/guides/wizards).
10
+
11
+ ## 🚨 Critical
12
+
13
+ - **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a raised exception — a non-bang `false` return advances the wizard and silently loses data.
14
+ - **`condition:` lambdas must be nil-safe.** They run against the typed `data` snapshot at every transition, including before their deciding step is filled (`nil`).
15
+ - **`review` must be the last step.** Declaring a step after `review` raises at load time.
16
+ - **`using:` targets a model only** — not an interaction, not a bare definition.
17
+ - **`execute` returns an Outcome** — `succeed(...)` / `failed(...)`, or raise to fail.
18
+
19
+ ## Wizard-level macros
20
+
21
+ | Macro | Meaning |
22
+ |---|---|
23
+ | `presents label:, icon:, description:` | The launch button's label + icon (same as interactions), plus an optional `description:` rendered as the wizard's header subheading. |
24
+ | `navigation :linear \| :free` | Stepper jump policy. `:linear` (default) — back to any visited step; `:free` — any visible visited step. Forward jumps to unvisited steps are never allowed. |
25
+ | `stepper false` | Hide the top rail (the step indicator). On by default. |
26
+ | `on_relaunch :new` | Controls a bare relaunch of a **tokened** wizard when the user has pending (in-progress) runs. Default `:prompt` shows a "resume or start new" chooser instead of silently forking; `:new` opts out and always mints a fresh run. No-op for keyed/`anonymous` wizards (they already auto-resume their single run). See [Anchoring & resume](/reference/wizard/anchoring-resume#relaunching-a-tokened-wizard). |
27
+ | `anchored with: Model` / `anchored via: :method` | Run against an existing record; read via `anchor`. `with:` resolves from the URL `:id` (resource-mounted); `via:` resolves by calling a controller method (portal-level, context-anchored). See [Anchoring & resume](/reference/wizard/anchoring-resume). |
28
+ | `cleanup_after <ttl> \| :never` | Idle TTL before the abandonment sweep reaps a session and rolls back its tracked records. Defaults to `config.wizards.cleanup_after`. `:never` opts out. |
29
+ | `concurrency_key { … }` / `concurrency_key :method` | Key a run by the returned value(s) (records → GID, scalars → string, arrays serialized element-wise — structured, **not** flat-joined; the tenant is folded in automatically). The keyed `in_progress` row is the lock — a second launch at the same key resumes, never forks. Omit → unlimited concurrent `wizard_token`-keyed runs — **except** an `anchored` wizard, which defaults to `{ [anchor, current_user] }` (one draft per user per record). See [Anchoring & resume](/reference/wizard/anchoring-resume#the-implied-anchored-key). |
30
+ | `one_time` | Retain the completed row at the `concurrency_key` (blocks restart, gate-able). **Requires a `concurrency_key`.** Omit → row deleted on completion (repeatable). See [One-time wizards](/reference/wizard/one-time). |
31
+ | `completed do \|wizard\| … end` | Custom body for the "already completed" page a finished **one-time** wizard shows when re-opened (replaces the default confirmation). See [`completed`](#completed) below and [One-time wizards](/reference/wizard/one-time#re-opening-a-completed-wizard). |
32
+ | `encrypt_data` | Encrypt the staged `data` column at rest using ActiveRecord's encryption keys (off by default), for flows that stage PII. Requires `active_record.encryption` keys — see [Storage & config](/reference/wizard/storage-config#encryption). |
33
+ | `anonymous` | Opt into **guest (unauthenticated) access** — the wizard runs pre-login (auth is required otherwise). The guest's identity is a server-minted run-id in the Rails session; it crosses the auth boundary only at its terminal `execute`. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`** — a guest is already session-keyed and repeatable, so declaring both raises (whichever is declared last). See [Authentication](/reference/wizard/anchoring-resume#authentication). |
34
+
35
+ ```ruby
36
+ class CompanyOnboardingWizard < Plutonium::Wizard::Base
37
+ presents label: "Onboard a company", icon: Phlex::TablerIcons::BuildingSkyscraper
38
+ navigation :linear
39
+ # ... steps ...
40
+ end
41
+ ```
42
+
43
+ ## `step`
44
+
45
+ ```ruby
46
+ step(key, label: nil, description: nil, condition: nil, using: nil, **using_opts, &block)
47
+ ```
48
+
49
+ A `step` is one screen. The block declares its fields with the existing field DSL (`attribute`/`input`/`validates`/`structured_input`/`form_layout`) and may attach the per-step hooks `on_submit`/`on_rollback`.
50
+
51
+ ```ruby
52
+ step :company, label: "Company details", condition: -> { data.plan.kind == "business" } do
53
+ attribute :name, :string
54
+ attribute :subdomain, :string
55
+ input :name
56
+ input :subdomain
57
+ validates :name, :subdomain, presence: true
58
+
59
+ form_layout do
60
+ section :identity, :name, :subdomain, label: "Identity", columns: 2
61
+ end
62
+ end
63
+ ```
64
+
65
+ | Option | Meaning |
66
+ |---|---|
67
+ | `label:` | The step's display label (stepper + heading). Defaults to `key.to_s.humanize`. |
68
+ | `description:` | Optional sub-label rendered under the step heading. |
69
+ | `condition:` | Lambda over `data` (and `anchor`) gating inclusion. Subtractive branching. Must be nil-safe. |
70
+ | `using:` | Import a field surface from a **model**. See below. |
71
+
72
+ The block is optional only when `using:` supplies everything.
73
+
74
+ ### Fields inside a step
75
+
76
+ A step's block is the same field DSL used on definitions and interactions:
77
+
78
+ - `attribute :name, :type` — declares a typed attribute (feeds the `data` snapshot).
79
+ - `input :name, as:, ...` — how the field renders.
80
+ - `validates :name, ...` — ActiveModel validations, run on Next. These also drive the form's field affordances exactly like a resource form: a `presence` validation renders the required marker (`*`), and `length`/`numericality`/`format`/`inclusion` feed `maxlength`/`min`/`max`/`pattern`/auto-choices. Validations imported via `using:` surface these too.
81
+ - `structured_input :name, repeat: N do |f| ... end` — a repeatable/structured group → `data.<step>.name` is an array of typed sub-objects. The sub-fields can come from the block (above), or from a model via `using:` / `fields:` (same selectors as a step's `using:`) instead of a block.
82
+ - `form_layout do ... end` — section the step's fields (`section`, `columns:`, `collapsible:`, etc.), scoped to this step.
83
+
84
+ See [plutonium-resource › Definition](/reference/resource/definition) for the full field/input/layout vocabulary.
85
+
86
+ ### `using:` a model
87
+
88
+ `using:` imports field declarations from an ActiveRecord model so a step needn't re-declare them. It is a **step option**, not a block method (avoids Ruby's `Module#using` refinements clash), and it targets a **model only**.
89
+
90
+ ```ruby
91
+ step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
92
+
93
+ step :details, label: "Details", using: Company, only: %i[tagline] do
94
+ attribute :referral_code, :string # plus a wizard-local field
95
+ input :referral_code
96
+ end
97
+ ```
98
+
99
+ What gets imported:
100
+
101
+ | Source | Imported |
102
+ |---|---|
103
+ | `Model.attribute_names` / `attribute_types` | The field universe + cast types. |
104
+ | `<Model>Definition` (auto-resolved) | Input styling (`as:`, options, labels). Best-effort — no definition is fine. |
105
+ | Transient `Model.new(slice).valid?` | Validations, keeping errors on imported fields + `:base`. |
106
+ | `<Model>Definition#form_layout` | Section layout, filtered to imported fields. |
107
+
108
+ | Selector / flag | Effect |
109
+ |---|---|
110
+ | `fields:` (alias `only:`) | Import only these attributes. |
111
+ | `except:` | Import everything except these. |
112
+ | `validate: false` | Skip validation reuse (write your own inline `validates`). |
113
+ | `layout: false` | Skip inherited `form_layout` (default single grid). |
114
+ | `validation_context:` | Run `valid?(context)` for context-scoped model validations. |
115
+
116
+ **Declaration reuse only** — `using:` never pulls in the model's persistence or callbacks. Data stages into `data`; your `execute`/`on_submit` does the writes.
117
+
118
+ ::: tip Why a model, not a definition
119
+ A `Plutonium::Resource::Definition` carries no link to its model — the controller binds them at request time. The only reliable direction is **model → definition**, so the model is the reuse target, and `<Model>Definition` is auto-resolved from it for styling.
120
+ :::
121
+
122
+ ### Attachment fields
123
+
124
+ A file field is a **`:string`** attribute (it holds the upload **token**, not the bytes) plus a file input (`as: :file`, `:uppy`, or `:attachment`):
125
+
126
+ ```ruby
127
+ step :photo, label: "Photo" do
128
+ attribute :photo, :string
129
+ input :photo, as: :file # server-side staged (default)
130
+ # input :photo, as: :uppy, direct_upload: true, endpoint: "/upload" # direct upload
131
+ end
132
+ ```
133
+
134
+ `data` is JSON staged across requests, so a file can't ride along — only its backend **token** (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data). `execute` assigns the token to the model's attachment natively (`model.photo.attach(data.photo.photo)` for AS, `model.update!(photo: data.photo.photo)` for active_shrine). The review summary and the input preview (on Back/resume) resolve the token to a displayable attachment automatically.
135
+
136
+ | | Declare | Behaviour |
137
+ |---|---|---|
138
+ | **Server-side** (default) | `as: :file` | file submitted with the step; the wizard uploads it to the backend cache while staging. AS *and* active_shrine. |
139
+ | **Direct upload** | `as: :uppy, direct_upload: true, endpoint:` | browser uploads to the endpoint, posts a token (async UI). |
140
+ | **Backend** (server-side) | `backend: :active_storage` / `:shrine` | defaults to `config.wizards.attachment_backend` (auto-detects active_shrine, else AS). **Must match the model** `execute` assigns to. |
141
+ | **Uploader** (Shrine only) | `uploader: PhotoUploader` | cache the file through a specific Shrine uploader (its cache-stage plugins — mime/dimension extraction, `generate_location`, processing — run instead of base `Shrine`'s). The minted token stays uploader-agnostic, so display + promotion are unaffected. Accepts a class or a class-name string; raises for the AS backend. Server-side staging only (direct upload configures the uploader at its endpoint). Its **validations are enforced on the step** — see the note below. |
142
+ | **Multiple** | array attribute + `multiple: true` | staged value is an array of tokens. |
143
+
144
+ ::: tip Uploader validations are enforced on the step
145
+ A Shrine file field is validated **on its step** against the field's **effective uploader** — its `uploader:` if given, else base `Shrine` (whichever carries the `Attacher.validate` rules). A file that violates them is rejected right there (a field error + re-render), exactly like a `validates` — *not* deferred to `execute`. Mechanically: `Uploader.upload` caches the file (running no validations), then the step's validation pass runs the attacher's validations against the staged token. This needs Shrine's optional `validation`/`validation_helpers` plugin — without it there's nothing to enforce and it's a clean no-op. ActiveStorage fields are likewise unaffected.
146
+ :::
147
+
148
+ ## Per-step hooks
149
+
150
+ `execute` is the default commit point (atomic, at the end). Per-step `on_submit` is opt-in save-as-you-go — use it only when a real record must exist mid-flow.
151
+
152
+ ### `on_submit`
153
+
154
+ Runs in its own transaction when the step completes (after its fields validate). Inside it:
155
+
156
+ - `persist record` (or a list) — register record(s) the engine tracks for resume + cleanup → `persisted[:step_key]`.
157
+ - `fail!("message")` — abort with a base (form-level) error.
158
+ - `fail!(:field, "message")` — abort with a field-level error.
159
+
160
+ ```ruby
161
+ on_submit do
162
+ charge = PaymentApi.authorize!(anchor, data.billing.card_token)
163
+ fail!("Card was declined") unless charge.ok?
164
+ persist Billing.create!(company: anchor, token: data.billing.card_token, charge_id: charge.id)
165
+ end
166
+ ```
167
+
168
+ The wizard never advances past a failed `on_submit`. Earlier committed steps are untouched (undo them via Cancel → cleanup).
169
+
170
+ ### `on_rollback`
171
+
172
+ Rollback happens on Cancel, abandonment-sweep, **or when this step becomes branch-hidden** (a later answer flips its `condition:` false, so save-as-you-go records it created would otherwise be orphaned). On any of these, the engine **always** destroys every `persist`'d record in reverse step order via `destroy!` (which respects a model's own soft-delete/paranoia override). When a step is pruned this way its `data` / `persisted` / `visited` state is also cleared, so re-entering that branch re-runs `on_submit` from scratch.
173
+
174
+ `on_rollback` is an **optional, ADDITIONAL** compensating block for side effects the engine can't see: refunding a charge, calling an external API, deleting something `persist` didn't track. It reads `persisted[...]` and runs **before** the engine's destroy (records still alive), **in addition to** it, never instead of it. Don't destroy the `persist`'d record yourself in the block; the engine does that.
175
+
176
+ ```ruby
177
+ # The engine destroys persisted[:billing] for you; this just refunds the charge.
178
+ on_rollback { PaymentApi.refund!(persisted[:billing].charge_id) }
179
+ ```
180
+
181
+ Supply an `on_rollback` when abandonment must do more than drop the record(s) (refund a charge, call an external API), or when `on_submit` registered no record at all (side-effect-only steps, whose `on_rollback` still runs). To *keep* a partial record rather than destroy it, make the model itself soft-delete (so its `destroy!` detaches) or use `cleanup_after :never`.
182
+
183
+ ## `review`
184
+
185
+ ```ruby
186
+ review(label: "Review", description: nil, condition: nil, summary: true, header: true, &block)
187
+ ```
188
+
189
+ The built-in **terminal** step. Must be last. It lists outstanding (invalid/unvisited) steps as jump links and gates Finish until all visible steps are valid. It declares no fields of its own.
190
+
191
+ What it renders depends on completion state and the `summary:` / block options:
192
+
193
+ | State | Body |
194
+ |---|---|
195
+ | **Incomplete** (a visible step is invalid/unvisited) | The outstanding "fix this" links **+** the auto-summary of what's entered so far (the review-and-fix view). |
196
+ | **Complete**, `summary: true` (default) | The auto-summary of every visible step; the custom block, if any, renders **below** it. |
197
+ | **Complete**, `summary: false`, with a block | The custom block **replaces** the summary (author owns the body). |
198
+ | **Complete**, `summary: false`, no block | A built-in "ready to complete" confirmation panel. |
199
+
200
+ | Option | Meaning |
201
+ |---|---|
202
+ | `label:` | The review step's label (default `"Review"`). |
203
+ | `description:` | Optional sub-label under the review heading. |
204
+ | `summary:` | Show the auto-summary of completed steps (default `true`). When `false`, the complete-state body is your block — or the built-in "ready to complete" panel if there's no block. The summary always renders in the incomplete state. |
205
+ | `header:` | Show the step-header section (the label plus the "check everything over" prompt, which only appears when the summary is shown) above the body (default `true`). `false` drops it for a chromeless finish. |
206
+
207
+ ```ruby
208
+ review label: "Review & submit" # auto-summary + gated finish
209
+
210
+ review label: "Review & submit" do |wizard| # custom content BELOW the summary
211
+ "By submitting you agree to the #{wizard.data.plan.plan} plan terms."
212
+ end
213
+
214
+ review summary: false, header: false # fully chromeless → "ready to complete" panel
215
+ ```
216
+
217
+ ::: tip
218
+ `stepper false` (a wizard-level macro) + `review summary: false, header: false` + no block gives a fully chromeless flow (no rail, no header, no summary), ending on the built-in "ready to complete" panel.
219
+ :::
220
+
221
+ ### The custom block's render context
222
+
223
+ The block runs **in the Phlex view context** (`self` is the rendering component), not the controller — that's what lets it emit markup. So you can:
224
+
225
+ - **return a String** (the simplest case) — it renders as the block's text;
226
+ - **emit Phlex** directly — `div`, `span`, `plain`, `render SomeComponent.new(...)`;
227
+ - reach **view / route helpers** via `helpers.*` (e.g. `helpers.link_to`, `helpers.current_user`, a path helper).
228
+
229
+ The block is **yielded the wizard**, so `wizard.data`, `wizard.anchor`, `wizard.persisted`, and `wizard.current_user` are all in hand.
230
+
231
+ ```ruby
232
+ review label: "Review & submit" do |wizard|
233
+ div(class: "text-sm") do
234
+ plain "Billing to "
235
+ strong { wizard.data.company.name }
236
+ plain " — "
237
+ plain helpers.link_to("see our terms", helpers.terms_path)
238
+ end
239
+ end
240
+ ```
241
+
242
+ Don't mix styles in one block: Phlex emits a returned String *in addition to* anything you wrote with `div`/`render`, so returning a String after emitting markup double-renders it. Pick one.
243
+
244
+ ## `completed`
245
+
246
+ ```ruby
247
+ completed do |wizard|
248
+ # …Phlex body…
249
+ end
250
+ ```
251
+
252
+ A custom body for the **"already completed" page** — what a finished [one-time wizard](/reference/wizard/one-time#re-opening-a-completed-wizard) shows when a user re-opens it. On completion a one-time wizard retains its row but clears the `data`, so there's nothing to review; re-entry renders this standalone page instead of re-running the flow. Only meaningful for one-time wizards (repeatable ones leave no completed row, so re-launching just starts fresh).
253
+
254
+ Without `completed`, a built-in confirmation renders (a success badge, the wizard's label, a short message, and a Continue button out). The block **replaces that body entirely** — you supply your own content (and your own way out):
255
+
256
+ ```ruby
257
+ class WelcomeWizard < Plutonium::Wizard::Base
258
+ concurrency_key { current_user }
259
+ one_time
260
+ # …steps…
261
+
262
+ completed do |wizard|
263
+ h1 { "You're all set up!" }
264
+ a(href: "/dashboard") { "Go to your dashboard" }
265
+ end
266
+ end
267
+ ```
268
+
269
+ The block runs in the **same Phlex view context** as the [review block](#the-custom-block-s-render-context) (`self` is the component; reach helpers via `helpers.*`) and is yielded the `wizard`. The same don't-mix-styles caveat applies.
270
+
271
+ ## `execute`
272
+
273
+ The at-end commit hook, run once after the last visible step, in one transaction.
274
+
275
+ ```ruby
276
+ def execute
277
+ company = Company.create!(name: data.company.name, subdomain: data.company.subdomain)
278
+ succeed(company).with_message("You're all set!")
279
+ end
280
+ ```
281
+
282
+ - Returns a `succeed(value)` / `failed(errors)` Outcome (the same Outcome interactions use — `.with_message`, `.with_redirect_response`, etc. all work).
283
+ - **Use bang methods** so a failure raises. The engine catches `ActiveRecord::RecordInvalid` (→ field errors) and `Plutonium::Wizard::StepError` (→ base error via `fail!`); any other error re-raises as a 500.
284
+ - On success the wizard marks the session completed, clears `data`/`persisted`, and redirects (PRG) so a back-button replay can't re-run `execute`.
285
+
286
+ ## Entry authorization — `authorize?`
287
+
288
+ A portal-level (standalone) wizard has no resource policy, so gate entry by defining an `authorize?` instance method. The controller checks it before each request; a falsy return → `ActionPolicy::Unauthorized` (403).
289
+
290
+ ```ruby
291
+ def authorize?
292
+ current_user.present? && !current_user.onboarded?
293
+ end
294
+ ```
295
+
296
+ ::: warning As-built: `authorize?` is an instance method
297
+ Define `def authorize?` on the wizard. (Resource-attached wizards instead use their action's policy predicate — see [Registration & launch](/reference/wizard/registration-launch).)
298
+ :::
299
+
300
+ ## Accessors
301
+
302
+ Available inside steps, `condition:`, `on_submit`, `on_rollback`, and `execute`:
303
+
304
+ | Accessor | Returns |
305
+ |---|---|
306
+ | `data` | Typed, dot-accessible snapshot of everything entered so far, **step-keyed** — read a field through its owning step: `data.<step>.<field>`. Read-only; not-yet-collected fields read as `nil` or their `default:`. Each step has its own sub-object, so two steps may declare the same field name without colliding. |
307
+ | `data.<step>.<field>` | The cast value (real Boolean/Integer/Date, not raw string). `data.<step>.<structured>` → array of typed sub-objects. An unknown step key reads as `nil`. |
308
+ | `anchor` | The record the wizard was launched against. Raises `NotAnchoredError` if the wizard isn't `anchored`. |
309
+ | `persisted[:step_key]` | Record(s) a per-step `on_submit` registered via `persist`. Lazily rehydrated on first access (located from stored GlobalIDs the first time you read the key, memoized thereafter). |
310
+
311
+ ## Outcome helpers
312
+
313
+ | Helper | Use |
314
+ |---|---|
315
+ | `succeed(value = nil)` | Success outcome (alias `success`). Chain `.with_message(...)`, `.with_redirect_response(...)`. |
316
+ | `failed(errors = nil, attribute = :base)` | Failure outcome. Accepts a string, an array, a hash (`{field => msg}`), or an errors object. |
317
+ | `fail!("message")` / `fail!(:field, "message")` | Raise a `StepError` from `on_submit`/`execute` (sugar over `raise`). |
318
+
319
+ ## Errors
320
+
321
+ | Error | Raised when |
322
+ |---|---|
323
+ | `Plutonium::Wizard::NotAnchoredError` | `anchor` called on a non-anchored wizard (also raised when a `via:` anchor resolves to `nil` or the wrong type). |
324
+ | `Plutonium::Wizard::StepError` | Raised by `fail!` (or directly) for a custom, non-AR step failure → maps to a form error. |
325
+ | `Plutonium::Wizard::UnknownWizardError` | A mount's `wizard_class` doesn't resolve to a loaded `Plutonium::Wizard::Base` subclass — a misconfigured mount or a tampered route param. |
326
+
327
+ ## Related
328
+
329
+ - [Anchoring & resume](/reference/wizard/anchoring-resume)
330
+ - [Storage & config](/reference/wizard/storage-config)
331
+ - [Registration & launch](/reference/wizard/registration-launch)
332
+ - [One-time wizards](/reference/wizard/one-time)
@@ -0,0 +1,33 @@
1
+ # Wizard Reference
2
+
3
+ The wizard subsystem builds **multi-step flows** — onboarding, checkout, multi-model create, branching questionnaires — as a single declarative class (`< Plutonium::Wizard::Base`). It orchestrates Plutonium's existing field DSL, form rendering, actions, and policies rather than inventing a parallel stack.
4
+
5
+ For a task-oriented walkthrough, start with the [Wizards guide](/guides/wizards).
6
+
7
+ ## In this section
8
+
9
+ - **[DSL](./dsl)** — every author-facing macro and accessor: `step`, `review`, `using:`, `condition:`, per-step `on_submit`/`persist`/`on_rollback`, `execute`, `data`/`anchor`/`persisted`.
10
+ - **[Anchoring & resume](./anchoring-resume)** — running against an existing record (`anchored` / `anchor`), instance identity, and how a user resumes where they left off.
11
+ - **[Storage & config](./storage-config)** — enabling the subsystem, the `plutonium_wizard_sessions` table, `config.wizards.*`, encryption, and the cleanup `SweepJob`.
12
+ - **[Registration & launch](./registration-launch)** — reaching a user: the `wizard` definition macro and portal-level `register_wizard`.
13
+ - **[One-time wizards](./one-time)** — `concurrency_key` + `one_time` durable completion markers and the `ensure_wizard_completed` gate.
14
+
15
+ ## At a glance
16
+
17
+ | Concept | Macro / accessor |
18
+ |---|---|
19
+ | Launch chrome | `presents label:, icon:, description:` |
20
+ | A screen | `step :key, label:, condition:, using: do ... end` |
21
+ | Branching | `condition: -> { data.<step>.<field> ... }` (subtractive, nil-safe) |
22
+ | Field reuse | `using: Model, fields:/only:/except:` (model only) |
23
+ | Terminal recap | `review label:` |
24
+ | Per-step write | `on_submit { persist record; fail!(...) }` + `on_rollback` |
25
+ | Commit | `def execute` → `succeed(...)` / `failed(...)` (use bang methods) |
26
+ | Existing record | `anchored with: Model` / `anchored via: :method` → `anchor` |
27
+ | Concurrency / resume | `concurrency_key { … }` (keyed row is the lock; tenant folded in) |
28
+ | Run once | `concurrency_key { … }` + `one_time` + `ensure_wizard_completed` |
29
+ | Cleanup TTL | `cleanup_after <ttl> \| :never` (+ `SweepJob`) |
30
+
31
+ ## Prerequisite
32
+
33
+ Wizards are opt-in. Set `config.wizards.enabled = true` and run `rails db:migrate`. See [Storage & config](./storage-config).