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:



349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 349

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.



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 323

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)
      # NO `dereference` (S12, ADR 0025) — same boundary rule as
      # enforce_invariants above; `parent` (the owner's own
      # state, projected fields included) stays readable.
      attrs = { parent: owner_instance.state }

      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_correction_target(instance, aggregate, command, domain:) ⇒ Object

corrects — CommandBuilder#corrects_impl's own comment. NOT expressible as an ordinary given: "has this exact record already emitted this exact event" is not a predicate over the record's OWN fields, it is a fact about the event log, so it is raised structurally here, the same way NotFound/AlreadyExists are, rather than through the expression evaluator. The build- time half — does ANYTHING in this aggregate ever emit the named event at all — is AggregateBuilder#seal_correction_targets; this is the dispatch-time half — has THIS record actually done so yet.

ALSO LOCATES the matched event now, not just its existence, and returns a {as_name => payload} bindings hash — one entry per :corrects mutation that named an as: — so given/ensures on a corrects-bearing command can reference the located OLD event by that name, the same shape enforce_ensures's own old: binding already has (CommandBuilder#corrects_impl's own comment: as: was stored, from the start, specifically to be wired into the evaluator once a real runtime consumer existed — this is that consumer). .reverse.find — the MOST RECENT matching event, if this record has somehow emitted the same correction target more than once; the prior existence-only check never had to make this choice, so it's a genuinely new one, made deliberately: as: reads as "the instance being corrected," which is naturally the latest fact on record, not an arbitrary one.



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 180

def enforce_correction_target(instance, aggregate, command, domain:)
  bindings = {}
  command.mutations.each do |mutation|
    next unless mutation.op == :corrects

    event_key  = "#{domain}::#{aggregate.hecks_name}"
    event_name = mutation.target.to_s
    corrected = @registry.event_log.reverse.find do |event|
      event.name == event_name && event.aggregate == event_key && event.id == instance.id
    end

    unless corrected
      raise NothingToCorrect,
            "#{command.hecks_name} refused — corrects #{event_name}, but " \
            "#{event_key} ##{instance.id} has never emitted it"
    end

    as = mutation.source[:as]
    bindings[as.to_sym] = corrected.payload if as && !as.to_s.empty?
  end
  bindings
end

#enforce_ensures(subject, command, args, old:, domain:, parent: nil, correction: {}) ⇒ 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.



248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 248

def enforce_ensures(subject, command, args, old:, domain:, parent: nil, correction: {})
  state = GuardState.new(subject)
  # S12, ADR 0025 — same boundary reasoning as enforce_givens
  # above: `subject`'s own stored references are no longer
  # dereferenced here; a `projects`-maintained field is already
  # part of `state`. `command`/`args` still dereferences — a
  # fresh reference-typed ARGUMENT stays in bounds.
  # `old` still wins over everything, unchanged. `correction`
  # (an `as:`-bound corrected event, if this command declares
  # one) wins right alongside it — a settled-record ensures can
  # reference the correction target exactly as freely as a
  # pre-mutation given already can.
  attrs = args.merge(dereference(domain, command, args))
  attrs = attrs.merge(parent: parent.state) if parent
  attrs = attrs.merge(correction) unless correction.empty?
  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, correction: {}) ⇒ 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. correction: — the {as_name => payload} bindings enforce_correction_target (above) already located, merged in LAST so an as: name wins the same way old: always wins in enforce_ensures, below — it is a fresh local binding a corrects command introduces, not a real argument/state field a caller could collide with by accident.



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 125

def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil, correction: {})
  state = GuardState.new(subject)
  # A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12,
  # ADR 0025) — `subject`'s own STORED references are no longer
  # dereferenced here at all. What used to be a live query against
  # another aggregate's own repository is now just `subject`'s own
  # state: a `projects :customer_status, from: :"customer.status"`
  # field is a REGULAR stored attribute, already present in
  # `subject`/`state` with no hydration step needed. `dereference`
  # is still called on `command`/`args`, below — that is a
  # DIFFERENT case the ADR explicitly keeps in bounds ("its command
  # arguments"): a reference-typed ARGUMENT this dispatch was just
  # handed (`Dispute`'s own `disputed_by`, say) has nothing stored
  # to project yet, so resolving it here, once, synchronously with
  # THIS command's own admission, is not the live-query-against-
  # another-aggregate's-stored-state pattern the boundary rule
  # forbids.
  attrs = args.merge(dereference(domain, command, args))
  attrs = attrs.merge(parent: parent.state) if parent
  attrs = attrs.merge(correction) unless correction.empty?
  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 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.

NO dereference (S12, ADR 0025) — an invariant may only read subject's own boundary, same rule enforce_givens/ enforce_ensures now hold to. No invariant in the corpus has ever read across a reference_to (verified before this change), so this is not a migration, just closing the same capability off here that was already unused.



290
291
292
293
294
295
296
297
298
299
300
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 290

def enforce_invariants(subject, aggregate, domain:)
  state = GuardState.new(subject)
  attrs = {}
  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:



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/hecks/runtime/command_rules/admissibility.rb', line 212

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