hecks

Commands declare the outside facts they need, and a rule across records gets an aggregate that owns it

Status: Partly built (section 1, for now and today on commands and queries; section 2 and port-answered facts are not). Date: 2026-09-28. Builds on item 5 of docs/HECKS_IMPLEMENTATION_PLAN.md (identity generation and replay) and stage 2 of its execution pipeline, “Runtime enrichment (UUIDs, clock, caller, external facts)”. Unblocks two QualityControl rules in ADR 0080. Ships in a 3.x minor after 3.0: every change here is additive, so it needs no major version. Section 1 is built for needs :now on commands, as the first slice (see “What is built”); the rest below is not.

Context

Business rules that belong in bluebooks still live in adapter code. An audit of the adapters in this repository and in the domains that consume it sorted them three ways:

The first kind needs a fact from outside the aggregate at the moment of the command:

Because the language cannot say “now”, the same lifetime has drifted between engines: one domain’s unsubscribe links last 365 days in its Ruby host and 730 days in its Rust host.

The second kind is a rule across many records: a seat cap counted over registrations, or a per-day cap counted over opened patches. One consuming domain enforces its seat cap in four places, two host implementations, the site and a status table, and its bluebook says the seat rule “lives in the site’s and the host’s seat rule, not in this aggregate”.

Parts of the machinery already exist:

Decision

1. A command declares the facts it needs; the runtime supplies them

A command names each outside fact it depends on and the port that answers it. At dispatch, before any given runs, the runtime asks the port and writes the answer into the command’s arguments. From there it is an ordinary argument: givens read it, and the event records it. Replay re-dispatches the recorded arguments and never asks again, exactly as with minted ids.

A sketch, not settled grammar:

command "Open" do
  needs :now                                         # from the clock port
  needs :fix_on_head, from: Git.ancestor?(fix_commit, "HEAD")

  given("the fix is on HEAD") { fix_on_head }
end

2. A rule across records gets an aggregate that owns it

There is no given that counts records. A rule that must hold across many records says that something owns those records, and that owner becomes an aggregate:

One aggregate saves atomically, so two requests cannot both take the last seat. A count-based given would need the store to run the count inside the save’s transaction to get the same guarantee, and it would hide the missing aggregate.

Consequences

Alternatives considered

What is built

The first slice, needs :now on commands, in Ruby and Rust:

Open items