A thin, behaviour-based action layer for application write operations. Enact standardizes the shape of every write:

load  authorize  cast  validate  resolve  execute  after_commit

The value is the uniform pipeline shape, the actor context, and the closed error taxonomy — not any novel validation or persistence machinery. Enact orchestrates; Ecto does the work: validation is Ecto changesets, input casting is Ecto embedded schemas, persistence is your existing schemas and changesets. No DSL, no parallel type system, no validation vocabulary.

Installation

def deps do
  [
    {:enact, "~> 0.1.0"}
  ]
end

Configure the default repo (or pass repo: per call):

config :enact, repo: MyApp.Repo

A complete action

defmodule MyApp.Projects.Actions.CreateProject do
  use Enact.Action

  alias MyApp.Accounts
  alias MyApp.Projects.Inputs.ProjectInput
  alias MyApp.Projects.Project

  @impl Enact.Action
  def input, do: ProjectInput

  @impl Enact.Action
  def authorize(ctx), do: MyApp.Policy.can?(ctx.actor, :create_project)

  @impl Enact.Action
  def resolvers do
    [owner: {:owner_id, &fetch_owner/2}]
  end

  @impl Enact.Action
  def execute(changeset, ctx) do
    updates =
      changeset
      |> Enact.updates(ctx)
      |> Map.put(:owner_id, ctx.assigns.owner.id)

    %Project{org_id: ctx.actor.org.id}
    |> Project.changeset(updates)
    |> ctx.repo.insert()
  end

  @impl Enact.Action
  def after_commit(project, _ctx) do
    MyApp.Analytics.track(:project_created, project)
  end

  # the context owns the trust-anchor-scoped query; the fetcher adapts
  # its result to the resolver contract
  defp fetch_owner(public_id, ctx) do
    case Accounts.get_org_user(ctx.actor, public_id) do
      nil -> :error
      user -> {:ok, user}
    end
  end
end

Application callers go through a context one-liner that forwards to Enact.run/3 (see the Phoenix guide). Action tests and IEx may call the runner directly:

case Projects.create_project(params, actor: conn.assigns.current_scope) do
  {:ok, project} -> ...
  {:error, %Enact.Error{type: :invalid, changeset: changeset}} -> ...
end

The actor is always explicit and required — actor: nil raises, and every write path answers "as whom?". Anonymous callers pass an explicit anonymous actor (see Enact.Actor), permitted only by actions declaring anonymous?: true.

Input schemas are just Ecto

Inputs are embedded-schema modules implementing the Enact.InputSchema behaviour — changeset/3 heads per mode, a fields/1 introspection manifest, and (for patch-mode use) a from_subject/1 projection that lets validate_required and cross-field get_field/2 rules work unmodified on PATCH:

defmodule MyApp.Projects.Inputs.ProjectInput do
  use Ecto.Schema
  use Enact.InputSchema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :name, :string
    field :slug, :string
    field :owner_id, :string
  end

  @all ~w(name slug owner_id)a
  @patch @all -- [:slug]
  # owner_id required, so the resolver always runs and execute can rely
  # on ctx.assigns.owner being present
  @required ~w(name slug owner_id)a

  @impl Enact.InputSchema
  def changeset(base, params, :create) do
    base
    |> cast_input(params, @all)
    |> validate_required(@required)
  end

  def changeset(base, params, :patch) do
    base
    |> cast_input(params, @patch)
    |> validate_required(@required)
  end

  @impl Enact.InputSchema
  def fields(:create), do: @all
  def fields(:patch), do: @patch

  @impl Enact.InputSchema
  def from_subject(project) do
    %__MODULE__{
      name: project.name,
      slug: project.slug,
      owner_id: MyApp.PublicIds.encode(:user, project.owner_id)
    }
  end
end

Enact.updates/2 extracts exactly the fields the caller provided, with their casted values — so PATCH semantics fall out: omitted keys are untouched, explicit null clears, arrays replace wholesale. Enact.Guardrails mechanically enforces the input-schema invariants (no field defaults, no primary keys, no associations) on first run and in CI.

Errors

Every failure is an %Enact.Error{} with one of five HTTP-shaped types: :invalid (422, carries the changeset), :forbidden (403), :not_found (404), :conflict (409), :internal (500). One renderer in your app handles all of them; actions never invent bespoke error atoms. Reference-resolution failures are field-level "not found" errors indistinguishable from validation failures — and cross-tenant probes are indistinguishable from nonexistent records.

Dry runs and confirmation

Enact.dry_run/3 runs everything up to (not including) execute and returns an %Enact.Preview{} — the exact updates map a real run would persist, the loaded subject, and a digest for confirmation flows:

{:ok, preview} = Projects.update_project_dry_run(params, actor: actor)
# show preview.updates to the user...
{:ok, project} =
  Projects.update_project(params, actor: actor, confirm_digest: preview.digest)

A digest mismatch returns :conflict — "the user confirmed this exact change to this record" is a mechanical guarantee.

Loading a subject

Enact.subject/3 loads the action's subject and authorizes the actor. No body, no write. Use it when the GET needs the record (edit, archive confirmation, create-under-parent):

{:ok, project} = Projects.update_project_subject(params, actor: actor)

Failures are :not_found or :forbidden. An action with no subject raises — use authorized/3 for a new form:

:ok = Projects.create_project_authorized(%{}, actor: actor)

Telemetry

The runner emits [:enact, :action, :run], [:enact, :action, :dry_run], [:enact, :action, :subject], and [:enact, :action, :authorized] events (plus matching :error events) with per-action, per-type metadata — observability and audit trails with zero action-author involvement.

Testing

Enact.Test ships assert_invalid/2, build_ctx/1, and errors_on/1 so host apps don't reinvent them.

Documentation

  • Usage Rules — the condensed do's and don'ts for writing actions; sync it into your agent instructions (CLAUDE.md / AGENTS.md) with usage_rules
  • Change Detection — how the validation base, presence-gated extraction, and PATCH fidelity actually work, and why force_changes:-style workarounds never appear
  • Phoenix Integration — actor/scope wiring, the reference FallbackController and error renderer, Inertia form posts, background jobs, telemetry
  • Recipes — worked examples: embedded data end-to-end, flattening embeds into columns, reading resolver assigns, MCP dry-run confirmation flows, empty-string-at-rest columns, partial updates on singular embeds
  • Testing Host Applications — copy-paste templates for the host-side test obligations (cross-tenant sweep, PATCH/create matrices, projection completeness, resolver coverage, guardrails in CI)
  • Design Specification — the authoritative design, including the rationale for every decision