enolaDocsPolicy as Code

Documentation

Policy as Code

Constraints turn architectural boundaries into reviewed rules. Enola checks them against the measured graph, and every finding says why the rule exists.

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.

A Markdown decision page that carries components: or rules: is a validation error. Pages reference rule ids; the rules themselves are reviewed as code.

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.

enola-intent.yamlyaml
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:

Validate the declarationshell
$ enola constraints lint .
Component resolution against the snapshot at ./.enola:
  notify                   2 member(s)
  storage                  2 member(s)

OK — every declaration is valid.
Watch for 0 member(s). A component that selects nothing is valid YAML and lints clean, but a rule built on it can never be violated. Enola also reports it as a standing advisory so empty compliance never reads as compliance.

Then make it a gate. Declared rules are checked at confidence 1.00:

Fail the check on a breachshell
$ 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.

QuestionForms
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:.

Three common formsyaml
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.

enola/constraints/billing.yamlyaml
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

ModeBehavior
ratchetDefault. Breaches report at 1.00 and the gate fails new ones; violations already in the baseline stay silent.
advisoryBreaches report at 0.90, below the gate’s floor. The rule informs without failing.
strictEvery breach fails the check, including ones the baseline already carried.

When a rule is breached, reach for the earliest option that is true:

  1. Fix it. The code is wrong and the rule stands.
  2. 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.
  3. 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.
  4. Leave it to the ratchet. A violation present in the baseline stays silent under the default mode.
A declared exemptionyaml
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.

Pre-edit contractshell
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 tree

A 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

CommandWhat it answers
constraints lintIs 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 mineWhich 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 ledgerWhich 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/constraints/storefront.rbruby
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
end

09 · 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.