hecks 1.2.0 → 1.3.0

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
  3. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  4. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  5. data/lib/hecks/behaviors/expectations.rb +32 -4
  6. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  7. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  8. data/lib/hecks/bluebook/model_check.rb +148 -17
  9. data/lib/hecks/forms/field_shape.rb +5 -3
  10. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  11. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  12. data/lib/hecks/fuzzing/form_census.rb +184 -0
  13. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  14. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  15. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  16. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  17. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  18. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  19. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  20. data/lib/hecks/fuzzing/properties.rb +23 -2
  21. data/lib/hecks/fuzzing/replay.rb +130 -20
  22. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  23. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  24. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  25. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  26. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  27. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  28. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  29. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  30. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  31. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  32. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  33. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  34. data/lib/hecks/fuzzing.rb +6 -0
  35. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  36. data/lib/hecks/naming.rb +70 -2
  37. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  38. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  39. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
  40. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  41. data/lib/hecks/projections/glossary/html.rb +250 -0
  42. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  43. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  44. data/lib/hecks/projections/glossary/page.css +271 -0
  45. data/lib/hecks/projections/glossary/page.js +72 -0
  46. data/lib/hecks/projections/glossary/sections.rb +17 -0
  47. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  48. data/lib/hecks/projections/glossary.rb +214 -286
  49. data/lib/hecks/projector/narrate_projector.rb +4 -11
  50. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  51. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  52. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  53. data/lib/hecks/runtime/entity_element.rb +168 -16
  54. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  55. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  56. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  57. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  58. data/lib/hecks/runtime/registry.rb +12 -0
  59. data/lib/hecks/runtime/routing.rb +67 -2
  60. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  61. data/lib/hecks/runtime/value/coercion.rb +77 -115
  62. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  63. data/lib/hecks/runtime/value.rb +7 -2
  64. data/lib/hecks/version.rb +1 -1
  65. data/lib/hecks/vocabulary.rb +2 -1
  66. metadata +23 -2
@@ -85,7 +85,7 @@ module Hecks
85
85
  # source_receiver_for, command_facts, refuse_unconsumed!) for every
86
86
  # piece that IS self-contained; what remains is the sequencing
87
87
  # itself, which further splitting would only relocate, not remove.
88
- # rubocop:disable-next Metrics/MethodLength
88
+ # rubocop:disable-next Metrics/MethodLength, Metrics/PerceivedComplexity
89
89
  def build(registry:, verb:, projected:, explicit:, passthrough: [], source_receiver: nil)
90
90
  args = projected.transform_keys(&:to_sym)
91
91
  unless explicit
@@ -99,6 +99,18 @@ module Hecks
99
99
  rescue UnknownVerb
100
100
  return args
101
101
  end
102
+ # AN ENTITY TARGET HAS NO SHAPE THIS BRANCH CAN BUILD — an
103
+ # implicit (unprojected) `to:` is always a bare scalar, the
104
+ # aggregate's own identity alone; an entity command's receiver is
105
+ # `{aggregate:, entities:}`, which needs the entity's own
106
+ # identity too, and nothing here resolves one (the same "cannot
107
+ # invent an entity identity" limit `source_receiver_for`'s own
108
+ # comment already states). So this stays exactly the pre-BUG#6
109
+ # behavior for an entity target: no receiver is lifted, and the
110
+ # payload forwards wholesale, unchanged, same as when there is
111
+ # no `source_receiver` at all.
112
+ return args unless target.entities.empty?
113
+
102
114
  inherited_receiver = source_receiver_for(target, source_receiver)
103
115
  return inherited_receiver ? args.merge(to: inherited_receiver) : args
104
116
  end
@@ -154,6 +166,26 @@ module Hecks
154
166
  raise UnknownVerb, "reaction target #{verb.inspect} does not resolve to an aggregate" unless aggregate
155
167
 
156
168
  *entity_names, command_name = command_path.split(".")
169
+
170
+ # A PORT OPERATION, reached by the SAME two-segment tail shape an
171
+ # entity command uses ("Head.Rest") — checked FIRST, same order
172
+ # `Dispatcher#dispatch` already resolves a live verb in (an
173
+ # aggregate that declared both a port and an entity of the same
174
+ # name would resolve to the port there too; no domain in this
175
+ # corpus does). A port has no `entities` of its own — the
176
+ # RECEIVER is always the aggregate itself — so `Target#entities`
177
+ # stays empty and `#command` holds the `PortOperation`, which
178
+ # answers `#creates?` (always false) the same way an ordinary
179
+ # `Command` does, letting `source_receiver_for` lift a same-
180
+ # aggregate policy's own Event.id as the operation's receiver
181
+ # exactly as it already does for a plain command.
182
+ if entity_names.one? && (port = aggregate.port(entity_names.first))
183
+ operation = port.operation(command_name)
184
+ raise UnknownVerb, "reaction target #{verb.inspect} does not resolve to a declared port operation" unless operation
185
+
186
+ return Target.new(aggregate: aggregate, entities: [], command: operation)
187
+ end
188
+
157
189
  owner = aggregate
158
190
  entities = entity_names.map do |entity_name|
159
191
  entity = owner.entities.find { |candidate| candidate.hecks_name == entity_name }
@@ -187,10 +219,45 @@ module Hecks
187
219
  # it cannot address another aggregate, invent an entity identity, or turn
188
220
  # a creation into a mutation. An explicit projected receiver is resolved
189
221
  # first and remains authoritative.
222
+ #
223
+ # ANSWERS REGARDLESS OF `target.entities` — the value this method hands
224
+ # back is ONLY EVER the ROOT AGGREGATE'S OWN identity (`source_receiver`
225
+ # carries nothing else: `event.aggregate`/`event.id` name the emitting
226
+ # AGGREGATE, never one of its entities). An entity target's own identity
227
+ # is resolved entirely separately, from `args` (`build`'s own `entity_
228
+ # identities` loop) — this method is never consulted for it, so
229
+ # answering for an entity target does not "invent an entity identity"
230
+ # any more than answering for a plain one invents THAT identity; it was
231
+ # already the one thing this method has ever supplied. `build`'s own
232
+ # EXPLICIT branch (below) already applies this correctly either way
233
+ # (`aggregate_identity ||= inherited_receiver`, entity identities read
234
+ # from `args` regardless) — the caller that actually needed a guard
235
+ # here is the IMPLICIT one, above, which has no shape to build a
236
+ # `{aggregate:, entities:}` receiver from an inherited scalar alone,
237
+ # and now guards itself for exactly that reason instead of this method
238
+ # doing it on that caller's behalf and, as a side effect, also refusing
239
+ # the EXPLICIT caller's own legitimate case — confirmed missing until
240
+ # `qa/stress_domains/waybill` (BUG#6) exercised it for the first time
241
+ # anywhere in the corpus: a saga dispatching into a nested entity's own
242
+ # command, inheriting its aggregate receiver from the event that
243
+ # triggered it.
190
244
  def source_receiver_for(target, source_receiver)
191
245
  return nil unless source_receiver
192
- return nil unless target.entities.empty?
193
- return nil if target.command.creates?
246
+ # `target.command.creates?` ALONE MISREADS EVERY ENTITY COMMAND —
247
+ # `Behaviour::Command#creates?`'s own comment: "a verb declared on
248
+ # an ENTITY always acts on that piece... which means `creates?`
249
+ # answers true for every one of them" (it never sets `@references`
250
+ # the way an aggregate-level command's own `reference_to` does,
251
+ # not because it brings anything new into being). `build`'s own
252
+ # `if target.entities.empty? && target.command.creates?` guard,
253
+ # 20-odd lines below, already reads `creates?` correctly for
254
+ # exactly this reason — an entity command is never a genuine
255
+ # creation, whatever `creates?` alone answers — so this checks the
256
+ # SAME compound condition instead of the bare, misleading half of
257
+ # it. Left unfixed, an entity target's own receiver was refused
258
+ # here even after this method stopped refusing on `target.entities`
259
+ # alone — the second half of BUG#6's own fix.
260
+ return nil if target.entities.empty? && target.command.creates?
194
261
 
195
262
  source = source_receiver.transform_keys(&:to_sym)
196
263
  source_aggregate = source[:aggregate].to_s
@@ -69,8 +69,8 @@ module Hecks
69
69
  "{command} refused — {field} is {current}, and {command} moves it only from {allowed}",
70
70
  %w[TypeMismatch value_object_shape] =>
71
71
  "{name} is a {type} — pass its fields as an object, not {offered}",
72
- %w[TypeMismatch reference_as_object] =>
73
- "{command} refused — a reference is an id, and {attribute} arrived as an object{known_by}",
72
+ %w[TypeMismatch reference_wrong_shape] =>
73
+ "{command} refused — a reference is an id, and {attribute} arrived as {offered}{known_by}",
74
74
  %w[TypeMismatch multi_field_scalar] =>
75
75
  "{type} has multiple fields and cannot stand in for a scalar",
76
76
  %w[TypeMismatch composite_identity] =>
@@ -106,6 +106,9 @@ module Hecks
106
106
  "{query} declares authorize with tenant: {field} — pass {field}: to name which {field} this ask is scoped to",
107
107
  %w[Unauthorized role_mismatch] =>
108
108
  "{command} refused — role: {role}, and the caller stated {caller_role}",
109
+ %w[Unauthorized cross_tenant_reference] =>
110
+ "{aggregate} {field} is {tenant}, but {attribute} names a {target} whose own {target_field} " \
111
+ "is {other} — a cross-tenant reference",
109
112
  %w[AttributeAbsent absent_read] =>
110
113
  "{aggregate} {field} is absent on this record — declared, not optional, and added since it was written. Backfill it " \
111
114
  "in a translation (backfill :{field}, default: ...), or declare it optional: true",
@@ -70,6 +70,15 @@ module Hecks
70
70
  # a boot with no era-plugin domain at all, whose first touch would
71
71
  # otherwise be a live dispatch's own `RepositoryFactory.build` read.
72
72
  @resolved_eras = {}
73
+ # THE SIBLING FACT `EraResolver.check!` records for an OLD checkout:
74
+ # domain name -> the newest held ordinal that superseded the era this
75
+ # boot resolved to; absent for every domain booting the current era.
76
+ # `RepositoryFactory.build` hands it to the adapter as
77
+ # `superseded_by:`, and `PostgresEra#append` refuses on it before
78
+ # ever issuing an INSERT — the in-process half of the era fence, the
79
+ # half that holds even for a connection row-level security cannot
80
+ # bite (BUG#24). Eager for exactly the reason `@resolved_eras` is.
81
+ @superseded_eras = {}
73
82
  # EAGER, NOT LAZY — see `#capability_graph`'s own comment for why.
74
83
  # `CapabilityGraph.new` only stores the registry reference; there is
75
84
  # no reason to defer it, and doing so removes the exact same
@@ -163,6 +172,9 @@ module Hecks
163
172
  # `Hecks/ThreadSharedIvarMutation` is the reason there is no `||=`
164
173
  # left here to flag.
165
174
  attr_reader :resolved_eras
175
+ # Domain name -> the ordinal that superseded this boot's own era, for
176
+ # an old checkout only — see `initialize`'s own comment on it.
177
+ attr_reader :superseded_eras
166
178
 
167
179
  def bluebook(name) = @bluebooks[name.to_s]
168
180
  def hecksagon(name) = @hecksagons[name.to_s]
@@ -1,5 +1,6 @@
1
1
  require_relative "errors"
2
2
  require_relative "refusal_wording"
3
+ require_relative "../rendering"
3
4
 
4
5
  module Hecks
5
6
  module Runtime
@@ -19,7 +20,7 @@ module Hecks
19
20
  def envelope(to, entity_depth: 0)
20
21
  return nil if to.nil?
21
22
 
22
- aggregate, entities = to.is_a?(Hash) ? parse_envelope_hash(to) : [to, []]
23
+ aggregate, entities = to.is_a?(Hash) ? parse_envelope_hash(to) : scalar_envelope(to)
23
24
 
24
25
  raise TypeMismatch, "to: must name the receiving aggregate identity" if aggregate.nil? || aggregate.to_s.empty?
25
26
  if entities.size != entity_depth
@@ -34,6 +35,49 @@ module Hecks
34
35
  Envelope.new(aggregate: aggregate, entities: entities)
35
36
  end
36
37
 
38
+ # BUG#7 — the non-Hash half of `to:` used to accept ANY Ruby object
39
+ # (`[to, []]`, unconditionally) as though it were a ready-made
40
+ # aggregate identity scalar. That is looser than the JSON step
41
+ # boundary (`bin/run`/`Fuzzing::Replay`/`StepBuilder`) ever needs it
42
+ # to be — every legitimate caller already hands this a `String`
43
+ # (`Naming.identity` canonicalizes every identity to one before it
44
+ # ever reaches a `to:`/legacy-args door) — and looser than Rust's own
45
+ # hand-written mirror of this exact boundary
46
+ # (`rust/src/kernel/routing.rs#RoutingEnvelope::from_json`, its own
47
+ # header: "Generated routers accept this shape while retaining the
48
+ # legacy mixed-args object as a compatibility input during
49
+ # migration"), which refuses anything that is neither a JSON string
50
+ # nor object outright, TypeMismatch, before ever reaching a domain's
51
+ # own command payload.
52
+ #
53
+ # The gap surfaced live on `examples/roster` — the first domain in
54
+ # this corpus to declare a command attribute literally named `to`
55
+ # (`Roster::Roster.Mark`, deliberately, per that bluebook's own
56
+ # header comment). `bin/qa_sweep`'s legacy-args dispatch convention
57
+ # (`runtime.dispatch(verb, **symbolize(args))`, `step_builder.rb`)
58
+ # flattens a command's own declared fact and its routing target into
59
+ # ONE Ruby kwargs hash — completely ordinary for every other domain,
60
+ # since Ruby's keyword-argument binding only steals a key that
61
+ # collides with `dispatch`'s own `to:`/`with:`/`saga_correlation:`
62
+ # parameter names. `Mark`'s `to` does collide, so a fuzzer-corrupted
63
+ # scalar offered for it (an out-of-range Integer, from
64
+ # `InvalidValueGenerator.corrupt`) was routed here as the AGGREGATE
65
+ # IDENTITY instead of the domain's own required argument — accepted
66
+ # unconditionally, leaving `Mark`'s own `to` fact absent from the
67
+ # payload entirely. Ruby refused `AbsentArgument` ("Mark was not
68
+ # given to — it takes to"); Rust's stricter `RoutingEnvelope::
69
+ # from_json` refuses the malformed scalar itself, TypeMismatch,
70
+ # before the domain payload is ever examined — the observed
71
+ # divergence. Tightening this branch to Rust's own contract (a
72
+ # scalar `to:` must be a `String`) makes both refuse the same way,
73
+ # for the same reason, at the same step.
74
+ def scalar_envelope(to)
75
+ return [to, []] if to.is_a?(String)
76
+
77
+ raise TypeMismatch, "to: must be a string aggregate identity or an entity route, got #{Rendering.describe(to)}"
78
+ end
79
+ private_class_method :scalar_envelope
80
+
37
81
  # The Hash-shaped half of `to:` — pulled out of `envelope` because
38
82
  # it is a self-contained parse (raises on an unrecognized key, then
39
83
  # returns the pair) with no dependency on anything `envelope` does
@@ -98,10 +142,31 @@ module Hecks
98
142
  end
99
143
  private_class_method :refuse_absent_facts!
100
144
 
145
+ # BUG#18 — an entity route naming NO entity at all (`entities: []`,
146
+ # or neither `entity:` nor `entities:` present) used to come back
147
+ # `[]` here unremarked, which only ever refused downstream for an
148
+ # ENTITY command (`envelope`'s own `entities.size != entity_depth`
149
+ # check, entity_depth >= 1) — for an AGGREGATE-level command
150
+ # (entity_depth 0), `[].size == 0` trivially satisfied that check,
151
+ # so the degenerate Hash `{aggregate:, entities: []}` reached the
152
+ # command's own validation instead of being refused as malformed
153
+ # routing. Rust's `RoutingEnvelope::from_json` never let it get
154
+ # that far: the Hash branch refuses "entity route requires at
155
+ # least one entity identity" unconditionally, before any
156
+ # entity_depth is known. Refusing it here too, at the same point,
157
+ # closes the gap without touching the scalar branch (a bare
158
+ # aggregate identity, no Hash at all) that every ordinary aggregate
159
+ # dispatch already uses instead of this shape (`Facade::Handle#
160
+ # dispatch`'s own `to: @id`, `CommandRequest`'s own header —
161
+ # "aggregate command: { to: "record-id", ... }" — and, now,
162
+ # `Judge#address`).
101
163
  def entity_identities(hash)
102
164
  raise TypeMismatch, "to: takes entity: or entities:, not both" if hash.key?(:entities) && hash.key?(:entity)
103
165
 
104
- hash.key?(:entities) ? Array(hash[:entities]) : Array(hash[:entity])
166
+ identities = hash.key?(:entities) ? Array(hash[:entities]) : Array(hash[:entity])
167
+ raise TypeMismatch, "to: entity route requires at least one entity identity" if identities.empty?
168
+
169
+ identities
105
170
  end
106
171
  private_class_method :entity_identities
107
172
  end
@@ -506,8 +506,45 @@ module Hecks
506
506
  )
507
507
  end
508
508
 
509
+ # BUG#6 — UNCONDITIONALLY THE SAGA'S OWN HOME DOMAIN, never inferred
510
+ # from `command_name`'s own shape. This used to guess: a leftover
511
+ # `::` after `Naming.command_ref`'s own rewrite was read as "already
512
+ # domain-qualified" and left alone. That heuristic cannot actually
513
+ # tell a genuinely cross-domain reference (`Banking::Account::
514
+ # Debit` -> one `::` survives) apart from a SAME-DOMAIN entity
515
+ # command reference (`Manifest::Slot::Fill` -> one `::` survives
516
+ # too, for an unrelated reason — entity nesting, not a domain
517
+ # qualifier) — both collapse to the identical "one `::` left" shape,
518
+ # and the string alone carries no further signal to split them
519
+ # (confirmed against `Naming.command_ref`'s own rewrite: it only
520
+ # ever strips the LAST `::`, so the count of what remains is blind
521
+ # to why it's there). Picking the cross-domain reading unconditionally
522
+ # left `qa/stress_domains/waybill`'s own `Packing` saga dispatching
523
+ # `Manifest::Slot::Fill` — an entity command in its OWN domain —
524
+ # unprefixed, so `Naming.split_verb` read "Manifest" as a domain
525
+ # name instead of this chapter's own aggregate, and the dispatch
526
+ # failed with `UnknownVerb`, silently recorded as an ordinary
527
+ # domain refusal rather than surfacing as the real bug it is.
528
+ #
529
+ # THE FIX MIRRORS `PolicyInterpreter#deliver`'s OWN MECHANISM,
530
+ # which never had this bug: a policy's cross-domain target is a
531
+ # SEPARATE, EXPLICIT field (`Policy#target_domain`, set only by the
532
+ # `across` keyword) — `deliver` unconditionally builds
533
+ # `"#{policy.target_domain || domain}::#{policy.trigger_command}"`,
534
+ # never asking whether `trigger_command` LOOKS already-qualified.
535
+ # A saga's own `dispatch`/`compensates` has no such explicit field
536
+ # and no keyword to set one — and, confirmed against the ENTIRE
537
+ # corpus (banking's Onboarding/Settlement/ExternalSettlement,
538
+ # quality_control's BugCiWatch, and this domain's own Packing),
539
+ # no saga anywhere ever dispatches genuinely cross-domain: "every
540
+ # command a saga fires lands inside its own bluebook chapter"
541
+ # (`Projections::Diagrams#saga_diagram`'s own comment, written
542
+ # independently of this fix and still true). So the home domain IS
543
+ # the only explicit context a saga dispatch ever has — this applies
544
+ # it the same way `deliver` applies its own default (no `across`)
545
+ # case, without inventing a keyword nothing in the corpus needs.
509
546
  def qualified(command_name, domain)
510
- command_name.include?("::") ? command_name : "#{domain}::#{command_name}"
547
+ "#{domain}::#{command_name}"
511
548
  end
512
549
 
513
550
  def end_saga(process_manager, event, domain)
@@ -55,13 +55,22 @@ module Hecks
55
55
  # itself, so a `reference: {value: ...}` offered against a `String`
56
56
  # query field is the documented allowance (see banking's own
57
57
  # `Account.OpenForCustomer`), not a C3.8 mismatch.
58
- # `argument: true` is the CALLER'S door only (`Interpreting#normalize_
59
- # args`, every command/entity/port dispatch): the one place a nil
60
- # for a non-optional attribute is the caller leaving a required
61
- # argument empty (C3.7). Every other caller — `sets` copying an
58
+ # `argument: true` is the COMMAND/ENTITY/PORT argument door only
59
+ # (`Interpreting#normalize_args`, every command/entity/port
60
+ # dispatch): the one place a nil for a non-optional attribute is
61
+ # the caller leaving a required argument empty (C3.7), absorbed
62
+ # via the type's own field defaults when every field has one
63
+ # (`nil_argument` below). Every other caller — `sets` copying an
62
64
  # optional argument into state, hydration, entity elements,
63
- # identity, defaults — is state assembly, where nil is a legitimate
64
- # "absent is not empty" value the aggregate's own attribute may hold.
65
+ # identity, defaults — is state assembly, where nil is a
66
+ # legitimate "absent is not empty" value the aggregate's own
67
+ # attribute may hold. `QueryInterpreter#normalize_args` never
68
+ # passes `argument: true` — a null required value-object-typed
69
+ # QUERY argument is checked, and refused, entirely on its own
70
+ # side (`null_vo_argument!`, query_interpreter.rb) precisely so
71
+ # it does NOT reach this default-absorbing fallback (QualityControl
72
+ # BUG#36 — a query's own null VO argument must refuse regardless
73
+ # of any default, unlike a command's).
65
74
  def for_attribute(aggregate, attribute, value, boundary: true, argument: false)
66
75
  return nil_or_missing(aggregate, attribute, value, argument) if attribute.nil? || value.nil?
67
76
  return reference_list(attribute, value) if attribute.list? && attribute.reference?
@@ -372,108 +381,6 @@ module Hecks
372
381
 
373
382
  def trusting_stored_state? = Thread.current[TRUSTED_LOAD_KEY] == true
374
383
 
375
- # S17, ADR 0026 — SEARCHES THE WHOLE ENTITY TREE, not only the
376
- # root's own direct children. `aggregate` here is always the
377
- # ROOT aggregate — `for_attribute`'s own `aggregate` argument is
378
- # never reassigned as hydration recurses into a nested element,
379
- # because coercion has to resolve value objects, and only the
380
- # root answers `.value_object` at all (Entity's own header
381
- # comment: an entity must NOT answer to it, or `Value.
382
- # for_attribute` could no longer tell a piece from a head). So
383
- # a NESTED entity — Dispatch, inside Handler — is not a direct
384
- # child of the root the way Handler itself is, and a plain
385
- # `aggregate.entities.find` stops one level short of it.
386
- def find_entity(construct, name)
387
- construct.entities.each do |candidate|
388
- return candidate if candidate.hecks_name == name
389
-
390
- found = find_entity(candidate, name)
391
- return found if found
392
- end
393
- nil
394
- end
395
-
396
- # Frozen through: a list read back out of the store is an answer,
397
- # not a handle on what is stored.
398
- #
399
- # ADR 0047 — this used to bail (`return value unless entity`) the
400
- # moment `attribute.type` named a value object rather than an
401
- # entity, handing back the raw, un-hydrated argument untouched.
402
- # A `sets :field` mutation sourced from a whole-array argument (as
403
- # opposed to element-by-element `append:`) went straight through
404
- # `for_attribute`'s `:list` branch, so `Banking::CardPayment.
405
- # Authorize`'s own `sets :tags` (`list_of(Tag)`) stored plain
406
- # Ruby Hashes as its `tags` elements forever — never a real
407
- # `Value`, never through `Tag`'s own `pattern:`/`invariant`
408
- # checks. `remove:`'s `==` comparison (a real `Value` against a
409
- # raw `Hash`) then always failed, since `Hash#==` refuses anything
410
- # that isn't itself a compatible Hash — the bug ADR 0047 traces in
411
- # full. Delegating to `hydrate_value_object_list` below closes
412
- # that gap the same way the ENTITY branch already worked: build a
413
- # real, validated `Value` per element, reusing `for_attribute`'s
414
- # own composite-construction path rather than inventing a second
415
- # one.
416
- def hydrate_entity_list(aggregate, attribute, value)
417
- entity = find_entity(aggregate, attribute.type.to_s)
418
- return hydrate_value_object_list(aggregate, attribute, value) unless entity
419
-
420
- hydrated = Array(value).map do |element|
421
- next element unless element.is_a?(Hash)
422
-
423
- element.each_with_object({}) do |(name, field_value), acc|
424
- key = name.to_sym
425
- field = entity.attribute(key)
426
- acc[key] = field ? for_attribute(aggregate, field, field_value) : field_value
427
- end
428
- end
429
- Freezer.deep(hydrated)
430
- end
431
-
432
- # The value-object sibling of the entity branch above: an element
433
- # already shaped like the target `Value` (or a `Hash`/scalar that
434
- # `fields_for` can still open) is rebuilt through the SAME `build`
435
- # a scalar composite attribute already uses (`for_attribute`'s own
436
- # `coerced = ... build(value_object, fields_for(...), aggregate)`
437
- # line) — same defaults, same `pattern:`/`admits:`/invariant
438
- # checks, same `trusting_stored_state?` bypass on a trusted load.
439
- # `attribute.type` naming neither an entity nor a value object
440
- # (a `list_of(String)`, say) has no shape to rebuild into, so the
441
- # element passes through unchanged, exactly as the entity branch's
442
- # own non-Hash elements do.
443
- #
444
- # NOT `Array(value).map` (unlike the entity branch above) — `value`
445
- # here is not always genuinely list-shaped. `MutationApplier#
446
- # removed`'s own `Value.for_attribute(aggregate, attribute, value)`
447
- # call (`attribute` = the LIST attribute, `mutation.target`; `value`
448
- # = the single REMOVE-target argument, already a real `Value` by
449
- # the time it gets here) reuses this exact branch — for `remove:`,
450
- # not for a whole-list `sets`. `Array(a_real_Value)` alone would be
451
- # harmless (`Value` defines neither `to_a` nor `to_ary`, so Kernel
452
- # wraps it `[value]`), but `Array(a_Hash)` is NOT harmless: Ruby's
453
- # `Array()` opens a bare Hash into its own `[[k, v], ...]` pairs,
454
- # not `[hash]` — silently shredding a single-element Hash-shaped
455
- # target into garbage instead of hydrating it. Branching on
456
- # `value.is_a?(Array)` up front (true only for a genuine whole-list
457
- # `sets`/hydrate load) keeps the single-target shape a single
458
- # target, hydrated the same way, never listified.
459
- def hydrate_value_object_list(aggregate, attribute, value)
460
- return value unless aggregate.respond_to?(:value_object)
461
-
462
- value_object = value_object_for(aggregate, attribute.type)
463
- return value unless value_object
464
-
465
- return hydrate_value_object_element(aggregate, attribute, value_object, value) unless value.is_a?(Array)
466
-
467
- hydrated = value.map { |element| hydrate_value_object_element(aggregate, attribute, value_object, element) }
468
- Freezer.deep(hydrated)
469
- end
470
-
471
- def hydrate_value_object_element(aggregate, attribute, value_object, element)
472
- return element if element.is_a?(self) && element.type_name == value_object.hecks_name
473
-
474
- build(value_object, fields_for(value_object, attribute.name, element), aggregate)
475
- end
476
-
477
384
  # `Value.identifier` used to live here: hand it a one-field value object
478
385
  # and it opened it, so `identified_by :number` could pass for an identity
479
386
  # and the runtime would guess which field was meant. THAT GUESS IS GONE.
@@ -490,7 +397,7 @@ module Hecks
490
397
  # it carried said retiring it meant changing how references are STORED ;
491
398
  # that is what happened.
492
399
 
493
- # A REFERENCE IS AN ID, SO AN OBJECT IS NOT ONE.
400
+ # A REFERENCE IS AN ID, SO ANYTHING ELSE IS NOT ONE.
494
401
  #
495
402
  # Nothing coerces a reference — `for_attribute` misses on
496
403
  # "Reference<Account>", which is no value object's name, and hands the
@@ -502,21 +409,76 @@ module Hecks
502
409
  # coercion because the sentence names the COMMAND, and `for_attribute`
503
410
  # never learns which command it is serving.
504
411
  #
505
- # An Array is deliberately not refused here. A reference is never a list
506
- # today, and inventing a rule for a shape the language cannot declare is
507
- # how decoration gets written.
412
+ # WIDENED PAST THE OBJECT SHAPE BY BUG#27 (QualityControl ledger,
413
+ # found live on `qa/stress_domains/referral_chain`'s `Member.Join`/
414
+ # `Referral.Issue`). A bare Boolean, Array, or `null` used to sail
415
+ # through here untouched — nothing but Hash/Value ever refused —
416
+ # then get `.to_s`'d into a lookup key by `CommandRules::
417
+ # References#reference_key` ("true", "false", "[8, 8]") and answer
418
+ # NotFound, or, for `null`, skip the lookup outright
419
+ # (`next if held.nil?`, command_rules/references.rb) and let the
420
+ # command run on to whatever its own `given` happened to say — a
421
+ # shape error misreading as a missing record, or as an unrelated
422
+ # domain refusal. Rust's generated `from_json` requires a JSON
423
+ # string for a required reference field before anything else runs
424
+ # (`JoinArgs.sponsor: expected String`); this closes the same gate
425
+ # at the same DISPATCH_ORDER step Ruby already runs it at
426
+ # (`normalize_args`, `Vocabulary::AggregateDispatchOrder`/
427
+ # `EntityDispatchOrder`), strictly before `resolve_references` ever
428
+ # receives a value to look up — so the two engines now agree on
429
+ # BOTH kind and order, not just kind.
430
+ #
431
+ # `nil` STAYS LEGITIMATE for a `reference_to ..., optional: true`
432
+ # argument (`Improvement.Open`'s own `reference_to Angle, optional:
433
+ # true` — `qa/bluebook/quality_control.bluebook`): the caller
434
+ # genuinely may have nothing to name yet, and `nil_argument`
435
+ # (interpreting.rb) already passes an optional reference's `nil`
436
+ # through untouched. A REQUIRED reference offered as `null` is a
437
+ # caller leaving a required argument empty in every OTHER sense
438
+ # this runtime already refuses (C3.7) — refusing it HERE, rather
439
+ # than falling through to `resolve_references`' own nil-skip and
440
+ # then whatever the command's `given` happens to say, is what
441
+ # actually names the empty argument instead of something else.
442
+ #
443
+ # A `has_many` reference's own Array shape is still never refused
444
+ # by ITS wrapper (`Array(value).find { ... }` only inspects the
445
+ # list's ELEMENTS) — a reference is never a scalar list-of-lists
446
+ # today, and inventing a rule for a shape the language cannot
447
+ # declare is how decoration gets written. `reference_list` (below)
448
+ # already owns "not an Array at all" for that case.
508
449
  def refuse_object_reference(command, attribute, value)
509
450
  return unless attribute.reference?
510
451
 
511
- offered = attribute.list? ? Array(value).find { |item| item.is_a?(Hash) || item.is_a?(self) } : value
512
- return unless offered.is_a?(Hash) || offered.is_a?(self)
452
+ if attribute.list?
453
+ offered = Array(value).find { |item| item.is_a?(Hash) || item.is_a?(self) }
454
+ return unless offered
455
+ else
456
+ return if value.nil? && attribute.optional?
457
+ return if value.is_a?(String)
458
+
459
+ offered = value
460
+ end
513
461
 
514
462
  raise TypeMismatch,
515
- RefusalWording.render("TypeMismatch", "reference_as_object",
463
+ RefusalWording.render("TypeMismatch", "reference_wrong_shape",
516
464
  command: command.hecks_name, attribute: attribute.name,
465
+ offered: reference_shape_description(offered),
517
466
  known_by: known_by(attribute))
518
467
  end
519
468
 
469
+ # "an object" for the Hash/Value shape — the ORIGINAL wording this
470
+ # method always gave, pinned byte for byte by
471
+ # `spec/runtime/reference_shape_spec.rb`, kept unchanged by BUG#27's
472
+ # widening. `Rendering.describe` for everything else: `true`,
473
+ # `false`, `nil`, `[8, 8]` — the same rendering every other
474
+ # TypeMismatch in this file already uses for "here is what you
475
+ # actually sent."
476
+ def reference_shape_description(value)
477
+ return "an object" if value.is_a?(Hash) || value.is_a?(self)
478
+
479
+ Rendering.describe(value)
480
+ end
481
+
520
482
  # "(Account is known by number)" — what to send instead. No article, on
521
483
  # purpose: "an Account" and "a Customer" differ by the target's first
522
484
  # letter, and a refusal pinned byte-for-byte should not hinge on an