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
@@ -0,0 +1,48 @@
1
+ ---
2
+ title: "Plutonium wasn't built for AI agents. Rails conventions are why they work."
3
+ titleTemplate: "Plutonium Blog"
4
+ date: 2026-09-10
5
+ description: The conventions were chosen for human reasons. Assistants turned out to be unusually accurate inside them, which was informative about both the framework and the docs.
6
+ author: Stefan Froelich
7
+ tags: [ai, conventions, rails]
8
+ draft: true
9
+ ---
10
+
11
+ # Plutonium wasn't built for AI agents. Rails conventions are why they work.
12
+
13
+ <BlogMeta />
14
+
15
+ There is an "For AI Agents" page in these docs and a `.claude/skills/` directory in the repo, so it is fair to assume this was designed as an AI thing. It was not. The order of events was the other way round, and the order matters, because it is the part that generalises to your own codebase.
16
+
17
+ I built Plutonium convention-heavy for ordinary human reasons: fewer decisions per feature, one place to change anything, code that reads the same across projects. Then I noticed that assistants were unusually accurate inside it, in a way they were not inside the hand-rolled Rails apps I had written before. That was interesting enough to investigate, and then to lean into.
18
+
19
+ ## Why the accuracy is higher
20
+
21
+ Not because of anything clever. Because there is less to guess.
22
+
23
+ Most of what an assistant gets wrong in an unfamiliar Rails app is not syntax. It is context it cannot see: which of four places a filter should live, whether this project puts business logic in the model or a service, what the local naming convention is, what already exists that it should not duplicate.
24
+
25
+ A Plutonium app answers most of those structurally:
26
+
27
+ - **The layers are named and their jobs are disjoint.** Model, definition, policy, controller, interaction. "Which fields are visible" has exactly one home, and it is not the one an assistant would guess from a filename.
28
+ - **Most behaviour is derived, not declared.** Field types come from columns, required markers from validations, select choices from `inclusion:`, preloads from the policy's permitted fields, tenant scope from associations. There is less code to write, so there is less code to get wrong.
29
+ - **Overrides are plain class inheritance.** `AdminPortal::PostDefinition < ::PostDefinition`. No registry, no precedence rules, no merge semantics that have to be learned before you can predict an outcome.
30
+ - **The generators produce conventional files.** An assistant that runs `pu:res:scaffold` starts from a correct skeleton rather than an invented one.
31
+
32
+ None of that was designed with a model in mind. It is just that "predictable enough that a newcomer can infer the next step" and "predictable enough that a language model can" turn out to be close to the same property.
33
+
34
+ ## What I did after noticing
35
+
36
+ Leaned in, with three things that cost the project nothing if you never use them.
37
+
38
+ **Skills.** `.claude/skills/` holds a router plus targeted guides. They are not documentation-for-humans copied into a folder. They are written as instructions: what to read before acting, what to ask, the mistakes that cost the most, and explicit gates that say "inspect the app yourself rather than asking the user to describe it."
39
+
40
+ **`llms.txt` and per-page markdown twins.** Every docs page has a raw `.md` sibling, plus generated `llms.txt` and `llms-full.txt`. An agent can pull exact source rather than scraping rendered HTML.
41
+
42
+ **A router skill that says what *not* to write.** This is the one that mattered most. The failure mode of an eager assistant is over-declaring: writing `field :title` when the type is already detected, restating a scope, redeclaring an input that auto-detection already got right. So the router now leads with the rule that a declaration matching the detected default is dead code.
43
+
44
+ ## If you do not care about AI
45
+
46
+ You lose nothing. There is no AI runtime in Plutonium, no model calls, no telemetry, no feature that only works with an assistant. The skills are markdown files you can ignore, and `llms.txt` is a static artifact of the docs build.
47
+
48
+ What is left is the thing that was there first: a framework where the conventions are strong enough that the next step is usually inferable. That was worth having when the only inference engine was a new team member reading the codebase on their first day.
@@ -0,0 +1,70 @@
1
+ ---
2
+ title: "Plutonium realtime is one line, and four things that line assumes"
3
+ titleTemplate: "Plutonium Blog"
4
+ date: 2026-09-17
5
+ description: The server broadcasts whether or not anyone can hear it. A feature that half-works in development and does nothing in production deserves its caveats up front.
6
+ author: Stefan Froelich
7
+ tags: [kanban, hotwire, actioncable]
8
+ draft: true
9
+ ---
10
+
11
+ # Plutonium realtime is one line, and four things that line assumes
12
+
13
+ <BlogMeta />
14
+
15
+ Turning on realtime for a kanban board is one line:
16
+
17
+ ```ruby
18
+ kanban do
19
+ realtime true
20
+ # ...
21
+ end
22
+ ```
23
+
24
+ Move a card and every other viewer of the same board sees it. That is true, and it is also the kind of claim that earns a framework a bad afternoon, because the line does not fail loudly when its assumptions are missing. It fails by working perfectly on the server and reaching nobody.
25
+
26
+ So here is the honest version.
27
+
28
+ ## What the line actually does
29
+
30
+ Two things, both server-side.
31
+
32
+ It renders a `<turbo-cable-stream-source>` element subscribing the page to a stream. And after a successful move, it broadcasts the updated column frames to that stream.
33
+
34
+ Stream names are tenant-scoped, so viewers in different tenants cannot receive each other's boards. That part is not optional and not something you can misconfigure into a leak.
35
+
36
+ What it does **not** do is give the browser anything capable of receiving a broadcast.
37
+
38
+ ## The four assumptions
39
+
40
+ **1. An ActionCable client in your JavaScript.** This is the one that bites. Plutonium's bundled JavaScript ships `@hotwired/turbo` only, with no cable client. Without `@hotwired/turbo-rails` or `@rails/actioncable` in your pack, the `<turbo-cable-stream-source>` element renders and never connects. No error, no console warning, no failed request to look at. The element is simply inert.
41
+
42
+ **2. The gems.** `turbo-rails` for `Turbo::StreamsChannel`, and ActionCable, which Rails already includes.
43
+
44
+ **3. A cable adapter in `config/cable.yml`.** `async` is fine for a single-process dev server. In multi-process production it is a trap: a broadcast from one worker never reaches clients connected to another, so realtime works for roughly one in N users depending on which worker they landed on. Redis or Solid Cable for anything real.
45
+
46
+ **4. ActionCable mounted.** At `/cable`, the usual place.
47
+
48
+ ## Why this is worth a blog post rather than a footnote
49
+
50
+ Because of the shape of the failure.
51
+
52
+ Miss any of these and the feature does not break. Your board still works. Drags still persist, the server still broadcasts, the code path still runs, tests that check the move endpoint still pass. The only symptom is that a second browser window does not update, which is exactly the thing nobody checks after the first time they see it work.
53
+
54
+ And it very often *does* work the first time, in development, on a single-process server with `async` cable and a dev pack that happened to include turbo-rails. Then it stops working in production, where there are four workers and a different pack, and the bug has no error attached to it.
55
+
56
+ A feature that degrades silently between environments is worse than one that raises. Raising is a bug report; silence is a support ticket six weeks later titled "does realtime actually work?"
57
+
58
+ ## The design tradeoff underneath
59
+
60
+ The obvious question is why the framework does not just bundle a cable client and remove three of the four steps.
61
+
62
+ Because that would ship an ActionCable dependency, a WebSocket connection attempt, and a chunk of JavaScript to every Plutonium app, including the large majority that never turn realtime on. Plutonium's asset bundle is deliberately small and its JavaScript deliberately boring. Paying that cost for everyone so one flag can be a true one-liner is a bad trade.
63
+
64
+ What the framework owes you instead is to be explicit that the line is not self-sufficient. Which is what the docs now do, and what this post is.
65
+
66
+ ## Checking it
67
+
68
+ The verification that actually matters takes two browsers and thirty seconds. Open the board in both, move a card in one, watch the other. If it does not move, look for the cable client first: it is the missing piece roughly always, because it is the only one of the four that no other Rails feature would have already forced you to set up.
69
+
70
+ The rest of realtime, the broadcasting, the tenant scoping, the frame updates, is the part you do not have to think about. The connection is the part you do.
@@ -0,0 +1,69 @@
1
+ ---
2
+ title: "Two forms, one DOM id, and the Rails validation errors that vanished"
3
+ titleTemplate: "Plutonium Blog"
4
+ date: 2026-09-14
5
+ description: A Turbo Stream replaces the first matching element in document order. When two forms on a page share an id, that is rarely the one the user submitted.
6
+ author: Stefan Froelich
7
+ tags: [hotwire, turbo, debugging]
8
+ draft: true
9
+ ---
10
+
11
+ # Two forms, one DOM id, and the Rails validation errors that vanished
12
+
13
+ <BlogMeta />
14
+
15
+ The bug report was the worst kind: "sometimes validation errors don't show up." Not an exception, not a 500, no log line. The form submits, the modal sits there, and whatever the server said about the invalid email address goes nowhere.
16
+
17
+ The cause is one sentence of Turbo's contract, and it is not a bug in Turbo.
18
+
19
+ ## The mechanism
20
+
21
+ `turbo_stream.replace("resource-form", …)` replaces the element with that id. If more than one element has that id, Turbo takes the first match in document order. The DOM does not enforce id uniqueness, and nothing in Rails warns you.
22
+
23
+ In an admin UI, duplicate form ids are not exotic. Two ways it happened here:
24
+
25
+ - A primary modal opens a secondary, stacked modal. Both render a resource form. Both call it `resource-form`.
26
+ - Any index page ships an off-screen Filters slideover with its own form, alongside the CRUD form.
27
+
28
+ So the server responds to a submission from the *second* form, Turbo finds the *first*, replaces something the user cannot see, and the visible form is untouched. From the user's side, nothing happened. From the logs' side, a 422 rendered and a stream was sent. Everything worked.
29
+
30
+ That gap between "the server did the right thing" and "the user saw nothing" is why this survives review. There is no failing test to write until you know the failure exists.
31
+
32
+ ## The fix
33
+
34
+ Ids get scoped to the Turbo frame they render in:
35
+
36
+ ```ruby
37
+ def turbo_scoped_dom_id(base)
38
+ base = base.to_s
39
+ case current_turbo_frame
40
+ when Plutonium::REMOTE_MODAL_FRAME then "#{base}-primary"
41
+ when Plutonium::REMOTE_MODAL_SECONDARY_FRAME then "#{base}-secondary"
42
+ else base
43
+ end
44
+ end
45
+ ```
46
+
47
+ Outside a modal you get `resource-form`. Inside the primary modal, `resource-form-primary`. Inside the secondary, `resource-form-secondary`. The stream replace derives its target the same way, from the frame the request came in on, so a submission from the stacked modal can only ever address the stacked modal's form.
48
+
49
+ The Filters slideover stopped borrowing the name entirely and uses `filter-form`, so a CRUD replace cannot reach it regardless of frames.
50
+
51
+ ## Two things that made it harder than it sounds
52
+
53
+ **Ids had to be resolved at render time, not construction time.** The obvious place to compute the id is when the form component initialises its attributes. Phlex cannot reach `view_context` there, and `current_turbo_frame` comes from the request. So resolution moved into a `form_attributes` override that runs during render, when the frame is actually knowable.
54
+
55
+ **The id had to be forced rather than merged.** Phlexi's `@namespace.dom_id` prepends a namespace token if you let it merge, which produced ids like `q filter-form`. A space in an id is legal HTML and completely breaks `getElementById`. Forcing the value avoided a bug that would have looked exactly like the one being fixed.
56
+
57
+ ## What generalises
58
+
59
+ You do not need Plutonium to hit this. You need a Turbo Stream, a component that hardcodes an id, and any situation where that component can appear twice. Modals over modals is the common one. A slideover, a drawer, an inline edit row inside a table that also has a create form: all the same shape.
60
+
61
+ Three things worth taking away:
62
+
63
+ **A stream replace is a query, not an address.** `replace("resource-form")` says "find something called this," and it will find something. It is only unambiguous if you have guaranteed uniqueness, which nothing does for you.
64
+
65
+ **Silence is the tell.** When a form submits and *nothing at all* happens, suspect that something did happen, off-screen. A stream that targets the wrong element is indistinguishable from a stream that was never sent, unless you go looking in the network tab.
66
+
67
+ **Component ids need a scope.** Any component that hardcodes a DOM id has an implicit assumption that it renders once per page. That assumption is fine right up until the day someone opens it in a modal.
68
+
69
+ The tests that came with the fix cover the helper in all five frame contexts, including no frame and a non-modal frame. Which is the other half of the lesson: once you know the failure mode, it is trivial to test. The expensive part was believing "sometimes errors don't show up" for long enough to find it.
@@ -0,0 +1,130 @@
1
+ ---
2
+ title: "What's new in Plutonium: async interactions, kanban boards, and wizards"
3
+ titleTemplate: "Plutonium Blog"
4
+ date: 2026-09-07
5
+ description: Three features that close the same gap, where you got most of the way through building something in Rails and then had to leave the framework to finish it.
6
+ author: Stefan Froelich
7
+ tags: [release, async, kanban, wizards]
8
+ draft: true
9
+ ---
10
+
11
+ # What's new in Plutonium: async interactions, kanban boards, and wizards
12
+
13
+ <BlogMeta />
14
+
15
+ Three features landed that share a shape. Each one closes a place where you could get most of the way through a feature and then had to drop out of the framework and hand-roll the rest: work that takes too long for a request, a board view, and a form that spans more than one screen.
16
+
17
+ ## Async interactions
18
+
19
+ An interaction is the button, form, policy gate and outcome in front of an operation. That works right up until the operation does not fit in a request: archiving five thousand records, generating a report, calling a third-party API that takes its time.
20
+
21
+ The old answer was to write the interaction anyway, enqueue a job from `execute`, invent somewhere to store progress, and build a page to show it. Now you declare the work with `async` and skip all of that:
22
+
23
+ ```ruby
24
+ class Blogging::ArchivePosts < ResourceInteraction
25
+ presents label: "Archive", icon: Phlex::TablerIcons::Archive
26
+
27
+ attribute :resources # bulk, so perform_on runs once per record
28
+ attribute :reason, :string
29
+
30
+ async do
31
+ on_failure :continue # :halt (default) | :continue | :transactional
32
+
33
+ def perform_on(post)
34
+ post.archive!(reason: options["reason"])
35
+ end
36
+ end
37
+ end
38
+ ```
39
+
40
+ `async` replaces `execute` entirely. Everything in front of it is unchanged: the interaction still validates its inputs, still gets gated by the same policy method, still renders the same form. Only what happens on submit changes. It persists a run, enqueues it, and redirects to it.
41
+
42
+ Three things worth calling out.
43
+
44
+ **The run is a resource.** Register it once per portal:
45
+
46
+ ```bash
47
+ rails g pu:async_interactions:install --dest=admin_portal
48
+ ```
49
+
50
+ Its show page *is* the progress page: a live count, the failures so far, and a banner above the resource's collection while a run is in flight. You wrote none of it.
51
+
52
+ **Failure has a policy, not a convention.** `on_failure` picks between `:halt` (stop at the first failure), `:continue` (record it and keep going), and `:transactional` (one transaction around the batch, so any failure rolls back everything).
53
+
54
+ **Permissions are re-derived at perform time, never replayed from dispatch.** The row records who initiated the run and under which tenant, then resolves targets through the policy scope again when the job actually runs. If someone loses access between dispatch and perform, the run respects that. There is [a longer post](/blog/jobs-are-not-permission-snapshots) on why this matters.
55
+
56
+ It is opt-in: a config flag and a migration.
57
+
58
+ ## Kanban boards
59
+
60
+ Any resource index can become a drag-and-drop board from a single `kanban do…end` block in the definition:
61
+
62
+ ```ruby
63
+ class TaskDefinition < ResourceDefinition
64
+ kanban do
65
+ column :todo,
66
+ scope: -> { where(status: "todo") },
67
+ on_enter: ->(r) { r.update!(status: "todo") },
68
+ role: :backlog
69
+
70
+ column :doing,
71
+ scope: -> { where(status: "doing") },
72
+ on_enter: ->(r) { r.update!(status: "doing") },
73
+ wip: 3
74
+
75
+ column :done,
76
+ scope: -> { where(status: "done") },
77
+ on_enter: :mark_done!, # Symbol calls record.mark_done!
78
+ accepts: [:doing] # only cards from :doing can land here
79
+ end
80
+ end
81
+ ```
82
+
83
+ Columns, WIP limits, locked columns and cross-column drop restrictions, all enforced server-side rather than merely hidden in the UI. Each column gets an `+ Add` button that opens the resource's normal new form and drops the new card into that column. Column actions run an interaction against every card in a column. Realtime is one line, and every connected viewer converges on the same board state after a move.
84
+
85
+ See [Kanban Boards](/guides/kanban) for a complete worked example: migration, model, definition and policy for a task board.
86
+
87
+ ## Wizards
88
+
89
+ Onboarding, checkout, "create four related records across five screens", a branching questionnaire: multi-step flows are now a single declarative class. Ordered `step`s collect typed data, `condition:` makes steps appear and disappear based on earlier answers, a built-in review step recaps everything before the Finish button, and `execute` commits at the end, atomically by default.
90
+
91
+ ```ruby
92
+ class CompanyOnboardingWizard < Plutonium::Wizard::Base
93
+ presents label: "Onboard a company", icon: Phlex::TablerIcons::BuildingSkyscraper
94
+
95
+ step :company, label: "Company details" do
96
+ attribute :name, :string
97
+ input :name
98
+ validates :name, presence: true
99
+ end
100
+
101
+ step :plan, label: "Plan" do
102
+ attribute :plan, :string
103
+ input :plan, as: :radio_buttons, choices: %w[free pro]
104
+ validates :plan, presence: true
105
+ end
106
+
107
+ review label: "Review & submit"
108
+
109
+ def execute
110
+ company = Company.create!(name: data.company.name, plan: data.plan.plan)
111
+ succeed(company).with_message("You're all set!")
112
+ end
113
+ end
114
+ ```
115
+
116
+ The part that matters most is that it reuses the field DSL you already know. `attribute`, `input`, `validates`, `structured_input` and `form_layout` behave exactly as they do in a definition or an interaction. There is no parallel stack to learn and no second way to render a select.
117
+
118
+ See [Wizards](/guides/wizards) for the full DSL, including per-step writes, resume, and one-time wizards.
119
+
120
+ ## The thing underneath the board
121
+
122
+ Kanban did not invent its ordering. It uses the same fractional positioning that powers [drag-to-reorder](/reference/positioning) on ordinary index tables, card grids and nested association tables. `positioned_on` on the model says how positions are stored, `position_on` in the definition says the list is orderable, and a drop writes exactly one decimal, the midpoint between its two neighbours. No renumbering sweep across the table.
123
+
124
+ A board and a sortable table are the same feature wearing different clothes, which is why they share a vocabulary. Positioning itself is stable, not experimental.
125
+
126
+ ## Kanban, wizards and async are marked experimental
127
+
128
+ Deliberately, and it says so on each page. The features are complete and in use, but the DSLs are new and I would rather rename a method in response to real use than freeze a first guess into a compatibility promise. Positioning is not in that group.
129
+
130
+ If you build on the experimental three, watch the changelog, and tell me where the API fights you. That feedback still changes things.
@@ -108,10 +108,19 @@ end
108
108
 
109
109
  Control which records appear in listings:
110
110
 
111
+ `relation_scope` is a macro — it takes a block. Writing it as a plain instance
112
+ method (`def relation_scope(relation)`) overrides nothing and your scoping is
113
+ silently ignored, so Plutonium raises if you try.
114
+
115
+ Always compose with `default_relation_scope(relation)`; skipping it raises too,
116
+ because it is what applies parent and tenant scoping.
117
+
111
118
  ```ruby
112
119
  class Blogging::PostPolicy < Blogging::ResourcePolicy
113
120
  # Called when listing posts
114
- def relation_scope(relation)
121
+ relation_scope do |relation|
122
+ relation = default_relation_scope(relation)
123
+
115
124
  if admin?
116
125
  relation # Admins see everything
117
126
  else
@@ -147,8 +156,8 @@ class AdminPortal::Blogging::PostPolicy < ::Blogging::PostPolicy
147
156
  true
148
157
  end
149
158
 
150
- def relation_scope(relation)
151
- relation # No scope restrictions for admins
159
+ relation_scope do |relation|
160
+ default_relation_scope(relation) # No extra restrictions for admins
152
161
  end
153
162
  end
154
163
  ```
@@ -101,7 +101,9 @@ class Blogging::CommentPolicy < Blogging::ResourcePolicy
101
101
  end
102
102
 
103
103
  # Scope to comments on published posts (or user's own posts)
104
- def relation_scope(relation)
104
+ relation_scope do |relation|
105
+ relation = default_relation_scope(relation)
106
+
105
107
  relation.joins(:post).where(
106
108
  blogging_posts: {published: true}
107
109
  ).or(
@@ -84,8 +84,8 @@ Authors should only see and manage their own posts. Create a portal-specific pol
84
84
  # packages/author_portal/app/policies/author_portal/blogging/post_policy.rb
85
85
  class AuthorPortal::Blogging::PostPolicy < ::Blogging::PostPolicy
86
86
  # Authors can only see their own posts
87
- def relation_scope(relation)
88
- relation.where(user_id: user.id)
87
+ relation_scope do |relation|
88
+ default_relation_scope(relation).where(user_id: user.id)
89
89
  end
90
90
 
91
91
  # Authors can always create posts
@@ -115,6 +115,78 @@ include Plutonium::Auth::Rodauth(:user)
115
115
 
116
116
  See [Reference › App › Portals](/reference/app/portals#controller-concern-auth).
117
117
 
118
+ ## Multiple portals in one browser
119
+
120
+ A person can hold a session in several portals at once — signed into the admin portal and the customer portal in the same browser, with neither evicting the other. Two settings make that work, and the generators emit both:
121
+
122
+ ```ruby
123
+ # app/rodauth/rodauth_plugin.rb — the shared base
124
+ configure do
125
+ enable :session_isolation
126
+ end
127
+ ```
128
+
129
+ ```ruby
130
+ # app/rodauth/admin_rodauth_plugin.rb — once per account type
131
+ configure do
132
+ session_key_prefix "admin_"
133
+ remember_cookie_key "_admin_remember"
134
+ end
135
+ ```
136
+
137
+ 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` and the rest. `session_isolation` uses that prefix to decide which entries belong to whom, and stops one configuration's login from clearing another's.
138
+
139
+ ::: danger Do not also set `session_key`
140
+ An explicit `session_key` is **not** prefixed (`convert_session_key`, `rodauth/features/base.rb:686` — only the default value passes through it). Setting both leaves the account id on a different name from every other key, so they stop rotating together. A session that holds an account id but no `authenticated_by` makes Rodauth raise on *every request* — `logged_in_via_remember_key?` calls `nil.include?` (`remember.rb:175`).
141
+
142
+ A config without a prefix at all is also not isolated: its keys are Rodauth's unprefixed defaults, indistinguishable from any other unprefixed config's, so `session_isolation` carries nothing for it.
143
+ :::
144
+
145
+ ::: details Why this is needed
146
+ 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 reset as a full `reset_session`.
147
+
148
+ 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 immediately autologins from its remember cookie and evicts the new one right back. The last `load_memory` call in the route block wins permanently, so the other portal can never hold a session at all.
149
+
150
+ `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.
151
+ :::
152
+
153
+ Reading a raw Rodauth session key? Go through its accessor, never the literal — with a prefix set, the literal is the wrong key:
154
+
155
+ ```ruby
156
+ after_login do
157
+ session[:after_welcome_redirect] = session.delete(login_redirect_session_key) # ✅
158
+ session[:after_welcome_redirect] = session.delete(:login_redirect) # ❌ wrong key
159
+ end
160
+ ```
161
+
162
+ **Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin and `session_key_prefix` in each account plugin — and **delete the existing `session_key "_x_session"` line** while you're there, for the reason above.
163
+
164
+ Every key name changes together, so old session cookies stop matching and are ignored — the safe outcome. Keeping the old `session_key` to "preserve" logins is what produces the half-migrated session that crashes.
165
+
166
+ In practice only *unremembered* sessions drop: `remember_cookie_key` is a cookie name and isn't touched by the prefix, so anyone holding a valid `_x_remember` cookie is restored by `load_memory` on their next request, which writes the new prefixed keys. Expect logouts for users who never ticked "Remember Me", not a full sign-out.
167
+
168
+ ## "Remember me" is opt-in
169
+
170
+ The login form renders a "Remember Me" checkbox whenever the config enables the `remember` feature, and the config only issues the 14-day cookie when it's ticked:
171
+
172
+ ```ruby
173
+ after_login { remember_login if param_or_nil(remember_param) == remember_remember_param_value }
174
+ ```
175
+
176
+ To go back to remembering everyone whether they asked or not, swap in the unconditional form:
177
+
178
+ ```ruby
179
+ after_login { remember_login }
180
+ ```
181
+
182
+ ::: warning Compare the value, don't just check presence
183
+ `param_or_nil(remember_param)` on its own is a truthiness check, and `remember_param` is shared with Rodauth's `/remember` settings page — which submits `forget` and `disable` as well as `remember`. A bare presence check means `remember=disable` would remember you. Compare against `remember_remember_param_value`, as above.
184
+
185
+ Plutonium's login form also passes `include_hidden: false` to `check_box`, so an unticked box sends nothing at all rather than Rails' default `"0"`. With the value comparison that's belt-and-braces rather than load-bearing (`"0" == "remember"` is already false) — but it keeps a junk param off the wire and keeps the form correct for anyone who reverts the hook to a bare truthiness check.
186
+ :::
187
+
188
+ Logged-in users can change the setting later on Rodauth's `/remember` page (Remember / Forget / Disable).
189
+
118
190
  ## Customizing the auth flow
119
191
 
120
192
  All inside `app/rodauth/<name>_rodauth_plugin.rb`, in the `configure do` block:
@@ -185,6 +257,7 @@ user
185
257
  - **"You need to set up Rodauth"** — run `pu:rodauth:install` first.
186
258
  - **Portal redirects to login even though you're authenticated** — the portal mount constraint references a different Rodauth account than the portal's controller concern uses. Match them up.
187
259
  - **Email confirmation never arrives in development** — Plutonium sets ActionMailer to `:test` by default. Check `tmp/letter_opener/` or your mail interceptor. In production, configure SMTP (see above).
260
+ - **Signing into one portal signs you out of another** — or one portal can never stay signed in at all. Missing `enable :session_isolation` and/or `session_key_prefix`; see [Multiple portals in one browser](#multiple-portals-in-one-browser).
188
261
 
189
262
  ## Related
190
263
 
@@ -55,6 +55,8 @@ def destroy?
55
55
  end
56
56
  ```
57
57
 
58
+ 🚨 **`record` is the resource CLASS on collection routes.** `read?` backs both `show?` (called with the record) and `index?` (called with the class — there is no single record to pass). The same goes for `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates. So `def read? = record.published?` raises `NoMethodError` the moment the index renders. Keep record-state rules out of these methods: filter what the list shows in `relation_scope` (step 6 below), and gate individual records in `show?` (which always receives the record). Record-action methods like `publish?` are safe — they are always evaluated against an instance.
59
+
58
60
  ### 4. Declare attribute permissions
59
61
 
60
62
  ```ruby
@@ -147,13 +147,15 @@ Loaded from `config/application.rb`. Migrations from all packages are picked up
147
147
  ## Per-portal overrides
148
148
 
149
149
  ```ruby
150
- # Definition — different fields per portal
150
+ # Definition — how fields render per portal
151
151
  class AdminPortal::PostDefinition < ::PostDefinition
152
- input :internal_notes, as: :text # admins see this; customers don't
153
152
  scope :pending_review
153
+ input :internal_notes, hint: "Not shown to the author"
154
154
  end
155
155
 
156
- # Policy — different rules per portal
156
+ # Policy — which fields exist, and who may act
157
+ # `internal_notes` appears for admins because THIS permits it,
158
+ # not because the definition above mentions it.
157
159
  class AdminPortal::PostPolicy < ::PostPolicy
158
160
  include AdminPortal::ResourcePolicy
159
161