plutonium 0.62.2 → 0.63.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 (255) 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/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -0,0 +1,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module Async
6
+ # Rebuilds the authorization context a run was dispatched under, and
7
+ # resolves the run's targets through it.
8
+ #
9
+ # A run performs inside an ActiveJob: no controller, no request, no
10
+ # +current_user+. But Plutonium does not authorize on a user — it
11
+ # authorizes on the PAIR (user, entity_scope). See
12
+ # Plutonium::Core::Controllers::Authorizable, which registers both
13
+ # (`authorize :user` / `authorize :entity_scope`) and whose
14
+ # +current_policy_context+ carries the entity scope into every policy it
15
+ # builds. Rebuilding only the user would resolve targets under the WRONG
16
+ # TENANT, and that direction fails OPEN: a broader scope silently includes
17
+ # records the initiator could not see when they dispatched the run.
18
+ #
19
+ # Nothing about what the initiator was ALLOWED to do is carried over from
20
+ # dispatch — only who they are and which tenant they were in. Permissions
21
+ # are re-derived here. That is deliberate: if a persisted run replayed the
22
+ # permissions it was created with, it would be a way to launder access that
23
+ # has since been revoked.
24
+ #
25
+ # == What is fresh, and when
26
+ #
27
+ # Be precise about this, because the guarantee is narrower than "fresh at
28
+ # perform time" and a vaguer claim would invite exactly the bug it is meant
29
+ # to prevent:
30
+ #
31
+ # * The initiator and scoped entity are loaded from the database when this
32
+ # object is CONSTRUCTED — so they reflect job start, not dispatch.
33
+ # * They are then CACHED, because they are ordinary belongs_to associations.
34
+ # A long-running executor holding one Context therefore keeps evaluating
35
+ # policies against the subjects as they were at job start. A predicate
36
+ # reading +user.role+ or +user.active?+ will NOT see a mid-run
37
+ # revocation.
38
+ # * {#refresh_subjects!} re-reads just those two, so a caller that needs a
39
+ # genuinely current answer can get one. It is the caller's job to decide
40
+ # how often; see that method.
41
+ # * Target RECORDS are loaded per {#targets} call and are likewise a
42
+ # snapshot. A predicate reading record state (+record.published?+) is only
43
+ # as current as the record instance handed to {#permitted?}.
44
+ #
45
+ # == Which policy applies
46
+ #
47
+ # Policy lookup in a controller passes +namespace: authorization_namespace+,
48
+ # which ActionPolicy derives from the CONTROLLER's module nesting (see
49
+ # ActionPolicy::Behaviours::Namespaced). That is how a portal gets its own
50
+ # policy — +StorefrontPortal::Blogging::PostPolicy+ rather than
51
+ # +Blogging::PostPolicy+. A job has no controller, so the namespace cannot
52
+ # be derived here; the run carries it, and lookup replays it.
53
+ #
54
+ # The run ALSO carries the policy that dispatch actually resolved, and this
55
+ # class refuses to run if today's lookup disagrees with it. The namespace
56
+ # alone only fixes lookup as the constants stand right now — a policy
57
+ # renamed, deleted or re-parented between enqueue and perform would resolve
58
+ # to something else, which is the same silent widening moved later in time.
59
+ class Context
60
+ # Raised when the stored context cannot be rebuilt. Deliberately fatal:
61
+ # every alternative to raising here resolves targets under a context
62
+ # that is wider than the one the run was dispatched under.
63
+ class UnresolvableError < StandardError; end
64
+
65
+ # Raised when the policy resolved now is not the policy dispatch
66
+ # resolved. A subclass so a caller can rescue either the specific case or
67
+ # every "this run cannot be trusted" case with one rescue.
68
+ class PolicyMismatchError < UnresolvableError; end
69
+
70
+ # Resolved targets, split three ways.
71
+ #
72
+ # +records+ passed BOTH checks: visible in the scope, and permitted by the
73
+ # policy predicate. +missing_ids+ was deleted or fell out of scope.
74
+ # +unauthorized_ids+ is still visible but the initiator may no longer act
75
+ # on it.
76
+ #
77
+ # The two failure buckets are surfaced rather than swallowed, and kept
78
+ # apart because they mean different things to an operator: "that record is
79
+ # gone" and "you may no longer archive that record" call for different
80
+ # responses. Silence in either is how bulk operations quietly
81
+ # under-apply — the run reports success having touched half the records.
82
+ Targets = Struct.new(:records, :missing_ids, :unauthorized_ids)
83
+
84
+ attr_reader :run
85
+
86
+ # When the two authorization subjects were last read from the database.
87
+ #
88
+ # Exposed because the subjects go stale (see the class comment) and only
89
+ # the caller knows how stale is too stale — so the caller needs to be able
90
+ # to ASK, without keeping a shadow copy of this object's state that can
91
+ # drift from it. The cadence policy stays with the caller; the clock stays
92
+ # with the state it describes.
93
+ #
94
+ # @return [Time]
95
+ attr_reader :subjects_read_at
96
+
97
+ def initialize(run)
98
+ @run = run
99
+ verify_resolvable!
100
+ # verify_resolvable! is what loads both subjects, so the clock starts
101
+ # once they are known good — not before.
102
+ @subjects_read_at = Time.current
103
+ end
104
+
105
+ # @return [ActiveRecord::Base] the user the run authorizes as
106
+ def initiator = run.initiator
107
+
108
+ # @return [ActiveRecord::Base, nil] the tenant, or nil for an unscoped portal
109
+ def scoped_entity = run.scoped_entity
110
+
111
+ # @return [ActiveRecord::Base, nil] the nested-route parent, nil if the
112
+ # dispatch was not nested
113
+ def parent = run.parent
114
+
115
+ # @return [Symbol, nil] the association the child hangs off {#parent}
116
+ def parent_association = run.parent_association&.to_sym
117
+
118
+ # @return [Class, nil]
119
+ def target_class = resolve_constant(run.target_type, "target_type")
120
+
121
+ # The dispatching controller's ActionPolicy namespace, as a Module.
122
+ #
123
+ # ActionPolicy.lookup calls +namespace.name+ and walks +namespace.namespace+,
124
+ # so it needs the Module itself — handed a String it raises NoMethodError.
125
+ # nil is a legitimate value meaning "top level", not a missing one.
126
+ #
127
+ # @return [Module, nil]
128
+ def authorization_namespace = resolve_constant(run.authorization_namespace, "authorization_namespace")
129
+
130
+ # The policy for the target resource, resolved the way the dispatching
131
+ # controller resolved it. Verified against the run's recorded policy in
132
+ # the constructor, so by the time anyone calls this it is known to match.
133
+ #
134
+ # @return [Class]
135
+ def target_policy_class = ::ActionPolicy.lookup(target_class, namespace: authorization_namespace)
136
+
137
+ # The policy predicate dispatch checked per record, e.g. :archive?.
138
+ #
139
+ # @return [Symbol, nil] nil only for opaque (untargeted) work
140
+ def policy_action = run.policy_action&.to_sym
141
+
142
+ # What Plutonium authorizes on, shaped for a policy constructor.
143
+ #
144
+ # The parent pair is included for the same reason the tenant is. Omitting
145
+ # it does not merely lose a filter: Policy#default_relation_scope picks
146
+ # ONE branch, parent or entity, so a nested run without its parent
147
+ # re-derives targets under the TENANT where dispatch used the parent.
148
+ # Wider, and not the scope the initiator was shown. It also leaves a host
149
+ # predicate reading +parent+ looking at nil — legal, since the policy
150
+ # declares it optional, so instead of raising it quietly answers false
151
+ # and every target is refused for a reason that names the predicate
152
+ # rather than the missing context.
153
+ #
154
+ # Both halves or neither: Policy#default_relation_scope raises on one
155
+ # without the other, and the migration records them together.
156
+ #
157
+ # @return [Hash]
158
+ def policy_context
159
+ {user: initiator, entity_scope: scoped_entity,
160
+ parent: parent, parent_association: parent_association}
161
+ end
162
+
163
+ # Builds the policy for a record or resource class, under the run's
164
+ # namespace.
165
+ #
166
+ # The leading :: is REQUIRED — Plutonium::ActionPolicy exists, so a bare
167
+ # ActionPolicy resolves to that namespace instead of the gem's.
168
+ #
169
+ # +lookup+ raises ActionPolicy::NotFound when nothing matches, which is
170
+ # the correct direction to fail: no policy must never mean no check.
171
+ #
172
+ # Deliberately NOT asserted against the run's recorded policy. That
173
+ # assertion is about the TARGET RESOURCE and is made once, up front. A
174
+ # per-record check would break legitimate STI runs: a run over
175
+ # Blogging::Post whose rows include Blogging::Article resolves
176
+ # ArticlePolicy for those rows, which correctly differs from the run's
177
+ # PostPolicy.
178
+ #
179
+ # Not a silent hole: a subtype policy missing +policy_action+ entirely
180
+ # raises via +send_with_report+ (see {#permitted?}), not "permitted".
181
+ #
182
+ # @param record [ActiveRecord::Base, Class]
183
+ # @return [Plutonium::Resource::Policy]
184
+ def policy_for(record)
185
+ # The record is POSITIONAL. ActionPolicy::Policy::Core#initialize is
186
+ # `def initialize(record = nil, *)`, so a `record:` KEYWORD is swallowed
187
+ # and the policy's own `record` stays nil — every predicate written as
188
+ # `record.published?` then blows up (or, worse, a predicate that guards
189
+ # on record state stops guarding). This is how ActionPolicy builds
190
+ # policies internally too; see Behaviours::PolicyFor#policy_for.
191
+ ::ActionPolicy.lookup(record, namespace: authorization_namespace).new(record, **policy_context)
192
+ end
193
+
194
+ # The target resource, narrowed to what the initiator may see in this
195
+ # tenant. Policy#apply_scope also enforces that the policy actually
196
+ # applied Plutonium's default (parent/entity) scoping, so a custom
197
+ # relation_scope that forgets it raises rather than leaking.
198
+ #
199
+ # @return [ActiveRecord::Relation]
200
+ def authorized_scope(relation = target_class.all)
201
+ policy_for(relation.klass).apply_scope(relation, type: :active_record_relation)
202
+ end
203
+
204
+ # Resolves the stored target ids through the policy scope, then through
205
+ # the policy predicate.
206
+ #
207
+ # BOTH checks are needed and they catch different revocations. The scope
208
+ # catches lost VISIBILITY — the record is no longer the initiator's to
209
+ # see. The predicate catches lost PERMISSION — the record is still
210
+ # visible but +archive?+ now returns false. Resolving by scope alone
211
+ # would let a run whose permission was revoked after enqueue proceed
212
+ # anyway, which is the whole failure this class exists to prevent, and
213
+ # is what dispatch itself checks per record (see
214
+ # Plutonium::Resource::Controllers::InteractiveActions#authorize_interactive_bulk_action!).
215
+ #
216
+ # The check lives here rather than in the executor so that no caller can
217
+ # perform work without it having happened.
218
+ #
219
+ # One query for the whole set, not one per id: a bulk run over a few
220
+ # thousand records is the normal case, and per-id lookups would also make
221
+ # the scope check easy to accidentally skip on the "just fetch this one"
222
+ # path. The predicates are then evaluated in memory, per record — that is
223
+ # N policy objects but no extra queries, unless a host's own predicate
224
+ # queries, which is the host's choice.
225
+ #
226
+ # @return [Targets]
227
+ def targets
228
+ unless run.target_type
229
+ raise UnresolvableError,
230
+ "run #{run.id} (#{run.class}) has no target_type; it is not a targeted run"
231
+ end
232
+
233
+ key = target_class.primary_key
234
+ # unhandled_target_ids, not target_ids: a run resumed after an
235
+ # interruption (see Async::ReapJob) must not redo — or re-record as
236
+ # missing/unauthorized — targets it already dispositioned.
237
+ ids = run.unhandled_target_ids
238
+ found = authorized_scope.where(key => ids).index_by { |record| record.public_send(key).to_s }
239
+
240
+ # Compared as strings because the two sides cross a type boundary:
241
+ # target_ids comes back out of a JSON column (and may have gone in as
242
+ # request params), while the ids on the loaded records are whatever the
243
+ # host's primary key is — bigint or uuid. Matching on raw values would
244
+ # report every target as missing the moment those disagree.
245
+ records = []
246
+ missing_ids = []
247
+ unauthorized_ids = []
248
+
249
+ ids.each do |id|
250
+ record = found[id.to_s]
251
+ if record.nil?
252
+ missing_ids << id
253
+ elsif permitted?(record)
254
+ records << record
255
+ else
256
+ unauthorized_ids << id
257
+ end
258
+ end
259
+
260
+ Targets.new(records: records, missing_ids: missing_ids, unauthorized_ids: unauthorized_ids)
261
+ end
262
+
263
+ # Asks the record's own policy the question dispatch asked.
264
+ #
265
+ # PUBLIC because it is exactly what an executor needs immediately before
266
+ # acting on a record. {#targets} answers this once, up front, which makes
267
+ # a good operator report but is only true as of that moment; a run over
268
+ # thousands of records acts long after it. Re-asking per record right
269
+ # before {Run#perform_on} is the caller's decision, and this is the
270
+ # sanctioned way to do it — reimplementing it would duplicate the
271
+ # +send_with_report+ behaviour below rather than share it.
272
+ #
273
+ # NOT named +permitted_now?+: on its own it answers from the subjects
274
+ # cached at construction, so a name promising currency would overstate it.
275
+ # Pair it with {#refresh_subjects!} when currency actually matters.
276
+ #
277
+ # send_with_report raises NotImplementedError when the predicate is
278
+ # missing, so an action renamed between enqueue and perform surfaces as a
279
+ # loud failure rather than a silent false.
280
+ #
281
+ # @param record [ActiveRecord::Base]
282
+ # @return [Boolean]
283
+ def permitted?(record)
284
+ policy_for(record).send_with_report(policy_action)
285
+ end
286
+
287
+ # Re-reads the two authorization subjects from the database.
288
+ #
289
+ # Policies are built from (initiator, scoped_entity), and both are cached
290
+ # belongs_to associations — so without this every check for the whole run
291
+ # evaluates against the subjects as they were when this object was built.
292
+ # A predicate reading +user.role+ would then look like a guard while
293
+ # guarding nothing. Reloading also drops association caches on the
294
+ # subject, so +user.organizations+ is re-read too.
295
+ #
296
+ # Two queries. Deliberately NOT called automatically: per-record reload is
297
+ # two queries per target, which is untenable for a large run, and the
298
+ # right cadence (per record, per batch, per interval) depends on the
299
+ # executor's shape. This provides the capability; the policy is the
300
+ # caller's.
301
+ #
302
+ # Re-runs the subject existence checks, so a tenant or initiator deleted
303
+ # MID-run stops the work the same way one deleted before it started does.
304
+ # Does not re-verify the recorded policy: constants do not change under a
305
+ # running process.
306
+ #
307
+ # @raise [UnresolvableError] if a subject has since been deleted
308
+ # @return [self]
309
+ def refresh_subjects!
310
+ run.reload_initiator
311
+ run.reload_scoped_entity if run.scoped_entity_type
312
+ run.reload_parent if run.parent_type
313
+ verify_subjects!
314
+ # Stamped only after the check passes: a refresh that raised did not
315
+ # produce a usable answer, so nothing may treat it as a fresh read.
316
+ @subjects_read_at = Time.current
317
+ self
318
+ end
319
+
320
+ private
321
+
322
+ # Constantizes a stored class/module name, converting the raw NameError
323
+ # into this class's domain error. A model or module renamed between
324
+ # enqueue and perform is a stored-context failure like any other here, and
325
+ # should name the run and the diagnosis rather than surfacing as an
326
+ # unattributed NameError from deep inside a job.
327
+ def resolve_constant(name, field)
328
+ return nil if name.nil?
329
+
330
+ name.constantize
331
+ rescue NameError
332
+ raise UnresolvableError,
333
+ "run #{run.id} recorded #{field} #{name.inspect}, which no longer resolves to a constant; " \
334
+ "refusing to guess what was meant"
335
+ end
336
+
337
+ def verify_resolvable!
338
+ verify_subjects!
339
+ verify_policy!
340
+ end
341
+
342
+ # Both checks below distinguish "the run carries no X" from "the run
343
+ # carries an X that has since been deleted". The association returns nil
344
+ # either way; only the *_type column tells them apart.
345
+ #
346
+ # Split out from verify_resolvable! so {#refresh_subjects!} can re-apply
347
+ # exactly these — a subject deleted mid-run must fail the same way one
348
+ # deleted before the run started does.
349
+ def verify_subjects!
350
+ # The initiator column is NOT NULL, so a nil association here means the
351
+ # user row is gone. Refusing is the whole point: a run whose initiator
352
+ # no longer exists has no one left to authorize as.
353
+ if run.initiator.nil?
354
+ raise UnresolvableError,
355
+ "run #{run.id} has no initiator (#{run.initiator_type}##{run.initiator_id} no longer exists)"
356
+ end
357
+
358
+ # nil scoped_entity is LEGITIMATE — an unscoped portal has no tenant,
359
+ # and the schema is nullable by design. But that is only true when the
360
+ # run carries no tenant at all. If the columns are populated and the row
361
+ # is gone, nil means "tenant unknown", and treating it as "no tenant"
362
+ # drops the entity filter entirely, returning records from EVERY tenant.
363
+ # That is precisely the fail-open case this class exists to prevent.
364
+ if run.scoped_entity_type.present? && run.scoped_entity.nil?
365
+ raise UnresolvableError,
366
+ "run #{run.id} was scoped to #{run.scoped_entity_type}##{run.scoped_entity_id}, " \
367
+ "which no longer exists; refusing to resolve targets without a tenant"
368
+ end
369
+
370
+ # Same distinction one more time, and the same direction of failure. A
371
+ # deleted parent nils the association, which reads as "not a nested
372
+ # dispatch" — and that hands the run entity scoping instead of parent
373
+ # scoping, which is wider.
374
+ if run.parent_type.present? && run.parent.nil?
375
+ raise UnresolvableError,
376
+ "run #{run.id} was nested under #{run.parent_type}##{run.parent_id}, " \
377
+ "which no longer exists; refusing to resolve targets without its parent"
378
+ end
379
+ end
380
+
381
+ # Checks that the policy resolving now is the one dispatch resolved.
382
+ #
383
+ # Only meaningful for a targeted run — untargeted work has no target
384
+ # resource and therefore no policy to pin.
385
+ def verify_policy!
386
+ return if run.target_type.nil?
387
+
388
+ # A targeted run with no recorded policy is not "unasserted", it is
389
+ # unverifiable: there is nothing to compare today's lookup against, and
390
+ # accepting it would silently disable the check for exactly the runs
391
+ # that need it. The dispatcher's job is to record this.
392
+ if run.policy_class_name.blank?
393
+ raise UnresolvableError,
394
+ "run #{run.id} targets #{run.target_type} but recorded no policy_class_name; " \
395
+ "refusing to resolve targets against an unverifiable policy"
396
+ end
397
+
398
+ # A targeted run with no predicate is the same hole one level down:
399
+ # there would be nothing to ask the policy, and "nothing to ask" must
400
+ # never collapse into "allowed".
401
+ if run.policy_action.blank?
402
+ raise UnresolvableError,
403
+ "run #{run.id} targets #{run.target_type} but recorded no policy_action; " \
404
+ "refusing to act on targets without a permission check"
405
+ end
406
+
407
+ resolved = target_policy_class
408
+ return if resolved.name == run.policy_class_name
409
+
410
+ raise PolicyMismatchError,
411
+ "run #{run.id} was dispatched under #{run.policy_class_name}, but " \
412
+ "#{run.target_type} now resolves to #{resolved.name} " \
413
+ "(namespace: #{run.authorization_namespace || "top level"}); " \
414
+ "refusing to run under a policy the initiator was never subject to"
415
+ end
416
+ end
417
+ end
418
+ end
419
+ end