Enact.Resolve (Enact v0.1.0)

Copy Markdown View Source

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}
  ]
end

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