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,129 @@
1
+ # One-time wizards
2
+
3
+ A **one-time** wizard runs once — for onboarding, a one-shot setup, a "welcome" flow. It needs a durable completion marker (you can't remember "done forever" in a session), which the DB store provides as a `completed` session row.
4
+
5
+ ## Declaring `one_time`
6
+
7
+ A one-time wizard is a **keyed** wizard (`concurrency_key`) that **retains** its completed row instead of deleting it. So `one_time` always pairs with a `concurrency_key` — the stable key the retained marker lives at (and the key the gate recomputes).
8
+
9
+ ```ruby
10
+ class WelcomeWizard < Plutonium::Wizard::Base
11
+ presents label: "Welcome"
12
+
13
+ concurrency_key { current_user } # the stable row to retain (tenant folded in)
14
+ one_time # retain the completed row → never again
15
+
16
+ step :greeting do
17
+ attribute :acknowledged, :string
18
+ input :acknowledged
19
+ validates :acknowledged, presence: true
20
+ end
21
+
22
+ review label: "Review"
23
+
24
+ def execute
25
+ succeed.with_message("Welcome aboard!")
26
+ end
27
+ end
28
+ ```
29
+
30
+ - **Completion** = the instance row reaching `status: completed`, **retained** at the wizard's `instance_key`.
31
+ - **`one_time` requires a `concurrency_key`** — declaring `one_time` without one raises (there's no stable row to retain). A wizard without `one_time` deletes its row on completion (repeatable).
32
+ - **`concurrency_key { current_user }`** keys completion per user; **`concurrency_key { anchor }`** keys it per anchored record ("set up *this* workspace once"). The **tenant (`current_scoped_entity`) is folded in automatically**, so in a tenant portal it's per-(user, tenant) for free.
33
+ - On completion, the row is kept as the marker but its `data` / `tracked_records` are nulled out (privacy + size).
34
+
35
+ The completion marker is recorded by the wizard's own finalize, the same `execute` → PRG path as any wizard — no extra code in `execute`.
36
+
37
+ ## Re-opening a completed wizard
38
+
39
+ Navigating back to a finished one-time wizard (its URL, or its bare launch route) doesn't re-run it — the retained `completed` row has had its `data` cleared, so there's nothing to resume or review. Instead the wizard renders a standalone **"already completed" page**: a success badge, the wizard's label, a short message, and a Continue button out.
40
+
41
+ Supply a [`completed` block](/reference/wizard/dsl#completed) on the wizard to replace that body with your own:
42
+
43
+ ```ruby
44
+ completed do |wizard|
45
+ h1 { "You're already set up." }
46
+ a(href: "/dashboard") { "Back to your dashboard" }
47
+ end
48
+ ```
49
+
50
+ This is a one-time-only concept: a **repeatable** wizard deletes its row on completion, so re-launching simply starts a fresh run — there's no completed page.
51
+
52
+ ## Gating a controller — `ensure_wizard_completed`
53
+
54
+ The `Plutonium::Wizard::Gate` concern installs a `before_action` that redirects users into the wizard until they complete it.
55
+
56
+ ```ruby
57
+ module AdminPortal
58
+ class DashboardController < AdminPortal::PlutoniumController
59
+ include Plutonium::Wizard::Gate
60
+ ensure_wizard_completed ::WelcomeWizard
61
+ end
62
+ end
63
+ ```
64
+
65
+ Flow:
66
+
67
+ 1. An un-completed user hits the gated page → the gate stashes their destination (`session[:return_to]`) and redirects to the wizard's first step.
68
+ 2. The user completes the wizard → finalize records the durable completion marker.
69
+ 3. The gate now passes them through; the controller bounces them back to the stashed destination (PRG).
70
+ 4. A completed user passes straight through, every time.
71
+
72
+ Extra options (`only:` / `except:`) are forwarded to `before_action`:
73
+
74
+ ```ruby
75
+ ensure_wizard_completed ::WelcomeWizard, only: %i[index show]
76
+ ```
77
+
78
+ The entry URL is derived from the `register_wizard` route helper (`<name>_wizard_path(step: <first_step>)`). Override `wizard_entry_path` for a custom mount. When gating from **outside** the wizard's own portal, also override `wizard_gate_route_set` (default `current_engine.routes`) so the gate looks the launch route up in the route set where the wizard is actually mounted.
79
+
80
+ ## How the gate keys completion
81
+
82
+ The gate **recomputes the wizard's `instance_key`** from its `concurrency_key`, resolving the key block against the **host controller**, so `current_user`, `current_scoped_entity` (folded automatically), `anchor`, and any custom host method are available. It then checks `completed?(instance_key:)`. This digest is computed by the same `Plutonium::Wizard.compute_instance_key` the runner uses, so the gate sees exactly the marker the wizard recorded.
83
+
84
+ ### Gating an anchored wizard
85
+
86
+ An anchor-keyed wizard (explicit `{ anchor }`, or the [implied anchored key](/reference/wizard/anchoring-resume#the-implied-anchored-key)) keys completion by its anchor — so the gate needs that anchor to recompute the key. It resolves it two ways:
87
+
88
+ - **Automatic** for a `via:`-anchored wizard — the gate calls the wizard's own `anchor_via` method on the host controller. Gating a `via: :current_scoped_entity` wizard inside its own entity-scoped portal is zero-config (`ConfigureOrgWizard` gated on an org-portal controller just works).
89
+ - **Explicit** otherwise — pass `anchor:` (a method name or proc, evaluated on the controller) when the anchor isn't auto-resolvable (a `with:`-anchored wizard, or gating from a different context):
90
+
91
+ ```ruby
92
+ ensure_wizard_completed ConfigureWizard, anchor: :current_widget
93
+ ensure_wizard_completed ConfigureWizard, anchor: -> { current_account.widget }
94
+ ```
95
+
96
+ If an anchor-keyed wizard's anchor can't be resolved and no `anchor:` is given, the gate **raises** (rather than silently mis-keying and looping you into the wizard forever). A wizard keyed by a *non-anchor* method the controller doesn't expose still gives the same clear error.
97
+
98
+ A wizard keyed by an anchor is only gateable **where that anchor can be reconstructed** — a tenant-anchored wizard, within that tenant's context. That's a property of the keying, not a gap in the gate.
99
+
100
+ ::: warning Only one-time wizards are gateable
101
+ `ensure_wizard_completed` raises unless the wizard is `one_time` (a `concurrency_key` **plus** `one_time`) — only a retained marker can be checked. Repeatable wizards have nothing durable to gate on.
102
+ :::
103
+
104
+ ## The launch action hides itself once completed
105
+
106
+ When you register a one-time wizard on a resource definition with the [`wizard` macro](/reference/wizard/registration-launch), the synthesized launch action (button/link) is **automatically hidden once the current user has completed it**. The macro attaches a render-time `condition:` to the action that recomputes the wizard's `instance_key` for the current context (the same `Plutonium::Wizard.compute_instance_key` the driving layer and the [gate](#how-the-gate-keys-completion) use) and returns false when a retained `completed` row exists at that key:
107
+
108
+ ```ruby
109
+ class CompanyDefinition < Plutonium::Resource::Definition
110
+ wizard :onboard, CompanyOnboardingWizard # one_time → button vanishes after completion
111
+ end
112
+ ```
113
+
114
+ - It keys exactly like the wizard's own completion: per-user for `concurrency_key { current_user }`, per-anchor for `concurrency_key { anchor }` (the anchor is the record the action sits on), with the tenant folded in.
115
+ - This is **display-only** — like every action `condition:`, it hides the button but does **not** revoke the route. Keep authorization in the policy (`def onboard? = …`).
116
+ - **Repeatable** (non-`one_time`) wizards get **no** completion condition — their launch action always shows.
117
+
118
+ A custom `condition:` **composes** with the completion check (they're AND-ed) — the action shows only when your condition is met **and** the wizard isn't already completed:
119
+
120
+ ```ruby
121
+ wizard :onboard, CompanyOnboardingWizard,
122
+ condition: -> { current_user.admin? } # admin AND not yet completed
123
+ ```
124
+
125
+ ## Related
126
+
127
+ - [DSL reference](/reference/wizard/dsl) — `concurrency_key`, `one_time`, `authorize?`.
128
+ - [Storage & config](/reference/wizard/storage-config) — the durable completion row.
129
+ - [Registration & launch](/reference/wizard/registration-launch) — mounting the wizard the gate redirects into.
@@ -0,0 +1,177 @@
1
+ # Registration & launch
2
+
3
+ A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`) — inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth — see [Hosting & the controller override hook](#hosting-the-controller-override-hook)).
4
+
5
+ ## On a resource — the `wizard` macro
6
+
7
+ A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action — sugar over the Action system.
8
+
9
+ ```ruby
10
+ class CompanyDefinition < Plutonium::Resource::Definition
11
+ wizard :configure, ConfigureCompanyWizard # anchored → record action
12
+ wizard :onboard, CompanyOnboardingWizard # no anchor → resource action
13
+ wizard :archive, ArchiveWithReasonWizard, record_action: true # override placement
14
+ end
15
+ ```
16
+
17
+ - **Placement is dictated by the wizard, not chosen** — an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
18
+ - **Auto-mounted on the resource controller** — the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
19
+ - **The anchor is IDOR-safe** — an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
20
+ - **Bulk wizards are not supported** — wizards are inherently per-instance flows. Use a bulk interaction instead.
21
+ - **Authorization mirrors actions** — a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
22
+
23
+ | Option | Meaning |
24
+ |---|---|
25
+ | `record_action:` | **(Record wizards only)** show on the record's **show page**. Default `true`; `false` removes it from there. |
26
+ | `collection_record_action:` | **(Record wizards only)** show on each **list row** (scoped to that row's record). Default `true`; `false` keeps it on the show page but off the list. |
27
+ | `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome — any `action` option passes through. |
28
+
29
+ Placement isn't an option — it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
30
+
31
+ ### Synthesized routes (resource-mounted)
32
+
33
+ ```
34
+ # anchored → member route (anchor = the scoped resource_record!)
35
+ GET /companies/:id/wizards/:wizard_name → launch (redirect to step)
36
+ GET/POST /companies/:id/wizards/:wizard_name(/:token)/:step
37
+
38
+ # non-anchored → collection route (create flow)
39
+ GET /companies/wizards/:wizard_name → launch (redirect to step)
40
+ GET/POST /companies/wizards/:wizard_name(/:token)/:step
41
+ ```
42
+
43
+ The synthesized launch action points at the **bare** wizard URL (no step). A `GET` there resolves the run (minting the per-run `:token` for a tokened wizard, or resolving the keyed identity) and redirects to its current step: the **resumed cursor** for an in-progress keyed run, else the **first step**, with the token already in the URL. So clicking the launch button resumes where the user left off (rather than jumping back to step 1) and never forks a fresh run on a first-step reload.
44
+
45
+ ## Route-mounted — `register_wizard`
46
+
47
+ For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard` — **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
48
+
49
+ ```ruby
50
+ # packages/admin_portal/config/routes.rb
51
+ AdminPortal::Engine.routes.draw do
52
+ register_wizard ::OnboardOrganizationWizard, at: "onboarding" # in-shell (portal default)
53
+ register_wizard ::SetupOrgWizard, at: "setup", layout: :basic # bare (BasicLayout)
54
+
55
+ register_resource ::Company
56
+ end
57
+
58
+ # config/routes.rb — a main-app (portal-less) wizard
59
+ Rails.application.routes.draw do
60
+ register_wizard ::AppOnboardingWizard, at: "onboarding" # main-app default → :basic
61
+ end
62
+ ```
63
+
64
+ | Argument | Meaning |
65
+ |---|---|
66
+ | `at:` (required) | The host-relative base path for the wizard's steps. |
67
+ | `as:` | Override the route helper name prefix (defaults to `at:`, then the wizard's name, e.g. `OnboardOrganizationWizard` → `onboarding`). |
68
+ | `public:` | Mount on a **public (unauthenticated) route** for an [`anonymous`](#public-mount-for-anonymous-wizards) wizard. Defaults to the wizard's own `anonymous?` flag. |
69
+ | `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host — **portal → the resource shell**, **main-app → `:basic`**. |
70
+
71
+ This draws the wizard's step routes within the host (so a portal mount inherits the portal's scope/auth/layout) and dispatches them to a wizard controller. It provides `<name>_wizard_path` / `_url` helpers.
72
+
73
+ ### Hosting & the controller override hook
74
+
75
+ `register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource` — there is no hand-written file unless you want to customize.
76
+
77
+ | Host | Controller | Base / auth |
78
+ |---|---|---|
79
+ | **Portal** | `<Portal>::WizardsController` if defined, else synthesized | the portal's `PlutoniumController` (inherits its auth/scope/layout) |
80
+ | **Main app, authenticated** | `::WizardsController` — **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
81
+ | **Main app, public** (`anonymous`) | `::PublicWizardsController` (synthesized) | a bare base + `Plutonium::Auth::Public` (guest) |
82
+
83
+ The synthesized **main-app** controller is **bare** — rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
84
+
85
+ ```ruby
86
+ # app/controllers/wizards_controller.rb
87
+ class WizardsController < ApplicationController
88
+ include Plutonium::Wizard::Controller # the complete include surface
89
+ include Plutonium::Auth::Rodauth(:user) # supplies current_user
90
+ end
91
+ ```
92
+
93
+ `Plutonium::Wizard::Controller` is the whole mechanism: including it on any base yields a renderable wizard controller. It pulls in `Plutonium::Core::Controller` (asset/layout machinery) and **contributes the `"plutonium"` view-lookup prefix itself**, so even a bare `ActionController::Base` host resolves the gem's shared partials (`plutonium/_flash`, …). For an app that needs no custom auth base, subclass the ready-made `Plutonium::Wizard::BaseController` (`< ActionController::Base` with the module already included). The module is the contract; the class is sugar.
94
+
95
+ ### Layout
96
+
97
+ `layout:` is the **Rails layout** the wizard renders in — a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
98
+
99
+ | `layout:` | Appearance | Layout |
100
+ |---|---|---|
101
+ | `:basic` | no sidebar/topbar — e.g. "set up your organization" | `basic` (`BasicLayout`) |
102
+ | `:resource` | sidebar + topbar + wizard (in-app) | the `resource` shell layout |
103
+ | *(any app layout)* | whatever that layout renders | passed straight to Rails |
104
+ | *(omitted)* | host default — see below | — |
105
+
106
+ - **Defaults by host:** portal → the `resource` shell; main-app → `:basic` (a bare main-app host has no shell to embed in). Pass `layout:` to override.
107
+ - **Turbo-frame requests are always layout-less** regardless of the setting — that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
108
+ - `layout:` is a **`register_wizard` option only**; it travels with the mount, not the wizard class. Resource-defined wizards take no `layout:` (always embedded).
109
+
110
+ ### Synthesized routes
111
+
112
+ ```
113
+ GET /onboarding → launch: resolve/mint the run, redirect to its step
114
+ GET /onboarding(/:token)/:step → renders the step
115
+ POST /onboarding(/:token)/:step → advances (the `_direction` param carries next/back/cancel)
116
+ ```
117
+
118
+ The bare **`/onboarding`** is the canonical entry point (helper `onboarding_wizard_launch_path`). A `GET` there resolves the run (minting the per-run `:token` for a tokened wizard, or resolving the keyed/guest identity) and `303`-redirects to its entry step: the **resumed cursor** for an in-progress keyed/guest run, else the **first visible step**. Because the redirect target already carries the token, the address bar shows a stable, shareable run URL from the first paint (the token no longer "appears" only after the first submit, and reloading the first step can't fork a second run). Link to `/onboarding` from menus/dashboards; the stepped `/onboarding(/:token)/:step` URLs are built for you by the engine.
119
+
120
+ The POST `_direction` param carries `next` / `back` / `cancel`. **Where Cancel sends the user** is captured at launch from a `?return_to=` query param (or the referer), sanitized to a same-host local path that isn't the wizard's own mount (open-redirect-safe); Cancel returns there, falling back to the host root. So linking to `/onboarding?return_to=/dashboard` lands a cancelled run back on the dashboard.
121
+
122
+ `scope_gid` (folded into the instance key) comes from the portal's scoping entity when the portal is entity-scoped.
123
+
124
+ ## Entry authorization
125
+
126
+ A portal-level wizard has no resource policy, so gate entry with an `authorize?` instance method on the wizard — checked before each request:
127
+
128
+ ```ruby
129
+ class WelcomeWizard < Plutonium::Wizard::Base
130
+ def authorize?
131
+ current_user.present? && !current_user.onboarded?
132
+ end
133
+ end
134
+ ```
135
+
136
+ A falsy return → `ActionPolicy::Unauthorized` (403). Resource-attached wizards instead use their action's policy predicate (`def onboard?` etc.).
137
+
138
+ ::: danger A portal-level wizard with no `authorize?` is open to ANY authenticated portal user
139
+ A portal-level wizard (registered with `register_wizard`) has **no resource policy** and **defaults to allowed** — so if you omit `authorize?`, every authenticated user of that portal can run it. **Always define `def authorize?`** for anything privileged (admin-only flows, per-user gating, tenant checks). The default-allow is only safe for flows that are genuinely fine for any signed-in portal user (e.g. self-service onboarding).
140
+ :::
141
+
142
+ ::: warning As-built: `authorize?` is an instance method
143
+ Define `def authorize?` on the wizard. The controller checks it only when the wizard responds to it (default: allowed).
144
+ :::
145
+
146
+ ## Public mount for `anonymous` wizards
147
+
148
+ **Wizards require authentication by default.** A guest ([`anonymous`](/reference/wizard/anchoring-resume#authentication)) wizard needs a route reachable **before** login. Because a portal engine is mounted *inside* the host's authentication constraint (`constraints Rodauth::Rails.authenticate(:user) { mount ... }`), a route drawn in the portal is unreachable pre-login. So `register_wizard` draws an `anonymous` wizard's route on the **main application's** route set, outside that constraint:
149
+
150
+ ```ruby
151
+ # in the portal engine's routes (register_wizard is available there)
152
+ register_wizard ::GuestSignupWizard, at: "signup", public: true
153
+ # → GET/POST /signup(/:token)/:step (a top-level, unauthenticated route)
154
+ ```
155
+
156
+ - `public: true` is the **default for an `anonymous` wizard**; you can pass it explicitly for clarity.
157
+ - A **non-`anonymous`** wizard may **not** be mounted public (raises) — and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
158
+ - The public route dispatches to a synthesized top-level `::PublicWizardsController` (a **distinct** const from an authenticated main-app `::WizardsController`, so the two never collapse onto each other) that renders **full-page** with a standalone layout (no resource sidebar / user menu) and treats the request as a guest via `Plutonium::Auth::Public`.
159
+
160
+ ## One-time gating
161
+
162
+ To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time) — a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
163
+
164
+ For a **one-time** wizard, the launch action this macro synthesizes also **hides itself once the current user has completed it** (via a render-time action `condition:`); a custom `condition:` composes with that check. See [The launch action hides itself once completed](/reference/wizard/one-time#the-launch-action-hides-itself-once-completed).
165
+
166
+ ## Constraints
167
+
168
+ - **`with:`-anchored wizards mount on the resource, not route-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro (it auto-mounts a member action whose anchor is the scoped `resource_record!`). Passing a `with:`-anchored wizard to **`register_wizard`** raises: a route-level mount has no resource record to anchor to. A **`via:`-anchored** (context-anchored) wizard *does* mount route-level; its anchor is resolved by a controller method, not a URL `:id`.
169
+ - **An authenticated main-app wizard needs an app-defined controller.** The synthesized main-app controller is bare (no `current_user`); supply your own `::WizardsController` with an auth concern (see [Hosting & the controller override hook](#hosting-the-controller-override-hook)). Portal mounts and `anonymous` public mounts need nothing.
170
+ - **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time — give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
171
+
172
+ ## Related
173
+
174
+ - [DSL reference](/reference/wizard/dsl) — `authorize?`, the wizard body.
175
+ - [Anchoring & resume](/reference/wizard/anchoring-resume) — anchors, instance keys.
176
+ - [One-time wizards](/reference/wizard/one-time) — completion + gating.
177
+ - [Custom actions guide](/guides/custom-actions) — the Action system the `wizard` macro builds on.
@@ -0,0 +1,151 @@
1
+ # Storage & config
2
+
3
+ The wizard subsystem is DB-backed by a single framework table, gated by an opt-in config flag. This page covers enabling it, the table, configuration, encryption, and the cleanup sweep.
4
+
5
+ ## Enabling the subsystem
6
+
7
+ Wizards are core code, but the storage table is **opt-in** so apps that don't use wizards stay schema-clean.
8
+
9
+ ```ruby
10
+ # config/initializers/plutonium.rb
11
+ Plutonium.configure do |config|
12
+ config.wizards.enabled = true # false by default
13
+ config.wizards.cleanup_after = 14.days # global default idle TTL for the sweep
14
+ config.wizards.encrypt_data = true # encrypt every wizard's data at rest (needs AR encryption keys)
15
+ config.wizards.attachment_backend = nil # server-side attachment staging backend (nil = auto-detect)
16
+ end
17
+ ```
18
+
19
+ ```bash
20
+ rails db:migrate
21
+ ```
22
+
23
+ | Config | Default | Meaning |
24
+ |---|---|---|
25
+ | `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes — both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
26
+ | `config.wizards.cleanup_after` | `14.days` | Global default idle TTL for the abandonment sweep; overridable per wizard via `cleanup_after`. |
27
+ | `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only** — see below. |
28
+ | `config.wizards.encrypt_data` | `false` | Encrypt **every** wizard's staged `data` at rest by default. Off by default because it needs ActiveRecord encryption keys; a wizard still overrides it individually with `encrypt_data` / `encrypt_data false`. See [Encryption](#encryption). |
29
+ | `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects — active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
30
+
31
+ ## Gem-shipped migration
32
+
33
+ The migration ships **in the gem** and Rails runs it **in place** — there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
34
+
35
+ - Once run, the table is dumped into your `schema.rb` / `structure.sql` like any other, so `db:schema:load` on fresh/CI databases recreates it normally.
36
+ - Disable later → the path isn't registered; the existing table is left alone (never auto-dropped).
37
+ - `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed) — standard for gem-shipped migrations.
38
+
39
+ ::: warning v1 supports the primary database only
40
+ The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use** — multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
41
+ :::
42
+
43
+ ## The table — `plutonium_wizard_sessions`
44
+
45
+ One framework-owned table serves everything; **no changes to your models.**
46
+
47
+ | Column | Purpose |
48
+ |---|---|
49
+ | `wizard` | The wizard class name. |
50
+ | `status` | `in_progress` \| `completing` \| `completed` (see the note below). |
51
+ | `current_step` | The step cursor. |
52
+ | `instance_key` (unique) | The deterministic identity digest (see [Anchoring & resume](/reference/wizard/anchoring-resume#instance-identity)). |
53
+ | `owner_type` / `owner_id` | The user (nullable — `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
54
+ | `anchor_type` / `anchor_id` | The anchor record (nullable). |
55
+ | `scope_type` / `scope_id` | The portal scoping entity / tenant (nullable). |
56
+ | `engine` | The portal (engine class name) the run was launched in, e.g. `"OrgPortal::Engine"`. The "continue where you left off" listing only shows (and links) runs whose `engine` matches the portal being viewed (two portals can share an entity scope, so `scope` alone can't identify the portal). |
57
+ | `token` | The per-run id for guest/tokened (no-`concurrency_key`) instances (nullable). |
58
+ | `data` | Staged field values (JSON; `jsonb` on PostgreSQL). |
59
+ | `tracked_records` | GlobalIDs of records registered via `persist`, by step key. Exposed to authors as `persisted[:key]`. |
60
+ | `visited` | Visited step keys. |
61
+ | `expires_at` | Concrete expiry, stamped `now + cleanup_after` on every write (`nil` = `:never`). |
62
+ | `completed_at` | Completion timestamp. |
63
+
64
+ What the single table powers:
65
+
66
+ - **Resume** — look up the `in_progress` row by `instance_key`.
67
+ - **One-time check** — does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
68
+ - **In-progress listing** — by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
69
+ - **Multi-tenancy** — the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
70
+ - **Sweep** — idle `in_progress`/`completing` rows past `expires_at`.
71
+
72
+ ::: tip The `persisted` / `tracked_records` naming
73
+ The column is `tracked_records`, not `persisted` — an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
74
+ :::
75
+
76
+ ## Encryption
77
+
78
+ A wizard may opt into encrypting its staged field values at rest, for flows that stage PII:
79
+
80
+ ```ruby
81
+ class CheckoutWizard < Plutonium::Wizard::Base
82
+ encrypt_data
83
+ # ...
84
+ end
85
+ ```
86
+
87
+ This encrypts the `data` column (the staged step values) — off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
88
+
89
+ **Encrypt everything by default.** Once your app has ActiveRecord encryption keys, you can flip encryption on for *all* wizards with one global flag, then override per wizard:
90
+
91
+ ```ruby
92
+ config.wizards.encrypt_data = true # every wizard's `data` is encrypted at rest
93
+ ```
94
+
95
+ ```ruby
96
+ class PublicSurveyWizard < Plutonium::Wizard::Base
97
+ encrypt_data false # explicit opt-OUT, even when the global default is on
98
+ end
99
+ ```
100
+
101
+ Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys — see the warning below.
102
+
103
+ **How it works.** Because `data` is one shared `jsonb` column across all wizards (some opting in, some not), a static model-level `encrypts :data` doesn't fit (it would encrypt every row, and fights the `jsonb` type). Instead, the store encrypts at write time using **ActiveRecord's configured encryptor** (`ActiveRecord::Encryption.encryptor`, the same keys as `encrypts`) and stores a self-describing envelope inside the column:
104
+
105
+ ```json
106
+ { "_enc": "<ciphertext>" }
107
+ ```
108
+
109
+ A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?` — so toggling the flag never strands existing runs.
110
+
111
+ ::: warning Requires ActiveRecord encryption keys
112
+ `encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard — rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
113
+ :::
114
+
115
+ ## Files
116
+
117
+ A file can't sit in the JSON `data` column, so a wizard stages only the backend's **upload token** (an ActiveStorage `signed_id`, or Shrine cached-file data) and `execute` assigns it to the model's attachment. This works for both server-side and direct uploads, ActiveStorage and active_shrine. See [DSL › Attachment fields](/reference/wizard/dsl#attachment-fields) and the [guide](/guides/wizards#file-uploads-attachments) for the full surface (`backend:`, `multiple:`, direct upload).
118
+
119
+ A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it — the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
120
+
121
+ ## Cleanup & the SweepJob
122
+
123
+ `cleanup_after` stamps a concrete `expires_at` (`now + ttl`) on every write, so an actively-progressing wizard keeps pushing its expiry forward. A later change to the wizard's TTL never retroactively shifts existing rows. `cleanup_after :never` stores a null `expires_at`, opting out of sweeping (partial records persist by design).
124
+
125
+ `Plutonium::Wizard::SweepJob` reaps idle `in_progress` / `completing` rows past `expires_at`: for each it runs the wizard's cleanup (each step's `on_rollback` if declared, then always destroy every tracked record, in reverse order) and deletes the row. Completed rows are never touched. The job is idempotent and safe to re-run.
126
+
127
+ ::: tip The `completing` state and its grace window
128
+ A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window — long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
129
+ :::
130
+
131
+ ### SweepJob is load-bearing for save-as-you-go
132
+
133
+ ::: warning Schedule the SweepJob
134
+ - For **`execute`-only** wizards, an unscheduled sweep merely leaves stale session rows (harmless).
135
+ - For **`on_submit` (save-as-you-go)** wizards, the sweep is the **only** thing that cleans up abandoned real domain records. Without it, partial records accumulate forever.
136
+
137
+ Schedule `Plutonium::Wizard::SweepJob` as a recurring job (e.g. via your scheduler / cron / `solid_queue` recurring tasks) for any app that uses `on_submit`.
138
+ :::
139
+
140
+ ```ruby
141
+ # e.g. a recurring job
142
+ Plutonium::Wizard::SweepJob.perform_later
143
+ ```
144
+
145
+ On completion of a one-time wizard, the row is kept as the durable marker but its `data` / `tracked_records` are nulled out (privacy + size).
146
+
147
+ ## Related
148
+
149
+ - [Anchoring & resume](/reference/wizard/anchoring-resume) — `instance_key`, resume.
150
+ - [DSL reference](/reference/wizard/dsl) — `cleanup_after`, `encrypt_data`, `persist`.
151
+ - [One-time wizards](/reference/wizard/one-time) — durable completion markers.
@@ -169,7 +169,7 @@ module Plutonium
169
169
  UNGROUPED_KEY = :ungrouped
170
170
 
171
171
  # One declared section, or the implicit `ungrouped` bucket (empty `fields`).
172
- Section = Struct.new(:key, :fields, :options, keyword_init: true) do
172
+ Section = Struct.new(:key, :fields, :options) do
173
173
  def ungrouped? = key == UNGROUPED_KEY
174
174
  def label = options.fetch(:label) { key.to_s.humanize }
175
175
  def description = options[:description]
@@ -181,7 +181,7 @@ module Plutonium
181
181
 
182
182
  # A section paired with the concrete fields it will render (after policy
183
183
  # filtering). Produced by #resolve_form_sections.
184
- ResolvedSection = Struct.new(:section, :fields, keyword_init: true)
184
+ ResolvedSection = Struct.new(:section, :fields)
185
185
 
186
186
  # Collects section/ungrouped calls from a form_layout block in order.
187
187
  class Builder