Enact.Action behaviour (Enact v0.3.0)

Copy Markdown View Source

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

after_commit(result, ctx)

(optional)
@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.

authorize(ctx)

@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 — proceed
  • false — {: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.

config()

(optional)
@callback config() :: keyword()

Static action configuration.

Recognized keys (all optional): mode: :create | :patch (default :create), anonymous?: boolean (default false).

execute(changeset, ctx)

@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.

input()

@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 %{}.

load_subject(params, ctx)

(optional)
@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].

rate_limits(ctx)

(optional)
@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}
  ]
end

The 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.

resolvers()

(optional)
@callback resolvers() :: keyword()

Declarative reference-resolution spec: [name: {field_or_path, fetcher}]. See Enact.Resolve.

validate(changeset, ctx)

(optional)
@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.