hecks 1.1.0 → 1.2.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.
@@ -2,6 +2,7 @@ require "json"
2
2
 
3
3
  require_relative "../../../../adapters/driven/sql_query_builder"
4
4
  require_relative "../../../../adapters/driven/postgres/outbox"
5
+ require_relative "../../../../adapters/driven/postgres/reconnect"
5
6
  require_relative "postgres_era/lineage"
6
7
  require_relative "postgres_era/lineage_manager"
7
8
  require_relative "../../../../ports/persistence/append_only"
@@ -42,10 +43,19 @@ module Hecks
42
43
  class PostgresEra
43
44
  include SqlQueryBuilder
44
45
  include Adapters::PostgresOutbox
46
+ include Adapters::PostgresReconnect
45
47
 
46
48
  attr_reader :aggregate
47
49
 
48
- def persistence_capabilities = [:atomic_put]
50
+ # `:cross_process_lock` tells `Interpreting#run_dispatch_order_with_isolation`
51
+ # (runtime/interpreting.rb) this repository can hold a REAL
52
+ # cross-process lock for the whole dispatch order itself, via
53
+ # `with_write_lock` below — so it should use that instead of the
54
+ # in-process `AggregateLock` `Mutex` every other non-CAS repository
55
+ # falls back to. See ADR 0036: that in-process Mutex is invisible
56
+ # to `rust/host` dispatching against the same PostgresEra-bound
57
+ # tables from a separate OS process.
58
+ def persistence_capabilities = %i[atomic_put cross_process_lock]
49
59
 
50
60
  # The capability idiom: only PostgresEra answers true, and only
51
61
  # PostgresEra carries an era_check! for the boot gate to delegate to.
@@ -148,11 +158,35 @@ module Hecks
148
158
 
149
159
  def initialize(aggregate:, settings: {}, root: nil)
150
160
  @aggregate = aggregate
161
+ @settings = settings
151
162
  @db = self.class.connect_for(aggregate.name, settings)
152
163
  # The domain names the journal (one journal per lineage). The
153
- # factory injects it; a directly-instantiated adapter (specs,
154
- # consoles) journals under the aggregate's own name.
155
- @domain = self.class.setting(settings, :domain, default: aggregate.storage_name).to_s
164
+ # factory injects it as the owning bluebook's own declared name
165
+ # (`RepositoryFactory.build`'s own `domain` positional arg
166
+ # `bluebook.name`, confirmed at `outbox.rb`'s own call site —
167
+ # never this default in real dispatch). This default exists only
168
+ # for a directly-instantiated adapter (specs, consoles) that
169
+ # skips the factory.
170
+ #
171
+ # When the aggregate DOES have an owning chapter (`hecks_owner`
172
+ # set — true for any aggregate sealed through a real bluebook,
173
+ # even if this adapter itself was built by hand), default to the
174
+ # chapter's own declared PascalCase name. That's the same string
175
+ # `rust/host`'s `pg_advisory_xact_lock` key derives from
176
+ # (`HECKS_DOMAIN`, deploy-time-set to `world.domain`) — the
177
+ # aggregate's own snake_case `storage_name` silently disagreed
178
+ # with it (`hashtext` is byte-sensitive: `"chess"` and `"Chess"`
179
+ # never collide), which is ADR 0036 Blocker 1.
180
+ #
181
+ # When there's no owning chapter at all (a bare
182
+ # `AggregateBuilder` fixture built outside any real bluebook —
183
+ # `hecks_owner` is only ever stamped by chapter construction, see
184
+ # `traits.rb`'s `hecks_owner = self`) there is no chapter name to
185
+ # match Rust against in the first place, so fall back to the
186
+ # aggregate's own name, same as before this ADR.
187
+ @domain = self.class.setting(
188
+ settings, :domain, default: aggregate.hecks_owner&.name || aggregate.storage_name
189
+ ).to_s
156
190
  @lineage = Lineage.new(@db, @domain)
157
191
  @lineage.ensure_base!
158
192
  # The era gate resolves which era this boot IS (an old checkout
@@ -274,6 +308,18 @@ module Hecks
274
308
  head_phase(declared, uncached, ids, args)
275
309
  end
276
310
 
311
+ # ADR 0036's actual fix — see `persistence_capabilities` above.
312
+ # Wraps the WHOLE dispatch order (hydrate through save), not just
313
+ # `append`'s own transaction below: `lock_writes!` has to be held
314
+ # before hydrate even starts, or two cross-process writers can
315
+ # both hydrate unlocked and race for the write, each blind to the
316
+ # other. `transaction` (via `include Adapters::PostgresOutbox`) is
317
+ # already re-entrant — `append`/`atomic_put`'s own inner
318
+ # `transaction do ... end`, deep inside the block below, joins
319
+ # this SAME transaction instead of opening/committing its own, so
320
+ # the advisory lock stays held until this whole block returns.
321
+ def with_write_lock(&block) = transaction { lock_writes!; block.call } # rubocop:disable Style/Semicolon
322
+
277
323
  # HELD FOR THE WHOLE TRANSACTION, not just around the INSERT — the
278
324
  # ordinal is assigned by the column's own `nextval()` default, inside
279
325
  # this same statement, so the lock has to already be held before that
@@ -290,7 +336,10 @@ module Hecks
290
336
  # still runs, cheaply, during AppendOnly#recover!'s full replay on
291
337
  # every boot (see `project` below), and a second write there would
292
338
  # make that replay pay real DB cost for a snapshot that's already
293
- # correct.
339
+ # correct. When `with_write_lock` above already drove this whole
340
+ # dispatch, `lock_writes!` here is a harmless re-acquire of the
341
+ # same already-held (per-session-reentrant) advisory lock; a bare
342
+ # `repository.save` outside a full dispatch still takes it fresh.
294
343
  def append(entry)
295
344
  transaction do
296
345
  lock_writes!
@@ -0,0 +1,374 @@
1
+ require_relative "../projector"
2
+
3
+ module Hecks
4
+ module Projections
5
+ # A CHAPTER, PROJECTED AS ITS UBIQUITOUS LANGUAGE — one glossary
6
+ # entry per term the business actually uses: the Domain itself, and
7
+ # every Aggregate, Entity, Value Object, Command (a verb), Query (a
8
+ # question), Read Model, Event, Role, Lifecycle, Saga, and Policy it
9
+ # declares — every BUSINESS-domain construct the language's own §30
10
+ # reference distinguishes. Deliberately not the deployment/wiring
11
+ # half (World, Hecksagon, Port, Adapter, Translation): Evans'
12
+ # Ubiquitous Language is the business model, not the plumbing that
13
+ # runs it.
14
+ #
15
+ # GROUPED UNDER THE AGGREGATE EACH TERM BELONGS TO — a reader thinks
16
+ # "what does Account mean" before they think "what starts with A",
17
+ # so a term's home is the aggregate that declares it (an Entity's
18
+ # own Commands/Queries/Lifecycle nest under ITS aggregate, not
19
+ # listed as their own peer); an Event or a Policy that reacts to one
20
+ # nests under the aggregate whose command actually raises it — the
21
+ # same "who does this" a reader would ask by hand. Only what
22
+ # genuinely belongs to no single aggregate (a Role, issued across
23
+ # several; a Read Model, joined across several) gets its own
24
+ # trailing section. Within a section, still one short sentence per
25
+ # term, dictionary-plain — this is not `DocsProjector` re-sorted
26
+ # (verbs, refusals, wire shapes, walked for a caller); it is what
27
+ # each term MEANS, walked for a domain expert.
28
+ #
29
+ # WHAT IT DELIBERATELY DOES NOT DO: invent, same discipline as
30
+ # `DocsProjector`. A term with no declared prose (most value objects;
31
+ # every event and role, which the language only ever spells as bare
32
+ # strings — a command's `emits:`/`role:`) gets a definition DERIVED
33
+ # from the graph around it — what it's shaped like, what emits it,
34
+ # who issues it — never a restated name standing in for a sentence
35
+ # nobody wrote.
36
+ #
37
+ # Projector.call(:glossary, bluebook: <the Bluebook chapter>)
38
+ module Glossary
39
+ extend Projector::Target
40
+
41
+ projects_as :glossary
42
+
43
+ Entry = Struct.new(:term, :kind, :definition, :within, :section, keyword_init: true)
44
+
45
+ # DISPLAY ORDER WITHIN A SECTION — the aggregate's own row first
46
+ # (what it IS), then the shapes it can be in and hold, then what
47
+ # you can ask of it, then what happens on its own.
48
+ KIND_RANK = {
49
+ "Aggregate" => 0, "Lifecycle" => 1, "Entity" => 2, "Value Object" => 3,
50
+ "Command" => 4, "Query" => 5, "Event" => 6, "Policy" => 7, "Saga" => 8
51
+ }.freeze
52
+
53
+ ROLES_SECTION = "Roles".freeze
54
+ READ_MODELS_SECTION = "Read Models".freeze
55
+
56
+ module_function
57
+
58
+ def call(bluebook:, options: {}) = render(bluebook)
59
+
60
+ # ── gathering ─────────────────────────────────────────────────────
61
+
62
+ # AN ENTITY CAN CARRY ITS OWN COMMANDS, QUERIES, AND VALUE OBJECTS
63
+ # TOO — walked the same one level down `DocsProjector` and
64
+ # `Projections::Diagrams` already walk it, so a domain's entity
65
+ # gaining any of these needs no change here either.
66
+ def holders(bluebook)
67
+ bluebook.aggregates.flat_map { |aggregate| [aggregate, *aggregate.entities] }
68
+ end
69
+
70
+ # EVERY HOLDER'S OWN AGGREGATE, ONE HOP OR ZERO — an aggregate
71
+ # maps to itself, an entity to whichever aggregate declared it.
72
+ # This is the single fact the whole grouping is built on: a
73
+ # Command/Query/Lifecycle's `within` already names its holder, so
74
+ # looking that name up here is enough to find its SECTION even
75
+ # when the holder is an entity two levels down from the chapter.
76
+ def holder_aggregate(bluebook)
77
+ bluebook.aggregates.each_with_object({}) do |aggregate, map|
78
+ map[aggregate.hecks_name] = aggregate.hecks_name
79
+ aggregate.entities.each { |entity| map[entity.hecks_name] = aggregate.hecks_name }
80
+ end
81
+ end
82
+
83
+ # EVERY EVENT'S OWN AGGREGATE — an event is never declared, only
84
+ # emitted, so its home is whichever aggregate the FIRST command
85
+ # that raises it belongs to. A policy or saga reacting to that
86
+ # event inherits the same home, the same way a reader tracing the
87
+ # reaction by hand would: "what raises this, and whose is that."
88
+ def event_aggregate(bluebook, holder_aggregate)
89
+ map = {}
90
+ holders(bluebook).each do |holder|
91
+ home = holder_aggregate[holder.hecks_name]
92
+ holder.commands.each { |command| command.emits.each { |event| map[event] ||= home } }
93
+ end
94
+ map
95
+ end
96
+
97
+ def entries(bluebook)
98
+ holder_agg = holder_aggregate(bluebook)
99
+ event_agg = event_aggregate(bluebook, holder_agg)
100
+
101
+ entries = []
102
+ entries += domain_entries(bluebook)
103
+ entries += aggregate_entries(bluebook)
104
+ entries += entity_entries(bluebook, holder_agg)
105
+ entries += value_object_entries(bluebook, holder_agg)
106
+ entries += command_entries(bluebook, holder_agg)
107
+ entries += query_entries(bluebook, holder_agg)
108
+ entries += read_model_entries(bluebook)
109
+ entries += event_entries(bluebook, event_agg)
110
+ entries += role_entries(bluebook)
111
+ entries += lifecycle_entries(bluebook, holder_agg)
112
+ entries += saga_entries(bluebook, event_agg)
113
+ entries += policy_entries(bluebook, event_agg)
114
+ entries
115
+ end
116
+
117
+ # THE CHAPTER ITSELF, AS A TERM — the one construct every other
118
+ # entry here lives inside (`Hecks.bluebook "Banking" do ... end`),
119
+ # and the one place `vision` — the single sentence written for a
120
+ # reader who doesn't know the domain yet — actually lives. Given
121
+ # no `section` (rendered once, at the very top, above every
122
+ # aggregate) since it is the one term that belongs to no aggregate
123
+ # by being the thing that holds them all.
124
+ def domain_entries(bluebook)
125
+ [Entry.new(term: bluebook.name, kind: "Domain", definition: bluebook.vision)]
126
+ end
127
+
128
+ def aggregate_entries(bluebook)
129
+ bluebook.aggregates.map do |aggregate|
130
+ Entry.new(term: aggregate.hecks_name, kind: "Aggregate", definition: aggregate.description,
131
+ section: aggregate.hecks_name)
132
+ end
133
+ end
134
+
135
+ def entity_entries(bluebook, holder_agg)
136
+ bluebook.aggregates.flat_map do |aggregate|
137
+ aggregate.entities.map do |entity|
138
+ Entry.new(term: entity.hecks_name, kind: "Entity", within: aggregate.hecks_name,
139
+ section: holder_agg[aggregate.hecks_name], definition: entity.description)
140
+ end
141
+ end
142
+ end
143
+
144
+ # A VALUE OBJECT CARRIES NO `description` — the language never gave
145
+ # it one (`Bluebook::ValueObject` declares `attributes`,
146
+ # `invariants`, `members`, `closed_set`, nothing else). Its
147
+ # definition is derived from its own shape instead: a closed set
148
+ # states its members, an open one its fields — the same
149
+ # distinction `DocsProjector#shape_of` draws.
150
+ #
151
+ # AGGREGATES ONLY, not `holders` — unlike commands and queries, an
152
+ # entity declares no value objects of its own (`Bluebook::Entity`
153
+ # deliberately does not answer `value_objects`; its argument types
154
+ # live on the aggregate above it, same as `DocsProjector#value_object_for`
155
+ # already has to account for).
156
+ def value_object_entries(bluebook, holder_agg)
157
+ bluebook.aggregates.flat_map do |aggregate|
158
+ aggregate.value_objects.map do |vo|
159
+ Entry.new(term: vo.hecks_name, kind: "Value Object", within: aggregate.hecks_name,
160
+ section: holder_agg[aggregate.hecks_name], definition: value_object_definition(vo))
161
+ end
162
+ end
163
+ end
164
+
165
+ # THE SHAPE, THEN THE RULES — a value object's own `invariant`
166
+ # bodies are real authored prose ("a currency is a three-letter
167
+ # code"), not derived the way the shape above is, and were
168
+ # sitting unread by this projector until now: `to_h`'s own
169
+ # `invariants` field already carries them, one `description` per
170
+ # `Bluebook::Invariant`.
171
+ def value_object_definition(value_object)
172
+ sentence = value_object_shape(value_object)
173
+ rules = value_object.invariants.map(&:description)
174
+ sentence += " Must satisfy: #{rules.join('; ')}." unless rules.empty?
175
+ sentence
176
+ end
177
+
178
+ # A CLOSED SET'S ROWS, not just their first field — the same
179
+ # correction `Projections::Vocabulary` already had to make
180
+ # (`StatementFrequency` names retention months and a paper fee
181
+ # alongside its cadence; flattening every field into one list
182
+ # produces well-formed nonsense, same as it did there).
183
+ def value_object_shape(value_object)
184
+ if value_object.closed_set?
185
+ if value_object.members.first && value_object.members.first.size > 1
186
+ rows = value_object.members.map { |row| "{ #{row.map { |f, v| "#{f}: #{v.inspect}" }.join(', ')} }" }
187
+ "One of: #{rows.join('; ')}."
188
+ else
189
+ "One of #{value_object.members.flat_map(&:values).uniq.map { |m| "`#{m}`" }.join(', ')}."
190
+ end
191
+ elsif value_object.attributes.empty?
192
+ "A marker with no fields of its own."
193
+ else
194
+ "{ #{value_object.attributes.map { |f| "#{f.name}: #{f.type}" }.join(', ')} }"
195
+ end
196
+ end
197
+
198
+ def command_entries(bluebook, holder_agg)
199
+ holders(bluebook).flat_map do |holder|
200
+ holder.commands.map do |command|
201
+ Entry.new(term: command.hecks_name, kind: "Command", within: holder.hecks_name,
202
+ section: holder_agg[holder.hecks_name], definition: command.goal)
203
+ end
204
+ end
205
+ end
206
+
207
+ def query_entries(bluebook, holder_agg)
208
+ holders(bluebook).flat_map do |holder|
209
+ holder.queries.map do |query|
210
+ Entry.new(term: query.hecks_name, kind: "Query", within: holder.hecks_name,
211
+ section: holder_agg[holder.hecks_name], definition: query.description)
212
+ end
213
+ end
214
+ end
215
+
216
+ # CROSS-AGGREGATE, ON PURPOSE — a read model joins heads from more
217
+ # than one aggregate (`ReadModel`'s own header: "an ask that
218
+ # gathers heads from more than one aggregate"), so it belongs to
219
+ # no single one and gets its own trailing section instead.
220
+ def read_model_entries(bluebook)
221
+ bluebook.read_models.map do |read_model|
222
+ Entry.new(term: read_model.name, kind: "Read Model", section: READ_MODELS_SECTION,
223
+ definition: read_model.description)
224
+ end
225
+ end
226
+
227
+ # AN EVENT IS NEVER ITS OWN DECLARATION — the language only ever
228
+ # spells one as a bare string in a command's `emits:`. So it earns
229
+ # a glossary entry by appearing there, and its "definition" is the
230
+ # one fact the graph actually holds about it: which verb(s) raise
231
+ # it, and — the other half nothing raises without a reader
232
+ # crossing over to check `bluebook.policies` by hand — which
233
+ # policy (if any) reacts to it. That is derived, not invented —
234
+ # nobody wrote a sentence this restates.
235
+ def event_entries(bluebook, event_agg)
236
+ by_event = Hash.new { |h, k| h[k] = [] }
237
+ holders(bluebook).each do |holder|
238
+ holder.commands.each do |command|
239
+ command.emits.each { |event| by_event[event] << command.hecks_name }
240
+ end
241
+ end
242
+ by_event.map do |event, commands|
243
+ sentence = "Raised by #{commands.uniq.map { |c| "`#{c}`" }.join(', ')}."
244
+ reactions = policies_on(bluebook, event)
245
+ sentence += " #{reactions.join(' ')}" unless reactions.empty?
246
+ Entry.new(term: event, kind: "Event", section: event_agg[event], definition: sentence)
247
+ end
248
+ end
249
+
250
+ # A POLICY NAMES ITS EVENT QUALIFIED (`"Account.AccountFrozen"`) —
251
+ # `bare` strips the aggregate a policy's own `on_event`/
252
+ # `trigger_command` always carries but an event's own glossary
253
+ # term (keyed off `emits:`, which is never qualified) never does.
254
+ def bare(qualified) = qualified.to_s.split(".").last
255
+
256
+ def policies_on(bluebook, event)
257
+ bluebook.policies.select { |policy| bare(policy.on_event) == event }.map do |policy|
258
+ domain = " in #{policy.target_domain}" if policy.target_domain
259
+ "Triggers policy `#{policy.name}` → `#{policy.trigger_command}`#{domain}."
260
+ end
261
+ end
262
+
263
+ # A ROLE IS THE SAME SHAPE OF FACT AS AN EVENT — free text on a
264
+ # command's `role:`, never declared on its own — so it gets the
265
+ # same treatment: who it is, told by what it does. CROSS-CUTTING
266
+ # BY NATURE (`System`/`Customer` issue commands across half the
267
+ # aggregates in this corpus), so — like a Read Model — it belongs
268
+ # to no single aggregate.
269
+ def role_entries(bluebook)
270
+ by_role = Hash.new { |h, k| h[k] = [] }
271
+ holders(bluebook).each do |holder|
272
+ holder.commands.select(&:role).each { |command| by_role[command.role] << command.hecks_name }
273
+ end
274
+ by_role.map do |role, commands|
275
+ Entry.new(term: role, kind: "Role", section: ROLES_SECTION,
276
+ definition: "Issues #{commands.uniq.map { |c| "`#{c}`" }.join(', ')}.")
277
+ end
278
+ end
279
+
280
+ # A LIFECYCLE HAS NO NAME OF ITS OWN — it is a body nested inside
281
+ # the aggregate or entity it governs (`lifecycle :status do ...
282
+ # end`), so it earns a glossary entry under ITS HOLDER's term,
283
+ # the same way `Command`/`Query` do — a reader who looks up
284
+ # `Account` and a reader who looks up what states an Account can
285
+ # hold are asking two different questions of the same name.
286
+ def lifecycle_entries(bluebook, holder_agg)
287
+ holders(bluebook).select(&:lifecycle).map do |holder|
288
+ lifecycle = holder.lifecycle
289
+ states = ([lifecycle.default] + lifecycle.transitions.map { |_name, transition| transition.target }).uniq
290
+ Entry.new(term: holder.hecks_name, kind: "Lifecycle", section: holder_agg[holder.hecks_name],
291
+ definition: "Starts at `#{lifecycle.default}`. States: #{states.map { |s| "`#{s}`" }.join(', ')}.")
292
+ end
293
+ end
294
+
295
+ # A SAGA'S OWN HANDLERS/DISPATCHES ARE NAMELESS TOO — the same
296
+ # shape as a Lifecycle's transitions, one level up: a sequence of
297
+ # states no individual step is worth its own glossary row for.
298
+ # `states`, from the builder's own precomputed leg order
299
+ # (`Behaviour::ProcessManager#saga`, the same field
300
+ # `DocsProjector#closing` already reads), says what a Lifecycle
301
+ # entry says for an aggregate — the shape of the whole journey,
302
+ # not just where it starts and ends. HOMED ON ITS STARTING EVENT —
303
+ # a saga usually ends somewhere else entirely (that is most of
304
+ # what makes it a saga), so "where it begins" is the only single
305
+ # aggregate honestly its own.
306
+ def saga_entries(bluebook, event_agg)
307
+ bluebook.process_managers.map do |saga|
308
+ shape = saga.to_h
309
+ sentence = "Starts on `#{shape[:starts_on]}`, ends on `#{shape[:ends_on]}`."
310
+ states = Array(shape[:states])
311
+ sentence += " States: #{states.map { |s| "`#{s}`" }.join(' → ')}." unless states.empty?
312
+ Entry.new(term: shape[:name], kind: "Saga", section: event_agg[bare(shape[:starts_on])],
313
+ definition: sentence)
314
+ end
315
+ end
316
+
317
+ # A POLICY IS A NAMED CONSTRUCT TOO (`policy "ReviewOnFreeze" do
318
+ # ... end`), the same as a Saga — so it earns its own glossary
319
+ # entry rather than surfacing only as a parenthetical on the
320
+ # event that triggers it. HOMED ON THE EVENT IT REACTS TO, not
321
+ # what it dispatches — `on_event` is always local to this
322
+ # bluebook, `trigger_command` sometimes is not (a cross-domain
323
+ # policy's own `across`). `for_each`, when declared, fans the
324
+ # dispatch out over more than one record; stated here because a
325
+ # reader who only sees "dispatches X" would otherwise assume one.
326
+ def policy_entries(bluebook, event_agg)
327
+ bluebook.policies.map do |policy|
328
+ sentence = "On `#{bare(policy.on_event)}`, dispatches `#{policy.trigger_command}`"
329
+ sentence += " in #{policy.target_domain}" if policy.target_domain
330
+ sentence += ", once per `#{policy.for_each}` row" if policy.for_each
331
+ Entry.new(term: policy.name, kind: "Policy", section: event_agg[bare(policy.on_event)],
332
+ definition: "#{sentence}.")
333
+ end
334
+ end
335
+
336
+ # ── rendering ─────────────────────────────────────────────────────
337
+
338
+ def render(bluebook)
339
+ all = entries(bluebook)
340
+ grouped = all.reject { |entry| entry.kind == "Domain" }.group_by { |entry| entry.section || "Cross-Domain Reactions" }
341
+
342
+ out = [chapter_header(bluebook)]
343
+ bluebook.aggregates.map(&:hecks_name).sort.each { |name| out << section(name, grouped[name]) if grouped[name] }
344
+ [ROLES_SECTION, READ_MODELS_SECTION, "Cross-Domain Reactions"].each do |name|
345
+ out << section(name, grouped[name]) if grouped[name]
346
+ end
347
+
348
+ "#{out.compact.join("\n\n")}\n"
349
+ end
350
+
351
+ def chapter_header(bluebook)
352
+ out = ["# #{bluebook.name} — Glossary", ""]
353
+ out += ["> #{bluebook.vision}", ""] if bluebook.vision
354
+ out << "The ubiquitous language: every term #{bluebook.name} declares, grouped under the aggregate " \
355
+ "it belongs to, in the domain's own words. Generated from `#{bluebook.name}`'s bluebook — a " \
356
+ "term missing here is a term the bluebook does not yet declare, and a definition missing here " \
357
+ "is a sentence nobody has written yet."
358
+ out.join("\n")
359
+ end
360
+
361
+ def section(title, rows)
362
+ return nil unless rows
363
+
364
+ sorted = rows.sort_by { |entry| [KIND_RANK[entry.kind] || 99, entry.term.downcase] }
365
+ "## #{title}\n\n| Term | Kind | Definition |\n|---|---|---|\n#{sorted.map { |entry| entry_row(entry) }.join("\n")}"
366
+ end
367
+
368
+ def entry_row(entry)
369
+ term = entry.within ? "**#{entry.term}** *(#{entry.within})*" : "**#{entry.term}**"
370
+ "| #{term} | #{entry.kind} | #{entry.definition || '—'} |"
371
+ end
372
+ end
373
+ end
374
+ 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`
@@ -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