hecks

ReadModel

Words available inside read_model 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.

Most of these run against examples/banking’s five read models, which between them carry every gathering and reducing shape the word has — a rooted portfolio, a filtered and capped dashboard, two reductions, and a rootless group_by. offset, authorize and nulls are declared by no read model in the corpus, so they get one of their own:

```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”) end Hecks.hecksagon(“Governance”) do Governance::RoleAssignment.persisted_by(“Memory”) Governance::RoleTransition.persisted_by(“Memory”) end


```ruby bluebook
Hecks.bluebook "ReadModelReference" do
  vision "The read-model words the corpus does not yet declare."

  aggregate "Depot" do
    attribute :code, Code

    identified_by :code
    value_object("Code") { attribute :value, String }

    command "OpenDepot" do
      sets :code
      emits "DepotOpened"
    end
  end

  aggregate "Parcel" do
    attribute :label, Label

    identified_by :label
    reference_to Depot
    attribute :region, Region
    attribute :weight, Weight, optional: true
    attribute :fragile, Fragile

    value_object("Label")   { attribute :value, String }
    value_object("Region")  { attribute :value, String }
    value_object("Weight")  { attribute :value, Integer }
    value_object("Fragile") { attribute :value, TrueClass }

    command "Accept" do
      attribute :depot,    Depot
      sets :label
      sets :region
      sets :weight
      sets :fragile
      emits "ParcelAccepted"
    end
  end

  read_model "DepotManifest" do
    description "One depot's parcels, heaviest first, skipping the heaviest of all."
    reference_to Depot
    include Depot
    include Parcel
    order_by :weight, :desc
    nulls :last
    limit 2
    offset 1
    inspect_query :sql
    authorize :depot_access, tenant: :region
  end

  # `any`/`all` (ADR 0078) need a boolean field examples/banking doesn't declare —
  # same reason `weight`/`nulls`/`offset`/`authorize` above live in this fixture,
  # not the real corpus.
  read_model "DepotHasFragileParcel" do
    reference_to Depot
    include Depot
    include Parcel
    any :fragile
  end

  read_model "DepotAllParcelsFragile" do
    reference_to Depot
    include Depot
    include Parcel
    all :fragile
  end
end

```ruby boot Hecks.hecksagon(“ReadModelReference”) do ReadModelReference::Depot.persisted_by(“Memory”) ReadModelReference::Parcel.persisted_by(“Memory”) end


```ruby
runtime.dispatch("Banking::Customer.Register", with: { reference: { value: "rm-1" },
                                                       name: { given: "Sofia", family: "Kovalevskaya" },
                                                       email: { address: "sofia@example.com" } })
account = Banking::Account.open!(customer: "rm-1", number: { value: "rm-a1" },
                                kind: { name: "current" }, daily_limit: { cents: 50_000 })
Banking::Account.open!(customer: "rm-1", number: { value: "rm-a2" },
                      kind: { name: "savings" }, daily_limit: { cents: 10_000 })

payment = Banking::CardPayment.authorize!(account: "rm-a1", authorisation: { value: "auth-1" },
                                         amount: { cents: 4_200 }, merchant: { value: "Corner Shop" })
payment.capture!
payment.dispute!(disputed_by: "rm-1")

second = Banking::CardPayment.authorize!(account: "rm-a1", authorisation: { value: "auth-2" },
                                        amount: { cents: 900 }, merchant: { value: "Kiosk" })
second.capture!
second.dispute!(disputed_by: "rm-1")

# SEVEN, on the OTHER account (S13, ADR 0025 — ComplianceDashboard
# gained a real `offset 5` alongside its own `limit 5`) — enough for
# the `where`/`order_by`/`offset` sections below to demonstrate a
# genuine second page, without disturbing "rm-a1"'s own two disputes
# that `count`/`median` already rely on above.
[4_200, 900, 500, 400, 300, 200, 100].each_with_index do |cents, index|
  card = Banking::CardPayment.authorize!(account: "rm-a2", authorisation: { value: "auth-page-#{index}" },
                                        amount: { cents: cents }, merchant: { value: "Shop#{index}" })
  card.capture!
  card.dispute!(disputed_by: "rm-1")
end

runtime.dispatch("ReadModelReference::Depot.OpenDepot", with: { code: { value: "dp-1" } })
runtime.dispatch("ReadModelReference::Parcel.Accept", with: { label: { value: "p-1" }, depot: "dp-1", region: { value: "north" }, weight: { value: 30 }, fragile: { value: true } })
runtime.dispatch("ReadModelReference::Parcel.Accept", with: { label: { value: "p-2" }, depot: "dp-1", region: { value: "north" }, weight: { value: 20 }, fragile: { value: false } })
runtime.dispatch("ReadModelReference::Parcel.Accept", with: { label: { value: "p-3" }, depot: "dp-1", region: { value: "north" }, weight: { value: 10 }, fragile: { value: false } })
runtime.dispatch("ReadModelReference::Parcel.Accept", with: { label: { value: "p-4" }, depot: "dp-1", region: { value: "north" }, fragile: { value: false } })

description

description description — fills description

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

A free-text label for the read model — no rules attached, read by nothing but a human.

runtime.registry.bluebook("Banking").read_model("CustomerPortfolio").description  # => "A customer's cross-account position, rebuilt from aggregate heads."

reference_to

reference_to reference_target, as: — fills reference_target

| argument | kind | required | fills | |—|—|—|—| | positional 1 | constant | true | reference_target | | as: | symbol | false | reference_name |

Names the aggregate this read model is anchored to — the one record every row centers on, everything else in include comes back a collection around it (see include). A read model declares only one; a second reference_to is refused when the bluebook builds.

Optional: a read model with no reference_to at all is ROOTLESS — no id argument at dispatch, every included head reads its own aggregate whole rather than being matched against a root. At least one include is still required (a read model naming neither refuses). See group_by, which this exists for.

CustomerPortfolio is rooted on a Customer, so the ask names one and the answer is that customer’s own position:

portfolio = runtime.query("Banking.CustomerPortfolio", customer: "rm-1").first
portfolio[:customer][:reference][:value]  # => "rm-1"

include

include aggregate, as: — fills aggregate_heads

| argument | kind | required | fills | |—|—|—|—| | positional 1 | constant | true | aggregate | | as: | symbol | false | as |

Gathers another aggregate into the read model alongside the reference. Cardinality is inferred, not declared: the reference target comes back as the one record, any other included aggregate comes back as a list — there’s no many: to spell out yourself. Declaring the same as: name twice is refused. See the queries-and-read-models guide for the full ComplianceDashboard example.

Each included aggregate becomes its own key on the row, pluralised for the many side and singular for the root:

portfolio.keys.first(3)  # => [:customer, :accounts, :atm_cards]
portfolio[:accounts].map { |row| row[:number][:value] }  # => ["rm-a1", "rm-a2"]

The root is one record, not a list — which is the difference include is drawing:

portfolio[:customer][:status]  # => "active"

group_by

group_by group_by — fills group_by

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | group_by |

Nests the eligible collection’s own rows into a Hash keyed by one or more of its own field values, in the order named — group_by :agg, :state on a StateStyle head comes back {"Board" => {"open" => {...}, "archived" => {...}}}, not a flat array. Held to the same “exactly one many-side head” rule where/order_by/etc already are (ReadModelBuilder#seal_group_by) — grouping is a question about ONE collection’s own rows. Requesting it also unwraps every single- attribute value object on that head’s own rows to its bare scalar (Runtime::Value.materialize_unwrapped, not the plain materialize every other head still gets) — grouping needs a real scalar to key by regardless, so a read model already asking for that gets the unwrap for free. Refuses at DISPATCH time (not build time — the aggregate this read model targets isn’t known until then) if a named field isn’t one the eligible collection’s own aggregate actually declares.

This is also what makes reference_to optional: a read model with no root — every included head reading its own aggregate whole, no id argument at dispatch — is group_by’s own real use (nesting a whole table by its own field values has no root record to hang off).

AccountsByKind groups by two fields, and the result nests one level per field rather than flattening:

by_kind = runtime.query("Banking.AccountsByKind").first[:accounts]
by_kind.keys  # => ["current", "savings"]
by_kind["savings"].keys  # => ["rm-a2"]

Grouping also unwraps single-attribute value objects to their bare scalar — daily_limit reads as a number here, where the same field on an ungrouped row is still a { cents: }:

by_kind["savings"]["rm-a2"][:daily_limit]  # => 10000

A leaf holds one row. When two rows reach the same full key path, the ask refuses with InvariantViolation rather than keeping one of them (ADR 0061, decision D1). The refusal names the read model, its group_by, the colliding ids and the shared key path, in the same words on Ruby and on the generated Rust runtime. A read model grouped group_by :bin over two parts in bin b1 answers:

PartsByBin groups by bin, but rows "p1", "p2" share bin = b1 — a group_by leaf holds one row; add a field that tells them apart

The refusal depends on the data: the same read model answers while every key path holds one row and refuses from the first request after a second row reaches one. A key path that names every identity field of the grouped aggregate cannot be shared by two rows, so it is accepted from the declaration and never checked; AccountsByKind is one, since number is Account’s identity.

count

count — fills count

Reduces the eligible collection’s own (already where-filtered) rows to a single Integer — how many match, not which ones. A bare word, no argument: its presence in the read model IS the value. Held to the same “exactly one many-side head” rule group_by/where/etc already are (ReadModelBuilder#seal_aggregation), and refused together with group_by or with median — a read model reports one shape. See Banking::DisputedPaymentCount for the real corpus example.

Two disputed charges on this account, and the answer is the number rather than the rows:

counted = runtime.query("Banking.DisputedPaymentCount", account: "rm-a1").first
counted[:card_payments]  # => 2

The reduction rides the report’s own where — only disputed charges were counted, and nothing at the call site said so.

median

median median_field — fills median_field

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | median_field |

Reduces the eligible collection’s own (already where-filtered) rows to the median of one numeric field — a bare number, or a value object carrying one (the same “numeric” where/order_by already lean on). An ODD number of rows answers the one true middle value ; an EVEN number answers the AVERAGE of the two middle values, sorted (not the lower or the upper of the two). An empty collection answers nil, not zero — “nothing to average” is a different fact from “the values averaged to zero.” Refused at DISPATCH time if the named field doesn’t exist, or exists but isn’t numeric — same timing as group_by’s own field check, for the same reason (the aggregate this read model targets isn’t known until then). Same seal_aggregation rule count carries: exactly one many-side head, never combined with group_by or with count. See Banking::DisputedPaymentMedian for the real corpus example.

The same two charges — 4,200 and 900 — reduced to the middle of them rather than counted:

runtime.query("Banking.DisputedPaymentMedian", account: "rm-a1").first[:card_payments]  # => 2550.0

An even number of values has no single middle, so the two nearest are averaged — which is why this answers a Float where count answers an Integer.

sum

sum sum_field — fills sum_field

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | sum_field |

Reduces the eligible collection’s own (already where-filtered) rows to the total of one Integer field — never Float; summing floats cannot be made to agree, byte for byte, between Ruby and Rust, so sum refuses a Float field at dispatch time (ADR 0078). An empty collection answers 0, not nil — “no disputes” means zero exposure, where median has no answer to give. Same seal_aggregation rule median carries: exactly one many-side head, never combined with group_by or with another reduction. See Banking::DisputedPaymentTotal for the real corpus example.

The same two disputed charges — 4,200 and 900 — added together instead of counted or averaged:

runtime.query("Banking.DisputedPaymentTotal", account: "rm-a1").first[:card_payments]  # => 5100

avg

avg avg_field — fills avg_field

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | avg_field |

Reduces the eligible collection’s own (already where-filtered) rows to the mean of one Integer field — sum / count, computed as a plain f64 division; both operands are exact integers within 2^53 (sum’s own overflow refusal), so IEEE 754’s correctly-rounded division agrees bit for bit between Ruby and Rust (ADR 0078). An empty collection answers nil, not zero — a rate of nothing is undefined. Same seal_aggregation rule every other reduction carries.

The same two charges averaged rather than summed:

runtime.query("Banking.DisputedPaymentAverage", account: "rm-a1").first[:card_payments]  # => 2550.0

min

min min_field — fills min_field

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | min_field |

Reduces the eligible collection’s own (already where-filtered) rows to the smallest value of one numeric field — Integer or Float alike, since comparing (unlike summing) never loses precision between Ruby and Rust. An empty collection answers nil. Same seal_aggregation rule every other reduction carries.

The smaller of the two disputed charges:

runtime.query("Banking.DisputedPaymentSmallest", account: "rm-a1").first[:card_payments]  # => 900

max

max max_field — fills max_field

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | max_field |

min’s own sibling: the largest value of the same numeric field, Integer or Float alike. An empty collection answers nil. Same seal_aggregation rule every other reduction carries.

The larger of the two disputed charges:

runtime.query("Banking.DisputedPaymentLargest", account: "rm-a1").first[:card_payments]  # => 4200

percentile

percentile percentile_field, at: — fills percentile_field

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | percentile_field | | at: | number | true | percentile_at |

Reduces the eligible collection’s own (already where-filtered) rows to the value at one interpolated rank, at: from 0.0 (the smallest) through 1.0 (the largest) — linear interpolation between the two closest ranks, matching SQL’s PERCENTILE_CONT. median is the fixed at: 0.5 case, sharing this exact fold so the two cannot silently diverge (ADR 0078). An empty collection answers nil. Same seal_aggregation rule every other reduction carries.

rm-a2 carries seven disputed charges — 4,200, 900, 500, 400, 300, 200 and 100 — so the 95th percentile lands between the two largest rather than collapsing to the median:

runtime.query("Banking.DisputedPaymentP95", account: "rm-a2").first[:card_payments].round(2)  # => 3210.0

Sorted, the seven values are 100, 200, 300, 400, 500, 900, 4200; rank 0.95 * 6 = 5.7 sits seven tenths of the way from the sixth value (900) to the seventh (4200).

any

any any_field — fills any_field

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | any_field |

Reduces the eligible collection’s own (already where-filtered) rows to whether ANY row’s declared boolean field is true — a bare question needing a true/false answer, not a number a caller must remember to compare to zero (count > 0 already answers this; any is the boolean-shaped restatement). An empty collection answers false — the ordinary OR-identity vacuous-truth reading (ADR 0078). No aggregate in examples/banking declares a bare boolean field yet, so this page’s own Parcel.fragile demonstrates it instead of a real corpus site.

Depot dp-1 holds one fragile parcel among four:

runtime.query("ReadModelReference.DepotHasFragileParcel", depot: "dp-1").first[:parcels]  # => true

all

all all_field — fills all_field

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | all_field |

any’s own sibling: whether EVERY row’s declared boolean field is true. An empty collection answers true — the ordinary AND-identity vacuous-truth reading (ADR 0078), the mirror image of any’s false.

Depot dp-1’s four parcels are not all fragile — only the first is:

runtime.query("ReadModelReference.DepotAllParcelsFragile", depot: "dp-1").first[:parcels]  # => false

where

where pairs, on: — fills options

| argument | kind | required | fills | |—|—|—|—| | positional 1 | pairs | true | | | on: | constant | false | target |

Same eight comparators as a query’s where (eq, ne, gt, gte, lt, lte, in, contains), including the same real-membership-vs- substring split on contains (see query.md’s where). Applied for real (ReadModelInterpreter#project and SqliteProjection#query_read_model both run Ports::Query::InMemory.execute against it) — but only against ONE collection: ReadModelBuilder#seal_query_options refuses at build unless the read model includes exactly one many-side aggregate, since where/order_by/limit/offset/authorize’s tenant all have to mean the same collection or naming which one is ambiguous. The “one” side (the reference target itself) is never filtered — a single row has nothing to filter.

ComplianceDashboard declares where(status: "disputed"), and it confines itself to the one many-side head — the account it is rooted on comes back whatever its own status:

dashboard = runtime.query("Banking.ComplianceDashboard", account: "rm-a2").first
dashboard[:card_payments].map { |row| row[:status] }  # => ["disputed", "disputed"]
dashboard[:account][:status]  # => "open"

order_by

order_by field, direction, on: — fills options

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | field | | positional 2 | symbol | false | direction | | on: | constant | false | target |

Same shape as a query’s order_by, applied to the same one many-side collection where is (see where). Without it, that collection still comes back in a stable order (record id) — not because ordering is optional, but because the underlying fetch has to answer in SOME order, and id is the fallback every engine agrees on.

ComplianceDashboard orders its disputes by amount, largest first — which is what makes dashboard (above) the SECOND page rather than an arbitrary two: the five biggest (4200, 900, 500, 400, 300) are the ones offset 5 skips, and 200/100 are what’s left:

dashboard[:card_payments].map { |row| row[:amount][:cents] }  # => [200, 100]

authorize

authorize policy, tenant: — fills options

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | policy | | tenant: | symbol | false | tenant |

Declares a policy name (recorded, never checked — no caller-identity or grant system exists to check it against) and, when tenant: is given, a mandatory tenant boundary that IS enforced: a caller must pass that field as an argument or the ask refuses with Unauthorized (Runtime::TenantScope), and every returned row is scoped to the value given, regardless of what other filters were declared. tenant: must name the same collection where/order_by/limit/offset would (ReadModelBuilder#seal_query_options holds it to the same “exactly one many-side head” rule).

DepotManifest declares authorize :depot_access, tenant: :region, so naming the depot is not enough — an ask that does not say which region it is scoped to is refused:

runtime.query("ReadModelReference.DepotManifest", depot: "dp-1")  # ~> Unauthorized: pass region:

Every ask further up this page passed one, which is why they answered at all. The policy name beside it is the half nothing checks:

runtime.registry.bluebook("ReadModelReference").read_model("DepotManifest").authorization.policy  # => "depot_access"

inspect_query

inspect_query mode — fills options

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | false | mode |

Asks to inspect the compiled query. On the aggregate-query path this is a capability gate through Ports::Query.validate!; the read model runtime never reaches that code at all, so declaring it here has no effect, refusal or otherwise.

DepotManifest declares it, and every ask above answered ordinary rows regardless — no inspection came back, and nothing refused either:

runtime.registry.bluebook("ReadModelReference").read_model("DepotManifest").inspection.mode  # => :sql

Written exemption (ADR 0025 principle 4) — the sentence above is the reason: the read model runtime never reaches the code this word gates at all, so a real corpus declaration would be strictly inert — even less than Query’s own version, which is at least a live capability gate against Ports::Query.validate!.

limit

limit value, on: — fills options

| argument | kind | required | fills | |—|—|—|—| | positional 1 | number | true | value | | on: | constant | false | target |

Same shape as a query’s limit, applied to the same one many-side collection where is (see where).

DepotManifest declares limit 2 over four parcels, and only the many side is capped — the depot itself is still one whole record:

manifest = runtime.query("ReadModelReference.DepotManifest", depot: "dp-1", region: { value: "north" }).first
manifest[:parcels].size  # => 2
manifest[:depot][:code][:value]  # => "dp-1"

offset

offset value, on: — fills options

| argument | kind | required | fills | |—|—|—|—| | positional 1 | number | true | value | | on: | constant | false | target |

Same shape as a query’s offset, applied to the same one many-side collection where is (see where).

DepotManifest orders four parcels heaviest first, skips one, and takes two — so the heaviest is deliberately not in the answer:

manifest[:parcels].map { |row| row[:label][:value] }  # => ["p-2", "p-3"]

Skip-then-take, the same reading SQL gives LIMIT n OFFSET m. Taking first and skipping after would have answered a single parcel here, and nothing at all one page further on.

Real, not only synthetic — banking’s own ComplianceDashboard declares it for the same reason: the sixth-through-tenth worst disputes, a genuine second page once the first five are reviewed:

runtime.registry.bluebook("Banking").read_model("ComplianceDashboard").offset.value  # => 5

cursor

cursor value — fills options

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | value |

Refused at build (ReadModelBuilder#seal_cursor, raises Malformed). No interpreter implements cursor pagination — declaring cursor here is always an error, not a silent no-op. Use limit/offset instead.

There is no working example to write, and that is the documentation — the word refuses where it is written:

Hecks::Bluebook::DSL::ReadModelBuilder.build("Paged") { include ReadModelReference::Parcel; cursor :label }  # ~> Malformed: no interpreter implements cursor pagination

Written exemption (ADR 0025 principle 4) — same reasoning as Query’s own cursor section: a word that refuses unconditionally at build has no corpus use to give, and S15 removes it from the core grammar regardless.

nulls

nulls mode — fills options

| argument | kind | required | fills | |—|—|—|—| | positional 1 | symbol | true | mode |

Sets how nulls sort relative to real values, for the same one many-side collection order_by sorts (see order_by). Same reading on every engine as a query’s nulls.

weight is optional on a Parcel, so one of the four has none. nulls :last puts it after every real weight rather than wherever the store would have left it — which is what keeps the offset above meaning the same thing twice running:

runtime.registry.bluebook("ReadModelReference").read_model("DepotManifest").null_semantics.mode  # => :last

Written exemption (ADR 0025 principle 4) — the one real read model here shaped for where/order_by/limit/offset at all (ComplianceDashboard, above) orders by CardPayment#amount, which is not optional; no read model in this corpus has an ordered single- collection view whose ordering field can genuinely be absent, so there is nothing real to demonstrate nulls sorting against yet.