# `Enact`
[🔗](https://github.com/svycal/enact/blob/v0.1.0/lib/enact.ex#L1)

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.

# `authorized`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
