Criteria — reusable predicate specifications
A criterion is a named, parameterised, pure boolean predicate over
a candidate type — the Specification Pattern (Eric Evans's
isSatisfiedBy, Spring Data's Specification<T>) made first-class in
the DSL. It names a cross-aggregate domain rule ("active customer",
"order in region", "may force-close") once, so the rule lives in one
place instead of being inlined and duplicated across where clauses and
guards.
Status. This ships the core of
docs/old/proposals/criterion.md: thecriteriondeclaration, body validation, and inline use in every existing boolean-expression position. A criterion referenced by aretrieval's or afind'swhereadditionally reifies — it emits a named, constructed predicate object on every backend rather than dissolving at compile time (see "Reification" below).when <predicate>operation guards with their auto-exposed side-effect-freeGET /<plural>/{id}/can_<op>endpoints are shipped on all five backends — node / .NET / python / elixir / java (a false gate → 409 Disallowed; the companion returns{ allowed }).Repo.findAll(<Criterion>, page?)from workflow bodies is shipped on every backend — it desugars to a syntheticfindAllBy<Criterion>retrieval (the enrich pass materialises it from the context's criteria), so it rides the existing retrieval pipeline.Repo.runaccepts a criterion too (read-path-architecture.md, "runtakes a criterion") —Repo.run(<Criterion>)/Repo.run(<Criterion>(args), page?)is first-class alongsideRepo.run(<Retrieval>), riding the identicalfindAllBy<Criterion>desugar asfindAll. A criterion and a retrieval may not share a name in one context (loom.criterion-retrieval-name-clash, an error), sorun(<Name>)always means exactly one of them. Ordering / fetch-shaping is expressed by an anonymous retrieval —Repo.run(retrieval { where: <Criterion> sort: […] loads: […] }, page?)— the call-site twin of a declaredretrieval;findAlland bare-criterionrunstay the un-shaped spellings, shaping lives on the (named or anonymous) retrieval. Itswhere:is a criterion reference in this release. A listfindon a repository is removed (loom.list-find-removed, an error): afind byX(...): T[]/T pagedis a bespoke list finder — pass a criterion torun(Repo.run(<Criterion>(args))) or name aretrievalinstead of accreting a method per query, and expose it withscaffoldPaged(of: …)+scaffoldPagedApi(of: …)when a client must call it.node scripts/codemod-list-find-to-criterion.mjs <path>rewrites the old form (see D-LIST-FIND-REMOVED). A unique-key reconstitution find (returning a singleT/T?by identity) is not a list query and stays legal, and so doesfind all(…), the override of the repository's list endpoint. Over a declaredunique (…)key it needs no declaration at all —Repo.getBy(<col>: v)/Repo.findBy(…)read it (D-REPO-KEY-LOOKUP;node scripts/codemod-find-to-findby.mjsmigrates the finds). A key that is not unique is a query: a criterion, read withRepo.find(<Criterion>)— the same codemod writescriterion <Plural><Find>for such a find and points its call sites atfind(C). The single-resultRepo.find(<Criterion>)is shipped on every backend as the source of anif letworkflow statement —if let x = Repo.find(<Criterion>) { … } else { … }— which binds the first match (non-null) in the then-branch and runselseon no match, and bound by a plainlet(let c = Customers.find(ByEmail(e))bindsCustomer?). Both ride the same sharedfindAllBy<Criterion>retrieval asfindAll, capped at one row, so no public endpoint leaks. A composed anonymouswhere:and thefrom <Criterion>(args)parameter binding are not yet shipped.
Declaration
context Sales {
enum OrderStatus { Draft, Confirmed, Closed }
aggregate Customer { active: bool, region: string }
aggregate Order { status: OrderStatus }
repository Customers for Customer { }
repository Orders for Order { }
// Single-line form. `of Customer` names the candidate; inside the
// body, bare field names (and `this`) resolve against the candidate
// aggregate — the same convention as `invariant` / `derived` bodies.
criterion ActiveCustomer of Customer = active == true
// Parameterised. (The parameter is named distinctly from the `region`
// field it compares against — a parameter shadows a same-named field.)
criterion InRegion(rgn: string) of Customer = region == rgn
// Comparison against an enum value.
criterion CanForceClose of Order = status != Closed
// `of bool` — a pure ambient predicate with no candidate (reads only
// parameters + ambient context such as `currentUser`).
criterion HasManagerRole of bool = currentUser.role == "manager"
}
Grammar:
criterion <Name>(<Param>*) of <T> [as <alias>] = <bool expression>
criterion <Name>(<Param>*) of <T> = <bool expression>
<T> (the candidate) must be an aggregate or bool in this
release.
of T as <alias> — an optional candidate binder
The candidate is this / bare fields by default. of T as o additionally
binds the author's chosen name o to that same candidate, so o.region
reads exactly like this.region (same aggregate, SQL-queryable — byte-
identical lowering). This is the one as-binding convention shared with
from … as o / join … as c (read-path-architecture.md, "Aligned with
criterion"); it is not a fixed self receiver — the alias is
author-chosen and optional, and every existing criterion keeps parsing.
criterion InRegion(rgn: string) of Order as o = o.region == rgn // ≡ region == rgn
The alias must not collide with a parameter name (loom.criterion-alias-collision).
Composition
Criteria compose with the ordinary boolean operators — the result is just another boolean expression:
criterion EligibleEuCustomer of Customer = ActiveCustomer && InRegion("EU")
There is no separate composition machinery: && / || / ! over
criterion references are the composition.
Use sites
A criterion reference is inlined at compile time wherever a boolean expression is expected. Today that means:
// A criterion body (bare reference):
criterion CustomersActive of Customer = ActiveCustomer
// A criterion body (composed + parameterised):
criterion CustomersActiveInRegion(r: string) of Customer = ActiveCustomer && InRegion(r)
// A single-row find filter:
repository Customers for Customer {
find activeByEmail(e: string): Customer? where ActiveCustomer && this.email == e
}
// Invariants and operation preconditions inline criteria the same way.
Because the reference is inlined — the predicate body is re-lowered with
its parameters substituted and the candidate rebound to the host
receiver — a criterion-driven where produces exactly the same
lowered expression (and therefore the same generated SQL) as the
equivalent hand-written inline filter. No backend query-engine change is
involved; criteria ride the existing where→SQL path on every backend.
Reification (retrieval and find criteria)
There are positions where a criterion is not dissolved into its host:
when a retrieval's where, or a repository
find's where, is exactly a named criterion reference. There the
criterion reifies — the backend emits a named, constructed predicate
object (the Specification pattern made real in generated code) that the
query method consumes, the functional analog of inlining lifted into a
reusable object:
criterion NamedLike(needle: string) of Customer = this.name == needle
retrieval ByName(needle: string) of Customer { where: NamedLike(needle) sort: [name asc] }
repository Customers for Customer { }
// … Customers.run(NamedLike(needle)) and Customers.run(ByName(needle)) share it
| Backend | What the criterion reifies to |
|---|---|
| .NET / EF | a Criterion<Customer> (with IsSatisfiedBy + a query-side ToExpression()), fed into the retrieval's Ardalis Specification<Customer> bundle (and into a find's .Where(crit.ToExpression())) |
| .NET / Dapper | a parameterised SQL WHERE fragment inlined into the retrieval's Run<Name>Async / the find method (Dapper emits SQL, not a reified object) |
| Hono / Drizzle | a module-level predicate function const namedLikeCriterion = (needle) => eq(schema.customers.name, needle), called by run<Name> and the matching find |
| Phoenix / Ecto | a shared query fragment def named_like_criterion(needle), do: dynamic([c], c.name == ^needle) the read filters by |
| Java / JPA | a Specification<T> factory on <Agg>Criteria, consumed via JpaSpecificationExecutor (the first backend to consume CriterionIR directly) |
| Python / SQLAlchemy | inlines the predicate at the call site (non-reifying) |
A criterion shared by a retrieval and a find reifies to a single predicate object (one module-level fn / one Ecto query fragment), consumed by both.
The emitted predicate is byte-identical to what inlining would
produce — reification is a code-organisation choice, not a behavioural
one, so cross-backend conformance/wire parity is unchanged. The rule for
when a criterion reifies is simply "if it has a name." A where
that is exactly one named criterion reifies; a composed one
(Active && InRegion(r)) or an anonymous boolean expression (most
invariants, preconditions, capability filters) has nothing to reify and
stays inline. See
docs/old/proposals/reified-criteria.md for
the full design and the remaining-work register (the anonymous filter
capability predicates and the principal/tenancy factory are the parts
that still inline).
Validation
| Diagnostic | When |
|---|---|
loom.criterion-unsupported-target |
Candidate type is neither an aggregate nor bool (e.g. of decimal — reserved for the future from-binding surface). |
loom.criterion-impure |
The body calls a mutating operation. Criteria are pure — call a pure function instead. |
loom.criterion-cycle |
A criterion (transitively) references itself. |
loom.criterion-arity |
A criterion call supplies the wrong number of arguments. |
loom.criterion-retrieval-name-clash |
A retrieval has the same name as a criterion in its context — run(<Name>), scaffoldPaged(of: <Name>) and where: <Name> would each have to pick one silently. Rename one. |
loom.call-arg-type |
A criterion call supplies an argument whose type isn't assignable to the declared parameter (the shared call-argument type check; arity stays loom.criterion-arity). Checked at the env-bearing call sites (body precondition / requires, derived, …). |
The body is an ordinary expression, so mutation (:=, +=), emit,
and workflow calls are already excluded by the grammar.
Advisories — the compiler steers you toward criteria
One error and one warning steer you toward the criterion / retrieval / index model:
| Code | Fires when | Steers you to |
|---|---|---|
loom.list-find-removed (error) |
A repository declares a find returning a collection (: T[] / T paged) other than find all — a bespoke list finder accreting on the repo. |
Pass a criterion to run (Repo.run(<Criterion>(args))) or name a retrieval instead; put it on the wire with scaffoldPaged(of: <it>) on the context and scaffoldPagedApi(of: <it>) on the api. node scripts/codemod-list-find-to-criterion.mjs <path> rewrites a source. A unique-key reconstitution find returning a single T / T? stays fine. |
loom.index-suggestion (warning) |
A field is read on a query filter — a find … where, a reified filter, a criterion body or a retrieval's where — but has no index. |
Add index: <Agg>.<field> on the aggregate's kind: state resource — the column then shows up in the migration. |
The index suggestion follows the migration: a
find … whererewritten as acriterion+retrievalkeeps earning the sameloom.index-suggestion.
The intent is "pit of success": the compiler notices a performance smell in a DDD model — an unusual and welcome nudge — and points at the idiomatic fix before it becomes a slow query in production.
See also
docs/old/proposals/criterion.md— the full design, including the deferredfindAll/when/fromsurfaces.docs/old/proposals/reified-criteria.md— the Specification-reification design (shipped for retrieval criteria; the remaining-work register forfind/ capability-filter reification).docs/workflow.md— repository finds.