hecks 0.3.0 → 1.0.1

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 (202) 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/sql_query_builder.rb +1 -1
  14. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  15. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  16. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  17. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  18. data/lib/hecks/adapters/driven.rb +19 -1
  19. data/lib/hecks/behaviors/dsl.rb +29 -0
  20. data/lib/hecks/behaviors/expectations.rb +62 -2
  21. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  22. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  23. data/lib/hecks/bluebook/assembly.rb +14 -1
  24. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  25. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  26. data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
  27. data/lib/hecks/bluebook/chapter.rb +21 -11
  28. data/lib/hecks/bluebook/command.rb +1 -1
  29. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  30. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  31. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  32. data/lib/hecks/bluebook/dsl/command_builder.rb +144 -3
  33. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
  34. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  35. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  36. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  37. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  39. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  40. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  41. data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
  42. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  43. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  44. data/lib/hecks/bluebook/expression/projection.json +48 -0
  45. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  46. data/lib/hecks/bluebook/expression.rb +1 -0
  47. data/lib/hecks/bluebook/hexagon.rb +1 -1
  48. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  49. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  50. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  51. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  52. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  53. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  54. data/lib/hecks/bluebook/model_check.rb +147 -6
  55. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  56. data/lib/hecks/bluebook/process_manager.rb +53 -11
  57. data/lib/hecks/bluebook/project_register.rb +25 -0
  58. data/lib/hecks/bluebook/value_object.rb +19 -2
  59. data/lib/hecks/doc/reference.rb +22 -1
  60. data/lib/hecks/facade/cli_door.rb +6 -3
  61. data/lib/hecks/facade/json_door.rb +16 -4
  62. data/lib/hecks/forms/app.rb +47 -6
  63. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  64. data/lib/hecks/forms/field_renderer.rb +11 -4
  65. data/lib/hecks/forms/html.rb +31 -0
  66. data/lib/hecks/forms/params.rb +30 -1
  67. data/lib/hecks/forms/port_argument.rb +46 -0
  68. data/lib/hecks/forms/record_renderer.rb +6 -2
  69. data/lib/hecks/forms/record_table.rb +6 -1
  70. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  71. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  72. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  73. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  74. data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
  75. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  76. data/lib/hecks/fuzzing/properties.rb +52 -6
  77. data/lib/hecks/fuzzing/replay.rb +51 -18
  78. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  79. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  80. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  81. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  82. data/lib/hecks/grammar/evolve.rb +33 -0
  83. data/lib/hecks/grammar/expression.bluebook +8 -5
  84. data/lib/hecks/grammar/expression_operators.json +497 -1
  85. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  86. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  87. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  88. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  89. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  90. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  91. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  92. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  93. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  94. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  95. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  96. data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
  97. data/lib/hecks/language/oidc.json +20 -0
  98. data/lib/hecks/language/port.bluebook +30 -2
  99. data/lib/hecks/naming.rb +54 -1
  100. data/lib/hecks/ports/access_control.port +7 -2
  101. data/lib/hecks/ports/access_control.rb +1 -1
  102. data/lib/hecks/ports/agent.port +6 -2
  103. data/lib/hecks/ports/agent.rb +1 -1
  104. data/lib/hecks/ports/authentication.port +4 -2
  105. data/lib/hecks/ports/authentication.rb +1 -1
  106. data/lib/hecks/ports/authorization.port +5 -2
  107. data/lib/hecks/ports/authorization.rb +14 -11
  108. data/lib/hecks/ports/clock.port +3 -2
  109. data/lib/hecks/ports/clock.rb +1 -1
  110. data/lib/hecks/ports/extraction.port +3 -2
  111. data/lib/hecks/ports/extraction.rb +1 -1
  112. data/lib/hecks/ports/identity_assignment.port +3 -2
  113. data/lib/hecks/ports/identity_assignment.rb +1 -1
  114. data/lib/hecks/ports/identity_generation.port +3 -2
  115. data/lib/hecks/ports/identity_generation.rb +1 -1
  116. data/lib/hecks/ports/identity_resolution.port +3 -2
  117. data/lib/hecks/ports/identity_resolution.rb +1 -1
  118. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  119. data/lib/hecks/ports/persistence/execution.rb +6 -1
  120. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  121. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  122. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  123. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  129. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  130. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  131. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  132. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  133. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  134. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  135. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  136. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  137. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  138. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  139. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  140. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  141. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  142. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  143. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  144. data/lib/hecks/ports/persistence.rb +1 -1
  145. data/lib/hecks/ports/projection.rb +20 -0
  146. data/lib/hecks/projections/diagrams.rb +230 -1
  147. data/lib/hecks/projections/shape.rb +15 -2
  148. data/lib/hecks/projector/cli_projector.rb +10 -1
  149. data/lib/hecks/projector/exporter.rb +19 -2
  150. data/lib/hecks/query_ir.rb +19 -0
  151. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  152. data/lib/hecks/query_specification/field_path.rb +20 -2
  153. data/lib/hecks/query_specification/hop_path.rb +7 -5
  154. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  155. data/lib/hecks/runtime/boot_gates.rb +41 -0
  156. data/lib/hecks/runtime/caller.rb +19 -3
  157. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  158. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  159. data/lib/hecks/runtime/command_interpreter.rb +165 -34
  160. data/lib/hecks/runtime/command_rules/admissibility.rb +101 -16
  161. data/lib/hecks/runtime/command_rules/arithmetic.rb +40 -4
  162. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  163. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  164. data/lib/hecks/runtime/dependency_planning.rb +66 -5
  165. data/lib/hecks/runtime/dispatcher.rb +19 -4
  166. data/lib/hecks/runtime/entity_interpreter.rb +92 -14
  167. data/lib/hecks/runtime/errors.rb +22 -0
  168. data/lib/hecks/runtime/identity.rb +30 -2
  169. data/lib/hecks/runtime/instance.rb +59 -4
  170. data/lib/hecks/runtime/interpreting.rb +21 -0
  171. data/lib/hecks/runtime/loader.rb +59 -18
  172. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  173. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  174. data/lib/hecks/runtime/read_model_interpreter.rb +89 -5
  175. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  176. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  177. data/lib/hecks/runtime/registry/verification.rb +88 -0
  178. data/lib/hecks/runtime/registry.rb +69 -8
  179. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  180. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  181. data/lib/hecks/runtime/value/admission.rb +19 -1
  182. data/lib/hecks/runtime/value/coercion.rb +75 -10
  183. data/lib/hecks/runtime.rb +17 -5
  184. data/lib/hecks/storehouse.rb +632 -0
  185. data/lib/hecks/version.rb +1 -1
  186. data/lib/hecks/vocabulary.rb +6 -1
  187. data/lib/hecks.rb +28 -3
  188. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  189. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  190. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  191. metadata +49 -37
  192. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  193. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  194. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  195. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  196. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  197. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  198. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  199. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  200. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  201. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  202. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -3,7 +3,16 @@ require_relative "../ir"
3
3
 
4
4
  module Hecks
5
5
  module Bluebook
6
- DispatchSpec = Struct.new(:command_name, :with_spec, keyword_init: true) do
6
+ # `compensates` a SECOND `DispatchSpec`, shape-identical to this
7
+ # one, naming the command that undoes THIS dispatch specifically
8
+ # (see `ProcessManagerBuilder::HandlerBuilder#dispatch_impl`'s own
9
+ # comment). `nil` for a dispatch with nothing to undo (a pure
10
+ # bookkeeping mark, or one whose own effect is superseded by a later
11
+ # command rather than needing its own compensation). Never nested
12
+ # further — a compensation is not itself compensable; no known
13
+ # corpus need, and ADR 0025's own "a word earns its place by being
14
+ # used" bar would refuse a second level speculatively.
15
+ DispatchSpec = Struct.new(:command_name, :with_spec, :compensates, keyword_init: true) do
7
16
  # A Struct already answers to_h; including the mixin puts the
8
17
  # DECLARED emission ahead of Struct's own in the ancestry, which
9
18
  # is what makes the shape data rather than a method body.
@@ -11,7 +20,8 @@ module Hecks
11
20
 
12
21
  emits_ir(
13
22
  command_name: -> { command_name.to_s },
14
- with_spec: -> { with_spec.map { |key, value| [key.to_s, Bluebook.render_value(value)] } }
23
+ with_spec: -> { with_spec.map { |key, value| [key.to_s, Bluebook.render_value(value)] } },
24
+ compensates: one(:compensates)
15
25
  )
16
26
  end
17
27
 
@@ -35,14 +45,31 @@ module Hecks
35
45
  # word — so here they are two objects, and a procedure either has a saga or
36
46
  # does not.
37
47
  #
38
- # `undoes` is the ordered list of commands the compensation sends. Today that
39
- # order is the AUTHOR's, written by hand in one `on :refused` leg, and the
40
- # runtime does not know which legs actually completed. When compensation
41
- # moves beside each dispatch — `reverses` on the step it reverses this is
42
- # where the completed ones, newest first, will live. The shape is already
43
- # right for it; only the source of the order changes.
44
- Saga = Struct.new(:trigger, :from_state, :to_state, :reversals, keyword_init: true) do
45
- def undoes = reversals.map(&:command_name)
48
+ # `undoes` is the ordered list of commands the compensation sends a
49
+ # STATIC PREVIEW, declaration order (`Behaviour::ProcessManager#saga`),
50
+ # not one instance's own runtime history. Per-dispatch compensation
51
+ # (`compensates`, on the step it compensates for) moved most of what
52
+ # a saga undoes off this leg's own hand-written body and onto
53
+ # whichever forward dispatch each one undoes this reads every
54
+ # declared `compensates` across the WHOLE saga first, then whatever
55
+ # this leg's own hand-written body still lists, for compensation
56
+ # that isn't expressible as "undo command X." WHICH of a declared
57
+ # `compensates` actually fires for one instance, and in what order
58
+ # (newest-first, completed-legs-only), is `SagaInterpreter`'s own
59
+ # dynamic `completed_compensations` — a per-instance runtime fact
60
+ # this declaration-only object could never hold.
61
+ #
62
+ # NAMING COLLISION, ONCE FLAGGED, NOW RESOLVED — `command`'s own
63
+ # `corrects event, reverses: true` (docs/implemented/decisions/0036-
64
+ # corrects-is-an-appended-fact-not-a-rewrite.md) already claimed
65
+ # `reverses` for a different meaning: auto-deriving a command's OWN
66
+ # corrective mutation from a past EVENT, not a saga's own
67
+ # compensating leg from a past DISPATCH. This feature keeps
68
+ # `reverses` reserved for `corrects` and uses `compensates` for
69
+ # per-dispatch saga compensation instead — a deliberate choice, not
70
+ # an accidental collision.
71
+ Saga = Struct.new(:trigger, :from_state, :to_state, :compensations, keyword_init: true) do
72
+ def undoes = compensations.map(&:command_name)
46
73
 
47
74
  def to_s = "#{trigger} → #{to_state} (#{undoes.join(', ')})"
48
75
  end
@@ -63,7 +90,22 @@ module Hecks
63
90
 
64
91
  emits_ir(
65
92
  name: :name,
66
- correlates_by: -> { correlates_by.to_s },
93
+ # M11 `&.`, not `.`: a DSL-built process manager always carries
94
+ # a real `correlates_by` (`ProcessManagerBuilder#build` refuses to
95
+ # mint one without it), but the IR class itself defaults it to
96
+ # `nil` and is what `Assembly::Build`'s `:identity` reader
97
+ # (`value&.to_sym`) round-trips against. A bare `.to_s` mapped
98
+ # that absent case to `""`, indistinguishable on the wire from a
99
+ # real empty name and read back as the wrong, non-nil `:""`
100
+ # instead of `nil` — the same nil-erasure S1 fixed for
101
+ # `render_value`, one field over. `correlates_by` is always a
102
+ # bare Symbol (`SagaInterpreter` hash-looks-up a payload by it),
103
+ # never a `Literal`-encoded polymorphic value, so this stays a
104
+ # local `&.` rather than routing through `Literal.render` — that
105
+ # would wrap a real value in a leading `:` and break both the
106
+ # `:identity` reader's plain `to_sym` and the pinned golden IR
107
+ # fixtures' bare-string spelling (`"reference.value"`).
108
+ correlates_by: -> { correlates_by&.to_s },
67
109
  starts_on: :starts_on,
68
110
  ends_on: :ends_on,
69
111
  states: :states,
@@ -16,6 +16,7 @@ module Hecks
16
16
 
17
17
  def initialize
18
18
  @entries = {}
19
+ @tenant_directories = Hash.new { |hash, key| hash[key] = [] }
19
20
  end
20
21
 
21
22
  def fetch(address) = entries.fetch(address.to_s)
@@ -25,6 +26,7 @@ module Hecks
25
26
 
26
27
  def register(bluebooks, registry, dispatcher, directory)
27
28
  bluebooks.each do |bluebook|
29
+ refuse_unless_safe_for_second_tenant!(bluebook, registry, directory)
28
30
  world = registry.world(bluebook.name)
29
31
  realm = world&.realm
30
32
  raise MissingRealm, "#{bluebook.name} in #{directory} has no world realm" if realm.to_s.empty?
@@ -62,6 +64,29 @@ module Hecks
62
64
 
63
65
  private
64
66
 
67
+ # THE ACTUAL "more than one tenant" MOMENT — Runtime::TenantCheck's
68
+ # own header names this table as the one place real multitenancy
69
+ # happens: the SAME on-disk directory (one domain, one
70
+ # `persisted_by` binding) registering a SECOND time, under a
71
+ # different realm, into this SAME shared route table. A directory's
72
+ # FIRST registration is never refused here — nothing shares its
73
+ # data yet, so a plain single-tenant deployment on an ordinary
74
+ # adapter (Postgres, no schema story) still boots exactly as
75
+ # before. Only the SECOND (and any later) registration of that
76
+ # same directory is refused, and refused before this call adds its
77
+ # routes to the table — so a leaking tenant's requests never
78
+ # become reachable through `Router#resolve` in the first place.
79
+ #
80
+ # Keyed on `directory` + `bluebook.name` only, not realm or
81
+ # `dispatcher` — two tenants share the identical on-disk domain,
82
+ # differing only by which realm/environment overlay booted it.
83
+ def refuse_unless_safe_for_second_tenant!(bluebook, registry, directory)
84
+ key = [directory, bluebook.name]
85
+ seen_before = @tenant_directories.key?(key) && @tenant_directories[key].any?
86
+ Runtime::TenantCheck.refuse_unless_tenant_capable!(registry, bluebook.name) if seen_before
87
+ @tenant_directories[key] << registry
88
+ end
89
+
65
90
  def current?(bluebook, world)
66
91
  bluebook.version.nil? || world.latest == bluebook.version
67
92
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "behaviour/value_object"
2
+ require_relative "expression/ast_json"
2
3
 
3
4
  module Hecks
4
5
  module Bluebook
@@ -28,9 +29,25 @@ module Hecks
28
29
  emits_ir(
29
30
  name: :hecks_name,
30
31
  attributes: many(:attributes),
31
- invariants: -> { invariants.map { |rule| { description: rule.description, canonical: rule.canonical } } },
32
+ # `ast:` a JSON-serializable rendering of the SAME predicate
33
+ # `canonical` already spells as text, alongside it rather than
34
+ # replacing it (`canonical` stays the human-facing/doctest-facing
35
+ # form; parsing it back would just re-derive what `ast` already
36
+ # states directly). Ground truth and the full reasoning:
37
+ # `Expression::AstJson`'s own header — built for `rust/host`'s
38
+ # own mint-time invariant check (`reference_validate.rs`), which
39
+ # has no kernel crate to parse `canonical` with.
40
+ invariants: -> { invariants.map { |rule| { description: rule.description, canonical: rule.canonical, ast: Expression::AstJson.emit_predicate(rule.canonical) } } },
32
41
  closed_set: :closed_set?,
33
- members: -> { members.map { |member| member.map { |field, value| [field.to_s, value.to_s] } } }
42
+ # THE FIELD NAME IS STRINGIFIED, NEVER THE VALUE. A `member` row can
43
+ # hold any of the scalar types an attribute declares — `Integer 84`
44
+ # (`StatementFrequency#retention_months`, statements.bluebook), not
45
+ # only `String` — and `value.to_s` used to erase that on the way
46
+ # out, so `84` and `"84"` (a member some other row might
47
+ # legitimately spell as text) became indistinguishable once they
48
+ # reached `to_h`. The declared name still moves (`field.to_s`) —
49
+ # that half was never a Ruby object with a type to lose.
50
+ members: -> { members.map { |member| member.map { |field, value| [field.to_s, value] } } }
34
51
  )
35
52
 
36
53
  class << self
@@ -257,7 +257,8 @@ module Hecks
257
257
  "guides" => guide_index(root),
258
258
  "reference" => reference_index(root),
259
259
  "tools" => tool_table(root),
260
- "corpus" => corpus_roster(root)
260
+ "corpus" => corpus_roster(root),
261
+ "diagrams" => diagram_showcase(root)
261
262
  }
262
263
  end
263
264
 
@@ -311,6 +312,26 @@ module Hecks
311
312
  text.length > 140 ? "#{text[0, 137]}..." : text
312
313
  end
313
314
 
315
+ # ONE REAL, COMMITTED FILE, READ FRESH — not re-derived from a boot
316
+ # (this module never requires `hecks/projections/diagrams`, and
317
+ # shouldn't just to draw one example). `docs/generated/diagrams/`
318
+ # is already held to the declaration by `spec/diagrams_spec.rb`'s
319
+ # own drift check; this just quotes its own output, so the two
320
+ # can't independently drift from each other either — a stale
321
+ # Order_lifecycle.mmd fails THAT spec long before this one runs.
322
+ def diagram_showcase(root)
323
+ lifecycle = File.read(File.join(root, "docs/generated/diagrams/pizzas/Order_lifecycle.mmd")).strip
324
+ <<~MARKDOWN.strip
325
+ `bin/project_diagrams` reads a booted domain's own declaration and draws it as Mermaid — nine kinds so far: `<Name>_lifecycle.mmd`, `relationships.mmd`, `dispatch.mmd`, `roles.mmd`, `ports.mmd`, `read_models.mmd`, `<Name>_surface.mmd` (what a command does, and what it writes), `<Name>_saga.mmd`, and `frameworks.mmd`. Nothing hand-drawn — the same reason a domain is data at all. Order's own lifecycle, straight off the bluebook above:
326
+
327
+ ```mermaid
328
+ #{lifecycle}
329
+ ```
330
+
331
+ The full set for every domain in this checkout — `examples/pizzas`, `examples/banking` — lives in [`docs/generated/diagrams/`](docs/generated/diagrams/), held to the declaration by `spec/diagrams_spec.rb` the same drift-refusing way this page is held to its own source.
332
+ MARKDOWN
333
+ end
334
+
314
335
  def corpus_roster(root)
315
336
  dirs = Dir.glob(File.join(root, "examples/*/")).sort
316
337
  lines = dirs.filter_map do |dir|
@@ -33,10 +33,13 @@ module Hecks
33
33
 
34
34
  pairs.each_with_object({}) do |pair, args|
35
35
  path, value = split(pair)
36
- argument = options[path] || options[expand(path, options)] ||
37
- raise(Runtime::NotFound, unknown(path, options.keys))
36
+ # key? first, never `||` — full names whichever spelling (the
37
+ # bare path, or its one-argument expansion) actually declares
38
+ # this option, and the lookup below must hold to that same
39
+ # decision rather than re-guessing which one exists.
40
+ full = options.key?(path) ? path : expand(path, options)
41
+ argument = options.key?(full) ? options[full] : raise(Runtime::NotFound, unknown(path, options.keys))
38
42
 
39
- full = options.key?(path) ? path : expand(path, options)
40
43
  next append(args, full.split("."), cast(value, argument[:type])) if argument[:list]
41
44
 
42
45
  bury(args, full.split("."), cast(value, argument[:type]))
@@ -81,12 +81,24 @@ module Hecks
81
81
  end
82
82
 
83
83
  # A URL segment or a JSON body's "command" field, checked against what
84
- # the aggregate actually declares. `klass.commands` is the same
85
- # sorted, snake_cased list `AggregateDoor` built for its own door, so
86
- # a name this accepts is a name a `Handle` can actually dispatch.
84
+ # a `Handle` can actually dispatch — NOT `klass.commands`, which is
85
+ # `AggregateDoor`'s own door-level list and includes the one creating
86
+ # command too (`aggregate_door.rb`'s `commands` singleton method maps
87
+ # every `ir.commands`, full stop). A `Handle` only ever defines
88
+ # singleton methods for the NON-creating ones
89
+ # (`Handle#define_verb_methods`, `@ir.commands.reject(&:creates?)`) —
90
+ # the creating command lives on the aggregate class itself, dispatched
91
+ # through `.creating_command` above, not through a `Handle` in hand.
92
+ # Accepting a creating-command name here let it past this gate clean,
93
+ # only to blow up as a raw `NoMethodError` the moment a caller tried
94
+ # `handle.public_send(name, **args)`, instead of the 404 this method
95
+ # promises. Filtering `reject(&:creates?)` here, the same filter
96
+ # `Handle` itself applies, is what keeps "accepted here" and
97
+ # "dispatchable there" the same set.
87
98
  def validate_command!(klass, name)
88
99
  wanted = name.to_s
89
- return wanted if klass.commands.include?(wanted)
100
+ dispatchable = klass.ir.commands.reject(&:creates?).map { |command| "#{Naming.snake(command.hecks_name)}!" }
101
+ return wanted if dispatchable.include?(wanted)
90
102
 
91
103
  raise Runtime::NotFound, "#{klass.ir.hecks_name} declares no command named #{wanted.inspect}"
92
104
  end
@@ -81,9 +81,24 @@ module Hecks
81
81
  raise RouteNotFound, "#{domain.inspect} is not exposed by this app — declared chapters: #{@exposed.join(', ')}"
82
82
  end
83
83
 
84
+ # H12 (docs/audits/2026-08-10-main-bug-audit.md) — splitting on the
85
+ # FIRST "." truncated any identity value containing a dot (an email
86
+ # `identified_by { email.address }`, a decimal-ish reference — an
87
+ # aggregate's identity is free-form unless its value object declares
88
+ # a `pattern:`, see S3 in the same audit) at its own first dot, so
89
+ # `reference.value=c.1` 404'd everywhere: detail page, JSON view, and
90
+ # its own index-table link. Only a LITERAL trailing ".html"/".json"
91
+ # now counts as a format — every other dot in the segment is just
92
+ # part of the identity. An identity that itself happens to end in
93
+ # exactly ".html" or ".json" is still ambiguous with a real format
94
+ # suffix (the same tension any extension-based content-negotiation
95
+ # scheme has), but that was already true before this fix and is not
96
+ # this bug.
84
97
  def split_format(segment)
85
- name, format = segment.to_s.split(".", 2)
86
- [name, format || "json"]
98
+ segment = segment.to_s
99
+ return [Regexp.last_match(1), Regexp.last_match(2)] if segment =~ /\A(.*)\.(html|json)\z/
100
+
101
+ [segment, "json"]
87
102
  end
88
103
 
89
104
  # ---- home -------------------------------------------------------
@@ -122,6 +137,21 @@ module Hecks
122
137
  aggregate = find_aggregate(chapter, aggregate_name)
123
138
  domain = chapter.name
124
139
 
140
+ # L11 (docs/audits/2026-08-10-main-bug-audit.md) — a record's own
141
+ # id is free-form (S3) and can collide with one of its own
142
+ # aggregate's command/query names ("Close", "Overdrawn", ...).
143
+ # A GET for such an id must still be able to reach that RECORD's
144
+ # own detail page when a record with that literal id actually
145
+ # exists — checking the verb first (the previous order) meant a
146
+ # record unlucky enough to be named after a real verb could never
147
+ # be viewed again. POST never means "view a record" at all
148
+ # (`record_route` only ever answers GET), so command submission
149
+ # there is unambiguous and is left to match the verb first, same
150
+ # as before.
151
+ if request.get? && (instance = @registry.repository(domain, aggregate).find(verb_or_id))
152
+ return record_route(request, domain, aggregate, verb_or_id, format, instance: instance)
153
+ end
154
+
125
155
  if (command = aggregate.command(verb_or_id))
126
156
  return command_route(request, domain, aggregate, command, format)
127
157
  end
@@ -157,7 +187,9 @@ module Hecks
157
187
  def submit_command(request, domain, aggregate, command, action)
158
188
  raw, envelope = submitted_command(request, aggregate, command)
159
189
  result = @dispatcher.dispatch("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}", **envelope)
160
- redirect("/#{domain}/#{aggregate.hecks_name}/#{result.id}.html")
190
+ # L12 — the id is free-form (S3), so it must be percent-encoded as
191
+ # a path segment here, not just interpolated raw.
192
+ redirect("/#{domain}/#{aggregate.hecks_name}/#{Escape.path(result.id)}.html")
161
193
  rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
162
194
  status = e.is_a?(Runtime::NotFound) ? 404 : 422
163
195
  command_form(domain, aggregate, command, action, status: status, values: raw, error: e)
@@ -221,14 +253,23 @@ module Hecks
221
253
  args = Params.extract(fields, asked)
222
254
  rows = @dispatcher.query("#{domain}::#{aggregate.hecks_name}.#{query.hecks_name}", **args)
223
255
  [rows.map { |row| Record.new(row[:id], row.reject { |k, _| k == :id }) }, nil]
224
- rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError => e
256
+ # L10 (docs/audits/2026-08-10-main-bug-audit.md) `Params.extract`
257
+ # (params.rb's `extract_list`) reads a list-of-value-object line as
258
+ # JSON (the honest fallback for a multi-attribute list element this
259
+ # prototype's textarea doesn't build a second widget for). A caller
260
+ # who types a non-JSON line into that field raises `JSON::ParserError`
261
+ # BEFORE dispatch ever sees it — both command submission paths
262
+ # already rescue it (`submit_command`, `command_json`); this one
263
+ # didn't, so a malformed list-of-VO query 500'd instead of showing
264
+ # the same 422 every other bad-input path shows.
265
+ rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
225
266
  [nil, e]
226
267
  end
227
268
 
228
- def record_route(request, domain, aggregate, id, format)
269
+ def record_route(request, domain, aggregate, id, format, instance: nil)
229
270
  return respond(405, "text/plain", "GET only") unless request.get?
230
271
 
231
- instance = @registry.repository(domain, aggregate).find(id)
272
+ instance ||= @registry.repository(domain, aggregate).find(id)
232
273
  return not_found(aggregate, id, format) unless instance
233
274
  # id LAST — same reasoning as the other JSON-serializing call
234
275
  # sites in this file (see aggregate_route's own comment).
@@ -57,7 +57,7 @@ module Hecks
57
57
  def self.header(domain, aggregate, command)
58
58
  <<~HTML
59
59
  <h1>#{Escape.html("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}")}</h1>
60
- #{command.role ? %(<span class="badge role">role: #{Escape.html(command.role)}</span>) : ''}
60
+ #{command.role ? %(<span class="badge role" title="Declared on the command; this prototype dispatches with no caller bound, so the check does not run.">role: #{Escape.html(command.role)} (not enforced here)</span>) : ''}
61
61
  #{command.creates? ? %(<span class="badge">creates a new #{Escape.html(aggregate.hecks_name)}</span>) : ''}
62
62
  #{command.goal ? %(<p class="goal">#{Escape.html(command.goal)}</p>) : ''}
63
63
  #{givens_callout(command)}
@@ -156,13 +156,20 @@ module Hecks
156
156
  # an existing record's own state hands back a NESTED hash instead
157
157
  # (`{amount: {cents: 1050}}`). Flat wins when both would answer,
158
158
  # since only the raw form is ever what the caller actually typed.
159
- flat = values[path.to_s] || values[path.to_sym]
160
- return flat unless flat.nil?
159
+ # `key?` decides which spelling answers, at every step below —
160
+ # never `||`, which would treat a genuinely-held `false` the
161
+ # same as an absent key and fall through to `nil`.
162
+ str = path.to_s
163
+ return values[str] if values.key?(str)
161
164
 
162
- path.to_s.split(".").reduce(values) do |acc, segment|
165
+ sym = path.to_sym
166
+ return values[sym] if values.key?(sym)
167
+
168
+ str.split(".").reduce(values) do |acc, segment|
163
169
  break nil unless acc.is_a?(Hash)
164
170
 
165
- acc[segment.to_sym] || acc[segment]
171
+ seg_sym = segment.to_sym
172
+ acc.key?(seg_sym) ? acc[seg_sym] : acc[segment]
166
173
  end
167
174
  end
168
175
  end
@@ -1,3 +1,5 @@
1
+ require "uri"
2
+
1
3
  module Hecks
2
4
  module Forms
3
5
  # Hand-rolled, on purpose — the repo has no ERB anywhere and no template
@@ -25,6 +27,35 @@ module Hecks
25
27
  # call site reads "this value fills an attribute" rather than repeating
26
28
  # the same escaping and leaving the reader to check they match.
27
29
  def self.attr(value) = html(value)
30
+
31
+ # L12 (docs/audits/2026-08-10-main-bug-audit.md) — safe as a
32
+ # query-string VALUE. `html`/`attr` guard against the value becoming
33
+ # markup, but say nothing about it staying inside the URL syntax
34
+ # position it was placed in: an aggregate's identity is free-form
35
+ # unless its value object declares a `pattern:` (see S3 in the same
36
+ # audit), so `&`, `+`, `?`, `#`, and `/` are all otherwise legal id
37
+ # characters, and each would corrupt an href/Location built by naive
38
+ # interpolation (a stray `&` smuggles a second query parameter, `#`
39
+ # truncates the path at a fragment, `/` splits the path into an
40
+ # extra segment, ...). Percent-encodes via
41
+ # `application/x-www-form-urlencoded` (`+` for space) — correct ONLY
42
+ # for a query-string value (query_form_renderer.rb's `quick_links`,
43
+ # record_renderer.rb's `?to=`). For a URL PATH segment use `path`
44
+ # below instead — `+` is a literal plus there, not an escaped space,
45
+ # so this method would corrupt any id containing a space. Callers
46
+ # still wrap the ASSEMBLED href/Location in `attr` (or `html`) as
47
+ # usual — this only covers the id's own component, not the
48
+ # surrounding markup.
49
+ def self.url(value) = URI.encode_www_form_component(value.to_s)
50
+
51
+ # Same guard as `url`, for a URL PATH segment instead of a
52
+ # query-string value. `encode_www_form_component` renders space as
53
+ # `+`, which is only meaningful inside a query string — in a path
54
+ # segment `+` is a literal plus, so an id like "John Smith" would
55
+ # round-trip to "John+Smith" and 404 against the real id "John
56
+ # Smith". Reuse the same percent-encoding and just correct that one
57
+ # character back to `%20`.
58
+ def self.path(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
28
59
  end
29
60
 
30
61
  # A tiny attribute-hash -> string helper, shared by every renderer in
@@ -41,15 +41,44 @@ module Hecks
41
41
  end
42
42
  end
43
43
 
44
+ # A path-prefix collision: one field named (say) "price" alongside
45
+ # another named "price.cents" implies "price" is BOTH a scalar leaf
46
+ # and the parent of a nested group — the two can never coexist in
47
+ # the same result hash. Depending on which pair `each_with_object`
48
+ # reaches first, the naive walk below used to fail in one of two
49
+ # ways: a scalar planted first left `acc[segment] ||= {}` seeing a
50
+ # truthy non-Hash and reusing IT as `node`, so the next `node[leaf] =
51
+ # value` blew up with a raw `TypeError` from calling `String#[]=`
52
+ # with a Symbol key; a scalar planted AFTER the nested group instead
53
+ # sailed through `node[leaf] = value` and silently clobbered the
54
+ # entire nested hash with the scalar, losing every sibling under it
55
+ # with no error at all. Both directions are checked explicitly here
56
+ # so either order raises the SAME clear `ArgumentError` instead of a
57
+ # confusing crash or silent data loss — this is the family of error
58
+ # every command/query submission path in app.rb already rescues into
59
+ # a 422 (`ArgumentError` sits right alongside the domain refusals in
60
+ # every one of those rescue clauses).
44
61
  def self.nest(pairs)
45
62
  pairs.each_with_object({}) do |(path, value), result|
46
63
  segments = path.to_s.split(".").map(&:to_sym)
47
64
  leaf = segments.pop
48
- node = segments.reduce(result) { |acc, segment| acc[segment] ||= {} }
65
+ node = segments.reduce(result) do |acc, segment|
66
+ existing = acc[segment]
67
+ raise nesting_collision(path) if existing && !existing.is_a?(Hash)
68
+
69
+ acc[segment] ||= {}
70
+ end
71
+ raise nesting_collision(path) if node[leaf].is_a?(Hash)
72
+
49
73
  node[leaf] = value
50
74
  end
51
75
  end
52
76
 
77
+ def self.nesting_collision(path)
78
+ ArgumentError.new("#{path.inspect} conflicts with another field at the same path — " \
79
+ "one names it as a plain value and another as a nested group")
80
+ end
81
+
53
82
  SKIP = Object.new.freeze
54
83
  private_constant :SKIP
55
84
 
@@ -0,0 +1,46 @@
1
+ module Hecks
2
+ module Forms
3
+ # bin/present's own `-p`/`--port` reader, pulled out of the script so
4
+ # it can be driven directly instead of only through a real server
5
+ # boot. Two spellings the previous inline version got wrong:
6
+ #
7
+ # --port=8080 the equals form — the old `ARGV.each_cons(2)` scan
8
+ # only ever recognized "--port", "8080" as TWO
9
+ # separate argv entries, so this spelling matched
10
+ # nothing and silently fell through to the default.
11
+ # -p abc a non-numeric value — the old code did `.to_i` on
12
+ # whatever followed unconditionally, so a typo
13
+ # quietly became port 0 (Rackup/WEBrick's actual
14
+ # behavior for `Port: 0` is to bind an EPHEMERAL
15
+ # port — arguably useful on purpose elsewhere, but
16
+ # never what a mistyped `-p abc` meant to ask for).
17
+ #
18
+ # Returns `[port, nil]` on a clean parse (falling back to `default`
19
+ # when neither spelling appears at all) or `[nil, message]` when an
20
+ # explicit port was given but isn't a real port number — the caller
21
+ # decides what to do with a refusal (bin/present aborts on it).
22
+ module PortArgument
23
+ module_function
24
+
25
+ def parse(argv, default: 4567)
26
+ equals = argv.find { |arg| arg.start_with?("--port=") }
27
+ return resolve(equals.split("=", 2).last) if equals
28
+
29
+ index = argv.each_index.find { |i| %w[-p --port].include?(argv[i]) }
30
+ return [default, nil] unless index
31
+
32
+ resolve(argv[index + 1])
33
+ end
34
+
35
+ def resolve(value)
36
+ return [nil, "-p/--port requires a value"] if value.nil? || value.empty?
37
+ return [nil, "-p/--port must be a whole number, got #{value.inspect}"] unless value.match?(/\A\d+\z/)
38
+
39
+ port = value.to_i
40
+ return [nil, "-p/--port must be between 1 and 65535, got #{port}"] unless (1..65_535).cover?(port)
41
+
42
+ [port, nil]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -76,8 +76,12 @@ module Hecks
76
76
  return "<p><em>No commands act on an existing #{Escape.html(aggregate.hecks_name)}.</em></p>" if commands.empty?
77
77
 
78
78
  items = commands.map do |cmd|
79
- href = "/#{domain}/#{aggregate.hecks_name}/#{cmd.hecks_name}.html?to=#{Escape.attr(id)}"
80
- %(<li><a href="#{href}"><span>#{Escape.html(cmd.hecks_name)}</span><span class="kind">#{Escape.html(cmd.goal.to_s)}</span></a></li>)
79
+ # L12 — the id is free-form (S3): percent-encoded as the query
80
+ # VALUE (a raw `&` here would smuggle a second bogus query
81
+ # parameter), then the assembled href is attribute-escaped as
82
+ # usual.
83
+ href = "/#{domain}/#{aggregate.hecks_name}/#{cmd.hecks_name}.html?to=#{Escape.url(id)}"
84
+ %(<li><a href="#{Escape.attr(href)}"><span>#{Escape.html(cmd.hecks_name)}</span><span class="kind">#{Escape.html(cmd.goal.to_s)}</span></a></li>)
81
85
  end
82
86
  %(<ul class="verb-list">#{items.join}</ul>)
83
87
  end
@@ -41,7 +41,12 @@ module Hecks
41
41
 
42
42
  def self.row(instance, aggregate, cols, domain)
43
43
  cells = cols.map { |name| "<td>#{Escape.html(cell(instance, name))}</td>" }.join
44
- href = "/#{domain}/#{aggregate.hecks_name}/#{instance.id}.html"
44
+ # L12 — the id is free-form (S3): percent-encoded as the path
45
+ # segment, HTML-escaped as the link text, and the assembled href
46
+ # is itself attribute-escaped (belt-and-suspenders — nothing else
47
+ # in `href` is untrusted, but this matches the convention used
48
+ # everywhere else an href is built from parts).
49
+ href = "/#{domain}/#{aggregate.hecks_name}/#{Escape.path(instance.id)}.html"
45
50
  "<tr><td><a href=\"#{Escape.attr(href)}\">#{Escape.html(instance.id)}</a></td>#{cells}</tr>"
46
51
  end
47
52