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
- #admissible_transition(declaring, command, subject) ⇒ Object
-
#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#invariantsalready checks each of ITS OWN instances one construct up. -
#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
oldcarrying the state as the givens saw it. -
#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. -
#enforce_invariants(subject, aggregate, domain:) ⇒ Object
THE AGGREGATE BOUNDARY, checked after every command, before save (S10, ADR 0025 — "Rules") — the same point
enforce_ ensuresalready 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 noargs/oldat all, only the record's own (dereferenced) state. -
#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 stepgivenalready runs at (both are preconditions, evaluated before any mutation) rather than earning its own DISPATCH_ORDER entry.
Instance Method Details
#admissible_transition(declaring, command, subject) ⇒ Object
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).
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 |