Reference resolution — the pipeline step that turns payload references
(public-facing IDs like usr_XXXX) into loaded records.
Actions declare resolvers as data:
def resolvers do
[
# scalar
owner: {:owner_id, &fetch_owner/2},
# path form → batch (one level deep)
milestone_owners: {[:milestones, :owner_id], &fetch_milestone_owners/2}
]
endResolution failures are field-level :invalid errors ("not found" on
the field), never :not_found — 404 belongs to the URL subject, and
"exists but not yours" must be indistinguishable from "doesn't exist".
All resolvers run and all failures are collected without
short-circuiting, tagged validation: :resolution so renderers can
distinguish reference errors from input-format errors without string
matching.
Scalar fetcher contract
(public_id, ctx) -> {:ok, struct} | :error | {:error, message}
Skipped when the caller did not provide the field (an untouched
reference on PATCH survives) and on explicit nil-clears (nothing to
resolve). A provided reference resolves even when identical to the
current value: re-resolution is re-authorization, and it keeps the
execute-side contract total — whenever a non-nil reference appears in
Enact.updates/2 output, the resolved record is in ctx.assigns. On
success the struct is stashed under the resolver name; execute/2
reads it there (ctx.assigns.owner.id) to translate public → internal
ids.
Batch fetcher contract (path form)
(ids, ctx) -> %{public_id => struct | {:error, message}}
The unique non-nil ids across the embed's item changesets are collected
into one fetcher call (N items ≠ N queries). Ids absent from the result
map render the generic "not found" on the item at its correct index;
{:error, message} values render precise per-item messages. On success
the lookup map is stashed under the resolver name, keyed by public id —
the execute-side join is a map read. Paths are one level deep only.
Error precision — the trust-anchor rule
Collapse is mandatory outside the trust anchor; precision is permitted
inside it. Missing and wrong-tenant must both produce bare :error
(generic "not found") — if they render differently, probing IDs
reveals which exist. Precise messages ({:error, "has been deactivated"}) are for records within the caller's own tenant only.
Safety comes from check ordering inside the fetcher: scope by the trust
anchor first; emit precise messages only about records the anchor-scoped
query returned.