hecks 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d056aad218f4fe26394319c5ce486211ac9790022de8159ba23f050a634f5df
4
- data.tar.gz: d6fa0c2f693152be740b0c5f658008a6ba71ae9bd80e175e8da78b142f00f133
3
+ metadata.gz: 82b36c04d3c930c3f3193aeb23d208c170ab95507dfbac4842b8dae59168a732
4
+ data.tar.gz: 1cac5931a30cf658980cc65cf8c31a90b36deae5460a9ed6846902d51faa4630
5
5
  SHA512:
6
- metadata.gz: 4ae526f5a34b194a9b3d23751fae0bb6bdee1bcd8011653904d2b324bf4478ee7b4049d96f0b2a74d005a810ac7023d6efa5a8d0639fb079ed8691da93a5f217
7
- data.tar.gz: c2b1a7be12ebfbd52ad5cb13ddd1aa7693a7d903d1402c580baad40414a7f3318d7263379658debc502492986956f97b5377681ac93cf5cb36e058c3e1ccfb46
6
+ metadata.gz: a90f3c1372ac1a9d3755fe3fbc83a9036cea933a9b63f9517bd52e1010c397c6e2dacd21c6a5030b1fdebbca4bb339c1cc651923b20c1bd9aa1160161ccd7d23
7
+ data.tar.gz: 6d1069bd54229fddc5befad9ff9afb727fba894816895acc59fcfec20000c765e0444cdd0fd23dc9a37a5a3478c209a0005c836f5351483fbadbfbebb7742566
@@ -204,7 +204,7 @@ module Hecks
204
204
 
205
205
  object = @aggregate.value_object(attribute.type)
206
206
  return "" unless object
207
- return object.attributes.first.name.to_s if object.attributes.size == 1
207
+ return object.sole_attribute.name.to_s if object.sole_attribute
208
208
 
209
209
  raise ArgumentError,
210
210
  "#{dialect_name} query adapter cannot compile contains on #{field} — " \
@@ -17,13 +17,19 @@ module Hecks
17
17
 
18
18
  # A single-attribute value object (EmailAddress{address},
19
19
  # CustomerNumber{value}) is a NAME for a scalar, not a genuine
20
- # group — [[feedback_name_the_scalar_field]]. Four call sites
21
- # already inline this exact check (`attributes.size == 1` /
22
- # `attributes.first`) `forms/field_shape.rb` (twice),
23
- # `adapters/driven/sql_query_builder.rb`,
24
- # `fuzzing/invalid_value_generator.rb` kept as-is for now
25
- # (not migrated to this reader), but any NEW caller should
26
- # reach for this rather than add a fifth inline copy.
20
+ # group — [[feedback_name_the_scalar_field]]. `adapters/driven/
21
+ # sql_query_builder.rb` and `fuzzing/invalid_value_generator.rb`
22
+ # now read through this rather than inlining the check.
23
+ #
24
+ # `forms/field_shape.rb`'s own two `attributes.first.name` sites
25
+ # (`closed_set_options`/`closed_set_field`) look identical but are
26
+ # NOT the same question they pick a closed set's DISCRIMINANT
27
+ # column, and a closed set can be genuinely multi-attribute
28
+ # (`Runtime::Value::Admission#member_matches?`'s own comment
29
+ # names a real one: `StatementFrequency`'s `cadence`/
30
+ # `retention_months`/`paper_fee_cents`). `sole_attribute` would
31
+ # return `nil` for that shape and break the discriminant lookup —
32
+ # left as `.first` on purpose, not a missed migration.
27
33
  def sole_attribute
28
34
  attributes.first if attributes.size == 1
29
35
  end
@@ -554,6 +554,59 @@ module Hecks
554
554
  end
555
555
  refuse_unknown_state_sources!(mutation)
556
556
  end
557
+ # A SECOND, SEPARATE pass — not folded into the loop above — so
558
+ # every mutation's own self-referential import (resolve_bare_set!)
559
+ # has already landed before any mutation's SOURCE is checked
560
+ # against the final `attributes` list. `sets :a, to: :b` declared
561
+ # before `sets :b` (bare, importing :b from the owner) is real
562
+ # and legal; checking inline, mutation by mutation, would refuse
563
+ # it for an ordering accident this language has never required
564
+ # authors to avoid. rubocop's Style/CombinableLoops can't see
565
+ # that dependency — it only sees two `@mutations.each` calls
566
+ # back to back — so it's disabled here, not satisfied.
567
+ # rubocop:disable-next Style/CombinableLoops
568
+ @mutations.each { |mutation| refuse_unknown_argument_sources!(mutation) }
569
+ end
570
+
571
+ # THE ONES `resolve_source` (CommandRules::Arithmetic) ONLY EVER
572
+ # READS FROM `args` — never a fallback to the record's own current
573
+ # state the way `append`'s own per-field resolution legitimately
574
+ # can (`MutationApplier#resolve_append_source`'s own `instance
575
+ # [source]` fallback, a real, intentional second meaning this
576
+ # check must not foreclose). For exactly these five ops, a bare
577
+ # Symbol source has exactly one legitimate reading — the name of
578
+ # a declared argument — so anything else is unreachable at
579
+ # runtime, not merely optional: no caller can ever supply a value
580
+ # under a name the command never declared (ArgumentGate's own
581
+ # `refuse_unknown_arguments` already refuses that), so the source
582
+ # resolves to nil FOREVER, indistinguishable from a legitimately
583
+ # absent OPTIONAL argument until this check existed to tell them
584
+ # apart. Mirrors `AggregateBuilder#seal_query_argument`'s
585
+ # identical shape for a query's own where-clause argument —
586
+ # same mistake, one construct over.
587
+ CHECKED_SYMBOL_SOURCE_OPS = %i[set increment decrement multiply remove].freeze
588
+ private_constant :CHECKED_SYMBOL_SOURCE_OPS
589
+
590
+ def refuse_unknown_argument_sources!(mutation)
591
+ return unless CHECKED_SYMBOL_SOURCE_OPS.include?(mutation.op)
592
+ return unless mutation.source.is_a?(Symbol)
593
+ # The bare self-referential shape (`sets :field` alone, `source
594
+ # == target`) is `resolve_bare_set!`'s own territory, not this
595
+ # check's — when neither the command nor the owner declares
596
+ # that name, the MORE SPECIFIC, pre-existing refusal one level
597
+ # up (`AggregateBuilder#seal_mutation_targets`, checking the
598
+ # mutation's TARGET against the aggregate's own fields) is the
599
+ # one that should fire, naming the field as a target problem,
600
+ # not — confusingly — as a source problem this check would
601
+ # otherwise misreport it as.
602
+ return if mutation.source.to_s == mutation.target.to_s
603
+ return if attributes.any? { |attr| attr.name == mutation.source }
604
+
605
+ raise Malformed,
606
+ "#{@name}'s sets :#{mutation.target} resolves :#{mutation.source} from its " \
607
+ "arguments, but #{@name} declares no #{mutation.source} attribute — an " \
608
+ "argument that does not exist resolves to nil, always, never what the " \
609
+ "caller actually sent"
557
610
  end
558
611
 
559
612
  # `state(:name)` names one of the OWNER'S OWN fields — a snapshot
@@ -7,10 +7,28 @@ module Hecks
7
7
 
8
8
  include WordGate
9
9
 
10
- def initialize(name, owner: nil)
11
- @name = name
12
- @owner = owner
13
- @operations = []
10
+ # `legacy_bare_port:` ONLY `Hecks.port`'s own top-level method
11
+ # (lib/hecks.rb) passes `true`. `PortBuilder#build` never refused
12
+ # an empty build (no verb, no signal, nothing) — `Port.new(verb:
13
+ # nil, signal: :reply)` is a real, allowed shape dsl_spec.rb's own
14
+ # "a port" tests rely on (`signal`-only, no `verb` at all). The
15
+ # AGGREGATE-scoped (`BindingProxy#port`) and hecksagon-ROOT
16
+ # (`HecksagonBuilder#port_impl`) callers both reach this SAME
17
+ # class with `owner: nil` too when they're building the bare-verb
18
+ # shape (`port_impl`'s own root-level port can be EITHER shape,
19
+ # decided only after `build` returns) — so `owner.nil?` cannot be
20
+ # the discriminator between "old Hecks.port semantics" and "real
21
+ # DomainPort semantics"; those two callers correctly want the
22
+ # stricter "declares no verb and no operations" refusal `build`
23
+ # already raises below, unchanged. Only the literal top-level
24
+ # `.port` file caller wants the older, looser rule.
25
+ def initialize(name, owner: nil, legacy_bare_port: false)
26
+ @name = name
27
+ @owner = owner
28
+ @operations = []
29
+ @signal = :reply
30
+ @answers = []
31
+ @legacy_bare_port = legacy_bare_port
14
32
  end
15
33
 
16
34
  # WHAT THE OUTSIDE TELLS US — an external fact arriving, translated
@@ -58,18 +76,42 @@ module Hecks
58
76
  # coerce-and-assign with nothing else, now executed by
59
77
  # `GenericDispatch`.
60
78
 
79
+ # `Hecks.port "x" do verb "y"; signal :effect end`'s own two words,
80
+ # reachable here too — a bare-verb `DomainPortBuilder.build` falls
81
+ # back to the SAME `Port` object `PortBuilder` produces (`build`,
82
+ # below), so any `.port` file can migrate to being parsed by this
83
+ # builder with zero change to its own text, or to any caller that
84
+ # reads `.verb`/`.signal` off the `Port` it gets back. Ordinary
85
+ # `def`s, exactly like `PortBuilder`'s own — `WordGate`'s own
86
+ # header is explicit that a word answered this way never reaches
87
+ # its `method_missing`, so no new self-hosted grammar row is
88
+ # needed for either word under this context.
89
+ def verb(value) = @verb = value.to_s
90
+ def signal(value) = @signal = value.to_sym
91
+
92
+ # THE METHOD CONTRACT — `PortBuilder#answers`'s own twin, added
93
+ # here after the fact: a `.port` file migrated to parse through
94
+ # this builder (the repoint `lib/hecks.rb#port`'s own comment
95
+ # describes) can still declare one (`extraction.port`'s own
96
+ # `answers :canonical`, real, live corpus text) — this builder's
97
+ # bare-verb fallback needs to carry it through to the same `Port`
98
+ # object `PortBuilder` itself would have built, or the migration
99
+ # would silently drop a method-contract check for any `.port`
100
+ # file that uses this word.
101
+ def answers(name) = @answers << name.to_sym
102
+
61
103
  def build
62
104
  raise Malformed, "#{@name} declares both a verb and operations — a port is one or the other, not both" if @verb && !@operations.empty?
63
105
 
64
- return Port.new(name: @name, verb: @verb, signal: :reply) if @verb
106
+ return MetaValidator.call_port(Port.new(name: @name, verb: @verb, signal: @signal, answers: @answers)) if @verb || (@legacy_bare_port && @operations.empty?)
65
107
 
66
108
  raise Malformed, "#{@name} declares no verb and no operations" if @operations.empty?
67
109
 
68
110
  DomainPort.new(name: @name, operations: @operations)
69
111
  end
70
112
 
71
- def self.build(name, owner: nil, &block)
72
- builder = new(name, owner: owner)
113
+ def self.build(name, owner: nil, legacy_bare_port: false, &block)
114
+ builder = new(name, owner: owner, legacy_bare_port: legacy_bare_port)
73
115
  builder.instance_eval(&block) if block
74
116
  builder.build
75
117
  end
@@ -0,0 +1,149 @@
1
+ require_relative "evaluator"
2
+ require_relative "resolver"
3
+
4
+ module Hecks
5
+ module Bluebook
6
+ module Expression
7
+ # ── AST → JSON — walks the REAL Evaluator/Resolver AST (the same
8
+ # objects a live dispatch parses `given`/`ensures`/invariant text
9
+ # into — see docs/implemented/guides/running-a-runtime.md's "The
10
+ # expression grammar") and emits plain, JSON-serializable Ruby
11
+ # Hashes, tagged by `"op"`.
12
+ #
13
+ # THE SAME TWO-METHOD WALK `rust/project/expr_emitter.rb`'s own
14
+ # `emit_bool`/`emit_resolver` already do, over the SAME AST — that
15
+ # file's own methods build RUST SOURCE-CODE STRINGS for `rust/
16
+ # project`'s codegen (`rust/src/kernel::expr::Expr` literals, baked
17
+ # into a generated domain's own compiled binary); this builds DATA
18
+ # instead, for a genuinely different consumer with a genuinely
19
+ # different constraint: `rust/host` can never link the `rust`
20
+ # (kernel) crate at all (a real, load-bearing build constraint —
21
+ # `reference_validate.rs`'s own header has the full reasoning: one
22
+ # path dependency would statically bake every domain's generated
23
+ # dispatch code into every Lambda binary), so a value object's own
24
+ # `invariant` predicate has to travel as something `rust/host` can
25
+ # deserialize and interpret itself, at RUNTIME, from `ir.json` — the
26
+ # exact same relationship `rust/project`'s own `Expr` literals
27
+ # already have to the compiled kernel, one layer further out.
28
+ #
29
+ # LIVES IN CORE `lib/hecks`, NOT `rust/project/` — `rust/project.rb`
30
+ # is a separate, downstream toolchain
31
+ # (`lib/hecks/projector.rb`'s own header: "a whole separate Ruby
32
+ # program"), never `require`d by core `lib/hecks/bluebook/*.rb`
33
+ # files (confirmed: no core file does). `value_object.rb`'s own
34
+ # `invariants:` IR emission needs this for EVERY domain's ordinary
35
+ # `to_h`/`ir.json` export — golden fixtures, `hecks-parse`'s parity
36
+ # comparisons, and any deploy artifact, not only a `bin/project_rust`
37
+ # run — so it belongs beside `Evaluator`/`Resolver` themselves, not
38
+ # bolted onto a tool that only sometimes runs.
39
+ #
40
+ # COMPLETE, not corpus-scoped: every node this grammar admits gets
41
+ # a real arm, the identical "raise, don't silently drop" discipline
42
+ # `expr_emitter.rb`'s own `emit_bool`/`emit_resolver` already hold
43
+ # to — even though, as of this writing, no real corpus VALUE OBJECT
44
+ # invariant exercises `Include`/`Modulo`/`BlockPredicate`/`Find`/
45
+ # `Array`/`MatchesRegex`/`Presence`/`Split`/`StartsWith`/`EndsWith`/
46
+ # `First`/`Last` (only `given`/`ensures` clauses do, elsewhere in
47
+ # the corpus — a different consumer of this same grammar).
48
+ # `rust/host/src/expr_json.rs`'s own header names exactly which of
49
+ # these its interpreter evaluates for real today versus refuses
50
+ # cleanly — a narrower, deliberate, documented boundary on the
51
+ # INTERPRETING side, not on this EMITTING side: an author is free
52
+ # to write ANY real expression in a value object's own `invariant`,
53
+ # and this always emits it faithfully; whether `rust/host` can yet
54
+ # CHECK it at mint time is that file's own question to answer, not
55
+ # this one's to pre-empt by refusing to even try.
56
+ module AstJson
57
+ module_function
58
+
59
+ def emit_predicate(canonical)
60
+ emit_bool(Evaluator.parse(canonical))
61
+ end
62
+
63
+ def emit_bool(node)
64
+ case node
65
+ when Evaluator::Or then { "op" => "or", "left" => emit_bool(node.left), "right" => emit_bool(node.right) }
66
+ when Evaluator::And then { "op" => "and", "left" => emit_bool(node.left), "right" => emit_bool(node.right) }
67
+ when Evaluator::Not then { "op" => "not", "expr" => emit_bool(node.node) }
68
+ when Evaluator::Compare
69
+ { "op" => "compare", "cmp" => emit_comparison(node.operator),
70
+ "left" => emit_resolver(node.left), "right" => emit_resolver(node.right) }
71
+ when Evaluator::Include
72
+ emit_include(node)
73
+ when Evaluator::Resolve
74
+ emit_resolver(node.expr)
75
+ else
76
+ raise "unhandled evaluator node #{node.class} — no JSON rendering exists for it (lib/hecks/bluebook/expression/ast_json.rb#emit_bool)"
77
+ end
78
+ end
79
+
80
+ def emit_comparison(op)
81
+ { "less_than" => op.compares_less_than, "equal" => op.compares_equal, "negated" => op.negated }
82
+ end
83
+
84
+ # The JSON-target sibling of `expr_emitter.rb`'s own
85
+ # `emit_include` — see that method's own comment for the full
86
+ # reasoning (a LITERAL array haystack has no `Expr::Include`-
87
+ # representable shape on EITHER target, kernel or host, so both
88
+ # rewrite it identically into an OR-of-equalities at emission
89
+ # time rather than carrying a shape neither interpreter could
90
+ # evaluate). A non-literal haystack still emits `include`
91
+ # unchanged.
92
+ EQ = Evaluator::OPERATORS.find { |op| op.symbol == "==" }
93
+ private_constant :EQ
94
+
95
+ def emit_include(node)
96
+ return { "op" => "include", "haystack" => emit_resolver(node.haystack), "needle" => emit_resolver(node.needle) } \
97
+ unless node.haystack.is_a?(Resolver::ArrayLiteral)
98
+
99
+ return { "op" => "bool", "value" => false } if node.haystack.elements.empty?
100
+
101
+ equalities = node.haystack.elements.map do |element|
102
+ { "op" => "compare", "cmp" => emit_comparison(EQ), "left" => emit_resolver(node.needle), "right" => emit_resolver(element) }
103
+ end
104
+ equalities.reduce { |left, right| { "op" => "or", "left" => left, "right" => right } }
105
+ end
106
+
107
+ def emit_resolver(node)
108
+ case node
109
+ when Resolver::IntegerLiteral then { "op" => "int", "value" => node.value }
110
+ when Resolver::FloatLiteral then { "op" => "float", "value" => node.value }
111
+ when Resolver::StringLiteral then { "op" => "str", "value" => node.value }
112
+ when Resolver::BoolLiteral then { "op" => "bool", "value" => node.value }
113
+ when Resolver::NilLiteral then { "op" => "nil" }
114
+ when Resolver::Lookup then { "op" => "lookup", "path" => node.path }
115
+ when Resolver::Addition then { "op" => "add", "left" => emit_resolver(node.left), "right" => emit_resolver(node.right) }
116
+ when Resolver::SignTest
117
+ { "op" => "sign_test", "cmp" => emit_comparison(node.operator), "receiver" => emit_resolver(node.receiver) }
118
+ when Resolver::Empty then { "op" => "empty", "receiver" => emit_resolver(node.receiver) }
119
+ when Resolver::ToS then { "op" => "to_s", "receiver" => emit_resolver(node.receiver) }
120
+ when Resolver::Modulo then { "op" => "modulo", "receiver" => emit_resolver(node.receiver), "divisor" => emit_resolver(node.divisor) }
121
+ when Resolver::Size then { "op" => "size", "receiver" => emit_resolver(node.receiver) }
122
+ when Resolver::BlockPredicate
123
+ { "op" => "block_predicate", "mode" => node.mode.to_s, "receiver" => emit_resolver(node.receiver),
124
+ "param" => node.param.to_s, "predicate" => emit_bool(node.predicate) }
125
+ when Resolver::Find
126
+ { "op" => "find", "receiver" => emit_resolver(node.receiver), "param" => node.param.to_s,
127
+ "predicate" => emit_bool(node.predicate), "path" => node.path.map(&:to_s) }
128
+ when Resolver::ArrayLiteral
129
+ { "op" => "array", "elements" => node.elements.map { |element| emit_resolver(element) } }
130
+ when Resolver::MatchesRegex
131
+ { "op" => "matches_regex", "receiver" => emit_resolver(node.receiver), "pattern" => node.pattern, "flags" => node.flags }
132
+ when Resolver::Presence
133
+ { "op" => "presence", "receiver" => emit_resolver(node.receiver), "negated" => node.negated }
134
+ when Resolver::Split
135
+ { "op" => "split", "receiver" => emit_resolver(node.receiver), "separator" => node.separator }
136
+ when Resolver::StartsWith
137
+ { "op" => "starts_with", "receiver" => emit_resolver(node.receiver), "substring" => node.substring }
138
+ when Resolver::EndsWith
139
+ { "op" => "ends_with", "receiver" => emit_resolver(node.receiver), "substring" => node.substring }
140
+ when Resolver::First then { "op" => "first", "receiver" => emit_resolver(node.receiver) }
141
+ when Resolver::Last then { "op" => "last", "receiver" => emit_resolver(node.receiver) }
142
+ else
143
+ raise "unhandled resolver node #{node.class} — no JSON rendering exists for it (lib/hecks/bluebook/expression/ast_json.rb#emit_resolver)"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -11,3 +11,4 @@ end
11
11
  require_relative "expression/canonical_form"
12
12
  require_relative "expression/resolver"
13
13
  require_relative "expression/evaluator"
14
+ require_relative "expression/ast_json"
@@ -184,7 +184,21 @@ module Hecks
184
184
  # forever, since nothing about *entering* it via default implies
185
185
  # anything must eventually move it, unlike a state a transition
186
186
  # explicitly delivered somewhere.
187
- def terminal_exempt(_lifecycle) = []
187
+ #
188
+ # Scoped to `default` alone, and only when the lifecycle actually
189
+ # declares real transitions elsewhere: an EMPTY lifecycle (no
190
+ # transitions at all) doesn't get this exemption — that's not "a
191
+ # machine whose entry point deliberately awaits external action,"
192
+ # it's much more likely a lifecycle nobody finished wiring, and
193
+ # should still warn (see spec/fixtures/model_check/lifecycle_
194
+ # findings.bluebook's own Widget::Part, which stays warned on
195
+ # purpose).
196
+ def terminal_exempt(lifecycle)
197
+ return [] if lifecycle.transitions.empty?
198
+
199
+ outgoing_sources = lifecycle.transitions.flat_map { |_, t| Array(t.from) }.to_set
200
+ outgoing_sources.include?(lifecycle.default) ? [] : [lifecycle.default]
201
+ end
188
202
 
189
203
  # ── process managers / sagas ──────────────────────────────────────
190
204
 
@@ -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,7 +29,15 @@ 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
42
  # THE FIELD NAME IS STRINGIFIED, NEVER THE VALUE. A `member` row can
34
43
  # hold any of the scalar types an attribute declares — `Integer 84`
@@ -67,11 +67,12 @@ module Hecks
67
67
  # scalar cannot stand for anything and the refusal has to say so.
68
68
  def scalar_for(attribute, aggregate, random:)
69
69
  value_object = aggregate.value_object(attribute.type.to_s)
70
- return "a bare scalar" unless value_object && value_object.attributes.size == 1
70
+ sole = value_object&.sole_attribute
71
+ return "a bare scalar" unless sole
71
72
 
72
73
  # One field, so a scalar is legal — corrupt the FIELD's own type instead,
73
74
  # which is still a shape the attribute cannot accept.
74
- { value_object.attributes.first.name.to_s => ["nested", "array"] }
75
+ { sole.name.to_s => ["nested", "array"] }
75
76
  end
76
77
 
77
78
  # An attribute the command never declared. `refuse_unknown_arguments` is a
@@ -4,7 +4,9 @@ Hecks.bluebook "Hecksagon" do
4
4
 
5
5
  identified_by PortName, as: :name
6
6
 
7
- attribute :verb, PortVerb, optional: true
7
+ attribute :verb, PortVerb, optional: true
8
+ attribute :signal, PortSignal, optional: true
9
+ attribute :answers, list_of(PortAnswer)
8
10
 
9
11
  value_object "PortName" do
10
12
  attribute :value, String
@@ -16,6 +18,24 @@ Hecks.bluebook "Hecksagon" do
16
18
  invariant("a verb is named") { !value.to_s.empty? }
17
19
  end
18
20
 
21
+ # SAME CLOSED SET port.bluebook's own "Port" aggregate declares —
22
+ # kept as its own local value object rather than shared cross-
23
+ # chapter, the same way PortVerb already is here.
24
+ value_object "PortSignal" do
25
+ attribute :value, String, one_of: ["reply", "effect"]
26
+ end
27
+
28
+ # `answers` — DomainPortBuilder's own twin of PortBuilder#answers
29
+ # (port.bluebook's "Port" aggregate, same shape), added once a real
30
+ # `.port` file migrated to parse through this builder (the repoint
31
+ # `lib/hecks.rb#port`'s own comment describes) used the word
32
+ # (`extraction.port`'s own `answers :canonical`). Local, not shared
33
+ # cross-chapter, same reasoning as PortSignal just above.
34
+ value_object "PortAnswer" do
35
+ attribute :value, String
36
+ invariant("an answer names a real method") { !value.to_s.empty? }
37
+ end
38
+
19
39
  # HOW DOMAINPORT IS SPELLED. These rows live with the concept
20
40
  # they describe; SyntaxBoot discovers every aggregate-local seed table.
21
41
  # Contexts represented here: DomainPort.
@@ -35,6 +55,8 @@ Hecks.bluebook "Hecksagon" do
35
55
  member word: "tells", context: "DomainPort", body: "keywords", inner: "PortOperation", opens: "PortOperation", fills: "", calls: "tells_impl"
36
56
  member word: "asks", context: "DomainPort", body: "keywords", inner: "PortOperation", opens: "PortOperation", fills: "", calls: "asks_impl"
37
57
  member word: "verb", context: "DomainPort", body: "none", inner: "", opens: "", fills: "verb"
58
+ member word: "signal", context: "DomainPort", body: "none", inner: "", opens: "", fills: "signal"
59
+ member word: "answers", context: "DomainPort", body: "none", inner: "", opens: "", fills: "answers"
38
60
 
39
61
  end
40
62
 
@@ -59,6 +81,8 @@ Hecks.bluebook "Hecksagon" do
59
81
  member keyword: "tells", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "name"
60
82
  member keyword: "asks", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "name"
61
83
  member keyword: "verb", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "verb"
84
+ member keyword: "signal", context: "DomainPort", at: "1", named: "", kind: "symbol", required: "true", fills: "signal"
85
+ member keyword: "answers", context: "DomainPort", at: "1", named: "", kind: "symbol", required: "true", fills: "answers"
62
86
 
63
87
  # THE RECEIVING AGGREGATE — PortOperationBuilder's own `reference_to`
64
88
  # (inside the block) is refused (#335); this is the sanctioned
@@ -48,7 +48,7 @@ module Hecks
48
48
  # that sets them runs, same as they were unset locals before that point.
49
49
  Context = Struct.new(:domain, :aggregate, :command, :args, :repository, :instance, :transition, :old_state,
50
50
  :result, :correlation, :route, :plan, :strategy, :persistence_outcome, :delegated_events,
51
- :dry_run)
51
+ :dry_run, :correction_bindings)
52
52
 
53
53
  def initialize(registry, rules:)
54
54
  @registry = registry
@@ -135,10 +135,13 @@ module Hecks
135
135
  # NotFound/AlreadyExists already get at hydration: "does the
136
136
  # fact this command's corrects names even exist" is not a
137
137
  # domain rule an author wrote, it is a precondition for the
138
- # domain rules to mean anything at all.
139
- @rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
138
+ # domain rules to mean anything at all. Also locates the
139
+ # correction target itself, if `as:` named one — carried on
140
+ # `ctx` so `step_enforce_ensures` (the settled-record half)
141
+ # can bind the SAME name too, not just this pre-mutation half.
142
+ ctx.correction_bindings = @rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
140
143
  @rules.enforce_givens(ctx.instance, ctx.command, ctx.args, domain: ctx.domain,
141
- declaring: ctx.aggregate, parent: ctx.instance)
144
+ declaring: ctx.aggregate, parent: ctx.instance, correction: ctx.correction_bindings)
142
145
  }
143
146
  end
144
147
 
@@ -239,7 +242,7 @@ module Hecks
239
242
  def step_enforce_ensures(ctx)
240
243
  step(:enforce_ensures) {
241
244
  @rules.enforce_ensures(ctx.instance, ctx.command, ctx.args, old: ctx.old_state,
242
- domain: ctx.domain, parent: ctx.instance)
245
+ domain: ctx.domain, parent: ctx.instance, correction: ctx.correction_bindings || {})
243
246
  }
244
247
  end
245
248
 
@@ -260,7 +263,7 @@ module Hecks
260
263
  seed_projected_fields(ctx)
261
264
  ctx.persistence_outcome = if ctx.strategy == DependencyPlanning::ATOMIC_PUT
262
265
  # A SECOND CREATION IS NOT A FRESH ONE — see
263
- # hydrate_legacy_creation's own comment; the
266
+ # hydrate_complete_state's own comment; the
264
267
  # same refusal, on the same terms, for the
265
268
  # complete-state path. `insert_only:` asks the
266
269
  # ADAPTER to decide and refuse ATOMICALLY
@@ -376,19 +379,61 @@ module Hecks
376
379
  found.dup
377
380
  end
378
381
 
379
- # Transitional compatibility for live source that has not yet acquired
380
- # explicit effects. It is deliberately isolated from the normal routing
381
- # and planning path so `reference_to` no longer chooses how a migrated
382
- # command hydrates or persists. Wave 8 removes this after the inventory is
383
- # empty; frozen eras retain their own shadow parser.
382
+ # WAVE 8 (equivalence-gap plan, item 1.6) NOT done, and this
383
+ # comment now says precisely how far it got, corrected from an
384
+ # EARLIER version of itself that briefly (same PR, never released)
385
+ # claimed the inventory was empty and deleted this method outright.
386
+ # A full corpus audit (`DependencyPlanning::Analyzer.call` against
387
+ # every `creates?`-true command in all 5 EXAMPLE domains — banking,
388
+ # pizzas, chess, compliance, roster) found and fixed 4 real
389
+ # authoring bugs (`Statement.Generate`, `CreatePizza`, `Chess::
390
+ # Game.Start`, `Roster.Open` — each declared an attribute and
391
+ # never `sets` it, so the field silently stayed nil on every
392
+ # created record regardless of what a caller sent), plus a real
393
+ # `DependencyPlanning::Analyzer` bug for ENTITY-owned commands
394
+ # (`root_aggregate:`, that class's own header) that surfaced one
395
+ # more live corpus bug of its own (`payment_cards.bluebook`'s own
396
+ # `Withdrawal.Dispute`, which never actually checked whether the
397
+ # card was retired) — all real, all kept.
384
398
  #
385
- # `ctx.plan.complete_state?` already claimed every command whose plan is
386
- # fully resolved in the two branches above `step_hydrate` tries first, so
387
- # reaching this check already means the plan is incomplete an
388
- # un-migrated command still routes here on `creates?` alone, the same as
389
- # the old `hydrate` did, regardless of whether it happens to have any
390
- # mutations (`write_set`). Requiring an EMPTY write_set here refused
391
- # every un-migrated creating command that sets even one field.
399
+ # DELETING THIS METHOD ON THAT BASIS TURNED OUT TO BE WRONG,
400
+ # caught by running the full suite rather than trusting the
401
+ # audit's own scope: the 5 example domains are nowhere near the
402
+ # WHOLE inventory of `creates?`-true commands this fallback
403
+ # actually carries. Dozens of separate, purpose-built spec
404
+ # fixtures across the suite (`spec/fixtures/*.bluebook`, and
405
+ # inline `Hecks.bluebook` blocks declared directly inside
406
+ # individual spec files — governance, mutation ops, ports,
407
+ # routing, tenant isolation, sagas, and more) declare their OWN
408
+ # small `creates?`-true commands the same incomplete way, and
409
+ # rely on this exact fallback to create anything at all. Deleting
410
+ # it produced 218 failures across specs with nothing to do with
411
+ # Wave 8's own subject — confirmed, not guessed, by actually
412
+ # running `bundle exec rspec` after the deletion, not merely by
413
+ # extrapolating from the one audited inventory. `spec/fixtures/
414
+ # till.bluebook`'s own `Till.OpenTill` (missing `sets :number`,
415
+ # the identical bug shape as the 4 fixed above) was fixed
416
+ # alongside the four real corpus ones as one instance of this — but
417
+ # it was one of many, not the last one, and finding the rest is
418
+ # real, separate, much larger follow-up work this pass does not
419
+ # attempt: a suite-wide audit of every declared bluebook fixture,
420
+ # not just the 5 example domains the plan's own text named.
421
+ #
422
+ # Transitional compatibility for live source that has not yet
423
+ # acquired explicit effects, same as it always was — deliberately
424
+ # isolated from the normal routing and planning path so
425
+ # `reference_to` no longer chooses how a migrated command hydrates
426
+ # or persists. A real, future Wave 8 removes this once THAT wider
427
+ # inventory is empty, not before.
428
+ #
429
+ # `ctx.plan.complete_state?` already claimed every command whose
430
+ # plan is fully resolved in the two branches above `step_hydrate`
431
+ # tries first, so reaching this check already means the plan is
432
+ # incomplete — an un-migrated command still routes here on
433
+ # `creates?` alone, the same as the old `hydrate` did, regardless
434
+ # of whether it happens to have any mutations (`write_set`).
435
+ # Requiring an EMPTY write_set here refused every un-migrated
436
+ # creating command that sets even one field.
392
437
  def legacy_implicit_creation?(ctx)
393
438
  ctx.route.nil? && ctx.command.creates?
394
439
  end
@@ -421,9 +466,11 @@ module Hecks
421
466
  aggregate: aggregate.hecks_name,
422
467
  identity: identity_reading(aggregate)))
423
468
 
424
- # A SECOND CREATION IS NOT A FRESH ONE — see hydrate_legacy_creation's
425
- # own comment; the same refusal, on the same terms, for the
426
- # complete-state path. ONLY when `strategy` will NOT be ATOMIC_PUT:
469
+ # A SECOND CREATION IS NOT A FRESH ONE — `creates?` on an identity
470
+ # a record already exists under refuses (`AlreadyExists`) rather
471
+ # than silently overwriting it, the same refusal `hydrate_prior_
472
+ # or_initial`'s own body gives for its own complete-but-state-
473
+ # dependent case, below. ONLY when `strategy` will NOT be ATOMIC_PUT:
427
474
  # an atomic-put-capable adapter enforces this itself, atomically,
428
475
  # via `insert_only:` in step_save (no read here, no race with the
429
476
  # write) — but `strategy_for` already fell back to a plain `save`
@@ -461,8 +508,8 @@ module Hecks
461
508
  identity: identity_reading(aggregate)))
462
509
  found = repository.find(id)
463
510
 
464
- # A SECOND CREATION IS NOT A FRESH ONE — see hydrate_legacy_
465
- # creation's own comment; the same refusal, on the same terms, for
511
+ # A SECOND CREATION IS NOT A FRESH ONE — see hydrate_complete_
512
+ # state's own comment; the same refusal, on the same terms, for
466
513
  # a complete-but-state-dependent command (one with a `given`
467
514
  # reading its own prior state, which is what routes here instead
468
515
  # of hydrate_complete_state). Gated on `command.creates?`: a
@@ -116,7 +116,13 @@ module Hecks
116
116
  # parent aggregate reaching ITS OWN customer) resolves the same
117
117
  # way `account.customer.status` does for a command-level reference.
118
118
  # nil for an aggregate command — CommandInterpreter never passes it.
119
- def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil)
119
+ # `correction:` the `{as_name => payload}` bindings
120
+ # `enforce_correction_target` (above) already located, merged in
121
+ # LAST so an `as:` name wins the same way `old:` always wins in
122
+ # `enforce_ensures`, below — it is a fresh local binding a
123
+ # `corrects` command introduces, not a real argument/state field
124
+ # a caller could collide with by accident.
125
+ def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil, correction: {})
120
126
  state = GuardState.new(subject)
121
127
  # A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12,
122
128
  # ADR 0025) — `subject`'s own STORED references are no longer
@@ -135,6 +141,7 @@ module Hecks
135
141
  # forbids.
136
142
  attrs = args.merge(dereference(domain, command, args))
137
143
  attrs = attrs.merge(parent: parent.state) if parent
144
+ attrs = attrs.merge(correction) unless correction.empty?
138
145
  command.givens.each do |given|
139
146
  next if Bluebook::Expression::Evaluator.call(given.canonical, state, attrs)
140
147
 
@@ -154,20 +161,43 @@ module Hecks
154
161
  # event at all — is `AggregateBuilder#seal_correction_targets`;
155
162
  # this is the dispatch-time half — has THIS record actually done
156
163
  # so yet.
164
+ #
165
+ # ALSO LOCATES the matched event now, not just its existence, and
166
+ # returns a `{as_name => payload}` bindings hash — one entry per
167
+ # `:corrects` mutation that named an `as:` — so `given`/`ensures`
168
+ # on a corrects-bearing command can reference the located OLD
169
+ # event by that name, the same shape `enforce_ensures`'s own
170
+ # `old:` binding already has (CommandBuilder#corrects_impl's own
171
+ # comment: `as:` was stored, from the start, specifically to be
172
+ # wired into the evaluator once a real runtime consumer existed —
173
+ # this is that consumer). `.reverse.find` — the MOST RECENT
174
+ # matching event, if this record has somehow emitted the same
175
+ # correction target more than once; the prior existence-only
176
+ # check never had to make this choice, so it's a genuinely new
177
+ # one, made deliberately: `as:` reads as "the instance being
178
+ # corrected," which is naturally the latest fact on record, not
179
+ # an arbitrary one.
157
180
  def enforce_correction_target(instance, aggregate, command, domain:)
181
+ bindings = {}
158
182
  command.mutations.each do |mutation|
159
183
  next unless mutation.op == :corrects
160
184
 
161
185
  event_key = "#{domain}::#{aggregate.hecks_name}"
162
186
  event_name = mutation.target.to_s
163
- next if @registry.event_log.any? do |event|
187
+ corrected = @registry.event_log.reverse.find do |event|
164
188
  event.name == event_name && event.aggregate == event_key && event.id == instance.id
165
189
  end
166
190
 
167
- raise NothingToCorrect,
168
- "#{command.hecks_name} refused — corrects #{event_name}, but " \
169
- "#{event_key} ##{instance.id} has never emitted it"
191
+ unless corrected
192
+ raise NothingToCorrect,
193
+ "#{command.hecks_name} refused — corrects #{event_name}, but " \
194
+ "#{event_key} ##{instance.id} has never emitted it"
195
+ end
196
+
197
+ as = mutation.source[:as]
198
+ bindings[as.to_sym] = corrected.payload if as && !as.to_s.empty?
170
199
  end
200
+ bindings
171
201
  end
172
202
 
173
203
  # LIFECYCLE STATE AS A COMMAND GUARD (S10, ADR 0025) — `command
@@ -215,16 +245,21 @@ module Hecks
215
245
  # Not new to ensures — `given` lives under the same rule — but an
216
246
  # ensures is more likely to collide, since it typically re-reads a
217
247
  # field the command just took in to mutate it.
218
- def enforce_ensures(subject, command, args, old:, domain:, parent: nil)
248
+ def enforce_ensures(subject, command, args, old:, domain:, parent: nil, correction: {})
219
249
  state = GuardState.new(subject)
220
250
  # S12, ADR 0025 — same boundary reasoning as enforce_givens
221
251
  # above: `subject`'s own stored references are no longer
222
252
  # dereferenced here; a `projects`-maintained field is already
223
253
  # part of `state`. `command`/`args` still dereferences — a
224
254
  # fresh reference-typed ARGUMENT stays in bounds.
225
- # `old` still wins over everything, unchanged.
255
+ # `old` still wins over everything, unchanged. `correction`
256
+ # (an `as:`-bound corrected event, if this command declares
257
+ # one) wins right alongside it — a settled-record ensures can
258
+ # reference the correction target exactly as freely as a
259
+ # pre-mutation given already can.
226
260
  attrs = args.merge(dereference(domain, command, args))
227
261
  attrs = attrs.merge(parent: parent.state) if parent
262
+ attrs = attrs.merge(correction) unless correction.empty?
228
263
  attrs = attrs.merge(old: old)
229
264
  command.ensures.each do |rule|
230
265
  next if Bluebook::Expression::Evaluator.call(rule.canonical, state, attrs)
@@ -68,9 +68,39 @@ module Hecks
68
68
  # made. The real mistake, an absent argument, was never the one refused,
69
69
  # which is what fuzz surfaced.
70
70
  #
71
- # Absent now resolves to nil. Whether it should be REFUSED instead
72
- # is a separate question — the language cannot yet say which arguments are
73
- # optional, and the meta-domain has plenty that are.
71
+ # STALE (as of the equivalence-gap plan's own audit): this used to
72
+ # say "the language cannot yet say which arguments are optional" —
73
+ # it already can, and always could once `attribute ..., optional:
74
+ # true` existed (`CommandBuilder#attribute_impl`,
75
+ # `attribute_collector.rb`): `sets` already sources correctly from
76
+ # an optional attribute, resolving absent to nil exactly as this
77
+ # method does, and REFUSING it here would be wrong, not merely
78
+ # undone work — `TillRoom::Till.TakeIn`'s own `note` (spec/
79
+ # fixtures/till.bluebook) and Banking's `CardPayment.Authorize`'s
80
+ # `tags` (payment_cards.bluebook) are real, live commands whose
81
+ # `sets` mutation is deliberately sourced from an optional
82
+ # attribute the caller may omit — `spec/runtime/command_rules_spec
83
+ # .rb`'s own "says an absent OPTIONAL argument is nil, not the
84
+ # name of the argument" pins exactly this as correct, not pending.
85
+ # The meta-domain's own self-hosted commands (Command.Declare's
86
+ # `role`/`goal`/`provenance`/`from`/`position`, and ~35 more sites
87
+ # across the language) all lean on the identical pattern — nil is
88
+ # the RIGHT answer for a `sets` sourced from a declared-optional
89
+ # attribute the caller left out, every time.
90
+ #
91
+ # The one thing that WAS still a real, narrow gap — a `sets`
92
+ # source Symbol naming NOTHING the command declares at all (a
93
+ # typo, not an optional argument) — silently resolved to nil
94
+ # forever the same way, indistinguishable at either build or run
95
+ # time from a legitimate optional absence. Closed at BUILD time
96
+ # instead of here: `CommandBuilder#refuse_unknown_argument_sources!`
97
+ # refuses it the moment the `.bluebook` file loads, mirroring
98
+ # `AggregateBuilder#seal_query_argument`'s identical check for a
99
+ # query's own where-clause argument. This function stays exactly
100
+ # what it always was — a pure, unconditional lookup — because by
101
+ # the time ANY mutation reaches it, the source has already been
102
+ # proven to name either a real, possibly-optional argument, or a
103
+ # StateRef/literal; there is nothing left here to refuse.
74
104
  def resolve_source(source, args)
75
105
  return args[source] if source.is_a?(Symbol)
76
106
 
@@ -62,13 +62,29 @@ module Hecks
62
62
  class Analyzer
63
63
  STATEFUL_MUTATIONS = %i[append increment decrement multiply clamp remove].freeze
64
64
 
65
- def self.call(aggregate:, command:) = new(aggregate, command).call
66
-
67
- def initialize(aggregate, command)
65
+ # `root_aggregate:` Wave 8's own audit surfaced a real bug here,
66
+ # not merely a missing feature: for an ENTITY-owned command,
67
+ # `EntityInterpreter` calls this with `aggregate:` set to the
68
+ # ENTITY itself (`element_interpreter.rb`'s own `Analyzer.call
69
+ # (aggregate: entity, command:)`), so `owner_fields` was always
70
+ # the entity's own attribute set. A `given`/`ensures` reading
71
+ # `parent.X` legitimately means the ROOT aggregate's own field —
72
+ # a genuinely different owner — but `classify_path`'s `:parent`
73
+ # branch checked that read against `owner_fields` (the entity's),
74
+ # which can never contain a root-level field, so every entity
75
+ # command with a real, legitimate `parent.*` read was refused as
76
+ # unresolved regardless of correctness. Defaults to `aggregate`
77
+ # (a no-op) for the plain-aggregate case — `CommandInterpreter`'s
78
+ # own call site never needed to change.
79
+ def self.call(aggregate:, command:, root_aggregate: aggregate) = new(aggregate, command, root_aggregate).call
80
+
81
+ def initialize(aggregate, command, root_aggregate = aggregate)
68
82
  @aggregate = aggregate
69
83
  @command = command
70
84
  @owner_fields = aggregate.attributes.to_set(&:name)
71
85
  @owner_fields << aggregate.lifecycle.field.to_sym if aggregate.lifecycle
86
+ @root_owner_fields = root_aggregate.attributes.to_set(&:name)
87
+ @root_owner_fields << root_aggregate.lifecycle.field.to_sym if root_aggregate.lifecycle
72
88
  # `projects` FIELDS (S12, ADR 0025) ARE OWNER STATE TOO — a
73
89
  # `given`/`ensures` reading one (e.g. `customer_status ==
74
90
  # "active"`) is reading this record's own stored field, same
@@ -80,9 +96,17 @@ module Hecks
80
96
  # a partial mutation, which is exactly right — a projected
81
97
  # field's freshness comes from the interpreter reseeding it on
82
98
  # save, not from anything a caller-supplied write set carries.
99
+ # Applies to BOTH `owner_fields` and `root_owner_fields` — an
100
+ # entity's own `parent.*` read can name the root aggregate's
101
+ # projected field just as easily as one of its real attributes
102
+ # (`Banking::Withdrawal.Dispute`'s own `parent.account_customer_
103
+ # status`, ATMCard's projected field, is a real, live example).
83
104
  if aggregate.respond_to?(:projected_fields)
84
105
  aggregate.projected_fields.each { |field| @owner_fields << field.name }
85
106
  end
107
+ if root_aggregate.respond_to?(:projected_fields)
108
+ root_aggregate.projected_fields.each { |field| @root_owner_fields << field.name }
109
+ end
86
110
  @payload_fields = command.attributes.to_set(&:name)
87
111
  @state_reads = Set.new
88
112
  @payload_reads = Set.new
@@ -115,7 +139,7 @@ module Hecks
115
139
 
116
140
  private
117
141
 
118
- attr_reader :aggregate, :command, :owner_fields, :payload_fields,
142
+ attr_reader :aggregate, :command, :owner_fields, :root_owner_fields, :payload_fields,
119
143
  :state_reads, :payload_reads, :writes, :known_writes, :unresolved
120
144
 
121
145
  # A fresh Instance supplies these values without reading a stored
@@ -204,13 +228,36 @@ module Hecks
204
228
  end
205
229
  end
206
230
 
231
+ # KNOWN, HARMLESS GAP: `corrects ..., as: :name`'s bound name
232
+ # (admissibility.rb's `enforce_correction_target`/`enforce_givens`/
233
+ # `enforce_ensures`) isn't special-cased here the way `:old`/
234
+ # `:parent` are — a given/ensures referencing it falls through to
235
+ # `unresolved` below (its own field lookup finds no owner/payload
236
+ # match), same net effect as any other not-yet-optimized command:
237
+ # `complete_state?` comes back false, so dispatch takes the safe
238
+ # `hydrate_existing` path instead of the `ATOMIC_PUT` fast path.
239
+ # Not a correctness bug — `as:`'s runtime binding (a plain `attrs`
240
+ # merge, exactly like `old:`'s) resolves and evaluates correctly
241
+ # regardless of what this STATIC analysis concludes — just a real,
242
+ # deliberately-left optimization gap: closing it would mean
243
+ # threading "which names this command declares as correction
244
+ # bindings" into the Analyzer, which doesn't have that per-command
245
+ # context today. Worth doing alongside `:old`/`:parent`'s own
246
+ # handling someday, not attempted here.
207
247
  def classify_path(path, phase)
208
248
  head, nested = path.split(".", 2)
209
249
  name = head.to_sym
210
250
 
211
251
  if name == :parent
212
252
  parent_field = nested.to_s.split(".", 2).first
213
- if parent_field.empty? || !owner_fields.include?(parent_field.to_sym)
253
+ # `root_owner_fields` NOT `owner_fields`. For an entity-owned
254
+ # command `owner_fields` is the ENTITY's own attribute set;
255
+ # `parent.X` always means the ROOT aggregate's own field, a
256
+ # genuinely different owner (`root_aggregate:`'s own header,
257
+ # above, has the full bug this fixes). Identical for a plain
258
+ # aggregate command, where root_aggregate defaults to aggregate
259
+ # itself and the two sets are the same set.
260
+ if parent_field.empty? || !root_owner_fields.include?(parent_field.to_sym)
214
261
  unresolved << "#{path} does not name parent aggregate state"
215
262
  else
216
263
  state_reads << parent_field.to_sym
@@ -98,7 +98,14 @@ module Hecks
98
98
  ctx.chain = chain
99
99
  ctx.route = route
100
100
  ctx.dry_run = dry_run
101
- ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command)
101
+ # `root_aggregate:` `entity` is the immediate owner (what
102
+ # `owner_fields` inside the Analyzer means), but a `parent.X`
103
+ # read inside this command's own given/ensures means the ROOT
104
+ # aggregate's own field, not the entity's — `aggregate` here IS
105
+ # that root (this method's own first parameter, never the
106
+ # entity). See DependencyPlanning::Analyzer.call's own header
107
+ # for the bug this closes.
108
+ ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command, root_aggregate: aggregate)
102
109
  # RESOLVED HERE, ONCE — see CommandInterpreter#call's own comment;
103
110
  # `step_hydrate_parent` reads `ctx.repository` without re-fetching.
104
111
  ctx.repository = @registry.repository(domain, aggregate)
@@ -73,10 +73,19 @@ module Hecks
73
73
  [fetch(bluebook, domain, head[:aggregate], reference_id)]
74
74
  elsif rootless
75
75
  # No root to FK-match against — a rootless model reads
76
- # each of its own heads WHOLE, independently. (Multiple
77
- # heads on one rootless model aren't cross-joined
78
- # against each other either each is its own bulk
79
- # read. A real, deliberate scope limit for now.)
76
+ # each of its own heads WHOLE, independently. Multiple
77
+ # heads on one rootless model are NEVER cross-joined
78
+ # against each other, and there is no DSL to declare
79
+ # one if you wanted to `ReadModelBuilder#include_impl`
80
+ # takes only `type`/`as:` (checked directly, not
81
+ # assumed), and `group_by` groups this bulk read's own
82
+ # output, it names no predicate between two heads.
83
+ # Building a cross-join here would mean CHOOSING a join
84
+ # semantics (equality on which fields?) nobody has
85
+ # declared — a real, deliberate scope limit pending a
86
+ # future `include ..., joins: ...`-shaped grammar
87
+ # addition (with its own Rust mirror), not a gap this
88
+ # interpreter can quietly grow into on its own.
80
89
  records(bluebook, domain, head[:aggregate])
81
90
  else
82
91
  matching(records(bluebook, domain, head[:aggregate])) do |record|
data/lib/hecks/version.rb CHANGED
@@ -12,5 +12,5 @@ module Hecks
12
12
  # it there, or even in the consuming Gemfile, never closed the gap,
13
13
  # because gemspec evaluation happens before anything Bundler
14
14
  # resolves is actually loadable yet.
15
- VERSION = "1.0.0"
15
+ VERSION = "1.0.1"
16
16
  end
data/lib/hecks.rb CHANGED
@@ -100,7 +100,27 @@ module Hecks
100
100
  end
101
101
 
102
102
  def hecksagon(name, &block) = collect(:add_hecksagon, Bluebook::DSL::HecksagonBuilder.build(name, &block))
103
- def port(name, &block) = collect(:add_port, Bluebook::DSL::PortBuilder.build(name, &block))
103
+ # REPOINTED TO DomainPortBuilder the migration DomainPort's own
104
+ # class comment names as its goal, now landed for the top-level
105
+ # `.port` file callers too (the aggregate-scoped `Thing.port(...)`,
106
+ # binding_proxy.rb, already went through this builder). Every real
107
+ # `.port` file only ever spells `verb`/`signal` (no `.port` file
108
+ # declares operations — that's DomainPort's own newer shape), and
109
+ # DomainPortBuilder's own bare-verb branch produces the exact same
110
+ # `Port` object PortBuilder itself did (dsl_spec.rb's own byte-
111
+ # identity check) — a pure repoint, no behavior change for any
112
+ # existing caller reading `.verb`/`.signal` off what comes back.
113
+ #
114
+ # `legacy_bare_port: true` — the ONE real semantic gap this repoint
115
+ # would otherwise open: `PortBuilder#build` never refused a
116
+ # completely empty build (no verb, no signal even), a real shape
117
+ # dsl_spec.rb's own "a port" tests exercise (`signal`-only, no
118
+ # `verb`). `DomainPortBuilder`'s own "declares no verb and no
119
+ # operations" refusal is real and correct for its OTHER two callers
120
+ # (`BindingProxy#port`, `HecksagonBuilder#port_impl`) — only this,
121
+ # the literal top-level `.port` file entry point, keeps the older,
122
+ # looser rule (see `DomainPortBuilder#initialize`'s own comment).
123
+ def port(name, &block) = collect(:add_port, Bluebook::DSL::DomainPortBuilder.build(name, legacy_bare_port: true, &block))
104
124
  def adapter(name, &block) = collect(:add_adapter, Bluebook::DSL::AdapterBuilder.build(name, &block))
105
125
  def world(name, &block) = collect(:add_world, Bluebook::DSL::WorldBuilder.build(name, &block))
106
126
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
@@ -140,6 +140,7 @@ files:
140
140
  - lib/hecks/bluebook/dsl/world_builder.rb
141
141
  - lib/hecks/bluebook/entity.rb
142
142
  - lib/hecks/bluebook/expression.rb
143
+ - lib/hecks/bluebook/expression/ast_json.rb
143
144
  - lib/hecks/bluebook/expression/canonical_form.rb
144
145
  - lib/hecks/bluebook/expression/evaluator.rb
145
146
  - lib/hecks/bluebook/expression/projection.json