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,67 +1,34 @@
1
- require "fileutils"
2
1
  require_relative "era_guard/shape_diff"
3
- require_relative "../bluebook/dsl/malformed"
4
- require_relative "../bluebook/meta_validator"
5
- require_relative "../naming"
6
- require_relative "../ports/loading"
7
- require_relative "../ports/persistence/lineage"
8
- require_relative "era_check"
9
- require_relative "registry"
2
+ require_relative "../../../../bluebook/dsl/malformed"
3
+ require_relative "../../../../bluebook/meta_validator"
4
+ require_relative "../../../../ports/loading"
5
+ require_relative "../../../../runtime/registry"
10
6
 
11
7
  module Hecks
12
8
  module Runtime
13
- # Detects when a bluebook's storage shape has drifted from what its data
14
- # was written under — a rename or restructure with nothing to explain
15
- # it. Refuses at boot, before a command can run into the gap and raise
16
- # something that only makes sense once you already suspect a rename.
9
+ # The shape-drift coverage primitives — ADR 0032. Not a driver: nothing
10
+ # here walks a registry or reads/writes a held snapshot on its own.
11
+ # `PostgresEra::LineageManager::CoverageCheck` calls `uncovered_
12
+ # attributes`/`unsafe_additions`/`refuse_uncovered!`/`refuse_unsafe_
13
+ # addition!`/`check_vanished_aggregates!` directly, per translation
14
+ # edge, over its own DB-held shapes; `Translation::Reattest` and
15
+ # `PostgresEra::LineageManager` call `shadow_parse` directly, to read
16
+ # historical bluebook text under old grammar defaults. Both are real,
17
+ # independent, currently-shipped consumers.
17
18
  #
18
- # The held source is a snapshot of the bluebook text as it stood the
19
- # last time its shape was accepted. It updates only when a drift is
20
- # seen AND covered by a translation — never on every boot, and never
21
- # silently when a rename has nothing to explain it.
19
+ # A prior version of this module ALSO drove its own top-level check —
20
+ # `check!`/`check_bluebook!`, walking a registry and reading/writing a
21
+ # held snapshot under `data/eras/*.bluebook` — duplicating, on its own,
22
+ # the same per-aggregate walk `CoverageCheck` already performs against
23
+ # `PostgresEra`'s own DB-held shapes. Nothing in production ever called
24
+ # it (only a direct unit spec did); deleted rather than kept unwired,
25
+ # per ADR 0032. Wanted again, it's rebuilt informed by `CoverageCheck`'s
26
+ # real orchestration, not resurrected from here.
22
27
  module EraGuard
23
28
  extend ShapeDiff
24
29
 
25
30
  module_function
26
31
 
27
- def check!(registry, directory)
28
- registry.bluebooks.each_value { |bluebook| check_bluebook!(registry, bluebook, directory) }
29
- end
30
-
31
- def check_bluebook!(registry, bluebook, directory)
32
- current_text = EraCheck.source_text_for(bluebook, directory)
33
- return unless current_text
34
-
35
- era_dir = File.join(registry.root, "data", "eras")
36
- held_path = File.join(era_dir, "#{Naming.snake(bluebook.name)}.bluebook")
37
-
38
- FileUtils.mkdir_p(era_dir)
39
- unless File.exist?(held_path)
40
- File.write(held_path, current_text)
41
- return
42
- end
43
-
44
- held_bluebook = shadow_parse(File.read(held_path, encoding: "UTF-8"), held_path)
45
- drifted = false
46
-
47
- bluebook.aggregates.each do |aggregate|
48
- lineage = Ports::Persistence::Lineage.for(registry, bluebook.name, aggregate)
49
- held_aggregate = held_bluebook.aggregate(lineage&.ancestor_name || aggregate.name)
50
- next unless held_aggregate
51
- next if shape(aggregate) == shape(held_aggregate)
52
-
53
- drifted = true
54
- uncovered = uncovered_attributes(aggregate, held_aggregate, lineage)
55
- refuse_uncovered!(bluebook, aggregate, uncovered) unless uncovered.empty?
56
-
57
- unsafe = unsafe_additions(aggregate, held_aggregate, lineage)
58
- refuse_unsafe_addition!(bluebook, aggregate, unsafe) unless unsafe.empty?
59
- end
60
-
61
- check_vanished_aggregates!(registry, bluebook, held_bluebook)
62
- File.write(held_path, current_text) if drifted
63
- end
64
-
65
32
  # An aggregate that existed in the held text and answers to no
66
33
  # current name — renamed silently, with nothing declaring `was:` to
67
34
  # explain where its data went — is exactly the disease this guards
@@ -87,8 +54,9 @@ module Hecks
87
54
  end
88
55
  end
89
56
 
90
- # The Layer-1 coverage refusal — one wording, whoever detects the
91
- # gap (the file-store boot check here, or Postgres's mint path).
57
+ # The Layer-1 coverage refusal — one wording, shared with whoever
58
+ # calls it (today, `PostgresEra::LineageManager::CoverageCheck`'s
59
+ # own mint-time coverage check).
92
60
  def refuse_uncovered!(bluebook, aggregate, uncovered)
93
61
  raise WiringError,
94
62
  "cannot boot #{bluebook.name}::#{aggregate.name}: its shape changed and " \
@@ -1,6 +1,6 @@
1
- require_relative "../../naming"
2
- require_relative "append_only"
3
- require_relative "../../runtime/registry"
1
+ require_relative "../../../../naming"
2
+ require_relative "../../append_only"
3
+ require_relative "../../../../runtime/registry"
4
4
 
5
5
  module Hecks
6
6
  module Ports
@@ -91,7 +91,7 @@ module Hecks
91
91
  # value-object hash, and a shallow dup would quietly mutate
92
92
  # the caller's copy of the original entry.
93
93
  state = deep_dup(entry.state)
94
- @renames.each { |old_name, new_name| state[new_name] = state.delete(old_name) if state.key?(old_name) }
94
+ apply_renames(state, @renames)
95
95
  @moves.each { |move| apply_move(state, move) }
96
96
  @converts.each { |convert| apply_convert(state, convert) }
97
97
  @drops.each { |name| apply_drop(state, name) }
@@ -183,6 +183,33 @@ module Hecks
183
183
  end
184
184
  end
185
185
 
186
+ # M27 (docs/audits/2026-08-10-main-bug-audit.md,
187
+ # docs/audits/2026-08-11-bug-triage.md) — SIMULTANEOUS, not
188
+ # sequential: `state[new] = state.delete(old)` per rename, run
189
+ # one rule at a time against the SAME hash it was reading from,
190
+ # loses data the instant one rule's destination is another
191
+ # rule's source. A swap (`rename :a, to: :b` alongside
192
+ # `rename :b, to: :a`) on `{a: 1, b: 2}` used to produce
193
+ # `{a: 1}` — the first rule wrote `b: 1` over the real `b: 2`
194
+ # before the second rule ever got a chance to read it, and the
195
+ # value the whole edge was supposed to preserve (2, moved to
196
+ # `:a`) was gone. The standard fix: snapshot every rule's OLD
197
+ # key and value from `state` FIRST, then remove every old key
198
+ # and only THEN write every new key — a rename never reads a
199
+ # key this same pass has already written to, so a swap or a
200
+ # longer chain applies as one permutation, not a sequence of
201
+ # edits each stepping on the last.
202
+ def apply_renames(state, renames)
203
+ snapshot = renames.filter_map { |old_name, new_name| [old_name, new_name, state[old_name]] if state.key?(old_name) }
204
+ snapshot.each { |old_name, _new_name, _value| state.delete(old_name) }
205
+ # rubocop:disable Style/CombinableLoops -- NOT combinable: a swap
206
+ # (:a<->:b) needs every delete done before any write, or the first
207
+ # rename's write becomes the second rename's delete target — see
208
+ # this method's own comment above.
209
+ snapshot.each { |_old_name, new_name, value| state[new_name] = value }
210
+ # rubocop:enable Style/CombinableLoops
211
+ end
212
+
186
213
  def apply_drop(state, name)
187
214
  name = name.to_s
188
215
  top, member = name.split(".", 2)
@@ -1,6 +1,6 @@
1
- require_relative "../../../../runtime/era_tamper"
2
- require_relative "../../../../runtime/registry"
3
- require_relative "../../../../runtime/storage_shape"
1
+ require_relative "../../era_tamper"
2
+ require_relative "../../../../../../runtime/registry"
3
+ require_relative "../../storage_shape"
4
4
 
5
5
  module Hecks
6
6
  module Adapters
@@ -1,5 +1,5 @@
1
- require_relative "../../../../naming"
2
- require_relative "../../../../translation/rule_compiler"
1
+ require_relative "../../../../../../naming"
2
+ require_relative "../../translation/rule_compiler"
3
3
 
4
4
  module Hecks
5
5
  module Adapters
@@ -67,15 +67,41 @@ module Hecks
67
67
  @db.exec_params("SELECT pg_advisory_xact_lock(hashtext('hecks_head_snapshot:' || $1))", [name])
68
68
  next if table_exists?(name)
69
69
 
70
+ # `operation` + a NULLABLE `state` — H3, docs/audits/2026-08-
71
+ # 10-main-bug-audit.md: a delete used to just `DELETE FROM`
72
+ # this table, leaving NO row at all for an id carried in
73
+ # from an ancestor era. `compile_head!`'s union below then
74
+ # had nothing current-era to outrank the ancestor
75
+ # matview's own (still-live-looking) `save` row with, so a
76
+ # deleted ancestor-carried record kept winning `DISTINCT
77
+ # ON` forever. A delete now upserts a TOMBSTONE row here
78
+ # instead (`operation = 'delete'`, `state` NULL) — the
79
+ # exact same ordinal-guarded upsert every write already
80
+ # uses, so it participates in the union/`DISTINCT ON`
81
+ # exactly like a save does, and out-ranks the ancestor row
82
+ # by ordinal the same way a real re-save already did
83
+ # ("re-saves are masked correctly" — the audit's own
84
+ # phrasing for why this half of the read path was never
85
+ # broken).
70
86
  @db.exec(<<~SQL)
71
87
  CREATE TABLE #{quote(name)} (
72
- id text PRIMARY KEY,
73
- ordinal bigint NOT NULL,
74
- state jsonb NOT NULL
88
+ id text PRIMARY KEY,
89
+ ordinal bigint NOT NULL,
90
+ operation text NOT NULL DEFAULT 'save',
91
+ state jsonb
75
92
  )
76
93
  SQL
77
94
  end
78
95
  end
96
+ # SELF-HEALING for a table this ADR predates — same idiom as
97
+ # `postgres/schema_builder.rb`'s own `hecks_version` backfill:
98
+ # unconditional, runs on every boot, a no-op once the column
99
+ # is there. A table created before H3's fix has no
100
+ # `operation` column and a `state NOT NULL` constraint a
101
+ # tombstone's NULL state would violate; both are corrected
102
+ # here regardless of which branch above just ran.
103
+ @db.exec("ALTER TABLE #{quote(name)} ADD COLUMN IF NOT EXISTS operation text NOT NULL DEFAULT 'save'")
104
+ @db.exec("ALTER TABLE #{quote(name)} ALTER COLUMN state DROP NOT NULL")
79
105
  backfill_head_snapshot!(name, storage_name, era)
80
106
  end
81
107
 
@@ -105,10 +131,13 @@ module Hecks
105
131
  end,
106
132
  upsert: lambda do |rows|
107
133
  rows.each do |row|
134
+ # Always `'save'` — `source_sql` above already filtered
135
+ # to `operation = 'save'`, so nothing this backfill ever
136
+ # sees is a delete tombstone.
108
137
  @db.exec_params(
109
- "INSERT INTO #{quote(name)} (id, ordinal, state) VALUES ($1, $2, $3) " \
110
- "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, state = EXCLUDED.state " \
111
- "WHERE #{quote(name)}.ordinal < EXCLUDED.ordinal",
138
+ "INSERT INTO #{quote(name)} (id, ordinal, operation, state) VALUES ($1, $2, 'save', $3) " \
139
+ "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, operation = EXCLUDED.operation, " \
140
+ "state = EXCLUDED.state WHERE #{quote(name)}.ordinal < EXCLUDED.ordinal",
112
141
  [row["id"], row["ordinal"], row["state"]]
113
142
  )
114
143
  end
@@ -149,9 +178,15 @@ module Hecks
149
178
  # already keeps the snapshot current as of every write.
150
179
  def ensure_first_head!(storage_name)
151
180
  ensure_head_snapshot!(storage_name, 1)
181
+ # `WHERE operation = 'save'` — era 1 has no ancestor tail to
182
+ # worry about, but the snapshot table can now hold delete
183
+ # tombstones too (H3, see `ensure_head_snapshot!`'s own
184
+ # comment), and a tombstone's `state` is NULL: without this
185
+ # filter a deleted id would still resolve here, just to a nil
186
+ # state, instead of correctly falling out of the head at all.
152
187
  @db.exec(<<~SQL)
153
188
  CREATE OR REPLACE VIEW #{quote(head_view(storage_name))} AS
154
- SELECT id, state FROM #{quote(head_snapshot(storage_name, 1))}
189
+ SELECT id, state FROM #{quote(head_snapshot(storage_name, 1))} WHERE operation = 'save'
155
190
  SQL
156
191
  end
157
192
 
@@ -359,13 +394,27 @@ module Hecks
359
394
  # under this era specifically.
360
395
  ensure_head_snapshot!(storage_name, era)
361
396
  @db.exec("DROP VIEW IF EXISTS #{quote(head_view(storage_name))}")
397
+ # THE CURRENT-ERA SIDE READS ITS OWN `operation` COLUMN NOW —
398
+ # H3 (docs/audits/2026-08-10-main-bug-audit.md). This used to
399
+ # hardcode `'save' AS operation` here, on the reasoning that a
400
+ # delete simply removed its row from the snapshot table. But
401
+ # for an id carried in from an ancestor era, that left the
402
+ # ancestor matview's own `save` row as the ONLY row on either
403
+ # side of this union — which then won `DISTINCT ON` and
404
+ # `WHERE operation = 'save'` let it straight through, so a
405
+ # deleted ancestor-carried record kept resurrecting forever.
406
+ # The snapshot side now upserts a real tombstone row on
407
+ # delete (`ensure_head_snapshot!`'s own comment) instead of
408
+ # deleting the row, so reading its actual `operation` here
409
+ # lets that tombstone outrank the stale ancestor `save` row
410
+ # by ordinal, exactly the way a genuine re-save already did.
362
411
  @db.exec(<<~SQL)
363
412
  CREATE VIEW #{quote(head_view(storage_name))} AS
364
413
  SELECT id, state FROM (
365
414
  SELECT DISTINCT ON (aggregate_id) aggregate_id AS id, operation, state FROM (
366
415
  SELECT ordinal, aggregate_id, operation, state FROM #{quote(view)}
367
416
  UNION ALL
368
- SELECT ordinal, id AS aggregate_id, 'save' AS operation, state
417
+ SELECT ordinal, id AS aggregate_id, operation, state
369
418
  FROM #{quote(head_snapshot(storage_name, era))}
370
419
  ) merged ORDER BY aggregate_id, ordinal DESC
371
420
  ) latest WHERE operation = 'save'
@@ -1,5 +1,5 @@
1
- require_relative "../../../../runtime/registry"
2
- require_relative "../../../../runtime/storage_shape"
1
+ require_relative "../../../../../../runtime/registry"
2
+ require_relative "../../storage_shape"
3
3
 
4
4
  module Hecks
5
5
  module Adapters
@@ -78,7 +78,35 @@ module Hecks
78
78
  # to revoke them from the owner itself); a deployment's app
79
79
  # role connects as a NON-owner and gets exactly INSERT, per
80
80
  # era, at mint time.
81
- @db.exec("REVOKE UPDATE, DELETE ON #{quoted_journal} FROM PUBLIC")
81
+ #
82
+ # GUARDED, same reasoning as the RLS ALTER TABLE calls just
83
+ # below: REVOKE still writes pg_class.relacl (and takes the
84
+ # matching lock) even when the resulting privileges are
85
+ # unchanged, so an unconditional reissue on every ordinary
86
+ # reboot raced two concurrent boots into
87
+ # `PG::InternalError: tuple concurrently updated` — read
88
+ # first, touch the catalog only on the boot that actually
89
+ # needs to.
90
+ #
91
+ # `relacl IS NULL`, not `has_table_privilege('public', ...,
92
+ # 'UPDATE')` — found live, not assumed: a brand-new table's
93
+ # PUBLIC privilege is already "no UPDATE" by Postgres's own
94
+ # default (nothing has ever been explicitly granted to
95
+ # PUBLIC), so `has_table_privilege` answers false BOTH before
96
+ # the REVOKE has ever run AND after it has — indistinguishable
97
+ # by that check alone, which meant the very first boot's own
98
+ # REVOKE never actually ran, `relacl` stayed NULL forever, and
99
+ # "journal rows accept no UPDATE/DELETE from PUBLIC" was true
100
+ # only by accident of Postgres's default, not by the explicit
101
+ # privilege revocation this method exists to record.
102
+ # `relacl IS NULL` means "default ACL, nothing explicit yet" —
103
+ # exactly the one-time signal needed, and (like the RLS flags
104
+ # below) `pg_table_is_visible(oid)`, not a bare relname match,
105
+ # for the same shared-instance/storehouse reason.
106
+ relacl_null = @db.exec_params(
107
+ "SELECT relacl IS NULL FROM pg_class WHERE relname = $1 AND pg_table_is_visible(oid)", [journal]
108
+ ).getvalue(0, 0)
109
+ @db.exec("REVOKE UPDATE, DELETE ON #{quoted_journal} FROM PUBLIC") if relacl_null == "t"
82
110
  # RLS goes on AT PROVISIONING, never mid-life — enabling it
83
111
  # later would deny every role that has no policy yet, on
84
112
  # whatever the shape of the schema happened to be at that
@@ -1,4 +1,4 @@
1
- require_relative "../../../../runtime/registry"
1
+ require_relative "../../../../../../runtime/registry"
2
2
 
3
3
  module Hecks
4
4
  module Adapters
@@ -74,6 +74,7 @@ module Hecks
74
74
  compile_head!(aggregate, era, label, edges, full: true)
75
75
  end
76
76
 
77
+ # rubocop:disable-next Metrics/BlockLength
77
78
  winners.each do |id, side|
78
79
  aggregates.each do |aggregate|
79
80
  state =
@@ -101,10 +102,16 @@ module Hecks
101
102
  "VALUES ($1, $2, $3, 'save', $4) RETURNING ordinal",
102
103
  [era, aggregate.storage_name, id, state]
103
104
  )[0]["ordinal"]
105
+ # Always `'save'` — a merge winner is read from either
106
+ # the matview's own `operation = 'save'` rows or the new
107
+ # world's live head (`new_states`, captured from
108
+ # `head_view`, which is save-only by construction), so
109
+ # nothing reaching this INSERT is ever a delete.
104
110
  @db.exec_params(
105
- "INSERT INTO #{quote(head_snapshot(aggregate.storage_name, era))} (id, ordinal, state) VALUES ($1, $2, $3) " \
106
- "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, state = EXCLUDED.state " \
107
- "WHERE #{quote(head_snapshot(aggregate.storage_name, era))}.ordinal < EXCLUDED.ordinal",
111
+ "INSERT INTO #{quote(head_snapshot(aggregate.storage_name, era))} (id, ordinal, operation, state) " \
112
+ "VALUES ($1, $2, 'save', $3) " \
113
+ "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, operation = EXCLUDED.operation, " \
114
+ "state = EXCLUDED.state WHERE #{quote(head_snapshot(aggregate.storage_name, era))}.ordinal < EXCLUDED.ordinal",
108
115
  [id, ordinal, state]
109
116
  )
110
117
  end
@@ -7,7 +7,27 @@ module Hecks
7
7
  # equal to the port's reference entry-JSON transform by the
8
8
  # cross-execution equivalence spec; the SQL here is a compilation
9
9
  # target, not a second source of truth.
10
+ #
11
+ # LOCKED, unlike every other statement `ensure_base!` runs — those
12
+ # are all `CREATE ... IF NOT EXISTS`/`ADD COLUMN IF NOT EXISTS`,
13
+ # which Postgres itself resolves safely under concurrent boots.
14
+ # `CREATE OR REPLACE FUNCTION` is not: it always rewrites the
15
+ # `pg_proc` row, so two sessions racing to (re)install the SAME
16
+ # function — these six are shared/global, not per-domain, so any
17
+ # two domains' concurrent first-boots can collide here — hit a
18
+ # real `PG::InternalError: tuple concurrently updated`, not a
19
+ # graceful no-op. `nested_transaction` is the same
20
+ # already-open-transaction-safe wrapper `ensure_field_cache!`
21
+ # uses for its own advisory lock; a fixed, domain-independent key
22
+ # is correct since these functions have no domain of their own.
10
23
  def install_transforms!
24
+ nested_transaction("hecks_tr_functions") do
25
+ @db.exec_params("SELECT pg_advisory_xact_lock(hashtext('hecks_tr_functions'))", [])
26
+ install_transform_functions!
27
+ end
28
+ end
29
+
30
+ def install_transform_functions!
11
31
  @db.exec(<<~SQL)
12
32
  CREATE OR REPLACE FUNCTION hecks_tr_extract(state jsonb, path text[], OUT remaining jsonb, OUT value jsonb, OUT present boolean)
13
33
  LANGUAGE plpgsql IMMUTABLE AS $fn$
@@ -9,7 +9,7 @@ require_relative "lineage/resumable_backfill"
9
9
  require_relative "lineage/head_compiler"
10
10
  require_relative "lineage/field_cache"
11
11
  require_relative "lineage/transform_installer"
12
- require_relative "../../../naming"
12
+ require_relative "../../../../../naming"
13
13
 
14
14
  module Hecks
15
15
  module Adapters
@@ -1,8 +1,8 @@
1
- require_relative "../../../../ports/persistence/lineage"
2
- require_relative "../../../../runtime/era_guard"
3
- require_relative "../../../../runtime/identity"
4
- require_relative "../../../../runtime/registry"
5
- require_relative "../../../../translation/audit"
1
+ require_relative "../../lineage"
2
+ require_relative "../../era_guard"
3
+ require_relative "../../../../../../runtime/identity"
4
+ require_relative "../../../../../../runtime/registry"
5
+ require_relative "../../translation/audit"
6
6
 
7
7
  module Hecks
8
8
  module Adapters
@@ -1,5 +1,5 @@
1
1
  require_relative "../lineage"
2
- require_relative "../../../../runtime/storage_shape"
2
+ require_relative "../../storage_shape"
3
3
 
4
4
  module Hecks
5
5
  module Adapters
@@ -14,7 +14,10 @@ module Hecks
14
14
  db = PostgresEra.connect_for(bluebook.name, settings)
15
15
  lineage = Lineage.new(db, bluebook.name, formerly_known_as: bluebook.formerly_known_as)
16
16
  lineage.ensure_base!
17
- role = settings[:role] || settings["role"]
17
+ # Both spellings honored, key? first — never `||`, which cannot
18
+ # tell a genuinely stored `false` apart from an absent key (see
19
+ # PostgresEra.setting's own comment for the full reasoning).
20
+ role = settings.key?(:role) ? settings[:role] : settings["role"]
18
21
 
19
22
  held = lineage.eras
20
23
  if held.empty?
@@ -1,6 +1,6 @@
1
1
  require_relative "../lineage"
2
- require_relative "../../../../runtime/registry"
3
- require_relative "../../../../translation/audit"
2
+ require_relative "../../../../../../runtime/registry"
3
+ require_relative "../../translation/audit"
4
4
 
5
5
  module Hecks
6
6
  module Adapters
@@ -1,7 +1,7 @@
1
- require_relative "../../../../runtime/registry"
2
- require_relative "../../../../runtime/storage_shape"
3
- require_relative "../../../../translation/audit"
4
- require_relative "../../../../translation/scaffold"
1
+ require_relative "../../../../../../runtime/registry"
2
+ require_relative "../../storage_shape"
3
+ require_relative "../../translation/audit"
4
+ require_relative "../../translation/scaffold"
5
5
 
6
6
  module Hecks
7
7
  module Adapters
@@ -4,8 +4,8 @@ require_relative "lineage_manager/era_resolver"
4
4
  require_relative "lineage_manager/minter"
5
5
  require_relative "lineage_manager/merge_coordinator"
6
6
  require_relative "lineage_manager/coverage_check"
7
- require_relative "../../../runtime/era_guard"
8
- require_relative "../../../runtime/registry"
7
+ require_relative "../era_guard"
8
+ require_relative "../../../../../runtime/registry"
9
9
 
10
10
  module Hecks
11
11
  module Adapters