plutonium 0.65.0 → 0.66.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +144 -133
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -2
@@ -4,7 +4,7 @@ How a wizard binds to an existing record (anchoring), how a running wizard is id
4
4
 
5
5
  ## Anchoring
6
6
 
7
- An **anchored** wizard runs against an existing record — the analogue of `attribute :resource` on an interaction. The anchor is read-only context, available from any step (and `condition:`/`on_submit`/`execute`) via the `anchor` accessor.
7
+ An **anchored** wizard runs against an existing record, the analogue of `attribute :resource` on an interaction. The anchor is read-only context, available from any step (and `condition:`/`on_submit`/`execute`) via the `anchor` accessor.
8
8
 
9
9
  ```ruby
10
10
  class ConfigureCompanyWizard < Plutonium::Wizard::Base
@@ -28,12 +28,12 @@ end
28
28
  | Declaration | Meaning |
29
29
  |---|---|
30
30
  | `anchored with: Company` | A single concrete type. |
31
- | `anchored with: [Company, Organization]` | Polymorphic — accepts any listed type. |
32
- | `anchored` (no `with:`) | Generic — the type binds at registration to whichever resource hosts it (shareable library wizard). |
33
- | *(omit `anchored`)* | No anchor — a pure data → create flow. |
31
+ | `anchored with: [Company, Organization]` | Polymorphic, accepts any listed type. |
32
+ | `anchored` (no `with:`) | Generic, the type binds at registration to whichever resource hosts it (shareable library wizard). |
33
+ | *(omit `anchored`)* | No anchor, a pure data → create flow. |
34
34
 
35
35
  ```ruby
36
- # A generic, shareable wizard — bound to a concrete type at registration.
36
+ # A generic, shareable wizard: bound to a concrete type at registration.
37
37
  class ArchiveWithReasonWizard < Plutonium::Wizard::Base
38
38
  anchored
39
39
 
@@ -59,15 +59,15 @@ anchor # => the record, for an anchored wizard
59
59
 
60
60
  `anchor` never returns `nil`. Anchored-vs-not is a static property of the wizard, so reaching for `anchor` when the wizard isn't `anchored` is a programming error, not a runtime condition to guard.
61
61
 
62
- The anchor is **not** part of `persisted` — `persisted` holds only records the wizard creates. The anchor is an input the wizard was launched against.
62
+ The anchor is **not** part of `persisted`; `persisted` holds only records the wizard creates. The anchor is an input the wizard was launched against.
63
63
 
64
64
  ### Anchor resolution per surface
65
65
 
66
66
  The wizard body never cares where the anchor came from; the launch surface resolves it:
67
67
 
68
- - **Record action** (`wizard :configure, ...` on a definition for a `with:`-anchored wizard) — auto-mounted as a **member route** (`/companies/:id/wizards/configure/:step`) on the resource controller. The anchor is resolved through that controller's scoped, policy-gated `resource_record!` — never an unscoped `find_by`, so a record outside the portal's authorized scope (or a non-existent id) 404s instead of leaking another tenant's record.
69
- - **Context anchor** (`anchored via: :method`) — mounted **portal-level** with `register_wizard`; the anchor is resolved by calling that method on the controller (e.g. `via: :current_scoped_entity` for the tenant). No URL `:id`, IDOR-safe (trusted context). An optional `with:` type-asserts the result.
70
- - **Collection action / create flow** — no anchor.
68
+ - **Record action** (`wizard :configure, ...` on a definition for a `with:`-anchored wizard), auto-mounted as a **member route** (`/companies/:id/wizards/configure/:step`) on the resource controller. The anchor is resolved through that controller's scoped, policy-gated `resource_record!`, never an unscoped `find_by`, so a record outside the portal's authorized scope (or a non-existent id) 404s instead of leaking another tenant's record.
69
+ - **Context anchor** (`anchored via: :method`), mounted **portal-level** with `register_wizard`; the anchor is resolved by calling that method on the controller (e.g. `via: :current_scoped_entity` for the tenant). No URL `:id`, IDOR-safe (trusted context). An optional `with:` type-asserts the result.
70
+ - **Collection action / create flow**: no anchor.
71
71
 
72
72
  ::: tip Anchored member routes are IDOR-safe by construction
73
73
  Because the anchor comes from `resource_record!` (the same scoped lookup CRUD and interactive record actions use), a `with:`-anchored wizard can only ever operate on a record the current user is authorized to see in this portal. A `via:`-anchored wizard is IDOR-safe by trusting the resolved context.
@@ -75,7 +75,7 @@ Because the anchor comes from `resource_record!` (the same scoped lookup CRUD an
75
75
 
76
76
  ## Instance identity
77
77
 
78
- Every running wizard has a deterministic **instance key** — a digest the session row is uniquely keyed by. There are two recipes, by identity axis ([see Identity, concurrency & repeatability](/reference/wizard/dsl)):
78
+ Every running wizard has a deterministic **instance key**, a digest the session row is uniquely keyed by. There are two recipes, by identity axis ([see Identity, concurrency & repeatability](/reference/wizard/dsl)):
79
79
 
80
80
  ```
81
81
  # concurrency_key set:
@@ -86,11 +86,11 @@ instance_key = SHA256(JSON([secret_key_base, "tokened", wizard, wizard_token]))
86
86
 
87
87
  The digest hashes the **JSON of a structured array**, not a flat-joined string, and is **salted with the app's `secret_key_base`**:
88
88
 
89
- - **`concurrency_key`** is serialized records → GID, scalars → string, arrays kept as a **nested structure (not joined)** — so two distinct keys can never collide into one row (`["a", "b"]` ≠ `"a|b"`). The **tenant (`current_scoped_entity`) is folded in automatically**, so the same user running the same keyed wizard in two tenant portals gets two distinct rows.
89
+ - **`concurrency_key`** is serialized records → GID, scalars → string, arrays kept as a **nested structure (not joined)**, so two distinct keys can never collide into one row (`["a", "b"]` ≠ `"a|b"`). The **tenant (`current_scoped_entity`) is folded in automatically**, so the same user running the same keyed wizard in two tenant portals gets two distinct rows.
90
90
  - **The `secret_key_base` salt** makes the digest a MAC over otherwise-public identifiers (the wizard name + key GIDs), so a run's existence/state can't be probed by recomputing the digest off-app.
91
- - **`wizard_token`** is the **per-run id** for runs with no `concurrency_key` — a fresh, unguessable token per launch makes each run distinct and repeatable. Its source depends on the run identity: an **authenticated** repeatable run carries it in the URL `:token` segment (guarded by [owner-scoping](#authentication)); a **guest (`anonymous`)** run keys off the **Rails session** (never the URL — no leak surface). It is **not** a pre-auth principal that survives login, and a wizard never crosses the auth boundary mid-flow.
91
+ - **`wizard_token`** is the **per-run id** for runs with no `concurrency_key`: a fresh, unguessable token per launch makes each run distinct and repeatable. Its source depends on the run identity: an **authenticated** repeatable run carries it in the URL `:token` segment (guarded by [owner-scoping](#authentication)); a **guest (`anonymous`)** run keys off the **Rails session** (never the URL), so there is no leak surface. It is **not** a pre-auth principal that survives login, and a wizard never crosses the auth boundary mid-flow.
92
92
 
93
- The owner, anchor, and scope are also stored as plain polymorphic columns (`owner_type`/`owner_id`, etc.) for listing and querying — but identity is the digest.
93
+ The owner, anchor, and scope are also stored as plain polymorphic columns (`owner_type`/`owner_id`, etc.) for listing and querying, but identity is the digest.
94
94
 
95
95
  ::: warning Rotating `secret_key_base` invalidates in-progress runs
96
96
  Because the salt is `secret_key_base`, rotating it changes every instance-key digest. In-progress runs become unresumable (their rows no longer match the recomputed key) and **one-time gates re-open** (the retained `completed` marker no longer matches). This only affects rows live at rotation time; new runs key off the new secret. Drain or accept the reset when rotating.
@@ -100,7 +100,7 @@ Because the salt is `secret_key_base`, rotating it changes every instance-key di
100
100
 
101
101
  A `concurrency_key`-keyed wizard's `in_progress` row **is the lock**: a second launch at the same key resumes it instead of forking. Look up the row by `instance_key`; if one exists, the user continues where they left off. A tokened (no `concurrency_key`) wizard resumes via its per-run id (carried in the URL `:token` segment for an authenticated run, or the Rails session for a guest run) and starts a fresh run otherwise.
102
102
 
103
- For a non-`anonymous` (authenticated) wizard, **every resume is owner-scoped**: a row may only be resumed by the user that owns it. A run id leaked in a URL can't be picked up by another logged-in user — the engine treats a foreign row as not-found (404). See [Authentication](#authentication).
103
+ For a non-`anonymous` (authenticated) wizard, **every resume is owner-scoped**: a row may only be resumed by the user that owns it. A run id leaked in a URL can't be picked up by another logged-in user, the engine treats a foreign row as not-found (404). See [Authentication](#authentication).
104
104
 
105
105
  ### Listing in-progress wizards
106
106
 
@@ -109,39 +109,40 @@ Plutonium::Wizard.in_progress_for(view_context, anchor: nil, wizard: nil)
109
109
  # → Array<Resume::Entry>, newest-first (delegates to Resume.entries_for)
110
110
  ```
111
111
 
112
- Like interactions, it takes the `view_context` and derives everything from it — the run **owner** (`current_user`), the **tenant scope** (`current_scoped_entity` when `scoped_to_entity?`, else `nil`), and the **portal** — returning that owner's in-progress runs **for the current portal**.
112
+ Like interactions, it takes the `view_context` and derives everything from it (the run **owner** (`current_user`), the **tenant scope** (`current_scoped_entity` when `scoped_to_entity?`, else `nil`), and the **portal**) returning that owner's in-progress runs **for the current portal**.
113
113
 
114
114
  **Each `entry` exposes:** `label`, `icon`, `current_step` (+ `current_step_label`), `updated_at`, `resume_url` (or `nil`), `resume_unresolved_reason`, `wizard_class`, and the raw `session` row.
115
115
 
116
- **Portal scoping.** A run is only ever listed (and linked) by the portal it was launched in — a non-scoped portal lists only unscoped runs; a scoped portal narrows to the current tenant. Two portals can share an entity scope, so the launching portal (the `engine` column) is recorded per-run because scope alone can't identify it.
116
+ **Portal scoping.** A run is only ever listed (and linked) by the portal it was launched in; a non-scoped portal lists only unscoped runs; a scoped portal narrows to the current tenant. Two portals can share an entity scope, so the launching portal (the `engine` column) is recorded per-run because scope alone can't identify it.
117
117
 
118
118
  **`resume_url`** is built through the current portal's routes:
119
119
  - `wizard`-macro **anchored** mount → `resource_url_for(record, wizard:, step:)`.
120
120
  - `register_wizard` mount → the named route (with the scope segment, and the `:token` for tokened runs).
121
121
  - Unresolvable here (e.g. a non-anchored `wizard`-macro run, whose resource identity isn't on the row) → `resume_url: nil` + a `resume_unresolved_reason` string. Render those without a link rather than guessing.
122
122
 
123
- **`anchor:` / `wizard:` filters** (for a per-record resume widget — "does this record have an unfinished draft of wizard X?"):
124
- - They narrow **in the query, before enrichment**, so discarded rows are never resume-URL-resolved or anchor-loaded — cheaper than filtering the returned array.
123
+ **`anchor:` / `wizard:` filters** (for a per-record resume widget, "does this record have an unfinished draft of wizard X?"):
124
+ - They narrow **in the query, before enrichment**, so discarded rows are never resume-URL-resolved or anchor-loaded, which is cheaper than filtering the returned array.
125
125
  - They compose, and the `wizard + anchor` pair is index-covered: `in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: record).first`. `wizard:` takes the wizard **class**.
126
- - For ad-hoc post-filtering the array still works (`select { |e| e.wizard_class == X }` is free — the class is on each entry). Avoid filtering on `e.session.anchor` (a polymorphic load per row) — use `anchor:`.
126
+ - Enrichment is the costly part: every returned row gets its resume and cancel URLs resolved and its anchor loaded. Fetching the full list and then `select { |e| e.wizard_class == X }` pays that cost for every draft you throw away, so pass `wizard:` instead. Avoid filtering on `e.session.anchor` (a polymorphic load per row); use `anchor:`.
127
+ - The one case for post-filtering: the **same render** already calls the unfiltered `in_progress_for` (a full "continue where you left off" list plus a per-wizard callout on one page). Those entries are already enriched, so `select` on `e.wizard_class` is free, while a second `wizard:` call would query and enrich those rows again.
127
128
 
128
129
  See the [guide](/guides/wizards#listing-in-progress-wizards) for a worked dashboard example.
129
130
 
130
131
  ### The implied anchored key
131
132
 
132
- An `anchored` wizard with **no explicit `concurrency_key`** is keyed by default — `{ [anchor, current_user] }`, with the tenant folded in. So an anchored wizard, out of the box, is **one in-progress draft per user per record**: re-launching it for the same record resumes your draft, and two users editing the same record get **independent** runs (no collision).
133
+ An `anchored` wizard with **no explicit `concurrency_key`** is keyed by default, `{ [anchor, current_user] }`, with the tenant folded in. So an anchored wizard, out of the box, is **one in-progress draft per user per record**: re-launching it for the same record resumes your draft, and two users editing the same record get **independent** runs (no collision).
133
134
 
134
- This is the right default because anchoring without keying is a footgun in both directions: a *tokened* anchored wizard forks a new run on every launch, while `{ anchor }` (record-only) keys across users — so a second user editing the same record collides with the first and is owner-scoped out (a 404). The implied key threads the needle. The anchor's GlobalID is already globally unique (and pins the tenant for a tenant-scoped record), so `[anchor, current_user]` is the full identity; the auto-folded tenant is redundant there but load-bearing for non-anchor keys.
135
+ This is the right default because anchoring without keying is a footgun in both directions: a *tokened* anchored wizard forks a new run on every launch, while `{ anchor }` (record-only) keys across users, so a second user editing the same record collides with the first and is owner-scoped out (a 404). The implied key threads the needle. The anchor's GlobalID is already globally unique (and pins the tenant for a tenant-scoped record), so `[anchor, current_user]` is the full identity; the auto-folded tenant is redundant there but load-bearing for non-anchor keys.
135
136
 
136
137
  Override when you want different semantics:
137
- - `concurrency_key { anchor }` — a true singleton: **one run per record, any user** ("configure this once, by anyone"). A concurrent second user is blocked (owner-scoped) until the first finishes.
138
- - `concurrency_key { wizard_token }` — make the anchored wizard **repeatable** (a fresh run per launch).
138
+ - `concurrency_key { anchor }`: a true singleton: **one run per record, any user** ("configure this once, by anyone"). A concurrent second user is blocked (owner-scoped) until the first finishes.
139
+ - `concurrency_key { wizard_token }`: make the anchored wizard **repeatable** (a fresh run per launch).
139
140
 
140
- Anonymous (guest) anchored wizards are exempt — a guest has no real user to key by, so they stay session-tokened.
141
+ Anonymous (guest) anchored wizards are exempt: a guest has no real user to key by, so they stay session-tokened.
141
142
 
142
143
  ### Relaunching a tokened wizard
143
144
 
144
- A keyed wizard auto-resumes (its keyed row is the lock), so a bare launch always continues the single in-progress run. A **tokened** wizard has no such single run — each bare launch could mint a fresh one. By default it doesn't silently fork: it prompts. Opt out for flows that should always start clean:
145
+ A keyed wizard auto-resumes (its keyed row is the lock), so a bare launch always continues the single in-progress run. A **tokened** wizard has no such single run: each bare launch could mint a fresh one. By default it doesn't silently fork: it prompts. Opt out for flows that should always start clean:
145
146
 
146
147
  ```ruby
147
148
  on_relaunch :new
@@ -149,13 +150,13 @@ on_relaunch :new
149
150
 
150
151
  With the default (`on_relaunch :prompt`), a bare launch (e.g. `GET /onboarding`) checks the user's pending runs (owner- and tenant-scoped, via the same listing as above). If any exist, it renders a **"resume or start new" page** (each pending run with a Resume link, plus a **Start new** button) instead of silently discarding that in-progress work. With no pending runs it starts fresh as usual, and **Start new** (the bare launch URL with `?new=1`) always forces a fresh run. Because the chooser only appears when a pending run exists, `:prompt` is a safe superset of `:new`. Use `on_relaunch :new` to opt out (always fork a fresh run) for flows meant to be run repeatedly from scratch.
151
152
 
152
- This only applies to authenticated tokened wizards: keyed wizards already auto-resume, and `anonymous` (guest) runs are session-keyed to a single run — `on_relaunch` is a no-op for both.
153
+ This only applies to authenticated tokened wizards: keyed wizards already auto-resume, and `anonymous` (guest) runs are session-keyed to a single run; `on_relaunch` is a no-op for both.
153
154
 
154
155
  On resume the engine:
155
156
 
156
157
  - Restores the step cursor and `data` (typed snapshot rehydrated from the JSON column).
157
- - Re-renders the current step's form seeded from staged `data` — including repeater rows (a `structured_input ..., repeat:` step re-renders the right number of filled rows, not one blank row).
158
- - Lazily rehydrates `persisted[:key]` from stored GlobalIDs on first access (memoized per request), so a per-step `on_submit` create flow returning later still sees records made by earlier steps — without paying a `GlobalID.locate` on requests that never read `persisted`.
158
+ - Re-renders the current step's form seeded from staged `data`, including repeater rows (a `structured_input ..., repeat:` step re-renders the right number of filled rows, not one blank row).
159
+ - Lazily rehydrates `persisted[:key]` from stored GlobalIDs on first access (memoized per request), so a per-step `on_submit` create flow returning later still sees records made by earlier steps, without paying a `GlobalID.locate` on requests that never read `persisted`.
159
160
 
160
161
  Navigation never loses data: **Back** moves the cursor without validating and never discards `data`. A step whose answer is later **un-chosen** (its `condition:` flips false) leaves the visible path and is **fully pruned**: its staged `data` is dropped, and if its `on_submit` had **persisted records** (save-as-you-go) those records are **rolled back**: its `on_rollback` runs first if declared (additive side-effect cleanup), then the engine **always** destroys them, so nothing is orphaned. The step's `persisted` / `data` / `visited` state is cleared, so re-entering that branch re-runs its `on_submit` from scratch. Pruning fires as soon as the branch is hidden (during the advance that flips it) and again as a safety net at finalize.
161
162
 
@@ -179,7 +180,7 @@ class GuestSignupWizard < Plutonium::Wizard::Base
179
180
 
180
181
  def execute # the ONE boundary a guest wizard may cross
181
182
  account = Account.create!(email: data.account.email)
182
- # sign the account in here (the host calls Rodauth) — no special framework handling
183
+ # sign the account in here (the host calls Rodauth), no special framework handling
183
184
  succeed(account)
184
185
  end
185
186
  end
@@ -189,6 +190,6 @@ An `anonymous` wizard must be [mounted on a public route](/reference/wizard/regi
189
190
 
190
191
  ## Related
191
192
 
192
- - [DSL reference](/reference/wizard/dsl) — `anchored`, `anchor`, `persisted`.
193
- - [Storage & config](/reference/wizard/storage-config) — the session table + columns.
194
- - [One-time wizards](/reference/wizard/one-time) — durable completion + the gate.
193
+ - [DSL reference](/reference/wizard/dsl): `anchored`, `anchor`, `persisted`.
194
+ - [Storage & config](/reference/wizard/storage-config): the session table + columns.
195
+ - [One-time wizards](/reference/wizard/one-time): durable completion + the gate.
@@ -1,37 +1,37 @@
1
1
  # Wizard DSL
2
2
 
3
3
  ::: warning Experimental
4
- Wizards are experimental — the DSL and behavior may change in a future release.
4
+ Wizards are experimental, the DSL and behavior may change in a future release.
5
5
  :::
6
6
 
7
- A wizard is a Ruby class — `class X < Plutonium::Wizard::Base`. It declares ordered `step`s, an optional terminal `review` step, wizard-level options, and an `execute` commit hook. This page is the full reference for the author-facing DSL.
7
+ A wizard is a Ruby class, `class X < Plutonium::Wizard::Base`. It declares ordered `step`s, an optional terminal `review` step, wizard-level options, and an `execute` commit hook. This page is the full reference for the author-facing DSL.
8
8
 
9
9
  For task-oriented walkthroughs, start with the [Wizards guide](/guides/wizards).
10
10
 
11
11
  ## 🚨 Critical
12
12
 
13
- - **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a raised exception — a non-bang `false` return advances the wizard and silently loses data.
13
+ - **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a raised exception, a non-bang `false` return advances the wizard and silently loses data.
14
14
  - **`condition:` lambdas must be nil-safe.** They run against the typed `data` snapshot at every transition, including before their deciding step is filled (`nil`).
15
15
  - **`review` must be the last step.** Declaring a step after `review` raises at load time.
16
- - **`using:` targets a model only** — not an interaction, not a bare definition.
17
- - **`execute` returns an Outcome** — `succeed(...)` / `failed(...)`, or raise to fail.
16
+ - **`using:` targets a model only**: not an interaction, not a bare definition.
17
+ - **`execute` returns an Outcome**: `succeed(...)` / `failed(...)`, or raise to fail.
18
18
 
19
19
  ## Wizard-level macros
20
20
 
21
21
  | Macro | Meaning |
22
22
  |---|---|
23
23
  | `presents label:, icon:, description:` | The launch button's label + icon (same as interactions), plus an optional `description:` rendered as the wizard's header subheading. |
24
- | `navigation :linear \| :free` | Stepper jump policy. `:linear` (default) — back to any visited step; `:free` — any visible visited step. Forward jumps to unvisited steps are never allowed. |
24
+ | `navigation :linear \| :free` | Stepper jump policy. `:linear` (default): back to any visited step; `:free`: any visible visited step. Forward jumps to unvisited steps are never allowed. |
25
25
  | `stepper false` | Hide the top rail (the step indicator). On by default. |
26
26
  | `on_relaunch :new` | Controls a bare relaunch of a **tokened** wizard when the user has pending (in-progress) runs. Default `:prompt` shows a "resume or start new" chooser instead of silently forking; `:new` opts out and always mints a fresh run. No-op for keyed/`anonymous` wizards (they already auto-resume their single run). See [Anchoring & resume](/reference/wizard/anchoring-resume#relaunching-a-tokened-wizard). |
27
27
  | `anchored with: Model` / `anchored via: :method` | Run against an existing record; read via `anchor`. `with:` resolves from the URL `:id` (resource-mounted); `via:` resolves by calling a controller method (portal-level, context-anchored). See [Anchoring & resume](/reference/wizard/anchoring-resume). |
28
28
  | `cleanup_after <ttl> \| :never` | Idle TTL before the abandonment sweep reaps a session and rolls back its tracked records. Defaults to `config.wizards.cleanup_after`. `:never` opts out. |
29
- | `concurrency_key { … }` / `concurrency_key :method` | Key a run by the returned value(s) (records → GID, scalars → string, arrays serialized element-wise — structured, **not** flat-joined; the tenant is folded in automatically). The keyed `in_progress` row is the lock — a second launch at the same key resumes, never forks. Omit → unlimited concurrent `wizard_token`-keyed runs — **except** an `anchored` wizard, which defaults to `{ [anchor, current_user] }` (one draft per user per record). See [Anchoring & resume](/reference/wizard/anchoring-resume#the-implied-anchored-key). |
29
+ | `concurrency_key { … }` / `concurrency_key :method` | Key a run by the returned value(s) (records → GID, scalars → string, arrays serialized element-wise, structured, **not** flat-joined; the tenant is folded in automatically). The keyed `in_progress` row is the lock, a second launch at the same key resumes, never forks. Omit → unlimited concurrent `wizard_token`-keyed runs, **except** an `anchored` wizard, which defaults to `{ [anchor, current_user] }` (one draft per user per record). See [Anchoring & resume](/reference/wizard/anchoring-resume#the-implied-anchored-key). |
30
30
  | `one_time` | Retain the completed row at the `concurrency_key` (blocks restart, gate-able). **Requires a `concurrency_key`.** Omit → row deleted on completion (repeatable). See [One-time wizards](/reference/wizard/one-time). |
31
31
  | `completed do \|wizard\| … end` | Custom body for the "already completed" page a finished **one-time** wizard shows when re-opened (replaces the default confirmation). See [`completed`](#completed) below and [One-time wizards](/reference/wizard/one-time#re-opening-a-completed-wizard). |
32
- | `encrypt_data` | Encrypt the staged `data` column at rest using ActiveRecord's encryption keys (off by default), for flows that stage PII. Requires `active_record.encryption` keys — see [Storage & config](/reference/wizard/storage-config#encryption). |
33
- | `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full`. Inherits to subclasses; otherwise `config.wizards.width` (default `:md`). Deliberately independent of `config.default_page_width` — see [Storage & config](/reference/wizard/storage-config). |
34
- | `anonymous` | Opt into **guest (unauthenticated) access** — the wizard runs pre-login (auth is required otherwise). The guest's identity is a server-minted run-id in the Rails session; it crosses the auth boundary only at its terminal `execute`. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`** — a guest is already session-keyed and repeatable, so declaring both raises (whichever is declared last). See [Authentication](/reference/wizard/anchoring-resume#authentication). |
32
+ | `encrypt_data` | Encrypt the staged `data` column at rest using ActiveRecord's encryption keys (off by default), for flows that stage PII. Requires `active_record.encryption` keys, see [Storage & config](/reference/wizard/storage-config#encryption). |
33
+ | `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full`. Inherits to subclasses; otherwise `config.wizards.width` (default `:md`). Deliberately independent of `config.default_page_width`, see [Storage & config](/reference/wizard/storage-config). |
34
+ | `anonymous` | Opt into **guest (unauthenticated) access**: the wizard runs pre-login (auth is required otherwise). The guest's identity is a server-minted run-id in the Rails session; it crosses the auth boundary only at its terminal `execute`. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`**: a guest is already session-keyed and repeatable, so declaring both raises (whichever is declared last). See [Authentication](/reference/wizard/anchoring-resume#authentication). |
35
35
 
36
36
  ```ruby
37
37
  class CompanyOnboardingWizard < Plutonium::Wizard::Base
@@ -76,17 +76,17 @@ The block is optional only when `using:` supplies everything.
76
76
 
77
77
  A step's block is the same field DSL used on definitions and interactions:
78
78
 
79
- - `attribute :name, :type` — declares a typed attribute (feeds the `data` snapshot).
80
- - `input :name, as:, ...` — how the field renders.
81
- - `validates :name, ...` — ActiveModel validations, run on Next. These also drive the form's field affordances exactly like a resource form: a `presence` validation renders the required marker (`*`), and `length`/`numericality`/`format`/`inclusion` feed `maxlength`/`min`/`max`/`pattern`/auto-choices. Validations imported via `using:` surface these too.
82
- - `structured_input :name, repeat: N do |f| ... end` — a repeatable/structured group → `data.<step>.name` is an array of typed sub-objects. The sub-fields can come from the block (above), or from a model via `using:` / `fields:` (same selectors as a step's `using:`) instead of a block.
83
- - `form_layout do ... end` — section the step's fields (`section`, `columns:`, `collapsible:`, etc.), scoped to this step.
79
+ - `attribute :name, :type`: declares a typed attribute (feeds the `data` snapshot).
80
+ - `input :name, as:, ...`: how the field renders.
81
+ - `validates :name, ...`: ActiveModel validations, run on Next. These also drive the form's field affordances exactly like a resource form: a `presence` validation renders the required marker (`*`), and `length`/`numericality`/`format`/`inclusion` feed `maxlength`/`min`/`max`/`pattern`/auto-choices. Validations imported via `using:` surface these too.
82
+ - `structured_input :name, repeat: N do |f| ... end`: a repeatable/structured group → `data.<step>.name` is an array of typed sub-objects. The sub-fields can come from the block (above), or from a model via `using:` / `fields:` (same selectors as a step's `using:`) instead of a block.
83
+ - `form_layout do ... end`: section the step's fields (`section`, `columns:`, `collapsible:`, etc.), scoped to this step.
84
84
 
85
85
  See [plutonium-resource › Definition](/reference/resource/definition) for the full field/input/layout vocabulary.
86
86
 
87
87
  #### Runtime input options
88
88
 
89
- A step's fields are fixed when the class loads, so a **proc** is how an option depends on the run. Proc-valued field/input options are resolved on every render, and the proc **takes the form** — `form.wizard` is the run, `form.object` is that step's staged data:
89
+ A step's fields are fixed when the class loads, so a **proc** is how an option depends on the run. Proc-valued field/input options are resolved on every render, and the proc **takes the form**: `form.wizard` is the run, `form.object` is that step's staged data:
90
90
 
91
91
  ```ruby
92
92
  step :plan do
@@ -104,15 +104,15 @@ input :answers, as: MyManifestComponent, config: ->(form) { form.wizard.anchor.m
104
104
  ```
105
105
 
106
106
  ::: warning Take the argument
107
- A **zero-argument** proc here will not work. It keeps its own binding — the same rule as [everywhere else](/reference/resource/definition#options-that-vary-per-render) — and a step block is `instance_exec`'d against an internal field recorder when the class loads, so `-> { anchor.available_tiers }` looks `anchor` up on that recorder and raises `NameError`.
107
+ A **zero-argument** proc here will not work. It keeps its own binding (the same rule as [everywhere else](/reference/resource/definition#options-that-vary-per-render)), and a step block is `instance_exec`'d against an internal field recorder when the class loads, so `-> { anchor.available_tiers }` looks `anchor` up on that recorder and raises `NameError`.
108
108
 
109
109
  That is deliberate: it is the same trade a `form_layout` section option makes, and it means an `input` line keeps its meaning when moved between a definition and a step. Nothing silently swaps `self`.
110
110
  :::
111
111
 
112
112
  Two limits worth knowing:
113
113
 
114
- - The **field set** is still fixed at class load. A proc varies an option, not which fields exist. To collect a shape known only at runtime, declare one `structured_input` and let a custom component render the inner controls. For bespoke markup, pass a block to `input` instead — it renders in the form's context with the field yielded.
115
- - `condition:` is **not** resolved this way, because it is not an option — it asks "should this render here, now?", so it always runs *against* the thing doing the rendering and reads it with no argument. A *step's* `condition:` runs against the **wizard** (it is evaluated in the runner to decide which steps exist, before any form is built — which is exactly why it can't be handed a form); a *field's* runs against the **form**, where `object` is that step's staged data. See [Definition › `condition:` is not an option](/reference/resource/definition#condition-is-not-an-option).
114
+ - The **field set** is still fixed at class load. A proc varies an option, not which fields exist. To collect a shape known only at runtime, declare one `structured_input` and let a custom component render the inner controls. For bespoke markup, pass a block to `input` instead, it renders in the form's context with the field yielded.
115
+ - `condition:` is **not** resolved this way, because it is not an option (it asks "should this render here, now?", so it always runs *against* the thing doing the rendering and reads it with no argument. A *step's* `condition:` runs against the **wizard** (it is evaluated in the runner to decide which steps exist, before any form is built) which is exactly why it can't be handed a form); a *field's* runs against the **form**, where `object` is that step's staged data. See [Definition › `condition:` is not an option](/reference/resource/definition#condition-is-not-an-option).
116
116
 
117
117
  ### `using:` a model
118
118
 
@@ -132,7 +132,7 @@ What gets imported:
132
132
  | Source | Imported |
133
133
  |---|---|
134
134
  | `Model.attribute_names` / `attribute_types` | The field universe + cast types. |
135
- | `<Model>Definition` (auto-resolved) | Input styling (`as:`, options, labels). Best-effort — no definition is fine. |
135
+ | `<Model>Definition` (auto-resolved) | Input styling (`as:`, options, labels). Best-effort, no definition is fine. |
136
136
  | Transient `Model.new(slice).valid?` | Validations, keeping errors on imported fields + `:base`. |
137
137
  | `<Model>Definition#form_layout` | Section layout, filtered to imported fields. |
138
138
 
@@ -144,10 +144,10 @@ What gets imported:
144
144
  | `layout: false` | Skip inherited `form_layout` (default single grid). |
145
145
  | `validation_context:` | Run `valid?(context)` for context-scoped model validations. |
146
146
 
147
- **Declaration reuse only** — `using:` never pulls in the model's persistence or callbacks. Data stages into `data`; your `execute`/`on_submit` does the writes.
147
+ **Declaration reuse only**: `using:` never pulls in the model's persistence or callbacks. Data stages into `data`; your `execute`/`on_submit` does the writes.
148
148
 
149
149
  ::: tip Why a model, not a definition
150
- A `Plutonium::Resource::Definition` carries no link to its model — the controller binds them at request time. The only reliable direction is **model → definition**, so the model is the reuse target, and `<Model>Definition` is auto-resolved from it for styling.
150
+ A `Plutonium::Resource::Definition` carries no link to its model; the controller binds them at request time. The only reliable direction is **model → definition**, so the model is the reuse target, and `<Model>Definition` is auto-resolved from it for styling.
151
151
  :::
152
152
 
153
153
  ### Attachment fields
@@ -162,31 +162,31 @@ step :photo, label: "Photo" do
162
162
  end
163
163
  ```
164
164
 
165
- `data` is JSON staged across requests, so a file can't ride along — only its backend **token** (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data). `execute` assigns the token to the model's attachment natively (`model.photo.attach(data.photo.photo)` for AS, `model.update!(photo: data.photo.photo)` for active_shrine). The review summary and the input preview (on Back/resume) resolve the token to a displayable attachment automatically.
165
+ `data` is JSON staged across requests, so a file can't ride along: only its backend **token** (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data). `execute` assigns the token to the model's attachment natively (`model.photo.attach(data.photo.photo)` for AS, `model.update!(photo: data.photo.photo)` for active_shrine). The review summary and the input preview (on Back/resume) resolve the token to a displayable attachment automatically.
166
166
 
167
167
  | | Declare | Behaviour |
168
168
  |---|---|---|
169
169
  | **Server-side** (default) | `as: :file` | file submitted with the step; the wizard uploads it to the backend cache while staging. AS *and* active_shrine. |
170
170
  | **Direct upload** | `as: :uppy, direct_upload: true, endpoint:` | browser uploads to the endpoint, posts a token (async UI). |
171
171
  | **Backend** (server-side) | `backend: :active_storage` / `:shrine` | defaults to `config.wizards.attachment_backend` (auto-detects active_shrine, else AS). **Must match the model** `execute` assigns to. |
172
- | **Uploader** (Shrine only) | `uploader: PhotoUploader` | cache the file through a specific Shrine uploader (its cache-stage plugins — mime/dimension extraction, `generate_location`, processing — run instead of base `Shrine`'s). The minted token stays uploader-agnostic, so display + promotion are unaffected. Accepts a class or a class-name string; raises for the AS backend. Server-side staging only (direct upload configures the uploader at its endpoint). Its **validations are enforced on the step** — see the note below. |
172
+ | **Uploader** (Shrine only) | `uploader: PhotoUploader` | cache the file through a specific Shrine uploader (its cache-stage plugins, mime/dimension extraction, `generate_location`, processing, run instead of base `Shrine`'s). The minted token stays uploader-agnostic, so display + promotion are unaffected. Accepts a class or a class-name string; raises for the AS backend. Server-side staging only (direct upload configures the uploader at its endpoint). Its **validations are enforced on the step**, see the note below. |
173
173
  | **Multiple** | array attribute + `multiple: true` | staged value is an array of tokens. |
174
174
 
175
175
  ::: tip Uploader validations are enforced on the step
176
- A Shrine file field is validated **on its step** against the field's **effective uploader** — its `uploader:` if given, else base `Shrine` (whichever carries the `Attacher.validate` rules). A file that violates them is rejected right there (a field error + re-render), exactly like a `validates` — *not* deferred to `execute`. Mechanically: `Uploader.upload` caches the file (running no validations), then the step's validation pass runs the attacher's validations against the staged token. This needs Shrine's optional `validation`/`validation_helpers` plugin — without it there's nothing to enforce and it's a clean no-op. ActiveStorage fields are likewise unaffected.
176
+ A Shrine file field is validated **on its step** against the field's **effective uploader**, its `uploader:` if given, else base `Shrine` (whichever carries the `Attacher.validate` rules). A file that violates them is rejected right there (a field error + re-render), exactly like a `validates`, *not* deferred to `execute`. Mechanically: `Uploader.upload` caches the file (running no validations), then the step's validation pass runs the attacher's validations against the staged token. This needs Shrine's optional `validation`/`validation_helpers` plugin, without it there's nothing to enforce and it's a clean no-op. ActiveStorage fields are likewise unaffected.
177
177
  :::
178
178
 
179
179
  ## Per-step hooks
180
180
 
181
- `execute` is the default commit point (atomic, at the end). Per-step `on_submit` is opt-in save-as-you-go — use it only when a real record must exist mid-flow.
181
+ `execute` is the default commit point (atomic, at the end). Per-step `on_submit` is opt-in save-as-you-go, use it only when a real record must exist mid-flow.
182
182
 
183
183
  ### `on_submit`
184
184
 
185
185
  Runs in its own transaction when the step completes (after its fields validate). Inside it:
186
186
 
187
- - `persist record` (or a list) — register record(s) the engine tracks for resume + cleanup → `persisted[:step_key]`.
188
- - `fail!("message")` — abort with a base (form-level) error.
189
- - `fail!(:field, "message")` — abort with a field-level error.
187
+ - `persist record` (or a list), register record(s) the engine tracks for resume + cleanup → `persisted[:step_key]`.
188
+ - `fail!("message")`: abort with a base (form-level) error.
189
+ - `fail!(:field, "message")`: abort with a field-level error.
190
190
 
191
191
  ```ruby
192
192
  on_submit do
@@ -232,13 +232,13 @@ What it renders depends on completion state and the `summary:` / block options:
232
232
  |---|---|
233
233
  | `label:` | The review step's label (default `"Review"`). |
234
234
  | `description:` | Optional sub-label under the review heading. |
235
- | `summary:` | Show the auto-summary of completed steps (default `true`). When `false`, the complete-state body is your block — or the built-in "ready to complete" panel if there's no block. The summary always renders in the incomplete state. |
235
+ | `summary:` | Show the auto-summary of completed steps (default `true`). When `false`, the complete-state body is your block, or the built-in "ready to complete" panel if there's no block. The summary always renders in the incomplete state. |
236
236
  | `header:` | Show the step-header section (the label plus the "check everything over" prompt, which only appears when the summary is shown) above the body (default `true`). `false` drops it for a chromeless finish. |
237
237
 
238
238
  The auto-summary renders each field through the display pipeline, honoring the input's declared `as:`/`label:`. An input hidden by its own `condition:` is left out, just as it was left off the step, so the summary never shows an empty row for a question the user wasn't asked:
239
239
 
240
240
  - a **choice input** (`select`/`radio_buttons` with `choices:`) resolves the stored value back to its label (`"pro"` → `"Pro"`) using the same choice mapper the form uses, so the recap matches what the user picked;
241
- - an **`as: :currency`** input formats the value as currency (`"1500.5"` → `"$1,500.50"`) rather than echoing a bare decimal — pass `unit:` on the input (the data snapshot has no `has_cents` reflection to infer it from).
241
+ - an **`as: :currency`** input formats the value as currency (`"1500.5"` → `"$1,500.50"`) rather than echoing a bare decimal, pass `unit:` on the input (the data snapshot has no `has_cents` reflection to infer it from).
242
242
 
243
243
  ```ruby
244
244
  review label: "Review & submit" # auto-summary + gated finish
@@ -256,10 +256,10 @@ review summary: false, header: false # fully chromeless → "re
256
256
 
257
257
  ### The custom block's render context
258
258
 
259
- The block runs **in the Phlex view context** (`self` is the rendering component), not the controller — that's what lets it emit markup. So you can:
259
+ The block runs **in the Phlex view context** (`self` is the rendering component), not the controller; that's what lets it emit markup. So you can:
260
260
 
261
- - **return a String** (the simplest case) — it renders as the block's text;
262
- - **emit Phlex** directly — `div`, `span`, `plain`, `render SomeComponent.new(...)`;
261
+ - **return a String** (the simplest case); it renders as the block's text;
262
+ - **emit Phlex** directly, `div`, `span`, `plain`, `render SomeComponent.new(...)`;
263
263
  - reach **view / route helpers** via `helpers.*` (e.g. `helpers.link_to`, `helpers.current_user`, a path helper).
264
264
 
265
265
  The block is **yielded the wizard**, so `wizard.data`, `wizard.anchor`, `wizard.persisted`, and `wizard.current_user` are all in hand.
@@ -269,7 +269,7 @@ review label: "Review & submit" do |wizard|
269
269
  div(class: "text-sm") do
270
270
  plain "Billing to "
271
271
  strong { wizard.data.company.name }
272
- plain " — "
272
+ plain ", "
273
273
  plain helpers.link_to("see our terms", helpers.terms_path)
274
274
  end
275
275
  end
@@ -285,9 +285,9 @@ completed do |wizard|
285
285
  end
286
286
  ```
287
287
 
288
- A custom body for the **"already completed" page** — what a finished [one-time wizard](/reference/wizard/one-time#re-opening-a-completed-wizard) shows when a user re-opens it. On completion a one-time wizard retains its row but clears the `data`, so there's nothing to review; re-entry renders this standalone page instead of re-running the flow. Only meaningful for one-time wizards (repeatable ones leave no completed row, so re-launching just starts fresh).
288
+ A custom body for the **"already completed" page**: what a finished [one-time wizard](/reference/wizard/one-time#re-opening-a-completed-wizard) shows when a user re-opens it. On completion a one-time wizard retains its row but clears the `data`, so there's nothing to review; re-entry renders this standalone page instead of re-running the flow. Only meaningful for one-time wizards (repeatable ones leave no completed row, so re-launching just starts fresh).
289
289
 
290
- Without `completed`, a built-in confirmation renders (a success badge, the wizard's label, a short message, and a Continue button out). The block **replaces that body entirely** — you supply your own content (and your own way out):
290
+ Without `completed`, a built-in confirmation renders (a success badge, the wizard's label, a short message, and a Continue button out). The block **replaces that body entirely**: you supply your own content (and your own way out):
291
291
 
292
292
  ```ruby
293
293
  class WelcomeWizard < Plutonium::Wizard::Base
@@ -315,13 +315,13 @@ def execute
315
315
  end
316
316
  ```
317
317
 
318
- - Returns a `succeed(value)` / `failed(errors)` Outcome (the same Outcome interactions use — `.with_message`, `.with_redirect_response`, etc. all work).
318
+ - Returns a `succeed(value)` / `failed(errors)` Outcome (the same Outcome interactions use, `.with_message`, `.with_redirect_response`, etc. all work).
319
319
  - **Use bang methods** so a failure raises. The engine catches `ActiveRecord::RecordInvalid` (→ field errors) and `Plutonium::Wizard::StepError` (→ base error via `fail!`); any other error re-raises as a 500.
320
320
  - On success the wizard marks the session completed, clears `data`/`persisted`, and redirects (PRG) so a back-button replay can't re-run `execute`.
321
321
 
322
- ## Entry authorization — `authorize?`
322
+ ## Entry authorization: `authorize?`
323
323
 
324
- A portal-level (standalone) wizard has no resource policy, so gate entry by defining an `authorize?` instance method. The controller checks it before each request; a falsy return → `ActionPolicy::Unauthorized` (403).
324
+ A `register_wizard` wizard (portal or main-app) has no resource policy, so gate entry by defining an `authorize?` instance method. The controller checks it before each request; a falsy return → `ActionPolicy::Unauthorized` (403). The default is `def authorize? = true`, so define it on every `register_wizard` wizard, including gated `one_time` ones.
325
325
 
326
326
  ```ruby
327
327
  def authorize?
@@ -330,7 +330,7 @@ end
330
330
  ```
331
331
 
332
332
  ::: warning As-built: `authorize?` is an instance method
333
- Define `def authorize?` on the wizard. (Resource-attached wizards instead use their action's policy predicate — see [Registration & launch](/reference/wizard/registration-launch).)
333
+ Define `def authorize?` on the wizard. (Resource-attached wizards instead use their action's policy predicate, see [Registration & launch](/reference/wizard/registration-launch).)
334
334
  :::
335
335
 
336
336
  ## Accessors
@@ -339,12 +339,12 @@ Available inside steps, `condition:`, `on_submit`, `on_rollback`, and `execute`:
339
339
 
340
340
  | Accessor | Returns |
341
341
  |---|---|
342
- | `data` | Typed, dot-accessible snapshot of everything entered so far, **step-keyed** — read a field through its owning step: `data.<step>.<field>`. Read-only; not-yet-collected fields read as `nil` or their `default:`. Each step has its own sub-object, so two steps may declare the same field name without colliding. |
342
+ | `data` | Typed, dot-accessible snapshot of everything entered so far, **step-keyed**: read a field through its owning step: `data.<step>.<field>`. Read-only; not-yet-collected fields read as `nil` or their `default:`. Each step has its own sub-object, so two steps may declare the same field name without colliding. |
343
343
  | `data.<step>.<field>` | The cast value (real Boolean/Integer/Date, not raw string). `data.<step>.<structured>` → array of typed sub-objects. An unknown step key reads as `nil`. |
344
344
  | `anchor` | The record the wizard was launched against. Raises `NotAnchoredError` if the wizard isn't `anchored`. |
345
345
  | `persisted[:step_key]` | Record(s) a per-step `on_submit` registered via `persist`. Lazily rehydrated on first access (located from stored GlobalIDs the first time you read the key, memoized thereafter). |
346
346
 
347
- A **proc-valued field/input option** on a step reaches the same accessors through `form.wizard` — see [Runtime input options](#runtime-input-options).
347
+ A **proc-valued field/input option** on a step reaches the same accessors through `form.wizard`, see [Runtime input options](#runtime-input-options).
348
348
 
349
349
  ## Outcome helpers
350
350
 
@@ -360,7 +360,7 @@ A **proc-valued field/input option** on a step reaches the same accessors throug
360
360
  |---|---|
361
361
  | `Plutonium::Wizard::NotAnchoredError` | `anchor` called on a non-anchored wizard (also raised when a `via:` anchor resolves to `nil` or the wrong type). |
362
362
  | `Plutonium::Wizard::StepError` | Raised by `fail!` (or directly) for a custom, non-AR step failure → maps to a form error. |
363
- | `Plutonium::Wizard::UnknownWizardError` | A mount's `wizard_class` doesn't resolve to a loaded `Plutonium::Wizard::Base` subclass — a misconfigured mount or a tampered route param. |
363
+ | `Plutonium::Wizard::UnknownWizardError` | A mount's `wizard_class` doesn't resolve to a loaded `Plutonium::Wizard::Base` subclass, a misconfigured mount or a tampered route param. |
364
364
 
365
365
  ## Related
366
366
 
@@ -1,16 +1,16 @@
1
1
  # Wizard Reference
2
2
 
3
- The wizard subsystem builds **multi-step flows** — onboarding, checkout, multi-model create, branching questionnaires — as a single declarative class (`< Plutonium::Wizard::Base`). It orchestrates Plutonium's existing field DSL, form rendering, actions, and policies rather than inventing a parallel stack.
3
+ The wizard subsystem builds **multi-step flows** (onboarding, checkout, multi-model create, branching questionnaires) as a single declarative class (`< Plutonium::Wizard::Base`). It orchestrates Plutonium's existing field DSL, form rendering, actions, and policies rather than inventing a parallel stack.
4
4
 
5
5
  For a task-oriented walkthrough, start with the [Wizards guide](/guides/wizards).
6
6
 
7
7
  ## In this section
8
8
 
9
- - **[DSL](./dsl)** — every author-facing macro and accessor: `step`, `review`, `using:`, `condition:`, per-step `on_submit`/`persist`/`on_rollback`, `execute`, `data`/`anchor`/`persisted`.
10
- - **[Anchoring & resume](./anchoring-resume)** — running against an existing record (`anchored` / `anchor`), instance identity, and how a user resumes where they left off.
11
- - **[Storage & config](./storage-config)** — enabling the subsystem, the `plutonium_wizard_sessions` table, `config.wizards.*`, encryption, and the cleanup `SweepJob`.
12
- - **[Registration & launch](./registration-launch)** — reaching a user: the `wizard` definition macro and portal-level `register_wizard`.
13
- - **[One-time wizards](./one-time)** — `concurrency_key` + `one_time` durable completion markers and the `ensure_wizard_completed` gate.
9
+ - **[DSL](./dsl)**: every author-facing macro and accessor, including `step`, `review`, `using:`, `condition:`, per-step `on_submit`/`persist`/`on_rollback`, `execute`, `data`/`anchor`/`persisted`.
10
+ - **[Anchoring & resume](./anchoring-resume)**: running against an existing record (`anchored` / `anchor`), instance identity, and how a user resumes where they left off.
11
+ - **[Storage & config](./storage-config)**: enabling the subsystem, the `plutonium_wizard_sessions` table, `config.wizards.*`, encryption, and the cleanup `SweepJob`.
12
+ - **[Registration & launch](./registration-launch)**: reaching a user via the `wizard` definition macro and portal-level `register_wizard`.
13
+ - **[One-time wizards](./one-time)**: `concurrency_key` + `one_time` durable completion markers and the `ensure_wizard_completed` gate.
14
14
 
15
15
  ## At a glance
16
16
 
@@ -1,10 +1,10 @@
1
1
  # One-time wizards
2
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.
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
4
 
5
5
  ## Declaring `one_time`
6
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).
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
8
 
9
9
  ```ruby
10
10
  class WelcomeWizard < Plutonium::Wizard::Base
@@ -28,15 +28,15 @@ end
28
28
  ```
29
29
 
30
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).
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
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
33
  - On completion, the row is kept as the marker but its `data` / `tracked_records` are nulled out (privacy + size).
34
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`.
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
36
 
37
37
  ## Re-opening a completed wizard
38
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.
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
40
 
41
41
  Supply a [`completed` block](/reference/wizard/dsl#completed) on the wizard to replace that body with your own:
42
42
 
@@ -47,9 +47,9 @@ completed do |wizard|
47
47
  end
48
48
  ```
49
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.
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
51
 
52
- ## Gating a controller — `ensure_wizard_completed`
52
+ ## Gating a controller: `ensure_wizard_completed`
53
53
 
54
54
  The `Plutonium::Wizard::Gate` concern installs a `before_action` that redirects users into the wizard until they complete it.
55
55
 
@@ -83,10 +83,10 @@ The gate **recomputes the wizard's `instance_key`** from its `concurrency_key`,
83
83
 
84
84
  ### Gating an anchored wizard
85
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:
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
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):
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
90
 
91
91
  ```ruby
92
92
  ensure_wizard_completed ConfigureWizard, anchor: :current_widget
@@ -95,10 +95,10 @@ An anchor-keyed wizard (explicit `{ anchor }`, or the [implied anchored key](/re
95
95
 
96
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
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.
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
99
 
100
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.
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
102
  :::
103
103
 
104
104
  ## The launch action hides itself once completed
@@ -112,10 +112,10 @@ end
112
112
  ```
113
113
 
114
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.
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
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:
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
119
 
120
120
  ```ruby
121
121
  wizard :onboard, CompanyOnboardingWizard,
@@ -124,6 +124,6 @@ wizard :onboard, CompanyOnboardingWizard,
124
124
 
125
125
  ## Related
126
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.
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.