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
@@ -177,11 +177,26 @@ module Hecks
177
177
 
178
178
  ordered = ordered_elements(rows, declared.order_by, declared.null_semantics,
179
179
  parent_key, entity.identity_heads)
180
- declared.limit ? ordered.first(resolve_query_value(declared.limit.value, args).to_i) : ordered
180
+ # OFFSET FIRST, THEN LIMIT — same fix, same reasoning, as
181
+ # #interpret's own rows above. `entity_rows` is the ONLY engine
182
+ # for entity/sub-list queries, so a declared offset here silently
183
+ # vanished for every entity query, not merely one path among
184
+ # several.
185
+ skipped = declared.offset ? ordered.drop(resolve_query_value(declared.offset.value, args).to_i) : ordered
186
+ declared.limit ? skipped.first(resolve_query_value(declared.limit.value, args).to_i) : skipped
181
187
  end
182
188
 
189
+ # FieldPath.dig, not a raw `element[clause.field.to_sym]` — an
190
+ # entity sub-list row is a plain hash merged from stored state
191
+ # (mixed string/symbol keys depending on adapter, per `#cell`'s own
192
+ # comment below), and a dotted `where` (`where "price.cents" < 100`)
193
+ # needs the same segment-by-segment walk every other query path
194
+ # already gets. Reading only the symbol spelling of the WHOLE
195
+ # dotted string as one key always missed — `element[:"price.cents"]`
196
+ # is never a real key — so a dotted where on an entity query
197
+ # silently matched nothing, on the only engine entity queries have.
183
198
  def element_where_holds?(clause, element, args)
184
- holds?(clause, element[clause.field.to_sym], args)
199
+ holds?(clause, QuerySpecification::FieldPath.dig(element, clause.field), args)
185
200
  end
186
201
 
187
202
  # A row's own key, however the store spells it. A sub-list row is a plain hash
@@ -191,7 +206,13 @@ module Hecks
191
206
  # to remove. It rides `comparable` for the same reason a where-clause does : an
192
207
  # identity is a value object, and `to_s` on one is an OBJECT ADDRESS — a sort key
193
208
  # that differs run to run, which is worse than the store order it replaced.
194
- def cell(row, key) = row[key.to_sym] || row[key.to_s]
209
+ # `key?` decides which spelling answers, never `||` a row whose
210
+ # value is a genuinely-held `false` must not fall through to the
211
+ # other spelling (usually absent) and land on `nil`.
212
+ def cell(row, key)
213
+ sym = key.to_sym
214
+ row.key?(sym) ? row[sym] : row[key.to_s]
215
+ end
195
216
 
196
217
  # A sub-list row is identified by its PARENT and then its own key : two
197
218
  # entities under different parents can share a sequence, so the parent has
@@ -244,10 +265,21 @@ module Hecks
244
265
 
245
266
  def comparable(value) = QuerySpecification::Common::Comparison.comparable(value)
246
267
 
268
+ # FieldPath.dig, not a raw `record[field]` — `record` is an Instance
269
+ # here, and a dotted order_by (`order_by "price.cents"`) is a
270
+ # single symbol key (`:"price.cents"`) that never matches anything
271
+ # `Instance#[]` actually holds, so a dotted order_by silently sorted
272
+ # by all-nil (the identity tier alone deciding every tie) on this,
273
+ # the reference/no-native-hook engine — the same bug already fixed
274
+ # for `where` (see `where_holds?` above) and for entity rows (see
275
+ # `ordered_elements` below), just not yet for this, the aggregate-
276
+ # level order_by.
247
277
  def ordered(records, order_by, null_semantics = nil)
248
278
  field = order_by&.field
249
279
  Ports::Query::Ordering.apply(records, order_by, null_semantics,
250
- identity: ->(record) { record.id.to_s }) { |record| comparable(record[field]) }
280
+ identity: ->(record) { record.id.to_s }) { |record|
281
+ comparable(QuerySpecification::FieldPath.dig(record, field))
282
+ }
251
283
  end
252
284
  end
253
285
  end
@@ -52,8 +52,16 @@ module Hecks
52
52
  visible = normalized_scopes.find { |scope| scope.facts.key?(source) }
53
53
  unless visible
54
54
  names = normalized_scopes.map(&:name).join(" then ")
55
+ # WHAT IS VISIBLE, NAMED. A refusal that only says which name is
56
+ # missing sent a modeler guessing field after field
57
+ # (`number`, `reference`…) at a fan-out row that is addressed
58
+ # by ONE key — `account`, the lowercase aggregate — which
59
+ # nothing else in the domain spells out. The names each scope
60
+ # actually offers are the whole diagnosis; the refusal now
61
+ # lists them, scope by scope.
62
+ offered = normalized_scopes.map { |scope| "#{scope.name}: #{scope.facts.keys.sort.join(', ')}" }.join("; ")
55
63
  raise UnknownArgument,
56
- "#{label}'s with: reads :#{source}, which is not visible in #{names}"
64
+ "#{label}'s with: reads :#{source}, which is not visible in #{names} (visible — #{offered})"
57
65
  end
58
66
  visible.facts.fetch(source)
59
67
  end
@@ -68,15 +68,24 @@ module Hecks
68
68
  root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
69
69
  projected = []
70
70
  rows_by_as = {}
71
- (root_heads + other_heads).each do |head|
71
+ (root_heads + order_other_heads(bluebook, root_heads, other_heads)).each do |head|
72
72
  rows = if head[:aggregate] == model.reference_target
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|
@@ -112,6 +121,81 @@ module Hecks
112
121
  end]
113
122
  end
114
123
 
124
+ # THE ROOT-FIRST FIX'S OWN FIX — root-first alone only reaches one
125
+ # level: it guarantees the root is in `projected` before any other
126
+ # head is matched, but a CHAIN of non-root heads (a head that
127
+ # references another non-root head, not the root) is still
128
+ # matched against whatever declaration order happened to put in
129
+ # `projected` so far. `include Coupon` before `include Promotion`
130
+ # on a read model rooted at Item, where Coupon references
131
+ # Promotion (which references Item), silently returned an empty
132
+ # `coupons` array — Coupon's match ran while `projected` held only
133
+ # Item, one level short of what it needed.
134
+ #
135
+ # Fixed the same way root-first was: not by asking bluebook authors
136
+ # to declare `include` in dependency order (the same promise
137
+ # `read_model_builder.rb` already makes and this file is the one
138
+ # place obligated to keep), but by topologically sorting the
139
+ # non-root heads on their OWN declared reference fields before
140
+ # this method's runtime matching ever runs — Kahn's algorithm,
141
+ # picking ready heads in DECLARED order at each step so declaring
142
+ # order still governs whenever there is no dependency to break a
143
+ # tie. This generalizes root-first (a chain of length 1) to a
144
+ # chain of any depth, and to a head depending on more than one
145
+ # other head at once (not just a straight chain).
146
+ #
147
+ # A cycle among non-root heads (A references B which references A)
148
+ # has no valid topological order at all — falls back to the
149
+ # remaining heads' declared order rather than looping forever, the
150
+ # same "whichever runs first finds nothing" behaviour this method
151
+ # had for every non-root head before root-first existed.
152
+ def order_other_heads(bluebook, root_heads, other_heads)
153
+ resolved = root_heads.map { |head| head[:aggregate] }
154
+ remaining = other_heads.dup
155
+ ordered = []
156
+ until remaining.empty?
157
+ ready, blocked = remaining.partition do |head|
158
+ depends_on(bluebook, head, other_heads).all? { |target| resolved.include?(target) }
159
+ end
160
+ if ready.empty?
161
+ ordered.concat(remaining)
162
+ break
163
+ end
164
+ ordered.concat(ready)
165
+ resolved.concat(ready.map { |head| head[:aggregate] })
166
+ remaining = blocked
167
+ end
168
+ ordered
169
+ end
170
+
171
+ # Which OTHER declared (non-root) heads a head's own aggregate
172
+ # holds a reference field toward — the same relationship this
173
+ # file's runtime matching checks record-by-record, asked here
174
+ # statically, once, to order heads before any record is read.
175
+ #
176
+ # `head[:aggregate]` names whatever `include` was given — and
177
+ # `include` accepts a nested ENTITY (Member, nested under
178
+ # ValueObject ; Handler and Dispatch, nested under ProcessManager
179
+ # — bluebook.bluebook's own `WholeBluebook` read model includes
180
+ # all three) just as readily as a top-level aggregate.
181
+ # `bluebook.aggregate` only ever finds the latter
182
+ # (Behaviour::Chapter#aggregate searches `@aggregates`, which
183
+ # holds no entities), so it returns nil for an entity-headed
184
+ # include — a real case, not a malformed one. `records`, below,
185
+ # already treats that nil as "no rows of its own to fetch" ;
186
+ # a head with no rows of its own has nothing to check for a
187
+ # reference field either, so it depends on nothing here, the
188
+ # same as it always silently read empty before this file's
189
+ # topological sort existed.
190
+ def depends_on(bluebook, head, other_heads)
191
+ aggregate = bluebook.aggregate(head[:aggregate])
192
+ return [] unless aggregate
193
+
194
+ other_heads.reject { |other| other[:aggregate] == head[:aggregate] }
195
+ .select { |other| reference_fields(aggregate, other[:aggregate]).any? }
196
+ .map { |other| other[:aggregate] }
197
+ end
198
+
115
199
  # `group_by`'s own declared fields, checked against the ONE
116
200
  # many-side head they apply to (`seal_group_by` already refuses
117
201
  # zero or several) — resolved here, once, rather than re-derived
@@ -70,6 +70,8 @@ module Hecks
70
70
  "{type} is a composite identity — an identity must have exactly one field",
71
71
  %w[TypeMismatch numeric_field] =>
72
72
  "{type}.{field} expects {expected}, got {offered}",
73
+ %w[TypeMismatch non_finite_field] =>
74
+ "{type}.{field} must be a finite number, got {offered}",
73
75
  %w[TypeMismatch pattern_mismatch] =>
74
76
  "{type}.{field} must match {pattern}, got {offered}",
75
77
  %w[TypeMismatch arithmetic_amount] =>
@@ -1,3 +1,5 @@
1
+ require_relative "../saga_pending_dispatch"
2
+
1
3
  module Hecks
2
4
  module Runtime
3
5
  class Registry
@@ -20,8 +22,27 @@ module Hecks
20
22
  # shares one adapter), and is the honest, documented fallback
21
23
  # for the rarer domain genuinely split across more than one
22
24
  # local adapter with no default declared.
25
+ #
26
+ # GENUINELY LAZY, GENUINELY POST-BOOT — unlike `rehydrate_sagas!`
27
+ # below (boot-only), this is called from live dispatch
28
+ # (`SagaInterpreter#checkpoint`/`#end_saga`, on every saga
29
+ # transition), so the FIRST call for a given domain can come from
30
+ # any dispatching thread, not just the boot thread. `@saga_persistence`
31
+ # itself is a plain Hash stood up once in `Registry#initialize` (no
32
+ # race on the container), but `resolve_saga_persistence` is real work
33
+ # (a `BindingPolicy.resolve` plus a lazy `repository` build) whose
34
+ # RESULT — the actual adapter instance a domain's sagas persist
35
+ # through — must be the SAME object for every caller: two threads
36
+ # racing the first lookup and each building their own adapter would
37
+ # silently split one domain's saga writes across two adapter
38
+ # instances (worse than `Dispatcher#reaction_depth`'s M20 — that bug
39
+ # corrupted a counter; this one can corrupt WHICH STORE a saga's
40
+ # state lands in). Double-checked locking against a DEDICATED mutex
41
+ # — never `@saga_mutex` — see `Registry#initialize`'s own comment for
42
+ # why reusing that one would deadlock.
23
43
  def saga_persistence(domain)
24
- (@saga_persistence ||= {})[domain.to_s] ||= resolve_saga_persistence(domain.to_s)
44
+ key = domain.to_s
45
+ @saga_persistence[key] || @saga_persistence_mutex.synchronize { @saga_persistence[key] ||= resolve_saga_persistence(key) }
25
46
  end
26
47
 
27
48
  # WALKS EVERY LOADED DOMAIN, repopulating `saga_instances` from
@@ -34,10 +55,23 @@ module Hecks
34
55
  # boot (`Loader.boot`, between `verify!` and dispatcher
35
56
  # construction) — a process that's been running has no reason to
36
57
  # re-walk its own already-current `saga_instances`.
58
+ #
59
+ # BOOT-TIME-ONLY (OR ITS TEST-RUNNER EQUIVALENT) — this method's
60
+ # only callers are `Loader.run_boot_gates!` (single-threaded, before
61
+ # `dispatcher_for` ever exists) and `Registry#reset_runtime_state!`
62
+ # (single-threaded test runner — see that method's own comment).
63
+ # Never called from live dispatch, so `@saga_instances` mutation
64
+ # here has no concurrent caller to race, unlike its OTHER two write
65
+ # points inside `@saga_mutex.synchronize` blocks (`saga_interpreter.
66
+ # rb`), which genuinely do and are guarded accordingly.
67
+ # rubocop:disable-next Hecks/ThreadSharedIvarMutation
37
68
  def rehydrate_sagas!
38
69
  @hecksagons.each_key do |domain|
39
- saga_persistence(domain).each_saga do |process_manager, correlation, state, memory|
40
- @saga_instances[process_manager][correlation] = { state: state, memory: memory }
70
+ saga_persistence(domain).each_saga do |process_manager, correlation, state, memory, completed_compensations = []|
71
+ pending = memory.delete(SAGA_PENDING_DISPATCH_KEY)
72
+ @saga_instances[process_manager][correlation] =
73
+ { state: state, memory: memory, completed_compensations: completed_compensations || [] }
74
+ warn_stalled_saga(domain, process_manager, correlation, state, pending) if pending
41
75
  end
42
76
  end
43
77
  self
@@ -45,6 +79,44 @@ module Hecks
45
79
 
46
80
  private
47
81
 
82
+ # THE OTHER HALF OF THE OUTBOX-SHAPED FIX (see saga_pending_
83
+ # dispatch.rb) — `SAGA_PENDING_DISPATCH_KEY`, if the crashed
84
+ # process left it standing, means the row's own `state` was
85
+ # checkpointed but the dispatch cascade that justifies it may
86
+ # never have run. Stripped out of `memory` before it becomes
87
+ # this instance's LIVE `:memory` (so nothing downstream — a
88
+ # `given`, a `with:` mapping, the fuzzer — ever sees it), and
89
+ # surfaced loudly instead: a WARNING plus a `saga_log` entry,
90
+ # never an automatic redrive (see saga_pending_dispatch.rb for
91
+ # why redriving without idempotent delivery would be worse than
92
+ # the stall). This is real, durable crash-recovery VISIBILITY —
93
+ # still not the reconciliation itself, which stays a human's
94
+ # call until hecks has idempotent redelivery to make it safe.
95
+ # rubocop:disable-next Hecks/ThreadSharedIvarMutation -- same
96
+ # justification as `rehydrate_sagas!`'s own disable comment
97
+ # above: this method's ONLY caller is that boot-time-only walk,
98
+ # never live dispatch, so `@saga_log` has no concurrent writer
99
+ # to race here.
100
+ def warn_stalled_saga(domain, process_manager, correlation, state, pending)
101
+ # `.transform_keys(&:to_sym)` — Heki's own `each_saga` only
102
+ # symbolizes `memory`'s TOP-level keys (`SagaStore#each_saga`'s
103
+ # own `transform_keys`, one level deep); a value NESTED under
104
+ # one of those keys, like this marker, comes back with plain
105
+ # string keys from Heki specifically, symbol keys already from
106
+ # Postgres/SQLite/D1's own `symbolize_names: true` parse. Normalizing
107
+ # here, once, is simpler than teaching every adapter's own
108
+ # shallow/deep parsing convention about this one reserved key.
109
+ pending = pending.transform_keys(&:to_sym)
110
+ dispatches = Array(pending[:dispatches]).join(", ")
111
+ warn "[hecks] #{domain} rehydrated #{process_manager} instance #{correlation.inspect} in state " \
112
+ "#{state.inspect} with a dispatch left pending from before the last crash/restart — " \
113
+ "#{dispatches} (on #{pending[:on].inspect}, #{pending[:from].inspect} -> #{pending[:to].inspect}) " \
114
+ "may or may not have actually run. hecks does not auto-redrive a pending saga dispatch (that " \
115
+ "needs idempotent delivery, which this pipeline doesn't have yet); reconcile this instance by hand."
116
+ @saga_log << { process_manager: process_manager, instance: correlation, rehydrated_stalled: true,
117
+ state: state, pending: pending }
118
+ end
119
+
48
120
  def resolve_saga_persistence(domain)
49
121
  anchor = hecksagon(domain) && bluebook(domain)&.aggregates&.first
50
122
  return Ports::Persistence::NULL_SAGA_STORE unless anchor
@@ -12,6 +12,7 @@ module Hecks
12
12
  module Verification
13
13
  def verify!
14
14
  verify_default_adapter!
15
+ verify_singleton_port_answers!
15
16
 
16
17
  @hecksagons.each_value do |hexagon|
17
18
  refuse_ungoverned_roles!(hexagon)
@@ -42,6 +43,8 @@ module Hecks
42
43
 
43
44
  repository(hexagon.domain, aggregate)
44
45
  end
46
+
47
+ warn_undurable_sagas!(hexagon)
45
48
  end
46
49
  self
47
50
  end
@@ -66,6 +69,7 @@ module Hecks
66
69
 
67
70
  def check_verb(bind)
68
71
  port = port_for(bind)
72
+ check_answers(port, bind.adapter)
69
73
  return if port.verb.to_s == bind.verb.to_s
70
74
 
71
75
  raise WiringError,
@@ -73,6 +77,55 @@ module Hecks
73
77
  "and cannot satisfy #{bind.verb}"
74
78
  end
75
79
 
80
+ # THE METHOD CONTRACT A `.port` FILE'S `verb`/`signal` NEVER
81
+ # CARRIED — an adapter can name the right port, satisfy the right
82
+ # verb, and admit every `.world` setting `check_settings` checks,
83
+ # and still be missing the one method a live dispatch will
84
+ # actually call. `answers` is optional per port (an empty list is
85
+ # today's pre-existing behavior, unchecked), so this only ever
86
+ # tightens a port that opted in.
87
+ def check_answers(port, adapter_name)
88
+ answers = Array(port.answers)
89
+ return if answers.empty?
90
+
91
+ klass = adapter_class(adapter_name)
92
+ missing = answers.reject { |method_name| klass.respond_to?(method_name) }
93
+ return if missing.empty?
94
+
95
+ raise WiringError,
96
+ "#{adapter_name} declares the #{port.name} port but does not respond to " \
97
+ "#{missing.map(&:inspect).join(', ')} — #{port.name}.port declares answers " \
98
+ "#{answers.map(&:inspect).join(', ')}"
99
+ end
100
+
101
+ # THE NINE SINGLETON PORTS' OWN GAP — `persistence`, `projection`
102
+ # and `loading` are per-aggregate bindings, checked above through
103
+ # every real `bind` a hexagon declares; a singleton port
104
+ # (`clock`, `authorization`, …) is never bound to an aggregate at
105
+ # all, so nothing above ever resolves one and nothing above ever
106
+ # ran `check_answers` against it. Each one's own `Ports::*.adapter`
107
+ # already refuses zero or multiple implementations, live, at
108
+ # first dispatch — that stays exactly as-is here (0 or 2+ is
109
+ # ambiguity, not a method-contract question, and asserting every
110
+ # declared port MUST have exactly one adapter would wrongly
111
+ # refuse a boot that simply never wires a port it doesn't use).
112
+ # This only ever tightens the ONE case those checks don't cover:
113
+ # exactly one adapter, wired, missing a method `answers` names.
114
+ PER_AGGREGATE_PORTS = %w[persistence projection loading].freeze
115
+
116
+ def verify_singleton_port_answers!
117
+ @ports.each_value do |port|
118
+ next if PER_AGGREGATE_PORTS.include?(port.name)
119
+ next if Array(port.answers).empty?
120
+
121
+ implementations = @adapters.values.select { |a| a.port == port.name }
122
+ next unless implementations.size == 1
123
+
124
+ check_answers(port, implementations.first.name)
125
+ end
126
+ self
127
+ end
128
+
76
129
  def check_settings(bind, settings)
77
130
  adapter = @adapters[bind.adapter]
78
131
  return unless adapter
@@ -152,6 +205,41 @@ module Hecks
152
205
  def commands_in(bluebook_ir)
153
206
  bluebook_ir.aggregates.flat_map { |aggregate| aggregate.commands + aggregate.entities.flat_map(&:commands) }
154
207
  end
208
+
209
+ # A domain that declares a `process_manager` but whose
210
+ # `saga_persistence` resolves to `NULL_SAGA_STORE` (no anchor
211
+ # aggregate, a RemoteRuntime-shaped adapter, an adapter that
212
+ # doesn't `respond_to?(:save_saga)`, or a rescued WiringError —
213
+ # see `SagaPersistence#resolve_saga_persistence`) gets sagas that
214
+ # advance correctly in-process and vanish on restart: no
215
+ # checkpoint written, nothing for `rehydrate_sagas!` to find, no
216
+ # compensation ever replayed. That is silent right up until the
217
+ # process actually dies mid-saga — the same "consistency/
218
+ # freshness defect applied to access control, failing open" ADR
219
+ # 0025 named for an unchecked `role`, here applied to saga
220
+ # durability instead.
221
+ #
222
+ # A WARNING, NOT A REFUSAL — unlike `refuse_ungoverned_roles!`,
223
+ # running sagas on a store with no `save_saga` is legitimate on
224
+ # purpose in a fast in-memory test/dev boot (this project's own
225
+ # `saga_durability_spec.rb` boots a process manager on `Memory`
226
+ # specifically to exercise the saga_mutex without real I/O), so
227
+ # refusing the boot outright would break a choice an author made
228
+ # deliberately. What a deploy needs is for the gap to be loud and
229
+ # undeniable, not for local dev/test to become impossible.
230
+ def warn_undurable_sagas!(hexagon)
231
+ bluebook_ir = bluebook(hexagon.domain)
232
+ return unless bluebook_ir
233
+ return if bluebook_ir.process_managers.empty?
234
+ return unless saga_persistence(hexagon.domain).equal?(Ports::Persistence::NULL_SAGA_STORE)
235
+
236
+ names = bluebook_ir.process_managers.map(&:name).join(", ")
237
+ warn "[hecks] #{hexagon.domain} declares process_manager(s) #{names} but its resolved " \
238
+ "persistence adapter has no save_saga — saga state advances correctly in-process " \
239
+ "and is LOST on restart (no checkpoint, no rehydration, no compensation replay). " \
240
+ "Bind this domain to an adapter that implements save_saga if this process_manager " \
241
+ "must survive a crash."
242
+ end
155
243
  end
156
244
  end
157
245
  end
@@ -45,9 +45,11 @@ module Hecks
45
45
  # GUARDS `saga_instances`' OWN mutation+checkpoint sequence
46
46
  # (`SagaInterpreter`'s 4 write points, §7) — the same shape of
47
47
  # hazard this codebase's own prior audit already flagged for
48
- # `@reaction_depth`, a thread-shared dispatcher ivar with no
49
- # lock, made meaningfully easier to hit once a persistence write
50
- # sits in the same critical section. Held across the in-memory
48
+ # `Dispatcher#reenter`'s reaction-depth counter (M20: a
49
+ # thread-shared ivar with no lock, since fixed by moving it to
50
+ # `Thread.current`, dispatcher.rb), made meaningfully easier to
51
+ # hit here once a persistence write sits in the same critical
52
+ # section. Held across the in-memory
51
53
  # mutation AND the checkpoint write together, never across a
52
54
  # saga's own dispatch cascade — see `SagaInterpreter#advance_saga`'s
53
55
  # own comment for why that distinction matters (non-reentrant
@@ -56,6 +58,35 @@ module Hecks
56
58
  @repositories = {}
57
59
  @projection_repositories = {}
58
60
  @bluebook_builders = {}
61
+ # EAGER, NOT LAZY — see `#resolved_eras`'s own comment for why. Built
62
+ # here rather than `@resolved_eras ||= {}` on first access so there is
63
+ # no window, post-boot, where two concurrently dispatching threads
64
+ # could race creating this Hash (Hecks/ThreadSharedIvarMutation; the
65
+ # same shape of hazard `Dispatcher#reenter`'s `@reaction_depth` was
66
+ # fixed for). Every WRITE into it still only ever happens at boot,
67
+ # single-threaded (`EraResolver.check!`, a `:pre_verify` boot gate) —
68
+ # this only removes the race on standing up the container itself for
69
+ # a boot with no era-plugin domain at all, whose first touch would
70
+ # otherwise be a live dispatch's own `RepositoryFactory.build` read.
71
+ @resolved_eras = {}
72
+ # EAGER, NOT LAZY — see `#capability_graph`'s own comment for why.
73
+ # `CapabilityGraph.new` only stores the registry reference; there is
74
+ # no reason to defer it, and doing so removes the exact same
75
+ # first-access race `#resolved_eras` above does, while preserving the
76
+ # "same instance every call" identity `spec/runtime/capability_graph_
77
+ # spec.rb` already requires.
78
+ @capability_graph = CapabilityGraph.new(self)
79
+ # `@saga_persistence` itself is eager (see `#saga_persistence`'s own
80
+ # comment) — only the PER-DOMAIN resolution inside it is genuinely
81
+ # expensive and lazy, guarded by this dedicated mutex. NOT the same
82
+ # mutex as `@saga_mutex`: `checkpoint` (saga_interpreter.rb) calls
83
+ # `saga_persistence(domain)` from INSIDE an `@saga_mutex.synchronize`
84
+ # block, so reusing `@saga_mutex` here would deadlock the very first
85
+ # time a saga advanced (a `Mutex` is not reentrant — the exact
86
+ # warning `@saga_mutex`'s own comment already gives for a different
87
+ # reason).
88
+ @saga_persistence = {}
89
+ @saga_persistence_mutex = Mutex.new
59
90
  end
60
91
 
61
92
  # THE BUILDER STAYS OPEN FOR THE LIFE OF THIS REGISTRY, keyed by chapter
@@ -67,6 +98,20 @@ module Hecks
67
98
  @bluebook_builders[name.to_s] ||= yield
68
99
  end
69
100
 
101
+ # BOOT-TIME-ONLY, SINGLE-THREADED — every `add_*` below (through
102
+ # `add_translation`) is called exclusively from `Hecks.collect`
103
+ # (hecks.rb), which is what `Hecks.bluebook`/`.hecksagon`/`.port`/
104
+ # `.adapter`/`.world`/`.translation` run inside while a `.bluebook`/
105
+ # `.hecksagon`/`.world` file is being `Kernel.load`ed — i.e. strictly
106
+ # during `Loader.boot`/`.boot_files`, before `dispatcher_for` ever
107
+ # hands this registry to a live, multi-threaded caller. Nothing
108
+ # downstream of boot ever calls these — verified by grepping every
109
+ # call site in lib/ and spec/ before writing this — so unlike
110
+ # `#resolved_eras`/`#capability_graph`/`#saga_persistence` (each
111
+ # reachable from live dispatch, and fixed for real above/in
112
+ # registry/saga_persistence.rb) there is no concurrent caller for
113
+ # `Hecks/ThreadSharedIvarMutation` to actually be warning about here.
114
+ # rubocop:disable Hecks/ThreadSharedIvarMutation
70
115
  def add_bluebook(item) = @bluebooks[item.name] = item
71
116
 
72
117
  # MERGED, NOT REPLACED — RECOVERED, not new (see Runtime::Loader
@@ -99,12 +144,16 @@ module Hecks
99
144
  end
100
145
 
101
146
  def add_translation(item) = @translations << item
147
+ # rubocop:enable Hecks/ThreadSharedIvarMutation
102
148
 
103
149
  # {domain name => era ordinal} as resolved by the boot-time era
104
150
  # gate. A lineage adapter writes into ITS OWN era's partition —
105
151
  # which, for an old checkout booting a held-but-superseded shape,
106
- # is not the newest one.
107
- def resolved_eras = @resolved_eras ||= {}
152
+ # is not the newest one. The Hash itself is stood up in `initialize`
153
+ # (see that comment) this is a plain reader, not a memoizer;
154
+ # `Hecks/ThreadSharedIvarMutation` is the reason there is no `||=`
155
+ # left here to flag.
156
+ attr_reader :resolved_eras
108
157
 
109
158
  def bluebook(name) = @bluebooks[name.to_s]
110
159
  def hecksagon(name) = @hecksagons[name.to_s]
@@ -133,6 +182,16 @@ module Hecks
133
182
  # test to get isolation (`Behaviors::Expectations.run_one`) — ~2s a
134
183
  # boot, 76 chess behaviours = two and a half minutes of booting the
135
184
  # same two files — can now boot once and reset between tests.
185
+ #
186
+ # SINGLE-THREADED CALLER, THE SAME REASON THE `add_*` CLUSTER ABOVE
187
+ # IS EXEMPT — `Behaviors::Expectations.run_one` is this method's ONLY
188
+ # caller (verified by grep before writing this), and it runs one
189
+ # test at a time: `Runner#run` maps over tests sequentially, and
190
+ # `Behaviors.rspec`'s generated examples run under RSpec's own
191
+ # single-threaded example loop. No production dispatch path calls
192
+ # this at all — a live Puma worker pool never resets a registry out
193
+ # from under itself mid-flight.
194
+ # rubocop:disable-next Hecks/ThreadSharedIvarMutation
136
195
  def reset_runtime_state!
137
196
  @event_log.clear
138
197
  @reaction_log.clear
@@ -146,9 +205,11 @@ module Hecks
146
205
  self
147
206
  end
148
207
 
149
- def capability_graph
150
- @capability_graph ||= CapabilityGraph.new(self)
151
- end
208
+ # Built eagerly in `initialize` (see that comment) — this is a plain
209
+ # reader, not a memoizer; `spec/runtime/capability_graph_spec.rb`
210
+ # asserts the SAME instance comes back every call, which this still
211
+ # gives, just without a lazy `||=` race on standing it up.
212
+ attr_reader :capability_graph
152
213
 
153
214
  def read_repository(domain, aggregate)
154
215
  key = [domain.to_s, aggregate.hecks_name]