axn 0.1.0.pre.alpha.6 → 0.1.0.pre.alpha.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96629802248edd18b5c1d3607b7aa3ad6775c9185a8667808f4d81ae98cfb69e
4
- data.tar.gz: 66185b1f3f784c7a4c57680ed7b1fdcda1ae958904b69d53dfa003fb50c88286
3
+ metadata.gz: 259cd996e88bc18c257764dcdbf09b8329d9455a468508db610fdee30312bdd0
4
+ data.tar.gz: 5ad4e15519eb9a4d8af7b4ebff976c2455411e46b24073a43c8d054e78a88798
5
5
  SHA512:
6
- metadata.gz: bc8ffc522d6226bcb15f401d42f23560cec3fd0497d30f710456d2b68f12c1238c5ca6453f151aec00baddebfe707b619a649177c62779f6db0cf44eb43e84fb
7
- data.tar.gz: 8bd82a4adf9fd10a612ae70ff4abfc7b37284970dbed31a6e615c5d842b8384e51ad9ff17b1a6914738257fc2bc2d86ce374fa8d18a21019f069e19e300653dd
6
+ metadata.gz: 766cf539c8c8dde70ab669b8dbed304b1e2b8359910b0a187502971db9175e72bf5f8041db02fa60be5327fc1d212e89258c8140a6434b9de368705fc66d6292
7
+ data.tar.gz: f0d4cea99d2a124ec1f0dbe3f475a2ec900d4fd3221d3e3d5529f9b01bb3efe1e3fe4f629433c7de23e2a98dee7852402a4ec48799aa8672b3505f13abdb6a53
data/AGENTS-consuming.md CHANGED
@@ -165,8 +165,17 @@ expects :user, model: true
165
165
  `user_id` always means *the record's primary key*, on every path. Passing both a record and a
166
166
  disagreeing `user_id` (default `:find` finder) raises `InboundValidationError` — contradictory
167
167
  input is a developer error. `klass:` must be a single Class/Module (no union, no `type:`-style
168
- pseudo-type); anything else raises `ArgumentError` at declaration. Source:
169
- `lib/axn/core/field_resolvers/model.rb`.
168
+ pseudo-type); anything else raises `ArgumentError` at declaration. The `model:` bag only accepts
169
+ `klass:`/`finder:`/`not_found_on:`/`id_type:`/`message:` (plus the shared ActiveModel options) — a
170
+ typo'd key (`fnder:`, `class:` for `klass:`) raises `ArgumentError` too, rather than silently
171
+ falling back to the default. Source: `lib/axn/core/field_resolvers/model.rb`.
172
+
173
+ **No record found is a contract violation, not a reported exception.** A finder returning `nil` and
174
+ one raising its not-found error (`ActiveRecord::RecordNotFound` for `:find`) are the same outcome:
175
+ an `InboundValidationError` reading `User not found` — distinct from the `User can't be blank` an
176
+ omitted `user_id` gets. It is NOT handed to `on_ignored_exception`, so a bad id from a tool caller
177
+ never pages; anything else the finder raises still does. Name your own miss class with
178
+ `model: { finder: :fetch!, not_found_on: MyApi::NotFound }` (a `StandardError` subclass or an array of them; `[]` opts out).
170
179
 
171
180
  **`on:` — subfields (the `:extract` resolver).** Declare expectations about nested data and get a
172
181
  flat reader:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.6.1
4
+
5
+ ### Added
6
+
7
+ * [FEAT] `Axn::Failure` exposes `originating_axn_class` and `originating_result`, giving failure handlers the failing axn's class and its result (including exposures made before `fail!`) without reaching through the action instance. The readers are defined on `Axn::Failure` only and are nil when the failure was not raised by an axn.
8
+ * [FEAT] A `model:` field's generated `<field>_id` is typed in `.input_schema`. ActiveRecord models using the default `:find` finder infer Integer, String, or UUID from the model's primary key; `model: { id_type: Integer }`/`String`/`:uuid` provides an explicit override for POROs, custom finders, or unavailable database metadata.
9
+
10
+ ### Changed
11
+
12
+ * [BREAKING] A `model:` lookup that means “not found” is now treated as an inbound validation failure, whether the finder returns `nil` or raises its configured not-found exception. This removes duplicate `best_effort`/`on_exception` noise for expected bad identifiers: required fields report one `Axn::InboundValidationError`, optional fields remain quiet, and tool-facing input errors are not reported. Other finder failures are still swallowed to `nil` and reported. `model: { finder:, not_found_on: <class or array> }` defines the not-found exception set for custom finders; an empty set opts out.
13
+ * [BREAKING] `Axn::Result#declared_fields` is now `#__declared_fields__`; `declared_fields`, along with `context`, `action`, `action_name`, `default_error`, and `default_success`, is available for field declarations. There is no compatibility alias for the result reader.
14
+ * [BREAKING] Unknown keys in `model:`, `type:`, `validate:`, and `shape:` option bags now raise at declaration instead of being silently ignored. The check applies consistently to top-level fields, subfields, shapes, `of:` positions, map axes, and `Axn::Factory.build`.
15
+ * [BREAKING] Explicit subfields now conjoin with an enclosing `shape:` member during schema generation, preserving the full declared shape and requiredness instead of silently replacing the member with a bare object. Model-generated `<field>_id` keys that collide with emitted nested objects are rejected at declaration.
16
+ * [FEAT] Validation errors rooted at `on: :ambient_context` identify that source in their rendered message, including nested and model-consistency errors, without changing structured error details.
17
+ * [BREAKING] The ActiveModel/ActiveSupport dependency floor is now `>= 8.1`.
18
+
19
+ ### Fixed
20
+
21
+ * [BUGFIX] `fails_on` now accepts multiple exception classes positionally, and `error`/`success`/`fails_on` reject invalid message values at declaration instead of misclassifying them or placing non-message objects in results.
22
+ * [BUGFIX] `Axn::Factory.build` correctly splats arrays for hook declarations and preserves single handlers that happen to implement `to_a`/`to_ary`; callbacks, memo declarations, and shape-block fields also support multiple entries in one call.
23
+ * [BUGFIX] A missing `model:` record produces one accurate validation error—“not found” when an id was supplied, “can't be blank” when it was omitted—instead of combining type and presence errors.
3
24
  ## 0.1.0-alpha.6
4
25
 
5
26
  ### Added
@@ -124,7 +124,7 @@ module Axn
124
124
  # @example Count-only heartbeat via a class method
125
125
  # on_enqueue_all :log_summary
126
126
  # def self.log_summary(count:) = info "Found #{count} events"
127
- def on_enqueue_all(handler = nil, **, &block) = _add_callback(:enqueue_all, handler:, **, block:)
127
+ def on_enqueue_all(*handlers, **, &block) = _add_callback(:enqueue_all, handlers, **, block:)
128
128
  end
129
129
  end
130
130
  end
@@ -26,7 +26,7 @@ module Axn
26
26
  (@declared_fields + Array(implicitly_allowed_fields)).each do |field|
27
27
  # Never define over a name the facade ITSELF answers to — its own ancestry up to Object,
28
28
  # private methods included, since those are the ones it dispatches on itself
29
- # (`default_error`, `_msg_resolver`). Declarations that would land such a name are refused up
29
+ # (`_default_error`, `_msg_resolver`). Declarations that would land such a name are refused up
30
30
  # front (Contract::ClassMethods#_reject_shadowed_exposure_name! and its inbound twin); this is
31
31
  # the definition-site half of that rule, so a config reaching a facade without passing through
32
32
  # the DSL cannot silently take a method away. Object/Kernel are deliberately NOT asked: an
@@ -38,9 +38,12 @@ module Axn
38
38
  end
39
39
  end
40
40
 
41
- attr_reader :declared_fields
41
+ # Namespaced like `Axn::Result`'s `__action__`/`__exposed_keys__` rather than left as
42
+ # `declared_fields`: every name this class owns is one an `expects`/`exposes` declaration may not
43
+ # take, so the facade's own surface stays out of the namespace an author writes field names in.
44
+ def __declared_fields__ = @declared_fields
42
45
 
43
- def inspect = ContextFacadeInspector.new(facade: self, action:, context:).call
46
+ def inspect = ContextFacadeInspector.new(facade: self, action: _action, context: _context).call
44
47
 
45
48
  def fail!(...)
46
49
  raise Axn::ContractViolation::MethodNotAllowed, "Call fail! directly rather than on the context"
@@ -48,7 +51,14 @@ module Axn
48
51
 
49
52
  private
50
53
 
51
- attr_reader :action, :context
54
+ # Underscored, like everything else this class owns. The facade's method table IS the set of
55
+ # field names a declaration is refused (Contract::ClassMethods#_reject_shadowed_wire_key! and
56
+ # its exposure twin ask it by ownership), so a helper named `action` or `context` would take two
57
+ # ordinary domain words away from every author while offering them nothing — neither name is
58
+ # reachable from an action or documented anywhere. Keep new helpers here underscored;
59
+ # spec/axn/core/context/facade_name_surface_spec.rb enforces it.
60
+ def _action = @action
61
+ def _context = @context
52
62
 
53
63
  # Define one field's reader. The base (outbound Result) facade reads the data source directly;
54
64
  # InternalContext overrides this to resolve declared inbound fields through the read path.
@@ -62,9 +72,9 @@ module Axn
62
72
  end
63
73
  end
64
74
 
65
- def _model_fields = action.class._model_fields
75
+ def _model_fields = _action.class._model_fields
66
76
 
67
- def action_name = @action.class.name.presence || "The action"
77
+ def _action_name = @action.class.name.presence || "The action"
68
78
 
69
79
  def _define_model_field_method(field, options)
70
80
  Axn::Internal::Memoization.define_memoized_reader_method(@__singleton, field) do
@@ -81,9 +91,9 @@ module Axn
81
91
 
82
92
  def _msg_resolver(event_type, exception:)
83
93
  Axn::Core::Flow::Handlers::Resolvers::MessageResolver.new(
84
- action.class._messages_registry,
94
+ _action.class._messages_registry,
85
95
  event_type,
86
- action:,
96
+ action: _action,
87
97
  exception:,
88
98
  )
89
99
  end
@@ -115,7 +115,7 @@ module Axn
115
115
  def rendered_field_name(field) = Axn::Internal::Reflection::PropertyNames.renderable_label(field)
116
116
 
117
117
  def class_name = facade.class.name
118
- def declared_fields = facade.send(:declared_fields)
118
+ def declared_fields = facade.__declared_fields__
119
119
 
120
120
  def format_for_inspect(field, value)
121
121
  return value.inspect if value.nil?
@@ -6,8 +6,13 @@ module Axn
6
6
  module Core
7
7
  # Inbound / Internal ContextFacade
8
8
  class InternalContext < ContextFacade
9
- def default_error = _msg_resolver(:error, exception: Axn::Failure.new).resolve_default_message
10
- def default_success = _msg_resolver(:success, exception: nil).resolve_default_message
9
+ # Underscored for the same reason as everything else on the facade (see ContextFacade's private
10
+ # section): the method table is the set of field names a declaration is refused. Public despite the
11
+ # underscore — the action-side `default_error`/`default_success` sugar dispatches these by name,
12
+ # and that pair stays unprefixed because Contract::InstanceMethods is surrenderable, so a field
13
+ # may take it and lose nothing but the helper.
14
+ def _default_error = _msg_resolver(:error, exception: Axn::Failure.new).resolve_default_message
15
+ def _default_success = _msg_resolver(:success, exception: nil).resolve_default_message
11
16
 
12
17
  private
13
18
 
@@ -17,16 +22,16 @@ module Axn
17
22
  # provided_data never mutated). A field with no config (implicitly-allowed) keeps the raw source
18
23
  # read. Model fields resolve through the shared resolve_model_value (record + sibling-id + default).
19
24
  def _define_reader_for(field)
20
- config = action.class.internal_field_configs.find { |c| c.field == field }
25
+ config = _action.class.internal_field_configs.find { |c| c.field == field }
21
26
  return super if config.nil?
22
27
 
23
28
  if config.validations.key?(:model)
24
29
  Axn::Internal::Memoization.define_memoized_reader_method(@__singleton, field) do
25
- Axn::Core::ContractForSubfields.resolve_model_value(action, config, config.validations[:model])
30
+ Axn::Core::ContractForSubfields.resolve_model_value(_action, config, config.validations[:model])
26
31
  end
27
32
  else
28
33
  @__singleton.define_method(field) do
29
- Axn::Core::ContractForSubfields.resolve_value(action, config)
34
+ Axn::Core::ContractForSubfields.resolve_value(_action, config)
30
35
  end
31
36
  end
32
37
  end
@@ -36,7 +41,7 @@ module Axn
36
41
  msg = <<~MSG
37
42
  Method ##{method_name} is not available on Axn::Core::InternalContext!
38
43
 
39
- #{action_name} may be missing a line like:
44
+ #{_action_name} may be missing a line like:
40
45
  expects :#{method_name}
41
46
  MSG
42
47
 
@@ -117,6 +117,7 @@ module Axn
117
117
  # declared no `shape:` must not gain the key here.
118
118
  def _snapshot_declared_shape!(validations, allowance, fields)
119
119
  _reject_unshaped_shape!(validations, "`shape:` on #{_declared_fields_label(fields)}")
120
+ _reject_unknown_shape_keys!(validations, "`shape:` on #{_declared_fields_label(fields)}")
120
121
  shape = Internal::ShapeGraph.hash_or_nil(validations[:shape])
121
122
  return if nil.equal?(shape)
122
123
 
@@ -169,6 +170,48 @@ module Axn
169
170
  "`ArgumentError: must supply :members`. Supply `shape: { members: [...] }`, or drop shape:."
170
171
  end
171
172
 
173
+ # THE key-set refusal for a `shape:` node (PRO-3387), at the same four positions
174
+ # `_reject_unshaped_shape!` reaches: a field's own, a shape MEMBER's, an `of:` element bag's, and either
175
+ # axis of a map (the last two share one call site, `_snapshot_inner_shape!`). `members:`/`container:`
176
+ # are the only two keys `ShapeValidator` reads (`ClassMethods::SHAPE_OPTION_KEYS`) — everything else
177
+ # declared cleanly and constrained nothing, the same hole `_reject_unknown_bag_keys!` already closes for
178
+ # `of:`/`model:`/`type:`/`validate:`. `where` is the same label its `_reject_unshaped_shape!` sibling
179
+ # takes at every call site — passed rather than recomputed, so the two guards read the position
180
+ # identically and neither can name it differently from the other.
181
+ #
182
+ # Stands down on a non-Hash `shape:` — that is `_reject_unshaped_shape!`'s defect one line up, and
183
+ # firing here too would report a bare `ArgumentError: must supply :members` graph as also missing keys
184
+ # it never had a chance to name.
185
+ #
186
+ # Raises ITSELF, rather than deferring, for a shape that ANSWERS a missing key from a Hash default
187
+ # (`Hash.new(…)` or a `default_proc`) — `copy_entries` below is entry-wise, so a `members:`/`container:`
188
+ # the shape only ANSWERS is invisible to it, and every REAL entry beside it (however few) reads as
189
+ # unknown. This used to defer to `_walk_shape_graph!`'s own `reject_defaulting_option_container!` call,
190
+ # on the assumption that it always runs after this one — true when there is no block, but this guard
191
+ # ALSO runs ahead of a block/subblock overwrite (PRO-3387, Codex round 2, PR #275), and that overwrite
192
+ # replaces `validations[:shape]` before `_walk_shape_graph!` ever reaches the ORIGINAL defaulting Hash
193
+ # — so deferring there let a defaulting raw shape beside a block skip this check entirely, real
194
+ # entries included. Raising here closes that: the SAME shared helper, so the underlying defect is
195
+ # named identically wherever it is caught, and `_walk_shape_graph!`'s own call stays as a backstop for
196
+ # the one position this method is never asked about (a shape reached via `internal_field_configs=`,
197
+ # which skips the whole declaration walk).
198
+ #
199
+ # `:shape` is the one entry `ShapeGraph.detach_option_containers!` deliberately skips — the field's own
200
+ # validations bag is detached there, but the shape graph beneath it is walked and copied by this
201
+ # module's own machinery instead — so the Hash handed here is still the CALLER's own object. Read
202
+ # through `ShapeGraph.copy_entries` (the bound `each` seam) rather than a direct `.keys`, which a
203
+ # caller-defined Hash subclass could answer with anything: the same discipline `_symbol_keyed_bag`
204
+ # already reads a caller-supplied bag by.
205
+ def _reject_unknown_shape_keys!(carrier, where)
206
+ return unless Internal::ShapeGraph.carries_key?(carrier, :shape)
207
+
208
+ shape = Internal::ShapeGraph.hash_or_nil(carrier[:shape])
209
+ return if nil.equal?(shape)
210
+
211
+ Internal::ShapeGraph.reject_defaulting_option_container!(shape) { where }
212
+ _reject_unknown_bag_keys!(Internal::ShapeGraph.copy_entries(shape), ClassMethods::SHAPE_OPTION_KEYS, option: "shape:")
213
+ end
214
+
172
215
  # THE refusal for a raw `shape:` kwarg asking to distribute — the reading PRO-3191 retires. Checked at
173
216
  # every position a raw kwarg can occupy, ahead of the block form's own write to the same slot (a block
174
217
  # legitimately builds a distributing shape; a raw kwarg no longer may), so `carrier` is passed rather
@@ -369,6 +412,7 @@ module Axn
369
412
  # what a member's nested shape contributes to its node's height.
370
413
  def _snapshot_inner_shape!(bag, walk, allowance, fields:, position:, via:, via_name:)
371
414
  _reject_unshaped_shape!(bag, _inner_shape_position_label(position, via, via_name, fields))
415
+ _reject_unknown_shape_keys!(bag, _inner_shape_position_label(position, via, via_name, fields))
372
416
  shape = Internal::ShapeGraph.hash_or_nil(bag[:shape])
373
417
  return NO_INNER_CONTRACTS if nil.equal?(shape)
374
418
 
@@ -601,6 +645,7 @@ module Axn
601
645
  # pre-pass, leaving no top-level `shape:` for this read to find.
602
646
  _reject_distributing_shape!(validations, "`shape:` on shape member #{_describe_shape_member(member, name)}")
603
647
  _reject_unshaped_shape!(validations, "`shape:` on shape member #{_describe_shape_member(member, name)}")
648
+ _reject_unknown_shape_keys!(validations, "`shape:` on shape member #{_describe_shape_member(member, name)}")
604
649
  nested = Internal::ShapeGraph.hash_or_nil(validations[:shape])
605
650
  return NO_INNER_CONTRACTS if nil.equal?(nested)
606
651
 
@@ -966,7 +1011,7 @@ module Axn
966
1011
  :_raise_member_confirmation_unsupported!,
967
1012
  :_snapshot_declared_shape!, :_validate_and_snapshot_shape!, :_walk_shape_graph!,
968
1013
  :_distributing_shape_depth,
969
- :_reject_unshaped_shape!, :_reject_distributing_shape!,
1014
+ :_reject_unshaped_shape!, :_reject_unknown_shape_keys!, :_reject_distributing_shape!,
970
1015
  :_distributing_shape_message, :_distributing_container_message,
971
1016
  :_inner_shape_position_label,
972
1017
  :_walk_inner_contracts!, :_walk_declared_inner_contracts!, :_new_path_allowance,
@@ -33,9 +33,218 @@ module Axn
33
33
  check_unanswerable_segments!(tree) # first: an unreachable path moots any ambiguity on it
34
34
  check_subfields_under_map!(tree)
35
35
  check_ambiguous_crossings!(tree) if crossings
36
+ check_model_id_object_claim!(tree, field_configs)
36
37
  check_dead_nil_tolerance!(tree, field_configs)
37
38
  end
38
39
 
40
+ # The subfield-free entry point. `check!` is skipped outright by the top-level seam when no subfield
41
+ # exists, on the documented grounds that with an empty tree no tolerance is unexercisable and no
42
+ # segment is read. That reasoning does not extend to the model-id claim, which needs no subfield at
43
+ # all — a top-level `<field>_id` carrying its own `shape:` block claims the key on its own — so the
44
+ # top-level seam routes here instead of skipping everything.
45
+ #
46
+ # Returns without building a tree when no `model:` is declared, which is what keeps the
47
+ # per-declaration build off the subfield-free path for every contract that cannot trip this.
48
+ def check_model_id_claims!(field_configs)
49
+ return if field_configs.none? { |c| c.validations[:model] }
50
+
51
+ tree = Axn::Internal::SubfieldTree.build(field_configs, [])
52
+ check_model_id_object_claim!(tree, field_configs)
53
+ end
54
+
55
+ # The MODEL-ID OBJECT-CLAIM check (PRO-3396): a `model:` field's generated `<field>_id` names the
56
+ # LOOKUP TOKEN the finder consumes — a scalar. Another declaration can claim that same wire key as an
57
+ # object WITH CONTENTS: a dotted `on:` whose intermediate segment is spelled `<field>_id`, an explicit
58
+ # `<field>_id` that a subfield then nests under, or a `shape:` member of that name carrying its own
59
+ # `members:`. One wire key cannot be both, and the emitter reconciled neither pair — `apply_model_id_child!`
60
+ # recognizes a sibling only by its `configs`, so an implicit intermediate is invisible to it, while
61
+ # `apply_implicit_node!` does not know a model's generated id might be underneath. Whichever ran second in
62
+ # the insertion-ordered walk simply overwrote the other, so the surviving property followed declaration
63
+ # order and the loser's contribution vanished silently.
64
+ #
65
+ # Refused rather than reconciled, because there is no coherent schema to emit: the property is either the
66
+ # scalar the finder reads or the object the descendant is read out of. A PLAIN scalar `<field>_id` sibling
67
+ # is untouched — that is the supported spelling, and the one `sibling_id_configs` exists to serve.
68
+ #
69
+ # The object claim is judged by what the emitter would actually NEST, not by the claimant's declared type:
70
+ # `node_configs_block_nesting?` is the same predicate emission and the drop pass consult, so a node whose
71
+ # children are dropped (a scalar or `model:` route at the id key) raises nothing here — an unreachable
72
+ # segment is `check_unanswerable_segments!`'s to report, and it runs first.
73
+ #
74
+ # THE CLAIM IS THE ONE THE EMITTER WRITES. That scope is what makes this check answerable at
75
+ # declaration, and it is why no `if:`/`unless:` is consulted anywhere below: input reflection is
76
+ # static-maximal, so a gated declaration is advertised exactly as an ungated one is and the document
77
+ # carries the collision either way. A member reached through an EXPLICIT intermediate is such a claim
78
+ # like any other: the emitter conjoins it with the node's own property rather than replacing it
79
+ # (PRO-3399), so nothing here has to ask whether a runtime-only claim is enforced on a given call —
80
+ # a question about ActiveModel's gate resolution that would have had to be re-derived here, and the
81
+ # reason the case was once out of scope.
82
+ def check_model_id_object_claim!(tree, field_configs)
83
+ tree.index.each do |config, path|
84
+ next unless config.validations[:model]
85
+ # A model under a `model:` (or otherwise non-nestable) ancestor is never nested by the emitter at
86
+ # all — `apply_nested_subfields!` stops at the blocking node — so neither this model's generated
87
+ # id nor anything beneath it reaches the document, and there is no emitted claim to collide with.
88
+ # Asked through `path_blocked?`, the drop pass's own predicate, so what this skips and what
89
+ # emission omits cannot drift.
90
+ next if Axn::Internal::Reflection::Schema.path_blocked?(path.ancestors)
91
+
92
+ id_key = Internal::FieldConfig.model_id_key(config.field)
93
+ claim = model_id_object_claimant(tree, path, field_configs, id_key)
94
+ raise_model_id_object_claim!(config, *claim, id_key) if claim
95
+ end
96
+ end
97
+
98
+ # The declaration that claims `id_key` as an object with contents, or nil. Two positions, mirroring the
99
+ # two the emitter reads a sibling from: at depth the id key is a CHILD of the model's own wire parent
100
+ # (`apply_model_id_child!` reads `children[id_field]`), at depth 0 it is another top-level config's own
101
+ # node (`build_input` scans `field_configs` by wire key — not `tree.roots`, which is keyed by reader name
102
+ # and so misses an aliased declaration of the same wire key).
103
+ #
104
+ # A model route at the id key is excluded on both sides: such a config emits its own generated id one
105
+ # level deeper (`<key>_id_id`) and never writes this key at all, the same exclusion `build_input` and
106
+ # `apply_model_id_child!` already apply when they look for an explicit sibling.
107
+ def model_id_object_claimant(tree, path, field_configs, id_key)
108
+ if path.ancestors.empty?
109
+ sibling_nodes = field_configs.filter_map do |c|
110
+ tree.index[c].node if c.field == id_key && !c.validations[:model]
111
+ end
112
+ descendant = sibling_nodes.filter_map { |node| claiming_descendant(node) }.first
113
+ return descendant && [descendant, :nested]
114
+ end
115
+
116
+ parent = path.parent_node
117
+ nested = parent.children[id_key]
118
+ descendant = nested && claiming_descendant(nested)
119
+ return [descendant, :nested] if descendant
120
+
121
+ member = claiming_shape_member(Axn::Internal::Reflection::Schema.emitted_shape_sources(path), id_key)
122
+ member && [member, :member]
123
+ end
124
+
125
+ # The declaration whose nesting under `node` makes the key an object. Nil when its own configs forbid
126
+ # nesting (a scalar or `model:` route there emits no object property, so nothing collides with the id).
127
+ #
128
+ # Two sources of contents, because a node has two. Subfield CHILDREN are the tree's own; a node's own
129
+ # `shape:` MEMBERS are not children at all and so were invisible to a `children`-only test — measured,
130
+ # `expects :company_id, on: :payload, type: Hash do … end` beside a `model:` emitted the member's
131
+ # object and dropped the model's id entirely, while the resolver still read that Hash as its token.
132
+ # The block applies to the CHILDREN only. `node_configs_block_nesting?` gates `apply_nested_subfields!`,
133
+ # which is what declines to nest subfield children — but the node's own property was already built by
134
+ # `build_property`, and `apply_structured_schema!` merged the representative's own `shape:` members
135
+ # into it on the way. So a merged node carrying a `model:` route beside a non-model route with its own
136
+ # members still emits that object, and returning early on the block missed it entirely.
137
+ def claiming_descendant(node)
138
+ nested = first_config_below(node) unless Axn::Internal::Reflection::Schema.node_configs_block_nesting?(node.configs)
139
+
140
+ nested || claiming_own_member(node)
141
+ end
142
+
143
+ # A member declared by the node's OWN `shape:` — the representative route's, which is the one
144
+ # `apply_structured_schema!` merges into the emitted property.
145
+ def claiming_own_member(node)
146
+ first_declared_member(Array(Axn::Internal::Reflection::Schema.property_representative(node.configs)))
147
+ end
148
+
149
+ def first_declared_member(configs)
150
+ configs.each do |config|
151
+ contents = object_contents_of(config)
152
+ return contents if contents
153
+ end
154
+ nil
155
+ end
156
+
157
+ # What `config` contributes as object CONTENTS at its own node, or nil. Two sources, because a member
158
+ # list is not the whole of what gets emitted:
159
+ #
160
+ # * its DECLARED members, returned as the member config itself so the message can name the
161
+ # declaration the author wrote; and
162
+ # * the properties reflection INFERS, which `shape_property_plan` seeds from a structured `type:`
163
+ # whenever an `of:`/`shape:` key is present at all. A `Data`-typed member with an explicitly
164
+ # EMPTY `shape: { members: [] }` declares nothing and still emits its type's own members — an
165
+ # object property where the lookup token belongs — so reading the raw list alone let it through.
166
+ # There is no config to name for one of these, so the property NAME is returned instead.
167
+ #
168
+ # `in_items` is excluded: there the shape describes the ARRAY'S ELEMENTS rather than the node, so the
169
+ # node is emitted as an array and is no object parent. Declared members are asked first, so the
170
+ # existing answer (and the existing message) is unchanged wherever one exists.
171
+ def object_contents_of(config)
172
+ declared = Axn::Internal::Reflection::Schema.named_members(config.validations.dig(:shape, :members)).first
173
+ return declared.first if declared
174
+
175
+ plan = Axn::Internal::Reflection::Schema.shape_property_plan(config, for_output: false)
176
+ return nil unless plan.emitted && !plan.in_items
177
+
178
+ plan.base_properties.keys.first
179
+ end
180
+
181
+ # Descends the way the EMITTER does, not the way the tree is shaped. A child the emitter declines to
182
+ # nest contributes no property, so it is no part of the claim: `apply_implicit_node!` drops an
183
+ # implicit child that collides with a non-nestable `shape:` member (a scalar, a mixed union) along
184
+ # with everything beneath it, leaving the parent emitted as a bare object with empty `properties`.
185
+ # A raw recursive walk still found the dropped descendant and reported it as nesting under the key,
186
+ # which was wrong twice over — the declaration was refused, and the message named a path the schema
187
+ # never emits.
188
+ #
189
+ # Asked through `path_blocked?`, the drop pass's own judgment and the same one `PropertyNames`
190
+ # consults per hop, so what this descends into and what emission nests cannot drift. The whole hop
191
+ # chain is passed each time rather than a carried-member accumulator, because that predicate carries
192
+ # internally from the start of the chain — and it is the PUBLIC half of the pair (`blocking_ancestor?`
193
+ # and `merged_shape_members` are private to reflection on purpose), so mirroring it needs no widening
194
+ # of that module's surface.
195
+ #
196
+ # An EXPLICIT child is always emitted as a property — `apply_children!` writes it unconditionally —
197
+ # and `path_blocked?` reports that hop unblocked, so it still counts as contents.
198
+ def first_config_below(node, hops = [])
199
+ node.children.each do |key, child|
200
+ chain = hops + [[node, key]]
201
+ next if Axn::Internal::Reflection::Schema.path_blocked?(chain)
202
+
203
+ return child.config if child.config
204
+
205
+ deeper = first_config_below(child, chain)
206
+ return deeper if deeper
207
+ end
208
+ nil
209
+ end
210
+
211
+ # The `shape:` spelling of the same claim: a member of the model's wire parent named `id_key` that
212
+ # carries members of its own, so `apply_structured_schema!` merges an object property at that key before
213
+ # `apply_model_id_child!` ever runs.
214
+ #
215
+ # No gate is consulted, deliberately: every claim this guard reads is one the emitter WRITES, and
216
+ # input reflection is static-maximal — a gated member is advertised exactly as an ungated one is, so
217
+ # the document carries the collision either way.
218
+ def claiming_shape_member(parent_configs, id_key)
219
+ Axn::Internal::Reflection::Schema.shape_members_at(parent_configs, id_key).find do |member|
220
+ object_contents_of(member)
221
+ end
222
+ end
223
+
224
+ # Names are RENDERED rather than interpolated raw, for the reason `raise_dead_tolerance!` documents: a
225
+ # declared name may hold bytes with no UTF-8 rendering, and joining one into this message would replace
226
+ # the contradiction being reported with an Encoding::CompatibilityError from the reporting itself.
227
+ def raise_model_id_object_claim!(config, claimant, kind, id_key)
228
+ label = ->(name) { Axn::Internal::Reflection::PropertyNames.renderable_label(name) }
229
+ where = config.on ? " (on #{label.call(config.on)})" : ""
230
+ # A claimant is the DECLARATION where there is one, and a bare emitted property name where the
231
+ # contents are inferred from a structured `type:` (see `object_contents_of`).
232
+ named = claimant.respond_to?(:field) ? claimant.field : claimant
233
+ claim =
234
+ if kind == :member
235
+ "a `shape:` member :#{label.call(named)} of the same name declares members of its own"
236
+ else
237
+ via = claimant.respond_to?(:on) && claimant.on ? " (on #{label.call(claimant.on)})" : ""
238
+ ":#{label.call(named)}#{via} nests underneath that same key"
239
+ end
240
+ raise ArgumentError,
241
+ "`model:` field :#{label.call(config.field)}#{where} generates the wire key " \
242
+ ":#{label.call(id_key)} for its lookup token, but #{claim}. One wire key cannot be both a " \
243
+ "lookup token and a nested object parent — the reflected schema can emit only one of the " \
244
+ "two, and which one survives follows declaration order. Rename the nested key, or drop the " \
245
+ "`model:` on :#{label.call(config.field)}."
246
+ end
247
+
39
248
  # The MAP-PARENT check: a subfield read out of a Hash that declares `of:`. `of:` names what every key of
40
249
  # that hash maps to, and a subfield names one of those keys — so the two describe the same keys two ways,
41
250
  # and no reflected schema can state both. JSON Schema's `additionalProperties` applies only to keys