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
@@ -165,7 +165,33 @@ module Hecks
165
165
  record = registry.repository(target_domain, target_ir).find(held)
166
166
  return true unless record
167
167
 
168
- comparable(record.state[:state]) != state
168
+ # THE FIELD A STATE LIVES ON, READ FROM THE TARGET'S OWN
169
+ # DECLARATION — not assumed to be literally named `state`. Every
170
+ # `none_in_state` fixture this comparator originally shipped
171
+ # with (spec/query_none_in_state_*_spec.rb) happens to declare a
172
+ # plain `attribute :state, ...` rather than a real `lifecycle`,
173
+ # which is how the previous hardcoded `record.state[:state]`
174
+ # passed every one of them while being wrong for the shape this
175
+ # whole comparator exists to answer about: a real state MACHINE.
176
+ # `lifecycle :field, default: ... do ... end` stores its state
177
+ # under `field` (`Instance#assign_creation_attributes`'s own
178
+ # `state[aggregate.lifecycle.field.to_sym] = ...`), and this
179
+ # codebase's own convention overwhelmingly names that field
180
+ # `status`, not `state` (`QualityControl::Clearance`'s own
181
+ # `lifecycle :status` among many others) — so the hardcoded key
182
+ # silently read `nil` from every real lifecycle-backed target,
183
+ # comparable(nil) != state was true unconditionally, and
184
+ # `none_in_state` against ANY lifecycle aggregate answered
185
+ # "not excluded" for every row, always, no matter its actual
186
+ # state. Found chasing `QualityControl::Bug.AwaitingClearance`
187
+ # (qa/bluebook/quality_control.bluebook), which is exactly this
188
+ # shape: `Clearance:green`/`Clearance:red` against a `lifecycle
189
+ # :status` aggregate. Falls back to `:state` when the target
190
+ # declares no lifecycle at all, so every existing fixture (a
191
+ # plain attribute literally named `state`) keeps answering
192
+ # exactly as before.
193
+ field = target_ir.lifecycle&.field || :state
194
+ comparable(record.state[field]) != state
169
195
  end
170
196
 
171
197
  def find_aggregate_by_name(registry, name)
@@ -1,4 +1,5 @@
1
1
  require_relative "../value"
2
+ require_relative "../entity_element"
2
3
 
3
4
  module Hecks
4
5
  module Runtime
@@ -174,17 +175,24 @@ module Hecks
174
175
  # Vendored addition, not (yet) upstream hecks (migration plan
175
176
  # task 4): the removal counterpart to #appended -- matches by
176
177
  # VALUE EQUALITY, element-wise, no read-modify-write (plan.
177
- # bluebook's own words: "so a concurrent Add can never be lost").
178
- # `mutation.source` is a single field reference (`:dependency`),
179
- # unlike append's field-map -- resolved and Value-coerced the
180
- # SAME way increment/decrement already coerce their own amount,
181
- # so the comparison is against a like-shaped Value, not a raw
182
- # scalar against a wrapped one.
178
+ # bluebook's own words: "so a concurrent Add can never be lost")
179
+ # -- UNLESS the target list is itself entity-typed (BUG#32,
180
+ # QualityControl ledger), in which case `EntityElement.
181
+ # list_element_match?` matches by the entity's own IDENTITY
182
+ # field instead -- see that method's own comment for the full
183
+ # "why identity, not whole-value equality" reasoning. `mutation.
184
+ # source` is a single field reference (`:dependency`), unlike
185
+ # append's field-map -- resolved and Value-coerced the SAME way
186
+ # increment/decrement already coerce their own amount, so the
187
+ # comparison is against a like-shaped Value, not a raw scalar
188
+ # against a wrapped one.
183
189
  def removed(instance, aggregate, mutation, args)
184
190
  value = @rules.resolve_source(mutation.source, args)
185
191
  attribute = aggregate.attribute(mutation.target)
186
192
  value = Value.for_attribute(aggregate, attribute, value) if attribute
187
- Array(instance[mutation.target]).reject { |element| element == value }
193
+ Array(instance[mutation.target]).reject do |element|
194
+ EntityElement.list_element_match?(aggregate, attribute, element, value)
195
+ end
188
196
  end
189
197
 
190
198
  # Vendored fix, not (yet) upstream hecks (migration plan
@@ -246,7 +254,17 @@ module Hecks
246
254
  check_entity_collision(aggregate, entity, current, fields)
247
255
  end
248
256
  fields[entity.lifecycle.field] ||= entity.lifecycle.default if entity.lifecycle
249
- fields
257
+ # BUG#12 — every one of THIS entity's own declared attributes
258
+ # the append mapping (and the identity/lifecycle filling just
259
+ # above) didn't already touch gets its own default, the same
260
+ # way a fresh aggregate's own attributes already do
261
+ # (`Instance.defaults`) — see `EntityElement#
262
+ # fill_declared_defaults`'s own comment for the full reasoning;
263
+ # shared rather than reimplemented so this aggregate-level
264
+ # entity creation and `EntityElement#appended_to_element`'s
265
+ # entity-nested-in-entity one can never drift on what "the
266
+ # default" means.
267
+ EntityElement.fill_declared_defaults(aggregate, entity, fields)
250
268
  end
251
269
 
252
270
  # THE MINTED IDENTITY IS ONE PAST THE HIGHEST HELD (C4.5) — not
@@ -1,6 +1,8 @@
1
1
  require_relative "../errors"
2
2
  require_relative "../refusal_wording"
3
3
  require_relative "../value"
4
+ require_relative "../../rendering"
5
+ require_relative "../../ports/query/in_memory"
4
6
 
5
7
  module Hecks
6
8
  module Runtime
@@ -40,6 +42,8 @@ module Hecks
40
42
  # This is what makes a `has_many` declared on an aggregate honest even
41
43
  # when a command supplies its list through an ordinary typed argument.
42
44
  def resolve_state_references(domain, construct, state)
45
+ own_tenant_field = tenant_field_for(construct)
46
+
43
47
  construct.attributes.each do |attribute|
44
48
  next unless attribute.reference?
45
49
 
@@ -51,6 +55,7 @@ module Hecks
51
55
  next unless target
52
56
 
53
57
  validate_reference_values(domain, target, held, list: attribute.list?)
58
+ enforce_tenant_boundary(domain, construct, attribute, target, held, state, own_tenant_field)
54
59
  end
55
60
 
56
61
  Array(construct.entities).each do |entity|
@@ -102,6 +107,76 @@ module Hecks
102
107
  end
103
108
  end
104
109
 
110
+ # ANGLE-8's OWN WRITE-SIDE HALF of `TenantScope.apply` (runtime/
111
+ # tenant_scope.rb) — the QUERY-side mechanism this mirrors. That
112
+ # module turns a declared `authorize policy, tenant: :field` into a
113
+ # synthetic where-clause checked against the CALLER's own supplied
114
+ # tenant argument; there is no caller-identity/session system this
115
+ # runtime has to check a WRITE's caller against (TenantScope's own
116
+ # header names that as a separate, still-open gap), so this checks
117
+ # the one thing that IS available without one: whether the record
118
+ # being written and the record it references agree about which
119
+ # tenant they belong to. `lib/hecks/fuzzing/properties/guards.rb`'s
120
+ # `commands_respect_tenant_scope` states the identical claim,
121
+ # read off `history[:instances]` after the fact — this is what
122
+ # makes that claim hold BY CONSTRUCTION (a refused write is never
123
+ # stored) rather than merely checked for regression.
124
+ #
125
+ # Hooked into `resolve_state_references` rather than a new
126
+ # DISPATCH_ORDER step deliberately: that method already walks
127
+ # every `reference_to`-typed attribute against the SETTLED,
128
+ # post-mutation state (the same moment `commands_respect_tenant_
129
+ # scope` itself inspects), already resolves the referenced record
130
+ # through the repository right above, and already runs from BOTH
131
+ # `CommandInterpreter#step_save` and `EntityInterpreter#step_save`
132
+ # — one change, both interpreters covered, no new vocabulary step
133
+ # to keep in sync with `Vocabulary::AggregateDispatchOrder`/
134
+ # `EntityDispatchOrder`.
135
+ def enforce_tenant_boundary(domain, construct, attribute, target, held, state, own_tenant_field)
136
+ return unless own_tenant_field && state.key?(own_tenant_field)
137
+
138
+ target_tenant_field = tenant_field_for(target)
139
+ return unless target_tenant_field
140
+
141
+ own_tenant = Ports::Query::InMemory.comparable(state[own_tenant_field])
142
+
143
+ values = attribute.list? ? Array(held) : [held]
144
+ values.each do |value|
145
+ key = reference_key(value)
146
+ next if key.empty?
147
+
148
+ record = @registry.repository(domain, target).find(key)
149
+ next unless record&.state&.key?(target_tenant_field)
150
+
151
+ target_tenant = Ports::Query::InMemory.comparable(record.state[target_tenant_field])
152
+ next if target_tenant == own_tenant
153
+
154
+ raise Unauthorized,
155
+ RefusalWording.render("Unauthorized", "cross_tenant_reference",
156
+ aggregate: construct.hecks_name, field: own_tenant_field,
157
+ tenant: Rendering.describe(state[own_tenant_field]),
158
+ attribute: attribute.name, target: target.name,
159
+ target_field: target_tenant_field,
160
+ other: Rendering.describe(record.state[target_tenant_field]))
161
+ end
162
+ end
163
+
164
+ # THE FIELD AN AGGREGATE'S OWN QUERY NAMES AS TENANT-SCOPING — the
165
+ # exact same lookup `Fuzzing::Properties::Guards#tenant_field_for`
166
+ # already established for the property that found this gap, reused
167
+ # here rather than reinvented: an aggregate's own declared tenant
168
+ # field is whichever field ONE OF ITS OWN queries names in
169
+ # `authorize policy, tenant: :field`. `nil` for a construct that
170
+ # declares no such query — not every aggregate is tenant-scoped,
171
+ # and an entity never declares a query of its own at all today
172
+ # (`Entity.queries` is always empty in the real corpus), so this
173
+ # answers `nil` for every entity without needing to special-case
174
+ # one.
175
+ def tenant_field_for(construct)
176
+ authorization = construct.queries.filter_map(&:authorization).find(&:tenant)
177
+ authorization&.tenant&.to_sym
178
+ end
179
+
105
180
  # `value` is the referenced record's own id, EXACTLY as `Identity.of`
106
181
  # would build it for that record — a bare scalar for a single-field
107
182
  # identity (the overwhelming common case; Banking's own plain
@@ -4,6 +4,7 @@ require_relative "value"
4
4
  require_relative "refusal_wording"
5
5
  require_relative "errors"
6
6
  require_relative "identity"
7
+ require_relative "instance"
7
8
 
8
9
  module Hecks
9
10
  module Runtime
@@ -24,6 +25,14 @@ module Hecks
24
25
  module EntityElement
25
26
  module_function
26
27
 
28
+ # BUG#3 — an addressing value that can never match a stored element,
29
+ # returned from `element_of`'s own `wants` coercion in place of a real
30
+ # `Value` (below). A unique object, never `nil`: a stored, OPTIONAL
31
+ # element field really can hold `nil`, and comparing THAT against a
32
+ # bare `nil` sentinel would accidentally "match" it.
33
+ UNMATCHABLE = Object.new.freeze
34
+ private_constant :UNMATCHABLE
35
+
27
36
  # ONE HOP PER CHAIN ENTRY. `container` starts as `instance` (the root
28
37
  # aggregate record) and becomes each just-located element in turn —
29
38
  # Dispatch's own element is found INSIDE the Handler element
@@ -71,6 +80,7 @@ module Hecks
71
80
  # rubocop:disable-next Metrics/AbcSize
72
81
  # rubocop:disable-next Metrics/CyclomaticComplexity
73
82
  # rubocop:disable-next Metrics/PerceivedComplexity
83
+ # rubocop:disable-next Metrics/MethodLength
74
84
  def element_of(root_aggregate, owner, entity, command_name, container, args, routed_identity = nil)
75
85
  entity_name = entity.hecks_name
76
86
  list_attr = owner.attributes.find { |a| a.list? && a.type.to_s == entity_name } ||
@@ -85,7 +95,35 @@ module Hecks
85
95
  command: command_name, entity: entity_name,
86
96
  identity: Identity.reading(entity)))
87
97
 
88
- [head, path, Value.for_attribute(root_aggregate, entity.attribute(head), raw)]
98
+ # AN IDENTITY OFFERED FOR ADDRESSING, NOT FOR STORAGE
99
+ # (BUG#3, found live by `bin/qa_sweep` — banking fuzz seed
100
+ # 23, `LedgerEntry.Amend sequence: { value: 0 }` against an
101
+ # entry-less ledger). Coercing it all the way to a typed
102
+ # `Value` here ran that type's own invariant BEFORE this
103
+ # method ever checks whether any element matches — a
104
+ # `sequence: 0` against `LedgerSequence`'s own "a ledger
105
+ # sequence is positive" invariant raised InvariantViolation,
106
+ # not NotFound, even when (as here) nothing was ever posted
107
+ # at all. Every element actually IN the list already
108
+ # satisfied its own type's invariant the moment it was
109
+ # created, so a value that fails it can never equal one —
110
+ # degrading to `UNMATCHABLE` here, instead of propagating,
111
+ # is exactly as safe as the ordinary "no match found" case
112
+ # below, and lines this addressing path up with the two
113
+ # conventions it already disagreed with: Rust's own
114
+ # `extract_id`/`extract_wants` (a raw scalar read, never a
115
+ # typed rebuild — rust/src/generated/*/*.rs) and
116
+ # `Identity.from`'s own raw-comparison convention for a
117
+ # ROOT aggregate's identity (this file's sibling,
118
+ # `identity.rb`). `raw` rides alongside `want` so the
119
+ # eventual NotFound below can still quote what was offered.
120
+ want = begin
121
+ Value.for_attribute(root_aggregate, entity.attribute(head), raw)
122
+ rescue InvariantViolation
123
+ UNMATCHABLE
124
+ end
125
+
126
+ [head, path, want, raw]
89
127
  end
90
128
  end
91
129
 
@@ -93,13 +131,15 @@ module Hecks
93
131
  position = if routed_identity
94
132
  original.find_index { |element| element_identity(entity, element).to_s == routed_identity.to_s }
95
133
  else
96
- original.find_index { |el| wants.all? { |head, _path, want| el[head] == want } }
134
+ original.find_index do |el|
135
+ wants.all? { |head, _path, want, _raw| want != UNMATCHABLE && el[head] == want }
136
+ end
97
137
  end
98
138
  unless position
99
139
  raise NotFound, RefusalWording.render(
100
140
  "NotFound", "entity_element_missing",
101
141
  entity: entity_name, identity: Identity.reading(entity),
102
- wants: wants&.map { |_h, path, want| Identity.scalar(path, want) }&.join(", "),
142
+ wants: wants&.map { |_h, path, _want, raw| Identity.scalar(path, raw) }&.join(", "),
103
143
  aggregate: owner.hecks_name,
104
144
  parent_id: container.respond_to?(:id) ? container.id.inspect : Rendering.describe(container)
105
145
  )
@@ -174,7 +214,7 @@ module Hecks
174
214
  # shape here without doing the same there would break that
175
215
  # intentional mirroring, which is what lets the two be diffed
176
216
  # against each other when one gets a fix the other needs too.
177
- # rubocop:disable-next Metrics/AbcSize
217
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity
178
218
  # `pre` — the element as it was before this command (C4.2): every
179
219
  # read below goes through it, every write lands on `element`.
180
220
  def apply_to_element(rules, aggregate, entity, element, mutation, args, pre = element)
@@ -203,6 +243,22 @@ module Hecks
203
243
  element[mutation.target] = rewrap_arithmetic_result(aggregate, attribute, current, result)
204
244
  when :clamp
205
245
  element[mutation.target] = rules.clamp(pre[mutation.target], mutation.source, mutation.target)
246
+ # `corrects` — BUG#30. `MutationApplier#apply`'s own aggregate-
247
+ # level `:corrects` branch's own comment gives the full reasoning;
248
+ # the same one applies here unchanged: this mutation targets no
249
+ # field on THIS element at all — its own event name, and whether
250
+ # the OWNING record has actually emitted it, was already checked
251
+ # once, up front, by `EntityInterpreter#step_enforce_givens`
252
+ # (`CommandRules::Admissibility#enforce_correction_target`, called
253
+ # there against the PARENT record/ROOT aggregate — see that
254
+ # step's own comment for exactly why). Whatever field a correction
255
+ # actually changes is an ORDINARY declared `sets`/`increment`/etc.
256
+ # mutation of its own, applied by one of the branches above like
257
+ # any other — `qa/stress_domains/corrections`' own `Entry.Amend`
258
+ # pairs `corrects "EntryRecorded", ...` with a separate `sets
259
+ # :amount`, exactly this shape.
260
+ when :corrects
261
+ nil
206
262
  else
207
263
  # The aggregate-level twin's own backstop
208
264
  # (MutationApplier#apply), for the same reason: applying
@@ -236,17 +292,26 @@ module Hecks
236
292
  element[source]
237
293
  end
238
294
 
239
- # `MutationApplier#appended`'s own entity-scoped twin. VALUE-
240
- # OBJECT elements only — an entity's own list, appended to by an
295
+ # `MutationApplier#appended`'s own entity-scoped twin. Usually a
296
+ # VALUE OBJECT element — an entity's own list, appended to by an
241
297
  # entity-owned command, holds a value object (`Member.pairs`'
242
298
  # own `Pair`, `Dispatch.with_spec`'s own `Binding`) the same way
243
- # every real corpus append does; entity-in-entity nesting (a
244
- # list of ANOTHER entity, owned by this one) is out of scope —
245
- # `MutationApplier#entity_element`'s own fallback is deliberately
246
- # not mirrored here, since nothing in this language's own
247
- # `EntityBuilder` can declare a nested entity to need it (see
248
- # S17's own scoping note on why Dispatch flattens under
249
- # ProcessManager instead of nesting under Handler).
299
+ # most real corpus appends do — but entity-in-entity nesting (a
300
+ # list of ANOTHER entity, owned by this one) is real now too:
301
+ # `qa/stress_domains/nested_pieces` (`Board.AddCard`, appending a
302
+ # `Card` onto `Board`'s own `cards`) is the first corpus member to
303
+ # do it, the comment this replaces having been written before that
304
+ # domain existed. `element_type` naming an entity rather than a
305
+ # value object falls through to `fields` unchanged, same as
306
+ # before — `MutationApplier#entity_element`'s own identity-minting/
307
+ # collision-checking fallback still isn't mirrored here (nothing
308
+ # in this corpus needs auto-minting at THIS depth — Card supplies
309
+ # its own identity in the append mapping — and collision-checking
310
+ # a nested entity is its own separate, unfixed question) — but
311
+ # BUG#12's fix (below) is: every declared attribute the append
312
+ # mapping doesn't name gets its own default the same way a fresh
313
+ # aggregate's own attributes already do (`Instance.defaults`),
314
+ # whichever branch built `fields`.
250
315
  def appended_to_element(aggregate, entity, element, mutation, args)
251
316
  fields = mutation.source.transform_values { |source| resolve_element_append_source(source, element, args) }
252
317
  element_type = entity.attribute(mutation.target)&.type
@@ -254,18 +319,105 @@ module Hecks
254
319
  value_object&.attributes&.each do |attribute|
255
320
  fields[attribute.name] = Value.scalar(fields[attribute.name]) if fields[attribute.name].is_a?(Value)
256
321
  end
257
- appended = value_object ? Value.build(value_object, fields, aggregate) : fields
322
+ appended =
323
+ if value_object
324
+ Value.build(value_object, fields, aggregate)
325
+ else
326
+ # `entity.entities`, NOT `aggregate.entities` — a piece
327
+ # nested inside a piece is a child of the OWNING entity
328
+ # (`Card` is `Board.entities`, never `Workspace.entities`;
329
+ # `Behaviour::Entity#entities` answers direct children only,
330
+ # by design — see its own comment), the same lexical-nesting
331
+ # rule `EntityBuilder#entity_impl` builds the tree with in
332
+ # the first place.
333
+ nested_entity = entity.entities.find { |piece| piece.hecks_name == element_type.to_s }
334
+ nested_entity ? fill_declared_defaults(aggregate, nested_entity, fields) : fields
335
+ end
258
336
  Freezer.deep(Array(element[mutation.target]) + [appended])
259
337
  end
260
338
 
339
+ # BUG#12 — an entity created via `sets :list, append: {...}` used
340
+ # to leave any of its OWN declared attributes the append mapping
341
+ # simply didn't name (an optional field a LATER, separate command
342
+ # sets — `Board.label`, `Card.note`) absent from the stored hash
343
+ # entirely, not even a `nil` placeholder, until that later command
344
+ # actually ran. `rust/project/json_codec.rb#emit_to_json_flat`'s
345
+ # own header comment documents the opposite as the intended
346
+ # contract for a persisted record: every declared field present,
347
+ # `null` when unset, "because Ruby's own `JSON.generate(state)`
348
+ # round-trip this mirrors does the same" — true for a freshly
349
+ # created AGGREGATE (`Instance.defaults` already fills one key per
350
+ # declared attribute, `default_for` per attribute), never true for
351
+ # an entity minted by an append. This closes that gap the same
352
+ # way: `Instance.default_for` is the SAME per-attribute default
353
+ # rule (nil with no declared `default:`, a fully-defaulted value
354
+ # object when every one of ITS OWN fields has one), reused rather
355
+ # than reimplemented so the two creation paths can never drift on
356
+ # what "the default" means. Additive only — a key `fields` already
357
+ # holds (the append mapping, an auto-minted identity, a lifecycle
358
+ # default) is never overwritten.
359
+ def fill_declared_defaults(aggregate, entity, fields)
360
+ entity.attributes.each do |attribute|
361
+ next if fields.key?(attribute.name)
362
+
363
+ fields[attribute.name] = attribute.list? ? Freezer.deep([]) : Instance.default_for(aggregate, attribute)
364
+ end
365
+ fields
366
+ end
367
+
261
368
  # `MutationApplier#removed`'s own entity-scoped twin — matches by
262
369
  # VALUE EQUALITY, element-wise, the same "so a concurrent Add can
263
- # never be lost" reasoning that method's own comment gives.
370
+ # never be lost" reasoning that method's own comment gives, UNLESS
371
+ # the list this targets is itself entity-typed — see
372
+ # `list_element_match?`, below, which both this and
373
+ # `MutationApplier#removed` now share.
264
374
  def removed_from_element(rules, aggregate, entity, element, mutation, args)
265
375
  value = rules.resolve_source(mutation.source, args)
266
376
  attribute = entity.attribute(mutation.target)
267
377
  value = Value.for_attribute(aggregate, attribute, value) if attribute
268
- Array(element[mutation.target]).reject { |candidate| candidate == value }
378
+ Array(element[mutation.target]).reject { |candidate| list_element_match?(aggregate, attribute, candidate, value) }
379
+ end
380
+
381
+ # BUG#32 (QualityControl ledger) — THE MATCH RULE `remove:` USES
382
+ # AGAINST ONE STORED LIST ELEMENT. VALUE EQUALITY for a
383
+ # VALUE-OBJECT-typed list stays exactly what it always was — an
384
+ # element and `value` are both real `Value`s there, so `==` already
385
+ # compares every field, the "concurrent Add can never be lost"
386
+ # shape `removed`/`removed_from_element`'s own headers describe.
387
+ # An ENTITY-typed list is different in kind, not just in type: a
388
+ # stored element is a plain Hash, never a `Value` (`Entity`'s own
389
+ # header — "an entity must never answer .value_object"), so there
390
+ # is no whole-value shape to compare against at all — only the
391
+ # entity's own IDENTITY field, the same field a caller already has
392
+ # to name to address that element any other way
393
+ # (`element_of`'s own `wants`, above). `value` arrives here already
394
+ # coerced against that identity field's declared type
395
+ # (`Coercion#hydrate_entity_identity`, run underneath
396
+ # `Value.for_attribute` before either caller above ever sees it),
397
+ # so this only has to know WHICH field to read off the stored
398
+ # element — `entity.identity_heads`'s own single head, when there
399
+ # is exactly one. A composite identity (more than one head, or
400
+ # none) has no single field a bare `remove:` target could mean —
401
+ # this returns `false` (never a match, the same documented no-op
402
+ # `hydrate_entity_identity`'s own header already commits to) rather
403
+ # than guessing which head, matching the narrow, honest boundary
404
+ # `MutationApplier#check_entity_collision`'s own header draws for
405
+ # entity identity elsewhere in this runtime.
406
+ #
407
+ # Shared by `MutationApplier#removed` (an aggregate's own list) and
408
+ # `#removed_from_element` (a list an ENTITY owns), so the two
409
+ # `remove:` call sites can never quietly disagree on what
410
+ # "matches" means — the same reasoning this file's own header
411
+ # gives for centralizing `locate_chain`/`element_of` once rather
412
+ # than twice.
413
+ def list_element_match?(aggregate, attribute, element, value)
414
+ entity = attribute&.list? ? Value.find_entity(aggregate, attribute.type.to_s) : nil
415
+ return element == value unless entity
416
+
417
+ head = entity.identity_heads.one? ? entity.identity_heads.first : nil
418
+ return false unless head
419
+
420
+ element.is_a?(Hash) && element[head] == value
269
421
  end
270
422
  end
271
423
  end
@@ -75,7 +75,8 @@ module Hecks
75
75
  # rubocop:disable-next Lint/StructNewOverride
76
76
  Context = Struct.new(:domain, :aggregate, :entity, :entity_name, :command, :command_name,
77
77
  :args, :repository, :instance, :chain, :element, :view, :transition,
78
- :old_element, :result, :route, :plan, :persistence_outcome, :dry_run, :outbox_rows)
78
+ :old_element, :result, :route, :plan, :persistence_outcome, :dry_run, :outbox_rows,
79
+ :correction_bindings)
79
80
 
80
81
  def initialize(registry, rules:)
81
82
  @registry = registry
@@ -208,9 +209,66 @@ module Hecks
208
209
  state: ctx.element)
209
210
  end
210
211
 
212
+ # BUG#30 — THE ENTITY-LEVEL HALF OF `CommandInterpreter#step_enforce_
213
+ # givens`'s own structural-before-declared ordering (see that
214
+ # method's comment for the shared reasoning): "does the fact this
215
+ # command's `corrects` names even exist" is checked here too, once,
216
+ # before the entity's own `given`s.
217
+ #
218
+ # ADMISSIBILITY IS CHECKED AGAINST THE PARENT/ROOT, NOT THE ENTITY —
219
+ # deliberately `ctx.instance`/`ctx.aggregate` (the PARENT aggregate
220
+ # record and the ROOT aggregate construct), never `ctx.view`/
221
+ # `ctx.entity` (the entity's own pre-mutation view/construct). This
222
+ # is not a simplification; it is the ONLY choice that lines up with
223
+ # how the event being corrected was actually recorded: an entity has
224
+ # no event stream of its own — `CommandRules::Emission#emit` (called
225
+ # from THIS class's own `step_emit`, and from `CommandInterpreter`'s
226
+ # `step_emit` for an aggregate-level command alike) always stamps an
227
+ # emitted event with the ROOT aggregate's own qualified name
228
+ # (`"#{domain}::#{aggregate.hecks_name}"`) and the PARENT record's
229
+ # own id (`ctx.instance.id`), regardless of which level dispatched
230
+ # it. `enforce_correction_target` (CommandRules::Admissibility)
231
+ # looks a correction target up by exactly those two fields plus the
232
+ # event name — asking it in terms of the entity instead would search
233
+ # for an event key/id that no emitted event could ever actually
234
+ # carry, and every entity-level correction would refuse
235
+ # (NothingToCorrect) even against a real, already-emitted event.
236
+ # `qa/stress_domains/corrections`' own `Entry.Amend` (corrects
237
+ # "EntryRecorded", which `Ledger.Record` — an AGGREGATE-level
238
+ # command — actually emits) is exactly this shape: the corrected
239
+ # event's `aggregate`/`id` are the LEDGER's, never the Entry's own
240
+ # (an Entry has no id an event could be filed under in the first
241
+ # place). `Fuzzing::Properties::Corrections#corrections_reference_
242
+ # an_emitted_event` independently encodes the identical rule
243
+ # (`aggregate_key` built off the OUTER aggregate for both the
244
+ # `corrects` target and the `emits` produced event, regardless of
245
+ # entity nesting depth) — this is that property's dispatch-time
246
+ # enforcement counterpart, not a new invention.
247
+ #
248
+ # One structural consequence, worth being explicit about for a
249
+ # Rust port: because the lookup is scoped to the PARENT record
250
+ # (not to any one entity element within it), an entity-level
251
+ # `corrects` only proves "this parent record has emitted the named
252
+ # event at some point" — it does NOT, and cannot, further narrow
253
+ # to "...specifically for THIS entity element" (a Ledger with three
254
+ # Entries all satisfy the same `EntryRecorded`-was-emitted check).
255
+ # That is not a gap this fix introduces: it is the SAME granularity
256
+ # the aggregate-level check already has (one record, one event
257
+ # history), just observed from one level down. A command wanting a
258
+ # tighter, element-specific correlation has to encode it itself, in
259
+ # its own `given`s, off `correction`-bound payload fields.
260
+ #
261
+ # `correction:` bindings computed here are threaded through to BOTH
262
+ # halves of the same command's admissibility, same as the
263
+ # aggregate-level path: `ctx.correction_bindings` is read again by
264
+ # `step_enforce_ensures`, below, so an `as:`-named binding is
265
+ # visible to a settled-record `ensures` exactly as freely as it is
266
+ # here, pre-mutation.
211
267
  def step_enforce_givens(ctx)
212
268
  step(:enforce_givens) do
213
- @rules.enforce_givens(ctx.view, ctx.command, ctx.args, domain: ctx.domain, declaring: ctx.entity, parent: ctx.instance)
269
+ ctx.correction_bindings = @rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
270
+ @rules.enforce_givens(ctx.view, ctx.command, ctx.args, domain: ctx.domain, declaring: ctx.entity, parent: ctx.instance,
271
+ correction: ctx.correction_bindings)
214
272
  end
215
273
  end
216
274
 
@@ -240,7 +298,14 @@ module Hecks
240
298
  def step_enforce_ensures(ctx)
241
299
  step(:enforce_ensures) do
242
300
  settled = Instance.new(aggregate: ctx.entity, id: ctx.view.id, state: ctx.element)
243
- @rules.enforce_ensures(settled, ctx.command, ctx.args, old: ctx.old_element, domain: ctx.domain, parent: ctx.instance)
301
+ # `correction:` same `as:`-bound corrected-event payload
302
+ # `step_enforce_givens` already located, above; `|| {}` covers
303
+ # a command with no `corrects` mutation at all, where
304
+ # `ctx.correction_bindings` is `{}` from that call already, or
305
+ # (belt-and-braces, matching `CommandInterpreter#step_enforce_
306
+ # ensures`'s own identical `|| {}`) never set.
307
+ @rules.enforce_ensures(settled, ctx.command, ctx.args, old: ctx.old_element, domain: ctx.domain, parent: ctx.instance,
308
+ correction: ctx.correction_bindings || {})
244
309
  end
245
310
  end
246
311
 
@@ -274,15 +274,79 @@ module Hecks
274
274
  value.is_a?(Symbol) ? args[value] : value
275
275
  end
276
276
 
277
+ # `boundary: false` always (C3.8 — a query's declared argument types
278
+ # name the argument for callers and generators, never a runtime
279
+ # shape checked here). A null required VALUE-OBJECT-typed query
280
+ # argument, though, is NOT a runtime-shape question at all: C3.7
281
+ # says a named query's declared value-object arguments are checked
282
+ # the same way a command argument's own is, so a `nil` offered for
283
+ # a non-optional value-object-typed query attribute
284
+ # (Governance::RoleAssignment.AssignmentsForActor's `actor_id`, say)
285
+ # has to refuse — passing it through unchecked (as this used to)
286
+ # let it through as a silent, unfiltered query instead, a real
287
+ # Ruby/Rust divergence the fuzzer caught (QualityControl BUG#2).
288
+ #
289
+ # `checked_vo?` true is handled by `null_vo_argument!` DIRECTLY,
290
+ # never by routing through `Value.for_attribute(argument: true)`
291
+ # into the shared `Value::Coercion#nil_argument` the COMMAND door
292
+ # (`Interpreting#coerce_declared_arguments`) still uses — that
293
+ # method builds a null value object from ZERO fields, which
294
+ # SUCCEEDS (silently absorbing the null via the type's own field
295
+ # defaults) whenever every field happens to have one
296
+ # (`Lease.Expired`'s `now`, a `LeaseInstant` with a `default: 0`
297
+ # field; `Account.Overdrawn`/`HighBalance`/`StrictlyAbove`/
298
+ # `AtMost`'s `floor`/`cap`, a two-defaulted-field `Money`) and only
299
+ # refuses when a field has none (`Order.CostingLessThan`'s
300
+ # `ceiling`, a defaultless `Price`) — a real QUERY-side divergence
301
+ # from Rust, which always refuses `TypeMismatch` on an explicit
302
+ # null argument regardless of any default (QualityControl BUG#36).
303
+ # `null_vo_argument!` instead treats an explicit null exactly the
304
+ # way `Value.fields_for` already treats any other WRONG-SHAPED
305
+ # (non-Hash, non-Value) value offered for that same attribute — a
306
+ # single-field value object auto-wraps into `{field: nil}` (whose
307
+ # OWN `nil` is a PRESENT key, so `Value.build`'s `apply_defaults`
308
+ # never fills it, and `check_required_fields` refuses it exactly
309
+ # as any other missing required field would); a multi-field value
310
+ # object refuses immediately with the same `value_object_shape`
311
+ # wording an ordinary wrong-shaped scalar already gets. Command
312
+ # arguments are deliberately UNTOUCHED — this is a query-only
313
+ # door; `nil_argument`'s own default-absorbing fallback still
314
+ # governs a null command argument exactly as it always has.
315
+ #
316
+ # C3.8's own bare-scalar carve-out stays intact: `checked_vo?` is
317
+ # only true for a value-object-typed attribute, so a bare
318
+ # `String`/`Integer` query argument offered nil still passes
319
+ # through exactly as it always did.
277
320
  def normalize_args(aggregate, declared, args)
278
321
  declared.attributes.each_with_object(args.dup) do |attribute, normalized|
279
322
  next unless normalized.key?(attribute.name)
280
323
 
281
- normalized[attribute.name] = Value.for_attribute(aggregate, attribute, normalized[attribute.name],
282
- boundary: false)
324
+ value = normalized[attribute.name]
325
+ normalized[attribute.name] = if checked_vo?(aggregate, attribute, value)
326
+ null_vo_argument!(aggregate, attribute)
327
+ else
328
+ Value.for_attribute(aggregate, attribute, value, boundary: false)
329
+ end
283
330
  end
284
331
  end
285
332
 
333
+ def checked_vo?(aggregate, attribute, value)
334
+ return false unless value.nil?
335
+ return false if attribute.optional? || attribute.list? || attribute.reference?
336
+ return false unless aggregate.respond_to?(:value_object)
337
+
338
+ !Value.value_object_for(aggregate, attribute.type).nil?
339
+ end
340
+
341
+ # Only ever reached when `checked_vo?` has already confirmed the
342
+ # attribute's type resolves to a real value object — see its own
343
+ # comment above for why this refuses unconditionally, never
344
+ # absorbing the null via the type's own field defaults.
345
+ def null_vo_argument!(aggregate, attribute)
346
+ value_object = Value.value_object_for(aggregate, attribute.type)
347
+ Value.build(value_object, Value.fields_for(value_object, attribute.name, nil), aggregate)
348
+ end
349
+
286
350
  def comparable(value) = QuerySpecification::Common::Comparison.comparable(value)
287
351
 
288
352
  # FieldPath.dig, not a raw `record[field]` — `record` is an Instance