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
@@ -12,6 +12,44 @@ description: Use BEFORE writing tests for a Plutonium resource, running pu:test:
12
12
  - **One file per (resource × portal).** Same model in admin and org portals = two test files. Each portal has different auth, scoping, and allowed actions.
13
13
  - **Stub methods are required.** Concerns ship with `NotImplementedError` stubs — your test class supplies the test data via `create_resource!`, `valid_create_params`, `policy_roles`, etc.
14
14
 
15
+ ---
16
+
17
+ ## 🛑 Before you scaffold tests: confirm the shape (ASK — don't infer)
18
+
19
+ "Write tests for X" leaves out what actually drives the files. Resolve each — confirming by inspection (next section):
20
+
21
+ 1. **Which concerns?** `crud` / `policy` / `definition` / `model` / `nested` / `interaction` / `portal_access`. Don't scaffold all blindly — pick what the resource needs.
22
+ 2. **Which portals?** **One file per (resource × portal)** — each has different auth, scoping, and allowed actions. A resource in admin + org ⇒ two files.
23
+ 3. **Nested?** A child resource needs `--parent=` **and** a real `parent_record!` stub.
24
+ 4. **Auth flavor.** Rodauth (the default `login_as` POSTs the hardcoded `password123`) or custom (override `sign_in_for_tests`)?
25
+
26
+ **Never ship a guessed policy matrix, factory name, or field list** — read the model/definition/policy for the real actions, roles, and fields before filling stubs.
27
+
28
+ ## ✅ Before you scaffold: verify the ground truth (CHECK — read it, don't ask for it)
29
+
30
+ You have file access — **inspect**; don't ask the user to describe their setup.
31
+
32
+ | Check | How | Why it matters |
33
+ |---|---|---|
34
+ | Harness installed | grep `test/test_helper.rb` for `require "plutonium/testing"` | Concerns never autoload — run `pu:test:install` first |
35
+ | Resource exposed in each named portal | The resource is `register_resource`'d in each portal | `--portals=` must match mounted engines |
36
+ | Portal engine names | `:admin` ⇒ `AdminPortal::Engine` | Mismatch ⇒ pass `path_prefix:` explicitly |
37
+ | Login password | Test accounts seeded with `password123` (fixtures/factories) | `login_as` POSTs that hardcoded value, or use `sign_in_for_tests` |
38
+ | Tenant binding | `create_resource!`/`policy_record` return `@tenant`-bound records | Else scope tests pass for the wrong reason |
39
+
40
+ Inspect with your own tools **before** scaffolding.
41
+
42
+ ## 🛠 Use the generator — fill the stubs, don't hand-write
43
+
44
+ | Task | Generator | Verify first |
45
+ |---|---|---|
46
+ | Install harness (once per app) | `pu:test:install` | Not already in `test_helper.rb` |
47
+ | Scaffold tests | `pu:test:scaffold Klass --portals=… --concerns=…` | Harness installed; resource exposed in those portals |
48
+
49
+ Hand-written test files drift from conventions — scaffold, then fill the `NotImplementedError` stubs with tenant-correct data.
50
+
51
+ ---
52
+
15
53
  ## Quick start
16
54
 
17
55
  ```bash
@@ -23,6 +23,44 @@ For field-level rendering (`field :foo, as: :markdown`, `display :status do |f|
23
23
 
24
24
  ---
25
25
 
26
+ ## 🛑 Before you customize UI: pick the lightest seam (ASK — don't infer)
27
+
28
+ Plutonium gives you escalating levels of customization. Reach for the **lightest that fits** — jumping to `view_template` or an eject loses breadcrumbs/header/DynaFrame behavior and saddles you with maintaining copied markup forever.
29
+
30
+ | You want to… | Reach for | **NOT** |
31
+ |---|---|---|
32
+ | Add a banner / extra section to one page | nested page class + a **render hook** (`render_before_content`, `render_after_content`) | overriding `view_template` |
33
+ | Re-arrange the record's fields | a custom `Display` (`display_template`) | a hand-rolled `Form`/`view_template` |
34
+ | Group form fields into sections | the `form_layout` DSL in the definition | a `Form` subclass |
35
+ | Recolor / rebrand | `plutoniumTailwindConfig.merge(...)` in `tailwind.config.js` | a plain object spread (drops Plutonium's defaults) |
36
+ | Replace whole chrome per-portal | `pu:eject:shell` / `pu:eject:layout` — **last resort, you own it after** | ejecting when a hook/class/theme would do |
37
+
38
+ Then: is the change **global** (base `PostDefinition`) or **per-portal** (`AdminPortal::PostDefinition`)? And does it touch CSS/JS (⇒ the asset toolchain must be set up)? Don't guess field names or the banner copy — read the definition.
39
+
40
+ ## ✅ Before you edit: verify the ground truth (CHECK — read it, don't ask for it)
41
+
42
+ You have file access — **inspect**; don't ask the user to describe their app.
43
+
44
+ | Check | How | Why it matters |
45
+ |---|---|---|
46
+ | Custom page/Display already exists | Read the definition for nested `ShowPage`/`Display`/`Form` | Re-declaring clobbers an existing override |
47
+ | Global vs per-portal | Is it `::PostDefinition` or `AdminPortal::PostDefinition`? | Override the right one |
48
+ | Real field names | Read the model/definition | Don't invent fields in `display_template` |
49
+ | Asset toolchain wired | `ls tailwind.config.js`; the CSS `@import`; has `pu:core:assets` run? | Brand/CSS edits won't compile otherwise |
50
+ | Stimulus registered | grep `app/javascript/controllers/index.js` for `registerControllers` | Else the interactive layer is dead |
51
+ | Build watcher | Is `yarn dev` running? (`PLUTONIUM_DEV=1` when working on the gem) | CSS/JS changes need the rebuild |
52
+
53
+ Inspect with your own tools **before** proposing code.
54
+
55
+ ## 🛠 Use the generator — and prefer hooks over ejecting
56
+
57
+ | Task | How | Verify first |
58
+ |---|---|---|
59
+ | Custom Tailwind + Stimulus toolchain | `pu:core:assets` | Not already run |
60
+ | Eject chrome (header/sidebar/layout) | `pu:eject:shell` / `pu:eject:layout --dest=portal` | A render hook / nested class / theme genuinely can't do it (last resort) |
61
+
62
+ ---
63
+
26
64
  # Part 1 — Pages
27
65
 
28
66
  Each definition has nested page classes. Override the ones you need to customize:
@@ -288,6 +326,17 @@ render field(:published_at).wrapped { |f| f.flatpickr_tag(min_date: Date.today,
288
326
  render field(:avatar).wrapped { |f| f.uppy_tag(allowed_file_types: %w[.jpg .png], max_file_size: 5.megabytes) }
289
327
  ```
290
328
 
329
+ ### Password & secret fields
330
+
331
+ `password_tag` masks the stored value — it **never emits the secret into the DOM**. A stored secret renders a sentinel; an untouched submit keeps it, an edit-to-new-value then failed re-render comes back blank + `required` (re-type — secrets are never echoed back), a *cleared* field comes back blank but **not** `required` (the clear may be intentional), a deliberately emptied field clears it (clear-by-blank), a typed value sets it. The sentinel is guarded by the `password-sentinel` Stimulus controller — the first edit (incl. **backspace**) wipes the whole field so a partial edit can't corrupt it.
332
+
333
+ Auto-detected by name: `password`/`token`/`salt`, `encrypted_*`, `*_password`/`*_digest`/`*_hash`/`*_token`/`*_key`/`*_salt`, or any name containing `secret`. A convenience, **not** a guarantee — odd-named secrets (`recovery_phrase`, `pin`) still leak unless masked explicitly.
334
+
335
+ ```ruby
336
+ field :api_token, as: :string # opt OUT — show a readable value (token to copy, checksum)
337
+ field :recovery_phrase, as: :password # opt IN — mask a secret the heuristic misses
338
+ ```
339
+
291
340
  ## Submit buttons
292
341
 
293
342
  Default `render_actions` produces the primary submit, plus an optional "Save and add another" / "Update and continue editing" secondary button.
@@ -508,6 +557,8 @@ Drives both framework `:new` / `:edit` and every interactive action on the defin
508
557
 
509
558
  Show pages with `permitted_associations` (see [[plutonium-behavior]]) render a tablist: **Details** tab first, then one tab per association. The active tab is reflected in the URL hash (`#products`, `#refund-requests`) so the page deep-links and the active state survives reload / back navigation. Tab rows scroll horizontally on narrow viewports — they don't wrap.
510
559
 
560
+ If the policy permits **no fields**, the empty Details tab is dropped and the first association tab leads instead.
561
+
511
562
  ---
512
563
 
513
564
  # Part 6 — Layout (Chrome) & Eject
@@ -0,0 +1,469 @@
1
+ ---
2
+ name: plutonium-wizard
3
+ description: Use BEFORE building any multi-step Plutonium flow — onboarding, checkout, multi-model create, branching questionnaire. Covers the wizard DSL (steps, branching, using:, review, attachment/file-upload fields, per-step on_submit/persist/rollback, execute), anchoring & resume, one-time wizards + gate, registration (wizard macro + register_wizard), guest/anonymous flows, at-rest data encryption, and storage/config. The single source for "how do I build a wizard".
4
+ ---
5
+
6
+ # Plutonium Wizards
7
+
8
+ A wizard is a multi-step flow authored as a single class — `class X < Plutonium::Wizard::Base`. It collects typed `data` across ordered `step`s, optionally branches with `condition:`, and commits at the end via `execute`. It reuses the existing field DSL, form rendering, actions, and policies — no parallel stack.
9
+
10
+ For the field/input vocabulary used inside a step, load [[plutonium-resource]]. For the Outcome / `succeed`/`failed` pattern and the Action system wizards register through, load [[plutonium-behavior]].
11
+
12
+ ## 🚨 Critical (read first)
13
+
14
+ - **Enable the subsystem first.** `config.wizards.enabled = true` in `config/initializers/plutonium.rb`, then `rails db:migrate`. It's `false` by default — without it there's no `plutonium_wizard_sessions` table.
15
+ - **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a **raised exception** — a non-bang `false` advances the wizard and silently loses data. Or call `fail!("msg")`.
16
+ - **`data` is step-keyed:** `data.<step>.<field>` (e.g. `data.company.name`, `data.plan.plan`). Each step has its own typed sub-object, so two steps may share a field name without colliding. Read a field through its owning step everywhere (`condition:`/`on_submit`/`execute`).
17
+ - **`condition:` lambdas must be nil-safe.** They run against `data` at every transition, including before their deciding step is filled (value is `nil`). `-> { data.plan.plan == "pro" }` ✓; `-> { data.plan.plan.upcase == "PRO" }` raises on nil ✗.
18
+ - **`review` must be the LAST step.** A step declared after `review` raises at load.
19
+ - **`using:` targets a MODEL only** — not an interaction, not a bare definition. Selectors `fields:`/`only:`/`except:`.
20
+ - **No generator.** Author wizards by hand, like interactions. They live in `app/wizards/`.
21
+ - **Wizards are portal- *or* main-app-hosted.** A `register_wizard` mount inside a portal inherits the portal's auth/scoping/layout. A `register_wizard` mount on the **main app** runs standalone — for an **authenticated** main-app wizard you MUST define your own `::WizardsController` (include `Plutonium::Wizard::Controller` + your auth concern); the synthesized fallback is **bare (no auth)**. Resource-anchored (`wizard` macro) wizards always run embedded on the resource controller.
22
+ - **Schedule `SweepJob`** (a periodic job/cron). It reaps abandoned/expired sessions — always good hygiene (stale `in_progress` rows pile up otherwise), and **load-bearing** for `on_submit`/`persist` wizards: it's the only thing that rolls back the partial domain records an abandoned save-as-you-go run leaves behind.
23
+
24
+ ---
25
+
26
+ ## 🛑 Before you author: confirm the configuration (ASK — don't infer)
27
+
28
+ Wizard configuration is dense and the dimensions **interact** — guess wrong about mounting, anchoring, or run identity and you get a wizard that compiles but misbehaves: it forks a new run on every visit, 404s on resume, leaks across tenants, or can't be gated. A one-line request ("a checkout wizard", "onboarding") does **not** determine these.
29
+
30
+ **STOP and ask the user — use `AskUserQuestion` — before writing the class.** Resolve each decision below (skip one only when the user already stated it), then restate the resolved shape in a sentence and confirm:
31
+
32
+ 1. **End result — what does `execute` do?** Create a new record, update an existing one, touch several models, or fire a side effect (email/charge/API)? This drives anchoring *and* persistence.
33
+ 2. **Anchored or fresh?** Does it operate on an **existing** record or create something new?
34
+ - existing record from the URL → `anchored with: Model` (resource-mounted member route)
35
+ - existing context (the tenant, the current user) → `anchored via: :current_scoped_entity`
36
+ - brand new → non-anchored
37
+ 3. **Mount, host & shell.** A resource action (`wizard` macro), a **portal** entry (`register_wizard` in a portal engine), or a **main-app** entry (`register_wizard` on the app)? Authenticated, or **public/guest pre-login** (`anonymous`, e.g. signup)? For a route mount, what **layout** (`layout: :basic` for a bare screen, `:resource` for the shell, or omit for the host default)? (Resource wizards are always embedded; an authenticated main-app wizard needs an app-defined `::WizardsController`.)
38
+ 4. **Run identity.** Resume the user's **one** in-progress run (keyed — `concurrency_key`), or start a **fresh** run each launch (tokened/repeatable)? (Anchored wizards default to one run per `[anchor, current_user]`.)
39
+ 5. **One-time?** Run at most once and keep a completed marker (`one_time`) — e.g. to **gate** a page behind it?
40
+ 6. **Persistence model.** Write everything atomically at `execute` (default, simplest), or **save-as-you-go** with per-step `on_submit`/`persist` (then `SweepJob` must be scheduled, and `on_rollback` added for any *uncompensated* side effect)?
41
+ 7. **Steps & branching.** Which steps/fields/validations? Any step shown only under a `condition:`?
42
+ 8. **Tenancy.** Is the host portal entity-scoped? (The tenant folds into run identity automatically — don't thread it by hand.)
43
+
44
+ These compound: *anchored ⇒ keyed by default*; *anonymous ⇒ no owner ⇒ tokened*; *one-time ⇒ keyed + gateable*; *save-as-you-go ⇒ SweepJob + rollback*. Surface the implication when you confirm ("public ⇒ guest ⇒ session-keyed, ownerless"). The DSL sections below map each decision to its macro.
45
+
46
+ ## ✅ Before you author: verify the ground truth (CHECK — read it, don't ask for it)
47
+
48
+ The ASK gate resolves the *design*; this confirms the app can actually *run* it. You have file access — inspect these yourself before writing the class (don't ask the user to confirm what you can read):
49
+
50
+ | Check | How | Why it matters |
51
+ |---|---|---|
52
+ | Subsystem enabled | grep `config/initializers/plutonium.rb` for `wizards.enabled = true`; confirm `plutonium_wizard_sessions` exists (`db:migrate`) | **OFF by default — without it nothing works** (the #1 gotcha) |
53
+ | Anchor model exists & reachable | Read the model an `anchored` wizard runs against | Missing/unreadable anchor ⇒ 404 / `NotAnchoredError` |
54
+ | Host portal exists & its scoping | Read the portal engine (`scope_to_entity`?) + its real module name | Tenant folds into run identity; a guessed portal name breaks `register_wizard` |
55
+ | Guest-flow prereqs | AR encryption keys if `encrypt_data`; no `concurrency_key`/`one_time` with `anonymous` | First write raises otherwise |
56
+ | `on_submit` ⇒ SweepJob scheduled | The recurring-job config | Abandoned mid-flow records pile up forever |
57
+
58
+ **Don't author the class until `config.wizards.enabled` is confirmed and the anchor/target model + portal are read.** Until then, any class you show is provisional — say so; don't present a guessed field/column mapping as final.
59
+
60
+ ---
61
+
62
+ ## Minimal wizard
63
+
64
+ The common case writes nothing until the end. Steps collect `data`; one `execute` does all writes atomically.
65
+
66
+ ```ruby
67
+ # app/wizards/company_onboarding_wizard.rb
68
+ class CompanyOnboardingWizard < Plutonium::Wizard::Base
69
+ presents label: "Onboard a company", icon: Phlex::TablerIcons::BuildingSkyscraper
70
+
71
+ step :company, label: "Company details" do
72
+ attribute :name, :string
73
+ attribute :subdomain, :string
74
+ input :name
75
+ input :subdomain
76
+ validates :name, :subdomain, presence: true
77
+ end
78
+
79
+ step :plan, label: "Plan" do
80
+ attribute :plan, :string
81
+ input :plan, as: :radio_buttons, choices: %w[free pro]
82
+ validates :plan, presence: true
83
+ end
84
+
85
+ review label: "Review & submit"
86
+
87
+ def execute
88
+ company = Company.create!(name: data.company.name, subdomain: data.company.subdomain, plan: data.plan.plan)
89
+ succeed(company).with_message("You're all set!")
90
+ end
91
+ end
92
+ ```
93
+
94
+ - `presents label:/icon:/description:` — launch button label + icon (same as interactions); the optional `description:` renders as the wizard's header subheading.
95
+ - A `step :key, label: do ... end` is one screen; the block uses the field DSL ([[plutonium-resource]]). `step` (and `review`) also take an optional `description:` — a sub-label under the heading. `label:` defaults to `key.to_s.humanize`.
96
+ - `data.<step>.<field>` reads the **typed** value (cast to declared type) for that step, e.g. `data.company.name`.
97
+ - `review` — built-in terminal step: auto-summary + gated Finish. Must be last.
98
+ - `execute` — runs once at the end in one transaction; returns `succeed(...)` / `failed(...)`.
99
+
100
+ ## Wizard-level macros
101
+
102
+ | Macro | Meaning |
103
+ |---|---|
104
+ | `presents label:, icon:, description:` | Launch button label + icon; the optional `description:` renders as the wizard's header subheading. |
105
+ | `navigation :linear \| :free` | Stepper jumps. `:linear` (default) = back to any visited step; `:free` = any visible visited step. Forward to unvisited is never allowed. |
106
+ | `stepper false` | Hide the top rail (step indicator). On by default. |
107
+ | `on_relaunch :new` | Bare-relaunching a **tokened** wizard with pending runs shows a "resume or start new" chooser by default (`:prompt`) instead of silently forking; `:new` opts out (always fresh). No-op for keyed/`anonymous` (already auto-resume). |
108
+ | `anchored with: Model` / `anchored via: :method` | Run against an existing record (read via `anchor`). `with:` = URL `:id` (resource-mounted); `via:` = a controller method (portal-level, context). |
109
+ | `cleanup_after <ttl> \| :never` | Idle TTL before the sweep reaps the session + rolls back tracked records. Default `config.wizards.cleanup_after`. |
110
+ | `concurrency_key { … }` | Key a run by the returned value(s) (tenant folded in). The keyed `in_progress` row is the lock — a second launch resumes, never forks. Omit → unlimited `wizard_token`-keyed runs — **except `anchored`**, which defaults to `{ [anchor, current_user] }` (one draft per user per record). `{ anchor }` = one per record any-user; `{ wizard_token }` = repeatable. |
111
+ | `one_time` | Retain the completed row at the `concurrency_key` → run once (gate-able). **Requires `concurrency_key`.** Omit → row deleted on complete (repeatable). |
112
+ | `completed do \|wizard\| … end` | Custom body for the "already completed" page a finished **one-time** wizard shows when re-opened (replaces the default confirmation). |
113
+ | `encrypt_data` | Encrypt the staged `data` column at rest via ActiveRecord's encryption keys (PII flows). Requires `active_record.encryption` keys — first write raises (naming the wizard) if unconfigured. Unset inherits `config.wizards.encrypt_data` (global default, off); `encrypt_data false` opts out when that default is on. |
114
+ | `anonymous` | Opt into **guest (unauthenticated) access.** Default = auth required. A guest wizard may authenticate only at its terminal `execute`; never mid-flow. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`** — a guest's identity is its session token (already session-keyed/repeatable); whichever macro is declared last raises. |
115
+
116
+ ## Branching — `condition:`
117
+
118
+ Subtractive: a falsy `condition:` removes the step from the visible path.
119
+
120
+ ```ruby
121
+ step :billing, label: "Billing", condition: -> { data.plan.plan == "pro" } do
122
+ attribute :card_token, :string
123
+ input :card_token
124
+ validates :card_token, presence: true
125
+ end
126
+ ```
127
+
128
+ `condition:` can also read `anchor`. Branch-hidden steps' data is pruned before `execute`. **Must be nil-safe** (see Critical).
129
+
130
+ ## Field reuse — `using:` a model
131
+
132
+ `using:` is a **step option** (not a block method) and targets a **model only**.
133
+
134
+ ```ruby
135
+ # Whole-step import — no block needed.
136
+ step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
137
+
138
+ # Mix imported + wizard-local fields.
139
+ step :details, using: Company, only: %i[tagline] do
140
+ attribute :referral_code, :string
141
+ input :referral_code
142
+ end
143
+ ```
144
+
145
+ Imports: field universe + types from `Model.attribute_names`/`attribute_types`; input styling from the auto-resolved `<Model>Definition`; validations via transient `Model.new(slice).valid?` (errors kept on imported fields + `:base`); inherited `form_layout`.
146
+
147
+ | Selector / flag | Effect |
148
+ |---|---|
149
+ | `fields:` (alias `only:`) | Import only these. |
150
+ | `except:` | Import all but these. |
151
+ | `validate: false` | Skip validation reuse (write inline `validates`). |
152
+ | `layout: false` | Skip inherited `form_layout`. |
153
+ | `validation_context:` | Run `valid?(context)`. |
154
+
155
+ **Declaration reuse only** — never the model's persistence. Data stages into `data`; `execute` does the writes.
156
+
157
+ ## Step internals
158
+
159
+ Inside a `step` block, the field DSL from [[plutonium-resource]] applies verbatim:
160
+
161
+ ```ruby
162
+ step :company, label: "Company details" do
163
+ attribute :name, :string # typed → feeds data.company.name
164
+ input :name # how it renders
165
+ validates :name, presence: true # ActiveModel, run on Next
166
+
167
+ structured_input :invites, repeat: 5 do |f| # data.company.invites → array of typed sub-objects
168
+ f.input :email, as: :email
169
+ f.input :role, as: :select, choices: %w[admin member]
170
+ end
171
+
172
+ form_layout do # section THIS step's fields
173
+ section :identity, :name, label: "Identity", columns: 2
174
+ end
175
+ end
176
+ ```
177
+
178
+ Repeater rows rehydrate from staged `data` on GET (resume / back re-renders filled rows).
179
+
180
+ Validations drive the form's field affordances just like a resource form: `presence` → the required marker (`*`); `length`/`numericality`/`format`/`inclusion` → `maxlength`/`min`/`max`/`pattern`/auto-choices. This holds for validations imported via `using:` too. (Structured-input sub-fields are the exception — they carry no validators, so no markers there.)
181
+
182
+ ## Attachment fields (file uploads)
183
+
184
+ A step can collect a file. Declare it like any field — a **`:string`** attribute (it holds the upload **token**, not the bytes) + a file input:
185
+
186
+ ```ruby
187
+ step :photo, label: "Photo" do
188
+ attribute :photo, :string
189
+ input :photo, as: :file # or as: :uppy / as: :attachment
190
+ end
191
+ ```
192
+
193
+ The file is staged into `data` as a backend **token** (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data) — never the bytes (they can't ride JSON `data` across steps). `execute` assigns that token to the model's attachment **natively** (both backends accept it):
194
+
195
+ ```ruby
196
+ def execute
197
+ member = Member.create!(name: data.profile.name)
198
+ member.photo.attach(data.photo.photo) if data.photo.photo.present? # ActiveStorage
199
+ # active_shrine: Member.create!(photo: data.photo.photo)
200
+ succeed(member)
201
+ end
202
+ ```
203
+
204
+ The review summary and the step's preview (on Back/resume) render the file automatically — `data.<step>.<field>` resolves the token to a displayable attachment at the boundary; nothing extra to write.
205
+
206
+ **Two upload modes** — same field, differ only by `direct_upload:`:
207
+
208
+ | Mode | Declare | Notes |
209
+ |---|---|---|
210
+ | **Server-side** (default) | `input :file, as: :file` | the file rides the step POST; the wizard uploads it to the backend's cache while staging. Simplest; works for **AS *and* active_shrine**; no JS/endpoint needed. |
211
+ | **Direct upload** | `input :file, as: :uppy, direct_upload: true, endpoint: "/upload"` | the browser uploads to the endpoint and posts back a token (async progress UI). Needs that endpoint reachable (AS direct-uploads, or Shrine's `upload_endpoint`). |
212
+
213
+ - **Backend** (server-side mode): defaults to `config.wizards.attachment_backend`, which **auto-detects** active_shrine → `:shrine`, else `:active_storage`. Override per field with `backend:`. It **must match the model `execute` assigns to** — an AS model ⇒ `:active_storage`, an active_shrine model ⇒ `:shrine` (an AS model won't accept a Shrine token, and vice-versa).
214
+ - **Uploader** (Shrine only): `input :photo, as: :file, backend: :shrine, uploader: PhotoUploader` caches through that uploader (running its cache-stage plugins — mime/dimension/location/processing — instead of base `Shrine`). The token stays uploader-agnostic, so display + `execute` promotion are unchanged. Server-side only; raises for `:active_storage`. The uploader's `:cache` storage must be the one the model's attacher promotes from (the default global `Shrine.storages[:cache]`). **Validations are enforced on the step** (when Shrine's optional `validation`/`validation_helpers` plugin is loaded — else a clean no-op): the file is validated against the field's effective uploader (its `uploader:`, else base `Shrine`), so a failing file is rejected with a field error — not deferred to `execute`. (`Uploader.upload` itself runs no validations; the step's validation pass does.)
215
+ - **Multiple:** an array attribute + `multiple: true` → the staged value is an array of tokens.
216
+ - **Cleanup:** a staged-then-abandoned upload is an unattached blob / cached Shrine file — each backend's own unattached cleanup reaps it (the wizard `SweepJob` doesn't touch it).
217
+
218
+ ## The review step
219
+
220
+ ```ruby
221
+ review label: "Review & submit" # auto-summary + gated finish
222
+
223
+ review label: "Review & submit" do |wizard| # custom content BELOW the summary
224
+ "By submitting you agree to the #{wizard.data.plan.plan} plan terms."
225
+ end
226
+
227
+ review summary: false, header: false # fully chromeless → "ready to complete" panel
228
+ ```
229
+
230
+ Always lists invalid/unvisited steps as fix-this jump links; Finish disabled until all visible steps valid. Declares no fields. The body is a small state machine:
231
+
232
+ | State | Body |
233
+ |---|---|
234
+ | **Incomplete** | outstanding fix-this links **+** auto-summary of what's entered |
235
+ | **Complete**, `summary: true` (default) | auto-summary; custom block (if any) renders **below** it |
236
+ | **Complete**, `summary: false` + block | block **replaces** the summary |
237
+ | **Complete**, `summary: false`, no block | built-in "ready to complete" panel |
238
+
239
+ - `summary:` (default true) — show the auto-summary of completed steps. `false` hands the complete-state body to your block (or the "ready to complete" panel). The summary always shows in the incomplete state.
240
+ - `header:` (default true) — the step-header section (label + the "check everything over" prompt, shown only when the summary is). `false` drops it for a chromeless finish. Pair with `stepper false` for no chrome at all.
241
+
242
+ The custom block runs **in the Phlex view context** (`self` is the component), so it may return a String, emit Phlex (`div`, `render Component.new(...)`), and reach helpers via `helpers.*`; it's yielded the `wizard` (`data`/`anchor`/`persisted`/`current_user`). Don't both emit markup and return a String — Phlex renders the returned String too, double-rendering it.
243
+
244
+ ## Per-step writes — `on_submit` / `persist` / `on_rollback`
245
+
246
+ `execute` is the default (atomic). Use `on_submit` **only** when a real record must exist mid-flow (external handoff, reviewer sees partials, payload too large for the row).
247
+
248
+ ```ruby
249
+ step :billing, label: "Billing" do
250
+ attribute :card_token, :string
251
+ input :card_token
252
+ validates :card_token, presence: true
253
+
254
+ on_submit do # runs when THIS step completes, own transaction
255
+ charge = PaymentApi.authorize!(anchor, data.billing.card_token)
256
+ fail!("Card was declined") unless charge.ok? # base error; fail!(:field, "msg") for field error
257
+ persist Billing.create!(company: anchor, token: data.billing.card_token) # → persisted[:billing]
258
+ end
259
+
260
+ # on_rollback = ADDITIONAL cleanup of UNTRACKED side effects (refund/external API).
261
+ # The engine ALWAYS destroys persisted[:billing] itself; this runs BEFORE that
262
+ # destroy (record still alive). Don't destroy the persist'd record here.
263
+ on_rollback { PaymentApi.refund!(persisted[:billing].charge_id) }
264
+ end
265
+ ```
266
+
267
+ **`persist` always cleans up.** On any rollback (Cancel, sweep, branch-prune) the engine **always** destroys every `persist`'d record via `destroy!` (respects a model's soft-delete override). `on_rollback` is **optional, additive** — it compensates side effects the engine can't see, runs **before** the destroy, and a side-effect-only step (no `persist`) still runs its `on_rollback`. To keep a partial record, make the model soft-delete or use `cleanup_after :never`.
268
+
269
+ `on_submit` is not atomic across steps (HTTP), which is why `cleanup_after` + `SweepJob` exist.
270
+
271
+ ## Accessors
272
+
273
+ | Accessor | Returns |
274
+ |---|---|
275
+ | `data` / `data.<step>.<field>` | Typed snapshot of everything entered, **step-keyed** (read through the owning step). Not-yet-collected → `nil` / `default:`. Read-only. |
276
+ | `anchor` | The launched-against record. Raises `NotAnchoredError` if not `anchored` (never nil). |
277
+ | `persisted[:step_key]` | Record(s) registered via `persist` in `on_submit`. Rehydrated on resume. |
278
+ | `succeed(v)` / `failed(errs)` | Outcome helpers (alias `success`). `.with_message`, `.with_redirect_response` chainable. |
279
+ | `fail!(msg)` / `fail!(:field, msg)` | Raise a `StepError` from `on_submit`/`execute`. |
280
+
281
+ ## Anchoring
282
+
283
+ ```ruby
284
+ anchored with: Company # single type
285
+ anchored with: [Company, Org] # polymorphic
286
+ anchored # generic — type bound at registration
287
+ # omit # pure create flow (no anchor)
288
+ ```
289
+
290
+ `anchor` raises `Plutonium::Wizard::NotAnchoredError` when the wizard isn't `anchored`. The anchor is read-only context, **not** part of `persisted`.
291
+
292
+ ## One-time wizards + gate
293
+
294
+ ```ruby
295
+ class WelcomeWizard < Plutonium::Wizard::Base
296
+ presents label: "Welcome"
297
+
298
+ concurrency_key { current_user } # stable row to retain (tenant folded in)
299
+ one_time # retain on complete → run once
300
+
301
+ step :greeting do
302
+ attribute :acknowledged, :string
303
+ input :acknowledged
304
+ validates :acknowledged, presence: true
305
+ end
306
+ review label: "Review"
307
+
308
+ def execute = succeed.with_message("Welcome aboard!")
309
+
310
+ def authorize? # standalone wizards have no resource policy
311
+ current_user.present?
312
+ end
313
+ end
314
+ ```
315
+
316
+ Gate a controller behind it:
317
+
318
+ ```ruby
319
+ module AdminPortal
320
+ class DashboardController < AdminPortal::PlutoniumController
321
+ include Plutonium::Wizard::Gate
322
+ ensure_wizard_completed ::WelcomeWizard
323
+ end
324
+ end
325
+ ```
326
+
327
+ Un-completed user → redirected into the wizard (destination stashed); on completion → bounced back (PRG). Completed users pass through. The gate recomputes the wizard's `instance_key` from its `concurrency_key` (resolved on the host controller — `current_user`/`current_scoped_entity`/custom available) and checks `completed?(instance_key:)`. Only one-time wizards are gateable. Use `concurrency_key { anchor }` for "set up this record once".
328
+
329
+ **Gating an anchored wizard:** the gate needs the anchor to recompute the key. A `via:`-anchored wizard is resolved automatically (the gate calls its `anchor_via` method on the controller); otherwise pass `ensure_wizard_completed Wizard, anchor: :method_or_proc`. An anchor it can't resolve raises (no silent loop). Anchor-keyed wizards are only gateable where the anchor is reconstructable.
330
+
331
+ **Re-opening a completed one-time wizard** doesn't re-run it (the retained row's `data` is cleared) — it renders an "already completed" page (success badge + label + Continue). Override the body with `completed do |wizard| … end`. Repeatable wizards have no completed page (re-launch starts fresh).
332
+
333
+ ## Registration & launch
334
+
335
+ **(a) On a resource definition** — the `wizard` macro synthesizes the launch action AND auto-mounts the wizard's routes on the resource's own controller. Placement mirrors interactions: anchored → record (member) action, non-anchored → resource (collection) action; no bulk:
336
+
337
+ ```ruby
338
+ class CompanyDefinition < Plutonium::Resource::Definition
339
+ wizard :configure, ConfigureCompanyWizard # anchored → record action: /companies/:id/wizards/configure/:step
340
+ wizard :onboard, CompanyOnboardingWizard # no anchor → resource action: /companies/wizards/onboard/:step
341
+ end
342
+ ```
343
+
344
+ The anchored member action resolves its anchor through the resource controller's scoped, policy-gated `resource_record!` (IDOR-safe: out-of-scope / non-existent ids 404). Gate it with a policy predicate named after the wizard key (`def configure? = update?`).
345
+
346
+ **(b) Route-mounted** — `register_wizard`, in a **portal** engine's routes or on the **main app**, alongside `register_resource`:
347
+
348
+ ```ruby
349
+ AdminPortal::Engine.routes.draw do
350
+ register_wizard ::OnboardOrganizationWizard, at: "onboarding" # portal, in-shell (default)
351
+ register_wizard ::SetupOrgWizard, at: "setup", layout: :basic # portal, bare (BasicLayout)
352
+ end
353
+
354
+ Rails.application.routes.draw do
355
+ register_wizard ::AppOnboardingWizard, at: "onboarding" # main app, :basic (default)
356
+ end
357
+ ```
358
+
359
+ Draws `GET /onboarding` (canonical launch) + `GET/POST /onboarding(/:token)/:step` + an `onboarding_wizard_path` helper, within the host (portal or app).
360
+
361
+ | `register_wizard` option | Meaning |
362
+ |---|---|
363
+ | `at:` (required) | Host-relative base path for the steps. |
364
+ | `as:` | Override the route-helper prefix (defaults to `at:`, then the wizard's name). |
365
+ | `public:` | Mount on a **public (unauthenticated)** route for an `anonymous` wizard. Defaults to the wizard's `anonymous?` flag. |
366
+ | `layout:` | The Rails layout to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Default by host — portal → the resource shell, main-app → `:basic`. Turbo-frame requests are always layout-less regardless. |
367
+
368
+ ### Hosting & the controller override hook
369
+
370
+ `register_wizard` dispatches to a wizard controller. **If you've defined one, it's used; otherwise it's synthesized** (same "app owns the controller" contract as `register_resource`):
371
+
372
+ | Host | Controller used | Auth |
373
+ |---|---|---|
374
+ | Portal | `<Portal>::WizardsController` if defined, else synthesized on the portal's `PlutoniumController` | the portal's (inherited) |
375
+ | Main app, **authenticated** | `::WizardsController` — **you must define it** | **yours** — `include Plutonium::Auth::Rodauth(:account)` |
376
+ | Main app, **public** (`anonymous`) | synthesized `::PublicWizardsController` (bare + `Auth::Public`) | none (guest) |
377
+
378
+ ```ruby
379
+ # Authenticated main-app wizard ⇒ define the controller yourself.
380
+ class WizardsController < ApplicationController
381
+ include Plutonium::Wizard::Controller # the complete include surface (rendering + driving + view prefix)
382
+ include Plutonium::Auth::Rodauth(:user) # supplies current_user; the synthesized bare fallback has NONE
383
+ end
384
+ ```
385
+
386
+ `Plutonium::Wizard::Controller` is the whole contract — including it on any base yields a renderable wizard controller (it pulls in `Core::Controller` and contributes the `"plutonium"` view prefix, so even a bare `ActionController::Base` host renders the shared partials). For an app that needs no custom auth base there's a ready-made `Plutonium::Wizard::BaseController` (`< ActionController::Base` + the module) to subclass. The module is the mechanism; the class is sugar.
387
+
388
+ > [!TIP]
389
+ > **`with:`-anchored wizards mount on the resource, not portal-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro — it auto-mounts a record (member) action whose anchor is the scoped `resource_record!`. Passing a `with:`-anchored wizard to `register_wizard` **raises** (no resource record). A **`via:`-anchored** (context) wizard mounts portal-level fine — its anchor is a controller method (e.g. `via: :current_scoped_entity`).
390
+
391
+ > [!DANGER]
392
+ > **A portal-level wizard with no `authorize?` is runnable by ANY authenticated portal user** — it has no resource policy and defaults to allowed. **Always define `def authorize?`** for anything privileged (admin-only, per-user gating, tenant checks).
393
+
394
+ ## Authentication
395
+
396
+ **Auth is required by default** — entry without a `current_user` is rejected. Authenticated lookups are **owner-scoped**: a run id leaked in a URL can't be resumed by another logged-in user (foreign row → 404).
397
+
398
+ Where `current_user` comes from depends on the host: a **portal** mount inherits the portal's auth concern; a **main-app authenticated** mount needs the auth on your own `::WizardsController` (see *Hosting & the controller override hook* above — a bare synthesized main-app controller has no `current_user`, so a non-anonymous wizard there would be rejected by the auth gate). The wizard module supplies a `current_user` default that **defers to the host's auth concern** when present and is `nil` on a bare host.
399
+
400
+ Opt into guest access with `anonymous`, and mount it on a **public route**:
401
+
402
+ ```ruby
403
+ class GuestSignupWizard < Plutonium::Wizard::Base
404
+ anonymous # runs pre-login; identity = a server-minted run-id in the Rails session
405
+ step(:account) { attribute :email, :string; input :email; validates :email, presence: true }
406
+ review label: "Review"
407
+ def execute # the ONE boundary it may cross
408
+ succeed(Account.create!(email: data.account.email)) # may also sign the user in (host calls Rodauth)
409
+ end
410
+ end
411
+
412
+ # in a portal's routes — drawn on a public (unauthenticated) route automatically
413
+ register_wizard ::GuestSignupWizard, at: "signup", public: true
414
+ ```
415
+
416
+ The guest run-id lives in the **Rails session** (`session["plutonium_wizards"][<wizard_key>]`) — **no cookie, no TTL** (the row's `cleanup_after` is the lifetime). It's browser-close ephemeral, **auto-cleared on login/logout** (Rodauth `reset_session`), cleared on completion, and **never in a URL**. Authenticated repeatable runs keep their URL `:token` segment instead (owner-scoped). **No mid-flow auth crossing**: a guest wizard never stamps an owner mid-flow or carries a token across login — it only ever authenticates at `execute`.
417
+
418
+ ## Listing in-progress wizards
419
+
420
+ `Plutonium::Wizard.in_progress_for(view_context)` (→ `Resume.entries_for(view_context)`) takes the `view_context` (as interactions do) and derives the run owner (`current_user`), tenant scope, and **portal** from it — returning that user's in-progress runs **for the current portal**, newest-first, for a "continue where you left off" dashboard. 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` — is recorded per-run; scope alone can't identify it.)
421
+
422
+ Each entry exposes the wizard's `label`/`icon`, `current_step` (+ `current_step_label`), `updated_at`, the raw `session` row, and a `resume_url` built through the **current portal's** routes — `resource_url_for(record, wizard:, step:)` for a `wizard`-macro **anchored** mount, the named route for a `register_wizard` mount; `nil` + `resume_unresolved_reason` when the row can't be resolved here (e.g. a non-anchored `wizard`-macro run). **Narrowing.** For the per-record / per-wizard resume widget ("does this record have an unfinished draft of wizard X?"), pass the optional `anchor:`/`wizard:` filters — they narrow **in the query, before enrichment**, so discarded rows are never URL-resolved or anchor-loaded (cheaper than `select`-ing the array, which enriches every row first). They compose, and the `wizard + anchor` pair is index-covered: `…in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: company).first`. Don't reach into `e.session.anchor` to filter (a polymorphic load per row). For ad-hoc post-filtering the array still works — `e.wizard_class` is already on each entry.
423
+
424
+ ## Storage & config
425
+
426
+ ```ruby
427
+ # config/initializers/plutonium.rb
428
+ Plutonium.configure do |config|
429
+ config.wizards.enabled = true # false by default — required
430
+ config.wizards.cleanup_after = 14.days # global default sweep TTL
431
+ config.wizards.encrypt_data = false # encrypt every wizard's data at rest (needs AR encryption keys)
432
+ config.wizards.database = :primary # reserved — v1 supports :primary only (else raises at boot)
433
+ config.wizards.attachment_backend = nil # server-side attachment staging backend (nil = auto-detect active_shrine/AS)
434
+ end
435
+ ```
436
+
437
+ - One framework table `plutonium_wizard_sessions` (gem-shipped migration, runs in place on `rails db:migrate`). No changes to your models.
438
+ - DB-backed → resume across devices, in-progress listing, durable one-time markers.
439
+ - **`Plutonium::Wizard::SweepJob`** (an `ActiveJob`) reaps idle/expired sessions and rolls back their tracked records. **Schedule it** for every wizard app — stale rows pile up otherwise, and for `on_submit`/`persist` wizards it's the *only* thing that rolls back abandoned mid-flow records. In a Solid Queue app (`rails g pu:lite:solid_queue` sets up the backend), add it to `config/recurring.yml`:
440
+
441
+ ```yaml
442
+ # config/recurring.yml
443
+ wizard_sweep:
444
+ class: Plutonium::Wizard::SweepJob
445
+ schedule: every 15 minutes
446
+ ```
447
+
448
+ (Or any recurring mechanism your app already has — sidekiq-cron, `whenever`, a cron'd rake task. `perform` takes no required args.)
449
+
450
+ ## Common gotchas
451
+
452
+ - **Forgot `config.wizards.enabled`** → no table, nothing works.
453
+ - **Non-bang `create`/`update` in `execute`/`on_submit`** → silent advance, lost data. Use `create!`/`update!` or `fail!`.
454
+ - **`condition:` not nil-safe** → raises on the value before its step is filled.
455
+ - **`review` not last** → load-time error.
456
+ - **`using:` a definition or interaction** → `using:` is model-only.
457
+ - **`one_time` without `concurrency_key`** → raises (no stable row to retain).
458
+ - **`anonymous` + `concurrency_key`/`one_time`** → raises (a guest is already session-keyed; whichever is declared last raises).
459
+ - **`encrypt_data` without AR encryption keys** → first write raises (naming the wizard). Run `bin/rails db:encryption:init`.
460
+ - **Gating a non-one-time wizard** (`ensure_wizard_completed` on a repeatable wizard) → raises.
461
+ - **`on_submit` wizard without scheduled SweepJob** → abandoned partial records pile up.
462
+ - **Rotating `secret_key_base`** → invalidates every `instance_key` digest (it's salted with the app secret): in-progress runs become unresumable and one-time gates re-open. Only affects rows live at rotation time.
463
+
464
+ ## Related Skills
465
+
466
+ - [[plutonium-resource]] — the `attribute`/`input`/`validates`/`structured_input`/`form_layout` field DSL used inside a step.
467
+ - [[plutonium-behavior]] — Outcomes (`succeed`/`failed`), the Action system the `wizard` macro builds on, policies.
468
+ - [[plutonium-app]] — portal engines and `register_wizard` placement (alongside `register_resource`).
469
+ - [[plutonium-testing]] — integration-testing wizard flows.
data/.cliff.toml CHANGED
@@ -66,3 +66,9 @@ ignore_tags = ""
66
66
  topo_order = false
67
67
  # sort the commits inside sections by oldest/newest order
68
68
  sort_commits = "oldest"
69
+
70
+ [bump]
71
+ # Pre-1.0 semver: a feature bumps the minor, a breaking change also bumps the
72
+ # minor (not the major) while we're on 0.x. Fixes bump the patch.
73
+ features_always_bump_minor = true
74
+ breaking_always_bump_major = false
data/Appraisals CHANGED
@@ -13,6 +13,7 @@ appraise "rails-7" do
13
13
  gem "bcrypt"
14
14
  gem "rotp"
15
15
  gem "rqrcode"
16
+ gem "active_shrine" # exercises the Shrine attachment backend in the dummy
16
17
  gem "tzinfo-data", platforms: %i[windows jruby]
17
18
  end
18
19
 
@@ -31,6 +32,7 @@ appraise "rails-8.0" do
31
32
  gem "bcrypt"
32
33
  gem "rotp"
33
34
  gem "rqrcode"
35
+ gem "active_shrine" # exercises the Shrine attachment backend in the dummy
34
36
  gem "tzinfo-data", platforms: %i[windows jruby]
35
37
  end
36
38
 
@@ -49,5 +51,6 @@ appraise "rails-8.1" do
49
51
  gem "bcrypt"
50
52
  gem "rotp"
51
53
  gem "rqrcode"
54
+ gem "active_shrine" # exercises the Shrine attachment backend in the dummy
52
55
  gem "tzinfo-data", platforms: %i[windows jruby]
53
56
  end