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.
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:
bin/qa_open_pr:120), release preflight checks (lib/hecks/release/runner/preflight.rb:53-95), and settlement guards in a mock ACH adapter (mock_ach_settlement_adapter.rb:28-32). These need moving, not new language.gh and git calls, webhook signature checks, token cryptography. These belong in adapters.The first kind needs a fact from outside the aggregate at the moment of the command:
HEAD” (bin/qa_open_pr:126), a payment’s customer check (the payments bluebook says “the expression sublanguage cannot call a port at all”), or a release tag not already pointing elsewhere (tagger.rb:22).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:
clock port with its SystemClock adapter (lib/hecks/adapters/driven/system_clock.adapter).Event#occurred_at works the same way.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
given never calls a port; it reads a value the runtime gathered beforehand. Givens stay pure, and a command’s outcome is a function of its recorded arguments.now is the common case. It comes from the clock port, so tests bind a fixed clock and both engines read the same declared rule. A lifetime written once in the bluebook, as issued_at + days(730) > now, cannot drift between Ruby and Rust.days, hours, comparison, addition), since today they admit only + and .modulo for arithmetic.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:
Event (or a Seating aggregate) holds seats_taken. A registration first takes a seat with ReserveSeat, and cancelling one releases it. The cap is an ordinary given on one aggregate.DailyQuota aggregate, identified by date, from which each PR takes a slot. The date comes from today (section 1).
The QualityControl ledger declares it: Open needs today, Take refuses once the day’s cap is spent (cap is the PR_CAP_PER_DAY dial), and the query Today needs today to find the day’s record. hecks quality_control patch.open dry-runs Take before it opens a PR and takes the slot once the PR is recorded. The day is the UTC day, not the local one.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.
DailyQuota, and the ancestry check as a declared fact the GitPr adapter answers.count(...) expression in givens. Rejected: it races with concurrent saves unless the store counts inside the save’s transaction, and it leaves the owning aggregate unnamed.The first slice, needs :now on commands, in Ruby and Rust:
needs. A command writes needs :now and declares the attribute it fills (attribute :now, Instant); the builder refuses a fact other than now, a repeated one, and a need with no attribute to fill. The Command IR carries "needs": [{"fact": "now"}] (an empty list on a command with none).decode_arguments step, before any argument refusal or given reads the arguments, so the dispatch-order vocabulary did not change. The strict with: envelope no longer counts a needed fact as absent.Ports::Clock.now already answers that. “Since midnight” needs a zone and stays a separate, later fact.days(730), hours(2) and minutes(15), written on a whole-number literal, become seconds when a predicate is stored (issued_at + days(730) > now is issued_at + 63072000 > now), by a new scale_call normalisation strategy. Both engines read one table of cases (spec/fixtures/canonical_form_cases.json). No timestamp type was added.now argument; QualityControl’s six commands and the lease_clock stress domain’s four declare needs :now instead.needs list in the IR, behind a small clock seam. It also passes the kernel a table of which commands need which facts, so a command a policy or saga triggers is answered the same way. Commands declared on entities are filled by Ruby but not yet by the Rust host or kernel, and a kernel-run binary relies on the host passing the table: generation would have to emit it for the kernel to fill on its own.needs :now and declares the attribute it fills, with the same refusals as a command (a fact the runtime cannot supply, a repeat, a need with no attribute). The Query IR carries "needs" only on a query that declares one, so no existing IR changed. Ruby fills in the query interpreter before the arguments are checked, the Rust host before it forwards the query, and the kernel from a query_needs table (kept apart from the commands’: a command and a query may share a qualified name). The query log echoes the arguments the caller offered, since no event records the answer. lease_clock’s Expired declares needs :now; a conformance fixture asks it with no now and both engines answer from their own clock.today is the other fact. Whole days since the epoch in UTC, the day the clock’s reading falls in, for a rule counted by the day. It is the zone-free answer to “since midnight”; a local zone remains a separate, later fact.needs :fix_on_head, from: ...) are not built, and a replay refuses them. The seat and daily-cap aggregates (section 2) are not built.needs, and how a replay answers one (the recorded value, or a refusal).today answers the UTC day only.sets that compute a value or clear a fieldwith: