Module: Axn::Core::Contract::SubfieldContradictions

Defined in:
lib/axn/core/contract/subfield_contradictions.rb

Overview

Declaration-time rejection of contradiction-only subfield contracts (PRO-2889). Walks a CANDIDATE tree (prospective configs included; nothing committed) and raises ArgumentError on the first provable contradiction. Every judgment reuses the canonical derivation in satisfiability mode (unknowable-at-declaration counts as satisfiable) — never a parallel re-derivation, the failure mode that sank PRO-2877's pulled detectors. Side-effect-free: inspects declared configs only, never runs user code.

Class Method Summary collapse

Class Method Details

.check!(field_configs, subfield_configs) ⇒ Object

Both checks re-scan the WHOLE candidate tree (prospective configs included), never just the new batch: a NEW declaration can invalidate an OLD subfield regardless of order — a new required descendant kills an old tolerance (dead-tolerance check), and a new type/shape declaration on a parent kills an old subfield's answerability (e.g. expects "bar.baz", on: :payload accepted while bar is unknown, then expects :bar, ..., type: String retro-strands bar.baz).



23
24
25
26
27
28
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 23

def check!(field_configs, subfield_configs)
  tree = Axn::Internal::SubfieldTree.build(field_configs, subfield_configs)
  check_unanswerable_segments!(tree) # first: an unreachable path moots any conflict on it
  check_conflicting_defaults!(tree)  # before dead-tolerance: an explicit conflict is the plainer diagnosis
  check_dead_nil_tolerance!(tree, field_configs)
end

.check_conflicting_defaults!(tree) ⇒ Object

The EXPLICIT-CONFLICT check (PRO-2901): a wire node reached by two+ routes where more than one route carries a default:. PRO-2883 made merged wire nodes first-class — the same wire key can be declared via two routes (expects "meta.count", on: :payload and expects :count, on: :meta, as: :meta_count) — but only ONE inbound default can win the shared wire key, and the executor's declaration-order pass silently lets the first-declared default write while every later route sees the key present and skips. Two explicit defaults for one wire value have no principled winner (declaration order is not a principle), so — unlike the inferred families 1–3, which defer — this rejects at declaration per the AGENTS.md doctrine that an explicit conflict raises loudly. Rejected uniformly, even for equal literals: agreeing today drifts tomorrow, and two Proc defaults can't be compared at all. Subfield-tree-only by construction (a top-level field can't merge with itself — the duplicate-field guard prevents it; the top-level <field>_id/model: default interplay is covered by PRO-2889's usable_id_token_default? sites).



42
43
44
45
46
47
48
49
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 42

def check_conflicting_defaults!(tree)
  each_explicit_node(tree.roots) do |_parent, _key, node|
    defaulted = node.configs.select(&:applied_default?)
    next if defaulted.size < 2

    raise_conflicting_defaults!(defaulted, tree.index[defaulted.first].wire_path)
  end
end

.check_dead_nil_tolerance!(tree, field_configs) ⇒ Object

Families 1+3: a statically-declared nil-tolerance (allow_nil:/optional:/allow_blank:/ presence: false) whose omission unconditionally fails — the flag advertises an omission the contract can never accept. Keyed on STATIC declarations only, so a future dynamic/ conditional requiredness signal (PRO-2881) is outside the reject set by construction.



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 128

def check_dead_nil_tolerance!(tree, field_configs)
  ann = Axn::Internal::Reflection::Schema.derive_annotations(tree.roots, satisfiability: true)

  field_configs.each do |config|
    next if Axn::Internal::Reflection::Schema::EXCLUDED_FROM_INPUT_SCHEMA.include?(config.field)
    next unless Axn::Internal::Reflection::Schema.nil_accepted?(config)

    node = tree.roots[config.reader_as]
    omittable = if config.validations[:model]
                  model_omittable?(config, node, field_configs, ann)
                else
                  Axn::Internal::Reflection::Schema.field_optional?(config, node.children, ann, satisfiability: true)
                end
    raise_dead_tolerance!(config, config.field, node, ann) unless omittable
  end

  each_explicit_node(tree.roots) do |parent, key, node|
    node.configs.each do |config|
      next unless Axn::Internal::Reflection::Schema.nil_accepted?(config)
      next if Axn::Internal::Reflection::Schema.node_optional?(node, ann, [config], satisfiability: true)
      # Skip ANY nil-accepted config at a sibling-id-rescued node, not only the model route: a
      # merged nil-tolerant non-model route (and a required grandchild the resolved record answers)
      # is exercisable via the same rescue the annotation credit grants — one shared predicate.
      next if Axn::Internal::Reflection::Schema.sibling_id_rescued?(parent, key, node)

      # Name the declaration by the field the user wrote (config.field) — symmetric with the
      # top-level loop above; the `on:` parent is implied and the stranded descendant is named.
      raise_dead_tolerance!(config, config.field, node, ann)
    end
  end
end

.check_unanswerable_segments!(tree) ⇒ Object

The UNANSWERABLE-SEGMENT check: a subfield whose resolution provably cannot traverse some segment — for EVERY contract-valid input, the read settles absent (a failed dig/method read is UnextractableError → nil, PRO-2886). Judged only along the hops the runtime actually digs (after the deepest reader-bearing ancestor — the same recipe resolve_parent uses), against each position's enforced declarations: its explicit configs plus the shape members an implicit position stands in for (ALL colliding members, nestable or not — answerability is about reading through the member's value, not nesting under it). Rejected regardless of the subfield's own optional:/default: — an unreachable path is dead machinery, rejected like the dotted-name model: spelling (PRO-2877), and with a default it degenerates to a constant field.



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 89

def check_unanswerable_segments!(tree)
  tree.index.each do |config, path|
    next unless config.subfield? # skip top-level depth-0 configs; they read no segment

    reader_index = Axn::Core::ContractForSubfields.deepest_reader_index(path)
    next if reader_index.nil?

    carried = []
    path.ancestors.each_with_index do |(node, seg), i|
      if i >= reader_index && (blocker = segment_blocker(node, carried, seg))
        raise_unanswerable!(config, blocker, seg)
      end
      carried = node.children[seg]&.implicit? ? Axn::Internal::Reflection::Schema.shape_members_at(node.configs + carried, seg) : []
    end
  end
end

.describe_default(config) ⇒ Object

A default's description for the conflict message. Side-effect-free BY CONSTRUCTION: it dispatches NO method on the default object, so no user code — a custom or singleton #inspect/#respond_to?, etc. — can run (and mask the intended declaration error) while reflection builds it. Classification is by class match (Klass === value, a C-level kind-of check that never invokes the value's own methods — the same trust normalize_schema_literal places in type checks). Only IMMEDIATES render their value: Ruby forbids singleton methods on Integer/Float/Symbol/true/false, so their #inspect is provably the core one and safe; a String (singleton #inspect possible), a container (recurses #inspect into arbitrary elements), a Proc, and any other object are named by kind instead. Reached only for applied defaults, so the value is never nil.



69
70
71
72
73
74
75
76
77
78
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 69

def describe_default(config)
  case config.default
  when Proc then "a callable"
  when Integer, Float, Symbol, TrueClass, FalseClass then config.default.inspect
  when String then "a String value"
  when Hash then "a Hash value"
  when Array then "an Array value"
  else "a non-literal default"
  end
end

.each_explicit_node(roots, &block) ⇒ Object

Depth-first over every explicit subfield node, yielding (parent_node, key, node).



161
162
163
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 161

def each_explicit_node(roots, &block)
  roots.each_value { |root| walk_children(root, &block) }
end

.first_required_descendant(node, ann, prefix = []) ⇒ Object

The shallowest explicit required descendant's dotted path (for the message) — descends through implicit intermediates that are required only transitively.



189
190
191
192
193
194
195
196
197
198
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 189

def first_required_descendant(node, ann, prefix = [])
  node.children.each do |key, child|
    path = prefix + [key]
    return path if ann[child].required && !child.implicit?

    deeper = first_required_descendant(child, ann, path)
    return deeper if deeper
  end
  nil
end

.model_omittable?(config, node, field_configs, ann) ⇒ Boolean

Mirrors apply_model_id_requiredness!'s omittability (satisfiability flavor): the model may be omitted when it is itself optional-for-schema AND no child subtree requires presence — OR a defaulted explicit <field>_id sibling supplies the lookup token on omission.

Returns:

  • (Boolean)


175
176
177
178
179
180
181
182
183
184
185
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 175

def model_omittable?(config, node, field_configs, ann)
  explicit_id = field_configs.find { |c| c.field == Internal::FieldConfig.model_id_key(config.field) }
  return true if explicit_id && Axn::Internal::Reflection::Schema.usable_id_token_default?(explicit_id)
  # The model's OWN usable default supplies a record on omission, so the tolerance is
  # exercisable regardless of a required descendant — mirrors field_optional?'s parent-default
  # short-circuit (checked BEFORE the child test, not gated behind it).
  return true if Axn::Internal::Reflection::Schema.usable_default?(config, subfield: false, satisfiability: true)

  Axn::Internal::Reflection::Schema.optional_for_schema?(config, satisfiability: true) &&
    !Axn::Internal::Reflection::Schema.children_require_presence?(node.children, ann)
end

.raise_conflicting_defaults!(configs, wire_path) ⇒ Object

Raises:

  • (ArgumentError)


51
52
53
54
55
56
57
58
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 51

def raise_conflicting_defaults!(configs, wire_path)
  routes = configs.map { |c| "#{c.field.inspect} (on #{c.on.inspect}, default: #{describe_default(c)})" }.join(" and ")
  raise ArgumentError,
        "conflicting default: declarations on wire path #{wire_path.join('.').inspect}: routes #{routes} both " \
        "carry a default: for the same wire value, and only declaration order — not any principle — decides " \
        "which one applies (the first-declared default writes the wire key; every later route then sees the " \
        "key present and is silently skipped). Keep a single default:, or split the routes onto distinct wire keys."
end

.raise_dead_tolerance!(config, owner, node, ann) ⇒ Object

Raises:

  • (ArgumentError)


200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 200

def raise_dead_tolerance!(config, owner, node, ann)
  # Names are rendered rather than interpolated raw, and the stranded path is joined from rendered
  # SEGMENTS: a declared name may hold non-UTF-8 bytes (a valid Latin-1 Symbol), and joining one to a
  # non-ASCII UTF-8 name raises Encoding::CompatibilityError from the message itself — so the author gets
  # an encoding failure instead of the contradiction being reported. `Symbol#inspect` supplies the leading
  # colon these read with, and escapes bytes that have no UTF-8 rendering.
  name = owner.inspect
  segments = first_required_descendant(node, ann)&.map { |segment| Axn::Internal::Reflection::PropertyNames.renderable_label(segment) }
  stranded = segments && ":#{segments.join('.')}"
  model_hint = if config.validations[:model]
                 " For a model: field, a record-supplying default: on #{name} or a defaulted " \
                   "#{Axn::Internal::Reflection::PropertyNames.renderable_label(owner)}_id sibling (declared first) also rescues omission."
               else
                 ""
               end
  raise ArgumentError,
        "#{name} is declared nil-tolerant (allow_nil:/optional:/allow_blank:, or an untyped " \
        "presence: false), but " \
        "#{stranded || 'its subtree'} is required and nothing rescues an omitted #{name}" \
        "the tolerance can never be exercised (every nil/omitted #{name} fails validation). " \
        "Drop the tolerance on #{name}, or mark #{stranded || 'the subtree'} optional: or give it a " \
        "default: (declare rescuing defaults BEFORE the dependent subfield). If it is only required when " \
        "#{name} is supplied, gate it conditionally: `expects ..., if: -> { " \
        "#{Axn::Internal::Reflection::PropertyNames.renderable_label(owner)}.present? }`.#{model_hint}"
end

.raise_unanswerable!(config, blocker, segment) ⇒ Object

Raises:

  • (ArgumentError)


115
116
117
118
119
120
121
122
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 115

def raise_unanswerable!(config, blocker, segment)
  types = Axn::Internal::Reflection::Schema.object_type_branches(blocker).map { |b| b.is_a?(Class) ? b.name : b.inspect }.join(", ")
  raise ArgumentError,
        "subfield #{config.field.inspect} (on #{config.on.inspect}) can never resolve: segment #{segment.inspect} " \
        "is read from #{blocker.field.inspect}, declared #{types}, which cannot answer it (no key access, no such " \
        "method) — no contract-valid input ever reaches this subfield. Make #{blocker.field.inspect} object-shaped, " \
        "or drop the subfield."
end

.segment_blocker(node, carried, segment) ⇒ Object

The first enforced declaration at this position that provably cannot answer segment (nil when the position is answerable). A position with any model: route resolves to a record — never refutable.



109
110
111
112
113
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 109

def segment_blocker(node, carried, segment)
  return nil if node.configs.any? { |c| c.validations[:model] }

  (node.configs + carried).find { |c| !Axn::Internal::Reflection::Schema.config_answers_segment?(c, segment) }
end

.walk_children(parent, &block) ⇒ Object



165
166
167
168
169
170
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 165

def walk_children(parent, &block)
  parent.children.each do |key, node|
    yield(parent, key, node) unless node.implicit?
    walk_children(node, &block)
  end
end