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
@@ -1,12 +1,12 @@
1
1
  # Profile
2
2
 
3
- Manages Rodauth account settings as a Plutonium resource — users view/edit personal fields and access Rodauth security features (change password, 2FA, etc.) on one page.
3
+ Manages Rodauth account settings as a Plutonium resource: users view/edit personal fields and access Rodauth security features (change password, 2FA, etc.) on one page.
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
- - **Profile association is always `:profile`** regardless of the model class — `current_user.profile`, `build_profile`, `params.require(:profile)` always work.
8
- - **Profile needs `pu:profile:conn`** — without it, no route, no `profile_url` helper, no user-menu link.
9
- - **Every user needs a profile row** — add an `after_create` callback (or `find_or_create_by`). Without it, `current_user.profile` is nil and the profile route errors.
7
+ - **Profile association is always `:profile`** regardless of the model class: `current_user.profile`, `build_profile`, `params.require(:profile)` always work. `pu:profile:conn` hardcodes `current_user.profile` in the code it generates.
8
+ - **Profile needs `pu:profile:conn`.** Without it: no route, no `profile_url` override, no user-menu link.
9
+ - **Check an existing profile policy for `update?`.** Policies generated by older versions lack `def update? = true`, which leaves the profile read-only once it exists. See [What `pu:profile:conn` generates](#what-pu-profile-conn-generates).
10
10
 
11
11
  ## Quick setup
12
12
 
@@ -42,7 +42,7 @@ rails g pu:profile:install AccountSettings bio:text --dest=main_app
42
42
 
43
43
  ## What gets created
44
44
 
45
- By default the model is `{UserModel}Profile` — `UserProfile`, `StaffUserProfile`, etc. — derived from `--user-model`.
45
+ By default the model is `{UserModel}Profile` (`UserProfile`, `StaffUserProfile`, etc.), derived from `--user-model`.
46
46
 
47
47
  ```
48
48
  app/models/[package/]user_profile.rb
@@ -59,10 +59,10 @@ has_one :profile, class_name: "UserProfile", dependent: :destroy
59
59
  ```
60
60
 
61
61
  ::: warning Association name is fixed
62
- Even when the class is `StaffUserProfile`, the association is `:profile`. Don't rename it — `current_user.profile`, `build_profile`, `params.require(:profile)` all assume this.
62
+ Even when the class is `StaffUserProfile`, the association is `:profile`. Don't rename it: `current_user.profile`, `build_profile`, `params.require(:profile)` all assume this.
63
63
  :::
64
64
 
65
- The generated definition injects a custom `ShowPage` that renders the `SecuritySection` component.
65
+ If you reuse a profile model whose user association has a different name (e.g. `has_one :user_profile`), add `has_one :profile, class_name: "UserProfile"` to the user model rather than editing `current_user.profile` out of the generated policy and `profile_url`. Then connect it with `pu:profile:conn UserProfile --dest=<portal>`.
66
66
 
67
67
  ## The `SecuritySection` component
68
68
 
@@ -78,7 +78,7 @@ Dynamically lists Rodauth security links based on which features are enabled on
78
78
  | `active_sessions` | Active Sessions |
79
79
  | `close_account` | Close Account |
80
80
 
81
- If a feature isn't enabled on the account, its link doesn't render — no configuration needed.
81
+ If a feature isn't enabled on the account, its link doesn't render; no configuration needed.
82
82
 
83
83
  To customize (e.g. add chrome, reorder), override `ShowPage#render_after_content`:
84
84
 
@@ -96,40 +96,77 @@ end
96
96
 
97
97
  See [UI › Pages](/reference/ui/pages) for page-class customization.
98
98
 
99
- ## Required: every user gets a profile
99
+ ## Connecting to a portal
100
+
101
+ `pu:profile:conn` registers the profile as a **singular** resource: `/profile` (no `:id`), and overrides the `profile_url` helper:
102
+
103
+ ```bash
104
+ rails g pu:profile:conn --dest=customer_portal
105
+ ```
106
+
107
+ This is what makes the profile visible. Without it, the model exists but has no route in any portal.
108
+
109
+ ### What `pu:profile:conn` generates
110
+
111
+ It runs `pu:res:conn` with `--singular --policy --definition`, then injects:
112
+
113
+ - **Policy:** `relation_scope { skip_default_relation_scope!; relation.where(user: user) }` (a profile belongs to the user, not the portal's entity), `create? = user.profile.nil?`, `update? = true`, `destroy? = false`, and `permitted_attributes_for_create` minus `:user`.
114
+ - **Controller:** `resource_params` merged with `user: current_user`, so the owner is set server-side.
115
+ - **Definition:** a `ShowPage` whose `render_after_content` renders `Plutonium::Profile::SecuritySection`.
116
+ - **Portal controller concern:** `profile_url`, which points at the profile or, when the user has none yet, at the new-profile form.
117
+
118
+ The explicit `update?` matters: the base policy's `update?` delegates to `create?`, which is false once the profile exists. Policies generated by older versions lack it and leave the profile read-only, so add it if it's missing:
100
119
 
101
120
  ```ruby
102
- class User < ApplicationRecord
103
- after_create :create_profile!
121
+ def update?
122
+ true
123
+ end
124
+ ```
104
125
 
105
- private
106
- def create_profile! = create_profile
126
+ ## Users without a profile row
127
+
128
+ The generated `profile_url` sends a user with no profile to the new-profile form, and `create?` allows exactly one, so a missing row does not break the route. It does mean the first visit is a "create profile" form, and the `SecuritySection` only appears after it is saved. If you want the show page from the first click, create the row up front:
129
+
130
+ ```ruby
131
+ class User < ApplicationRecord
132
+ after_create :create_profile
107
133
  end
108
134
  ```
109
135
 
110
- Without this, `current_user.profile` returns nil and the profile route errors. For existing users at migration time, run a one-off backfill:
136
+ and backfill existing users:
111
137
 
112
138
  ```bash
113
- rails runner "User.find_each(&:create_profile)"
139
+ rails runner "User.find_each { |u| u.profile || u.create_profile }"
114
140
  ```
115
141
 
116
- ## Connecting to a portal
142
+ Skip this when the profile has required fields the user must fill in, or when existing code creates profiles itself (the unique `user_id` index makes a second insert fail).
117
143
 
118
- `pu:profile:conn` registers the profile as a **singular** resource — `/profile` (no `:id`), and exposes the `profile_url` helper:
144
+ ## Linking to the profile
119
145
 
120
- ```bash
121
- rails g pu:profile:conn --dest=customer_portal
146
+ Nothing to add. `Plutonium::Auth::Rodauth` defines `profile_url` returning `nil`, and the topbar avatar menu renders a "Profile" entry whenever it returns a URL. `pu:profile:conn` overrides it in the portal's controller concern, which is what makes the link appear. Elsewhere:
147
+
148
+ ```ruby
149
+ link_to("Profile", profile_url) if profile_url
122
150
  ```
123
151
 
124
- This is what makes the profile visible. Without it, the model exists but has no route in any portal.
152
+ ## Account features on the profile page
125
153
 
126
- ## Linking to the profile
154
+ Per-user settings that aren't part of the profile row (API tokens, connected accounts, notification preferences) are discovered from the profile page. Link them from the same `ShowPage` hook that renders `SecuritySection`, instead of adding custom items to the sidebar or overriding the topbar partial:
127
155
 
128
156
  ```ruby
129
- link_to("Profile", profile_url) if respond_to?(:profile_url)
157
+ class ShowPage < ShowPage
158
+ private
159
+
160
+ def render_after_content
161
+ render Plutonium::Profile::SecuritySection.new
162
+ div(class: "mt-8") do
163
+ a(href: resource_url_for(ApiToken), class: "font-medium text-[var(--pu-text)] hover:underline") { "API tokens" }
164
+ end
165
+ end
166
+ end
130
167
  ```
131
168
 
132
- The `respond_to?` guard is defensive — only portals that ran `pu:profile:conn` have the helper.
169
+ A resource like `ApiToken` belongs to the user, not the portal's entity, so connect it with `pu:res:conn ApiToken --dest=<portal> --policy` and give it the same shape the profile has: `skip_default_relation_scope!` plus `relation.where(user: user)` in the policy, and `resource_params` merged with `user: current_user` in the controller.
133
170
 
134
171
  ## Customizing the definition
135
172
 
@@ -173,13 +210,14 @@ rails g pu:profile:conn StaffUserProfile --dest=admin_portal
173
210
 
174
211
  ## Gotchas
175
212
 
176
- - **`current_user.profile` is nil** — every user needs a profile row. Add `after_create :create_profile!` to the user model.
177
- - **`profile_url` is undefined** — the profile isn't connected to this portal. Run `pu:profile:conn --dest=<portal>`.
178
- - **Custom resource name** — pass it as the first positional argument to `pu:profile:install`. The association is still `:profile`.
179
- - **SecuritySection shows nothing** — none of the relevant Rodauth features are enabled on the account. Enable `change_password`, `otp`, etc. on the Rodauth plugin.
213
+ - **Profile is read-only after creation.** The policy predates the generated `update?`, so `update?` falls back to `create?` (`user.profile.nil?`). Add `def update? = true`.
214
+ - **First visit shows a "create profile" form.** The user has no profile row yet. Not an error; add `after_create :create_profile` plus a backfill if they should land on the show page.
215
+ - **No Profile link in the user menu.** `profile_url` still returns the default `nil` because the profile isn't connected to this portal. Run `pu:profile:conn --dest=<portal>`.
216
+ - **Custom resource name**: pass it as the first positional argument to `pu:profile:install`. The association is still `:profile`.
217
+ - **SecuritySection shows nothing**: none of the relevant Rodauth features are enabled on the account. Enable `change_password`, `otp`, etc. on the Rodauth plugin.
180
218
 
181
219
  ## Related
182
220
 
183
- - [Accounts](./accounts) — Rodauth feature flags that gate SecuritySection links
184
- - [Resource › Definition](/reference/resource/definition) — customizing the profile definition
221
+ - [Accounts](./accounts): Rodauth feature flags that gate SecuritySection links
222
+ - [Resource › Definition](/reference/resource/definition): customizing the profile definition
185
223
  - [App › Generators › Profile generators](/reference/app/generators#profile-generators)
@@ -1,10 +1,10 @@
1
1
  # Async Interactions
2
2
 
3
3
  ::: warning Experimental
4
- Async interactions are experimental — the DSL and behavior may change in a future release.
4
+ Async interactions are experimental: the DSL and behavior may change in a future release.
5
5
  :::
6
6
 
7
- For the task-oriented walkthrough — declaring the action, its policy, and where it appears — start with the [Custom actions guide](/guides/custom-actions). This page is the reference.
7
+ For the task-oriented walkthrough (declaring the action, its policy, and where it appears), start with the [Custom actions guide](/guides/custom-actions). This page is the reference.
8
8
 
9
9
  `async` turns an interaction from "does the work inline" into "persists a run, enqueues it, and redirects to it."
10
10
 
@@ -42,7 +42,7 @@ The flag gates the migration, not just the behaviour: while it is off the runs m
42
42
  ```ruby
43
43
  class Blogging::ArchivePosts < ResourceInteraction
44
44
  presents label: "Archive", icon: Phlex::TablerIcons::Archive
45
- attribute :resources # bulk — perform_on runs once per record
45
+ attribute :resources # bulk: perform_on runs once per record
46
46
  attribute :reason, :string
47
47
 
48
48
  async do
@@ -59,7 +59,7 @@ One class. There is no second file, and no name to invent for the run.
59
59
 
60
60
  ### Why the block declares `perform_on` rather than executing
61
61
 
62
- The block is **not** the body of `#execute`. The work happens later, in a job, in a process with no controller, no request and no `view_context` — it cannot be a closure over anything in the interaction, which is the same reason the row records the initiator and tenant instead of serialising them. So the block declares a `Plutonium::Interaction::Async::Run` subclass with exactly the API a standalone run has, and the validated attributes arrive through `options`.
62
+ The block is **not** the body of `#execute`. The work happens later, in a job, in a process with no controller, no request and no `view_context`, so it cannot be a closure over anything in the interaction, which is the same reason the row records the initiator and tenant instead of serialising them. So the block declares a `Plutonium::Interaction::Async::Run` subclass with exactly the API a standalone run has, and the validated attributes arrive through `options`.
63
63
 
64
64
  `def` opens a fresh scope, so those method bodies cannot accidentally capture the interaction's locals.
65
65
 
@@ -82,13 +82,13 @@ async do
82
82
  end
83
83
  ```
84
84
 
85
- `options` is a JSON column, so dispatch writes it through `ActiveJob::Arguments`. Primitives are stored verbatim — a String stays a String in the column — and only values needing one get a serializer envelope, so a `Date` arrives as a `Date` and a `BigDecimal` as a `BigDecimal` rather than as strings. Hosts can register their own serializers.
85
+ `options` is a JSON column, so dispatch writes it through `ActiveJob::Arguments`. Primitives are stored verbatim (a String stays a String in the column) and only values needing one get a serializer envelope, so a `Date` arrives as a `Date` and a `BigDecimal` as a `BigDecimal` rather than as strings. Hosts can register their own serializers.
86
86
 
87
87
  An attribute that can't be carried at all is refused at dispatch, naming the interaction, rather than being written to a row whose work then fails deep in a job.
88
88
 
89
89
  ### Files
90
90
 
91
- A file can't ride the options column: JSON has no files, and the request's tempfile is deleted on the way out. So an uploaded file is staged to its backend's cache at dispatch and carried as the token — `options["import_file"]` is that token, a String.
91
+ A file can't ride the options column: JSON has no files, and the request's tempfile is deleted on the way out. So an uploaded file is staged to its backend's cache at dispatch and carried as the token: `options["import_file"]` is that token, a String.
92
92
 
93
93
  Use `attachment` to read it back:
94
94
 
@@ -110,16 +110,16 @@ end
110
110
 
111
111
  #### Per-field backend and uploader
112
112
 
113
- `backend:` and `uploader:` are read off the attribute's own `input` declaration — the same options, in the same place, a wizard step reads them from:
113
+ `backend:` and `uploader:` are read off the attribute's own `input` declaration, the same options in the same place a wizard step reads them from:
114
114
 
115
115
  ```ruby
116
116
  attribute :import_file
117
117
  input :import_file, as: :uppy, uploader: Catalog::ImportUploader
118
118
  ```
119
119
 
120
- The uploader's `Attacher.validate` rules run when the interaction validates, so a file that breaks them **fails the form** — the submitter sees a field error and nothing is dispatched. Without that the interaction would validate clean, dispatch, and the author's `validate_max_size` would surface as a run failure on a page the submitter has already left. A no-op for ActiveStorage fields and for uploaders declaring no rules.
120
+ The uploader's `Attacher.validate` rules run when the interaction validates, so a file that breaks them **fails the form**: the submitter sees a field error and nothing is dispatched. Without that the interaction would validate clean, dispatch, and the author's `validate_max_size` would surface as a run failure on a page the submitter has already left. A no-op for ActiveStorage fields and for uploaders declaring no rules.
121
121
 
122
- Validating means staging first, since Shrine validates an assigned cached file — so an upload that fails validation has still been written to the cache, and is reaped by the backend's own unattached-cache cleanup. Wizards make the same trade on every step submit.
122
+ Validating means staging first, since Shrine validates an assigned cached file, so an upload that fails validation has still been written to the cache, and is reaped by the backend's own unattached-cache cleanup. Wizards make the same trade on every step submit.
123
123
 
124
124
  Where no `backend:` is declared: `config.async_interactions.attachment_backend`, then `config.attachment_backend`, then auto-detection (active_shrine loaded → Shrine, else ActiveStorage). Same layering wizards use.
125
125
 
@@ -139,7 +139,7 @@ class Blogging::ArchivePosts < ResourceInteraction
139
139
  end
140
140
  ```
141
141
 
142
- Passing both a class and a block raises `ArgumentError` — the block *is* a run class, so there is nothing to combine.
142
+ Passing both a class and a block raises `ArgumentError`: the block *is* a run class, so there is nothing to combine.
143
143
 
144
144
  `async` must be the only thing that defines `#execute` on the class. Declaring it on a class that already has its own `execute` raises `ArgumentError` at load time (an interaction either executes inline or runs async, never both).
145
145
 
@@ -149,7 +149,7 @@ Nothing is passed in explicitly. Everything the run needs is already reachable f
149
149
 
150
150
  - **Targets.** `attribute :resource` / `attribute :resources`, already narrowed by the controller's policy scope, stored as ids (`target_ids`) and re-resolved at perform time, never serialized as records.
151
151
  - **Initiator + tenant.** `current_user` / `current_scoped_entity`, two of the things every Plutonium policy authorizes on.
152
- - **Nested-route parent.** `current_parent` and `current_nested_association` — `/orgs/1/posts/5/comments` records `(Post#5, :comments)`. Both halves or neither, since `Policy#default_relation_scope` raises on one alone. This is the third policy input, and it is not optional detail: that method picks **one** branch, parent *or* entity, so a nested run without its parent re-derives targets under the tenant where dispatch used the parent — wider than the scope the initiator was shown. It also leaves a host predicate reading `parent` looking at `nil`, which (being declared `optional: true`) answers false rather than raising, refusing every target for a reason that names the predicate instead of the missing context.
152
+ - **Nested-route parent.** `current_parent` and `current_nested_association`: `/orgs/1/posts/5/comments` records `(Post#5, :comments)`. Both halves or neither, since `Policy#default_relation_scope` raises on one alone. This is the third policy input, and it is not optional detail: that method picks **one** branch, parent *or* entity, so a nested run without its parent re-derives targets under the tenant where dispatch used the parent, wider than the scope the initiator was shown. It also leaves a host predicate reading `parent` looking at `nil`, which (being declared `optional: true`) answers false rather than raising, refusing every target for a reason that names the predicate instead of the missing context.
153
153
  - **The policy actually resolved.** `policy_class_name`, not an inferred `"#{Model}Policy"` (a namespaced portal or an STI fallback would make that guess wrong), plus `policy_action`, the predicate dispatch checked (e.g. `"archive?"`).
154
154
  - **`authorization_namespace`.** The portal's module name, so perform-time policy lookup finds the same narrowed policy dispatch did.
155
155
 
@@ -159,10 +159,10 @@ An opaque (untargeted) run records none of the target/policy columns: there's no
159
159
 
160
160
  The job has no controller, no request, no `current_user`. `Async::Context` rebuilds the authorization triple from the row and re-checks it from scratch. It does not trust anything the dispatching request already decided:
161
161
 
162
- - The **scope** check re-runs `Post.associated_with(tenant)` style filtering — or, for a nested dispatch, the parent's association. A target that left the tenant or the parent, or was deleted, between dispatch and perform is reported `missing`/`unauthorized`, never silently skipped.
162
+ - The **scope** check re-runs `Post.associated_with(tenant)` style filtering (or, for a nested dispatch, the parent's association). A target that left the tenant or the parent, or was deleted, between dispatch and perform is reported `missing`/`unauthorized`, never silently skipped.
163
163
  - The **predicate** check re-asks the policy the same question dispatch asked (`policy_action`), per target, immediately before `perform_on`, not once up front. An initiator whose permission was revoked mid-run stops applying to the remaining targets.
164
164
  - A **policy mismatch** (the class renamed/re-parented/re-namespaced since dispatch) refuses to run at all, rather than silently authorizing under a different policy than the initiator was ever subject to.
165
- - A **deleted subject** — initiator, tenant or parent — refuses the run. In each case the association nils out, and nil reads as "there was never one": no tenant, or not a nested dispatch. Both of those drop a filter rather than narrowing, so the `*_type` column is what tells "carries none" apart from "carries one that is gone".
165
+ - A **deleted subject** (initiator, tenant or parent) refuses the run. In each case the association nils out, and nil reads as "there was never one": no tenant, or not a nested dispatch. Both of those drop a filter rather than narrowing, so the `*_type` column is what tells "carries none" apart from "carries one that is gone".
166
166
 
167
167
  This is deliberate and asymmetric: a resolution failure always fails **closed** (refuse / report missing), never open (never "assume permitted").
168
168
 
@@ -201,12 +201,12 @@ end
201
201
 
202
202
  `controller_for` is required: the controller's name doesn't match `Run`'s real, namespaced class, so inference can't find it on its own. No policy/definition files are generated: `Plutonium::Interaction::Async::RunPolicy`/`Async::RunDefinition` already resolve automatically (Rails matches `Plutonium::Interaction::Async::RunDefinition` by the exact class name, and ActionPolicy's own lookup finds `AsyncRunPolicy` the same way).
203
203
 
204
- If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml` (`--schedule` to override the default `every 15 minutes`) — see [Stalled runs and ReapJob](#stalled-runs-and-reapjob). Idempotent, so running the generator against a second portal doesn't duplicate the entry.
204
+ If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml` (`--schedule` to override the default `every 15 minutes`), see [Stalled runs and ReapJob](#stalled-runs-and-reapjob). Idempotent, so running the generator against a second portal doesn't duplicate the entry.
205
205
 
206
206
  A registered run resource gets, for free:
207
207
 
208
208
  - **A progress page.** The show page IS the progress page. It self-refreshes via polling (not ActionCable) while `state` is `pending`/`running`, and stops carrying the poll once the run settles, so a finished run is a static page, not a background request per viewer.
209
- - **A self-refreshing index.** The runs index polls on the same terms as the progress page: one frame around the whole collection, armed only while some run is still working, disarmed the moment none is. One request per interval regardless of page size, and it re-fetches the URL you are on, so filters, sort and page survive the refresh. A frame per row is not an option — `turbo-frame` is not in the content model of `tr`, so the parser hoists it out of the table before Turbo sees it.
209
+ - **A self-refreshing index.** The runs index polls on the same terms as the progress page: one frame around the whole collection, armed only while some run is still working, disarmed the moment none is. One request per interval regardless of page size, and it re-fetches the URL you are on, so filters, sort and page survive the refresh. A frame per row is not an option: `turbo-frame` is not in the content model of `tr`, so the parser hoists it out of the table before Turbo sees it.
210
210
  - **A running banner.** Any OTHER resource's index page lists runs currently in progress against it, above the collection, so a user who dispatched a bulk action and navigated away can find it again. Scoped through the same `authorized_resource_scope` every cross-resource read goes through, so a run in another tenant can never surface. If a resource_class is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while trying to build a link to a route that doesn't exist.
211
211
  - **Tenant scoping.** A run's `associated_with` scope filters on the tenant it was dispatched in (recorded on the row), not on walking the object graph, since the two polymorphic tenant columns make the generic scope unusable.
212
212
  - **A humanized target label.** `run.target_label` reads the target class through `model_name.human` ("Post", not "Blogging::Post"), falling back to the raw string if that class has since been renamed or removed.
@@ -235,7 +235,7 @@ production:
235
235
  schedule: every 15 minutes
236
236
  ```
237
237
 
238
- Without Solid Queue — or for another scheduler like `whenever` — add it yourself:
238
+ Without Solid Queue, or for another scheduler like `whenever`, add it yourself:
239
239
 
240
240
  ```ruby
241
241
  # whenever gem
@@ -249,13 +249,13 @@ A 15 to 30 minute cadence is reasonable against the default 1-hour `stall_after`
249
249
  ::: warning This is a time heuristic, not a lease
250
250
  Resuming is based on elapsed time, not a true distributed lock. A run that is merely slow (not dead) and happens to cross `stall_after` gets resumed too.
251
251
 
252
- What bounds that is `lock_version`. Both the reaper's resume and the executor's claim bump it, so the worker that is still alive holds a version the row no longer has: its very next write raises `ActiveRecord::StaleObjectError`, and the executor treats that as "no longer mine" — it abandons the pass without marking the run failed and without overwriting the new worker's progress. Two things it deliberately does not do: it cannot interrupt a `perform_on` already in flight, so one target may be applied twice (once by each side), and it cannot roll back what the superseded worker already committed. Set `stall_after` well above this app's slowest legitimate run — the fence bounds the damage of a bad value, it does not make one free.
252
+ What bounds that is `lock_version`. Both the reaper's resume and the executor's claim bump it, so the worker that is still alive holds a version the row no longer has: its very next write raises `ActiveRecord::StaleObjectError`, and the executor treats that as "no longer mine": it abandons the pass without marking the run failed and without overwriting the new worker's progress. Two things it deliberately does not do: it cannot interrupt a `perform_on` already in flight, so one target may be applied twice (once by each side), and it cannot roll back what the superseded worker already committed. Set `stall_after` well above this app's slowest legitimate run: the fence bounds the damage of a bad value, it does not make one free.
253
253
 
254
254
  ### Long work must say it is alive
255
255
 
256
256
  `stall_after` is a **silence** threshold, not a runtime limit. Every write the executor makes refreshes the clock, so a targeted run with quick targets heartbeats once per target for free. Two shapes get nothing, and both are exactly the "long-running task" case:
257
257
 
258
- - **Opaque work.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count. A `perform` that outlives `stall_after` is reaped mid-flight and — having no `handled_target_ids` to resume from — re-runs **from scratch**.
258
+ - **Opaque work.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count. A `perform` that outlives `stall_after` is reaped mid-flight and, having no `handled_target_ids` to resume from, re-runs **from scratch**.
259
259
  - **A single `perform_on`** that outlives `stall_after` on its own.
260
260
 
261
261
  Call `heartbeat!` from inside such work:
@@ -265,7 +265,7 @@ class Billing::ReissueInvoicesRun < Plutonium::Interaction::Async::Run
265
265
  def perform
266
266
  invoices.each_slice(500) do |slice|
267
267
  reissue(slice)
268
- heartbeat! # "still working" — resets the stall clock
268
+ heartbeat! # "still working", resets the stall clock
269
269
  end
270
270
  end
271
271
  end
@@ -273,11 +273,11 @@ end
273
273
 
274
274
  This is deliberately not automatic. A background thread would have to guess a cadence, and would go on reporting a wedged worker as healthy; only the work itself knows it is making progress.
275
275
 
276
- `heartbeat!` also **answers**. The write is conditional on this worker still holding the row's `lock_version`, so one that was superseded inside a long `perform` raises `ActiveRecord::StaleObjectError` at its next beat and abandons the pass — for opaque work that is the only place it can find out before `finish!`. Under `:transactional` the beat is inside the batch transaction like everything else, so it stays invisible to the reaper until the batch commits.
276
+ `heartbeat!` also **answers**. The write is conditional on this worker still holding the row's `lock_version`, so one that was superseded inside a long `perform` raises `ActiveRecord::StaleObjectError` at its next beat and abandons the pass; for opaque work that is the only place it can find out before `finish!`. Under `:transactional` the beat is inside the batch transaction like everything else, so it stays invisible to the reaper until the batch commits.
277
277
 
278
278
  ### Queue-level concurrency
279
279
 
280
- On a queue that provides ActiveJob concurrency controls — Solid Queue does, whenever it is in the bundle — the run job also declares a per-run semaphore, and the reaper a global one:
280
+ On a queue that provides ActiveJob concurrency controls (Solid Queue does, whenever it is in the bundle), the run job also declares a per-run semaphore, and the reaper a global one:
281
281
 
282
282
  | Job | Key | Limit | Duration |
283
283
  |---|---|---|---|
@@ -286,10 +286,10 @@ On a queue that provides ActiveJob concurrency controls — Solid Queue does, wh
286
286
 
287
287
  This is declared only when the method exists; Plutonium depends on no queue backend, and nothing above requires one.
288
288
 
289
- It is not a second copy of the claim. `claim!` can only *refuse* a duplicate delivery, and only once a worker is already running it — by which point a reaper's resume has re-entered `perform_on` for one target. The semaphore removes the race a step earlier: the second delivery waits instead of racing, so on a queue that supports it the double-applied target does not happen at all. Keying the run job on `stall_after` matters here — Solid Queue's 3-minute default would expire the semaphore mid-batch on any run big enough to be worth dispatching.
289
+ It is not a second copy of the claim. `claim!` can only *refuse* a duplicate delivery, and only once a worker is already running it, by which point a reaper's resume has re-entered `perform_on` for one target. The semaphore removes the race a step earlier: the second delivery waits instead of racing, so on a queue that supports it the double-applied target does not happen at all. Keying the run job on `stall_after` matters here: Solid Queue's 3-minute default would expire the semaphore mid-batch on any run big enough to be worth dispatching.
290
290
  :::
291
291
 
292
292
  ## Related
293
293
 
294
- - [Interactions](/reference/behavior/interactions) — `async` is declared inside `Plutonium::Resource::Interaction`; everything else about inputs, validation and outcomes is unchanged.
295
- - [Policies](/reference/behavior/policies) — the policy dispatch checks and the job re-checks are the same predicate.
294
+ - [Interactions](/reference/behavior/interactions): `async` is declared inside `Plutonium::Resource::Interaction`; everything else about inputs, validation and outcomes is unchanged.
295
+ - [Policies](/reference/behavior/policies): the policy dispatch checks and the job re-checks are the same predicate.
@@ -1,25 +1,25 @@
1
1
  # Controller
2
2
 
3
- Plutonium controllers ship full CRUD out of the box; nearly all customization belongs elsewhere. The controller stays thin — when in doubt, push the change to the definition (UI) or the policy (auth).
3
+ Plutonium controllers ship full CRUD out of the box; nearly all customization belongs elsewhere. The controller stays thin; when in doubt, push the change to the definition (UI) or the policy (auth).
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
7
  - **Don't override CRUD actions.** Use hooks (`resource_params`, `redirect_url_after_submit`, presentation hooks). Overriding `create` / `update` usually breaks authorization, params filtering, or both.
8
- - **Named custom routes only.** Always pass `as:` — without it, `resource_url_for` can't build URLs (critical for nested resources).
9
- - **Authorization is verified after every action** — if you write a custom action, you MUST call `authorize_current!` yourself or use `skip_verify_authorize_current` / `skip_verify_authorize_current!`.
8
+ - **Named custom routes only.** Always pass `as:`. Without it, `resource_url_for` can't build URLs (critical for nested resources).
9
+ - **Authorization is verified after every action.** If you write a custom action, you MUST call `authorize_current!` yourself or use `skip_verify_authorize_current` / `skip_verify_authorize_current!`.
10
10
  - **Cross-resource queries: use `authorized_resource_scope(OtherModel)`, not raw `where`.** Otherwise you bypass that resource's tenancy and visibility rules.
11
11
 
12
12
  ## Base classes
13
13
 
14
14
  ```ruby
15
- # app/controllers/resource_controller.rb — installed once
15
+ # app/controllers/resource_controller.rb (installed once)
16
16
  class ResourceController < ApplicationController
17
17
  include Plutonium::Resource::Controller
18
18
  end
19
19
 
20
- # app/controllers/posts_controller.rb — per resource, generated
20
+ # app/controllers/posts_controller.rb (per resource, generated)
21
21
  class PostsController < ::ResourceController
22
- # Empty — all CRUD inherited
22
+ # Empty: all CRUD inherited
23
23
  end
24
24
  ```
25
25
 
@@ -59,10 +59,10 @@ Plus interactive-action routes for every action declared in the definition (`/po
59
59
  |---|---|
60
60
  | Field rendering (inputs, displays, columns) | [Definition](/reference/resource/definition) |
61
61
  | Search, filters, scopes, sorting | [Query](/reference/resource/query) |
62
- | Custom operations (publish, archive, import) — the *button* | [Interaction](./interactions) + action on definition |
63
- | The operation itself, once a job/API/task also needs it | The **model** — see [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for) |
62
+ | Custom operations (publish, archive, import): the *button* | [Interaction](./interactions) + action on definition |
63
+ | The operation itself, once a job/API/task also needs it | The **model**, see [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for) |
64
64
  | Authorization rules | [Policy](./policies) |
65
- | Form / show / page chrome | Definition (custom page classes — see [UI › Pages](/reference/ui/pages)) |
65
+ | Form / show / page chrome | Definition (custom page classes, see [UI › Pages](/reference/ui/pages)) |
66
66
  | **Custom redirect logic** | **[Controller hook](#redirect-hooks)** |
67
67
  | **Param munging** | **[Controller hook](#parameter-hook)** |
68
68
  | **Custom index query shape** | **[Controller hook](#index-query-hook)** |
@@ -128,7 +128,7 @@ def present_scoped_entity? = true
128
128
  def submit_scoped_entity? = true
129
129
  ```
130
130
 
131
- Conditional pattern — show parent only when accessed standalone:
131
+ Conditional pattern: show parent only when accessed standalone:
132
132
 
133
133
  ```ruby
134
134
  def present_parent?
@@ -156,9 +156,9 @@ end
156
156
 
157
157
  ## Custom actions
158
158
 
159
- Prefer **interactive actions** (definition + interaction — see [Resource › Actions](/reference/resource/actions)) for anything a user triggers from a page: you get the button, the policy check, the form, and the flash for free. The only reasons to hand-write a controller action: unusual response shapes, external service callbacks, etc.
159
+ Prefer **interactive actions** (definition + interaction, see [Resource › Actions](/reference/resource/actions)) for anything a user triggers from a page: you get the button, the policy check, the form, and the flash for free. The only reasons to hand-write a controller action: unusual response shapes, external service callbacks, etc.
160
160
 
161
- Either way the *operation* should be a named method on the model — that's what keeps it reachable from a job or an API. The controller and the interaction are two different front doors to the same `post.publish!`.
161
+ Either way the *operation* should be a named method on the model; that's what keeps it reachable from a job or an API. The controller and the interaction are two different front doors to the same `post.publish!`.
162
162
 
163
163
  ```ruby
164
164
  class PostsController < ::ResourceController
@@ -179,7 +179,7 @@ end
179
179
  ```
180
180
 
181
181
  ::: warning Always name custom routes
182
- Without `as:`, `resource_url_for` can't build the URL — particularly critical for nested resources.
182
+ Without `as:`, `resource_url_for` can't build the URL, which is particularly critical for nested resources.
183
183
  :::
184
184
 
185
185
  ## Key methods
@@ -206,10 +206,10 @@ permitted_attributes # Allowed attributes for the current
206
206
  current_authorized_scope # Scoped collection the user can access
207
207
  ```
208
208
 
209
- **Other resources** — cross-resource auth. Use these, NOT raw `where` / `find`:
209
+ **Other resources**: cross-resource auth. Use these, NOT raw `where` / `find`:
210
210
 
211
211
  ```ruby
212
- authorize! other_record, to: :show? # ActionPolicy — raises if denied
212
+ authorize! other_record, to: :show? # ActionPolicy: raises if denied
213
213
  allowed_to?(:show?, other_record) # Boolean check
214
214
  policy_for(OtherModel) # Policy instance for class or record
215
215
  policy_for(other_record).show?
@@ -219,7 +219,7 @@ authorized_resource_scope(OtherModel, relation: OtherModel.published) # On a re
219
219
  authorized_resource_scope(OtherModel, type: :create) # Different action
220
220
  ```
221
221
 
222
- `authorized_resource_scope` applies the *other* resource's `relation_scope` AND the current policy context (entity scope, etc.). **Always prefer it over `OtherModel.all` / raw `where`** in cross-resource controller code — otherwise you bypass that resource's tenancy and visibility rules.
222
+ `authorized_resource_scope` applies the *other* resource's `relation_scope` AND the current policy context (entity scope, etc.). **Always prefer it over `OtherModel.all` / raw `where`** in cross-resource controller code; otherwise you bypass that resource's tenancy and visibility rules.
223
223
 
224
224
  ### Definition access
225
225
 
@@ -268,7 +268,7 @@ current_nested_association # :posts
268
268
  parent_input_param # :user
269
269
  ```
270
270
 
271
- The nesting is declared by the route, not inferred from the URL: each nested route carries the key of its own registration, and `current_parent_class` / `current_nested_association` read it back. (There is no `parent_route_param` — the id parameter is derived from the parent's own route, and a singular parent contributes none at all.)
271
+ The nesting is declared by the route, not inferred from the URL: each nested route carries the key of its own registration, and `current_parent_class` / `current_nested_association` read it back. (There is no `parent_route_param`; the id parameter is derived from the parent's own route, and a singular parent contributes none at all.)
272
272
 
273
273
  Parent fields are excluded from forms/displays by default. Toggle with the [presentation hooks](#presentation-hooks).
274
274
 
@@ -295,7 +295,7 @@ Plutonium auto-detects which `belongs_to` association points to the scoped class
295
295
  # Portal config
296
296
  scope_to_entity Competition::Team, param_key: :team
297
297
 
298
- # Model — association name differs from param_key, but Plutonium finds by class
298
+ # Model: association name differs from param_key, but Plutonium finds by class
299
299
  class Match < ApplicationRecord
300
300
  belongs_to :competition_team
301
301
  end
@@ -327,14 +327,14 @@ Full mechanics in [Tenancy › Entity scoping](/reference/tenancy/entity-scoping
327
327
  After-action callbacks ensure authorization happened:
328
328
 
329
329
  ```ruby
330
- verify_authorize_current # all actions — `authorize_current!` must have been called
331
- verify_current_authorized_scope # all except :new and :create — scope must have been loaded
330
+ verify_authorize_current # all actions: `authorize_current!` must have been called
331
+ verify_current_authorized_scope # all except :new and :create: scope must have been loaded
332
332
  ```
333
333
 
334
334
  Skip only when handling auth manually. Two forms:
335
335
 
336
336
  ```ruby
337
- # Class-level — across multiple actions
337
+ # Class-level: across multiple actions
338
338
  class PostsController < ::ResourceController
339
339
  skip_verify_authorize_current only: [:preview]
340
340
  skip_verify_current_authorized_scope only: [:preview]
@@ -344,7 +344,7 @@ class PostsController < ::ResourceController
344
344
  end
345
345
  end
346
346
 
347
- # Per-action — bang methods, inside the action body
347
+ # Per-action: bang methods, inside the action body
348
348
  def preview
349
349
  skip_verify_authorize_current!
350
350
  skip_verify_current_authorized_scope!
@@ -352,7 +352,7 @@ def preview
352
352
  end
353
353
  ```
354
354
 
355
- Prefer the per-action bang form when only one action skips — keeps the exception co-located with the code that needs it.
355
+ Prefer the per-action bang form when only one action skips, which keeps the exception co-located with the code that needs it.
356
356
 
357
357
  ## Response formats
358
358
 
@@ -401,8 +401,8 @@ See [App › Portals](/reference/app/portals) for the full portal controller sto
401
401
 
402
402
  ## Related
403
403
 
404
- - [Policies](./policies) — authorization called from controllers
405
- - [Interactions](./interactions) — business logic for custom actions
406
- - [Resource › Definition](/reference/resource/definition) — UI config (where most "controller-like" tweaks belong)
407
- - [Resource › Actions](/reference/resource/actions) — registering interactive actions
408
- - [Tenancy › Nested resources](/reference/tenancy/nested-resources) — parent/child routing
404
+ - [Policies](./policies): authorization called from controllers
405
+ - [Interactions](./interactions): business logic for custom actions
406
+ - [Resource › Definition](/reference/resource/definition): UI config (where most "controller-like" tweaks belong)
407
+ - [Resource › Actions](/reference/resource/actions): registering interactive actions
408
+ - [Tenancy › Nested resources](/reference/tenancy/nested-resources): parent/child routing
@@ -2,15 +2,15 @@
2
2
 
3
3
  The behavior layer is intentionally thin:
4
4
 
5
- - **[Controllers](./controllers) route** — handle requests, redirect after submit, transform params.
6
- - **[Policies](./policies) authorize** — decide who can do what, which fields they can see, which records they can access.
7
- - **[Interactions](./interactions) present** — declare the inputs for a custom operation (publish, archive, import, send invitation), render as a button and a form, and hand back an outcome.
8
- - **[Async Interactions](./async-interactions)** — `async` declares a persisted, resumable run instead of executing inline, for bulk operations and anything else too slow to hold a request open.
5
+ - **[Controllers](./controllers) route**: handle requests, redirect after submit, transform params.
6
+ - **[Policies](./policies) authorize**: decide who can do what, which fields they can see, which records they can access.
7
+ - **[Interactions](./interactions) present**: declare the inputs for a custom operation (publish, archive, import, send invitation), render as a button and a form, and hand back an outcome.
8
+ - **[Async Interactions](./async-interactions)**: `async` declares a persisted, resumable run instead of executing inline, for bulk operations and anything else too slow to hold a request open.
9
9
 
10
10
  Registering an action and rendering it lives in [Resource › Definition](/reference/resource/definition) and [Resource › Actions](/reference/resource/actions). This section covers **writing** the controller hook, policy method, or interaction class behind it.
11
11
 
12
12
  ::: tip And the operation itself lives on the model
13
- An interaction is a presentation object — it can only be constructed with a `view_context`. Logic may *start* in `execute`, but the second caller (a job, an API controller, a rake task, the console) is the trigger to move it onto the record, Rails-style. See [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for).
13
+ An interaction is a presentation object, so it can only be constructed with a `view_context`. Logic may *start* in `execute`, but the second caller (a job, an API controller, a rake task, the console) is the trigger to move it onto the record, Rails-style. See [Interactions › What an interaction is for](./interactions#what-an-interaction-is-for).
14
14
  :::
15
15
 
16
16
  For multi-tenant `relation_scope` and entity scoping, see [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).