The behaviour every action module implements.
use Enact.Action sets @behaviour Enact.Action and overridable
defaults for the optional callbacks — that is its entire body; there is
no DSL and no code generation.
Required: input/0, authorize/1, execute/2. An open write is a
written authorize/1 returning true, never an omitted callback.
Every callback is either pure data (config/0, input/0, resolvers/0)
or a single-purpose function over (changeset | params, ctx). The runner
(Enact.run/3) orchestrates them as:
load → authorize → rate limit → cast → validate → resolve → execute → after_commit
Summary
Callbacks
Post-commit side effects (job insertion, analytics). Runs strictly after a successful commit, outside the transaction; its return value is ignored and it never rolls back. A raise propagates to the caller, but the write is already committed and the success telemetry event has already been emitted — the audit trail records the write either way.
Authorizes the actor against the loaded context. Required — a forgotten policy is indistinguishable from an open write. Runs before cast — unauthorized callers trigger no input work.
Static action configuration.
Persists the write. Runs inside repo.transaction/1; {:error, reason}
rolls back. A returned %Ecto.Changeset{} error (declared constraints)
is promoted to :invalid; any other error becomes :internal.
The input-schema module (Enact.InputSchema), or nil for input-less
actions (archive, cancel, resend) — the runner then skips cast and
validate, and Enact.updates/2 returns %{}.
Fetches the subject — the URL-anchored record the action operates on or
within (the updated record in patch mode; the parent in
create-under-parent). Always invoked. The default is a no-op that
returns :no_subject (leave ctx.subject nil). Returning nil
produces :not_found. Must scope by the trust anchor (§9 of the
design spec). Params are string-keyed — the runner stringifies atom
keys at the run/dry_run/subject/authorized boundary — and values are uncast. Match
%{"id" => id}, not params[:id].
Rate-limit buckets for this call: a list of {key, window_ms, limit},
each allowing at most limit writes per window_ms milliseconds for
key. The default returns [].
Declarative reference-resolution spec: [name: {field_or_path, fetcher}].
See Enact.Resolve.
Ordinary changeset pipeline for operation-specific rules (payload-
intrinsic rules belong in the input module's own validations). Not
invoked for input: nil actions.
Callbacks
@callback after_commit(result :: term(), ctx :: Enact.Context.t()) :: term()
Post-commit side effects (job insertion, analytics). Runs strictly after a successful commit, outside the transaction; its return value is ignored and it never rolls back. A raise propagates to the caller, but the write is already committed and the success telemetry event has already been emitted — the audit trail records the write either way.
@callback authorize(ctx :: Enact.Context.t()) :: boolean() | {:error, term()}
Authorizes the actor against the loaded context. Required — a forgotten policy is indistinguishable from an open write. Runs before cast — unauthorized callers trigger no input work.
Return values:
true— proceedfalse—{:error, %Enact.Error{type: :forbidden}}{:error, reason}—{:error, %Enact.Error{type: :forbidden, reason: reason}}({:error, :foo}→{:error, %Enact.Error{type: :forbidden, reason: :foo}}){:error, %Enact.Error{}}— passed through
reason is for logs, telemetry, and the host renderer. Do not echo it
wholesale. Match host-owned atoms in the renderer when the 403 copy
should vary; leave the default opaque.
An open write (any non-anonymous actor; or anyone, on anonymous?: true
actions) is a written true, never an omitted callback.
@callback config() :: keyword()
Static action configuration.
Recognized keys (all optional): mode: :create | :patch (default
:create), anonymous?: boolean (default false).
@callback execute(changeset :: Ecto.Changeset.t(), ctx :: Enact.Context.t()) :: {:ok, term()} | {:error, term()}
Persists the write. Runs inside repo.transaction/1; {:error, reason}
rolls back. A returned %Ecto.Changeset{} error (declared constraints)
is promoted to :invalid; any other error becomes :internal.
@callback input() :: module() | nil
The input-schema module (Enact.InputSchema), or nil for input-less
actions (archive, cancel, resend) — the runner then skips cast and
validate, and Enact.updates/2 returns %{}.
@callback load_subject(params :: map(), ctx :: Enact.Context.t()) :: struct() | nil | :no_subject | {:error, term()}
Fetches the subject — the URL-anchored record the action operates on or
within (the updated record in patch mode; the parent in
create-under-parent). Always invoked. The default is a no-op that
returns :no_subject (leave ctx.subject nil). Returning nil
produces :not_found. Must scope by the trust anchor (§9 of the
design spec). Params are string-keyed — the runner stringifies atom
keys at the run/dry_run/subject/authorized boundary — and values are uncast. Match
%{"id" => id}, not params[:id].
@callback rate_limits(ctx :: Enact.Context.t()) :: [ {key :: term(), window_ms :: pos_integer(), limit :: pos_integer()} ]
Rate-limit buckets for this call: a list of {key, window_ms, limit},
each allowing at most limit writes per window_ms milliseconds for
key. The default returns [].
The runner calls it after authorize/1 and before cast, in
Enact.run/3 and Enact.dry_run/3. ctx has the actor, subject, raw
params, and passthrough assigns. If any bucket is full, the call
returns {:error, %Enact.Error{type: :rate_limited}}, with
meta.retry_after_ms set to the longest wait among the full buckets.
Dry runs check buckets without counting. Runs count one write against
every bucket after validation and the digest check, just before
repo.transaction/1. The adapter counts and checks in one step, so
concurrent runs cannot exceed limit. An invalid run or a digest
mismatch uses no slot. A run that rolls back after counting still uses
its slots. So does a run that another bucket rejects at the count: it
returns :rate_limited, and the slots it took in its other buckets
stay counted.
The runner does not namespace keys by action. Actions that return the
same {key, window_ms} share a bucket. Include the action or a feature
name in the key unless you want that:
@impl Enact.Action
def rate_limits(ctx) do
[
{"team_invite:scope:#{ctx.subject.id}", :timer.minutes(10), 10},
{"team_invite:user:#{ctx.actor.id}", :timer.hours(1), 15}
]
endThe adapter set in config :enact, rate_limiter: MyApp.RateLimiter
stores the buckets (see Enact.RateLimiter). Limits based on data, such as a cap on pending
records, belong in validate/2.
@callback resolvers() :: keyword()
Declarative reference-resolution spec: [name: {field_or_path, fetcher}].
See Enact.Resolve.
@callback validate(changeset :: Ecto.Changeset.t(), ctx :: Enact.Context.t()) :: Ecto.Changeset.t()
Ordinary changeset pipeline for operation-specific rules (payload-
intrinsic rules belong in the input module's own validations). Not
invoked for input: nil actions.