The constraints program — this vocabulary, its evaluator, the provider seam it reads, and the Ruby surface for writing laws as sentences — was contributed by Muhamed Isabegović, who also maintains the Ruby and Rails integration gems enola-labs endorses.
INTENT.md states what an architecture is meant to look like;
constraints state what it is not allowed to do, and the
constraints explainer verdicts them against the measured graph on
every snapshot. Two sections carry the whole vocabulary: components
name sets of measured facts, rules state enforcement over them.
Constraints are file-level only: they live in enola-intent.yaml
or under enola/constraints/ (or a cluster config's intent: entry,
which overrides the repo's files wholesale) — never on a page. A page
carrying components: or rules: is a validation error, not a
merge: constraints are the repo's declared desired architecture,
reviewed beside the code they govern, and a decision page references
rule ids rather than carrying the rules themselves.
Some of what a rule can select is measured by a provider rather than an
extractor — ancestor: reads a resolved ancestry chain, and a rule may verdict over
a runtime observation or a declared signature. See PROVIDERS.md.
The rest of this page is a reference: 21 rule forms, the selector dialect, recipes, modes, exemptions. You do not need any of it to write your first rule. The loop is four commands, and it is worth running once on a repository you know before reading further.
1. Ask what enola can bind for you. constraints init writes a
first declaration, binding every shipped recipe whose required roles
resolve to directories your repository actually has. It refuses to
overwrite an existing declaration and it guesses nothing — which
means the honest answer on many repositories is that it binds
nothing:
$ enola constraints init .
clean not bound: no directory for frameworks, interface-adapters, use-cases, entities
layered not bound: no directory for presentation, application, domain, infrastructure
ports-and-adapters not bound: no directory for core, ports, adapters
rails-conventions not bound: no directory for controllers, jobs, models, mailers, policies, …
nothing to bind: no shipped recipe's roles resolve to a directory here
That is not a failure. A recipe is a named pattern with required roles (Recipes), and a repository laid out its own way has none of them. Read the list as a question — is my repository one of these, under different directory names? If it is, bind the recipe by naming the roles yourself. If it is not, write the rule directly, which is step 2.
2. Write one rule, by hand. Two components and one rule is a
complete declaration. This goes in enola-intent.yaml, beside the
layers: you may already have:
components:
- name: storage
match: ["storage/**"]
- name: notify
match: ["notify/**"]
rules:
- id: storage-sends-nothing
forbid: storage
to: notify
via: imports
because: "persistence must not decide what the buyer is told"because: is mandatory, and it is not decoration: every finding this
rule produces carries it, so a violation says why the rule exists
rather than only that it was broken.
3. Check that it means what you think. constraints lint
validates the vocabulary and — when a snapshot exists — resolves each
component against it, so you find out that a selector matches nothing
before a verdict silently passes:
$ enola constraints lint .
Component resolution against the snapshot at ./.enola:
notify 2 member(s)
match notify/**
storage 2 member(s)
match storage/**
OK — every declaration is valid.
A component with 0 member(s) is the failure mode to watch for. It
is valid YAML, it lints clean, and it can never be violated.
4. Enforce it. The constraints explainer verdicts declared
rules on every snapshot at confidence 1.00, so the gate can fail on
them:
$ enola check --fail-on=constraints .
FAIL — 1 structural regression introduced.
law: 1 rule · 1 breach · none excused
Regressions (fail):
- [constraints] 1.00 — Constraint storage-sends-nothing violated: storage -> notify via imports
forbidden imports edge
storage/storage.go:3
import "layersgate/notify"
Then: what is the law actually doing? As a declaration grows,
the question stops being "does it pass" and becomes "how much of it
is being excused rather than obeyed". constraints ledger answers
that — every rule beside its breaches, its suppressions and its
exemptions, each with an owner, a reason and an age:
$ enola constraints ledger .
law: 1 rule · 1 breach · none excused
read from the snapshot generated 2026-08-25T19:30:54Z — a rule declared since then is not counted here
storage-sends-nothing [ratchet] — 1 breach reported, 0 excused · declared in enola-intent.yaml
because: persistence must not decide what the buyer is told
constraints mine is the fifth command, and it belongs after these
four rather than before them: it proposes candidate rules out of the
regularities your snapshot already shows, which is useful once you
know what a rule means and can judge a proposal. It never
self-adopts.
From here, the reference. Components is the selector
dialect, The 21 rule forms is everything a rule
can say, Modes is how strictly each is enforced,
Exemptions is how a breach is
signed off, and plan / plan_check
answers which rules govern a change before you make it.
A repo whose law outgrows one file splits it into per-domain files
under enola/constraints/*.yaml — visible source at the repo root,
never under .enola/. Each file carries the same two sections the
inline declaration does (components: and/or rules:), and loading
merges every file's entries after the inline ones, in sorted filename
order, so the resolved sets are deterministic; inline stays legal for
small repos. Validation runs over the merged set — a rule in
billing.yaml may name a component declared in domains.yaml or
inline — but one component name or rule id declared twice across
sources is an error naming both declaring files, and each compiled
fact cites the file that declared it, so a verdict names
billing.yaml rather than the merged whole. The split is what makes
CODEOWNERS work: each domain's file routes to the team that owns that
domain's law.
A component names the facts its selector matches:
components:
- name: domain # lowercase token
match: ["app/domain/**"] # exact path, prefix/** subtree, or **/name
# basename glob — nothing else
kind: module # optional: module, route, storage, symbol —
# plus test_ref, file_ref, lint and dependency,
# which a component acquires ONLY by naming them
name_pattern: "*Serializer" # optional: a fact-name family — an exact
# name, one prefix*, or one *suffix
service: billing # optional: one repo of a multi-repo snapshot,
# by exact repo label
where: { framework: rails } # optional: a predicate over measured fact props
owns: methods # optional: a concept's members' methods are
# theirs — the one field that widens, and the
# one a rule may override for its own reachmatch speaks a bounded glob dialect of three forms: an exact
repo-relative path, a prefix/** subtree, and a **/name basename
glob. Any other glob form is rejected at parse time, so a selector the
evaluator would silently fail to match is an error the author sees
instead. Membership is exact — path equality, declared subtree, or
final-segment name over a fact's file, an optional kind narrowing, an
optional fact-name narrowing — and a fact with no file matches
nothing. A component whose selector matches nothing surfaces as a
standing 0.4 advisory, so vacuous compliance never reads as
compliance.
The basename form exists because some conventions live in a filename
rather than a directory, and the files obeying one are routinely
spread across trees that share no prefix. Stimulus is the case that
asked for it: a Rails monolith keeps controllers in
app/javascript/controllers/** and beside their view components in
app/components/**, so **/*_controller.js is the only spelling of
"every Stimulus controller" — no prefix reaches both, and the whole
repository reaches far too much.
**/ means "at any depth", including the repository root, and what
follows it is one path segment carrying at most one * around a
non-empty literal: **/*_controller.js, **/Gemfile, **/schema.*.
The * never crosses a /, and it is matched against the file's
final segment only — a directory named x_controller.js does not put
the files under it in the component. Everything else stays out
deliberately: no ?, no character class, no brace set, no escape, no
** between segments, and no second *. **/* is malformed rather
than a spelling of "everything", for the same reason name_pattern: *
is. A malformed pattern is a named error at declaration time; a
well-formed one that matches nothing is the dead-selector advisory,
which is a different report and reads differently.
Declared layers keep the first two forms and not the third: a layer is a place, and a filename that appears in several places is not one.
name_pattern narrows membership to a family of fact names, and it
speaks the bounded name dialect require_name's pattern and
require's when_edge_to speak: an exact name, one trailing *
matching a prefix, or one leading * matching a suffix. Nothing else
— no second star, no ?, no character class — for the reason the
match dialect is bounded too, and screened by the same
ValidNamePattern the evaluator's MatchBoundedName implements, so a
family a declaration may write is a family the evaluator recognizes. A
pattern with no star is plain string equality, which is what
name_pattern always was. It narrows and never selects on its own: a
component carrying one still needs a match, a service or a
where. The screen costs something and the cost is deliberate — an
exact name carrying a glob metacharacter, Ruby's Config#[], is not
declarable, because admitting it would mean admitting a pattern the
matcher has a second reading of.
Because it is a name narrowing rather than a where: predicate, a
name-patterned component is legal in every rule role, edge forms
included: forbid: constructors, to: fetchers, via: calls is a
declaration this dialect makes writable. What such a component does
not gain is grounding by file. A path-granular edge target resolves to
a file, and a file cannot show which of the facts measured in it the
edge landed on, so a name-narrowed component is joined to no path
target — exactly as it was when the narrowing could only be one name.
service scopes the selector to one repository of a multi-repo
(append-mode) snapshot, by the exact repo label every appended fact
carries. It ANDs with the other narrowings — members are facts of
that repo — and it is the one selector that makes match optional: a
component with a service and no patterns is the whole service, and
then it also contains the synthetic service node the cross-repo
linker emits, so service-to-service depends_on edges are walkable
like any other. A fact with no repo label matches no service, fail
closed.
where selects members by what the measured facts carry instead of
by where their files sit, so a rule can name an enforceable concept
rather than a directory. It is a membership selector and only that.
The forms that read a member's own props take it as it stands; the forms
that walk edges take it once the component declares what it OWNS — see
A concept in an edge role below for the declaration, the precedence,
and the two pairings that stay refused.
components:
- name: view-components
where: { superclass: "ViewComponent::Base" }
- name: ember-components
where: { framework: ember, symbol_kind: class }
- name: models
where: { kind: storage, storage_kind: model }
- name: hairy-methods
where: { symbol_kind: method, cyclomatic: ">=20" }Every key names something the extractors measured. That is the whole vocabulary: a predicate can only reach a concept the facts already carry. Two worked examples of the limit, both measured on a production Rails+Ember monolith (153,252 facts, 2026-08-13):
-
{ framework: ember, symbol_kind: class }selects 2,200 Ember component classes. It works because the TypeScript extractor putsframework: emberon the class. -
There is no predicate in this vocabulary that names a Stimulus controller.
framework: stimulusis set on thestatic targets/valuesfield members, never on the class (tsextractor/ts.go), so it returns 52 method facts and{ framework: stimulus, symbol_kind: class }returns 0. Selecting on the base class instead depends on what the TypeScript extractor emits in the snapshot you are reading, which is a moving target and not a property of this vocabulary: ask the snapshot (query_facts kind=symbol prop=superclass) rather than this page. What does not move is that 42 of the 50 controllers are writtenexport default class extends Controller, anonymously, so a name-based selector reaches at most 8 of them however the base class is measured. -
Conjunction only. Every pair must hold. There is no
orand no negation: a disjunction is two components, which reads better than a nested boolean, and a negation asks the snapshot to answer for facts it may simply never have measured. -
kindis the one reserved key; every other key is a fact property name.kindnarrows the fact kind, the same narrowing the component's ownkind:field carries — declaring both spellings is an error rather than a silent precedence rule. -
superclass:is one level, and only one. The extractor recordssuperclassexactly as the source wrote it, sosuperclass: ViewComponent::Baseselects the classes that name that parent directly and nothing written underneath them: on the monolith that is 269 of the 357 classes whose ancestry reaches it, 310 of 531 forApplicationRecord. This vocabulary has no transitive spelling — a rule that must cover a hierarchy names each level, or widens the component another way.A component whose members are named as the parent by classes it does not contain gets a 0.4 advisory carrying the count and the classes — the case neither the dead-selector nor the unmeasured-property advisory could see, because the selector worked and simply reached less than the concept it names. Its witnesses are lexical, like the property, and the count is neither a floor nor a ceiling. It misses: a subclass that spelled its parent relatively — the unqualified
Baseinside a module — writes text no member's fact name equals. It over-attributes: the index is keyed on the parent as written and read by the member's resolved fact name, so a module-scopedBaseand a top-levelBaseare one key, andWidgets::Card < Baseis named as a subclass of a member it does not inherit from. Both are the same fact aboutsuperclass— it is source text, and no reading of it is transitive or namespace-aware without a resolution pass the extractor did not make. -
ancestor:is the transitive spelling, and it is a separate key. A component declaringancestor: ApplicationRecordholds every class from which a chain of resolvedimplementsedges reaches that name: the grandchild that spelled its parent asBaseinside a module, the class that got there through a mixin, all of them with names already qualified. The root itself is not a member, the same assuperclass:. The chain comes from a resolving provider (the Rubydex provider emits it), so when the snapshot holds no resolved ancestry at all the component is unevaluable with a named cause, every rule naming it stays silent, and a 0.4 finding says which provider would settle it. It is a new key rather than a new reading ofsuperclass:because the same declaration must not select 269 classes on one machine and 357 on another depending on which gem is installed; the two keys are two claims, and a declaration may carry both.components: - name: records ancestor: ApplicationRecord - name: view-components match: ["app/components/**"] ancestor: "ViewComponent::Base"
-
Values match one whole member at a time, the same containment the
requireform'swhen_prop_containsreads set props with. For the space-joined set props (columns,fk_constraints,decorators) that is containment; for a scalar prop — which decomposes into a single token — it is exactly equality. One semantic, not two, and never a substring:company_idis not satisfied byparent_company_id. -
Numeric props take thresholds:
">=20","<=2",">0","<3.5". Quote them — a bare>=opens a folded scalar in YAML.The grammar is one ASCII comparator and one decimal number: an optional
-, digits, an optional fractional part. Nothing else parses, and everything else is a named error rather than a literal string nothing will ever equal."=>30", the hash-rocket transposition, is rejected. So are Go's other numeric literal forms, each of which meant something no reader of the YAML would guess:"<=Inf"validated clean and selected every fact carrying the property as a number,">=1_0"means ten,">=0x1fp0"means thirty-one. So are the comparators a rendered document leaves behind —"≥30","≤30","≫30","﹥30","⇒30","❯30"— which arrive by exactly the route"=>30"does and select nothing. That screen is an allowlist, not a list of named runes: enumerating the ones that look like a relation has no edge, and every one left off compiled to a literal token. A value may open with any ASCII rune, or with a letter, a digit or a combining mark — the alphabets an identifier is written in. A non-ASCII symbol or punctuation rune opens nothing this grammar can mean.A threshold against a property no measured fact carries as a number is a 1.0 finding for the same reason an unmeasured property is — it can never hold.
-
A value carries no whitespace, of any kind. The screen reads the same alphabet the decoder splits on, so a non-breaking space pasted out of a rendered document is refused exactly as a plain one is. The compiled form percent-escapes whitespace so the round trip is lossless, and a compiled predicate that does not decode back into property tests selects nothing and says so. A declaration never compiles to a predicate different from what it says — in either direction.
-
whereANDs withmatch,service,kindandname_pattern, for the same reason every SELECTOR on a component narrows: a component carrying both a path scope and a predicate is their intersection, which is how a path scope you trust gets sharpened rather than replaced. Awherealone needs nomatch: the predicate is the selector.The AND holds wherever a component is joined to a file, including the edge TARGET join: a file-granular import target names no fact, so it is resolved against the component's
matchglobs, and for a predicate component that join additionally requires the component to have measured a member in the resolved file. A path component keeps the plain glob join — its globs ARE its claim about files. The edge target join is unreachable for a predicate component today, because an edge form naming one is refused at declaration time (below); the AND is written where the join is defined rather than where it is called, so relaxing that refusal cannot silently widen it. -
An unmeasured property fails closed and loudly. A
wherenaming a property no measured fact in the snapshot carries is a validation problem inconstraints lint(exit 1, with near-miss suggestions) and a 1.0 finding from the explainer, and every rule naming that component emits no verdict — because an empty component makes every rule over it hold, and that reads exactly like compliance. This is distinct from the 0.4 dead-selector advisory, which covers a measured property whose value happens to match nothing.The census is scoped to the component's own service: a component reading
service: billingis judged against billing's facts, never against the union's. A component naming a service the snapshot does not contain is unasked before the question is asked — the 0.4 absent-service advisory, never a 1.0 measurement claim about a repo that was never loaded. -
A constraint finding is never incidental. The gate's ratchet files a finding as incidental when the change touched nothing it cites, which is right for a moving mean+2σ threshold and wrong for a declared rule: the fail-closed findings cite the COMPONENT, and a component is exactly what does not change when the code moves out from under its selector. Constraint findings are graded on their own terms, so the 1.0 "selector cannot be evaluated" finding fails
checkwhen a snapshot stops measuring what a declaration reads. -
A breach that stopped being reported is not automatically a breach that was fixed.
checkprints two further sections rather than folding either into "Resolved by this change". No longer verdicted — the code the breach named is still measured and no longer selected by the component its rule binds; changing a class's superclass silences every rule that named it exactly this way. No longer declared — the rule was deleted, re-formed under a preserved id, or the witness was carved out by an exemption, with the breaching code untouched. Neither is graded, both are legitimate acts, and neither reads as good news.A third section, not attributable to this change, covers what the pair of snapshots has no standing to judge at all: the repository a witness was measured in left a union snapshot (which reads exactly like deleted code, and which
WarnDifferentRepocannot see because it keys on the snapshot's own identity rather than on the union's members — there is aunion_membershipwarning for it now), or the baseline carried the finding without the declaration that produced it.The inverse matters as much and is more ordinary, because it steals credit for work someone did. A rule's declaration identity now excludes its bookkeeping —
source,recipe,instanceandbecause— so moving a rule between constraints files or relabelling a recipe instance no longer files every breach the same change fixed under "the law stopped asking". Exemptions are compared per witness rather than as one blob, so adding a carve-out for witness X and fixing witness Y in one change credits Y to the change that fixed it.
A predicate selects the facts that CARRY a property, and every property
this vocabulary can test — superclass, symbol_kind, storage_kind,
framework, cyclomatic, decorators — is measured on the class.
The call graph connects methods. In Ruby a class's calls ride its
Owner#method and Owner.method facts, which carry none of those props
and therefore cannot be members of the component. Whether those methods'
edges count as the class's edges is a statement about what a component
MEANS, and no selector makes it. Five rounds encoded an answer in code
while the verdict printed a different one — worst case 269 breaches at
full confidence against every member of a 269-member component.
So ownership is declared. A component says what it owns, and a rule may override that for its own reach:
components:
- name: exceptions
where: { superclass: StandardError }
owns: methods # its members' methods are the member's
rules:
- id: exceptions-avoid-the-database
forbid: exceptions
to: models
via: calls
because: an exception carries a message, never a query
owns: # optional: this rule's reach only
- component: exceptions
owns: nothingowns takes methods or nothing, and absent is not nothing:
an absent ownership is a component whose meaning at an edge is unstated,
and an edge role over it is refused. An explicit nothing is a
declaration — a member's own facts and nothing else — which compiles.
methods reaches the member's methods, and nothing else. What it
adds is exactly the facts the graph's has_method edges reach, which the
graph wires for method, function and getter symbols. It does not
reach the rest of a member's body: a constant, a nested class or
an attr_accessor variable written inside a member carries no
has_method edge and is not owned. So an edge landing on
TimeoutError::CODE lands outside a concept owning methods, and a
rule forbidding that landing reports a true breach — the component never
claimed the constant. Lexical enclosure is a larger semantic than this
vocabulary states, and stating it would need its own measurement.
The precedence is stated once: the rule's override wins over the component's declaration, and a component neither declares is undeclared. Two overrides for one component in one rule have no precedence between them and are a named error rather than a last-one-wins. A test pins the precedence in both directions, permissive and strict, so a later change cannot quietly invert it.
owns is the one component field that widens, and it is not a
selector: membership is exactly what the selectors chose, so cap
counts the same set and constraints lint prints the same numbers. What
it widens is what a rule may WALK. That distinction is what keeps the
exception from reading as licence to widen a selector — a field that
changed membership would have to narrow, and this one cannot change
membership at all.
A verdict states the basis it reached each end on, in one three-state vocabulary used at both: exact (the fact is a member), owned (it is a member's method and the declaration says that is the member's), or grounded (it names no fact and joins through the measured file). Every edge form words both ends, so no sentence can state one end and leave the other to be read as exactly measured.
Edge forms re-open only where the basis can be stated. Two refusals survive the ownership declaration, both statements about what the vocabulary can say rather than about any snapshot:
| role resolves | over | refused because |
|---|---|---|
the SOURCE of the edge (forbid, forbid_reach, allow, protocol, an outbound require_edge, owners:, except:, an inbound require_edge's to:) |
imports |
every imports edge rides a dependency fact, which carries none of the props a predicate tests, and no ownership reaches a file's dependency facts |
the TARGET of the edge (protect, private, an inbound require_edge, to:, only:, steps:) |
imports |
an imports target names a path, so it reaches a component only through the measured file grounding joins to match globs — which needs globs and refuses a name_pattern |
Both are refused at declaration time, so no such rule compiles into a
fact. Note that private and a via-less forbid_reach walk every
rule-via kind, imports among them, so a concept in either is refused
whichever role it fills.
And what the declaration cannot see, the snapshot answers. A concept
may declare an ownership honestly and still reach nothing — an estate
that measures no methods for its members, members carrying no edge of
the kind the rule walks. The reach question is asked per role and on
one side: a source-side role asks only whether the component's edge
sources carry such an edge, a target-side role only whether such an edge
resolves onto it. The previous machinery ORed three arms belonging to
different directions, so deleting an unrelated INBOUND edge flipped an
owners: rule from a false breach to a correct refusal. A role that
resolves nothing silences its rule with a 1.0 finding naming the role,
the side and the edge kind; a role whose empty resolution is no verdict,
unreachable on some but not all of a multi-kind rule's edges, gets a 0.4
note instead — refusing there would delete enforcement that worked.
Two roles are deliberately not asked: require_edge and protocol
decide their subject's measurability from the extraction census, and for
the existential form an empty target resolution IS the breach it exists
to report. Asking the reach question there silences exactly the total
violation, which is how a round shipped reporting zero breaches against a
total one.
Every combination above is covered by a matrix over every rule form and
every role, run through the real extractors over an edge kind riding
member facts (calls) and one riding dependency facts (imports), and
driven from the schema's own form table so a form added later fails the
matrix rather than defaulting into a column.
The file-hosting carrier and the inherits: closure remain held out
(PR #94, not merged): the first because its guard is blind to symbol
kinds outside a small set, the second because its lookup is keyed on
written parent text while the walk keys on fact names.
The pre-edit contract answers for a raw path when the snapshot carries
a member in it — the arm plan --paths needs, since a where-only
component has no match patterns for a path to join. A file nobody has
written yet is still refused: nothing has been measured about it, and
that is exactly what a predicate cannot answer for.
Every rule has a lowercase-token id, unique per declaration, and a
mandatory because: — the rationale every resulting finding surfaces,
so a violation always says why the rule exists, not only that it was
broken. The edge forms (forbid, allow, protect) also need a
via: from the closed edge vocabulary: calls, depends_on,
implements, imports. implements walks inheritance and mixin
inclusion — the include/extend/prepend edges the Ruby extractor
emits — so who-may-include rules need no form of their own.
rules:
- id: domain-stays-pure # forbid: this component must not reach that one
forbid: domain
to: adapters
via: depends_on
because: "the domain must not know its delivery mechanisms"
- id: web-through-services # allow: edges may land only in the named components
allow: web
only: [services]
via: calls
because: "controllers orchestrate; they never reach storage directly"
- id: billing-owned # protect: only the named owners may reach this one
protect: billing
owners: [payments]
via: calls
because: "billing invariants are enforced at the payments boundary"
- id: pack-internals # private: non-exported members stay inside
private: billing
except: [payments] # optional: components also allowed to reach in
because: "only the pack's public surface is a contract"
- id: no-legacy-helpers # forbid_fact: this component must be empty
forbid_fact: legacy
because: "app/legacy is frozen; new code lands in app/domain"
- id: bounded-public-api # cap: membership must not exceed a count
cap: public_api
max_members: 20
because: "every exported surface here is a compatibility promise"
- id: company-fk # require: members must carry a prop value
require: tables
when_prop_contains: {prop: columns, value: company_id}
must_prop_contain: {prop: fk_constraints, value: company_id->companies}
because: "tenant isolation rides the company FK; a bare company_id column is a leak"
- id: promise-getters-cached # require + when_edge_to: an edge selects, a prop is demanded
require: component-getters
when_prop_contains: {prop: symbol_kind, value: getter}
when_edge_to: ["*.reactiveUnwrap", "*.getPromiseState"] # literals, never components
via: calls # which edge kind the antecedent reads
must_prop_contain: {prop: decorators, value: cached}
because: "a getter that unwraps a promise recomputes on every read unless it memoizes"
- id: jobs-perform # require_defines: class members must define a method
require_defines: jobs
method: perform
because: "the queue calls perform on every job"
- id: jobs-named-job # require_name: member names must match a convention
require_name: jobs
pattern: "*Job" # prefix*, *suffix, or an exact name — nothing else
because: "the scheduler discovers jobs by their suffix"
- id: no-getter-prefixes # forbid_name: member names must not match a pattern
forbid_name: models
pattern: "get_*" # the same dialect require_name speaks
surface: exported # optional: judge exported members only
because: "a reader is a noun; get_ says the class is a bag of fields"
- id: every-event-consumed # require_edge: every member must have an edge
require_edge: events
to: handlers # optional: omit to accept the edge from anywhere
via: calls
direction: inbound # inbound: someone points at the member
# outbound: the member points somewhere
because: "an event nobody consumes is dead weight or a silent contract break"
- id: checkout-protocol # protocol: members conform to an ordered step list
protocol: checkout-callers # the component whose members must conform
steps: # ordered step components, first step first
- validate-cart
- reserve-stock
- charge-payment
via: calls
because: "Charging without reserving oversells; reserving without validating reserves garbage."require_defines verdicts protocol: every class-kind member symbol of
the component must have a measured method symbol of the declared name,
in either qualified shape the extractors emit — <Class>#<method>
(instance) or <Class>.<method> (class-level). A class that inherits,
includes or extends anything is out of the rule's scope, not in
breach of it: the definition could ride composition (a superclass, an
included concern) the store does not resolve through, and fail-closed
means never guessing a composed definition absent. The form therefore
verdicts exactly the classes whose omission is visible.
require_name verdicts convention: every member fact's name must
match the declared pattern. The dialect is deliberately bounded —
prefix*, *suffix, or an exact name, never a general glob or
regex — for the same reason match patterns are: a convention the
evaluator would silently mis-apply must be impossible to declare.
Every member is in scope; a name always exists.
forbid_name is its negative: every member fact's name must not match
the declared pattern, in the same bounded dialect and through the same
matcher, so a pattern means one thing whichever way it is read; for a
method the pattern is also tried against the bare method name after its
owner, so get_* reaches Order#get_total. With
surface: exported only members whose measured exported prop is true
are judged, because a private helper is not the surface a naming
convention governs; without it every member is.
private verdicts visibility: members of the component whose measured
exported prop is false may be reached only from inside the
component (or from an except: component), over every rule-via
edge kind at once — privacy is about any measured reach, so the form
carries no via: of its own. Non-exported is the extractor's own
measurement (Go capitalization, Ruby private markers and packwerk
public dirs, TypeScript export, Python underscore prefixes, …); a
member with no boolean exported prop, or whose facts disagree about
visibility, is out of the rule's scope, fail closed.
require_edge verdicts existence — the one form that demands an edge
rather than forbidding one. For every member of the component, at
least one measured edge of the via kind must exist in the declared
direction: inbound means some source points at the member,
outbound means the member points somewhere. With a to: the demand
narrows to edges whose counterpart is one of that component's members;
without one, any measured via-edge satisfies. A member with zero such
edges is a violation with the member as its witness — OrderPlaced has no inbound calls edge from handlers — at the same proof-class 1.0
every decided rule verdicts at. Measurability fails closed on the
snapshot's own extraction census: an absence only verdicts where facts
of the searched side's file kinds demonstrably source via-kind edges
elsewhere in the snapshot. A member whose absence the census cannot
back — the member's own file kind sources no such edges anywhere
(outbound), or the searched sources include a file kind that sources
other edge kinds but never this one (inbound) — is skipped with a
named count in one 0.4 advisory per rule, never silently compliant and
never falsely violated, the same honest degrade the reach skip and the
dead-selector advisory set the shape for.
protocol verdicts ordered obligation — structurally, never
temporally. The steps are an ordered list of components; for every
member of the protocol component, a measured via edge into step
K's members obliges measured via edges into every step 1..K-1's
members. A caller that reaches charge-payment without
reserve-stock is a violation with the member as its witness —
OrderFlow calls charge-payment without reserve-stock, naming the
highest skipped step — while a member touching every prerequisite,
touching only step 1, or touching no step at all stays silent: the
protocol binds participants, not bystanders. See "Protocol ordering"
below for what this form can and cannot honestly claim.
require verdicts what a member fact carries rather than what edges
it makes: every member matching the optional when_prop_contains gate
(every member, when the gate is omitted) must satisfy
must_prop_contain. Containment is whole-member over the fact's
space-separated set prop — columns contains company_id is never
satisfied by parent_company_id — and a member whose gated prop was
never measured is out of the rule's scope, not in breach of it. The
census props the company-FK example reads (columns,
fk_constraints) are measured from whichever schema dump the project
keeps — db/structure.sql or db/schema.rb, the SQL one winning where
both exist — in the same shape either way.
when_edge_to is the form's second antecedent, for the conventions
whose criterion is a call rather than a property: "a getter that works
with promises carries the caching decorator" is selected by the calls
themselves, and no extractor prop should have to be invented to name
one organisation's helpers. Each entry is a literal matched against
the edge target in the bounded dialect require_name speaks —
prefix*, *suffix, or an exact name, with ValidNamePattern and
MatchBoundedName shared between the validator and the evaluator so
what may be declared and what is matched cannot drift. A target carries
no whitespace of any kind — the compiled rule holds the set as one
whitespace-separated prop, and the screen is the same unicode.IsSpace
the split that reads it back uses, so a target cannot validate as one
name and evaluate as two. The suffix form
is what fits a real graph: *.reactiveUnwrap matches
ember_app/app/utils.reactiveUnwrap without the declaration having to
know where the helper lives. via: says which edge kind is read and is
required — every form whose verdict turns on one kind of edge names
it, and only forbid_reach, which is deliberately about any path,
omits one. A rule may declare both antecedents; they narrow together,
exactly as every other field of a selector does, so a member must
satisfy each declared clause to be in the rule's scope.
Nothing in the edge antecedent resolves a second component. The near
end is the member fact, the far end is the string the declaration
wrote, and only relations riding the member fact itself are read —
dependency carriers, whose edges belong to a file, are deliberately
not folded in, because attributing a file's edges to each member of
that file is precisely the ownership claim this form must not make.
That restraint has one honest cost, and the form pays it out loud: when
the antecedent selects no member of the component, the rule emits
one 0.4 advisory — require rule <id> skipped: no member of <component> makes a calls edge the antecedent selects — instead of a
clean report. Two readings reach that state and the advisory names
both, because the facts cannot tell them apart: nobody makes the call,
or the selector and the edges live on different facts (a Ruby class's
calls ride its Owner#method facts, and the class fact carries only
what its class body called — an include, an attr_reader, a
validates). Either way the rule looked and found nothing, and a rule
that holds because it looked at nothing must never read as compliance.
The advisory is read off the antecedent's own answers, on the same
representative fact per member the verdict is evidenced from, so a
relation on some other fact cannot certify a component the antecedent
never asked.
The boundary is all-or-nothing, deliberately: a component where some members answer the antecedent and others are blind to it gets no advisory, because telling a blind member from one that simply makes no such call needs a notion of which fact owns which edge that these facts do not carry.
A breach is a decided-rule finding at confidence 1.0 — the rule is
declared and each membership is either an exact fact name or a target
grounded on the measured file it names, and the verdict says which —
with the rule's because in the description. Target resolution fails
closed: an edge whose target names nothing measured is skipped, never
guessed into a violation.
The TypeScript extractor records every decorated class member's (and
class's) decorators as a sorted, deduped, space-separated decorators
set prop, marks get accessors with symbol_kind: getter, and counts
each getter's distinct outgoing call edges into getter_calls —
emitted even at 0, so measured-cheap and unmeasured never look the
same. That makes a caching convention like "expensive getters carry
@cached" declarable over measured facts:
components:
- name: component-getters
kind: symbol
match: ["app/components/**"]
rules:
- id: expensive-getters-carry-cached
require: component-getters
when_prop_contains: {prop: symbol_kind, value: getter}
must_prop_contain: {prop: decorators, value: cached}
mode: advisory
because: "mined 2026-08-11 over a large Rails monolith: 106 of 10283
getters carry @cached (60 of 6992 in components), and even of getters
with >=5 outgoing calls only 7 of 290 carry it — while every one of the
106 @cached getters skews expensive (52% loop vs 17% of the uncached).
The revealed convention is weak: @cached is deliberate, not ambient, so
the rule is advisory and judged-cheap getters are absorbed by
witness-named exemptions, never by silencing the rule."Where the expense boundary IS a call the convention names, say so with
when_edge_to and let the graph decide who is in scope:
rules:
- id: promise-getters-are-cached
require: component-getters
when_prop_contains: {prop: symbol_kind, value: getter}
when_edge_to: ["*.reactiveUnwrap", "*.getPromiseState"]
via: calls
must_prop_contain: {prop: decorators, value: cached}
because: "a getter that unwraps a promise recomputes on every read unless it
memoizes; measured 2026-08-13 over a large Ember app: 478 of 6883 component
getters call one of the two helpers and 463 of them carry no @cached"The antecedent is the criterion itself rather than a proxy for it, and it needs no new extractor prop: the call relation is already measured, so a convention about those two helper names stays in the declaration that cares about them instead of entering a general tool's vocabulary.
Two honesty boundaries the form imposes. The expense boundary itself
is not expressible as a gate — getter_calls is a count and
when_prop_contains is set membership — so the boundary lives in the
mined evidence carried by because: and in exemptions: a getter
judged cheap gets a witness-named exemption, never a weaker rule. And
a member whose symbol_kind was never measured (a file class the
extractor cannot classify) is out of the when clause's scope, not in
breach of it. Template read fan-in is deliberately absent from the
expense signals: no template->member edge exists to derive it from —
the .hbs scanner refuses bare {{name}} as ambiguous and strict-mode
.gts tokens resolve against imports only — and a guessed fan-in is
worse than an absent one.
Concern discipline composes from the edge forms — no dedicated form
exists because none is needed. "Concerns must not depend on their
includers" is a forbid … via: calls; "only models may include model
concerns" is a protect … via: implements over the measured include
edges; both sides are named by ordinary path components:
components:
- name: model-concerns
match: ["app/models/concerns/**"]
- name: models
match: ["app/models/**"]
rules:
- id: concerns-off-their-includers
forbid: model-concerns
to: models
via: calls
because: "a concern calling its includer is an inheritance cycle in disguise"
- id: only-models-include
protect: model-concerns
owners: [models]
via: implements
because: "model concerns assume an ActiveRecord includer"An include whose constant resolves to nothing measured verdicts nothing — fail closed, like every other target resolution here.
Everything above forbids: edges that must not exist, members that must
not exist, names and props that must not deviate. require_edge is
the vocabulary's first existential primitive — the building block
recipes for whole architectural styles compose from — because an
event-driven, plugin, or pub/sub architecture is not defined by what
its parts must avoid but by what must be wired: every event has a
handler, every job class is enqueued somewhere, every route is called
by some client, every interface has an implementor. Before this form,
an orphaned event was invisible — nothing forbidden happened; nothing
happened at all — and the only existence check in the system was the
unused-routes explainer's routes-versus-clients census, one hardwired
special case of exactly this shape.
The worked event-driven pair:
components:
- name: events
match: ["app/events/**"]
kind: symbol
- name: handlers
match: ["app/handlers/**"]
rules:
- id: every-event-consumed # an event nothing calls is dead weight
require_edge: events
to: handlers
via: calls
direction: inbound
because: "an event nobody consumes is dead weight or a silent contract break"
- id: every-handler-subscribes # a handler that calls no event is wiring debt
require_edge: handlers
to: events
via: calls
direction: outbound
because: "a handler consuming nothing is dead wiring the bus will never invoke"OrderPlaced with a measured handler call stays silent;
OrderCancelled with none verdicts at 1.0 with the member as its
stable witness. Modes, exempt: (witness is the member identity, e.g.
OrderCancelled has no inbound calls edge from handlers), the check
gate's delta scoping, and constraints_for/plan's obligation
statements (members of events must have an inbound calls edge from handlers) all apply exactly as they do to every other law form.
A rule names a set of parts and holds when no dependency cycle runs among
them. forbid_cycles names the first part and among the rest, every one
a declared component:
rules:
- id: parts-never-cycle
forbid_cycles: jobs
among: [models, mailers]
because: "parts that reach each other in a circle cannot be taken apart"The reading contracts the module graph to one node per part, admits the
reference and rollup edges (between declared parts a constant reference
is a dependency, and on Ruby it is the only kind there is; associations
stay out, as everywhere), drops self-edges, and reports every strongly
connected component of two or more parts as one finding naming the parts
in the circle and the module edges that close it. A cycle inside one
part is not what the rule states. The repository-wide cycles explainer
is unchanged: it excludes those edge kinds because estate-wide they merge
everything, and a declared set is small and named. On the Ruby surface
the law reads jobs.must_not_cycle_with :models, :mailers.
A to_name literal naming a bare method matches the method of a chained
or receiver-qualified call target as well: update_all is the call
whether the extractor recorded it as update_all, where.update_all or
Order.update_all, which is how a law about the mutating persistence
methods holds against a query that reaches them through a relation
chain. A literal carrying a receiver (Order.update_all) stays exact.
A naming pair. require_name takes requires, a template with one
*: a member matching the pattern must have a sibling in the same
component named by the template with the captured base substituted, so
pattern: "with_*" with requires: "without_*" asks Room#with_guests
for Room#without_guests. The base is read on the member's own part of
the name, so a method on another class never satisfies it. On the Ruby
surface: chat.names_must_match "with_*", requires: "without_*".
A public surface by path. A component takes public, a list of
bounded globs naming the files that are its visible surface. The
private form then decides visibility by path: inside those files a
member is the surface, outside them it is internal, whatever the
language's own keyword says. Ruby marks every method exported, so this
is how a Ruby component states a surface at all. On the Ruby surface:
part :billing, files: "app/billing/**", public: "app/billing/public/**".
A receiver-qualified literal. A forbid with to_name takes
receiver: none to match only call targets with no receiver part, so
params alone is named and request.params is not; the default, any,
matches bare, chained and receiver-qualified forms alike. On the Ruby
surface: models.must_not_call "params", receiver: :none.
Why a file belongs where it belongs. enola constraints explain <path> names the components whose selectors admit a fact in the file,
the selector that did it, and the edges the file's facts make, read off
the same membership the evaluator verdicts on, so the sentence and the
verdict cannot disagree. --json prints the same as data.
A strict Rails arrangement. rails-strict ships as a recipe: the
Rails laws, the request API kept out of models and services with
receiver: none, no circle among the parts, and concerns that stay
independent of their includers over an optional concerns role.
rules:
- id: mixins-stay-independent
independent: concerns
because: "a mixin that knows its includer is half a class in hiding"For each member module, the includers are the classes whose resolved
ancestry includes it, read off the ancestry a provider emitted (the
Rubydex provider does). The member's own edges, the edges of the methods
it encloses and the edges its files carry are walked over every rule-via
kind; one landing on an includer or on an includer's member is one
finding. When the snapshot holds no resolved ancestry the rule emits one
0.4 finding saying which provider would settle it and no verdict, the
same refusal the ancestor: key makes. It takes no via. On the Ruby
surface: concerns.must_not_reach_includers.
require_defines takes any_of beside method, exclusive with it: a
class member satisfies the rule by defining at least one of the named
methods, and the finding names the whole list.
rules:
- id: entry-point
require_defines: services
any_of: [call, run]
because: "a service answers to one of two doors"On the Ruby surface: services.must_define_one_of :call, :run.
protocol closes the last gap in the rule vocabulary's expressiveness
table: ordered interaction sequences. It does so with a form that is
honest about what a static fact graph can and cannot verify.
What a static graph cannot verify: temporal ordering. "validate is CALLED before charge at runtime" is a claim about execution sequence, and no dependency snapshot — however complete — can back it. A tool that graded runtime order from static edges would be lying about its own evidence, so this form does not.
What it mechanically verifies instead: structural protocol
conformance. A member of the protocol component that makes a measured
via edge into step K's surface without measured via edges into
every earlier step's surface is a caller that structurally skips a
mandatory step — it demonstrably wires the later step and demonstrably
does not wire the prerequisite. That absence is decidable from the
graph, verdicted at the same proof-class 1.0 every decided rule
gets, and every violation description states the boundary in as many
words: structural protocol conformance, not runtime ordering.
The worked checkout protocol: CompleteFlow calling all three steps
stays silent; OrderFlow calling validate-cart and charge-payment
but never reserve-stock verdicts as OrderFlow calls charge-payment without reserve-stock (the highest skipped step titles the witness;
the description lists every missing prerequisite); a file that touches
no step is a bystander the rule does not bind. Measurability rides the
same extraction census require_edge fails closed on: a member whose
(repo, file-extension) class never demonstrably sources via-kind
edges is skipped by name in one 0.4 advisory per rule — a wiring
file whose calls the extractor cannot see might be a step-skipping
caller, and that silence must stay visible. A step whose component
matches nothing raises the ordinary dead-selector advisory.
Ordering claims carry a verification level by design: every
compiled protocol rule fact carries verification: structural, the
only level this snapshot can honestly claim, and the schema leaves
room for a future observed level owned by the runtime provider —
which could capture real call sequences and verdict actual order the
way the structural level verdicts wiring. Only the structural level
exists today, and nothing in the system pretends otherwise.
Everything else composes as usual: modes, exempt: (the witness is
the violation identity, e.g. OrderFlow calls charge-payment without reserve-stock), the check gate's delta scoping, and
constraints_for/plan's obligation statement (members of checkout-callers that reach charge-payment via calls must also reach reserve-stock, validate-cart, in the declared order of obligation — structural conformance, not runtime ordering). A protocol rule in a
recipe references roles as its steps, so one declared order
instantiates per bounded context — the checkout example above is the
natural recipe body. With this form the ArchSpec parity table's
protocols family graduates from partial to covered-structural: the
structural half of ordered-interaction sequences is expressible and
verdictable, the runtime half remains future provider work, and the
parity re-measure belongs to the next harness run.
Five forms and two component keys read what only the fact graph holds: storage facts, the routes behind code, the seams between repositories, the pages compiled from a knowledge base, and the history of every snapshot. Each refuses by name when the snapshot cannot answer, so silence never reads as compliance.
storage_stays_home: <component>holds when every storage fact a member reaches (callsordepends_onto a model a storage fact names) is itself a member. The breach names the table and the model, and the first suggested action is the owning part's public member that already reaches the same table. Ruby:billing.storage_must_stay_home.handles: [POST, PUT, PATCH, DELETE]on a symbol component admits the members a route with one of those methods reaches throughhandled_by, sorequire_edgestates "a mutating action reaches a policy" with nothing new. Ruby:part :mutating_actions, files: "app/controllers/**", handles: [:post, :put, :patch, :delete].cap_runtime: <component>withmetric: queriesandmax: Nreads theruntime-queries:frames a runtime capture measured for files inside the component and names every frame over the budget. A snapshot with no capture makes the rule unevaluable with the causeno_runtime_capture. Ruby:billing.must_keep_budget metric: :queries, max: 20.require_consumer: <route component>breaches for every member route no loaded client calls, read from the cross-repository route match; a single-repository snapshot refuses withno_counterparty. Ruby:api.must_have_consumer.unique_across: <component>withby: table(orname) breaches when members in two different repositories share the value, naming both owners; members from one repository refuse withno_counterparty. Ruby:tables.must_be_unique_across by: :table.governed_by: <page path or glob>on a component admits the measured facts in files the selected pages anchor;status:supersededafter the glob keeps the pages with that status,supersedes:<page>the pages that supersede it, so "the code of the superseded decision" and "the code of the superseding one" are two components andforbidstates the law between them. Ruby:part :old_way, governed_by: "wiki/shop/adrs/*.md status:superseded".require_governed: <component>breaches for every member file no compiled page anchors; a snapshot with no pages refuses withno_compiled_pages. Ruby:old_way.must_be_governed.
Two spellings add time. since: YYYY-MM-DD on any rule dates it: the
explainer verdicts as usual and stamps the date, and check reads the
architecture history's newest revision at or before the date, reports a
breach that revision already carried and grades one it did not; a date
before the first revision keeps every breach graded and adds a descriptive
finding naming the first revision's date. growth: N on cap lets the
count exceed the baseline's count by N before the cap fails; without a
baseline the cap alone applies. Ruby: since "2026-08-01" and growth 2
inside a law.
Where the history has no revision at or before the date, git decides
instead: check reads the witness line's author date with one
git blame --porcelain -w per witness file, reports a breach whose line
was last changed before the date and grades one changed after it, and
remembers the dates it read under .enola/blame_cache.json by the file's
blob hash, so an unchanged file never asks git twice. Author time, never
commit time. Three cases cannot be dated and grade as a rule without a
date would, each said once as a descriptive finding: no git or an
untracked witness file, a witness line not yet committed, and a shallow
clone whose boundary commit is newer than the date. A shallow CI checkout
that wants dated rules deepens the clone; the binary does not guess past
the boundary.
Every edge breach (forbid, protect, private) and every cycle breach
now leads its suggested actions with the smallest cut the graph can see:
the far part's public member with the same bare name, else its public
surface, else the part the offender's other edges mostly reach; for a
cycle, the lightest edge of the circle by module edges. When the facts
support none, the action says so rather than offering a generic sentence.
Recipe roles may carry selector defaults (match, kind, name_pattern,
where): a binding that gives none inherits the role's, key by key, and a
defaulted role is never required of the binding. A team's own recipe under
enola/recipes/ can therefore carry its conventions with their selectors,
so the binding in enola/constraints/ is the recipe's name and the mode
alone, and every path is overridable where a tree differs. The shipped
recipes stay framework-general; house conventions belong in the
repository's recipe, where the team that owns them reviews them.
A recurring architectural pattern — event-driven, ports-and-adapters, a migration target state — is the same handful of rules written again and again with different paths in the component selectors. A recipe names the pattern once: role slots plus parameterized rules, instantiated per bounded context by binding paths to roles. Two artifacts carry it:
Recipe definitions live in enola/recipes/<name>.yaml, beside
the constraints directory — visible source, never under .enola/.
One recipe per file: a recipe: name, roles: slots, and rules:
in the full existing rule vocabulary, referencing roles instead
of components:
recipe: event-driven
roles:
- name: events
- name: bus
- name: handlers
rules:
- id: events-consumed
require_edge: events
to: handlers
via: calls
direction: inbound
because: "An event nobody consumes is dead weight."
- id: only-bus-calls-handlers
protect: handlers
owners: [bus]
via: calls
because: "Handlers are reached through the bus, never directly."
- id: events-are-named
require_name: events
pattern: "*Event"
because: "The suffix is the contract."Instantiations live in the existing enola/constraints/*.yaml
files, as use_recipe: entries binding each role to a real component
selector (the same match/service/kind/name_pattern/where
narrowings a component takes, so a role a recipe only reads the props
of can be bound to a concept — surface: { where: { superclass: StandardError } } — as readily as to a directory; a role some rule in
the recipe resolves against an edge cannot, and binding a where: to
one is refused on the EXPANDED declaration, naming the expanded
component):
use_recipe:
- recipe: event-driven
as: orders-events
bind:
events: { match: ["app/events/orders/**"] }
bus: { match: ["app/lib/event_bus.rb"] }
handlers: { match: ["app/handlers/orders/**"] }
mode: advisory
exempt:
- rule: events-consumed
witness: "LegacyOrderMigratedEvent has no inbound calls edge from orders-events/handlers"
owner: "dana"
because: "Fired only by the migration backfill, consumed manually."
since: "2026-08-11"Recipes are a compile-time concept. Each instantiation expands
into ordinary components (orders-events/events) and ordinary rules
(orders-events/events-consumed) at load time, with role references
substituted for instance components, and the expanded set flows
through the same validation, compilation and evaluation machinery
every hand-written rule uses — the engine never sees a recipe. That
is what makes every existing capability compose for free: modes,
exemptions, guidance, lifecycle telemetry (per instance-prefixed rule
id), mining, plan/constraints_for, check rendering, the Exempted
bucket, dead-exemption warnings. The instance-wide mode: overrides
every expanded rule's mode; per-rule modes declared in the recipe are
the defaults when the instance declares none. Exemptions attach at
the instance, scoped to a recipe rule by its unprefixed id, because a
template cannot know concrete witnesses — a recipe rule carrying
exempt: is a validation error.
Validation is file-cited and fail-closed, like everything else in the
vocabulary: a recipe rule referencing an undeclared role, an
instantiation missing a binding for any role the rules reference,
binding a role the recipe does not declare, duplicate recipe names
across files (both cited), duplicate instance names across files
(both cited — the expanded ids would collide), an exemption naming a
rule the recipe lacks, and use_recipe inside a recipe (no recursion
in v1) are all errors. The one warning: a declared role no rule
references is a dead role — reported, never fatal.
constraints lint lists each recipe (name, roles, rule count) and
each instantiation under its declaring file (instance, recipe,
bindings, expanded rule count), and expanded rules keep their
provenance all the way to the verdict: a violation's description
traces to rule orders-events/events-consumed (recipe event-driven, instantiated in enola/constraints/orders.yaml), so a reviewer can
walk law back to pattern.
Two recipes this vocabulary is aimed at, as sketches — documented here, not shipped files, because each repo binds its own paths:
The vanilla Rails views recipe (Stimulus/Hotwire, no SPA): the
extractors already measure markup wiring — stimulus-binding facts
from data-controller attributes resolved to
app/javascript/controllers/ at markup-declared level, and
turbo-frame declaration/reference facts from turbo_frame_tag. A
rails-views recipe binds views, controllers (Stimulus), and
components roles per domain: require_edge demands every Stimulus
controller is bound by some view (inbound, so a dead controller is a
breach instead of invisible), require_name holds the
*_controller.js convention, and a forbid keeps views from
reaching application services directly. Instantiated once per domain
slice, the same three rules police every slice's markup wiring
without a per-domain rewrite.
The Ember-to-Rails page-migration recipe: the migration's target
state, declared per page. Roles for the rails-page (the new
views/controller subtree) and the ember-remnant (the legacy route's
app code); the rules are forbid_fact on the remnant (a migrated
page's Ember code must be gone), require_edge demanding the Rails
page is actually routed, and naming/reach rules for the new subtree's
conventions. Each page migration is one use_recipe entry — and
because every instantiation expands to rules with stable
instance-prefixed ids, the drift telemetry that trends mining runs
counts conforming pages over time: the migration's progress is the
number of instances whose rules verdict clean, measured, not
asserted.
With service-scoped components the rule forms reach across repositories — the edges are the ones the cross-repo linker measures (service-to-service and finer), so "the frontend must not touch billing's internal surface" is one forbid:
components:
- name: frontend
service: frontend
- name: billing-internal
service: billing
match: ["internal/**"]
rules:
- id: no-internal-reach
forbid: frontend
to: billing-internal
via: calls
because: "internal surfaces are not a contract"The counterparty rule intentcheck's seams follow applies here too: a
component naming a service absent from the snapshot is unasked —
every rule naming it emits no verdicts, because a snapshot cannot
answer for a repo it does not contain — and one 0.4 advisory
(Constraint component … names service … not present in this snapshot) keeps the silence visible, exactly like the dead-selector
advisory.
ratchet(the default): breaches verdict at1.0and the check gate fails new ones — pre-existing violations stay silent, the same delta scoping every finding gets.advisory: breaches report at0.9, deliberately below the gate's floor, titledAdvisory constraint … violated— the declaring file chose reporting over enforcement.strict: breaches are titledStrict constraint … violatedand failenola checkeven when the baseline already carried them — the one deliberate exception to delta scoping, for rules decided to hold now rather than merely to stop getting worse.
A strict violation's only override is the suppression ledger, a
committed .enola/suppressions.yaml the gate only ever reads:
entries:
- rule: company-fk # or finding_title_prefix: "…" — exactly one
owner: alice
reason: "legacy tables migrate in Q4"
date: "2026-08-10"Every entry is a signed excuse — owner, reason and date are required,
parsing is strict, and an invalid ledger rejects as a whole. A
suppressed finding is reported in the verdict's own Suppressed
bucket (text and JSON) and never fails; the ledger applies to ratchet
findings too. enola never writes this file.
A rule may carry an exempt: list: declared, reasoned carve-outs
riding the law itself. Each entry names one witness — the exact
violation identity the rule would otherwise report, the same string a
violation is titled with (users must have fk_constraints containing company_id->companies, app/domain/billing -> app/adapters/http via depends_on) and the same identity the lifecycle ledger folds on —
plus who decided the carve-out, why, and when:
rules:
- id: company-fk
require: tables
when_prop_contains: {prop: columns, value: company_id}
must_prop_contain: {prop: fk_constraints, value: company_id->companies}
because: "tenant isolation joins through companies"
exempt:
- witness: "legacy_imports must have fk_constraints containing company_id->companies"
owner: dana
because: "legacy_imports keys company_id to the archived companies snapshot, not companies"
since: "2026-08-01"All four fields are required — an exemption without an owner, a reason and a date is how a violation becomes permanent silently, so a partial entry rejects at parse, cited under its declaring file. Witnesses are matched exactly; there are no glob forms.
An exempted witness produces no violation in any mode — ratchet,
advisory, strict, all unchanged for every other witness. Instead it
produces one Exempted from constraint <id>: <witness> finding at
0.9: rendered in enola check's own Exempted by declaration
bucket (on every run, not only the one that introduced it) and in the
insight listings, always carrying the owner, the date and the reason —
counted, never silent. An exemption whose witness matches nothing the
rule reports is a dead exemption, warned at 0.4 like the
dead-selector advisory: it either outlived its violation (delete it)
or never matched (fix the witness).
Exempted witnesses are decisions, not debt: the lifecycle ledger
keeps them out of a rule's standing-violation set and the lifecycle
report lists them separately ("N exempted by declaration").
constraints lint validates every entry and counts exemptions per
file; constraints_for and plan_check report each bound rule's
exemptions with their reasons, so an agent about to edit sees the
carve-out beside the law. Mining proposes no exemptions ever — it
reports reality, and a carve-out is a decision only an operator signs.
The decision hierarchy, strongest first — reach for the earliest one that is true:
- Fix it — the violation is wrong and the rule stands. No vocabulary needed.
- Exempt it (
exempt:on the rule) — the witness is decided to be out of the rule's scope, permanently and with a reason, and the decision should live beside the law it carves out of. - Suppress it (
.enola/suppressions.yaml) — the violation is real and stands, but strict-mode enforcement must not block while it is being worked off: a temporary, signed excuse in the gate's own ledger, separate from the declaration. - Baseline it (ratchet's implicit merge-base baseline) — nobody decided anything: the violation predates the rule and the ratchet merely stops it getting worse. Invisible and unreasoned, which is exactly why anything decided deserves one of the forms above.
Everything above is law — a rule states what the architecture must
not do, and a breach is a decided finding. The guide form is
steering: "similar implementations here used X; consider it." It
names a component and carries the advice itself, with optional
exemplars pointing at prior art:
components:
- name: components
match: ["app/components/**"]
rules:
- id: getters-cached
guide: components
message: "Expensive derived getters here use @cached — consider it (see exemplars)"
exemplars:
- app/components/sortable-table.js
- app/components/avatar-stack.js
because: "recomputing derived state on every render is the recurring perf bug here"message is required — the advice is what a guidance rule delivers.
exemplars name prior art by repo-relative file path or exact fact
name; they are shape-checked at parse time (non-empty, whitespace-free)
but never required to exist — prior art may move without the
advice going stale. Existence is a delivery concern: constraints_for
annotates each exemplar present/absent against the current
snapshot (a measured fact carrying it as its file or its exact name;
fail closed — unresolvable is absent), and constraints lint reports
absent exemplars as a note, never an error. Presence is a
tri-state: with no snapshot to measure against (plan's
declarations-only mode) every exemplar is unmeasured, rendered
unmeasured — no snapshot — "absent" and "never looked" must never
read the same.
The whole point is pre-edit steering: a constraints_for query
for a target inside a guided component — including a file that does
not exist yet — returns the guidance in its own guidance list,
separate from the law under rules: message, mode, annotated
exemplars. Two modes, both non-enforcing:
notify(the default): the contract channel only — no finding, ever.advisory: additionally ONE0.9finding per guided component, titledGuidance for <component>: <rule id>— never one per member, because guidance is not a violation census — so the advice ridescheckoutput visibly and can never fail anything.
The enforce-class modes (ratchet, strict) are rejected on a
guidance rule at validation — and so is exempt:, because guidance
emits no violations to exempt. Graduation to law means writing a law
form — a forbid, a require, a require_name — on the declaring
file, not hardening the guidance.
Guidance also rides the gate: advice travels with the diff, never
gates. When enola check grades a delta, every guidance rule whose
component contains a file the change touched — added, removed or
modified, derived from the fact delta — renders in its own
Guidance for this change (N) section: rule id, message, because:,
and the exemplars with their tri-state presence. The JSON verdict
carries the same entries in a guidance array (rule, component,
message, mode, because, exemplars, matched changed files), sorted by
rule id and stable across runs. A guidance entry is not a
violation, is counted by no failure policy, and never moves the exit
code in any mode combination; guidance for components the delta never
touched stays silent, so a ten-file change surfaces only the advice
those ten files selected. A partial (intersection-graded) verdict
carries guidance the same way, over its own graded delta; a declined
or errored gate carries none, because there is no trustworthy delta
for the advice to travel with.
Shipped in v0.4.4.
A repository whose team writes Ruby may write its laws in Ruby. Files
ending in .rb in enola/constraints/ are read beside the YAML ones,
parsed with the Ruby grammar the extractors already carry and never
executed, and compiled to the same declaration the YAML loader
produces: the same merge order, the same per-file provenance stamp, and
the same evaluator, lint surface and pre-edit contract. A repository may
hold one of each while a team moves.
A declaration has two levels. part names a piece of the application in
the team's own words; rails declares the conventional parts of a Rails
application from the directories Rails puts them in, so a team writes
only what is theirs. A law is a sentence, its reason, and optionally
its mode and its carve-outs.
Enola.architecture "storefront" do
rails
part :service_objects, files: "app/services/**", kind: :symbol,
where: { symbol_kind: "class" }, owns: :methods
law "background jobs never invoke controller code" do
jobs.must_not_call controllers
why "rendering from a job goes through ApplicationController.renderer"
seen_in "2,552 of 2,557 call edges"
end
endNineteen verbs cover the 21 rule forms, and a test walks the form table and fails if any form cannot be reached from a verb, so a form added later without a way to say it breaks the build rather than quietly having no surface.
| Sentence | Form it compiles to |
|---|---|
a.must_not_call b |
forbid / to, via calls unless another via is named |
a.must_not_reach b |
forbid_reach / to |
a.may_only_call b, c |
allow / only |
a.is_reached_only_by b |
protect / owners |
a.must_be_reached_by b |
require_edge / to, inbound |
a.must_reach b |
require_edge / to, outbound |
a.stays_inside except: b |
private / except |
a.must_follow b, c |
protocol / steps |
a.must_define :call |
require_defines / method |
a.names_must_match "*Job" |
require_name / pattern |
a.names_must_not_match "get_*" |
forbid_name / pattern |
a.must_be_empty |
forbid_fact |
a.at_most 12 |
cap / max_members |
a.must_carry prop: "framework", value: "rails" |
require / must_prop_contain |
a.advises "prefer a slot" |
guide / message |
A part is written in snake_case because that is what a Ruby file reads
like, and a component name is a lowercase token, so the underscore
becomes a dash on the way through: part :service_objects is the
component service-objects.
Laws a Rails codebase can state today, each compiling to a form above.
They are written to be read and adapted rather than copied: the parts
they name come from rails, and the reasons are the ones a team would
actually give.
Enola.architecture "storefront" do
rails
part :service_objects, files: "app/services/**", kind: :symbol,
where: { symbol_kind: "class" }, owns: :methods
part :queries, files: "app/queries/**"
part :maintenance, files: "app/tasks/**"
part :public_api, files: "app/controllers/api/**"
part :legacy, files: "app/legacy/**"
# Layering: what may reach what.
law "background jobs never invoke controller code" do
jobs.must_not_call controllers
why "a job that renders goes through ApplicationController.renderer"
end
law "models never reach controllers, however indirectly" do
models.must_not_reach controllers
why "a model that knows the request cannot be used off the request"
end
law "controllers reach the database through queries and services only" do
controllers.may_only_call queries, service_objects
why "a controller that builds its own scope cannot be reused or tested apart from the request"
end
law "the public API is reached only by controllers" do
public_api.is_reached_only_by controllers
why "an internal caller taking the API path skips authorization written at the controller"
end
# Shape: what a member must be.
law "a service object has exactly one door" do
service_objects.must_define :call
why "callers never reach a second public method, so the object can change behind it"
end
law "every mailer action is delivered, never called" do
mailers.must_be_reached_by jobs
why "mail sent inline in a request makes the request wait on SMTP"
mode :advisory
end
# Naming: the conventions a reviewer repeats.
law "jobs are named for the queue that runs them" do
jobs.names_must_match "*Job"
why "the scheduler discovers jobs by their suffix"
end
law "policies are named for the model they authorize" do
policies.names_must_match "*Policy"
why "Pundit resolves the policy class from the record's class name"
end
law "maintenance tasks live in the Maintenance namespace" do
maintenance.names_must_match "Maintenance::*"
why "the gem resolves task constants from it; a task outside never appears in the runner"
end
law "no get_ prefixes on a model's public surface" do
models.names_must_not_match "get_*", surface: :exported
why "a reader is a noun; get_ says the class is a bag of fields"
end
# Size and drift.
law "the public API surface stays reviewable" do
public_api.at_most 40
why "an API that grows without a decision is an API nobody decided"
mode :advisory
end
law "app/legacy is frozen" do
legacy.must_be_empty
why "new code lands in app/domain; the directory exists only until it is empty"
end
endEach law carries its reason because every finding surfaces it: a
violation says why the rule exists rather than only that it was broken.
seen_in appends the measurement a law was mined from, which is what
separates a law the estate actually keeps from one somebody wished for.
A part may also be selected by ancestry: part :records, ancestor: "ApplicationRecord" holds every class whose resolved chain reaches that
name, and a bind takes ancestor: the same way.
Beside the verbs, a law may carry id (when a finding's token must stay
stable across a rewording), why and seen_in (its reason and the
measurement behind it), mode, via, direction, exemplar (prior art
for a guidance law), when_carrying prop:, value: and when_calling "literal", via: (the antecedents that narrow a demand to the members it
is about), and exempt "witness", because:, owner:, since: (a carve-out
that says who owns it and when it was taken). A far end written as a bare
name is a part this declaration selected; written as a string it is a
literal the graph recorded, which is the difference between naming
something we declared and something we merely measured.
A repository adopts a convention set it did not author by instantiating a recipe, binding each role the recipe declares to its own parts:
use_recipe :ember_conventions, as: :app, mode: :advisory do
bind :components, files: "app/components/**"
bind :fetchers, files: "app/services/**", kind: :symbol, where: { symbol_kind: "class" }
endNothing in the surface is Rails-specific except the rails line, which is
sugar for parts a Rails layout already names. Every other construct takes
globs, predicates and services, so a Go service, an Ember application and
a Python worker declare their laws the same way.
A convention set nobody can adopt in one line is a convention nobody
adopts, so some ship with the binary. rails-conventions is the first:
seven laws about where a Rails application's parts may reach, each
carrying its reason, bound to the repository's own directories at the
instantiation site.
use_recipe:
- recipe: rails-conventions
as: app
bind:
controllers: { match: ["app/controllers/**"] }
jobs: { match: ["app/jobs/**"] }
models: { match: ["app/models/**"] }
mailers: { match: ["app/mailers/**"] }
policies: { match: ["app/policies/**"] }
serializers: { match: ["app/serializers/**"] }
view-components: { match: ["app/components/**"] }A shipped recipe is a recipe like any other: it declares roles, its rules
carry because:, it is verdicted through the same evaluator, and its
findings cite enola:recipes as the file they came from rather than a
path that exists in no repository.
The rest describe arrangements rather than frameworks, so they apply to
any language the extractors read. layered names presentation,
application, domain and infrastructure, and holds the direction of the
calls between them. ports-and-adapters keeps a core that names ports and
never the adapters implementing them. modular-monolith holds a module's
internals private to it while letting its public surface be called.
event-driven separates publishers from handlers and asks that every
event declared has somewhere to land.
use_recipe:
- recipe: ports-and-adapters
as: billing
bind:
core: { match: ["lib/billing/**"] }
ports: { match: ["lib/billing/ports/**"] }
adapters: { match: ["lib/billing/adapters/**"] }Each one is three or four roles and three to six laws, so adopting an arrangement is a paragraph of binding rather than a file of hand-written rules, and the laws arrive already carrying the reason they exist.
Four more ship beside them. vanilla-rails is plain Rails: the extra
directories (services, forms, policies, decorators, presenters) must stay
empty, each with a stated reason, and models never reach controllers.
clean is four rings (frameworks, interface adapters, use cases,
entities) with every outward reach forbidden. cqrs splits commands,
queries and read models, and adds the one law the split exists for: a
query never calls a mutating persistence method, stated as a to_name
literal list over calls. ruby-conventions bans the get_, set_ and
is_ prefixes over whatever part the repository binds as its code.
A recipe may mark a role optional. A binding may leave it out, the
rules that reference it are expanded away for that instantiation, and
the lint surface names each law the binding did not take, so a recipe
can grow a role without breaking every repository that already binds it.
rails-conventions grew helpers and services this way: services and
models never reach helpers, services never reach controllers, and the
request API (render, redirect_to, params, session, cookies,
flash) stays out of models and services, all advisory, all in force
only where the two roles are bound.
The rule
supply-chainstates — every external dependency declared, pinned, and given a stated purpose — is H14 from Stanislav Rumega's position paper Tell Your Coding Agent to Work as an Architect First.
supply-chain is the odd one out, and worth reading as the shape rather
than the rule. It has one role and one law — every declared dependency
names a version — and the role binds to nothing the repository has to
name, because what an unpinned dependency IS is a property the facts
carry rather than a place in the tree:
use_recipe:
- recipe: supply-chain
as: supply
bind:
unpinned-dependencies: {}The role's own selector is kind: dependency with
where: {type: package, pinned: false}, over the package facts the
manifests extractor measures (see INTENT.md), and the law is a
forbid_fact over it — no new rule form, because "this set must be empty" was
already one. dependency is an opt-in member kind for the same reason
test_ref and lint are: a component that does not name it never acquires a
package, so no existing rule changes what it judges.
One caveat before adopting it: where: fails closed and loudly on a property
no measured fact carries, so a repository with no manifest enola reads gets a
1.0 selector cannot be evaluated finding rather than silence. A rule that
holds because it looked at nothing must never read as compliance — but it is
surprising the first time.
A first declaration in one command. enola constraints init [repo]
reads the shipped recipes, binds every role whose conventional directory
the repository has, and writes one use_recipe per recipe whose required
roles all resolved to enola/constraints/recipes.yaml, refusing to
overwrite. A recipe missing a required directory is not bound and the
output says which; nothing is guessed. --dry-run prints instead of
writing and --recipe NAME limits the binding to one recipe.
A repository still authors its own, under enola/recipes/, and a
local recipe of the same name replaces the shipped one entirely. What a
team wrote about its own codebase beats what arrived in a binary, and the
replacement is reported rather than silent, so nobody has to wonder which
one ran. The two laws in rails-conventions that report on a
crossing rather than a breach (jobs and models reaching a controller,
where ApplicationController.renderer is the sanctioned path) ship as
advisory for that reason.
The authoring loop. enola constraints lint parses the declaration
(repo file, enola/constraints/ files — each listed with its own
component and rule counts, plus its use_recipe: instantiations —
enola/recipes/ definitions, and any cluster override), reports every
validation problem with its file context rather than dying on the first, and —
when a snapshot exists on disk — resolves each component against it so
you see what a selector actually selects before a rule built on it
verdicts anything. No snapshot degrades to a named validation-only
mode; nothing is generated or written. Exit 1 on validation
problems, 0 otherwise.
Discovering the law instead of writing it. enola constraints mine
walks the current snapshot's fact store for near-invariants —
high-regularity properties with named exceptions — and reports each
one as a candidate constraint declaration in this vocabulary. Four
regularity families are mined:
- Prop implications (
require+when_prop_contains/must_prop_contain): facts of one kind whose prop A contains X nearly always have prop B containing Y — the company-fk shape ("storage facts whose columns contain company_id nearly always have fk_constraints containing company_id->companies"), plus the unconditional form when a prop value holds across nearly the whole kind. A conditional candidate must beat the consequent's base rate, or the antecedent added no information and the unconditional form is the honest rule. - Naming (
require_name+pattern): facts of one kind under a directory subtree nearly all matching one bounded pattern (prefix*/*suffix), mined at word boundaries and emitted only where the cluster beats the whole population's match rate. - Edge regularities (
forbid/toandallow/only): via-edges leaving a directory cluster nearly never land in some other cluster (with the actual crossings named), or land almost entirely inside a small set of clusters. A forbid candidate needs at least one would-be violation: a zero-crossing pair is indistinguishable from no opportunity and would flood the report with unevidenced law. - Method presence (
require_defines+method): plain classes (no inheritance, no mixins — the same fail-closed scope the evaluator uses) under a cluster nearly all defining one method.
Every candidate carries its regularity as a numerator/denominator,
names every exception (fact and file), and renders a would-be
declaration that constraints lint accepts verbatim — the emitted
YAML round-trips through the real parser, and the named exceptions
are exactly the violations the rule would report if adopted. The
report is ranked by confidence x support; the support floor,
confidence floor and exception ceiling are flags
(--min-support, --min-confidence, --max-exceptions), printed in
the report header, and anything below a floor is suppressed with a
count, never silently. --jsonl writes the full report as an
artifact beside the ranked text.
Every candidate also carries a stable identity — the regularity's
semantic key, built from what the rule is about (family, scope, and
the rule's own parameters: the antecedent/consequent prop pair, the
cluster and pattern, the source/target clusters and via, the cluster
and method) with the parts pipe-joined and escaped. It deliberately
excludes everything that moves between snapshots: the rank, the
numerator and denominator, the exception list, and the statement text
the numbers are printed into. Mining the same repository twice
therefore names the same regularity with the same identity even as
its numbers shift, which is what makes candidates from different runs
foldable into a time series: a regularity is the same rule observed
again, never the same rank re-occupied. The identity is exported
on every candidate line of the --jsonl artifact.
Candidates are proposals, never self-adopting law. Mining reads
an existing snapshot and writes nothing: it never generates a
snapshot, never touches enola/constraints/, never modifies a
declaration, and never feeds the check path. Adopting a candidate is
the operator's act: copy the would-be declaration into a file under
enola/constraints/, rewrite because: into the real rationale (the
mined text is evidence, not a decision), review the mode (candidates
propose advisory; graduation to ratchet or strict is a
decision), reconcile its components with ones already declared, and
commit it for review like any other law. Exit 0 when a report was
produced (even an empty one), 2 when there is no snapshot to mine.
Rules that belong in the linter start there. Some regularities
need the graph (a call edge the linker resolved, a prop implication,
a method's presence across a cluster) and some are file-local
syntax: a naming regularity over the classes, functions and
top-level bindings declared in JavaScript or TypeScript files under
a directory, or a forbidden import from one directory into
another. --scaffold-eslint DIR writes the second kind as ESLint
rule scaffolds under DIR: a rule module per candidate, a
RuleTester test whose valid cases are the candidate's conforming
witnesses and whose invalid cases are its named exceptions, and an
index.js registering them, so the directory loads as a plugin and
each file moves into the repository's own plugin unchanged. The
TypeScript extractor qualifies a symbol with its module path
(src/services.ApiError) and names members through their class
(src/commands/repo.RepoClone.description); the scaffold cuts both
down to the declaration the rule can see, and a pattern that is only
the module path is a tautology the miner no longer ranks. Every
candidate the scaffolder leaves is listed with the reason it stays
a constraint proposal. Nothing is written to the repository's plugin
and no ESLint configuration is touched: the scaffold is a starting
point the operator reviews, like the would-be declaration.
Every excuse this vocabulary admits already demands a signature. A suppression
entry names an owner, a reason and a date, and the ledger rejects as a whole if
any is missing. An exempt: carve-out names the same three beside the witness
it covers. What was never asked is the question those signatures answer
together: how much of the declared law is being excused rather than obeyed.
enola constraints ledger asks it:
law: 34 rules (28 ratchet, 4 advisory, 2 strict) · 12 breaches · 5 excused (42%) · oldest excuse 214 days · 2 excuses matched nothing
company-fk [ratchet] — 7 breaches reported, 3 excused · declared in enola/constraints/billing.yaml
because: tenant isolation rides the company FK
exemption by alice (2024-11-02, 296 days ago) — users must have fk_constraints containing company_id->companies
"legacy tables migrate in Q4"
suppression by bob (2026-06-01, 85 days ago)
"second signature while the migration lands"
Three things in that output are the point, and none of them is visible one verdict at a time.
The rate. A rule most of whose breaches are signed away is a rule to reconsider — the declaration is asking for something the codebase has decided not to do, and the team is paying a signature per occurrence to say so. Read one excuse at a time, that looks like thirty separate reasonable decisions. Read as a ratio, it is one wrong rule. The gate cannot make that judgement and should not try; what it can do is make the number available to whoever can.
The age. An excuse nobody has revisited in a year is a reason that has
probably stopped being true. since: and date: were mandatory long before
anything read them; this is what they were mandatory for.
The ones that match nothing. An excuse that excused nothing in this
snapshot is the row somebody can act on today: the breach it covered was fixed,
moved, or stopped being selected, and the signature is still standing. It is
marked inline and counted on the summary line. The snapshot's own census
already reported the same two facts as unused suppression and
exemption matching nothing; the ledger is where they acquire a denominator.
The report reads the snapshot's compiled intent facts, not the working
tree's YAML — deliberately, so both halves of every ratio come from one state
of the law. A rule declared since the snapshot was generated is therefore
absent, and the report prints the snapshot's timestamp so a reader can tell.
This is the opposite choice from plan, which answers governance from the
working tree precisely because its question is about an edit that has not
happened yet.
A report, never a gate. It exits 0 whenever it produced a report. Nothing
here changes what enola check fails on, and nothing here fails a build: a gate
that failed on its own unpopularity is the one nobody would leave enabled — the
false-alarm economy of EXPLAINERS.md applied to the gate's own
record rather than to its findings.
enola check carries the same summary as one line beside its census line, and
as a law object in --format json. A repository that declares no rules gets
no line and no object at all: undeclared is unasked, and a zeroed ledger would
read as a law with nothing wrong with it.
The contract, moved into the planning loop. enola plan (and the
plan_check MCP tool, the same code path) answers, before any edit
lands in the tree: which declared constraints govern the intended
change, what the change's blast radius is, and — for a patch — which
constraint verdicts WOULD appear if it were applied.
Three input forms:
--paths a.rb,b.rb(or positional paths): for each path, the declared components whose selectors cover it — a path nobody has written yet still answers, which is the pre-edit point — with every rule binding them (statement, mode,because:, declaring file), plus the path's blast radius: fan-in and fan-out over the current snapshot's rule-via edges, exact counts with capped, sorted samples.--symbols X,Y: the same, keyed by exact fact name. A name nothing measured carries is reported as unmeasured, never guessed at.--patch change.diff: the counterfactual. The unified diff is applied to a scratch copy of the repository — the working tree and its.enolaare never touched — facts are regenerated over the scratch tree and over the unpatched tree, the constraints engine verdicts both, and the delta is reported in three buckets: new (violations the patch would introduce, each naming the rule, the would-be witness, and itsbecause:), resolved (violations the patch would clear), and unchanged. A patch that does not apply, or that touches files outside the snapshot's scope, is a named error, never a guess.
--json emits the report as a stable machine-readable document —
targets with their governing rules and blast radius, the snapshot's
generation timestamp and staleness, and the counterfactual buckets —
which is the agent-facing contract.
Honesty rules, same as everywhere else in this vocabulary: an
identical plan against an identical snapshot renders byte-identically
(everything is sorted); when no rule governs a target the report says
so explicitly rather than staying silent; when the on-disk snapshot no
longer matches the working tree the report states the staleness
(generation timestamp plus the drifted files) instead of silently
answering from old facts. Governance answers from the working tree's
declarations (enola-intent.yaml plus enola/constraints/), so an
edit to the law is visible without regenerating a snapshot.
A report, never a gate. Like enola check, the verdict is for
the caller to weigh: plan exits 0 whenever a report was produced —
counterfactual violations included — and 2 only when it could not
run (a patch that does not apply, --symbols with no snapshot, an
invalid declaration). It never writes into the target tree, never
mutates the repo's .enola, and the counterfactual's scratch
materialization is deleted when the call returns.
The agent workflow this is built for:
enola plan --paths <files you intend to touch>(orplan_check) — read the governing rules and the blast radius before writing anything.- Shape the change so it satisfies the contract; for a concrete
patch,
enola plan --patch change.diffnames the rule any violating edge would breach while the tree is still clean. - Make the edit.
enola checkafter — the gate confirms what the plan predicted.
This ordering is the point: the self-correction benchmark measures that violations drop sharply when the contract is in reach at planning time rather than at the CI gate, and plan-check is that contract as a first-class query.