hecks

The DSL names one idea one way, and a word earns its place by being used

Status: Accepted — implemented. Every slice of the work plan (S0a-S13) is DONE as of 2026-08-28; docs/dsl-work-slices.md re-cuts the plan into those slices and holds the per-slice record, checked against the code, and docs/1.0-readiness.md records the gate this decision blocked. Two corrections to this text stand (inline below): has_many/has_one/belongs_to were kept, not deleted, and then_set→sets landed. Trust the slice table over this document’s prose — this document records the decision, not a live status feed.

Context

The language grew by accretion, and accretion has a signature: the same idea acquires a second spelling, a word outlives the reason it was added, and a rename lands on one side of a boundary but not the other. None of these announce themselves. Each one was a reasonable local decision, and the sum is a language that says the same thing several ways and says some things that aren’t true.

A word-by-word review of the live surface — every reference page, checked against syntax.bluebook, the builders, the runtime, and the corpus — found five recurring shapes:

One idea, several spellings. identified_by had three (bare field, ValueObject, as:, and a block). attribute’s type position had three (bare constant, omitted-defaults-to-String, quoted text). sets/then_set and report/read_model each had two, from renames applied on one side only. from: was a pure synonym for to: that the language’s own refusal message had already forgotten. Four relationship words — reference_to, has_many, has_one, belongs_to — compiled to one mechanism, and the reference page said so out loud: “THREE WORDS, THREE FIELD NAMES, ONE MECHANISM.”

Words that read as configuration and configure nothing. consistency, freshness, and use_index are parsed, held in the IR, and read at runtime by exactly one thing — the HTML form renderer, which prints them as badges. No adapter honours any of them. Both corpus uses carry comments explaining behaviour that does not happen: “Read against yesterday’s close rather than blocking on whatever posted a second ago” sits above a query that blocks exactly as before.

Magic-string agreements the language never checks. emits "X" and a policy’s on "X" are two free strings with no check between them, in two different spellings (emits bare, policy on qualified by aggregate, process-manager on bare again). A policy listening for an event nobody emits loads cleanly, deploys, and silently never runs — verified by probe. The same is true of the 37 command-name strings in trigger/dispatch. This is not hypothetical: "Account.Freeze" survives in frozen era text where the live bluebook says "Account.FreezeAccount". That policy was dead, shipped, and minted into an era; the reference-doctest work caught it, not the language.

Prose that turns out to be load-bearing. role and goal sit on adjacent lines in every command and read as a matched documentary pair. goal is documentation. role gates access control by exact string equality against Caller.current.role — eight distinct free-text values across 103 commands, no closed set, so role "Branch Clerk" with a capital C silently locks out every caller. Meanwhile the framework ships Governance::RoleAssignment, a real RBAC model with actors, scopes, and time bounds — connected to none of it at the time this paragraph was written. Update, verified live 2026-08-28: this specific gap is closed — see the role becomes real RBAC bullet under Commands below.

One vocabulary declared twice, already drifted. The thirteen reading words shared by query and read_model are declared separately per context in syntax.bluebook, and the copies disagree: where fills wheres in one and options in the other; order_by fills order_field/order_way versus field/direction; limit fills limit versus value. The same shape recurs between AggregateBuilder and EntityBuilder, which each implement identified_by and its pending-identity resolution, and between the two state machines — lifecycle/transition on aggregates, state/transition: on process managers — which express trigger, from, and to in entirely different grammars.

Decision

Four principles, and the decisions that follow from them.

Principles

  1. One idea, one spelling. Where two texts mean the same thing, the redundant one is refused, not merely discouraged. This is why identified_by :"number.value" is refused when identified_by :number already names it, and why sets :number, to: :number is refused when sets :number does.
  2. The DSL names a field where it could name a type. Type names appear where a type is being declared, not where a field is being referred to.
  3. A word that reads as behaviour must be behaviour. A word the runtime does not honour is a false statement in the model, and worse than a missing feature — a reader cannot tell it apart from one that works.
  4. A word earns its place by being used. A word stays if it has a real corpus use and a running doctest. An external consumer outside this repo is a valid exemption from the corpus bar, written down and naming the consumer, never assumed.

Identity

References

Attributes

Reads

Commands

Events and reactions

Aggregates and entities

Rules — invariants, preconditions, postconditions

Consistency across aggregate boundaries

The costs of this decision, recorded rather than argued away. Cross-aggregate reads are currently consistent, because dispatch runs a command’s mutation, emission, policy reaction, and saga advance synchronously in one call. Replacing them with projections makes those checks eventually consistent, so given("customer is active") can pass for a customer suspended moments earlier. The framework also does not avoid locks: every append takes pg_advisory_xact_lock(hashtext('hecks_ordinal:' || domain)) for the whole transaction — a domain-wide write lock, coarser than row locks — so each maintaining reaction is another write through that same serialisation point. What is avoided is read locks: the dereference happens before the transaction opens. This decision therefore trades consistent reads for more writes through a domain-wide lock, deliberately, in exchange for aggregates whose rules are answerable inside their own boundaries.

Added attributes and absence

Adding an attribute to an aggregate is free today — EraGuard reads only the held shape, so nothing demands a translation — and a record written before the field existed simply lacks it. That is safe in three of four cases, and silently wrong in the fourth:

new attribute on an existing record  
has default: filled safe
is list_of(...) frozen [] safe
a value object whose fields all have defaults built from {} safe
anything else absent unsafe

The rule catches what can be predicted at declaration; the refusal catches what it cannot — state written outside the framework, or a record restored from a backup taken before the field existed.

Mechanism

Coverage standard

A word stays if it has a real corpus use and a running doctest. An external consumer is a written exemption from the corpus bar, naming the consumer.

The doctest bar alone is what let the inert words through — consistency had a running example demonstrating it being declared, which is precisely the thing not in question.

A domain is more than its .bluebook: ports are declared in .hecksagon and realms in .world, so the corpus bar has to be measured across all three file types. Counted that way, port (4), operation (1), asks (2), answers (3), refuses (6), world (3) and realm (3) all pass — pizzas’ PaymentGateway.Receive exercises the port vocabulary for real. Eleven words fail as of this writing (S13, below, closed the gap — see docs/dsl-work-slices.md’s S13 row for current status, live-verified rather than trusted, per this document’s own header):

group words remedy
paging and query options offset cursor nulls inspect_query corpus use
port vocabulary not reached tells verb corpus use, or exemption
worlds latest corpus use, or exemption
hecksagon subscribe corpus use, or exemption
classification generic corpus use
rename formerly_known_as exemption — used on a client domain’s rename

All eleven are implemented, unlike the three inert words. This is under-exercise, not vaporware.

domain_port is a further instance of the report/read_model split: the DSL word is port, declared on an aggregate inside a hecksagon, while the construct, the reference page, and the IR all say domain_port. It should be resolved the same way — one name, and the corpus already votes for port.

A related instance, out of this ADR’s own scope, worth naming so it isn’t lost. This review is scoped to the DSL surface a domain author writes — .bluebook/.hecksagon/.world — not the Ruby codebase’s own internal vocabulary. But the identical anti-pattern this ADR exists to fix (one word, several unrelated meanings) is live in that internal vocabulary too: “projection” names six distinct things across the codebase — three related-but-formally-distinct senses documented in lib/hecks/projector.rb’s own opening taxonomy (a description-only projection, an export that carries a running system, and a state projection/read-model), plus Ports::Projection (read-model catch-up), bin/project (forces that catch-up), and RustProjection (a separate toolchain under a confusingly identical module name). projector.rb’s header already documents the collision; nothing yet renames any side of it. Not itemized above because none of these six are spelled by a domain author — but the same coverage standard this ADR applies to the DSL (one name per idea, remedy named per surviving word) is the right standard to eventually hold this to as well, likely as its own follow-on pass rather than folded into the DSL migration this ADR sequences.

Consequences

Sequenced work plan

One ADR, then one commit per concept, each moving grammar, runtime, corpus, and docs together and green at every step. Layer-by-layer sequencing (all grammar, then all runtime) was rejected: it leaves the tree red between layers and makes bisect useless, which defeats the pre-push doctest gate.

# commit depends on notes
0 shadow_parse legacy grammar — prerequisite for every removal
1 scoped-constant bridge — unlocks 7, 8, and admits:
2 identity 0 includes the meta-validator fix for bare-scalar heads
3 references — no _id, / hop operator, handle accessors, delete has_*, acyclic rule 0 largest corpus churn: 243 reference_to sites, every dispatch call, every hop path
4 attributes — type position, closed sets 0 source-only; no stored shape moves
5 reads — read_model revert, shared reading vocabulary, delete the inert three 0 shared-context concept added to syntax.bluebook
6 commands — sets rename, omittable to:, delete from:, else raise 0 143 + 35 call sites, mechanical
7 events first-class with payloads 0, 1 largest design change; touches emits, on, trigger, with:, saga dispatch
8 reactions — one state-machine vocabulary, first-class command references 0, 1, 7 five process managers rewrite
9 role → Governance RBAC 0 security-affecting; add the new check before removing the old
10 entity/aggregate shared vocabulary 0 also fixes the hop_path.rb:191 assumption
11 rules — aggregate invariant, lifecycle state guards, named preconditions 0, 10 removes 35 "account is open" givens and dedups the rest
12 added-attribute absence — EraGuard demands a translation where absence is possible; named refusal as the backstop 0 independent of projections; closes a hole that already exists
13 projects, and rules confined to their own boundary 0, 7, 8, 11, 12 deletes dereference; 49 givens migrate; carries its own adapter-agnostic rebuild and ProjectionAbsent — no existing backfill path to inherit
14 coverage standard — corpus uses and written exemptions for the eighteen all closes principle 4

Rejected alternatives