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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/outbox.rb +7 -4
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +19 -0
- data/lib/hecks/adapters/driven/postgres.rb +20 -17
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +5 -2
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/assembly/marks.rb +13 -3
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +10 -5
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +131 -16
- data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/append_only.rb +11 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +85 -5
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +302 -0
- data/lib/hecks/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +29 -13
- data/lib/hecks/projector/exporter.rb +24 -0
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/interpreting.rb +16 -7
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +5 -4
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -52
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- 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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
154
|
-
#
|
|
155
|
-
|
|
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
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|