hecks 1.1.0 → 1.3.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
  3. data/lib/hecks/adapters/driven/postgres/outbox.rb +7 -4
  4. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  5. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
  6. data/lib/hecks/adapters/driven/postgres.rb +20 -17
  7. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
  9. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  10. data/lib/hecks/behaviors/expectations.rb +32 -4
  11. data/lib/hecks/bluebook/assembly/marks.rb +13 -3
  12. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  13. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  14. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
  15. data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
  16. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  17. data/lib/hecks/bluebook/model_check.rb +148 -17
  18. data/lib/hecks/forms/field_shape.rb +5 -3
  19. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  20. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  21. data/lib/hecks/fuzzing/form_census.rb +184 -0
  22. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  23. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  24. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  25. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  26. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  27. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  28. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  29. data/lib/hecks/fuzzing/properties.rb +23 -2
  30. data/lib/hecks/fuzzing/replay.rb +130 -20
  31. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  32. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  33. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  34. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  35. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  36. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  37. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  38. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  39. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  40. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  41. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  42. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  43. data/lib/hecks/fuzzing.rb +6 -0
  44. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  45. data/lib/hecks/naming.rb +70 -2
  46. data/lib/hecks/ports/persistence/append_only.rb +11 -0
  47. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  48. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  49. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +85 -5
  50. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  51. data/lib/hecks/projections/glossary/html.rb +250 -0
  52. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  53. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  54. data/lib/hecks/projections/glossary/page.css +271 -0
  55. data/lib/hecks/projections/glossary/page.js +72 -0
  56. data/lib/hecks/projections/glossary/sections.rb +17 -0
  57. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  58. data/lib/hecks/projections/glossary.rb +302 -0
  59. data/lib/hecks/projections.rb +1 -0
  60. data/lib/hecks/projector/cli_projector.rb +29 -13
  61. data/lib/hecks/projector/exporter.rb +24 -0
  62. data/lib/hecks/projector/narrate_projector.rb +4 -11
  63. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  64. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  65. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  66. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  67. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  68. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  69. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  70. data/lib/hecks/runtime/entity_element.rb +168 -16
  71. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  72. data/lib/hecks/runtime/interpreting.rb +16 -7
  73. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  74. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  75. data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
  76. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  77. data/lib/hecks/runtime/registry.rb +12 -0
  78. data/lib/hecks/runtime/routing.rb +67 -2
  79. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  80. data/lib/hecks/runtime/value/coercion.rb +77 -52
  81. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  82. data/lib/hecks/runtime/value.rb +7 -2
  83. data/lib/hecks/version.rb +1 -1
  84. data/lib/hecks/vocabulary.rb +2 -1
  85. metadata +25 -2
@@ -53,6 +53,51 @@ module Hecks
53
53
  # they're included so a real generated sequence occasionally
54
54
  # produces the value at all, since nothing had, repo-wide, before.
55
55
  INTEGER_EDGE_CASES = [0, -1, 2_147_483_647, -2_147_483_648, 2**100, -(2**100)].freeze
56
+ # BUG#35 (QualityControl QA ledger, `lease-clock-json-precision`) —
57
+ # a Bignum edge case (`2**100`, above) landing on an Integer-typed
58
+ # CLOCK or COUNT reading fires the already-catalogued `Json::Num`/
59
+ # f64 precision-loss class (`rust/src/kernel/json.rs`'s
60
+ # `parse_number` parses every number through `s.parse::<f64>()`
61
+ # before any target-type conversion runs, and `Json::Num` is a
62
+ # plain `f64` end to end — see that file's own header) on a NEW
63
+ # site every time a new clock/count-shaped field is authored,
64
+ # without adding any new coverage: both engines already refuse
65
+ # (`TypeMismatch`, out of `i64` range either way) for this shape,
66
+ # `tenant_ledger`'s own NOTES.md already logged the identical class
67
+ # on a stored money attribute, and `spec/support/
68
+ # rust_conformance_helpers.rb`'s own `reduce_to_wire_precision`
69
+ # already documents and normalizes past the wire format's own loss
70
+ # for a query's echoed args. An exact-integer Rust JSON
71
+ # deserialization path was considered and rejected as this
72
+ # generator's own fix instead: `Json::Num(f64)` is pattern-matched
73
+ # throughout `rust/src/kernel` (query_comparators.rs,
74
+ # query_ordering.rs, read_model.rs, named_query.rs, this kernel's
75
+ # own `Fielded` impl, arithmetic overflow checks) and emitted by
76
+ # codegen (`rust/project/reactions.rb`, `rust/codegen/src/
77
+ # reactions.rs`) — adding a second, exact-integer numeric variant
78
+ # would mean auditing and updating every one of those sites, a
79
+ # refactor of the JSON layer itself, not a one-domain fix (exactly
80
+ # the ledger's own stated concern: "risks affecting every other
81
+ # domain's large-integer handling"). So instead, per the ledger's
82
+ # own sanctioned fallback: an Integer-typed field whose name reads
83
+ # as a clock or a count is capped to a narrower, still-real
84
+ # edge-case pool below (still exercises the ordinary i32
85
+ # boundaries, just never a value past f64's own 2**53 exact-
86
+ # integer ceiling) — every OTHER Integer-typed field (a money
87
+ # amount, an identity sequence, anything not clock/count-shaped)
88
+ # still draws from the full `INTEGER_EDGE_CASES` pool above,
89
+ # unchanged.
90
+ SAFE_INTEGER_EDGE_CASES = [0, -1, 2_147_483_647, -2_147_483_648].freeze
91
+ # Matched against the value object's own declared name plus the
92
+ # attribute's own name (`value_for`'s `"#{context} #{attribute.
93
+ # name}"`, e.g. "LeaseInstant value", "RetryCount value") — a
94
+ # clock reading ("instant"/"clock"/"expir(es/y)"/"ttl"/"now"/
95
+ # "timestamp"/"epoch") or a quantity capped by a policy ("count").
96
+ # Deliberately narrow: matches the ledger's own "clock/count"
97
+ # wording exactly, not every plausibly-large-looking name (an
98
+ # "amount"/"cents"/"sequence" field is not clock/count-shaped and
99
+ # keeps the full Bignum edge-case pool).
100
+ CLOCK_OR_COUNT_NAME_PATTERN = /clock|instant|expir|ttl|\bnow\b|timestamp|epoch|count/i
56
101
  # NaN and +/-Infinity — the real find (see `spec/runtime/
57
102
  # numeric_boundary_spec.rb`): `Value::Coercion#check_numeric_fields`
58
103
  # used to let all three sail through untyped-checked (each really
@@ -148,7 +193,7 @@ module Hecks
148
193
  def primitive(type_name, random:, name: nil)
149
194
  case type_name
150
195
  when "String" then string_value(random, name: name)
151
- when "Integer" then integer_value(random)
196
+ when "Integer" then integer_value(random, name: name)
152
197
  when "Float" then float_value(random)
153
198
  when "TrueClass", "FalseClass" then random.rand(2).zero?
154
199
  else raise ArgumentError, "ValueGenerator does not know primitive type #{type_name.inspect}"
@@ -172,12 +217,19 @@ module Hecks
172
217
  # sequence that can never get past one never reaches the state a deeper
173
218
  # bug would need. Zero and negative are still real, reachable outcomes —
174
219
  # via the edge-case pool, deliberately, not by starving them entirely.
175
- def integer_value(random)
176
- return INTEGER_EDGE_CASES.sample(random: random) if random.rand < EDGE_CASE_PROBABILITY
220
+ def integer_value(random, name: nil)
221
+ if random.rand < EDGE_CASE_PROBABILITY
222
+ pool = clock_or_count_shaped?(name) ? SAFE_INTEGER_EDGE_CASES : INTEGER_EDGE_CASES
223
+ return pool.sample(random: random)
224
+ end
177
225
 
178
226
  random.rand(1..1000)
179
227
  end
180
228
 
229
+ def clock_or_count_shaped?(name)
230
+ name.to_s.match?(CLOCK_OR_COUNT_NAME_PATTERN)
231
+ end
232
+
181
233
  def float_value(random)
182
234
  return FLOAT_EDGE_CASES.sample(random: random) if random.rand < EDGE_CASE_PROBABILITY
183
235
 
data/lib/hecks/fuzzing.rb CHANGED
@@ -10,3 +10,9 @@ require_relative "fuzzing/invalid_value_generator"
10
10
  require_relative "fuzzing/sequence_generator"
11
11
  require_relative "fuzzing/replay"
12
12
  require_relative "fuzzing/properties"
13
+ require_relative "fuzzing/rotation_priority"
14
+ require_relative "fuzzing/sweep_depth"
15
+ require_relative "fuzzing/persistence_parity"
16
+ require_relative "fuzzing/form_census"
17
+ require_relative "fuzzing/target_capabilities"
18
+ require_relative "fuzzing/structural_skips"
@@ -284,8 +284,20 @@ Hecks.bluebook "Bluebook" do
284
284
  template: "{command} refused — {field} is {current}, and {command} moves it only from {allowed}"
285
285
  member refusal: "TypeMismatch", site: "value_object_shape",
286
286
  template: "{name} is a {type} — pass its fields as an object, not {offered}"
287
- member refusal: "TypeMismatch", site: "reference_as_object",
288
- template: "{command} refused a reference is an id, and {attribute} arrived as an object{known_by}"
287
+ # RENAMED FROM `reference_as_object` (BUG#27, QualityControl ledger) —
288
+ # a reference command argument that is an object was never the only
289
+ # wrong shape, only the first one anyone noticed: a bare Boolean,
290
+ # Array, or `null` used to sail past this refusal entirely, get
291
+ # `.to_s`'d into a lookup key by `CommandRules::References#
292
+ # reference_key` ("true", "false", "[8, 8]"), and answer NotFound (or,
293
+ # for `null`, skip the lookup outright and answer whatever the
294
+ # command's own `given` said instead) — a shape error misreading as a
295
+ # missing record. `{offered}` now names what actually arrived; the
296
+ # Hash/Value case still renders "an object" (Value::Coercion's own
297
+ # `reference_shape_description`), so the wording already pinned byte
298
+ # for byte in spec/runtime/reference_shape_spec.rb is unchanged.
299
+ member refusal: "TypeMismatch", site: "reference_wrong_shape",
300
+ template: "{command} refused — a reference is an id, and {attribute} arrived as {offered}{known_by}"
289
301
  member refusal: "TypeMismatch", site: "multi_field_scalar",
290
302
  template: "{type} has multiple fields and cannot stand in for a scalar"
291
303
  member refusal: "TypeMismatch", site: "composite_identity",
@@ -322,6 +334,9 @@ Hecks.bluebook "Bluebook" do
322
334
  "{field} this ask is scoped to"
323
335
  member refusal: "Unauthorized", site: "role_mismatch",
324
336
  template: "{command} refused — role: {role}, and the caller stated {caller_role}"
337
+ member refusal: "Unauthorized", site: "cross_tenant_reference",
338
+ template: "{aggregate} {field} is {tenant}, but {attribute} names a {target} whose own " \
339
+ "{target_field} is {other} — a cross-tenant reference"
325
340
  member refusal: "AttributeAbsent", site: "absent_read",
326
341
  template: "{aggregate} {field} is absent on this record — declared, not optional, and " \
327
342
  "added since it was written. Backfill it in a translation (backfill :{field}, " \
data/lib/hecks/naming.rb CHANGED
@@ -39,6 +39,47 @@ module Hecks
39
39
  .downcase
40
40
  end
41
41
 
42
+ # AN IDENTIFIER AS A PERSON WOULD SAY IT — `ATMCard` -> "ATM card",
43
+ # `AccrueInterest` -> "Accrue interest", `daily_limit` -> "Daily
44
+ # limit", `Back office` -> "Back office". The same two word-boundary
45
+ # splits `snake` uses, with a space instead of an underscore — plus
46
+ # the one thing `snake` cannot give back: an all-caps run stays an
47
+ # acronym ("ATM", "KYC") instead of being lowercased into a word
48
+ # nobody says ("Atm"). First word capitalized, the rest lowercased,
49
+ # so a headword reads as sentence case whatever casing it was
50
+ # declared in.
51
+ def words(text)
52
+ parts = text.to_s
53
+ .tr("_", " ")
54
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1 \2')
55
+ .gsub(/([a-z\d])([A-Z])/, '\1 \2')
56
+ .split
57
+ parts.each_with_index.map do |part, index|
58
+ next part if part.match?(/\A[A-Z]{2,}\z/)
59
+
60
+ index.zero? ? part.capitalize : part.downcase
61
+ end.join(" ")
62
+ end
63
+
64
+ # "A, B, and C" / "A or B" — the Oxford-comma list every English
65
+ # sentence a projection writes wants; lived in `NarrateProjector`
66
+ # alone until a second projection needed it.
67
+ def to_sentence_list(items, conj: "and")
68
+ case items.size
69
+ when 0 then ""
70
+ when 1 then items[0].to_s
71
+ when 2 then "#{items[0]} #{conj} #{items[1]}"
72
+ else "#{items[0..-2].join(', ')}, #{conj} #{items[-1]}"
73
+ end
74
+ end
75
+
76
+ # The vowel-LETTER heuristic — safe here for the same reason
77
+ # `Projections::Statements#article` gives: a construct name is a
78
+ # plain word, never "hour" or "university".
79
+ def a_or_an(word)
80
+ %w[a e i o u].include?(word.to_s[0].to_s.downcase) ? "an" : "a"
81
+ end
82
+
42
83
  # The name a COLLECTION of something takes.
43
84
  #
44
85
  # There were two of these and one was wrong. A read model's gathered heads
@@ -102,10 +143,37 @@ module Hecks
102
143
  text.include?(".") ? text.split(".", 2).last : text
103
144
  end
104
145
 
146
+ # DOMAIN, AGGREGATE, then the REST dot-joined into one command path.
147
+ #
148
+ # The `::` boundary between domain and aggregate is unambiguous by
149
+ # construction — every caller here has already prefixed the domain
150
+ # itself (`PolicyInterpreter#deliver`, `SagaInterpreter#qualified`,
151
+ # `Router#dispatch`'s own rebuilt string) before this ever runs. A
152
+ # THIRD `::` segment can still show up past that boundary: a bare
153
+ # `ScopedConstant` naming a port operation (`command_ref`'s own
154
+ # comment — `Aggregate::Port::Operation`, three colon-joined
155
+ # segments with no `.` of its own) only gets its LAST `::` rewritten
156
+ # to `.` there, at DSL-build time, because nothing at that point
157
+ # knows yet whether the constant names a port operation or a
158
+ # domain-qualified command (`Domain::Aggregate::Command`, the OTHER
159
+ # shape `command_ref` documents) — both are textually identical.
160
+ # Here, past the already-resolved domain boundary, any leftover
161
+ # `::` is unambiguous: it is that same rewrite artifact, and folding
162
+ # it into the dot-joined tail recovers exactly the
163
+ # `Aggregate::Port.Operation` shape a working port dispatch already
164
+ # expects (`spec/port_operation_interpreter_spec.rb`'s own
165
+ # `"Payments::Payment.PaymentGateway.Receive"`). `Outbox::Fanout
166
+ # .kind_for` and `ReactionInvocation#resolve_target` both already
167
+ # assume this contract on their own end; this is what actually
168
+ # delivers it to them.
105
169
  def split_verb(verb)
106
170
  path, command = verb.to_s.split(".", 2)
107
- domain, aggregate = path.to_s.split("::", 2)
108
- return nil unless domain && aggregate && command
171
+ return nil unless path && command
172
+
173
+ domain, aggregate, *rest = path.to_s.split("::")
174
+ return nil unless domain && aggregate
175
+
176
+ command = "#{rest.join('.')}.#{command}" unless rest.empty?
109
177
 
110
178
  [domain, aggregate, command]
111
179
  end
@@ -147,6 +147,17 @@ module Hecks
147
147
  yield
148
148
  end
149
149
 
150
+ # ONLY an adapter advertising `:cross_process_lock` (PostgresEra —
151
+ # see ADR 0036) implements this; `run_dispatch_order_with_isolation`
152
+ # (runtime/interpreting.rb) checks `capabilities` before ever
153
+ # calling it, so the plain `yield` fallback here only guards
154
+ # against a stray direct call, not the real dispatch path.
155
+ def with_write_lock(&)
156
+ return @adapter.with_write_lock(&) if @adapter.respond_to?(:with_write_lock)
157
+
158
+ yield
159
+ end
160
+
150
161
  # THE OUTBOX CONTRACT — four optional adapter methods, probed
151
162
  # together the way `save_saga`/`delete_saga`/`each_saga` are
152
163
  # (`Registry::SagaPersistence`): an adapter either has an outbox
@@ -287,6 +287,56 @@ module Hecks
287
287
  raise Runtime::WiringError, "cannot rename #{@formerly_known_as} to #{@domain}: #{e.message.strip}"
288
288
  end
289
289
 
290
+ # THE FENCE HAS TO BE ABLE TO BITE THIS CONNECTION, OR NOTHING
291
+ # ensure_base! BUILDS MEANS ANYTHING. The whole write-fence is
292
+ # row-level security (FORCE ROW LEVEL SECURITY above, plus
293
+ # advance_era!'s one INSERT policy), and Postgres exempts a
294
+ # superuser — or any role granted BYPASSRLS — from every policy
295
+ # on every table, unconditionally: FORCE only narrows the
296
+ # OWNER's exemption and has no lever against either of those.
297
+ # Found live, not reasoned about (BUG#24): the QA ledger's own
298
+ # `.world` named a bare database, so every session connected as
299
+ # the machine's default Postgres user — a superuser — and an
300
+ # old checkout wrote its own superseded era straight through
301
+ # two mints, 37 rows no newer head could read, and nothing
302
+ # warned. Asked of the catalog ONCE, at boot, BEFORE anything
303
+ # is provisioned: the answer is a fact about the ROLE, not about
304
+ # any table, so there is nothing to wait for and nothing to
305
+ # half-build first.
306
+ #
307
+ # Refuses by default — quiet divergence is the enemy — naming
308
+ # the role and both ways out. `allow_superuser` boots anyway,
309
+ # but says so on stderr on EVERY boot, because the only guard
310
+ # left standing then is PostgresEra#append's own in-process
311
+ # superseded-era check (the belt to this suspender), and an
312
+ # operator reading the log deserves to know which one they are
313
+ # relying on. Presence-over-truthiness for the setting itself
314
+ # is `PostgresEra.setting`'s job (the caller's); here a truthy
315
+ # value opts in and anything else does not.
316
+ def check_fence_applies!(allow_superuser: false)
317
+ row = @db.exec("SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user")[0]
318
+ exempt = []
319
+ exempt << "a superuser" if row["rolsuper"] == "t"
320
+ exempt << "granted BYPASSRLS" if row["rolbypassrls"] == "t"
321
+ return if exempt.empty?
322
+
323
+ role = row["rolname"].inspect
324
+ unless allow_superuser
325
+ raise Runtime::WiringError,
326
+ "cannot boot #{@domain}: PostgresEra's era write-fence is row-level security, and this " \
327
+ "connection's role #{role} is #{exempt.join(' and ')} — Postgres exempts it from every " \
328
+ "policy, FORCE included, so an old checkout connected this way keeps writing a superseded " \
329
+ "era and nothing refuses. Connect as an ordinary role instead (database " \
330
+ "\"postgres://<role>@<host>/<db>\" in the .world — a non-superuser OWNER still provisions " \
331
+ "and mints), or declare `allow_superuser true` in the same persisted_by block to boot with " \
332
+ "the fence void, on the record."
333
+ end
334
+
335
+ warn "[hecks] #{@domain}: booting PostgresEra as #{role}, #{exempt.join(' and ')}, under " \
336
+ "allow_superuser — the era write-fence is void for this connection; only this process's own " \
337
+ "superseded-era check (PostgresEra#append) stands between an old checkout and a superseded era"
338
+ end
339
+
290
340
  # Nothing provisioned yet — build it. Provisioned and owned —
291
341
  # keep it current. Provisioned by SOMEONE ELSE — this is an app
292
342
  # role, and the owner has already done this work.
@@ -22,6 +22,12 @@ module Hecks
22
22
  def check!(registry:, bluebook:, current_text:, settings:, directory: nil)
23
23
  db = PostgresEra.connect_for(bluebook.name, settings)
24
24
  lineage = Lineage.new(db, bluebook.name, formerly_known_as: bluebook.formerly_known_as)
25
+ # FIRST, before ensure_base! provisions or verifies anything —
26
+ # the one point every PostgresEra boot passes through, and the
27
+ # earliest at which the connection's own role is known. See
28
+ # check_fence_applies!'s own header (BUG#24) for why a
29
+ # superuser connection refuses here by default.
30
+ lineage.check_fence_applies!(allow_superuser: PostgresEra.setting(settings, :allow_superuser, default: false))
25
31
  lineage.ensure_base!
26
32
  # Both spellings honored, key? first — never `||`, which cannot
27
33
  # tell a genuinely stored `false` apart from an absent key (see
@@ -78,6 +84,14 @@ module Hecks
78
84
  # called with a superseded ordinal.
79
85
  lineage.grant_role!(role, aggregates: bluebook.aggregates, era: matched[:ordinal]) if role
80
86
  registry.resolved_eras[bluebook.name] = matched[:ordinal]
87
+ # ...and the boot REMEMBERS that it is superseded, not only
88
+ # which era it is: `RepositoryFactory.build` hands this to
89
+ # the adapter as `superseded_by:`, and `PostgresEra#append`
90
+ # refuses on it before issuing the INSERT. That is the
91
+ # in-process half of "may not keep WRITING" — the half that
92
+ # holds even for a connection the RLS fence cannot bite
93
+ # (a superuser under allow_superuser; BUG#24).
94
+ registry.superseded_eras[bluebook.name] = latest[:ordinal]
81
95
  return
82
96
  end
83
97
 
@@ -2,6 +2,7 @@ require "json"
2
2
 
3
3
  require_relative "../../../../adapters/driven/sql_query_builder"
4
4
  require_relative "../../../../adapters/driven/postgres/outbox"
5
+ require_relative "../../../../adapters/driven/postgres/reconnect"
5
6
  require_relative "postgres_era/lineage"
6
7
  require_relative "postgres_era/lineage_manager"
7
8
  require_relative "../../../../ports/persistence/append_only"
@@ -42,10 +43,19 @@ module Hecks
42
43
  class PostgresEra
43
44
  include SqlQueryBuilder
44
45
  include Adapters::PostgresOutbox
46
+ include Adapters::PostgresReconnect
45
47
 
46
48
  attr_reader :aggregate
47
49
 
48
- def persistence_capabilities = [:atomic_put]
50
+ # `:cross_process_lock` tells `Interpreting#run_dispatch_order_with_isolation`
51
+ # (runtime/interpreting.rb) this repository can hold a REAL
52
+ # cross-process lock for the whole dispatch order itself, via
53
+ # `with_write_lock` below — so it should use that instead of the
54
+ # in-process `AggregateLock` `Mutex` every other non-CAS repository
55
+ # falls back to. See ADR 0036: that in-process Mutex is invisible
56
+ # to `rust/host` dispatching against the same PostgresEra-bound
57
+ # tables from a separate OS process.
58
+ def persistence_capabilities = %i[atomic_put cross_process_lock]
49
59
 
50
60
  # The capability idiom: only PostgresEra answers true, and only
51
61
  # PostgresEra carries an era_check! for the boot gate to delegate to.
@@ -148,11 +158,35 @@ module Hecks
148
158
 
149
159
  def initialize(aggregate:, settings: {}, root: nil)
150
160
  @aggregate = aggregate
161
+ @settings = settings
151
162
  @db = self.class.connect_for(aggregate.name, settings)
152
163
  # The domain names the journal (one journal per lineage). The
153
- # factory injects it; a directly-instantiated adapter (specs,
154
- # consoles) journals under the aggregate's own name.
155
- @domain = self.class.setting(settings, :domain, default: aggregate.storage_name).to_s
164
+ # factory injects it as the owning bluebook's own declared name
165
+ # (`RepositoryFactory.build`'s own `domain` positional arg
166
+ # `bluebook.name`, confirmed at `outbox.rb`'s own call site —
167
+ # never this default in real dispatch). This default exists only
168
+ # for a directly-instantiated adapter (specs, consoles) that
169
+ # skips the factory.
170
+ #
171
+ # When the aggregate DOES have an owning chapter (`hecks_owner`
172
+ # set — true for any aggregate sealed through a real bluebook,
173
+ # even if this adapter itself was built by hand), default to the
174
+ # chapter's own declared PascalCase name. That's the same string
175
+ # `rust/host`'s `pg_advisory_xact_lock` key derives from
176
+ # (`HECKS_DOMAIN`, deploy-time-set to `world.domain`) — the
177
+ # aggregate's own snake_case `storage_name` silently disagreed
178
+ # with it (`hashtext` is byte-sensitive: `"chess"` and `"Chess"`
179
+ # never collide), which is ADR 0036 Blocker 1.
180
+ #
181
+ # When there's no owning chapter at all (a bare
182
+ # `AggregateBuilder` fixture built outside any real bluebook —
183
+ # `hecks_owner` is only ever stamped by chapter construction, see
184
+ # `traits.rb`'s `hecks_owner = self`) there is no chapter name to
185
+ # match Rust against in the first place, so fall back to the
186
+ # aggregate's own name, same as before this ADR.
187
+ @domain = self.class.setting(
188
+ settings, :domain, default: aggregate.hecks_owner&.name || aggregate.storage_name
189
+ ).to_s
156
190
  @lineage = Lineage.new(@db, @domain)
157
191
  @lineage.ensure_base!
158
192
  # The era gate resolves which era this boot IS (an old checkout
@@ -175,6 +209,20 @@ module Hecks
175
209
  # promise.
176
210
  @era = settings.key?(:era) ? settings[:era] : settings["era"]
177
211
  @era ||= @lineage.current_era
212
+ # THE IN-PROCESS HALF OF THE ERA FENCE. `EraResolver.check!` sets
213
+ # `registry.superseded_eras[domain]` for a held-but-superseded
214
+ # boot only, and `RepositoryFactory.build` merges it in here as
215
+ # `superseded_by:` — so this is nil for every current-era boot AND
216
+ # for a directly-instantiated adapter (specs, consoles), which,
217
+ # like `era:` above, self-resolves rather than being told. When
218
+ # set, `append`/`atomic_put` refuse BEFORE issuing an INSERT (see
219
+ # `refuse_superseded_write!`): the RLS fence already refuses the
220
+ # same write for an ordinary role, but a superuser walks through
221
+ # RLS (BUG#24), and this checkout's own knowledge that it is stale
222
+ # is the one guard no role attribute can void. Same coalescing as
223
+ # `era:` — the key is always PRESENT from the factory, holding nil
224
+ # for the ordinary case.
225
+ @superseded_by = settings.key?(:superseded_by) ? settings[:superseded_by] : settings["superseded_by"]
178
226
  # Unconditional and idempotent, regardless of era — belt-and-
179
227
  # suspenders self-healing (compile_head! already ensures this for
180
228
  # a freshly-minted era's own name; ensure_first_head! for era 1's)
@@ -274,6 +322,18 @@ module Hecks
274
322
  head_phase(declared, uncached, ids, args)
275
323
  end
276
324
 
325
+ # ADR 0036's actual fix — see `persistence_capabilities` above.
326
+ # Wraps the WHOLE dispatch order (hydrate through save), not just
327
+ # `append`'s own transaction below: `lock_writes!` has to be held
328
+ # before hydrate even starts, or two cross-process writers can
329
+ # both hydrate unlocked and race for the write, each blind to the
330
+ # other. `transaction` (via `include Adapters::PostgresOutbox`) is
331
+ # already re-entrant — `append`/`atomic_put`'s own inner
332
+ # `transaction do ... end`, deep inside the block below, joins
333
+ # this SAME transaction instead of opening/committing its own, so
334
+ # the advisory lock stays held until this whole block returns.
335
+ def with_write_lock(&block) = transaction { lock_writes!; block.call } # rubocop:disable Style/Semicolon
336
+
277
337
  # HELD FOR THE WHOLE TRANSACTION, not just around the INSERT — the
278
338
  # ordinal is assigned by the column's own `nextval()` default, inside
279
339
  # this same statement, so the lock has to already be held before that
@@ -290,8 +350,12 @@ module Hecks
290
350
  # still runs, cheaply, during AppendOnly#recover!'s full replay on
291
351
  # every boot (see `project` below), and a second write there would
292
352
  # make that replay pay real DB cost for a snapshot that's already
293
- # correct.
353
+ # correct. When `with_write_lock` above already drove this whole
354
+ # dispatch, `lock_writes!` here is a harmless re-acquire of the
355
+ # same already-held (per-session-reentrant) advisory lock; a bare
356
+ # `repository.save` outside a full dispatch still takes it fresh.
294
357
  def append(entry)
358
+ refuse_superseded_write!
295
359
  transaction do
296
360
  lock_writes!
297
361
  append_and_project!(entry)
@@ -299,11 +363,27 @@ module Hecks
299
363
  entry
300
364
  end
301
365
 
366
+ # BEFORE the transaction, before the lock, before the INSERT — a
367
+ # superseded checkout takes nothing and touches nothing. Reads are
368
+ # deliberately untouched: `EraResolver.check!`'s own contract for an
369
+ # old checkout is "may keep BOOTING and READING, but may not keep
370
+ # WRITING", and the head views it reads through are its own era's.
371
+ def refuse_superseded_write!
372
+ return unless @superseded_by
373
+
374
+ raise Runtime::WiringError,
375
+ "cannot write #{table} for #{@domain}: this checkout booted era #{@era}, which era " \
376
+ "#{@superseded_by} has superseded — its shape was replaced by a mint, and a write here would " \
377
+ "land in a partition no newer head reads. Reads still work; pull the current bluebook and " \
378
+ "reboot to write again."
379
+ end
380
+
302
381
  # Outcome detection, journal append and every derived projection share
303
382
  # the SAME transaction and domain write lock. The lineage-aware head
304
383
  # determines whether this id is already visible; no repository `find`
305
384
  # occurs before entering this adapter-native operation.
306
385
  def atomic_put(entry, insert_only: false)
386
+ refuse_superseded_write!
307
387
  status = nil
308
388
  transaction do
309
389
  lock_writes!
@@ -13,13 +13,16 @@ module Hecks
13
13
  settings = (registry.world(domain)&.for_binding(settings_verb, bind.adapter) || {})
14
14
  .reject { |key, _| key.to_sym == :role }
15
15
  registry.check_settings(bind, settings)
16
- # The domain and the resolved era ride along after the
17
- # declared-settings check: a lineage adapter journals per
18
- # DOMAIN and writes into its own ERA's partition neither of
19
- # which a world's settings carry.
16
+ # The domain, the resolved era, and (for an old checkout) the era
17
+ # that superseded it ride along after the declared-settings
18
+ # check: a lineage adapter journals per DOMAIN, writes into its
19
+ # own ERA's partition, and refuses to write at all once that era
20
+ # is superseded (`PostgresEra#append`, BUG#24) — none of which a
21
+ # world's settings carry.
20
22
  adapter = registry.adapter_class(bind.adapter)
21
23
  .new(aggregate: aggregate,
22
- settings: settings.merge(domain: domain.to_s, era: registry.resolved_eras[domain.to_s]),
24
+ settings: settings.merge(domain: domain.to_s, era: registry.resolved_eras[domain.to_s],
25
+ superseded_by: registry.superseded_eras[domain.to_s]),
23
26
  root: registry.root)
24
27
  repository = AppendOnly.new(adapter)
25
28
  recover ? repository.recover! : repository