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
@@ -1,14 +1,14 @@
1
1
  require "json"
2
2
 
3
- require_relative "sql_query_builder"
3
+ require_relative "../../../../adapters/driven/sql_query_builder"
4
4
  require_relative "postgres_era/lineage"
5
5
  require_relative "postgres_era/lineage_manager"
6
- require_relative "../../ports/persistence/append_only"
7
- require_relative "../../query_specification/common/order_by"
8
- require_relative "../../runtime/errors"
9
- require_relative "../../runtime/event"
10
- require_relative "../../runtime/instance"
11
- require_relative "../../runtime/registry"
6
+ require_relative "../../../../ports/persistence/append_only"
7
+ require_relative "../../../../query_specification/common/order_by"
8
+ require_relative "../../../../runtime/errors"
9
+ require_relative "../../../../runtime/event"
10
+ require_relative "../../../../runtime/instance"
11
+ require_relative "../../../../runtime/registry"
12
12
 
13
13
  module Hecks
14
14
  module Adapters
@@ -67,12 +67,30 @@ module Hecks
67
67
  )
68
68
  end
69
69
 
70
+ # BOTH SPELLINGS OF A SETTING ARE HONORED — a world's settings hash
71
+ # may arrive symbol-keyed (built straight in Ruby) or string-keyed
72
+ # (round-tripped through JSON), and a domain that names one is not
73
+ # obligated to also skip the other. `key?` decides which spelling
74
+ # actually exists, never `||` — `||` cannot tell a genuinely stored
75
+ # `false` apart from an absent key, and would silently prefer the
76
+ # OTHER spelling (or `default`) instead of returning the real, held
77
+ # answer. See Hecks::QuerySpecification::FieldPath#read for the same
78
+ # discipline applied to stored state instead of settings.
79
+ def self.setting(settings, key, default: nil)
80
+ return settings[key] if settings.key?(key)
81
+
82
+ str_key = key.to_s
83
+ return settings[str_key] if settings.key?(str_key)
84
+
85
+ default
86
+ end
87
+
70
88
  def self.connect_for(name, settings)
71
89
  # LAZY, ON PURPOSE — same reasoning as Sqlite's own initialize:
72
90
  # a domain that never wires PostgresEra should never need the gem.
73
91
  require "pg"
74
92
 
75
- declared = settings[:database] || settings["database"]
93
+ declared = setting(settings, :database)
76
94
  if declared.to_s.empty?
77
95
  raise Runtime::WiringError,
78
96
  "#{name} binds PostgresEra, which needs a database connection, " \
@@ -94,7 +112,7 @@ module Hecks
94
112
  # TABLE ... SET SCHEMA migrations transparent to the rest of the
95
113
  # adapter. A domain with no `schema` setting keeps Postgres's
96
114
  # own default search_path (public), same as before this existed.
97
- schema = settings[:schema] || settings["schema"]
115
+ schema = setting(settings, :schema)
98
116
  if schema.to_s != ""
99
117
  # THE SCHEMA ITSELF, IDEMPOTENTLY — a domain naming a `schema:`
100
118
  # nobody has created yet used to fail on its FIRST table-
@@ -132,14 +150,29 @@ module Hecks
132
150
  # The domain names the journal (one journal per lineage). The
133
151
  # factory injects it; a directly-instantiated adapter (specs,
134
152
  # consoles) journals under the aggregate's own name.
135
- @domain = (settings[:domain] || settings["domain"] || aggregate.storage_name).to_s
153
+ @domain = self.class.setting(settings, :domain, default: aggregate.storage_name).to_s
136
154
  @lineage = Lineage.new(@db, @domain)
137
155
  @lineage.ensure_base!
138
156
  # The era gate resolves which era this boot IS (an old checkout
139
157
  # boots a held-but-superseded era and keeps writing its own
140
158
  # partition); a directly-instantiated adapter defaults to the
141
159
  # newest.
142
- @era = settings[:era] || settings["era"] || @lineage.current_era
160
+ #
161
+ # NOT `self.class.setting(...)` here — RepositoryFactory#build
162
+ # always merges `era: registry.resolved_eras[domain]` into
163
+ # settings, so the key is genuinely PRESENT (not absent) for
164
+ # any domain the era boot gate hasn't resolved yet (or that
165
+ # doesn't have one at all), just holding `nil`. `setting`'s own
166
+ # presence-over-truthiness discipline (correct for a field like
167
+ # `:role`, where a stored `false` is a real, distinct answer
168
+ # from "unset") does the wrong thing for `:era` specifically:
169
+ # `nil` can never be a meaningful era override — only a real
170
+ # ordinal or "not resolved, self-resolve" ever apply — so
171
+ # coalescing here, not `setting`'s presence check, is what
172
+ # actually honors this comment's own "defaults to the newest"
173
+ # promise.
174
+ @era = settings.key?(:era) ? settings[:era] : settings["era"]
175
+ @era ||= @lineage.current_era
143
176
  # Unconditional and idempotent, regardless of era — belt-and-
144
177
  # suspenders self-healing (compile_head! already ensures this for
145
178
  # a freshly-minted era's own name; ensure_first_head! for era 1's)
@@ -309,8 +342,31 @@ module Hecks
309
342
  end
310
343
  end
311
344
 
345
+ # The journal carries FORCE ROW LEVEL SECURITY with exactly two
346
+ # policies — hecks_current_era's INSERT and hecks_read_all's
347
+ # SELECT (advance_era! above) — and no DELETE policy at all, for
348
+ # anyone. FORCE means even the table's own owner is fenced by
349
+ # that (only an actual Postgres superuser or a role granted
350
+ # BYPASSRLS sits above it — see lineage.rb's own header), so a
351
+ # plain `DELETE ... WHERE aggregate = $1` from an ordinary
352
+ # connection silently matches zero rows: no privilege error, no
353
+ # exception, just a no-op that looks like success. Counting
354
+ # before and comparing to what the DELETE itself reports is what
355
+ # tells "nothing to delete" apart from "RLS silently ate the
356
+ # delete" — the same row count, from the same statement, either
357
+ # way, with no separate query racing the DELETE for an answer.
312
358
  def reset!
313
- @db.exec_params("DELETE FROM #{@lineage.quoted_journal} WHERE aggregate = $1", [table])
359
+ before = @db.exec_params(
360
+ "SELECT count(*) FROM #{@lineage.quoted_journal} WHERE aggregate = $1", [table]
361
+ )[0]["count"].to_i
362
+ result = @db.exec_params("DELETE FROM #{@lineage.quoted_journal} WHERE aggregate = $1", [table])
363
+ if before.positive? && result.cmd_tuples.zero?
364
+ raise Runtime::WiringError,
365
+ "reset! deleted 0 of #{before} row(s) for #{table} in #{@lineage.quoted_journal} — " \
366
+ "FORCE ROW LEVEL SECURITY admits no DELETE policy on the journal, so this connection's " \
367
+ "DELETE silently matched nothing. reset! only works connected as an actual Postgres " \
368
+ "superuser or a role granted BYPASSRLS, not as the provisioner or an app role."
369
+ end
314
370
  self
315
371
  end
316
372
 
@@ -355,13 +411,14 @@ module Hecks
355
411
  # `SagaInterpreter`'s own mutex (§7) serializing IN-PROCESS writers,
356
412
  # and gets the SAME cross-process safety an aggregate's own writes
357
413
  # get from this adapter — no better, no worse.
358
- def save_saga(process_manager:, correlation:, state:, memory:)
414
+ def save_saga(process_manager:, correlation:, state:, memory:, completed_compensations: [])
359
415
  @db.exec_params(
360
- "INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory) " \
361
- "VALUES ($1, $2, $3, $4, $5) " \
416
+ "INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory, completed_compensations) " \
417
+ "VALUES ($1, $2, $3, $4, $5, $6) " \
362
418
  "ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \
363
- "SET state = EXCLUDED.state, memory = EXCLUDED.memory, updated_at = now()",
364
- [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory)]
419
+ "SET state = EXCLUDED.state, memory = EXCLUDED.memory, " \
420
+ "completed_compensations = EXCLUDED.completed_compensations, updated_at = now()",
421
+ [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory), JSON.generate(completed_compensations)]
365
422
  )
366
423
  end
367
424
 
@@ -376,11 +433,12 @@ module Hecks
376
433
  return enum_for(:each_saga) unless block_given?
377
434
 
378
435
  @db.exec_params(
379
- "SELECT process_manager, correlation, state, memory FROM hecks_saga_instances WHERE domain = $1",
436
+ "SELECT process_manager, correlation, state, memory, completed_compensations FROM hecks_saga_instances WHERE domain = $1",
380
437
  [@domain]
381
438
  ).each do |row|
382
439
  yield row["process_manager"], row["correlation"], row["state"],
383
- JSON.parse(row["memory"], symbolize_names: true)
440
+ JSON.parse(row["memory"], symbolize_names: true),
441
+ JSON.parse(row["completed_compensations"] || "[]", symbolize_names: true)
384
442
  end
385
443
  end
386
444
 
@@ -404,9 +462,9 @@ module Hecks
404
462
 
405
463
  if entry.save?
406
464
  @db.exec_params(
407
- "INSERT INTO #{quoted_head_snapshot} (id, ordinal, state) VALUES ($1, $2, $3) " \
408
- "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, state = EXCLUDED.state " \
409
- "WHERE #{quoted_head_snapshot}.ordinal < EXCLUDED.ordinal",
465
+ "INSERT INTO #{quoted_head_snapshot} (id, ordinal, operation, state) VALUES ($1, $2, 'save', $3) " \
466
+ "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, operation = EXCLUDED.operation, " \
467
+ "state = EXCLUDED.state WHERE #{quoted_head_snapshot}.ordinal < EXCLUDED.ordinal",
410
468
  [entry.id, ordinal, state_json]
411
469
  )
412
470
  # SAME TRANSACTION, SAME ORDINAL — every field cache stays
@@ -419,7 +477,30 @@ module Hecks
419
477
  @lineage.upsert_field_cache_row!(cache_table, entry.id, ordinal, state_json, query_expression(field))
420
478
  end
421
479
  else
422
- @db.exec_params("DELETE FROM #{quoted_head_snapshot} WHERE id = $1", [entry.id])
480
+ # A TOMBSTONE ROW, NOT A BARE DELETE — H3 (docs/audits/2026-08-
481
+ # 10-main-bug-audit.md). `DELETE FROM head_snapshot` used to be
482
+ # the whole story here, which is correct in isolation but wrong
483
+ # once an ancestor era is in the picture: for a record carried
484
+ # into this era from an ancestor, removing this era's row left
485
+ # NOTHING on the current-era side of `compile_head!`'s union to
486
+ # outrank the ancestor matview's own (still-present, still
487
+ # `save`) row, so `DISTINCT ON` picked the ancestor's row and
488
+ # the "deleted" record kept reading back forever. Upserting a
489
+ # tombstone (`operation = 'delete'`, `state` NULL) instead
490
+ # means this era always has ITS OWN newest-ordinal row for the
491
+ # id, exactly like a real re-save already did ("re-saves are
492
+ # masked correctly" — the audit's own phrasing for why that
493
+ # half of this was never broken) — it just carries `operation
494
+ # = 'delete'` instead of `'save'`, so `head_view`'s own `WHERE
495
+ # operation = 'save'` still correctly hides it. Ordinal-guarded
496
+ # the same as every other upsert here, so an out-of-order
497
+ # replay can never let a stale delete clobber a newer save.
498
+ @db.exec_params(
499
+ "INSERT INTO #{quoted_head_snapshot} (id, ordinal, operation, state) VALUES ($1, $2, 'delete', NULL) " \
500
+ "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, operation = EXCLUDED.operation, " \
501
+ "state = EXCLUDED.state WHERE #{quoted_head_snapshot}.ordinal < EXCLUDED.ordinal",
502
+ [entry.id, ordinal]
503
+ )
423
504
  @field_caches.each_value { |cache_table| @lineage.delete_field_cache_row!(cache_table, entry.id) }
424
505
  end
425
506
 
@@ -656,15 +737,21 @@ module Hecks
656
737
  def create_saga_table!
657
738
  @db.exec(<<~SQL)
658
739
  CREATE TABLE IF NOT EXISTS hecks_saga_instances (
659
- domain text NOT NULL,
660
- process_manager text NOT NULL,
661
- correlation text NOT NULL,
662
- state text NOT NULL,
663
- memory jsonb NOT NULL,
664
- updated_at timestamptz NOT NULL DEFAULT now(),
740
+ domain text NOT NULL,
741
+ process_manager text NOT NULL,
742
+ correlation text NOT NULL,
743
+ state text NOT NULL,
744
+ memory jsonb NOT NULL,
745
+ completed_compensations jsonb NOT NULL DEFAULT '[]'::jsonb,
746
+ updated_at timestamptz NOT NULL DEFAULT now(),
665
747
  PRIMARY KEY (domain, process_manager, correlation)
666
748
  )
667
749
  SQL
750
+ # `CREATE TABLE IF NOT EXISTS` above is a no-op against a table
751
+ # this same domain already created before this column existed —
752
+ # the same idiom `rust/host/src/journal.rs`'s own
753
+ # `sagas_backfilled` column addition already uses.
754
+ @db.exec("ALTER TABLE hecks_saga_instances ADD COLUMN IF NOT EXISTS completed_compensations jsonb NOT NULL DEFAULT '[]'::jsonb")
668
755
  end
669
756
  end
670
757
  end
@@ -1,5 +1,5 @@
1
1
  require "digest"
2
- require_relative "../../projector/exporter"
2
+ require_relative "../../../../../../projector/exporter"
3
3
 
4
4
  module Hecks
5
5
  module Translation
@@ -1,6 +1,7 @@
1
- require_relative "../../runtime/errors"
2
- require_relative "../../runtime/instance"
3
- require_relative "../../runtime/value/invariant_violation"
1
+ require_relative "../../../../../../runtime/errors"
2
+ require_relative "../../../../../../runtime/instance"
3
+ require_relative "../../../../../../runtime/value/invariant_violation"
4
+ require_relative "../../../../../../bluebook/model_check"
4
5
 
5
6
  module Hecks
6
7
  module Translation
@@ -21,8 +22,16 @@ module Hecks
21
22
  next unless lifecycle
22
23
 
23
24
  held = instance[lifecycle.field]
24
- allowed = lifecycle.states | [lifecycle.default]
25
- unless held.nil? || allowed.include?(held)
25
+ # `Lifecycle#states` answers default+targets only — a state
26
+ # legitimately declared just as a `from:` (a terminal
27
+ # transition's source, never anyone's target) is real and
28
+ # reachable but invisible to it. `ModelCheck.full_states`
29
+ # is the full declared set (default, every target, AND
30
+ # every from) that `fuzzing/properties.rb`'s own replay
31
+ # check already uses for this identical question — see its
32
+ # comment on this same hole.
33
+ allowed = Bluebook::ModelCheck.full_states(lifecycle)
34
+ unless held.nil? || allowed.include?(held.to_s)
26
35
  violations << "#{aggregate.name}##{id}: #{lifecycle.field} is #{held.inspect}, " \
27
36
  "a state this era's lifecycle never reaches"
28
37
  end
@@ -1,5 +1,5 @@
1
- require_relative "../../ports/persistence/append_only"
2
- require_relative "../../ports/persistence/lineage"
1
+ require_relative "../../../../../../ports/persistence/append_only"
2
+ require_relative "../../lineage"
3
3
 
4
4
  module Hecks
5
5
  module Translation
@@ -53,15 +53,14 @@ module Hecks
53
53
  return if rekeyed
54
54
 
55
55
  rules = Ports::Persistence::Lineage.from_declared(declared, aggregate.name)
56
- compute_tops = declared.computes.flat_map { |compute| [compute.from, compute.to] }
57
- .map { |path| path.to_s.split(".").first }
56
+ compute_paths = declared.computes.flat_map { |compute| [compute.from, compute.to] }.map(&:to_s)
58
57
 
59
58
  before.each do |id, state|
60
59
  next unless after.key?(id)
61
60
 
62
61
  entry = Ports::Persistence::Entry.new(operation: "save", id: id, state: state.transform_keys(&:to_sym))
63
- expected = normalize(rules.translate(entry).state).reject { |key, _| compute_tops.include?(key) }
64
- actual = normalize(after[id]).reject { |key, _| compute_tops.include?(key) }
62
+ expected = strip_compute_paths(normalize(rules.translate(entry).state), compute_paths)
63
+ actual = strip_compute_paths(normalize(after[id]), compute_paths)
65
64
  next if expected == actual
66
65
 
67
66
  diverged = (expected.keys | actual.keys).select { |key| expected[key] != actual[key] }
@@ -71,6 +70,32 @@ module Hecks
71
70
  end
72
71
 
73
72
  def normalize(state) = JSON.parse(JSON.generate(state))
73
+
74
+ # Exempts exactly the paths a compute owns, not the whole
75
+ # top-level attribute it happens to live under. A bare path
76
+ # ("price_cents") is itself the compute's entire value — dropping
77
+ # the whole top-level key is correct, there's nothing else there
78
+ # to check. A DOTTED path ("price.cents") only owns that one
79
+ # member of the value object it reaches into; every sibling
80
+ # member (e.g. "price.currency") is untouched by the compute and
81
+ # must stay subject to the equivalence check below. Blanket-
82
+ # dropping the whole top-level key for a dotted compute used to
83
+ # exempt the entire attribute — silent data loss elsewhere in the
84
+ # same value object (a migration that nulls or drops a sibling
85
+ # field) produced zero violations, defeating the one gate whose
86
+ # entire purpose is to catch exactly that.
87
+ def strip_compute_paths(state, paths)
88
+ paths.each do |path|
89
+ segments = path.split(".")
90
+ if segments.length == 1
91
+ state.delete(segments.first)
92
+ else
93
+ parent = segments[0..-2].reduce(state) { |node, segment| node.is_a?(Hash) ? node[segment] : nil }
94
+ parent.delete(segments.last) if parent.is_a?(Hash)
95
+ end
96
+ end
97
+ state
98
+ end
74
99
  end
75
100
  end
76
101
  end
@@ -33,7 +33,11 @@ module Hecks
33
33
  path.to_s.split(".").reduce(state) do |node, segment|
34
34
  break nil unless node.is_a?(Hash)
35
35
 
36
- node[segment] || node[segment.to_sym]
36
+ # `key?` decides which spelling answers, never `||` — a
37
+ # `false` genuinely held at this path must not fall through
38
+ # to the other spelling (usually absent) and read as `nil`,
39
+ # which would wrongly mark a `false`-valued attribute "unfed".
40
+ node.key?(segment) ? node[segment] : node[segment.to_sym]
37
41
  end
38
42
  end
39
43
  end
@@ -1,7 +1,7 @@
1
1
  require "tempfile"
2
- require_relative "../runtime/era_guard"
3
- require_relative "../runtime/registry"
4
- require_relative "../runtime/storage_shape"
2
+ require_relative "../era_guard"
3
+ require_relative "../../../../../runtime/registry"
4
+ require_relative "../storage_shape"
5
5
 
6
6
  module Hecks
7
7
  module Translation
@@ -1,4 +1,4 @@
1
- require_relative "../../runtime/storage_shape"
1
+ require_relative "../../storage_shape"
2
2
 
3
3
  module Hecks
4
4
  module Translation
@@ -0,0 +1,48 @@
1
+ require_relative "../plugin"
2
+ require_relative "era/era_check"
3
+ require_relative "era/era_guard"
4
+ require_relative "era/era_tamper"
5
+ require_relative "era/storage_shape"
6
+ require_relative "era/lineage"
7
+ require_relative "era/postgres_era"
8
+ require_relative "era/translation"
9
+
10
+ module Hecks
11
+ module Ports
12
+ module Persistence
13
+ module Plugins
14
+ # ADR 0033 — requiring THIS FILE is installing the plugin. Nothing in
15
+ # Hecks core requires it; an app that binds `PostgresEra`, or wants
16
+ # schema-translation support at all, requires it explicitly — the
17
+ # same shape every adapter-specific spec fixture already uses to
18
+ # load one particular `.adapter` file rather than all of them.
19
+ module Era
20
+ module_function
21
+
22
+ # `Runtime::Loader.run_boot_gates!` asks every loaded persistence
23
+ # plugin to contribute here, generically — it never mentions
24
+ # `EraCheck` or "era" by name. Two gates, both `:pre_verify`:
25
+ #
26
+ # `:era_compute_rules` — unconditional, whenever this plugin is
27
+ # loaded at all. A `compute`/`rekey` rule requires Postgres
28
+ # whatever adapter is actually bound (ADR 0031's own reasoning,
29
+ # unchanged) — this is the rich, adapter-aware version of that
30
+ # check; `Runtime::Loader`'s own structural backstop (plain
31
+ # `Bluebook::Translation` data, no plugin-specific class) only
32
+ # ever fires when NO persistence plugin is loaded at all.
33
+ #
34
+ # `:era_check` — conditional, exactly ADR 0031's own gate,
35
+ # unchanged: registered only when this registry has an aggregate
36
+ # actually bound to a lineage-capable adapter.
37
+ def contribute_boot_gates(registry, gates)
38
+ gates.register(:era_compute_rules, ->(reg, _dir) { Runtime::EraCheck.check_compute_rules_for_registry!(reg) }, phase: :pre_verify)
39
+ gates.register(:era_check, Runtime::EraCheck.method(:check_lineage!), phase: :pre_verify) if
40
+ Runtime::EraCheck.lineage_capable_registry?(registry)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ Hecks::Ports::Persistence.register_plugin(:era, Hecks::Ports::Persistence::Plugins::Era)
@@ -9,7 +9,7 @@ module Hecks
9
9
  end
10
10
 
11
11
  require_relative "persistence/binding_policy"
12
- require_relative "persistence/lineage"
12
+ require_relative "persistence/plugin"
13
13
  require_relative "persistence/repository_factory"
14
14
  require_relative "persistence/append_only"
15
15
  require_relative "persistence/execution"
@@ -29,10 +29,30 @@ module Hecks
29
29
  class Worker
30
30
  attr_reader :projection
31
31
 
32
+ # The only two policies anything in this codebase ever passes
33
+ # (`bin/project`, every spec) — there is no third, legitimate
34
+ # "lenient append" policy on record anywhere. Before this, any
35
+ # value OTHER than the exact symbol `:strict` silently fell
36
+ # through the `consistent?` check below and appended onto
37
+ # divergent history without a word — not just a real typo like
38
+ # `:strikt`, but a caller-supplied String `"strict"` too (this
39
+ # duck-typed fine via `policy.to_sym`, but that was luck, not a
40
+ # contract: nothing here declared what a valid policy even was).
41
+ # Refusing loudly at construction, once, for anything outside
42
+ # this list turns a silent no-op into an immediate, named error
43
+ # — the "refuse rather than silently skip" reading of L1, since
44
+ # `:strict` really is meant to be the only enforcing contract.
45
+ VALID_POLICIES = %i[refresh strict].freeze
46
+
32
47
  def initialize(authoritative, projection, policy: :refresh)
33
48
  @authoritative = authoritative
34
49
  @projection = projection
35
50
  @policy = policy.to_sym
51
+ unless VALID_POLICIES.include?(@policy)
52
+ raise ArgumentError,
53
+ "unknown projection catch_up! policy #{@policy.inspect} — expected one of " \
54
+ "#{VALID_POLICIES.map(&:inspect).join(' or ')}"
55
+ end
36
56
  end
37
57
 
38
58
  # Invoke from a separate process or scheduler. The command-side write