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
@@ -58,6 +58,26 @@ module Hecks
58
58
  Size = Struct.new(:receiver, keyword_init: true)
59
59
  Lookup = Struct.new(:path, keyword_init: true)
60
60
 
61
+ # UPDATE 2026-08-27: every "vendored addition, not (yet) upstream
62
+ # hecks" comment on this file's own MatchesRegex/Presence/Split/
63
+ # First/Last/StartsWith/EndsWith below (plus ArrayLiteral above)
64
+ # described a REAL gap, found the hard way, in the history each
65
+ # comment tells — and that history stays exactly as written,
66
+ # on purpose. What changed is the PRESENT TENSE claim "not (yet)
67
+ # upstream": a review of this exact migration found these eight
68
+ # symbols had working Ruby parse/interpret arms but had NEVER
69
+ # gone through Propose -> Render -> Admit
70
+ # (lib/hecks/grammar/expression_operators.json) the way every
71
+ # other operator here has — a closed-vocabulary guard
72
+ # (spec/operator_conformance_spec.rb) built entirely over TABLES
73
+ # structurally could not see hand-coded Struct/parse/interpret
74
+ # additions, so eight operators ran in Ruby, admitted nowhere,
75
+ # invisible to the one guard whose whole job was "reads in every
76
+ # target." All eight are now ledger-admitted for real, with full
77
+ # Rust kernel parity (rust/src/kernel/expression_operators/
78
+ # {pattern_match,presence,text,positional}.rs) — the two-tier
79
+ # gap is closed, not merely tracked.
80
+ #
61
81
  # `receiver.match?(/pattern/)` -- vendored addition, not (yet)
62
82
  # upstream hecks (migration plan task 8): confirmed the
63
83
  # SINGLE most impactful corpus-wide dispatch-time gap of the
@@ -332,6 +352,15 @@ module Hecks
332
352
  options |= Regexp::EXTENDED if flags.include?("x")
333
353
 
334
354
  Regexp.new(pattern, options).match?(text)
355
+ rescue RegexpError => e
356
+ # M9: a malformed pattern between the slashes (an unclosed
357
+ # character class, say) is a defect in the EXPRESSION TEXT
358
+ # itself, exactly the same category of author mistake an
359
+ # unresolvable attribute name already refuses for — `Regexp.new`
360
+ # raising a raw `RegexpError` crossed this sublanguage's own
361
+ # refusal boundary the same way the `ZeroDivisionError`/
362
+ # `TypeError` cases elsewhere in this file did.
363
+ raise EvaluationError, "match? given an invalid pattern #{pattern.inspect} — #{e.message}"
335
364
  end
336
365
 
337
366
  # The elements of a bracketed literal, or nil if this isn't one.
@@ -392,9 +421,19 @@ module Hecks
392
421
  quote = nil if char == quote
393
422
  elsif ['"', "'"].include?(char)
394
423
  quote = char
395
- elsif ["(", "{"].include?(char)
424
+ # `[`/`]` -- the identical lesson this method's own `(`/`{`
425
+ # comment already names, a third time (found live via the
426
+ # type-directed bounded-exhaustive expression generator,
427
+ # Phase 7 of the equivalence-gap plan): `ArrayLiteral` can
428
+ # appear as a general sub-expression now, not only as
429
+ # `.include?`'s own haystack, so an array element containing
430
+ # its own top-level `+` (`[0, 0 + 0]`) used to read as THIS
431
+ # expression's own addition split point -- the whole
432
+ # receiver before `.all?`/`.any?`/etc. torn in half before
433
+ # `parse_block_opener` ever saw it as one atomic leaf.
434
+ elsif ["(", "{", "["].include?(char)
396
435
  depth += 1
397
- elsif [")", "}"].include?(char)
436
+ elsif [")", "}", "]"].include?(char)
398
437
  depth -= 1
399
438
  elsif char == "+" && depth.zero?
400
439
  return [expr[0...index].strip, expr[(index + 1)..].strip]
@@ -518,18 +557,109 @@ module Hecks
518
557
  Evaluator.apply(node.operator, number, 0)
519
558
  end
520
559
 
560
+ # FOUND LIVE via the type-directed bounded-exhaustive expression
561
+ # generator (Phase 7, equivalence-gap plan — spec/
562
+ # bounded_exhaustive_expression_spec.rb): `.modulo(`'s own
563
+ # argument position accepts any numeric sub-expression, including
564
+ # ANOTHER `.modulo(...)` call — `0.modulo(num_b.modulo(-1))` is
565
+ # perfectly well-typed — but `expr.rindex(marker)` finds the
566
+ # RIGHTMOST (innermost) `.modulo(` in the whole string, not the
567
+ # OUTERMOST one a nested call needs split at. For that expression
568
+ # it found the INNER `.modulo(` (inside `num_b.modulo(-1)`) and
569
+ # split there, producing a receiver of `"0.modulo(num_b"` and a
570
+ # divisor of `"-1)"` — both garbage, both re-parsed as bogus
571
+ # `Lookup` paths, both then refusing with "cannot resolve" — a
572
+ # SILENT MISPARSE that happened to fail safe into a real
573
+ # `EvaluationError` rather than a raw crash, which is exactly why
574
+ # this had gone unnoticed: nothing before this generator existed
575
+ # ever fed `.modulo` a nested `.modulo` call, random fuzzing
576
+ # essentially never manufactures that specific shape by chance,
577
+ # and the resulting refusal LOOKS like an ordinary, correct one
578
+ # unless you already know every name this generator's own
579
+ # synthetic state declares (real corpus authors would see this as
580
+ # a mysterious "cannot resolve" on text they never wrote).
581
+ #
582
+ # Fixed the same way `split_addition`/`Evaluator.top_level_index`
583
+ # already handle nested `(`/`{` elsewhere in this exact file:
584
+ # find the FIRST (leftmost, outermost) occurrence of the marker,
585
+ # then track paren/quote depth from there to find ITS OWN
586
+ # matching close — not just strip the string's own trailing `)`
587
+ # and hope it belongs to this call.
588
+ # Not just the FIRST occurrence, either — `.modulo` also CHAINS
589
+ # (`x.modulo(a).modulo(b)`, the receiver of the OUTER call itself
590
+ # ending in a `.modulo(...)` call), a second real shape the
591
+ # leftmost-occurrence-only version of this fix still mis-parsed:
592
+ # the first `.modulo(`'s own matching close paren lands mid-
593
+ # string (right after `a)`, before the second `.modulo(b)`), so
594
+ # it correctly fails the "reaches the end" check below and must
595
+ # be tried again at the NEXT occurrence rather than giving up.
596
+ # Trying occurrences strictly left to right and taking the FIRST
597
+ # one whose matching close reaches the string's last character
598
+ # handles both shapes with the same rule: for NESTING
599
+ # (`.modulo(x.modulo(y))`), the leftmost (outer) occurrence's own
600
+ # paren-depth tracking already walks straight through the inner
601
+ # call to the true final `)`; for CHAINING, the leftmost
602
+ # occurrence's close lands short and is rejected, so the next
603
+ # occurrence (the true outermost call) is tried instead.
521
604
  def match_call(expr, marker)
522
- index = expr.rindex(marker)
523
- return nil unless index && expr.end_with?(")")
605
+ start = 0
606
+ while (index = expr.index(marker, start))
607
+ close = matching_paren(expr, index + marker.length)
608
+ return [expr[0...index], expr[(index + marker.length)...close]] if close == expr.length - 1
524
609
 
525
- [expr[0...index], expr[(index + marker.length)...-1]]
610
+ start = index + 1
611
+ end
612
+ nil
526
613
  end
527
614
 
615
+ # `matching_brace` (resolver/block_predicates.rb)'s own twin, one
616
+ # bracket pair over: `start` is the index just past the OPENING
617
+ # `(` already consumed by the caller (depth starts at 1, not 0,
618
+ # for the same reason).
619
+ def matching_paren(expr, start)
620
+ depth = 1
621
+ quote = nil
622
+ index = start
623
+ while index < expr.length
624
+ char = expr[index]
625
+ if quote
626
+ quote = nil if char == quote
627
+ elsif ['"', "'"].include?(char)
628
+ quote = char
629
+ elsif char == "("
630
+ depth += 1
631
+ elsif char == ")"
632
+ depth -= 1
633
+ return index if depth.zero?
634
+ end
635
+ index += 1
636
+ end
637
+ nil
638
+ end
639
+
640
+ # Both operands are coerced to a real Integer/Float BEFORE the
641
+ # zero-check, and the check reads the COERCED divisor — not the
642
+ # raw `divisor_value` (which might not even respond to `.zero?`,
643
+ # a String for instance) and not a `.to_i`-truncated stand-in for
644
+ # it either. The old order checked a truncated `divisor.to_i`
645
+ # AFTER already validating the untruncated value wasn't zero, so
646
+ # a divisor merely small (`0.3`, truncating to `0`) sailed past
647
+ # the guard and then blew up `Integer#%` with a raw
648
+ # `ZeroDivisionError` the moment it reached zero anyway.
649
+ #
650
+ # The modulo itself is plain `%` on the coerced values, matching
651
+ # `add`'s own no-truncation precedent just above — Ruby's native
652
+ # `%` already handles every Integer/Float combination correctly
653
+ # (promoting to Float when either side is one), so rounding both
654
+ # operands down to Integer first was pure data loss with no
655
+ # purpose: `7.5.modulo(2.5)` silently became `7 % 2` (`1`)
656
+ # instead of the real `0.0`.
528
657
  def apply_modulo(receiver_value, divisor_value)
529
- divisor = require_number(divisor_value, "modulo")
658
+ receiver = require_number(receiver_value, "modulo")
659
+ divisor = require_number(divisor_value, "modulo")
530
660
  raise EvaluationError, "divided by 0" if divisor.zero?
531
661
 
532
- require_number(receiver_value, "modulo").to_i % divisor.to_i
662
+ receiver % divisor
533
663
  end
534
664
 
535
665
  def lookup(expr, state, attrs)
@@ -544,13 +674,34 @@ module Hecks
544
674
  # `found_of` (the `Find` node's own path projection) can walk a
545
675
  # `.find { ... }`-produced element the identical way `lookup`
546
676
  # walks a plain attribute path, rather than duplicating the
547
- # `value[segment.to_sym] || value[segment]` step twice in this
548
- # file.
677
+ # symbol-or-string key step twice in this file. `key?` decides
678
+ # which spelling answers — a bare `||` between the two would
679
+ # treat a genuinely-held `false` the same as an absent key and
680
+ # fall through to the other spelling, landing on `nil`.
549
681
  def walk_path(value, segments)
550
682
  segments.reduce(value) do |current, segment|
551
683
  break nil unless current.respond_to?(:[])
552
684
 
553
- current[segment.to_sym] || current[segment]
685
+ if current.is_a?(Hash)
686
+ sym = segment.to_sym
687
+ current.key?(sym) ? current[sym] : current[segment]
688
+ else
689
+ begin
690
+ current[segment]
691
+ rescue TypeError
692
+ # M9 (docs/audits/2026-08-10-main-bug-audit.md): a dotted
693
+ # path can walk onto an Array (e.g. the result of `.split`,
694
+ # or a `list_of` attribute) — Array#[] demands an
695
+ # Integer/Range and raises a raw TypeError for a String
696
+ # segment ("no implicit conversion of String into
697
+ # Integer"), which used to cross straight past this
698
+ # sublanguage's own refusal boundary and crash the
699
+ # runtime instead of reading as "this predicate doesn't
700
+ # apply here."
701
+ raise EvaluationError,
702
+ "cannot read #{segment.inspect} from #{describe(current)}"
703
+ end
704
+ end
554
705
  end
555
706
  end
556
707
 
@@ -3,7 +3,7 @@ require_relative "../ir"
3
3
 
4
4
  module Hecks
5
5
  module Bluebook
6
- Port = Struct.new(:name, :verb, :signal, keyword_init: true) do
6
+ Port = Struct.new(:name, :verb, :signal, :answers, keyword_init: true) do
7
7
  def reply? = signal == :reply
8
8
  def effect? = signal == :effect
9
9
  end
@@ -25,10 +25,23 @@ module Hecks
25
25
  class Judge
26
26
  include Readings
27
27
 
28
- # Children offered BEFORE the parent's own lists. An attribute's type is
29
- # offered as the id of the thing it names, so both the value objects and the
30
- # entities have to exist before any attribute names one.
31
- EAGER_CHILDREN = { "Aggregate" => %w[Entity ValueObject] }.freeze
28
+ # Children offered BEFORE the parent's own lists, IN THIS ORDER. An
29
+ # attribute's type is offered as the id of the thing it names, so the
30
+ # value objects have to exist before anything that can name one — an
31
+ # aggregate's own attributes, AND an entity's own (M13: an entity is its
32
+ # own root, repeating the aggregate's whole shape one level down, so its
33
+ # attributes resolve against the SAME value-object pool). ValueObject
34
+ # first, Entity second, so an entity's own attributes are never offered
35
+ # before the value objects they may reference exist — a self-hosting
36
+ # casualty found live: the meta-grammar's own Handler/Dispatch/Member/
37
+ # Keyword/Argument entities (S17, ADR 0026) failed reference resolution
38
+ # on their own plain value-object-typed attributes (`HandlerText`,
39
+ # `MemberPosition`, ...) the moment entity attributes started being
40
+ # checked at all, because `@plan.names`' own (incidental) declaration
41
+ # order happened to walk Entity first. The order is stated here, not
42
+ # left to whatever order the plan's own category table iterates in —
43
+ # see `detail_node`'s own use of this constant, below.
44
+ EAGER_CHILDREN = { "Aggregate" => %w[ValueObject Entity] }.freeze
32
45
 
33
46
  # Categories an ENTITY declares as well as an aggregate. The IR reuses
34
47
  # Command and Query for a piece's own commands and queries, so the
@@ -157,13 +170,32 @@ module Hecks
157
170
  identity = extra.merge(node_identity(plan, category, node, index, parent_id))
158
171
  receiver ||= { aggregate: id, entities: [] }
159
172
  eager, later = children_of(category).partition { |child| eager?(category, child) }
173
+ # ORDERED AS `EAGER_CHILDREN` DECLARES, not as `children_of` happens to
174
+ # list them — `children_of` reads `@plan.names`, whose own order is an
175
+ # accident of which .bluebook file registered which category first,
176
+ # never a promise about which of two eager children exists before the
177
+ # other. `EAGER_CHILDREN`'s own array IS that promise (ValueObject
178
+ # before Entity), so the walk keeps only what this parent actually
179
+ # has, in the order the constant states — see that constant's own
180
+ # comment for the bug this exact reordering fixes.
181
+ eager = Array(EAGER_CHILDREN[category]) & eager
160
182
 
161
183
  eager.each { |child| walk_all(child, node, id, entity_child_extra(child, identity), receiver: receiver) }
162
184
  setters(plan, category, node, receiver)
163
- appends(plan, category, node, receiver)
185
+ # BEFORE `appends`, not after — the same reason `EAGER_CHILDREN`
186
+ # walks an aggregate's OWN entities before its OWN attributes
187
+ # (M13): a piece nested inside a piece (Handler's own
188
+ # `dispatches, list_of(Dispatch)` — S17, ADR 0026) must exist
189
+ # before this piece's own attribute list can reference it as a
190
+ # HELD entity, the same way `Account#ledger` needs Account's own
191
+ # entities walked eagerly. `nest_entities` is a no-op for every
192
+ # category but "Entity" (its own early return), so reordering it
193
+ # ahead of `appends` costs nothing for anything else that walks
194
+ # through here.
195
+ nest_entities(category, node, id, parent_id)
196
+ appends(plan, category, node, receiver, parent_id)
164
197
  later.each { |child| walk_all(child, node, id, entity_child_extra(child, identity), receiver: receiver) }
165
198
  within_entity(category, node, id, parent_id)
166
- nest_entities(category, node, id, parent_id)
167
199
  sealers(plan, category, receiver)
168
200
  end
169
201
 
@@ -353,8 +385,9 @@ module Hecks
353
385
  end
354
386
  end
355
387
 
356
- def appends(plan, category, node, receiver)
388
+ def appends(plan, category, node, receiver, parent_id)
357
389
  id = receiver[:entities].last || receiver[:aggregate]
390
+ owner_id = owning_aggregate_ref(category, id, parent_id)
358
391
  plan.appends.each do |list_name, append|
359
392
  rows_for(category, list_name, node).each_with_index do |row, index|
360
393
  chosen = append_for(category, list_name, append, row, node)
@@ -367,7 +400,7 @@ module Hecks
367
400
  v(index)
368
401
  else
369
402
  carried(@plan.category(category), chosen.verb, argument,
370
- cell(category, list_name, row, field, id, chosen))
403
+ cell(category, list_name, row, field, id, chosen, owner_id))
371
404
  end
372
405
  [argument.to_sym, value]
373
406
  end
@@ -378,21 +411,41 @@ module Hecks
378
411
  end
379
412
  end
380
413
 
414
+ # WHICH AGGREGATE OWNS THE VALUE OBJECTS an attribute's TYPE may
415
+ # resolve against. An aggregate owns its own — `id` already names
416
+ # it. An entity never has value objects of its own (Entity
417
+ # deliberately never answers `value_object` — see entity.rb's own
418
+ # comment on why); its attributes read the SAME pool its
419
+ # enclosing aggregate declares, one level up the construct tree
420
+ # no matter how many entities deep this attribute is nested —
421
+ # `parent_id` names it because `detail_node`/`nest_entities`
422
+ # thread the ROOT aggregate's id down as `parent_id` at every
423
+ # entity level, never the direct (possibly entity) parent.
424
+ def owning_aggregate_ref(category, id, parent_id)
425
+ category == "Entity" ? parent_id : id
426
+ end
427
+
381
428
  def sealers(plan, category, receiver)
382
429
  id = receiver[:entities].last || receiver[:aggregate]
383
430
  plan.sealers.each { |verb| send_to("Bluebook::#{verb_for(plan, verb)}", id, to: receiver) }
384
431
  end
385
432
 
386
- # An aggregate's attribute names its value object by TYPE, and the language
387
- # models that as a reference — so the type is offered as the value object's
388
- # own id. This is the rule "attributes must use value-object types",
389
- # enforced by reference resolution rather than by a predicate.
433
+ # An aggregate's or an entity's attribute names its value object by TYPE,
434
+ # and the language models that as a reference — so the type is offered as
435
+ # the value object's own id. This is the rule "attributes must use
436
+ # value-object types", enforced by reference resolution rather than by a
437
+ # predicate — for a HEAD's own attributes, aggregate or entity alike: an
438
+ # entity is its own root, repeating the aggregate's whole shape one level
439
+ # down (entity.rb's own words), and an undeclared type on an entity's
440
+ # attribute must fail the same reference resolution an aggregate's own
441
+ # does, not go unchecked because only "Aggregate.attributes" was ever
442
+ # asked.
390
443
  # An attribute's type is offered as the ID OF THE THING IT NAMES, so the
391
444
  # language resolves it as a reference and "the type is declared" costs no
392
445
  # predicate. Three kinds, three ids: a value object and an entity both hang
393
446
  # off this aggregate, so they share its prefix; another aggregate's head
394
447
  # hangs off the chapter.
395
- def cell(category, list_name, row, field, id, append)
448
+ def cell(category, list_name, row, field, id, append, aggregate_id)
396
449
  value = row_value(row, field)
397
450
  # A default keeps its TYPE by being written as a literal — 0.0 rather than
398
451
  # "0.0" — because the language holds it as text and text alone forgets.
@@ -400,13 +453,22 @@ module Hecks
400
453
  return value unless field == :type
401
454
  # A REFERENCE names another head WHEREVER it is written — on a head, on
402
455
  # a command, on a piece, on an ask — so it is offered as that head's
403
- # id in all four. Only the head's own attributes additionally qualify
456
+ # id in all four. Only a HEAD's own attributes additionally qualify
404
457
  # an ordinary type into a value object's id ; a command argument's
405
458
  # type is text and stays text.
406
459
  return points_at(row, id) if append.verb == "Reference"
407
- return value unless "#{category}.#{list_name}" == "Aggregate.attributes"
460
+ return value unless attribute_list?(category, list_name)
461
+
462
+ Naming.identity([owning_aggregate_id(aggregate_id, value), value])
463
+ end
408
464
 
409
- Naming.identity([owning_aggregate_id(id, value), value])
465
+ # A HEAD'S OWN ATTRIBUTES — an aggregate's, or an entity's (its own root,
466
+ # one level down). Every other "attributes" list belongs to something that
467
+ # is not a head at all (a command's arguments, a value object's own
468
+ # fields), and a type written there is a name, not a reference — the same
469
+ # distinction `cell`'s own comment draws.
470
+ def attribute_list?(category, list_name)
471
+ list_name.to_s == "attributes" && %w[Aggregate Entity].include?(category)
410
472
  end
411
473
 
412
474
  # `id` NAMES THE ATTRIBUTE'S OWN AGGREGATE, not necessarily the
@@ -40,6 +40,10 @@ module Hecks
40
40
  def judge!
41
41
  send_to("Port::Port.Declare", @port.name, name: v(@port.name),
42
42
  verb: v(@port.verb), signal: v(@port.signal))
43
+
44
+ Array(@port.answers).each do |answer|
45
+ send_to("Port::Port.AddAnswer", @port.name, name: @port.name, value: v(answer))
46
+ end
43
47
  end
44
48
  end
45
49
  end
@@ -53,6 +53,14 @@ module Hecks
53
53
  # argument became indistinguishable from one carrying a literal string.
54
54
  def with_spec_rows(node) = pair_rows(node.to_h[:with_spec])
55
55
 
56
+ # THE SAME READ, ONE LEVEL IN — `compensates` folds `DispatchSpec`
57
+ # into the language's own `compensates_command_name`/
58
+ # `compensates_with_spec` (`Assembly::Contracts`' own comment on
59
+ # "Dispatch"), so its OWN with_spec pairs live nested one hash
60
+ # down from where `with_spec_rows` looks. `&.dig(...)` — no
61
+ # compensation at all is not an error, it is `pair_rows(nil)`, empty.
62
+ def compensates_with_spec_rows(node) = pair_rows(node.to_h[:compensates]&.dig(:with_spec))
63
+
56
64
  # A read model carries the same options an ask does, plus its filters — see
57
65
  # option_rows.
58
66
  def read_model_option_rows(node) = option_rows(node, filters: true)
@@ -155,10 +163,12 @@ module Hecks
155
163
  # was being handed a blank and could never refuse.
156
164
  def mutation_rows(node)
157
165
  Array(node.mutations).flat_map do |mutation|
158
- # `:delegate` (CommandBuilder#delegates_to's own comment) rides
159
- # the SAME multi-binding shape `:append` does — `with: {...}`
160
- # is a field map, same as append's own `fields:`.
161
- next set_row(mutation) unless [:append, :delegate].include?(mutation.op)
166
+ # `:delegate`/`:corrects` (CommandBuilder#delegates_to's and
167
+ # #corrects_impl's own comments) ride the SAME multi-binding
168
+ # shape `:append` does `with: {...}`/the assembled
169
+ # `as:`/`reason:`/`reverses:` hash is a field map, same as
170
+ # append's own `fields:`.
171
+ next set_row(mutation) unless [:append, :delegate, :corrects].include?(mutation.op)
162
172
 
163
173
  mutation.source.map do |field, argument|
164
174
  # Spelled the way Mutation#appended_fields spells it, because
@@ -207,9 +207,22 @@ module Hecks
207
207
  # at a time.
208
208
  def members_row(row) = members_of(row)
209
209
 
210
+ # THE KEY IS STRINGIFIED, NEVER THE VALUE — the same split
211
+ # `Bluebook::ValueObject#to_h`'s own `members:` emission makes
212
+ # (lib/hecks/bluebook/value_object.rb, L7 docs/audits/
213
+ # 2026-08-11-bug-triage.md). `Pair.value` is declared `String`
214
+ # (shape.bluebook), but a real pair can hold whatever native Ruby
215
+ # type the source member line actually wrote (`StatementFrequency`'s
216
+ # own `retention_months: 84`, examples/banking/bluebook/
217
+ # statements.bluebook) — `text` already unwraps the stored Value to
218
+ # that real type, so calling `.to_s` here erased it a second time,
219
+ # on the side spec/round_trip_spec.rb compares straight against a
220
+ # fresh raw load (`Reconstruction.of` directly, no Assembly in
221
+ # between): `declared 84, read back "84"` the moment `to_h` stopped
222
+ # erasing it on the OTHER side and this one kept erasing it alone.
210
223
  def members_of(value_object_row)
211
224
  Array(value_object_row[:members]).map do |member|
212
- Array(member[:pairs]).map { |pair| [text(pair[:key]).to_s, text(pair[:value]).to_s] }
225
+ Array(member[:pairs]).map { |pair| [text(pair[:key]).to_s, text(pair[:value])] }
213
226
  end
214
227
  end
215
228
 
@@ -238,7 +251,12 @@ module Hecks
238
251
  # Symbol there — the IR was not uniform about it, and only a round trip
239
252
  # ever said so.
240
253
  identified_by: identity_paths(row),
241
- attributes: Array(row[:attributes]).map { |field| shape_field(field) },
254
+ # `text(row[:aggregate])` the OWNING aggregate, the same one
255
+ # `Judge#owning_aggregate_ref` resolved this piece's own
256
+ # attribute types against on the way in, so reconstruction reads
257
+ # a value-object-typed attribute back the identical way an
258
+ # aggregate's own is (`Shapes#shape_field`'s own comment).
259
+ attributes: Array(row[:attributes]).map { |field| shape_field(field, text(row[:aggregate])) },
242
260
  # ADR 0028 — the SAME shape `aggregate(row)`'s own
243
261
  # `preconditions:` reads two hand-typed methods up, read by
244
262
  # hand for the identical reason: `entity(row)` is hand-typed
@@ -301,7 +319,28 @@ module Hecks
301
319
  dispatches: Array(row[:dispatches]).map { |leg| dispatch(leg) })
302
320
  end
303
321
 
304
- def dispatch(row) = declaration("Dispatch", row)
322
+ # `compensates` TWO FLAT FIELDS on this same row
323
+ # (`process_manager.bluebook`'s own comment on `Dispatch` for
324
+ # why), assembled BY HAND into the shape its own field actually
325
+ # is — `declaration()`'s generic per-field hash-build has no
326
+ # way to turn two cells into a second object, so this reads
327
+ # them directly and passes the result through `extra:`, the
328
+ # same seam `handler`/`process_manager` already use for a
329
+ # `:children` shape a flat Contract cannot describe.
330
+ # `compensates_command_name` absent means no compensation at
331
+ # all — a plain dispatch with nothing to undo. A PLAIN HASH, the
332
+ # SAME declaration shape `to_h` spells for everything else in
333
+ # this file (this file's own top comment) — never a real
334
+ # `DispatchSpec` here; `Assembly#dispatch` is the one place a
335
+ # declaration hash becomes the real object, and building it
336
+ # twice, in two different shapes, is exactly the kind of drift
337
+ # this whole arc exists to remove.
338
+ def dispatch(row)
339
+ name = text(row[:compensates_command_name])
340
+ compensates = name && { command_name: name, with_spec: pairs(row[:compensates_with_spec]) }
341
+
342
+ declaration("Dispatch", row, compensates: compensates)
343
+ end
305
344
 
306
345
  def read_model(row)
307
346
  declaration("ReadModel", row).merge(query_name: text(row[:query_name])).merge(options_of(row))
@@ -52,14 +52,33 @@ module Hecks
52
52
  def identity_path(part) = text(part[:value]).to_s
53
53
 
54
54
  # An argument, a parameter, a piece's attribute — the three places an
55
- # attribute is written that carry no owner id to strip.
55
+ # attribute is written that carry no owner id to strip... except a
56
+ # piece's own attribute now does : an entity is its own root (repeating
57
+ # the aggregate's whole shape one level down), so ITS attributes name a
58
+ # value object exactly the way an aggregate's own do (`Judge#cell`'s own
59
+ # comment), and reconstruction has to undo the same owned-vs-reference
60
+ # split `attribute`, below, already undoes for an aggregate — not the
61
+ # cruder "any qualified name is a Reference" guess this used before,
62
+ # which mistook a piece's own value-object type for a cross-aggregate
63
+ # head every time.
56
64
  #
57
- # A REFERENCE among them came in as the head's ID, because that is what
58
- # Command/Entity/Query.Reference offer, so it goes back out as the
59
- # encoding the IR spells. A qualified name is the tell : an ordinary
60
- # type names something declared beside it (`Money`, `AccountNumber`) and
61
- # never carries a chapter, while a head's id always does.
62
- def shape_field(field)
65
+ # `aggregate_id`, passed only for a piece's own attribute (an
66
+ # argument or a parameter carries no value-object type at all — see
67
+ # entity.bluebook's/command.bluebook's own comments on why so the
68
+ # generic reader path calls this with one argument and gets the
69
+ # OLD reading unchanged), is the piece's OWNING aggregate — the same
70
+ # one its own attribute types were resolved against on the way in
71
+ # (`Judge#owning_aggregate_ref`).
72
+ #
73
+ # A REFERENCE among the other two came in as the head's ID, because
74
+ # that is what Command/Query.Reference offer, so it goes back out as
75
+ # the encoding the IR spells. A qualified name is the tell : an
76
+ # ordinary type names something declared beside it (`Money`,
77
+ # `AccountNumber`) and never carries a chapter, while a head's id
78
+ # always does.
79
+ def shape_field(field, aggregate_id = nil)
80
+ return attribute(field, aggregate_id) if aggregate_id
81
+
63
82
  type = text(field[:type]).to_s
64
83
 
65
84
  {
@@ -221,9 +240,10 @@ module Hecks
221
240
  # language does not hold is a named gap, not a byte-for-byte one).
222
241
  def mutation(target, op, bindings)
223
242
  base = { target: target.to_sym, op: op.to_sym, sign: Hecks::Bluebook::Mutation.sign_for(op) }
224
- # `:delegate` (CommandBuilder#delegates_to's own comment) rides
225
- # the SAME multi-binding shape `:append` does.
226
- return base.merge(fields: appended(bindings)) if ["append", "delegate"].include?(op)
243
+ # `:delegate`/`:corrects` (CommandBuilder#delegates_to's and
244
+ # #corrects_impl's own comments) ride the SAME multi-binding
245
+ # shape `:append` does.
246
+ return base.merge(fields: appended(bindings)) if ["append", "delegate", "corrects"].include?(op)
227
247
 
228
248
  base.merge(source: classified(bindings.first))
229
249
  end