plutonium-behavior

$npx mdskill add radioactive-labs/plutonium-core/plutonium-behavior

The behavior layer is intentionally thin: **controllers route**, **policies authorize**, **interactions act**. Registering an action and rendering it lives in [[plutonium-resource]] — this skill covers how to *write* the controller hook, policy method, or interaction class behind it.

SKILL.md

.github/skills/plutonium-behaviorView on GitHub ↗
---
name: plutonium-behavior
description: Use BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things".
---

# Plutonium Behavior — Controllers, Policies, Interactions

The behavior layer is intentionally thin: **controllers route**, **policies authorize**, **interactions act**. Registering an action and rendering it lives in [[plutonium-resource]] — this skill covers how to *write* the controller hook, policy method, or interaction class behind it.

For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]].

## 🚨 Critical (read first)

- **Use generators.** `pu:res:scaffold` creates the base trio (controller/policy/interaction-base); `pu:res:conn` creates portal-specific versions. Never hand-write them.
- **Don't override CRUD actions.** Use hooks (`resource_params`, `redirect_url_after_submit`, presentation hooks). Overriding `create`/`update` usually breaks authorization, params filtering, or both.
- **`create?` and `read?` default to `false`.** Always override them explicitly. Derived methods (`update?`, `show?`, etc.) inherit automatically.
- **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detection works; production raises.
- **`ActiveRecord::RecordInvalid` is NOT rescued automatically in interactions.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
- **Return `succeed(...)` or `failed(...)`** from `execute` — the controller can't tell what happened otherwise.
- **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
- **`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]].
- **For `has_cents` fields, use the virtual name (`:price`), not `:price_cents`** in `permitted_attributes_for_*`.
- **Custom action ⇒ policy method.** `action :publish` needs `def publish?` on the policy (undefined methods return `false`).
- **Named custom routes.** When adding custom routes, always pass `as:` so `resource_url_for` can build URLs.

---

# Part 1 — Controllers

Plutonium controllers ship full CRUD out of the box; nearly all customization lives in definitions / policies / interactions. The controller stays thin.

## Base classes

```ruby
# app/controllers/resource_controller.rb (installed once)
class ResourceController < ApplicationController
  include Plutonium::Resource::Controller
end

# app/controllers/posts_controller.rb (per resource, generated by pu:res:scaffold)
class PostsController < ::ResourceController
  # Empty — all CRUD inherited
end
```

## What you get for free

| Action | Route | Purpose |
|--------|-------|---------|
| `index` | GET `/posts` | List with pagination, search, filters, sorting |
| `show` | GET `/posts/:id` | Display single record |
| `new` | GET `/posts/new` | Form |
| `create` | POST `/posts` | Create |
| `edit` | GET `/posts/:id/edit` | Form |
| `update` | PATCH `/posts/:id` | Update |
| `destroy` | DELETE `/posts/:id` | Delete |

Plus interactive-action routes for every action declared in the definition.

## Where customization belongs

| Concern | Lives in |
|---|---|
| Field rendering (inputs, displays, columns) | Definition |
| Search, filters, scopes, sorting | Definition |
| Custom operations (publish, archive, import) | Interaction (+ action in definition) |
| Authorization rules | Policy |
| Form/show/page chrome | Definition (custom page classes) |
| **Custom redirect logic** | **Controller hook** |
| **Param munging** | **Controller hook** |
| **Custom index query shape** | **Controller hook** |
| **Presentation of parent/entity fields** | **Controller hook** |

## Override hooks

All hooks are private methods. Override only the ones you need.

### Redirect hooks

```ruby
class PostsController < ::ResourceController
  private

  # Where to go after create/update: "show" (default), "edit", "new", "index"
  def preferred_action_after_submit = "edit"

  # Custom URL after create/update (overrides preferred_action_after_submit)
  def redirect_url_after_submit = posts_path

  # Custom URL after destroy
  def redirect_url_after_destroy = posts_path
end
```

### Parameter hook

```ruby
def resource_params
  params = super
  params[:tags] = params[:tags].split(",") if params[:tags].is_a?(String)
  params
end
```

### Index query hook

```ruby
def filtered_resource_collection
  base = current_authorized_scope
  base = base.featured if params[:featured]
  current_query_object.apply(base, raw_resource_query_params)
end
```

### Presentation hooks

Control whether parent / scoped-entity fields appear in forms and displays. Defaults are `false` (hidden, since they're inferred from the URL/portal).

```ruby
def present_parent?         = true   # show parent field on displays
def submit_parent?          = true   # include parent field in forms (default: tracks present_parent?)
def present_scoped_entity?  = true
def submit_scoped_entity?   = true
```

## Custom actions

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.).

```ruby
class PostsController < ::ResourceController
  def publish
    authorize_current!(resource_record!, to: :publish?)
    resource_record!.update!(published: true)
    redirect_to resource_url_for(resource_record!), notice: "Published!"
  end
end
```

Route must be named:

```ruby
resources :posts do
  member { post :publish, as: :publish }   # `as:` required!
end
```

## Key methods

### Resource access

```ruby
resource_class            # The model class
resource_record!          # Current record (raises if not found)
resource_record?          # Current record (nil if not found)
resource_params           # Permitted params for create/update
current_parent            # Parent record for nested routes
current_scoped_entity     # Tenant entity for the current portal (nil if not scoped)
```

### Authorization

**Current resource:**

```ruby
authorize_current!(record, to: :action?)  # Check permission
current_policy
permitted_attributes
current_authorized_scope                  # Scoped records the user can access
```

**Other resources** (cross-resource auth — use these, not raw `where` / `find`):

```ruby
authorize! other_record, to: :show?       # ActionPolicy — raises if denied
allowed_to?(:show?, other_record)         # Boolean check
policy_for(OtherModel)                    # Policy instance for class or record
policy_for(other_record).show?

authorized_resource_scope(OtherModel)              # Scope on the model class
authorized_resource_scope(OtherModel, relation: OtherModel.published)  # On a relation
authorized_resource_scope(OtherModel, type: :create)                   # Different action
```

`authorized_resource_scope` applies the *other* resource's `relation_scope` AND the current policy context (entity scope, etc.). **Always prefer it over `OtherModel.all` / raw `where` in cross-resource controller code** — otherwise you bypass that resource's tenancy and visibility rules.

### Definition access

```ruby
current_definition
```

### UI builders (rarely needed in controllers)

```ruby
build_form
build_detail
build_collection
```

### URL generation

```ruby
resource_url_for(@post)                          # show URL
resource_url_for(@post, action: :edit)           # edit URL
resource_url_for(Post)                           # index URL

# Nested
resource_url_for(@comment, parent: @post)
resource_url_for(Comment, action: :new, parent: @post)

# Cross-package
resource_url_for(@post, package: AdminPortal)

# Interactive actions (see Part 3 below)
resource_url_for(@post, interaction: :publish)
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
```

## Nested resources

Routes prefixed with `nested_` automatically resolve the parent:

```ruby
# Route: /users/:user_id/nested_posts/:id
class PostsController < ::ResourceController
  # current_parent              => User instance
  # current_nested_association  => :posts
  # resource_record!            => Post scoped to that User
end
```

| Method | Returns |
|---|---|
| `current_parent` | Parent record |
| `current_nested_association` | `:posts` |
| `parent_route_param` | `:user_id` |
| `parent_input_param` | `:user` |

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.

## Entity scoping (multi-tenancy)

When a portal calls `scope_to_entity SomeModel`, every controller in that portal automatically:

- Scopes queries to the entity
- Excludes the entity field from forms (detected by association class)
- Injects the entity on create/update
- Exposes `current_scoped_entity`

Plutonium auto-detects which `belongs_to` association points to the scoped class, even when `param_key` differs from the association name. If a model has **multiple associations to the same scoped class**, you get a runtime error and must override:

```ruby
class MatchesController < ::ResourceController
  private
  def scoped_entity_association = :home_team
end
```

For the full mechanics, load [[plutonium-tenancy]].

## Authorization verification

After-action callbacks ensure auth was performed:

```ruby
verify_authorize_current         # all actions
verify_current_authorized_scope  # all except new/create
```

Skip only when handling auth manually. Two forms:

```ruby
# Class-level — skip across multiple actions
class PostsController < ::ResourceController
  skip_verify_authorize_current only: [:custom_action]
  skip_verify_current_authorized_scope only: [:custom_action]

  def custom_action
    # do auth manually
  end
end

# Per-action — bang methods, call inside the action body
def custom_action
  skip_verify_authorize_current!
  skip_verify_current_authorized_scope!
  # do auth manually
end
```

Prefer the per-action bang form when only one action skips — keeps the exception co-located with the code that needs it.

## Portal-specific controllers

Portal controllers inherit from the feature-package controller if one exists (and include the portal's `Concerns::Controller`); otherwise from the portal's `ResourceController`.

```ruby
# Feature package controller exists
class AdminPortal::PostsController < ::PostsController
  include AdminPortal::Concerns::Controller
end

# No feature package — inherits portal base
class AdminPortal::PostsController < AdminPortal::ResourceController
end
```

Non-resource portal pages (dashboard, settings) inherit from `PlutoniumController`:

```ruby
module AdminPortal
  class DashboardController < PlutoniumController
    def index; end
  end
end
```

---

# Part 2 — Policies

Built on [ActionPolicy](https://actionpolicy.evilmartians.io/). Plutonium adds:

- Attribute permissions (`permitted_attributes_for_*`)
- Association permissions (`permitted_associations`)
- Automatic entity scoping
- Derived action methods (`update?` inherits from `create?`, etc.)

## Base class

```ruby
# app/policies/resource_policy.rb (installed once)
class ResourcePolicy < Plutonium::Resource::Policy
  # App-wide defaults
end

# app/policies/post_policy.rb (per resource, generated)
class PostPolicy < ResourcePolicy
  def create? = user.present?
  def read?   = true

  def permitted_attributes_for_create
    %i[title content]
  end

  def permitted_attributes_for_read
    %i[title content author created_at]
  end
end
```

## Action permissions

### Must override

```ruby
def create?  # default: false
  user.present?
end

def read?    # default: false
  true
end
```

### Derived (inherit automatically)

| Method | Inherits from | Override when |
|--------|---------------|---------------|
| `update?` | `create?` | Different update rules |
| `destroy?` | `create?` | Different delete rules |
| `index?` | `read?` | Custom listing rules |
| `show?` | `read?` | Record-specific read rules |
| `new?` | `create?` | Rarely needed |
| `edit?` | `update?` | Rarely needed |
| `search?` | `index?` | Search-specific rules |

### Custom actions

Define `def <action>?` matching the definition's `action :<action>`. Undefined methods return `false`:

```ruby
def publish? = update? && record.draft?
def archive? = create? && !record.archived?
def invite_user? = user.admin?
```

### Bulk actions — per-record auth

```ruby
def bulk_archive?
  create? && !record.locked?    # checked per record in the selection
end
```

How it works:

- Policy is checked **per record** in the selected set.
- **Backend:** if any record fails, the entire request is rejected.
- **UI:** only actions ALL selected records support are shown (intersection).
- Records come from `current_authorized_scope` — users can only select what they're allowed to access.

## Attribute permissions

```ruby
# Must override for production
def permitted_attributes_for_read
  %i[title content author published_at created_at]
end

def permitted_attributes_for_create
  %i[title content]
end
```

### Derived

| Method | Inherits from |
|---|---|
| `permitted_attributes_for_update` | `permitted_attributes_for_create` |
| `permitted_attributes_for_index` | `permitted_attributes_for_read` |
| `permitted_attributes_for_show` | `permitted_attributes_for_read` |
| `permitted_attributes_for_new` | `permitted_attributes_for_create` |
| `permitted_attributes_for_edit` | `permitted_attributes_for_update` |

### Per-action override

```ruby
def permitted_attributes_for_index
  %i[title author created_at]                # minimal for the table
end

def permitted_attributes_for_read
  %i[title content author tags created_at]   # fuller for the show page
end
```

🚨 **Index has no `record`.** `permitted_attributes_for_index` is evaluated at collection level — `record` is `nil`. `permitted_attributes_for_show` (and `_for_read`) ARE evaluated per record. So if you write a record-dependent `_for_read`:

```ruby
def permitted_attributes_for_read
  attrs = %i[title content]
  attrs << :archive_reason if record.archived?   # uses record
  attrs
end
```

…you MUST also define an explicit `permitted_attributes_for_index` — otherwise inheritance kicks in, runs the `_for_read` body during the table render, and `record.archived?` blows up on `NoMethodError: undefined method 'archived?' for nil`.

```ruby
def permitted_attributes_for_index
  %i[title content]                              # no record-dependent fields
end
```

Same rule for `permitted_attributes_for_create` vs `_for_new` (new has no persisted record).

### Policy vs definition — what controls what

`permitted_attributes_for_*` controls **which fields appear** on a view. Definition `field`/`input`/`display`/`column` declarations only control **how** they render. A `field :name` in the definition does nothing unless `:name` is also in the relevant `permitted_attributes_for_*`.

Common mistake: adding a definition declaration and wondering why the field doesn't show — check the policy.

### Anti-pattern: nested-attributes hashes

```ruby
# ❌ NEVER
def permitted_attributes_for_create
  [:name, {variants_attributes: [:id, :name, :_destroy]}]
end
```

Plutonium extracts nested params via the form definition, not the policy. Hash entries get iterated as field names by the form renderer and render as literal text inputs.

```ruby
# ✅ Policy permits just the association name
def permitted_attributes_for_create
  [:name, :variants]
end
```

`nested_input :variants` in the definition handles the rest. See [[plutonium-resource]] › Nested Inputs.

## Association permissions

```ruby
def permitted_associations
  %i[comments tags author]
end
```

Declares which associations get their own **tab on the show page**. When `permitted_associations` is non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs.

Each named association must:

- Exist on the model (raises `ArgumentError: unknown association ...` otherwise).
- Point to a class that's itself a registered Plutonium resource (raises `... is not a registered resource` otherwise).

This is **NOT** the same as:

- **Nested forms** — declared with `nested_input :variants` in the definition, requires `accepts_nested_attributes_for` on the model. See [[plutonium-resource]] › Nested Inputs.
- **Association fields on tables / show details** — controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.

## Collection scoping (`relation_scope`)

Filter which records the user can see. **Always compose with `default_relation_scope(relation)` explicitly** — `super` is unreliable inside the block, and bypassing this triggers `verify_default_relation_scope_applied!`:

```ruby
relation_scope do |relation|
  relation = default_relation_scope(relation)
  user.admin? ? relation : relation.where(author: user)
end
```

For tenant scoping, parent scoping, `skip_default_relation_scope!`, and `associated_with` resolution: load [[plutonium-tenancy]].

## Portal-specific policies

```ruby
class PostPolicy < ResourcePolicy
  def create? = user.present?
end

# Admin: more permissive
class AdminPortal::PostPolicy < ::PostPolicy
  include AdminPortal::ResourcePolicy
  def destroy? = true
  def permitted_attributes_for_create = %i[title content featured internal_notes]
end

# Public: read-only
class PublicPortal::PostPolicy < ::PostPolicy
  include PublicPortal::ResourcePolicy
  def create? = false
end
```

## Authorization context

```ruby
user                # current user
record              # the resource being authorized
entity_scope        # current scoped entity (multi-tenancy)
parent              # parent record for nested resources (nil otherwise)
parent_association  # association name on parent (e.g. :comments)
```

### Custom context

```ruby
# Policy
class PostPolicy < ResourcePolicy
  authorize :department, allow_nil: true

  def create? = department&.allows_posting?
end

# Controller
class PostsController < ResourceController
  authorize :department, through: :current_department

  private
  def current_department = current_user.department
end
```

## Common patterns

### Block archived records

```ruby
def update?  = !record.try(:archived?) && super
def destroy? = !record.try(:archived?) && super
```

### Owner-based

```ruby
def update?  = record.author == user || user.admin?
def destroy? = update?
```

### Role-based

```ruby
def create? = user.admin? || user.editor?

def update?
  return true if user.admin?
  user.editor? && record.author == user
end
```

### Conditional attribute access

```ruby
def permitted_attributes_for_create
  attrs = %i[title content]
  attrs += %i[featured author_id] if user.admin?
  attrs
end
```

---

# Part 3 — Interactions

Interactions encapsulate business logic into testable units. They're registered as actions in definitions (see [[plutonium-resource]] › Actions) and executed by the controller.

## Structure

```ruby
# app/interactions/resource_interaction.rb (installed once)
class ResourceInteraction < Plutonium::Resource::Interaction
end

# A real interaction
class PublishPostInteraction < ResourceInteraction
  presents label: "Publish",
           icon: Phlex::TablerIcons::Send,
           description: "Make this post public"

  attribute :resource
  attribute :publish_date, :datetime, default: -> { Time.current }

  input :publish_date

  validates :publish_date, presence: true

  private

  def execute
    resource.update!(published_at: publish_date)
    succeed(resource).with_message("Post published!")
  rescue ActiveRecord::RecordInvalid => e
    failed(e.record.errors)
  end
end
```

## Attributes

ActiveModel-style:

```ruby
attribute :resource                                # single record (record action)
attribute :resources                               # array of records (bulk action)
attribute :email, :string
attribute :count, :integer, default: 1
attribute :active, :boolean, default: -> { true }  # callable default
attribute :tags, :array
attribute :metadata, :hash
attribute :date, :datetime
```

The presence of `:resource` / `:resources` / neither determines the action type — see [[plutonium-resource]] › Action Types.

## Inputs

Same DSL as definition `input` (load [[plutonium-resource]] for the full list of `as:` types, options, dynamic blocks, etc.):

```ruby
input :email
input :role, as: :select, choices: %w[admin user]
input :content, as: :text
```

Auto-detection rule from [[plutonium-resource]] applies here too: if the attribute type already implies the right widget, don't redeclare `as:`.

## Presentation

```ruby
presents label: "Archive Record",
         icon: Phlex::TablerIcons::Archive,
         description: "Move to archive"

# Access
MyInteraction.label
MyInteraction.icon
MyInteraction.description
```

If `action :foo, interaction: FooInteraction` doesn't override `label:`/`icon:`/etc., these `presents` values are used.

## `execute` — outcomes

`execute` MUST return a `succeed(...)` or `failed(...)` outcome. Validations run automatically before `execute`; if they fail, the interaction short-circuits to `failed()`.

### Success

```ruby
succeed(resource)                                       # auto-redirect to resource
succeed(resource).with_message("Done!")
succeed(resource).with_message("Heads up!", :alert)
succeed(resource).with_redirect_response(custom_path)   # different destination
succeed(resource).with_file_response(path, filename: "report.pdf")
```

### Failure

```ruby
failed("Something went wrong")
failed(resource.errors)
failed(email: "is invalid", name: "is required")  # hash form
failed("Invalid value", :email)                   # string + attribute
```

### Chaining

```ruby
def execute
  CreateUserInteraction.call(view_context:, **user_params)
    .and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
    .and_then { |r| LogActivity.call(view_context:, user: r.value) }
    .with_message("User created and welcomed!")
end
```

The chain short-circuits on the first failure.

## Validations

Standard ActiveModel — run automatically before `execute`:

```ruby
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
validates :role, inclusion: {in: %w[admin user guest]}

validate :custom_check

private

def custom_check
  errors.add(:resource, "cannot be modified when archived") if resource.archived?
end
```

## Accessing context

```ruby
def execute
  current_user = view_context.controller.helpers.current_user
  resource.update!(updated_by: current_user)
  succeed(resource)
end
```

A shorter `current_user` helper is conventional:

```ruby
private
def current_user = view_context.controller.helpers.current_user
```

## Interaction types

| Attribute pattern | Action type | Where it shows up |
|---|---|---|
| `attribute :resource` | Record action | Show page + per-row in table |
| `attribute :resources` | Bulk action | Bulk toolbar above table |
| neither | Resource action | Index page header |

**Bulk action authorization:** per-record. See [[plutonium-resource]] › Action Types and Part 2 above.

## Generating interaction URLs

Use `resource_url_for` with the `interaction:` kwarg. Action type is inferred from the element and presence of `ids:`:

```ruby
# Record action — instance argument
resource_url_for(@post, interaction: :publish)
# => /posts/:id/record_actions/publish

# Resource action — class, no ids
resource_url_for(Post, interaction: :import)
# => /posts/resource_actions/import

# Bulk action — class + ids
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
# => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3

# Composes with parent / entity scoping
resource_url_for(@post, parent: @user, interaction: :publish)
```

The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb routes to the right controller action. Passing both `interaction:` and `action:` raises `ArgumentError`.

## Complete example

```ruby
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
  presents label: "Invite User",
           icon: Phlex::TablerIcons::UserPlus

  attribute :resource          # the company
  attribute :email, :string
  attribute :role, :string

  input :email
  input :role, as: :select, choices: -> { UserInvite.roles.keys }

  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
  validates :role,  presence: true, inclusion: {in: UserInvite.roles.keys}
  validate :not_already_invited

  private

  def execute
    invite = UserInvite.create!(
      company: resource, email: email, role: role,
      invited_by: current_user
    )
    UserInviteMailer.invitation(invite).deliver_later
    succeed(resource).with_message("Invitation sent to #{email}")
  rescue ActiveRecord::RecordInvalid => e
    failed(e.record.errors)
  end

  def not_already_invited
    return unless email.present?
    if UserInvite.exists?(company: resource, email: email, state: :pending)
      errors.add(:email, "already has a pending invitation")
    end
  end

  def current_user = view_context.controller.helpers.current_user
end
```

---

## Related Skills

- [[plutonium-resource]] — registering interactions as actions; field/input/display syntax
- [[plutonium-tenancy]] — `relation_scope`, entity scoping, nested resources
- [[plutonium-ui]] — custom interaction form templates, page classes
- [[plutonium-testing]] — testing controllers, policies, interactions

More from radioactive-labs/plutonium-core

SkillDescription
plutoniumUse BEFORE starting any Plutonium work — new app, new feature, or first edit in an unfamiliar area. Routes you to the right skill and bootstraps greenfield work.
plutonium-appUse BEFORE installing Plutonium, creating a portal or feature package, mounting an engine, or registering resources/routes. Covers initial setup, the package system, portal engines, route registration (including singular and custom routes), and resource-to-portal wiring.
plutonium-authUse BEFORE installing Rodauth, configuring account types, building login/password flows, or wiring a profile / account-settings page. Covers the full auth surface — Rodauth installation, accounts, admin accounts, SaaS setup, profile resource, security section.
plutonium-resourceUse BEFORE creating, scaffolding, or editing any Plutonium resource — model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, index views, page customization. The single source for "what is a resource and how do I configure one".
plutonium-tenancyUse BEFORE any multi-tenant work — scoping a model to a tenant, writing relation_scope, configuring portal entity strategies, setting up parent/child nested resources, or wiring user invitations. The single source for entity scoping, nested resources, and invites.
plutonium-testingUse BEFORE writing tests for a Plutonium resource, running pu:test:scaffold, or including Plutonium::Testing::* concerns. Covers the full testing toolkit — CRUD, policy, definition, interaction, model, nested, portal access, and auth helpers.
plutonium-uiUse BEFORE building or customizing any Plutonium UI — page classes, forms, displays, tables, custom Phlex components, layouts, Stimulus controllers, Tailwind config, design tokens, themes, or component classes. Covers the full view + asset toolchain.