hecks

Lifecycle

Words available in the Lifecycle body.

The tables on this page are generated from the language’s own aggregate-local syntax tables (lib/hecks/language/**/*.bluebook) by hecks language_run.project_reference — do not edit inside the markers. The prose between them is hand-written and survives regeneration.

Every example on this page runs against examples/banking, whose Account carries a three-move lifecycle:

```ruby boot Hecks::Adapters::Folder.new.load_bluebooks(File.join(InMemoryDomain::ROOT, “examples/banking/bluebook”))

Hecks.hecksagon(“Banking”) do attaches “Governance” Banking::Customer.persisted_by(“Memory”) Banking::Account.persisted_by(“Memory”) end Hecks.hecksagon(“Governance”) do Governance::RoleAssignment.persisted_by(“Memory”) Governance::RoleTransition.persisted_by(“Memory”) end


```ruby
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "lc-1" },
                                                       name: { given: "Ada", family: "Byron" },
                                                       email: { address: "ada@example.com" } })
account = Banking::Account.open!(customer: "lc-1", number: { value: "lc-a1" },
                                kind: { name: "current" }, daily_limit: { cents: 50_000 })

transition

transition pairs, from:, from: — fills transitions

| argument | kind | required | fills | |—|—|—|—| | positional 1 | pairs | true | | | from: | text | false | from_state | | from: | list | false | from_state |

One legal move: "Command" => "state", from: "state" — the command may fire only when the field is at from: (or, given an array, at one of several), and lands at the target state after. See lifecycles.md for enforcement, the refusal it produces, and what hecks model_check flags when a transition can never fire.

Account’s own lifecycle declares transition "FreezeAccount" => "frozen", from: "open". Nothing assigns :status — firing the command IS the assignment:

account.status  # => "open"
account.freeze_account!
account.status  # => "frozen"

The transition’s own from: is enforced, not decorative — a second freeze_account is refused rather than silently repeated:

account.freeze_account!  # ~> LifecycleRefused: FreezeAccount refused — status is "frozen", and FreezeAccount moves it only from "open"

The refusal names FreezeAccount itself, not the transition, because this corpus declares the command as command "FreezeAccount", from: "open" (a Command-context word — see command.md — added by S10, ADR 0025). That guard is checked in the SAME dispatch step every given already runs at, which is BEFORE admissible_transition — the transition’s own check — ever gets a turn, so it is what a caller actually sees. It used to take two independent declarations to say “open” here: a free-text given("account is open") { status == "open" } alongside the transition’s own from: "open", each able to drift out of sync with the other. Now there is one: the command’s from: names the SAME lifecycle field the transition does, so there is nothing left to disagree. CardPayment, elsewhere in this corpus, still carries the older, given-shaped guard on every one of its own commands — see lifecycles.md, which walks that overlap in full.

Given a list, the command may fire from any of several states — transition "CloseAccount" => "closed", from: ["open", "frozen"] closes an account whether or not it was frozen first:

runtime.dispatch_flat("Banking::Account.CloseAccount", number: { value: "lc-a1" })
Banking::Account.find("lc-a1").status  # => "closed"

mark

mark name, state — fills marks

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | name | | positional 2 | text | true | state |

Names a meaning and the states that carry it: mark :holds_seat, "pending", "succeeded". A reader outside the domain (a host counting seats, a launcher deciding what counts as a failure) asks the lifecycle for the states instead of keeping its own copy of the list. The meaning is a lowercase word; every state must be the default or a transition target, and a state may be named once per mark. A lifecycle with no marks writes no marks into its IR.

Any lifecycle can carry marks, on an aggregate or on a nested entity.

payment = Hecks::Bluebook::DSL::LifecycleBuilder.build(:status, default: "pending") do
  mark :holds_seat, "pending", "succeeded"
  transition "Succeed" => "succeeded", from: "pending"
end
payment.marked(:holds_seat)  # => ["pending", "succeeded"]
payment.to_h[:marks]  # => {"holds_seat"=>["pending", "succeeded"]}

A state the lifecycle does not have is refused when the lifecycle is built:

Hecks::Bluebook::DSL::LifecycleBuilder.build(:status, default: "pending") { mark :holds_seat, "pending", "paid" }  # ~> Malformed: lifecycle :status mark :holds_seat names "paid", which is not a state of the lifecycle (states: pending)

A host reads a mark through a capability. The payments capability may add an optional holds_seat: "Payment.holds_seat" entry (spelled Aggregate.mark_name) to the chapter’s provides "payments" row. It must name a mark declared on that aggregate’s lifecycle, or the chapter is refused when it is built. The exported ir.json then carries the states in its payments fact as holds_seat: ["pending", "succeeded"], and the Rust host counts seats from that list. A chapter that leaves the entry out exports exactly what it did before, and the host falls back to its built-in default list with one warning.

The registrations capability takes one: frees_seat: "Registration.frees_seat", over a registration lifecycle’s mark :frees_seat, "archived". A registration in one of those states gives its seat back, so the host leaves it out of the seats an event has used. The fact carries frees_seat: ["archived"]; a chapter that omits the entry gets archived and one warning.

The newsletter capability takes three such entries, all optional, over the subscriber’s lifecycle: awaiting_confirmation: (states waiting for the emailed confirm link), receives_issues: (states that are sent each issue) and left: (states of someone who has unsubscribed):

provides "newsletter",
         subscribe: "Subscriber.Subscribe", add_name: "Subscriber.AddName",
         confirm: "Subscriber.Confirm", unsubscribe: "Subscriber.Unsubscribe",
         awaiting_confirmation: "Subscriber.awaiting_confirmation",
         receives_issues:       "Subscriber.receives_issues",
         left:                  "Subscriber.left"

Each is exported in the newsletter fact as a state list. The host falls back per mark to pending, confirmed and unsubscribed with one warning when a chapter leaves it out.