hecks 1.1.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 (85) 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/outbox.rb +7 -4
  4. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  5. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
  6. data/lib/hecks/adapters/driven/postgres.rb +20 -17
  7. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
  9. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  10. data/lib/hecks/behaviors/expectations.rb +32 -4
  11. data/lib/hecks/bluebook/assembly/marks.rb +13 -3
  12. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  13. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  14. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
  15. data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
  16. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  17. data/lib/hecks/bluebook/model_check.rb +148 -17
  18. data/lib/hecks/forms/field_shape.rb +5 -3
  19. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  20. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  21. data/lib/hecks/fuzzing/form_census.rb +184 -0
  22. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  23. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  24. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  25. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  26. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  27. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  28. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  29. data/lib/hecks/fuzzing/properties.rb +23 -2
  30. data/lib/hecks/fuzzing/replay.rb +130 -20
  31. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  32. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  33. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  34. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  35. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  36. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  37. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  38. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  39. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  40. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  41. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  42. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  43. data/lib/hecks/fuzzing.rb +6 -0
  44. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  45. data/lib/hecks/naming.rb +70 -2
  46. data/lib/hecks/ports/persistence/append_only.rb +11 -0
  47. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  48. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  49. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +85 -5
  50. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  51. data/lib/hecks/projections/glossary/html.rb +250 -0
  52. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  53. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  54. data/lib/hecks/projections/glossary/page.css +271 -0
  55. data/lib/hecks/projections/glossary/page.js +72 -0
  56. data/lib/hecks/projections/glossary/sections.rb +17 -0
  57. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  58. data/lib/hecks/projections/glossary.rb +302 -0
  59. data/lib/hecks/projections.rb +1 -0
  60. data/lib/hecks/projector/cli_projector.rb +29 -13
  61. data/lib/hecks/projector/exporter.rb +24 -0
  62. data/lib/hecks/projector/narrate_projector.rb +4 -11
  63. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  64. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  65. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  66. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  67. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  68. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  69. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  70. data/lib/hecks/runtime/entity_element.rb +168 -16
  71. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  72. data/lib/hecks/runtime/interpreting.rb +16 -7
  73. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  74. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  75. data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
  76. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  77. data/lib/hecks/runtime/registry.rb +12 -0
  78. data/lib/hecks/runtime/routing.rb +67 -2
  79. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  80. data/lib/hecks/runtime/value/coercion.rb +77 -52
  81. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  82. data/lib/hecks/runtime/value.rb +7 -2
  83. data/lib/hecks/version.rb +1 -1
  84. data/lib/hecks/vocabulary.rb +2 -1
  85. metadata +25 -2
@@ -0,0 +1,302 @@
1
+ require_relative "../projector"
2
+ require_relative "../naming"
3
+ require_relative "statements"
4
+ require_relative "glossary/sections"
5
+ require_relative "glossary/sentences"
6
+ require_relative "glossary/mermaid"
7
+ require_relative "glossary/markdown"
8
+ require_relative "glossary/html"
9
+
10
+ module Hecks
11
+ module Projections
12
+ # A CHAPTER, PROJECTED AS ITS UBIQUITOUS LANGUAGE — a glossary for the
13
+ # whole team, in the sense Evans meant (DDD ch. 2): ONE language,
14
+ # shared by domain experts and developers, written the same way in
15
+ # conversation, diagrams, documents and code, so that a subject-matter
16
+ # expert can read the model and say "yes, that's how it works" or
17
+ # "no, that's wrong". A banker, a support rep, the CEO and an engineer
18
+ # read the same page.
19
+ #
20
+ # THREE THINGS FOLLOW FROM THAT AUDIENCE. First, no type jargon: a
21
+ # term is a term, not "an Aggregate" or "a Value Object", and every
22
+ # identifier is spelled as a person says it (`Naming.words`: `ATMCard`
23
+ # is "ATM card"). Second, nothing invented: every sentence is either
24
+ # the domain author's own words (a `description`, a `goal`, an
25
+ # invariant) or built mechanically from a declared fact ("Recorded
26
+ # after Freeze account") — the same discipline `DocsProjector` holds
27
+ # to; a wrong sentence is worse than a missing one. Third, grouped
28
+ # under the aggregate each term belongs to, A to Z within it — a
29
+ # reader thinks "what does Account mean" before "what starts with A",
30
+ # and once there reads the way a dictionary reads.
31
+ #
32
+ # TWO FILES FROM ONE SOURCE. `glossary.md` is the document — every
33
+ # construct in it renders on GitHub as-is (headings, blockquotes,
34
+ # ```mermaid fences, lists, in-page links). `html/index.html` is that
35
+ # exact Markdown string rendered into a page with a navigation rail;
36
+ # it is built FROM the Markdown, not beside it, so the two cannot
37
+ # drift. `bin/project_glossary` writes both to `<domain>/glossary/` beside
38
+ # the bluebook (examples/banking, examples/pizzas, and the QA ledger in qa/).
39
+ #
40
+ # Projector.call(:glossary, bluebook: <the Bluebook chapter>)
41
+ # # => { "glossary.md" => "...", "html/index.html" => "..." }
42
+ module Glossary
43
+ extend Projector::Target
44
+
45
+ projects_as :glossary, emits: :files
46
+
47
+ # One term. `kind` is a Symbol the renderers never print; `facts`
48
+ # carries the declaration objects the sentence is built from;
49
+ # `headword` and `slug` are assigned once the document's order is
50
+ # known (a headword may need qualifying, a slug depends on what
51
+ # came before it).
52
+ Entry = Struct.new(:name, :kind, :within, :section, :facts, :headword, :slug, keyword_init: true)
53
+
54
+ # One `##` of the document: an aggregate (with its own object, for
55
+ # the lede and diagrams) or one of the three trailing groups.
56
+ Section = Struct.new(:name, :title, :aggregate, :terms, :slug, keyword_init: true)
57
+
58
+ Document = Struct.new(:bluebook, :sections, :index, keyword_init: true)
59
+
60
+ module_function
61
+
62
+ def call(bluebook:, options: {})
63
+ markdown = Markdown.render(document(bluebook))
64
+ { "glossary.md" => markdown, "html/index.html" => Html.render(markdown) }
65
+ end
66
+
67
+ # ── the document ─────────────────────────────────────────────────
68
+
69
+ def document(bluebook)
70
+ sections = sections(bluebook, entries(bluebook))
71
+ Slugs.assign!(bluebook, sections)
72
+ Document.new(bluebook: bluebook, sections: sections, index: Index.new(sections))
73
+ end
74
+
75
+ # AGGREGATES FIRST, A TO Z BY THEIR SPOKEN NAME ("Account" before
76
+ # "ATM card"), then the three groups nothing homes to one aggregate.
77
+ def sections(bluebook, entries)
78
+ grouped = entries.group_by(&:section)
79
+ list = bluebook.aggregates.sort_by { |aggregate| Naming.words(aggregate.hecks_name).downcase }.map do |aggregate|
80
+ Section.new(name: aggregate.hecks_name, title: Naming.words(aggregate.hecks_name), aggregate: aggregate,
81
+ terms: with_headwords(grouped.fetch(aggregate.hecks_name, []), aggregate.hecks_name))
82
+ end
83
+ [ROLES, READ_MODELS, REACTIONS].each do |name|
84
+ held = grouped[name == REACTIONS ? nil : name]
85
+ list << Section.new(name: name, title: name, terms: with_headwords(held, name)) if held
86
+ end
87
+ list
88
+ end
89
+
90
+ # A HEADWORD IS QUALIFIED ONLY WHEN IT WOULD REPEAT within its own
91
+ # section — "Open" the action and "Open (the list)" the question;
92
+ # "Return (key issuance)" beside another Return — never numbered
93
+ # (Chicago 18.9, MDN's disambiguation pages: qualify the headword).
94
+ def with_headwords(entries, section_name)
95
+ entries.each { |entry| entry.headword = Naming.words(entry.name) }
96
+ entries.group_by(&:headword).each_value do |group|
97
+ next if group.size == 1
98
+
99
+ group.each { |entry| entry.headword = qualified(entry, group, section_name) }
100
+ end
101
+ entries.sort_by { |entry| [entry.headword.downcase, entry.kind.to_s] }
102
+ end
103
+
104
+ def qualified(entry, group, section_name)
105
+ base = Naming.words(entry.name)
106
+ return "#{base} (the list)" if entry.kind == :query && group.any? { |other| other.kind == :command }
107
+ return "#{base} (#{Naming.words(entry.within).downcase})" if entry.within && entry.within != section_name
108
+
109
+ base
110
+ end
111
+
112
+ # ── gathering ────────────────────────────────────────────────────
113
+
114
+ # AN ENTITY CAN CARRY ITS OWN COMMANDS AND QUERIES TOO — walked the
115
+ # same one level down `DocsProjector` and `Projections::Diagrams`
116
+ # already walk it.
117
+ def holders(bluebook)
118
+ bluebook.aggregates.flat_map { |aggregate| [aggregate, *aggregate.entities] }
119
+ end
120
+
121
+ # EVERY HOLDER'S OWN AGGREGATE, ONE HOP OR ZERO — an aggregate maps
122
+ # to itself, an entity to whichever aggregate declared it. The one
123
+ # fact the grouping is built on.
124
+ def holder_aggregate(bluebook)
125
+ bluebook.aggregates.each_with_object({}) do |aggregate, map|
126
+ map[aggregate.hecks_name] = aggregate.hecks_name
127
+ aggregate.entities.each { |entity| map[entity.hecks_name] = aggregate.hecks_name }
128
+ end
129
+ end
130
+
131
+ # EVERY EVENT'S RAISERS — an event is never declared, only emitted,
132
+ # so its home is whichever aggregate the FIRST command that raises
133
+ # it belongs to; a policy or saga reacting to it inherits that home.
134
+ def event_raisers(bluebook)
135
+ holders(bluebook).each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |holder, map|
136
+ holder.commands.each { |command| command.emits.each { |event| map[event] << [holder, command] } }
137
+ end
138
+ end
139
+
140
+ def bare(qualified) = qualified.to_s.split(".").last
141
+
142
+ def entries(bluebook)
143
+ homes = holder_aggregate(bluebook)
144
+ raisers = event_raisers(bluebook)
145
+ home_of = ->(event) { raisers.key?(event) ? homes[raisers[event].first.first.hecks_name] : nil }
146
+
147
+ entries = []
148
+ entries += entity_entries(bluebook)
149
+ entries += value_object_entries(bluebook)
150
+ entries += verb_entries(bluebook, homes)
151
+ entries += event_entries(bluebook, raisers, home_of)
152
+ entries += policy_entries(bluebook, home_of)
153
+ entries += saga_entries(bluebook, home_of)
154
+ entries += role_entries(bluebook)
155
+ entries += read_model_entries(bluebook)
156
+ entries
157
+ end
158
+
159
+ def entity_entries(bluebook)
160
+ bluebook.aggregates.flat_map do |aggregate|
161
+ aggregate.entities.map do |entity|
162
+ Entry.new(name: entity.hecks_name, kind: :entity, within: aggregate.hecks_name,
163
+ section: aggregate.hecks_name, facts: { entity: entity })
164
+ end
165
+ end
166
+ end
167
+
168
+ # AGGREGATES ONLY — an entity declares no value objects of its own
169
+ # (`Bluebook::Entity` deliberately does not answer `value_objects`).
170
+ def value_object_entries(bluebook)
171
+ bluebook.aggregates.flat_map do |aggregate|
172
+ aggregate.value_objects.map do |value_object|
173
+ Entry.new(name: value_object.hecks_name, kind: :value_object, within: aggregate.hecks_name,
174
+ section: aggregate.hecks_name, facts: { value_object: value_object })
175
+ end
176
+ end
177
+ end
178
+
179
+ def verb_entries(bluebook, homes)
180
+ holders(bluebook).flat_map do |holder|
181
+ commands = holder.commands.map do |command|
182
+ Entry.new(name: command.hecks_name, kind: :command, within: holder.hecks_name,
183
+ section: homes[holder.hecks_name], facts: { command: command, holder: holder })
184
+ end
185
+ queries = holder.queries.map do |query|
186
+ Entry.new(name: query.hecks_name, kind: :query, within: holder.hecks_name,
187
+ section: homes[holder.hecks_name], facts: { query: query })
188
+ end
189
+ commands + queries
190
+ end
191
+ end
192
+
193
+ def event_entries(bluebook, raisers, home_of)
194
+ raisers.map do |event, raised_by|
195
+ reactions = bluebook.policies.select { |policy| bare(policy.on_event) == event }
196
+ Entry.new(name: event, kind: :event, section: home_of.call(event),
197
+ facts: { event: event, raised_by: raised_by, policies: reactions })
198
+ end
199
+ end
200
+
201
+ def policy_entries(bluebook, home_of)
202
+ bluebook.policies.map do |policy|
203
+ Entry.new(name: policy.name, kind: :policy, section: home_of.call(bare(policy.on_event)),
204
+ facts: { policy: policy })
205
+ end
206
+ end
207
+
208
+ def saga_entries(bluebook, home_of)
209
+ bluebook.process_managers.map do |saga|
210
+ shape = saga.to_h
211
+ Entry.new(name: shape[:name], kind: :saga, section: home_of.call(bare(shape[:starts_on])),
212
+ facts: { saga: shape })
213
+ end
214
+ end
215
+
216
+ # CROSS-CUTTING BY NATURE — `System` and `Customer` issue commands
217
+ # across half the aggregates here — so a role belongs to no single
218
+ # one and gets its own section.
219
+ def role_entries(bluebook)
220
+ by_role = Hash.new { |hash, key| hash[key] = [] }
221
+ holders(bluebook).each do |holder|
222
+ holder.commands.select(&:role).each { |command| by_role[command.role] << [holder, command] }
223
+ end
224
+ by_role.map do |role, issues|
225
+ Entry.new(name: role, kind: :role, section: ROLES, facts: { role: role, commands: issues })
226
+ end
227
+ end
228
+
229
+ # A read model joins heads from more than one aggregate — its own
230
+ # header says so — so it belongs to none of them.
231
+ def read_model_entries(bluebook)
232
+ bluebook.read_models.map do |read_model|
233
+ Entry.new(name: read_model.name, kind: :read_model, section: READ_MODELS, facts: { read_model: read_model })
234
+ end
235
+ end
236
+
237
+ # ── anchors ──────────────────────────────────────────────────────
238
+
239
+ # GITHUB'S OWN HEADING SLUGS, REPRODUCED — lowercase, punctuation
240
+ # dropped, spaces to hyphens, a repeat gets "-1", "-2" in document
241
+ # order. Computed here, once, in the order the headings will appear,
242
+ # so a link written into the Markdown lands on the same heading
243
+ # whether GitHub renders the `.md` or `Html` renders the page.
244
+ module Slugs
245
+ module_function
246
+
247
+ def github(text) = text.to_s.downcase.gsub(/[^\p{Word}\- ]/, "").tr(" ", "-")
248
+
249
+ def assign!(bluebook, sections)
250
+ seen = Hash.new(0)
251
+ take = lambda do |text|
252
+ base = github(text)
253
+ seen[base] += 1
254
+ seen[base] == 1 ? base : "#{base}-#{seen[base] - 1}"
255
+ end
256
+ take.call(Markdown.title(bluebook))
257
+ sections.each do |section|
258
+ section.slug = take.call(section.title)
259
+ section.terms.each { |entry| entry.slug = take.call(entry.headword) }
260
+ end
261
+ end
262
+ end
263
+
264
+ # WHERE A DECLARED REFERENCE POINTS — keyed structurally (a command
265
+ # by its holder AND name, an event by its bare name), never by
266
+ # searching prose for a matching word. Anything not declared here
267
+ # (a cross-domain command like `Notifications.Send`) answers nil,
268
+ # and the sentence says it in words with no link.
269
+ class Index
270
+ def initialize(sections)
271
+ @by_key = {}
272
+ sections.each do |section|
273
+ @by_key[[:aggregate, section.name]] = section if section.aggregate
274
+ section.terms.each { |entry| @by_key[key_of(entry)] = entry }
275
+ end
276
+ end
277
+
278
+ def key_of(entry)
279
+ case entry.kind
280
+ when :command, :query, :value_object then [entry.kind, entry.within, entry.name]
281
+ else [entry.kind, entry.name]
282
+ end
283
+ end
284
+
285
+ def [](kind, name, within: nil)
286
+ @by_key[within ? [kind, within, name] : [kind, name]]
287
+ end
288
+
289
+ # A Markdown link to a term's own heading, or the plain words when
290
+ # the chapter declares no such term. `label:` overrides the link
291
+ # text for a sentence that has to tell two same-named terms apart.
292
+ def link(kind, name, within: nil, label: nil)
293
+ target = self[kind, name, within: within]
294
+ return label || Naming.words(name) unless target
295
+
296
+ text = label || (target.is_a?(Section) ? target.title : target.headword)
297
+ "[#{text}](##{target.slug})"
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
@@ -30,3 +30,4 @@ require_relative "projections/reference"
30
30
  require_relative "projections/model"
31
31
  require_relative "projections/diagrams"
32
32
  require_relative "projections/statements"
33
+ require_relative "projections/glossary"
@@ -156,6 +156,33 @@ module Hecks
156
156
  entity ? "#{entity.hecks_name}." : "", verb.hecks_name].join
157
157
  end
158
158
 
159
+ # THE ARGUMENTS A RECEIVER ADDS, BEFORE ANY VERB-SPECIFIC ONE. Shared by
160
+ # `command_spec` and `port_spec` — a port operation always addresses an
161
+ # aggregate record (`port_spec` passes `receiver: :aggregate`, never
162
+ # `:entity` or `nil`, because a port is declared on an aggregate, never
163
+ # an entity), and this is exactly the same "how do I name the record"
164
+ # question a command with a receiver already answers, so it is answered
165
+ # once, here, rather than duplicated.
166
+ #
167
+ # `nil` — a creating command — takes none: there is no existing record
168
+ # yet for `to=` to name.
169
+ def receiver_options(receiver, aggregate, entity)
170
+ case receiver
171
+ when :entity
172
+ [
173
+ { path: "to.aggregate", type: "String", required: true,
174
+ note: "id of the #{aggregate.hecks_name} holding the #{entity.hecks_name}" },
175
+ { path: "to.entity", type: "String", required: true,
176
+ note: "id of the #{entity.hecks_name} to act on" }
177
+ ]
178
+ when :aggregate
179
+ [{ path: "to", type: "String", required: true,
180
+ note: "id of the #{aggregate.hecks_name} to act on" }]
181
+ else
182
+ []
183
+ end
184
+ end
185
+
159
186
  # ── one verb ──────────────────────────────────────────────────────
160
187
 
161
188
  def command_spec(bluebook, aggregate, entity, command)
@@ -166,7 +193,6 @@ module Hecks
166
193
  else
167
194
  (command.creates? ? nil : :aggregate)
168
195
  end
169
- legacy_arguments = []
170
196
 
171
197
  # THE RECEIVER IS NOT A COMMAND ARGUMENT. An aggregate command names
172
198
  # its record through to; an entity command needs both the aggregate
@@ -178,18 +204,8 @@ module Hecks
178
204
  # Existing aggregate scripts may still spell the receiver id=... .
179
205
  # That alias is deliberately hidden from help and recorded separately
180
206
  # as legacy_arguments; new help and examples teach only to=... .
181
- if entity
182
- arguments = [
183
- { path: "to.aggregate", type: "String", required: true,
184
- note: "id of the #{aggregate.hecks_name} holding the #{entity.hecks_name}" },
185
- { path: "to.entity", type: "String", required: true,
186
- note: "id of the #{entity.hecks_name} to act on" }
187
- ] + arguments
188
- elsif receiver == :aggregate
189
- arguments = [{ path: "to", type: "String", required: true,
190
- note: "id of the #{aggregate.hecks_name} to act on" }] + arguments
191
- legacy_arguments = [{ path: "id", type: "String", required: true }]
192
- end
207
+ arguments = receiver_options(receiver, aggregate, entity) + arguments
208
+ legacy_arguments = receiver == :aggregate ? [{ path: "id", type: "String", required: true }] : []
193
209
 
194
210
  { verb: fqn(bluebook, aggregate, command, entity), kind: :command,
195
211
  summary: command.goal, role: command.role, role_gated: !command.role.to_s.empty?,
@@ -54,6 +54,30 @@ module Hecks
54
54
  { capable_aggregates: capable.map { |aggregate| { name: aggregate.name, storage_name: aggregate.storage_name } } }
55
55
  end
56
56
 
57
+ # A BINDING fact, same shape/reasoning as `lineage` above: every
58
+ # aggregate's DECLARED persistence adapter name (`persisted_by`),
59
+ # not part of the canonical bluebook shape `call` exports (ADR
60
+ # 0001 — the IR describes what's declared, never which adapter a
61
+ # deployment binds it to). Unlike `lineage`, this needs no era
62
+ # plugin — `BindingPolicy` is core, always loaded — and covers
63
+ # EVERY aggregate, not just lineage-capable ones: `rust/host`
64
+ # (`ir.rs`'s own `refuse_unsupported_persistence_adapters`) reads
65
+ # this to refuse loudly, at boot, against a domain bound to an
66
+ # adapter it has no backend for (Heki, Memory, Sqlite, D1,
67
+ # LocalStorage — `rust/host` understands only Postgres/PostgresEra
68
+ # today), rather than silently building up a second, disjoint
69
+ # history nothing but Rust ever reads while the real state stays
70
+ # wherever its own adapter actually wrote it.
71
+ def persistence(registry, domain_name)
72
+ bluebook = registry.bluebooks.fetch(domain_name)
73
+ aggregates = bluebook.aggregates.map do |aggregate|
74
+ adapter = Ports::Persistence::BindingPolicy.resolve(registry, domain_name, aggregate).adapter
75
+ { name: aggregate.name, storage_name: aggregate.storage_name, adapter: adapter }
76
+ end
77
+
78
+ { aggregates: aggregates }
79
+ end
80
+
57
81
  # Translation IR, always as an array, WITH each aggregate's
58
82
  # precompiled SQL attached (`compiled_translation_aggregate`) —
59
83
  # this is the export a consumer embeds (`ir.json`'s `translations`
@@ -284,18 +284,11 @@ module Hecks
284
284
 
285
285
  # ── small sentence carpentry ──────────────────────────────────────
286
286
 
287
- def to_sentence_list(items, conj: "and")
288
- case items.size
289
- when 0 then ""
290
- when 1 then items[0].to_s
291
- when 2 then "#{items[0]} #{conj} #{items[1]}"
292
- else "#{items[0..-2].join(', ')}, #{conj} #{items[-1]}"
293
- end
294
- end
287
+ # Both now live in `Naming` (a second projection, the glossary,
288
+ # needed them); kept here as names so this file reads as it did.
289
+ def to_sentence_list(items, conj: "and") = Naming.to_sentence_list(items, conj: conj)
295
290
 
296
- def a_or_an(word)
297
- %w[a e i o u].include?(word.to_s[0].to_s.downcase) ? "an" : "a"
298
- end
291
+ def a_or_an(word) = Naming.a_or_an(word)
299
292
  end
300
293
  end
301
294
  end
@@ -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,8 +1,15 @@
1
1
  module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
- LimitSpec = Struct.new(:value, keyword_init: true) do
5
- def to_h = { value: QuerySpecification.render_value(value) }
4
+ # `target` — see `WhereClause`'s own header (ADR 0055): which
5
+ # many-side `include`d aggregate this `limit` applies to, on a
6
+ # `read_model` declaring more than one. `nil` for a plain `Query`,
7
+ # and for a `read_model` with a single many-side head.
8
+ LimitSpec = Struct.new(:value, :target, keyword_init: true) do
9
+ def to_h
10
+ base = { value: QuerySpecification.render_value(value) }
11
+ target ? base.merge(target: target.to_s) : base
12
+ end
6
13
  end
7
14
  end
8
15
  end
@@ -1,8 +1,15 @@
1
1
  module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
- OffsetSpec = Struct.new(:value, keyword_init: true) do
5
- def to_h = { value: QuerySpecification.render_value(value) }
4
+ # `target` — see `WhereClause`'s own header (ADR 0055): which
5
+ # many-side `include`d aggregate this `offset` applies to, on a
6
+ # `read_model` declaring more than one. `nil` for a plain `Query`,
7
+ # and for a `read_model` with a single many-side head.
8
+ OffsetSpec = Struct.new(:value, :target, keyword_init: true) do
9
+ def to_h
10
+ base = { value: QuerySpecification.render_value(value) }
11
+ target ? base.merge(target: target.to_s) : base
12
+ end
6
13
  end
7
14
  end
8
15
  end
@@ -1,8 +1,15 @@
1
1
  module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
- OrderBy = Struct.new(:field, :direction, keyword_init: true) do
5
- def to_h = { field: field.to_s, direction: direction.to_s }
4
+ # `target` see `WhereClause`'s own header (ADR 0055): which
5
+ # many-side `include`d aggregate this `order_by` applies to, on a
6
+ # `read_model` declaring more than one. `nil` for a plain `Query`,
7
+ # and for a `read_model` with a single many-side head.
8
+ OrderBy = Struct.new(:field, :direction, :target, keyword_init: true) do
9
+ def to_h
10
+ base = { field: field.to_s, direction: direction.to_s }
11
+ target ? base.merge(target: target.to_s) : base
12
+ end
6
13
  end
7
14
  end
8
15
  end
@@ -1,8 +1,21 @@
1
1
  module Hecks
2
2
  module QuerySpecification
3
3
  module Common
4
- WhereClause = Struct.new(:field, :op, :value, keyword_init: true) do
5
- def to_h = { field: field.to_s, op: op.to_s, value: QuerySpecification.render_value(value) }
4
+ # `target` which many-side `include`d aggregate this clause applies
5
+ # to on a `read_model` with more than one (ADR 0055's own `on:`,
6
+ # `ReadModelBuilder#where_impl`). Always `nil` for a plain `Query`'s
7
+ # own `where` (that builder never overrides `where_impl` to accept
8
+ # `on:`), and `nil` for a `read_model` with a single many-side head,
9
+ # where naming one is unnecessary. `to_h` omits the key entirely
10
+ # rather than emitting `target: nil` — the same "absent, not null"
11
+ # convention `count`/`median_field` already established
12
+ # (`lib/hecks/bluebook/read_model.rb`), so every read model that
13
+ # never uses `on:` keeps its existing wire shape byte-identical.
14
+ WhereClause = Struct.new(:field, :op, :value, :target, keyword_init: true) do
15
+ def to_h
16
+ base = { field: field.to_s, op: op.to_s, value: QuerySpecification.render_value(value) }
17
+ target ? base.merge(target: target.to_s) : base
18
+ end
6
19
  end
7
20
  end
8
21
  end
@@ -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