Class Methods
.expects and .exposes
Actions have a declarative interface, whereby you explicitly declare both inbound and outbound arguments. Specifically, variables you expect to receive are specified via expects, and variables you intend to expose are specified via exposes.
Both expects and exposes support the same core options:
| Option | Example (same for exposes) | Meaning |
|---|---|---|
sensitive | expects :password, sensitive: true | Filters the field's value when logging, reporting errors, or calling inspect |
default | expects :foo, default: 123 | If foo is missing or explicitly nil, it'll default to this value (not applied for blank values) |
optional | expects :foo, optional: true | Recommended: Don't fail if the value is missing, nil, or blank. Equivalent to allow_blank: true |
allow_nil | expects :foo, allow_nil: true | Don't fail if the value is nil (but will fail for blank strings) |
allow_blank | expects :foo, allow_blank: true | Don't fail if the value is blank (nil, empty string, whitespace, etc.) |
allow_empty | expects :ids, type: Array, allow_empty: true | Speaks to emptiness only, leaving nullability to the options above: true accepts an empty value while the field stays required and non-nil; false rejects an empty one (pair it with optional: for "may be omitted, but not empty"). Requires a type: whose values can be empty, and accepts only true/false/nil. See the four requiredness contracts |
if / unless | expects :coupon, type: String, if: :promo_enabled? | Conditionally validate: gates every check in this declaration (including the implicit presence check) on an action method (Symbol) or Proc. See Conditional validation |
type | expects :foo, type: String | Custom type validation -- fail unless name.is_a?(String) |
| anything else | expects :foo, inclusion: { in: [:apple, :peach] } | Any other arguments will be processed as ActiveModel validations (i.e. as if passed to validates :foo, <...> on an ActiveModel class — ActiveModel, not ActiveRecord: axn validates a plain value that arrived over the wire, with no record and no database behind it) — with five exceptions. confirmation: axn extends past ActiveModel's own behavior (see below). inclusion:/exclusion: constrain the value at the position they are declared at, where ActiveModel distributes a set over an Array value's elements (see Where a validator applies). format:/numericality: are refused at declaration on a container-typed field rather than silently constraining the container's to_s or coercing it. A literal no value of the declared type could compare with is refused at declaration, at every type — inclusion:/acceptance:/comparison: when nothing could satisfy it, exclusion:/comparison: { other_than: } when nothing could fail it. And uniqueness: — plus a bare top-level message: — is refused at declaration at every position, rather than declaring cleanly and raising Unknown validator on every call: uniqueness is an ActiveRecord validator, needing a record and a relation to query that an axn contract has not got (check uniqueness on the model, or as validate: ->(value) { ... } querying it yourself), and message: is not one of ActiveModel's shared options, so it belongs inside the bag of the check whose wording it overrides (type: { klass: String, message: "..." }, of: { klass: String, message: "..." }, length: { minimum: 3, message: "..." }). An Axn::FormObject is an ActiveModel model rather than an axn contract, so validates there keeps ActiveModel's own readings throughout |
Dynamic sensitive fields
The sensitive option can accept a proc or symbol in addition to a boolean, allowing you to conditionally filter fields based on runtime values. Those — plus nil, which means false — are the only values it accepts: anything else raises at class definition, because a value that isn't a redaction rule would silently leave the field logged in the clear rather than fail (sensitive: "yes" reads like an opt-in and redacts nothing). The rule is the same wherever sensitive: is accepted: a field, an exposes, an on: subfield, and a shape member.
class MyAction
include Axn
expects :include_pii, type: :boolean
expects :ssn, sensitive: -> { !include_pii }
exposes :api_response, sensitive: :should_redact?
def call
expose api_response: fetch_data
end
private
def should_redact?
!include_pii || result.api_response[:contains_secrets]
end
end
# When include_pii is false, ssn is filtered
MyAction.call(include_pii: false, ssn: "123-45-6789")
#=> inputs: { ssn: [FILTERED], include_pii: false }
# When include_pii is true, ssn is visible
MyAction.call(include_pii: true, ssn: "123-45-6789")
#=> inputs: { ssn: "123-45-6789", include_pii: true }The callable receives no arguments and is evaluated via instance_exec, so it has access to:
- All
expectsfield values (via their reader methods, e.g.,include_pii) - Exposed values via
result.field(e.g.,result.api_response) — bare field names are not available forexposes-only fields - Any instance methods defined on the action
Timing: sensitive evaluated before defaults
For expects fields, the sensitive callable is evaluated before defaults are applied. This means if your sensitivity logic depends on another field's value, that field should either be required or you should handle nil explicitly:
# CAUTION: mode may be nil if caller doesn't provide it
expects :mode, default: "public"
expects :api_key, sensitive: -> { mode != "debug" } # mode could be nil here!
# SAFER: handle nil explicitly
expects :api_key, sensitive: -> { mode.nil? || mode != "debug" }This is because automatic logging of inputs happens before defaults are applied in the execution flow. For exposes fields, this is not a concern since output logging happens after the action completes.
Validation details
WARNING
While we support complex interface validations, in practice you usually just want a type, if anything. Remember this is your validation about how the action is called, not pretty user-facing errors (there's a different pattern for that).
In addition to the standard ActiveModel validations, we also support five additional custom validators:
type: Foo- fails unless the provided value.is_a?(Foo)- Edge case: use
type: :booleanto handle a boolean field (since ruby doesn't have a Boolean class to pass in directly)- Boolean
expectsfields also define a predicate reader, soexpects :enabled, type: :booleanprovides bothenabledandenabled?on the action instance. The same applies to subfield readers. Booleanexposesfields provide predicate readers on the result, soexposes :enabled, type: :booleanprovidesresult.enabled?.
- Boolean
- Edge case: use
type: :uuidto handle a confirming given string is a UUID (with or without-chars) - Edge case: use
type: :paramsto accept either a Hash or ActionController::Parameters (Rails-compatible) type:must name a type — a class or module, a union of them (type: [String, Symbol]), or one of:boolean/:uuid/:params— and this is checked at declaration:type: false,type: 5,type: [String, nil]andtype: { klass: false }all raiseArgumentErrorwhen the class is defined rather than reachingvalue.is_a?(...)and raising a bareTypeErroron every call. Same rule at every position a field's owntype:can be declared —expects,exposes, a subfield, an ambient subfield, and a shape member.
- Edge case: use
of:- names what is INSIDE a container: fortype: Array, each element; fortype: Hash, its keys and/or values. A validator declared alongsideof:(rather than inside it) still constrains the container's own value, not its contents — see Where a validator appliesFor
type: Array:of: Foovalidates each element (fails unless every element.is_a?(Foo)). Accepts the same forms astype:: a single class (of: String), a union array (of: [String, Numeric]— an element passes if it matches any), the:boolean/:uuid/:paramssymbols, or aData.defineclass. Error messages report the failing element's index (e.g.element at index 2 is not a String). Passof: { klass: Foo, message: "..." }to override the type description while still reporting the index —message:is only accepted here, not on a Hash'sof:For
type: Hash:of: { keys: Foo, values: Bar }declares a map. Either axis may be omitted, which is how you say that axis is unconstrained (of: { values: Integer }constrains only the values, leaving any key through); an axis you do write has to name a type, sovalues: [],values: nilandvalues: falseall raiseArgumentErrorrather than passing as a constraint that matches nothing (omitting the axis is the spelling of "unconstrained"; writing one that names nothing is not), andof: {}raises on the same rule. Each axis accepts the same formstype:does — a single class, a union array (keys: [String, Symbol]— a key passes if it matches any), the:boolean/:uuid/:paramssymbols, or aData.defineclass. The bare form (of: Foo) is Array-only and raisesArgumentErroron a Hash — a bare class can't say which axis it constrains, so both have to be spelled out (or omitted) explicitly. Error messages report the failing entry's ordinal position rather than its key (e.g.key at index 0 is not a Symbol,value at index 0 is not a Integer): the key itself is never rendered into the message, since a validation message settles unredacted, and rendering it would leak asensitive:map's own data intoresult.exception.messageand the log lineThe inner-contract bag: the
of:value at any one position, naming what that position holds —klass:(its class, playing the roletype:plays at the top level),of:(what is inside it, recursively),shape:(its members, in the same raw form the field-levelshape:option takes),message:(the description used when that position fails), and the value validators for the value at that position:format:,inclusion:,exclusion:,length:,numericality:,comparison:,presence:,absence:,acceptance:andvalidate:. It sits in exactly three positions — an Array's element (of: { ... }), a map'skeys:axis and a map'svalues:axis — and a bare class in any of them is still sugar for{ klass: <that class> }. Which grammar a bag's ownof:is held to is decided by that bag'sklass:, exactly astype:decides it at the top level:klass: Arraytakes an element contract,klass: Hashtakes akeys:/values:pair. A block reaches an Array's element (type: Array, of: Hash do field :sku, type: String enddeclares the element's members) but never an axis, so a map's shaped values are spelled with the raw form, whosemembers:are objects answeringfieldandvalidations— aStruct.new(:field, :validations)is enough.rubyexpects :matrix, type: Array, of: { klass: Array, of: Integer } expects :buckets, type: Array, of: { klass: Hash, of: { values: Integer } } expects :by_region, type: Hash, of: { values: { klass: Array, of: Integer } } expects :counts, type: Hash, of: { keys: { klass: String }, values: { klass: Integer, message: "must be a whole number" } }A bag's value validators constrain the value at that position — the same rule a field's own validators follow, one rung down. So
of: { klass: String, format: { with: /\A[A-Z]{2}\z/ } }holds every element to a two-letter code,of: { values: { klass: Integer, numericality: { greater_than: 0 } } }holds every map value positive, andof: { keys: { klass: Symbol, inclusion: { in: KNOWN } } }holds every key to a known set.exclusion:works correctly here where it never could at a field level, and avalidate: ->(value) { ... }callable reaches a single element. Failure messages locate the position, never the key:element at index 1 is invalid,value at index 0 must be greater than 0.Because it is the same rule, it is enforced by the same guards:
format:andnumericality:are refused when the bag'sklass:names a container (their contents have their own position — nest anotherof:), and a set no value of the bag'sklass:could satisfy is refused as unsatisfiable, exactly as at a field. Its vacuity mirror runs there too: an inverted validator forbidding literals no value of the position's class could be enforces nothing, soof: { klass: Integer, exclusion: { in: ["admin"] } }andof: { klass: Integer, comparison: { other_than: "admin" } }are refused as well — both guards at all four positions, and a declaration broken both ways reports the unsatisfiable half first. Validators a position cannot read are refused outright:type:(the bag spells thatklass:),model:(it resolves against a<field>_idreader),confirmation:(it reads a sibling reader),coerce:(a transform rather than a constraint) anduniqueness:(it needs a record and a relation).Whether a position admits
nullin its emitted type is derived from the bag's ownklass:and its validators together, soof: { klass: [String, NilClass] }advertises the null branch andof: { klass: [String, NilClass], presence: true }does not.A bag's
optional:/allow_nil:/allow_blank:govern the position, exactly as they do on a named shape member.of: { klass: String, format: /\A[A-Z]{2}\z/, allow_nil: true }accepts["AB", nil]and rejects["ab"]: a tolerated value is admitted without the position asking anything else of it.allow_nil:admitsnilonly;allow_blank:(and itsoptional:sugar) admitsniland blank. Widening the class instead —of: { klass: [String, NilClass] }— widens only the type check, so ActiveModel still runsformat:/length:/inclusion:/numericality:on theniland rejects it; reach for the tolerance rather than the union whenever the bag carries another validator.A bag has to constrain something — at least one of
klass:,of:,shape:or a value validator.of: {}andof: { message: "..." }raiseArgumentErrorat declaration, since a message describes a constraint rather than being one.klass:is the part you may leave out (of: { shape: ... }constrains an element's members while leaving its class open), but a bag carryingof:must name its container:of: { of: Integer }raises, because with noklass:nothing says whether that innerof:is an element contract or a map's axes. Aklass:that names no single container is refused for the same reason —of: { klass: String, of: Integer }(a String has nothing inside it) andof: { klass: [Array, Hash], of: ... }(a union names two grammars) both raise. And aklass:naming something that is not a type at all raises wherever it is written —of: false,of: { klass: 5 },of: { values: { klass: nil } },of: { klass: [String, nil] }— since it takes exactly the formstype:does (a class or module, a union of them, or one of:boolean/:uuid/:params) and anything else reachesvalue.is_a?(...)and raises a bareTypeErroron every call; the message namesklass:, which is what there is to fix at every position a bag sits at.Failures compose one position per level, so a message locates the value inside the whole structure rather than at its outermost container:
matrixabove reportselement at index 0: element at index 1 is not a Integer, andby_regionreportsvalue at index 0: element at index 1 is not a Integer. A map entry is located by its ordinal, never its key, at every depth, for the leak reason given above.Only valid alongside
type: Arrayortype: Hash(exactly) — using it on any other type, including a union liketype: [Array, Hash], raisesArgumentErrorat declaration time, since axn can't tell which container's grammar the bag belongs toA Hash's
of: { values: Bar }reflects in the schema asadditionalProperties: <Bar's schema>(a unionvalues:emitsanyOfbranches, aDatavalues:class emits its members asproperties); akeys:axis that names only a TYPE emits nothing — every JSON object key is already a string, sokeys: Stringwould say nothing actionable andkeys: Symbolwould misdescribe the wire format (an axis carrying a constraint does reflect, aspropertyNames— see below). A nested bag reflects at the node it names, so nesting in the declaration is nesting in the document:of: { klass: Array, of: Integer }on an Array emitsitems: { type: "array", items: { type: "integer" } }, andof: { values: { klass: Hash, of: { values: Integer } } }on a Hash emitsadditionalProperties: { type: "object", additionalProperties: { type: "integer" } }. On output, a self-gated positional validator (of: { klass: String, inclusion: { in: ["a"], if: :flag } }) is reduced away rather than emitted — the action may successfully expose a value a closed gate never checked, and an output schema that rejects what the action serializes is worse than one that says less. On input it is advertised regardless, reflection being static-maximal there.A bag's value validators reflect at the node the bag names, through the same projection a field's do:
of: { klass: String, format: { with: /\A[A-Z]{2}\z/ } }emitsitems: { type: "string", pattern: "^[A-Z]{2}$" }, andof: { values: { klass: Integer, numericality: { greater_than: 0 } } }emitsadditionalProperties: { type: "integer", exclusiveMinimum: 0 }. Alength:at a position measures the element's own length, so it emitsmaxLengthfor a string element where the field's ownlength:emitsmaxItemsfor the array. A bag that names noklass:has its type inferred from its validators, exactly as a field with notype:does —of: { numericality: { greater_than: 0 } }emitsitems: { type: "number", exclusiveMinimum: 0 }. Aformat:orlength:alone infers nothing (at a position or at a field: neither a pattern nor a size names one JSON type), so those reflect only alongside aklass:. On output an inferred type stands down unless the validators prove the value will SERIALIZE as a JSON number, which takes a declaredtype:or bothonly_numeric: trueand a staticonly_integer: true— and then emits"integer". Two separate things defeat it.numericality:otherwise accepts a numeric string, so an action may expose"1"and serialize it as a JSON string that an inferred"number"would reject; andonly_numeric:alone proves only that the value is aNumeric, which is not the same as a JSON number —Complex(1, 2)satisfies it and serializes as"1+2i". Among Numerics only an Integer's#to_sis an integer literal, which is exactly the testonly_integer:applies, so the two together pin an Integer. Aninclusion:set names an output type only where its members pass the same equality-safety test theenumitself is gated on:Integer#==falls back toother == self, so a value object comparing equal to1satisfiesinclusion: { in: [1] }and serializes as its own string, which an inferred"integer"would reject. AString/Symbol/boolean/nilmember settles that alone, their==never matching a foreign class, while a numeric member needs the position to pin its class — so a bare numeric set infers nothing outbound. On output apatternis emitted only where the value IS the string it serializes to — ActiveModel matchesvalue.to_swhile the value serializer renders aTimeasiso8601, so the two measure different strings — and a stringlength:is gated on exactly the same question, for the same reason: ActiveModel measures the value's own#length, sotype: Time, length: { is: 23 }accepts a Time whoseto_sis 23 characters and serializes it as the 20-character"2026-08-25T12:00:00Z", which the emittedminLengththen rejects. A COLLECTION size is exempt by construction,minItems/maxItems/minProperties/maxPropertiescounting the very elements the serializer writes — and an outbound numeric bound only where the position's numbers reach the wire unchanged, which anIntegerand aFloatdo and every otherNumeric(rendered throughFloat(), which rounds) does not. A declaredformat:reaches a union's branches too, writing itspatterninto each string branch rather than nowhere at all; and where it meets the patternonly_integer:installs, the two compose asallOf: [{ pattern: … }, { pattern: … }]rather than one replacing the other, both being enforced. An array, object or boolean branch is dropped by every spelling ofnumericality:, a bare one included: ActiveModel asks whether the value parses as a number before it reads any option, so no such branch is reachable andtype: [TrueClass, Integer], numericality: trueemits{ type: "integer" }instead of advertising a boolean it rejects on every call. The two options decide the rest. Anumericality: { only_integer: true }reaches every branch of a union, and what it does to each is decided by the branch's declared token rather than by its emitted type. The token has to be a static one: ActiveModel resolvesonly_integer:per call, so a Proc or Symbol proves nothing about any single call — when it comes back false the integer check never runs — and the narrowing stands down in both directions, exactly as a per-call numeric bound does. A numeric branch narrows to"integer"where its token admits an Integer (type: [Integer, Float]emits{ type: "integer" }rather than ananyOfwhose"number"branch accepts1.5) and is dropped where it does not — no Float satisfiesonly_integer:, sotype: [String, Float]has no reachable Float branch at all. A string branch is kept, ActiveModel parsing a numeric string so"2"really is accepted, and carries the validator's own integer test as apatternso it stops advertising"abc"alongside it.only_numeric: trueis a narrowing in its own right, applying with or withoutonly_integer:beside it: the option makes ActiveModel demand a Numeric object rather than parse anything, so it drops the string branch —type: [String, Integer], numericality: { only_numeric: true }rejects"abc"and the numeric string"1"alike — and empties a lonetype: Stringposition. It narrows under a Proc or Symbol token too, ActiveModel reading this option truthily rather than resolving it per call. Thenullbranch is exempt from both options, since nullability owns it. Where the narrowing leaves no branch standing, the position admits nothing and the schema says so withenum: []rather than falling back to the wider node:type: Float, numericality: { only_integer: true }accepts no value at all, since no Float'sto_sis an integer literal and a JSON integer is not a Float. Where a numeric bound lands on a union, the branches that cannot carry it are dropped on input rather than left advertising values the validator rejects —type: [String, Integer], numericality: { greater_than: 0 }emits{ type: "integer", exclusiveMinimum: 0 }, not ananyOfwhose string branch accepts"abc". That says less than the runtime allows (ActiveModel does accept the numeric string"5") and is the licensed direction: input reflection may be stricter, never looser. The nullability branch stays, a nil being skipped by the validator rather than bounded by it; and output is never narrowed this way, since there a dropped branch would reject a value axn serializedA constrained
keys:axis is the one thing that now reflects, aspropertyNames— a barekeys: Stringor a bag that only names a class still emits nothing, since every JSON object key is already a string and that says nothing actionable. Only the constraints that survive the string form of a key are emitted: aformat:emitspattern, alength:emitsminLength/maxLength, and aninclusion:set emits its members as property names. The whole inbound projection stands down when the axis's declared class excludes String — a JSON key is a String, sokeys: { klass: Symbol, format: … }can never be satisfied from JSON and every inbound keyword there would name a key axn refuses (:uuidcounts as string-shaped and still reflects; a klass-less axis constrains no class and reflects too). On output that class gate lifts — the key has already been serialized to a String — and a different one takes its place, because three of the keywords ask about the key OBJECT rather than the property name it becomes. Alength:is measured by ActiveModel on the object's#lengthwhileminLength/maxLengthmeasure the property name, and a class may have both (a key whose#lengthcounts segments serializes to"a/b"). Aninclusion:set is matched by Ruby==, which can identify values that serialize differently (1 == 1.0, so a{ in: [1] }axis accepts a key that serializes to"1.0"). Apresence:asks the object's ownblank?, so a key that is present can still render as the empty property name — a key whoseto_sis""satisfies it and thepropertyNames: { minLength: 1 }it emits rejects the map that results. (absence:needs no gate: it emits nothing into apropertyNamesnode at all.) Either way the emitted keyword would reject output the action itself produced, so all three are emitted only where the axis guarantees a key that is its own property name — String, a String subclass, or Symbol — and withheld for every other token, an absentklass:included, since the keys may then be anything. Note the direction: a broad token likeObjectadmits a String without guaranteeing one, which is enough for the inbound gate and not enough for this one. Aformat:needs no gate at all — ActiveModel matchesvalue.to_sand the serializer writes that sameto_s, so its subject is the property name already. Aninclusion:set emits inbound as its reachable subset — a JSON key is a String, so it can only ever equal a String member, makingin: ["a", 1]project to exactlyenum: ["a"]rather than to nothing. With no reachable member the set stands down, on the same grounds a non-String axis does. That inbound stand-down is about what a client may SEND: a non-String axis rejects a string key (keys: Symbolrefuses{"a" => 1}while accepting{a: 1}), so advertisingenum: ["a", "b"]there would name a key axn refuses. Outbound the set is emitted only for the axes the wire-form gate above admits, and its members are rendered by the key serializer rather than the value serializer — a distinction that decides what the map actually produces, aTimevalue serializing asiso8601and aTimekey asto_s. A set whose members have no property-name form is read by what the AXIS admits, not by the set alone. Where the axis's class EXCLUDES String —keys: { klass: Integer, inclusion: { in: [1] } }, which really does accept{ 1 => v }at runtime — the wire cannot reach that position at all and the whole inbound projection stands down, the axis's other constraints emitted without anenum. Where the class ADMITS a String key and no member is one —keys: { klass: [String, Integer], inclusion: { in: [1] } }— the position IS reachable from JSON and no JSON key satisfies it, so it emitspropertyNames: { enum: [] }. Standing down there would tell a client every key is acceptable when none is, which is the one direction reflection may not err in; the empty set also says something useful, that the declaration cannot be driven from JSON at all. A numeric bound on a keys axis emits nothing — a Ruby Hash key may legitimately be an Integer, but nopropertyNamessubschema says "parses to an integer greater than zero", so that stays a Ruby-side check.Where a
shape:sits beside a constrainedkeys:axis, the emittedpropertyNamesis a union:propertyNames: { anyOf: [<the axis constraint>, { enum: [<the shape's key names>] }] }. JSON Schema appliespropertyNamesto every key including onespropertiesmatches, while the runtime exempts a shape-named key from both axes — so the bare constraint would forbid a key the shape requires, leaving a node no value satisfies. The union is the runtime rule verbatim: a key is one the shape names, or one the axis admitsThe
of:option bag itself only accepts the keys its container allows (klass:/of:/shape:/message:plus the shared ActiveModel options for an Array's element;keys:/values:plus those shared options for a Hash, with each axis taking a type or an inner-contract bag of its own) — anything else, a misspelled option (mesage:formessage:) included, raisesArgumentErrorat declaration time, naming every offending key at once. Three of the shared options are refused in a bag at every position,on:,except_on:andstrict:, since none of them names anything axn has (no validation contexts, no strict-raising mode).if:/unless:are refused on a map axis specifically (of: { values: { klass: Integer, if: :flag } }raises): an axis is the one position a bag is never handed to ActiveModel as a validator entry, so nothing would read them — gate the field instead (expects :m, type: Hash, of: { values: Integer }, if: :flag). A subfield (on:) rooted at a map is still refused at any depth, as "not supported yet"of:besideshape:on a Hash is supported, and a Hash is the only container where the two name different nodes:shape:(or a block) names specific keys,of:names everything else. A key the shape names is therefore exempt from the map contract, on both axes — which is exactly what JSON Schema means byadditionalPropertiesapplying only to keyspropertiesdoes not match, so the runtime and the emitted document say the same thing. It is also what makes the pairing worth writing: the named keys usually differ in type from the rest, and a named key that wants more than the blanket type simply restates it. The exemption matches a string key as readily as a symbol one ({"label" => "q3"}is the exemptlabel, not an entry forof:to check), and the ordinal in a failure message counts every entry, exempt keys includedrubyexpects :metrics, type: Hash, of: { values: Integer } do field :label, type: String end # { label: "q3", visits: 120, signups: 4 } => ok # { label: "q3", visits: "lots" } => Metrics value at index 1 is not a Integer # { visits: 120 } => Metrics label is not a String...and the emitted property carries both halves at one node:
ruby# input_schema[:properties][:metrics] { type: "object", additionalProperties: { type: "integer" }, properties: { label: { type: "string", minLength: 1 } }, required: ["label"], minProperties: 1 }
validate: [callable]- Support custom validations (fails if any string is returned OR if it raises an exception)- Example:ruby
expects :foo, validate: ->(value) { "must be pretty big" unless value > 10 }
- Example:
model: true(ormodel: TheModelClassormodel: { klass: TheModelClass, finder: :find }) - allows auto-hydrating a record when only given its IDExample:
rubyexpects :user, model: true # or expects :user, model: User # or with custom finder expects :user, model: { klass: User, finder: :find }This line will add expectations that:
user_idis provided (automatically derived from field name)User.find(user_id)(or custom finder) returns a record
And, when used on
expects, will create reader methods for you:user(the auto-found record)user_id(the record's primary key) — see below
NOTES
- The system automatically looks for
#{field}_id(e.g.,:user→:user_id) - The
klassoption defaults to the field name classified (e.g.,:user→User) - The
finderoption defaults to:findbut can be any method that takes an ID directly - This works with any class that has a finder method (e.g.,
User.find,ApiService.find_by_id, etc.) - For external APIs, you can pass a
Methodobject as the finder
The
<field>_idreader. Alongsideuser, amodel:field defines auser_idreader whose one meaning is the primary key of the record — regardless of whether you were called withuser:oruser_id::rubyexpects :user, model: true # called with user_id: 5 → user_id == 5, user resolves the record # called with user: <rec> → user_id == rec.id, user is that recordIt never triggers an extra lookup: for the default
:findfinder a supplied id is the pk and is returned as-is; otherwise it reads the (memoized) record's.id, reusing the same resolutionuseralready does. So it's meaningful even with a custom finder — where theuser_idkey holds a finder-specific token,user_idstill returns the resolved record's actual primary key. The reader is alias-aware (as: :raw_user→raw_user_id) and silently defers (with a debug-level log) to any same-named method you've already declared. (Composite primary keys are not supported by the singular<field>_idconvention.)Record / id consistency. For the default
:findfinder, passing both a record and a<field>_idthat disagree (user: <rec id=5>, user_id: 9) raisesInboundValidationErrorrather than silently preferring one — contradictory input is a developer error. Passing just one, or both in agreement, is fine. The check is skipped for custom finders, where the<field>_idvalue is a lookup token, not a primary key, so a record-vs-id comparison would be meaningless.klass:must name a single Class or Module — a record is resolved by calling the finder method on it, so there is nothing for a union or atype:-style pseudo-type to dispatch through, and either raisesArgumentErrorat declaration.
confirmation: true- declares a companion input,<field>_confirmation, and fails unless it matches the field's actual value- Note this departs from ActiveModel, which lets an omitted confirmation pass. See Confirmation pairs for the details.
Where a validator applies
A validator constrains the value at the position it is declared at.
On the expects that is the field's own value, so inclusion: on a type: Array field asks whether the ARRAY is a member of the set (inclusion: { in: [["a", "b"], ["c"]] }), and length: measures the array's size.
A constraint on the contents belongs at the contents' own position — inside of: — and until that bag accepts value validators, a validate: ->(value) { ... } callable expresses it.
Two validators have no reading at a container position at all and are refused at declaration: format:, which ActiveModel matches against value.to_s, so on an Array it would constrain the Ruby inspect form, and numericality:, which parses a numeric coercion no container has.
comparison: and acceptance: are not refused by key — their options can name a container, and then they work (comparison: { equal_to: ["a"] } accepts ["a"], comparison: { greater_than_or_equal_to: { "read" => true } } accepts a Hash superset, acceptance: { accept: [["a"]] } accepts ["a"]) — so they are judged by their literals instead, below.
An inclusion: set, an acceptance: accept: set, or a comparison: bound that no value of the declared type: could satisfy is refused on the same terms, at every type — type: Array, inclusion: { in: ["a", "b"] }, type: Integer, inclusion: { in: ["1", "2"] }, type: Array, comparison: { greater_than: 1 } and type: Array, acceptance: true (compared against ActiveModel's default ["1", true]) alike — since each rejects every input while looking like a constraint.
It stands down wherever a value could still pass or the literals cannot be judged at declaration: a contract that admits nil at all (a tolerance flag, a nil-admitting type:, or a validator like acceptance: that skips nil of its own accord — judged across every validator on the field, since one entry's tolerance does not carry a field whose other checks still reject nil), a contract that admits a blank value the entry's own allow_blank: then skips (type: Array, presence: false, comparison: { equal_to: 1, allow_blank: true } accepts [] and rejects ["a"], so it enforces something after all — while the same entry without presence: false is still refused, since the default presence check rejects [] and nothing passes; and a sibling validator that rejects that same blank puts it back out of reach), an undeclared or pseudo-type type:, a set read dynamically (a Symbol, a Proc, an ActiveRecord::Relation) or held in an Array subclass, a Hash accept: set, and a comparison: bound ActiveModel resolves per call.
The four <=>-decided comparison: bounds — greater_than:, greater_than_or_equal_to:, less_than:, less_than_or_equal_to: — are judged only at a container position, because outside one those operators belong to the declared class: a Comparable value object whose <=> accepts a Numeric satisfies type: Money, comparison: { greater_than: 0 }, and even inside the closed world Date > 0 is true (Ruby reads a Numeric bound as an Astronomical Julian Day Number), so no ancestry test predicts it. equal_to: is judged at every type, exactly as its inverted twin other_than: is: both are decided by ==, which never crosses outside a cross-comparable family, so type: String, comparison: { equal_to: 1 } rejects every String as surely as type: String, comparison: { other_than: 1 } forbids none.
The judgment is a closed world: it reaches only pairs drawn from Ruby's core value types — String, Symbol, Integer, Float, Rational, BigDecimal, nil, true/false, Array, Hash, Set, Date, Time, DateTime — and stands down on every other pair, a SUBCLASS of one of them included. Equality is not inferable from ancestry: an Array subclass equals a plain Array (SubArray.new([1]) == [1]), a Regexp subclass equals a plain Regexp, and a Comparable value object may accept any class its <=> chooses to — so the guard names what it knows rather than enumerating exceptions, and a pair it does not vouch for declares. Within that set, a literal in the same cross-comparable family as the declared type still compares across classes (every Numeric with every other, and the Date/Time/DateTime trio), which is why type: Float, inclusion: { in: [0, 1] } declares. The container the set is written in does not change that: a Set, and a Hash (whose members are its keys), have their members read out at declaration, so inclusion: { in: Set[0, 1] } is judged — and enforced — exactly as the Array spelling is. A Set subclass is the one exception, since reading its members would run traversal code of its own; it keeps ActiveModel's hash/eql? membership and the guard stands down on it.
A Range set is the one literal the position itself decides. At a scalar position its bounds stand the check down, because cross-type comparison genuinely works there ((1.0..5.0).cover?(3) is true, so a Float-bounded range is satisfiable on type: Integer). At a container position they are judged, because <=> is nil across unrelated classes and type: Array, inclusion: { in: 1..5 } can never match.
An empty Range is judged at every position, because emptiness is not a question about the bounds' type — a Range that contains nothing matches nothing whatever the declared type is. type: Integer, inclusion: { in: (1...1) } rejects every value and type: Integer, exclusion: { in: (2..1) } forbids none; both are refused, and so is an exclusive Range whose endpoints meet or one whose bounds run backwards on any Numeric/Date/Time/DateTime bound. It is scoped to those bounds because they are the ones ActiveModel decides with cover?; a String-bounded Range is iterated instead, and there Ruby's single-character shortcut makes the obvious reading wrong — ("b".."a").include?("a") is true while cover? reports the range empty — so a reversed inclusive String range really does reject a value and stands the check down.
A tolerated blank rescues comparison: and acceptance: but not inclusion:, and the difference is the schema rather than the runtime. An inclusion: set is the emitted enum, and a blank that passes only by being skipped is never a member of it — so type: Array, presence: false, inclusion: { in: [1], allow_blank: true } would emit {"type": "array", "enum": [1]}, a node admitting neither 1 (wrong type) nor [] (not in the enum). The projection of a satisfiable contract must itself be satisfiable, so that declaration is refused even though [] passes at runtime. comparison: and acceptance: carry none of their literals into the schema — the node is just the declared type — so there is nothing for the blank to contradict.
A live presence: and an absence: declared together are refused, at every type: they are exact complements — presence rejects every blank value, absence rejects every value that is not blank — so nothing satisfies both. The presence half is usually the check axn infers, which is why a declaration naming only absence: reaches this, and why allow_empty: true (or presence: false) is the fix rather than a workaround. This one needs no size reasoning, and does not do any: the blank axis coincides with the size axis only for a type whose blank values are its empty ones, and for a String it does not. "Live" is load-bearing: a blank-tolerant presence: { allow_blank: true } is skipped for exactly the values a presence check would reject, so it enforces nothing and leaves absence: unopposed.
A declaration whose admissible sizes form an empty interval is refused on the same terms — a floor it imposes sitting above a ceiling it also imposes, so nothing can satisfy it. Three spellings reach it: a length: ceiling of 0 against the non-emptiness floor a typed field carries, a length: whose own minimum: sits above its own maximum:, and an inclusion: set whose every member is outside the admitted sizes (type: Array, inclusion: { in: [[]] }). Each emitted a document no caller could satisfy — {minItems: 1, maxItems: 0}, or an enum naming nothing the size bounds allow. To declare "the empty container, and nothing else", drop the floor with allow_empty: true (or presence: false); the ceiling then reflects as maxItems: 0. As above, a nil tolerance stands the refusal down, and a set the guard cannot read (a Symbol, a Proc, a Range, an Array or Set subclass), a set whose container decides membership some other way (a Hash or a Set set has its members read out at declaration, so it is judged exactly as the Array spelling is — but a frozen Array carrying its own include? is stored as you declared it and decides membership itself), a member whose equality axn does not vouch for (the same closed world as above, asked by exact class and by ownership of the == that would run — so a subclass, or a plain object carrying a singleton ==, decides membership for itself), or a member axn cannot measure with Ruby's own length, empty?, blank? and present? — the four methods the checks that hold a size bound ask the value by — all leave the inclusion: branch silent. So does a blank-tolerant inclusion:, wherever a blank value could actually arrive: ActiveModel skips such an entry outright for a blank value, so the set stops being the only way through. A live presence check rules that out (it rejects every blank value whatever its size), and on a container so does a floor above 0 — but not on a String, where " " is blank and two characters long.
An if:/unless: gate does not excuse a refusal about what a validator can mean — the container-position refusal, and the type-membership half of the value-constraint one. A gate decides whether a check runs, not what it can express, and reflection is static-maximal, so the gated constraint is emitted regardless.
It does stand down the two rules above, which are about what a contract admits rather than what a validator means, and a gated check is not admitting or refusing anything on the calls where it is skipped. A gate is weighed per entry, so a gate on any entry supplying a bound, a set, or either half of the complement stands the rule down — and a gate on the whole declaration does so by reaching every one of them. Weighing it through the entries rather than as a fact of its own is what keeps ActiveModel's precedence: a blank nested if: drops the shared gate for that key, leaving the entry enforced after all — presence: { unless: :archived }, absence: { if: :archived } is a working contract, and length: { minimum: 3, maximum: 2, if: :legacy } really does admit ["a"] whenever legacy is false. The stand-down is deliberately coarse — one gated entry suspends the whole comparison — because a refused declaration cannot be recovered from at runtime while an admitted one merely stays broken.
Reflection does not follow the rules down: a gated length: { maximum: 0 } still emits maxItems: 0 beside the inferred floor, so a schema can carry an unsatisfiable pair whose contract is satisfiable. That is the emitter's static-maximal policy rather than these rules', and it is the one gap between them.
A bound that does not equal itself is refused on both verdicts, because it is degenerate rather than wrong-typed — it is of the declared type, so nothing above catches it. Every one of ActiveModel's five non-inverted operators reports false against a NaN (x == NaN, x > NaN, x >= NaN, x < NaN, x <= NaN are all false for every x, NaN included), so type: Float, comparison: { equal_to: Float::NAN } rejects every value; its one inverted operator reports true against a NaN, so type: Float, comparison: { other_than: Float::NAN } forbids none. BigDecimal::NAN is read the same way. A set is not judged this way: a collection's membership test short-circuits on object identity before it asks ==, so [Float::NAN].include?(Float::NAN) is true and exclusion: { in: [Float::NAN] } really does forbid the value.
exclusion: and comparison: { other_than: ... } are judged on the opposite verdict, because they are inverted: their literals decide when a check fails, so literals of the wrong type make the check impossible to fail rather than impossible to pass. Such a declaration enforces nothing — the author wrote a constraint, the class defines cleanly, and every value passes — and it is refused at declaration, at every type: type: Array, exclusion: { in: ["admin"] }, type: Integer, exclusion: { in: ["1", "2"] }, an empty forbidden set, and type: Integer, comparison: { other_than: "a" } alike.
comparison: is judged only where the bound is the sole thing the entry can reject. ActiveModel rejects a blank value before it looks at any bound, so an entry on a type that has one (Array, Hash, Set, String, Symbol) enforces something whatever the bound says — type: Array, comparison: { other_than: 1 } really does reject [] — and stands down. It is judged on a type with no blank value (Integer, Float, Rational, Date, Time), or once allow_blank: puts the blank values out of reach.
Everything the satisfiability guard stands down on, this one stands down on too, read by the same machinery: a set axn may not read side-effect-free, a dynamically-sourced one, an undeclared or pseudo-type type:, a non-empty Range's bounds at a scalar position, a Symbol/Proc bound, and any class pair outside the closed equality world. A declared supertype stands it down as well — type: Object, exclusion: { in: ["admin"] } really does forbid the String "admin", which type: Object admits.
Two things differ from its mirror, both following from the inversion. Tolerance is read for the opposite purpose: a tolerated nil is one more value that passes, and no amount of passing makes a check something can fail, so optional:/allow_nil:/allow_blank: never rescue one. What they do instead is discount forbidden literals — ActiveModel skips a tolerated value before any validator sees it, so a literal the flag exempts can never be the value that fails, and type: NilClass, exclusion: { in: [nil] }, allow_nil: true is refused for having no other. A literal the flag does not skip still counts, including one an entry's own allow_nil: false puts back. And a gate does not rescue one either — a gate can only remove the check, so closed it enforces nothing and open it enforces nothing.
Vacuity is judged only for those two. length: { maximum: Float::INFINITY } and length: { minimum: 0 } enforce nothing but are ActiveModel's own spelling for "no bound", so refusing them would refuse declarations that work as written; a format: pattern matching every string (/.*/) is not detectable in general, and a shortlist of broken patterns is an exception list that cannot be completed; and vacuity for a non-inverted set (inclusion:, acceptance:) would need the declared type's whole value space to sit inside the set, which is knowable only for nil/true/false. All four declare cleanly.
Describing the shape of structured fields (block syntax)
For a structured field — type: Array, type: Hash, or a class such as a Data.define — you can pass a block to declare per-member contracts (types, enums, descriptions, nesting). This works on both expects and exposes:
exposes :integrations, type: Array, of: IntegrationRecord do
field :source, type: String
field :status, type: String, inclusion: { in: %w[connected connected_with_issues needs_reconnect incomplete error] }
field :config, type: Hash do # nested object
field :region, type: String
end
field :endpoints, type: Array do # nested array of objects
field :url, type: String
end
end- The block requires a single, structured
type:(Array, Hash, or a class). Declaring it on a scalar type (String,Integer,:boolean, …), a union (type: [Array, String]), or with notype:raisesArgumentErrorat declaration time. - For
type: Array, each element is validated and errors report the element's index (e.g.element at index 2: status is not included in the list). For atype: Hash/class, the single value's members are validated directly. - A raw
shape:kwarg — a Hash you build yourself, rather than the block above — names the members of the value it is declared on, with no exception:shape:besidetype: ArrayraisesArgumentErrorat declaration, pointing at the inner-contract bag (of: { klass: Hash, shape: { members: [...] } }) or at the block above, both of which still distribute. A raw shape namingcontainer: Arrayitself is refused the same way, wherever it is written — an Array has no members of its own, soshape:has exactly one meaning at every position. - Members accept validations (
type,inclusion, …),optional/allow_blank/allow_nil/allow_empty,sensitive:,user_facing:(expectsshapes only), anddescription, and recurse — a member with its own block validates its nested members at any depth. On anexpectsshape,user_facing:on a member has full parity with a field's —true/String/Symbol/Proc — and surfaces that member's own failure to the caller; a member that doesn't opt in stays dev-facing. On anexposesshape it is rejected at declaration (an outbound failure is a dev-facing bug — bad output — never the caller's fault), so theexposesexample above cannot carry it. Members are reader-less, validation/schema-only declarations, sodefault:andpreprocess:are not supported on a member (they raise at declaration time): they produce or transform a value, which needs a resolution target (a reader) to land on — a member has none; declare it as anexpects … on:subfield if you need those. (model:is likewise rejected — it resolves a record from an id and exposes a<field>_idreader a member can't provide; usetype: Klassfor a plain instance check.) Two members under the same parent — or two top-level members of one shape — must have distinct names (a duplicate raises at declaration), and may not carry two names that render as the same JSON property (which raises when the schema is first built). Members of different parent blocks never collide even at the same depth: azipinside afromblock and azipinside atoblock are properties of different objects, exactly likezipunder bothbillingandshippingfor subfields. - A declared contract is fixed at declaration: axn deep-copies a
shape:you pass as a raw Hash, so mutating that Hash (or the members Array, or a nested shape inside it) afterwards does not change a class already declared — building one up in a loop and declaring after each step gives each class the members it had at the time. Your object is copied, never frozen, so reusing or extending it is fine. Theof:/validate:/inclusion:option containers are copied on the same terms — appending to aninclusion:list after declaring cannot widen a declared enum. A container axn cannot copy faithfully is refused at declaration rather than copied: if it defines methods of its own — aninclude?, a duplication hook, anything, whether on its class, on a module extended onto it, or on the object itself — pass a plain Array, orfreezeyours, which is stored as-is with no copy at all (a frozen container can't be mutated afterwards, which is the only thing the copy protects against).dupcopies the elements but shares the instance variables and drops singleton methods, so a copy answers as you declared only where every answer is Ruby's own: a set whoseinclude?reads its own identity, an ivar, or a singleton method accepts what you declared and its copy does not — and the copy is the stored contract, so the class would reject the values you declared as valid. A membership container that is not an Array (aSet, aRange, your own object answeringinclude?) is stored as your object and answers membership itself, so it is neither copied nor refused — a mutable one is still yours to widen after the class is declared. Shape members are copied too, whatever they are: axn reads each one'sfield, validations, metadata,sensitive:,user_facing:,method_call:anddescriptiononce and stores its ownShapeConfig, so a member object of your own — and any nestedshape:it carries — is snapshotted rather than shared. Mutating it afterwards changes nothing about a class already declared. sensitive:is supported on a member and redacts its value from logs, exception context, andinspect— statically (sensitive: true) or dynamically (aProc/Symbolresolved against the action, like a top-level field). Redaction is by member name, so for a Hash value (or anArrayof Hashes) it is precise — only the sensitive member is masked, its siblings and every array element handled individually. When the value carrying the member is not a Hash — an object-backed shape (type: SomeData,Array, of: SomeStruct), or a malformed non-Hash value a caller sent by mistake (which reaches the pre-validation before-log) — the filter (which redacts Hash keys) can't reach inside it, so the entire value is masked (person: [FILTERED]) rather than risk a leak. This over-redacts — non-sensitive siblings inside that object are hidden too — and applies only to a field that actually carries a sensitive member (a shape with none is logged in full). For per-member precision on an object, expose the sensitive attribute as its own subfield (expects :ssn, on: :person, method_call: true, sensitive: true), which resolves to a scalar value that filters precisely.- A member is read off the element by declared data only — a Hash key, or a
Struct/OpenStruct/Datamember (Datavia#to_h, so no method is ever invoked). Reading a member off a non-Dataobject (a reader) or an Array (an Array method) invokes a method, so — like a subfield'smethod_call:— it's opt-in:field :status, type: String, method_call: true. Without the flag, reaching such a member raisesAxn::ContractViolation::MethodCallNotPermittedError(loud, never silent — no method runs, so a mutating one likefield :popnever mutates during validation). The rule applies at each depth of a nested shape. - Unlike
expects … on:subfields, a shape block does not define reader methods — there is no single value to bind (an array has many elements). It is a contract on structure only. - Composes with
of:: on an Array,of:checks each element's class while the block describes that element's fields; on a Hash, the block names specific keys andof:governs every key it does not name (see the exemption above).of:is optional.
Shape block vs. on: subfield — two tools, two jobs
Both describe nested structure, but they answer different questions:
- A shape block (
expects :items, type: Array do field … end) validates a structure — it constrains the members of a value you already hold and defines no reader. Reach for it to assert the shape of an array's elements or a hash/object you pass through as one unit. - An
on:subfield (expects :zip, on: "address.billing") reads a value out — it lifts a nested value up to a flat, validated field with its own reader.
Rule of thumb: use a shape when you want to check the shape of a value; use on: when you want to read a value out of one.
How optional, allow_blank and allow_nil work with validators
When you specify optional: true, allow_blank: true, or allow_nil: true on a field, these options are automatically passed through to all validators applied to that field. This means:
- ActiveModel validations (like
inclusion,length, etc.) will respect these options - Custom validators (
type,validate,model,of) will also respect these options - Type validator edge case: Note passing
allow_blankis nonsensical for type: :params and type: :boolean ofvalidator note: these options govern whether the whole Array field may be absent — they do not make individual elements optional. Anil(or blank) element is still validated againstof:regardless.
Recommended approach: Use optional: true instead of allow_blank: true for better clarity. The optional parameter is equivalent to allow_blank: true and makes the intent clearer.
allow_empty: is not one of these pass-through options: it speaks to emptiness rather than nil-tolerance, so it is never pushed into your validators. allow_empty: true suppresses the automatic presence check (leaving the type check to reject nil), and allow_empty: false installs a non-emptiness check of its own — one that asks the value's empty? rather than measuring its length, so it works on a type: :params value that reports no length.
If none of optional, allow_blank, allow_nil or allow_empty: true is specified, a default presence validation is automatically added (unless the type is :boolean or :params, which have their own validation logic as described above).
Requiredness is two questions
Requiredness is really two independent questions — may the value be nil (or absent), and may it be empty? All four combinations are declarable:
| Declaration | nil / absent | empty ([], {}, "") | non-empty |
|---|---|---|---|
type: Array | rejected | rejected | accepted |
type: Array, allow_empty: true | rejected | accepted | accepted |
type: Array, optional: true | accepted | accepted | accepted |
type: Array, optional: true, allow_empty: false | accepted | rejected | accepted |
optional:, allow_blank: and allow_nil: are three spellings of the third row. allow_empty: is the only option that speaks to emptiness alone, and it requires a type: whose values can be empty (Array, Hash, Set, String, :params, or any class or module defining empty?) — on a type with no empty state to talk about it raises at declaration. A union type must have an empty state on every member: type: [Hash, Array] is fine, type: [Array, Integer] raises. Emptiness is empty?, not blank?: a whitespace-only String is not empty, so type: String, optional: true, allow_empty: false accepts " " and rejects "".
Set is listed above because a Set has an empty state at runtime, and the runtime rules are exactly the four rows. Reflection is the caveat: Set has no JSON Schema mapping, so a type: Set field falls back to the permissive { type: "string" } hint — and a Set that rejects empty therefore advertises minLength: 1, a string-shaped floor over a value that is not a string, while a declared length: ceiling advertises maxLength on the same terms. Treat a reflected Set as a hint, not a contract.
A field that rejects empty reflects that into its schema as minItems / minProperties / minLength, and a declared ceiling reflects as maxItems / maxProperties / maxLength (an exact length: { is: 2 } emitting both). Two spellings name a ceiling. length: does, as written. absence: does on a container — it rejects every non-blank value, and where a type's blank values are exactly its empty ones (Array, Hash, Set) that leaves size 0 as the only admissible size, so expects :tags, type: Array, absence: true, allow_empty: true emits maxItems: 0. A String is excluded, because " " is blank and two characters long: an absence: there bounds whitespace, which no size key expresses. So is a gated absence: — a ceiling read out of it is one axn infers rather than one you wrote, and it is inferred only from a check that always runs, which a gate on the entry and a gate on the whole declaration both prevent. A bound ActiveModel resolves per call (a Symbol or Proc) and an infinite one emit nothing, since no fixed number expresses them.
A declared numericality: or comparison: bound reflects the same way: greater_than as exclusiveMinimum, greater_than_or_equal_to as minimum, less_than as exclusiveMaximum, less_than_or_equal_to as maximum, equal_to as const, and a numericality: { in: 1..10 } range as both bounds. A bound follows the emitted type into a union's anyOf branches, exactly as a size bound does, so type: [Integer, Float], numericality: { greater_than: 0 } carries exclusiveMinimum on both. Where two of them bound the same side — two validators declaring greater_than, or an in: range beside an explicit greater_than_or_equal_to — the emitted bound is the tighter one, since ActiveModel enforces both. Two equal_to: values that disagree emit enum: [] — a node no value satisfies, which is exactly what the contract is. other_than:, odd:/even:, a per-call Symbol/Proc bound, a non-finite bound, and a non-numeric comparison: bound emit nothing — no keyword says what they mean.
An inclusion: set is likewise intersected with any enum the position's own type already imposes rather than replacing it, so of: { klass: TrueClass, inclusion: { in: [true, false] } } keeps enum: [true] — the type accepts only the singleton, and both constraints are enforced.
A declared format: reflects as pattern when the regex translates faithfully, and emits nothing when it cannot. JSON Schema's pattern is an ECMA-262 source carrying no flags, so /\A[A-Z]{2}\z/ reflects as "^[A-Z]{2}$" — \A/\z become the ECMA input anchors, which is exact, since ^/$ there mean start/end of input with no m flag available to change that. A regex axn cannot translate exactly emits nothing rather than an approximation: a flag that changes what the source means (/i has no pattern spelling at all, Ruby's /m is ECMA's s, and /n matches bytes where a pattern is a Unicode string — a non-ASCII pattern is not refused, though Ruby marks every one of them with a fixed encoding), a Ruby-only escape or construct (\Z, \h, \p{…}, a POSIX bracket class, an atomic or named group, an inline flag group, a possessive quantifier, a class intersection, a nested class union — Ruby reads [a[bc]] as the set {a,b,c} where ECMA reads [a[bc] plus a literal ]), an escape whose character set differs between the dialects (\s/\S — Ruby's is ASCII whitespace, ECMA's also includes NBSP and the Unicode Zs category — and \b/\B, where Ruby's word boundary is Unicode-aware though its own \w is not, so /\A\Bé\B\z/ rejects "é" while ^\Bé\B$ accepts it; \d and \w themselves agree and are emitted), a \A/\z anywhere but the ends, a brace that is not a quantifier, a numeric backreference (where the referenced group did not participate, Ruby fails and ECMA matches empty), a hex or octal escape (Ruby reads \xHH as a byte and ECMA as a character; \uHHHH agrees and is emitted), a source in an encoding the translator cannot inspect, and a pattern ActiveModel resolves per call. format: { without: } emits nothing too — its honest spelling is not: { pattern: … }, and that slot is already spoken for.
A flagless pattern counts UTF-16 code units where Ruby counts characters, and which side that favours depends on the quantifier — ^.$ needs one unit and "😀" has two, so ECMA rejects what Ruby accepts; but ^.{2}$ needs two, so ECMA accepts what Ruby rejects. Telling those apart means parsing the quantifier context, so anything able to match a character outside the BMP stands down at both positions: ., the complements \D/\W, a negated class [^…], and a literal astral character. \d/\w are ASCII-only and can never match one, and a BMP character like é is one code unit either way, so both still reflect.
One narrowing is input-only: a Ruby ^/$ is a line anchor where ECMA's, with no flag available, is an input anchor. Those are zero-width assertions, so no code units are consumed and no quantifier can reverse the direction — Ruby's positions are a strict superset of ECMA's, making the emitted pattern reliably stricter. (That spelling needs ActiveModel's multiline: true to run at all — without it format: { with: /^\d+$/ } raises on every call.) On output it stands down too, since there the schema describes what the action produces and a narrowing rejects values axn successfully serialized. Only an exactly-translated pattern reflects outbound.
Only one thing may answer the emptiness question per declaration. An explicit presence: occupies the very check allow_empty: governs, so the two must agree — presence: false, allow_empty: false and presence: true, allow_empty: true each raise at declaration, naming both spellings. An author-declared length: is a different matter: it is your own size constraint, so allow_empty: false defers to a length: floor of 1 or more (and makes it fire on the empty value even under optional:, which would otherwise tolerate blank), adds its own floor alongside a length: that only caps the size, and raises for one that explicitly admits an empty value (minimum: 0, is: 0, maximum: 0, a range starting at 0, or its own allow_blank: true). allow_empty: true asks for nothing to be enforced, so it never conflicts with a length:.
Conditional validation (if: / unless:)
Both expects and exposes accept ActiveModel's if:/unless: as declaration-level options. The condition gates every validator in the declaration — including the automatically-added presence check — so a field can be conditionally required:
expects :promo_enabled, type: :boolean
expects :coupon_code, type: String, if: :promo_enabled?When promo_enabled is falsey, coupon_code is wholly unvalidated (it may be omitted, and a supplied value is not type-checked); when truthy, it is required and must be a String. unless: is the negation. Both may be given together and combine with AND — every condition must pass for validation to run. This also composes with subfields, making "required only when the parent is supplied" expressible:
expects :data, optional: true
expects :user, type: String, on: :data, if: -> { data.present? }To gate a single check instead of the whole declaration, nest the condition in that validator's own options — no duplicate declaration needed:
expects :num, type: Integer, numericality: { greater_than: 100, if: :big_num_needed? }Rules and caveats:
- Conditions gate validation only.
default:andpreprocess:are pipeline stages, not validations — they still apply when the condition is false. Readers andsensitive:filtering are likewise ungated. - Condition forms: a Symbol names an action method or reader (a boolean field's generated
?predicate works:if: :promo_enabled?); a Proc should be zero-arity and call reader methods (if: -> { data.present? }). Inside a Proc, method calls resolve to the action, butselfis a validation-internal object — instance variables will not resolve; use readers. - Conditions must be cheap and side-effect-free: a declaration-level condition may be evaluated once per validator on the field during a single validation pass.
- Combining a tolerance flag (
optional:/allow_nil:/allow_blank:) with an explicitpresence:raises at declaration — the tolerance would make the presence check unable to fire.allow_empty:raises alongside an explicitpresence:only when the two disagree about emptiness (presence: truewithallow_empty: true, orpresence: falsewithallow_empty: false); agreeing spellings are redundant but legal. - Shape-block members (
field :xinsidedo … end) supportif:/unless:too, with the same action-scoped semantics — the condition resolves against the action, not the element being validated (a condition cannot reference sibling members). This also means Symbol validator arguments (e.g.inclusion: { in: :allowed_statuses }) now resolve on members.
Schema reflection advertises the maximal contract
input_schema never executes conditions. It reflects every conditional field as if every gate were open — if: treated as true, unless: treated as false, every declared validator counted — so the schema may be stricter than the runtime (it can tell a caller a field is required when a closed gate would have accepted omission), but never looser. One narrow, documented exception: a gated required subfield whose condition does not reference its own parent's presence — omitting the parent while the condition is true passes the schema but fails at runtime with a normal validation error (the canonical if: -> { parent.present? } pattern is exact). Two refinements: a Symbol condition referencing a declared sibling field (like if: :promo_enabled? above) is emitted exactly, as a JSON Schema allOf/if/then conditional instead of an unconditional requirement; and a gated required subfield keeps its nested required without forcing its ancestors, so the parent's own declared optionality is honored. On output_schema, a gated exposed field is left untyped (a closed gate skips every validator, so the action can expose whatever it assigned — no type is assertable).
Axn has no validation contexts. ActiveModel lets a model gate a validator on a context (record.valid?(:create) against validates … on: :create), but axn validates with no context at all, so a validator carrying on: would never run on any call. Rather than accept a check that silently enforces nothing, axn refuses the declaration:
expects :v, type: { klass: String, on: :create }
# => ArgumentError: `on:` inside type: on ["v"] names an ActiveModel validation context, and axn validates
# with no context — so that check runs on no call and the declaration is left unenforced.Use if:/unless: to gate a check instead. The same rejection covers a shape member's own on:, and on: on an exposes.
Note that this is only about on: inside a validator's options. A declaration-level on: on expects is a completely different option — it is axn's subfield parent (expects :zip, on: :address) — and is unaffected.
Axn also has no strict-raising mode. ActiveModel's strict: asks errors.add to raise instead of recording the error, so the exception reaches the caller in place of a validation result. Axn already settles a contract violation by raising — the errors are collected, composed into one message, and turned into a failed result — so a strict raise arrives at that same handling having skipped the composition, and can only take information away: a user_facing: field loses its message to the generic one, co-occurring violations are dropped (errors.add raises on the first), and a strict: naming a class outside StandardError escapes the call, which no axn call otherwise does. It is refused wherever a validator's options are written:
expects :v, numericality: { greater_than: 5 }, strict: true
# => ArgumentError: `strict:` inside the declaration on ["v"] is ActiveModel's strict-raising mode, and axn
# does not have one: a contract violation already raises, and the strict exception lands in the same
# handling with LESS to say.The refusal covers both tiers ActiveModel reads (strict: on the declaration, and inside one validator's own bag), at every position a bag sits — a field, a subfield, an ambient subfield, an exposes, a shape member, and an of: bag at any depth — and it holds whatever the value is. ActiveModel reads the option by truthiness, so strict: false and strict: nil raise nothing — but strict: true is supported nowhere, which makes the falsy spelling a switch that cannot be turned on rather than a no-op inside a real option (which is what coerce: false and confirmation: false are, and why those stay legal). Admitting it would also only move the error: a config-driven strict: flag would declare cleanly where the flag is false and raise at class definition where it is true. To shape what a failure says, use message: on the check, user_facing: on the field, or fails_on.
Confirmation pairs (confirmation:)
confirmation: true declares a companion input alongside the field and fails unless the two match — the password/password-confirmation pattern, as one line.
expects :password, type: String, sensitive: true, confirmation: true
# also accepts `password_confirmation`; you do not declare it yourselfWhat the companion inherits. type:, coerce:, preprocess:, method_call:, user_facing: and sensitive: all carry over, so redaction and caller-facing errors treat password_confirmation exactly as they treat password. default: deliberately does not: a defaulted companion would produce and then match its own value, quietly passing a confirmation nobody supplied. Neither do shape:, of: or inclusion: — the companion is checked for equality against the base, and a value equal to one that already satisfies those constraints satisfies them too.
When it is required. Exactly when the base field is present. Send password without its confirmation and the call fails; send neither and it passes, because there is nothing to confirm. A companion you do supply is always compared, whatever the base holds — a blank or nil base is still a value a mismatched companion contradicts. Anything that stops the comparison from running also stops the requirement: an if:/unless: on the declaration or on the entry itself (confirmation: { if: :admin }), and an allow_blank: on the entry for a blank base.
A base with a default: is always present, so its companion is always required — and since default: is not inherited, the schema never shows the default's value. The only passing call supplies a confirmation equal to whatever that default happens to be.
The companion's reader. You get password_confirmation, or <base reader>_confirmation when the base is aliased (as: :pw reads as pw_confirmation). That reader is inferred rather than declared, so it defers: if you wrote a method of that name, or another declaration's as: claims it, yours stands and the companion goes without one. Validation, redaction and reflection are unaffected — a companion without a reader is enforced against the wire value directly, so a method you wrote can never stand in for the input the pair requires. A reader name belongs to whichever declaration answers to it, so everything that names a reader follows that declaration rather than the companion that yielded — an on: parent, and a Symbol if:/unless: gate, in the emitted schema as at run time. That holds wherever the two sit relative to each other: which declaration owns a name is settled across the whole contract, not at the point each line is read.
Declaring the companion yourself overrides the implicit one, so expects :password_confirmation, type: String, optional: true alongside the base gives it whatever contract you want. The implicit version only fills in when you have not.
Not supported on exposes (there is no caller input to confirm an output against) or on a shape block's field (a member has no reader for a companion to attach to). Both raise at declaration.
This departs from ActiveModel on purpose
ActiveModel skips the comparison whenever the confirmation accessor reads nil, so an omitted password_confirmation silently passes there. That default fits ActiveModel's situation rather than axn's: its confirmation attribute is a virtual accessor on a persistent record, reading nil on every save that does not touch the field — failing on nil would break user.update(name: "x") until someone re-typed the password, which is why the Rails guides tell you to add a presence check by hand. An axn call carries no such history: one inbound message, validated once. So the presence check is built in rather than left as a footnote.
Details specific to .exposes
For fields you declare via exposes, you'll need a corresponding expose call — unless the field is also declared via expects, in which case axn auto-copies it from the input into the result on all outcome paths (success, fail!, and exception). See Re-exposing an expected field.
Details specific to .expects
user_facing: — surface a violation to the caller
By default a failed expects validation is dev-facing: it lands in the exception bucket, pages the global handler, and result.error is the generic "Something went wrong". Mark a field user_facing: and a violation of it settles as a failure instead — firing on_failure, skipping the global report, and surfacing a meaningful message on result.error:
expects :note, user_facing: true # surfaces the field's own message ("Note can't be blank")
expects :note, user_facing: "Add a note" # override the surfaced message
expects :note, user_facing: :note_message # call an action method to compute it
expects :note, user_facing: ->(e) { ... } # compute it from the InboundValidationErrorThe value matches the error/fail!/fails_on handler shape — true, a String, a Symbol naming an action method, or a Proc; one that resolves blank falls back to the field's own validation message. The surfaced message is a failure reason, so a declared base error attaches it under the base by default (standalone with no base), just like a fail! message. The field stays required (unlike optional:, which removes the check) — user_facing: changes who is blamed for a violation, not whether it's validated. In a mixed failure (a user_facing: field and a plain one both invalid), the dev-facing one dominates and the call still pages. user_facing: works at any depth: on a subfield (on:), classification follows the subfield's own declaration, and on a parent whose subfields fail because the parent itself failed, those stranded checks are attributed to the parent rather than paging over its user-facing message. Any dev-facing violation anywhere still dominates a mixed failure. user_facing: composes at shape depth too: a shape-carrying field's own errors (its presence/type check, independent of its members) honor the field's own user_facing:, and a shape member may itself opt into user_facing: (defaulting dev-facing) with the same true/String/Symbol/Proc parity a field has. One exception remains a declaration error: an ambient_context subfield is framework-supplied — there is no user to face. See the narrative for the full picture.
Nested/Subfield expectations
expects is for defining the inbound interface. Usually it's enough to declare the top-level fields you receive, but sometimes you want to make expectations about the shape of that data, and/or to define easy accessor methods for deeply nested fields. expects supports the on option for this (all the normal attributes can be applied as well):
class Foo
expects :event
expects :data, type: Hash, on: :event
expects :some, :random, :fields, on: :data
expects :optional_field, on: :data, default: "default value"
def call
puts "THe event.data.random field's value is: #{random}"
end
endSubfield Defaults
Defaults work the same way for subfields as they do for top-level fields - they are applied when the subfield is missing or explicitly nil, but not for blank values. The default is resolved at the value level, when the subfield is read: the reader and validation see it, but it is never written back into the parent. axn never mutates or materializes a caller-supplied object (or Hash) to apply a subfield default — the parent reader returns exactly what the caller passed, and the child reader returns the default. A subfield default therefore fixes the child's own nil; it does not satisfy the parent's own validations (a required parent given {} or omitted still fails its own presence — a child default no longer launders it non-blank).
Reaching into nested parents
on: accepts a dotted path — this is the tool for pulling a single deeply-nested value out of a big provided hash, declaring it (and validating it) as a flat field with a clean reader named after the leaf:
expects :address, type: Hash
expects :zip, on: "address.billing", type: String # validates address[:billing][:zip]; defines a `zip` readerNow zip reads address[:billing][:zip] directly — you name only the leaf you care about, not every intermediate. The root segment (address) must be a declared field (or subfield); the dots after it name intermediate keys that need no declaration of their own. Resolution is canonical: the chain resolves through the deepest declared ancestor's reader (so on: "payload.company" where :company is a model: subfield sees the resolved record, exactly like on: :company), and only undeclared intermediate segments are dug as plain hashes. Nested keys are read indifferently: a parent holding either symbol or string keys resolves the same (symbol keys are checked first). A dotted tail addresses the wire node, not a route to it — so where the same wire key was declared twice (two spellings of one route, distinguishable only by as:), a dotted reference through it names neither and is refused at declaration. Anchor on the route you mean instead (on: :<that route's reader>).
The field name is always a single key; the path lives entirely in on:. (A dotted field name — expects "billing.zip", on: :address — is not valid; write the leaf as the name and the path in on:.)
Nested parents support the full kwarg surface
default:, preprocess:, and sensitive: work on a nested parent too — whether reached via a dotted path (on: "address.billing") or by pointing on: at another subfield. default: and preprocess: resolve on the read path, when the subfield is read: a nested default: returns the declared value for a missing/nil leaf, and a nested preprocess: transforms the resolved value. Neither materializes intermediate objects nor writes into the parent — the parent reader returns exactly what the caller passed, while the subfield's own reader (named after its leaf, or an as: alias) returns the transformed value. A nested sensitive: is not part of that read: it resolves only when something requests redaction — logging, inspect, or an error report — and then filters its full nested path from that output. An ambient parent (on: :ambient_context) supports default:/preprocess:/coerce: the same way — they resolve on the read path against the framework-supplied value; only user_facing: stays unsupported there (see below).
Ambient context (on: :ambient_context)
ambient_context is a reserved, always-present parent whose subfield values are supplied per-invocation by the framework rather than by the caller's arguments. It's how an action declares a dependency on ambient request/tenant state — the current company, the acting user, a request id — as an explicit part of its contract:
class ChargeCard
include Axn
expects :company, on: :ambient_context, model: Company # framework-supplied, not a caller argument
expects :actor, on: :ambient_context, model: User
def call = do_thing(company, actor) # `company` / `actor` read like any other declared input
endEach declared ambient subfield resolves from the first source that provides it, checked in order:
- an explicit
ambient_context:kwarg on the call (ChargeCard.call(ambient_context: { company_id: 7 })) — an explicit kwarg replaces the provider entirely (no merge), so passingambient_context: {}ornildeliberately supplies nothing; - otherwise the configured
Axn.config.ambient_context_provider(a callable returning a Hash); - otherwise, in a Rails app, a live view over every registered
ActiveSupport::CurrentAttributes; - otherwise
{}.
Whatever the source, the hash is filtered to the declared ambient subfields, along their declared paths — only keys you declared survive, so ambient state never carries a process-wide dump of Current into logs or exception context. Ambient subfields are validated like any other input (a required one that resolves absent fails the call) but are deliberately excluded from input_schema (they're framework-supplied, never client input).
Declare the dependency — don't reach into Current directly
Prefer expects :company, on: :ambient_context over reading Current.company inside call. A declared ambient subfield is visible in the contract (a caller can see what ambient state the action needs), is validated and sensitive-filtered, and is trivially driven in tests by passing ambient_context: (or the with_ambient_context helper) — no CurrentAttributes setup. Reading Current directly hides all of that. The optional Axn/AmbientContextBypass RuboCop cop flags a direct Current.<attr> read inside an Axn and points at the on: :ambient_context fix.
Ambient subfields nest to any depth, exactly like a non-ambient parent — the source can be a nested object and subfields reach into it:
expects :request, on: :ambient_context, type: Hash
expects :ip, on: :request, type: String # resolves ambient_context[:request][:ip]
# equivalently, without the intermediate reader:
expects :ip, on: "ambient_context.request", type: StringThe filter reconstructs only the declared leaves along their paths, never a whole sub-hash — so an undeclared sibling at any depth (request[:token] when only request[:ip] is declared) never reaches the resolved value, logs, or exception context. sensitive: composes down the path (mark a nested leaf, or an ancestor, and the reconstructed nested value is filtered). default:, preprocess:, and coerce: are supported on any ambient subfield (nested or not) — they resolve on the read path against the framework-supplied value, exactly as for every other subfield. A shape: block is supported on an ambient subfield the filter copies whole — one with no nested subfields, or a model: node (whose children read off the resolved record): the shape validates against that copied value. A non-model: shape node that also declares nested subfields is rejected at declaration — declare the nested structure one way, either the shape: (validation only) or subfields (expects :ip, on: :request, which also give readers and sensitive:). user_facing: is not supported on an ambient subfield, including on a shape member (rejected at declaration): an ambient value is framework-supplied, so there is no caller to face.
Resolving a subfield by calling a method (method_call:)
By default a subfield is resolved by reading declared data off its parent: a Hash key, or a Struct/OpenStruct/Data member. That's the safe path, and it's all you need for the usual case of reaching into a nested payload.
Sometimes the value you want isn't stored data but the result of invoking a method on the parent — an Array method (items.count), a plain object's reader (event.data), a Data object's computed method, or an attribute off a resolved model: record (company.name). Because invoking an arbitrary method can have side effects (and resolution runs during inbound validation), and because a method result has no JSON-schema representation, this is opt-in: declare method_call: true.
expects :event
expects :data, on: :event, method_call: true # invokes event.data (a reader, not a key)
expects :payload, type: Hash
expects :count, on: "payload.items", type: Integer, as: :item_count, method_call: true # invokes Array#count
expects :company, model: Company
expects :name, on: :company, method_call: true # invokes company.name off the resolved recordNote the last example: reaching into a resolved model: record reads its attributes by invoking methods, so it needs method_call: true too — a record from a finder can expose computed or side-effecting readers just like any other object, so it isn't treated as automatically safe.
Reaching a method-dispatch segment without method_call: true raises Axn::ContractViolation::MethodCallNotPermittedError. It settles as a bug (fires the global on_exception; result.error shows the generic headline) with the actionable fix — the field, the parent's runtime class, and "add method_call: true" — on the exception's own message. This is deliberately loud: it is never silently treated as an absent value.
method_call: is a per-declaration property, so it's only valid on a subfield (a declaration with on:). It composes with default: — the method is invoked and a nil result falls back to the declared default (a value-level default, resolved on read). preprocess: and coerce: compose with method_call: — the method is invoked and its result is coerced then preprocessed (the same order as a top-level field), all on the read path.
method_call: true means "permit dispatch resolving this expectation" uniformly across its path — so a single dotted-on: declaration whose intermediate must be method-dispatched works in one line:
expects :event
expects :name, on: "event.data", method_call: true # event.data (a method) → Hash; [:name] read as a keyHere the implicit data hop is method-dispatched (honoring the declaration's flag) and the name leaf is read as a plain key. An explicitly declared intermediate keeps its own flag — a child opting in never makes its parent dispatch — so declaring expects :data, on: :event (no flag) leaves data key-access-only even if a subfield beneath it sets method_call: true.
The DRY idiom for reading many leaves off a method-resolved object
When you need several values off the same method-resolved object (a PORO, a model: record), declare that hop once with method_call: true, then read its leaves with plain key access — no per-leaf flag:
expects :event
expects :data, on: :event, method_call: true # invoke event.data once → a Hash
expects :name, on: :data, type: String # plain key reads off the resolved Hash
expects :role, on: :data, type: String, default: "member"The flat one-line spelling above is best for pulling a single leaf through a method intermediate; declaring the object hop once is best when you want many leaves off it.
Subfield readers always generate
Every subfield defines a top-level reader method (e.g., random in the example above). When a sub-key's name would collide with an existing reader, rename it with as:/prefix: (below) so both values stay reachable.
Renaming the reader (as: / prefix:)
By default the generated reader is named after the field — expects :channel defines a channel reader. Use as: to give the reader a different name while keeping channel as the caller-facing contract. The most common motivation is freeing the field's name so you can define your own method on top of the raw input:
expects :channel, as: :raw_channel # caller still passes `channel:`
def channel = @channel ||= Channel.find(raw_channel)The wire key stays canonical everywhere caller-facing — validation messages, required-inputs, logging, and sensitive-field filtering all still key off channel. Only the in-action reader (and its ? predicate) is renamed.
as: applies to a single field. For subfields it's especially handy to disambiguate or namespace unwrapped values; prefix: is sugar that renames several at once (literal concatenation, so you supply the separator):
expects :event_params, type: Hash
expects :id, on: :event_params, as: :event_id # reader: event_id (extracts `id`)
expects :id, :type, on: :event_params, prefix: :event_ # readers: event_id, event_typeas: and prefix: cannot be combined (raises at declaration). A renamed reader must clear the same reserved-name bar as a field and can't collide with another reader — which is how you disambiguate two subfields that share a leaf key (e.g. zip under both billing and shipping, or two routes converging on one wire path): give each a distinct as:. Renaming composes with model: — the model is resolved (including the <field>_id lookup) against the wire key and exposed under the aliased reader. Neither value may be dotted (a reader name must name a method, and a dotted prefix: composes one that doesn't), and each must be a String or Symbol in an ASCII-compatible encoding — see the name rules. Every spelling of absence (nil, false, an empty or whitespace-only String, the empty Symbol) means "no rename". Two fields under the same parent — or two top-level fields — may not share a wire key, and may not carry two names that render as the same JSON property (:café spelled in UTF-8 and in ISO-8859-1 are one property); a name whose bytes have no UTF-8 rendering at all is refused as a property name too. Both rules, and exactly when each raises, are in Names that one JSON property can't keep apart.
When you declare subfields on: a renamed parent, reference it by its reader name (the alias), not the wire key — on: is resolved by calling the parent's reader:
expects :channel, type: Hash, as: :raw_channel
expects :id, on: :raw_channel # ✅ reader name; on: :channel would raise (no `channel` reader)preprocess
expects also supports a preprocess option that, if set to a callable, will be executed before applying any defaults or validations. Use it for a custom, field-specific transform. For the common case of turning a wire string into a Ruby type (Date/Symbol/…), prefer coerce: (below), which is the shared, standard inverse of the output serializer. If the preprocess callable raises an exception, that'll be swallowed and the action failed.
coerce
expects supports a coerce: option that parses an inbound wire string into its declared Ruby type before your preprocess, defaults, and validation run — the inbound inverse of how a Date/Symbol result serializes on the way out. This closes the round-trip gap: a JSON client (or a Rails form) sending "2026-07-08" or "active" is accepted for a Date/Symbol field, rather than rejected for not already being the Ruby object.
expects :on, coerce: Date # "2026-07-08" → Date
expects :mode, coerce: Symbol, inclusion: { in: %i[a b] } # "a" → :a, then validated
expects :count, coerce: Integer # "123" → 123 (base 10)
expects :active, coerce: :boolean # "true"/"on"/"1"/1 → true; "false"/"0"/0 → false
expects :on, type: { klass: Date, coerce: true } # explicit form (use with sibling type options like message:)
expects :on, coerce: [Date, String] # union: parse a date if possible, else keep the stringThe supported types are Date, DateTime, Time, Symbol, Integer, Float, and :boolean. Coercion is coerce-or-leave: only strings are transformed (a value already of the right type, or a JSON-native number, is untouched; a blank string is left as-is so presence validation still applies), and an unparseable string passes through to a normal validation error (reported as "could not be coerced to a Date", distinct from a wrong-type "is not a Date"). coerce: is opt-in per field, so a direct Ruby caller's strictness is unchanged. It works on top-level expects fields and subfields (on:) alike, including ambient_context subfields (the coerced value is what the reader and validation see).
:boolean accepts the case-insensitive strings 1/true/t/yes/y/on and 0/false/f/no/n/off, plus the integers 1/0 (the one type that also coerces a non-string wire form). Both sides are an explicit allowlist — an unrecognized value ("maybe", 2) is left uncoerced and fails validation rather than silently becoming true.
Date/time coercion accepts any ISO-8601-shaped wire string — a YYYY-MM-DD date optionally followed by a time (T or space separator, optional seconds/fraction, optional Z/±HH:MM offset). That covers JSON/RFC3339 timestamps, a Rails date_field (2026-07-08), a datetime-local (2026-07-08T14:30, no offset — read in the local zone), and Rails' Time#to_s (2026-07-08 14:30:00 +0000). Ambiguous or partial input that Ruby's Date.parse/Time.parse would otherwise guess against today's date ("12", "01/02/2026", a bare 14:30 time) is left uncoerced and fails validation rather than becoming a silently-wrong value.
Coercing a whole action: coerce_input_types
Per-field coerce: is the right tool for a single wire-shaped field. For an action that is entirely transport-facing — a controller handing it a params hash of strings, an adapter decoding JSON — annotating every field is noise. The coerce_input_types config setting declares "treat all inbound values here as wire data": when on, every field with a coercible declared type behaves as if it set coerce: true.
# Whole app (a consumer's informed choice — e.g. a pure-API service):
Axn.config.coerce_input_types = true
# One action (or a base class its controller-facing actions inherit):
class CreateThing
include Axn
configure { |c| c.coerce_input_types = true }
expects :starts_on, type: Date # "2026-07-08" is now coerced, no per-field coerce:
endThe default is off (false), and deliberately so: type: Date is a contract assertion, and a string where a Date is declared is usually a bug for an in-process Ruby caller — coercing it globally by default would mask that. You opt in where you know the input crossed a wire.
A field's own coerce: always wins over the flag, so a mixed action can opt one field back out with the explicit form:
class ImportRow
include Axn
configure { |c| c.coerce_input_types = true }
expects :on, type: Date # coerced
expects :raw, type: { klass: Date, coerce: false } # left strict despite the flag
endScope matches coerce: itself — top-level fields and subfields (non-coercible types like String/Hash are untouched either way), with each field's own tri-state coerce: flag still winning over the action-wide setting.
.success and .error
The success and error declarations allow you to customize the error and success messages on the returned result.
Both methods accept a string (returned directly), a symbol (resolved as a local instance method on the action), or a block (evaluated in the action's context, so can access instance methods and variables).
When an exception is available (e.g., during error), handlers can receive it in either of two equivalent ways:
- Keyword form: accept
exception:and it will be passed as a keyword - Positional form: if the handler accepts a single positional argument, it will be passed positionally
This applies to both blocks and symbol-backed instance methods. Choose the style that best fits your codebase (clarity vs concision).
In callables and symbol-backed methods, you can access:
- Input data: Use field names directly (e.g.,
name) - Output data: Use
result.fieldpattern (e.g.,result.greeting) - Instance methods and variables: Direct access
success { "Hello #{name}, your greeting: #{result.greeting}" }
error { |e| "Bad news: #{e.message}" }
error { |exception:| "Bad news: #{exception.message}" }
# Using symbol method names
success :build_success_message
error :build_error_message
def build_success_message
"Hello #{name}, your greeting: #{result.greeting}"
end
def build_error_message(e)
"Bad news: #{e.message}"
end
def build_error_message(exception:)
"Bad news: #{exception.message}"
endMessage Matching Order
Messages follow the base/reason model: an unconditional error/success (literal or block) is the base headline, while a conditional (if:/unless:) or explicitly standalone: false entry is a reason. Resolution shows the most-recently-declared matching reason (attached under the base), or — when none matches — the base headline, or finally the generic default.
How It Works
- Entries are stored last-defined-first and evaluated in that order.
- The displayed message is the first matching reason (a conditional or
standalone: falseentry), attached under the base. - If no reason matches, the base headline is shown — it's found by shape, so its declaration position doesn't matter.
- Among multiple reasons that could match (or multiple unconditional headlines), the most-recently declared wins — so declare the most-specific reasons last.
The base's position doesn't matter
Because the base is identified by shape, matching reasons are attached under it no matter where it's declared — there is no "shadowing" to avoid (declaring it last is fine):
class MyAction
include Axn
error "Invalid input provided", if: ArgumentError
error "Record not found", if: ActiveRecord::RecordNotFound
error "Something went wrong" # the base — position-independent
end
# ArgumentError raised => "Something went wrong: Invalid input provided"
# unmatched exception => "Something went wrong" (base alone)With Inheritance
Child class entries are evaluated before parent class entries, so a child's headline (or matching reason) wins over the parent's:
class ParentAction
include Axn
error "Parent error"
end
class ChildAction < ParentAction
error "Child error" # wins — child is evaluated first
endConditional messages
While .error and .success set the default messages, you can register conditional messages using an optional if: or unless: matcher. The matcher can be:
- an exception class (e.g.,
ArgumentError) - a class name string (e.g.,
"Axn::InboundValidationError") - a symbol referencing a local instance method predicate (arity 0 or 1, or keyword
exception:), e.g.:bad_input? - a callable (arity 0 or 1, or keyword
exception:)
Symbols are resolved as methods on the action instance. If the method accepts exception: it will be passed as a keyword; otherwise, if it accepts one positional argument, the raised exception is passed positionally; otherwise it is called with no arguments. If the action does not respond to the symbol, we fall back to constant lookup (e.g., if: :ArgumentError behaves like if: ArgumentError). Symbols are also supported for the message itself (e.g., success :method_name), resolved via the same rules.
error "bad"
# Custom message with exception class matcher
error "Invalid params provided", if: ActiveRecord::InvalidRecord
# Custom message with callable matcher and message
error(if: ArgumentError) { |e| "Argument error: #{e.message}" }
error(if: -> { name == "bad" }) { "Bad input #{name}, result: #{result.status}" }
# Base error attaches to a conditional reason by default
error "Foo" # base — never itself shown as a reason
error("bar", if: ArgumentError) # ArgumentError => "Foo: bar"
error(if: TypeError, &:message) # TypeError => "Foo: <exception.message>"
# (reasons are checked last-declared-first; if two conditional reasons both match the same
# exception, the later-declared one wins — keep their matchers disjoint to avoid surprises)
# Custom message with symbol predicate (arity 0)
error "Transient error, please retry", if: :transient_error?
def transient_error?
# local decision based on inputs/outputs
name == "temporary"
end
# Symbol predicate (arity 1), receives the exception
error(if: :argument_error?) { |e| "Bad argument: #{e.message}" }
def argument_error?(e)
e.is_a?(ArgumentError)
end
# Symbol predicate (keyword), receives the exception via keyword
error(if: :argument_error_kw?) { |exception:| "Bad argument: #{exception.message}" }
def argument_error_kw?(exception:)
exception.is_a?(ArgumentError)
end
# Lambda predicate with keyword
error "AE", if: ->(exception:) { exception.is_a?(ArgumentError) }
# Using unless: for inverse logic
error "Custom error", unless: :should_skip?
def should_skip?
# local decision based on inputs/outputs
name == "temporary"
endCombining if: and unless:
if: and unless: may be given together on the same message; they combine with AND — the message only matches when every condition passes — the same combination rule used by conditional steps and by conditional validation on field declarations. These are different mechanisms, though: a message's if:/unless: only selects which failure message renders, while a field's if:/unless: (see Conditional validation) gates whether the field is validated at all.
Composing error messages across actions
Most of the time you don't need to do anything special: declare a base error on the parent and it attaches to the parent's own failures and any child failure surfaced via call!. A child that fails via fail! re-raises the same Axn::Failure (no wrapping), so the base is prepended automatically — see Prefixing failure reasons.
class OuterAction
include Axn
error "Couldn't onboard"
def call
InnerAction.call!(...) # inner's fail!("email taken") surfaces as "Couldn't onboard: email taken"
end
endReach for an explicit call + fail! only when the base headline isn't enough — specifically:
Per-call-site context, when a single class-level headline can't express what you need (e.g. distinguishing two invocations of the same child). Don't also repeat the headline in the
fail!string — a declared base already attaches to it ("<base>: validating: …").rubydef call a = StepA.call(...); fail!("validating: #{a.error}") unless a.ok? b = StepB.call(...); fail!("charging: #{b.error}") unless b.ok? endAbsorbing an unhandled child exception into a parent failure rather than letting it stay an exception. A child that fails via a raw exception (not
fail!) re-raises that exception throughcall!, so the parent settles as anexceptionoutcome. Itsresult.errorstill aggregates normally (the parent's base prefixed to the child's resolved message) — unless the parent declares its own conditionalerror "…", if: SomeErrormatching the bubbled exception, which replaces the child's presentation rather than prefixing it. An unhandled exception picks up an authored leaf only when a conditional reason matches it (reason matching is independent offails_on); with no matching reason only the declared base headers chain. The raw exception#messagestays out ofresult.errorunless you opt it in explicitly witherror(if: SomeError, &:message)orfails_on SomeError, &:message. Running the child with non-bangcallandfail!ing on!result.ok?instead converts it to afailureoutcome, and lets you author the message yourself. Either way the exception is reported once — so this choice is about the outcome, and about authoring a message the automatic aggregation wouldn't produce.
Suppressing reports for expected failures
If an inner action raises an exception that is an expected business outcome (not a bug), declare fails_on ExceptionClass on the inner action to reclassify it into the failure bucket — it fires on_failure, skips Axn.config.on_exception, and preserves the original exception on result.exception. See Suppressing reports for expected failures.
.async
Configures the async execution behavior for the action. This determines how the action will be executed when call_async is called.
class MyAction
include Axn
# Configure Sidekiq
async :sidekiq do
sidekiq_options queue: "high_priority", retry: 5, priority: 10
end
# Or use keyword arguments (shorthand)
async :sidekiq, queue: "high_priority", retry: 5
# Configure ActiveJob
async :active_job do
queue_as "data_processing"
self.priority = 10
self.wait = 5.minutes
end
# Disable async execution
async false
expects :input
def call
# Action logic here
end
endAvailable Adapters
:sidekiq - Integrates with Sidekiq background job processing
- Supports all Sidekiq configuration options via
sidekiq_options - Supports keyword argument shorthand for common options (
queue,retry,priority)
:active_job - Integrates with Rails' ActiveJob framework
- Supports all ActiveJob configuration options
- Works with any ActiveJob backend (Sidekiq, Delayed Job, etc.)
false - Disables async execution
call_asyncwill raise aNotImplementedError
Inheritance
Async configuration is inherited from parent classes. Child classes can override the parent's configuration:
class ParentAction
include Axn
async :sidekiq do
sidekiq_options queue: "parent_queue"
end
end
class ChildAction < ParentAction
# Inherits parent's Sidekiq configuration
# Can override with its own configuration
async :active_job do
queue_as "child_queue"
end
endDefault Configuration
If no async configuration is specified, the action will use the default configuration set via Axn.config.set_default_async. If no default is set, async execution is disabled.
prefer_inherited and prefer_axn
prefer_inherited(*names) and prefer_axn(*names) say which implementation is live for an instance-side sugar name (log, fail!, result, …) that both axn and your own class hierarchy define — include Axn steps aside for your hierarchy's version by default, and these two declarations make that explicit (prefer_inherited, silencing the one-time deferral warning) or override it (prefer_axn, putting axn's own implementation back in front, since a bare super from your own def reaches the inherited version either way). Both raise at declaration for a name axn does not hand over to anyone (call, an axn internal, a Ruby method). Scoped to the class that writes them — a subclass may choose differently from its parent without changing the parent's or a sibling's behavior. See Inheritance & Method Conflicts for the full explanation and worked examples.
Callbacks
In addition to the global exception handler, a number of custom callback are available for you as well, if you want to take specific actions when a given Axn succeeds or fails.
Callback Ordering
- Callbacks are executed in last-defined-first order, similar to messages
- Child class callbacks execute before parent class callbacks
- Multiple matching callbacks of the same type will all execute
Callbacks vs Hooks
- Hooks (
before/after) are executed as part of thecall-- exceptions orfail!s here will change a successful action call to a failure (i.e.result.ok?will be false) - Callbacks (defined below) are executed after the
call-- exceptions orfail!s here will not changeresult.ok?
Note: Symbol method handlers for all callback types follow the same argument pattern as message handlers:
- If the method accepts
exception:as a keyword, the exception is passed as a keyword - If the method accepts one positional argument, the exception is passed positionally
- Otherwise, the method is called with no arguments
Combining if: and unless:
if: and unless: may be given together on the same callback; they combine with AND — the callback only fires when every condition passes — the same combination rule used by messages, conditional steps, and conditional validation on field declarations.
on_success
This is triggered after the Axn completes successfully, once the enclosing database transaction has committed (immediately if none is open); it is skipped if that transaction rolls back. Nested on_success callbacks fire child-first (inner before outer). Difference from after: if the given block raises an error, this WILL be reported to the global exception handler, but will NOT change ok? to false.
on_error
Triggered on ANY error (explicit fail! or uncaught exception). Optional filter argument works the same as on_exception (documented below).
on_error is a superset of on_failure and on_exception, so it co-fires with whichever specific bucket applies: a fail! triggers both on_error and on_failure, and an uncaught exception triggers both on_error and on_exception. If you register on_error alongside the specific callback, expect both to run — they are not mutually exclusive.
on_failure
Triggered ONLY on explicit fail! (i.e. not by an uncaught exception). Optional filter argument works the same as on_exception (documented below).
on_exception
Much like the globally-configured on_exception hook, you can also specify exception handlers for a specific Axn class:
class Foo
include Axn
on_exception do |exception|
# e.g. trigger a slack error
end
endNote that by default the on_exception block will be applied to any StandardError that is raised, but you can specify a matcher using the same logic as for conditional messages (if: or unless:):
Exceptions outside StandardError
Two families outside StandardError are still bugs, and are treated as such: SystemStackError (runaway recursion) and ScriptError (NotImplementedError, LoadError, SyntaxError). Either settles as an exception outcome that call returns like any other, with on_error and on_exception firing and the global handler notified; call! raises it, as always.
Every other exception outside StandardError passes straight through untouched — no outcome, no callbacks, no report, and no completion log line (the "About to execute" line is already out before your call runs, so auto_log still emits that one) — raised from call as well as call!. Signals and exit mean the process is going away, Timeout::ExitException must reach the enclosing Timeout.timeout intact for the timeout to fire, and any library may define its own control-flow signal as a direct Exception subclass. Since that set is open-ended, axn names what it swallows rather than what it lets through. See what call can still raise.
class Foo
include Axn
on_exception(if: NoMethodError) do |exception|
# e.g. trigger a slack error
end
on_exception(unless: :transient_error?) do |exception|
# e.g. trigger a slack error for non-transient errors
end
def transient_error?
# local decision based on inputs/outputs
name == "temporary"
end
on_exception(if: ->(e) { e.is_a?(ZeroDivisionError) }) do
# e.g. trigger a slack error
end
endIf multiple on_exception handlers are provided, ALL that match the raised exception will be triggered in the order provided.
The global handler will be triggered after all class-specific handlers.
.fails_on
fails_on reclassifies the listed exception classes from the exception outcome into the failure outcome: a matching raised exception settles as a failed result (firing on_failure, not on_exception, and skipping the global on_exception report) while the original exception is preserved on result.exception so the normal error message resolution still applies. It does not wrap the exception in Axn::Failure.
class SubmitOrder
include Axn
fails_on ActiveRecord::RecordInvalid # default message
# fails_on ActiveRecord::RecordInvalid, "Unable to submit" # positional string
# fails_on(ActiveRecord::RecordInvalid) { |e| e.message } # block (receives the exception)
# fails_on [RecordInvalid, RecordNotUnique], "Couldn't save"
# fails_on RecordInvalid, "Unable to submit", standalone: true # message replaces the base headline
# fails_on ActiveRecord::RecordNotFound, if: -> { Rails.env.staging? } # gate CLASSIFICATION itself
def call = order.save!
endReclassification only means anything for an exception axn settles onto a result. A signal, an exit, NoMemoryError, and a library's own control-flow signal are raised straight through .call and never reach classification, so naming one here would be inert — it raises ArgumentError at class definition instead of silently doing nothing. (fails_on Exception is still accepted.)
Signature: fails_on(exceptions, message = nil, standalone: nil, if: nil, unless: nil, &block) — exceptions is an Exception class or array of classes; the optional message/block is wired through the error DSL (so it composes with base/reason attachment and ordering). standalone: is forwarded to that wired error: omitted (the default) the message attaches as a reason under any declared base error; standalone: true makes it replace the base headline instead — the same knob error itself exposes. Because it only configures the wired message, passing standalone: (true or false) without a message/block raises at declaration rather than silently doing nothing. See Reclassifying exceptions as failures for the full explanation.
Conditional reclassification (if:/unless:)
if:/unless: gate classification itself — whether the exception is reclassified into the failure bucket at all — evaluated at settlement time against the action instance (same mechanism as error/success/callbacks: a Symbol naming an action method, a callable (instance_exec'd, receiving the exception positionally or as exception:), a String naming a constant, an Exception class, or an array of any of those, ANDed both within and across if:/unless:). This is a separate concern from message/&block, which only shape the text once classification has already happened:
fails_on ActiveRecord::RecordNotFound, if: ->(exception:) { exception.record_type_class.retryable? }
fails_on ActiveRecord::RecordInvalid, if: :interactive? # reads an action method/readerA message declared alongside if:/unless: is gated by the same condition — it never surfaces for an exception this declaration didn't actually reclassify, so a closed gate means both no reclassification and no message.
Unlike standalone:, if:/unless: is fully meaningful with no message at all — it gates classification, not presentation. error/fail! messages are unaffected either way.
A condition runs at most once per exception, per action instance that settles it — classification and a declared message share the same verdict rather than each asking the condition separately, so the two can never disagree even for a condition that isn't perfectly deterministic (though it should be: reach for a pure, cheap check). An inherited fails_on declaration consulted by more than one nested action (e.g. a subclass re-raising into another action that shares the same declaration) evaluates independently at each level, against that level's own action — never reusing a different level's answer.
A literal boolean is rejected at declaration (fails_on X, if: false or if: true) rather than silently doing the opposite of what it looks like: Matcher's "no condition" convention treats a bare falsey value as always matches, so if: false would mean always reclassify, and a bare true isn't a recognized rule shape at all, so it would silently mean never (with a runtime warning). For a decision you can make when the class loads — for example, only reclassifying in one environment — guard the declaration itself instead: fails_on ActiveRecord::RecordNotFound if Rails.env.staging?. if:/unless: earns its keep on state only known at call time: the exception's own attributes, the action's inputs, a per-request flag.
A Symbol condition resolves against a public action method only (a private method falls through to constant lookup, fails that too, and reads as "no match" with a warning) — the same resolution error/success/callbacks already use.
A condition that raises never reclassifies, for if: or unless: — a broken condition is always inert, warned and logged, never a match either way.
Contract reflection (.input_schema / .output_schema)
.input_schema and .output_schema return JSON Schema Hashes derived from your expects/exposes declarations — the lingua franca that OpenAPI, MCP inputSchema, and LLM function-calling parameters all speak. Paired with Axn::Extensions::Serialization.render(result) (which renders a result to a JSON-safe Hash), this is the groundwork for exposing any Axn as a callable tool. Both methods are read-only and off the execution path — reflecting an Axn never instantiates it, runs its validators, or executes any of your code. (One deliberate exception: input_schema logs a single diagnostic warning per class when it omits a deep subfield that has no JSON-object representation — see below — writing only to the configured logger.)
class FindWidget
include Axn
expects :id, type: :uuid
expects :verbose, type: :boolean, default: false
exposes :widget, type: Hash
end
FindWidget.input_schema
#=> { type: "object",
# properties: { id: { type: "string", format: "uuid", minLength: 1 },
# verbose: { type: "boolean", default: false } },
# required: ["id"] } # `verbose` is optional — it has a defaultA field is marked required unless a declared signal says it may be omitted: a usable default: (present, and not blank — a default: {}/"" can't satisfy the field's presence, so it stays required), or a nil/blank-tolerant declaration (optional: / allow_nil: / allow_blank:). presence: false alone does not make a typed field omittable — it only drops the presence (blank) check, leaving the type check to still reject nil; combine it with a tolerance flag (or use optional:/allow_nil: directly) to actually permit omission. Every exposes field is required in output_schema (the serializer always emits every key; nullability is carried by the property's type, e.g. ["string", "null"]).
Requiredness is advisory, not a runtime guarantee
To keep reflection cheap and free of running your code, the schema is built from your declarations, not by test-running your validators against each default. In these narrow cases the reflected required can therefore disagree with what Axn.call actually accepts:
- a non-blank but invalid default (e.g.
expects :name, type: String, default: 123) is reflected as optional, but omitting it still fails validation at runtime — a self-contradictory contract; - a
do…endshape member under a nil-tolerant parent that carries its own object default (e.g.expects :payload, type: Hash, allow_nil: true, default: -> { {} }with a required member): strict reflection ignores aProcdefault, so it reflects the parent as nullable/omittable, but at runtime the Proc fills{}and the required member is then enforced, so the omitted/nilcall fails.
These surface as ordinary, recoverable validation errors (a tool client simply gets a failed result and can retry). Give the default a valid value, or send the parent explicitly, and the schema and runtime agree.
Names that one JSON property can't keep apart
Before any of this, a name has to be a name, which every option that carries one is held to at class definition. It must be a String or a Symbol — those are the only two types whose to_s and to_sym are each other's inverse, so they are the only values with one name to canonicalize to — and it must be written in an ASCII-compatible encoding. This covers the names expects/exposes take, as:, prefix:, a subfield's on:, and Axn::Factory.build's expose_return_as:; anything else raises an ArgumentError naming the option and the offending class rather than whatever the value's own to_sym happened to raise. The optional ones (everything but a field name) run their absent check first, so every spelling of "not supplied" — nil, false, an empty or whitespace-only String, the empty Symbol — still means the option was omitted; a field name is never optional, so expects nil names no field and raises. The encoding rule is not a UTF-8 rule: a Latin-N name is ASCII-compatible and legal, while a wide encoding (UTF-16, UTF-32) interns to a different Symbol than the UTF-8 property it renders as, so nothing a caller sends could ever match it.
A declared name becomes a JSON property name — in input_schema/output_schema, and in a result rendered by Axn::Extensions::Serialization.render — so two further rules apply to every name axn can emit.
- It must have a UTF-8 rendering. JSON is a UTF-8 format, so a Symbol holding bytes that don't convert has no property name at all, and
JSON.generaterefuses it outright. - It must not collapse onto a property another declared name already renders as.
:caféspelled in UTF-8 and the same word spelled in ISO-8859-1 are two different Symbols and one property; emitting it twice silently drops one of the two.
Six things name a property at a node, and each is judged against all the others: a top-level field; a subfield leaf at its resolved parent; a shape member at any depth; a model:-generated <field>_id; a nested key a dotted on: introduces; and the members of a structured type declared alongside a shape (a Data field's own members, or an of: element type's inside items).
One wire slot named twice by the same spelling is a merge, not a collision — two routes to one node, a shape member and a subfield of one name, a generated <field>_id and an explicitly declared one — and still emits exactly one property. Only two different spellings that render alike are rejected, and the error names both spellings and the property they collapse to.
When a rule fires depends on what can see the defect: what your declarations alone settle raises at class definition, while what only the built schema reveals raises the first time a projection is demanded (input_schema, output_schema, render) — and at app setup for tool axns.
| at class definition | at first projection | |
|---|---|---|
| no UTF-8 rendering | an exposes field name — it names a property in the serialized body whatever a schema emits | a top-level expects name, a subfield, a shape member |
| collapses onto one property | two field names; two exposes names; two subfields under one parent — including one route spelled two ways (on: "p" and on: :p) | two shape members; a model:-generated <field>_id against a differently-spelled explicit one; a structured type's members against a shape's; two routes only resolution equates (on: "foo.bar" against on: :bar, where bar is itself a subfield of foo) |
Rule 1 is applied to the property names a schema actually emits, so a name it never emits — a leaf on a subfield the tree drops, a shape member under a scalar of:, a member of a type an outbound gate strips — is not rejected for bytes that never reach a schema.
Both rules rest on one premise, which the DSL guarantees for you: a name must render through Ruby's own to_s, so that the property axn judges is the property JSON.generate emits. expects/exposes symbolize every name you declare, and a shape member is stored as the Symbol it was judged under, so nothing you can declare violates it. A name built into a field config and assigned onto a class directly can — a String subclass (or a String carrying a singleton) that defines to_s has bytes and a rendering, and only its author knows which one names the property — and such a name is refused when the projection is built, alongside the two rules above.
Two further bounds guard size rather than naming, and they are different limits:
- The graph you declared, at class definition: a
shape:graph may have at most 25,000 member paths. A nested shape object shared between sibling members multiplies out, so N levels of two-way sharing are 2^N paths, and every walk of a stored graph pays one step per path — runtime shape validation on each call, andsensitive:redaction once per class, or once per logged call for asensitive:that resolves against the action (a Proc or Symbol), which no memo can decide ahead of time. Measured with the bound removed, 786,000 paths (18 levels of two-way sharing) cost ≈1.3s per log line that way, and ≈2s for the one derivation any contract makes on its first logged call. Shallow sharing is unaffected. - The schema it would emit, when a projection is first built: at most 25,000 JSON properties. What counts is derived from the same decisions the emitter makes, so anything the schema names nowhere costs nothing here. A shape whose members never become properties: a scalar
of:(of: Stringwithfield :length) validates members off an element that stays a string, and an outbound-gated or non-member-keyed value emits no object at all. On output that includes anything a per-validator gate could skip —exposes :x, type: { klass: SomeData, if: :flag }promises none ofSomeData's members, so they cost nothing there, while the same declaration on input still counts them (a gate can only relax enforcement at runtime, so the input schema advertises the type regardless). And a whole config the projection represents nowhere: one rooted aton: :ambient_context, a subfield under amodel:/non-object/mixed-union parent at any depth, amodel:route's own declared type on input (the client sends<field>_id), or the second of two routes to one wire path, whoseshape:/of:the emitter never reaches (the property is built from the first). Anof:element type's own members do reachitems, so they still count — including a unionof: [A, B], where each element type reachesitemsin its ownanyOfbranch (two branches may name the same member without colliding: they describe one property two ways) — as does a contract whose fields merely sum past the bound (only the total reveals that one).
Subfields nest to any depth: a dotted on: path (on: "address.billing") and a subfield of a subfield both appear as recursively nested object properties, keyed by wire key (aliases resolve to the key a client actually sends). A required subfield at any depth forces its whole ancestor chain into required (and strips those ancestors' nullability): a nil/omitted ancestor yields every descendant absent, so runtime could never satisfy the leaf. Intermediate keys introduced by a dotted segment reflect as plain object properties that are required (and non-nullable) exactly when something beneath them is. The structural exclusions: a deep subfield whose chain passes through a model: parent (the client sends <field>_id, not the object) or a non-object parent (type: Array, a mixed union) has no JSON-object representation. These are omitted from the schema — calling input_schema on such a class logs a one-time warning naming the omitted field(s), so the gap is visible rather than silent when you build tooling on the schema. A nested model: subfield reached via a dotted on: (expects :widget, on: "payload.order", model:) is not one of these exclusions — it reflects like any nested model, with the client sending the nested <field>_id (order.widget_id).
Ruby-object input types are coercible
The schema advertises each type: as its JSON wire form — so expects :on, type: Date shows { type: "string", format: "date" } and expects :mode, type: Symbol shows { type: "string" }. Add coerce: (see coerce above) so a JSON client sending the string "2026-07-08" or "active" is parsed into the declared Date/Symbol — the inbound inverse of how the value serializes on output. Without coerce:, core still validates strictly against the Ruby type (a direct Ruby caller must pass a real Date).
Declare type: on every tool input
Axn::Tools::Invoker (the entry point an adapter uses to run an Axn as a tool) always coerces, so a field only picks up that coercion — and gets a useful entry in the schema above — when it declares a type: axn recognizes. Declare type: on every tool input rather than reaching for a defensive per-field coerce: true; the always-on tool coercion and the schema reflection both key off the same type: declaration.