hecks 1.2.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 (66) 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_era.adapter +5 -0
  4. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  5. data/lib/hecks/behaviors/expectations.rb +32 -4
  6. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  7. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  8. data/lib/hecks/bluebook/model_check.rb +148 -17
  9. data/lib/hecks/forms/field_shape.rb +5 -3
  10. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  11. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  12. data/lib/hecks/fuzzing/form_census.rb +184 -0
  13. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  14. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  15. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  16. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  17. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  18. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  19. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  20. data/lib/hecks/fuzzing/properties.rb +23 -2
  21. data/lib/hecks/fuzzing/replay.rb +130 -20
  22. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  23. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  24. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  25. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  26. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  27. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  28. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  29. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  30. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  31. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  32. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  33. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  34. data/lib/hecks/fuzzing.rb +6 -0
  35. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  36. data/lib/hecks/naming.rb +70 -2
  37. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  38. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  39. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
  40. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  41. data/lib/hecks/projections/glossary/html.rb +250 -0
  42. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  43. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  44. data/lib/hecks/projections/glossary/page.css +271 -0
  45. data/lib/hecks/projections/glossary/page.js +72 -0
  46. data/lib/hecks/projections/glossary/sections.rb +17 -0
  47. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  48. data/lib/hecks/projections/glossary.rb +214 -286
  49. data/lib/hecks/projector/narrate_projector.rb +4 -11
  50. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  51. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  52. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  53. data/lib/hecks/runtime/entity_element.rb +168 -16
  54. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  55. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  56. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  57. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  58. data/lib/hecks/runtime/registry.rb +12 -0
  59. data/lib/hecks/runtime/routing.rb +67 -2
  60. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  61. data/lib/hecks/runtime/value/coercion.rb +77 -115
  62. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  63. data/lib/hecks/runtime/value.rb +7 -2
  64. data/lib/hecks/version.rb +1 -1
  65. data/lib/hecks/vocabulary.rb +2 -1
  66. metadata +23 -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
@@ -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
 
@@ -209,6 +209,20 @@ module Hecks
209
209
  # promise.
210
210
  @era = settings.key?(:era) ? settings[:era] : settings["era"]
211
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"]
212
226
  # Unconditional and idempotent, regardless of era — belt-and-
213
227
  # suspenders self-healing (compile_head! already ensures this for
214
228
  # a freshly-minted era's own name; ensure_first_head! for era 1's)
@@ -341,6 +355,7 @@ module Hecks
341
355
  # same already-held (per-session-reentrant) advisory lock; a bare
342
356
  # `repository.save` outside a full dispatch still takes it fresh.
343
357
  def append(entry)
358
+ refuse_superseded_write!
344
359
  transaction do
345
360
  lock_writes!
346
361
  append_and_project!(entry)
@@ -348,11 +363,27 @@ module Hecks
348
363
  entry
349
364
  end
350
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
+
351
381
  # Outcome detection, journal append and every derived projection share
352
382
  # the SAME transaction and domain write lock. The lineage-aware head
353
383
  # determines whether this id is already visible; no repository `find`
354
384
  # occurs before entering this adapter-native operation.
355
385
  def atomic_put(entry, insert_only: false)
386
+ refuse_superseded_write!
356
387
  status = nil
357
388
  transaction do
358
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
@@ -0,0 +1,250 @@
1
+ require_relative "../../forms/html"
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Glossary
6
+ # THE PAGE, RENDERED FROM THE MARKDOWN — `html/index.html` is
7
+ # `glossary.md` read back and dressed: a navigation rail built from
8
+ # its `##` headings, each `###` a term entry, every ```mermaid fence
9
+ # a diagram, every `[x](#y)` an in-page link. Nothing here reads
10
+ # the bluebook; if the page shows it, the Markdown says it, so the
11
+ # two cannot drift.
12
+ #
13
+ # A SUBSET RENDERER, NOT A MARKDOWN LIBRARY — the projector emits
14
+ # a fixed handful of constructs (`Markdown`'s own header lists
15
+ # them), and this reads exactly those. The Gemfile keeps every
16
+ # dependency justified in its own comment; a full Markdown engine
17
+ # for six constructs would not earn one.
18
+ #
19
+ # The look lives in page.css / page.js beside this file — read at
20
+ # render time and inlined, so the page is one self-contained file.
21
+ # The only things it fetches are the two typefaces (Google Fonts)
22
+ # and Mermaid itself (cdnjs), which draws the diagrams in the
23
+ # browser; without a network the diagrams show as their source.
24
+ module Html
25
+ Block = Struct.new(:type, :text, :items, :level, keyword_init: true)
26
+
27
+ FONTS = "https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;" \
28
+ "0,6..72,600;1,6..72,400&family=Instrument+Sans:wght@400;500;600&display=swap".freeze
29
+ MERMAID = "https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js".freeze
30
+
31
+ module_function
32
+
33
+ def render(markdown)
34
+ blocks = parse(markdown)
35
+ slugs = heading_slugs(blocks)
36
+ title = blocks.find { |block| block.type == :heading && block.level == 1 }&.text.to_s
37
+ sections = split_sections(blocks)
38
+ page(title, sections, slugs)
39
+ end
40
+
41
+ # ── reading the Markdown ─────────────────────────────────────────
42
+
43
+ def parse(markdown)
44
+ lines = markdown.lines.map(&:chomp)
45
+ blocks = []
46
+ index = 0
47
+ while index < lines.size
48
+ line = lines[index]
49
+ if line.start_with?("```mermaid")
50
+ block, index = fence(lines, index)
51
+ blocks << block
52
+ elsif (heading = line.match(/\A(\#{1,3}) (.+)\z/))
53
+ blocks << Block.new(type: :heading, text: heading[2], level: heading[1].size)
54
+ index += 1
55
+ elsif line.start_with?("> ")
56
+ blocks << Block.new(type: :quote, text: line[2..])
57
+ index += 1
58
+ elsif line.start_with?("- ")
59
+ items, index = run(lines, index) { |text| text.start_with?("- ") }
60
+ blocks << Block.new(type: :list, items: items.map { |item| item[2..] })
61
+ elsif line.strip.empty?
62
+ index += 1
63
+ else
64
+ para, index = run(lines, index) { |text| !text.strip.empty? }
65
+ blocks << Block.new(type: :paragraph, text: para.join(" "))
66
+ end
67
+ end
68
+ blocks
69
+ end
70
+
71
+ # The mermaid source between the opening fence at `index` and its
72
+ # closing one, and the index just past that.
73
+ def fence(lines, index)
74
+ close = ((index + 1)...lines.size).find { |at| lines[at] == "```" } || lines.size
75
+ [Block.new(type: :mermaid, text: lines[(index + 1)...close].join("\n")), close + 1]
76
+ end
77
+
78
+ # The consecutive lines from `index` that satisfy the block, and
79
+ # the index just past them.
80
+ def run(lines, index)
81
+ taken = []
82
+ while index < lines.size && yield(lines[index])
83
+ taken << lines[index]
84
+ index += 1
85
+ end
86
+ [taken, index]
87
+ end
88
+
89
+ # The same slugs GitHub would give these headings, in the same
90
+ # order — so the links the Markdown carries land here too.
91
+ #
92
+ # KEYED BY THE BLOCK ITSELF, NOT ITS VALUE — a Struct compares by
93
+ # members, so two "### Open" headings in different sections would
94
+ # otherwise be one key, and the first would answer with the
95
+ # second's "-1" slug, leaving `#open` with nothing to land on.
96
+ def heading_slugs(blocks)
97
+ seen = Hash.new(0)
98
+ slugs = {}.compare_by_identity
99
+ blocks.select { |block| block.type == :heading }.each do |block|
100
+ base = Slugs.github(block.text)
101
+ seen[base] += 1
102
+ slugs[block] = seen[base] == 1 ? base : "#{base}-#{seen[base] - 1}"
103
+ end
104
+ slugs
105
+ end
106
+
107
+ # Everything before the first `##` is the front matter; each `##`
108
+ # opens a section that runs to the next.
109
+ def split_sections(blocks)
110
+ sections = [{ heading: nil, blocks: [] }]
111
+ blocks.each do |block|
112
+ sections << { heading: block, blocks: [] } if block.type == :heading && block.level == 2
113
+ sections.last[:blocks] << block
114
+ end
115
+ sections
116
+ end
117
+
118
+ # ── writing the page ─────────────────────────────────────────────
119
+
120
+ def page(title, sections, slugs)
121
+ front, *rest = sections
122
+ <<~HTML
123
+ <!doctype html>
124
+ <html lang="en">
125
+ <head>
126
+ <meta charset="utf-8">
127
+ <meta name="viewport" content="width=device-width, initial-scale=1">
128
+ <title>#{escape(title)}</title>
129
+ <link rel="stylesheet" href="#{FONTS}">
130
+ <script src="#{MERMAID}"></script>
131
+ <style>
132
+ #{asset('page.css')}
133
+ </style>
134
+ </head>
135
+ <body>
136
+ <div class="page">
137
+ #{rail(title, rest, slugs)}
138
+ <main>
139
+ #{front_matter(front[:blocks])}
140
+ #{rest.map { |section| section_html(section, slugs) }.join("\n")}
141
+ </main>
142
+ </div>
143
+ <script>
144
+ #{asset('page.js')}
145
+ </script>
146
+ </body>
147
+ </html>
148
+ HTML
149
+ end
150
+
151
+ def rail(title, sections, slugs)
152
+ domain = title.split(" — ").first
153
+ items = sections.map do |section|
154
+ heading = section[:heading]
155
+ %(<li><a href="##{slugs[heading]}">#{inline(heading.text)}</a></li>)
156
+ end
157
+ <<~HTML
158
+ <nav class="rail" aria-label="Sections">
159
+ <p class="domain">#{escape(domain)}</p>
160
+ <p class="sub">Glossary</p>
161
+ <input type="search" placeholder="Find a term" aria-label="Find a term" autocomplete="off">
162
+ <p class="count"></p>
163
+ <ol>
164
+ #{items.join("\n")}
165
+ </ol>
166
+ </nav>
167
+ HTML
168
+ end
169
+
170
+ def front_matter(blocks)
171
+ parts = ["<header>"]
172
+ blocks.each do |block|
173
+ case block.type
174
+ when :heading then parts << "<h1>#{inline(block.text.split(' — ').first)}</h1>"
175
+ when :quote then parts << "<p class=\"vision\">#{inline(block.text)}</p>"
176
+ when :paragraph then parts << "<p class=\"lede\">#{inline(block.text)}</p>"
177
+ when :mermaid then parts << figure(block, "How it all fits together")
178
+ end
179
+ end
180
+ parts << "</header>"
181
+ parts.join("\n")
182
+ end
183
+
184
+ # A section is its heading, then its opening (lede, diagrams, the
185
+ # rules list), then its terms — each `###` opens an article that
186
+ # runs to the next. A bold-only paragraph is the caption of
187
+ # whatever figure or list follows it.
188
+ def section_html(section, slugs)
189
+ heading = section[:heading]
190
+ state = { parts: [%(<section id="#{slugs[heading]}">), "<h2>#{inline(heading.text)}</h2>"],
191
+ caption: nil, in_terms: false }
192
+ section[:blocks].drop(1).each { |block| section_block(block, state, slugs) }
193
+ state[:parts] << "</article></div>" if state[:in_terms]
194
+ state[:parts] << "</section>"
195
+ state[:parts].join("\n")
196
+ end
197
+
198
+ def section_block(block, state, slugs)
199
+ parts = state[:parts]
200
+ case block.type
201
+ when :heading
202
+ parts << (state[:in_terms] ? "</article>" : "<div class=\"terms\">")
203
+ parts << %(<article class="term" id="#{slugs[block]}">) << "<h3>#{inline(block.text)}</h3>"
204
+ state[:in_terms] = true
205
+ when :mermaid
206
+ parts << figure(block, state.delete(:caption))
207
+ when :list
208
+ parts << "<p class=\"caption\">#{escape(state[:caption])}</p>" if state.delete(:caption)
209
+ parts << "<ul class=\"rules\">#{block.items.map { |item| "<li>#{inline(item)}</li>" }.join}</ul>"
210
+ when :quote
211
+ parts << "<p class=\"about\">#{inline(block.text)}</p>"
212
+ when :paragraph
213
+ paragraph(block.text, state)
214
+ end
215
+ end
216
+
217
+ def paragraph(text, state)
218
+ if text.match?(/\A\*\*[^*]+\*\*\z/)
219
+ state[:caption] = text.delete("*")
220
+ else
221
+ klass = text.start_with?("Always true:") ? " class=\"rule\"" : ""
222
+ state[:parts] << "<p#{klass}>#{inline(text)}</p>"
223
+ end
224
+ end
225
+
226
+ def figure(block, caption)
227
+ parts = ["<figure>"]
228
+ parts << "<figcaption>#{escape(caption)}</figcaption>" if caption
229
+ parts << "<pre class=\"mermaid\">#{escape(block.text)}</pre>" << "</figure>"
230
+ parts.join("\n")
231
+ end
232
+
233
+ # ── inline text ──────────────────────────────────────────────────
234
+
235
+ def escape(text) = Forms::Escape.html(text)
236
+
237
+ # Escape first, so nothing in the prose ever becomes a tag; then
238
+ # the three inline forms the Markdown uses, on the escaped text.
239
+ def inline(text)
240
+ escape(text)
241
+ .gsub(/\[([^\]]+)\]\(#([^)]+)\)/) { %(<a href="##{Regexp.last_match(2)}">#{Regexp.last_match(1)}</a>) }
242
+ .gsub(/\*\*(.+?)\*\*/) { "<strong>#{Regexp.last_match(1)}</strong>" }
243
+ .gsub(/\*(.+?)\*/) { "<em>#{Regexp.last_match(1)}</em>" }
244
+ end
245
+
246
+ def asset(name) = File.read(File.join(__dir__, name))
247
+ end
248
+ end
249
+ end
250
+ end