agentleFS
igmarinigmarin/elixir-phoenix-skills/skills/ash-framework/SKILL.md

ash-framework

Skill4 starsChanged 4 months agoMITView on GitHub

MANDATORY when considering, adopting, or working with Ash Framework for Elixir applications. Invoke before starting a new Ash project or major refactor. Guides defining Ash resources with attributes and relationships, configuring actions and policies, using Ash extensions (AshPostgres, AshPhoenix, AshJsonApi), and migrating from Phoenix contexts to Ash DSL patterns. Trigger words: Ash Framework, Ash resource, Ash action, resource-oriented, DSL, alternative to contexts,

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Follow this sequence when starting a new Ash project:

    line 36
  2. Very specific:

    Debugging authorization failures: If a call raises Ash.Error.Forbidden, enable policy breakdown logging:

    line 165
  3. Very specific:

    Add {:ash_phoenix, "~> 2.0"} to deps.

    line 175
  4. Very specific:

    Add {:ash_json_api, "~> 1.0"} to deps.

    line 195

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Ash Framework

RULES — Follow these with no exceptions

1. Use use Ash.Resource for domain resources — never manually implement protocols 2. Define actions explicitly — don't rely on defaults [:read, :create] without understanding what they expose 3. Add policies for authorization — every resource with sensitive data must have explicit policy blocks 4. Use Ash.Changeset.for_create/3 and Ash.Changeset.for_update/3 — not bare struct manipulation 5. Run mix ash_postgres.generate_migrations before manual migration — let Ash generate the schema 6. Verify resource loads — run mix compile and confirm no Spark.Error.DslError before proceeding

End-to-End Workflow

Follow this sequence when starting a new Ash project:

  1. Add dependencies — add {:ash, "~> 3.0"} and {:ash_postgres, "~> 2.0"} to mix.exs
  2. Configure Repo — change use Ecto.Repo to use AshPostgres.Repo, otp_app: :my_app
  3. Define Domain module — create a domain with use Ash.Domain and resources do ... end
  4. Define Resource — use use Ash.Resource, domain: MyApp.Domain, data_layer: AshPostgres.DataLayer
  5. Configure postgres — add table and repo in the postgres do block
  6. Define attributes — use uuid_primary_key, attribute, timestamps() in the attributes do block
  7. Define relationships — use belongs_to, has_many, many_to_many in relationships do block
  8. Define actions — use actions do with defaults, create, update, read blocks
  9. Add policies — use policies do block with authorize_if or forbid_if rules
  10. Generate migrations — run mix ash_postgres.generate_migrations then mix ash_postgres.migrate
  11. Test with Ash API — use Domain.create!(resource, attributes) to verify the resource works

Core Concepts

Resource Definition

defmodule MyApp.Blog.Post do
  use Ash.Resource,
    domain: MyApp.Blog,
    data_layer: AshPostgres.DataLayer

  postgres do
    table "posts"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id

    attribute :title, :string do
      allow_nil? false
      constraints [max_length: 255]
    end

    attribute :body, :string do
      allow_nil? false
    end

    attribute :status, :atom do
      constraints [one_of: [:draft, :published, :archived]]
      default :draft
    end

    timestamps()
  end

  relationships do
    belongs_to :author, MyApp.Accounts.User do
      allow_nil? false
    end

    has_many :comments, MyApp.Blog.Comment
  end

  actions do
    defaults [:read, :destroy]

    create :create do
      primary? true
      accept [:title, :body, :status, :author_id]
    end

    update :publish do
      accept []
      change set_attribute(:status, :published)
    end

    read :published do
      filter expr(status == :published)
    end
  end
end

Using Actions

# Create a post
post =
  MyApp.Blog.Post
  |> Ash.Changeset.for_create(:create, %{
    title: "Hello World",
    body: "This is my first post",
    author_id: user.id
  })
  |> MyApp.Blog.create!()

# Read posts
posts =
  MyApp.Blog.Post
  |> Ash.Query.for_read(:published)
  |> Ash.Query.filter(author_id == ^user.id)
  |> MyApp.Blog.read!()

# Update post
post
|> Ash.Changeset.for_update(:publish)
|> MyApp.Blog.update!()

Policies (Authorization)

policies do
  policy action_type(:read) do
    authorize_if relates_to_actor_via(:author)
    authorize_if expr(status == :published)
  end

  policy action_type(:create) do
    authorize_if actor_present()
  end

  policy action(:update) do
    authorize_if relates_to_actor_via(:author)
  end

  policy action(:destroy) do
    authorize_if relates_to_actor_via(:author)
  end
end

Debugging authorization failures: If a call raises Ash.Error.Forbidden, enable policy breakdown logging:

# config/dev.exs
config :ash, :policies, log_policy_breakdowns: :error

AshPhoenix LiveView Integration

Add {:ash_phoenix, "~> 2.0"} to deps. See AshPhoenix docs for full LiveView and form component examples.

# Build form from changeset in mount
form =
  post
  |> Ash.Changeset.for_update(:update, %{})
  |> AshPhoenix.Form.for_update()
  |> to_form()

# Handle save event — reassign form on error
case Blog.update(Ash.Changeset.for_update(post, :update, params)) do
  {:ok, post}  -> {:noreply, put_flash(socket, :info, "Saved.") |> assign(post: post)}
  {:error, cs} -> {:noreply, assign(socket, form: cs |> AshPhoenix.Form.for_update() |> to_form())}
end

AshJsonApi Integration

Add {:ash_json_api, "~> 1.0"} to deps. See AshJsonApi docs for pagination, includes, and error serialization.

# In your resource
use Ash.Resource,
  domain: MyApp.Blog,
  data_layer: AshPostgres.DataLayer,
  extensions: [AshJsonApi.Resource]

json_api do
  type "post"

  routes do
    base "/posts"
    get :read
    index :published
    post :create
    patch :publish
  end
end
# router.ex
scope "/api/json" do
  pipe_through :api
  forward "/", AshJsonApi.Router, domains: [MyApp.Blog]
end

Calculations and Aggregates

aggregates do
  count :comment_count, :comments
  count :published_comment_count, :comments do
    filter expr(status == :published)
  end
end

# Use in queries
MyApp.Blog.Post
|> Ash.Query.filter(comment_count > 0)
|> MyApp.Blog.read!()

Common Pitfalls

❌ Don't✅ Do
Rely on defaults [:read, :create] without knowing what they exposeDefine actions explicitly and accept only the intended attributes
Build filters with string interpolation ("status == '#{s}'")Use pinned expressions: Ash.Query.filter(status == ^status)
Alter the DB schema by hand before defining the resourceDefine the resource first, then mix ash_postgres.generate_migrations
Skip policy blocks on resources with sensitive dataAdd policies do ... end with explicit authorize_if/forbid_if
Manipulate structs directly for writesUse Ash.Changeset.for_create/3 and Ash.Changeset.for_update/3
Rescue a generic error and lose contextMatch specific types: Ash.Error.Forbidden, Ash.Error.Query.NotFound
Offset-paginate large result setsUse keyset pagination (Ash.Query.page(after: ...))

Custom Validations — use the action layer, not DB constraints

create :create do
  accept [:title, :body, :author_id]

  validate str_length(:title, min: 1, max: 255) do
    message "Title must be between 1 and 255 characters"
  end
end

For multi-field or conditional logic, implement a custom Ash.Resource.Validation module:

defmodule MyApp.Validations.TitleNotBlank do
  use Ash.Resource.Validation

  @impl true
  def validate(changeset, _opts, _context) do
    case Ash.Changeset.get_attribute(changeset, :title) do
      nil -> {:error, field: :title, message: "can't be blank"}
      ""  -> {:error, field: :title, message: "can't be blank"}
      _   -> :ok
    end
  end
end

Filtering — use ^ for safe interpolation, never string interpolation

# NEVER: Ash.Query.filter("status == '#{params["status"]}'"})  -- injection risk
MyApp.Blog.Post
|> Ash.Query.filter(status == ^status and author_id == ^current_user.id)
|> Ash.Query.sort([inserted_at: :desc])

Not Found — match on Ash.Error.Query.NotFound explicitly

case MyApp.Blog.Post |> Ash.get(id) do
  {:ok, post}                            -> {:ok, post}
  {:error, %Ash.Error.Query.NotFound{}} -> {:error, :not_found}
  {:error, error}                        -> {:error, error}
end

Error Handling — match Ash error types specifically

case MyApp.Blog.Post
     |> Ash.Changeset.for_create(params)
     |> MyApp.Blog.create() do
  {:ok, post}                                      -> {:ok, post}
  {:error, %Ash.Error.InvalidInput{fields: fields}} -> {:error, :validation, fields}
  {:error, %Ash.Error.Forbidden{}}                 -> {:error, :unauthorized}
  {:error, %Ash.Error.Changeset{errors: errors}}   -> {:error, :invalid_changeset, errors}
  {:error, error} ->
    Logger.error("Unexpected error: #{inspect(error)}")
    {:error, :internal_error}
end

Pagination — use keyset pagination for large result sets

MyApp.Blog.Post
|> Ash.Query.page(limit: 20, after: last_inserted_at)
|> MyApp.Blog.read!()

Migrations from Ecto to Ash

Always create the Ash resource first, then let Ash generate migrations — never alter the DB schema before defining the resource.

# Step 1: Create Ash resource matching existing schema
defmodule MyApp.Blog.Post do
  use Ash.Resource, domain: MyApp.Blog, data_layer: AshPostgres.DataLayer
  postgres do
    table "posts"
    repo MyApp.Repo
  end
end

# Step 2: Generate and run migration
# mix ash_postgres.generate_migrations
# mix ash_postgres.migrate

# Step 3: Update context to delegate to Ash
def get_post!(id) do
  MyApp.Blog.Post |> Ash.get!(id)
end

Integration

PredecessorThis SkillSuccessor
elixir-essentialsash-frameworkphoenix-json-api
ecto-essentialsash-frameworkphoenix-authorization-patterns

Companion skills:

  • testing-essentials — test Ash actions and policies
  • phoenix-liveview-essentials — wire AshPhoenix forms into LiveView

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.