hecks 1.0.0 → 1.0.2

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.
@@ -321,7 +321,8 @@ module Hecks
321
321
 
322
322
  domain, aggregate_name, = Naming.split_verb(asked[:query])
323
323
  args = asked[:args] || {}
324
- rows = query_eligible_rows(asked.fetch(:instances_at), domain, aggregate_name, declared.wheres, args)
324
+ rows = query_eligible_rows(asked.fetch(:instances_at), domain, aggregate_name, declared.wheres, args,
325
+ bluebooks: bluebooks)
325
326
  ordered = Ports::Query::Ordering.apply(
326
327
  rows, declared.order_by, declared.null_semantics, identity: ->(row) { row[:id].to_s }
327
328
  ) { |row| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, declared.order_by.field)) }
@@ -361,21 +362,65 @@ module Hecks
361
362
  # same way #eligible_rows' own rows are, since a stable sort
362
363
  # (Ordering.apply's own `identity:`) and the real answer's own
363
364
  # `record.state.merge(id: record.id)` both need it.
364
- def query_eligible_rows(instances, domain, aggregate_name, wheres, args)
365
+ # `bluebooks:` needed ONLY to recognise and resolve a `/` HOP
366
+ # clause (`engagement/client/status`, hop_chain.bluebook's own
367
+ # PricedAboveViaEngagement): a hop's head names one of the OWNING
368
+ # aggregate's declared references, and only the declaration graph
369
+ # can say which attribute that is and which aggregate it targets.
370
+ # A local clause never consults it. Latent gap this closed, found
371
+ # by the fuzzer itself the first time a generated sequence ever
372
+ # built a full hop chain AND had its paged query answer a row
373
+ # (seed 1, the moment scalar_value_objects.bluebook joined the
374
+ # fixtures corpus and shifted every seeded draw): the recompute
375
+ # dug `engagement/client/status` as a LOCAL dotted path, found
376
+ # nil, and declared every genuinely-eligible row ineligible — a
377
+ # false property violation against a correct runtime answer,
378
+ # reproducible on an untouched main with this same 4-step script.
379
+ def query_eligible_rows(instances, domain, aggregate_name, wheres, args, bluebooks: {})
380
+ aggregate = bluebooks[domain]&.aggregate(aggregate_name)
365
381
  prefix = "#{domain}::#{aggregate_name}#"
366
382
  instances.filter_map do |key, state|
367
383
  next unless key.start_with?(prefix)
368
384
 
369
385
  row = state.merge(id: key.split("#").last)
370
386
  next unless wheres.all? do |clause|
371
- held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, clause.field))
372
- Ports::Query::InMemory.holds?(clause, held, args)
387
+ resolved = resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
388
+ held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, resolved.field))
389
+ Ports::Query::InMemory.holds?(resolved, held, args)
373
390
  end
374
391
 
375
392
  row
376
393
  end
377
394
  end
378
395
 
396
+ # `Runtime::ReferenceHop#fold`, independently restated over the
397
+ # replay's own `:instances_at` snapshot instead of live
398
+ # repositories — the same shape every other recompute in this
399
+ # file takes (never the runtime's own code path, or the property
400
+ # would be checking the runtime against itself). One hop peels
401
+ # off the head (`HopPath.next_hop`, the identical one-step
402
+ # primitive the live fold uses), the inner clause recurses
403
+ # through `query_eligible_rows` against the TARGET's own
404
+ # snapshot rows (so a multi-hop tail resolves hop by hop, exactly
405
+ # as the live path's own recursion does), and the ids that
406
+ # answered fold back as the same local `in` membership clause the
407
+ # live fold builds. A clause with no `/`, or one whose head this
408
+ # aggregate's declarations cannot resolve, passes through
409
+ # untouched and evaluates locally as it always did.
410
+ def resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
411
+ return clause unless aggregate && QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
412
+
413
+ hop, rest = QuerySpecification::HopPath.next_hop(clause.field, aggregate.attributes)
414
+ target = hop.target
415
+ return clause unless target
416
+
417
+ inner = QuerySpecification::Common::WhereClause.new(field: rest, op: clause.op, value: clause.value)
418
+ ids = query_eligible_rows(instances, domain, target.hecks_name, [inner], args, bluebooks: bluebooks)
419
+ .map { |row| row[:id].to_s }.uniq
420
+
421
+ QuerySpecification::Common::WhereClause.new(field: hop.attribute.name, op: "in", value: ids)
422
+ end
423
+
379
424
  # `QueryInterpreter#resolve_query_value`, reproduced: a declared
380
425
  # limit/offset is either a literal or a Symbol naming an argument
381
426
  # the caller supplied.
@@ -509,6 +509,15 @@ Hecks.bluebook "Bluebook" do
509
509
  member word: "query", context: "Aggregate", body: "keywords", inner: "Query", opens: "Query", fills: "", calls: "query_impl"
510
510
  member word: "policy", context: "Aggregate", body: "keywords", inner: "Policy", opens: "Policy", fills: "", calls: "policy_impl"
511
511
  member word: "value_object", context: "Aggregate", body: "keywords", inner: "ValueObject", opens: "ValueObject", fills: "value_objects"
512
+ # THE BARE SHORTHAND — `value_object "Price", Integer`, no block:
513
+ # a second row for the SAME word with `body: "none"`, the exact
514
+ # two-row move `identified_by` (above) already makes for its own
515
+ # block/blockless split — the body gate picks whichever row
516
+ # matches how the line was actually written. Declares a value
517
+ # object with exactly one attribute, named `value`, of the given
518
+ # type; sugar for the block form's own single `attribute :value,
519
+ # Type` line (`AggregateBuilder#value_object`'s own comment).
520
+ member word: "value_object", context: "Aggregate", body: "none", inner: "", opens: "", fills: "value_objects"
512
521
  member word: "command", context: "Aggregate", body: "keywords", inner: "Command", opens: "Command", fills: "", calls: "command_impl"
513
522
  member word: "attribute", context: "Aggregate", body: "none", inner: "", opens: "", fills: "attributes", calls: "attribute_impl"
514
523
  member word: "invariant", context: "Aggregate", body: "source", inner: "", opens: "", fills: "invariants", calls: "invariant_impl"
@@ -558,6 +567,14 @@ Hecks.bluebook "Bluebook" do
558
567
  member keyword: "query", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
559
568
  member keyword: "policy", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
560
569
  member keyword: "value_object", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
570
+ # The bare shorthand's own TYPE argument — optional, because the
571
+ # block form gives no type at all (its attributes carry their own);
572
+ # `kind: "constant"` for the same reason `attribute`'s own type
573
+ # argument (below) is: a type is a bare constant, never quoted
574
+ # text. Type-AND-block is refused by the builder itself
575
+ # (`AggregateBuilder#value_object`), not here — an argument row
576
+ # cannot see the body, only the call.
577
+ member keyword: "value_object", context: "Aggregate", at: "2", named: "", kind: "constant", required: "false", fills: "type"
561
578
  member keyword: "command", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "name"
562
579
  member keyword: "command", context: "Aggregate", at: "", named: "from", kind: "literal", required: "false", fills: "from"
563
580
  member keyword: "attribute", context: "Aggregate", at: "1", named: "", kind: "symbol", required: "true", fills: "name"
@@ -4,7 +4,9 @@ Hecks.bluebook "Hecksagon" do
4
4
 
5
5
  identified_by PortName, as: :name
6
6
 
7
- attribute :verb, PortVerb, optional: true
7
+ attribute :verb, PortVerb, optional: true
8
+ attribute :signal, PortSignal, optional: true
9
+ attribute :answers, list_of(PortAnswer)
8
10
 
9
11
  value_object "PortName" do
10
12
  attribute :value, String
@@ -16,6 +18,24 @@ Hecks.bluebook "Hecksagon" do
16
18
  invariant("a verb is named") { !value.to_s.empty? }
17
19
  end
18
20
 
21
+ # SAME CLOSED SET port.bluebook's own "Port" aggregate declares —
22
+ # kept as its own local value object rather than shared cross-
23
+ # chapter, the same way PortVerb already is here.
24
+ value_object "PortSignal" do
25
+ attribute :value, String, one_of: ["reply", "effect"]
26
+ end
27
+
28
+ # `answers` — DomainPortBuilder's own twin of PortBuilder#answers
29
+ # (port.bluebook's "Port" aggregate, same shape), added once a real
30
+ # `.port` file migrated to parse through this builder (the repoint
31
+ # `lib/hecks.rb#port`'s own comment describes) used the word
32
+ # (`extraction.port`'s own `answers :canonical`). Local, not shared
33
+ # cross-chapter, same reasoning as PortSignal just above.
34
+ value_object "PortAnswer" do
35
+ attribute :value, String
36
+ invariant("an answer names a real method") { !value.to_s.empty? }
37
+ end
38
+
19
39
  # HOW DOMAINPORT IS SPELLED. These rows live with the concept
20
40
  # they describe; SyntaxBoot discovers every aggregate-local seed table.
21
41
  # Contexts represented here: DomainPort.
@@ -35,6 +55,8 @@ Hecks.bluebook "Hecksagon" do
35
55
  member word: "tells", context: "DomainPort", body: "keywords", inner: "PortOperation", opens: "PortOperation", fills: "", calls: "tells_impl"
36
56
  member word: "asks", context: "DomainPort", body: "keywords", inner: "PortOperation", opens: "PortOperation", fills: "", calls: "asks_impl"
37
57
  member word: "verb", context: "DomainPort", body: "none", inner: "", opens: "", fills: "verb"
58
+ member word: "signal", context: "DomainPort", body: "none", inner: "", opens: "", fills: "signal"
59
+ member word: "answers", context: "DomainPort", body: "none", inner: "", opens: "", fills: "answers"
38
60
 
39
61
  end
40
62
 
@@ -59,6 +81,8 @@ Hecks.bluebook "Hecksagon" do
59
81
  member keyword: "tells", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "name"
60
82
  member keyword: "asks", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "name"
61
83
  member keyword: "verb", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "verb"
84
+ member keyword: "signal", context: "DomainPort", at: "1", named: "", kind: "symbol", required: "true", fills: "signal"
85
+ member keyword: "answers", context: "DomainPort", at: "1", named: "", kind: "symbol", required: "true", fills: "answers"
62
86
 
63
87
  # THE RECEIVING AGGREGATE — PortOperationBuilder's own `reference_to`
64
88
  # (inside the block) is refused (#335); this is the sanctioned
@@ -48,7 +48,7 @@ module Hecks
48
48
  # that sets them runs, same as they were unset locals before that point.
49
49
  Context = Struct.new(:domain, :aggregate, :command, :args, :repository, :instance, :transition, :old_state,
50
50
  :result, :correlation, :route, :plan, :strategy, :persistence_outcome, :delegated_events,
51
- :dry_run)
51
+ :dry_run, :correction_bindings)
52
52
 
53
53
  def initialize(registry, rules:)
54
54
  @registry = registry
@@ -135,10 +135,13 @@ module Hecks
135
135
  # NotFound/AlreadyExists already get at hydration: "does the
136
136
  # fact this command's corrects names even exist" is not a
137
137
  # domain rule an author wrote, it is a precondition for the
138
- # domain rules to mean anything at all.
139
- @rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
138
+ # domain rules to mean anything at all. Also locates the
139
+ # correction target itself, if `as:` named one — carried on
140
+ # `ctx` so `step_enforce_ensures` (the settled-record half)
141
+ # can bind the SAME name too, not just this pre-mutation half.
142
+ ctx.correction_bindings = @rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
140
143
  @rules.enforce_givens(ctx.instance, ctx.command, ctx.args, domain: ctx.domain,
141
- declaring: ctx.aggregate, parent: ctx.instance)
144
+ declaring: ctx.aggregate, parent: ctx.instance, correction: ctx.correction_bindings)
142
145
  }
143
146
  end
144
147
 
@@ -239,7 +242,7 @@ module Hecks
239
242
  def step_enforce_ensures(ctx)
240
243
  step(:enforce_ensures) {
241
244
  @rules.enforce_ensures(ctx.instance, ctx.command, ctx.args, old: ctx.old_state,
242
- domain: ctx.domain, parent: ctx.instance)
245
+ domain: ctx.domain, parent: ctx.instance, correction: ctx.correction_bindings || {})
243
246
  }
244
247
  end
245
248
 
@@ -260,7 +263,7 @@ module Hecks
260
263
  seed_projected_fields(ctx)
261
264
  ctx.persistence_outcome = if ctx.strategy == DependencyPlanning::ATOMIC_PUT
262
265
  # A SECOND CREATION IS NOT A FRESH ONE — see
263
- # hydrate_legacy_creation's own comment; the
266
+ # hydrate_complete_state's own comment; the
264
267
  # same refusal, on the same terms, for the
265
268
  # complete-state path. `insert_only:` asks the
266
269
  # ADAPTER to decide and refuse ATOMICALLY
@@ -376,19 +379,61 @@ module Hecks
376
379
  found.dup
377
380
  end
378
381
 
379
- # Transitional compatibility for live source that has not yet acquired
380
- # explicit effects. It is deliberately isolated from the normal routing
381
- # and planning path so `reference_to` no longer chooses how a migrated
382
- # command hydrates or persists. Wave 8 removes this after the inventory is
383
- # empty; frozen eras retain their own shadow parser.
382
+ # WAVE 8 (equivalence-gap plan, item 1.6) NOT done, and this
383
+ # comment now says precisely how far it got, corrected from an
384
+ # EARLIER version of itself that briefly (same PR, never released)
385
+ # claimed the inventory was empty and deleted this method outright.
386
+ # A full corpus audit (`DependencyPlanning::Analyzer.call` against
387
+ # every `creates?`-true command in all 5 EXAMPLE domains — banking,
388
+ # pizzas, chess, compliance, roster) found and fixed 4 real
389
+ # authoring bugs (`Statement.Generate`, `CreatePizza`, `Chess::
390
+ # Game.Start`, `Roster.Open` — each declared an attribute and
391
+ # never `sets` it, so the field silently stayed nil on every
392
+ # created record regardless of what a caller sent), plus a real
393
+ # `DependencyPlanning::Analyzer` bug for ENTITY-owned commands
394
+ # (`root_aggregate:`, that class's own header) that surfaced one
395
+ # more live corpus bug of its own (`payment_cards.bluebook`'s own
396
+ # `Withdrawal.Dispute`, which never actually checked whether the
397
+ # card was retired) — all real, all kept.
384
398
  #
385
- # `ctx.plan.complete_state?` already claimed every command whose plan is
386
- # fully resolved in the two branches above `step_hydrate` tries first, so
387
- # reaching this check already means the plan is incomplete an
388
- # un-migrated command still routes here on `creates?` alone, the same as
389
- # the old `hydrate` did, regardless of whether it happens to have any
390
- # mutations (`write_set`). Requiring an EMPTY write_set here refused
391
- # every un-migrated creating command that sets even one field.
399
+ # DELETING THIS METHOD ON THAT BASIS TURNED OUT TO BE WRONG,
400
+ # caught by running the full suite rather than trusting the
401
+ # audit's own scope: the 5 example domains are nowhere near the
402
+ # WHOLE inventory of `creates?`-true commands this fallback
403
+ # actually carries. Dozens of separate, purpose-built spec
404
+ # fixtures across the suite (`spec/fixtures/*.bluebook`, and
405
+ # inline `Hecks.bluebook` blocks declared directly inside
406
+ # individual spec files — governance, mutation ops, ports,
407
+ # routing, tenant isolation, sagas, and more) declare their OWN
408
+ # small `creates?`-true commands the same incomplete way, and
409
+ # rely on this exact fallback to create anything at all. Deleting
410
+ # it produced 218 failures across specs with nothing to do with
411
+ # Wave 8's own subject — confirmed, not guessed, by actually
412
+ # running `bundle exec rspec` after the deletion, not merely by
413
+ # extrapolating from the one audited inventory. `spec/fixtures/
414
+ # till.bluebook`'s own `Till.OpenTill` (missing `sets :number`,
415
+ # the identical bug shape as the 4 fixed above) was fixed
416
+ # alongside the four real corpus ones as one instance of this — but
417
+ # it was one of many, not the last one, and finding the rest is
418
+ # real, separate, much larger follow-up work this pass does not
419
+ # attempt: a suite-wide audit of every declared bluebook fixture,
420
+ # not just the 5 example domains the plan's own text named.
421
+ #
422
+ # Transitional compatibility for live source that has not yet
423
+ # acquired explicit effects, same as it always was — deliberately
424
+ # isolated from the normal routing and planning path so
425
+ # `reference_to` no longer chooses how a migrated command hydrates
426
+ # or persists. A real, future Wave 8 removes this once THAT wider
427
+ # inventory is empty, not before.
428
+ #
429
+ # `ctx.plan.complete_state?` already claimed every command whose
430
+ # plan is fully resolved in the two branches above `step_hydrate`
431
+ # tries first, so reaching this check already means the plan is
432
+ # incomplete — an un-migrated command still routes here on
433
+ # `creates?` alone, the same as the old `hydrate` did, regardless
434
+ # of whether it happens to have any mutations (`write_set`).
435
+ # Requiring an EMPTY write_set here refused every un-migrated
436
+ # creating command that sets even one field.
392
437
  def legacy_implicit_creation?(ctx)
393
438
  ctx.route.nil? && ctx.command.creates?
394
439
  end
@@ -421,9 +466,11 @@ module Hecks
421
466
  aggregate: aggregate.hecks_name,
422
467
  identity: identity_reading(aggregate)))
423
468
 
424
- # A SECOND CREATION IS NOT A FRESH ONE — see hydrate_legacy_creation's
425
- # own comment; the same refusal, on the same terms, for the
426
- # complete-state path. ONLY when `strategy` will NOT be ATOMIC_PUT:
469
+ # A SECOND CREATION IS NOT A FRESH ONE — `creates?` on an identity
470
+ # a record already exists under refuses (`AlreadyExists`) rather
471
+ # than silently overwriting it, the same refusal `hydrate_prior_
472
+ # or_initial`'s own body gives for its own complete-but-state-
473
+ # dependent case, below. ONLY when `strategy` will NOT be ATOMIC_PUT:
427
474
  # an atomic-put-capable adapter enforces this itself, atomically,
428
475
  # via `insert_only:` in step_save (no read here, no race with the
429
476
  # write) — but `strategy_for` already fell back to a plain `save`
@@ -461,8 +508,8 @@ module Hecks
461
508
  identity: identity_reading(aggregate)))
462
509
  found = repository.find(id)
463
510
 
464
- # A SECOND CREATION IS NOT A FRESH ONE — see hydrate_legacy_
465
- # creation's own comment; the same refusal, on the same terms, for
511
+ # A SECOND CREATION IS NOT A FRESH ONE — see hydrate_complete_
512
+ # state's own comment; the same refusal, on the same terms, for
466
513
  # a complete-but-state-dependent command (one with a `given`
467
514
  # reading its own prior state, which is what routes here instead
468
515
  # of hydrate_complete_state). Gated on `command.creates?`: a
@@ -116,7 +116,13 @@ module Hecks
116
116
  # parent aggregate reaching ITS OWN customer) resolves the same
117
117
  # way `account.customer.status` does for a command-level reference.
118
118
  # nil for an aggregate command — CommandInterpreter never passes it.
119
- def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil)
119
+ # `correction:` the `{as_name => payload}` bindings
120
+ # `enforce_correction_target` (above) already located, merged in
121
+ # LAST so an `as:` name wins the same way `old:` always wins in
122
+ # `enforce_ensures`, below — it is a fresh local binding a
123
+ # `corrects` command introduces, not a real argument/state field
124
+ # a caller could collide with by accident.
125
+ def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil, correction: {})
120
126
  state = GuardState.new(subject)
121
127
  # A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12,
122
128
  # ADR 0025) — `subject`'s own STORED references are no longer
@@ -135,6 +141,7 @@ module Hecks
135
141
  # forbids.
136
142
  attrs = args.merge(dereference(domain, command, args))
137
143
  attrs = attrs.merge(parent: parent.state) if parent
144
+ attrs = attrs.merge(correction) unless correction.empty?
138
145
  command.givens.each do |given|
139
146
  next if Bluebook::Expression::Evaluator.call(given.canonical, state, attrs)
140
147
 
@@ -154,20 +161,43 @@ module Hecks
154
161
  # event at all — is `AggregateBuilder#seal_correction_targets`;
155
162
  # this is the dispatch-time half — has THIS record actually done
156
163
  # so yet.
164
+ #
165
+ # ALSO LOCATES the matched event now, not just its existence, and
166
+ # returns a `{as_name => payload}` bindings hash — one entry per
167
+ # `:corrects` mutation that named an `as:` — so `given`/`ensures`
168
+ # on a corrects-bearing command can reference the located OLD
169
+ # event by that name, the same shape `enforce_ensures`'s own
170
+ # `old:` binding already has (CommandBuilder#corrects_impl's own
171
+ # comment: `as:` was stored, from the start, specifically to be
172
+ # wired into the evaluator once a real runtime consumer existed —
173
+ # this is that consumer). `.reverse.find` — the MOST RECENT
174
+ # matching event, if this record has somehow emitted the same
175
+ # correction target more than once; the prior existence-only
176
+ # check never had to make this choice, so it's a genuinely new
177
+ # one, made deliberately: `as:` reads as "the instance being
178
+ # corrected," which is naturally the latest fact on record, not
179
+ # an arbitrary one.
157
180
  def enforce_correction_target(instance, aggregate, command, domain:)
181
+ bindings = {}
158
182
  command.mutations.each do |mutation|
159
183
  next unless mutation.op == :corrects
160
184
 
161
185
  event_key = "#{domain}::#{aggregate.hecks_name}"
162
186
  event_name = mutation.target.to_s
163
- next if @registry.event_log.any? do |event|
187
+ corrected = @registry.event_log.reverse.find do |event|
164
188
  event.name == event_name && event.aggregate == event_key && event.id == instance.id
165
189
  end
166
190
 
167
- raise NothingToCorrect,
168
- "#{command.hecks_name} refused — corrects #{event_name}, but " \
169
- "#{event_key} ##{instance.id} has never emitted it"
191
+ unless corrected
192
+ raise NothingToCorrect,
193
+ "#{command.hecks_name} refused — corrects #{event_name}, but " \
194
+ "#{event_key} ##{instance.id} has never emitted it"
195
+ end
196
+
197
+ as = mutation.source[:as]
198
+ bindings[as.to_sym] = corrected.payload if as && !as.to_s.empty?
170
199
  end
200
+ bindings
171
201
  end
172
202
 
173
203
  # LIFECYCLE STATE AS A COMMAND GUARD (S10, ADR 0025) — `command
@@ -215,16 +245,21 @@ module Hecks
215
245
  # Not new to ensures — `given` lives under the same rule — but an
216
246
  # ensures is more likely to collide, since it typically re-reads a
217
247
  # field the command just took in to mutate it.
218
- def enforce_ensures(subject, command, args, old:, domain:, parent: nil)
248
+ def enforce_ensures(subject, command, args, old:, domain:, parent: nil, correction: {})
219
249
  state = GuardState.new(subject)
220
250
  # S12, ADR 0025 — same boundary reasoning as enforce_givens
221
251
  # above: `subject`'s own stored references are no longer
222
252
  # dereferenced here; a `projects`-maintained field is already
223
253
  # part of `state`. `command`/`args` still dereferences — a
224
254
  # fresh reference-typed ARGUMENT stays in bounds.
225
- # `old` still wins over everything, unchanged.
255
+ # `old` still wins over everything, unchanged. `correction`
256
+ # (an `as:`-bound corrected event, if this command declares
257
+ # one) wins right alongside it — a settled-record ensures can
258
+ # reference the correction target exactly as freely as a
259
+ # pre-mutation given already can.
226
260
  attrs = args.merge(dereference(domain, command, args))
227
261
  attrs = attrs.merge(parent: parent.state) if parent
262
+ attrs = attrs.merge(correction) unless correction.empty?
228
263
  attrs = attrs.merge(old: old)
229
264
  command.ensures.each do |rule|
230
265
  next if Bluebook::Expression::Evaluator.call(rule.canonical, state, attrs)
@@ -68,9 +68,39 @@ module Hecks
68
68
  # made. The real mistake, an absent argument, was never the one refused,
69
69
  # which is what fuzz surfaced.
70
70
  #
71
- # Absent now resolves to nil. Whether it should be REFUSED instead
72
- # is a separate question — the language cannot yet say which arguments are
73
- # optional, and the meta-domain has plenty that are.
71
+ # STALE (as of the equivalence-gap plan's own audit): this used to
72
+ # say "the language cannot yet say which arguments are optional" —
73
+ # it already can, and always could once `attribute ..., optional:
74
+ # true` existed (`CommandBuilder#attribute_impl`,
75
+ # `attribute_collector.rb`): `sets` already sources correctly from
76
+ # an optional attribute, resolving absent to nil exactly as this
77
+ # method does, and REFUSING it here would be wrong, not merely
78
+ # undone work — `TillRoom::Till.TakeIn`'s own `note` (spec/
79
+ # fixtures/till.bluebook) and Banking's `CardPayment.Authorize`'s
80
+ # `tags` (payment_cards.bluebook) are real, live commands whose
81
+ # `sets` mutation is deliberately sourced from an optional
82
+ # attribute the caller may omit — `spec/runtime/command_rules_spec
83
+ # .rb`'s own "says an absent OPTIONAL argument is nil, not the
84
+ # name of the argument" pins exactly this as correct, not pending.
85
+ # The meta-domain's own self-hosted commands (Command.Declare's
86
+ # `role`/`goal`/`provenance`/`from`/`position`, and ~35 more sites
87
+ # across the language) all lean on the identical pattern — nil is
88
+ # the RIGHT answer for a `sets` sourced from a declared-optional
89
+ # attribute the caller left out, every time.
90
+ #
91
+ # The one thing that WAS still a real, narrow gap — a `sets`
92
+ # source Symbol naming NOTHING the command declares at all (a
93
+ # typo, not an optional argument) — silently resolved to nil
94
+ # forever the same way, indistinguishable at either build or run
95
+ # time from a legitimate optional absence. Closed at BUILD time
96
+ # instead of here: `CommandBuilder#refuse_unknown_argument_sources!`
97
+ # refuses it the moment the `.bluebook` file loads, mirroring
98
+ # `AggregateBuilder#seal_query_argument`'s identical check for a
99
+ # query's own where-clause argument. This function stays exactly
100
+ # what it always was — a pure, unconditional lookup — because by
101
+ # the time ANY mutation reaches it, the source has already been
102
+ # proven to name either a real, possibly-optional argument, or a
103
+ # StateRef/literal; there is nothing left here to refuse.
74
104
  def resolve_source(source, args)
75
105
  return args[source] if source.is_a?(Symbol)
76
106
 
@@ -62,13 +62,29 @@ module Hecks
62
62
  class Analyzer
63
63
  STATEFUL_MUTATIONS = %i[append increment decrement multiply clamp remove].freeze
64
64
 
65
- def self.call(aggregate:, command:) = new(aggregate, command).call
66
-
67
- def initialize(aggregate, command)
65
+ # `root_aggregate:` Wave 8's own audit surfaced a real bug here,
66
+ # not merely a missing feature: for an ENTITY-owned command,
67
+ # `EntityInterpreter` calls this with `aggregate:` set to the
68
+ # ENTITY itself (`element_interpreter.rb`'s own `Analyzer.call
69
+ # (aggregate: entity, command:)`), so `owner_fields` was always
70
+ # the entity's own attribute set. A `given`/`ensures` reading
71
+ # `parent.X` legitimately means the ROOT aggregate's own field —
72
+ # a genuinely different owner — but `classify_path`'s `:parent`
73
+ # branch checked that read against `owner_fields` (the entity's),
74
+ # which can never contain a root-level field, so every entity
75
+ # command with a real, legitimate `parent.*` read was refused as
76
+ # unresolved regardless of correctness. Defaults to `aggregate`
77
+ # (a no-op) for the plain-aggregate case — `CommandInterpreter`'s
78
+ # own call site never needed to change.
79
+ def self.call(aggregate:, command:, root_aggregate: aggregate) = new(aggregate, command, root_aggregate).call
80
+
81
+ def initialize(aggregate, command, root_aggregate = aggregate)
68
82
  @aggregate = aggregate
69
83
  @command = command
70
84
  @owner_fields = aggregate.attributes.to_set(&:name)
71
85
  @owner_fields << aggregate.lifecycle.field.to_sym if aggregate.lifecycle
86
+ @root_owner_fields = root_aggregate.attributes.to_set(&:name)
87
+ @root_owner_fields << root_aggregate.lifecycle.field.to_sym if root_aggregate.lifecycle
72
88
  # `projects` FIELDS (S12, ADR 0025) ARE OWNER STATE TOO — a
73
89
  # `given`/`ensures` reading one (e.g. `customer_status ==
74
90
  # "active"`) is reading this record's own stored field, same
@@ -80,9 +96,17 @@ module Hecks
80
96
  # a partial mutation, which is exactly right — a projected
81
97
  # field's freshness comes from the interpreter reseeding it on
82
98
  # save, not from anything a caller-supplied write set carries.
99
+ # Applies to BOTH `owner_fields` and `root_owner_fields` — an
100
+ # entity's own `parent.*` read can name the root aggregate's
101
+ # projected field just as easily as one of its real attributes
102
+ # (`Banking::Withdrawal.Dispute`'s own `parent.account_customer_
103
+ # status`, ATMCard's projected field, is a real, live example).
83
104
  if aggregate.respond_to?(:projected_fields)
84
105
  aggregate.projected_fields.each { |field| @owner_fields << field.name }
85
106
  end
107
+ if root_aggregate.respond_to?(:projected_fields)
108
+ root_aggregate.projected_fields.each { |field| @root_owner_fields << field.name }
109
+ end
86
110
  @payload_fields = command.attributes.to_set(&:name)
87
111
  @state_reads = Set.new
88
112
  @payload_reads = Set.new
@@ -115,7 +139,7 @@ module Hecks
115
139
 
116
140
  private
117
141
 
118
- attr_reader :aggregate, :command, :owner_fields, :payload_fields,
142
+ attr_reader :aggregate, :command, :owner_fields, :root_owner_fields, :payload_fields,
119
143
  :state_reads, :payload_reads, :writes, :known_writes, :unresolved
120
144
 
121
145
  # A fresh Instance supplies these values without reading a stored
@@ -204,13 +228,36 @@ module Hecks
204
228
  end
205
229
  end
206
230
 
231
+ # KNOWN, HARMLESS GAP: `corrects ..., as: :name`'s bound name
232
+ # (admissibility.rb's `enforce_correction_target`/`enforce_givens`/
233
+ # `enforce_ensures`) isn't special-cased here the way `:old`/
234
+ # `:parent` are — a given/ensures referencing it falls through to
235
+ # `unresolved` below (its own field lookup finds no owner/payload
236
+ # match), same net effect as any other not-yet-optimized command:
237
+ # `complete_state?` comes back false, so dispatch takes the safe
238
+ # `hydrate_existing` path instead of the `ATOMIC_PUT` fast path.
239
+ # Not a correctness bug — `as:`'s runtime binding (a plain `attrs`
240
+ # merge, exactly like `old:`'s) resolves and evaluates correctly
241
+ # regardless of what this STATIC analysis concludes — just a real,
242
+ # deliberately-left optimization gap: closing it would mean
243
+ # threading "which names this command declares as correction
244
+ # bindings" into the Analyzer, which doesn't have that per-command
245
+ # context today. Worth doing alongside `:old`/`:parent`'s own
246
+ # handling someday, not attempted here.
207
247
  def classify_path(path, phase)
208
248
  head, nested = path.split(".", 2)
209
249
  name = head.to_sym
210
250
 
211
251
  if name == :parent
212
252
  parent_field = nested.to_s.split(".", 2).first
213
- if parent_field.empty? || !owner_fields.include?(parent_field.to_sym)
253
+ # `root_owner_fields` NOT `owner_fields`. For an entity-owned
254
+ # command `owner_fields` is the ENTITY's own attribute set;
255
+ # `parent.X` always means the ROOT aggregate's own field, a
256
+ # genuinely different owner (`root_aggregate:`'s own header,
257
+ # above, has the full bug this fixes). Identical for a plain
258
+ # aggregate command, where root_aggregate defaults to aggregate
259
+ # itself and the two sets are the same set.
260
+ if parent_field.empty? || !root_owner_fields.include?(parent_field.to_sym)
214
261
  unresolved << "#{path} does not name parent aggregate state"
215
262
  else
216
263
  state_reads << parent_field.to_sym
@@ -98,7 +98,14 @@ module Hecks
98
98
  ctx.chain = chain
99
99
  ctx.route = route
100
100
  ctx.dry_run = dry_run
101
- ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command)
101
+ # `root_aggregate:` `entity` is the immediate owner (what
102
+ # `owner_fields` inside the Analyzer means), but a `parent.X`
103
+ # read inside this command's own given/ensures means the ROOT
104
+ # aggregate's own field, not the entity's — `aggregate` here IS
105
+ # that root (this method's own first parameter, never the
106
+ # entity). See DependencyPlanning::Analyzer.call's own header
107
+ # for the bug this closes.
108
+ ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command, root_aggregate: aggregate)
102
109
  # RESOLVED HERE, ONCE — see CommandInterpreter#call's own comment;
103
110
  # `step_hydrate_parent` reads `ctx.repository` without re-fetching.
104
111
  ctx.repository = @registry.repository(domain, aggregate)
@@ -73,10 +73,19 @@ module Hecks
73
73
  [fetch(bluebook, domain, head[:aggregate], reference_id)]
74
74
  elsif rootless
75
75
  # No root to FK-match against — a rootless model reads
76
- # each of its own heads WHOLE, independently. (Multiple
77
- # heads on one rootless model aren't cross-joined
78
- # against each other either each is its own bulk
79
- # read. A real, deliberate scope limit for now.)
76
+ # each of its own heads WHOLE, independently. Multiple
77
+ # heads on one rootless model are NEVER cross-joined
78
+ # against each other, and there is no DSL to declare
79
+ # one if you wanted to `ReadModelBuilder#include_impl`
80
+ # takes only `type`/`as:` (checked directly, not
81
+ # assumed), and `group_by` groups this bulk read's own
82
+ # output, it names no predicate between two heads.
83
+ # Building a cross-join here would mean CHOOSING a join
84
+ # semantics (equality on which fields?) nobody has
85
+ # declared — a real, deliberate scope limit pending a
86
+ # future `include ..., joins: ...`-shaped grammar
87
+ # addition (with its own Rust mirror), not a gap this
88
+ # interpreter can quietly grow into on its own.
80
89
  records(bluebook, domain, head[:aggregate])
81
90
  else
82
91
  matching(records(bluebook, domain, head[:aggregate])) do |record|