Skip to content

Latest commit

 

History

History
1804 lines (1387 loc) · 65.2 KB

AGENTS.md

File metadata and controls

1804 lines (1387 loc) · 65.2 KB

Instructions for Agents

This document provides an index and source reference of guidelines, processes, and best practices for agents to follow.

Documents

Use the simple-english skill when authoring technical documentation or instructions.

Use the plain-english skill when authoring non-technical documentation, marketing copy, or other content intended for a general audience.

Do not apply both to the same text.

The Plan

  • Initial Plan: documents/initial-plan.adoc (historical snapshot - not edited after the fact; corrections and later-phase plans live in their own documents instead)
  • Phase 6 Plan: documents/phase-6-plan.adoc
  • Phase 7 Plan: documents/phase-7-plan.adoc
  • Phase 8 Plan: documents/phase-8-plan.adoc
  • Phase 9 Plan: documents/phase-9-plan.adoc
  • Phase 10 Plan: documents/phase-10-plan.adoc
  • Phase 11 Plan: documents/phase-11-plan.adoc
  • Phase 12 Plan: documents/phase-12-plan.adoc
  • Phase 12 JSON output decision: documents/phase-12-json-output-decision.adoc
  • Phase 12 confirmation decision: documents/phase-12-confirmation-decision.adoc
  • Phase 13 Plan: documents/phase-13-plan.adoc
  • Phase 14 Plan: documents/phase-14-plan.adoc
  • Phase 15 Plan: documents/phase-15-plan.adoc
  • Phase 16 Plan: documents/phase-16-plan.adoc
  • Phase 17 Plan: documents/phase-17-plan.adoc
  • Phase 18 Plan: documents/phase-18-plan.adoc
  • Burrito distribution decision: documents/burrito-decision.adoc
  • Development and CI quality gates decision: documents/quality-gates-decision.adoc

Project Structure

Linear Team and Project

The Linear CLI project of the "Elixir Traitors" (EXT) team on the https://linear.app/the-rubyists[Rubyists Linear Organization] manages the project roadmap and issue tracking.

Rules for Interacting with Linear

Always only use mix lc to interact with linear.app. Do not look for an mcp or any other way to interact with issues or the project.

mix lc --help mix lc issue --help mix lc issue list --help mix lc issue create --help mix lc issue status --help mix lc issue comment --help

This should be enough to get started

Repository layout

This repo uses two Mix projects side-by-side, not nested:

  • app/ — The CLI itself (LinearCli application, version-managed, Burrito-released). All end-user features live here.
  • lib/, mix.exs (root) — Repo-management tooling (RepoTasks project; its own small deps, such as req, so a fresh clone needs mix deps.get here as well as in app/). Mix tasks that operate on the repo as a whole: mix lc, mix setup, mix git_hooks, mix stokowski, mix container.build/publish. These tasks orchestrate other tools as child processes and know nothing about app/'s internal modules.

Supporting directories (not Mix projects):

  • bin/ — Wrapper shell scripts (lcls, lcreate, lclose, lcomment, lproj). Each one just calls exec lc ....
  • ci/ — Shell scripts for CI and release: conventional-commit enforcement, container build/publish, Homebrew formula bump.
  • git-hooks/ — commit-msg and pre-push hooks; installed by mix setup.
  • oci/ — Containerfile for the published container image.
  • schema/ — The Linear GraphQL schema (LinearAPI.graphql), kept for reference.
  • cinemas/ — Terminal session recordings (.cinema.gif) embedded in Readme.adoc.
  • .ai/prompts/ — Stokowski agent-stage prompt files (global.md, implement.md, investigate.md, etc.).
  • vendor/ — Git submodules: ash, oban, usage_rules, ruby-linear-cli, and others. Source references and local development copies, not build inputs — the app uses Hex packages, not these.
  • documents/ — Architecture decision records and phase plans (see The Plan above).

App module structure (app/lib/linear_cli/)

The app is organized in layers:

LinearCli.CLI                        — Entry point; Optimus argument parsing and dispatch
  CLI.Commands.System                — whoami, version subcommands
  CLI.Commands.Profiles              — profile create/list/use/show/clear/delete subcommands
  CLI.Commands.Teams                 — team list/favorite/unfavorite subcommands
  CLI.Commands.Projects              — project list/favorite/unfavorite/update subcommands
  CLI.Commands.Issues.Read           — issue read/list/show subcommands
  CLI.Commands.Issues.Filter         — shared issue filter resolution
  CLI.Commands.Issues.Create         — issue create subcommand
  CLI.Commands.Issues.Development    — issue branch/PR subcommands
  CLI.Commands.Issues.Mutations      — issue update/status/assign/comment subcommands
  CLI.Commands.Issues.Move           — issue move subcommand
  CLI.Commands.Issues.Relations      — issue relation list/add/remove subcommands
  CLI.Commands.Issues.Graph          — --graph transitive dependency graph builder
  CLI.Issue.Identifiers              — issue ID expansion shared across subcommands
  CLI.WhatFor                        — Interactive prompts (team, project, label selection)
  CLI.Display                        — Output formatting helpers
  CLI.Output                         — Command-level JSON detection, success values, and status routing
  CLI.Projects                       — Project-specific prompt/resolution helpers
  CLI.Prompt                         — Low-level readline-style prompt wrapper

LinearCli.Linear        — Ash domain; all Linear API resources and actions
  Linear.Issue/Team/... — Ash resources (actions are manual GraphQL calls)
  LinearCli.Api         — Thin GraphQL client (Req); the only module that
                          speaks to api.linear.app directly

LinearCli.Profiles      — Named team/project bundles, persisted in SQLite
LinearCli.Favorites     — Favorited teams/projects, same SQLite file
LinearCli.Git           — Git branch creation/detection logic
LinearCli.Rollover      — Monthly issue-rollover logic (pure, no Oban dep)
  Rollover.Worker       — Thin Oban wrapper around Rollover.run/2

LinearCli.ObanRepo      — Runtime adapter selector (SQLite or Postgres)
  ObanRepo.Sqlite/.Postgres — Ecto repos for each backend

LinearCli.Application   — OTP Application; starts interactive or daemon mode

Data flows top-to-bottom through these layers: CLI.Commands.* modules call the LinearCli.Linear domain code interface; the domain's Ash resources call LinearCli.Api; LinearCli.Api calls the Linear GraphQL API. Profiles and Favorites are side-channels consulted by CLI.Commands.* modules for defaults, not part of the main API data flow.

The daemon mode (started when LINEAR_CLI_DAEMON=true) runs LinearCli.Rollover.Worker via Oban on a monthly cron schedule. The interactive CLI mode never starts Oban or an Ecto repo.

Maintenance convention

Update this section when adding a new top-level directory, a new major module (one that other modules depend on or that introduces a new architectural layer), or a new phase plan document. File-level changes inside existing directories do not need a structural-doc update.

Domain documentation

  • Ash domain ERD: documents/ash-domain-erd.adoc — canonical inventory of the LinearCli.Linear domain: resources, actions, code interfaces, GraphQL associations, and shared helpers. Must be updated in the same change whenever an Ash resource, action, code interface, association attribute, or shared helper is added, removed, renamed, or materially changed.
  • GitHub Update-branch validation decision: documents/github-update-branch-validation-decision.adoc — canonical security rationale and required regression behavior for the narrowly scoped exception that permits GitHub's trusted Update branch merge commits. Must be read and preserved whenever changing commit-subject validation, ci/validate_commit_range.sh, Git hooks, or CI quality-gate orchestration.

Standards

  • Bash error handling: never use set -e, set -u, or set -o pipefail (including combined forms such as set -euo pipefail). Handle every command that can fail with an explicit status check, diagnostic, and exit path. See documents/style/bash.adoc for the required Bash style.
  • Conventional Commits: app/usage-rules.md — enforced by the commit-msg and pre-push hooks at git-hooks/ (run mix setup once per clone to activate them).
  • Dogfooding & running lc locally (no MCP, no escript): app/usage-rules.md
  • Accessibility: app/usage-rules.md — the actual reason this project exists; see documents/motivation.adoc for the full why.

ash usage

A declarative, extensible framework for building Elixir applications.

Rules for working with Ash

Understanding Ash

Ash is an opinionated, composable framework for building applications in Elixir. It provides a declarative approach to modeling your domain with resources at the center. Read documentation before attempting to use its features. Do not assume that you have prior knowledge of the framework or its conventions.

ash:actions usage

Actions

  • Create specific, well-named actions rather than generic ones
  • Put all business logic inside action definitions
  • Use hooks like Ash.Changeset.after_action/2, Ash.Changeset.before_action/2 to add additional logic inside the same transaction.
  • Use hooks like Ash.Changeset.after_transaction/2, Ash.Changeset.before_transaction/2 to add additional logic outside the transaction.
  • When other resources should react to an action, and your action doesn't depend on the return value, emit a signal with emit in the action (with dynamic?: true when you emit it yourself with Ash.Signals.emit/4) and listen to it in those resources, instead of calling them from a hook. See the signals rules.
  • Use action arguments for inputs that need validation
  • Use preparations to modify queries before execution
  • Preparations support where clauses for conditional execution
  • Use only_when_valid? to skip preparations when the query is invalid
  • Use changes to modify changesets before execution
  • Use validations to validate changesets before execution
  • Prefer domain code interfaces to call actions instead of directly building queries/changesets and calling functions in the Ash module
  • A resource could be only generic actions. This can be useful when you are using a resource only to model behavior.
  • Instead of defining functions in the domain, you should be defining actions and exposing them through code interface calls in the domain. Use standard actions when they fit what you're doing and generic actions when you need arbitrary functionality.

Error Handling

Functions to call actions, like Ash.create and code interfaces like MyApp.Accounts.register_user all return ok/error tuples. All have ! variations, like Ash.create! and MyApp.Accounts.register_user!. Use the ! variations when you want to "let it crash", like if looking something up that should definitely exist, or calling an action that should always succeed. Always prefer the raising ! variation over something like {:ok, user} = MyApp.Accounts.register_user(...).

All Ash code returns errors in the form of {:error, error_class}. Ash categorizes errors into four main classes:

  1. Forbidden (Ash.Error.Forbidden) - Occurs when a user attempts an action they don't have permission to perform
  2. Invalid (Ash.Error.Invalid) - Occurs when input data doesn't meet validation requirements
  3. Framework (Ash.Error.Framework) - Occurs when there's an issue with how Ash is being used
  4. Unknown (Ash.Error.Unknown) - Occurs for unexpected errors that don't fit the other categories

These error classes help you catch and handle errors at an appropriate level of granularity. An error class will always be the "worst" (highest in the above list) error class from above. Each error class can contain multiple underlying errors, accessible via the errors field on the exception.

Using Validations

Validations ensure that data meets your business requirements before it gets processed by an action. Unlike changes, validations cannot modify the changeset - they can only validate it or add errors.

Validations work on both changesets and queries. Built-in validations that support queries include:

  • action_is, argument_does_not_equal, argument_equals, argument_in
  • byte_size, compare, confirm, match, negate, one_of, present, string_length
  • Custom validations that implement the supports/1 callback

Common validation patterns:

# Built-in validations with custom messages
validate compare(:age, greater_than_or_equal_to: 18) do
  message "You must be at least 18 years old"
end
validate match(:email, "@")
validate one_of(:status, [:active, :inactive, :pending])

# Conditional validations with where clauses
validate present(:phone_number) do
  where present(:contact_method) and eq(:contact_method, "phone")
end

# only_when_valid? - skip validation if prior validations failed
validate expensive_validation() do
  only_when_valid? true
end

# Action-specific vs global validations
actions do
  create :sign_up do
    validate present([:email, :password])  # Only for this action
  end
  
  read :search do
    argument :email, :string
    validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)  # Validates query arguments
  end
end

validations do
  validate present([:title, :body]), on: [:create, :update]  # Multiple actions
end
  • Create custom validation modules for complex validation logic:

    defmodule MyApp.Validations.UniqueUsername do
      use Ash.Resource.Validation
    
      @impl true
      def init(opts), do: {:ok, opts}
    
      @impl true
      def validate(changeset, _opts, _context) do
        # Validation logic here
        # Return :ok or {:error, message}
      end
    end
    
    # Usage in resource:
    validate {MyApp.Validations.UniqueUsername, []}
  • Make validations atomic when possible to ensure they work correctly with direct database operations by implementing the atomic/3 callback in custom validation modules.

    defmodule MyApp.Validations.IsEven do
      # transform and validate opts
    
      use Ash.Resource.Validation
    
      @impl true
      def init(opts) do
        if is_atom(opts[:attribute]) do
          {:ok, opts}
        else
          {:error, "attribute must be an atom!"}
        end
      end
    
      @impl true
      # This is optional, but useful to have in addition to validation
      # so you get early feedback for validations that can otherwise
      # only run in the datalayer
      def validate(changeset, opts, _context) do
        value = Ash.Changeset.get_attribute(changeset, opts[:attribute])
    
        if is_nil(value) || (is_number(value) && rem(value, 2) == 0) do
          :ok
        else
          {:error, field: opts[:attribute], message: "must be an even number"}
        end
      end
    
      @impl true
      def atomic(changeset, opts, context) do
        {:atomic,
          # the list of attributes that are involved in the validation
          [opts[:attribute]],
          # the condition that should cause the error
          # here we refer to the new value or the current value
          expr(rem(^atomic_ref(opts[:attribute]), 2) != 0),
          # the error expression
          expr(
            error(^InvalidAttribute, %{
              field: ^opts[:attribute],
              # the value that caused the error
              value: ^atomic_ref(opts[:attribute]),
              # the message to display
              message: ^(context.message || "%{field} must be an even number"),
              vars: %{field: ^opts[:attribute]}
            })
          )
        }
      end
    end
  • Avoid redundant validations - Don't add validations that duplicate attribute constraints:

    # WRONG - redundant validation
    attribute :name, :string do
      allow_nil? false
      constraints min_length: 1
    end
    
    validate present(:name) do  # Redundant! allow_nil? false already handles this
      message "Name is required"
    end
    
    validate attribute_does_not_equal(:name, "") do  # Redundant! min_length: 1 already handles this
      message "Name cannot be empty"
    end
    
    # CORRECT - let attribute constraints handle basic validation
    attribute :name, :string do
      allow_nil? false
      constraints min_length: 1
    end

Using Preparations

Preparations modify queries before they're executed. They are used to add filters, sorts, or other query modifications based on the query context.

Common preparation patterns:

# Built-in preparations
prepare build(sort: [created_at: :desc])
prepare build(filter: [active: true])

# Conditional preparations with where clauses
prepare build(filter: [visible: true]) do
  where argument_equals(:include_hidden, false)
end

# only_when_valid? - skip preparation if prior validations failed
prepare expensive_preparation() do
  only_when_valid? true
end

# Action-specific vs global preparations
actions do
  read :recent do
    prepare build(sort: [created_at: :desc], limit: 10)
  end
end

preparations do
  prepare build(filter: [deleted: false]), on: [:read, :update]
end

Using Changes

Changes allow you to modify the changeset before it gets processed by an action. Unlike validations, changes can manipulate attribute values, add attributes, or perform other data transformations.

Common change patterns:

# Built-in changes with conditions
change set_attribute(:status, "pending")
change relate_actor(:creator) do
  where present(:actor)
end
change atomic_update(:counter, expr(^counter + 1))

# Action-specific vs global changes
actions do
  create :sign_up do
    change set_attribute(:joined_at, expr(now()))  # Only for this action
  end
end

changes do
  change set_attribute(:updated_at, expr(now())), on: :update  # Multiple actions
  change manage_relationship(:items, type: :append), on: [:create, :update]
end
  • Create custom change modules for reusable transformation logic:

    defmodule MyApp.Changes.SlugifyTitle do
      use Ash.Resource.Change
    
      def change(changeset, _opts, _context) do
        title = Ash.Changeset.get_attribute(changeset, :title)
    
        if title do
          slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-")
          Ash.Changeset.change_attribute(changeset, :slug, slug)
        else
          changeset
        end
      end
    end
    
    # Usage in resource:
    change {MyApp.Changes.SlugifyTitle, []}
  • Create a change module with lifecycle hooks to handle complex multi-step operations:

    defmodule MyApp.Changes.ProcessOrder do
      use Ash.Resource.Change
    
      def change(changeset, _opts, context) do
        changeset
        |> Ash.Changeset.before_transaction(fn changeset ->
          # Runs before the transaction starts
          # Use for external API calls, logging, etc.
          MyApp.ExternalService.reserve_inventory(changeset, scope: context)
          changeset
        end)
        |> Ash.Changeset.before_action(fn changeset ->
          # Runs inside the transaction before the main action
          # Use for related database changes in the same transaction
          Ash.Changeset.change_attribute(changeset, :processed_at, DateTime.utc_now())
        end)
        |> Ash.Changeset.after_action(fn changeset, result ->
          # Runs inside the transaction after the main action, only on success
          # Use for related database changes that depend on the result
          MyApp.Inventory.update_stock_levels(result, scope: context)
          {changeset, result}
        end)
        |> Ash.Changeset.after_transaction(fn changeset,
          {:ok, result} ->
            # Runs after the transaction completes (success or failure)
            # Use for notifications, external systems, etc.
            MyApp.Mailer.send_order_confirmation(result, scope: context)
            {changeset, result}
    
          {:error, error} ->
            # Runs after the transaction completes (success or failure)
            # Use for notifications, external systems, etc.
            MyApp.Mailer.send_order_issue_notice(result, scope: context)
            {:error, error}
        end)
      end
    end
    
    # Usage in resource:
    change {MyApp.Changes.ProcessOrder, []}

Atomic Changes

Atomic changes execute directly in the database as part of the update query, without requiring the record to be loaded first. This provides better performance and correct behavior under concurrent updates.

Why atomic matters:

  • Avoids race conditions (e.g., incrementing a counter)
  • Better performance (no round-trip to load the record)
  • Required for bulk operations to work efficiently

Built-in atomic changes:

# Increment a counter atomically
change atomic_update(:view_count, expr(view_count + 1))

# Set a value using an expression
change set_attribute(:updated_at, expr(now()))

Making custom changes atomic: Implement the atomic/3 callback to support atomic execution:

defmodule MyApp.Changes.IncrementVersion do
  use Ash.Resource.Change

  @impl true
  def change(changeset, _opts, _context) do
    # Fallback for non-atomic execution
    current = Ash.Changeset.get_attribute(changeset, :version) || 0
    Ash.Changeset.change_attribute(changeset, :version, current + 1)
  end

  @impl true
  def atomic(_changeset, _opts, _context) do
    # Atomic implementation - runs in the database
    {:atomic, %{version: expr(coalesce(version, 0) + 1)}}
  end
end

Using require_atomic? false

By default, update and destroy actions require all changes and validations to support atomic execution. If they don't, the action will raise an error.

IMPORTANT: When you see require_atomic? false on an action, carefully consider whether it is truly necessary. This option should be used sparingly.

When require_atomic? false is needed:

  • The action has before_action or around_action hooks that need to read or modify the record
  • A change reads the current record state (e.g., Ash.Changeset.get_data/2) and cannot be rewritten atomically
  • Complex validations that cannot be expressed as database expressions

When require_atomic? false is NOT needed:

  • Simple attribute transformations (these can usually be made atomic)
  • Setting timestamps or default values (use expr(now()) instead)
  • Incrementing counters (use atomic_update/2)
  • After-action hooks (these don't prevent atomic execution)
  • After-transaction hooks (these don't prevent atomic execution)
actions do
  update :update do
    # AVOID unless truly necessary
    require_atomic? false
  end

  update :increment_views do
    # GOOD - fully atomic, no need to disable
    change atomic_update(:view_count, expr(view_count + 1))
  end
end

If you find yourself adding require_atomic? false, first check if your changes and validations can be rewritten with atomic/3 callbacks. Only disable atomic requirements when the action genuinely needs to read or manipulate the record in hooks.

Custom Modules vs. Anonymous Functions

Prefer to put code in its own module and refer to that in changes, preparations, validations etc.

For example, prefer this:

defmodule MyApp.MyDomain.MyResource.Changes.SlugifyName do
  use Ash.Resource.Change

  def change(changeset, _, _) do
    Ash.Changeset.before_action(changeset, fn changeset, _ ->
      slug = MyApp.Slug.get()
      Ash.Changeset.force_change_attribute(changeset, :slug, slug)
    end)
  end
end

change MyApp.MyDomain.MyResource.Changes.SlugifyName

Action Types

  • Read: For retrieving records
  • Create: For creating records
  • Update: For changing records
  • Destroy: For removing records
  • Generic: For custom operations that don't fit the other types

ash:aggregates usage

Aggregates

Aggregates allow you to retrieve summary information over groups of related data, like counts, sums, or averages. Define aggregates in the aggregates block of a resource.

Aggregates can work over relationships or directly over unrelated resources:

aggregates do
  # Related aggregates - use relationship path
  count :published_post_count, :posts do
    filter expr(published == true)
  end

  sum :total_sales, :orders, :amount

  exists :is_admin, :roles do
    filter expr(name == "admin")
  end

  # Unrelated aggregates - use resource module directly
  count :matching_profiles_count, Profile do
    filter expr(name == parent(name))
  end
  
  sum :total_report_score, Report, :score do
    filter expr(author_name == parent(name))
  end
  
  exists :has_reports, Report do
    filter expr(author_name == parent(name))
  end
end

For unrelated aggregates, use parent/1 to reference fields from the source resource.

Aggregate Types

  • count: Counts related items meeting criteria
  • sum: Sums a field across related items
  • exists: Returns boolean indicating if matching related items exist (also supports unrelated resources)
  • first: Gets the first related value matching criteria
  • list: Lists the related values for a specific field
  • max: Gets the maximum value of a field
  • min: Gets the minimum value of a field
  • avg: Gets the average value of a field

Using Aggregates

# Using code interface options (preferred)
users = MyDomain.list_users!(
  load: [:published_post_count, :total_sales],
  query: [
    filter: [published_post_count: [greater_than: 5]],
    sort: [published_post_count: :desc]
  ]
)

# Manual query building (for complex cases)
User |> Ash.Query.filter(published_post_count > 5) |> Ash.read!()

# Loading on existing records
Ash.load!(users, :published_post_count)

Join Filters

For complex aggregates involving multiple relationships, use join filters:

aggregates do
  sum :redeemed_deal_amount, [:redeems, :deal], :amount do
    # Filter on the aggregate as a whole
    filter expr(redeems.redeemed == true)

    # Apply filters to specific relationship steps
    join_filter :redeems, expr(redeemed == true)
    join_filter [:redeems, :deal], expr(active == parent(require_active))
  end
end

Inline Aggregates

Use aggregates inline within expressions:

# Related inline aggregates
calculate :grade_percentage, :decimal, expr(
  count(answers, query: [filter: expr(correct == true)]) * 100 /
  count(answers)
)

# Unrelated inline aggregates
calculate :profile_count, :integer, expr(
  count(Profile, filter: expr(name == parent(name)))
)

calculate :stats, :map, expr(%{
  profiles: count(Profile, filter: expr(active == true)),
  reports: count(Report, filter: expr(author_name == parent(name))),
  has_active_profile: exists(Profile, active == true and name == parent(name))
})

ash:authorization usage

Authorization

  • When performing administrative actions, you can bypass authorization with authorize?: false
  • To run actions as a particular user, look that user up and pass it as the actor option
  • Always set the actor on the query/changeset/input, not when calling the action
  • Use policies to define authorization rules
# Good
Post
|> Ash.Query.for_read(:read, %{}, actor: current_user)
|> Ash.read!()

# BAD, DO NOT DO THIS
Post
|> Ash.Query.for_read(:read, %{})
|> Ash.read!(actor: current_user)

Policies

To use policies, add the Ash.Policy.Authorizer to your resource:

defmodule MyApp.Post do
  use Ash.Resource,
    domain: MyApp.Blog,
    authorizers: [Ash.Policy.Authorizer]

  # Rest of resource definition...
end

Policy Basics

Policies determine what actions on a resource are permitted for a given actor. Define policies in the policies block:

policies do
  # A simple policy that applies to all read actions
  policy action_type(:read) do
    # Authorize if record is public
    authorize_if expr(public == true)

    # Authorize if actor is the owner
    authorize_if relates_to_actor_via(:owner)
  end

  # A policy for create actions
  policy action_type(:create) do
    # Only allow active users to create records
    forbid_unless actor_attribute_equals(:active, true)

    # Ensure the record being created relates to the actor
    authorize_if relating_to_actor(:owner)
  end
end

Policy Evaluation Flow

Policies evaluate from top to bottom with the following logic:

  1. All policies that apply to an action must pass for the action to be allowed
  2. Within each policy, checks evaluate from top to bottom
  3. The first check that produces a decision determines the policy result
  4. If no check produces a decision, the policy defaults to forbidden

IMPORTANT: Policy Check Logic

the first check that yields a result determines the policy outcome

# WRONG - This is OR logic, not AND logic!
policy action_type(:update) do
  authorize_if actor_attribute_equals(:admin?, true)    # If this passes, policy passes
  authorize_if relates_to_actor_via(:owner)           # Only checked if first fails
end

To require BOTH conditions in that example, you would use forbid_unless for the first condition:

# CORRECT - This requires BOTH conditions
policy action_type(:update) do
  forbid_unless actor_attribute_equals(:admin?, true)  # Must be admin
  authorize_if relates_to_actor_via(:owner)           # AND must be owner
end

Alternative patterns for AND logic:

  • Use multiple separate policies (each must pass independently)
  • Use a single complex expression with expr(condition1 and condition2)
  • Use forbid_unless for required conditions, then authorize_if for the final check

Bypass Policies

Use bypass policies to allow certain actors to bypass other policy restrictions. This should be used almost exclusively for admin bypasses.

policies do
  # Bypass policy for admins - if this passes, other policies don't need to pass
  bypass actor_attribute_equals(:admin, true) do
    authorize_if always()
  end

  # Regular policies follow...
  policy action_type(:read) do
    # ...
  end
end

Field Policies

Field policies control access to specific fields (attributes, calculations, aggregates):

field_policies do
  # Only supervisors can see the salary field
  field_policy :salary do
    authorize_if actor_attribute_equals(:role, :supervisor)
  end

  # Allow access to all other fields
  field_policy :* do
    authorize_if always()
  end
end

Policy Checks

There are two main types of checks used in policies:

  1. Simple checks - Return true/false answers (e.g., "is the actor an admin?")
  2. Filter checks - Return filters to apply to data (e.g., "only show records owned by the actor")

You can use built-in checks or create custom ones:

# Built-in checks
authorize_if actor_attribute_equals(:role, :admin)
authorize_if relates_to_actor_via(:owner)
authorize_if expr(public == true)

# Custom check module
authorize_if MyApp.Checks.ActorHasPermission

Custom Policy Checks

Create custom checks by implementing Ash.Policy.SimpleCheck or Ash.Policy.FilterCheck:

# Simple check - returns true/false
defmodule MyApp.Checks.ActorHasRole do
  use Ash.Policy.SimpleCheck

  def match?(%{role: actor_role}, _context, opts) do
    actor_role == (opts[:role] || :admin)
  end
  def match?(_, _, _), do: false
end

# Filter check - returns query filter
defmodule MyApp.Checks.VisibleToUserLevel do
  use Ash.Policy.FilterCheck

  def filter(actor, _authorizer, _opts) do
    expr(visibility_level <= ^actor.user_level)
  end
end

# Usage
policy action_type(:read) do
  authorize_if {MyApp.Checks.ActorHasRole, role: :manager}
  authorize_if MyApp.Checks.VisibleToUserLevel
end

ash:calculations usage

Calculations

Calculations allow you to define derived values based on a resource's attributes or related data. Define calculations in the calculations block of a resource:

calculations do
  # Simple expression calculation
  calculate :full_name, :string, expr(first_name <> " " <> last_name)

  # Expression with conditions
  calculate :status_label, :string, expr(
    cond do
      status == :active -> "Active"
      status == :pending -> "Pending Review"
      true -> "Inactive"
    end
  )

  # Using module calculations for more complex logic
  calculate :risk_score, :integer, {MyApp.Calculations.RiskScore, min: 0, max: 100}
end

Expression Calculations

Expression calculations use Ash expressions and can be pushed down to the data layer when possible:

calculations do
  # Simple string concatenation
  calculate :full_name, :string, expr(first_name <> " " <> last_name)

  # Math operations
  calculate :total_with_tax, :decimal, expr(amount * (1 + tax_rate))

  # Date manipulation
  calculate :days_since_created, :integer, expr(
    date_diff(^now(), inserted_at, :day)
  )
end

Expressions

In order to use expressions outside of resources, changes, preparations etc. you will need to use Ash.Expr.

It provides both expr/1 and template helpers like actor/1 and arg/1.

For example:

import Ash.Expr

Author
|> Ash.Query.aggregate(:count_of_my_favorited_posts, :count, [:posts], query: [
  filter: expr(favorited_by(user_id: ^actor(:id)))
])

See the expressions guide for more information on what is available in expresisons and how to use them.

Module Calculations

For complex calculations, create a module that implements Ash.Resource.Calculation:

defmodule MyApp.Calculations.FullName do
  use Ash.Resource.Calculation

  # Validate and transform options
  @impl true
  def init(opts) do
    {:ok, Map.put_new(opts, :separator, " ")}
  end

  # Specify what data needs to be loaded
  @impl true
  def load(_query, _opts, _context) do
    [:first_name, :last_name]
  end

  # Implement the calculation logic
  @impl true
  def calculate(records, opts, _context) do
    Enum.map(records, fn record ->
      [record.first_name, record.last_name]
      |> Enum.reject(&is_nil/1)
      |> Enum.join(opts.separator)
    end)
  end
end

# Usage in a resource
calculations do
  calculate :full_name, :string, {MyApp.Calculations.FullName, separator: ", "}
end

Calculations with Arguments

You can define calculations that accept arguments:

calculations do
  calculate :full_name, :string, expr(first_name <> ^arg(:separator) <> last_name) do
    argument :separator, :string do
      allow_nil? false
      default " "
      constraints [allow_empty?: true, trim?: false]
    end
  end
end

Using Calculations

# Using code interface options (preferred)
users = MyDomain.list_users!(load: [full_name: [separator: ", "]])

# Filtering and sorting
users = MyDomain.list_users!(
  query: [
    filter: [full_name: [separator: " ", value: "John Doe"]],
    sort: [full_name: {[separator: " "], :asc}]
  ]
)

# Manual query building (for complex cases)
User |> Ash.Query.load(full_name: [separator: ", "]) |> Ash.read!()

# Loading on existing records
Ash.load!(users, :full_name)

Code Interface for Calculations

Define calculation functions on your domain for standalone use:

# In your domain
resource User do
  define_calculation :full_name, args: [:first_name, :last_name, {:optional, :separator}]
end

# Then call it directly
MyDomain.full_name("John", "Doe", ", ")  # Returns "John, Doe"

ash:code_interfaces usage

Code Interfaces

Domains and Resources can define code interfaces. Prefer writing code interfaces instead of regular elixir functions.

Use code interfaces on domains to define the contract for calling into Ash resources. See the Code interface guide for more.

Define code interfaces on the domain, like this:

resource ResourceName do
  define :fun_name, action: :action_name
end

For more complex interfaces with custom transformations:

define :custom_action do
  action :action_name
  args [:arg1, :arg2]

  custom_input :arg1, MyType do
    transform do
      to :target_field
      using &MyModule.transform_function/1
    end
  end
end

Prefer using the primary read action for "get" style code interfaces, and using get_by when the field you are looking up by is the primary key or has an identity on the resource.

resource ResourceName do
  define :get_thing, action: :read, get_by: [:id]
end

Avoid direct Ash calls in web modules - Don't use Ash.get!/2 and Ash.load!/2 directly in LiveViews/Controllers, similar to avoiding Repo.get/2 outside context modules:

You can also pass additional inputs in to code interfaces before the options:

resource ResourceName do
  define :create, action: :action_name, args: [:field1]
end
Domain.create!(field1_value, %{field2: field2_value}, actor: current_user)

You should generally prefer using this map of extra inputs over defining optional arguments.

# BAD - in LiveView/Controller
group = MyApp.Resource |> Ash.get!(id) |> Ash.load!(rel: [:nested])

# GOOD - use code interface with get_by
resource DashboardGroup do
  define :get_dashboard_group_by_id, action: :read, get_by: [:id]
end

# Then call:
MyApp.Domain.get_dashboard_group_by_id!(id, load: [rel: [:nested]])

Code interface options - Prefer passing options directly to code interface functions rather than building queries manually:

# PREFERRED - Use the query option for filter, sort, limit, etc.
# the query option is passed to `Ash.Query.build/2`
posts = MyApp.Blog.list_posts!(
  query: [
    filter: [status: :published],
    sort: [published_at: :desc],
    limit: 10
  ],
  load: [author: :profile, comments: [:author]]
)

# All query-related options go in the query parameter
users = MyApp.Accounts.list_users!(
  query: [filter: [active: true], sort: [created_at: :desc]],
  load: [:profile]
)

# AVOID - Verbose manual query building
query = MyApp.Post |> Ash.Query.filter(...) |> Ash.Query.load(...)
posts = Ash.read!(query)

Supported options: load:, query: (which accepts filter:, sort:, limit:, offset:, etc.), page:, stream?:

Using Scopes in LiveViews - When using Ash.Scope, the scope will typically be assigned to scope in LiveViews and used like so:

# In your LiveView
MyApp.Blog.create_post!("new post", scope: socket.assigns.scope)

Inside action hooks and callbacks, use the provided context parameter as your scope instead:

|> Ash.Changeset.before_transaction(fn changeset, context ->
  MyApp.ExternalService.reserve_inventory(changeset, scope: context)
  changeset
end)

Predicate interfaces (?-suffixed names)

When a code interface name ends in ? (e.g. define :user_exists?, action: :user_exists?, args: [:email]), Ash treats it as a predicate interface and generates two action functions (same pattern as calculation interfaces):

  • user_exists/… (with ? stripped from the name) returns {:ok, result} or {:error, reason} (from :action)
  • user_exists?/… returns the unwrapped result (typically bare true/false); raises on failure (from :action!)
  • No user_exists!/… or user_exists?!/…

The action's run callback still returns {:ok, result} or {:error, reason}; the code interface unwraps it for the ?-suffixed function.

Authorization helpers for predicate interfaces:

  • can_user_exists/… returns {:ok, true/false} or {:error, reason}
  • can_user_exists?/… returns a bare boolean
  • No can_user_exists??/… is generated

Both action functions come from the default functions: list (:action and :action!). Omit :action to skip the tuple form; omit :action! to skip the predicate form. See the Code interface guide for examples.

Authorization Functions

For predicate interfaces (names ending in ?), see Predicate interfaces above for naming rules.

For each action defined in a code interface, Ash automatically generates corresponding authorization check functions:

  • can_action_name?(actor, params \\ %{}, opts \\ []) - Returns true/false for authorization checks
  • can_action_name(actor, params \\ %{}, opts \\ []) - Returns {:ok, true/false} or {:error, reason}

Example usage:

# Check if user can create a post
if MyApp.Blog.can_create_post?(current_user) do
  # Show create button
end

# Check if user can update a specific post
if MyApp.Blog.can_update_post?(current_user, post) do
  # Show edit button
end

# Check if user can destroy a specific comment
if MyApp.Blog.can_destroy_comment?(current_user, comment) do
  # Show delete button
end

These functions are particularly useful for conditional rendering of UI elements based on user permissions.

ash:code_structure usage

Code Structure & Organization

  • Organize code around domains and resources
  • Each resource should be focused and well-named
  • Create domain-specific actions rather than generic CRUD operations
  • Put business logic inside actions rather than in external modules
  • Use resources to model your domain entities

ash:data_layers usage

Data Layers

Data layers determine how resources are stored and retrieved. Examples of data layers:

  • Postgres: For storing resources in PostgreSQL (via AshPostgres)
  • ETS: For in-memory storage (Ash.DataLayer.Ets)
  • Mnesia: For distributed storage (Ash.DataLayer.Mnesia)
  • Embedded: For resources embedded in other resources (data_layer: :embedded) (typically JSON under the hood)
  • Ash.DataLayer.Simple: For resources that aren't persisted at all. Leave off the data layer, as this is the default.

Specify a data layer when defining a resource:

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

  postgres do
    table "posts"
    repo MyApp.Repo
  end

  # ... attributes, relationships, etc.
end

For embedded resources:

defmodule MyApp.Address do
  use Ash.Resource,
    data_layer: :embedded

  attributes do
    attribute :street, :string
    attribute :city, :string
    attribute :state, :string
    attribute :zip, :string
  end
end

Each data layer has its own configuration options and capabilities. Refer to the rules & documentation of the specific data layer package for more details.

ash:exist_expressions usage

Exists Expressions

Use exists/2 to check for the existence of records, either through relationships or unrelated resources:

Related Exists

# Check if user has any admin roles
Ash.Query.filter(User, exists(roles, name == "admin"))

# Check if post has comments with high scores
Ash.Query.filter(Post, exists(comments, score > 50))

Unrelated Exists

# Check if any profile exists with the same name
Ash.Query.filter(User, exists(Profile, name == parent(name)))

# Check if user has any reports
Ash.Query.filter(User, exists(Report, author_name == parent(name)))

# Complex existence checks
Ash.Query.filter(User, 
  active == true and 
  exists(Profile, active == true and name == parent(name))
)

Unrelated exists expressions automatically apply authorization using the target resource's primary read action. Use parent/1 to reference fields from the source resource.

ash:generating_code usage

Generating Code

Use mix ash.gen.* tasks as a basis for code generation when possible. Check the task docs with mix help <task>. Be sure to use --yes to bypass confirmation prompts. Use --yes --dry-run to preview the changes.

ash:migrations usage

Migrations and Schema Changes

After creating or modifying Ash code, run mix ash.codegen <short_name_describing_changes> to ensure any required additional changes are made (like migrations are generated). The name of the migration should be lower_snake_case. In a longer running dev session it's usually better to use mix ash.codegen --dev as you go and at the end run the final codegen with a sensible name describing all the changes made in the session.

ash:query_filter usage

Ash.Query.filter is a macro

Important: You must require Ash.Query if you want to use Ash.Query.filter/2, as it is a macro.

If you see errors like the following:

Ash.Query.filter(MyResource, id == ^id)
error: misplaced operator ^id

The pin operator ^ is supported only inside matches or inside custom macros...
iex(3)> Ash.Query.filter(MyResource, something == true)
error: undefined variable "something"
└─ iex:3

You are very likely missing a require Ash.Query

Common Query Operations

  • Filter: Ash.Query.filter(query, field == value)
  • Sort: Ash.Query.sort(query, field: :asc)
  • Load relationships: Ash.Query.load(query, [:author, :comments])
  • Limit: Ash.Query.limit(query, 10)
  • Offset: Ash.Query.offset(query, 20)

ash:querying_data usage

Querying Data

Use Ash.Query to build queries for reading data from your resources. The query module provides a declarative way to filter, sort, and load data.

ash:relationships usage

Relationships

Relationships describe connections between resources and are a core component of Ash. Define relationships in the relationships block of a resource.

Best Practices for Relationships

  • Be descriptive with relationship names (e.g., use :authored_posts instead of just :posts)
  • Configure foreign key constraints in your data layer if they have them (see references in AshPostgres)
  • Always choose the appropriate relationship type based on your domain model

Relationship Types

  • For Polymorphic relationships, you can model them using Ash.Type.Union; see the “Polymorphic Relationships” guide for more information.
relationships do
  # belongs_to - adds foreign key to source resource
  belongs_to :owner, MyApp.User do
    allow_nil? false
    attribute_type :integer  # defaults to :uuid
  end

  # has_one - foreign key on destination resource
  has_one :profile, MyApp.Profile

  # has_many - foreign key on destination resource, returns list
  has_many :posts, MyApp.Post do
    filter expr(published == true)
    sort published_at: :desc
  end

  # many_to_many - requires join resource
  many_to_many :tags, MyApp.Tag do
    through MyApp.PostTag
    source_attribute_on_join_resource :post_id
    destination_attribute_on_join_resource :tag_id
  end
end

The join resource must be defined separately:

defmodule MyApp.PostTag do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer

  attributes do
    uuid_primary_key :id
    # Add additional attributes if you need metadata on the relationship
    attribute :added_at, :utc_datetime_usec do
      default &DateTime.utc_now/0
    end
  end

  relationships do
    belongs_to :post, MyApp.Post, primary_key?: true, allow_nil?: false
    belongs_to :tag, MyApp.Tag, primary_key?: true, allow_nil?: false
  end

  actions do
    defaults [:read, :destroy, create: :*, update: :*]
  end
end

Loading Relationships

# Using code interface options (preferred)
post = MyDomain.get_post!(id, load: [:author, comments: [:author]])

# Complex loading with filters
posts = MyDomain.list_posts!(
  query: [load: [comments: [filter: [is_approved: true], limit: 5]]]
)

# Manual query building (for complex cases)
MyApp.Post
|> Ash.Query.load(comments: MyApp.Comment |> Ash.Query.filter(is_approved == true))
|> Ash.read!()

# Loading on existing records
Ash.load!(post, :author)

Prefer to use the strict? option when loading to only load necessary fields on related data.

MyApp.Post
|> Ash.Query.load([comments: [:title]], strict?: true)

Managing Relationships

There are two primary ways to manage relationships in Ash:

1. Using change manage_relationship/2-3 in Actions

Use this when input comes from action arguments:

actions do
  update :update do
    # Define argument for the related data
    argument :comments, {:array, :map} do
      allow_nil? false
    end

    argument :new_tags, {:array, :map}

    # Link argument to relationship management
    change manage_relationship(:comments, type: :append)

    # For different argument and relationship names
    change manage_relationship(:new_tags, :tags, type: :append)
  end
end

2. Using Ash.Changeset.manage_relationship/3-4 in Custom Changes

Use this when building values programmatically:

defmodule MyApp.Changes.AssignTeamMembers do
  use Ash.Resource.Change

  def change(changeset, _opts, context) do
    members = determine_team_members(changeset, context.actor)

    Ash.Changeset.manage_relationship(
      changeset,
      :members,
      members,
      type: :append_and_remove
    )
  end
end

Quick Reference - Management Types

  • :append - Add new related records, ignore existing
  • :append_and_remove - Add new related records, remove missing
  • :remove - Remove specified related records
  • :direct_control - Full CRUD control (create/update/destroy)
  • :create - Only create new records

Quick Reference - Common Options

  • on_lookup: :relate - Look up and relate existing records
  • on_no_match: :create - Create if no match found
  • on_match: :update - Update existing matches
  • on_missing: :destroy - Delete records not in input
  • value_is_key: :name - Use field as key for simple values

For comprehensive documentation, see the Managing Relationships section.

Examples

Creating a post with tags:

MyDomain.create_post!(%{
  title: "New Post",
  body: "Content here...",
  tags: [%{name: "elixir"}, %{name: "ash"}]  # Creates new tags
})

# Updating a post to replace its tags
MyDomain.update_post!(post, %{
  tags: [tag1.id, tag2.id]  # Replaces tags with existing ones by ID
})

ash:signals usage

Signals

Signals let one resource react to what happens in another without either depending on the other. A signal module declares typed signals, resources emit them from their actions, and other resources listen to them with their own actions.

When to Use Signals

  • Use a signal when other resources should react to an action, and the resource taking the action shouldn't know about them. For example, awarding loyalty points when an order is placed. Prefer this to calling those resources from an after_action hook.
  • Don't use a signal when the action needs something back from the other resource to finish. Call that resource directly, for example through its code interface. Signals are fire and forget: the emitter gets nothing back from its listeners.
  • Use a notifier, not a signal, for "at most once" side effects after the transaction commits, like publishing to PubSub.
  • With the default delivery, listeners of :before_action and :after_action signals run as nested actions inside the emitter's transaction. A failing listener fails the emitting action and rolls it back.

Declaring Signals

defmodule MyApp.Shop.Signals do
  use Ash.Signals

  signals do
    signal :order_placed do
      argument :order_id, :uuid, allow_nil?: false
      argument :customer_id, :uuid, allow_nil?: false
    end
  end
end
  • Declare signals in a signal module, not in a resource. Emitters and listeners both depend on the signal module, and never on each other.
  • Each signal defines a struct, like MyApp.Shop.Signals.OrderPlaced, which is what listeners receive.
  • Every signal has a signal_id field, a UUIDv7 generated when it is emitted. Don't declare an argument named signal_id.
  • A signal's phase is when it is emitted: :before_action, :after_action or :after_transaction. It defaults to :after_action, which you should prefer. :before_action and :after_transaction signals make update and destroy actions non-atomic, and in bulk actions, :after_transaction signals are emitted once per record instead of once per batch.

Emitting Signals

create :place do
  accept [:customer_id, :total]

  emit MyApp.Shop.Signals, :order_placed, fields: [order_id: :id]
end
  • Emit signals with emit in create, update, destroy and generic actions. An action can only emit the signals it declares with emit.
  • For a signal emitted in another phase than :after_action, pass the phase, as in emit MyApp.Shop.Signals, :order_shipped, phase: :after_transaction. It must match the phase the signal declares.
  • Signal fields are taken from the record's attributes of the same name. In generic actions, they are taken from the action's arguments, and fields takes them from the action's result.
  • Use fields: [signal_field: :attribute] to take signal fields from other attributes, and values: [signal_field: value] to set them to values or templates like actor(:id), arg(:reason) or previous(:status).
  • Use where to emit only in some cases: emit MyApp.Shop.Signals, :order_status_changed, where: [changing(:status)].
  • emit runs in its place among the action's changes, and is checked at compile time.
  • An update or destroy that emits a :before_action or :after_transaction signal, or uses previous/1, can't be atomic and needs require_atomic? false. :after_action signals don't need it.
  • fields only reads attributes. When the signal needs a calculation, an aggregate, related data or any other transformation, emit it from your own code: declare it with emit MyApp.Shop.Signals, :order_placed, dynamic?: true, and call Ash.Signals.emit/5 from a change during the signal's phase, or Ash.Signals.emit_many/5 from after_batch/3. Emitting a signal the action doesn't declare raises an error.

Listening to Signals

signals_in do
  from MyApp.Shop.Signals do
    on :order_placed, :award_points
    on :order_placed, :open_task, action: :create, inputs: [reference: :order_id]
    on :order_placed, :mark_ordered, read_action: :active, get_by: [id: :customer_id]
  end
end
  • Each on has a name, unique within the resource, and runs the action with that name unless action is set.

  • A generic action receives the signal struct as its signal argument. With batch?: true, it is called once with every signal emitted together, as its signals argument.

    action :award_points do
      argument :signal, MyApp.Shop.Signals.OrderPlaced, allow_nil?: false
      run MyApp.Loyalty.AwardPoints
    end
  • A create action creates a record for each signal. Its inputs are taken from the signal fields of the same name, except signal_id. Map others with inputs: [input: :signal_field].

  • An update or destroy action runs on the records selected for each signal by a read action, the primary read action unless read_action is set. Select them with get_by: [field: :signal_field], args: [read_argument: :signal_field], or both. If no records are selected, nothing happens. To react to only some signals, filter in the read action.

  • Create, update and destroy listeners handle the signals emitted together as bulk actions.

  • A listener's delivery decides how signals reach it. The default, Ash.Signals.Delivery.Inline, runs it immediately, during the signal's phase. Other deliveries come from extensions and implement Ash.Signals.Delivery, for example to run the listener in a job: on :order_placed, :send_receipt, delivery: {SomeJobDelivery, queue: :mailers}. Each listener chooses its own delivery, and the emitter doesn't change.

  • Run a listener in a job when it does slow work or calls external services. Keep it inline when its changes must be committed with the emitter's, or when its failure should fail the emitting action.

  • Listeners run with the emitting action's actor, tenant and authorize?. The listener resource's policies are checked against that actor, so write them with the emitting actor in mind.

  • A listener's return value is ignored. Don't design listeners to return something the emitter needs.

Setup

  • Add the :ash_signals compiler to the project: compilers: [:ash_signals] ++ Mix.compilers(). Without it, listeners are searched for every time a signal is emitted, which is much slower.
  • With the Phoenix code reloader, add :ash_signals to the endpoint's reloadable_compilers too.
  • Listeners defined after compilation, like resources defined inside tests, aren't found when signals are consolidated. If tests define listeners, set ash: [consolidate_signals: Mix.env() != :test] in the project config.

Testing

  • Use Ash.Signals.Test. assert_emits_signal/4 and refute_emits_signal/4 run a function, check the signals it emitted, and return its result.
  • assert_signal_emitted/4 and refute_signal_emitted/4 check the signals emitted so far, after setup :capture_signals.
import Ash.Signals.Test

test "placing an order emits order_placed" do
  order =
    assert_emits_signal MyApp.Shop.Signals, :order_placed, %{order_id: order_id}, fn ->
      MyApp.Shop.place_order!(customer, 100)
    end

  assert order.id == order_id
end

ash:temporal usage

Temporal Resources

Temporal resources are experimental. A temporal resource keeps every version of a record, and each version is valid for a half-open period [from, to). Every read and write happens "as of" one instant. Read the "Temporal Resources" guide before working with them.

Supported Data Layers

  • Only AshPostgres.DataLayer on PostgreSQL 18+ and Ash.DataLayer.Ets support temporal resources. On any other data layer, a resource that declares temporal won't compile.
  • Manual actions skip temporal handling entirely. If you write one, you manage periods yourself.

Defining a Temporal Resource

temporal do
  strategy :context
  attribute :valid_at
  # optional: wall-clock time each version was written
  recorded_at :recorded_at
end
  • You don't have to declare the period attribute. If you leave it out, temporal adds an Ash.Type.Range over :utc_datetime_usec with [from, to) bounds.
  • If you do declare it, it must be an Ash.Type.Range over :utc_datetime_usec, :utc_datetime or :datetime, with allow_nil? false, an inclusive lower bound and an exclusive upper bound. Ranges over dates, naive datetimes or other types are refused at compile time.
  • Never put the period attribute in an action's accept, and never set it in a change. Its value always comes from the write's as_of.
  • Use mix ash.codegen for migrations. It generates the PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) and installs btree_gist. Don't write these by hand.

Reading

  • To read at a point in time, use Ash.Query.as_of/2 or pass as_of: anywhere tenant: is accepted (Ash.read, Ash.get, code interfaces, for_read, and so on).
  • If you leave out as_of, the read happens now and returns exactly one row per record. It never returns the full history.
  • Every read is a single instant. A range-valued as_of on a read raises Ash.Error.Query.AsOfNotAnInstant. Don't try to fetch several versions of a record in one query.
  • as_of propagates like tenant. Loaded relationships, calculations, aggregates and nested actions all run at the same instant, so you don't need to pass it to them again.
  • In expressions, now(), ago/2 and from_now/2 mean the query's as_of, not the wall clock. Write expr(expires_at > now()), not ^DateTime.utc_now(). Pinning the wall clock breaks time travel.
Ash.get!(MyApp.Subscription, 1, as_of: ~U[2026-01-15 00:00:00Z])

MyApp.Subscription
|> Ash.Query.filter(plan == "gold")
|> Ash.Query.as_of(~U[2026-01-15 00:00:00Z])
|> Ash.read!()

Writing

  • A write makes something true from as_of onward. If you don't pass as_of, it uses now. One instant is pinned for the whole write, and &DateTime.utc_now/0 defaults and set_attribute(..., &DateTime.utc_now/0) resolve to that same instant. Back-dated writes therefore get back-dated timestamps. Use recorded_at if you need the actual time a version was written.
  • Pass as_of as an action option (for_create(:create, input, as_of: ...), for_update(...), for_destroy(...)). Ash.Changeset.as_of/2 is applied too late for cascading destroys, manage_relationship, and identity pre-checks.
  • A create opens [as_of, ∞). You can't create a record if any later version of it already exists.
  • An update splits the version valid at as_of. The new values run from as_of to wherever that version ended. To schedule a future change, write an update with a future as_of, not a create.
  • A destroy closes the version valid at as_of down to [from, as_of). It doesn't delete history.
  • An update or destroy with no version valid at as_of (because the history ended, the record hasn't started yet, or as_of falls in a gap) fails as a stale record. To bring back a record whose history has ended, create it again rather than updating it.
  • On Postgres, concurrent writes to the same record are retried automatically, up to 25 conflicts, after which AshPostgres.Temporal.WriteConflict is raised. It's safe to retry. Under REPEATABLE READ/SERIALIZABLE you get a serialization failure instead, and retrying is up to you.

Changes, Validations and Preparations

  • Any change, validation or preparation that runs on a temporal resource must declare temporal_safe?/1 returning true. Otherwise the action raises Ash.Error.Framework.NotTemporalSafe. The callback defaults to false.
  • Only declare a module temporal safe if it never reads the wall clock (use now() in expressions or the subject's as_of instead), has no side effects that assume the present, and does all of its reads and nested actions through Ash so as_of propagates.
  • The built-in changes, validations and preparations are temporal safe. These aren't, because they wrap arbitrary functions: before_action, after_action, before_transaction, after_transaction, and anonymous-function changes, validations and preparations. Move that logic into a module that declares temporal_safe?/1.
  • For third-party modules that you've verified are safe but that don't declare the callback, add them to config :ash, :temporal_safe_modules, [...]. The list is read at compile time.
defmodule MyApp.Changes.Slugify do
  use Ash.Resource.Change

  @impl true
  def temporal_safe?(_opts), do: true

  @impl true
  def change(changeset, _opts, _context) do
    # ...
  end
end

Relationships and Identities

  • To make a belongs_to point at another temporal resource for the matching period, declare temporal_keys {:valid_at, :valid_at}. On Postgres this produces a PERIOD foreign key.
  • PERIOD foreign keys only support NO ACTION, so on_delete/on_update referential actions are rejected at compile time. Cascade in the application instead, for example with change cascade_destroy(:subscriptions).
  • Write identities as usual (identity :unique_email, [:email]). On a temporal resource they become "unique at every instant" (UNIQUE (... WITHOUT OVERLAPS)). Don't add the period attribute to an identity.

Authorization

  • Data and filter checks are evaluated as of the query's as_of. Actor attribute checks (actor_attribute_equals, ^actor(:field)) use the actor struct exactly as you loaded it. Ash does not re-fetch the actor at the query's instant.
  • Load the actor as of the same instant you query. Otherwise you're authorizing historical data with the actor's current attributes.
as_of = ~U[2026-01-15 00:00:00Z]
actor = Ash.get!(MyApp.User, user_id, as_of: as_of)

MyApp.Subscription
|> Ash.Query.as_of(as_of)
|> Ash.read!(actor: actor)

ash:testing usage

Testing

When testing resources:

  • Test your domain actions through the code interface
  • Use test utilities in Ash.Test
  • Test authorization policies work as expected using Ash.can?
  • Use authorize?: false in tests where authorization is not the focus
  • Write generators using Ash.Generator
  • Assert on emitted signals with Ash.Signals.Test
  • Prefer to use raising versions of functions whenever possible, as opposed to pattern matching

Preventing Deadlocks in Concurrent Tests

When running tests concurrently, using fixed values for identity attributes can cause deadlock errors. Multiple tests attempting to create records with the same unique values will conflict.

Use Globally Unique Values

Always use globally unique values for identity attributes in tests:

# BAD - Can cause deadlocks in concurrent tests
%{email: "test@example.com", username: "testuser"}

# GOOD - Use globally unique values
%{
  email: "test-#{System.unique_integer([:positive])}@example.com",
  username: "user_#{System.unique_integer([:positive])}",
  slug: "post-#{System.unique_integer([:positive])}"
}

Creating Reusable Test Generators

For better organization, create a generator module:

defmodule MyApp.TestGenerators do
  use Ash.Generator

  def user(opts \\ []) do
    changeset_generator(
      User,
      :create,
      defaults: [
        email: "user-#{System.unique_integer([:positive])}@example.com",
        username: "user_#{System.unique_integer([:positive])}"
      ],
      overrides: opts
    )
  end
end

# In your tests
test "concurrent user creation" do
  users = MyApp.TestGenerators.generate_many(user(), 10)
  # Each user has unique identity attributes
end

This applies to ANY field used in identity constraints, not just primary keys. Using globally unique values prevents frustrating intermittent test failures in CI environments.