hecks 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
data/lib/hecks/naming.rb CHANGED
@@ -61,7 +61,21 @@ module Hecks
61
61
  def singularize(text)
62
62
  word = text.to_s
63
63
  return "#{word[0..-4]}y" if word.length > 3 && word.end_with?("ies")
64
- return word[0..-2] if word.length > 1 && word.end_with?("s")
64
+
65
+ # `plural`'s OWN second rule adds "es" (not bare "s") after
66
+ # s/x/z/ch/sh — undone here the same way, or a word `plural`
67
+ # itself would have suffixed with "es" comes back missing its
68
+ # own trailing letter ("Boxes" -> "Boxe", not "Box") once this
69
+ # only ever knew how to drop a bare "s". Checked BEFORE the
70
+ # bare-"s" rule below: stripping "es" first and confirming what
71
+ # is left actually ends in one of those five shapes is what
72
+ # keeps an ordinary "-es" word (e.g. "Invoices" -> "Invoice")
73
+ # from also losing a letter it never doubled.
74
+ if word.length > 3 && word.end_with?("es") && word[0..-3].match?(/(s|x|z|ch|sh)\z/)
75
+ return word[0..-3]
76
+ end
77
+
78
+ return word[0..-2] if word.length > 1 && word.end_with?("s")
65
79
 
66
80
  word
67
81
  end
@@ -117,5 +131,44 @@ module Hecks
117
131
  path, _, command = text.rpartition("::")
118
132
  path.empty? ? text : "#{path}.#{command}"
119
133
  end
134
+
135
+ # `emits Account::AccountFrozen` / `on Account::AccountFrozen` — the
136
+ # event-side twin of `command_ref`, above (ADR 0025, S6 — "events
137
+ # first-class"). Identical transform (a bare `ScopedConstant`'s last
138
+ # `::` becomes `.`, a String passes through unchanged for legacy
139
+ # `shadow_parse` text and for corpus sites this pass didn't migrate —
140
+ # see that method's own comment for why both rules exist), given its
141
+ # own name because the two references mean different things even
142
+ # though the rewrite is byte-identical: an event name is not a
143
+ # command name that happens to share a format.
144
+ def event_ref(value) = command_ref(value)
145
+
146
+ # `transition Account::AccountDebited => "state"` / `starts_on
147
+ # Transfer::TransferRequested` / `ends_on Transfer::TransferSettled`
148
+ # — a process manager's OWN event references (ADR 0025, S6),
149
+ # DELIBERATELY NOT `event_ref` — found live, not assumed, wiring a
150
+ # real migrated corpus site into `bin/model_check` for the first
151
+ # time (2026-08-28): `SagaInterpreter#begin_saga`/`#advance_saga`
152
+ # match `pm.starts_on`/`pm.handler_for` against `event.name`, which
153
+ # `CommandRules::Emission#emit` stamps BARE — a command's own
154
+ # `emits AccountDebited` never carries its aggregate's name at all
155
+ # (unlike a policy's cross-aggregate `on`, matched instead by
156
+ # `Naming.demodulise(event.aggregate)` split apart from the bare
157
+ # name — `PolicyInterpreter#policies_for`). Handing a saga's own
158
+ # matcher the DOTTED `event_ref` form ("Account.AccountDebited")
159
+ # would silently name an event no command in the domain ever
160
+ # actually emits — caught by `bin/model_check`'s own `deaf_handler`/
161
+ # `deaf_trigger` findings the moment a real qualified corpus site
162
+ # existed to trip them, not by any unit test in isolation.
163
+ #
164
+ # A qualifier is still worth WRITING (`Account::`) — the same
165
+ # provenance a reader gets from `trigger Account::Debit` — it is
166
+ # only not worth KEEPING: `demodulise` drops everything but the
167
+ # final segment, so `Account::AccountDebited` and a bare
168
+ # `AccountDebited` resolve to the identical stored string. A String
169
+ # passes through unchanged either way, exactly like `command_ref`'s
170
+ # own legacy branch — this corpus never spelled one dotted to begin
171
+ # with, so there is nothing here to strip.
172
+ def event_name_ref(value) = demodulise(value)
120
173
  end
121
174
  end
@@ -1,4 +1,9 @@
1
1
  Hecks.port "access_control" do
2
- verb "granted_by"
3
- signal :reply
2
+ verb "granted_by"
3
+ signal :reply
4
+ answers :session_for_identity
5
+ answers :provision
6
+ answers :available_roles
7
+ answers :grant
8
+ answers :all_people
4
9
  end
@@ -47,7 +47,7 @@ module Hecks
47
47
  implementations = registry.adapters.values.select { |a| a.port == NAME }
48
48
 
49
49
  case implementations.size
50
- when 1 then Adapters.const_get(implementations.first.name)
50
+ when 1 then registry.adapter_class(implementations.first.name)
51
51
  when 0
52
52
  raise Runtime::WiringError,
53
53
  "no adapter implements the #{NAME} port — nothing can answer who's allowed in"
@@ -1,4 +1,8 @@
1
1
  Hecks.port "agent" do
2
- verb "asked_by"
3
- signal :reply
2
+ verb "asked_by"
3
+ signal :reply
4
+ answers :ask
5
+ answers :interpret
6
+ answers :critique
7
+ answers :suggest_name
4
8
  end
@@ -150,7 +150,7 @@ module Hecks
150
150
  implementations = registry.adapters.values.select { |a| a.port == NAME }
151
151
 
152
152
  case implementations.size
153
- when 1 then Adapters.const_get(implementations.first.name)
153
+ when 1 then registry.adapter_class(implementations.first.name)
154
154
  when 0
155
155
  raise Runtime::WiringError,
156
156
  "no adapter implements the #{NAME} port — nothing can conduct an interview"
@@ -1,4 +1,6 @@
1
1
  Hecks.port "authentication" do
2
- verb "authenticated_by"
3
- signal :reply
2
+ verb "authenticated_by"
3
+ signal :reply
4
+ answers :authorization_url
5
+ answers :verify
4
6
  end
@@ -35,7 +35,7 @@ module Hecks
35
35
  implementations = registry.adapters.values.select { |a| a.port == NAME }
36
36
 
37
37
  case implementations.size
38
- when 1 then Adapters.const_get(implementations.first.name)
38
+ when 1 then registry.adapter_class(implementations.first.name)
39
39
  when 0
40
40
  raise Runtime::WiringError,
41
41
  "no adapter implements the #{NAME} port — nothing can authenticate a sign-in"
@@ -1,4 +1,7 @@
1
1
  Hecks.port "authorization" do
2
- verb "authorized_by"
3
- signal :reply
2
+ verb "authorized_by"
3
+ signal :reply
4
+ answers :holds_role?
5
+ answers :authorized_as?
6
+ answers :live_role_for
4
7
  end
@@ -15,21 +15,24 @@ module Hecks
15
15
  # A caller decides what to DO with the answer — dispatch under that
16
16
  # role, refuse, log, prefer it over some other fallback value — this
17
17
  # only answers the question asked. Nothing here binds a
18
- # `Runtime::Caller`, and nothing here is consulted by
19
- # `CommandRules::Authorization`: that rule still only compares an
20
- # ALREADY-BOUND caller's role against a command's, the same as it
21
- # always has. `spec/act_as_spec.rb` remains the precedent for the
22
- # OTHER shape two separate registries, queried directly by name —
23
- # for whenever Governance is not in the same boot as the caller;
24
- # this port is the same two questions asked through one adapter
25
- # when it is.
18
+ # `Runtime::Caller`.
19
+ #
20
+ # STALE AS OF THE `as_of`/`scope` split below, this used to also say
21
+ # nothing here is consulted by `CommandRules::Authorization` it now
22
+ # is: `refuse_role_mismatch` calls `holds_role?` directly, once a
23
+ # caller binds an `actor_id` and the command's domain has Governance
24
+ # attached (see that rule's own header). `spec/act_as_spec.rb`
25
+ # remains the precedent for the OTHER shape — two separate
26
+ # registries, queried directly by name — for whenever Governance is
27
+ # not in the same boot as the caller; this port is the same
28
+ # questions asked through one adapter when it is.
26
29
  module Authorization
27
30
  NAME = "authorization"
28
31
 
29
32
  module_function
30
33
 
31
- def holds_role?(registry, actor_id:, role:)
32
- adapter(registry).holds_role?(registry, actor_id: actor_id, role: role)
34
+ def holds_role?(registry, actor_id:, role:, as_of: nil, scope: nil)
35
+ adapter(registry).holds_role?(registry, actor_id: actor_id, role: role, as_of: as_of, scope: scope)
33
36
  end
34
37
 
35
38
  def authorized_as?(registry, from_role:, to_role:)
@@ -44,7 +47,7 @@ module Hecks
44
47
  implementations = registry.adapters.values.select { |a| a.port == NAME }
45
48
 
46
49
  case implementations.size
47
- when 1 then Adapters.const_get(implementations.first.name)
50
+ when 1 then registry.adapter_class(implementations.first.name)
48
51
  when 0
49
52
  raise Runtime::WiringError,
50
53
  "no adapter implements the #{NAME} port — nothing can answer a role check"
@@ -1,4 +1,5 @@
1
1
  Hecks.port "clock" do
2
- verb "timed_by"
3
- signal :reply
2
+ verb "timed_by"
3
+ signal :reply
4
+ answers :now
4
5
  end
@@ -47,7 +47,7 @@ module Hecks
47
47
  implementations = registry.adapters.values.select { |a| a.port == NAME }
48
48
 
49
49
  case implementations.size
50
- when 1 then Adapters.const_get(implementations.first.name)
50
+ when 1 then registry.adapter_class(implementations.first.name)
51
51
  when 0
52
52
  raise Runtime::WiringError,
53
53
  "no adapter implements the #{NAME} port — nothing can say what time it is"
@@ -1,4 +1,5 @@
1
1
  Hecks.port "extraction" do
2
- verb "extracted_by"
3
- signal :reply
2
+ verb "extracted_by"
3
+ signal :reply
4
+ answers :canonical
4
5
  end
@@ -20,7 +20,7 @@ module Hecks
20
20
  implementations = registry.adapters.values.select { |a| a.port == NAME }
21
21
 
22
22
  case implementations.size
23
- when 1 then Adapters.const_get(implementations.first.name)
23
+ when 1 then registry.adapter_class(implementations.first.name)
24
24
  when 0
25
25
  raise Runtime::WiringError,
26
26
  "no adapter implements the #{NAME} port — nothing can recover a " \
@@ -1,4 +1,5 @@
1
1
  Hecks.port "identity_assignment" do
2
- verb "assigned_by"
3
- signal :reply
2
+ verb "assigned_by"
3
+ signal :reply
4
+ answers :assign
4
5
  end
@@ -30,7 +30,7 @@ module Hecks
30
30
  implementations = registry.adapters.values.select { |a| a.port == NAME }
31
31
 
32
32
  case implementations.size
33
- when 1 then Adapters.const_get(implementations.first.name)
33
+ when 1 then registry.adapter_class(implementations.first.name)
34
34
  when 0
35
35
  raise Runtime::WiringError,
36
36
  "no adapter implements the #{NAME} port — nothing can assign an identity"
@@ -1,4 +1,5 @@
1
1
  Hecks.port "identity_generation" do
2
- verb "generated_by"
3
- signal :reply
2
+ verb "generated_by"
3
+ signal :reply
4
+ answers :uuid
4
5
  end
@@ -34,7 +34,7 @@ module Hecks
34
34
  implementations = registry.adapters.values.select { |a| a.port == NAME }
35
35
 
36
36
  case implementations.size
37
- when 1 then Adapters.const_get(implementations.first.name)
37
+ when 1 then registry.adapter_class(implementations.first.name)
38
38
  when 0
39
39
  raise Runtime::WiringError,
40
40
  "no adapter implements the #{NAME} port — nothing can mint an identity"
@@ -1,4 +1,5 @@
1
1
  Hecks.port "identity_resolution" do
2
- verb "resolved_by"
3
- signal :reply
2
+ verb "resolved_by"
3
+ signal :reply
4
+ answers :resolve
4
5
  end
@@ -25,7 +25,7 @@ module Hecks
25
25
  implementations = registry.adapters.values.select { |a| a.port == NAME }
26
26
 
27
27
  case implementations.size
28
- when 1 then Adapters.const_get(implementations.first.name)
28
+ when 1 then registry.adapter_class(implementations.first.name)
29
29
  when 0
30
30
  raise Runtime::WiringError,
31
31
  "no adapter implements the #{NAME} port — nothing can resolve an identity"
@@ -64,11 +64,32 @@ module Hecks
64
64
  def append(entry) = @adapter.append(entry)
65
65
  def project(entry) = @adapter.project(entry)
66
66
 
67
- def save(instance)
67
+ # Returns an `Outcome`, not a bare `Instance` — every call site
68
+ # (`CommandInterpreter`/`EntityInterpreter`'s own `step_save`,
69
+ # `RebuildSweep#refresh`) reads it that way.
70
+ #
71
+ # `expected_version:` requests optimistic-concurrency CAS — commit
72
+ # only if the stored record's version still matches what THIS
73
+ # instance was read at. It is `nil` both when a caller explicitly
74
+ # doesn't want CAS (`RebuildSweep#refresh`'s own projection-field
75
+ # touch-up, which has no `given` to protect) and when the instance
76
+ # is brand new (never read from storage, so `instance.version` is
77
+ # nil) — both cases fall through to the plain, unconditional
78
+ # `project(entry)` below, byte-for-byte today's behavior. Only an
79
+ # adapter that both receives a non-nil `expected_version` AND
80
+ # declares `:optimistic_concurrency` gets CAS treatment; every
81
+ # other adapter/call site is unaffected.
82
+ def save(instance, expected_version: nil)
68
83
  entry = Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
69
84
  append(entry)
70
- project(entry)
71
- instance
85
+ if expected_version && capabilities.include?(:optimistic_concurrency)
86
+ saved = @adapter.project(entry, expected_version: expected_version)
87
+ return Outcome.new(status: :stale, instance: instance) if saved.nil?
88
+
89
+ return Outcome.new(status: :saved, instance: saved)
90
+ end
91
+ saved = @adapter.project(entry)
92
+ Outcome.new(status: :saved, instance: saved || instance)
72
93
  end
73
94
 
74
95
  def atomic_put(instance, insert_only: false)
@@ -90,7 +111,22 @@ module Hecks
90
111
  true
91
112
  end
92
113
 
93
- def record_event(event) = @adapter.record_event(event) if @adapter.respond_to?(:record_event)
114
+ # NOT an endless `def record_event = ... if ...` — same gotcha as
115
+ # `events` above, and it bit for real here: this guard evaluated
116
+ # against `@adapter` at class-body time (nil, always false), so
117
+ # `record_event` was never defined at all. `emission.rb`'s own
118
+ # `repository.record_event(event) if repository.respond_to?(:record_event)`
119
+ # therefore never fired for any adapter, ever — every declared
120
+ # `emits` was computed and reported in `registry.event_log` (an
121
+ # in-process array, gone at exit) but never durably recorded.
122
+ # Caught because a live tail of a domain's own persisted events
123
+ # found nothing to tail. `sqlite_spec.rb`/`postgres_spec.rb`/
124
+ # `postgres_era_spec.rb` all call `adapter.record_event` directly,
125
+ # bypassing this wrapper — which is exactly why no spec noticed.
126
+ def record_event(event)
127
+ @adapter.record_event(event) if @adapter.respond_to?(:record_event)
128
+ end
129
+
94
130
  def query_read_model(domain, model, args, bluebook = nil)
95
131
  return unless @adapter.respond_to?(:query_read_model)
96
132
 
@@ -3,7 +3,12 @@ module Hecks
3
3
  module Persistence
4
4
  # Adapter outcomes are runtime facts, never command lifecycle.
5
5
  Outcome = Struct.new(:status, :instance, keyword_init: true) do
6
- STATUSES = %i[inserted replaced updated conflicted missing saved].freeze
6
+ # `:stale` is an optimistic-concurrency conflict on a plain `save`
7
+ # (someone else committed since this instance was read) — never a
8
+ # domain refusal, distinct from `:conflicted` (a `creates?`
9
+ # command's identity already exists, `AlreadyExists`). See
10
+ # `Runtime::StaleWrite` (runtime/errors.rb) and `AppendOnly#save`.
11
+ STATUSES = %i[inserted replaced updated conflicted missing saved stale].freeze
7
12
 
8
13
  def initialize(status:, instance: nil)
9
14
  normalized = status.to_sym
@@ -0,0 +1,54 @@
1
+ module Hecks
2
+ module Ports
3
+ module Persistence
4
+ # A tiny, real seam — ADR 0033. Core never names a persistence plugin;
5
+ # a plugin makes itself known as a side effect of being `require`d, the
6
+ # same way an adapter's own `.rb` file already registers itself
7
+ # (`Runtime::Registry#add_adapter`) rather than core listing adapter
8
+ # names anywhere. `register_plugin` is the ENTIRE contract a plugin
9
+ # must satisfy: anything responding to `contribute_boot_gates(registry,
10
+ # gates)` (a no-op is a valid implementation).
11
+ #
12
+ # This is deliberately NOT the same registry `Hecks::Projector` uses
13
+ # (ADR 0027) or `Runtime::BootGates` (ADR 0031) — each of those is
14
+ # scoped to its own seam (IR-in/artifact-out; per-boot phased gates).
15
+ # A shared base is deferred until a third registry actually wants one.
16
+ #
17
+ # NAME-KEYED, PROCESS-WIDE, NOT PER-BOOT — unlike `BootGates` (one
18
+ # instance per `Loader.boot` call, because gate REGISTRATION is
19
+ # capability-conditional per registry), a persistence plugin is either
20
+ # `require`d into this process or it isn't; there is no "this boot's
21
+ # registry doesn't need it" case to isolate against, so a plain
22
+ # module-level hash is the right shape here, not an instantiable class.
23
+ module Plugin
24
+ @plugins = {}
25
+
26
+ class << self
27
+ def register(name, plugin)
28
+ @plugins[name.to_sym] = plugin
29
+ end
30
+
31
+ def registered?(name)
32
+ @plugins.key?(name.to_sym)
33
+ end
34
+
35
+ def each(&)
36
+ @plugins.each_value(&)
37
+ end
38
+
39
+ def any? = !@plugins.empty?
40
+ end
41
+ end
42
+
43
+ # `Ports::Persistence.plugin?(:era)` / `.each_plugin` — the ordinary
44
+ # call surface; `Plugin.register`/`.registered?`/`.each` stay reachable
45
+ # directly for a plugin's own file to call at require-time.
46
+ module_function
47
+
48
+ def register_plugin(name, plugin) = Plugin.register(name, plugin)
49
+ def plugin?(name) = Plugin.registered?(name)
50
+ def each_plugin(&) = Plugin.each(&)
51
+ def plugins_loaded? = Plugin.any?
52
+ end
53
+ end
54
+ end
@@ -1,9 +1,9 @@
1
- require_relative "../ports/persistence"
2
- require_relative "../ports/persistence/binding_policy"
3
- require_relative "../ports/persistence/lineage"
4
- require_relative "../naming"
5
- require_relative "../framework"
6
- require_relative "registry"
1
+ require_relative "../../../../ports/persistence"
2
+ require_relative "../../../../ports/persistence/binding_policy"
3
+ require_relative "lineage"
4
+ require_relative "../../../../naming"
5
+ require_relative "../../../../framework"
6
+ require_relative "../../../../runtime/registry"
7
7
 
8
8
  module Hecks
9
9
  module Runtime
@@ -44,11 +44,46 @@ module Hecks
44
44
  # the first refuses toward a scaffold that was never the real
45
45
  # drift.
46
46
  def check!(registry, directory)
47
+ check_compute_rules_for_registry!(registry)
48
+ check_lineage!(registry, directory)
49
+ end
50
+
51
+ # The domain-agnostic half, split out for ADR 0031's boot-gate
52
+ # registry: a compute rule requires Postgres whatever adapter is
53
+ # actually bound, so this must run for EVERY registry, the same way
54
+ # `registry.verify!` does — it is not conditional on any adapter
55
+ # being lineage-capable, and must never be skipped by
56
+ # `check_lineage!`'s own capability gate below.
57
+ def check_compute_rules_for_registry!(registry)
58
+ registry.bluebooks.each_value { |bluebook| check_compute_rules!(registry, bluebook) }
59
+ end
60
+
61
+ # The capability-gated half — ADR 0031's registered `:era_check`
62
+ # gate. Registration is conditional on `lineage_capable_registry?`;
63
+ # `check_bluebook!` below still carries its own per-bluebook
64
+ # `lineage_capable?` return-early, unchanged, for a registry with a
65
+ # mix of lineage-capable and plain-adapter bluebooks.
66
+ def check_lineage!(registry, directory)
47
67
  registry.bluebooks.each_value do |bluebook|
48
68
  check_bluebook!(registry, bluebook, source_text_for(bluebook, directory), directory: directory)
49
69
  end
50
70
  end
51
71
 
72
+ # The `:era_check` gate's own registration predicate: true iff at
73
+ # least one bluebook's own anchor (first) aggregate resolves to a
74
+ # lineage-capable adapter — mirrors `check_bluebook!`'s existing
75
+ # per-bluebook anchor check, just asked once, up front, of the
76
+ # whole registry, so a registry with nothing lineage-capable bound
77
+ # anywhere never registers the gate at all.
78
+ def lineage_capable_registry?(registry)
79
+ registry.bluebooks.each_value.any? do |bluebook|
80
+ first = bluebook.aggregates.first
81
+ next false unless first
82
+
83
+ lineage_capable?(registry, adapter_for(registry, bluebook.name, first))
84
+ end
85
+ end
86
+
52
87
  # The domain's own directory first, matched by name — a real app's
53
88
  # directory may hold more than one file once `uses_framework`
54
89
  # exists, so ".first" alone can no longer be trusted, the exact
@@ -80,8 +115,6 @@ module Hecks
80
115
  end
81
116
 
82
117
  def check_bluebook!(registry, bluebook, current_text, directory: nil)
83
- check_compute_rules!(registry, bluebook)
84
-
85
118
  first = bluebook.aggregates.first
86
119
  return unless first
87
120
 
@@ -1,67 +1,34 @@
1
- require "fileutils"
2
1
  require_relative "era_guard/shape_diff"
3
- require_relative "../bluebook/dsl/malformed"
4
- require_relative "../bluebook/meta_validator"
5
- require_relative "../naming"
6
- require_relative "../ports/loading"
7
- require_relative "../ports/persistence/lineage"
8
- require_relative "era_check"
9
- require_relative "registry"
2
+ require_relative "../../../../bluebook/dsl/malformed"
3
+ require_relative "../../../../bluebook/meta_validator"
4
+ require_relative "../../../../ports/loading"
5
+ require_relative "../../../../runtime/registry"
10
6
 
11
7
  module Hecks
12
8
  module Runtime
13
- # Detects when a bluebook's storage shape has drifted from what its data
14
- # was written under — a rename or restructure with nothing to explain
15
- # it. Refuses at boot, before a command can run into the gap and raise
16
- # something that only makes sense once you already suspect a rename.
9
+ # The shape-drift coverage primitives ADR 0032. Not a driver: nothing
10
+ # here walks a registry or reads/writes a held snapshot on its own.
11
+ # `PostgresEra::LineageManager::CoverageCheck` calls `uncovered_
12
+ # attributes`/`unsafe_additions`/`refuse_uncovered!`/`refuse_unsafe_
13
+ # addition!`/`check_vanished_aggregates!` directly, per translation
14
+ # edge, over its own DB-held shapes; `Translation::Reattest` and
15
+ # `PostgresEra::LineageManager` call `shadow_parse` directly, to read
16
+ # historical bluebook text under old grammar defaults. Both are real,
17
+ # independent, currently-shipped consumers.
17
18
  #
18
- # The held source is a snapshot of the bluebook text as it stood the
19
- # last time its shape was accepted. It updates only when a drift is
20
- # seen AND covered by a translation never on every boot, and never
21
- # silently when a rename has nothing to explain it.
19
+ # A prior version of this module ALSO drove its own top-level check
20
+ # `check!`/`check_bluebook!`, walking a registry and reading/writing a
21
+ # held snapshot under `data/eras/*.bluebook`duplicating, on its own,
22
+ # the same per-aggregate walk `CoverageCheck` already performs against
23
+ # `PostgresEra`'s own DB-held shapes. Nothing in production ever called
24
+ # it (only a direct unit spec did); deleted rather than kept unwired,
25
+ # per ADR 0032. Wanted again, it's rebuilt informed by `CoverageCheck`'s
26
+ # real orchestration, not resurrected from here.
22
27
  module EraGuard
23
28
  extend ShapeDiff
24
29
 
25
30
  module_function
26
31
 
27
- def check!(registry, directory)
28
- registry.bluebooks.each_value { |bluebook| check_bluebook!(registry, bluebook, directory) }
29
- end
30
-
31
- def check_bluebook!(registry, bluebook, directory)
32
- current_text = EraCheck.source_text_for(bluebook, directory)
33
- return unless current_text
34
-
35
- era_dir = File.join(registry.root, "data", "eras")
36
- held_path = File.join(era_dir, "#{Naming.snake(bluebook.name)}.bluebook")
37
-
38
- FileUtils.mkdir_p(era_dir)
39
- unless File.exist?(held_path)
40
- File.write(held_path, current_text)
41
- return
42
- end
43
-
44
- held_bluebook = shadow_parse(File.read(held_path, encoding: "UTF-8"), held_path)
45
- drifted = false
46
-
47
- bluebook.aggregates.each do |aggregate|
48
- lineage = Ports::Persistence::Lineage.for(registry, bluebook.name, aggregate)
49
- held_aggregate = held_bluebook.aggregate(lineage&.ancestor_name || aggregate.name)
50
- next unless held_aggregate
51
- next if shape(aggregate) == shape(held_aggregate)
52
-
53
- drifted = true
54
- uncovered = uncovered_attributes(aggregate, held_aggregate, lineage)
55
- refuse_uncovered!(bluebook, aggregate, uncovered) unless uncovered.empty?
56
-
57
- unsafe = unsafe_additions(aggregate, held_aggregate, lineage)
58
- refuse_unsafe_addition!(bluebook, aggregate, unsafe) unless unsafe.empty?
59
- end
60
-
61
- check_vanished_aggregates!(registry, bluebook, held_bluebook)
62
- File.write(held_path, current_text) if drifted
63
- end
64
-
65
32
  # An aggregate that existed in the held text and answers to no
66
33
  # current name — renamed silently, with nothing declaring `was:` to
67
34
  # explain where its data went — is exactly the disease this guards
@@ -87,8 +54,9 @@ module Hecks
87
54
  end
88
55
  end
89
56
 
90
- # The Layer-1 coverage refusal — one wording, whoever detects the
91
- # gap (the file-store boot check here, or Postgres's mint path).
57
+ # The Layer-1 coverage refusal — one wording, shared with whoever
58
+ # calls it (today, `PostgresEra::LineageManager::CoverageCheck`'s
59
+ # own mint-time coverage check).
92
60
  def refuse_uncovered!(bluebook, aggregate, uncovered)
93
61
  raise WiringError,
94
62
  "cannot boot #{bluebook.name}::#{aggregate.name}: its shape changed and " \
@@ -1,6 +1,6 @@
1
- require_relative "../../naming"
2
- require_relative "append_only"
3
- require_relative "../../runtime/registry"
1
+ require_relative "../../../../naming"
2
+ require_relative "../../append_only"
3
+ require_relative "../../../../runtime/registry"
4
4
 
5
5
  module Hecks
6
6
  module Ports
@@ -91,7 +91,7 @@ module Hecks
91
91
  # value-object hash, and a shallow dup would quietly mutate
92
92
  # the caller's copy of the original entry.
93
93
  state = deep_dup(entry.state)
94
- @renames.each { |old_name, new_name| state[new_name] = state.delete(old_name) if state.key?(old_name) }
94
+ apply_renames(state, @renames)
95
95
  @moves.each { |move| apply_move(state, move) }
96
96
  @converts.each { |convert| apply_convert(state, convert) }
97
97
  @drops.each { |name| apply_drop(state, name) }
@@ -183,6 +183,33 @@ module Hecks
183
183
  end
184
184
  end
185
185
 
186
+ # M27 (docs/audits/2026-08-10-main-bug-audit.md,
187
+ # docs/audits/2026-08-11-bug-triage.md) — SIMULTANEOUS, not
188
+ # sequential: `state[new] = state.delete(old)` per rename, run
189
+ # one rule at a time against the SAME hash it was reading from,
190
+ # loses data the instant one rule's destination is another
191
+ # rule's source. A swap (`rename :a, to: :b` alongside
192
+ # `rename :b, to: :a`) on `{a: 1, b: 2}` used to produce
193
+ # `{a: 1}` — the first rule wrote `b: 1` over the real `b: 2`
194
+ # before the second rule ever got a chance to read it, and the
195
+ # value the whole edge was supposed to preserve (2, moved to
196
+ # `:a`) was gone. The standard fix: snapshot every rule's OLD
197
+ # key and value from `state` FIRST, then remove every old key
198
+ # and only THEN write every new key — a rename never reads a
199
+ # key this same pass has already written to, so a swap or a
200
+ # longer chain applies as one permutation, not a sequence of
201
+ # edits each stepping on the last.
202
+ def apply_renames(state, renames)
203
+ snapshot = renames.filter_map { |old_name, new_name| [old_name, new_name, state[old_name]] if state.key?(old_name) }
204
+ snapshot.each { |old_name, _new_name, _value| state.delete(old_name) }
205
+ # rubocop:disable Style/CombinableLoops -- NOT combinable: a swap
206
+ # (:a<->:b) needs every delete done before any write, or the first
207
+ # rename's write becomes the second rename's delete target — see
208
+ # this method's own comment above.
209
+ snapshot.each { |_old_name, new_name, value| state[new_name] = value }
210
+ # rubocop:enable Style/CombinableLoops
211
+ end
212
+
186
213
  def apply_drop(state, name)
187
214
  name = name.to_s
188
215
  top, member = name.split(".", 2)