Module: Hecks::Runtime::CommandRules::Admissibility

Included in:
Hecks::Runtime::CommandRules
Defined in:
lib/hecks/runtime/command_rules/admissibility.rb

Overview

Whether a command may run at all: its declared givens, and the lifecycle transition it asks for.

Instance Method Summary collapse

Instance Method Details

#admissible_transition(declaring, command, subject) ⇒ Object

Raises:



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 264

def admissible_transition(declaring, command, subject)
  lifecycle = declaring.lifecycle
  return nil unless lifecycle

  candidates = lifecycle.transitions_for(command.hecks_name)
  return nil if candidates.empty?

  # `Value.scalar` unwrap -- vendored addition, not (yet)
  # upstream hecks (migration plan task 9): a VO-typed
  # lifecycle field (the norm, not the exception, per this
  # corpus's own no-primitive-envy convention) holds a real
  # `Runtime::Value` here, and a bare `.to_s` on that hit Ruby's
  # default `Object#to_s` instead of unwrapping the inner
  # scalar first -- `current` came back as a raw object-pointer
  # string (`"#<Hecks::Runtime::Value:0x...>"`) that could
  # never match any declared `from` state, so EVERY transition
  # on a VO-typed lifecycle field refused unconditionally, and
  # when it refused the message leaked the pointer too.
  # Confirmed live via `Plan::Task.Complete` (status defaults to
  # `TaskStatus`, a single-field VO), not inferred. Reuses
  # `Value.scalar` -- this file's own third candidate for "how
  # to unwrap a Value/Hash-shaped field," already built and
  # already documented for exactly this job ("rendering a value
  # object into a column or a message, where there is no path
  # to consult," `value/coercion.rb`'s own comment) -- rather
  # than inventing a second unwrap helper beside `Resolver#
  # unwrap_scalar`'s bare-comparison one. Duck-typed the same
  # way : a bare, non-VO lifecycle field passes through
  # unchanged (`Value.scalar` only opens a `Value` instance).
  current  = Value.scalar(subject[lifecycle.field]).to_s
  admitted = candidates.find { |t| !t.constrained? || Array(t.from).include?(current) }
  return admitted if admitted

  allowed = candidates.flat_map { |t| Array(t.from) }.uniq
  raise LifecycleRefused,
        RefusalWording.render("LifecycleRefused", "transition_blocked",
                              command: command.hecks_name, field: lifecycle.field,
                              current: Rendering.describe(current),
                              allowed: allowed.map(&:inspect).join(" or "))
end

#check_entity_invariants(owner_construct, owner_instance, domain:) ⇒ Object

A PIECE'S OWN SHAPE RULE, checked against EVERY INSTANCE the aggregate holds — not a separate boundary from the aggregate's own invariants just above (same two checkpoints: after every mutation, before save), just a WIDER one: the aggregate's own consistency includes each of its pieces individually looking right, the same way ValueObject#invariants already checks each of ITS OWN instances one construct up. RECURSES into nested pieces (S17, ADR 0026 — Dispatch inside Handler) the same way check_entity_invariants's own caller recurses nowhere else needs to, since a piece's entities are already exactly as reachable as an aggregate's.

list_attr reuses the EXACT lookup EntityInterpreter# element_of already makes to locate a SINGLE addressed element by identity — this reads every element instead, but the "which field on the owner holds this piece's own instances" question is the identical one. A piece declaring invariants that nothing on its owner actually holds (no matching list attribute) is a static-analysis gap for a future gate, not a runtime concern here — next past it rather than raising mid-enforcement for an unrelated command.



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 240

def check_entity_invariants(owner_construct, owner_instance, domain:)
  owner_construct.entities.each do |entity|
    next if entity.invariants.empty?

    list_attr = owner_construct.attributes.find { |a| a.list? && a.type.to_s == entity.hecks_name }
    next unless list_attr

    Array(owner_instance[list_attr.name]).each do |element|
      wrapped = Instance.new(aggregate: entity, id: nil, state: element)
      element_state = GuardState.new(wrapped)
      attrs = dereference(domain, entity, wrapped)
              .merge(parent: owner_instance.state.merge(dereference(domain, owner_construct, owner_instance)))

      entity.invariants.each do |invariant|
        next if Bluebook::Expression::Evaluator.call(invariant.canonical, element_state, attrs)

        raise InvariantViolation, "#{entity.hecks_name} refused — #{invariant.description}"
      end

      check_entity_invariants(entity, wrapped, domain: domain)
    end
  end
end

#enforce_ensures(subject, command, args, old:, domain:, parent: nil) ⇒ Object

The far side of the contract: evaluated against the SETTLED record — after mutations and the lifecycle move, before anything persists — with old carrying the state as the givens saw it. Injected into the attrs at evaluation time only; the payload gate never sees it.

old — and every dispatch ARGUMENT — wins over a same-named STATE field in expression scope (Resolver#fetch checks attrs first). An ensures naming a field the command also takes as an argument (or, on an entity, a field that doubles as the addressing argument element_of reads) will read the ARGUMENT, not the settled value. Not new to ensures — given lives under the same rule — but an ensures is more likely to collide, since it typically re-reads a field the command just took in to mutate it.



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 178

def enforce_ensures(subject, command, args, old:, domain:, parent: nil)
  state = GuardState.new(subject)
  owner = subject.aggregate if subject.respond_to?(:aggregate)
  # Same merge-order reasoning as enforce_givens above: an
  # aliased command-level reference must override its own raw
  # id argument, not the other way round. `old` still wins over
  # everything, unchanged.
  attrs = dereference(domain, owner, subject).merge(args).merge(dereference(domain, command, args))
  attrs = attrs.merge(parent: parent.state.merge(dereference(domain, parent.aggregate, parent.state))) if parent
  attrs = attrs.merge(old: old)
  command.ensures.each do |rule|
    next if Bluebook::Expression::Evaluator.call(rule.canonical, state, attrs)

    raise EnsuresNotMet, "#{command.hecks_name} refused — #{rule.description}"
  end
end

#enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil) ⇒ Object

domain: is only needed to dereference a reference-typed field (customer.status) — see References#dereference. owner is the declaring aggregate/entity when subject carries one (an entity's pre-mutation view does, same as an aggregate's Instance); a subject with no .aggregate just hydrates nothing from state, same as GuardState degrades above.

MERGE ORDER MATTERS, and it is NOT "args always win": an unaliased command-level reference dereferences under a different name than the argument holds (account_id the arg, account the hydrated key — no collision, order is moot). An ALIASED one (reference_to Customer, as: :customer) hydrates under the SAME name the argument itself holds — customer is both the raw id an arg puts there and the key customer.status expects to dig into. If args merged last, the raw id (a String) would win and .status on a String is where a fuzzer found this — TypeError, not a refusal. Command-level dereferencing is the one thing that is SUPPOSED to override its own source argument for exactly this reason; args still wins over stored OWNER state (unchanged from before this fix). parent: is an entity command's OWN parent aggregate record (EntityInterpreter's ctx.instance — "the PARENT aggregate record", its own doc comment) — the entity's containment, not a declared reference attribute, so it doesn't come from dereference's attribute scan the way owner's do. Hydrated the same shape regardless: the parent's own state, MERGED so the dereferenced hash wins over the raw reference it replaces (ADR 0025 dropped the _id suffix that used to keep the two apart by name, so parent.state.merge(dereference(...)) is now load-bearing, not redundant) — plus ITS OWN references dereferenced one level in, so parent.customer.status (a parent aggregate reaching ITS OWN customer) resolves the same way account.customer.status does for a command-level reference. nil for an aggregate command — CommandInterpreter never passes it.



119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 119

def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil)
  state = GuardState.new(subject)
  owner = subject.aggregate if subject.respond_to?(:aggregate)
  attrs = dereference(domain, owner, subject).merge(args).merge(dereference(domain, command, args))
  attrs = attrs.merge(parent: parent.state.merge(dereference(domain, parent.aggregate, parent.state))) if parent
  command.givens.each do |given|
    next if Bluebook::Expression::Evaluator.call(given.canonical, state, attrs)

    raise GivenNotMet, "#{command.hecks_name} refused — #{given.description}"
  end

  enforce_lifecycle_guard(declaring, command, subject) if declaring
end

#enforce_invariants(subject, aggregate, domain:) ⇒ Object

THE AGGREGATE BOUNDARY, checked after every command, before save (S10, ADR 0025 — "Rules") — the same point enforce_ ensures already checks at, and for the same reason: an invariant is a claim about the SETTLED record, not the command that produced it, so it reads no args/old at all, only the record's own (dereferenced) state. subject here is always the AGGREGATE's own instance — CommandInterpreter passes its own ctx.instance, and EntityInterpreter passes the PARENT record (ctx.instance, not the element), since an entity mutation changes data inside the SAME aggregate boundary the invariant guards; there is no separate "entity invariant" to check the piece's own view against.



207
208
209
210
211
212
213
214
215
216
217
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 207

def enforce_invariants(subject, aggregate, domain:)
  state = GuardState.new(subject)
  attrs = dereference(domain, aggregate, subject)
  aggregate.invariants.each do |invariant|
    next if Bluebook::Expression::Evaluator.call(invariant.canonical, state, attrs)

    raise InvariantViolation, "#{aggregate.hecks_name} refused — #{invariant.description}"
  end

  check_entity_invariants(aggregate, subject, domain: domain)
end

#enforce_lifecycle_guard(declaring, command, subject) ⇒ Object

LIFECYCLE STATE AS A COMMAND GUARD (S10, ADR 0025) — command "Debit", from: "open" checked here, folded into the SAME dispatch step given already runs at (both are preconditions, evaluated before any mutation) rather than earning its own DISPATCH_ORDER entry. A GUARD, never a transition: it names no target state and step_advance_lifecycle never sees it — see admissible_transition, right below, for the transition this is deliberately NOT reusing (its own StateTransition#target is required, and a guard-only command has none to give it).

Raises:



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 142

def enforce_lifecycle_guard(declaring, command, subject)
  return unless command.from

  lifecycle = declaring.lifecycle
  current   = Value.scalar(subject[lifecycle.field]).to_s
  return if Array(command.from).include?(current)

  # ROUTED THROUGH RefusalWording's OWN "transition_blocked"
  # TEMPLATE — the same one #admissible_transition, right below,
  # already raises LifecycleRefused through for the same
  # refusal class. This used to hand-roll its own wording
  # inline ("...only runs from..." vs. the template's "...moves
  # it only from...") — two shapes for one refusal kind, so
  # anything string-matching a LifecycleRefused message (a
  # property, a spec, a caller) had to know both existed rather
  # than one.
  raise LifecycleRefused,
        RefusalWording.render("LifecycleRefused", "transition_blocked",
                              command: command.hecks_name, field: lifecycle.field,
                              current: Rendering.describe(current),
                              allowed: Array(command.from).map(&:inspect).join(" or "))
end