hecks

Policy

Words available inside policy do ... end / translates do ... end.

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.

on, trigger and across run against examples/banking and examples/compliance, which wire a real cross-domain reaction between them. where and for_each appear nowhere in the corpus, so they get a chapter of their own here — a card that is blocked whenever its holder draws a severe enough alert:

```ruby bluebook Hecks.bluebook “PolicyReference” do vision “The two policy modifiers the corpus never needed.”

aggregate “RefCard” do attribute :serial, Serial

identified_by :serial
attribute :holder, Holder

value_object "Serial" do
  attribute :value, String
end

value_object "Holder" do
  attribute :value, String
end

lifecycle :status, default: "active" do
  transition "Block" => "blocked", from: "active"
end

command "Issue" do
  sets :serial
  sets :holder
  emits "RefCardIssued"
end

command "Block" do
  reference_to RefCard
  emits "RefCardBlocked"
end

query "ForHolder" do
  attribute :holder, Holder
  where(holder: :holder)
end   end

aggregate “RefAlert” do attribute :ref, AlertRef

identified_by :ref
attribute :holder,   Holder
attribute :severity, Severity

value_object "AlertRef" do
  attribute :value, String
end

value_object "Holder" do
  attribute :value, String
end

value_object "Severity" do
  attribute :level, Integer, default: 0
end

# NOT `command "Raise"` — that entry point would be spelled `RefAlert.raise`,
# and `raise` is Kernel's, so the facade never sees the call.
command "RaiseAlert" do
  sets :ref
  sets :holder
  sets :severity
  emits "RefAlertRaised"
end   end

# A job the outside runs. Nothing here names the port: the hecksagon maps ask :check. aggregate “RefJob” do attribute :ref, JobRef

identified_by :ref

value_object "JobRef" do
  attribute :value, String
end

command "Submit" do
  sets :ref
  emits "RefJobSubmitted"
end   end

policy “CheckWhenSubmitted” do on “RefJob.RefJobSubmitted” ask :check, with: { ref: :ref } end

policy “BlockCardsOnSevereAlert” do on “RefAlertRaised” where { severity.level >= 3 } for_each “RefCard.ForHolder” # THE ROW, AND NOTHING ELSE. Block takes only which card; the # alert’s own ref/holder/severity are none of its business, # and without this projection they would ride along and be refused. trigger RefCard::Block, with: { ref_card: :ref_card } end end


```ruby boot
Hecks::Adapters::Folder.new.load_bluebooks(File.join(InMemoryDomain::ROOT, "examples/banking/bluebook"))
Kernel.load(File.join(InMemoryDomain::ROOT, "examples/compliance/bluebook/compliance.bluebook"))

Hecks.hecksagon("Banking") do
  attaches "Governance"
  subscribe "Compliance.AccountFreezeReviewOpened"
  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

Hecks.hecksagon("Compliance") do
  attaches "Governance"
  Compliance::AccountFreezeReview.persisted_by("Memory")
  Compliance::BoxSurrenderReview.persisted_by("Memory")
end

Hecks.hecksagon("PolicyReference") do
  PolicyReference::RefCard.persisted_by("Memory")
  PolicyReference::RefAlert.persisted_by("Memory")
  PolicyReference::RefJob.persisted_by("Memory")

  PolicyReference::RefJob.port "Auditor" do
    asks "Check", to: RefJob do
      attribute :ref, JobRef
      answers "RefJobChecked"
      refuses "RefJobRefused"
    end
  end
end
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "po-1" },
                                                       name: { given: "Dorothy", family: "Vaughan" },
                                                       email: { address: "dorothy@example.com" } })
account = Banking::Account.open!(customer: "po-1", number: { value: "po-a1" },
                                kind: { name: "current" }, daily_limit: { cents: 50_000 })

on

on on_event, on_event — fills on_event

| argument | kind | required | fills | |—|—|—|—| | positional 1 | constant | true | on_event | | positional 1 | text | true | on_event |

The event this policy reacts to. By the time it arrives it is already a committed fact — on names it unconditionally; where, below, is where a policy adds a condition of its own.

ReviewOnFreeze names "Account.AccountFrozen". Freezing the account is the only act here — nothing mentions compliance at the call site:

runtime.dispatch_flat("Banking::Account.FreezeAccount", number: { value: "po-a1" })
runtime.registry.reaction_log.last[:on]  # => "AccountFrozen"

trigger

trigger trigger_command, trigger_command, with: — fills trigger_command

| argument | kind | required | fills | |—|—|—|—| | positional 1 | constant | true | trigger_command | | positional 1 | text | true | trigger_command | | with: | pairs | false | with_spec |

The command on’s event fires, given as a bare command constant — Aggregate::Command, never domain-prefixed — because it defaults to this policy’s own domain; see across to reach another one. Without with:, the event’s whole payload forwards verbatim as the command’s arguments, so the two shapes have to agree before either is written; with: is how a trigger whose shape is not its event’s says what it actually needs. A policy that ends up triggering the event it reacts to does not loop forever: Dispatcher::MAX_REACTION_DEPTH (5) stops the chain and records why.

The triggered command really ran — a review exists that nothing in the banking call chain asked for:

runtime.registry.reaction_log.last[:trigger]    # => "Compliance::AccountFreezeReview.Open"
runtime.registry.reaction_log.last[:delivered]  # => true
Compliance::AccountFreezeReview.find("po-a1").status  # => "open"

with: names what the trigger is given. Same key => value shape a saga’s own dispatch ..., with: takes, and read the same way: a Symbol names a field on the triggering event, anything else is a literal the policy supplies itself.

Left off, the whole payload forwards — which is a real constraint on the target, not a detail. FreezeAccountsOnSuspension, in the same chapter, reacts to CustomerSuspended and triggers Account::FreezeAccount; without a projection, FreezeAccount would have to be able to TAKE the standing that event carries, a field a freeze never reads, and Account would need a value object declared solely to type it. Naming what the trigger wants is what makes that unnecessary:

```ruby skip

examples/banking/bluebook/

policy “FreezeAccountsOnSuspension” do on “CustomerSuspended” for_each “Account.OpenForCustomer” trigger Account::FreezeAccount, with: { account: :account } end


`account` is the key the fan-out merges for each row it answers — the
row is part of the source a projection reads from, not something bolted
on after it, which is what lets a trigger be given the record and
nothing else. See `for_each` below for the fan-out itself.

A fan-out's `with:` can also read the row's own fields: `with: { refunded: :charged }`
takes `charged` from each row the query answers. The projection's source is the row's
fields, then the emitting record's identity, then the event payload, each overriding the
one before, so the payload always wins a name it shares with a row and the row key stays
the row's id. A policy with no `with:` forwards the payload and the row key only, never the
row's fields. The build-time check admits the same names: a field of the query's
aggregate, its lifecycle field, a projected field, `:id`, or the row key.

A target that cannot take every field it is given is refused, the
triggering command still succeeds, and the reason lands in the reaction
log rather than in the caller's lap — which is a good way for a policy
to stay broken for a long time without anyone noticing.

## ask

<!-- generated:begin word=ask -->
`ask ask, with:` — fills `ask`

| argument | kind | required | fills |
|---|---|---|---|
| positional 1 | symbol | true | ask |
| `with:` | pairs | false | with_spec |
<!-- generated:end -->

The need a policy puts to the outside, in the domain's own words. `ask :check` says
what the domain wants done; it never says which port does it. The hecksagon declares the
port (`asks "Check", to: RefJob` on a port of `RefJob`), and boot reads the match: the
event's aggregate and the ask's name against that aggregate's declared asks. The reaction
then dispatches exactly what `trigger RefJob::Auditor::Check` would have, `with:` and all,
and the outcome comes back as the ask's `answers` or `refuses` event for another policy to
react to.

```ruby
runtime.dispatch_flat("PolicyReference::RefJob.Submit", ref: { value: "job-1" })
runtime.registry.reaction_log.last[:trigger]  # => "PolicyReference::RefJob::Auditor.Check"

If one aggregate declares the same ask name on two ports, the hecksagon picks with PolicyReference::RefJob.ask_via "Check", port: "Auditor", and boot refuses the ambiguity until it does. An ask that no declared ask answers is a boot refusal and a model_check error, and a declared ask no policy reaches is a warning. trigger still works for a command, and for the older spelling that names a port operation, which model_check flags so the move to ask can be counted. An ask does not combine with across: it resolves in its own domain.

across

across target_domain, expect_undelivered: — fills target_domain

| argument | kind | required | fills | |—|—|—|—| | positional 1 | text | true | target_domain | | expect_undelivered: | flag | false | expect_undelivered |

Names the domain a trigger reaches into when it lives outside this policy’s own. Leave it off and trigger is assumed to name a command in the same domain as the event that fired it — the ordinary case.

ReviewOnFreeze declares across "Compliance", and that is the whole difference between the bare AccountFreezeReview::Open it writes and the fully-qualified command the runtime actually dispatched:

runtime.registry.reaction_log.first[:trigger]  # => "Compliance::AccountFreezeReview.Open"

across is a real relationship declaration now, not just a routing detail — Hecks::Bluebook::ModelCheck checks it against the sibling hecksagon’s own subscribe/attaches lines (see docs/implemented/reference/hecksagon.md’s own “Checked, not routed” section for the other half). Banking’s real corpus is clean here — its own subscribe "Compliance.AccountFreezeReviewOpened" is exactly what acknowledges ReviewOnFreeze’s across "Compliance":

require "hecks/bluebook/model_check"
banking  = runtime.registry.bluebook("Banking")
review_on_freeze = banking.policies.find { |p| p.target_domain == "Compliance" }
findings = Hecks::Bluebook::ModelCheck.call(banking, hecksagon: runtime.registry.hecksagon("Banking"))
findings.any? { |f| f.subject == review_on_freeze.name }  # => false

where

where do ... end — fills where

A guard on whether this policy fires at all, read against the triggering event’s own payload — where { amount.cents > 1_000_00 }. Same extraction as a command’s given/ensures (the block’s source is read once, at build time, and never called directly — only the extracted text is evaluated, by the same expression evaluator a command’s own rules run through), but no description argument the way given/ensures each carry one: a given’s description becomes a refusal message, and a where that does not hold refuses nothing — the policy is silently a no-op for that event, exactly like an event qualifier (on "Account.Frozen" vs. a Payment.Frozen) that does not match. Nothing is logged either way; a policy that never applies to an event leaves no more trace than one that was never declared.

BlockCardsOnSevereAlert guards on severity.level >= 3. A mild alert raises nothing anywhere — the card stays active and the reaction log does not grow:

PolicyReference::RefCard.issue!(serial: { value: "po-c1" }, holder: { value: "po-h1" })
before = runtime.registry.reaction_log.size

PolicyReference::RefAlert.raise_alert!(ref: { value: "po-al1" }, holder: { value: "po-h1" }, severity: { level: 1 })
PolicyReference::RefCard.find("po-c1").status  # => "active"
runtime.registry.reaction_log.size == before   # => true

for_each

for_each for_each — fills for_each

| argument | kind | required | fills | |—|—|—|—| | positional 1 | text | true | for_each |

Fan-out: trigger fires once per row a declared query answers, instead of once for the event — for_each "Account.OpenForCustomer". The named query runs against the SAME domain the event belongs to, using the event’s own payload as the query’s arguments, unless the verb is itself domain-qualified ("Domain::Aggregate.query_name") — the same default a saga’s own dispatch command name already takes. Each matching row’s own id is merged into the forwarded payload under the iterated aggregate’s own reference-key convention (account for Account — the same bare name reference_to Account mints), so trigger’s target command addresses the right record without either side having to say the argument’s name twice. where, above, still gates the whole fan-out, evaluated once against the event, not once per row.

The event’s own payload does not carry the emitting record’s identity, so a query argument named for an identity head of the emitting aggregate is lent the record’s id when the payload lacks it, and a payload value is never overridden. A Registration.Cancel that only does reference_to Registration emits an empty payload, and for_each "Refund.Tracking" still finds the refunds whose registration_id is the cancelled registration. The Rust kernel lends the same id for an aggregate with a single-component identity.

One alert, two cards held by the same person, and the fan-out runs once per row ForHolder answers — the alert never named a card. Each row is recorded under for_row::

PolicyReference::RefCard.issue!(serial: { value: "po-c2" }, holder: { value: "po-h1" })

PolicyReference::RefAlert.raise_alert!(ref: { value: "po-al2" }, holder: { value: "po-h1" }, severity: { level: 5 })
runtime.registry.reaction_log.last(2).map { |row| row[:for_row] }  # => ["po-c1", "po-c2"]

Each row really is delivered to, and both cards moved — nothing at the call site named a card:

runtime.registry.reaction_log.last(2).map { |row| row[:delivered] }.uniq  # => [true]
PolicyReference::RefCard.find("po-c1").status  # => "blocked"
PolicyReference::RefCard.find("po-c2").status  # => "blocked"

A card belonging to somebody else is not in the query’s answer, so the fan-out never reaches it — this one was issued before the alert above and is still untouched:

PolicyReference::RefCard.issue!(serial: { value: "po-c3" }, holder: { value: "po-h2" })
PolicyReference::RefAlert.raise_alert!(ref: { value: "po-al3" }, holder: { value: "po-h2" }, severity: { level: 5 })
runtime.registry.reaction_log.last[:for_row]  # => "po-c3"

Only po-h2’s own card is in that fan-out — po-c1 and po-c2 were never candidates, because the query answered about a different holder.

The key the row arrives under is ref_card — BARE, not ref_card_id. A command referencing its OWN aggregate is addressed by the bare reference key, and a fan-out is always that case, since it iterates one aggregate’s rows to fire that same aggregate’s command. The _id spelling is the FOREIGN one (Account.Open’s reference_to Customer, which is how the Onboarding saga opens an account), and it is what this merged unconditionally before — so every fan-out in the language was refused, per row, into the reaction log.