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
|
@@ -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
|
-
|
|
193
|
-
|
|
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
|
|
73
|
-
"{command} refused — a reference is an id, and {attribute} arrived as
|
|
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) :
|
|
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
|
-
|
|
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
|
|
59
|
-
#
|
|
60
|
-
# for a non-optional attribute is
|
|
61
|
-
# argument empty (C3.7)
|
|
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
|
|
64
|
-
# "absent is not empty" value the aggregate's own
|
|
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
|
|
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
|
-
#
|
|
506
|
-
#
|
|
507
|
-
#
|
|
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
|
-
|
|
512
|
-
|
|
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", "
|
|
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
|