hecks 0.3.0 → 1.0.0

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 (194) 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/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -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
@@ -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