A macro for defining Input/Output schemas with reduced boilerplate.
Usage
defmodule MyApp.MyStep.Input do
use ALLM.Pipeline.Schema
schema do
field :name, String.t(), required: true
field :count, integer(), default: 0
field :bulk, [map()], log: false
field :api_key, String.t(), redact: true
end
endOptions
json: true- Adds@derive Jason.Encoderto make the struct JSON-encodablejson_schema: true- Derives__allm_schema__(:json_schema)— see "The derived JSON schema is opt-in" below
An unknown use option raises ArgumentError, so json_schmea: true fails
loudly rather than silently leaving the schema underived.
Field Options
| Option | Values | Effect |
|---|---|---|
:required | true | adds the field to @enforce_keys; cast/1 reports :missing |
:default | term | the struct default (a nil default is "no default") |
:log | true / false / unset | see "The three states of log:" below |
:artifact | true | implies log: false, and lists the field in __allm_schema__(:artifact) |
:redact | true | value replaced by "[REDACTED]" at serialization |
:nilable | true / false | overrides the generated-type nilability rule in either direction — see "The narrow nilability rule" below |
:values | non-empty list of atoms, or of binaries | the enum vocabulary, accepted only on a String.t() / atom() field or a list of them — see "The LLM-facing options" below |
:description | binary | the model-facing property description |
:wire | false, true, or a binary | false excludes the field from the derived schema; true is the default (included, own name) stated explicitly; a binary names a differing wire property |
:json_schema | map | that field's subschema, used verbatim — the escape hatch from the type mapping |
An unknown field option raises ArgumentError at the using module's compile
time, naming the option and the field. So does a non-boolean value on any
of the five boolean options (everything except default: and the four
LLM-facing options): redact: "true" is a compile error rather than a
silently-unredacted field.
The LLM-facing options
values:, description:, wire: and json_schema: exist to make the
OpenAI strict-mode JSON schema a derived artifact of the declaration rather
than a second hand-maintained description of the same shape. They generate
nothing on their own; ALLM.Pipeline.Schema.JsonSchema reads them.
values: takes a compile-time expression, not only a literal list —
field/3 unquotes its options, so values: Schemas.Ordinance.fiscal_impacts()
is evaluated at the using module's compile time and validated as the resolved
list. That is deliberate: the real vocabularies in this project are accessor
calls with a single owner, and requiring a literal would prescribe a copy of
a list that already has one. The list must be non-empty and homogeneous —
all atoms or all binaries — and may not contain nil, because a null
member is derived from the field's nilability, never declared
(is_atom(nil) is true, so a bare list-of-atoms check would wave it
through).
Strings are the common case: projects.scale is a string column, so its
field is String.t() with a string values: and no atom coercion. Atom
coercion is a property of the declared type (atom()), not of values:.
wire: false marks a field the harness populates rather than the model — a
tokens_used read off the response envelope, or a key copied from the Input.
Demanding it in the schema's required would ask the model to invent it.
The derived JSON schema is opt-in
__allm_schema__(:json_schema) exists only on a module declaring
json_schema: true; on any other schema the key raises FunctionClauseError
like any unknown key. The opt-in is not ceremony: an unmappable type is a
compile error (an open map() / term() / bare list() has no closed
strict-mode rendering), and most schemas in this tree legitimately carry such
fields because they never cross a wire. Deriving for every schema would turn
each of those into a build failure.
The narrow nilability rule
A field's generated @type t entry gains | nil iff it has neither
required: true nor a non-nil default:, its declared type does not already
end in | nil, and it carries no explicit nilable: option. nilable: true
forces the tail — even onto a required: field — and nilable: false forbids
it. A field with a default always holds that default, and a required field is
non-nil by construction, so the rule adds | nil exactly where a value can
genuinely be absent at runtime.
Two readings the rule depends on, both deliberate:
default: nilis not a default (the producers test!= nil), sofield(:engine, term(), default: nil)gains| nil.default: falseIS a default (false != nil), so it does not.
The rule is applied here in process_fields/1 and rewrites no field/3
source line. Consequences: a hand-written | nil is detected and left alone
rather than doubled; __allm_schema__(:types) keeps reporting the declared
AST, while :generated_types reports what was spliced; and every field
declared after this inherits the rule without an edit.
mix allm_pipeline.nilability --report prints the current state per module.
The three states of log:
ALLM.Pipeline.StepLog keeps heavy bodies out of input_data /
output_data with two layers: the per-field flags below, and a retained
package-level fallback list of generic field names (:raw_html, :html,
:content, :engine) that applies to every struct it serializes — including
DSL structs, and including plain defstruct / Ecto structs it recurses into,
which have no flags at all.
So log: is deliberately three-state rather than boolean:
log: false— never in the row.log: true— in the row even if the field's name is on the fallback list. Without this state, "this field is named:contentbut must be logged" is unsayable, which would reproduce the global-by-name wart for those four names.- unset — the fallback decides.
artifact: true generates nothing
It declares intent and feeds __allm_schema__(:artifact), and it implies
log: false. It does not generate an artifact_content/1: every Step
module implements that callback by hand, so a generated one would have no
consumer. Combining it with log: true is a compile-time error — the two
express opposite intents and the serializer's drop set would be contradictory.
redact: true applies at serialization, not at construction
The value is replaced by the literal string "[REDACTED]" when
ALLM.Pipeline.StepLog serializes the struct — construction-time scrubbing
would destroy the value the field exists to carry. Four paths were therefore
not covered; subphase 2.3 closed the second in code and three remain
uncovered, none of which the flag can reach:
artifact_content/1— an opaque binary the Step builds itself. The rule that replaces coverage is documentation: aredact: truefield must not be included inartifact_content/1.— closed in subphase 2.3. They used toALLM.Pipeline.Executor's validation error messagesinspect/1the rejected term intostep_logs.errorand the logs;Executor'srender_shape/1now renders the term's type and key NAMES only, never its values. Listed rather than deleted because it is the one of the four that a code fix could reach, and the reason it could is that the Executor owns the whole message.ALLM.Pipeline.Executor.log_summary/4, which writes a caller-supplied map straight tooutput_datawithout passing through the serializer at all.Inspectand exception messages. DSL structs derive noInspectexclusion, so a redacted value renders in full underinspect/1— andnew/1routes throughstruct!/2, whoseKeyErroron an unknown key renders the partially-built struct, secret included. Usecast/1rather thannew/1on any input that mixes a redacted field with untrusted keys.
Generated Functions
new/0— a struct with defaults (only when there are no required fields)new/1— a struct from a keyword list, or from a map with atom or string keys. Raises on an unknown key (struct!/2semantics) and on a field supplied twice (both key forms in a map, or repeated in a keyword list)cast/1—{:ok, t()} | {:error, [issue()]}; see below__allm_schema__/1— introspection; see below
cast/1 does not interpret the declared type
cast/1 checks the shape of its input and nothing else:
- the input is a map (atom or string keys), a keyword list, or an existing
%__MODULE__{}— otherwise{:error, [{:__input__, :not_castable}]} - every key resolves to a declared field — otherwise
{key, :unknown_field}(unknown keys are an error, never silently dropped) - no field is supplied twice — under both its atom and its string key in a
map, or repeated in a keyword list — otherwise
{field, :duplicate_key} - every
required: truefield is present and non-nil — otherwise{field, :missing} - a struct of a different module →
{:error, [{:__struct__, :wrong_struct}]}
It takes a term(), not map() | keyword() | t(): it is called from
ALLM.Pipeline.Executor.validate_input/2 with whatever a caller handed
run_step/5, which runs BEFORE the Executor's try/rescue — so it never
raises, and the :not_castable arm is the reachable answer for anything
else.
It performs no runtime checking of the declared type. The declared types
are arbitrary quoted AST ([map()], String.t(), term(), module-qualified
aliases); interpreting them at runtime means writing a type checker, dialyzer
already covers them statically, and a misinterpretation would reject live
production input at the top of ALLM.Pipeline.Executor.run_step/5.
cast/1 returns all issues, not the first one.
__allm_schema__/1, and why it is not __schema__/1
Every Ecto.Schema module also exports __schema__/1, and
ALLM.Pipeline.StepLog recurses into live Ecto structs. A
function_exported?(mod, :__schema__, 1) predicate would classify every Ecto
struct as DSL-owned; __schema__(:fields) then succeeds with a colliding
shape while __schema__(:dropped) raises FunctionClauseError — on the
un-rescued step-log write path. The distinct name is what makes the predicate
correct. Do not "simplify" it back.
| Key | Returns | Notes |
|---|---|---|
:fields | [atom()] | declaration order |
:types | [{atom(), Macro.t()}] | the declared type AST, exactly as written |
:generated_types | [{atom(), Macro.t()}] | the AST actually spliced into @type t |
:required | [atom()] | same set as @enforce_keys |
:defaults | keyword() | fields with a non-nil default: |
:dropped | [atom()] | log: false or artifact: true |
:kept | [atom()] | explicit log: true |
:declared_logged | [atom()] | derived: :fields − :dropped. Not the persisted set |
:artifact | [atom()] | artifact: true |
:redacted | [atom()] | redact: true |
:nilable | [atom()] | fields declared nilable: true — explicit declarations only |
:values | [{atom(), [atom()] | [String.t()]}] | declaration order; the vocabulary as declared, so the coercion path can tell an atom vocabulary from a string one |
:wire | [{atom(), boolean() | String.t()}] | declaration order; annotated fields only |
:json_schema | map() | the derived strict-mode schema — only on a module declaring json_schema: true |
An unknown key raises FunctionClauseError, so a typo fails loudly.
:declared_logged is deliberately not called :logged. It reports what the
field flags say and ignores the retained fallback list, so for a struct with an
unflagged field :content, String.t() it lists :content even though
:content never reaches output_data. The persisted set is computable only
with the fallback in hand, which is the serializer's job, not the schema's.
:types vs :generated_types diverge for exactly the fields the nilability
rule fires on. They are separate keys because that rule is applied by this
macro and rewrites no source: the declared AST is byte-identical before and
after, so only the generated AST can show the change. Use :types to ask
"what did the author write?" and :generated_types to ask "what does dialyzer
see?".
Not implemented here
The coercion half of values: and wire: — reading a model's parsed
payload back into this struct — belongs to ALLM.Pipeline.LLMStep
(extraction plan Phase 3.2), which reads __allm_schema__(:values) and
__allm_schema__(:wire). This module records the declaration; it never
parses a payload.
Summary
Types
One declared field: its name, its type AST and its options.
The AST is the declared type in process_fields/1's argument and the
generated one (the narrow nilability rule's | nil tail already applied)
everywhere downstream — introspection_clauses/3, flagged/3, optioned/2,
json_schema_clause/3 and ALLM.Pipeline.Schema.JsonSchema.derive!/3 all
receive the generated form. The distinction matters: the nullable-union
decision in the derived JSON schema is read off that tail.
@type issue() :: {atom() | String.t(), :missing | :unknown_field | :wrong_struct | :not_castable | :duplicate_key}
One problem cast/1 found.
The first element names the offending field. It is an atom for every declared
field and for the two whole-term slots (:__struct__, :__input__); an
unknown key arrives as-written, so a string-keyed map's unknown key is
reported as a String.t() (a key that matches no field cannot be safely
converted to an atom).
:duplicate_key is reported when one field is supplied twice — under
both its atom and its string key in a map, or repeated in a keyword list
(which permits repeats). The field name is reported, never either value —
which of the two would have won is unspecified (map iteration order, or
last-writer-wins for a keyword list) and deliberately so, because no caller
should depend on it.