enola-guides 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +4 -2
  4. data/examples/laws/README.md +32 -0
  5. data/examples/laws/clean-rings/README.md +10 -0
  6. data/examples/laws/clean-rings/enola/constraints/clean.yaml +8 -0
  7. data/examples/laws/cluster-laws/README.md +9 -0
  8. data/examples/laws/cluster-laws/enola/constraints/a-mutating-action-is-authorized.rb +10 -0
  9. data/examples/laws/cluster-laws/enola/constraints/every-api-route-has-a-consumer.rb +9 -0
  10. data/examples/laws/cluster-laws/enola/constraints/one-owner-per-table.rb +9 -0
  11. data/examples/laws/cqrs-queries-never-write/README.md +11 -0
  12. data/examples/laws/cqrs-queries-never-write/enola/constraints/cqrs.yaml +7 -0
  13. data/examples/laws/foreign-keys/README.md +10 -0
  14. data/examples/laws/foreign-keys/enola/constraints/foreign-keys.yaml +12 -0
  15. data/examples/laws/governed-code/README.md +11 -0
  16. data/examples/laws/governed-code/enola/constraints/governance.rb +8 -0
  17. data/examples/laws/instantiation/README.md +14 -0
  18. data/examples/laws/instantiation/enola/constraints/ceremony.yaml +11 -0
  19. data/examples/laws/maintenance-tasks/README.md +9 -0
  20. data/examples/laws/maintenance-tasks/enola/constraints/a-maintenance-task-never-invokes-controller-code.rb +9 -0
  21. data/examples/laws/maintenance-tasks/enola/constraints/every-maintenance-task-lives-in-the-maintenance-namespace.rb +8 -0
  22. data/examples/laws/mixins-stay-independent/README.md +11 -0
  23. data/examples/laws/mixins-stay-independent/enola/constraints/concerns.yaml +9 -0
  24. data/examples/laws/naming-conventions/README.md +10 -0
  25. data/examples/laws/naming-conventions/enola/constraints/naming.yaml +5 -0
  26. data/examples/laws/one-door/README.md +8 -0
  27. data/examples/laws/one-door/enola/constraints/one-door.yaml +18 -0
  28. data/examples/laws/one-owner-per-table/README.md +12 -0
  29. data/examples/laws/one-owner-per-table/enola/constraints/tables.rb +8 -0
  30. data/examples/laws/paired-names/README.md +9 -0
  31. data/examples/laws/paired-names/enola/constraints/pairs.yaml +11 -0
  32. data/examples/laws/parts-never-cycle/README.md +12 -0
  33. data/examples/laws/parts-never-cycle/enola/constraints/cycles.yaml +12 -0
  34. data/examples/laws/protocol-any-of/README.md +10 -0
  35. data/examples/laws/protocol-any-of/enola/constraints/entry.yaml +8 -0
  36. data/examples/laws/public-surface/README.md +11 -0
  37. data/examples/laws/public-surface/enola/constraints/surface.yaml +10 -0
  38. data/examples/laws/public-tool-prefix/README.md +8 -0
  39. data/examples/laws/public-tool-prefix/enola/constraints/public-tools.rb +8 -0
  40. data/examples/laws/rails-forbidden-names/README.md +14 -0
  41. data/examples/laws/rails-forbidden-names/enola/constraints/naming.yaml +13 -0
  42. data/examples/laws/rails-mutations-are-authorized/README.md +11 -0
  43. data/examples/laws/rails-mutations-are-authorized/enola/constraints/authorization.rb +9 -0
  44. data/examples/laws/rails-request-boundaries/README.md +12 -0
  45. data/examples/laws/rails-request-boundaries/enola/constraints/rails.yaml +13 -0
  46. data/examples/laws/rails-strict-arrangement/README.md +10 -0
  47. data/examples/laws/rails-strict-arrangement/enola/constraints/strict.yaml +12 -0
  48. data/examples/laws/readers-read-the-store-only/README.md +7 -0
  49. data/examples/laws/readers-read-the-store-only/enola/constraints/readers.yaml +13 -0
  50. data/examples/laws/receiverless-request-api/README.md +10 -0
  51. data/examples/laws/receiverless-request-api/enola/constraints/bare.yaml +10 -0
  52. data/examples/laws/resolved-ancestry/README.md +15 -0
  53. data/examples/laws/resolved-ancestry/enola/constraints/records.yaml +11 -0
  54. data/examples/laws/routes-have-consumers/README.md +12 -0
  55. data/examples/laws/routes-have-consumers/enola/constraints/consumers.rb +8 -0
  56. data/examples/laws/storage-stays-home/README.md +12 -0
  57. data/examples/laws/storage-stays-home/enola/constraints/ownership.rb +9 -0
  58. data/examples/laws/vanilla-rails-stays-empty/README.md +10 -0
  59. data/examples/laws/vanilla-rails-stays-empty/enola/constraints/vanilla.yaml +11 -0
  60. data/examples/rails-shop/enola/constraints/a-concern-never-reaches-the-class-that-includes-it.rb +9 -0
  61. data/examples/rails-shop/enola/constraints/a-mutating-action-is-authorized.rb +10 -0
  62. data/examples/rails-shop/enola/constraints/a-service-answers-to-call.rb +9 -0
  63. data/examples/rails-shop/enola/constraints/background-jobs-never-invoke-controller-code.rb +9 -0
  64. data/examples/rails-shop/enola/constraints/billing-is-documented.rb +10 -0
  65. data/examples/rails-shop/enola/constraints/billing-keeps-to-its-own-tables.rb +9 -0
  66. data/examples/rails-shop/enola/constraints/frames-keep-their-query-budget.rb +9 -0
  67. data/examples/rails-shop/enola/constraints/jobs-models-and-mailers-never-depend-on-each-other-in-a-circle.rb +9 -0
  68. data/examples/rails-shop/enola/constraints/orders-are-reached-through-their-public-surface.rb +9 -0
  69. data/examples/rails-shop/enola/constraints/reach-for-an-existing-service.rb +8 -0
  70. data/examples/rails-shop/enola/constraints/scopes-come-in-pairs.rb +9 -0
  71. data/examples/rails-shop/enola/constraints/the-public-api-stays-small.rb +10 -0
  72. data/examples/rails-shop/enola/constraints/the-request-api-stays-in-controllers.rb +9 -0
  73. data/lib/enola-guides.rb +1 -1
  74. data/templates/constraints-starter/background-jobs-never-invoke-controller-code.rb +9 -0
  75. data/templates/constraints-starter/models-do-not-reach-controllers.rb +9 -0
  76. data/templates/constraints-starter/policies-only-answer.rb +9 -0
  77. data/templates/constraints-starter/the-request-api-stays-in-controllers.rb +9 -0
  78. metadata +74 -3
  79. data/examples/rails-shop/enola/constraints/laws.rb +0 -93
  80. data/templates/constraints-starter.rb +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4da894ee2a8bbae62bfd0c91d741184cb544ab52c7daa172cc4d29d6760b0e88
4
- data.tar.gz: 57a1e3127acc18cfae45d00e8d68d980ca5884dea25c0edd616fff96816b4c02
3
+ metadata.gz: df9b7b5b2b3fc6589f9353c4f2198b08283fe80fafd2dacda91dea2d66c5a2fb
4
+ data.tar.gz: 20172bf3167db5fb3d690d401f3313d38861d10f86f7010607c4ccae98353b68
5
5
  SHA512:
6
- metadata.gz: 30fbd5e723192833b38e5e7108cdfab7dc48c8604a614c58a07c1d3c39c8fef2c3f4c941667fd67011fa93865d75891d0804479cbf79b909d3d42cad41c014c4
7
- data.tar.gz: 8cb01c56994da025c7493b7c8e7ecfa9197b5f19a331c4722cbe9776e2a32d713514251ce1d6d0cae437b24707bf38b901682e0db101f20222281693833d5ba6
6
+ metadata.gz: f705dc88218a61a94d6261b624e6744d8fd14212e6524b062f2f9f9520751013968f4a208382861516265ac4ca41db11ca483e09acf52792120aa704f01251a0
7
+ data.tar.gz: 65b0c4c5cd94ca30b85d3528737489c83c0ff73b8a7b3402dd2d39c45742af8ca679cfaef2ec04ea8837e3ac5d92a1881960b2a7429df198e758ef7b7d8c8435
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 (2026-08-22)
4
+
5
+ One law per file: the Rails shop, the cluster laws, the maintenance-task laws
6
+ and the starter each put every law in a file of its own that declares the parts it
7
+ speaks about, so a reader finds a law by its name and a reviewer sees one
8
+ law per diff.
9
+
10
+ ## 0.1.1 (2026-08-22)
11
+
12
+ The laws catalogue: `examples/laws/`, twenty-six complete declarations one
13
+ directory each, nineteen proven by enola's benchmark suite and seven
14
+ generalised from daily use (foreign keys, maintenance tasks, a public tool
15
+ prefix, one door to a schema, readers that read the store only, cluster
16
+ laws), each with a README and verified with `constraints lint`.
17
+
3
18
  ## 0.1.0 (2026-08-22)
4
19
 
5
20
  First content. Eight guides (start here, a Rails application, an Ember
data/README.md CHANGED
@@ -18,11 +18,13 @@ gem contents enola-guides
18
18
  ## Start with an example
19
19
 
20
20
  [`examples/rails-shop`](examples/rails-shop): a Rails application declaring
21
- thirteen laws in the Ruby DSL, from "jobs never invoke controller code" to
21
+ thirteen laws in the Ruby DSL, one file each, from "jobs never invoke controller code" to
22
22
  "billing keeps to its own tables" and "a mutating action is authorized",
23
23
  with Prism and Rubydex configured as providers beside the extractors.
24
24
  [`examples/ember-app`](examples/ember-app): an Ember application bound to
25
- the conventions recipe in one block.
25
+ the conventions recipe in one block. [`examples/laws`](examples/laws/README.md):
26
+ twenty-six laws, one directory each, from foreign keys on a tenant column
27
+ to parts that may not cycle, each a complete declaration you copy and run.
26
28
 
27
29
  ## Guides
28
30
 
@@ -0,0 +1,32 @@
1
+ # Laws, one directory each
2
+
3
+ Every directory holds a complete declaration you can copy into a repository's
4
+ `enola/` and run. Most are proven by enola's own benchmark suite; the rest are
5
+ generalised from laws in daily use on a multi-repository estate.
6
+
7
+ - [`governed-code`](governed-code/README.md): The knowledge pages compile into the graph as anchors, and a law demands that every file of a part has one.
8
+ - [`rails-forbidden-names`](rails-forbidden-names/README.md): A naming convention phrased as a prohibition.
9
+ - [`rails-mutations-are-authorized`](rails-mutations-are-authorized/README.md): The code behind a mutating route is selected by the route's method through the handled_by edge the Rails binder draws, and must reach a policy.
10
+ - [`rails-request-boundaries`](rails-request-boundaries/README.md): The Rails laws a team actually writes.
11
+ - [`rails-strict-arrangement`](rails-strict-arrangement/README.md): The strict Rails arrangement: the Rails laws, no circle among the parts, and concerns that stay independent of their includers.
12
+ - [`clean-rings`](clean-rings/README.md): Four rings.
13
+ - [`cqrs-queries-never-write`](cqrs-queries-never-write/README.md): A command-query split.
14
+ - [`instantiation`](instantiation/README.md): What Ruby code constructs, as a fact.
15
+ - [`mixins-stay-independent`](mixins-stay-independent/README.md): A module never reaches the classes that include it.
16
+ - [`naming-conventions`](naming-conventions/README.md): Ruby naming conventions: no get_, set_ or is_ prefixes; a predicate ends in a question mark and has_ is fine.
17
+ - [`one-owner-per-table`](one-owner-per-table/README.md): unique_across compares the members of different repositories; with the storage facts of one repository there is nobody to share a table with, so the rule refuses by name rather than reporting every table as unique.
18
+ - [`paired-names`](paired-names/README.md): A naming pair: every with_ method has a without_ twin.
19
+ - [`parts-never-cycle`](parts-never-cycle/README.md): Parts that may not depend on each other in a circle.
20
+ - [`protocol-any-of`](protocol-any-of/README.md): A protocol satisfied by one of several methods.
21
+ - [`public-surface`](public-surface/README.md): A path-defined public surface.
22
+ - [`receiverless-request-api`](receiverless-request-api/README.md): A literal far end narrowed to receiver-less calls.
23
+ - [`resolved-ancestry`](resolved-ancestry/README.md): Ancestry a provider resolved, and a component selected by it.
24
+ - [`routes-have-consumers`](routes-have-consumers/README.md): A law across repositories refuses by name inside one.
25
+ - [`storage-stays-home`](storage-stays-home/README.md): A part keeps to the tables it owns.
26
+ - [`vanilla-rails-stays-empty`](vanilla-rails-stays-empty/README.md): Plain Rails: the extra directories must stay empty, each with a stated reason.
27
+ - [`foreign-keys`](foreign-keys/README.md): A foreign-key law reads the storage facts the schema yields: every table carrying a tenant column must declare the foreign key to the tenant table.
28
+ - [`maintenance-tasks`](maintenance-tasks/README.md): Two laws for a maintenance-tasks directory: a naming convention the runner depends on, and a boundary against controller code that has no request to read.
29
+ - [`public-tool-prefix`](public-tool-prefix/README.md): A naming law on an externally exposed surface: every class in the public tools directory carries the prefix, so the surface stays greppable and a public class can never shadow an internal one.
30
+ - [`one-door`](one-door/README.md): A boundary with one sanctioned crossing: controllers may not reach the GraphQL schema, except the single execute action, named as an exemption with its owner and reason so the carve-out is a decision and not a hole.
31
+ - [`readers-read-the-store-only`](readers-read-the-store-only/README.md): The allow form: a part's calls may land only in the named parts.
32
+ - [`cluster-laws`](cluster-laws/README.md): Laws that only a cluster snapshot can answer: a table owned by two repositories, mutating actions selected through the routes they handle, and routes no loaded client calls.
@@ -0,0 +1,10 @@
1
+ # clean rings
2
+
3
+ Four rings. An entity that calls a use case and a use case that calls an
4
+ adapter both point outward; the controller calling inward is the direction the
5
+ arrangement allows. The shipped clean recipe names the two outward reaches.
6
+
7
+ Declaration: `enola/constraints/clean.yaml`.
8
+
9
+ Verify it on your own tree: copy `enola/` into the repository, run
10
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,8 @@
1
+ use_recipe:
2
+ - recipe: clean
3
+ as: clean
4
+ bind:
5
+ frameworks: { match: ["app/controllers/**"] }
6
+ interface-adapters: { match: ["app/adapters/**"] }
7
+ use-cases: { match: ["app/use_cases/**"] }
8
+ entities: { match: ["app/entities/**"] }
@@ -0,0 +1,9 @@
1
+ # cluster laws
2
+
3
+ Laws that only a cluster snapshot can answer: a table owned by two
4
+ repositories, mutating actions selected through the routes they handle, and
5
+ routes no loaded client calls. Each refuses by name in a single-repository
6
+ snapshot rather than reporting silence as compliance.
7
+
8
+ One file per law under `enola/constraints/`, each file declaring the parts it speaks about. Copy it, run `enola constraints lint .`,
9
+ then `enola --generate . && enola check .`.
@@ -0,0 +1,10 @@
1
+ Enola.architecture "platform" do
2
+ part :mutating_actions, service: :backend, files: "app/controllers/**", handles: [:post, :put, :patch, :delete]
3
+ part :policies, service: :backend, files: "app/policies/**"
4
+
5
+ law "a mutating action is authorized" do
6
+ mutating_actions.must_reach :policies
7
+ why "an action behind a POST, PUT, PATCH or DELETE route changes state, and the policy call is the one place the change is authorized"
8
+ mode :advisory
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ Enola.architecture "platform" do
2
+ part :api, service: :backend, files: "config/**", kind: :route
3
+
4
+ law "every api route has a consumer" do
5
+ api.must_have_consumer
6
+ why "a route nobody calls is a surface nobody maintains"
7
+ mode :advisory
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ Enola.architecture "platform" do
2
+ part :tables, files: "app/models/**", kind: :storage
3
+
4
+ law "one owner per table" do
5
+ tables.must_be_unique_across by: :table
6
+ why "a table two repositories both model is written by two codebases that never see each other's validations or callbacks"
7
+ mode :advisory
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # cqrs queries never write
2
+
3
+ A command-query split. A query that calls update_all writes; a read model that
4
+ issues a command is a feedback loop; a query that only counts is clean. The
5
+ shipped cqrs recipe names the two breaches by the mutating method and the
6
+ command reached.
7
+
8
+ Declaration: `enola/constraints/cqrs.yaml`.
9
+
10
+ Verify it on your own tree: copy `enola/` into the repository, run
11
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,7 @@
1
+ use_recipe:
2
+ - recipe: cqrs
3
+ as: cqrs
4
+ bind:
5
+ commands: { match: ["app/commands/**"] }
6
+ queries: { match: ["app/queries/**"] }
7
+ read-models: { match: ["app/read_models/**"] }
@@ -0,0 +1,10 @@
1
+ # foreign keys
2
+
3
+ A foreign-key law reads the storage facts the schema yields: every table
4
+ carrying a tenant column must declare the foreign key to the tenant table. The
5
+ require form verdicts what a member fact carries, with a when antecedent
6
+ narrowing to the tables the law is about, so a table without the column is
7
+ never asked.
8
+
9
+ Declaration under `enola/constraints/`. Copy it, run `enola constraints lint .`,
10
+ then `enola --generate . && enola check .`.
@@ -0,0 +1,12 @@
1
+ components:
2
+ - name: tables
3
+ match: ["db/structure.sql", "db/schema.rb", "app/models/**"]
4
+ kind: storage
5
+
6
+ rules:
7
+ - id: account-fk
8
+ require: tables
9
+ when_prop_contains: { prop: columns, value: account_id }
10
+ must_prop_contain: { prop: fk_constraints, value: "account_id->accounts" }
11
+ mode: advisory
12
+ because: "A table carrying account_id references an account, so the database should say so with a foreign key. Advisory while a backfill path that ships the column before the constraint is reconciled with the rule; the verdict names every table that carries the column without the key."
@@ -0,0 +1,11 @@
1
+ # governed code
2
+
3
+ The knowledge pages compile into the graph as anchors, and a law demands that
4
+ every file of a part has one. The charge service is anchored by the decision
5
+ page; the refund service is not, and is named. Without any compiled page the
6
+ rule would refuse rather than call everything ungoverned.
7
+
8
+ Declaration: `enola/constraints/governance.rb`.
9
+
10
+ Verify it on your own tree: copy `enola/` into the repository, run
11
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,8 @@
1
+ Enola.architecture "shop" do
2
+ part :billing, files: "app/billing/**"
3
+
4
+ law "billing is governed" do
5
+ billing.must_be_governed
6
+ why "code under a decision needs the page that decided it"
7
+ end
8
+ end
@@ -0,0 +1,14 @@
1
+ # instantiation
2
+
3
+ What Ruby code constructs, as a fact. `new` on a literal constant is an
4
+ instantiates relation from the calling member to the class, which is the one
5
+ call whose result has a knowable type; `new` on a variable receiver names no
6
+ class and records nothing. A construction whose result is immediately sent a
7
+ message is the one-shot ceremony, named on the calling member as Class.method,
8
+ so a component can select the members that perform it and a rule can forbid
9
+ it. A construction bound to a local first is not the ceremony.
10
+
11
+ Declaration: `enola/constraints/ceremony.yaml`.
12
+
13
+ Verify it on your own tree: copy `enola/` into the repository, run
14
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,11 @@
1
+ components:
2
+ - name: one-shot-builders
3
+ kind: symbol
4
+ match: ["app/services/**"]
5
+ where:
6
+ one_shot_call: "Payments::Charge.call"
7
+
8
+ rules:
9
+ - id: no-one-shot-charge
10
+ forbid_fact: one-shot-builders
11
+ because: "a charge is built and called in one breath, so nobody can hold it to test it"
@@ -0,0 +1,9 @@
1
+ # maintenance tasks
2
+
3
+ Two laws for a maintenance-tasks directory: a naming convention the runner
4
+ depends on, and a boundary against controller code that has no request to
5
+ read. Both were mined from a codebase where they already held, which is the
6
+ right moment to declare a law.
7
+
8
+ One file per law under `enola/constraints/`, each file declaring the parts it speaks about. Copy it, run `enola constraints lint .`,
9
+ then `enola --generate . && enola check .`.
@@ -0,0 +1,9 @@
1
+ Enola.architecture "app" do
2
+ part :tasks, files: "app/tasks/**"
3
+ part :controllers, files: "app/controllers/**"
4
+
5
+ law "a maintenance task never invokes controller code" do
6
+ tasks.must_not_call controllers
7
+ why "a task runs from the runner or the console with no request around it, so controller code reached from one has nothing to read"
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ Enola.architecture "app" do
2
+ part :maintenance_tasks, files: "app/tasks/**", kind: :symbol, where: { symbol_kind: "class" }
3
+
4
+ law "every maintenance task lives in the Maintenance namespace" do
5
+ maintenance_tasks.names_must_match "Maintenance::*"
6
+ why "the task runner resolves task constants from that namespace, so a task declared outside it never appears in the runner and cannot be run by anyone"
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ # mixins stay independent
2
+
3
+ A module never reaches the classes that include it. Taggable calls Post, which
4
+ includes it; Auditable calls Ledger, which does not. Who includes whom comes
5
+ from the resolved ancestry the rubydex provider emits, so the rule refuses
6
+ with a named cause on a snapshot without it and names Taggable on one with it.
7
+
8
+ Declaration: `enola/constraints/concerns.yaml`.
9
+
10
+ Verify it on your own tree: copy `enola/` into the repository, run
11
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,9 @@
1
+ components:
2
+ - name: concerns
3
+ match: ["app/models/concerns/**"]
4
+ kind: symbol
5
+ where: { symbol_kind: interface }
6
+ rules:
7
+ - id: mixins-stay-independent
8
+ independent: concerns
9
+ because: "a mixin that knows its includer is half a class in hiding"
@@ -0,0 +1,10 @@
1
+ # naming conventions
2
+
3
+ Ruby naming conventions: no get_, set_ or is_ prefixes; a predicate ends in a
4
+ question mark and has_ is fine. Three offenders and two clean names in one
5
+ class.
6
+
7
+ Declaration: `enola/constraints/naming.yaml`.
8
+
9
+ Verify it on your own tree: copy `enola/` into the repository, run
10
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,5 @@
1
+ use_recipe:
2
+ - recipe: ruby-conventions
3
+ as: ruby
4
+ bind:
5
+ code: { match: ["app/**"], kind: symbol, where: { symbol_kind: method } }
@@ -0,0 +1,8 @@
1
+ # one door
2
+
3
+ A boundary with one sanctioned crossing: controllers may not reach the GraphQL
4
+ schema, except the single execute action, named as an exemption with its owner
5
+ and reason so the carve-out is a decision and not a hole.
6
+
7
+ Declaration under `enola/constraints/`. Copy it, run `enola constraints lint .`,
8
+ then `enola --generate . && enola check .`.
@@ -0,0 +1,18 @@
1
+ components:
2
+ - name: controllers
3
+ match: ["app/controllers/**"]
4
+ - name: schema
5
+ match: ["app/graphql/**"]
6
+
7
+ rules:
8
+ - id: controllers-reach-the-schema-through-one-door
9
+ forbid: controllers
10
+ to: schema
11
+ via: calls
12
+ mode: advisory
13
+ exempt:
14
+ - witness: "Api::GraphqlController#execute -> AppSchema.execute via calls"
15
+ owner: platform
16
+ since: "2026-08-22"
17
+ because: "the one sanctioned entry point"
18
+ because: "Controllers reach the schema through exactly one door, the execute action; every other controller stays out of it. A second door is a second place to forget authentication."
@@ -0,0 +1,12 @@
1
+ # one owner per table
2
+
3
+ unique_across compares the members of different repositories; with the storage
4
+ facts of one repository there is nobody to share a table with, so the rule
5
+ refuses by name rather than reporting every table as unique. The verdict that
6
+ names two owners needs the cluster, which the harness measures per repository
7
+ by design.
8
+
9
+ Declaration: `enola/constraints/tables.rb`.
10
+
11
+ Verify it on your own tree: copy `enola/` into the repository, run
12
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,8 @@
1
+ Enola.architecture "shop" do
2
+ part :tables, files: "app/models/**", kind: :storage
3
+
4
+ law "one owner per table" do
5
+ tables.must_be_unique_across by: :table
6
+ why "two writers to one table disagree in the end"
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # paired names
2
+
3
+ A naming pair: every with_ method has a without_ twin. with_history has its
4
+ twin, with_guests does not, archive is outside the pattern and never asked.
5
+
6
+ Declaration: `enola/constraints/pairs.yaml`.
7
+
8
+ Verify it on your own tree: copy `enola/` into the repository, run
9
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,11 @@
1
+ components:
2
+ - name: chat
3
+ match: ["app/chat/**"]
4
+ kind: symbol
5
+ where: { symbol_kind: method }
6
+ rules:
7
+ - id: paired-scopes
8
+ require_name: chat
9
+ pattern: "with_*"
10
+ requires: "without_*"
11
+ because: "a reader who finds with_ expects without_ beside it"
@@ -0,0 +1,12 @@
1
+ # parts never cycle
2
+
3
+ Parts that may not depend on each other in a circle. Jobs reach models and
4
+ models reach jobs, so the two form a cycle at the part level; mailers sit
5
+ downstream of models only. The rule contracts the module graph to one node per
6
+ declared part, admits the symbol-rollup edges Ruby has, and names the circle;
7
+ the downstream part is not in it.
8
+
9
+ Declaration: `enola/constraints/cycles.yaml`.
10
+
11
+ Verify it on your own tree: copy `enola/` into the repository, run
12
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,12 @@
1
+ components:
2
+ - name: jobs
3
+ match: ["app/jobs/**"]
4
+ - name: models
5
+ match: ["app/models/**"]
6
+ - name: mailers
7
+ match: ["app/mailers/**"]
8
+ rules:
9
+ - id: parts-never-cycle
10
+ forbid_cycles: jobs
11
+ among: [models, mailers]
12
+ because: "parts that reach each other in a circle cannot be taken apart"
@@ -0,0 +1,10 @@
1
+ # protocol any of
2
+
3
+ A protocol satisfied by one of several methods. Charge defines call, Refund
4
+ defines run, Reconcile defines neither; any_of accepts either door and names
5
+ the class with none, listing what would have satisfied it.
6
+
7
+ Declaration: `enola/constraints/entry.yaml`.
8
+
9
+ Verify it on your own tree: copy `enola/` into the repository, run
10
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,8 @@
1
+ components:
2
+ - name: services
3
+ match: ["app/services/**"]
4
+ rules:
5
+ - id: entry-point
6
+ require_defines: services
7
+ any_of: [call, run]
8
+ because: "a service answers to one of two doors; a class with neither is one nobody can invoke"
@@ -0,0 +1,11 @@
1
+ # public surface
2
+
3
+ A path-defined public surface. Ruby has no visibility keyword the extractor
4
+ can read at the file level, so a component names the files that are its public
5
+ surface; reaching a public file from outside is fine and reaching an internal
6
+ one is the breach.
7
+
8
+ Declaration: `enola/constraints/surface.yaml`.
9
+
10
+ Verify it on your own tree: copy `enola/` into the repository, run
11
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,10 @@
1
+ components:
2
+ - name: billing
3
+ match: ["app/billing/**"]
4
+ public: ["app/billing/public/**"]
5
+ - name: orders
6
+ match: ["app/orders/**"]
7
+ rules:
8
+ - id: billing-surface
9
+ private: billing
10
+ because: "billing is reached through its public files; the rest is implementation"
@@ -0,0 +1,8 @@
1
+ # public tool prefix
2
+
3
+ A naming law on an externally exposed surface: every class in the public tools
4
+ directory carries the prefix, so the surface stays greppable and a public
5
+ class can never shadow an internal one.
6
+
7
+ Declaration under `enola/constraints/`. Copy it, run `enola constraints lint .`,
8
+ then `enola --generate . && enola check .`.
@@ -0,0 +1,8 @@
1
+ Enola.architecture "app" do
2
+ part :public_tools, files: "app/public_tools/**", kind: :symbol, where: { symbol_kind: "class" }
3
+
4
+ law "public tools carry the Public prefix" do
5
+ public_tools.names_must_match "Public*"
6
+ why "an externally exposed tool class is greppable by its prefix and cannot shadow an internal one"
7
+ end
8
+ end
@@ -0,0 +1,14 @@
1
+ # rails forbidden names
2
+
3
+ A naming convention phrased as a prohibition. require_name demands a pattern
4
+ and has no negative, so "no get_ prefixes on a model's public surface" could
5
+ not be declared. forbid_name takes the same bounded pattern dialect, tries it
6
+ against a method's bare name after its owner, and with surface: exported
7
+ judges the public surface only: the exported get_total is the breach, the
8
+ private get_lines is not the convention's surface, and total is outside the
9
+ pattern.
10
+
11
+ Declaration: `enola/constraints/naming.yaml`.
12
+
13
+ Verify it on your own tree: copy `enola/` into the repository, run
14
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,13 @@
1
+ components:
2
+ - name: model-methods
3
+ kind: symbol
4
+ match: ["app/models/**"]
5
+ where:
6
+ symbol_kind: method
7
+
8
+ rules:
9
+ - id: no-getter-prefixes
10
+ forbid_name: model-methods
11
+ pattern: "get_*"
12
+ surface: exported
13
+ because: "a reader is a noun; get_ says the class is a bag of fields"
@@ -0,0 +1,11 @@
1
+ # rails mutations are authorized
2
+
3
+ The code behind a mutating route is selected by the route's method through the
4
+ handled_by edge the Rails binder draws, and must reach a policy. create does
5
+ and update does not; index handles a GET and is never asked. The handles key
6
+ is what lets the law speak about routes without naming a single action.
7
+
8
+ Declaration: `enola/constraints/authorization.rb`.
9
+
10
+ Verify it on your own tree: copy `enola/` into the repository, run
11
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,9 @@
1
+ Enola.architecture "shop" do
2
+ part :mutating_actions, files: "app/controllers/**", handles: [:post, :put, :patch, :delete]
3
+ part :policies, files: "app/policies/**"
4
+
5
+ law "a mutating action is authorized" do
6
+ mutating_actions.must_reach :policies
7
+ why "every write passes a policy before it touches a record"
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ # rails request boundaries
2
+
3
+ The Rails laws a team actually writes. A model reads session and calls a
4
+ helper; a service reads params; another service calls only itself. The shipped
5
+ rails-conventions recipe, with its optional helpers and services roles bound,
6
+ names the model's helper call, the model's session read and the service's
7
+ params read, and leaves the clean service alone.
8
+
9
+ Declaration: `enola/constraints/rails.yaml`.
10
+
11
+ Verify it on your own tree: copy `enola/` into the repository, run
12
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,13 @@
1
+ use_recipe:
2
+ - recipe: rails-conventions
3
+ as: rails
4
+ bind:
5
+ controllers: { match: ["app/controllers/**"] }
6
+ jobs: { match: ["app/jobs/**"] }
7
+ models: { match: ["app/models/**"] }
8
+ mailers: { match: ["app/mailers/**"] }
9
+ policies: { match: ["app/policies/**"] }
10
+ serializers: { match: ["app/serializers/**"] }
11
+ view-components: { match: ["app/components/**"] }
12
+ helpers: { match: ["app/helpers/**"] }
13
+ services: { match: ["app/services/**"] }
@@ -0,0 +1,10 @@
1
+ # rails strict arrangement
2
+
3
+ The strict Rails arrangement: the Rails laws, no circle among the parts, and
4
+ concerns that stay independent of their includers. Jobs and models reach each
5
+ other, which is the circle; Taggable reaches Invoice, which includes it.
6
+
7
+ Declaration: `enola/constraints/strict.yaml`.
8
+
9
+ Verify it on your own tree: copy `enola/` into the repository, run
10
+ `enola constraints lint .`, then `enola --generate . && enola check .`.
@@ -0,0 +1,12 @@
1
+ use_recipe:
2
+ - recipe: rails-strict
3
+ as: strict
4
+ bind:
5
+ controllers: { match: ["app/controllers/**"] }
6
+ jobs: { match: ["app/jobs/**"] }
7
+ models: { match: ["app/models/**"] }
8
+ mailers: { match: ["app/mailers/**"] }
9
+ policies: { match: ["app/policies/**"] }
10
+ serializers: { match: ["app/serializers/**"] }
11
+ view-components: { match: ["app/components/**"] }
12
+ concerns: { match: ["app/models/concerns/**"], kind: symbol, where: { symbol_kind: interface } }
@@ -0,0 +1,7 @@
1
+ # readers read the store only
2
+
3
+ The allow form: a part's calls may land only in the named parts. Here readers
4
+ that must stay replayable may reach the store and nothing else.
5
+
6
+ Declaration under `enola/constraints/`. Copy it, run `enola constraints lint .`,
7
+ then `enola --generate . && enola check .`.
@@ -0,0 +1,13 @@
1
+ components:
2
+ - name: readers
3
+ match: ["internal/readers/**"]
4
+ - name: store
5
+ match: ["internal/store/**"]
6
+
7
+ rules:
8
+ - id: readers-read-the-store-only
9
+ allow: readers
10
+ only: [store]
11
+ via: calls
12
+ mode: advisory
13
+ because: "A reader derives its answer from the store alone, which is what keeps every reader replayable against a snapshot; a reader that reaches anything else answers differently on a different machine."