Enact

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