Module: Axn::Internal::Reflection::Schema

Defined in:
lib/axn/internal/reflection/schema.rb

Overview

Builds JSON Schema (input/output) from an Axn's declared contract. Read-only, off the execution path — it inspects declared field configs, never runs the action or its validators.

REQUIREDNESS IS DERIVED FROM DECLARED SIGNALS, NOT BY VALIDATING. A field is omittable (absent from required) when a declared signal says so — a usable default, or a nil-tolerant validator set (optional:/allow_nil:/allow_blank:). A field that rejects nil by type alone (allow_empty: true) stays required and non-nullable: emptiness is permitted, absence is not. We deliberately do NOT run the field's validators against its default to confirm the omitted call would actually pass; that duplicate-validation pass was expensive and fragile. The tradeoff is a documented divergence, narrow: a non-blank but otherwise-invalid default (type: String, default: 123; type: :uuid, default: "nope") is reflected as optional though the omitted call fails at runtime. The safe direction (schema stricter than runtime) never causes failed calls; the unsafe case above only arises from a self-contradictory contract and surfaces as a normal, recoverable validation error. A required subfield at ANY depth forces its whole ancestor chain required and non-nullable (a nil/omitted ancestor yields every descendant absent, PRO-2857).

Defined Under Namespace

Classes: NodeAnnotation, ShapePropertyPlan

Constant Summary collapse

TYPE_MAP =
{
  String => "string",
  Symbol => "string",
  Integer => "integer",
  Float => "number",
  Numeric => "number",
  Hash => "object",
  Array => "array",
  # NOTE: TrueClass/FalseClass are intentionally absent — TypeValidator accepts only the singleton
  # value, so single_type_for reflects them as boolean + a single-member enum, not the full domain.
  Date => "string",
  DateTime => "string",
  Time => "string",
}.freeze
FORMAT_MAP =
{
  Date => "date",
  DateTime => "date-time",
  Time => "date-time",
}.freeze
SIZE_CONSTRAINT_KEYS =

JSON Schema spells the emptiness floor differently per type. A type absent here (integer, boolean, number) has no empty state, so no floor is expressible for it.

{
  "array" => :minItems,
  "object" => :minProperties,
  "string" => :minLength,
}.freeze
EXCLUDED_FROM_INPUT_SCHEMA =
%i[ambient_context].freeze
SEGMENT_JUDGED_SCALARS =

The builtin scalars whose reader-method surface we judge as the class's own public methods: an instance answers a segment read iff the declared class publicly defines the method (post-PRO-2886 extraction: a Hash-like source reads any key; everything else is a public_send). Anything outside this list — Data/Struct/custom classes, model records — may answer dynamically, so it is never judged (optimistic: rejection needs proof).

ACCEPTED DIVERGENCE from the strict no-false-rejection doctrine. TypeValidator is is_a?, so a type: String value can be a String SUBCLASS that adds methods, or a plain String carrying a singleton method — either is contract-valid yet answers a segment this judgment refutes. We judge anyway, deliberately: the approved design takes the DECLARED class's method surface as the contract (type: String promises the String surface, not whatever an exotic subclass bolts on), so a subclass adding readers doesn't hold the declaration hostage. The conventional instance of each listed class IS exactly that class, so the judgment matches real inputs; the subclass/ singleton case is the narrow, documented exception. The membership test below is k <= s, so a declared class equal to (or a subclass of) a judged entry is judged on that entry's surface.

Numeric and Date are excluded — the boundary is drawn narrower there for a different reason: every contract-valid type: Numeric value is a STRICT subclass (Integer/Float/Rational/ BigDecimal/…) whose surface is wider than Numeric itself (Integer#bit_length exists but Numeric.public_method_defined?(:bit_length) is false), and type: Date admits DateTime (adding hour/minute/…). There the subclass IS the conventional instance, so judging on the abstract class would refute a segment ordinary valid input answers — a real false positive — so both stay optimistic, same as Data/Struct/unknown classes.

[String, Symbol, Integer, Float, Array, DateTime, Time, TrueClass, FalseClass].freeze
FRAMEWORK_SERIALIZATION_OWNERS =

active_support reopens Data/Struct/Hash (and Object) with member-keyed as_json/to_h; those owners are safe. Any other owner means the value class (or an included module) overrides the method, which serialize_value would follow — so the serialized shape is no longer provably an object keyed by the declared members.

[Data, Struct, Hash, Object].freeze
FLIPPABLE_JSON_TYPES =

Whether inbound coercion could flip the Ruby truthiness of the referenced field between its wire value and its settled value — the ONLY way coercion changes a truthiness judgment, and the reason an unless: gate can't be emitted declaratively for such a field. Coerce-or-leave (Coercion.coerce_value) transforms String wire values through the parse-based COERCERS, and — for a :boolean target specifically — a non-String value too (Coercion#coerce_boolean also accepts an Integer, per its acceptance table: idempotent true/false, integer 0/1, and FALSY_STRINGS/TRUTHY_STRINGS). Among the coercible targets (Coercion::SUPPORTED) only :boolean maps a truthy wire value to a falsey Ruby value — Date/Time/Integer/Float/Symbol all yield a truthy value from a truthy String, and a schema-valid boolean is already true/false (idempotent, no flip). A flip is therefore possible only when the ref's declared type BOTH (a) admits the :boolean coercion branch AND (b) admits some OTHER branch whose schema-valid wire values include one coerce_boolean maps to false — i.e. a branch admitting a FALSY_STRINGS member (a JSON string branch) or admitting integer 0 (a JSON integer/number branch, since coerce_boolean checks value.zero? before any type-specific parse). A string+format branch (Date/Time) still counts: JSON Schema treats format as annotation-only by default, so the schema still admits an arbitrary String wire value the coercer can reach. A plain :boolean-only property emits no other branch, so no schema-valid input can reach the falsey path — no flip. AND (c) coercion isn't explicitly disabled: explicit coerce: false can't flip; an explicit coerce: true can; an ABSENT flag with a coercible branch is treated as flippable (the class-level coerce_input_types override may enable coercion, and reflection must not resolve per-class config — conservative toward the safe fallback). Declared-config inspection only, side-effect-free (single_type_for is pure).

%w[string integer number].freeze
PARAMS_CLASS_NAME =

Parameters is identified by rendered class NAME rather than by the constant: this file is one an adapter gem loads directly, and naming a Rails constant here would put an unresolvable reference in its load graph for every consumer running without Rails. It is the same identify-by-name form TypeValidator already uses to recognize a test double, and the rendering is read natively (Internal::ClassName.of_module) so a class cannot answer this question for itself.

"ActionController::Parameters"
EMPTY_CONTAINER_CLASSES =

The container classes whose empty? is RUBY'S OWN — the ones the emptiness axis is declared on. Set sits behind defined? because set is not always loaded.

[::Hash, ::Array, ::String].freeze

Class Method Summary collapse

Class Method Details

.annotate_node!(node, ann, satisfiability: false) ⇒ Object

Post-order: a node's annotation only depends on its (already-annotated) children.



433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
# File 'lib/axn/internal/reflection/schema.rb', line 433

def annotate_node!(node, ann, satisfiability: false)
  node.children.each_value { |child| annotate_node!(child, ann, satisfiability:) }
  credit_sibling_id_defaults!(node, ann) if satisfiability

  # ANCESTOR-FORCING is derived from the RELAXABLE-filtered subset of the node's configs: a route
  # whose requiredness a conditional gate can relax at runtime can't oblige an omitted/nil
  # ancestor to be present — only a route with an UNGATED nil-rejecting check can. That covers
  # both a declaration-level gate (`if:`/`unless:` on the whole declaration) AND a per-validator
  # nested gate on every check that could reject nil (e.g. `presence: { if: -> { data.present? } }`
  # — the presence is gated off when the ancestor is absent, so the omitted ancestor validates).
  # Passing the filtered subset to node_optional? (rather than the full set, then subtracting a
  # fully-gated node afterward) is what makes a MIXED node correct: a node merged from an
  # ungated-but-omittable route (e.g. `optional: true`) and a gated-required route forces nothing,
  # because its only ancestor-relevant obligation — the ungated route — is itself omittable. The
  # prior two-step form (full-set node_optional? then relax only when EVERY config is gated)
  # over-forced exactly that shape, wrongly rejecting a runtime-valid contract in satisfiability mode.
  #
  # This is ONLY the ancestor-propagation signal. Own-level emission stays static-maximal: the
  # emission sites (apply_children!/field_optional?) call node_optional? with the full or
  # per-route config set directly, so a gated route's own nested `required` obligation is
  # unchanged. Edge cases preserved: an implicit node ignores the `configs` param inside
  # node_optional? (a pure subtree test), so its ancestor-forcing is untouched; a fully-relaxable
  # node yields an empty subset, and `[].all?` is vacuously true → node_optional? true → not
  # required; an all-ungated node passes its full set (unchanged).
  # The satisfiability short-circuit inside node_optional? (the usable_default? line) still reads
  # the FULL node.configs regardless of the param, so a node-level default keeps rescuing every
  # route. Mode-independent: satisfiability mode needs it so a declared tolerance above a gated
  # child is exercisable (not dead), and strict mode honors the ancestor's own declared optionality
  # instead of inventing strictness the declaration disavowed (the design doc's "one deliberate
  # exception").
  required = !node_optional?(node, ann, node.configs.reject { |c| requiredness_conditionally_relaxable?(c) }, satisfiability:)

  if node.implicit?
    # An implicit node's nullability has no config of its own to consult (required IS the transitive
    # presence test here), so it's simply the inverse.
    nullable = !required
  else
    # required_child? (and apply_nested_subfields!'s nullability line it feeds) always reasons about
    # the node's non-model representative config — the same one apply_children! emits the property from,
    # read through the one owner of that rule (property_representative). A node with no non-model
    # config (a pure model: route) never nests, so its nullable is unused; false is an inert default.
    representative = property_representative(node.configs)
    nullable = representative ? nil_allowed?(representative) && !required_child?(representative, node.children, ann) : false
  end

  ann[node] = NodeAnnotation.new(required:, nullable:)
end

.apply_children!(prop, children, parent_configs, ann) ⇒ Object

Emits one level of children into prop (which must already have :properties/:required arrays), recursing into each child's own subtree. parent_configs are the configs whose subfields these children are — used to decide, by the same predicate as the drop pass, whether an implicit child may merge into a colliding shape member. They are the top-level/subfield configs at an explicit parent (ALL of them at a merged node, mirroring SubfieldTree), or the shape members an implicit intermediate merged into (so nested members block at depth), or empty for a fresh implicit intermediate that claimed no shape member.

A single wire path can be declared via two routes (Node#configs size > 1), and the routes can disagree on kind: a model: route emits the generated <leaf>_id while a plain route emits the object property. Both are enforced at runtime, so both are emitted, each required per its OWN route's configs — not the node as a whole.

ACCEPTED DIVERGENCE (looser-than-runtime, the only such case here): at a merged model+non-model node the non-model route's raw-key object property admits an object value that runtime ALWAYS rejects — the model resolver reads the raw key as the record, and a JSON object is never a model instance, so only absent/null are JSON-satisfiable. Left as-is: sending the object yields a normal, recoverable validation error, and the generated <leaf>_id already advertises the working path.



990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
# File 'lib/axn/internal/reflection/schema.rb', line 990

def apply_children!(prop, children, parent_configs, ann)
  required_model_ids = []
  children.each do |key, node|
    if node.implicit?
      apply_implicit_node!(prop, key, node, parent_configs, ann)
      next
    end

    model_configs = node.configs.select { |c| c.validations[:model] }
    non_model_configs = node.configs.reject { |c| c.validations[:model] }
    # The object property is built from ONE of them; see property_representative, which every layer that
    # has to name that config reads (requiredness annotation, and the size cap's shape charge).

    unless model_configs.empty?
      # The id key derives from the LEAF wire segment (a dotted model name digs `<leaf>_id` off
      # the same nested parent at runtime). A user may declare an explicit nested `<field>_id`
      # subfield; don't clobber it with the generic model-generated one.
      id_field = Internal::FieldConfig.model_id_key(key)
      _, subprop = model_id_property(model_configs.first)
      prop[:properties][id_field] ||= subprop
      unless node_optional?(node, ann, model_configs)
        prop[:required] << id_field.to_s
        required_model_ids << id_field
      end
    end

    representative = property_representative(node.configs)
    next unless representative

    child_prop = build_property(representative, subfield: true)
    apply_nested_subfields!(child_prop, node, ann)
    # `null` survives only when every non-model route tolerates nil (runtime enforces all of them;
    # the property itself is built from the first non-model config) AND no required descendant is
    # stranded — a nil node yields every descendant absent (PRO-2857), so a required one below it
    # forbids nil even for a non-object node whose subfield shape isn't nested here.
    null_ok = non_model_configs.all? { |c| nil_allowed?(c) } && !subtree_requires_presence?(node, ann)
    reject_null!(child_prop) unless null_ok
    prop[:properties][key] = child_prop.compact
    prop[:required] << required_key(key) unless node_optional?(node, ann, non_model_configs)
  end
  # A required nested model id can't be null (a null token resolves the model to nil at runtime).
  # Done after the loop so it survives an explicit id subfield declared after the model: subfield.
  required_model_ids.each { |id_field| reject_null!(prop[:properties][id_field]) if prop[:properties][id_field] }
end

.apply_implicit_node!(prop, key, node, parent_configs, ann) ⇒ Object

An implicit node (a dotted-path intermediate with no declaration of its own) emits a bare object property whose only content is its children. When a shape: member of any parent_configs claims the key, merge into it only if EVERY colliding member is nestable_as_object? — the SAME predicate on the SAME member configs that blocking_ancestor? uses (it scans ALL of the node's configs), so emission and the drop pass agree: a non-nestable member (a scalar, or a mixed union like type: [Hash, Array]) on ANY route blocks and its deep configs stay in dropped_deep_subfields rather than forcing a self-contradictory property. The block is judged from the member configs directly, NOT from a pre-seeded property: at a merged node the object property is built from the first non-model config, so a scalar member declared on a LATER config seeds nothing to collide with, yet must still block (matching SubfieldTree, which scans every config).

A blocked merge omits the deep SHAPE but not the deep OBLIGATION: runtime validates the dropped subfields regardless of representability, so when the dropped subtree requires presence (subtree_requires_presence? — the same predicate used everywhere) the colliding member's own property still inherits that obligation. The member is forced required and its null admission stripped (reject_null! handles both type: arrays and anyOf unions) — because a nil/absent member strands the required descendant (PRO-2857). Nothing else about the member is touched (no forced object type, no properties — its shape stays dropped). An all-optional dropped subtree strands nothing, so the member keeps its declared flags (runtime accepts omission/nil there).



1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
# File 'lib/axn/internal/reflection/schema.rb', line 1054

def apply_implicit_node!(prop, key, node, parent_configs, ann)
  members = shape_members_at(parent_configs, key)
  if members.any? { |member| !nestable_as_object?(member) }
    if subtree_requires_presence?(node, ann)
      prop[:required] << required_key(key)
      reject_null!(prop[:properties][key]) if prop[:properties][key]
    end
    return
  end

  # Carry the (all-nestable) colliding members as the parent configs for this node's own children,
  # so a deeper implicit hop tests their NESTED shape members (a member-of-a-member). Same members
  # the drop pass carries, so the two agree at depth.
  existing = prop[:properties][key]
  target = existing || {}
  target.delete(:format)
  target[:properties] ||= {}
  target[:required] ||= []
  apply_children!(target, node.children, members, ann)
  target[:required] = target[:required].uniq
  # A fresh implicit intermediate is nullable exactly when nothing beneath requires presence (a nil
  # parent digs every descendant to nil, PRO-2857) — the precomputed annotation's bare nullable (an
  # implicit node has no config of its own to collide against). A shape-member collision additionally
  # caps it by the members' OWN nil-tolerance — nullable only when EVERY colliding member tolerates
  # nil (runtime enforces all routes), read from each config via nil_allowed? (the same predicate the
  # parent nesting uses) never sniffed off the emitted property: an untyped nil-tolerant member emits
  # no `type`, so a null branch is invisible there and property-sniffing would force it non-nullable
  # though runtime accepts a nil member. With no colliding member, an existing merge target (e.g. a
  # Data placeholder property with no shape member) falls back to non-nullable (stricter than
  # runtime), while a genuinely fresh node (no property, no member) follows its subtree.
  nullable = ann[node].nullable &&
             (members.any? ? members.all? { |m| nil_allowed?(m) } : existing.nil?)
  target[:type] = nullable ? %w[object null] : "object"
  target[:required] = nil if target[:required].empty?
  prop[:properties][key] = target.compact
  prop[:required] << required_key(key) if ann[node].required
end

.apply_member_size_constraints(members, minimum) ⇒ Object

A union emits one branch per member type instead of a single type:, and the validators reject empty whichever branch the value takes — so the floor belongs on every branch that could hold an empty value. A branch with no empty state (an integer member) and the nullability branch carry none, decided by the same size-bearing test the single-type path uses, applied per branch.



1272
1273
1274
1275
1276
1277
# File 'lib/axn/internal/reflection/schema.rb', line 1272

def apply_member_size_constraints(members, minimum)
  members.map do |member|
    key = size_constraint_key_for(member[:type])
    key ? member.merge(key => minimum) : member
  end
end

.apply_model_id_requiredness!(config, children, field_configs, properties, required, ann) ⇒ Object

A model lookup needs a non-nil token. Single source of truth for the generated <field>_id's requiredness AND nullability, considering the model field plus any explicit <field>_id sibling (order-independent — runs after all properties are built).

The id is OMITTABLE only when the model field itself is omittable (a nil-tolerant model, or one with its own usable default) AND no descendant requires presence per its own annotation (a defaulted descendant is self-rescuing at read time). A subfield default now applies at read time at any depth under a model — value-level defaults, PRO-2889, no synthesis involved — so a defaulted descendant resolves to its own value and never forces the id; only a descendant with no rescuing signal (no usable default, not nil-tolerant) strands an omitted record and keeps the id required. OR an explicit <field>_id sibling carries a usable DEFAULT (inbound defaults supply the token before the lookup). A merely nullable/optional explicit id with no default doesn't help. When the id IS required it also can't be null, so any null branch is stripped.

KNOWN LIMITATION (accepted divergence): this covers a shallow model field and its explicit shallow id sibling. Self-referential id/model contracts nested under a parent (a model: subfield with a sibling defaulted <field>_id subfield) are not reconciled here — the parent may reflect as required though runtime synthesizes it. That is the safe direction (stricter than runtime).



1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
# File 'lib/axn/internal/reflection/schema.rb', line 1572

def apply_model_id_requiredness!(config, children, field_configs, properties, required, ann)
  id_field, = model_id_property(config)
  explicit_id = field_configs.find { |c| c.field == id_field }
  # A default at ANY depth under the model applies at read time (value-level defaults,
  # PRO-2889) — no synthesis is involved — so descendant omittability is the ordinary
  # annotation-derived rule, same as every other parent.
  model_omittable = optional_for_schema?(config) && !children_require_presence?(children, ann)
  return if model_omittable || (explicit_id && usable_default?(explicit_id, subfield: false))

  key = id_field.to_s
  required << key unless required.include?(key)
  reject_null!(properties[id_field]) if properties[id_field]
end

.apply_nested_subfields!(prop, node, ann) ⇒ Object

Mutates prop to nest the node's children as prop[:properties]/prop[:required], recursing through the whole subtree. Forces the parent to type: object (it now has structure). The parent is nullable only when it tolerates nil AND strands no required descendant: runtime treats a nil parent as "subfields absent" (PRO-2857), so a nil-accepting parent with an all-optional subtree accepts null, while a required descendant (which a nil parent can't yield) keeps it object-only. Only applies when EVERY admissible parent type is object-shaped (Hash/:params/untyped) — a non-object parent (type: Array) or a mixed union (type: [Hash, Array]) keeps its declared type(s) and its subfields' shape is omitted, since object properties can't represent a non-object branch (deep descendants there are in dropped_deep_subfields; its children still shape requiredness via required_child?, matching runtime). node's own representative config (the FIRST non-model config at a merged node) shapes the property itself (type, nullability) — see NodeAnnotation. node.configs is EVERY config at the node: it decides both whether to nest at all (node_configs_block_nesting?, the same predicate the drop pass uses, so a route the tree drops from is never re-nested) and, threaded on as parent configs, which shape: members might collide with an implicit child.



937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
# File 'lib/axn/internal/reflection/schema.rb', line 937

def apply_nested_subfields!(prop, node, ann)
  children = node.children
  return if children.empty?

  node_configs = node.configs
  if node_configs_block_nesting?(node_configs)
    # A non-nestable parent (non-object type, mixed union, or model route) omits its children's
    # SHAPE but NOT their OBLIGATION: field_optional? still forces the parent required when a child
    # requires presence, so its nullability must agree. A nil parent yields every descendant absent
    # (PRO-2857), stranding the required descendant, so strip the parent's `null` admission
    # (reject_null! handles both a type array and an anyOf union) — mirroring the nested-child guard
    # in apply_children!. Predicate: children_require_presence?(children), the same transitive
    # presence test as the nested analog's subtree_requires_presence?(node); required_child?'s
    # shape-synthesis clause is inert for a non-object parent, so the plain presence test is exact
    # and keeps the two sites' reasoning identical.
    reject_null!(prop) if children_require_presence?(children, ann)
    return
  end

  prop.delete(:format)
  prop[:properties] ||= {}
  prop[:required] ||= []

  apply_children!(prop, children, node_configs, ann)

  prop[:required] = prop[:required].uniq
  # A nil parent yields its subfields as absent, so `null` is admissible exactly when the parent
  # accepts nil and no required nested obligation is stranded (required_child? — which counts a
  # required shape member only when the parent's OWN default materializes it). Read from the
  # precomputed annotation (derive_annotations already applied this same rule to `node`), NOT
  # `prop[:required]`, which also carries shape members that a bare nil parent never triggers.
  prop[:type] = ann[node].nullable ? %w[object null] : "object"
  prop[:required] = nil if prop[:required].empty?
end

.apply_size_constraints!(prop, config) ⇒ Object

The emptiness axis, as JSON Schema sees it: minItems/minProperties/minLength keyed off the emitted type. A field rejects empty when it carries an explicit length minimum, or when the default presence check applies without blank-tolerance — presence is !blank?, so it forbids the empty value too. Only allow_blank is consulted, never allow_nil: nil-tolerance is the other axis and says nothing about whether an empty value is admissible. Emitting this is what keeps a required collection's schema from advertising [] as acceptable when the runtime rejects it — which is why it follows the emitted type into a union's anyOf branches as well as a single type:. For a String under presence: the runtime also rejects whitespace-only values, which minLength cannot express, so the emitted constraint stays a floor rather than an exact mirror.



1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
# File 'lib/axn/internal/reflection/schema.rb', line 1257

def apply_size_constraints!(prop, config)
  minimum = declared_size_minimum(config)
  return unless minimum

  if prop[:anyOf]
    prop[:anyOf] = apply_member_size_constraints(prop[:anyOf], minimum)
  elsif (key = size_constraint_key_for(prop[:type]))
    prop[key] = minimum
  end
end

.apply_structured_schema!(prop, config, for_output:) ⇒ Object

Combine of: (bare element baseline) and shape: (typed member contracts) into items:/properties:. Precedence: shape: enriches/overrides of: baseline.



1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
# File 'lib/axn/internal/reflection/schema.rb', line 1326

def apply_structured_schema!(prop, config, for_output:)
  return unless config.validations[:of] || config.validations[:shape]

  plan = shape_property_plan(config, for_output:)
  # The shape the PLAN carries, never a second read of the config: one answer to which members are
  # emitted here, so a rule charged against the plan and this emission cannot walk different lists.
  shape = plan.shape

  if plan.in_items?
    # The plan's own type schema, not a second `items_schema_for` call: one build, and the plan is then
    # literally what gets emitted rather than a parallel derivation of it.
    items = plan.type_schema
    if shape && plan.emitted
      member_props, required = member_properties(shape[:members], for_output:)
      items = items.merge(type: "object", properties: plan.base_properties.merge(member_props))
      items[:required] = required unless required.empty?
    end
    prop[:items] = items unless items.empty?
  elsif shape
    return unless plan.emitted

    prop[:type] = nil_allowed?(config) ? %w[object null] : "object"
    prop.delete(:format)
    member_props, required = member_properties(shape[:members], for_output:)
    prop[:properties] = plan.base_properties.merge(member_props)
    prop[:required] = required unless required.empty?
  end
end

.apply_type_info!(prop, type_info, config, nullable:) ⇒ Object

Writes the resolved JSON type (and nullability/format/singleton-enum) from json_type_for into prop.



1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
# File 'lib/axn/internal/reflection/schema.rb', line 1232

def apply_type_info!(prop, type_info, config, nullable:)
  if type_info[:anyOf]
    members = type_info[:anyOf]
    members = drop_uuid_format(members) if type_allows_blank?(config)
    prop[:anyOf] = nullable ? members + [{ type: "null" }] : members
  elsif type_info[:type]
    prop[:type] = nullable ? [type_info[:type], "null"] : type_info[:type]
    # A `type: :uuid, allow_blank: true` field accepts "" at runtime (TypeValidator treats a blank
    # uuid as valid under allow_blank), but a strict `format: "uuid"` validator would reject "".
    # Drop the uuid format there so the schema doesn't reject a value the contract accepts.
    prop[:format] = type_info[:format] if type_info[:format] && !(type_info[:format] == "uuid" && type_allows_blank?(config))
    # A singleton type (TrueClass/FalseClass) constrains the value via enum; nil joins it when nullable.
    prop[:enum] = nullable ? type_info[:enum] + [nil] : type_info[:enum] if type_info[:enum]
  end
end

.blank_default_rejected?(config) ⇒ Boolean

Whether this field's own checks would reject the blank/empty literal value its default supplies — THE single definition of "can this default relax the field", read both when judging the default's usability and when deciding requiredness (an omitted call resolves the default, so a rejected one cannot be omitted). Two checks govern blankness and either can be the only one present, so both are asked, each against the value IT rejects:

* a presence validator rejects a BLANK value (`presence_blank?`) — `presence: true` does,
absent/`presence: false` does not, `presence: { allow_blank: true }` accepts it (`allow_nil`
alone doesn't help a non-nil blank like ""/{}/[]);
* `allow_empty: false`'s own check rejects an EMPTY one (`empty_default?`), which is a different
value set: a whitespace-only String default is blank but not empty, and passes.

A Proc default is unknowable at declaration (usable_default? settles it before reaching here) and a non-applied subfield default supplies nothing to reject. Gates are deliberately not consulted, as everywhere else on the input side: a gated check is counted as if it ran.

Returns:

  • (Boolean)


822
823
824
825
826
827
828
829
830
# File 'lib/axn/internal/reflection/schema.rb', line 822

def blank_default_rejected?(config)
  return false unless config.respond_to?(:default)

  value = config.default
  return false if value.nil? || value.is_a?(Proc)
  return true if presence_blank?(value) && presence_rejects_blank?(config.validations)

  empty_default?(value) && config.validations.key?(Axn::Internal::FieldConfig::NON_EMPTINESS_KEY)
end

.boolean_coercion_can_flip_truthiness?(ref) ⇒ Boolean

Returns:

  • (Boolean)


723
724
725
726
727
728
729
730
731
732
733
734
735
# File 'lib/axn/internal/reflection/schema.rb', line 723

def boolean_coercion_can_flip_truthiness?(ref)
  type_opt = ref.validations[:type]
  return false unless type_opt

  if type_opt.is_a?(Hash)
    klasses = Array(type_opt[:klass])
    return false if type_opt[:coerce] == false
  else
    klasses = Array(type_opt)
  end

  klasses.include?(:boolean) && klasses.any? { |k| FLIPPABLE_JSON_TYPES.include?(single_type_for(k, for_output: false)[:type]) }
end

.branch_answers_segment?(branch, segment) ⇒ Boolean

Whether ONE admissible declared branch can answer reading segment off its value.

Returns:

  • (Boolean)


356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# File 'lib/axn/internal/reflection/schema.rb', line 356

def branch_answers_segment?(branch, segment)
  return true if branch == :params

  klasses = case branch
            when :uuid then [String]
            when :boolean then [TrueClass, FalseClass]
            else [branch]
            end
  klasses.any? do |k|
    next true unless k.is_a?(Class)
    next true if k <= Hash

    judged = SEGMENT_JUDGED_SCALARS.any? { |s| k <= s }
    !judged || k.public_method_defined?(segment)
  end
end

.build_input(field_configs, subfield_configs = [], resolved: nil, klass: nil) ⇒ Object



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/axn/internal/reflection/schema.rb', line 148

def build_input(field_configs, subfield_configs = [], resolved: nil, klass: nil)
  tree = resolved&.tree || Axn::Internal::SubfieldTree.build(field_configs, Array(subfield_configs))
  ann = resolved&.annotations || derive_annotations(tree.roots)
  properties = {}
  required = []
  conditionals = []

  field_configs.each do |config|
    next if EXCLUDED_FROM_INPUT_SCHEMA.include?(config.field)

    node = tree.roots[config.reader_as]
    if config.validations[:model]
      # Emit the generated `<field>_id` property (don't clobber an explicitly-declared one).
      # Its requiredness/nullability is decided in the post-pass below so it can account for an
      # explicit `<field>_id` sibling regardless of declaration order.
      id_field, id_prop = model_id_property(config)
      properties[id_field] ||= id_prop
    else
      prop = build_property(config)
      apply_nested_subfields!(prop, node, ann)

      properties[config.field] = prop.compact
      unless field_optional?(config, node.children, ann)
        clause = conditional_requiredness_clause(config, field_configs, node, klass)
        clause ? conditionals << clause : required << required_key(config.field)
      end
    end
  end

  # Second pass (after all properties exist, so it's independent of declaration order): decide each
  # generated model `<field>_id`'s requiredness/nullability from the model field + its explicit sibling.
  field_configs.select { |config| config.validations[:model] }.each do |config|
    children = tree.roots[config.reader_as].children
    apply_model_id_requiredness!(config, children, field_configs, properties, required, ann)
  end

  schema = { type: "object", properties: }
  schema[:allOf] = conditionals unless conditionals.empty?
  schema[:required] = required.uniq unless required.empty?
  schema
end

.build_input_for(klass) ⇒ Object

Subfields nest recursively: a dotted on: path, a subfield of a subfield, and a dotted field name all become nested object properties keyed by wire key (SubfieldTree resolves reader aliases and dotted segments once, up front). A STRUCTURAL EXCLUSION remains: 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, so it's omitted — surfaced via dropped_deep_subfields / the input_schema warning. A depth-1 subfield under such a parent is silently omitted (the parent keeps its declared type), as ever.

resolved: accepts a prebuilt ResolvedSubfields artifact (the per-class cache) so callers on a repeated path skip the tree build + annotation derivation; it must have been built from the same configs. Without it, both are computed fresh — the standalone entry point is unchanged. The inbound projection OF A CLASS. The one place build_input's argument list is assembled from a class, so the reflected reader and the setup-time validator cannot drift into building two different schemas from the same declaration.



144
145
146
# File 'lib/axn/internal/reflection/schema.rb', line 144

def build_input_for(klass)
  build_input(klass.internal_field_configs, klass.subfield_configs, resolved: klass._resolved_subfields, klass:)
end

.build_output(field_configs) ⇒ Object

Every exposed field is always present in the serialized output: Values.serialize_exposed iterates every outbound config and emits its key (value nil when unset). JSON Schema required means property PRESENCE, not non-nullness, so every exposed field is required; nullability is carried by the property type ("null").



1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
# File 'lib/axn/internal/reflection/schema.rb', line 1105

def build_output(field_configs)
  properties = {}
  required = []

  field_configs.each do |config|
    properties[config.field] = build_property(config, for_output: true).compact
    required << required_key(config.field)
  end

  schema = { type: "object", properties: }
  schema[:required] = required.uniq unless required.empty?
  schema
end

.build_property(config, for_output: false, subfield: false) ⇒ Object



1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
# File 'lib/axn/internal/reflection/schema.rb', line 1178

def build_property(config, for_output: false, subfield: false)
  prop = {}
  # `#description` is beyond the documented member contract (see declared_attribute).
  description = declared_attribute(config, :description)
  prop[:description] = description if description

  # OUTPUT safety runs the other direction from input: the property must admit a SUPERSET of
  # what the serializer can emit. A closed outbound gate skips EVERY validator (not just
  # presence), so the exposed value can be anything the action assigned — no type/format/enum/
  # default is assertable. Leave the property untyped (description only): untyped is the only
  # superset of an unconstrained value. Mirrors the module's output doctrine of leaving a value
  # untyped rather than asserting a type the serialized value could contradict.
  return prop if for_output && conditionally_gated?(config)

  # OUTPUT-EFFECTIVE validations (see effective_validations, the one derivation of them): everything
  # below reads the config through that subset, so a per-validator gate drops the same entry here as
  # in the plan every property-name rule is charged against. Rebuild the config only when an entry
  # actually drops, judged against the SAME read of `validations` the reduction was given — a
  # caller-supplied member's reader may mint a fresh Hash per read, so comparing against a second read
  # would rebuild every config (and a duck-typed member answers no `with` at all).
  declared = config.validations
  effective = effective_validations(declared, for_output:)
  config = config.with(validations: effective) unless effective.equal?(declared)

  type_info = json_type_for(config.validations, for_output:)
  nullable = nil_allowed?(config)
  apply_type_info!(prop, type_info, config, nullable:)

  declared_default = declared_attribute(config, :default)
  if !declared_default.nil? && !declared_default.is_a?(Proc)
    # Only a truthy subfield default is applied at runtime, so a falsey `default: false` subfield
    # must not advertise a default the runtime never applies. Top-level defaults apply by key-presence.
    emit_default = subfield ? config.applied_default? : true
    prop[:default] = normalize_schema_literal(declared_default) if emit_default
  end

  if (inclusion = config.validations[:inclusion])
    enum_values = inclusion_enum_values(inclusion)
    prop[:enum] = enum_for_inclusion(enum_values, nullable:) if enum_values
  end

  apply_structured_schema!(prop, config, for_output:)

  # LAST, because the floor's KEY is chosen from the property's type (`minItems`/`minProperties`/
  # `minLength`) and a shape block is what establishes that type: a custom class or module carrying one
  # holds the permissive fallback until `apply_structured_schema!` rewrites it to `object`. Deriving
  # the key any earlier reads an intermediate type and lands the floor under a key that cannot express
  # it. Nothing above depends on the constraint already being there.
  apply_size_constraints!(prop, config)

  prop
end

.children_require_presence?(children, ann) ⇒ Boolean

Whether any direct child node may NOT be omitted from the parent object — a read of each child's own precomputed annotation, never a fresh descent into its subtree.

Returns:

  • (Boolean)


542
543
544
# File 'lib/axn/internal/reflection/schema.rb', line 542

def children_require_presence?(children, ann)
  children.values.any? { |node| ann[node].required }
end

.condition_reference(rule, field_configs) ⇒ Object

The declared top-level inbound field a Symbol condition reads: an exact reader-name match, or — for a ?-suffixed Symbol — the boolean field whose generated predicate alias it names. The condition reads the READER; the emitted schema keys by the field's WIRE key.



689
690
691
692
693
694
695
696
697
# File 'lib/axn/internal/reflection/schema.rb', line 689

def condition_reference(rule, field_configs)
  name = rule.to_s
  exact = field_configs.find { |c| c.reader_as.to_s == name }
  return exact if exact
  return nil unless name.end_with?("?")

  base = name.delete_suffix("?")
  field_configs.find { |c| c.reader_as.to_s == base && c.boolean? }
end

.conditional_requiredness_clause(config, field_configs, node, klass) ⇒ Object

An exact JSON Schema conditional for a gated-but-otherwise-required top-level field whose single Symbol condition references a declared sibling field. Ruby truthiness on a JSON value is precisely "present, and neither false nor null", so the emitted clause matches the runtime gate exactly. Returns nil — fall back to unconditional required, the static-maximal safe direction — unless EVERY guard holds:

* exactly one gate (if: XOR unless:), and its rule is a Symbol;
* the Symbol resolves to a declared top-level inbound field's reader (condition_reference);
* the referenced field carries no default: and no preprocess: (either can make the settled
runtime value diverge from what the caller sent, flipping the gate relative to the wire)
and is not model:-routed (lookup success isn't wire-expressible) nor schema-excluded;
* for an unless: gate, the referenced field's type can't admit boolean coercion of a
schema-admissible wire value coerce_boolean maps to false — a falsy STRING or the integer 0
(boolean_coercion_can_flip_truthiness?). Coercion only flips a truthy wire value to falsey:
for an if: gate that direction keeps the emitted `then`
stricter than runtime (safe — still emitted), but for an unless: gate it opens the runtime
`else` gate the emitted clause left closed (looser than runtime — fall back);
* (a subfield default BENEATH the referenced field needs no guard: value-level defaults
resolve the child's value on the read path and never synthesize the parent — PRO-2903 —
so a wire-omitted referenced field settles nil/falsey exactly as the clause reads it;
a subfield preprocess likewise never materializes an absent root);
* the referenced reader is the FRAMEWORK-GENERATED one — a Symbol condition names a reader
method, but a user can suppress predicate generation (a pre-existing `?` method) or
redefine a plain reader after `expects`, and runtime would then evaluate the USER method
against the settled value while the clause conditions on the wire value. Verified via
source_location against the generation site (framework_generated_reader?), pure
introspection. `klass` is nil for direct build_input callers → fall back (safe direction);
* the gated field is not model:-routed and has no subfields of its own (a required
descendant unconditionally forces the field, contradicting a conditional requirement);
* no NIL-REJECTING validator entry carries a per-validator (nested) gate key — blank or not.
The clause models the DECLARATION gate; a nested gate on a nil-rejecting entry un-ties that
entry from it (AM's measured per-key merge): a blank same-key override un-gates the entry
(unconditionally required — clause looser than runtime), and a non-blank nested gate ties it
to a different condition (also inexact). Nil-TOLERANT nested-gated entries are harmless.


641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
# File 'lib/axn/internal/reflection/schema.rb', line 641

def conditional_requiredness_clause(config, field_configs, node, klass)
  return nil if config.validations[:model] || node.children.any?

  gates = config.validations.slice(*Internal::FieldConfig::CONDITIONAL_GATE_KEYS)
  return nil unless gates.size == 1

  # The emitted clause conditions requiredness on exactly this DECLARATION gate — exact only if
  # every nil-rejecting validator entry inherits that gate unmodified. A nested gate KEY on such an
  # entry breaks that (AM's measured per-key merge, fields.rb#validator_gate_open?): a BLANK
  # same-key nested override un-gates the entry, making it unconditionally required (clause looser
  # than runtime), while a NON-blank nested gate ties the entry to a DIFFERENT condition than the
  # clause emits (also inexact). Either way fall back to unconditional required (the static-maximal
  # safe direction). Nil-TOLERANT entries never reject an omitted value, so a nested gate on them
  # can't affect requiredness — don't fall back on those.
  entries = Axn::Validation::Base.validator_entries(config.validations)
  shared = shared_validation_options(config)
  return nil if entries.any? { |key, opt| !nil_tolerant_validation?(key, opt, shared) && entry_mentions_gate_key?(opt) }

  rule = gates.values.first
  return nil unless rule.is_a?(Symbol)

  ref = condition_reference(rule, field_configs)
  return nil unless ref
  return nil if ref.validations[:model] || !ref.default.nil? || ref.preprocess
  return nil if EXCLUDED_FROM_INPUT_SCHEMA.include?(ref.field)
  return nil unless framework_generated_reader?(klass, rule)

  # An unless: gate treated static-maximally emits `else: required`, firing only when the
  # referenced wire value is FALSEY. But inbound boolean coercion can flip a schema-admissible
  # truthy wire value ("false"/"f"/"0" as a String, or the JSON number 0) to a falsey settled
  # value, opening the runtime gate while the emitted `if` still reads the wire value as truthy —
  # so the schema would NOT require the gated field though the runtime does (looser than
  # runtime). For an if: gate the same flip makes the schema stricter (the emitted `then` keeps
  # requiring while the runtime gate closes), so only unless: must fall back to unconditional
  # required.
  return nil if gates.key?(:unless) && boolean_coercion_can_flip_truthiness?(ref)

  condition = {
    required: [required_key(ref.field)],
    properties: { ref.field => { not: { enum: [false, nil] } } },
  }
  branch = gates.key?(:if) ? :then : :else
  { if: condition, branch => { required: [required_key(config.field)] } }
end

.conditionally_gated?(config) ⇒ Boolean

Whether the config's declaration carries a declaration-level if:/unless: gate — the signal that its enforcement (NOT its shape) is conditional at runtime. Asked of a config here and of already-read validations in shape_property_plan (which holds nothing but the reduced Hash); one predicate, so the two cannot answer differently. The reduction never removes a declaration-level gate key, so both spellings see the same keys.

Returns:

  • (Boolean)


1689
# File 'lib/axn/internal/reflection/schema.rb', line 1689

def conditionally_gated?(config) = gated_validations?(config.validations)

.config_answers_segment?(config, segment) ⇒ Boolean

Whether a config's declared type admits SOME branch that can answer segment. A model: route resolves to a record, whose method surface is never statically refutable.

Returns:

  • (Boolean)


375
376
377
378
379
# File 'lib/axn/internal/reflection/schema.rb', line 375

def config_answers_segment?(config, segment)
  return true if config.validations[:model]

  object_type_branches(config).any? { |branch| branch_answers_segment?(branch, segment) }
end

.credit_sibling_id_defaults!(node, ann) ⇒ Object

Satisfiability-only post-adjustment (runs before this node's own requiredness is computed, so the credit propagates up every ancestor): a model-routed child that a sibling <key>_id subfield can rescue is re-annotated non-required. The sibling's value-level default supplies the lookup token at read time (see ContractForSubfields.resolve_model_via_id), so omitting the record still resolves it and the record answers the subtree; the record's attributes are unknowable at declaration, so crediting the rescue is the satisfiability doctrine. STRICT (schema) mode is untouched — it keeps its documented stricter-than-runtime divergence for self-referential id/model subfield pairs (apply_model_id_requiredness!'s KNOWN LIMITATION).



489
490
491
492
493
494
495
496
# File 'lib/axn/internal/reflection/schema.rb', line 489

def credit_sibling_id_defaults!(node, ann)
  node.children.each do |key, child|
    next if child.implicit? || !ann[child].required
    next unless sibling_id_rescued?(node, key, child)

    ann[child] = NodeAnnotation.new(required: false, nullable: ann[child].nullable)
  end
end

.custom_serialization?(klass, method) ⇒ Boolean

Returns:

  • (Boolean)


415
416
417
# File 'lib/axn/internal/reflection/schema.rb', line 415

def custom_serialization?(klass, method)
  klass.method_defined?(method) && !FRAMEWORK_SERIALIZATION_OWNERS.include?(klass.instance_method(method).owner)
end

.declared_attribute(config, name) ⇒ Object

An attribute a config may or may not carry, read tolerantly: #description and #default, enumerated at each call site. A FieldConfig answers both and a ShapeConfig answers #description only (a member is reader-less, so default: is rejected on one), which is what this exists for — one emission path over two config types, plus the configs a downstream caller builds itself and hands to the public build_input.

ShapeGraph.read is the same tolerant read the declaration guards use, so both layers agree about what a config has.



84
# File 'lib/axn/internal/reflection/schema.rb', line 84

def declared_attribute(config, name) = Axn::Internal::ShapeGraph.read(config, name)

.declared_size_minimum(config) ⇒ Object

The smallest size this field's validators admit, or nil when they admit an empty value. An explicit length: floor wins over the implicit 1 that the emptiness check and the presence check each carry — a caller needs the tightest of them, and all three forbid empty. The floor is read by Validation::Base's shared definition, the same one the emptiness reconciliation judges a declaration by, so what the runtime enforces and what the schema advertises cannot drift; a per-call (Symbol/Proc) or infinite floor is unemittable and falls through to the presence check.

A blank-tolerant length: contributes its floor only when an empty value would be rejected ANYWAY. Blank-tolerance on one entry says an empty value stands THAT entry aside, not that an empty value gets through: with nothing else rejecting it the contract admits "empty or at least 3", which no floor expresses, so emitting 3 would reject a value the contract accepts — but where a presence or emptiness check rejects every empty value, 3 or more is all the contract admits and the floor is exact. Truthiness decides the tolerance, not key presence: a nil-tolerance injects an explicit allow_blank: false.

A context-scoped entry contributes no floor either, for the stronger reason that it runs on NO call (Validation::Base.entry_context_scoped?), so its floor is a constraint the contract never applies. That is not the gate treatment: a GATED entry may be open on a given call, and is counted as if it were — static-maximal, which can leave the input schema stricter than a closed-gate runtime but never looser, and is the policy for every gated constraint here.

Only length: is consulted, never a size:: size is absent from KNOWN_VALIDATION_KEYS, so a declaration carrying it raises "Unknown key(s) :size" and can never reach reflection.



1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
# File 'lib/axn/internal/reflection/schema.rb', line 1308

def declared_size_minimum(config)
  validations = config.validations
  # Whether an empty value can get through at all decides BOTH branches below: it is the floor of 1 a
  # presence/emptiness check imposes on its own, and it is what tells a blank-tolerant `length:` apart
  # from one whose blank-tolerance is moot.
  rejects_empty = empty_value_rejected?(validations)

  length = effective_entry_options(validations[:length], shared_validation_options(config))
  if !entry_context_scoped?(length) && (rejects_empty || !length[:allow_blank])
    declared = Axn::Validation::Base.declared_length_floor(length)
    return declared if Axn::Validation::Base.emittable_length_floor?(declared)
  end

  rejects_empty ? 1 : nil
end

.derive_annotations(roots, satisfiability: false) ⇒ Object

One bottom-up pass over the whole subfield tree, computed once from build_input and threaded through every emission site below (apply_nested_subfields!/apply_children!/apply_implicit_node!/ apply_model_id_requiredness!) instead of each of them independently re-walking the subtree via subtree_requires_presence?/required_child? — the repeated-recomputation pattern behind PR #149's rounds-5/8/9 findings (a dropped/blocked deep shape agreeing at some sites but not others). compare_by_identity: SubfieldTree::Node is a plain Data value, so identity (not #==/#hash on its contents) is what distinguishes one tree position from another.



426
427
428
429
430
# File 'lib/axn/internal/reflection/schema.rb', line 426

def derive_annotations(roots, satisfiability: false)
  ann = {}.compare_by_identity
  roots.each_value { |node| annotate_node!(node, ann, satisfiability:) }
  ann
end

.drop_uuid_format(members) ⇒ Object

Strip format: "uuid" from anyOf members: a blank-tolerant uuid accepts "" at runtime, which a strict format: uuid validator would reject (mirrors the scalar-type relaxation above).



1789
1790
1791
# File 'lib/axn/internal/reflection/schema.rb', line 1789

def drop_uuid_format(members)
  members.map { |m| m[:format] == "uuid" ? m.except(:format) : m }
end

.dropped_deep_subfields(field_configs, subfield_configs, resolved: nil) ⇒ Object

The subfield configs build_input omits from the input schema: deep configs (a dotted on: path, a subfield of a subfield, or a dotted field name) whose chain passes through a model: or non-object parent, so they have no JSON-object representation. They validate at runtime but are absent from the schema; a caller can surface this otherwise-silent gap. A representable deep chain (every explicit ancestor object-shaped) is NOT dropped — it nests in the schema. Subfields rooted at a deliberately-excluded parent (EXCLUDED_FROM_INPUT_SCHEMA, e.g. ambient_context) are skipped: their absence is intentional. Side-effect-free (SubfieldTree inspects declared configs only).

resolved: accepts the per-class ResolvedSubfields cache, whose dropped was already computed from the same tree at build time (see ResolvedSubfields.build) — reading it here is a cheap reader, not a recomputation. Without it, both the tree and the verdict are built fresh.



202
203
204
205
206
# File 'lib/axn/internal/reflection/schema.rb', line 202

def dropped_deep_subfields(field_configs, subfield_configs, resolved: nil)
  return resolved.dropped if resolved

  dropped_from_deep_paths(Axn::Internal::SubfieldTree.build(field_configs, Array(subfield_configs)).deep_paths)
end

.dropped_from_deep_paths(deep_paths) ⇒ Object

The judgment over a tree's deep candidates: which of the [config, hops] pairs SubfieldTree.build collected (a config reached through more than one hop) have no JSON-object representation. Tree construction only COLLECTS these — whether a chain can hold JSON object properties is a question about what this layer can EMIT, so the two public entry points (this one, and dropped_deep_subfields for a caller that has only configs, not a built tree) both funnel through the same private judgment.



213
214
215
# File 'lib/axn/internal/reflection/schema.rb', line 213

def dropped_from_deep_paths(deep_paths)
  compute_dropped(deep_paths)
end

.effective_entry_options(entry, declaration_options) ⇒ Object

An entry's options as validates will hand them over — the declaration-wide shared options with the entry's own merged on top, so a shared tolerance or context is judged here exactly as at runtime.



1773
# File 'lib/axn/internal/reflection/schema.rb', line 1773

def effective_entry_options(entry, declaration_options) = Axn::Validation::Base.effective_entry_options(entry, declaration_options)

.effective_validations(validations, for_output:) ⇒ Object

THE ONE derivation of the validations a projection is BUILT from, and the reason it is a function rather than a step inside build_property: a per-validator (nested) gate can skip an INDIVIDUAL check on a given call (type: { klass: Integer, if: :flag } with flag falsey lets a nonblank wrong-typed value through), so its constraint can't be promised outbound — and every rule DERIVED from what the projection emits has to start from the same reduced view, or it describes a schema the emitter never emits. The projection size cap charged 25,000 properties for a gated type: SomeData whose members build_property drops before it emits anything, because "exact given the plan" says nothing when the plan's input differs.

What survives with EVERY gate closed: entries carrying a gate of their own (entry_self_gated?) drop, ungated entries stay (a gated inclusion: alongside an ungated type: still emits the type), and declaration-level gate keys stay too (inert to this reduction — a wholly gated outbound config is already left untyped by its own earlier return, in both build_property and shape_property_plan). INPUT is untouched and returns the SAME Hash: static-maximal is the safe direction there (a gate can only relax enforcement at runtime), and identity is what lets build_property skip rebuilding a config.



1459
1460
1461
1462
1463
1464
# File 'lib/axn/internal/reflection/schema.rb', line 1459

def effective_validations(validations, for_output:)
  return validations unless for_output

  effective = validations.reject { |_key, opt| entry_self_gated?(opt) }
  effective.size == validations.size ? validations : effective
end

.empty_container?(value) ⇒ Boolean

Whether a default is an EMPTY container, decided by WHOSE empty? would answer it. Ownership is the whole test, because it separates the two things a subclass can be: one that INHERITS the built-in's empty? answers with Ruby's own code, so running it is safe and its empty instance is as empty as the built-in's; one that OVERRIDES it (or carries a singleton) is caller code, which a reflection verdict must not run — and not recognizing it is also what matches the runtime, since that same override is what the emptiness check will ask. Anything else — a lazy collection, an arbitrary object — is unrecognized for the same reason, so no empty? of a caller's writing is ever dispatched here.

The owner read is bound (NativeMethods.method_owner); the call that follows it needs no guard, because the implementation it dispatches is the one whose owner was just established.

Returns:

  • (Boolean)


876
877
878
879
880
881
# File 'lib/axn/internal/reflection/schema.rb', line 876

def empty_container?(value)
  owner = Axn::Internal::NativeMethods.method_owner(value, :empty?)
  return false unless owner && native_empty_owner?(owner)

  value.empty?
end

.empty_default?(value) ⇒ Boolean

Whether a default is EMPTY — the question allow_empty: false's own check asks of a value, which is not blankness: a whitespace-only String is blank but not empty, and false has no empty state at all.

Returns:

  • (Boolean)


903
# File 'lib/axn/internal/reflection/schema.rb', line 903

def empty_default?(value) = empty_container?(value)

.empty_value_rejected?(validations) ⇒ Boolean

Whether an empty value is rejected by something OTHER than the author's own length: — either allow_empty: false's own check or a live presence check (every empty value is blank, so a presence check that rejects blank rejects every empty value). THE question "can an empty value get through here", which decides both the fallback floor of 1 and whether a blank-tolerant length: still contributes its floor.

Returns:

  • (Boolean)


849
850
851
852
853
# File 'lib/axn/internal/reflection/schema.rb', line 849

def empty_value_rejected?(validations)
  return true if validations.key?(Axn::Internal::FieldConfig::NON_EMPTINESS_KEY)

  presence_rejects_blank?(validations)
end

.entry_context_scoped?(opt) ⇒ Boolean

Returns:

  • (Boolean)


1768
# File 'lib/axn/internal/reflection/schema.rb', line 1768

def entry_context_scoped?(opt) = Axn::Validation::Base.entry_context_scoped?(opt)

.entry_effective_gate_keys(entry_opts, decl_gates) ⇒ Object

Which gate keys EFFECTIVELY gate a single validator entry, given the declaration-level gates (decl_gates = the sliced :if/:unless off the whole declaration, already blank-canonicalized). Owned by Validation::Base so the declaration-time nil-skip push-down judges runtime skippability identically; structural (never evaluates a condition), which is what keeps reflection side-effect-free.



1718
# File 'lib/axn/internal/reflection/schema.rb', line 1718

def entry_effective_gate_keys(entry_opts, decl_gates) = Axn::Validation::Base.entry_effective_gate_keys(entry_opts, decl_gates)

.entry_mentions_gate_key?(opt) ⇒ Boolean

Whether a single validator ENTRY's options MENTION a per-validator gate key at all — blank or not (contrast entry_self_gated?, which requires a NON-blank value). A blank nested gate is not inert for the declaration-level requiredness clause: per AM's measured per-key merge (fields.rb#validator_gate_open?), a blank nested same-key value OVERRIDES and drops the shared (declaration) gate for that key before AM ignores it — un-gating the entry. So an entry that mentions ANY gate key no longer inherits the declaration gate verbatim.

Returns:

  • (Boolean)


1707
1708
1709
1710
1711
# File 'lib/axn/internal/reflection/schema.rb', line 1707

def entry_mentions_gate_key?(opt)
  return false unless opt.is_a?(Hash)

  Internal::FieldConfig::CONDITIONAL_GATE_KEYS.any? { |k| opt.key?(k) }
end

.entry_self_gated?(opt) ⇒ Boolean

Whether a single validator ENTRY carries a real per-validator (nested) if:/unless: gate — one that can skip that entry alone (e.g. presence: { if: -> { ... } }, type: { klass: Integer, if: :flag }). Owned by Validation::Base so the emptiness axis's deferral test and this reasoning judge one entry the same way.

Returns:

  • (Boolean)


1699
# File 'lib/axn/internal/reflection/schema.rb', line 1699

def entry_self_gated?(opt) = Axn::Validation::Base.entry_self_gated?(opt)

.enum_for_inclusion(enum_values, nullable:) ⇒ Object

The enum: member list for an inclusion set. nullable (nil_allowed?) is the runtime truth: when false, a literal nil member is dropped (an explicit nil is rejected there); when true, nil is added if not already present.



1159
1160
1161
1162
1163
1164
1165
# File 'lib/axn/internal/reflection/schema.rb', line 1159

def enum_for_inclusion(enum_values, nullable:)
  members = normalize_schema_literal(enum_values)
  return members.compact unless nullable

  # Identity check, not include?/==: an enum member with a custom `==` must not run during reflection.
  members.any? { |m| m.equal?(nil) } ? members : members + [nil]
end

.enum_scalar_type(value) ⇒ Object



1671
1672
1673
1674
1675
1676
1677
# File 'lib/axn/internal/reflection/schema.rb', line 1671

def enum_scalar_type(value)
  return "string" if value.is_a?(String)
  return "integer" if value.is_a?(Integer)
  return "number" if value.is_a?(Float)

  nil
end

.field_optional?(config, children, ann, satisfiability: false) ⇒ Boolean

A field is absent from required when a declared signal makes it omittable.

Returns:

  • (Boolean)


588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
# File 'lib/axn/internal/reflection/schema.rb', line 588

def field_optional?(config, children, ann, satisfiability: false)
  has_required_child = required_child?(config, children, ann)

  # A usable default on the PARENT materializes it (with its declared contents) before validation,
  # so it may always be omitted — its own default, not its subfields, decides. (A default whose
  # contents fail a child's validators is a separate, narrow divergence handled by usable_default?.)
  return true if usable_default?(config, subfield: false, satisfiability:)

  # The parent's own nil-tolerance (optional:/allow_nil:) only makes it omittable when no required
  # child would be stranded — so it must be checked AFTER the required-child test, not ahead of it.
  return true if nil_tolerance_rescues_absence?(config, satisfiability:) && !has_required_child

  # No parent-level omission signal remains. A subfield default resolves only the CHILD's value on
  # the read path (ContractForSubfields.resolve_value) — it never synthesizes the parent — so a
  # descendant default cannot rescue the parent's own omission. The parent's requiredness is decided
  # by its OWN signals (own default / own nil-tolerance, above) plus required-child stranding; a
  # child default fixes the child's nil, not the parent's own presence/blank obligation.
  false
end

.framework_generated_reader?(klass, rule_name) ⇒ Boolean

Whether the method a Symbol condition names still resolves to the reader Axn generated (not a user method that would evaluate against the settled value instead of the wire value). The generation site is recorded on Contract::GENERATED_READER_SOURCE_PATH; a generated reader — and a boolean predicate alias, which shares the aliased definition's source_location — reports that file, while a user def reports the declaring file. Pure introspection, side-effect-free.

Returns:

  • (Boolean)


742
743
744
745
746
# File 'lib/axn/internal/reflection/schema.rb', line 742

def framework_generated_reader?(klass, rule_name)
  return false unless klass.respond_to?(:method_defined?) && klass.method_defined?(rule_name)

  klass.instance_method(rule_name).source_location&.first == Axn::Core::Contract::GENERATED_READER_SOURCE_PATH
end

.gated_validations?(validations) ⇒ Boolean

Returns:

  • (Boolean)


1691
1692
1693
# File 'lib/axn/internal/reflection/schema.rb', line 1691

def gated_validations?(validations)
  Internal::FieldConfig::CONDITIONAL_GATE_KEYS.any? { |k| validations.key?(k) }
end

.inclusion_enum_values(inclusion) ⇒ Object

The literal membership set of an inclusion: validator, whether declared as the hash long form ({ in: [...] } / { within: [...] }) or the equivalent bare-Array shorthand (inclusion: %w[a b c]). The two enforce the same set at runtime, so reflection treats them identically (PRO-2944). Exact Array only (instance_of?, not is_a?): an Array subclass could override the map/each the enum and type inference downstream depend on, and reflection must never run user code — a subclass set (or a dynamic Symbol/Proc source) simply reflects no enum (returns nil).



1173
1174
1175
1176
# File 'lib/axn/internal/reflection/schema.rb', line 1173

def inclusion_enum_values(inclusion)
  values = inclusion.is_a?(Hash) ? (inclusion[:in] || inclusion[:within]) : inclusion
  values if values.instance_of?(Array)
end

.items_schema_for(of_validations, for_output: false) ⇒ Object



1494
1495
1496
1497
1498
1499
1500
1501
# File 'lib/axn/internal/reflection/schema.rb', line 1494

def items_schema_for(of_validations, for_output: false)
  klasses = Array(of_validations[:klass])
  if klasses.size == 1
    single_items_schema(klasses.first, for_output:)
  else
    { anyOf: klasses.map { |k| single_items_schema(k, for_output:) } }
  end
end

.json_type_for(validations, for_output: false) ⇒ Object



1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
# File 'lib/axn/internal/reflection/schema.rb', line 1640

def json_type_for(validations, for_output: false)
  if validations[:type]
    type_opt = validations[:type]
    klass = type_opt.is_a?(Hash) ? type_opt[:klass] : type_opt
    type_hashes = Array(klass).map { |k| single_type_for(k, for_output:) }.uniq
    return type_hashes.first if type_hashes.size == 1

    return { anyOf: type_hashes }
  end

  if validations[:inclusion]
    enum_values = inclusion_enum_values(validations[:inclusion])
    if enum_values&.any?
      types = enum_values.map { |v| enum_scalar_type(v) }.uniq
      return { type: types.first } if types.size == 1 && types.first

      # mixed (or unrecognized) value types → let `enum` constrain; emit no `type`
      return {}
    end
  end

  if validations[:numericality]
    numericality = validations[:numericality]
    return { type: "integer" } if numericality.is_a?(Hash) && numericality[:only_integer]

    return { type: "number" }
  end

  {}
end

.member_keyed_object_type?(klass) ⇒ Boolean

Returns:

  • (Boolean)


398
399
400
401
402
403
404
405
406
407
408
# File 'lib/axn/internal/reflection/schema.rb', line 398

def member_keyed_object_type?(klass)
  return true if klass == :params
  return false unless klass.is_a?(Class)
  return true if klass == Hash
  return false unless klass < Data || klass < Struct

  # A Data/Struct serializes member-keyed via its built-in to_h — unless it carries a CUSTOM as_json
  # OR a custom to_h, either of which serialize_value would follow instead (as_json first) and which
  # may emit a scalar/array/differently-keyed hash.
  !custom_serialization?(klass, :as_json) && !custom_serialization?(klass, :to_h)
end

.member_name(member) ⇒ Object

A member's NAME, or nil when it has none. Even #field is read tolerantly, and skipped rather than raised on: a DECLARED member always has one (the walk rejects a nameless member and stores a Symbol), so what this tolerance is for is the configs a caller builds itself and hands to the public build_input.



89
90
91
92
# File 'lib/axn/internal/reflection/schema.rb', line 89

def member_name(member)
  name = Axn::Internal::ShapeGraph.fetch(member, :field)
  Axn::Internal::ShapeGraph.missing?(name) ? nil : name
end

.member_properties(members, for_output:) ⇒ Object

A DECLARED member's field is already the Symbol the declaration walk judged it under (ShapeConfig normalizes, and the walk canonicalizes a duck-typed member's name once, beside the duplicate check). It is still symbolized here because build_input is public: a config a downstream caller built itself may carry a raw name, and every other schema property key is a Symbol (top-level config.field, symbolized wire keys) — so this keeps a string-named member colliding with a dotted/explicit subfield (bar.baz) resolving to the one :bar property that every downstream lookup (apply_implicit_node!'s existing, explicit-child overwrite) already keys by symbol, not a String duplicate alongside it.

required renders the SAME Symbol rather than converting the name a second time: two conversions of one caller object are two answers it can give, and a name that gave them differently would list a required property this method never emitted.



1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
# File 'lib/axn/internal/reflection/schema.rb', line 1525

def member_properties(members, for_output:)
  props = {}
  required = []
  named_members(members).each do |m, name|
    key = name.to_sym
    props[key] = build_property(m, for_output:).compact
    # On OUTPUT, a member whose presence obligation can be gated off — either wholesale by a
    # declaration-level gate, or because every nil-rejecting entry is nil-tolerant or covered by a
    # per-validator (nested) gate — can legitimately be skipped or emitted without a value by a
    # closed gate (the serializer emits no key, or a nil/blank one, for it). requiredness_conditionally_relaxable?
    # (superset of conditionally_gated?) subsumes both cases, so requiredness is dropped along with
    # (already-handled) gated constraints. INPUT stays static-maximal (a client is still expected to
    # send the member) — stricter, and safe.
    required << key.to_s unless optional_for_schema?(m) || (for_output && requiredness_conditionally_relaxable?(m))
  end
  [props, required]
end

.model_id_property(config) ⇒ Object

Returns [id_field_symbol, prop_hash] for a model: config. No type constraint: find/custom finders accept any nonblank PK token, and inferring the real PK type would require a DB load.



1545
1546
1547
1548
1549
1550
1551
1552
# File 'lib/axn/internal/reflection/schema.rb', line 1545

def model_id_property(config)
  model_opts = config.validations[:model]
  klass = model_opts[:klass]
  klass_name = klass.is_a?(Class) ? klass.name : klass.to_s
  id_field = Axn::Internal::FieldConfig.model_id_key(config.field)
  prop = { description: config.description || "ID of the #{klass_name} record" }
  [id_field, prop.compact]
end

.named_members(members) ⇒ Object

The members of a shape that actually name a property, paired with that name. Captured through the shared seam rather than iterated directly: Array(...) preserves a caller's Array SUBCLASS and then dispatches its filter_map, so a list answering that differently from each made reflection disagree with the declaration guard and the runtime validator — which both capture with each — about which members exist. One owned Array, three consumers.



126
127
128
# File 'lib/axn/internal/reflection/schema.rb', line 126

def named_members(members)
  Axn::Internal::ShapeGraph.capture(members).filter_map { |m| (name = member_name(m)) && [m, name] }
end

.native_empty_owner?(owner) ⇒ Boolean

Returns:

  • (Boolean)


883
884
885
886
887
888
889
890
# File 'lib/axn/internal/reflection/schema.rb', line 883

def native_empty_owner?(owner)
  return true if EMPTY_CONTAINER_CLASSES.any? { |klass| klass.equal?(owner) }
  return true if defined?(Set) && ::Set.equal?(owner)

  # The rendered name is a Ruby-made String (the bound `Module#to_s`), so comparing it dispatches String's
  # own `==` whatever the owner is.
  Axn::Internal::ClassName.of_module(owner) == PARAMS_CLASS_NAME
end

.nestable_as_object?(config) ⇒ Boolean

ALL admissible branches are object-shaped — so the subfields may nest as properties without rejecting a valid non-object branch. A mixed union (type: [Hash, Array]) is NOT nestable: at runtime the subfield can be read from the Array branch too (e.g. Array#length), so forcing type: object would disallow a valid array input.

Returns:

  • (Boolean)


298
299
300
# File 'lib/axn/internal/reflection/schema.rb', line 298

def nestable_as_object?(config)
  object_type_branches(config).all? { |k| [Hash, :params].include?(k) }
end

.nil_accepted?(config) ⇒ Boolean

Whether the field's validators, taken together, permit a nil/omitted value — the one question requiredness and nullability turn on, owned by Validation::Base so a field config's own optional? answers it identically.

Returns:

  • (Boolean)


1682
# File 'lib/axn/internal/reflection/schema.rb', line 1682

def nil_accepted?(config) = Axn::Validation::Base.nil_accepted?(config.validations)

.nil_allowed?(config) ⇒ Boolean

Returns:

  • (Boolean)


1775
1776
1777
# File 'lib/axn/internal/reflection/schema.rb', line 1775

def nil_allowed?(config)
  nil_tolerance_rescues_absence?(config)
end

.nil_tolerance_rescues_absence?(config, satisfiability: false) ⇒ Boolean

Whether this config's nil-tolerance actually rescues an ABSENT value. It does not when the field declares a literal default its own blankness checks reject: axn resolves a declared default for a nil value however it arrived — an omitted key or an explicit null — so the validators see that default, never nil, and the tolerance is never what decides the call. THE single definition, so requiredness and nullability (which the same resolution governs) cannot disagree.

Satisfiability mode resolves toward satisfiable and ignores the veto, matching the Proc-default rule: a caller who SUPPLIES a value still has a working contract, so a dead default is no reason to reject the declaration.

Returns:

  • (Boolean)


768
769
770
771
772
# File 'lib/axn/internal/reflection/schema.rb', line 768

def nil_tolerance_rescues_absence?(config, satisfiability: false)
  return false unless nil_accepted?(config)

  satisfiability || !blank_default_rejected?(config)
end

.nil_tolerant_validation?(key, opt, declaration_options) ⇒ Boolean

Returns:

  • (Boolean)


1766
# File 'lib/axn/internal/reflection/schema.rb', line 1766

def nil_tolerant_validation?(key, opt, declaration_options) = Axn::Validation::Base.nil_tolerant_validation?(key, opt, declaration_options)

.node_configs_block_nesting?(configs) ⇒ Boolean

Whether the configs declared at a subfield node forbid nesting its children as object properties: a model: route (the client sends <field>_id, not the object) or a non-nestable type (a non-object type or a mixed union) on ANY config. Single source of truth for the drop pass (blocking_ancestor?, via path_blocked?) and emission (apply_nested_subfields!), so the two never disagree on which deep structure is representable — a node the tree drops from is never re-nested in the schema. Every route is enforced at runtime, so any one non-nestable route defeats nesting.

Returns:

  • (Boolean)


308
309
310
# File 'lib/axn/internal/reflection/schema.rb', line 308

def node_configs_block_nesting?(configs)
  configs.any? { |c| c.validations[:model] || !nestable_as_object?(c) }
end

.node_optional?(node, ann, configs = node.configs, satisfiability: false) ⇒ Boolean

Whether a node may be absent from its parent object. An implicit node (a dotted-path intermediate with no declaration of its own) is omittable exactly when nothing beneath it requires presence. An explicit node follows the single-level rule at every depth: a usable default always rescues omission (declaration allows a default only when on: names a top-level reader, but a dotted field NAME can land that defaulted config on a deeper node — honored here either way; a default whose contents fail a child's validators is the same accepted divergence as at the top level); otherwise it must tolerate nil AND strand no required descendant — a nil node yields every descendant absent (PRO-2857), so a nil-tolerant node with a required subtree is NOT omittable (reflected required/non-nullable, matching runtime). With multiple configs at one node (the same wire path declared via two routes) runtime enforces all of them, so the node is omittable only if every config is. configs defaults to the whole node but may be a subset: a merged node's model and non-model routes emit separate properties (<leaf>_id vs the object), each required per its own routes' configs, not the node as a whole.

Returns:

  • (Boolean)


565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
# File 'lib/axn/internal/reflection/schema.rb', line 565

def node_optional?(node, ann, configs = node.configs, satisfiability: false)
  return !subtree_requires_presence?(node, ann) if node.implicit?

  # Satisfiability doctrine: a default on ANY of the node's OWN configs (node.configs — the FULL
  # set, not the possibly-subset `configs` param) resolves the SHARED value at this node on the
  # read path, so it rescues omission for every route reading it. Each sibling route then validates
  # against that resolved value — being optimistic that the default satisfies each sibling's
  # validator is the satisfiability doctrine (rejection is reserved for provably dead declarations).
  # Gated on satisfiability so strict schema mode stays byte-identical to the per-config rule below.
  return true if satisfiability && node.configs.any? { |c| usable_default?(c, subfield: true, satisfiability: true) }

  configs.all? do |c|
    usable_default?(c, subfield: true, satisfiability:) ||
      (nil_tolerance_rescues_absence?(c, satisfiability:) && !subtree_requires_presence?(node, ann))
  end
end

.normalize_scalar_literal(value) ⇒ Object

A literal the serializer refuses outright — a non-finite default: Float::INFINITY, which no JSON default could carry — is reported exactly as declared. Reflection describes a declaration and must never raise on user data, and a reflected literal makes no encodability promise; serialize_exposed's output, which does make one, is where that refusal belongs.



1150
1151
1152
1153
1154
# File 'lib/axn/internal/reflection/schema.rb', line 1150

def normalize_scalar_literal(value)
  Values.serialize_value(value)
rescue Axn::Extensions::Serialization::UnserializableValue
  value
end

.normalize_schema_literal(value) ⇒ Object

Deep-copy a reflected literal (a default: value or an inclusion enum member) and normalize any leaf whose JSON wire form differs from its Ruby form — Time/DateTime/Date → iso8601 String, Symbol → String, non-Integer/Float Numeric (BigDecimal/Rational) → Float — so the emitted default/enum matches the property's advertised type. Scalar wire coercion is delegated to Values.serialize_value (the single source of truth for it), so the two never drift. Mutable String leaves are duped so a consumer mutating the returned schema can't reach the stored contract; an unrecognized object is left as-is (schema literals are already simple values, so this deliberately does NOT follow Values.serialize_value's as_json/to_h coercion).



1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
# File 'lib/axn/internal/reflection/schema.rb', line 1127

def normalize_schema_literal(value)
  # Only EXACT built-in containers are traversed/duped (instance_of?, not is_a?): an Array/Hash/
  # String SUBCLASS could override map/each_with_object/dup with user code, and reflection must stay
  # side-effect-free — so a subclass (like any other unrecognized object) is left opaque.
  if value.instance_of?(Hash)
    # Dup mutable String keys too (leaving them shared would let a consumer mutating a returned key
    # in place corrupt FieldConfig#default).
    value.each_with_object({}) { |(k, v), h| h[k.instance_of?(String) ? k.dup : k] = normalize_schema_literal(v) }
  elsif value.instance_of?(Array)
    value.map { |v| normalize_schema_literal(v) }
  elsif value.instance_of?(String)
    value.dup
  elsif value.is_a?(Symbol) || value.is_a?(Time) || value.is_a?(Date) || value.is_a?(Numeric)
    normalize_scalar_literal(value)
  else
    value
  end
end

.object_shaped?(config) ⇒ Boolean

Whether a field's declared type can be represented as a JSON object (so its subfields can nest as object properties): Hash, :params, or untyped. A type: Array (or other non-object) parent is not — its subfields are extracted differently at runtime and have no object-property shape. ANY admissible branch is object-shaped (Hash/:params/untyped) — so runtime's {} synthesis from subfield defaults can satisfy the parent type ({} is a Hash, matching an object branch).

Returns:

  • (Boolean)


281
282
283
# File 'lib/axn/internal/reflection/schema.rb', line 281

def object_shaped?(config)
  object_type_branches(config).any? { |k| [Hash, :params].include?(k) }
end

.object_type_branches(config) ⇒ Object



323
324
325
326
327
328
# File 'lib/axn/internal/reflection/schema.rb', line 323

def object_type_branches(config)
  type_opt = config.validations[:type]
  return [Hash] unless type_opt # untyped parent — object-shaped for both any?/all?

  Array(type_opt.is_a?(Hash) ? type_opt[:klass] : type_opt)
end

.object_typed_element?(klass) ⇒ Boolean

Whether an element type is an OBJECT on the wire a client sends (input): Hash/:params/Data/Struct.

Returns:

  • (Boolean)


1487
1488
1489
1490
1491
1492
# File 'lib/axn/internal/reflection/schema.rb', line 1487

def object_typed_element?(klass)
  return true if klass == :params
  return false unless klass.is_a?(Class)

  klass <= Hash || klass < Data || klass < Struct
end

.optional_for_schema?(config, subfield: false, satisfiability: false) ⇒ Boolean

Optional (client may omit) iff a usable default exists, or — with no usable default — the validators tolerate a nil/omitted value. Top-level exposes requiredness is NOT decided here: build_output marks every top-level exposed key required directly (the serializer always emits them). This method reaches a for_output config only for a nested shape member, which is serialized from the actual value and so honors its own optional:/allow_nil:/default:.

Returns:

  • (Boolean)


753
754
755
756
757
# File 'lib/axn/internal/reflection/schema.rb', line 753

def optional_for_schema?(config, subfield: false, satisfiability: false)
  return true if usable_default?(config, subfield:, satisfiability:)

  nil_tolerance_rescues_absence?(config, satisfiability:)
end

.path_blocked?(hops) ⇒ Boolean

Walk a deep config's ancestor chain hop by hop, carrying the shape members an implicit hop merged into so a deeper implicit hop can test their OWN nested shape members (a member-of-a-member). carried is the object-shaped member configs the current node stands in for (empty for a real node or a fresh implicit intermediate that claimed no shape member).

Public: PropertyNames.emitted_configs asks this at EVERY depth (not just the deep configs compute_dropped reports), because the emitter blocks a property at whichever ancestor blocks it — so property attribution needs the same per-hop answer, not a second predicate that could drift.

Returns:

  • (Boolean)


231
232
233
234
235
236
237
238
239
# File 'lib/axn/internal/reflection/schema.rb', line 231

def path_blocked?(hops)
  carried = []
  hops.each do |node, key|
    return true if blocking_ancestor?(node, key, carried)

    carried = merged_shape_members(node, key, carried)
  end
  false
end

.presence_blank?(value) ⇒ Boolean

A default value ActiveModel's presence validator treats as blank (and so rejects): false, a whitespace-only String, or an empty container. (nil is handled by the caller.)

Returns:

  • (Boolean)


894
895
896
897
898
899
# File 'lib/axn/internal/reflection/schema.rb', line 894

def presence_blank?(value)
  return true if value.equal?(false)
  return value.strip.empty? if value.instance_of?(String)

  empty_container?(value)
end

.presence_rejects_blank?(validations) ⇒ Boolean

Whether an active presence: check here rejects every blank value: one is declared, it is not blank-tolerant, and it is not context-scoped (an entry that runs on no call rejects nothing). THE single definition, read by the blank-default judgment and by the size-floor emission. A truthy non-Hash entry carries no tolerance, so it rejects blank.

Returns:

  • (Boolean)


836
837
838
839
840
841
842
# File 'lib/axn/internal/reflection/schema.rb', line 836

def presence_rejects_blank?(validations)
  presence = validations[:presence]
  return false unless presence

  opts = effective_entry_options(presence, validations.slice(*Axn::Validation::Base.shared_validation_option_keys))
  !opts[:allow_blank] && !entry_context_scoped?(opts)
end

.property_representative(configs) ⇒ Object

The config a subfield node's own object property is BUILT from: the first route that is not a model: one (a model route emits <leaf>_id in place of the object, so it shapes no object property). Nil at a pure-model node, which emits no object property at all.

One owner for three readers, because each of them has to name the SAME config: apply_children!, which emits the property; annotate_node!, which decides its nullability; and the projection size cap, which charges that config's shape and must charge no other — a second route to one wire path is enforced at runtime but its shape:/of: is never emitted, so charging it rejected a contract over a schema it does not have.



321
# File 'lib/axn/internal/reflection/schema.rb', line 321

def property_representative(configs) = configs.reject { |c| c.validations[:model] }.first

.reject_null!(prop) ⇒ Object

Forbid null on a property (a required model-id token can't be null). Strips the null branch from an explicit type/anyOf; for the generated id property (untyped — a model PK has no fixed JSON type) there's no branch to strip, so add an explicit not: { type: "null" } constraint.



1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
# File 'lib/axn/internal/reflection/schema.rb', line 1589

def reject_null!(prop)
  if prop[:type].is_a?(Array)
    non_null = prop[:type] - ["null"]
    prop[:type] = non_null.size == 1 ? non_null.first : non_null
  elsif prop[:anyOf].is_a?(Array)
    prop[:anyOf] = prop[:anyOf].reject { |member| member[:type] == "null" }
  elsif !prop.key?(:type)
    prop[:not] = { type: "null" }
  end
end

.required_child?(config, children, ann) ⇒ Boolean

Whether a nil/absent parent leaves a required nested obligation unmet — so it can't validate and the parent is neither omittable nor nullable. Single source of truth for both the parent's requiredness (field_optional?) and nullability (apply_nested_subfields!), so the two never disagree. Two sources:

* a required subfield ANYWHERE in the subtree — a nil parent yields every descendant absent
(PRO-2857), so a required grandchild is stranded exactly like a required child; OR
* a required shape (`do…end`) member WHEN the parent has its OWN applied default that
materializes it: a top-level parent's default still resolves to its materialized value (e.g.
`{}`) through the read-path reader ShapeValidator's `source:` reads, so ShapeValidator runs
against the materialized value and enforces the member — omission can't be rescued by the
parent's nil-tolerance. Counts a Proc default (materialization fires before
the Proc's value matters — the applicability hazard). A SUBFIELD default no longer triggers
this: it resolves the child's value on the read path and never synthesizes the parent, so a
nil parent short-circuits ShapeValidator regardless of any descendant default.

Returns:

  • (Boolean)


534
535
536
537
538
# File 'lib/axn/internal/reflection/schema.rb', line 534

def required_child?(config, children, ann)
  return true if children_require_presence?(children, ann)

  config.applied_default? && synthesizable?(config) && required_shape_member?(config)
end

.required_key(name) ⇒ Object

The required entry for a property keyed by name: a String holding the bytes that name is KEYED by.

Rendered from a String's own bytes rather than by dispatching its to_s, for the same reason member_properties renders a member's entry from the one Symbol it keyed the property by: required and properties are two readers of one name, and a name that answers them differently lists a required property this schema never emitted — a schema no input can satisfy. Ruby stores a plain String key as a frozen copy of its bytes, so a SINGLETON to_s on such a name diverted the required entry alone, needing no second declaration to go wrong.

A Symbol keeps the rendering it always had, which is Ruby's own and cannot be overridden at all (a Symbol takes no subclass instance and no singleton). So does anything else, because the property-name rules refuse a name that renders through its own code (NativeMethods.native_name_rendering?) before any validated projection returns — only the public build_input/build_output reach here with one.

Every site that writes a required entry goes through this, not only the two a caller's own name can reach (a top-level inbound field and an exposed one). At the others the name has already been interned to a Symbol by the time it arrives — SubfieldTree interns a wire segment, model_id_key builds the generated id, and a conditional-requiredness clause is emitted only for a framework-generated reader — so this is a no-op there. They route through it anyway so that "what String does a required entry hold" has one answer rather than one per site, which is how the top-level pair came to disagree with properties in the first place.



114
115
116
117
118
119
# File 'lib/axn/internal/reflection/schema.rb', line 114

def required_key(name)
  case name
  when ::String then ::String.new(name)
  else name.to_s
  end
end

.required_shape_member?(config) ⇒ Boolean

Whether the parent's shape (do…end) block declares a member that isn't schema-optional.

Returns:

  • (Boolean)


583
584
585
# File 'lib/axn/internal/reflection/schema.rb', line 583

def required_shape_member?(config)
  named_members(config.validations.dig(:shape, :members)).any? { |m, _name| !optional_for_schema?(m) }
end

.requiredness_conditionally_relaxable?(config) ⇒ Boolean

Whether a config's requiredness can be RELAXED at runtime by a conditional GATE — the signal that a required-looking route can't oblige an omitted/nil ancestor to be present, because a closed gate skips the check that would otherwise reject the nil ancestor. Reasoned on EFFECTIVE gates (entry_effective_gate_keys), which model AM's measured per-key merge of the declaration gate with each entry's nested gate — so the two tiers combine exactly as at runtime without ever evaluating a condition. Relaxable iff BOTH:

* some gate exists anywhere — a declaration-level one (already blank-canonicalized) or a real
(non-blank) nested one; AND
* every NIL-REJECTING entry is effectively gated — the gate a closed runtime pass would skip is
precisely the check that rejects the nil/absent ancestor, so nothing forces it. A nil-tolerant
entry never rejects nil, so it imposes no ancestor obligation to relax.

The measured merge is what makes the corner cases correct: a declaration gate with a BLANK same-key nested override on the lone presence check leaves it effectively UN-gated (the override drops the shared gate, then AM ignores the blank), so an ungated nil-rejecting check still forces the ancestor — NOT relaxable. A DISTINCT-key declaration gate (unless:) surviving alongside a blank nested if: still gates the entry — relaxable.

The "some gate exists" conjunct is load-bearing: a STATICALLY nil-tolerant config (optional:/ allow_nil:, no gate) must NOT be relaxed. Static tolerance does not skip a required child's validators (a nil optional parent still strands a required descendant — PRO-2857), so such a config stays in the subset for node_optional?'s subtree-stranding test to apply; dropping it would vacuously ([].all?) mark the node omittable and lose that test. Only a GATE — which skips the gated check entirely when closed — genuinely relaxes requiredness. Own-level emission is unaffected (this governs ancestor propagation only; see annotate_node!).

Returns:

  • (Boolean)


1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
# File 'lib/axn/internal/reflection/schema.rb', line 1744

def requiredness_conditionally_relaxable?(config)
  gate_keys = Internal::FieldConfig::CONDITIONAL_GATE_KEYS
  decl_gates = config.validations.slice(*gate_keys)
  # `entries` are the real VALIDATORS — shared options (strict:, on:, …) aren't validators and
  # must not be mistaken for a nil-rejecting one (see nil_accepted?/validator_entries).
  entries = Axn::Validation::Base.validator_entries(config.validations)

  some_gate = decl_gates.any? || entries.any? { |_key, opt| entry_self_gated?(opt) }
  return false unless some_gate

  shared = shared_validation_options(config)
  entries.all? do |key, opt|
    nil_tolerant_validation?(key, opt, shared) || entry_effective_gate_keys(opt, decl_gates).any?
  end
end

.set_includes_nil?(opt) ⇒ Boolean

Returns:

  • (Boolean)


1767
# File 'lib/axn/internal/reflection/schema.rb', line 1767

def set_includes_nil?(opt) = Axn::Validation::Base.set_includes_nil?(opt)

.shape_members_at(parent_configs, key) ⇒ Object

Every shape: member declared at key across parent_configs (the implicit node collides with them). Each config is a top-level field config OR a shape-member config carried through implicit descent; both respond to .validations and expose nested members via dig(:shape, :members).



1095
1096
1097
1098
1099
# File 'lib/axn/internal/reflection/schema.rb', line 1095

def shape_members_at(parent_configs, key)
  Array(parent_configs).flat_map do |config|
    named_members(config.validations.dig(:shape, :members)).filter_map { |m, name| m if name.to_sym == key }
  end
end

.shape_overlay_applies?(of_validations, for_output:) ⇒ Boolean

Whether a shape block should overlay object properties onto an array's items. OUTPUT: each element must provably serialize to a member-keyed object (a plain Data/Struct/Hash of:). INPUT: the elements must be object-typed (Hash/:params/Data/Struct) or untyped (no of: — the client sends objects). A scalar of: (String/Integer/…) reads members off the scalar, so it is NOT overlaid.

Returns:

  • (Boolean)


1470
1471
1472
1473
1474
1475
1476
# File 'lib/axn/internal/reflection/schema.rb', line 1470

def shape_overlay_applies?(of_validations, for_output:)
  return shaped_items_serialize_to_object?(of_validations) if for_output
  return true unless of_validations # untyped elements: client sends objects with the shape members

  klasses = Array(of_validations[:klass])
  klasses.any? && klasses.all? { |k| object_typed_element?(k) }
end

.shape_property_plan(config, for_output:) ⇒ Object



1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
# File 'lib/axn/internal/reflection/schema.rb', line 1400

def shape_property_plan(config, for_output:)
  # THE reason the charge and the emitter cannot start from different configs: the effective derivation
  # happens HERE, on the way in, so no caller can hand this a config the emitter would not have used.
  # `build_property` applies the same derivation before it emits, which makes the one here idempotent
  # (nothing left to drop) rather than a second opinion.
  validations = effective_validations(config.validations, for_output:)
  of = validations[:of]
  shape = validations[:shape]
  in_items = Array(json_type_for(validations, for_output:)[:type]).include?("array")
  nothing = ShapePropertyPlan.new(emitted: false, in_items:, type_schema: {}, shape:)

  # The same two gates `apply_structured_schema!` opens with, in the same order. A declaration with
  # neither `of:` nor `shape:` contributes no object properties AT ALL — not even its type's own members —
  # so a `Data` used purely as a `type:` names nothing, and a rule keyed on these names must not fire on
  # it. Likewise a wholly gated outbound config, which `build_property` leaves untyped before reaching
  # emission.
  return nothing unless of || shape
  return nothing if for_output && gated_validations?(validations)
  # An INPUT model route emits `<field>_id` in place of the field, so `apply_structured_schema!` is never
  # reached for one — stated here rather than only in the emitter's branch, so a consumer deriving from this
  # plan (the projection size cap; collision attribution) cannot charge or attribute a property the schema
  # names nowhere. On OUTPUT the field itself is emitted, so its shape is emitted with it.
  return nothing if !for_output && validations[:model]

  if in_items
    # Overlay the shape's object properties onto items only when the ELEMENTS are objects.
    emitted = shape_overlay_applies?(of, for_output:)
    # `items_schema_for` seeds an element type's own members whenever there is an `of:`, shape or not.
    return ShapePropertyPlan.new(emitted:, in_items:, shape:, type_schema: of ? items_schema_for(of, for_output:) : {})
  end

  # Only the `elsif shape` branch emits object properties for a non-array field: `of:` without a shape on
  # a non-array type reaches neither branch.
  return nothing unless shape

  # A shaped object field IS an object, even when its declared type: (e.g. a Data.define subclass) isn't
  # in TYPE_MAP — on input unconditionally, on output only when the value serializes member-keyed.
  emitted = !for_output || shape_serializes_to_object?(validations)
  type_klass = validations.dig(:type, :klass)
  base = emitted && type_klass.is_a?(Class) && type_klass < Data ? type_klass.members.to_h { |m| [m, {}] } : {}
  # A non-array type contributes at ONE node (a multi-class `type:` reflects as `anyOf` branches of
  # scalar types, which name no properties), so its schema is just those properties.
  ShapePropertyPlan.new(emitted:, in_items:, shape:, type_schema: { properties: base })
end

.shape_serializes_to_object?(validations) ⇒ Boolean

Whether a shaped field's value serializes to a member-keyed JSON object (so advertising object + the shape's properties on OUTPUT matches serialize_exposed). Only asserted for types with a language-guaranteed member-keyed serialization: :params, an untyped shape (caller supplies a Hash), Hash, or a Data/Struct that does NOT define its OWN as_json. Values.serialize_value follows a value's own as_json before to_h, so a Data/Struct that overrides as_json may emit a scalar/array/differently-keyed hash — treat it (like any reader-only or custom-to_h class) as statically unknowable and leave it untyped on output.

Takes VALIDATIONS rather than a config because its one caller (shape_property_plan) has already reduced the config to the validations the projection is built from — see effective_validations.

Returns:

  • (Boolean)


391
392
393
394
395
396
# File 'lib/axn/internal/reflection/schema.rb', line 391

def shape_serializes_to_object?(validations)
  type_klass = validations.dig(:type, :klass)
  return true if type_klass.nil?

  Array(type_klass).all? { |k| member_keyed_object_type?(k) }
end

.shaped_items_serialize_to_object?(of_validations) ⇒ Boolean

Whether an of: element type provably serializes to a member-keyed object (output items). Needs of:.

Returns:

  • (Boolean)


1479
1480
1481
1482
1483
1484
# File 'lib/axn/internal/reflection/schema.rb', line 1479

def shaped_items_serialize_to_object?(of_validations)
  return false unless of_validations

  klasses = Array(of_validations[:klass])
  klasses.any? && klasses.all? { |k| member_keyed_object_type?(k) }
end

.shared_validation_options(config) ⇒ Object

The declaration-wide options every entry of a config rides alongside — the tier the per-entry judgments resolve against.



1762
1763
1764
# File 'lib/axn/internal/reflection/schema.rb', line 1762

def shared_validation_options(config)
  config.validations.slice(*Axn::Validation::Base.shared_validation_option_keys)
end

.sibling_id_rescued?(parent, key, node) ⇒ Boolean

Whether a node's model route is rescued by a sibling <key>_id default — the SINGLE source of truth for both the satisfiability annotation credit (credit_sibling_id_defaults!) and SubfieldContradictions' per-config tolerance loop, so the two can't drift on which nodes the id rescues. Three conjuncts:

* the node carries a `model:` route (the record it resolves answers the subtree at runtime);
* every NON-model route merged onto the node is own-level satisfiability-tolerant (a usable
default or nil-accepting) — own-level only, because the model subtree is satisfied via the
resolved record; it's the non-model route's OWN wire value the id can't supply (a pure-model
node has no non-model route, so the empty set trivially satisfies this); AND
* a sibling `<key>_id` child carries a default usable as a lookup token (usable_id_token_default?
rejects a blank literal — the model resolver blank-guards the id).

parent is the node whose children include both node (keyed by key) and the id sibling.

Returns:

  • (Boolean)


510
511
512
513
514
515
516
517
518
# File 'lib/axn/internal/reflection/schema.rb', line 510

def sibling_id_rescued?(parent, key, node)
  return false unless node.configs.any? { |c| c.validations[:model] }

  non_model = node.configs.reject { |c| c.validations[:model] }
  return false unless non_model.all? { |c| usable_default?(c, subfield: true, satisfiability: true) || nil_accepted?(c) }

  sibling = parent.children[Internal::FieldConfig.model_id_key(key)]
  !!sibling&.configs&.any? { |c| usable_id_token_default?(c) }
end

.single_items_schema(klass, for_output: false) ⇒ Object



1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
# File 'lib/axn/internal/reflection/schema.rb', line 1503

def single_items_schema(klass, for_output: false)
  # A Data element serializes member-keyed via to_h, so its array items reflect as objects — except
  # on OUTPUT when the element isn't provably member-keyed (a custom as_json/to_h serialize_value
  # would follow); leave those items untyped rather than promise an object.
  if klass.is_a?(Class) && klass < Data && (!for_output || member_keyed_object_type?(klass))
    { type: "object", properties: klass.members.to_h { |m| [m, {}] } }
  else
    json_type_for({ type: klass }, for_output:)
  end
end

.single_type_for(klass, for_output:) ⇒ Object



1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
# File 'lib/axn/internal/reflection/schema.rb', line 1600

def single_type_for(klass, for_output:)
  return { type: "boolean" } if klass == :boolean
  # TypeValidator accepts only the singleton value for TrueClass/FalseClass, so constrain the schema
  # to it (a bare `type: "boolean"` would let a client send the other value and pass validation).
  return { type: "boolean", enum: [true] } if klass == TrueClass
  return { type: "boolean", enum: [false] } if klass == FalseClass
  return { type: "string", format: "uuid" } if klass == :uuid
  return { type: "object" } if klass == :params

  # A declared type that ADMITS a Complex value (`type: Numeric` or `type: Complex`, i.e. Complex is
  # the class or one of its ancestors) can serialize to a JSON number (real Numerics) OR a String
  # (Complex — Float() rejects it, so Values.serialize_value falls back to to_s). Its output wire
  # form isn't knowable from the declaration, so leave it UNTYPED on output rather than assert
  # "number" the serialized value could contradict. Input still resolves below: `Numeric` maps to
  # "number" (a JSON number is a real Numeric and validates), `Complex` to the permissive "string".
  return {} if for_output && klass.is_a?(Class) && klass >= Complex

  if TYPE_MAP.key?(klass)
    result = { type: TYPE_MAP[klass] }
    result[:format] = FORMAT_MAP[klass] if FORMAT_MAP.key?(klass)
    return result
  end

  # A Numeric subclass not in TYPE_MAP (BigDecimal, Rational, …) serializes to a JSON number
  # (Values.serialize_value coerces it via Float()), so reflect it as "number" rather than the
  # object/string fallback. Complex is the exception: Float() rejects it, so on input it drops to
  # the permissive "string" below (a JSON client can't send a Complex anyway; output is handled
  # above).
  return { type: "number" } if klass.is_a?(Class) && klass < Numeric && !(klass <= Complex)

  # Unknown class: the serialized shape is only knowable at runtime (Values.serialize_value emits
  # an object for an as_json/to_h value but a string for a to_s-only one), so on output leave it
  # UNTYPED rather than assert `object` the serialized value might contradict. On input, keep a
  # permissive `string` hint (a JSON client can't send a Ruby object anyway — see the reflection
  # docs on coercing Ruby-object input types).
  return {} if for_output

  { type: "string" }
end

.size_constraint_key_for(type) ⇒ Object

The JSON Schema floor key for an emitted type, or nil for a type with no empty state. Reads the single-type String and the [T, "null"] nullable pair alike; "null" is never size-bearing.



1281
1282
1283
# File 'lib/axn/internal/reflection/schema.rb', line 1281

def size_constraint_key_for(type)
  Array(type).filter_map { |t| SIZE_CONSTRAINT_KEYS[t] }.first
end

.subtree_requires_presence?(node, ann) ⇒ Boolean

Whether omitting/nil-ing this node's value strands a required descendant — the transitive extension of the one-level required-child test.

Returns:

  • (Boolean)


548
549
550
# File 'lib/axn/internal/reflection/schema.rb', line 548

def subtree_requires_presence?(node, ann)
  children_require_presence?(node.children, ann)
end

.synthesizable?(config) ⇒ Boolean

Whether an object ({}) could stand in for this config's value: its declared type must admit an object AND it must not be a model: route (a {} there is rejected by ModelValidator and would be preferred by the model resolver over a caller-supplied <field>_id). required_child? uses this to decide whether the parent's OWN applied default materializes an object that would then enforce its required shape members.

Returns:

  • (Boolean)


290
291
292
# File 'lib/axn/internal/reflection/schema.rb', line 290

def synthesizable?(config)
  object_shaped?(config) && !config.validations[:model]
end

.type_allows_blank?(config) ⇒ Boolean

Whether the TYPE validator itself tolerates a blank value (type: :uuid, allow_blank: true folds allow_blank into the type validator's options). Only the type validator's own option matters for dropping format: "uuid" — a blank-tolerant length:/other validator doesn't make TypeValidator accept "", so the format must stay.

Returns:

  • (Boolean)


1783
1784
1785
# File 'lib/axn/internal/reflection/schema.rb', line 1783

def type_allows_blank?(config)
  effective_entry_options(config.validations[:type], shared_validation_options(config))[:allow_blank] == true
end

.usable_default?(config, subfield:, satisfiability: false) ⇒ Boolean

A default lets the client omit the field (Axn applies it before validation). We judge usability by declared SHAPE only — never by running the field's validators. A Proc default is unknowable at declaration, so the two modes diverge on it (the ONLY semantic delta): strict (schema) mode resolves toward required — the safe direction — while satisfiability mode (the declaration-rejection detector) resolves toward satisfiable, since the Proc DOES apply at runtime and rejection is reserved for provably dead declarations. For a subfield, only a truthy default is applied at runtime (next unless config.default), so a falsey subfield default never counts.

An empty literal default ({}/""/[]) makes the field omittable only when nothing here would reject the synthesized blank — asked of every check that governs blankness/emptiness (blank_default_rejected?), since either can be the one standing between the field and an empty value. (A blank rejected by an author's OWN size constraint — a length: floor — is a self-contradictory contract: the same accepted divergence as a non-blank invalid default, where the schema reflects optional though the omitted call fails at runtime.)

The emptiness check is limited to literal containers (Hash/Array/String): reflection must stay side-effect-free, and calling empty? on an arbitrary default (e.g. an ActiveRecord::Relation or other lazy collection) could issue a query or run user code. A non-literal default is present.

Returns:

  • (Boolean)


792
793
794
795
796
797
798
799
800
801
802
803
804
805
# File 'lib/axn/internal/reflection/schema.rb', line 792

def usable_default?(config, subfield:, satisfiability: false)
  # `#default` is beyond the documented member contract, so absent and nil are one answer here — both
  # mean "no default to relax the field with", which is what the original respond_to? guard did.
  value = declared_attribute(config, :default)
  return false if value.nil?
  # The governing split (PRO-2889): a Proc default is unknowable at declaration. Strict (schema)
  # mode resolves toward required — the safe direction — while satisfiability mode (the
  # declaration-rejection detector) resolves toward satisfiable: the Proc DOES apply at runtime,
  # and rejection is reserved for provably dead declarations.
  return satisfiability if value.is_a?(Proc)
  return false if blank_default_rejected?(config)

  subfield ? config.applied_default? : true
end

.usable_id_token_default?(config) ⇒ Boolean

Whether an <field>_id default can actually serve as a model LOOKUP token — the shared test for every id-rescue site (sibling_id_rescued?, which serves both the annotation credit and the contradictions loop, and SubfieldContradictions' model_omittable?). usable_default? judges a default for the FIELD's OWN omission, where a blank literal ("" / {}) is usable when no presence validator rejects it — but the model resolver blank-guards the id (Model#derive_value: return nil if id_value.blank?), so a blank id default can never resolve a record and never rescues an omitted model. It must therefore be satisfiability-usable AND not a blank literal. A Proc default stays optimistic (unknowable at declaration), matching usable_default?'s satisfiability doctrine.

Returns:

  • (Boolean)


913
914
915
916
917
918
919
920
# File 'lib/axn/internal/reflection/schema.rb', line 913

def usable_id_token_default?(config)
  return false unless usable_default?(config, subfield: true, satisfiability: true)

  value = declared_attribute(config, :default)
  return true if value.is_a?(Proc)

  !presence_blank?(value)
end

.validator_entry_options(entry) ⇒ Object



1769
# File 'lib/axn/internal/reflection/schema.rb', line 1769

def validator_entry_options(entry) = Axn::Validation::Base.validator_entry_options(entry)