Elixir and the BEAM for AI systems
Validating LLM Tool Call Arguments With Ecto
Ecto schemaless changesets as the pre-execution trust boundary on LLM tool-call arguments, not another output-shape validator.
TL;DR: Structured LLM output asks whether the model’s response is shaped correctly. This post asks a different question: should the action the model just requested actually run? A schemaless
Ecto.Changesetis the right tool for tool-call arguments because they arrive as a plain map with a known shape per tool, and you don’t want a schema module per tool. But shape validation is only the first of three tiers — type, then range/enum, then authorization — and the third one is where a well-formed, in-range argument still points at someone else’s row. JSON Schema and the MCP 2026-07-28 spec’s composition support can’t do that tier for you, no matter how expressive the schema gets.
The output-parsing post and this one are asking different questions
Structured LLM output in Elixir is a library scorecard for one direction: the model produced text, and you need it cast into a struct your database can use — InstructorLite, ReqLLM, or a hand-rolled changeset loop. That post’s whole argument is downstream of one assumption: the output arrived, and now you’re parsing it.
This post is the other direction. Before a tool-calling model’s
chosen function ever executes, it hands you a JSON object of arguments —
%{"invoice_id" => "inv_88", "amount_cents" => 4500, "reason" => "goodwill"}
— and you have to decide whether to run refund_invoice/1
with that map, or reject the call. That decision happens before
execution, not after a response lands, and it isn’t a parsing problem.
It’s an admission-control problem, structurally closer to validating a
webhook payload than to casting a completion into a struct. Same Ecto
primitive both posts reach for — a changeset — pointed at a different
question.
The model is an untrusted input source the moment it picks arguments
Here’s the part that’s easy to lose once tool calling feels routine: the arguments a model hands your tool aren’t a fixed payload you control. They’re a choice the model made, informed by a system prompt, the conversation so far, and whatever the model inferred it should do next — including inferences a prompt injection buried in a tool result put there. The moment a function call can touch a database row, a filesystem path, or a payment, “the model asked for this” stops being a reason to trust the argument and starts being the reason to check it.
That’s a trust-boundary framing, not a security-theater one. A web
form is untrusted input because a human can type anything into it. A
tool call is untrusted input for the same reason, plus one more: the
“human” here is a language model whose job is literally to produce
plausible-looking structured data, which means a malformed or malicious
argument from an LLM is more likely to look well-formed than a
typo from a person fat-fingering a form field. The parsing layer that
catches a human’s typo won’t catch a model’s confidently wrong
tenant_id.
A well-formed argument and a safe argument are different properties, and only one of them is a parsing problem.
Why a schemaless changeset fits a tool call
Ecto’s normal changeset flow assumes a schema module — a
%User{}, a %Order{}, something with fields
defined at compile time and usually a database table behind it. Tool
arguments don’t have that. Every tool your agent exposes has its own
argument shape, and writing a full Ecto.Schema module per
tool for data you’re never going to persist is exactly the kind of
scaffolding-for-later that doesn’t earn its keep.
The schemaless form solves this directly. Pass a
{data, types} tuple instead of a struct, and
cast/4 works the same way:
defmodule RefundArgs do
@types %{
invoice_id: :string,
amount_cents: :integer,
reason: :string
}
def changeset(params) do
{%{}, @types}
|> Ecto.Changeset.cast(
params, Map.keys(@types)
)
|> Ecto.Changeset.validate_required(
[:invoice_id, :amount_cents, :reason]
)
end
endcast/4 here (three required args plus the implicit
options) takes the empty map as your “data,” the @types map
as the field/type contract, params as whatever the model
sent, and the list of permitted keys. Anything not in that list gets
silently dropped — which is itself doing security work, not just
tidiness: a model that hallucinates an extra key like
"admin" => true never makes it into the changeset at
all, because cast/4 only pulls fields you explicitly
permitted. That’s the same allow-list instinct behind casting only
permitted params out of a Phoenix form submission, applied one layer
earlier — to the arguments the model is proposing before any handler
runs.
One tool, one module, no macro, no table. If you’ve got a dozen tools, that’s a dozen small modules like this one — each a few lines, each independently testable, none of them coupled to anything you have to persist.
Three tiers of validation, and only two of them are a parsing problem
Getting cast/4 to accept a map is tier one — type and
shape. It’s necessary and it’s also the least interesting part of this
problem, because it’s exactly what any JSON Schema validator does too.
Tier two is range and enum, still inside Ecto.Changeset’s
wheelhouse:
def changeset(params) do
{%{}, @types}
|> Ecto.Changeset.cast(
params, Map.keys(@types)
)
|> Ecto.Changeset.validate_required(
[:invoice_id, :amount_cents, :reason]
)
|> Ecto.Changeset.validate_number(
:amount_cents,
greater_than: 0,
less_than_or_equal_to: 50_000
)
|> Ecto.Changeset.validate_inclusion(
:reason,
["duplicate", "error", "goodwill"]
)
endThat’s still just a smarter parser. A refund_invoice
call for $45 on a real invoice ID with a valid reason
string passes both tiers cleanly — and it should still get rejected if
inv_88 doesn’t belong to the tenant whose conversation
triggered the call. That’s tier three, and it’s the one a schema —
Ecto’s or JSON Schema’s — structurally cannot express, because “does
this row belong to this caller” isn’t a property of the argument. It’s a
property of the argument and a database lookup and the
caller’s identity, three things no static schema has access to at
once.
Say the tool’s authorization check lives in its own function, run only after the changeset is valid:
def authorize(changeset, tenant_id) do
with {:ok, args} <-
Ecto.Changeset.apply_action(
changeset, :insert
),
%Invoice{} = inv <-
Invoices.get_for_tenant(
tenant_id, args.invoice_id
) do
{:ok, args}
else
nil -> {:error, :not_found}
error -> error
end
endapply_action/2 only returns {:ok, struct}
when changeset.valid? is true — an invalid changeset comes
back as {:error, changeset} and the with
short-circuits, so tier three never runs against arguments that failed
tier one or two. Invoices.get/1 and the
Invoice struct here are illustrative of your own app’s
lookup, not a specific library call — swap in whatever your context
module actually does.
This is where it meets tenant isolation from the other side: that post is isolation at the data layer, this is authorization on one individual call. It’s the tier worth the most attention in a multi-tenant product, because it’s the one where a rejection isn’t a UX nicety, it’s the entire security boundary. If you’re building the kind of AI product where a tool call can touch another tenant’s data, this is the exact design conversation I have with fractional CTO clients before it ships, not after a pen test finds it — the authorization tier is cheap to add up front and expensive to retrofit once a dozen tools already skip it.
Can a changeset express what JSON Schema’s oneOf expresses?
Not natively, and it’s worth being honest about the gap rather than
pretending it away. The MCP
2026-07-28 specification — the current protocol version — has tool
inputSchema on full JSON Schema 2020-12: schemas can now
use oneOf, anyOf, allOf
composition and conditionals, on top of the type: "object"
root constraint MCP already required. That’s a real feature — a tool
like refund_invoice that accepts either a
duplicate reason (no extra fields) or a
goodwill reason (requires an approver_id) is
naturally expressed as oneOf two sub-schemas.
Ecto.Changeset has no built-in equivalent of that
composition.
What it does have is pattern matching on the function head, which gets you the same outcome by a different route — you write the branch explicitly instead of declaring it:
@types %{
invoice_id: :string,
amount_cents: :integer,
reason: :string,
approver_id: :string
}
# Shared tiers. Note approver_id is a known
# TYPE but is not cast here — the base call
# does not permit it.
defp base_changeset(params) do
{%{}, @types}
|> Ecto.Changeset.cast(
params,
[:invoice_id, :amount_cents, :reason]
)
|> Ecto.Changeset.validate_required(
[:invoice_id, :amount_cents, :reason]
)
end
def changeset(
%{"reason" => "goodwill"} = params
) do
base_changeset(params)
|> Ecto.Changeset.cast(
params, [:approver_id]
)
|> Ecto.Changeset.validate_required(
[:approver_id]
)
end
def changeset(params), do: base_changeset(params)The split matters more than it looks. approver_id lives
in @types so the goodwill branch can cast it, but the base
clause doesn’t permit it — so on any other branch, an
approver_id the model slipped in is dropped rather than
accepted. That’s the allow-list from two sections up paying off a second
time, this time as an authorization control rather than tidiness.
This punts, and I want to name the punt precisely: you’re
hand-writing the discriminated union that oneOf declares in
one place. It works, it’s testable, and for a handful of tools with a
handful of variants it’s genuinely fine — the same “you can write fifty
lines instead of taking a dependency” argument the structured-output post
makes about the parsing side applies here too. Where it stops being fine
is a tool surface large enough, or a schema composed deeply enough, that
the pattern-match branches start duplicating logic across variants
faster than you can keep them in sync. At that point you’re not avoiding
a JSON Schema validator, you’re reimplementing a worse one by hand —
that’s the point to actually evaluate a library that walks the composed
schema for you, not a claim that the boundary needs one today.
Bounding the depth of what you’re willing to walk
The same spec adds two controls worth separating, because they carry
different force. Dereferencing is a hard rule: implementations MUST
NOT automatically dereference $ref values that
resolve to a network URI. Bounding is a recommendation:
implementations SHOULD apply “a maximum schema depth, a
cap on the total number of subschemas, or a per-validation time
budget.”
Note what that bound is aimed at — the spec is defending the
validator against a malicious schema. The version that bites
you is the one it doesn’t cover: a malicious argument. Nothing
stops a compromised or adversarially prompted model from nesting a tool
argument fifty maps deep and handing your cast/4 call a
structure that costs real CPU to even reject. Be clear about what the
guard below does and doesn’t cover: it bounds depth, not breadth. A flat
map with three hundred thousand keys sails through it. If your HTTP
layer doesn’t already cap request body size, cap node count too — the
spec names all three bounds for a reason. Same reasoning, different
attacker input, and it’s your inference to act on rather than the spec’s
requirement.
Ecto.Changeset doesn’t walk nested structure recursively
on your behalf, so the bound has to be your own, ahead of the
changeset:
defmodule ToolArgs.Depth do
@max_depth 8
def check(value, depth \\ 0)
def check(_v, depth)
when depth > @max_depth,
do: {:error, :too_deep}
def check(map, depth)
when is_map(map) do
walk(Map.values(map), depth)
end
def check(list, depth)
when is_list(list) do
walk(list, depth)
end
def check(_scalar, _depth), do: {:ok, :ok}
defp walk(values, depth) do
Enum.reduce_while(
values, {:ok, :ok},
fn v, acc ->
case check(v, depth + 1) do
{:ok, _} -> {:cont, acc}
err -> {:halt, err}
end
end
)
end
endRun this before cast/4, not after — the whole point is
to reject the pathological argument before you spend cycles walking it
through a changeset. It’s a small function, it’s the kind of thing that
never shows up in a demo, and it’s exactly the tier a stack that only
validates shape and range will quietly skip.
Should you tell the model why its call was rejected?
Sometimes — but returning your validation error verbatim isn’t the
free win it looks like, because a model that gets fed your rejection
reason learns the shape of your guard, not just that this one call
failed. Both InstructorLite and the raw retry loop in the
structured-output post
feed changeset errors back to the model on purpose, because for a
parsing failure the retry usually converges: “your JSON didn’t have a
required field” is information the model can act on correctly next time,
and a couple of retries against your own schema costs you a call, not a
security incident.
An authorization rejection is a different kind of failure, and
treating it the same way is the mistake to watch for. If
refund_invoice on inv_88 gets denied because
it belongs to another tenant, and your loop re-prompts with “that
invoice doesn’t belong to this account, try again,” you’ve handed a
probing agent — or the injected instruction steering it — a working
oracle for enumerating which invoice IDs do belong to the
current tenant, one denied guess at a time. The fix isn’t silence; it’s
a flatter, generic denial for tier-three failures — “that action isn’t
permitted” with no specifics — while tier-one and tier-two failures keep
the specific, retryable message that actually helps the model
self-correct. Log the specific reason server-side, where a human reviews
it later; don’t hand it back into the context the model is reasoning
from next. That log is the same artifact an agent evidence pack
is built to collect after the fact — this post is the check that runs
before the tool executes, that post is what a reviewer asks for once it
already has.
Where this fits next to the rest of the stack
None of this replaces an allow-list of which tools an agent can call in the first place, and it isn’t a substitute for the transport-level auth an MCP server built on Phoenix already needs before a tool call reaches your code at all. It’s the layer in between: the tool is allowed, the caller is authenticated, and you still have to decide — argument by argument — whether this specific call, with these specific values, should run. A JSON Schema validator, however expressive MCP’s spec makes it, checks the shape of the request. Only your own authorization tier checks whether the request belongs to the person asking for it.
If you’re standing up tool-calling agents against real customer data and want a second set of eyes on where the authorization boundary actually needs to sit, that’s a conversation worth having before the first tool ships, not after an agent finds the gap for you.