hecks 0.3.0 → 1.0.1

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 (202) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sql_query_builder.rb +1 -1
  14. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  15. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  16. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  17. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  18. data/lib/hecks/adapters/driven.rb +19 -1
  19. data/lib/hecks/behaviors/dsl.rb +29 -0
  20. data/lib/hecks/behaviors/expectations.rb +62 -2
  21. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  22. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  23. data/lib/hecks/bluebook/assembly.rb +14 -1
  24. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  25. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  26. data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
  27. data/lib/hecks/bluebook/chapter.rb +21 -11
  28. data/lib/hecks/bluebook/command.rb +1 -1
  29. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  30. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  31. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  32. data/lib/hecks/bluebook/dsl/command_builder.rb +144 -3
  33. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
  34. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  35. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  36. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  37. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  39. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  40. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  41. data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
  42. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  43. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  44. data/lib/hecks/bluebook/expression/projection.json +48 -0
  45. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  46. data/lib/hecks/bluebook/expression.rb +1 -0
  47. data/lib/hecks/bluebook/hexagon.rb +1 -1
  48. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  49. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  50. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  51. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  52. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  53. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  54. data/lib/hecks/bluebook/model_check.rb +147 -6
  55. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  56. data/lib/hecks/bluebook/process_manager.rb +53 -11
  57. data/lib/hecks/bluebook/project_register.rb +25 -0
  58. data/lib/hecks/bluebook/value_object.rb +19 -2
  59. data/lib/hecks/doc/reference.rb +22 -1
  60. data/lib/hecks/facade/cli_door.rb +6 -3
  61. data/lib/hecks/facade/json_door.rb +16 -4
  62. data/lib/hecks/forms/app.rb +47 -6
  63. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  64. data/lib/hecks/forms/field_renderer.rb +11 -4
  65. data/lib/hecks/forms/html.rb +31 -0
  66. data/lib/hecks/forms/params.rb +30 -1
  67. data/lib/hecks/forms/port_argument.rb +46 -0
  68. data/lib/hecks/forms/record_renderer.rb +6 -2
  69. data/lib/hecks/forms/record_table.rb +6 -1
  70. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  71. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  72. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  73. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  74. data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
  75. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  76. data/lib/hecks/fuzzing/properties.rb +52 -6
  77. data/lib/hecks/fuzzing/replay.rb +51 -18
  78. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  79. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  80. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  81. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  82. data/lib/hecks/grammar/evolve.rb +33 -0
  83. data/lib/hecks/grammar/expression.bluebook +8 -5
  84. data/lib/hecks/grammar/expression_operators.json +497 -1
  85. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  86. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  87. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  88. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  89. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  90. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  91. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  92. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  93. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  94. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  95. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  96. data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
  97. data/lib/hecks/language/oidc.json +20 -0
  98. data/lib/hecks/language/port.bluebook +30 -2
  99. data/lib/hecks/naming.rb +54 -1
  100. data/lib/hecks/ports/access_control.port +7 -2
  101. data/lib/hecks/ports/access_control.rb +1 -1
  102. data/lib/hecks/ports/agent.port +6 -2
  103. data/lib/hecks/ports/agent.rb +1 -1
  104. data/lib/hecks/ports/authentication.port +4 -2
  105. data/lib/hecks/ports/authentication.rb +1 -1
  106. data/lib/hecks/ports/authorization.port +5 -2
  107. data/lib/hecks/ports/authorization.rb +14 -11
  108. data/lib/hecks/ports/clock.port +3 -2
  109. data/lib/hecks/ports/clock.rb +1 -1
  110. data/lib/hecks/ports/extraction.port +3 -2
  111. data/lib/hecks/ports/extraction.rb +1 -1
  112. data/lib/hecks/ports/identity_assignment.port +3 -2
  113. data/lib/hecks/ports/identity_assignment.rb +1 -1
  114. data/lib/hecks/ports/identity_generation.port +3 -2
  115. data/lib/hecks/ports/identity_generation.rb +1 -1
  116. data/lib/hecks/ports/identity_resolution.port +3 -2
  117. data/lib/hecks/ports/identity_resolution.rb +1 -1
  118. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  119. data/lib/hecks/ports/persistence/execution.rb +6 -1
  120. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  121. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  122. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  123. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  129. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  130. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  131. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  132. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  133. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  134. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  135. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  136. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  137. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  138. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  139. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  140. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  141. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  142. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  143. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  144. data/lib/hecks/ports/persistence.rb +1 -1
  145. data/lib/hecks/ports/projection.rb +20 -0
  146. data/lib/hecks/projections/diagrams.rb +230 -1
  147. data/lib/hecks/projections/shape.rb +15 -2
  148. data/lib/hecks/projector/cli_projector.rb +10 -1
  149. data/lib/hecks/projector/exporter.rb +19 -2
  150. data/lib/hecks/query_ir.rb +19 -0
  151. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  152. data/lib/hecks/query_specification/field_path.rb +20 -2
  153. data/lib/hecks/query_specification/hop_path.rb +7 -5
  154. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  155. data/lib/hecks/runtime/boot_gates.rb +41 -0
  156. data/lib/hecks/runtime/caller.rb +19 -3
  157. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  158. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  159. data/lib/hecks/runtime/command_interpreter.rb +165 -34
  160. data/lib/hecks/runtime/command_rules/admissibility.rb +101 -16
  161. data/lib/hecks/runtime/command_rules/arithmetic.rb +40 -4
  162. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  163. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  164. data/lib/hecks/runtime/dependency_planning.rb +66 -5
  165. data/lib/hecks/runtime/dispatcher.rb +19 -4
  166. data/lib/hecks/runtime/entity_interpreter.rb +92 -14
  167. data/lib/hecks/runtime/errors.rb +22 -0
  168. data/lib/hecks/runtime/identity.rb +30 -2
  169. data/lib/hecks/runtime/instance.rb +59 -4
  170. data/lib/hecks/runtime/interpreting.rb +21 -0
  171. data/lib/hecks/runtime/loader.rb +59 -18
  172. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  173. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  174. data/lib/hecks/runtime/read_model_interpreter.rb +89 -5
  175. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  176. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  177. data/lib/hecks/runtime/registry/verification.rb +88 -0
  178. data/lib/hecks/runtime/registry.rb +69 -8
  179. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  180. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  181. data/lib/hecks/runtime/value/admission.rb +19 -1
  182. data/lib/hecks/runtime/value/coercion.rb +75 -10
  183. data/lib/hecks/runtime.rb +17 -5
  184. data/lib/hecks/storehouse.rb +632 -0
  185. data/lib/hecks/version.rb +1 -1
  186. data/lib/hecks/vocabulary.rb +6 -1
  187. data/lib/hecks.rb +28 -3
  188. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  189. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  190. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  191. metadata +49 -37
  192. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  193. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  194. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  195. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  196. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  197. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  198. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  199. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  200. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  201. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  202. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -9,6 +9,7 @@ require_relative "../ports/persistence/execution"
9
9
  require_relative "instance"
10
10
  require_relative "refusal_wording"
11
11
  require_relative "entity_element"
12
+ require_relative "rebuild_sweep"
12
13
 
13
14
  module Hecks
14
15
  module Runtime
@@ -31,13 +32,23 @@ module Hecks
31
32
  # Runtime::RefusalWording's own doc comment gives the same reason.
32
33
  DISPATCH_ORDER = Hecks::Vocabulary.symbols("AggregateDispatchOrder")
33
34
 
35
+ # A LAST-RESORT SAFETY VALVE, NOT THE NORMAL OUTCOME PATH — see
36
+ # `Runtime::StaleWrite`'s own comment. Two concurrent writers
37
+ # against one aggregate resolve through exactly one retry in the
38
+ # ordinary case (the loser's retried hydrate reads the winner's now-
39
+ # committed state and its own `given` refuses for real, raising
40
+ # `GivenNotMet`, not `StaleWrite`) — this cap exists for pathological
41
+ # contention (many concurrent writers on one hot aggregate), not the
42
+ # two-writer case.
43
+ MAX_STALE_WRITE_RETRIES = 5
44
+
34
45
  # EVERY CROSS-STEP LOCAL `call` used to thread through its own literal
35
46
  # sequence, held in one place now that the sequence is data-driven —
36
47
  # `result` and `transition`/`old_state` default to nil until the step
37
48
  # that sets them runs, same as they were unset locals before that point.
38
49
  Context = Struct.new(:domain, :aggregate, :command, :args, :repository, :instance, :transition, :old_state,
39
50
  :result, :correlation, :route, :plan, :strategy, :persistence_outcome, :delegated_events,
40
- :dry_run)
51
+ :dry_run, :correction_bindings)
41
52
 
42
53
  def initialize(registry, rules:)
43
54
  @registry = registry
@@ -49,14 +60,34 @@ module Hecks
49
60
  # dispatch would (givens checked, mutations applied to `ctx.instance`
50
61
  # in memory); `step_save`/`step_emit` are the only two that read this
51
62
  # flag, each skipping its own real work — see their own comments.
63
+ # RETRIES THE WHOLE METHOD BODY on `StaleWrite` — a fresh `ctx`, a
64
+ # fresh `step_hydrate` re-reading current state, so `enforce_givens`
65
+ # re-evaluates against reality rather than the snapshot that just
66
+ # went stale. See `MAX_STALE_WRITE_RETRIES`/`Runtime::StaleWrite`
67
+ # for why exhaustion is a pathological-contention signal, not the
68
+ # expected shape of a two-writer race.
52
69
  def call(domain, aggregate, command, args, correlation = nil, route: nil, dry_run: false)
53
- ctx = Context.new(domain, aggregate, command, args)
54
- ctx.correlation = correlation
55
- ctx.route = route
56
- ctx.dry_run = dry_run
57
- ctx.plan = DependencyPlanning::Analyzer.call(aggregate: aggregate, command: command)
58
- run_dispatch_order(DISPATCH_ORDER, ctx)
59
- [ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
70
+ attempt = 0
71
+ begin
72
+ ctx = Context.new(domain, aggregate, command, args)
73
+ ctx.correlation = correlation
74
+ ctx.route = route
75
+ ctx.dry_run = dry_run
76
+ ctx.plan = DependencyPlanning::Analyzer.call(aggregate: aggregate, command: command)
77
+ # RESOLVED HERE, ONCE, BEFORE HYDRATION — `Registry#repository`
78
+ # memoizes, so this and `step_hydrate`'s own read of `ctx.repository`
79
+ # (no second fetch there any more) always name the same instance;
80
+ # the isolation decision below (lock vs. CAS+retry) needs the
81
+ # repository's capabilities before a single step runs.
82
+ ctx.repository = @registry.repository(domain, aggregate)
83
+ lock_id = Identity.best_effort(aggregate, args, route, reference_key: reference_key(command))
84
+ run_dispatch_order_with_isolation(DISPATCH_ORDER, ctx, lock_key_id: lock_id)
85
+ [ctx.instance, ctx.result, ctx.plan, ctx.persistence_outcome]
86
+ rescue StaleWrite
87
+ attempt += 1
88
+ retry if attempt < MAX_STALE_WRITE_RETRIES
89
+ raise
90
+ end
60
91
  end
61
92
 
62
93
  private
@@ -82,7 +113,8 @@ module Hecks
82
113
  end
83
114
 
84
115
  def step_hydrate(ctx)
85
- ctx.repository = @registry.repository(ctx.domain, ctx.aggregate)
116
+ # `ctx.repository` is resolved once, in `#call`, before the
117
+ # isolation decision (lock vs. CAS+retry) — not here any more.
86
118
  ctx.strategy = ctx.plan.strategy_for(capabilities: ctx.repository.capabilities)
87
119
  ctx.instance = step(:hydrate) {
88
120
  if ctx.plan.complete_state? && ctx.plan.state_independent?
@@ -99,8 +131,17 @@ module Hecks
99
131
 
100
132
  def step_enforce_givens(ctx)
101
133
  step(:enforce_givens) {
134
+ # STRUCTURAL, before the declared givens — the same ordering
135
+ # NotFound/AlreadyExists already get at hydration: "does the
136
+ # fact this command's corrects names even exist" is not a
137
+ # domain rule an author wrote, it is a precondition for the
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)
102
143
  @rules.enforce_givens(ctx.instance, ctx.command, ctx.args, domain: ctx.domain,
103
- declaring: ctx.aggregate, parent: ctx.instance)
144
+ declaring: ctx.aggregate, parent: ctx.instance, correction: ctx.correction_bindings)
104
145
  }
105
146
  end
106
147
 
@@ -201,7 +242,7 @@ module Hecks
201
242
  def step_enforce_ensures(ctx)
202
243
  step(:enforce_ensures) {
203
244
  @rules.enforce_ensures(ctx.instance, ctx.command, ctx.args, old: ctx.old_state,
204
- domain: ctx.domain, parent: ctx.instance)
245
+ domain: ctx.domain, parent: ctx.instance, correction: ctx.correction_bindings || {})
205
246
  }
206
247
  end
207
248
 
@@ -219,9 +260,10 @@ module Hecks
219
260
 
220
261
  step(:save) do
221
262
  @rules.resolve_state_references(ctx.domain, ctx.aggregate, ctx.instance.state)
263
+ seed_projected_fields(ctx)
222
264
  ctx.persistence_outcome = if ctx.strategy == DependencyPlanning::ATOMIC_PUT
223
265
  # A SECOND CREATION IS NOT A FRESH ONE — see
224
- # hydrate_legacy_creation's own comment; the
266
+ # hydrate_complete_state's own comment; the
225
267
  # same refusal, on the same terms, for the
226
268
  # complete-state path. `insert_only:` asks the
227
269
  # ADAPTER to decide and refuse ATOMICALLY
@@ -233,18 +275,63 @@ module Hecks
233
275
  # this strategy exists to skip.
234
276
  ctx.repository.atomic_put(ctx.instance, insert_only: ctx.command.creates?)
235
277
  else
236
- ctx.repository.save(ctx.instance)
237
- Ports::Persistence::Outcome.new(status: :saved, instance: ctx.instance)
278
+ # `expected_version:` is `ctx.instance.version` — nil for a
279
+ # brand-new record (never read from storage) or when the
280
+ # repository isn't CAS-capable, either of which falls straight
281
+ # through to a plain, unconditional save inside `AppendOnly#save`.
282
+ ctx.repository.save(ctx.instance, expected_version: ctx.instance.version)
238
283
  end
239
284
  if ctx.persistence_outcome.status == :conflicted
240
285
  raise(AlreadyExists, RefusalWording.render("AlreadyExists", "creating_duplicate",
241
286
  command: ctx.command.hecks_name, aggregate: ctx.aggregate.hecks_name,
242
287
  identity: identity_reading(ctx.aggregate),
243
288
  offered: Rendering.describe(ctx.instance.id)))
289
+ elsif ctx.persistence_outcome.status == :stale
290
+ # NOT a `RefusalWording.render` call — this is not a declared
291
+ # vocabulary refusal, just a plain, informative message. See
292
+ # `Runtime::StaleWrite`'s own comment: caught by `#call`'s
293
+ # retry loop, re-raised only once retries are exhausted.
294
+ raise(StaleWrite,
295
+ "#{ctx.command.hecks_name} on #{ctx.aggregate.hecks_name} " \
296
+ "(#{identity_reading(ctx.aggregate)}: #{Rendering.describe(ctx.instance.id)}) lost a race — " \
297
+ "another write committed against this record after it was read")
244
298
  end
245
299
  end
246
300
  end
247
301
 
302
+ # THE ONE-TIME, SYNCHRONOUS HALF OF `projects` (S12, ADR 0025) —
303
+ # `RebuildSweep` (`runtime/rebuild_sweep.rb`) is deliberately the
304
+ # ONLY thing that keeps a projected field current against a
305
+ # target that changes AFTER this record was written — no reactive
306
+ # `Policy#for_each` keeping it live in real time, that stays
307
+ # deferred, same as that file's own header explains. But without
308
+ # SOME synchronous population, a projected field never gets an
309
+ # INITIAL value at all until an operator remembers to run a
310
+ # sweep by hand — every acting command reading it (`given
311
+ # ("customer is active") { customer_status == "active" }`, say)
312
+ # would refuse a freshly created, genuinely active record for no
313
+ # real reason, which is not the eventual-consistency tradeoff the
314
+ # ADR accepts, just a bug. So: every time a record with `projects`
315
+ # fields is about to save — creating or acting, either can be the
316
+ # first time a referenced record resolves — read each one ONCE,
317
+ # here, using the exact same `RebuildSweep.remote_value` a sweep
318
+ # itself would compute. This is still eventually consistent in
319
+ # the sense the ADR means: a change on the TARGET side after this
320
+ # save still needs a sweep to reach here. It is only ever
321
+ # SYNCHRONOUS with THIS record's own write, never a live read
322
+ # triggered by a `given`/`ensures`/`invariant` mid-dispatch — the
323
+ # boundary rule those enforce holds exactly as before.
324
+ def seed_projected_fields(ctx)
325
+ return if ctx.aggregate.projected_fields.empty?
326
+
327
+ ctx.aggregate.projected_fields.each do |field|
328
+ value = RebuildSweep.remote_value(@registry, ctx.domain, ctx.aggregate, ctx.instance.state, field)
329
+ next if value.nil?
330
+
331
+ ctx.instance.state[field.name] = value
332
+ end
333
+ end
334
+
248
335
  # A DELEGATING COMMAND EMITS NOTHING OF ITS OWN (`CommandBuilder#build`'s
249
336
  # own guard refuses declaring `emits` alongside `delegates_to`) — its
250
337
  # result IS whatever `step_delegate_to_entity` already collected from
@@ -292,19 +379,61 @@ module Hecks
292
379
  found.dup
293
380
  end
294
381
 
295
- # Transitional compatibility for live source that has not yet acquired
296
- # explicit effects. It is deliberately isolated from the normal routing
297
- # and planning path so `reference_to` no longer chooses how a migrated
298
- # command hydrates or persists. Wave 8 removes this after the inventory is
299
- # 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.
398
+ #
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.
300
428
  #
301
- # `ctx.plan.complete_state?` already claimed every command whose plan is
302
- # fully resolved in the two branches above `step_hydrate` tries first, so
303
- # reaching this check already means the plan is incomplete — an
304
- # un-migrated command still routes here on `creates?` alone, the same as
305
- # the old `hydrate` did, regardless of whether it happens to have any
306
- # mutations (`write_set`). Requiring an EMPTY write_set here refused
307
- # every un-migrated creating command that sets even one field.
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.
308
437
  def legacy_implicit_creation?(ctx)
309
438
  ctx.route.nil? && ctx.command.creates?
310
439
  end
@@ -321,7 +450,7 @@ module Hecks
321
450
  offered: Rendering.describe(id)))
322
451
  end
323
452
 
324
- Instance.new(aggregate: aggregate, id: id)
453
+ Instance.new(aggregate: aggregate, id: id, args: args)
325
454
  end
326
455
 
327
456
  def hydrate_complete_state(repository, aggregate, command, args, route, strategy)
@@ -337,9 +466,11 @@ module Hecks
337
466
  aggregate: aggregate.hecks_name,
338
467
  identity: identity_reading(aggregate)))
339
468
 
340
- # A SECOND CREATION IS NOT A FRESH ONE — see hydrate_legacy_creation's
341
- # own comment; the same refusal, on the same terms, for the
342
- # 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:
343
474
  # an atomic-put-capable adapter enforces this itself, atomically,
344
475
  # via `insert_only:` in step_save (no read here, no race with the
345
476
  # write) — but `strategy_for` already fell back to a plain `save`
@@ -355,7 +486,7 @@ module Hecks
355
486
  offered: Rendering.describe(id)))
356
487
  end
357
488
 
358
- Instance.new(aggregate: aggregate, id: id)
489
+ Instance.new(aggregate: aggregate, id: id, args: args)
359
490
  end
360
491
 
361
492
  # A complete command may still depend on prior state: lifecycle guards
@@ -377,8 +508,8 @@ module Hecks
377
508
  identity: identity_reading(aggregate)))
378
509
  found = repository.find(id)
379
510
 
380
- # A SECOND CREATION IS NOT A FRESH ONE — see hydrate_legacy_
381
- # 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
382
513
  # a complete-but-state-dependent command (one with a `given`
383
514
  # reading its own prior state, which is what routes here instead
384
515
  # of hydrate_complete_state). Gated on `command.creates?`: a
@@ -398,7 +529,7 @@ module Hecks
398
529
  offered: Rendering.describe(id)))
399
530
  end
400
531
 
401
- found ? found.dup : Instance.new(aggregate: aggregate, id: id)
532
+ found ? found.dup : Instance.new(aggregate: aggregate, id: id, args: args)
402
533
  end
403
534
 
404
535
  # THE JOIN, THE DIG, AND THE READING — all shared with `EntityInterpreter`
@@ -116,11 +116,32 @@ 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
- owner = subject.aggregate if subject.respond_to?(:aggregate)
122
- attrs = dereference(domain, owner, subject).merge(args).merge(dereference(domain, command, args))
123
- attrs = attrs.merge(parent: parent.state.merge(dereference(domain, parent.aggregate, parent.state))) if parent
127
+ # A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12,
128
+ # ADR 0025) `subject`'s own STORED references are no longer
129
+ # dereferenced here at all. What used to be a live query against
130
+ # another aggregate's own repository is now just `subject`'s own
131
+ # state: a `projects :customer_status, from: :"customer.status"`
132
+ # field is a REGULAR stored attribute, already present in
133
+ # `subject`/`state` with no hydration step needed. `dereference`
134
+ # is still called on `command`/`args`, below — that is a
135
+ # DIFFERENT case the ADR explicitly keeps in bounds ("its command
136
+ # arguments"): a reference-typed ARGUMENT this dispatch was just
137
+ # handed (`Dispute`'s own `disputed_by`, say) has nothing stored
138
+ # to project yet, so resolving it here, once, synchronously with
139
+ # THIS command's own admission, is not the live-query-against-
140
+ # another-aggregate's-stored-state pattern the boundary rule
141
+ # forbids.
142
+ attrs = args.merge(dereference(domain, command, args))
143
+ attrs = attrs.merge(parent: parent.state) if parent
144
+ attrs = attrs.merge(correction) unless correction.empty?
124
145
  command.givens.each do |given|
125
146
  next if Bluebook::Expression::Evaluator.call(given.canonical, state, attrs)
126
147
 
@@ -130,6 +151,55 @@ module Hecks
130
151
  enforce_lifecycle_guard(declaring, command, subject) if declaring
131
152
  end
132
153
 
154
+ # `corrects` — CommandBuilder#corrects_impl's own comment. NOT
155
+ # expressible as an ordinary `given`: "has this exact record
156
+ # already emitted this exact event" is not a predicate over the
157
+ # record's OWN fields, it is a fact about the event log, so it is
158
+ # raised structurally here, the same way NotFound/AlreadyExists
159
+ # are, rather than through the expression evaluator. The build-
160
+ # time half — does ANYTHING in this aggregate ever emit the named
161
+ # event at all — is `AggregateBuilder#seal_correction_targets`;
162
+ # this is the dispatch-time half — has THIS record actually done
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.
180
+ def enforce_correction_target(instance, aggregate, command, domain:)
181
+ bindings = {}
182
+ command.mutations.each do |mutation|
183
+ next unless mutation.op == :corrects
184
+
185
+ event_key = "#{domain}::#{aggregate.hecks_name}"
186
+ event_name = mutation.target.to_s
187
+ corrected = @registry.event_log.reverse.find do |event|
188
+ event.name == event_name && event.aggregate == event_key && event.id == instance.id
189
+ end
190
+
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?
199
+ end
200
+ bindings
201
+ end
202
+
133
203
  # LIFECYCLE STATE AS A COMMAND GUARD (S10, ADR 0025) — `command
134
204
  # "Debit", from: "open"` checked here, folded into the SAME
135
205
  # dispatch step `given` already runs at (both are preconditions,
@@ -175,15 +245,21 @@ module Hecks
175
245
  # Not new to ensures — `given` lives under the same rule — but an
176
246
  # ensures is more likely to collide, since it typically re-reads a
177
247
  # field the command just took in to mutate it.
178
- def enforce_ensures(subject, command, args, old:, domain:, parent: nil)
248
+ def enforce_ensures(subject, command, args, old:, domain:, parent: nil, correction: {})
179
249
  state = GuardState.new(subject)
180
- owner = subject.aggregate if subject.respond_to?(:aggregate)
181
- # Same merge-order reasoning as enforce_givens above: an
182
- # aliased command-level reference must override its own raw
183
- # id argument, not the other way round. `old` still wins over
184
- # everything, unchanged.
185
- attrs = dereference(domain, owner, subject).merge(args).merge(dereference(domain, command, args))
186
- attrs = attrs.merge(parent: parent.state.merge(dereference(domain, parent.aggregate, parent.state))) if parent
250
+ # S12, ADR 0025 — same boundary reasoning as enforce_givens
251
+ # above: `subject`'s own stored references are no longer
252
+ # dereferenced here; a `projects`-maintained field is already
253
+ # part of `state`. `command`/`args` still dereferences — a
254
+ # fresh reference-typed ARGUMENT stays in bounds.
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.
260
+ attrs = args.merge(dereference(domain, command, args))
261
+ attrs = attrs.merge(parent: parent.state) if parent
262
+ attrs = attrs.merge(correction) unless correction.empty?
187
263
  attrs = attrs.merge(old: old)
188
264
  command.ensures.each do |rule|
189
265
  next if Bluebook::Expression::Evaluator.call(rule.canonical, state, attrs)
@@ -197,16 +273,23 @@ module Hecks
197
273
  # ensures` already checks at, and for the same reason: an
198
274
  # invariant is a claim about the SETTLED record, not the
199
275
  # command that produced it, so it reads no `args`/`old` at all,
200
- # only the record's own (dereferenced) state. `subject` here is
276
+ # only the record's own state. `subject` here is
201
277
  # always the AGGREGATE's own instance — `CommandInterpreter`
202
278
  # passes its own `ctx.instance`, and `EntityInterpreter` passes
203
279
  # the PARENT record (`ctx.instance`, not the element), since an
204
280
  # entity mutation changes data inside the SAME aggregate
205
281
  # boundary the invariant guards; there is no separate "entity
206
282
  # invariant" to check the piece's own view against.
283
+ #
284
+ # NO `dereference` (S12, ADR 0025) — an invariant may only read
285
+ # `subject`'s own boundary, same rule `enforce_givens`/
286
+ # `enforce_ensures` now hold to. No invariant in the corpus has
287
+ # ever read across a `reference_to` (verified before this
288
+ # change), so this is not a migration, just closing the same
289
+ # capability off here that was already unused.
207
290
  def enforce_invariants(subject, aggregate, domain:)
208
291
  state = GuardState.new(subject)
209
- attrs = dereference(domain, aggregate, subject)
292
+ attrs = {}
210
293
  aggregate.invariants.each do |invariant|
211
294
  next if Bluebook::Expression::Evaluator.call(invariant.canonical, state, attrs)
212
295
 
@@ -247,8 +330,10 @@ module Hecks
247
330
  Array(owner_instance[list_attr.name]).each do |element|
248
331
  wrapped = Instance.new(aggregate: entity, id: nil, state: element)
249
332
  element_state = GuardState.new(wrapped)
250
- attrs = dereference(domain, entity, wrapped)
251
- .merge(parent: owner_instance.state.merge(dereference(domain, owner_construct, owner_instance)))
333
+ # NO `dereference` (S12, ADR 0025) — same boundary rule as
334
+ # enforce_invariants above; `parent` (the owner's own
335
+ # state, projected fields included) stays readable.
336
+ attrs = { parent: owner_instance.state }
252
337
 
253
338
  entity.invariants.each do |invariant|
254
339
  next if Bluebook::Expression::Evaluator.call(invariant.canonical, element_state, attrs)
@@ -45,7 +45,13 @@ module Hecks
45
45
  # what Vocabulary::MutationOp declares — MutationApplier's own
46
46
  # `when :delegate` branch (mutation_applier.rb) never calls
47
47
  # #sign_of either, same as `remove`'s own note above.
48
- MutationOp.new(name: "delegate", sign: nil)
48
+ MutationOp.new(name: "delegate", sign: nil),
49
+ # CommandBuilder#corrects_impl's own comment gives the full
50
+ # reasoning — a command amending a past event rather than
51
+ # acting fresh. Carries no sign, like delegate: it does no
52
+ # arithmetic of its own; the record's actual change, if any,
53
+ # is an ordinary `sets` declared alongside it.
54
+ MutationOp.new(name: "corrects", sign: nil)
49
55
  ].freeze
50
56
 
51
57
  # A mutation's source is either the NAME OF AN ARGUMENT or a LITERAL, and
@@ -62,9 +68,39 @@ module Hecks
62
68
  # made. The real mistake, an absent argument, was never the one refused,
63
69
  # which is what fuzz surfaced.
64
70
  #
65
- # Absent now resolves to nil. Whether it should be REFUSED instead
66
- # is a separate question — the language cannot yet say which arguments are
67
- # 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.
68
104
  def resolve_source(source, args)
69
105
  return args[source] if source.is_a?(Symbol)
70
106
 
@@ -37,7 +37,8 @@ module Hecks
37
37
 
38
38
  authorized =
39
39
  if caller.actor_id && governance_attached?(domain)
40
- Ports::Authorization.holds_role?(registry, actor_id: caller.actor_id, role: command.role)
40
+ Ports::Authorization.holds_role?(registry, actor_id: caller.actor_id, role: command.role,
41
+ as_of: caller.as_of, scope: caller.scope)
41
42
  else
42
43
  caller.role == command.role
43
44
  end
@@ -128,31 +128,39 @@ module Hecks
128
128
  unwrapped.to_s
129
129
  end
130
130
 
131
- # A related record's OWN fields, reachable by name from `given`/
132
- # `ensures` — `customer.status`, `account.customer.status` — without
131
+ # A COMMAND ARGUMENT's own related record, reachable by name from
132
+ # `given`/`ensures` — `disputed_by.status`, say, `CardPayment
133
+ # .Dispute`'s own fresh `Reference<Customer>` argument — without
133
134
  # teaching the pure expression evaluator anything about
134
135
  # repositories. The lookup happens HERE, once, before evaluation;
135
136
  # `Resolver#lookup` just digs into a plain Hash exactly as it
136
137
  # always has.
137
138
  #
138
- # `owner` is either the declaring aggregate/entity (its OWN
139
- # `reference_to`, read off `source` the record's stored state)
140
- # or the command itself (a reference-typed ARGUMENT, read off
141
- # `source` the dispatch payload). Same shape either way: every
142
- # reference-typed attribute `owner` declares becomes a key in the
143
- # result, named by stripping the attribute's own `_id` suffix
144
- # (`customer_id` `customer`) a no-op for an aliased reference
145
- # that already carries no suffix (`reference_to Account, as:
146
- # :source` `source`), which is why this needs no separate case
147
- # for `as:`.
139
+ # `owner` NARROWED TO `command` ONLY (S12, ADR 0025 — "rules
140
+ # confined to their own aggregate boundary"): dereferencing the
141
+ # DECLARING aggregate/entity's own STORED `reference_to` used to
142
+ # be the other half of this method's job — a live query against
143
+ # another aggregate's own repository, every time a `given`/
144
+ # `ensures`/`invariant` ran. That half is gone; a cross-aggregate
145
+ # fact a rule needs now has to be a `projects`-maintained LOCAL
146
+ # field (`AggregateBuilder#projects_impl`'s own comment), already
147
+ # present in `subject`'s own state, no hydration needed. A
148
+ # reference-typed COMMAND ARGUMENT stays in bounds, though — the
149
+ # ADR's own boundary list names "its command arguments" as
150
+ # readable, and nothing is stored yet for a fresh argument to
151
+ # project from; resolving it once here, synchronous with THIS
152
+ # command's own admission, is a different shape from a live query
153
+ # against an ALREADY-PERSISTED reference. `enforce_givens`/
154
+ # `enforce_ensures` are this method's only two remaining callers,
155
+ # both passing `command`/`args`, never a `subject`'s own
156
+ # aggregate — verified before this comment was written, not
157
+ # assumed.
148
158
  #
149
- # RECURSES into what it finds, so a chain like
150
- # `account.customer.status` resolves in one pass: hydrating
151
- # `account` also hydrates ITS OWN `customer_id` into a nested
152
- # `customer` key. Depth-bounded rather than cycle-detected — nothing
153
- # in this corpus dots more than two hops, and a bound is simpler
154
- # than tracking visited (type, id) pairs for a cycle nothing here
155
- # declares.
159
+ # RECURSES into what it finds, so a chain deeper than one hop
160
+ # still resolves in one pass. Depth-bounded rather than cycle-
161
+ # detected nothing in this corpus dots more than two hops on a
162
+ # fresh argument, and a bound is simpler than tracking visited
163
+ # (type, id) pairs for a cycle nothing here declares.
156
164
  DEREFERENCE_DEPTH = 4
157
165
  private_constant :DEREFERENCE_DEPTH
158
166