01 · Where rules live
Store constraints with the repository
Intent describes the expected architecture. Constraints define which relationships are allowed, and Enola checks them against each snapshot. A declaration has two parts: components name sets of measured facts, and rules define what may happen between them.
Rules live in files, never on decision pages. Put a small declaration in enola-intent.yaml. When it grows, split it into per-domain files under enola/constraints/*.yaml. Enola merges them in filename order, reports a name declared twice as an error that names both files, and cites the declaring file in every finding. That makes CODEOWNERS work: each domain’s file routes to the team that owns it.
02 · First rule
Write one rule, check it, enforce it
Two components and one rule are a complete declaration. because: is required, and every finding the rule produces carries it, so a violation explains why the boundary exists.
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"Lint before you rely on it. With a snapshot on disk, constraints lint resolves each component and shows how many facts it selects:
$ enola constraints lint . Component resolution against the snapshot at ./.enola: notify 2 member(s) storage 2 member(s) OK — every declaration is valid.
Then make it a gate. Declared rules are checked at confidence 1.00:
$ 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
storage/storage.go:3
import "layersgate/notify"03 · Rule forms
What a rule can say
There are 21 rule forms. Most declarations need only a few of them. Edge rules name a via: from a closed vocabulary: calls, depends_on, implements or imports.
| Question | Forms |
|---|---|
| Who may reach whom? | forbid, allow, protect, private, forbid_reach, forbid_cycles |
| What must exist, or stay empty? | require_edge, forbid_fact, cap |
| What must members carry or be called? | require, require_defines, require_name, forbid_name |
| In what order must calls happen? | protocol |
| What should an author consider? | guide (advice, never a violation) |
Path selectors accept an exact path, a prefix/** subtree, or a **/name basename. Other patterns fail validation. Components can also filter by fact kind, name, measured properties with where:, or repository with service:.
rules:
- id: web-through-services
allow: web
only: [services]
via: calls
because: "controllers orchestrate; they never reach storage directly"
- id: pack-internals
private: billing
except: [payments]
because: "only the pack's public surface is a contract"
- id: jobs-named-job
require_name: jobs
pattern: "*Job"
because: "the scheduler discovers jobs by their suffix"04 · Recipes
Adopt a named pattern instead of writing it
A recipe is a recurring arrangement written once: role slots plus rules that already carry their reasons. You bind each role to your own directories. Enola ships layered, clean, ports-and-adapters, modular-monolith, event-driven, cqrs, rails-conventions, vanilla-rails, ruby-conventions and supply-chain.
use_recipe:
- recipe: ports-and-adapters
as: billing
bind:
core: { match: ["lib/billing/**"] }
ports: { match: ["lib/billing/ports/**"] }
adapters: { match: ["lib/billing/adapters/**"] }enola constraints init binds shipped recipes whose required roles match existing directories. Repositories with custom layouts may produce no bindings; bind their roles manually or write rules directly. A recipe under enola/recipes/ replaces a shipped recipe with the same name.
05 · Modes and exceptions
Decide how strictly each rule holds
| Mode | Behavior |
|---|---|
| ratchet | Default. Breaches report at 1.00 and the gate fails new ones; violations already in the baseline stay silent. |
| advisory | Breaches report at 0.90, below the gate’s floor. The rule informs without failing. |
| strict | Every breach fails the check, including ones the baseline already carried. |
When a rule is breached, reach for the earliest option that is true:
- Fix it. The code is wrong and the rule stands.
- Exempt it. An exempt: entry on the rule names the exact violation, an owner, a reason and a date. The exemption stays with the rule and is reported on every run.
- Suppress it. The violation is real but must not block while it is worked off. Record it in .enola/suppressions.yaml with an owner, reason and date. Enola reads this file but never writes it.
- Leave it to the ratchet. A violation present in the baseline stays silent under the default mode.
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"
since: "2026-08-01"An exemption that no longer matches a violation is reported as dead. A guide rule attaches advice and examples to a component. It appears in enola check output for affected changes and does not alter the exit code.
06 · Before the edit
Ask which rules govern a change
enola plan, and the plan_check MCP tool that shares its code path, answers before anything is written: which rules cover these paths, what depends on them, and which violations a patch would introduce. It reads the working tree’s declarations, so an edit to the rules is visible without a new snapshot.
enola plan --paths app/domain/orders,app/domain/billing
enola plan --symbols payments.Authorize
enola plan --patch change.diff # applied to a scratch copy, never the working treeA patch plan reports violations the change would add, clear or leave unchanged. plan is a report, not a gate: it exits 0 whenever it produced a report, and 2 only when it could not run. Run enola check after the edit to confirm what the plan predicted.
07 · Maintain
Review constraints over time
| Command | What it answers |
|---|---|
| constraints lint | Is every declaration valid, and what does each component select? Reports every problem with its file, not only the first. Exits 1 on validation problems. |
| constraints explain <path> | Which components admit this file, through which selector, and which edges its facts make. |
| constraints mine | Which near-invariants does the snapshot already show? Each candidate names its exceptions and prints a declaration lint accepts. Candidates are proposals; mining never writes a rule. |
| constraints ledger | Which violations are exempted or suppressed? Lists each rule’s breaches, exemptions, and suppressions with owners and ages, and flags entries that match nothing. This command reports results and never fails a build. |
When adopting a mined candidate, replace its because: text with the project’s reason and select a mode. Candidates start as advisory.
08 · Ruby
Declare constraints in Ruby
Teams that write Ruby can declare rules in .rb files under enola/constraints/. Enola parses these files without executing them. They compile to the same declaration as YAML and use the same evaluator. rails declares the conventional Rails components, so a team only needs to name its own.
Enola.architecture "storefront" do
rails
law "background jobs never invoke controller code" do
jobs.must_not_call controllers
why "rendering from a job goes through ApplicationController.renderer"
end
end09 · Full reference
Use the canonical reference for authoring
Every rule form with its options, the full selector dialect, where: predicates and ownership, recipe definitions, cross-repo rules, the Ruby verb table and the Rails catalogue are in CONSTRAINTS.md. For how intent is declared and which files carry it, see Intent.