End-to-end examples of common patterns. The samples follow the documented conventions: context-scoped module layout, private fetchers dispatching to context functions, and presence-preserving handling of Enact.updates/2 output.

1. Embedded data end-to-end

An order with line items: nested input casting, per-item validation, batch resolution of item references, and replace-wholesale persistence. The item schema is defined before the parent, because an alias referenced by embeds_many must resolve when the schema block expands.

# lib/my_app/orders/inputs/line_item_input.ex
defmodule MyApp.Orders.Inputs.LineItemInput do
  use Ecto.Schema
  import Ecto.Changeset
  # item schemas take the bare import — changeset/2 is invoked by
  # cast_embed, so they do not adopt the behaviour via use
  import Enact.InputSchema, only: [cast_input: 3]

  @primary_key false
  embedded_schema do
    field :product_id, :string
    field :quantity, :integer
  end

  def changeset(item, params) do
    item
    |> cast_input(params, [:product_id, :quantity])
    |> validate_required([:product_id, :quantity])
    |> validate_number(:quantity, greater_than: 0)
  end
end
# lib/my_app/orders/inputs/order_input.ex
defmodule MyApp.Orders.Inputs.OrderInput do
  use Ecto.Schema
  use Enact.InputSchema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :note, :string
    embeds_many :items, MyApp.Orders.Inputs.LineItemInput
  end

  @impl Enact.InputSchema
  def changeset(base, params, :create) do
    base
    |> cast_input(params, [:note])
    |> cast_embed(:items, required: true)
  end

  @impl Enact.InputSchema
  def fields(:create), do: [:note, :items]
end
# lib/my_app/orders/actions/create_order.ex
defmodule MyApp.Orders.Actions.CreateOrder do
  use Enact.Action

  alias MyApp.Catalog
  alias MyApp.Orders.Inputs.OrderInput
  alias MyApp.Orders.Order

  @impl Enact.Action
  def input, do: OrderInput

  @impl Enact.Action
  def authorize(ctx), do: MyApp.Policy.can?(ctx.actor, :create_order)

  @impl Enact.Action
  def resolvers do
    [products: {[:items, :product_id], &fetch_products/2}]
  end

  @impl Enact.Action
  def execute(changeset, ctx) do
    updates = Enact.updates(changeset, ctx)
    products = ctx.assigns.products

    # items arrive as plain maps; swap public IDs for internal foreign keys
    # by reading the batch lookup map
    items =
      Enum.map(updates.items, fn item ->
        %{item | product_id: products[item.product_id].id}
      end)

    %Order{org_id: ctx.actor.org.id}
    |> Order.changeset(%{updates | items: items})
    |> ctx.repo.insert()
  end

  # one query for all unique IDs; scoped by the trust anchor first, and
  # precise messages only for records the scoped query returned
  defp fetch_products(public_ids, ctx) do
    ctx.actor
    |> Catalog.get_products_by_public_ids(public_ids)
    |> Map.new(fn product ->
      value = if product.active?, do: product, else: {:error, "is no longer available"}
      {product.public_id, value}
    end)
  end
end

An unknown or cross-tenant product_id is absent from the fetcher's result map and renders the generic "not found" at the correct item index. A deactivated product in the caller's own tenant renders the precise message. Both arrive as ordinary 422 field errors.

2. Flattening an embed into columns

The API models an address as a nested object; the table stores flat columns. The translation lives in execute/2, and each of the three presence cases (omitted, explicit null, provided) must be handled:

# lib/my_app/customers/actions/update_customer.ex
defmodule MyApp.Customers.Actions.UpdateCustomer do
  use Enact.Action

  alias MyApp.Customers
  alias MyApp.Customers.Customer
  alias MyApp.Customers.Inputs.CustomerInput

  @impl Enact.Action
  def config, do: [mode: :patch]

  @impl Enact.Action
  def input, do: CustomerInput

  @impl Enact.Action
  def load_subject(%{"id" => id}, ctx), do: Customers.get_customer(ctx.actor, id)

  @impl Enact.Action
  def authorize(ctx), do: MyApp.Policy.can?(ctx.actor, :update, ctx.subject)

  @impl Enact.Action
  def execute(changeset, ctx) do
    updates =
      changeset
      |> Enact.updates(ctx)
      |> flatten_address()

    ctx.subject
    |> Customer.changeset(updates)
    |> ctx.repo.update()
  end

  @address_columns ~w(address_line1 address_city address_postal_code)a

  defp flatten_address(updates) do
    case Map.fetch(updates, :address) do
      # omitted → columns untouched
      :error ->
        updates

      # explicit null → clear every column
      {:ok, nil} ->
        updates
        |> Map.delete(:address)
        |> Map.merge(Map.from_keys(@address_columns, nil))

      # provided → the embed arrives as a plain map; spread it
      {:ok, address} ->
        updates
        |> Map.delete(:address)
        |> Map.merge(%{
          address_line1: address.line1,
          address_city: address.city,
          address_postal_code: address.postal_code
        })
    end
  end
end

Use Map.fetch/2, not Map.get/2. Map.get returns nil for both "omitted" and "explicit null", collapsing two cases that must map differently. In the input module, from_subject/1 leaves the address embed unseeded; a validation that needs the current address reads ctx.subject directly.

3. Reading resolver assigns in execute/2

The scalar counterpart of recipe 1's batch lookup. Resolution is presence-gated, so whenever a non-nil reference appears in updates, the resolved record is present in ctx.assigns. Handle the three presence cases explicitly:

@impl Enact.Action
def resolvers do
  [owner: {:owner_id, &fetch_owner/2}]
end

@impl Enact.Action
def execute(changeset, ctx) do
  updates =
    changeset
    |> Enact.updates(ctx)
    |> translate_owner(ctx)

  ctx.subject
  |> Project.changeset(updates)
  |> ctx.repo.update()
end

defp translate_owner(updates, ctx) do
  case Map.fetch(updates, :owner_id) do
    # omitted → untouched
    :error -> updates
    # explicit null → the foreign key clears; nil passes through
    {:ok, nil} -> updates
    # provided → resolved and re-authorized; swap public ID for internal
    {:ok, _public_id} -> %{updates | owner_id: ctx.assigns.owner.id}
  end
end

defp fetch_owner(public_id, ctx) do
  case MyApp.Accounts.get_org_user(ctx.actor, public_id) do
    nil -> :error
    user -> {:ok, user}
  end
end

4. Dry-run previews in an MCP response

A two-phase confirmation flow for an agent-facing tool. preview.updates is plain atom-keyed data, embeds included, so it JSON-encodes directly. Serialize preview.subject for the current side of the diff. The digest, not the serialized display, carries confirmation integrity, so the display format can be reshaped freely.

defmodule MyAppWeb.MCP.UpdateProjectTool do
  alias MyApp.Projects

  # Phase 1: no confirmation token → validate fully, reflect back, change nothing
  def call(params, scope) do
    case Projects.update_project_dry_run(params, actor: scope) do
      {:ok, preview} ->
        current =
          preview.subject
          |> Projects.serialize()
          |> Map.take(Map.keys(preview.updates))

        %{
          status: "needs_confirmation",
          changes: preview.updates,
          current: current,
          # resolver names only — loaded records never reach the agent; if
          # the UI needs display info, render it host-side from your own reads
          resolved: preview.resolved,
          confirm_digest: preview.digest
        }

      {:error, error} ->
        render_error(error)
    end
  end

  # Phase 2: same params + the digest → execute
  def call(params, scope, confirm_digest) do
    case Projects.update_project(params, actor: scope, confirm_digest: confirm_digest) do
      {:ok, project} ->
        %{status: "done", project: Projects.serialize(project)}

      {:error, %Enact.Error{type: :conflict}} ->
        %{status: "stale", message: "The change no longer matches what was confirmed. Preview again."}

      {:error, error} ->
        render_error(error)
    end
  end
end

The full pipeline runs again on confirm — authorization, validation, resolution — so a digest match with changed world state still surfaces :invalid or :not_found normally. The digest binds the action, mode, locator params, and updates map. Both phases emit separate telemetry events, so audit trails count previews and executions separately.

5. Empty-string-at-rest columns

Most optional text should be nullable at rest, with NULL as the single representation of empty; those fields need none of the following (see the column convention in the Change Detection guide). On a NOT NULL DEFAULT '' column, however, "" is a valid value, and default cast behavior converts it to nil, which violates the NOT NULL constraint at persistence. The pattern: declare the exception in cast_input/4's keep_empty_strings: option, and reject explicit null in the action.

# lib/my_app/customers/inputs/customer_input.ex
defmodule MyApp.Customers.Inputs.CustomerInput do
  use Ecto.Schema
  use Enact.InputSchema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :name, :string
    field :summary, :string
  end

  @scalars ~w(name summary)a
  # summary is not in @required: on create, omission must be allowed so
  # the column default applies, and validate_required would reject it
  @required ~w(name)a

  # NOT NULL DEFAULT '' columns: "" is a value, so empties must survive
  # casting instead of coalescing to nil
  @empty_string_text ~w(summary)a

  @impl Enact.InputSchema
  def changeset(base, params, :create) do
    base
    |> cast_input(params, @scalars, keep_empty_strings: @empty_string_text)
    |> validate_required(@required)
  end

  # the :patch head applies the same cast; from_subject/1 projects the
  # stored value (possibly "") like any other scalar
end

The emptiness test is trimmed, so " " counts as empty, and the keep_empty_strings: disposition stores it as "" rather than coalescing to nil.

Explicit null is the remaining case. The field must never be null but may be blank, which validate_required cannot express: it rejects both nil and blank strings. Only presence distinguishes "omitted" (allowed — the column default applies) from "explicit null" (an error), so the rule lives in the action's validate/2:

@impl Enact.Action
def validate(changeset, ctx) do
  if Enact.provided?(ctx, :summary) and is_nil(get_field(changeset, :summary)) do
    add_error(changeset, :summary, "can't be null")
  else
    changeset
  end
end

The cases resolve as follows: "" persists as ""; " " counts as empty and persists as ""; explicit null returns a 422 on :summary; omitted leaves the field untouched, and create inserts fall to the column default. The drift test in the Testing guide catches any ""-at-rest field wired with a plain cast.

6. Partial updates on a singular embed

The default embed contract is replace-wholesale. For a singular config object — a booking_policy with several flags — that forces callers to send the whole object to change one flag. Declaring the embed in the input module's partial_embeds/1 manifest switches it to partial-object semantics: Enact.updates/2 filters its sub-keys by presence, so the updates map, previews, and confirmation digests carry exactly what the caller sent, one level down. (embeds_many cannot be declared partial — merging arrays requires item identity, which is a different contract; the guardrails enforce this.)

PATCH { "booking_policy": { "allow_booking": false } }

The item schema stays shape-only. Partial sends cast over an empty struct, so validate_required here would wrongly reject them:

# lib/my_app/scheduling/inputs/booking_policy_input.ex
defmodule MyApp.Scheduling.Inputs.BookingPolicyInput do
  use Ecto.Schema
  import Enact.InputSchema, only: [cast_input: 3]

  @primary_key false
  embedded_schema do
    field :allow_booking, :boolean
    field :allow_reschedule, :boolean
  end

  def changeset(item, params) do
    cast_input(item, params, [:allow_booking, :allow_reschedule])
  end
end
# lib/my_app/scheduling/inputs/link_input.ex
defmodule MyApp.Scheduling.Inputs.LinkInput do
  use Ecto.Schema
  use Enact.InputSchema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :name, :string
    embeds_one :booking_policy, MyApp.Scheduling.Inputs.BookingPolicyInput
  end

  @impl Enact.InputSchema
  def changeset(base, params, _mode) do
    base
    |> cast_input(params, [:name])
    |> cast_embed(:booking_policy)
  end

  @impl Enact.InputSchema
  def fields(_mode), do: [:name, :booking_policy]

  @impl Enact.InputSchema
  def from_subject(link), do: %__MODULE__{name: link.name}

  @impl Enact.InputSchema
  def partial_embeds(_mode), do: [:booking_policy]
end

execute/2 merges the partial object over the current value using Enact.merged/4 — the same function merged-result validations use, so the merge semantics have one definition:

@impl Enact.Action
def execute(changeset, ctx) do
  updates =
    changeset
    |> Enact.updates(ctx)
    |> merge_booking_policy(changeset, ctx)

  ctx.subject
  |> Link.changeset(updates)
  |> ctx.repo.update()
end

defp merge_booking_policy(updates, changeset, ctx) do
  case Map.fetch(updates, :booking_policy) do
    # provided → the result-state view is the merged object
    {:ok, %{}} ->
      %{updates | booking_policy: Enact.merged(changeset, ctx, :booking_policy)}

    # omitted (untouched) or explicit null (clears the whole object)
    _ ->
      updates
  end
end

When the policy is stored as flat columns instead of an embed, the merge disappears: provided sub-keys become columns, and omitted columns stay out of the write — untouched by the same presence semantics that protect top-level fields:

defp flatten_booking_policy(updates, ctx) do
  case Map.fetch(updates, :booking_policy) do
    {:ok, %{} = partial} ->
      updates
      |> Map.delete(:booking_policy)
      # atoms derive from the input schema's closed field set
      |> Map.merge(Map.new(partial, fn {key, value} -> {:"policy_#{key}", value} end))

    {:ok, nil} ->
      updates
      |> Map.delete(:booking_policy)
      |> Map.merge(%{policy_allow_booking: nil, policy_allow_reschedule: nil})

    :error ->
      updates
  end
end

The cases resolve as follows: a provided sub-key merges; an explicitly-null sub-key is present as nil and clears that flag; an omitted sub-key is absent and stays untouched; a null for the whole object clears everything. Previews and digests contain exactly the provided sub-keys, so the user confirms the partial change itself.

Validating the merged result

Validations see the partially-cast object, not the merged result — the base never seeds embeds, so get_field on the item changeset returns nil for omitted sub-keys. Per-key intrinsic rules (formats, bounds) still work in the item schema, since Ecto validators skip absent and nil changes. But a rule about the merged policy belongs in the action's validate/2, against the result-state view built by Enact.merged/4 — the same function the execute merge uses:

@impl Enact.Action
def validate(changeset, ctx) do
  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 at least one option enabled")
  end
end

merged/4 reads each sub-key from the caller's casted value where provided — including explicit nulls, which correctly read as clears — and from ctx.subject where not. The key list derives from the schema by default. See its documentation for the full case table (whole-object nulls, missing current objects).

Displaying partial changes in a confirmation flow

A confirmation preview serves two purposes. The digest binds the delta: the exact change the user confirms. The display provides context: the current and resulting values. Bind the delta; display both. Extending recipe 4's response shape:

current = Scheduling.serialize(preview.subject).booking_policy
resulting = Map.merge(current, preview.updates.booking_policy)

%{
  changes: preview.updates,                  # the delta; bound by the digest
  current: %{booking_policy: current},
  resulting: %{booking_policy: resulting},   # display context; not digest-bound
  confirm_digest: preview.digest
}

resulting is computed against current state, which can change before the confirming run. The design makes no reservation: the confirming run re-validates against current state, and the confirmed delta is what executes.