plutonium 0.62.2 → 0.64.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 (256) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/.standard.yml +1 -1
  13. data/CHANGELOG.md +73 -0
  14. data/CLAUDE.md +87 -0
  15. data/Rakefile +34 -0
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/app/assets/plutonium.js +685 -102
  19. data/app/assets/plutonium.js.map +4 -4
  20. data/app/assets/plutonium.min.js +53 -53
  21. data/app/assets/plutonium.min.js.map +4 -4
  22. data/app/views/rodauth/_login_form.html.erb +13 -0
  23. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  24. data/docs/.vitepress/config.ts +81 -3
  25. data/docs/.vitepress/theme/blog.data.ts +44 -0
  26. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  27. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  28. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  29. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  30. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  31. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  32. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  33. data/docs/.vitepress/theme/index.ts +8 -4
  34. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  35. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  36. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  37. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  38. data/docs/blog/index.md +13 -0
  39. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  40. data/docs/blog/introducing-plutonium.md +253 -0
  41. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  42. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  43. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  44. data/docs/blog/two-forms-one-dom-id.md +69 -0
  45. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  46. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  47. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  48. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  49. data/docs/guides/authentication.md +73 -0
  50. data/docs/guides/authorization.md +2 -0
  51. data/docs/guides/creating-packages.md +5 -3
  52. data/docs/guides/custom-actions.md +74 -12
  53. data/docs/guides/customizing-ui.md +9 -2
  54. data/docs/guides/index.md +1 -0
  55. data/docs/guides/kanban.md +7 -5
  56. data/docs/guides/nested-resources.md +11 -1
  57. data/docs/guides/performance.md +104 -0
  58. data/docs/guides/user-invites.md +1 -1
  59. data/docs/guides/wizards.md +9 -1
  60. data/docs/index.md +3 -3
  61. data/docs/public/images/home/tour-actions.png +0 -0
  62. data/docs/public/images/home/tour-async.png +0 -0
  63. data/docs/public/images/home/tour-kanban.png +0 -0
  64. data/docs/public/images/home/tour-tenancy.png +0 -0
  65. data/docs/public/images/home/tour-wizard.png +0 -0
  66. data/docs/public/images/reference/async-progress-page.png +0 -0
  67. data/docs/public/images/reference/async-running-banner.png +0 -0
  68. data/docs/public/templates/experimental.rb +34 -0
  69. data/docs/public/templates/pluton8.rb +14 -0
  70. data/docs/reference/app/portals.md +15 -3
  71. data/docs/reference/auth/accounts.md +19 -0
  72. data/docs/reference/behavior/async-interactions.md +295 -0
  73. data/docs/reference/behavior/controllers.md +17 -4
  74. data/docs/reference/behavior/index.md +7 -1
  75. data/docs/reference/behavior/interactions.md +152 -22
  76. data/docs/reference/configuration.md +5 -0
  77. data/docs/reference/index.md +1 -0
  78. data/docs/reference/kanban/dsl.md +7 -4
  79. data/docs/reference/kanban/index.md +1 -1
  80. data/docs/reference/kanban/positioning.md +26 -4
  81. data/docs/reference/positioning.md +568 -0
  82. data/docs/reference/resource/actions.md +97 -4
  83. data/docs/reference/resource/definition.md +181 -9
  84. data/docs/reference/tenancy/invites.md +1 -1
  85. data/docs/reference/tenancy/nested-resources.md +60 -2
  86. data/docs/reference/ui/assets.md +4 -0
  87. data/docs/reference/ui/components.md +57 -4
  88. data/docs/reference/ui/displays.md +20 -10
  89. data/docs/reference/ui/index.md +1 -1
  90. data/docs/reference/wizard/dsl.md +33 -0
  91. data/docs/reference/wizard/storage-config.md +1 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  93. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  95. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  97. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  98. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  99. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  100. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  101. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  102. data/gemfiles/postgres.gemfile.lock +85 -85
  103. data/gemfiles/rails_7.gemfile.lock +323 -141
  104. data/gemfiles/rails_8.0.gemfile.lock +126 -116
  105. data/gemfiles/rails_8.1.gemfile.lock +127 -117
  106. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  107. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  108. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  109. data/lib/generators/pu/invites/install_generator.rb +3 -3
  110. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  112. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  113. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  114. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  115. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  116. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  118. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  119. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  120. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  121. data/lib/plutonium/action/base.rb +71 -9
  122. data/lib/plutonium/action/interactive.rb +9 -0
  123. data/lib/plutonium/attachments.rb +254 -0
  124. data/lib/plutonium/configuration.rb +82 -1
  125. data/lib/plutonium/core/controller.rb +50 -7
  126. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  127. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  128. data/lib/plutonium/definition/base.rb +51 -0
  129. data/lib/plutonium/definition/display_layout.rb +112 -0
  130. data/lib/plutonium/definition/index_views.rb +8 -7
  131. data/lib/plutonium/definition/input_aliases.rb +38 -0
  132. data/lib/plutonium/definition/page_widths.rb +65 -0
  133. data/lib/plutonium/definition/positioning.rb +126 -0
  134. data/lib/plutonium/definition/sorting.rb +17 -2
  135. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  136. data/lib/plutonium/interaction/README.md +61 -24
  137. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  138. data/lib/plutonium/interaction/async/context.rb +419 -0
  139. data/lib/plutonium/interaction/async/executor.rb +422 -0
  140. data/lib/plutonium/interaction/async/job.rb +80 -0
  141. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  142. data/lib/plutonium/interaction/async/run.rb +394 -0
  143. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  144. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  145. data/lib/plutonium/interaction/base.rb +34 -7
  146. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  147. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  148. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  149. data/lib/plutonium/kanban/board.rb +14 -0
  150. data/lib/plutonium/kanban/column.rb +4 -2
  151. data/lib/plutonium/kanban/dsl.rb +4 -1
  152. data/lib/plutonium/kanban/grouping.rb +9 -22
  153. data/lib/plutonium/kanban/positioning.rb +5 -65
  154. data/lib/plutonium/positioning/config.rb +94 -0
  155. data/lib/plutonium/positioning/model.rb +128 -0
  156. data/lib/plutonium/positioning.rb +25 -86
  157. data/lib/plutonium/railtie.rb +1 -0
  158. data/lib/plutonium/resource/controller.rb +118 -38
  159. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  160. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  161. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  162. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  163. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  164. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  165. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  166. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  167. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  168. data/lib/plutonium/resource/policy.rb +33 -0
  169. data/lib/plutonium/resource/query_object.rb +36 -0
  170. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  171. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  172. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  173. data/lib/plutonium/testing/resource_policy.rb +6 -2
  174. data/lib/plutonium/ui/action_button.rb +12 -7
  175. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  176. data/lib/plutonium/ui/block.rb +21 -1
  177. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  178. data/lib/plutonium/ui/component/methods.rb +5 -0
  179. data/lib/plutonium/ui/component/positionable.rb +112 -0
  180. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  181. data/lib/plutonium/ui/component/section.rb +185 -0
  182. data/lib/plutonium/ui/display/base.rb +13 -1
  183. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  184. data/lib/plutonium/ui/display/components/section.rb +18 -0
  185. data/lib/plutonium/ui/display/resource.rb +141 -22
  186. data/lib/plutonium/ui/display/theme.rb +20 -1
  187. data/lib/plutonium/ui/export_button.rb +1 -1
  188. data/lib/plutonium/ui/form/base.rb +8 -7
  189. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  190. data/lib/plutonium/ui/form/components/section.rb +7 -62
  191. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  192. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  193. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  194. data/lib/plutonium/ui/form/query.rb +2 -4
  195. data/lib/plutonium/ui/form/resource.rb +92 -15
  196. data/lib/plutonium/ui/form/theme.rb +17 -0
  197. data/lib/plutonium/ui/form/wizard.rb +25 -1
  198. data/lib/plutonium/ui/grid/card.rb +79 -11
  199. data/lib/plutonium/ui/grid/resource.rb +47 -5
  200. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  201. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  202. data/lib/plutonium/ui/kanban/card.rb +2 -1
  203. data/lib/plutonium/ui/kanban/column.rb +12 -6
  204. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  205. data/lib/plutonium/ui/layout/base.rb +10 -3
  206. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  207. data/lib/plutonium/ui/page/base.rb +19 -0
  208. data/lib/plutonium/ui/page/edit.rb +4 -1
  209. data/lib/plutonium/ui/page/index.rb +69 -18
  210. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  211. data/lib/plutonium/ui/page/new.rb +4 -1
  212. data/lib/plutonium/ui/page/show.rb +27 -10
  213. data/lib/plutonium/ui/page/wizard.rb +10 -1
  214. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  215. data/lib/plutonium/ui/page_width.rb +58 -0
  216. data/lib/plutonium/ui/table/base.rb +34 -1
  217. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  218. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  219. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  220. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  221. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  222. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  223. data/lib/plutonium/ui/table/resource.rb +50 -5
  224. data/lib/plutonium/ui/table/theme.rb +59 -2
  225. data/lib/plutonium/ui/wizard/review.rb +4 -2
  226. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  227. data/lib/plutonium/version.rb +1 -1
  228. data/lib/plutonium/wizard/attachments.rb +32 -197
  229. data/lib/plutonium/wizard/base.rb +6 -1
  230. data/lib/plutonium/wizard/configuration.rb +12 -0
  231. data/lib/plutonium/wizard/controller.rb +14 -0
  232. data/lib/plutonium/wizard/driving.rb +99 -15
  233. data/lib/plutonium/wizard/dsl.rb +23 -0
  234. data/lib/plutonium/wizard/resume.rb +127 -49
  235. data/lib/plutonium/wizard/runner.rb +46 -2
  236. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  237. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  238. data/lib/plutonium.rb +21 -0
  239. data/lib/rodauth/features/session_isolation.rb +92 -0
  240. data/lib/rodauth/plugins.rb +1 -0
  241. data/package.json +2 -1
  242. data/plutonium.gemspec +20 -20
  243. data/src/css/components.css +89 -1
  244. data/src/css/slim_select.css +20 -0
  245. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  246. data/src/js/controllers/bulk_actions_controller.js +10 -2
  247. data/src/js/controllers/kanban_controller.js +30 -21
  248. data/src/js/controllers/positioned_controller.js +452 -0
  249. data/src/js/controllers/register_controllers.js +6 -0
  250. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  251. data/src/js/controllers/run_progress_controller.js +73 -0
  252. data/src/js/drag/sortable.js +186 -0
  253. data/yarn.lock +108 -63
  254. metadata +101 -22
  255. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  256. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75154d0671bfe1934ecdb6d045b6a0e119be04f3d9faa37740cf8c50eb113dab
4
- data.tar.gz: e2dd3963d7a7862918a0ff3f3b482dec18115fec0c54ab5b186178a13006ae05
3
+ metadata.gz: 6abf981b5e375a2c576de8bfe5fe6448b115753356b6c91f1c3b0ae946e66935
4
+ data.tar.gz: b519f55b96e20f592baab81b34a633de562dcd8060dbe2bf65cbff7342ae76dd
5
5
  SHA512:
6
- metadata.gz: 9cbfdd419b58dbef8e5f70c91de197065edfdbc108dd93062f407ebff187e61be334c8ff63e96b05a5bee5e4333b51681dfb8403ea294d41c232d4b1885eae38
7
- data.tar.gz: 1114c97d24fd9c5341779419b545bb376503d2cc7e6b8b403b35ae8260c63f5c8db0483894c288ef8960ce72420952246e1e15f42f2bdb33e3e4da114130eaa8
6
+ metadata.gz: cbf194184d5d16439031b3802f5fc7b05b7f22b24a2075cac2d03a5e6869160f5332ea6cc4d83d3a580169db7405266435e5f9b7f4f1b42befd334a93a470f53
7
+ data.tar.gz: ec740ece535648156d103872cb0362347b6b2f2823d425aa0b94134f336a32355c807f767d05a673f173670fb1efd572c42240542b48b44f0ab5dc8b23ed63cc
@@ -20,6 +20,48 @@ Entry point for all Plutonium work. Does three things:
20
20
  - **Unattended execution:** always pass `--dest=`, `--force` (when re-running meta-generators), `--auth=`, `--skip-bundle`, `--quiet` so generators don't block on prompts. See [Unattended execution](#unattended-execution).
21
21
  - **Inspect before you act.** Every targeted skill now opens with a CHECK gate — read the relevant files yourself before scaffolding or editing. Don't ask the user to describe their app when you can read it.
22
22
 
23
+ ## The mental model (read once — it decides what you should write)
24
+
25
+ Plutonium applies Rails' bargain — follow the convention and the framework carries you; reach for an escape hatch when you need one — to the layer **above CRUD**: auth, authorization, multi-tenancy, admin UI, business operations. Four consequences change what you should actually type.
26
+
27
+ ### 1. Everything is derived from something you already declared
28
+
29
+ Not "defaults someone picked for you" — **computed from existing declarations**:
30
+
31
+ | Derived | From |
32
+ |---|---|
33
+ | Field types, required markers, select choices | model columns, associations, attachments, enums, **and validations** (`presence: true` → required; `inclusion:` → select choices) |
34
+ | A collection's preloads (index, kanban, export) | the policy's permitted field set — there is **no `includes` list to write or maintain** |
35
+ | Tenant scope | your associations — direct `belongs_to`, then `has_one`/`has_one :through`, then reverse `has_many` |
36
+ | Action type (record / bulk / resource) | whether the interaction declares `:resource`, `:resources`, or neither |
37
+ | An association input's typeahead | the **target resource's own `search` block** |
38
+ | CRUD, nested and action routes | one `register_resource` line |
39
+
40
+ ⇒ **Declare only what differs.** A `field :title` matching the detected type is dead code — and one more line to fall out of step when the column changes. This is the single most common way generated-looking code goes wrong.
41
+
42
+ ### 2. Definition and policy answer different questions
43
+
44
+ - **Definition** = *how* a field renders.
45
+ - **Policy** = *whether it appears at all*.
46
+
47
+ "Only admins see this field" is `permitted_attributes_for_*`. Never a definition declaration, and never a `condition:` (that only hides UI — the route stays live).
48
+
49
+ ### 3. Overrides are plain Ruby inheritance
50
+
51
+ `AdminPortal::PostDefinition < ::PostDefinition`, and the same for policies and controllers. App-level default, portal-level subclass. No registry of overrides, no precedence DSL, no merge semantics — so "why does this field show here but not there" is always readable as a class hierarchy.
52
+
53
+ ### 4. Climb the escape-hatch ladder only as far as the problem requires
54
+
55
+ 1. **Change an option** — `input :content, as: :markdown`
56
+ 2. **Render inline** — `display :priority, as: :phlexi_render, with: ->(value, attrs) do … end`
57
+ 3. **Write a component** — a *field* component (subclasses the Phlexi base) plugs into `as:`; anything with its own constructor goes through a block (`display :card do |field| … end`)
58
+ 4. **Implement a hook** — controller hooks instead of reopening `create`/`update`; page `render_before_*` / `render_after_*` instead of `view_template`
59
+ 5. **Replace the page** — `view_template` on the nested class, or an ERB view at the controller path (ERB wins when both exist)
60
+
61
+ Reaching for rung 5 on a rung-1 problem is how you end up owning breadcrumbs, the header and turbo frame wiring you never meant to touch.
62
+
63
+ **Underneath all of it, it stays Rails.** Models are plain ActiveRecord, controllers inherit from Rails controllers, views resolve through Rails view paths. A Plutonium resource and a hand-written controller coexist in one app.
64
+
23
65
  ## ✅ Orient before you route (CHECK — read the app, don't assume)
24
66
 
25
67
  A one-line request rarely says whether this is a new app, a half-built one, or a multi-tenant one — and those change which path you take. Spend 30 seconds reading the app **before** loading a bundle or running anything:
@@ -40,6 +82,7 @@ This is the global "look before you leap"; each targeted skill carries its own A
40
82
  | **[[plutonium-app]]** | Installation, packages (feature + portal), portal engines, mounting, `register_resource` (including singular and custom routes), `pu:res:conn` |
41
83
  | **[[plutonium-resource]]** | The resource itself — `pu:res:scaffold`, field types, model layer (`Plutonium::Resource::Record`, `has_cents`, SGID, routing), definition layer (fields/inputs/displays/columns, search/filters/scopes/sorting, custom actions, bulk actions, index views, page customization) |
42
84
  | **[[plutonium-behavior]]** | Controllers (hooks, key methods, presentation), policies (action methods, `permitted_attributes_for_*`, `permitted_associations`), interactions (structure, outcomes, chaining, URL generation) |
85
+ | **[[plutonium-async-interactions]]** | Async interactions — `async`, the Run STI model, failure policies (`halt`/`continue`/`transactional`), authorization re-derivation at perform time, registering AsyncRun as a resource (progress page + running banner), scheduling `ReapJob` |
43
86
  | **[[plutonium-ui]]** | Page classes, forms, displays, tables, custom Phlex components, layouts, modals & tabs, Tailwind config, Stimulus, design tokens, `.pu-*` classes, Phlexi themes |
44
87
  | **[[plutonium-kanban]]** | `kanban do…end` DSL in a Definition — columns, `card_fields`, `position_on`, `realtime`, column actions, `kanban_move?` policy, quick-add, static vs dynamic boards |
45
88
  | **[[plutonium-auth]]** | Rodauth install, account types (basic / admin / SaaS), profile resource, security section |
@@ -74,6 +117,7 @@ Add when relevant:
74
117
  | Override a controller action, hook, redirect, or `resource_params` | **[[plutonium-behavior]]** |
75
118
  | Write `relation_scope`, `permitted_attributes_for_*`, `permitted_associations`, action methods, or any policy override | **[[plutonium-behavior]]** (+ **[[plutonium-tenancy]]** if scoping) |
76
119
  | Write an interaction class for business logic | **[[plutonium-behavior]]** |
120
+ | Make a bulk/long-running interaction async (`async`), or schedule the stalled-run reaper | **[[plutonium-async-interactions]]** |
77
121
  | Scope a model to a tenant, write `associated_with`, set portal entity strategy | **[[plutonium-tenancy]]** |
78
122
  | Configure parent/child nested routes, custom parent resolution | **[[plutonium-tenancy]]** |
79
123
  | Set up user invitations or entity membership | **[[plutonium-tenancy]]** |
@@ -402,13 +402,13 @@ end
402
402
  ## Per-portal overrides
403
403
 
404
404
  ```ruby
405
- # Definition
405
+ # Definition — how fields render (NOT whether they appear)
406
406
  class AdminPortal::PostDefinition < ::PostDefinition
407
- input :internal_notes, as: :text # admins see this; customers don't
408
407
  scope :pending_review
408
+ input :internal_notes, hint: "Not shown to the author"
409
409
  end
410
410
 
411
- # Policy
411
+ # Policy — whether a field appears at all
412
412
  class AdminPortal::PostPolicy < ::PostPolicy
413
413
  include AdminPortal::ResourcePolicy
414
414
  def destroy? = true
@@ -0,0 +1,191 @@
1
+ ---
2
+ name: plutonium-async-interactions
3
+ description: Use BEFORE building any bulk operation or long-running interaction. Covers async, the Run STI model, failure policies (halt/continue/transactional), authorization re-derivation at perform time, registering AsyncRun as a resource (progress page + running banner), and scheduling ReapJob for stalled runs. The single source for "how do I make an interaction async".
4
+ ---
5
+
6
+ # Plutonium Async Interactions
7
+
8
+ `async` turns an interaction from "does the work inline" into "persists a run, enqueues it, and redirects to it." Reach for it once the work is too slow to hold a request open — thousands of records, report generation, a third-party call.
9
+
10
+ For everything about the interaction itself (inputs, validation, outcomes, `execute`), load [[plutonium-behavior]] first. `async` only replaces what `execute` does, not the rest of the interaction's shape.
11
+
12
+ ## 🚨 Critical (read first)
13
+
14
+ - **Experimental.** The DSL and behavior may change in a future release — same status as [[plutonium-wizard]] and [[plutonium-kanban]]. Fine to build on; expect to revisit it on upgrade.
15
+ - **Enable the subsystem first.** `rails g pu:async_interactions:install --dest=<portal>` flips `config.async_interactions.enabled = true`, schedules `ReapJob`, and connects the run resource to that portal; then `rails db:migrate`. Pass `--skip-portal` to enable it before any portal exists. Off by default, so no `plutonium_async_runs` table otherwise.
16
+ - **`async` fully replaces `#execute`.** An interaction either executes inline or runs async — declaring both raises `ArgumentError` at load.
17
+ - **Define `perform_on(record)` for targeted work, `perform` for opaque work.** A run class implementing neither fails loudly (naming the class) the first time it's performed, rather than a bare `NoMethodError`.
18
+ - **A nested dispatch records its parent.** `parent_type`/`parent_id`/`parent_association` join initiator and tenant on the row, because `Policy#default_relation_scope` picks parent scoping **or** entity scoping, not both — a nested run missing its parent re-derives targets under the wider tenant scope, and any predicate reading `parent` silently answers false. A parent deleted mid-run refuses the run, exactly like a deleted tenant.
19
+ - **Permissions are re-derived at perform time, never replayed from dispatch.** The job rebuilds `(initiator, tenant)` from the row and re-checks the policy scope and predicate per target, immediately before each `perform_on`. A permission revoked mid-run stops applying to what's left. Both failure directions (scope, predicate) fail closed (refuse/report), never open.
20
+ - **Register `Run` as a resource per portal.** Its show page IS the progress page, and other resources' index pages get a "runs in progress" banner for free. Nothing renders without registration.
21
+ - **Read `outcome`, never bare `state`, when displaying a run's result.** A `:continue` run that under-applied still has `state == "completed"`; only `outcome` says `"completed_with_errors"`.
22
+ - **Long work must call `heartbeat!`.** `stall_after` measures SILENCE, and the executor only writes per target — so opaque `perform` writes nothing at all between claim and finish. An opaque run longer than `stall_after` is reaped and, having no `handled_target_ids`, re-run from scratch. Call `heartbeat!` inside the loop. It also raises `StaleObjectError` if another worker took the run over, which for opaque work is the only way to find out.
23
+ - **A crashed/stalled run does not auto-heal.** Nothing revisits a `"running"` row on its own. `pu:async_interactions:install` schedules `Plutonium::Interaction::Async::ReapJob` for you when Solid Queue is in the bundle; otherwise (or on another scheduler) you must schedule it yourself. Unscheduled, a crash mid-batch leaves that row stuck forever.
24
+
25
+ ---
26
+
27
+ ## ✅ Before you build: verify the ground truth (CHECK, don't ask for it)
28
+
29
+ | Check | How | Why it matters |
30
+ |---|---|---|
31
+ | Subsystem enabled | grep `config/initializers/plutonium.rb` for `async_runs.enabled` | Not enabled means no table; `async` raises `Dispatchable::NotEnabledError` the moment it dispatches, naming the flag |
32
+ | Run registered in the target portal | grep the portal's `config/routes.rb` for `register_resource ::Plutonium::Interaction::Async::Run` | Unregistered means dispatch redirects to a 404; the running banner silently skips that portal (by design, see below) |
33
+ | Existing run classes for the pattern | `ls app/runs/` or grep `< Plutonium::Interaction::Async::Run` | Match the host's existing `on_failure` conventions rather than guessing |
34
+ | ReapJob scheduled | grep `config/recurring.yml` / `config/schedule.rb` for `ReapJob` | An `on_submit`-shaped bulk workflow with no reaper leaves crashed runs stuck |
35
+
36
+ ---
37
+
38
+ ## Declaring the work
39
+
40
+ `async` with a block. One file — the run is declared inline and needs no name:
41
+
42
+ ```ruby
43
+ class Blogging::ArchivePosts < ResourceInteraction
44
+ presents label: "Archive", icon: Phlex::TablerIcons::Archive
45
+ attribute :resources # bulk: perform_on runs once per record
46
+ attribute :reason, :string
47
+
48
+ async do
49
+ on_failure :continue # :halt (default) | :continue | :transactional
50
+ def perform_on(post) # targeted: called once per resolved target
51
+ post.archive!(reason: options["reason"])
52
+ end
53
+ end
54
+ end
55
+
56
+ class Reports::GenerateMonthly < ResourceInteraction
57
+ attribute :period, :string
58
+
59
+ async do
60
+ def perform # opaque: no target, called once
61
+ Reports::Monthly.generate!(options["period"])
62
+ end
63
+ end
64
+ end
65
+ ```
66
+
67
+ **The block is the run's class body, not the body of `#execute`.** The work happens later, in a process with no controller and no `view_context`, so it cannot close over anything in the interaction — which is why it declares `perform_on`/`perform` rather than executing directly. Validated attributes arrive through `options`, and `def` opens a fresh scope, so those bodies can't accidentally capture the interaction's locals.
68
+
69
+ The block defines `<Interaction>::Run` — a real, named constant, because the class name is persisted in `type` and constantized in the job process.
70
+
71
+ **Pass a class instead** to share one run across several interactions that do the same kind of work:
72
+
73
+ ```ruby
74
+ async Blogging::ArchivePostsRun
75
+ ```
76
+
77
+ | `on_failure` | One target raises |
78
+ |---|---|
79
+ | `:halt` (default) | Stop immediately; run ends `"failed"`; remaining targets never attempted |
80
+ | `:continue` | Record the failure (`errors_log`), keep going; run ends `"completed"`. Check `outcome`, not `state`, to see it under-applied |
81
+ | `:transactional` | Whole batch in one DB transaction; any failure rolls back everything, including targets already applied |
82
+
83
+ `attribute :resource` (singular) means a one-target run; `attribute :resources` (plural) means bulk; neither means opaque. Same inference rule ordinary interactive actions use, see [[plutonium-resource]] › Actions.
84
+
85
+ ### What's recorded, and why
86
+
87
+ Nothing is passed explicitly. Dispatch reads it off the interaction/controller it's already running in:
88
+
89
+ - **Targets** as ids (`target_ids`), re-resolved through the policy scope at perform time. Never serialized records, which would be stale and unauthorized the moment anything changed.
90
+ - **Initiator + tenant** (`current_user` / `current_scoped_entity`), the pair every Plutonium policy authorizes on.
91
+ - **`policy_class_name`**, the policy dispatch actually resolved, not an inferred `"#{Model}Policy"` (wrong under a namespaced portal or an STI target).
92
+ - **`policy_action`**, the predicate dispatch checked (e.g. `"archive?"`), re-asked per target at perform time.
93
+ - **`authorization_namespace`**, the portal's module name, so perform-time lookup finds the same policy dispatch did.
94
+
95
+ An opaque run records none of the target/policy columns. Nothing to re-verify without a subject.
96
+
97
+ ## Attributes and files
98
+
99
+ Validated attributes reach the run through `options`, a JSON column written via `ActiveJob::Arguments` — primitives verbatim, `Date`/`BigDecimal`/`Time` round-tripped with their types. An attribute that can't be carried is refused at dispatch.
100
+
101
+ Files can't ride a JSON column, and the request's tempfile is gone by the time the job runs, so an uploaded file is staged to its backend's cache and carried as a token. Read it back with `attachment`:
102
+
103
+ ```ruby
104
+ attribute :import_file
105
+
106
+ async do
107
+ def perform
108
+ attachment(:import_file).open { |f| CSV.foreach(f, headers: true) { |row| ... } }
109
+ end
110
+ end
111
+ ```
112
+
113
+ `attachment(:key)` / `attachments(:key)` give `filename`, `content_type`, `url`, `open`, `download`.
114
+
115
+ `backend:` and `uploader:` come off the attribute's `input` declaration, exactly as in a wizard step — `input :import_file, as: :uppy, uploader: Catalog::ImportUploader`. The uploader's `Attacher.validate` rules run when the interaction validates, so a bad file **fails the form** rather than surfacing as a run failure the submitter never sees. Where no `backend:` is declared: `config.async_interactions.attachment_backend` → `config.attachment_backend` → auto-detect.
116
+
117
+ ## Registering the Run resource
118
+
119
+ Use the generator, per portal. Never hand-write the route/controller:
120
+
121
+ ```bash
122
+ rails g pu:async_interactions:install --dest=admin_portal
123
+ ```
124
+
125
+ ```ruby
126
+ # packages/admin_portal/config/routes.rb
127
+ register_resource ::Plutonium::Interaction::Async::Run
128
+ ```
129
+
130
+ ```ruby
131
+ # packages/admin_portal/app/controllers/admin_portal/async_runs_controller.rb
132
+ class AdminPortal::AsyncRunsController < AdminPortal::ResourceController
133
+ controller_for ::Plutonium::Interaction::Async::Run
134
+
135
+ include AdminPortal::Concerns::Controller
136
+ end
137
+ ```
138
+
139
+ `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 (exact class-name match for the definition, ActionPolicy's own lookup for the policy).
140
+
141
+ If Solid Queue is in the bundle, this also schedules `Async::ReapJob` in `config/recurring.yml` — see [Scheduling ReapJob](#scheduling-reapjob-stalled-runs). `--schedule` overrides the default `every 15 minutes`. Idempotent, so running it against a second portal doesn't duplicate the entry.
142
+
143
+ Registering gets you, for free:
144
+
145
+ - **Progress page.** The show page IS the progress page, self-polling while `pending`/`running`, and stops carrying the poll once settled (a finished run is a static page, not an eternal background request per viewer).
146
+ - **Self-refreshing index.** The runs index polls on the same terms, one frame around the whole collection, armed only while some run is still working. One request per interval whatever the page size, and it re-fetches the current URL so filters, sort and page survive. No action needed on your part.
147
+ - **Running banner.** Any OTHER registered resource's index lists in-progress runs targeting it, above the collection, scoped through the same `authorized_resource_scope` every cross-resource read uses (a run in another tenant can't surface). If a `resource_class` is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while building a link to a nonexistent route. No action needed on your part.
148
+ - **`target_label`.** The show page/table read `run.target_label` (`model_name.human`, e.g. `"Post"`) rather than the raw `target_type` string (`"Blogging::Post"`).
149
+ - **Tenant scoping via `associated_with`.** Filters on the tenant the run was dispatched in (recorded on the row), because the two tenant columns are polymorphic and the generic object-graph scope can't walk them.
150
+
151
+ ## Scheduling ReapJob (stalled runs)
152
+
153
+ A worker crash mid-batch, or a job the queue silently drops, leaves a run `"running"`/`"pending"` forever. Nothing else revisits it. `ReapJob` finds runs with no activity (`last_activity_at`, or `created_at` if never picked up) past `config.async_interactions.stall_after` (default `1.hour`), and resumes them: resets to `"pending"`, re-enqueues.
154
+
155
+ This is safe, not a blind replay. The executor tracks `handled_target_ids` and only re-attempts what's left.
156
+
157
+ Safe *given a heartbeat*: `handled_target_ids` only exists for targeted work, so opaque work resumes by re-running `perform` whole. Long opaque work must call `heartbeat!` (see Critical, above) so it is never judged stalled in the first place.
158
+
159
+ `rails g pu:async_interactions:install` schedules it automatically when Solid Queue is in the bundle:
160
+
161
+ ```yaml
162
+ # config/recurring.yml (Solid Queue)
163
+ production:
164
+ reap_stalled_async_runs:
165
+ class: Plutonium::Interaction::Async::ReapJob
166
+ schedule: every 15 minutes
167
+ ```
168
+
169
+ Without Solid Queue — or for another scheduler like `whenever` — add it yourself:
170
+
171
+ ```ruby
172
+ # whenever gem
173
+ every 15.minutes do
174
+ runner "Plutonium::Interaction::Async::ReapJob.perform_later"
175
+ end
176
+ ```
177
+
178
+ 15 to 30 minutes is a reasonable cadence against the default 1-hour `stall_after`. This is a time heuristic, not a true lease: a merely-slow (not dead) run that crosses `stall_after` gets resumed too. `lock_version` bounds what that costs — the resumed row's version no longer matches the still-live worker's, so that worker stops at its next write instead of racing the new one. It does **not** interrupt an in-flight `perform_on` (one target can be applied twice, once by each side), and it does not roll back what the superseded worker already committed. Set `stall_after` well above the app's slowest legitimate run.
179
+
180
+ On Solid Queue (or any queue providing ActiveJob concurrency controls) this is tightened further, automatically and with no configuration: `Async::Job` declares a semaphore of 1 keyed on the run id, held for `stall_after`, and `ReapJob` one global sweep at a time. A second delivery of the same run then waits rather than racing, so the one target the fence cannot save from a double apply is not applied twice either. Nothing declares it when the queue does not support it.
181
+
182
+ ## Full reference
183
+
184
+ `docs/reference/behavior/async-interactions.md` has the complete write-up: authorization re-derivation in detail, outcome-vs-state, everything above with more context.
185
+
186
+ ## Related Skills
187
+
188
+ - [[plutonium-behavior]] — the interaction itself: inputs, validation, `succeed`/`failed`, policies.
189
+ - [[plutonium-resource]] — Actions (inferred bulk/record/resource shape), Definition (`field`/`display`/`column`).
190
+ - [[plutonium-tenancy]] — entity scoping, `associated_with`, portal tenant strategies.
191
+ - [[plutonium-wizard]] — the other long-lived, persisted flow primitive (multi-step, not async execution). `Wizard::SweepJob` is `ReapJob`'s sibling for abandoned wizard sessions.
@@ -222,6 +222,39 @@ For portal wiring (`AdminPortal::Concerns::Controller`), see [[plutonium-app]]
222
222
 
223
223
  ---
224
224
 
225
+ ## Multiple portals in one browser
226
+
227
+ Each portal authenticates through its own Rodauth configuration, and a person can hold a session in several at once — sign into the admin portal and the customer portal in the same browser without either evicting the other.
228
+
229
+ Two settings make that work, and the generators emit both:
230
+
231
+ ```ruby
232
+ # app/rodauth/rodauth_plugin.rb — the shared base
233
+ configure do
234
+ enable :session_isolation
235
+ end
236
+
237
+ # app/rodauth/<name>_rodauth_plugin.rb — once per account type
238
+ configure do
239
+ session_key_prefix "admin_"
240
+ remember_cookie_key "_admin_remember"
241
+ end
242
+ ```
243
+
244
+ 🚨 **Both are required.** `session_key_prefix` namespaces every session key a configuration touches — the account id plus `authenticated_by`, `login_redirect`, `two_factor_auth_setup`, … `session_isolation` uses that prefix to decide ownership and stops a login from clearing the other configurations' keys.
245
+
246
+ 🚨 **Never set `session_key` alongside it.** Explicit values bypass `convert_session_key` (`rodauth/features/base.rb:686`) so they are NOT prefixed — the account id then rotates separately from every other key. A session holding an account id with no `authenticated_by` makes Rodauth raise on every request (`logged_in_via_remember_key?` → `nil.include?`, `remember.rb:175`). A config with no prefix at all is simply not isolated: its keys are the unprefixed defaults, so nothing is carried for it.
247
+
248
+ **Why:** Rodauth resets the session on every login — including the `remember` feature's `load_memory` autologin — to defend against session fixation, and rodauth-rails implements that as a full `reset_session`. Without `session_isolation`, signing into one portal wipes every other portal's session; and because `RodauthApp#route` calls `load_memory` for *every* configuration on *every* request, the evicted configuration autologins from its remember cookie on the next request and evicts the new one right back. The last `load_memory` in the route block wins permanently, so the other portal can never hold a session at all.
249
+
250
+ `session_isolation` carries only the *other configurations'* session entries across the reset. The session id is still rotated and application session data is still cleared, so session fixation is still defeated.
251
+
252
+ **Reading a raw Rodauth session key?** Go through its accessor, never the literal — `session.delete(login_redirect_session_key)`, not `session.delete(:login_redirect)`. With a prefix set, the literal is the wrong key.
253
+
254
+ **Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin, add `session_key_prefix` to each account plugin, and **delete any existing `session_key "_x_session"` line**. Every key name changes together, so stale session cookies are simply ignored — the safe outcome. Keeping the old `session_key` to preserve logins is exactly what produces the crashing half-migrated session. Only *unremembered* sessions actually drop: `remember_cookie_key` is a cookie name and is not prefixed, so anyone holding a valid `_x_remember` cookie is restored by `load_memory` on their next request.
255
+
256
+ ---
257
+
225
258
  ## Common customizations
226
259
 
227
260
  All inside the Rodauth `configure do ... end` block in `app/rodauth/<name>_rodauth_plugin.rb`.
@@ -410,6 +443,9 @@ link_to("Profile", profile_url) if respond_to?(:profile_url)
410
443
  - **`pu:saas:setup` runs four other generators** — don't re-run portal, profile, welcome, or invites separately.
411
444
  - **Profile requires `pu:profile:conn`** — without it, no route, no `profile_url`, no menu link.
412
445
  - **Users need a profile row.** Add an `after_create` callback (or `find_or_create_by`) — `current_user.profile` is otherwise nil.
446
+ - **Concurrent portal logins need `enable :session_isolation` + `session_key_prefix`.** Missing either and signing into one portal silently evicts the others — see Multiple portals in one browser.
447
+ - **Never hardcode a Rodauth session key.** Use the accessor (`login_redirect_session_key`), since `session_key_prefix` changes the literal.
448
+ - **"Remember me" is opt-in.** Configs use `after_login { remember_login if param_or_nil(remember_param) == remember_remember_param_value }` and the login form renders the checkbox. Compare against the value, not just presence — a bare truthiness check means `remember=disable` would remember you. Plutonium's form also passes `include_hidden: false` so an unticked box sends nothing — belt-and-braces next to the value comparison, not the thing holding it up.
413
449
 
414
450
  ---
415
451
 
@@ -17,6 +17,7 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
17
17
  - **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detection works; production raises.
18
18
  - **`ActiveRecord::RecordInvalid` is NOT rescued automatically in interactions.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
19
19
  - **Return `succeed(...)` or `failed(...)`** from `execute` — the controller can't tell what happened otherwise.
20
+ - **An interaction is a presentation object** (it can only be built with a `view_context`). Logic may *start* in `execute`; the **second caller** — a job, an API controller, a rake task, the console — is the trigger to move it onto the **model**. Don't pre-extract, and don't invent a service layer. See Part 3 › Where the logic goes.
20
21
  - **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
21
22
  - **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly. `super` works when extending a parent policy (e.g., a package base) that itself calls it. See [[plutonium-tenancy]].
22
23
  - **For `has_cents` fields, use the virtual name (`:price`), not `:price_cents`** in `permitted_attributes_for_*`.
@@ -32,7 +33,8 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
32
33
  | The requirement (in plain words) | Goes in | **NOT** in |
33
34
  |---|---|---|
34
35
  | "only \<role/owner\> may do X" — *who is allowed* | **Policy** `def x?` | a `condition:` proc — that only hides the button; the route stays live and callable |
35
- | "doing X changes state / sends mail / charges a card" — *the work* | **Interaction** `execute`, registered as an action | a hand-written controller action; an override of `create`/`update` |
36
+ | "there's a button that does X" — *the trigger* | **Interaction** (+ action in the definition) | a hand-written controller action; an override of `create`/`update` |
37
+ | "doing X changes state / sends mail / charges a card" — *the work* | a named **model** method the interaction calls (`post.publish!`) — inline in `execute` is fine while the button is the only caller | a service-object layer; three chained interactions |
36
38
  | "after create/update go to Y" · "munge a param" · "reshape the index query" | **Controller hook** (`redirect_url_after_submit`, `resource_params`, `filtered_resource_collection`) | overriding `create`/`update`/`index` |
37
39
  | "which fields are visible / editable" | **Policy** `permitted_attributes_for_*` | the definition — that only controls *how* a field renders |
38
40
 
@@ -109,7 +111,8 @@ Plus interactive-action routes for every action declared in the definition.
109
111
  |---|---|
110
112
  | Field rendering (inputs, displays, columns) | Definition |
111
113
  | Search, filters, scopes, sorting | Definition |
112
- | Custom operations (publish, archive, import) | Interaction (+ action in definition) |
114
+ | Custom operations (publish, archive, import) — the *button* | Interaction (+ action in definition) |
115
+ | The operation itself, once a job/API/task also needs it | The **model** (`post.publish!`) — see Part 3 › Where the logic goes |
113
116
  | Authorization rules | Policy |
114
117
  | Form/show/page chrome | Definition (custom page classes) |
115
118
  | **Custom redirect logic** | **Controller hook** |
@@ -158,6 +161,10 @@ def filtered_resource_collection
158
161
  end
159
162
  ```
160
163
 
164
+ **Don't add eager loading unprompted.** Which associations a page renders is decided by the definition, so an `includes` list written now is a guess that goes stale when a column is added. Adding one is a performance change the user didn't ask for.
165
+
166
+ When a user actually reports a slow index or an N+1: suggest [goldiloader](https://github.com/salsify/goldiloader) first — it eager-loads on traversal, so it tracks whatever the definition renders and needs no list to maintain. Only hand-write `def filtered_resource_collection = super.includes(...)` if they decline the gem, and use the policy's `relation_scope` instead when the association is also read on show/export/typeahead. Full detail: [Guides › Performance](/guides/performance).
167
+
161
168
  ### Presentation hooks
162
169
 
163
170
  Control whether parent / scoped-entity fields appear in forms and displays. Defaults are `false` (hidden, since they're inferred from the URL/portal).
@@ -171,13 +178,15 @@ def submit_scoped_entity? = true
171
178
 
172
179
  ## Custom actions
173
180
 
174
- 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.).
181
+ Prefer **interactive actions** (definition + interaction) for anything a user triggers from a page. The only reason to hand-write a controller action is unusual flows (custom response shapes, external service callbacks, etc.).
182
+
183
+ Either way the *operation* is a named model method — the controller and the interaction are two front doors onto the same `post.publish!`.
175
184
 
176
185
  ```ruby
177
186
  class PostsController < ::ResourceController
178
187
  def publish
179
188
  authorize_current!(resource_record!, to: :publish?)
180
- resource_record!.update!(published: true)
189
+ resource_record!.publish!
181
190
  redirect_to resource_url_for(resource_record!), notice: "Published!"
182
191
  end
183
192
  end
@@ -279,10 +288,12 @@ end
279
288
  | Method | Returns |
280
289
  |---|---|
281
290
  | `current_parent` | Parent record |
291
+ | `current_parent_class` | `User` |
282
292
  | `current_nested_association` | `:posts` |
283
- | `parent_route_param` | `:user_id` |
284
293
  | `parent_input_param` | `:user` |
285
294
 
295
+ The parent class and association come from the **route** (each nested route carries its registration key), not from parsing the URL. There is no `parent_route_param`.
296
+
286
297
  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.
287
298
 
288
299
  ## Entity scoping (multi-tenancy)
@@ -423,6 +434,8 @@ end
423
434
  | `search?` | `index?` | Search-specific rules |
424
435
  | `typeahead?` | `index?` | Autocomplete-specific rules |
425
436
 
437
+ 🚨 **`record` is the resource CLASS on collection routes** (`current_policy_subject = resource_record? || resource_class`). `read?` backs both `show?` (instance) and `index?` (class); `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates (incl. kanban column actions) are class-backed too. `def read? = record.published?` raises `NoMethodError` on index — filter the list in `relation_scope`, gate individual records in `show?`. Record actions (`publish?` etc.) and bulk actions are always evaluated per record instance — no type guard needed.
438
+
426
439
  `export_csv?` is the exception — it defaults to `false` (not derived) so CSV export is strictly opt-in. Override it to `true` (or `index?`) to enable the built-in export. The exported column set is `permitted_attributes_for_export` (defaults to `permitted_attributes_for_index`). See [[plutonium-resource]] → CSV Export.
427
440
 
428
441
  ### Custom actions
@@ -656,7 +669,41 @@ end
656
669
 
657
670
  # Part 3 — Interactions
658
671
 
659
- Interactions encapsulate business logic into testable units. They're registered as actions in definitions (see [[plutonium-resource]] › Actions) and executed by the controller.
672
+ An interaction is the entry point from a Plutonium page into an operation: it declares the inputs, renders as a button and a form, is gated by a policy, and returns an outcome the controller turns into a flash + redirect. Registered as actions in definitions (see [[plutonium-resource]] › Actions) and executed by the controller.
673
+
674
+ For a bulk operation, or anything too slow to hold a request open, `async` declares a persisted, resumable run instead of executing inline. See [[plutonium-async-interactions]].
675
+
676
+ ## Where the logic goes
677
+
678
+ An interaction is a **presentation object**. Its job is the button, the form, input-shape validation, and the user-facing outcome. It is not the home of a domain operation, because of one line in the base class:
679
+
680
+ ```ruby
681
+ def initialize(view_context:, **attributes)
682
+ ```
683
+
684
+ `view_context:` is required. So a caller that isn't a Plutonium page has two options: duplicate the logic, or manufacture a `view_context` it has no business owning. **`view_context` is the tell.**
685
+
686
+ | | |
687
+ |---|---|
688
+ | **Logic may start in `execute`** | A one-off with a single caller is fine inline. Don't pre-extract — Plutonium ships no service layer to put it in, and YAGNI. |
689
+ | **The trigger to extract is the second caller** | A background job, an API controller, a rake task, the console, another interaction. |
690
+ | **The destination is the model** | Fat models, per Rails convention. Name it in domain language — `publish!`, `archive!`, `register!` — not persistence (`update_published_at`). Never a `PublishPostService`. |
691
+
692
+ ```ruby
693
+ # 🚫 Three interactions, three view_contexts. A signup API or a seeds script
694
+ # can supply none of them — the email and the audit row are stranded.
695
+ CreateUserInteraction.call(view_context:, **user_params)
696
+ .and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
697
+ .and_then { |user| LogActivity.call(view_context:, user:) }
698
+
699
+ # ✅ One model method; the interaction presents it
700
+ def execute
701
+ user = User.register!(**attributes) # welcome email + audit row live in here
702
+ succeed(user).with_message("Welcome aboard!")
703
+ end
704
+ ```
705
+
706
+ Chaining three interactions is usually one model method wearing three presentation costumes.
660
707
 
661
708
  ## Structure
662
709
 
@@ -665,7 +712,14 @@ Interactions encapsulate business logic into testable units. They're registered
665
712
  class ResourceInteraction < Plutonium::Resource::Interaction
666
713
  end
667
714
 
668
- # A real interaction
715
+ # app/models/post.rb what publishing MEANS (a scheduler job can call this too)
716
+ class Post < ApplicationRecord
717
+ def publish!(on: Time.current)
718
+ update!(published: true, published_at: on)
719
+ end
720
+ end
721
+
722
+ # A real interaction — the button in front of it
669
723
  class PublishPostInteraction < ResourceInteraction
670
724
  presents label: "Publish",
671
725
  icon: Phlex::TablerIcons::Send,
@@ -681,7 +735,7 @@ class PublishPostInteraction < ResourceInteraction
681
735
  private
682
736
 
683
737
  def execute
684
- resource.update!(published_at: publish_date)
738
+ resource.publish!(on: publish_date)
685
739
  succeed(resource).with_message("Post published!")
686
740
  rescue ActiveRecord::RecordInvalid => e
687
741
  failed(e.record.errors)
@@ -778,22 +832,32 @@ failed(email: "is invalid", name: "is required") # hash form
778
832
  failed("Invalid value", :email) # string + attribute
779
833
  ```
780
834
 
781
- ### Chaining
835
+ ### Chaining — `and_then`
836
+
837
+ On a `Success`, `and_then` yields **the value** (NOT the outcome — there is no `r.value` in the block) and returns whatever the block returns; on a `Failure` it short-circuits, returning the failure untouched.
838
+
839
+ Use it to compose outcomes **inside one `execute`**, e.g. a guard:
782
840
 
783
841
  ```ruby
784
842
  def execute
785
- CreateUserInteraction.call(view_context:, **user_params)
786
- .and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
787
- .and_then { |r| LogActivity.call(view_context:, user: r.value) }
788
- .with_message("User created and welcomed!")
843
+ unlocked_resource.and_then do |post|
844
+ post.publish!(on: publish_date)
845
+ succeed(post).with_message("Post published!")
846
+ end
847
+ end
848
+
849
+ private
850
+
851
+ def unlocked_resource
852
+ resource.locked? ? failed("This post is locked") : succeed(resource)
789
853
  end
790
854
  ```
791
855
 
792
- The chain short-circuits on the first failure.
856
+ ⚠️ **Don't chain interactions to sequence business operations** — see Where the logic goes above.
793
857
 
794
858
  ## Validations
795
859
 
796
- Standard ActiveModel — run automatically before `execute`:
860
+ Standard ActiveModel — run automatically before `execute`; if they fail, `execute` never runs:
797
861
 
798
862
  ```ruby
799
863
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
@@ -808,6 +872,21 @@ def custom_check
808
872
  end
809
873
  ```
810
874
 
875
+ ### Which validation goes where
876
+
877
+ | | Interaction validation | Model validation |
878
+ |---|---|---|
879
+ | Asks | "Can I read this input?" — present, parses, right type, plausible format | "Is this record legal?" — invariants that hold no matter who calls |
880
+ | Exists to | render a form error next to the field | protect the data from every caller, including ones with no form |
881
+ | Runs | before `execute`, never touching the model | inside `save!`/`update!` — i.e. inside the model method |
882
+
883
+ They surface **differently**, and that should inform where a rule lives:
884
+
885
+ - An interaction validation attaches to a declared attribute → the re-rendered modal shows it inline against that input **and** in the summary.
886
+ - `failed(record.errors)` flattens `ActiveModel::Errors` to **full messages on `:base`** (`Array(errors)` → `errors.to_a` → `full_messages`) → error summary only, never against a field, phrased with the *model's* attribute names.
887
+
888
+ So it's fine — often right — to **duplicate** a cheap invariant as an interaction validation purely for the better error placement, while the model keeps the authoritative copy. What must not happen is the model-side copy going missing: when a job calls `post.publish!`, the interaction's validations aren't in the picture.
889
+
811
890
  ## Accessing context
812
891
 
813
892
  ```ruby
@@ -818,6 +897,8 @@ def execute
818
897
  end
819
898
  ```
820
899
 
900
+ This write is **correctly inline**. "Who clicked the button" is context only the presentation layer holds — a job has no answer for it, so there's no second caller to extract for.
901
+
821
902
  A shorter `current_user` helper is conventional:
822
903
 
823
904
  ```ruby
@@ -860,7 +941,25 @@ The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb
860
941
 
861
942
  ## Complete example
862
943
 
944
+ Inviting a user is a textbook second-caller case — a seats-provisioning job, an admin rake task, and a signup API all need to send the same invitation. So the operation lives on `Company`; the interaction is the button in front of it.
945
+
946
+ ```ruby
947
+ # app/models/company.rb — what inviting MEANS: the row, the mail, the audit trail
948
+ class Company < ApplicationRecord
949
+ has_many :user_invites
950
+
951
+ def invite!(email:, role:, by:)
952
+ user_invites.create!(email: email, role: role, invited_by: by).tap do |invite|
953
+ UserInviteMailer.invitation(invite).deliver_later
954
+ end
955
+ end
956
+
957
+ def pending_invite_for?(email) = user_invites.exists?(email: email, state: :pending)
958
+ end
959
+ ```
960
+
863
961
  ```ruby
962
+ # app/interactions/company/invite_user_interaction.rb
864
963
  class Company::InviteUserInteraction < Plutonium::Resource::Interaction
865
964
  presents label: "Invite User",
866
965
  icon: Phlex::TablerIcons::UserPlus
@@ -872,6 +971,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
872
971
  input :email
873
972
  input :role, as: :select, choices: -> { UserInvite.roles.keys }
874
973
 
974
+ # Input shape only — readable email? a role that exists?
875
975
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
876
976
  validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
877
977
  validate :not_already_invited
@@ -879,21 +979,18 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
879
979
  private
880
980
 
881
981
  def execute
882
- invite = UserInvite.create!(
883
- company: resource, email: email, role: role,
884
- invited_by: current_user
885
- )
886
- UserInviteMailer.invitation(invite).deliver_later
982
+ resource.invite!(email: email, role: role, by: current_user)
887
983
  succeed(resource).with_message("Invitation sent to #{email}")
888
984
  rescue ActiveRecord::RecordInvalid => e
889
985
  failed(e.record.errors)
890
986
  end
891
987
 
988
+ # Deliberately duplicated. UserInvite enforces uniqueness for real (a job calling
989
+ # company.invite! must hit it too); this copy exists only so the message lands on
990
+ # the :email field instead of in the base error summary.
892
991
  def not_already_invited
893
- return unless email.present?
894
- if UserInvite.exists?(company: resource, email: email, state: :pending)
895
- errors.add(:email, "already has a pending invitation")
896
- end
992
+ return if email.blank?
993
+ errors.add(:email, "already has a pending invitation") if resource.pending_invite_for?(email)
897
994
  end
898
995
 
899
996
  def current_user = view_context.controller.helpers.current_user