hecks

Running a runtime

A port is a second implementation of dispatch — a runtime, likely in a different language, that accepts the same commands and queries a bluebook declares and produces the same refusals and events a real boot of it would. One exists: rust/ (Cargo crate + Ruby generator, hecks build.project_rust the driver — see docs/implemented/decisions/0011-rust-compiles-types-interprets-dispatch.md for the architecture decision, and docs/HECKS_IMPLEMENTATION_PLAN.md §8 for its current, honestly-scoped status). This page is what running it, extending it to a new domain construct, or starting an analogous port in a different language actually requires: what the canonical IR contains, field by field, the exact order a command’s dispatch runs in, and how the given/ensures/invariant text you’ll find in that IR is supposed to be read. Everything here is either proven live against a real domain below, or cited against the Ruby source that is the actual authority, named so you can go verify it yourself rather than trust a paraphrase.

One architectural choice this page used to leave open, and doesn’t anymore: whether a port INTERPRETS the IR at its own runtime start-up (read the JSON, hold the AST, dispatch generically against it) or COMPILES it once, ahead of time, into native dispatch code for each command (a build step emits real functions; nothing at runtime reads IR at all). rust/ does neither purely — it compiles TYPE SHAPES ahead of time and interprets DISPATCH BEHAVIOR (given/ensures/ mutations, as data) generically at runtime, through one small, hand-written kernel. “Interpret data, don’t compile source,” below, is the argument for why; it’s not a hypothetical anymore, it’s what got built, after a pure-compile first attempt didn’t converge. The facts in the rest of this page hold regardless of which split a NEW port chooses, because they describe what the IR contains and what order dispatch runs in, not how any one port consumes that — but “Interpret data, don’t compile source” is no longer just an argument to weigh, it’s also a report from having tried the alternative first.

Getting the IR

The IR is Hecks::Bluebook#to_h, per aggregate, per bluebook — the same shape spec/golden/ir/*.json pins and hecks ir prints. Hecks::Projector::Exporter.call(registry) returns it as a real Ruby Hash, keyed by bluebook name; .json(registry) wraps it in JSON.pretty_generate for a file or a pipe. Boot the domain the same way any other guide does — Kernel.load the real bluebook file, wire every aggregate to "Memory" — and read it off runtime.registry:

```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”) Banking::ATMCard.persisted_by(“Memory”) Banking::Transfer.persisted_by(“Memory”) Banking::CardPayment.persisted_by(“Memory”) Banking::ExternalTransfer.persisted_by(“Memory”) Banking::ScheduledPayment.persisted_by(“Memory”) Banking::SafeDepositBox.persisted_by(“Memory”) Banking::OnboardingCase.persisted_by(“Memory”) end Hecks.hecksagon(“Governance”) do Governance::RoleAssignment.persisted_by(“Memory”) Governance::RoleTransition.persisted_by(“Memory”) end


```ruby
ir = Hecks::Projector::Exporter.call(runtime.registry).fetch("Banking")

ir.keys # => [:ir_version, :name, :version, :vision, :classification, :formerly_known_as, :namespace, :aggregates, :read_models, :policies, :process_managers, :attaches_to, :provides, :canonical_form]

Every key below is a real Ruby Symbol, not a JSON string — Exporter.call returns the object graph’s own to_h, unconverted. Round-trip it through Exporter.json and JSON.parse instead and every key becomes a string; pick whichever your build step wants to consume, but don’t mix assumptions about which one you’re holding.

ir_version and version name two different things, easy to conflate: ir_version is this export shape’s own version (bump it when to_h’s own keys change in a way a consumer needs to know about, not when a domain adds a command) — it’s the same for every bluebook, current value 1. version is Banking’s own declared business version ("v1", the DSL’s Hecks.bluebook "Banking", version: "v1") and is nil for a domain that never set one, Pizzas among them:

ir[:ir_version] # => 1
ir[:version]    # => "v1"

If your build step shells out instead of running in-process, `hecks

` prints the same thing — with one trap: booting a domain's *real* `.hecksagon` (as opposed to the Memory wiring above) may wire a Postgres-backed adapter, and Postgres's own `NOTICE` lines land on the same stdout your script is trying to parse as pure JSON. Wire your own hecksagon against `"Memory"`, the way every doctested example on this page does, rather than booting the domain's committed one, and this never comes up. ## The shape: bluebook → aggregate → attribute An aggregate carries thirteen keys. `lifecycle` is `nil` when the aggregate declares none; `entities`, `queries`, `commands`, `value_objects`, `invariants`, `preconditions` are always arrays, empty when there's nothing to say: ```ruby account = ir.fetch(:aggregates).find { |a| a[:name] == "Account" } account.keys # => [:name, :description, :identified_by, :attributes, :value_objects, :commands, :invariants, :preconditions, :projected_fields, :lifecycle, :entities, :queries, :ports, :provenance] ``` `invariants` and `preconditions` are the aggregate-level rules (S10, ADR 0025 — see [commands.md](/hecks/docs/implemented/guides/commands.html#given--the-rule-you-enforce-going-in)): `invariants` is checked after every command, before save, the same way a value object's own `invariants` are; `preconditions` holds the aggregate's own NAMED `given`s — the ones a command references back by description rather than re-declaring: ```ruby account[:invariants].map { |i| i[:description] } # => ["the balance never goes negative"] account[:preconditions].map { |p| p[:description] } # => ["customer is active", "customer is not closed"] ``` `identified_by` is always an array of dotted paths, even for a single scalar field — `["reference.value"]` for `Customer`, one entry per identity component. A composite identity is more than one entry, joined by `:` at dispatch time; `SafeDepositBox` is the real one in this corpus: ```ruby account[:identified_by] # => ["number.value"] safe_deposit_box = ir.fetch(:aggregates).find { |a| a[:name] == "SafeDepositBox" } safe_deposit_box[:identified_by] # => ["branch_code.value", "box_number.value"] ``` Every `attributes` entry — on an aggregate, a value object, a command, or an entity, the shape never varies — carries the same seven keys: ```ruby balance_attr = account[:attributes].find { |a| a[:name] == :balance } balance_attr # => { name: :balance, type: "Money", list: false, default: nil, optional: false, pattern: nil, admits: nil, relationship: nil } ``` An attribute's `name` is a `Symbol` (the DSL takes `attribute :balance, Money`); an aggregate's, a command's, and a value object's own `name` are all plain `String`s (the DSL takes those as quoted chapter/verb names) — the export does not normalize the two onto one type, so match each key with the kind it actually is, not the kind that reads more consistently. `type` is one of three things: a scalar (`"String"`, `"Integer"`, `"Float"`), the name of a value object declared on the same aggregate (resolve it against that aggregate's own `value_objects`, not a global namespace — two aggregates may each declare a `Money`), or `"Reference"` for a pointer at another aggregate's identity: ```ruby account[:attributes].find { |a| a[:name] == :customer }[:type] # => "Reference" ``` `list` marks a repeated attribute (a command's `list_of` field) — a `Vec`/array of `type`, never `Option`-wrapped the way a scalar attribute might be in a language that distinguishes the two. `pattern` is a regex string when a value object's field was declared with `pattern:` (an email address, for instance); `admits` is set only when an attribute's closed set is borrowed from a DIFFERENT aggregate's value object rather than its own; `relationship` names which word minted a `Reference`-typed attribute — `"reference_to"`, `"has_many"`, `"has_one"`, or `"belongs_to"` — nil for every scalar/value-object attribute, since none of those words apply: ```ruby account[:attributes].find { |a| a[:name] == :customer }[:relationship] # => "reference_to" ``` ```ruby external_transfer = ir.fetch(:aggregates).find { |a| a[:name] == "ExternalTransfer" } external_transfer[:attributes].find { |a| a[:name] == :direction }[:admits] # => "Account::LedgerDirection" ``` `default` is `nil` when nothing was declared, the literal value when it's a scalar, and a nested `Hash` when the default is itself a value object — `SafeDepositBox`'s `size` defaults to `Size`, not to a bare string: ```ruby safe_deposit_box[:attributes].find { |a| a[:name] == :size }[:default] # => { value: "small" } ``` ## Value objects: fields, invariants, closed sets A `value_objects` entry is `name`, `attributes` (the same six-key shape as above, recursively — a value object can nest another one), `invariants`, `closed_set`, `members`. A ordinary value object has `closed_set: false` and an empty `members`; its `invariants` are the rules your port has to check at construction time, each one a rule row — `description`, the `canonical` text the expression grammar section below reads, and `ast`, the same expression as an `"op"`-tagged tree (see "Rule rows" under the command section): ```ruby account[:value_objects].find { |vo| vo[:name] == "DailyLimit" } # => { name: "DailyLimit", attributes: [{ name: :cents, type: "Integer", list: false, default: 0, optional: false, pattern: nil, admits: nil }], invariants: [{ description: "a daily limit is non-negative", canonical: "!cents.negative?", ast: { "op" => "not", "expr" => { "op" => "sign_test", "cmp" => { "less_than" => true, "equal" => false, "negated" => false }, "receiver" => { "op" => "lookup", "path" => ["cents"] } } } }], closed_set: false, members: [] } ``` A closed set inverts that: `invariants` is empty (membership IS the rule) and `members` lists every admitted value, one row per member, a row being an array of `[field, value]` pairs — almost always one pair, `[["value", "small"]]`, because most closed sets wrap a single scalar, but the shape doesn't assume that: ```ruby size_vo = safe_deposit_box[:value_objects].find { |vo| vo[:name] == "Size" } size_vo[:closed_set] # => true size_vo[:members] # => [[["value", "small"]], [["value", "medium"]], [["value", "large"]]] ``` The natural target for a closed set in a language with real enums is a real enum, not a string your port compares by hand — one variant per member row, named from its value. ## Commands: the roster and what each key means A command carries `name`, `role`, `goal`, `references`, `attributes`, `givens`, `ensures`, `mutations`, `emits`. `role` and `goal` are prose, not load-bearing for dispatch itself (role-checking, when a command declares one, is a separate opt-in rule — see [commands.md](/hecks/docs/implemented/guides/commands.html)). `references` is the field that decides creating vs. acting, and it decides it exactly the way `IR::Command#creates?` is actually implemented — `references.nil?`, nothing more: ```ruby credit = account[:commands].find { |c| c[:name] == "Credit" } open = account[:commands].find { |c| c[:name] == "Open" } credit[:references] # => "Account" open[:references] # => nil ``` A creating command derives its own identity from its own arguments (no record exists yet to look one up on); an acting command's `references` names the aggregate its identity is derived against instead — for `Credit`, `"Account"`, matched by the addressing key `docs/implemented/guides/commands.md` calls `reference_to`. #### Rule rows `givens` and `ensures` — and every other rule list in the IR: an aggregate's or entity's `invariants` and `preconditions`, a value object's `invariants` — are arrays of `{ description:, canonical:, ast: }`. `description` is the human-readable prose you'd show in a refusal message; `canonical` is the normalized boolean expression text the next section walks in full; `ast` is that same expression as a plain JSON tree, every node tagged by `"op"` from a closed roster (`Hecks::Bluebook::Expression::AstJson::OPS`), with `lookup` and `find` paths as segment arrays. `ast` is derived from `canonical` at emission and carries the whole meaning: a port that evaluates rules reads `ast` and never re-parses the text; a port that only displays them keeps `canonical`. A policy's `where` is the one rule that is not a row — it is a bare string, with its tree beside it as `where_ast` (`nil` when there is no `where`). ```ruby debit = account[:commands].find { |c| c[:name] == "Debit" } debit[:givens].first[:ast] # => { "op" => "compare", "cmp" => { "less_than" => false, "equal" => true, "negated" => false }, "left" => { "op" => "lookup", "path" => ["customer", "status"] }, "right" => { "op" => "str", "value" => "active" } } ``` The text form, for the rest of this section: ```ruby debit = account[:commands].find { |c| c[:name] == "Debit" } debit[:givens].map { |g| g[:canonical] } # => ["customer.status == \"active\"", "balance.cents >= amount.cents", "daily_limit.cents >= amount.cents"] debit[:ensures].map { |e| e[:canonical] } # => ["old.balance.cents == balance.cents + amount.cents", "ledger.size == old.ledger.size + 1"] ``` `mutations` is the one worth being careful with, because its shape branches on `op`. A `set`/`increment`/`decrement` entry carries `source`, itself a small tagged union — `{ kind: "argument", name: }` when the mutation reads a command argument (the overwhelming majority of real mutations), `{ kind: "literal", value: }` when the bluebook wrote a bare literal instead: ```ruby credit[:mutations].find { |m| m[:target] == :balance } # => { target: :balance, op: :increment, source: { kind: "argument", name: "amount" } } ``` An `append` entry carries `fields` instead of `source` — and here is the one real gotcha in the whole export: a field whose value is a bare argument name serializes as that name's plain string, but a field whose value is a LITERAL nested value object (as `Credit`'s own `sets :ledger, append: { ..., direction: { value: "credit" } }` does) serializes as that literal `Hash`'s `Kernel#inspect` string, not as structured JSON — because `IR::Command::Mutation#appended_fields` calls `.inspect` on anything that isn't a bare `Symbol`, not a second recursive `to_h`: ```ruby credit[:mutations].find { |m| m[:target] == :ledger } # => { target: :ledger, op: :append, fields: { amount: "amount", narrative: "narrative", direction: "{:value=>\"credit\"}" } } ``` A port that wants that literal back as data, not as a string to re-parse, has to special-case it — walk the ORIGINAL declaration instead of the exported field, or teach your codegen to recognize a `{:key=>"value", ...}`-shaped string and treat it as the literal it came from. This is a real shape a real bluebook writes (`Credit` and `Debit` both do it, for `direction`), not an edge case you can defer. **A `set`/`increment`/`decrement` mutation's own literal `source` gets none of that treatment.** `Mutation#classified_source` wraps any non-`Symbol` source as `{ kind: "literal", value: source }` — the ORIGINAL Ruby object, untouched, not a second call to `.inspect`. A literal value object there arrives as a real, structured `Hash` your port can build from directly: ```ruby customer = ir.fetch(:aggregates).find { |a| a[:name] == "Customer" } customer[:commands].find { |c| c[:name] == "Reinstate" }[:mutations] # => [{ target: :standing, op: :set, source: { kind: "literal", value: { value: "good" } } }] ``` Keep the two straight, because they look identical at a glance and resolve oppositely: `append`'s literal `fields` are text to re-parse; `set`/`increment`/`decrement`'s literal `source` is already the structured value, keyed by the SAME field names the target value object declares — build the target type straight from the `Hash`'s own keys, no string handling involved. `emits` is the plain array of event name strings a successful dispatch raises, in the order they were declared — one for almost every command in this corpus, but the shape does not assume exactly one. ## Arithmetic mutations: what `increment`/`decrement` actually change `increment`/`decrement`'s own `source` follows the identical tagged union `set` does (`argument` or `literal`), but applying one is not "add source to target." `CommandRules::Arithmetic#arithmetic`/ `#arithmetic_value_object`, read directly, coerce the source into the TARGET ATTRIBUTE's OWN type FIRST (`Value.for_attribute(aggregate, attribute, amount)`), then find the ONE field that's `Integer` in BOTH the target's current value and the now-coerced amount, and change ONLY that field — every other field the target value object carries passes through the mutation untouched: ```ruby scheduled_payment = ir.fetch(:aggregates).find { |a| a[:name] == "ScheduledPayment" } scheduled_payment[:commands].find { |c| c[:name] == "Retry" }[:mutations] # => [{ target: :attempts, op: :increment, source: { kind: "literal", value: { value: 1 } } }] ``` `Retry`'s own amount is the raw literal `Hash` `{ value: 1 }` — `RetryCount`'s one field, matched by name, not a bare integer your port has to guess a shape for. `Account.Debit`'s own `decrement` on `balance` is the other real shape: ```ruby debit[:mutations].find { |m| m[:target] == :balance } # => { target: :balance, op: :decrement, source: { kind: "argument", name: "amount" } } ``` The source there is an argument declared `PositiveMoney`, decrementing a target declared plain `Money` — different value-object TYPES, bridged only by both sharing one `Integer`-typed field, `cents`. `Money`'s OTHER field, `currency`, is untouched by the mutation — `balance.currency` staying `"USD"` after a debit is not an accident your port has to special-case, it's this rule. ## Lifecycles An aggregate with a `lifecycle` block exports `field`, `default`, `transitions` — the last one a flat array, one entry per `transition` line, `{ command:, to_state:, from_state: }`. A `from:` naming more than one source state in the bluebook flattens into more than one transition row here, one per source, all sharing the same `command` and `to_state`: ```ruby account[:lifecycle][:field] # => "status" account[:lifecycle][:default] # => "open" account[:lifecycle][:transitions].select { |t| t[:command] == "CloseAccount" } # => [{ command: "CloseAccount", to_state: "closed", from_state: "open" }, { command: "CloseAccount", to_state: "closed", from_state: "frozen" }] ``` A command not named in any `transitions` row runs with no lifecycle gate at all — dispatch's own `admissible_transition` step (next section) simply finds nothing to check and moves on. ## Entities `entities` is an array of the same aggregate shape, one level down — `name`, `description`, `identified_by`, `attributes`, plus its own `lifecycle` when it declares one. [entities.md](/hecks/docs/implemented/guides/entities.html) is where an entity's OWN commands and their caller-supplied identity live — a port generating THOSE (an entity's own `Amend`/`Reverse`-shaped dispatch, addressing one element of a list by an identity a CALLER supplies) is a separate, larger feature this page does not walk. What this page's own build order needs is narrower: an `append` onto an entity list, the one thing every entity-bearing aggregate's OWN creating/acting commands actually do (`Account.Credit`/`Debit` append a `LedgerEntry`; neither ever supplies its identity or its lifecycle field). Two fields on the element `MutationApplier#entity_element` fills in that a `sets append:` binding never names, because Ruby never asks the caller to: ```ruby account[:entities].find { |e| e[:name] == "LedgerEntry" }[:identified_by] # => ["sequence.value"] credit[:mutations].find { |m| m[:target] == :ledger }[:fields].keys # => [:amount, :narrative, :direction] ``` `sequence` is nowhere in that field list. It's minted from the list's own CURRENT LENGTH, one-indexed (`Array(current).size + 1`), wrapped into whichever single-field value object its own dotted path names — the exact same "bare-declared, single-field-VO" shape a composite AGGREGATE identity's own components resolve through, one level down. `LedgerEntry`'s own `lifecycle` (`state`, default `"posted"`) fills the same way, from its own declared `default:`, when the fields don't name it either. Both are genuinely absent from what the command declares — not a gap in what got exported, an intentional case a port's `append` codegen has to fill in on its own, the same place Ruby fills it. ## Dispatch, in the order it actually runs Seventeen steps, read by `Runtime::CommandInterpreter::DISPATCH_ORDER` off the language's own declared vocabulary (and projected into the Rust kernel as `AggregateStep::ORDER`, which `kernel/dispatch.rs`'s `dispatch()` loops over) — this is not a summary, it is the literal list: ```ruby skip decode_arguments # raw facts into declared arguments — a no-op step in both engines today (routing/generated from_json still decode) refuse_unknown_arguments # every arg key must be a declared attribute or an addressing key (id/identity/reference) refuse_absent_arguments # every non-optional declared attribute must be present normalize_args # coerce raw hashes into typed Values — invariant checks fire HERE refuse_role_mismatch # only if the command declares role: and the caller opted into role checking resolve_references # a reference argument (bare id string) is checked to actually exist hydrate # creating: derive identity, USUALLY refuse AlreadyExists here — see below; acting: look up, refuse NotFound enforce_givens # every given must read true against the hydrated instance + args admissible_transition # if the command names a lifecycle transition, its from: must match the current state assign_creation_attributes # creating commands only — see below apply_mutations # every declared sets, in declared order advance_lifecycle # if a transition was found, write the target state delegate_to_entity # only if the command declares delegates_to — runs the target entity command's own givens/mutations/ensures inline enforce_ensures # every ensures must read true, args merged with old: the pre-mutation state enforce_invariants # every aggregate-level invariant, plus every entity-list element's own, on the settled candidate record save # write the instance back through the aggregate's repository emit # build and return the declared events ``` Three facts about that list are true and not written down anywhere else in this repository's guides, because nothing before this page needed a second runtime to know them: **`hydrate`'s own `AlreadyExists` check does NOT always run before `enforce_givens`, and there is no single fixed position for it** — BUG#28 (QualityControl ledger), found the hard way: a creating command whose `given` fails while its identity already exists refused `GivenNotMet` in Ruby but `AlreadyExists` in a first Rust port, because Rust's generic `dispatch()` checked eagerly, unconditionally, at `hydrate` — matching Ruby's `hydrate_prior_or_initial`/`hydrate_legacy_ creation` (a STATE-DEPENDENT creating command — one with a `from:` guard, or a `given`/`ensures` reading the aggregate's own not-yet- existing state), but diverging from Ruby's THIRD hydration path, `hydrate_complete_state`. When `DependencyPlanning::Analyzer` classifies a creating command as BOTH `complete_state?` (every owner field gets a deterministic value — a default, or a `sets` sourced from a fresh argument or literal) AND `state_independent?` (no `given`/`ensures`/ mutation reads the aggregate's own prior state) on an `atomic_put`- capable adapter (Memory/SQLite/Postgres/D1/LocalStorage — every real adapter except Heki), `hydrate_complete_state` explicitly SKIPS its eager existence check and defers it ALL THE WAY to `save` — after `enforce_givens`, `admissible_transition`, `apply_mutations`, `enforce_ensures` AND `enforce_invariants` have all already run, mirroring `repository.atomic_put(insert_only: true)`'s own atomic check-and-write. `ReferralChain::Member.Join` (its `given` dereferences a fresh `sponsor` argument, never its own state) is the shape that surfaces this: a caller offering an already-taken `handle` under a sponsor that's no longer in good standing gets `GivenNotMet`, not `AlreadyExists` — the `given` never even reaches a real, already- existing record, because `hydrate_complete_state` never looked one up. This is latent throughout the mature banking corpus too (`Account. Open`, `OnboardingCase.Open`, `Transfer.Request`, and others each carry an explicit "OWN GIVEN, NOT SHARED (S12, ADR 0025)" comment) — narrow enough that plain fuzzing rarely rolls the combination (a duplicate identity AND a failing given, on the SAME dispatch), but real. Both Rust code generator (`rust/codegen`) derives this classification straight from the exported IR's own attributes/mutations/given/ensures data — see `rust/codegen/src/dependency_planning.rs`'s own header for why that's a deliberate re-derivation rather than a precomputed fact threaded through `ir.json`. **`assign_creation_attributes` and `apply_mutations` are two different steps, and only the first is implicit.** A creating command's attributes are copied onto the fresh instance by NAME MATCH alone — every command attribute whose name matches one of the aggregate's own attribute names is assigned, unconditionally, with no `sets` required to say so. `Pizzas::Order.CreatePizza` never declares a single `sets`, and its exported `mutations` array is empty — the whole record is built by this implicit step alone: ```ruby open[:mutations].map { |m| m[:target] } # => [:number, :kind, :daily_limit] ``` `Account.Open` DOES declare `sets :number` and its siblings, even though every name already matches — legal, and not a no-op collision: `assign_creation_attributes` runs first and sets `number` from `args[:number]`; `apply_mutations` runs after and sets it again, from the identical source, to the identical value. Redundant in this specific case, but the two steps are not the same step wearing two names — a `sets` whose source is NOT a plain pass-through (an `increment:`, a literal, a differently-named argument) only ever runs through the second step, never the first. **Every `given`/`ensures`/invariant expression resolves a bare name against the command's ARGUMENTS first, the INSTANCE second, and raises if neither has it** — `Bluebook::Expression::Resolver#fetch`, read directly: ```ruby skip def fetch(name, state, attrs) key = name.to_sym return attrs[key] if attrs.key?(key) return state[key] if known?(state, key) raise EvaluationError, "cannot resolve #{name.inspect} — no such attribute or argument" end ``` That's why `Debit`'s own given, `"balance.cents >= amount.cents"`, can mix `balance` (an instance field) and `amount` (a command argument) in one comparison with no marker distinguishing them in the text — the resolver tries the argument hash first, by design, so an argument name that happens to collide with an instance field name always wins as the argument. A dotted path (`balance.cents`) resolves its head the same way, then walks the rest with plain `[]`, on whatever the head resolved to. ## The expression grammar A command's predicate is Ruby, parsed by Prism (Ruby's own parser gem, not a second grammar written by hand) and reduced to this canonical text at DSL-build time — the same reason a domain as a whole is data rather than code: ```ruby skip given("at most 10 toppings") { toppings.size < 10 } ↓ "toppings.size < 10" ``` An operator is admitted to this grammar only once it reads as a real rendering of a real rule already in the corpus — an operator with no rendering is not a slow operator, it is not an operator. Every operator the evaluator runs passes through this admission gate on each suite run, checked, not aspired to. `given`, `ensures`, and every value-object `invariant` are strings — already normalized at DSL-build time (whitespace collapsed, `.length` rewritten to `.size`), so the SAME text you see in the IR is exactly what a real dispatch evaluates, byte for byte. The grammar is small and closed, projected from `lib/hecks/bluebook/expression/projection.json` and held equal to it by `spec/operator_conformance_spec.rb`: | symbol | category | arity | notes | |---|---|---|---| | `\|\|` | logical | 2 | lowest precedence | | `&&` | logical | 2 | | | `.include?` | membership | 2 | `haystack.include?(needle)`; haystack must resolve to `Array` or `String` | | `>=`, `<=`, `<`, `>`, `==`, `!=` | comparison | 2 | reduced to two primitives, next section | | `!` | logical | 1 | prefix negation of the whole remaining expression | | `+` | arithmetic | 2 | | | `.modulo` | arithmetic | 2 | | | `.positive?`, `.negative?`, `.zero?` | sign_test | 1 | sugar for comparing the receiver against literal `0` | | `.empty?`, `.size` | sized | 1 | `Array`, `String`, `Hash` only | | `.to_s` | to_string | 1 | `String`, `Integer`, `Float`, `Boolean`, `nil` only — raises `EvaluationError` on anything else | | `.any?`, `.none?`, `.all?`, `.find` | enumeration | 2 | `list.any? { \|x\| predicate }` — the block's parameter is bound for the predicate (a whole boolean expression, blocks nest freely); `.find { }` may carry a dotted path, `list.find { \|x\| … }.a.b`, and is nil when nothing matches. Braces only, never `do…end` | | `.match?` | pattern_match | 2 | `receiver.match?(/pattern/flags)` — a real Ruby-Regexp source between the slashes; `i`/`m`/`x` flags supported | | `.present?`, `.blank?` | presence | 1 | Rails-standard semantics: `nil`/`false` are blank, `String`/`Array` are blank when empty, everything else is present | | `.set?`, `.unset?` | presence | 1 | narrower than `.present?`/`.blank?` on purpose: `!receiver.nil?`, full stop — an assigned-but-empty `String`/`Array` is `.set?`, unlike `.present?`'s own reading of the identical value | | `.split` | text | 2 | `receiver.split("SEP")` — produces an `Array`, literal separator only | | `.start_with?`, `.end_with?` | text | 2 | `String` receiver only | | `.first`, `.last` | positional | 1 | over an array-typed receiver (an `ArrayLiteral`, a `.split` result, or a list-typed field of scalar elements) — `nil` on an empty list | Every one of the six comparison operators reduces to two primitives — `less_than` and `equal` — combined with a boolean algebra rather than six separate code paths: each operator's row says which primitive(s) OR together and whether the result negates. ```ruby ops = Hecks::Bluebook::Expression::Evaluator::OPERATORS ops.map { |op| [op.symbol, op.compares_less_than, op.compares_equal, op.negated] }.sort # => [["!=", false, true, true], ["<", true, false, false], ["<=", true, true, false], ["==", false, true, false], [">", true, true, true], [">=", true, false, true]] ``` Read `>=` off that table: `compares_less_than: true`, `compares_equal: false`, `negated: true` — `a >= b` is computed as `!(a < b)`, not as its own comparison. A sign test is sugar over the identical primitives, against a literal `0` — `x.positive?` parses to exactly the same shape `x > 0` would. A `given`/`ensures` string parses into one of six node kinds — `Or`, `And`, `Not`, `Compare`, `Include`, `Resolve` — parsed once (per distinct string, cached) and interpreted fresh against every dispatch, against two inputs: `state` (the instance, or for an `ensures`, the instance PLUS the merged key `old:` holding the pre-mutation snapshot) and `attrs` (the command's arguments). Every real leaf in this corpus's own commands, walked directly rather than invented: ```ruby Hecks::Bluebook::Expression::Evaluator.parse("status == \"open\"").class.name.split("::").last # => "Compare" Hecks::Bluebook::Expression::Evaluator.parse("toppings.size < 10").class.name.split("::").last # => "Compare" Hecks::Bluebook::Expression::Evaluator.parse("old.balance.cents == balance.cents + amount.cents").class.name.split("::").last # => "Compare" ``` Below `Compare`, the left and right sides are `Resolver` leaves — a smaller grammar for the non-boolean part: integer/float/string/bool/nil/ array literals, `+` addition, `.modulo`, a sign test, `.empty?`/`.size`, `.to_s`, `.match?`, `.present?`/`.blank?`, `.set?`/`.unset?`, `.split`, `.start_with?`/`.end_with?`, `.first`/`.last`, the block-taking enumeration operators (`.any?`/`.none?`/`.all?`/`.find { |x| … }`, whose body is a whole boolean expression parsed by `Evaluator` again), and the true leaf, `Lookup` — a bare or dotted name resolved against `attrs` then `state`, per `fetch` above. A `given` that only ever reads a bare instance field, no operator at all, still parses — into a `Resolve` node, whose value is checked for Ruby truthiness (`!nil? && != false`), not equality against literal `true`: ```ruby node = Hecks::Bluebook::Expression::Evaluator.parse("some_flag") node.class.name.split("::").last # => "Resolve" ``` ## Interpret data, don't compile source There are two ways to turn a `given`/`ensures`/invariant string into something your target language runs. **Compile it**: parse the real text once, walk the AST, and emit bespoke boolean SOURCE CODE in your target language for that one expression — a different function body for `"cents >= 0"` than for `"toppings.size < 10"`. **Interpret it**: parse the real text once, but instead of emitting source, emit the AST itself as DATA in your target language, and write ONE generic function — once, by hand — that walks any `Expr` tree this grammar can produce. The first approach is a natural place to start (it's what value-object invariant checking looks like before you need anything past a value object's own scalar fields), and it's also where the problems below actually show up — not as edge cases, but on the very first `given` a real command needs. **Compiling forces you to solve, statically, two problems the grammar itself never has.** First, a fixed type vocabulary: a `given` can read ANY of the aggregate's own attributes, not just scalar ones — `Order.AddTopping`'s own `"toppings.size < 10"` reads a LIST attribute, and `"status == \"open\""`-shaped text reads the lifecycle field, which is NOT one of the aggregate's own `attributes` in the exported IR at all (see "Lifecycles" above — it's its own top-level key): ```ruby account[:attributes].map { |a| a[:name] }.include?(account[:lifecycle][:field].to_sym) # => false ``` A compiler emitting typed source has to know every one of these categories in advance and pick correctly-typed literals to match (is the `0` in `"cents >= 0"` an integer or a float literal in your target language? — it depends on `cents`'s own declared type, which the compiler has to look up before it can even emit the comparison). Second, a fixed receiver: a value-object invariant only ever reads that value object's own fields, so a compiler built for invariants can hard-code one receiver name. `given`/`ensures` run against a hydrated AGGREGATE instance instead, addressed by whatever local variable your generated dispatch function happens to call it — a choice YOUR codegen makes, not a fact the IR states. **An interpreter has neither problem, because both are really the same problem: baking a decision into generated SOURCE that a genuinely dynamic evaluation would rather make at the moment it's needed.** A runtime `interpret(expr, state)` keeps values dynamically typed until the instant two of them are actually compared — no advance type lookup for a numeric literal, because comparison coerces then, not at codegen time. And `state` is an ordinary function PARAMETER, not something baked into which name a piece of generated source happens to use — the same interpreter serves a value object checking itself and a command evaluating a `given` against a looked-up record, because both are just "whatever `state` was passed this call." **This generalizes past expressions, to dispatch itself.** A generator that emits one bespoke function per command SHAPE — one for a plain creating command, another for an acting command with a given and an append mutation, a third once a lifecycle transition shows up — never converges, because the real dispatch algorithm isn't shaped that way either: `CommandInterpreter#call` is ONE method walking `DISPATCH_ORDER` over whatever IR a command carries, not one method per command in the domain. A port's dispatcher should mirror that: one hand-written, generic function, driven by a small amount of per-command DATA (which givens, which mutations, whether it creates or acts) — not N generated functions each hardcoding one shape's control flow. **Name the one place this genuinely can't be pushed all the way down.** READING a named field generalizes cleanly — implement one small lookup/field-access interface per type, the identical shape for every type, and a generic interpreter can read anything through it. WRITING into a `sets` target does not, in a statically-typed target language: appending a value object to a list means CONSTRUCTING one of a specific, differently-shaped type per aggregate, which has no generic equivalent without runtime reflection. That still needs a small amount of per-command generated glue — real, worth being honest about, and a much smaller boundary than "generate the whole dispatch function by hand" was. This is exactly the split `rust/` runs on, not a hypothetical: `Expr` and `interpret()` (`rust/src/kernel/expr.rs`) are the generic READING/behavior half, hand-written once; `dispatch()` (`rust/src/kernel/dispatch.rs`) is the generic per-command orchestration, also hand-written once; `hecks build.project_rust` (driving `rust/codegen`) is the small, per-command WRITING glue this paragraph names as the one place generation still earns its keep — real Rust struct literals and `Vec::push` calls, generated because constructing a specific type has no generic equivalent, nothing more. ## The persistence contract A port needs somewhere to put and find records, exactly the shape a Ruby adapter needs. That contract — which methods are required, which are delegated without a presence check, which are optional passthroughs, and the one method (`delete`) whose return value is explicitly NOT part of the contract — is already documented in full, with a worked example against the smallest real adapter, in [writing-an-adapter.md](/hecks/docs/implemented/guides/writing-an-adapter.html). Nothing about that contract is Ruby-specific; read it there rather than here. ## A build order that keeps every intermediate state honest Roughly the order the facts above unlock capability, smallest first — and, for `rust/`, the order things actually happened in, not just a plan: types and empty-mutation creating commands first (against Pizzas), then invariants/`given`/mutations, then lifecycle, then `ensures` (needed a real kernel change — see `docs/implemented/decisions/0011-rust-compiles-types-interprets-dispatch.md`), then entities last, against Banking, once Pizzas alone stopped exercising new shapes: 1. **Types.** Every `value_objects` entry (closed sets as real enums), every aggregate's own record shape from `attributes`. 2. **Creating commands with empty `mutations`.** `assign_creation_attributes` alone — no `given`, no invariant check yet, just identity derivation and the `AlreadyExists` refusal. 3. **Invariant checking.** Every value object's `invariants` — this is what turns step 2 from "always accepts" into a real refusal path, and it's the expression grammar above, applied to a value object's own fields as `state` with an empty `attrs`. 4. **`given` evaluation and `apply_mutations`.** Unlocks acting commands with no lifecycle — `enforce_givens`, the `source` tagged-union walk (`argument` vs `literal`), `append`'s `fields` gotcha from above. 5. **Lifecycle.** `admissible_transition` + `advance_lifecycle`, against the flattened `transitions` array. 6. **`ensures`, with `old:`.** Needs the pre-mutation snapshot kept around across step 4's mutation. 7. **Entities.** `append`-typed mutations whose element type is another aggregate-local record, with its own identity-minting rule. Sagas, policies, and the query DSL are real parts of the IR (`process_managers`, `policies`, and each aggregate's own `queries` key) that this page does not walk — they're [policies-and-process-managers.md](/hecks/docs/implemented/guides/policies-and-process-managers.html) and [queries-and-read-models.md](/hecks/docs/implemented/guides/queries-and-read-models.html)'s subjects, written for a domain author rather than a port author, but the IR shapes those pages describe are the same ones `Hecks::Projector::Exporter` emits, not a second format.