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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +214 -286
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -115
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- 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
|
-
|
|
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
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|
|
240
|
-
# OBJECT
|
|
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
|
-
#
|
|
244
|
-
# list of ANOTHER entity, owned by this one) is
|
|
245
|
-
# `
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|