plutonium 0.50.0 → 0.51.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,838 @@
1
+ ---
2
+ name: plutonium-behavior
3
+ description: Use BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things".
4
+ ---
5
+
6
+ # Plutonium Behavior — Controllers, Policies, Interactions
7
+
8
+ The behavior layer is intentionally thin: **controllers route**, **policies authorize**, **interactions act**. Registering an action and rendering it lives in [[plutonium-resource]] — this skill covers how to *write* the controller hook, policy method, or interaction class behind it.
9
+
10
+ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]].
11
+
12
+ ## 🚨 Critical (read first)
13
+
14
+ - **Use generators.** `pu:res:scaffold` creates the base trio (controller/policy/interaction-base); `pu:res:conn` creates portal-specific versions. Never hand-write them.
15
+ - **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.
16
+ - **`create?` and `read?` default to `false`.** Always override them explicitly. Derived methods (`update?`, `show?`, etc.) inherit automatically.
17
+ - **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detection works; production raises.
18
+ - **`ActiveRecord::RecordInvalid` is NOT rescued automatically in interactions.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
19
+ - **Return `succeed(...)` or `failed(...)`** from `execute` — the controller can't tell what happened otherwise.
20
+ - **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
21
+ - **`relation_scope` must compose with `default_relation_scope(relation)` explicitly** — not `super`. See [[plutonium-tenancy]].
22
+ - **For `has_cents` fields, use the virtual name (`:price`), not `:price_cents`** in `permitted_attributes_for_*`.
23
+ - **Custom action ⇒ policy method.** `action :publish` needs `def publish?` on the policy (undefined methods return `false`).
24
+ - **Named custom routes.** When adding custom routes, always pass `as:` so `resource_url_for` can build URLs.
25
+
26
+ ---
27
+
28
+ # Part 1 — Controllers
29
+
30
+ Plutonium controllers ship full CRUD out of the box; nearly all customization lives in definitions / policies / interactions. The controller stays thin.
31
+
32
+ ## Base classes
33
+
34
+ ```ruby
35
+ # app/controllers/resource_controller.rb (installed once)
36
+ class ResourceController < ApplicationController
37
+ include Plutonium::Resource::Controller
38
+ end
39
+
40
+ # app/controllers/posts_controller.rb (per resource, generated by pu:res:scaffold)
41
+ class PostsController < ::ResourceController
42
+ # Empty — all CRUD inherited
43
+ end
44
+ ```
45
+
46
+ ## What you get for free
47
+
48
+ | Action | Route | Purpose |
49
+ |--------|-------|---------|
50
+ | `index` | GET `/posts` | List with pagination, search, filters, sorting |
51
+ | `show` | GET `/posts/:id` | Display single record |
52
+ | `new` | GET `/posts/new` | Form |
53
+ | `create` | POST `/posts` | Create |
54
+ | `edit` | GET `/posts/:id/edit` | Form |
55
+ | `update` | PATCH `/posts/:id` | Update |
56
+ | `destroy` | DELETE `/posts/:id` | Delete |
57
+
58
+ Plus interactive-action routes for every action declared in the definition.
59
+
60
+ ## Where customization belongs
61
+
62
+ | Concern | Lives in |
63
+ |---|---|
64
+ | Field rendering (inputs, displays, columns) | Definition |
65
+ | Search, filters, scopes, sorting | Definition |
66
+ | Custom operations (publish, archive, import) | Interaction (+ action in definition) |
67
+ | Authorization rules | Policy |
68
+ | Form/show/page chrome | Definition (custom page classes) |
69
+ | **Custom redirect logic** | **Controller hook** |
70
+ | **Param munging** | **Controller hook** |
71
+ | **Custom index query shape** | **Controller hook** |
72
+ | **Presentation of parent/entity fields** | **Controller hook** |
73
+
74
+ ## Override hooks
75
+
76
+ All hooks are private methods. Override only the ones you need.
77
+
78
+ ### Redirect hooks
79
+
80
+ ```ruby
81
+ class PostsController < ::ResourceController
82
+ private
83
+
84
+ # Where to go after create/update: "show" (default), "edit", "new", "index"
85
+ def preferred_action_after_submit = "edit"
86
+
87
+ # Custom URL after create/update (overrides preferred_action_after_submit)
88
+ def redirect_url_after_submit = posts_path
89
+
90
+ # Custom URL after destroy
91
+ def redirect_url_after_destroy = posts_path
92
+ end
93
+ ```
94
+
95
+ ### Parameter hook
96
+
97
+ ```ruby
98
+ def resource_params
99
+ params = super
100
+ params[:tags] = params[:tags].split(",") if params[:tags].is_a?(String)
101
+ params
102
+ end
103
+ ```
104
+
105
+ ### Index query hook
106
+
107
+ ```ruby
108
+ def filtered_resource_collection
109
+ base = current_authorized_scope
110
+ base = base.featured if params[:featured]
111
+ current_query_object.apply(base, raw_resource_query_params)
112
+ end
113
+ ```
114
+
115
+ ### Presentation hooks
116
+
117
+ Control whether parent / scoped-entity fields appear in forms and displays. Defaults are `false` (hidden, since they're inferred from the URL/portal).
118
+
119
+ ```ruby
120
+ def present_parent? = true # show parent field on displays
121
+ def submit_parent? = true # include parent field in forms (default: tracks present_parent?)
122
+ def present_scoped_entity? = true
123
+ def submit_scoped_entity? = true
124
+ ```
125
+
126
+ ## Custom actions
127
+
128
+ Prefer **interactive actions** (definition + interaction) for anything with business logic. The only reason to hand-write a controller action is unusual flows (custom response shapes, external service callbacks, etc.).
129
+
130
+ ```ruby
131
+ class PostsController < ::ResourceController
132
+ def publish
133
+ authorize_current!(resource_record!, to: :publish?)
134
+ resource_record!.update!(published: true)
135
+ redirect_to resource_url_for(resource_record!), notice: "Published!"
136
+ end
137
+ end
138
+ ```
139
+
140
+ Route must be named:
141
+
142
+ ```ruby
143
+ resources :posts do
144
+ member { post :publish, as: :publish } # `as:` required!
145
+ end
146
+ ```
147
+
148
+ ## Key methods
149
+
150
+ ### Resource access
151
+
152
+ ```ruby
153
+ resource_class # The model class
154
+ resource_record! # Current record (raises if not found)
155
+ resource_record? # Current record (nil if not found)
156
+ resource_params # Permitted params for create/update
157
+ current_parent # Parent record for nested routes
158
+ current_scoped_entity # Tenant entity for the current portal (nil if not scoped)
159
+ ```
160
+
161
+ ### Authorization
162
+
163
+ **Current resource:**
164
+
165
+ ```ruby
166
+ authorize_current!(record, to: :action?) # Check permission
167
+ current_policy
168
+ permitted_attributes
169
+ current_authorized_scope # Scoped records the user can access
170
+ ```
171
+
172
+ **Other resources** (cross-resource auth — use these, not raw `where` / `find`):
173
+
174
+ ```ruby
175
+ authorize! other_record, to: :show? # ActionPolicy — raises if denied
176
+ allowed_to?(:show?, other_record) # Boolean check
177
+ policy_for(OtherModel) # Policy instance for class or record
178
+ policy_for(other_record).show?
179
+
180
+ authorized_resource_scope(OtherModel) # Scope on the model class
181
+ authorized_resource_scope(OtherModel, relation: OtherModel.published) # On a relation
182
+ authorized_resource_scope(OtherModel, type: :create) # Different action
183
+ ```
184
+
185
+ `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.
186
+
187
+ ### Definition access
188
+
189
+ ```ruby
190
+ current_definition
191
+ ```
192
+
193
+ ### UI builders (rarely needed in controllers)
194
+
195
+ ```ruby
196
+ build_form
197
+ build_detail
198
+ build_collection
199
+ ```
200
+
201
+ ### URL generation
202
+
203
+ ```ruby
204
+ resource_url_for(@post) # show URL
205
+ resource_url_for(@post, action: :edit) # edit URL
206
+ resource_url_for(Post) # index URL
207
+
208
+ # Nested
209
+ resource_url_for(@comment, parent: @post)
210
+ resource_url_for(Comment, action: :new, parent: @post)
211
+
212
+ # Cross-package
213
+ resource_url_for(@post, package: AdminPortal)
214
+
215
+ # Interactive actions (see Part 3 below)
216
+ resource_url_for(@post, interaction: :publish)
217
+ resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
218
+ ```
219
+
220
+ ## Nested resources
221
+
222
+ Routes prefixed with `nested_` automatically resolve the parent:
223
+
224
+ ```ruby
225
+ # Route: /users/:user_id/nested_posts/:id
226
+ class PostsController < ::ResourceController
227
+ # current_parent => User instance
228
+ # current_nested_association => :posts
229
+ # resource_record! => Post scoped to that User
230
+ end
231
+ ```
232
+
233
+ | Method | Returns |
234
+ |---|---|
235
+ | `current_parent` | Parent record |
236
+ | `current_nested_association` | `:posts` |
237
+ | `parent_route_param` | `:user_id` |
238
+ | `parent_input_param` | `:user` |
239
+
240
+ Parent fields are excluded from forms/displays by default — toggle with the presentation hooks above. For `has_one` associations, routes are singular (no `:id`); index redirects to show (or new if no record exists). See [[plutonium-tenancy]] for the full nested-routing story.
241
+
242
+ ## Entity scoping (multi-tenancy)
243
+
244
+ When a portal calls `scope_to_entity SomeModel`, every controller in that portal automatically:
245
+
246
+ - Scopes queries to the entity
247
+ - Excludes the entity field from forms (detected by association class)
248
+ - Injects the entity on create/update
249
+ - Exposes `current_scoped_entity`
250
+
251
+ Plutonium auto-detects which `belongs_to` association points to the scoped class, even when `param_key` differs from the association name. If a model has **multiple associations to the same scoped class**, you get a runtime error and must override:
252
+
253
+ ```ruby
254
+ class MatchesController < ::ResourceController
255
+ private
256
+ def scoped_entity_association = :home_team
257
+ end
258
+ ```
259
+
260
+ For the full mechanics, load [[plutonium-tenancy]].
261
+
262
+ ## Authorization verification
263
+
264
+ After-action callbacks ensure auth was performed:
265
+
266
+ ```ruby
267
+ verify_authorize_current # all actions
268
+ verify_current_authorized_scope # all except new/create
269
+ ```
270
+
271
+ Skip only when handling auth manually. Two forms:
272
+
273
+ ```ruby
274
+ # Class-level — skip across multiple actions
275
+ class PostsController < ::ResourceController
276
+ skip_verify_authorize_current only: [:custom_action]
277
+ skip_verify_current_authorized_scope only: [:custom_action]
278
+
279
+ def custom_action
280
+ # do auth manually
281
+ end
282
+ end
283
+
284
+ # Per-action — bang methods, call inside the action body
285
+ def custom_action
286
+ skip_verify_authorize_current!
287
+ skip_verify_current_authorized_scope!
288
+ # do auth manually
289
+ end
290
+ ```
291
+
292
+ Prefer the per-action bang form when only one action skips — keeps the exception co-located with the code that needs it.
293
+
294
+ ## Portal-specific controllers
295
+
296
+ Portal controllers inherit from the feature-package controller if one exists (and include the portal's `Concerns::Controller`); otherwise from the portal's `ResourceController`.
297
+
298
+ ```ruby
299
+ # Feature package controller exists
300
+ class AdminPortal::PostsController < ::PostsController
301
+ include AdminPortal::Concerns::Controller
302
+ end
303
+
304
+ # No feature package — inherits portal base
305
+ class AdminPortal::PostsController < AdminPortal::ResourceController
306
+ end
307
+ ```
308
+
309
+ Non-resource portal pages (dashboard, settings) inherit from `PlutoniumController`:
310
+
311
+ ```ruby
312
+ module AdminPortal
313
+ class DashboardController < PlutoniumController
314
+ def index; end
315
+ end
316
+ end
317
+ ```
318
+
319
+ ---
320
+
321
+ # Part 2 — Policies
322
+
323
+ Built on [ActionPolicy](https://actionpolicy.evilmartians.io/). Plutonium adds:
324
+
325
+ - Attribute permissions (`permitted_attributes_for_*`)
326
+ - Association permissions (`permitted_associations`)
327
+ - Automatic entity scoping
328
+ - Derived action methods (`update?` inherits from `create?`, etc.)
329
+
330
+ ## Base class
331
+
332
+ ```ruby
333
+ # app/policies/resource_policy.rb (installed once)
334
+ class ResourcePolicy < Plutonium::Resource::Policy
335
+ # App-wide defaults
336
+ end
337
+
338
+ # app/policies/post_policy.rb (per resource, generated)
339
+ class PostPolicy < ResourcePolicy
340
+ def create? = user.present?
341
+ def read? = true
342
+
343
+ def permitted_attributes_for_create
344
+ %i[title content]
345
+ end
346
+
347
+ def permitted_attributes_for_read
348
+ %i[title content author created_at]
349
+ end
350
+ end
351
+ ```
352
+
353
+ ## Action permissions
354
+
355
+ ### Must override
356
+
357
+ ```ruby
358
+ def create? # default: false
359
+ user.present?
360
+ end
361
+
362
+ def read? # default: false
363
+ true
364
+ end
365
+ ```
366
+
367
+ ### Derived (inherit automatically)
368
+
369
+ | Method | Inherits from | Override when |
370
+ |--------|---------------|---------------|
371
+ | `update?` | `create?` | Different update rules |
372
+ | `destroy?` | `create?` | Different delete rules |
373
+ | `index?` | `read?` | Custom listing rules |
374
+ | `show?` | `read?` | Record-specific read rules |
375
+ | `new?` | `create?` | Rarely needed |
376
+ | `edit?` | `update?` | Rarely needed |
377
+ | `search?` | `index?` | Search-specific rules |
378
+
379
+ ### Custom actions
380
+
381
+ Define `def <action>?` matching the definition's `action :<action>`. Undefined methods return `false`:
382
+
383
+ ```ruby
384
+ def publish? = update? && record.draft?
385
+ def archive? = create? && !record.archived?
386
+ def invite_user? = user.admin?
387
+ ```
388
+
389
+ ### Bulk actions — per-record auth
390
+
391
+ ```ruby
392
+ def bulk_archive?
393
+ create? && !record.locked? # checked per record in the selection
394
+ end
395
+ ```
396
+
397
+ How it works:
398
+
399
+ - Policy is checked **per record** in the selected set.
400
+ - **Backend:** if any record fails, the entire request is rejected.
401
+ - **UI:** only actions ALL selected records support are shown (intersection).
402
+ - Records come from `current_authorized_scope` — users can only select what they're allowed to access.
403
+
404
+ ## Attribute permissions
405
+
406
+ ```ruby
407
+ # Must override for production
408
+ def permitted_attributes_for_read
409
+ %i[title content author published_at created_at]
410
+ end
411
+
412
+ def permitted_attributes_for_create
413
+ %i[title content]
414
+ end
415
+ ```
416
+
417
+ ### Derived
418
+
419
+ | Method | Inherits from |
420
+ |---|---|
421
+ | `permitted_attributes_for_update` | `permitted_attributes_for_create` |
422
+ | `permitted_attributes_for_index` | `permitted_attributes_for_read` |
423
+ | `permitted_attributes_for_show` | `permitted_attributes_for_read` |
424
+ | `permitted_attributes_for_new` | `permitted_attributes_for_create` |
425
+ | `permitted_attributes_for_edit` | `permitted_attributes_for_update` |
426
+
427
+ ### Per-action override
428
+
429
+ ```ruby
430
+ def permitted_attributes_for_index
431
+ %i[title author created_at] # minimal for the table
432
+ end
433
+
434
+ def permitted_attributes_for_read
435
+ %i[title content author tags created_at] # fuller for the show page
436
+ end
437
+ ```
438
+
439
+ 🚨 **Index has no `record`.** `permitted_attributes_for_index` is evaluated at collection level — `record` is `nil`. `permitted_attributes_for_show` (and `_for_read`) ARE evaluated per record. So if you write a record-dependent `_for_read`:
440
+
441
+ ```ruby
442
+ def permitted_attributes_for_read
443
+ attrs = %i[title content]
444
+ attrs << :archive_reason if record.archived? # uses record
445
+ attrs
446
+ end
447
+ ```
448
+
449
+ …you MUST also define an explicit `permitted_attributes_for_index` — otherwise inheritance kicks in, runs the `_for_read` body during the table render, and `record.archived?` blows up on `NoMethodError: undefined method 'archived?' for nil`.
450
+
451
+ ```ruby
452
+ def permitted_attributes_for_index
453
+ %i[title content] # no record-dependent fields
454
+ end
455
+ ```
456
+
457
+ Same rule for `permitted_attributes_for_create` vs `_for_new` (new has no persisted record).
458
+
459
+ ### Policy vs definition — what controls what
460
+
461
+ `permitted_attributes_for_*` controls **which fields appear** on a view. Definition `field`/`input`/`display`/`column` declarations only control **how** they render. A `field :name` in the definition does nothing unless `:name` is also in the relevant `permitted_attributes_for_*`.
462
+
463
+ Common mistake: adding a definition declaration and wondering why the field doesn't show — check the policy.
464
+
465
+ ### Anti-pattern: nested-attributes hashes
466
+
467
+ ```ruby
468
+ # ❌ NEVER
469
+ def permitted_attributes_for_create
470
+ [:name, {variants_attributes: [:id, :name, :_destroy]}]
471
+ end
472
+ ```
473
+
474
+ Plutonium extracts nested params via the form definition, not the policy. Hash entries get iterated as field names by the form renderer and render as literal text inputs.
475
+
476
+ ```ruby
477
+ # ✅ Policy permits just the association name
478
+ def permitted_attributes_for_create
479
+ [:name, :variants]
480
+ end
481
+ ```
482
+
483
+ `nested_input :variants` in the definition handles the rest. See [[plutonium-resource]] › Nested Inputs.
484
+
485
+ ## Association permissions
486
+
487
+ ```ruby
488
+ def permitted_associations
489
+ %i[comments tags author]
490
+ end
491
+ ```
492
+
493
+ Declares which associations get their own **tab on the show page**. When `permitted_associations` is non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs.
494
+
495
+ Each named association must:
496
+
497
+ - Exist on the model (raises `ArgumentError: unknown association ...` otherwise).
498
+ - Point to a class that's itself a registered Plutonium resource (raises `... is not a registered resource` otherwise).
499
+
500
+ This is **NOT** the same as:
501
+
502
+ - **Nested forms** — declared with `nested_input :variants` in the definition, requires `accepts_nested_attributes_for` on the model. See [[plutonium-resource]] › Nested Inputs.
503
+ - **Association fields on tables / show details** — controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.
504
+
505
+ ## Collection scoping (`relation_scope`)
506
+
507
+ Filter which records the user can see. **Always compose with `default_relation_scope(relation)` explicitly** — `super` is unreliable inside the block, and bypassing this triggers `verify_default_relation_scope_applied!`:
508
+
509
+ ```ruby
510
+ relation_scope do |relation|
511
+ relation = default_relation_scope(relation)
512
+ user.admin? ? relation : relation.where(author: user)
513
+ end
514
+ ```
515
+
516
+ For tenant scoping, parent scoping, `skip_default_relation_scope!`, and `associated_with` resolution: load [[plutonium-tenancy]].
517
+
518
+ ## Portal-specific policies
519
+
520
+ ```ruby
521
+ class PostPolicy < ResourcePolicy
522
+ def create? = user.present?
523
+ end
524
+
525
+ # Admin: more permissive
526
+ class AdminPortal::PostPolicy < ::PostPolicy
527
+ include AdminPortal::ResourcePolicy
528
+ def destroy? = true
529
+ def permitted_attributes_for_create = %i[title content featured internal_notes]
530
+ end
531
+
532
+ # Public: read-only
533
+ class PublicPortal::PostPolicy < ::PostPolicy
534
+ include PublicPortal::ResourcePolicy
535
+ def create? = false
536
+ end
537
+ ```
538
+
539
+ ## Authorization context
540
+
541
+ ```ruby
542
+ user # current user
543
+ record # the resource being authorized
544
+ entity_scope # current scoped entity (multi-tenancy)
545
+ parent # parent record for nested resources (nil otherwise)
546
+ parent_association # association name on parent (e.g. :comments)
547
+ ```
548
+
549
+ ### Custom context
550
+
551
+ ```ruby
552
+ # Policy
553
+ class PostPolicy < ResourcePolicy
554
+ authorize :department, allow_nil: true
555
+
556
+ def create? = department&.allows_posting?
557
+ end
558
+
559
+ # Controller
560
+ class PostsController < ResourceController
561
+ authorize :department, through: :current_department
562
+
563
+ private
564
+ def current_department = current_user.department
565
+ end
566
+ ```
567
+
568
+ ## Common patterns
569
+
570
+ ### Block archived records
571
+
572
+ ```ruby
573
+ def update? = !record.try(:archived?) && super
574
+ def destroy? = !record.try(:archived?) && super
575
+ ```
576
+
577
+ ### Owner-based
578
+
579
+ ```ruby
580
+ def update? = record.author == user || user.admin?
581
+ def destroy? = update?
582
+ ```
583
+
584
+ ### Role-based
585
+
586
+ ```ruby
587
+ def create? = user.admin? || user.editor?
588
+
589
+ def update?
590
+ return true if user.admin?
591
+ user.editor? && record.author == user
592
+ end
593
+ ```
594
+
595
+ ### Conditional attribute access
596
+
597
+ ```ruby
598
+ def permitted_attributes_for_create
599
+ attrs = %i[title content]
600
+ attrs += %i[featured author_id] if user.admin?
601
+ attrs
602
+ end
603
+ ```
604
+
605
+ ---
606
+
607
+ # Part 3 — Interactions
608
+
609
+ Interactions encapsulate business logic into testable units. They're registered as actions in definitions (see [[plutonium-resource]] › Actions) and executed by the controller.
610
+
611
+ ## Structure
612
+
613
+ ```ruby
614
+ # app/interactions/resource_interaction.rb (installed once)
615
+ class ResourceInteraction < Plutonium::Resource::Interaction
616
+ end
617
+
618
+ # A real interaction
619
+ class PublishPostInteraction < ResourceInteraction
620
+ presents label: "Publish",
621
+ icon: Phlex::TablerIcons::Send,
622
+ description: "Make this post public"
623
+
624
+ attribute :resource
625
+ attribute :publish_date, :datetime, default: -> { Time.current }
626
+
627
+ input :publish_date
628
+
629
+ validates :publish_date, presence: true
630
+
631
+ private
632
+
633
+ def execute
634
+ resource.update!(published_at: publish_date)
635
+ succeed(resource).with_message("Post published!")
636
+ rescue ActiveRecord::RecordInvalid => e
637
+ failed(e.record.errors)
638
+ end
639
+ end
640
+ ```
641
+
642
+ ## Attributes
643
+
644
+ ActiveModel-style:
645
+
646
+ ```ruby
647
+ attribute :resource # single record (record action)
648
+ attribute :resources # array of records (bulk action)
649
+ attribute :email, :string
650
+ attribute :count, :integer, default: 1
651
+ attribute :active, :boolean, default: -> { true } # callable default
652
+ attribute :tags, :array
653
+ attribute :metadata, :hash
654
+ attribute :date, :datetime
655
+ ```
656
+
657
+ The presence of `:resource` / `:resources` / neither determines the action type — see [[plutonium-resource]] › Action Types.
658
+
659
+ ## Inputs
660
+
661
+ Same DSL as definition `input` (load [[plutonium-resource]] for the full list of `as:` types, options, dynamic blocks, etc.):
662
+
663
+ ```ruby
664
+ input :email
665
+ input :role, as: :select, choices: %w[admin user]
666
+ input :content, as: :text
667
+ ```
668
+
669
+ Auto-detection rule from [[plutonium-resource]] applies here too: if the attribute type already implies the right widget, don't redeclare `as:`.
670
+
671
+ ## Presentation
672
+
673
+ ```ruby
674
+ presents label: "Archive Record",
675
+ icon: Phlex::TablerIcons::Archive,
676
+ description: "Move to archive"
677
+
678
+ # Access
679
+ MyInteraction.label
680
+ MyInteraction.icon
681
+ MyInteraction.description
682
+ ```
683
+
684
+ If `action :foo, interaction: FooInteraction` doesn't override `label:`/`icon:`/etc., these `presents` values are used.
685
+
686
+ ## `execute` — outcomes
687
+
688
+ `execute` MUST return a `succeed(...)` or `failed(...)` outcome. Validations run automatically before `execute`; if they fail, the interaction short-circuits to `failed()`.
689
+
690
+ ### Success
691
+
692
+ ```ruby
693
+ succeed(resource) # auto-redirect to resource
694
+ succeed(resource).with_message("Done!")
695
+ succeed(resource).with_message("Heads up!", :alert)
696
+ succeed(resource).with_redirect_response(custom_path) # different destination
697
+ succeed(resource).with_file_response(path, filename: "report.pdf")
698
+ ```
699
+
700
+ ### Failure
701
+
702
+ ```ruby
703
+ failed("Something went wrong")
704
+ failed(resource.errors)
705
+ failed(email: "is invalid", name: "is required") # hash form
706
+ failed("Invalid value", :email) # string + attribute
707
+ ```
708
+
709
+ ### Chaining
710
+
711
+ ```ruby
712
+ def execute
713
+ CreateUserInteraction.call(view_context:, **user_params)
714
+ .and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
715
+ .and_then { |r| LogActivity.call(view_context:, user: r.value) }
716
+ .with_message("User created and welcomed!")
717
+ end
718
+ ```
719
+
720
+ The chain short-circuits on the first failure.
721
+
722
+ ## Validations
723
+
724
+ Standard ActiveModel — run automatically before `execute`:
725
+
726
+ ```ruby
727
+ validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
728
+ validates :role, inclusion: {in: %w[admin user guest]}
729
+
730
+ validate :custom_check
731
+
732
+ private
733
+
734
+ def custom_check
735
+ errors.add(:resource, "cannot be modified when archived") if resource.archived?
736
+ end
737
+ ```
738
+
739
+ ## Accessing context
740
+
741
+ ```ruby
742
+ def execute
743
+ current_user = view_context.controller.helpers.current_user
744
+ resource.update!(updated_by: current_user)
745
+ succeed(resource)
746
+ end
747
+ ```
748
+
749
+ A shorter `current_user` helper is conventional:
750
+
751
+ ```ruby
752
+ private
753
+ def current_user = view_context.controller.helpers.current_user
754
+ ```
755
+
756
+ ## Interaction types
757
+
758
+ | Attribute pattern | Action type | Where it shows up |
759
+ |---|---|---|
760
+ | `attribute :resource` | Record action | Show page + per-row in table |
761
+ | `attribute :resources` | Bulk action | Bulk toolbar above table |
762
+ | neither | Resource action | Index page header |
763
+
764
+ **Bulk action authorization:** per-record. See [[plutonium-resource]] › Action Types and Part 2 above.
765
+
766
+ ## Generating interaction URLs
767
+
768
+ Use `resource_url_for` with the `interaction:` kwarg. Action type is inferred from the element and presence of `ids:`:
769
+
770
+ ```ruby
771
+ # Record action — instance argument
772
+ resource_url_for(@post, interaction: :publish)
773
+ # => /posts/:id/record_actions/publish
774
+
775
+ # Resource action — class, no ids
776
+ resource_url_for(Post, interaction: :import)
777
+ # => /posts/resource_actions/import
778
+
779
+ # Bulk action — class + ids
780
+ resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
781
+ # => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3
782
+
783
+ # Composes with parent / entity scoping
784
+ resource_url_for(@post, parent: @user, interaction: :publish)
785
+ ```
786
+
787
+ The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb routes to the right controller action. Passing both `interaction:` and `action:` raises `ArgumentError`.
788
+
789
+ ## Complete example
790
+
791
+ ```ruby
792
+ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
793
+ presents label: "Invite User",
794
+ icon: Phlex::TablerIcons::UserPlus
795
+
796
+ attribute :resource # the company
797
+ attribute :email, :string
798
+ attribute :role, :string
799
+
800
+ input :email
801
+ input :role, as: :select, choices: -> { UserInvite.roles.keys }
802
+
803
+ validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
804
+ validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
805
+ validate :not_already_invited
806
+
807
+ private
808
+
809
+ def execute
810
+ invite = UserInvite.create!(
811
+ company: resource, email: email, role: role,
812
+ invited_by: current_user
813
+ )
814
+ UserInviteMailer.invitation(invite).deliver_later
815
+ succeed(resource).with_message("Invitation sent to #{email}")
816
+ rescue ActiveRecord::RecordInvalid => e
817
+ failed(e.record.errors)
818
+ end
819
+
820
+ def not_already_invited
821
+ return unless email.present?
822
+ if UserInvite.exists?(company: resource, email: email, state: :pending)
823
+ errors.add(:email, "already has a pending invitation")
824
+ end
825
+ end
826
+
827
+ def current_user = view_context.controller.helpers.current_user
828
+ end
829
+ ```
830
+
831
+ ---
832
+
833
+ ## Related Skills
834
+
835
+ - [[plutonium-resource]] — registering interactions as actions; field/input/display syntax
836
+ - [[plutonium-tenancy]] — `relation_scope`, entity scoping, nested resources
837
+ - [[plutonium-ui]] — custom interaction form templates, page classes
838
+ - [[plutonium-testing]] — testing controllers, policies, interactions