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
@@ -48,7 +48,19 @@ module Hecks
48
48
  # declares at least one command
49
49
  # or query — everything you can
50
50
  # DO to it and ASK about it,
51
+ # AND what each command WRITES,
51
52
  # in one place
53
+ # <Name>_saga.mmd stateDiagram-v2 one per process_manager —
54
+ # its own states, and what
55
+ # each transition dispatches
56
+ # elsewhere in the domain
57
+ # frameworks.mmd flowchart every OTHER domain this one
58
+ # depends on — a shared
59
+ # framework it `uses_framework`,
60
+ # or a domain a policy reaches
61
+ # `across` — the one diagram
62
+ # here that looks OUTWARD past
63
+ # this domain's own boundary
52
64
  #
53
65
  # CONSTRUCT NAMES (aggregate/entity/command/event) ARE USED BARE,
54
66
  # UNSANITIZED, as Mermaid node/entity ids — safe because this
@@ -100,6 +112,14 @@ module Hecks
100
112
  files["#{holder.hecks_name}_surface.mmd"] = surface_diagram(bluebook, holder)
101
113
  end
102
114
 
115
+ bluebook.process_managers.each do |saga|
116
+ files["#{saga.hecks_name}_saga.mmd"] = saga_diagram(bluebook, saga)
117
+ end
118
+
119
+ if (diagram = frameworks_diagram(bluebook, options[:hecksagon]))
120
+ files["frameworks.mmd"] = diagram
121
+ end
122
+
103
123
  files
104
124
  end
105
125
 
@@ -397,17 +417,226 @@ module Hecks
397
417
  # anything. Command edges are solid ("does"); query edges are
398
418
  # dotted ("asks") — the same solid/dotted split `ports.mmd`
399
419
  # already uses for "routes to:" versus "exposes".
420
+ #
421
+ # A WRITE TARGET IS A PLAIN RECTANGLE — a sixth shape, the first
422
+ # here with no special bracket at all: an attribute is the
423
+ # smallest, most passive thing this vocabulary names, a single
424
+ # field living INSIDE the cylinder rather than a bounded thing of
425
+ # its own. `command.mutations` (`sets`/`increment`/`decrement`/
426
+ # `append`) was invisible everywhere before this — not just in a
427
+ # diagram, in ANY projection, including the prose ones — despite
428
+ # being the single densest fact in the whole IR (53 real
429
+ # mutations across pizzas + banking). `dispatch.mmd` draws what a
430
+ # command EMITS; this draws what it WRITES, the other half of
431
+ # "what actually happens" a command never showed before.
432
+ #
433
+ # THE SAME ATTRIBUTE NODE MERGES ACROSS COMMANDS — real in
434
+ # banking: `Account.Credit` and `Account.Debit` both point at the
435
+ # same `balance` node, the same "one node, several incoming
436
+ # edges" merge `read_models.mmd` already does for an aggregate
437
+ # fed by several read_models.
438
+ #
439
+ # THE LABEL NAMES THE REAL SOURCE, NOT JUST THE VERB — an
440
+ # increment/decrement/set almost always takes its value from an
441
+ # argument, but not always the SAME-NAMED one: real in banking,
442
+ # `Account.Credit`'s own `balance` is incremented by its
443
+ # `amount` argument, and `LedgerEntry.Amend`'s own `amount` is
444
+ # incremented by its `adjustment` argument. A literal source
445
+ # (pizzas' own `Order.Purchase` sets `status` to the literal
446
+ # `"sold"`, not an argument at all) is named as verbatim as
447
+ # every other fact in this file. `append`'s own fields carry no
448
+ # single source at all — its own field NAMES are the fact worth
449
+ # stating (real: `Order.AddTopping` appends `name, amount`).
400
450
  def surface_diagram(bluebook, holder)
401
451
  lines = holder.commands.map { |command| " #{holder.hecks_name}[(#{holder.hecks_name})] -->|does| #{command_node(holder.hecks_name, command.hecks_name)}" }
452
+ lines += holder.commands.flat_map { |command| command.mutations.map { |mutation| mutation_edge(holder, command, mutation) } }
402
453
  lines += holder.queries.map { |query| " #{holder.hecks_name}[(#{holder.hecks_name})] -.->|asks| #{query_node(holder.hecks_name, query.hecks_name)}" }
403
454
 
404
- subject = "#{holder.hecks_name}'s own declared commands and queries"
455
+ subject = "#{holder.hecks_name}'s own declared commands (and what each writes) and queries"
405
456
  "#{header(bluebook.name, subject)}flowchart LR\n#{lines.uniq.join("\n")}\n"
406
457
  end
407
458
 
408
459
  def query_node(aggregate_name, query_name)
409
460
  %(qry_#{aggregate_name}_#{query_name}{"#{aggregate_name}.#{query_name}"})
410
461
  end
462
+
463
+ def mutation_edge(holder, command, mutation)
464
+ shape = mutation.to_h
465
+ label = mutation_label(shape)
466
+ target = attribute_node(holder.hecks_name, shape[:target])
467
+ %( #{command_node(holder.hecks_name, command.hecks_name)} -->|"#{label}"| #{target})
468
+ end
469
+
470
+ def mutation_label(shape)
471
+ verb = "#{shape[:op]}s"
472
+ # `fields:` (not `source:`) IS the multi-binding shape
473
+ # (`Mutation#to_h`'s own `[:append, :delegate, :corrects]`
474
+ # branch) — checked by the KEY'S PRESENCE, not by re-listing
475
+ # which ops use it a second time here, the same lesson
476
+ # `Change.op`'s own `admits: Vocabulary::MutationOp` already
477
+ # drew (command.bluebook's own comment): a second list of "the
478
+ # ops that mean multi-binding" is exactly the kind of copy that
479
+ # drifts — `:delegate` already carried this shape with nothing
480
+ # here reading it correctly, caught only once `:corrects` gave
481
+ # banking's own real diagrams a fields:-shaped mutation to
482
+ # actually render.
483
+ detail = shape[:fields] ? shape[:fields].keys.join(", ") : mutation_source_detail(shape[:source])
484
+ "#{verb}: #{detail}"
485
+ end
486
+
487
+ # A LITERAL VALUE CAN CONTAIN A DOUBLE QUOTE OF ITS OWN — real in
488
+ # banking: `Customer.Reinstate` sets `standing` to a rendered
489
+ # value-object literal, `{:value=>"good"}`, whose own embedded `"`
490
+ # broke this label's outer `|"..."|` quoting outright (caught by
491
+ # running the real generated output through mermaid.parse(), not
492
+ # by eye — the same way `read_models.mmd`'s own unquoted `[]` bug
493
+ # was caught). Swapped for a single quote here rather than
494
+ # escaped, the same "state it, don't invent it, just make it
495
+ # legal Mermaid" trade `read_models.mmd`'s own quoting fix made.
496
+ def mutation_source_detail(source)
497
+ case source[:kind]
498
+ when "literal" then "'#{source[:value].to_s.tr('"', "'")}'"
499
+ when "argument" then source[:name]
500
+ else source[:kind] # a source kind this file has no real corpus example of yet — named, not hidden
501
+ end
502
+ end
503
+
504
+ def attribute_node(holder_name, attribute_name)
505
+ %(attr_#{holder_name}_#{attribute_name}[#{attribute_name}])
506
+ end
507
+
508
+ # ── sagas -> stateDiagram-v2 ─────────────────────────────────────
509
+
510
+ # A SAGA HAS A LIFECYCLE TOO — the same `stateDiagram-v2` shape
511
+ # `lifecycle_diagram` already draws, one file per process_manager
512
+ # the same way lifecycle is one file per lifecycle-bearing holder.
513
+ # What's different is the label: a lifecycle's own edge is labeled
514
+ # by the COMMAND that causes it (an aggregate transitions because
515
+ # something was DONE to it); a saga's edge is labeled by the EVENT
516
+ # that causes it (a saga advances because something HAPPENED,
517
+ # possibly nowhere near the saga itself) — the same command/event
518
+ # split `dispatch.mmd`'s own stadium/hexagon vocabulary already
519
+ # draws, here spent on which noun labels a stateDiagram-v2 edge
520
+ # instead.
521
+ #
522
+ # THE LABEL ALSO NAMES WHAT THE TRANSITION DISPATCHES — a fact no
523
+ # existing diagram states for a saga at all: a lifecycle's own
524
+ # edge only ever names the one command that caused it; a saga's
525
+ # edge can fire several commands at once (real in banking:
526
+ # Settlement's own AccountDebited handler dispatches both
527
+ # Transfer.Debited and Account.Credit). Confirmed real in the
528
+ # corpus: no saga dispatch ever declares a `to:`/`target_domain`
529
+ # of its own (unlike a policy's `across`) — every command a saga
530
+ # fires lands inside its own bluebook chapter, so this never needs
531
+ # `dispatch.mmd`'s own "triggers in X" cross-domain label.
532
+ #
533
+ # THE COMPENSATING LEG READS LIKE ANY OTHER — its own trigger is
534
+ # the literal string "refused" (`ProcessManager::REFUSED`, this
535
+ # language's own Trigger vocabulary), not invented text: a
536
+ # dispatch declined is exactly as real a cause of a state
537
+ # transition as an event announced, and the diagram states it
538
+ # exactly as verbatim as every other edge here does.
539
+ def saga_diagram(bluebook, saga)
540
+ edges = saga.handlers.map { |handler| saga_edge(handler, saga) }
541
+
542
+ subject = "#{saga.hecks_name}'s own declared states and what each transition dispatches " \
543
+ "(starts on #{saga.starts_on}, ends on #{saga.ends_on})"
544
+ <<~MERMAID
545
+ #{header(bluebook.name, subject)}stateDiagram-v2
546
+ [*] --> #{saga.states.first}
547
+ #{edges.join("\n")}
548
+ MERMAID
549
+ end
550
+
551
+ # THE REFUSED EDGE'S OWN DISPATCH LIST IS PARTLY DERIVED NOW —
552
+ # per-dispatch saga compensation (`compensates`) moved a saga's own
553
+ # compensating dispatches OFF the hand-written `on :refused` leg
554
+ # and onto whichever forward dispatch each one undoes, so
555
+ # `handler.dispatches` alone would render an EMPTY compensating
556
+ # edge for any saga using it — accurate to the DECLARATION, wrong
557
+ # about what the runtime actually does at refusal (it derives and
558
+ # fires every declared `compensates`, newest first). `saga` is
559
+ # passed through for exactly this — only the REFUSED handler needs
560
+ # it, every other edge's own `handler.dispatches` already says
561
+ # everything real about it.
562
+ def saga_edge(handler, saga)
563
+ label = handler.event_type
564
+ # DERIVED FIRST, then the hand-written body — the same order
565
+ # `SagaInterpreter#unwind` actually runs them in (every
566
+ # completed leg's own `compensates` before this leg's own
567
+ # hand-written dispatches), not declaration order on the page.
568
+ dispatched = handler.event_type == Bluebook::ProcessManager::REFUSED ? derived_compensations(saga) : []
569
+ dispatched += handler.dispatches.map(&:command_name)
570
+ label += " / dispatches #{dispatched.join(', ')}" unless dispatched.empty?
571
+
572
+ " #{handler.from_state} --> #{handler.to_state}: #{label}"
573
+ end
574
+
575
+ # Every `compensates` any forward dispatch in this saga declares,
576
+ # declaration order — the same commands `SagaInterpreter#unwind`
577
+ # derives and fires (newest-first, at actual refusal time; this
578
+ # diagram states them in declaration order, since it draws the
579
+ # saga's own shape, not one instance's own runtime history).
580
+ def derived_compensations(saga)
581
+ saga.handlers.flat_map { |handler| handler.dispatches.filter_map { |dispatch| dispatch.compensates&.command_name } }
582
+ end
583
+
584
+ # ── frameworks -> flowchart ─────────────────────────────────────
585
+
586
+ # EVERY OTHER DIAGRAM IN THIS FILE STAYS INSIDE ONE DOMAIN'S OWN
587
+ # BOUNDARY — this is the one that steps outside it. A real domain
588
+ # depends on another domain's own aggregates in exactly two ways:
589
+ # `uses_framework "X"` in its `.hecksagon` (`Hecksagon#framework_
590
+ # members`), which loads X's whole bluebook into THIS registry,
591
+ # unconditionally, the moment this domain boots; or a policy's own
592
+ # `across "X"` (`Policy#target_domain`), which only reaches X when
593
+ # the policy's declared event actually fires. Same underlying
594
+ # fact `dispatch.mmd`'s own `trigger_edge` already draws from the
595
+ # command's side ("triggers in X") — this draws it again from the
596
+ # DOMAIN's side, next to the structural `uses_framework` fact
597
+ # `dispatch.mmd` never sees at all (that lives in the `.hecksagon`,
598
+ # which no other diagram here is handed).
599
+ #
600
+ # NEITHER THIS DOMAIN NOR EACH DEPENDENCY GETS THE holders() TREATMENT
601
+ # — a whole domain is drawn as ONE cylinder, the same "a bounded,
602
+ # addressable thing" shape every other diagram here already spends
603
+ # on a single aggregate, just scaled up one level: a domain is a
604
+ # bigger box the same kind of box lives inside.
605
+ #
606
+ # DOTTED FOR `attaches`, SOLID FOR `reaches across` — the reverse
607
+ # of which fact is "always true" between the two: attaching a
608
+ # framework is a standing declaration, true every time this domain
609
+ # boots, so it gets the same dotted "this always belongs" treatment
610
+ # `ports.mmd` gives an aggregate's own `-.->|exposes|` edge.
611
+ # Reaching across only happens when a real policy actually fires —
612
+ # the same solid edge `dispatch.mmd`'s own `trigger_edge` already
613
+ # draws for the identical fact, kept solid here so the same
614
+ # relationship reads the same way in both diagrams.
615
+ #
616
+ # `options[:hecksagon]` IS THE ONE DIAGRAM IN THIS FILE THAT NEEDS
617
+ # MORE THAN `bluebook` — `framework_members` lives on the
618
+ # `Hecksagon`, a sibling IR object `bin/project_diagrams` already
619
+ # has in hand (`registry.hecksagon(chapter_name)`) but `bluebook`
620
+ # itself carries no reference to. No hecksagon handed in (an older
621
+ # caller, or a spec that doesn't care) just means no frameworks.mmd
622
+ # — same "nothing to state" skip every other diagram here already
623
+ # takes when its own underlying data is empty.
624
+ def frameworks_diagram(bluebook, hecksagon)
625
+ return nil unless hecksagon
626
+
627
+ lines = hecksagon.framework_members.map { |name| domain_edge(bluebook.name, "attaches", name, dotted: true) }
628
+ lines += bluebook.policies.filter_map(&:target_domain).uniq
629
+ .map { |name| domain_edge(bluebook.name, "reaches across", name, dotted: false) }
630
+ return nil if lines.empty?
631
+
632
+ subject = "#{bluebook.name}'s own declared uses_framework and cross-domain policy targets"
633
+ "#{header(bluebook.name, subject)}flowchart LR\n#{lines.uniq.join("\n")}\n"
634
+ end
635
+
636
+ def domain_edge(from, label, to, dotted:)
637
+ arrow = dotted ? "-.->" : "-->"
638
+ %( #{from}[(#{from})] #{arrow}|#{label}| #{to}[(#{to})])
639
+ end
411
640
  end
412
641
  end
413
642
  end
@@ -1,4 +1,4 @@
1
- require_relative "../runtime/storage_shape"
1
+ require_relative "../ports/persistence"
2
2
  require_relative "../projector"
3
3
 
4
4
  module Hecks
@@ -25,7 +25,20 @@ module Hecks
25
25
 
26
26
  module_function
27
27
 
28
- def call(bluebook:, options: {}) = Runtime::StorageShape.project(bluebook)
28
+ # ADR 0033 `Runtime::StorageShape` lives in the era persistence
29
+ # plugin now; the `:shape` projection itself stays registered
30
+ # unconditionally (the seam `spec/projector_seam_spec.rb` enforces
31
+ # is "every file here registers," not "every projection always
32
+ # succeeds") but refuses clearly if asked to run with the plugin
33
+ # unloaded, rather than raising on an undefined constant.
34
+ def call(bluebook:, options: {})
35
+ unless Ports::Persistence.plugin?(:era)
36
+ raise "the :shape projection needs the era persistence plugin loaded " \
37
+ "(require \"hecks/ports/persistence/plugins/era\")"
38
+ end
39
+
40
+ Runtime::StorageShape.project(bluebook)
41
+ end
29
42
  end
30
43
  end
31
44
  end
@@ -192,7 +192,8 @@ module Hecks
192
192
  end
193
193
 
194
194
  { verb: fqn(bluebook, aggregate, command, entity), kind: :command,
195
- summary: command.goal, role: command.role, creates: command.creates?,
195
+ summary: command.goal, role: command.role, role_gated: !command.role.to_s.empty?,
196
+ creates: command.creates?,
196
197
  receiver: receiver, legacy_receiver: (receiver == :aggregate ? :id : nil),
197
198
  legacy_arguments: legacy_arguments,
198
199
  refusals: refusals(command, holder), arguments: arguments }
@@ -227,7 +228,15 @@ module Hecks
227
228
  # names it short, which is the same split `shorten` already makes.
228
229
  { verb: [fqn(bluebook, aggregate, operation).sub(/\.[^.]+\z/, ""), port.name, operation.hecks_name].join("."),
229
230
  kind: :command, creates: false, receiver: :aggregate, refusals: [],
231
+ # `role:` HERE IS DESCRIPTIVE TEXT, NOT AN AUTHORIZATION GATE —
232
+ # who calls whom through the port, for `--help`/`verb_help`'s
233
+ # "issued by" line. A port operation never reaches
234
+ # `CommandRules::Authorization#refuse_role_mismatch` (only
235
+ # `CommandInterpreter`/`EntityInterpreter` call it, never the port
236
+ # dispatch path), so `role_gated: false` always, unlike
237
+ # `command_spec` where the same key name means a real one.
230
238
  role: operation.outbound? ? "#{aggregate.hecks_name} asking #{port.name}" : "#{port.name} telling #{aggregate.hecks_name}",
239
+ role_gated: false,
231
240
  summary: port_summary(port, operation), arguments: arguments }
232
241
  end
233
242
 
@@ -1,6 +1,5 @@
1
1
  require "json"
2
- require_relative "../runtime/era_check"
3
- require_relative "../translation/rule_compiler"
2
+ require_relative "../ports/persistence"
4
3
 
5
4
  module Hecks
6
5
  module Projector
@@ -30,7 +29,15 @@ module Hecks
30
29
  # Reuses `Runtime::EraCheck`'s own capability predicates rather than
31
30
  # re-deriving them — the boot-time gate and this export must never
32
31
  # answer differently for the same aggregate.
32
+ #
33
+ # ADR 0033 — `Runtime::EraCheck` lives in the (optional) era
34
+ # persistence plugin now; unloaded, this answers exactly what it
35
+ # already answers for a domain with nothing lineage-capable bound —
36
+ # `capable_aggregates: []` — rather than raising on an undefined
37
+ # constant.
33
38
  def lineage(registry, domain_name)
39
+ return { capable_aggregates: [] } unless Ports::Persistence.plugin?(:era)
40
+
34
41
  bluebook = registry.bluebooks.fetch(domain_name)
35
42
  capable = bluebook.aggregates.select do |aggregate|
36
43
  adapter_name = Runtime::EraCheck.adapter_for(registry, domain_name, aggregate)
@@ -130,7 +137,17 @@ module Hecks
130
137
  # expression` is nil unless this edge rekeys — the bare
131
138
  # `aggregate_id` passthrough head_compiler.rb itself falls back to
132
139
  # for the overwhelming common case.
140
+ # ADR 0033 — `Translation::RuleCompiler` lives in the era plugin;
141
+ # unloaded, there is nothing that can compile this SQL, so this
142
+ # falls back to the bare declared fields (`translation_aggregate`
143
+ # alone) rather than raising on an undefined constant. A consumer
144
+ # embedding this JSON without the era plugin loaded gets the same
145
+ # declared-rules shape, just without precompiled SQL to execute —
146
+ # consistent with there being no mint/audit machinery to run it
147
+ # against either.
133
148
  def compiled_translation_aggregate(aggregate)
149
+ return translation_aggregate(aggregate) unless Ports::Persistence.plugin?(:era)
150
+
134
151
  translation_aggregate(aggregate).merge(
135
152
  compiled_state_expression: Translation::RuleCompiler.compile_rules(aggregate),
136
153
  compiled_id_expression: Translation::RuleCompiler.rekeyed?(aggregate) ? Translation::RuleCompiler.compile_id_expression(aggregate) : nil
@@ -233,6 +233,25 @@ module Hecks
233
233
  # hand whether this is ALREADY a chapter-wide reference before
234
234
  # assuming it's fresh duplication," not as an automatic signal
235
235
  # either way.
236
+ #
237
+ # THE IDENTICAL GAP, ONE LEVEL DOWN: chapter-wide ENTITY-scoped
238
+ # sharing (`EntityBuilder#given`'s own bare form,
239
+ # `docs/implemented/resolution-rules/chapter-entity-given.md`) hits this same wall for
240
+ # the same structural reason — `SafeDepositBox.Visit` still shows
241
+ # as its own "(declared)" owner here even after becoming a bare
242
+ # reference to `Account.LedgerEntry`'s declaration, because a piece
243
+ # resolving a chapter-wide reference still write-throughs the
244
+ # resolved `Given` into its own `@named_givens` (so ITS OWN
245
+ # commands can read it back locally without a second hop). This is
246
+ # not a NEW limitation this feature introduces — it is the exact
247
+ # same IR-cannot-distinguish-declared-from-referenced fact, one
248
+ # scope wider. `bin/query_ir duplicates` confirms this directly:
249
+ # `Account.LedgerEntry (declared)` and `SafeDepositBox.Visit
250
+ # (declared)` both appear under the same `given: "customer is
251
+ # active"` group — verify by hand, same as the aggregate-level
252
+ # case above, before assuming a group naming two pieces under
253
+ # different aggregates is fresh duplication rather than an already-
254
+ # resolved chapter-wide reference.
236
255
  def declaration_count(rules)
237
256
  declared = rules.select { |r| r.location.end_with?(" (declared)") }
238
257
  declared_owners = declared.map { |r| owner_of(r.location) }.to_set
@@ -25,12 +25,24 @@ module Hecks
25
25
  end
26
26
  end
27
27
 
28
+ # M3 — an UNDECLARED (`native`) null policy used to render no
29
+ # `NULLS ...` clause at all here, leaving each dialect's own
30
+ # default to decide: Postgres puts nulls LAST on ASC (and FIRST
31
+ # on DESC), while `#order` above — this same "native" default,
32
+ # for Memory — puts nulls FIRST on ASC (and LAST on DESC), the
33
+ # SQLite convention. Same query, same data, different row order
34
+ # depending only on which adapter ran it. Rendered explicitly
35
+ # here instead, so an undeclared policy means the SAME total
36
+ # order everywhere rather than "whatever this store already does"
37
+ # — matching `#order`'s own default rather than the other way
38
+ # round, since that default is unconditional (Memory/Heki have no
39
+ # dialect to defer to) and SQLite already agrees with it natively.
28
40
  def sql_order(expression, direction, policy)
29
41
  direction = direction.to_s.downcase == "desc" ? "DESC" : "ASC"
30
42
  nulls = case policy&.mode.to_s
31
43
  when "first" then " NULLS FIRST"
32
44
  when "last" then " NULLS LAST"
33
- else ""
45
+ else direction == "DESC" ? " NULLS LAST" : " NULLS FIRST"
34
46
  end
35
47
  "#{expression} #{direction}#{nulls}, id #{direction}"
36
48
  end
@@ -31,7 +31,11 @@ module Hecks
31
31
  # plain row hash); the rest read through whatever each step holds. A
32
32
  # stored nested value object is a plain hash by the time it is read
33
33
  # back, keyed by symbol in memory and by string off a wire decode,
34
- # so both spellings are tried.
34
+ # so both spellings are tried — `key?` first, never `||`, because
35
+ # `||` falls through a genuinely-stored `false` to the OTHER
36
+ # spelling (usually absent) and returns `nil` instead. The seal
37
+ # admits boolean leaves (`SCALAR_PRIMITIVES` below), so a `false`
38
+ # here is a real, held answer, not a missing one.
35
39
  def dig(holder, field)
36
40
  return nil if field.nil?
37
41
 
@@ -40,7 +44,21 @@ module Hecks
40
44
 
41
45
  def read(current, segment)
42
46
  return nil if current.nil?
43
- return current[segment.to_sym] || current[segment] if current.is_a?(Hash)
47
+
48
+ if current.is_a?(Hash)
49
+ sym = segment.to_sym
50
+ return current.key?(sym) ? current[sym] : current[segment]
51
+ end
52
+
53
+ # M5 — "or nil, never raise" is this method's whole contract, and
54
+ # an Array broke it: `Array#[]` demands an Integer index, so
55
+ # `current[segment]` (a String) raised `TypeError` straight
56
+ # through `dig` instead of answering nil. A dotted path stepping
57
+ # INTO a list_of attribute (`where "tags.name" == "x"` against a
58
+ # bare list, rather than each element) has no single member a
59
+ # bare index would name anyway — nil is the honest answer, the
60
+ # same one a dangling reference or a missing key already gets.
61
+ return nil if current.is_a?(Array)
44
62
 
45
63
  current[segment]
46
64
  end
@@ -7,11 +7,13 @@ module Hecks
7
7
  # sibling, not its member. FieldPath walks a SHAPE, which cannot
8
8
  # loop, and answers nil, never raising, because there is nothing
9
9
  # left to say beyond "not found." HopPath walks the REFERENCE
10
- # GRAPH instead, which the language does not guarantee acyclic —
11
- # aggregates only refuse a DIRECT bidirectional pair
12
- # (BluebookBuilder#validate_no_bidirectional_references! explicitly
13
- # declines to take a position on a longer ring) so a walk here
14
- # needs to say WHY it stopped, not just that it did.
10
+ # GRAPH instead `BluebookBuilder#validate_no_bidirectional_references!`
11
+ # refuses any reference CYCLE at declaration time (ADR 0025,
12
+ # "References" — widened from a direct pair to any ring, DFS over
13
+ # the chapter's own reference graph), so what's left to guard here
14
+ # is depth, not cycles: MAX_HOPS below, and this module's own
15
+ # `refusal` states for a target the chapter doesn't resolve. A walk
16
+ # here still needs to say WHY it stopped, not just that it did.
15
17
  #
16
18
  # Every method below takes an ATTRIBUTE ARRAY, never a "shape"
17
19
  # object — deliberately, because the two real callers hold their
@@ -0,0 +1,45 @@
1
+ module Hecks
2
+ module Runtime
3
+ # A PROCESS-WIDE, STRIPED MUTEX REGISTRY — the concurrency-control
4
+ # mechanism for every adapter that does NOT declare
5
+ # `:optimistic_concurrency` (Heki, Memory today; see
6
+ # `CommandInterpreter#call`/`EntityInterpreter#call`, which choose
7
+ # between this and Postgres's CAS+retry purely off
8
+ # `repository.capabilities`).
9
+ #
10
+ # WHY A LOCK SUFFICES HERE AND CAS IS NOT NEEDED: both adapters hold
11
+ # process-local data. `Adapters::Memory.tenant_capable?`'s own comment
12
+ # states the confirmed fact this relies on — two `Runtime.boot` calls
13
+ # get two entirely separate adapter instances; there is never a SECOND
14
+ # PROCESS writing the same Heki file or the same Memory Hash, only
15
+ # possibly other THREADS within this one process. A `Mutex` held for
16
+ # the full hydrate-through-save critical section closes the identical
17
+ # lost-update gap CAS closes for Postgres, with no schema, no version
18
+ # column, and no retry loop — the second thread simply doesn't start
19
+ # its own hydrate until the first thread's save has landed.
20
+ #
21
+ # STRIPED, NOT ONE GLOBAL LOCK: keyed by `[domain, aggregate.hecks_name,
22
+ # id]`, so two dispatches against two DIFFERENT records never block
23
+ # each other. The registry Hash itself is guarded by its own top-level
24
+ # Mutex only for the moment a new per-key Mutex is created — two
25
+ # threads locking DIFFERENT keys for the first time never wait on one
26
+ # another beyond that brief creation window.
27
+ module AggregateLock
28
+ @registry_lock = Mutex.new
29
+ @locks = {}
30
+
31
+ class << self
32
+ # `AggregateLock.for(domain, aggregate, id).synchronize { ... }`
33
+ # `id: nil` — identity could not be resolved yet (see
34
+ # `Identity.best_effort`) — locks by aggregate TYPE alone, coarser
35
+ # (every record of this aggregate serializes against every other)
36
+ # but still correct: it can only ever make dispatch MORE
37
+ # conservative than a resolved id would.
38
+ def for(domain, aggregate, id = nil)
39
+ key = id.nil? ? [domain.to_s, aggregate.hecks_name] : [domain.to_s, aggregate.hecks_name, id.to_s]
40
+ @registry_lock.synchronize { @locks[key] ||= Mutex.new }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,41 @@
1
+ module Hecks
2
+ module Runtime
3
+ # A boot's own small set of phase-tagged, conditionally-present gates —
4
+ # ADR 0031. Registration is instance-scoped, ONE PER `Loader.boot`
5
+ # CALL, never a module-level singleton: a process that boots more than
6
+ # one registry in its lifetime (every spec suite does) must never let
7
+ # one boot's capability profile leak into the next boot's gate list.
8
+ #
9
+ # A gate is anything `.call(registry, directory)`-able — an existing
10
+ # module method handed over as a `Method` object (`EraCheck.method
11
+ # (:check_lineage!)`) needs no wrapper; a bare block does. Phases run
12
+ # in the order `run!` is called, gates within a phase in registration
13
+ # order — today exactly one gate per phase, so ordering among
14
+ # same-phase gates has never been exercised.
15
+ #
16
+ # This is deliberately NOT the same registry `Hecks::Projector` uses
17
+ # (ADR 0027) — that one is a process-wide, static IR-in/artifact-out
18
+ # registry with no bindings and no live-state mutation; this one is
19
+ # per-boot and gates real I/O (a Postgres mint, a saga-store read).
20
+ # Sharing a primitive between them is deferred until a third consumer
21
+ # actually wants it (0031's own Rejected Alternatives).
22
+ class BootGates
23
+ def initialize
24
+ @gates = Hash.new { |h, k| h[k] = [] }
25
+ end
26
+
27
+ def register(name, gate, phase:)
28
+ @gates[phase] << [name, gate]
29
+ self
30
+ end
31
+
32
+ def registered?(name)
33
+ @gates.values.flatten(1).any? { |registered_name, _gate| registered_name == name }
34
+ end
35
+
36
+ def run!(phase, registry, directory)
37
+ @gates[phase].each { |pair| pair.last.call(registry, directory) }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -19,15 +19,31 @@ module Hecks
19
19
  # `CommandRules::Authorization` check a real Governance
20
20
  # `RoleAssignment` instead, once the command's domain has Governance
21
21
  # attached — see that module's own header for the full split.
22
- Current = Struct.new(:role, :actor_id, keyword_init: true)
22
+ #
23
+ # `as_of` and `scope` are BOTH optional too, and both self-asserted
24
+ # by the caller rather than derived from the command — deliberately:
25
+ # `as_of` is filled at the door from `Ports::Clock.now`, never
26
+ # inside the interpreter (see `Ports::Clock`'s own header), so
27
+ # there is no other place to source it from. `scope` stays here
28
+ # rather than becoming a command-level DSL construct on purpose —
29
+ # a scope check that lived in the bluebook would put an
30
+ # authorization concern inside the domain declaration itself; this
31
+ # keeps it an application-boundary fact instead, the same shape
32
+ # `role`/`actor_id` already are. The tradeoff: a caller states what
33
+ # scope it is acting in, and `holds_role?` verifies a live grant
34
+ # exists for THAT scope — it does not independently confirm the
35
+ # scope matches whatever the command's own target data belongs to.
36
+ Current = Struct.new(:role, :actor_id, :as_of, :scope, keyword_init: true)
23
37
 
24
38
  module_function
25
39
 
26
40
  def current = Thread.current[:hecks_caller]
27
41
 
28
- def as(role:, actor_id: nil)
42
+ def as(role:, actor_id: nil, as_of: nil, scope: nil)
29
43
  previous = Thread.current[:hecks_caller]
30
- Thread.current[:hecks_caller] = Current.new(role: role.to_s, actor_id: actor_id&.to_s)
44
+ Thread.current[:hecks_caller] = Current.new(
45
+ role: role.to_s, actor_id: actor_id&.to_s, as_of: as_of, scope: scope&.to_s
46
+ )
31
47
  yield
32
48
  ensure
33
49
  Thread.current[:hecks_caller] = previous
@@ -19,8 +19,19 @@ module Hecks
19
19
  # the aggregate rather than describe it : `id`, whatever the aggregate is
20
20
  # identified by, and the reference key of the root a command reaches
21
21
  # through. Refusing those would refuse every dispatch there is.
22
- def refuse_unknown_arguments(domain, aggregate, command, args)
23
- addressing = [:id, *aggregate.identity_heads, reference_key(command)] + correlation_keys(domain)
22
+ #
23
+ # `extra_identity_heads:` EntityInterpreter's own callers only. An
24
+ # entity dispatch addresses not just the root aggregate but every
25
+ # entity ALONG THE CHAIN it walks to reach the piece the command
26
+ # actually belongs to (`Handler.Dispatch.Bind` is two hops), and each
27
+ # hop's own `identity_heads` is addressing the same way the root's
28
+ # are — `element_of` (entity_element.rb) reads them straight out of
29
+ # `args` to find the element, never as a fact the command itself
30
+ # declares. `[]` for a plain aggregate/port-operation dispatch, which
31
+ # has no chain to add.
32
+ def refuse_unknown_arguments(domain, aggregate, command, args, extra_identity_heads: [])
33
+ addressing = [:id, *aggregate.identity_heads, *extra_identity_heads, reference_key(command)] +
34
+ correlation_keys(domain)
24
35
  known = (command.attributes.map(&:name) + addressing).compact.map(&:to_sym)
25
36
  # SORTED. Payload order is whatever the caller happened to write, and
26
37
  # refusal wording is contract — pinned byte-for-byte by the corpus, so
@@ -71,6 +71,18 @@ module Hecks
71
71
  # never through this method.
72
72
  when :delegate
73
73
  nil
74
+ # `corrects` — CommandBuilder#corrects_impl's own comment gives
75
+ # the full reasoning for storing it as a mutation at all. A REAL
76
+ # no-op here too: it targets no field on THIS instance either —
77
+ # its own event name, and whether THIS record has actually
78
+ # emitted it, is checked once, up front, by
79
+ # CommandRules::Admissibility#enforce_correction_target, not
80
+ # here. Whatever field this correction actually changes is an
81
+ # ORDINARY declared (or, for `reverses: true`, derived — see
82
+ # AggregateBuilder#seal_correction_targets) mutation of its own,
83
+ # applied by one of the branches above like any other.
84
+ when :corrects
85
+ nil
74
86
  else
75
87
  # Every declared op has a `when` above — this is not a real
76
88
  # runtime path today, only a backstop against the day one