Enact (Enact v0.1.0)

Copy Markdown View Source

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

Actions 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's authorize/1 and fetchers read it). actor: nil always raises ArgumentError — anonymous callers pass an explicit anonymous actor instead (see Enact.Actor), permitted only by actions declaring anonymous?: true.
  • :repo — the Ecto repo; defaults to config :enact, repo: MyApp.Repo.
  • :assigns — optional map merged into ctx.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 in load_subject/2 / resolvers/0; persistable values are params or stamped in execute/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/2 returning nil:not_found
  • authorize/1 returning false:forbidden; {:error, reason}:forbidden with 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

authorized(action, params, opts)

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

dry_run(action, params, opts)

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

merged(changeset, ctx, embed, keys \\ nil)

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

provided?(context, key)

@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

run(action, params, opts)

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

subject(action, params, opts)

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

updates(changeset, ctx)

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