A thin, behaviour-based action layer for application write operations.
Enact standardizes the shape of every write:
load → authorize → cast → validate → resolve → execute → after_commitActions implement Enact.Action; input casting and validation are plain
Ecto via Enact.InputSchema modules; results are {:ok, result} or
{:error, %Enact.Error{}} with a closed, HTTP-shaped error taxonomy.
Enact.run(CreateProject, params, actor: conn.assigns.current_scope)Options
:actor— required; opaque to Enact (only the action'sauthorize/1and fetchers read it).actor: nilalways raisesArgumentError— anonymous callers pass an explicit anonymous actor instead (seeEnact.Actor), permitted only by actions declaringanonymous?: true.:repo— the Ecto repo; defaults toconfig :enact, repo: MyApp.Repo.:assigns— optional map merged intoctx.assigns, the documented channel for request metadata (IP, session id) that isn't part of the actor. Resolver-stashed keys are merged after and win on collision. Never pass pre-loaded domain records or persistable fields here — fetching belongs inload_subject/2/resolvers/0; persistable values are params or stamped inexecute/2.
Unknown options raise ArgumentError — a misspelled option (an
assigns: typo, or worse, a confirm_digest: typo silently skipping
the confirmation check) must never be ignored.
Error normalization
load_subject/2returningnil→:not_foundauthorize/1returningfalse→:forbidden;{:error, reason}→:forbiddenwith that reason ({:error, :foo}becomes{:error, %Enact.Error{type: :forbidden, reason: :foo}})- post-validate invalid changeset →
:invalid(one response carries all cast + validation errors; there is no cast-stage fast-fail) %Ecto.Changeset{}error from execute (declared constraints) → promoted to:invalid- any other execute failure →
:internal
Telemetry
The runner emits (measurements %{duration: native_time}; metadata
%{action:, mode:, actor:}; error events additionally carry type:,
the Enact.Error type):
[:enact, :action, :run]/[:enact, :action, :run, :error][:enact, :action, :dry_run]/[:enact, :action, :dry_run, :error][:enact, :action, :subject]/[:enact, :action, :subject, :error][:enact, :action, :authorized]/[:enact, :action, :authorized, :error]— distinct events so form GETs, previews, and executions are never conflated
The run event fires after a successful commit but before
after_commit/2 runs, so a raising side effect cannot suppress the
audit record of a committed write.
Summary
Functions
Authorizes the actor to perform the action. No body, no write.
Runs the side-effect-free front of the pipeline — load, authorize, cast,
validate, resolve — then stops before any write, returning
{:ok, %Enact.Preview{}} or the identical error surface to run/3.
Builds the per-sub-key result-state view of a partial embed
(partial_embeds/1, see Enact.InputSchema) — the values the object
will hold after the write.
Whether the caller provided a key (or path) in the raw params — the reification of "what did the caller say?".
Runs an action through the full pipeline.
Loads the action's subject and authorizes the actor. No body, no write.
Extracts the updates map from a post-validation changeset: exactly the castable fields the caller provided, with their casted values.
Functions
@spec authorized(module(), map(), keyword()) :: :ok | {:error, Enact.Error.t()}
Authorizes the actor to perform the action. No body, no write.
Returns :ok or {:error, %Enact.Error{}} (:forbidden, or
:not_found when load fails). Does not return a record. For a new
form. For edit, archive, or create-under-parent, use subject/3.
Still runs load_subject/2 so authorize/1 sees ctx.subject.
Params keys are stringified exactly as in run/3.
@spec dry_run(module(), map(), keyword()) :: {:ok, Enact.Preview.t()} | {:error, Enact.Error.t()}
Runs the side-effect-free front of the pipeline — load, authorize, cast,
validate, resolve — then stops before any write, returning
{:ok, %Enact.Preview{}} or the identical error surface to run/3.
Built for confirmation flows (agent-facing surfaces, MCP tools): the
preview reflects the casted, normalized updates back for confirmation,
carries the loaded subject for old → new diffs, and its digest feeds
run/3's :confirm_digest option. Authorization runs (don't preview
what you can't do), and distinct telemetry events are emitted so audit
trails never conflate previews with executions.
Params keys are stringified exactly as in run/3.
@spec merged(Ecto.Changeset.t(), Enact.Context.t(), atom(), [atom()] | nil) :: map()
Builds the per-sub-key result-state view of a partial embed
(partial_embeds/1, see Enact.InputSchema) — the values the object
will hold after the write.
Each key reads the caller's casted value where the key was provided (an explicit null reads as a clear) and the subject's current value where it was not. It is used in two places:
validate/2— rules about the merged result read it directly:policy = Enact.merged(changeset, ctx, :booking_policy) if policy.allow_booking or policy.allow_reschedule, do: changeset, else: add_error(changeset, :booking_policy, "must keep one option enabled")execute/2— it computes the merged object to persist, using the same definition validation saw:%{updates | booking_policy: Enact.merged(changeset, ctx, :booking_policy)}
Do not build this view with get_change(...) || ctx.subject.field:
get_change returns nil both when there is no change and when the
change is nil, so the fallback returns the old value when the caller
sends an explicit null. Presence-gating handles nil-clears correctly.
The key list defaults to the embed's scalar fields, derived from the schema; pass an explicit list to restrict it. Cases:
- sub-key provided → the casted incoming value (explicit null →
nil) - sub-key omitted → the subject's current value
- whole embed omitted → every key reads the current value
- whole embed explicitly null → every key reads
nil(the object is being cleared) - no current object (create mode, or a nil subject value) → unprovided
keys read
nil
Returns a plain atom-keyed map. On an already-invalid changeset the
incoming values may be incomplete (there is no cast-stage fast-fail);
gate such rules with Enact.Validations.check/2 where that matters.
@spec provided?(Enact.Context.t(), atom() | [atom() | non_neg_integer()]) :: boolean()
Whether the caller provided a key (or path) in the raw params — the reification of "what did the caller say?".
A key is provided even when its value is nil (an explicit null-clear
is a statement). Accepts a single atom for top-level presence, or a path
where atoms descend into maps (string- or atom-keyed) and non-negative
integers index into lists. Anything unreachable — missing key,
out-of-range index, non-map element — returns false. Never raises and
never converts strings to atoms.
iex> ctx = %Enact.Context{params: %{"note" => nil, "items" => [%{"qty" => 1}]}}
iex> Enact.provided?(ctx, :note)
true
iex> Enact.provided?(ctx, :missing)
false
iex> Enact.provided?(ctx, [:items, 0, :qty])
true
iex> Enact.provided?(ctx, [:items, 5, :qty])
false
iex> Enact.provided?(ctx, [:note, :deep])
false
@spec run(module(), map(), keyword()) :: {:ok, term()} | {:error, Enact.Error.t()}
Runs an action through the full pipeline.
Returns {:ok, result} (whatever execute/2 produced) or
{:error, %Enact.Error{}}.
Accepts one option beyond the shared set: :confirm_digest — a digest
from a prior dry_run/3 preview. The runner recomputes the digest
post-validation and returns :conflict on mismatch, making "the user
confirmed this exact change to this record" a mechanical guarantee
across the confirmation gap. Non-confirmation callers never pass it.
Params may be atom- or string-keyed at the call site. The runner
stringifies keys (recursively, through plain maps and lists) before
any callback runs, so load_subject/2 and ctx.params always see
the Plug/JSON shape. Values are not rewritten. Both :id and "id"
at the same level raises.
@spec subject(module(), map(), keyword()) :: {:ok, struct()} | {:error, Enact.Error.t()}
Loads the action's subject and authorizes the actor. No body, no write.
Returns {:ok, subject} or {:error, %Enact.Error{}} (:not_found,
:forbidden). Does not cast, validate, or resolve. Pass locator params
(the path id), not the form body.
The action must load a subject. If load_subject/2 returns
:no_subject, this raises ArgumentError — use authorized/3 for a
new form.
Params keys are stringified exactly as in run/3.
@spec updates(Ecto.Changeset.t(), Enact.Context.t()) :: map()
Extracts the updates map from a post-validation changeset: exactly the castable fields the caller provided, with their casted values.
Key selection is presence-in-raw-params intersected with the mode's
castable fields (fields/1) — uniform for scalars and embeds. Omitted
keys are absent (untouched on PATCH); explicit null is present as
nil (clears); [] on an embed is present as [] (clears the array).
Embed values are dumped to plain, atom-keyed maps (recursively,
schema-driven; scalar structs like Date and Decimal stay intact),
so the map feeds persistence changesets and JSON encoders directly.
The result is uniformly atom-keyed at every level, and the atoms come
from the schema definitions — never from client params (string keys in
params are matched by presence, not converted), so the atom space stays
closed regardless of input. Ecto.Changeset.cast accepts the map
as-is; just don't merge string-keyed entries into it.
For embeds declared in the input module's partial_embeds/1 manifest,
the dumped map contains only the sub-keys the caller provided (an
explicitly-null sub-key is present as nil; an omitted one is absent).
See Enact.InputSchema.
Never use bare apply_changes/1 output for persistence — it erases
omitted-vs-provided. For input: nil actions this returns %{}.