✅ fresh
last synced 2026-09-29T21:12:19.681221+00:00 · coverage 100% (
rule-engine)Validation by Beadloom
doc_sync— same source assync-check.
Rule Engine
Architecture-as-Code rule engine: parse rules.yml, validate rule definitions, and evaluate them against the architecture graph and code imports.
Source: src/beadloom/graph/rules/ (the rules/ package). src/beadloom/graph/rule_engine.py is a thin backwards-compatible re-export shim; new code imports from beadloom.graph.rules.
The package is decomposed by responsibility (BDL-059 S3, cohesion-driven):
rules/types.py— constants, rule dataclasses,NodeMatcher,Violation(the model), plus the vocabulary the model is matched in:import_path_as_path/matches_import_target/MATCHING_FORM_HINT. Theuntil:grammar is no longer here:exit_condition_deadlinemoved toinfrastructure/exit_condition.pyin BDL-070 B2, becauseonboardingdeclares an exit condition too and was importing this peer domain to read what one is.beadloom.graph.rules.exit_condition_deadlinestill answers —rules/__init__.pyre-exports it.rules/loader.py—load_rules/validate_rules(YAML → typed rules + DB validation);AUTHORING_KEYS, derived from the one dispatch table; the memo that lets oneinitparserules.ymlonce, andforget_parsed_rules().rules/attribution.py— which node a source FILE belongs to, and how many files belong to none.rules/evaluators.py— per-rule-type evaluation (deny / require / import-boundary / forbid-edge / layer / cardinality / unregistered-feature / module-coverage) + shared node/edge lookup helpers.evaluate_one_import_rule— oneforbid_importrule over a list of imports — is public since BDL-074 C3, becausetest_import_boundaryruns it over test imports.rules/liveness.py— rule liveness: whether a rule can fire at all, for every rule type (BDL-061.48). It answers about the CONFIGURATION, never about the code. Since BDL-070 A5 it reads a node's layer throughlayers.own_layer_ofand its tags throughnode_tags, so the answer it decides alayersrule's liveness on is the answer the evaluator decides its verdict on.rules/layers.py— what layer a node is in: its own declared layer, else its nearestpart_ofancestor's, and which node's tag decided (layer_membership). Pure, and it reads the rule's ownlayerslist, so no layer tag is written down in it (BDL-070 A1). Since BDL-070 B2 it also answers what containment makes of an edge INSIDE one layer:shares_tagged_ancestorandsame_layer_crossings.rules/layer_reach.py— how much of its edge set a layer rule judged, counted against the layer each end is IN, and the finding that states the fraction (BDL-070 A2, recounted in B3).rules/layer_declaration.py— which declared layers no node is in. A layer rule names TAGS rather than ref_ids, so it fell outsidevalidate_rules'isinstancechain and a rule could declare a layer nothing carries without anything saying so. One predicate answers both surfaces — thevalidate_ruleswarning and the evaluator'swarnfinding — and the finding stands down when fewer than two layers are populated AND the rule is inert, becauselivenessnames them for exactly that graph (BDL-070 A6). Both halves are needed since BDL-070 B5-fix: a rule whose one inhabited layer holds two peers that cross is live, so liveness says nothing about it, and standing down on the layer count alone would drop the report entirely (BDL-UX #296).rules/advisories.py— the rule types whose findings report a rule's REACH rather than a defect (layer_population,layer_declaration, and since BDL-074 C3suite_population), and the one thing that follows:lint --fail-on-warndoes not exit 1 on them (BDL-070 A8).rules/node_tags.py— the tags each node carries, read once per evaluation run. One object in place of the five identical closures deny / require / forbid-edge / layer / cardinality each kept (BDL-070 A2), and of the sixth cacheliveness._GraphFactskept beside them (BDL-070 A5).rules/exemptions.py— what aforbid_importexemption is doing: which crossings it covers, how many it swallows, and whether its exit condition has passed (BDL-061.49).rules/layer_crossings.py— what the rule SAYS about a dependency that stays inside one layer: the un-excused crossings as findings, at the rule's declared severity, and the exemption entries that have stopped earning their place (BDL-070 B3).layersdecides what crosses andlayer_exemptionsdecides what an entry does about it; this turns the pair into findings.rules/layer_edges.py— the set of edges a layer rule finds against, as(src, dst)pairs, for an instrument that DRAWS the graph rather than reporting on it (BDL-070 B4). It projects the rule's own findings; it holds no predicate of its own.rules/layer_exemptions.py— what a SAME-LAYER exemption is doing: which peer crossings it excuses, how many, and whether its exit condition has passed (BDL-070 B2).layers.same_layer_crossingsdecides what crosses; this decides what an entry does about it, the same splitexemptions.pydraws for the import boundary rules.rules/cycles.py— cycle detection (WHITE/GREY/BLACK colored DFS, path-as-set membership) + edge-liveness SQL helpers.rules/doc_area.py—doc_area_coherence: the source-to-docs placement convention read OUT of the graph under test, and the nodes that contradict it. No layout literal appears in it (BDL-062.2).rules/summary_facts.py—summary_facts: the numeric and version claims a nodesummarystates, checked against the same fact the project computes. The extraction and the comparison are the documentation audit's, so there is no second notion of "a version" here (BDL-062.1).rules/test_binding.py—test_binding: a test file bound to no node (thefilesleg) and a node with no bound test file, its own or apart_ofdescendant's (theforleg), over the binding the reindex records intest_files(BDL-074 C3).rules/test_import_boundary.py—test_import_boundary: chooses which recorded TEST imports a boundary judges, then hands them toforbid_import's own evaluator (BDL-074 C3).rules/scenario_binding.py—scenario_binding: a scenario's@node:tag against the node folder its feature file sits in. Whether the scenario's steps execute that node is NOT judged (see below) (BDL-074 C3).rules/suite_tables.py— thetest_filesandtest_importstables read for the suite rules (IndexedTestFilecarries each file'spath,ref_id,placementand, since BDL-074 F1, its recordedkind), andNodeSelection(which nodes a matcher selects, and whether a node is one of them orpart_ofone). Every reader returnsNonefor an index written before those tables existed, so a rule says "reindex" rather than reporting every node untested (BDL-074 C3).rules/listed_exemptions.py—ExemptionLedger: what aListedExemptiondoes — which entry excuses a subject, which entries excuse nothing (dead) and which exemptions are past theiruntildate while still excusing something (expired).excusedcounts the subjects the entries excused in a run, andexemptions_used(BDL-074 F1) how many exemptions excused at least one, which thefilesleg states asexcused by K exemption(s). The counterpart ofexemptions.pyfor exemptions that list paths or node ids rather than a from/to glob pair (BDL-074 C3).rules/__init__.py—evaluate_allorchestration + the remediation post-pass + stable public re-exports.
Specification
Purpose
Enforce architectural constraints declaratively. Rules are defined in a YAML file and evaluated against the graph database (nodes, edges, code_imports, code_symbols, file_index, and sync_state tables). Fifteen rule types exist — load_rules dispatched nine from BDL-051 S3a, BDL-061 S4 added scenario_coverage, BDL-062 added doc_area_coherence (.2) and summary_facts (.1), and BDL-074 C3 added test_binding, test_import_boundary and scenario_binding. This table listed seven until BDL-061.48 counted them against the loader, and ten until BDL-062 .1 counted them again; the count and the rows are checked against the loader's own dispatch, not against each other:
| Type | Keyword | Semantics |
|---|---|---|
| deny | deny | Forbid imports between matched nodes |
| require | require | Mandate specific edge relationships |
| forbid_cycles | forbid_cycles | Detect circular dependencies via DFS |
| forbid_import | forbid_import | Forbid file-level imports between glob-matched paths |
| forbid_edge | forbid | Forbid specific edge patterns between tagged node groups |
| layer | layers | Enforce layered architecture direction |
| cardinality | check | Enforce complexity limits per node |
| unregistered_feature_candidate | unregistered_feature_candidate | Flag substantial domain-only modules that model no feature |
| module_coverage | module_coverage | Require every src/ module to be a tracked node or explicitly exempt |
| scenario_coverage | scenario_coverage | Bind behaviour-bearing nodes to executable scenarios, both ways |
| doc_area_coherence | doc_area_coherence | Document a node where this graph's own convention documents nodes like it |
| summary_facts | summary_facts | Check a number or version stated in a node summary against the project |
| test_binding | test_binding | A test file bound to no node, and a node with no bound test file |
| test_import_boundary | test_import_boundary | Forbid imports from test files, narrowed to the tests of matched nodes |
| scenario_binding | scenario_binding | A scenario's @node: tag names the node folder its feature file sits in |
Constants
VALID_NODE_KINDS: frozenset[str] = frozenset({
"domain", "feature", "service", "entity", "adr"
})
VALID_EDGE_KINDS: frozenset[str] = frozenset({
"part_of", "depends_on", "uses", "implements", "touches_entity", "touches_code"
})
SUPPORTED_SCHEMA_VERSIONS: frozenset[int] = frozenset({1, 2, 3})
# Every key a rule may declare to select its type. A rule declares exactly one.
# The keys of the dispatch table below, plus `layers`: fifteen.
AUTHORING_KEYS: frozenset[str] = frozenset({*_MAPPING_PARSERS, _KEY_READ_FROM_THE_RULE})AUTHORING_KEYS is the single definition of that set, and since BDL-073 B3 it is derived from the loader's dispatch table rather than listed beside it, so a rule type cannot be accepted by one and missing from the other. The dispatch is ONE table, _MAPPING_PARSERS, from authoring key to parser: the fourteen keys whose value is a mapping, forbid_cycles among them. layers stays explicit (_KEY_READ_FROM_THE_RULE), because a layer rule's layers is a list and enforce, allow_skip, edge_kind and exempt sit beside it, so its parser is handed the whole rule. It is the one key the table cannot hold.
Three readers use the set. The loader's own "must have exactly one of" message is built from sorted(AUTHORING_KEYS). The rule-type table above is asserted equal to it. And onboarding.scanner.rules_gen._detect_rule_type reads it: until B3 that function kept a twelve-key map of its own, held to this set by a test, because a key the loader accepts and a copy does not know becomes the word unknown in the generated .beadloom/AGENTS.md, and nothing failed when it did (BDL-062 .4, BDL-UX #179). The display labels stay in rules_gen (_LABEL_FOR_KEY: check reads as cardinality, forbid as forbid_edge), because the table maps a key to a parser and holds no display names.
forbid_cycles was the old chain's else arm. It is a table entry because that arm had the same mapping guard, the same message shape and the same parser signature as the other ten; it was last only because the exactly-one check above the chain left it the last key. The table has no requires_mapping column, because with layers explicit every entry requires a mapping. Both are B3's departures from the RFC, ruled on by review beadloom-8cbm, which compared load_rules on main and on the branch over 1904 generated rules files and found 0 differences.
Data Structures
All dataclasses are frozen (immutable).
NodeMatcher
Matches graph nodes by ref_id, kind, tag, and/or exclude. In deny rules, at least one of ref_id, kind, or tag must be non-None. In require rules, an empty matcher (has_edge_to: {}) is allowed and matches any node — used for "must have at least one edge of this kind" semantics.
| Field | Type | Description |
|---|---|---|
ref_id | str | None | Exact ref_id to match, or None for any. |
kind | str | None | Node kind to match, or None for any. |
tag | str | None | Tag the node must have, or None for any. |
exclude | tuple[str, ...] | None | Ref_ids to exclude from matching, or None for no exclusions. |
def matches(self, node_ref_id: str, node_kind: str, *, tags: set[str] | None = None) -> boolReturns False immediately if node_ref_id is in exclude. Otherwise returns True if all non-None fields (ref_id, kind, tag) match the given node. The tags parameter is optional for backward compatibility; when tags is None and self.tag is set, the tag check is skipped.
In YAML, exclude accepts either a single string or a list of strings; both are normalized to a tuple internally by _parse_node_matcher().
DenyRule
Forbids imports between nodes matched by from_matcher and to_matcher.
| Field | Type | Description |
|---|---|---|
name | str | Unique rule name. |
description | str | Human-readable description. |
from_matcher | NodeMatcher | Matches the source (importing) node. |
to_matcher | NodeMatcher | Matches the target (imported) node. |
unless_edge | tuple[str, ...] | Edge kinds that exempt the import from violation. |
RequireRule
Requires that matched nodes have at least one outgoing edge to a target node.
| Field | Type | Description |
|---|---|---|
name | str | Unique rule name. |
description | str | Human-readable description. |
for_matcher | NodeMatcher | Matches nodes that must satisfy the rule. |
has_edge_to | NodeMatcher | Matches the required target node. |
edge_kind | str | None | If set, restricts to edges of this specific kind. |
CycleRule
Detects circular dependencies in the graph using an iterative WHITE/GREY/BLACK colored DFS: each search frame holds its live path as a set (GREY membership) for O(1) cycle-closing tests, and reports each unique normalized cycle once (max_depth-bounded).
| Field | Type | Description |
|---|---|---|
name | str | Unique rule name. |
description | str | Human-readable description. |
edge_kind | str | tuple[str, ...] | Edge kind(s) to check for cycles. |
max_depth | int | Maximum DFS depth (default 10). |
severity | str | "error" or "warn". |
ImportBoundaryRule
Controls file-level import boundaries using fnmatch glob patterns against code_imports.
The two globs are matched against two different vocabularies.
from:is matched against the repo-relative source file path as indexed —src/beadloom/tui/app.py, source root included.to:is matched against the dotted import path with dots replaced by slashes —beadloom.infrastructure.dbbecomesbeadloom/infrastructure/db: no source root, no file extension, because an import names a module, not a file. Ato:written assrc/beadloom/infrastructure/**therefore matches nothing, ever — the defect that left two of this project's own twelve rules (tui-no-direct-infra,onboarding-no-direct-infra) unable to fire whilelint --strictprinted12 rules, 0 violations(BDL-UX #172; this reference taught the broken form, which is why the fix belongs here and not only inrules.yml). Writebeadloom/infrastructure/**, or**/infrastructure/**if the package root varies.
A
to:glob covering a package also covers a bare import of the package itself.from pkg.infrastructure import dbis indexed withimport_path == "pkg.infrastructure"— the target is the package, not the module — sopkg/infrastructure/**is matched againstpkg/infrastructure/as well, and the most common Python reach-in form is caught. Sibling names are unaffected:pkg/infrastructure_docsstill does not match. (Also BDL-UX #172: the probe injected to reproduce that bead —from beadloom.infrastructure import dbin the TUI — fired under no glob form before this.)
| Field | Type | Description |
|---|---|---|
name | str | Unique rule name. |
description | str | Human-readable description. |
from_glob | str | Glob matched against the source file path (src/pkg/tui/app.py). |
to_glob | str | Glob matched against the dotted import path, dots → slashes (pkg/infrastructure/db). |
severity | str | "error" or "warn". |
exempt | tuple[ImportExemption, ...] | Named, expiring exceptions (default empty). |
ImportExemption
One recorded exception to an ImportBoundaryRule. An exemption baselines a pre-existing crossing instead of narrowing the rule that catches it: the boundary keeps its full scope, so a new crossing still fails, while what is tolerated today is visible, attributed and dated.
| Field | Type | Description |
|---|---|---|
to_glob | str | Matched like the rule's to (dotted import path). Default "*" — any target. |
from_glob | str | Matched like the rule's from (source file path). Default "*" — any source. |
reason | str | Mandatory. Why this crossing is tolerated. |
until | str | Mandatory. The condition that retires the entry — a date or an event (see below). |
load_rules raises ValueError when an entry omits reason or until, or sets neither from nor to (an entry matching both would exempt the whole rule). A deadline already in the past is not a load error: rejecting a config for the passage of time would break a project that changed nothing, so it is reported by the run instead.
What until is (BDL-061.49). It answers one question — what retires this entry — and there are two honest answers, so the grammar admits both:
- a deadline: the value LEADS with an ISO
YYYY-MM-DDdate, optionally followed by the prose that explains it (2026-09-01 — when the repository read seam lands). It is parsed, and once that day has passed while the entry is still suppressing something, the run reports it; - an event: anything else (
the rule is re-scoped — BDL-UX #150 follow-up). Not parseable, and deliberately still legal: what retires a real baseline is usually a landed change, not a day. An event is reported as prose, never treated as satisfied.
The spelling is pinned to a leading YYYY-MM-DD by a pattern rather than delegated to date.fromisoformat, because that parser widened in Python 3.11 (20260101 and week dates parse there and raise on 3.10) — the same until: must not be enforceable on one supported interpreter and prose on another. A date in the MIDDLE of a sentence is an event: a deadline is the first thing an exit condition says, or it is not one. exit_condition_deadline is the single definition, shared with the guards.<name>.exclusions[].until of flow.yml and with layers.exempt[].until, so the three surfaces cannot promise different things. It lives in infrastructure/exit_condition.py since BDL-070 B2, below every layer that declares one.
Every exemption is visible, whatever it does. The three channels are exhaustive over what an entry can be doing, and this is the guarantee the rules.yml comment used to overstate:
| The entry | What the run says |
|---|---|
| suppresses nothing | a dead finding (rule_liveness, warn): "suppresses nothing … delete it" |
| suppresses something, past its deadline | an expired finding (rule_liveness, warn) naming the date and how many crossings it is still excusing |
| suppresses something, within its deadline or on an event | counted: LintResult.violations_suppressed, the ", N crossings suppressed by an exemption" clause on the summary line, and one entry per crossing in --format json's suppressed array |
A blanket from: "*" / to: "*" entry therefore cannot hide either: it suppresses crossings (counted) or it suppresses none (dead).
Severity is warn, and expiry never changes what is suppressed. A finding here is a statement about the CONFIGURATION, not about the code — the distinction BDL-061.48 drew for inert rules — and it is honoured harder in this case: a crossing does not reappear at error severity because a calendar day passed, because a build that reddens with no commit behind it is worse than the silence being fixed. A project that wants a hard deadline has lint --fail-on-warn.
Named limit. The suppressed count appears wherever a run could read as clean — rich, --format json, and the 0 violations, N rules evaluated line the CLI prints for a format whose clean output states no verdict of its own (porcelain, github). It does not appear in porcelain output that already carries violations (one line per violation is the format's contract), nor in the Gate's own N rules, 0 violations step summary, which belongs to application/gate.py.
Rule liveness (a rule that cannot fire)
A rule that cannot match is indistinguishable from a rule that passed. Both contribute 0 violations and 1 to N rules evaluated. Every rule type therefore reports its own inertness instead of counting as clean — rules/liveness.py for the eight matcher/graph-based types, evaluate_import_boundary_rules for forbid_import (whose diagnosis falls out of the import scan it already runs), scenario_coverage.py for scenario_coverage (whose legs are decided by files on disk that no index holds), doc_area.py for doc_area_coherence (whose applicability is decided by the graph's own data rather than by its configuration), and summary_facts.py for summary_facts (for the same reason, and because it reports a second kind of ignorance no other rule has: one node whose claim cannot be checked, while the rule itself is live). The three suite rules of BDL-074 C3 report their own in test_binding.py, test_import_boundary.py and scenario_binding.py, and liveness.py counts each through that module's *_inert_reason predicate, so the finding and the count cannot disagree.
Reporting a rule and counting it are two questions, and for scenario_coverage and doc_area_coherence two modules answer them. The finding says what stood down and which glob did it; LintResult.rules_inert says how many of my rules checked nothing. liveness.py counts scenario_coverage through that module's own inert_reason predicate and doc_area_coherence through doc_area_inert_reason — not second copies of them — and does not report either a second time. Until BDL-061.66 the count had no branch for the type at all: 13 rules evaluated, 0 inert printed over a rule that had stood all four legs down.
What "cannot fire" means, per rule type. This table is the contract: a liveness check narrower than the invariant it names is the defect this section exists to close (BDL-UX #172, BDL-061.48).
| Rule type | Inert when | Reported by |
|---|---|---|
deny | its from or to matcher selects 0 nodes (an unknown ref_id is named as such; otherwise the tag or kind nobody carries is named) | liveness.py |
require | its for selects 0 nodes — or its has_edge_to selects 0, in which case every node it matches would fail, which is equally broken | liveness.py |
forbid_cycles | the graph holds 0 live (active) edges of the declared edge_kind(s), so there is no chain to walk | liveness.py |
forbid_import | its from glob matches 0 indexed source files, or its to glob matches 0 indexed import paths | evaluators.py (a stale exempt entry: exemptions.py) |
forbid (edge) | its from/to selects 0 nodes, or the graph holds 0 edges of its edge_kind | liveness.py |
layers | no live edge_kind edge is one the rule COMPARES — across two layers for direction, or inside one layer against the shared-container predicate. An edge with an unlayered end is passed over and an edge inside one tagged container is legal by construction, so neither is a check the rule performed. A node is in the layer its own tag declares, else its nearest part_of container's, which is the reading the rule's own verdict rests on. How many layers hold a node decides only WHICH reason is printed: with fewer than 2 inhabited, the tags nobody carries are named, and otherwise the edge set is (BDL-070 B5-fix, beadloom-5tcc.6, closing BDL-UX #296 — before it, one run could report an error from a rule and count that same rule inert) | liveness.py |
check (cardinality) | its for selects 0 nodes, or no threshold is set at all (max_symbols, max_files and min_doc_coverage all unset), so nothing is compared | liveness.py |
unregistered_feature_candidate | its for selects 0 nodes, or none of the nodes it selects declares a source, so it has no files to inspect | liveness.py |
module_coverage | its source_root holds 0 modules, on disk or in the index — "complete coverage" of nothing | liveness.py |
scenario_coverage | per leg: its for matcher selects 0 nodes (coverage leg) or a references glob matches 0 documents (reference leg). Its features glob matching 0 files is the exception — that stands all four legs down, and only that state is counted in rules_inert | reported by scenario_coverage.py, counted by liveness.py |
doc_area_coherence | no source area in the graph reaches the majority threshold over min_support observations, so the convention the rule enforces cannot be read off the graph at all — a flat docs tree, a project mid-migration, a graph too small to hold a convention | reported by doc_area.py, counted by liveness.py |
summary_facts | no node summary in the graph states a number or version the project computes a fact for, so there is no claim to check. A claim naming a fact the project DECLINED to compute is a separate report — that node is unverifiable while the rule as a whole is still live | reported by summary_facts.py, counted by liveness.py |
test_binding | the index holds no test_files table (it was written before test files were indexed), or every declared leg judges nothing: the files glob matches no test file the leg judges and the for matcher selects no node. One dead leg beside a live one is reported per leg and does not count the rule inert | reported by test_binding.py, counted by liveness.py |
test_import_boundary | no test import is recorded, or its from glob matches 0 test files with imports, its of matcher leaves no judged file, or its to glob matches 0 recorded test import paths — decided over every recorded test import, before any crossing | reported by test_import_boundary.py, counted by liveness.py |
scenario_binding | its features glob matches 0 feature files | reported by scenario_binding.py, counted by liveness.py when a project_root is given |
scenario_coverage — behaviour bound to an executable claim (BDL-061 S4)
The .feature file is the source of truth for behaviour and the PRD references it by name (BDL-061 CONTEXT, option б). This rule reports where that binding is missing, reading the suite through scenario-binding.
- name: scenario-coverage
description: "Behaviour-bearing nodes carry an executable scenario; a scenario names its bead"
severity: warn
scenario_coverage:
for: { kind: feature } # default: kind: feature
features: "tests/acceptance/features/**/*.feature" # default (Q3)
references:
- "docs/**/PRD.md" # default: none
non_behavioural:
- node: rule-types
reason: "frozen dataclasses; the behaviour is the evaluator's"| Leg | Finding |
|---|---|
| coverage | a node matched by for that no scenario names, and no declaration excuses |
| suite | a scenario that names no bead; a scenario naming a @node: that is not in the graph; a file that could not be read; a file in the suite that declares no scenario |
| reference | a scenario a references document claims exists and the suite does not contain |
| declaration | a non_behavioural entry naming a node outside the population, or one that has a scenario anyway |
| excused | one statement per run naming how many of how many nodes left the population and why — silent when nothing is excused |
| population | one statement per run naming how many graph nodes the rule's for matcher reaches, and how many are outside it, by kind — silent when the population is the whole graph |
reason is mandatory on a declaration and there is deliberately no until: unlike an import exemption this is not a debt that expires but a classification that is either true or false. What keeps it honest is that a dead declaration is itself a finding.
A live declaration is stated, not merely honoured. PLAN's criterion is that a node may declare itself non-behavioural with a named reason and is accepted; accepted in silence is a different promise, because the excused node leaves the population, the coverage fraction improves and nothing says the denominator moved. Every run that excuses anything therefore prints one line — 1 of 2 node(s) in this rule's population are excused as non-behavioural, so every coverage figure below is a fraction of 1: ... — carrying each node with its reason. It is warn whatever the rule declares: doing the thing PLAN says is accepted must never redden a pipeline. It is silent when nothing is excused, because a line about zero on every lint of every project is how a real one goes unread.
The population states its own reach. The population is defined by KIND, and a node's kind is one line in services.yml: changing kind: feature to kind: component removes a node from the rule with no finding of any sort, so the count falls by one and the run stays the same colour (BDL-061.63, measured by .14). Widening the rule to components is not the answer — excluding plumbing is the architecture model's own definition of the split, and it would have added 24 findings to 68 without a decision having been taken. What the rule can do is print the denominator beside the fraction: this rule's population is 37 of 61 graph node(s) (kind=feature); the other 24 are outside it and no finding of this rule reaches them: component (24). It does not catch the reclassification — an evaluator that remembered its own past population would be a writer, the shape BDL-UX #147/#189 were filed for — but it puts a shrinking denominator on the same line as an improving fraction. warn whatever the rule declares, and silent when nothing is outside, for the same two reasons the excused statement is.
for.exclude is rejected on this rule type, with the error naming the excluded nodes and routing the author to non_behavioural. An exclude entry carries no reason, is never reported and never expires, and a matcher excluded down to nothing reports only that it selects no node — which is CONTEXT's "an unnamed exclusion is how a gate is quietly switched off", in the one place this rule could still be switched off quietly. The requirement is scoped to scenario_coverage: exclude is shared by every rule type, and demanding a reason everywhere would turn an adopter's green project red on upgrade for rules this epic never touched.
Severity is warn by design and is meant to stay there. A finding here is about declared intent — that a behaviour was specified — and an error would turn every adopter's green project red on the upgrade that ships the rule. Loudness replaces blocking: every message carries the population it is a fraction of (none of 7 scenarios in 2 files carries @node:billing).
Two boundaries, stated rather than discovered. A features glob that matches no file reports the glob and does not then report every node: one configuration error printing as N architecture findings buries the finding that would fix it. It stands all four legs down, not the coverage leg alone — measured on this repository, repointing features: takes lint from 68 findings to exactly 1, and the 33 reference findings an empty suite would make definitionally true go with them. That is why this state, and only this state, is counted in rules_inert. And the bead id is not verified against the tracker — reading it from the rule engine would make a domain depend on the application layer — so what is checked is that a scenario names one. The @node: reference is verified.
forbid_import additionally reports a stale exempt entry — dead or expired, at most one finding per entry (rules/exemptions.py, from the counts the import scan already produced). That is a statement about an exemption, not about the rule, so it is not counted in LintResult.rules_inert; what those entries suppressed is counted separately, as LintResult.violations_suppressed. Two counters, because they answer two questions: which of my rules cannot check anything and what did my checks catch and excuse.
Severity depends on how much the rule stood down. A partial inertness — one dead glob beside nine live ones, an exemption that excuses nothing, a matcher selecting no node while the rule's other legs still fire — is warn whatever the rule declares: it describes the configuration, not the code, and error would turn an adopter's green pipeline red on upgrade. A total stand-down, where the rule could check NONE of its population, carries the severity the project declared. At that point "found nothing wrong" and "never ran" are the same output, and a project that deliberately escalated the rule has had its escalation evaporate exactly when it mattered (BDL-062 .9, BDL-UX #195). What that costs an adopter depends on the severity the rule ships: doc-area-coherence ships warn and still reports warn, so nothing changes for anyone, while graph-summary-facts ships error and will block a project whose summaries state no checkable number — measured on a graph beadloom init --mode bootstrap produced, 0 of 3 summaries state one, so that run is reachable rather than exotic. severity: warn on the rule is the one-key opt-out (BDL-062 .14). module_coverage, scenario_coverage and unregistered_feature_candidate still report a total stand-down at warn; all three ship warn, so only a project that escalated them is affected (BDL-UX #197). A warn here is not the same as being quiet — the finding is printed by default, appears in --json under kind: "rule_liveness", and LintResult.rules_inert qualifies the rule count on the summary line (13 rules evaluated, 2 of them unable to check anything), so a green run cannot advertise checks that never looked.
Two deliberate limits, named rather than left to be discovered:
- Liveness is silent on an empty graph (no nodes) and, for
forbid_import, on an index with no imports at all. That is a fact about the index, not about the rules — lint's header already says0 files scanned— and firing there would flood every fresh clone and every language Beadloom does not extract imports from. denyliveness is matcher-based only. An index with no resolved imports makes everydenyrule inert too; that is again the index's property and lint's header states it (0 imports resolved).
ForbidEdgeRule
Forbids graph edges between matched nodes (operates on edges table, unlike DenyRule which checks code_imports).
| Field | Type | Description |
|---|---|---|
name | str | Unique rule name. |
description | str | Human-readable description. |
from_matcher | NodeMatcher | Matches the source node. |
to_matcher | NodeMatcher | Matches the target node. |
edge_kind | str | None | If set, restricts to edges of this kind. |
severity | str | "error" or "warn". |
LayerDef
Defines a single architecture layer for use in LayerRule.
| Field | Type | Description |
|---|---|---|
name | str | Layer name. |
tag | str | Tag identifying nodes in this layer. |
LayerRule
Enforces dependency direction between ordered architecture layers.
| Field | Type | Description |
|---|---|---|
name | str | Unique rule name. |
description | str | Human-readable description. |
layers | tuple[LayerDef, ...] | Ordered layers (top to bottom). |
enforce | str | "top-down" — higher layers may depend on lower, not reverse. |
allow_skip | bool | If False, forbids skipping intermediate layers (default True). |
edge_kind | str | Edge kind to check (default "uses"). |
severity | str | "error" or "warn". |
exempt | tuple[LayerExemption, ...] | Same-layer crossings the rule excuses (default empty). |
LayerExemption
One same-layer crossing a layer rule excuses, why, and what retires it (BDL-070 B2).
| Field | Type | Description |
|---|---|---|
from_glob | str | fnmatch pattern over the source node's ref_id. |
to_glob | str | fnmatch pattern over the target node's ref_id. |
reason | str | Why the crossing stands. Mandatory. |
until | str | Its exit condition — a leading YYYY-MM-DD, or an event. Mandatory. |
covers(src, dst) matches BOTH ends and matches direction: a -> b and b -> a are two crossings, and a project that excused one did not excuse the other. load_rules raises ValueError when an entry omits from, to, reason or until, or when both globs are * — an entry matching every edge would exempt the rule rather than a crossing in it. The shape mirrors ImportExemption and for the same reason: an exclusion with no reason and no exit condition is how a gate is switched off without saying so. It differs in what it names, because a same-layer crossing is an EDGE and an entry naming one end would excuse everything that touches it.
CardinalityRule
Enforces complexity limits per node (architectural smell detection).
| Field | Type | Description |
|---|---|---|
name | str | Unique rule name. |
description | str | Human-readable description. |
for_matcher | NodeMatcher | Matches nodes to check. |
max_symbols | int | None | Maximum symbols a node OWNS (nested nodes excluded). |
max_files | int | None | Maximum files per node. |
min_doc_coverage | float | None | Minimum documentation coverage: the fraction of the node's sync pairs NOT known to be behind (stale/missing excluded; a pair the freshness engine could not check is not counted against the docs — BDL-UX #175). |
severity | str | "error" or "warn" (default "warn"). |
Rule (type alias)
Rule = (DenyRule | RequireRule | CycleRule | ImportBoundaryRule | ForbidEdgeRule | LayerRule
| CardinalityRule | UnregisteredFeatureCandidateRule | ModuleCoverageRule
| ScenarioCoverageRule | DocAreaCoherenceRule | SummaryFactsRule
| TestBindingRule | TestImportBoundaryRule | ScenarioBindingRule)Violation
| Field | Type | Description |
|---|---|---|
rule_name | str | Name of the violated rule. |
rule_description | str | Description of the violated rule. |
rule_type | str | "deny", "require", "cycle", "forbid_import", "forbid", "layer", "cardinality", "test_binding", "test_import_boundary", "scenario_binding", "suite_population" (a suite rule's population statement — see above), "layer_population", "layer_declaration", or "rule_liveness" (a rule that cannot fire — see above). |
severity | str | "error" or "warn". |
file_path | str | None | Source file path (for deny/import violations). |
line_number | int | None | Line number (for deny/import violations). |
from_ref_id | str | None | Source node ref_id; on a per-node rule_liveness finding it is the node the finding is about. |
to_ref_id | str | None | Target node ref_id. |
message | str | Human-readable explanation of the violation. |
doc_area_coherence — a node documented where its own graph says it should (BDL-062 .2)
The convention is derived from the graph under test, never declared. A literal such as docs/domains/<package>/ would ship one project's tree as every adopter's and be wrong for a feature-sliced project on the day it is installed, so no directory name appears in the rule, in its defaults, or in its configuration block.
- name: doc-area-coherence
description: "A node is documented where this graph documents nodes from its source area"
severity: error # ships `warn`; a project whose layout has settled raises it
doc_area_coherence:
threshold: 0.6 # default: the share of an area's pairs a mapping must cover
min_support: 2 # default: the observations a dominant mapping must rest onEach node/doc pair is reduced to two comparable segments:
| Side | How the segment is found |
|---|---|
| source area | the segment directly below the source root, and the root is derived too: the descent takes one segment for as long as there is exactly one supported way down, supported meaning min_support sources agree on the next segment |
| docs area | the segment at the area depth, and the depth is derived too: each doc path is asked where in it a source area is named, and the depth that answer lands at most often is read for every doc path, whatever the segment is called there |
The second pass is what lets the rule see a document filed under a directory that names no source area at all — the commonest shape of the drift it exists to catch, and the case a first cut of the rule (vocabulary matching alone) could not see.
The source root is not what every source shares. It was, until BDL-062 .9, and that is a unanimity rule: unanimity hands each individual source a veto over the whole derivation. One node whose source was site/ — a committed asset tree beside the code — collapsed this repository's root from src/beadloom to nothing for all 85 other pairs, and the rule then either invented findings against a bogus convention or compared nothing at all (BDL-UX #195). Three things end the descent, and each is the right answer to a different graph: a genuine fork (two or more supported next segments) is where the areas begin, which is the depth being looked for; no supported segment at all means there is no shared root to speak of; and a source too short to have that depth is a node whose source IS the root, reported as rootless rather than allowed to stop the descent.
A pair whose source falls outside the derived root is excluded from the comparison and counted, never dropped: the population sentence ends "; N sit outside the source root", and examined adds up to every pair the graph offered. A reader who cannot see the exclusions cannot tell a graph with no outliers from one whose outliers vanished.
A mapping source area -> docs area is dominant when it covers at least threshold of that area's pairs and rests on at least min_support of them. min_support is not decoration: without it every area holding a single documented node is unanimous at one observation, and a graph of six nodes in six areas reports a clean sweep having compared nothing that could disagree.
| Outcome | What it means |
|---|---|
| a finding | the node's docs area is not the one its source area agreed on; the message names both, the strength of the mapping, and the sample the verdict rests on |
| silence | every compared pair agrees with a dominant mapping |
rule_liveness | no mapping is dominant: the rule states it checked nothing, and lint's summary counts it in N of them unable to check anything |
threshold at or below 0.5 and min_support below 2 are rejected by the loader: both are configuration that reads as a rule and behaves as a silence.
Severity ships warn. A convention check that fails an adopter's first beadloom ci on their own house style is a check they switch off. This repository sets error, because its layout has been settled since BDL-051 and a contradiction there is a defect rather than a matter of taste.
summary_facts — a number in a node summary checked against the project (BDL-062 .1)
A summary is the sentence every other surface quotes — beadloom ctx, prime, the generated site, the agent adapters — and until this rule nothing compared it against anything. Measured on this repository at BDL-062: the root node claimed v1.5.0 against a computed 3.0.0 and mcp-server claimed 14 tools against a catalogue of 18, both wrong across three major releases with no check going red.
- name: graph-summary-facts
description: "A number or version stated in a node summary agrees with the project"
severity: error # the shipped default
summary_facts: {} # no keys: an unknown key is REJECTED, not ignoredThe block takes no keys, and that is the design. What counts as a version, what counts as a claim about a count, and how close a count has to be are decided once by the documentation audit — DocScanner.scan_line for the extraction, compare_facts for the comparison and its per-fact tolerances. A knob here would be a second answer to a question already answered, and a second notion of "a version" beside the audit's is how the next drift class starts. The loader rejects an unknown key rather than ignoring it: a setting that looks configured and does nothing is the failure this rule family exists to catch.
| Outcome | What it means |
|---|---|
| a finding | the claim differs from the computed fact; the message names the node, both values, the fact's provenance, the summary verbatim and the population the verdict rests on |
| silence | the claim agrees, or the summary states no number this project computes a fact for |
rule_liveness, per node | the claim names a fact the project declined to compute; the finding carries the registry's own reason verbatim (FactSet.not_applicable), never a wording invented here, and names the node in from_ref_id |
rule_liveness, whole rule | no summary in the graph states a checkable number, so nothing was verified and nothing was cleared |
unverifiable never folds into a pass. A project whose version cannot be resolved and a project whose every summary checked out must not be described by the same word. The two rule_liveness shapes above are deliberately distinct: one node nobody can check is not the same report as a whole graph nobody checked.
Both per-node outcomes name the node in the same field. A disagreement always carried the claim's ref_id, and until BDL-062 .10 an unverifiable claim carried nothing, because liveness_finding hardcoded from_ref_id=None — the same rule reporting on the same node, attributable through one channel and anonymous through the other. liveness_finding now takes a keyword-only from_ref_id defaulting to None, the same shape .9 added severity in and for the same reason: every caller that has no node to name keeps its output byte for byte, because a liveness finding is usually about a rule that could not run and a node id there would be an invention.
And the projected finding carries it too, since BDL-067 .14. linter._finding now emits node beside kind, rule, severity, locations, why and remediation, reading Violation.from_ref_id. The value was already in the shape's prose — why opens with Node '<ref_id>' (kind=…) — and nowhere a reader could take it from, so beadloom init, naming the graph file an adopter has to open, would have had to parse an English sentence. null for a finding about no single node.
The not_applicable fallback in collect_claims serves the injected FactSet, not the registry's. A FactSet the registry builds covers every name DocScanner scans for — measured across five project shapes, including a database with no schema at all, where the set difference was empty in both directions every time, and a test fails the day it stops being — so on that path the fallback cannot fire. fact_set= is public and a caller passing one is under no such obligation, so the branch is reachable, and removing it raises KeyError on exactly that caller. The reason it states is this caller never declared the fact, which is a different sentence from the registry's this project declined to compute it.
Severity ships error, unlike the warn a convention check gets. A number that contradicts the project it describes is wrong in every house style, and the value sits in the adopter's own graph, so there is no house preference to respect.
The suite rules — test_binding, test_import_boundary, scenario_binding (BDL-074 C3)
Three rule types judge the test suite against the graph. test_binding and test_import_boundary read the binding the reindex records (BDL-074 C1): the test_files table holds each test file's path, the node it binds to and its placement, and test_imports holds its imports. A test file binds to the node whose code its path mirrors, or to the node whose tests: list names it; the placement values are the PLACEMENT_* names infrastructure/repository.py defines. scenario_binding reads the acceptance suite through graph/scenarios.py (scenario-binding) and parses no Gherkin itself.
- name: test-files-bind-to-a-node
severity: error # default: warn
test_binding:
files: "tests/**" # the file leg: a path glob over test files
exempt:
- files: [tests/test_mixed.py] # path globs; or `nodes: [<ref_id>, ...]`
reason: "<why this file cannot be placed yet>" # mandatory
until: "<a YYYY-MM-DD deadline, or the event that retires it>" # mandatory
- name: features-have-bound-tests
test_binding:
for: { kind: feature } # the node leg: a NodeMatcher
- name: domain-unit-tests-import-no-infrastructure
test_import_boundary: # default severity: error
from: "tests/unit/**" # test file path glob
to: "pkg/infrastructure/**" # dotted import path, dots -> slashes
of: { tag: layer-domain } # optional: the test's node, or a container of it
exempt: # forbid_import's entries: from/to, reason, until
- from: "tests/unit/test_fixture.py"
reason: "<why this crossing is tolerated>"
until: "<deadline or event>"
- name: scenarios-live-in-their-node-folder
scenario_binding: # default severity: warn
features: "tests/acceptance/**/*.feature" # default: tests/acceptance/features/**/*.feature
exempt:
- files: [tests/acceptance/features/checkout.feature] # `files` only
reason: "<why the file stays where it is>"
until: "<deadline or event>"test_binding has two legs, and each runs only when it is declared. A block that names neither files nor for is refused at load time.
The
filesleg judges every indexed test file the glob matches, except those a kind folder places (placementother_kind). Those are not judged, and the population statement names them BY their recorded kind and count, never under one phrase (BDL-074 F1): an acceptance step file runs scenarios that bind through their@node:tags, judged by the project'sscenario_bindingrules, which the statement names; a self-check tests the project's own files and binds to no node by design, a sanctioned outcome rather than a gap. Any other recorded kind is named as bound to no node and not judged by this rule. A judged file with no node is a finding that names its placement.Each kind states how it was recognised (BDL-074 G2): by its folder in the test layout the reindex recorded (
infrastructure.repository.read_test_layout), and whether that folder was declared in.beadloom/config.yml(tests.kinds) or is Beadloom's default, which notests.kindsentry replaces. The folder is trusted, not verified: what a file holds is never checked against its kind, so a unit test dropped intotests/self_check/counts as a self-check, and the statement says so rather than implying a check. An index that recorded no layout saysrecognised by its folder aloneand asks for a reindex. On this repository, measured bybeadloom lint --strict --format jsonon 2026-09-28, the ruletest-files-bind-to-a-nodeopens its statement with the counts and names the acceptance step files by their folder (elided with…):test files: judged 438 of 615 indexed test file(s) matching `tests/**` (0 outside the glob): 271 bound to a node, 167 bound to none — 167 excused by 4 exemption(s), 0 reported; not judged by their path, by kind: 74 acceptance step file(s) — …, recognised by the folder `tests/acceptance/` declared in .beadloom/config.yml (`tests.kinds`) (the folder is trusted, not verified: what a file holds is not checked against its kind); 103 self-check file(s) — …The
forleg judges every node the matcher selects. A node is bound when a test file is bound to it or to one of itspart_ofdescendants. Acceptance scenarios are not counted here, because that binding isscenario_coverage's population. A node with no bound test file is not an untested node while any test file binds to no node, since the unbound file may test it. Every node finding therefore states how many test files bind to no node.exemptentries each list exactly one offiles(fnmatchglobs over paths) ornodes(exactref_idvalues), as a non-empty list, with a non-emptyreasonanduntil. Anodesexemption on a rule withoutfor, or afilesexemption on a rule withoutfiles, is refused.
test_import_boundary is forbid_import run over the imports of test files. It takes forbid_import's from, to and exempt keys, parsed by the same _parse_import_boundary, and matches them in the same two vocabularies. It adds of, a node matcher that keeps only the test files whose bound node it selects or is part_of a node it selects: "a unit test OF a domain node". Under of, a file bound to no node cannot be the test of a matched node, so it is not judged and it is counted. The from glob is matched against a test file's path with fnmatchcase, case-sensitively on every platform, as test_binding and the exemptions match (beadloom-2mj3.15): fnmatch folds case where the platform's normcase does, so a Tests/Unit/** glob would judge tests/unit/... on Windows and not on Linux.
- The judging is
forbid_import's ownevaluate_one_import_rule, over the chosen imports. A crossing, an exemption that excuses nothing and an exemption past its date mean what they mean for source code; the findings are restamped withrule_type: test_import_boundaryand getforbid_import's remediation. - Liveness is decided first, over EVERY recorded test import. When no judged file imports the target, only the stale-exemption findings are computed, because the evaluator handed only the judged imports would report a clean boundary as a dead
toglob. - The imports come from
test_imports, whose reader keeps an aliasedimport a as bthat the code-import extractor drops. The population statement says so. - The population statement counts the crossings an exemption excused, because
lint'sN crossings suppressedclause readscode_importsonly.
scenario_binding judges the layout one folder per node, <suite root>/<domain>/<node>/*.feature. The suite root is the features glob's segments before its first wildcard, and no folder name is configured.
- A feature file in the suite root, or in a folder that names no node, is one finding for the whole file. A feature file that declares no scenario is judged by its place alone (
beadloom-2mj3.15): it is counted among the files the population statement names, so a misplaced empty file is a finding like any other. - A folder between the suite root and the node folder that names a node must name a
part_ofcontainer of the node folder. A folder that names no node there (services/on a graph with noservicesnode) is not judged. - In a correctly placed file, every scenario must carry
@node:<folder>. It may carry other node tags beside it. exemptentries listfilesonly, with areasonand anuntil.
Not implemented: whether a scenario's steps EXECUTE the node its tag names. The RFC asks for this second half, and it needs a runtime trace this rule does not have. A static stand-in was measured on this repository on 2026-09-28 and rejected: reading the imports of the step module that loads each feature, resolved to nodes and widened by part_of ancestors, found 59 of the 81 (tag, step file) pairs the suite map's coverage run observed executing. It missed 22, six of them in feature files already in their correct node folder, because a step that drives the CLI executes far more than it imports. It never claimed a tag the run did not execute (0 of 59), so it is sound and far from complete. As a rule it would have reported 22 correct placements to find 4 real ones. Every population statement of this rule ends with not judged: whether a scenario's steps execute the node it names, which needs a runtime trace.
What each suite rule states about its population (suite_population)
Every run of each suite rule, clean or not, adds one warn finding of rule_type: suite_population, built by types.population_finding(). It states how much the rule judged and why the rest was not judged: files matched, outside the glob, bound and unbound, excused and by how many exemptions, reported, and the files a kind folder places, by kind. A count of findings is then readable as a fraction of a population. The type is in advisories.ADVISORY_RULE_TYPES, so lint --fail-on-warn does not exit 1 on it. Without that entry a project declaring any suite rule could never pass --fail-on-warn again.
Listed exemptions (ListedExemption, rules/listed_exemptions.py)
A ListedExemption holds entries, reason and until. It LISTS its entries rather than holding one glob, so ExemptionLedger judges each entry on its own:
- An entry that excuses nothing is reported by name as a
rule_livenessfinding, "delete it". A test file listed as not yet placed is therefore reported the run after it moves to its node's folder, which a glob over a whole folder could not say. - An exemption past the date its
untilleads with, while it still excuses something, is reported once with the count. Expiry is a finding and never re-enables anything: nothing reappears aterrorbecause a day passed. The date grammar is the sharedinfrastructure/exit_condition.pyone.
Every liveness finding the three rules make is warn, including a total stand-down, because none of them passes the declared severity to liveness_finding.
rules.yml Schema
Schema supports versions 1, 2, and 3. Version 3 ADDED an optional top-level tags: block described as bulk tag assignments; nothing ever applied it, and BDL-070 A6 withdrew load_rules_with_tags, the only function that read it. A node's tags are declared on the node, and a rules.yml still carrying such a block loads unchanged while the block assigns nothing.
version: 3
rules:
# --- deny: forbid imports between matched nodes ---
- name: <unique-rule-name>
description: "<description>"
deny:
from: { ref_id: ..., kind: ..., tag: ..., exclude: [...] } # NodeMatcher
to: { ref_id: ..., kind: ..., tag: ..., exclude: [...] } # NodeMatcher
unless_edge: [<edge_kind>, ...] # optional, defaults to []
# --- require: mandate specific edge relationships ---
- name: <unique-rule-name>
description: "<description>"
require:
for: { ref_id: ..., kind: ..., exclude: [...] } # NodeMatcher
has_edge_to: { ref_id: ..., kind: ... } # NodeMatcher (or {} for any node)
edge_kind: <edge_kind> # optional
# --- forbid_cycles: detect circular dependencies ---
- name: <unique-rule-name>
description: "<description>"
severity: warn # optional, default: error
forbid_cycles:
edge_kind: depends_on # string or list of edge kinds
# --- forbid_import: file-level import boundaries ---
# NOTE the two vocabularies: `from` matches the SOURCE FILE PATH (source root
# included), `to` matches the DOTTED IMPORT PATH with dots -> slashes (no source
# root, no extension). A `src/`-prefixed `to` can never match (BDL-UX #172).
- name: <unique-rule-name>
description: "<description>"
forbid_import:
from: "src/pkg/module_a/**" # file path glob
to: "pkg/module_b/**" # import path glob
exempt: # optional, baselines existing crossings
- to: "pkg/module_b/atomic_io" # `from` optional; at least one required
reason: "<why this crossing is tolerated>" # mandatory
until: "<a YYYY-MM-DD deadline, or the event that retires it>" # mandatory
# --- forbid (forbid_edge): forbid graph edges between tagged groups ---
- name: <unique-rule-name>
description: "<description>"
forbid:
from: { tag: ui-layer } # NodeMatcher with tag
to: { tag: native-layer }
edge_kind: uses # optional
# --- layers: enforce layered architecture ---
- name: <unique-rule-name>
description: "<description>"
severity: warn
layers:
- name: services
tag: layer-service
- name: domains
tag: layer-domain
- name: infrastructure
tag: layer-infra
enforce: top-down # higher layers may depend on lower
allow_skip: true # optional, default: true
edge_kind: depends_on # optional, default: uses
exempt: # optional, excuses same-layer crossings
- from: <src-ref-id-or-glob> # mandatory
to: <dst-ref-id-or-glob> # mandatory
reason: "<why this crossing stands>" # mandatory
until: "<a YYYY-MM-DD deadline, or the event that retires it>" # mandatory
# --- check (cardinality): enforce complexity limits ---
- name: <unique-rule-name>
description: "<description>"
severity: warn
check:
for: { kind: domain } # NodeMatcher
max_symbols: 180 # optional (Beadloom's domain-size-limit; counts OWNED symbols since BDL-UX #144)
max_files: 50 # optional
min_doc_coverage: 0.8 # optionalEach rule must contain exactly one key of AUTHORING_KEYS — the Keyword column of the table under Purpose. The sample above shows seven of the fifteen. The three suite rules are sampled under The suite rules above.
Loading and Parsing
def load_rules(rules_path: Path) -> list[Rule]- Read
rules_pathas text, decoded as UTF-8 whatever the locale. If the memo holds an entry for the resolved path whose text equals the text just read, return a new list of the rules parsed then, without parsing (see One parse perinitbelow). - Otherwise parse the text with
yaml.safe_load. The document must be a mapping. - Validate top-level
versionfield is inSUPPORTED_SCHEMA_VERSIONS({1, 2, 3}). RaiseValueErroron mismatch or absence. - Read
rules, defaulting to[]when the key is absent. RaiseValueErrorif it is not a list. - For each entry: a. Require a mapping, and in it a non-empty string
namefield. b. Enforce unique names (tracked viaseen_namesset). RaiseValueErroron duplicate. c. Resolve the severity BEFORE the rule type, so a rule wrong in both ways is reported for its severity. An omittedseverityiswarnwhen the rule's key is in_KEYS_THAT_DEFAULT_TO_WARN(unregistered_feature_candidate,module_coverage,scenario_coverage,doc_area_coherence,test_binding,scenario_binding) anderrorotherwise. d. TakeAUTHORING_KEYS.intersection(rule). None or several raiseValueError:rule '<name>' must have exactly one of <every authoring key, sorted>. e.layersis handed, with the whole rule, to_parse_layer_rule. Any other key's value must be a mapping — one message for all fourteen,Rule '<name>': '<key>' must be a mapping— and is handed to_MAPPING_PARSERS[key]. - Remember
(text, tuple(rules))under the resolved path and return the list. A file that raises is not remembered. NodeMatcherparsing validates: for deny rules, at least one ofref_id,kind, ortagmust be present. For require rules,has_edge_toaccepts an empty dict{}(matches any node) viaallow_empty=True.kind(if present) is validated againstVALID_NODE_KINDS.excludeaccepts a string or list, normalized to a tuple.
One parse per init — the memo (BDL-073 B4, F1)
One beadloom init re-indexes and then lints, and both read the same rules.yml: the reindex through application/reindex/rules_loader.py, the Gate's lint step through graph/linter.py. load_rules therefore remembers, in the module-level _PARSED, the text it read and the rules it returned for each resolved path. init --yes and init --bootstrap each parse once where they parsed twice, measured by a counting stand-in for the loader's yaml in tests/integration/graph/rules/test_load_rules_parses_once.py.
- An entry is trusted only while the file holds the same TEXT. A path alone would serve old rules to the TUI, which refreshes in one long process while its user edits the file.
(st_mtime_ns, st_size)would do the same for an edit that keeps the size and lands inside one timestamp tick; that key was red in two tests. The owner accepted the text comparison in place of the stat key recorded on 2026-09-20 (CONTEXT, 2026-09-25). - The comparison costs microseconds against a parse of milliseconds. Measured with
timeiton this repository'srules.yml(25 243 bytes), Darwin arm64, CPython 3.13.7: a hit — read, resolve, compare — 50.5 µs, and a parse 15.94 ms (B4); a hit including the copy below 51.12 µs, and a parse 13.31 ms in that run (F1). - Every call returns a list of its own. The rules are stored as a tuple and a hit returns
list(...)of it, so one caller'sappendorclearcannot change what the next caller is served; the copy measured 0.078 µs. The rules themselves are frozen dataclasses and are shared. forget_parsed_rules()forgets every entry, so the next call parses.tests/conftest.pycalls it before every test through an autouse fixture. mutmut 3.7.0 runs the clean suite in its parent process and forks each mutant's child from it, so a memo the parent filled could answer a child's test without executing the mutated body. B5 measured the fixture as insurance rather than a load-bearing fix: with it disabled in themutants/copy, all 136load_rulesverdicts of a serial run were identical, because every killable mutant is killed first by a test on its owntmp_path.
The memo lives as long as the process, so it applies to every caller in it, not only to init.
Validation Against Database
def validate_rules(rules: list[Rule], conn: sqlite3.Connection) -> list[str]Collects all ref_id values from all matchers across all rules (deny, require, forbid_edge, cardinality and unregistered-feature-candidate). Queries the nodes table for each. Returns a list of warning strings for any ref_id not found in the database. This is advisory (warnings, not errors).
A LayerRule is checked too, since BDL-070 A6. It names no ref_id, so it was outside the chain above and the same class of mistake went unreported: a layer whose tag no node carries. One warning per rule names every empty layer, through layer_declaration.declaration_warnings — the function the evaluator's finding is also derived from, so the two surfaces cannot state different tags. The tag map is read only when the rule set holds a layer rule.
Its return value is consumed, not dropped. Until BDL-061.48 linter.py called this function as a bare statement and discarded the list, so a rule naming no-such-node-at-all produced the exact right diagnosis and threw it away while lint --strict printed 13 rules evaluated, 0 violations at exit 0. The unknown-ref_id question is now answered per rule by liveness.py (which names the ref_id in the finding, attributed to the rule that references it) and by this function for any rule kind the liveness pass does not model — one finding per rule, never two.
Evaluation
Deny Rule Evaluation
def evaluate_deny_rules(conn: sqlite3.Connection, rules: list[DenyRule]) -> list[Violation]Algorithm:
- Query all rows from
code_importswhereresolved_ref_id IS NOT NULL, and build the run'sFileAttributionsnapshot (see Source attribution below). - For each import row
(file_path, line_number, import_path, resolved_ref_id): a. AskFileAttribution.candidates(file_path)for every node that CONTAINS the file, most specific first. b. Skip if the list is empty — and note that these files are counted, not merely skipped: seeLintResult.files_unattributed. c. Look up(ref_id, kind)for the target via_get_node. d. For each deny rule, take the most specific candidate whosefrom_matchermatches (skipping a candidate equal to the target — a self-reference). The FIRST match decides: a file attributed to both a component and the domain above it produces one verdict, not two, and the finer node is the one that describes the crossing. e. If a candidate matched andto_matchermatches the target, check for exemption: ifunless_edgeis non-empty, queryedgesfor any edge of those kinds between the matched source and the target. If found, skip. f. Otherwise, emit aViolationnaming the matched candidate asfrom_ref_id.
Source attribution (rules/attribution.py, BDL-061.50)
A deny rule used to resolve the SOURCE node of an import from code_symbols.annotations alone, taking the first value that named a node. Two silent defects followed:
- a file whose annotation the extractor could not read — or that carries none — was invisible to every deny rule, while its
depends_onedge existed, becauseimport_resolverderives that edge from OWNERSHIP. Measured on this repository before the fix: 22 of 128 import-source files (17%), among themservices/cli.pyand two TUI widgets. This is BDL-UX #146's disease in the linter. - "the first annotation that names a node" is dictionary order, so a rule written against a specific node quietly stopped matching when a coarser one happened to be listed first.
FileAttribution.candidates(file_path) answers with every node that contains the file, ranked most specific first:
| Candidate | Rank |
|---|---|
a node the file annotates that declares no source of its own (what a feature node IS) | most specific — an annotation is a per-file declaration |
a node whose source covers the file (annotated or not) | by covering-prefix length, longest first |
Containment, not mere mention: an annotation naming a node whose own source lies elsewhere is a cross-reference and is not a candidate. Measured: this repository's services/commands/setup.py annotates individual commands domain=onboarding; counting that label as containment reported the CLI's own mcp-server import as an error-severity domain-to-service breach, on a file whose derived depends_on edge says service.
Known limit, stated rather than discovered later: attribution is per FILE, not per symbol. A module whose symbols carry different annotations contributes all of them as candidates, and an import is attributed to the module rather than to the symbol whose lines enclose it.
What remains unattributable — a file under no node's source that annotates no source-less node — is counted and reported, never silently skipped: LintResult.files_unattributed, carried in --json under summary.files_unattributed, printed on the rich header (Files: N scanned, M imports resolved, K attributable to no node) and appended to the CLI's no-violations summary line. A deny rule that never saw a file did not clear it. The clause is absent when the count is zero, so the common line keeps its shape. Limit: the porcelain format is a strict one-line-per-violation contract, so when violations ARE printed in that format the count appears only in --json.
Require Rule Evaluation
def evaluate_require_rules(conn: sqlite3.Connection, rules: list[RequireRule]) -> list[Violation]Algorithm:
- Fetch all
(ref_id, kind)from thenodestable. - For each rule, iterate all nodes. If
for_matchermatches a node: a. Query all outgoing edges from that node (edges WHERE src_ref_id = ?). b. For each edge, optionally filter byedge_kind. Look up the target node via_get_node. c. If any target matcheshas_edge_to, the node satisfies the rule. d. If no matching edge is found, emit aViolation.
The layer a node is in (rules/layers.py, BDL-070 A1)
@dataclass(frozen=True)
class LayerMembership:
ref_id: str
index: int # the layer, as its index in the declared order
declared_by: str # the node whose own tag decided — itself, or a container
def layer_membership(ref_id, layers, parents, tags) -> LayerMembership | None
def layer_of(ref_id, layers, parents, tags) -> int | None
def own_layer_of(ref_id, layers, tags) -> int | None
def part_of_generations(ref_id, parents) -> list[tuple[str, ...]]
def part_of_ancestors(ref_id, parents) -> frozenset[str]
def layer_population(edges, layer_at) -> LayerPopulation
def tagged_containers(ref_id, layers, parents, tags) -> frozenset[str]
def shares_tagged_ancestor(src, dst, layers, parents, tags) -> bool
def same_layer_crossings(edges, layers, parents, tags) -> list[tuple[str, str]]One answer to "what layer is this node in", for every caller that asks. Three bodies asked it before and disagreed: evaluate_layer_rules read a node's OWN tags and skipped every edge whose ends carried none, application/architecture_view.py climbed part_of with the four tags and their ranks written into it, and liveness.py did neither. Measured on this repository at aa4bfad4: 362 live depends_on edges, 16 with a layer at both ends by own tags, 354 by part_of ancestry.
The functions are pure — the declaration, the parent map and the tag map are arguments, and there is no connection and no filesystem — so the rule's hardest property is testable without a graph. A layer is returned as its INDEX in the declared order (0 is topmost), because the index is what the rule compares to decide direction.
What containment makes of an edge INSIDE one layer (BDL-070 B2). shares_tagged_ancestor is the predicate RFC Q1 decided: an edge between two ends in the same layer is legal when one container the declaration gives a layer holds BOTH ends, and a crossing when none does. It is reflexive — a node that declares a layer is the container of its own membership — or a part would cross with the very container it is inside. A container carrying no declared layer tag shares nothing, which is what makes the predicate say anything: this repository's root service holds every domain and every service and is untagged, so peers under it cross, and tagging that root would make every same-layer edge legal by construction.
Measured on this repository on 2026-09-13 over a warm full rebuild of the index: 365 live depends_on edges, 357 with a layer at both ends by ancestry, 130 inside one layer, of which 116 run between two parts of one container and 14 between peers. The two predicates that existed before split that population 0/130 and 130/0 — one passed every same-layer edge and the other flagged every one — so neither could tell an internal edge from a peer crossing.
same_layer_crossings applies it to an edge set and returns only the crossings. An edge whose ends are in DIFFERENT layers is not among them: direction is the rest of the layer rule's business. An edge with an unlayered end is not judged at all.
Two properties hold, and both are settled by the declaration rather than by a dictionary's iteration order:
- A node that declares a layer keeps it and does not climb. A node that declares none takes the layer of the nearest
part_ofgeneration that does, andNonewhen no generation does.layer_membershipreturns that index together withdeclared_by, the node whose own tag decided, so a finding about an untagged component can name the container it is in rather than leave a reader to look it up.layer_ofis one line over it, so the walk exists once. - A tie is decided top-down. A node carrying two declared layer tags is in the topmost of them —
evaluate_layer_rulesiterated the node's tagsetand took the first match, so its answer depended on hash order — and two tagged ancestors at the same distance resolve the same way. There is no uniformly conservative choice here, so the tie is settled for determinism and said so out loud. Measured on this repository's graph on 2026-09-12: no node has more than onepart_ofparent, so the ancestor tie is unreachable here and exists for the graphs Beadloom ships to.
part_of_generations is the ONE part_of ancestry walk in the codebase. import_resolver._part_of_ancestors reads the direct edges out of SQLite and calls part_of_ancestors rather than climbing a second time; a test derives that from the source and fails on a second walk reachable from graph/rules/.
layer_population counts an edge set into evaluated (a layer at both ends) and skipped_untagged (the rest). The resolver is a parameter rather than a fixed call, so the same counting serves a caller asking what own tags reach and a caller asking what ancestry reaches; the rule asks the second.
What the rule says about an edge inside one layer (rules/layer_crossings.py, BDL-070 B3)
SAME_LAYER_REMEDIATION: str
def same_layer_statements(rule, edges, parents, tags) -> list[Violation]One pass over the edge set produces both halves of what a layer rule has to say about its own layer: the crossings no exempt: entry excuses, as findings at the severity the rule declares, and the entries that have stopped earning their place. They come from one pass because they are two readings of one split — deriving them separately is how an excused count and a reported count come to disagree.
The finding names each end with the container that gives it its layer, and the remediation offers all three honest moves: remove the dependency, bring both ends inside one container the declaration gives a layer, or write an exempt: entry saying why it stands and what would retire it. A remediation naming only the exemption would be advice to silence the check.
The edges a layer rule finds against (rules/layer_edges.py, BDL-070 B4)
def flagged_layer_edges(conn, rule) -> frozenset[tuple[str, str]]The rule engine reports its verdict as findings with messages and remediations, which is what a person reads. An instrument that DRAWS the graph needs the same verdict as a set of edges, and until this module existed the one that draws it answered the question itself: the architecture view flagged every depends_on edge at dst_rank <= src_rank, which is every edge pointing up and every edge staying inside one layer. Measured on this repository on 2026-09-13 over a warm full rebuild of the index, the view flagged 130 edges and the rule found against none of them — 116 dependencies between two parts of one domain and 14 crossings rules.yml excuses by name.
This is not a fourth predicate; it is a projection of the rule's own verdict. The findings come from evaluate_layer_rules and the edges are read off them, so a caller gets the direction check, the skip check, the same-layer predicate and the project's exempt: entries without any of the four being stated twice. A shared predicate called by both sides was the alternative, and it was rejected for this seam: it leaves two call sites that agree only while somebody keeps them agreeing, which is the failure BDL-070 exists to close. The cost is one extra evaluation of the rule for a caller that also lints — a pass over the edge set in memory.
The pairs are the ends of the rule's EDGE findings, selected by types.LAYER_EDGE_RULE_TYPE. A finding about the rule itself — the population it judged, a declared layer no node carries, an exemption excusing nothing — names no edge and is not among them. An empty set therefore means the rule found against nothing, which is a different fact from the rule judging nothing: layer_rule_reach answers the second, and a caller rendering a verdict per edge needs both, because an edge the rule never judged must not be drawn as healthy.
What a same-layer exemption is doing (rules/layer_exemptions.py, BDL-070 B2)
def layer_exemption_index_for(rule, src_ref_id, dst_ref_id) -> int | None
def excused_crossings(rule, crossings) -> tuple[list[tuple[str, str]], dict[int, int]]
def stale_layer_exemption_findings(rule, excused_per_exemption, *, today=None) -> list[Violation]A layer rule that reports peer crossings needs a way to say "this one is known and decided", or the only way to a green result is to narrow the rule until it catches nothing. An exemption is honest only while it stays visible, and the two ways an entry can stop being visible are the ones exemptions.py names for forbid_import: it excuses a crossing and nobody says so, or its exit condition passes and nobody notices.
Both are reported. An entry that excuses nothing is DEAD — the edge it named is gone, so the entry only hides the next one that looks like it. An entry still excusing crossings past its own deadline is EXPIRED, reported with the count it is still excusing. Both are warn and neither enforces: a crossing does not become a finding because a calendar day passed. An entry naming an event never expires on its own, because nothing in a date can observe whether the event happened, so the COUNT is the mechanism an event-dated entry relies on to be remembered.
layers.same_layer_crossings decides what crosses; this decides what an entry does about it. The population a layer rule judged is counted BEFORE any exemption is consulted, so excusing a crossing does not shrink the denominator a reader checks the verdict against.
excused_crossings returns both halves — the crossings no entry excuses and a count per entry — because both are needed, and deriving one from the other twice is how a reported count and an excused count come to disagree.
The population a layer rule judged (rules/layer_reach.py, BDL-070 A2)
LAYER_POPULATION_RULE_TYPE = "layer_population"
@dataclass(frozen=True)
class LayerReach:
rule_name: str
edge_kind: str
population: LayerPopulation # what the rule decides on, by own tag or by container
def layer_rule_reach(conn, rule) -> LayerReach
def layer_rule_reaches(conn, rules) -> list[LayerReach] # one read of the graph for the whole list
def population_statement(rule, reach) -> list[Violation] # the finding, with its remediation
def stated_populations(reaches) -> list[LayerReach] # the ones there is anything to say about
def population_phrase(reach) -> str # the clause, for a line already being readarchitecture-layers ships at severity: error, so what it evaluates decides whether main is mergeable — and it judged 16 of 363 live depends_on edges on this repository on 2026-09-12, because it read a node's OWN tags. The green line said 0 violations, 16 rules evaluated, which is what it would say for 363 of 363. evaluate_layer_rules emits one finding per rule naming the fraction it judged, and since BDL-070 B3 that fraction is the one it decides on: 357 of 365 here, measured 2026-09-13. One population and not two, because there is one predicate — a second figure beside it would be a second answer to "how much did the rule judge".
The statement is a FINDING rather than a clause in the summary line, following scenario_coverage._population_statement: tui/data_providers.py and application/debt_report/collect.py call evaluate_all directly and never see a LintResult, so a clause in the summary cannot reach them.
It is always warn and never the rule's declared severity. A statement about a rule's reach is not a boundary breach, and emitting it at error would turn a green Gate red on upgrade for a graph nobody changed.
And it does not exit 1 under --fail-on-warn either (rules/advisories.py, BDL-070 A8). warn keeps a finding out of --strict and out of the Gate, and it does not keep it out of that flag, which exits on any finding. Measured on a fixture with two tagged edges and one untagged, clean under every rule it declares: the code before this release exits 0 and the code with the population statement exits 1. So ADVISORY_RULE_TYPES — layer_population and layer_declaration — is subtracted in LintResult.fails_on_warn, the key that flag reads as has_errors is --strict's. The exclusion selects by rule type and stops at error: an expired exemption, an inert rule and an unbound scenario are statements about something a person chose, and they still exit 1 — and so does an advisory at error, so the flag stays a superset of --strict rather than reading softer than it on the same run. Both advisory constructors hardcode warn today and neither is obliged to, which is why the bound is in the predicate rather than asserted about them (A8 re-review, Minor 3). A pipeline that wants the advisories to block reads their records out of --format json. Release B made the under-evaluation itself the subject: the rule judges the whole population and decides at its declared severity, and the advisory that reports the reach stays out of that flag.
It is silent in two cases and loud in a third:
- No edge of the rule's kind — there is no population to report, and a rule that can look at nothing is already the subject of a liveness finding. Saying it twice is noise.
- Every edge reached — there is nothing the rule could not see. A line saying so on every run of every project trains a reader to skip the one that matters.
- Zero of N reached — reported, once for the rule rather than once per unjudged edge. This is the case where "the rule found nothing wrong" and "the rule never looked" are the same output.
What the rule decides on (BDL-070 Release B)
Release A changed what architecture-layers REPORTS and left what it DECIDES alone. Release B (beadloom-ku26) is the half that moves the decision onto the population Release A made visible, and it is a verdict change: a project that upgrades over both at once can read a finding on a graph nobody edited.
- The rule decides on the layer each end is IN.
evaluate_layer_rulesresolves both ends throughlayer_membership— a node's own declared tag, else the nearestpart_ofcontainer that declares one — and moves to the next edge only when an end is in no declared layer at all. Measured on this repository on 2026-09-13 over a warm full rebuild of the index: 365 livedepends_onedges, 357 judged, 8 skipped because an end is inside nothing that declares a layer. The porcelain record is# layer_population:architecture-layers:depends_on:357:365:8. The lineage is part of the figure and not a detail of how it was taken — see the note below on BDL-UX #290, which is why older measurements quoted in this section read 362 and 363 over the same repository. - A finding about an inherited layer names where the layer came from. The message carries
inherited from '<container>'beside the layer name, because the first question such a finding raises is why a node nobody tagged is in that layer. An end carrying its own tag reads exactly as it read before, which is what makes the differential against the pre-release evaluator legible. - An edge inside one layer is legal when a container the declaration gives a layer holds both ends, and a finding when none does.
layer_crossings.same_layer_statementsreports the crossings noexempt:entry excuses, at the severity the rule declares, and the dead or expired entries beside them. This is the first release in which the peer-dependency line most layered architectures state is checked by anything.
So a green architecture-layers now means "none of the 357 edges this rule judged points the wrong way, and none of them crosses between peers". The population finding beside it is what says 357 rather than 365, and it stays warn: it reports the rule's reach rather than a breach.
On this repository the verdict did not move, and that is not a property of the predicate. Two beads ran first. beadloom-46am removed the single depends_on edge running from a domain into the application layer, and beadloom-xmfs dispositioned every same-layer crossing left: two fixed by moving the shared vocabulary below both layers, fourteen excused by name in rules.yml, each with a reason and an exit condition. Read the same graph with the exempt: block off and the fourteen are reported.
Where the population is reported (BDL-070 A3)
The finding reaches a reader who reads findings. The line most readers read is the summary, so LintResult carries the same fact as DATA — layer_populations: list[LayerReach], one entry per declared layer rule, empty for a project that declares none — and every rendering states it in its own idiom rather than parsing another's prose:
| Rendering | How the population appears |
|---|---|
format_rich | a clause on the summary line, on the GREEN line and the RED one alike: , architecture-layers judged 357 of 365 live depends_on edge(s) |
format_json | summary.layer_populations[] — rule, edge_kind, evaluated, total, skipped_untagged. Additive against the keys lint --format json carried before BDL-070 |
format_github | one leading ::notice:: per rule — notice, not warning, because the fraction is not a finding against anyone's code and must not colour a pull request |
format_porcelain | one leading marked line, # layer_population:rule:edge_kind:evaluated:total:skipped. The # marker is the same one scope-check --porcelain leads its verdict with, and a rule name cannot begin with it, so a consumer drops the marked lines and reads exactly the seven-field records it read before |
beadloom lint's clean line | 0 violations, N rules evaluated gains the same clause |
The number the clause prints has a lineage: an index carried forward and an index built fresh over one tree resolve beadloom.application.graph_reads differently, so the denominator this repository reads moves by one between them (BDL-UX #290). Hold the lineage constant across a before/after comparison.
The clause is present at FULL reach too, and the finding is not. They differ deliberately. A finding is an item somebody triages, in every project, on every run, so N of N would be noise; a clause on a line already being read costs nothing, and N of N versus N of M is the distinction this rule exists to make readable. The case that made it concrete is the one this epic was opened on: 16 of 362, measured on this repository at aa4bfad4, before BDL-070 B3. A rule handed no edge of its kind states nothing in either channel — liveness already says it could not fire.
The CLI's clean line is keyed on the FORMAT, not on an empty rendering. It used to print when the formatter returned nothing at all, which was the same test until a clean porcelain or github run started carrying a population line. Keyed on emptiness, the one sentence saying there were no violations would have vanished from exactly the two formats it exists for.
LintResult.layer_populations is counted in linter._evaluate beside inert_rule_names and suppressed_crossings rather than returned by evaluate_all, which returns findings. Both counts come from reach_of over one connection, so they cannot differ in logic, and a test holds the numbers on the result against the numbers in the finding.
The surfaces outside beadloom lint (BDL-070 A4)
Five surfaces report a lint result without being beadloom lint, and two of them never see a LintResult at all — which is why the population is emitted as a finding in the first place.
| Surface | How the population appears |
|---|---|
application/gate.py lint_step | the clause, appended to the step summary beside _suppressed_note, from the linter's own formatter so the Gate line cannot drift from the command it summarises |
services/mcp_server.py handle_lint | summary.layer_populations[], the same five keys lint --format json carries — it emits reach.to_dict(), so there is one shape and not a copy of it. Additive, and outside the severity filter: a finding filter must not be able to hide something that is not a finding |
tui/data_providers.py + widgets/lint_panel.py | past lint(). The provider carries the finding's rule_type and message, which it dropped before, and the panel leads its list with the population rows, rendering the MESSAGE — a population row carries no from_ref_id, and the rule description beside it describes the boundary rather than how much of it was looked at |
application/debt_report/collect.py | past lint(). Counts the reaches over the same rules it evaluated and carries population_phrase on DebtData → DebtReport; the Rich report prints counted over: ... under Rule Violations, and format_debt_json carries layer_populations |
onboarding/scanner/prime.py | the clause on the Health: line, and health.layer_populations in --format json. One clause per DECLARED layer rule rather than per finding, so the list prime caps at ten findings can grow without this growing with it |
One wording, not five. population_phrase is the single clause and stated_populations the single "is there anything to say" filter. linter._population_note and format_github were rewritten to call them, so the six places that state the fraction cannot drift into six wordings — the defect this epic is about, at the scale of a sentence.
A4 states; it does not re-count. Every number these five surfaces printed before it, they print after it — the population advisory is still counted among the warnings, exactly as A2 left it. Whether an advisory counts as a violation is one question with one answer, and it belongs in LintResult's counting properties rather than in five leaves.
A test names the callers of evaluate_all and fails when a new one appears. The set is derived by an AST scan over the installed package, following import and from ... import by name (including as), and compared for equality: linter._evaluate, tui.data_providers.LintDataProvider.refresh and debt_report.collect._count_violations. Its ceiling is that a caller reaching the evaluator through an attribute chain or importlib binds no name the scan reads, which is held by a case of its own rather than left to be rediscovered.
node_tags.NodeTags is the tag lookup the five evaluators share. It reads nodes.extra["tags"] once, on the first question, and answers from memory afterwards — the closures it replaces read one node per call, and four of the five call sites still skip tags entirely when no rule in their set matches on one.
Reading the whole table puts every row on the path of every tag question, so a row it cannot read is skipped rather than raised: extra that is null, that does not parse, or that parses to something other than an object leaves the node with no tags. One node at a time, a malformed extra could only break the question that asked about that node; unguarded, one such row would have failed every tag question in the run and escaped evaluate_all as a traceback instead of a LintError (BDL-070 A8).
What did not change, and it is held by a test rather than argued. The layer rule's DECISIONS are compared against a verbatim transcription of evaluate_layer_rules as it stood before, and the other four rule kinds against the closure they each kept, both run against this repository's own index in the same process (tests/self_check/architecture/test_the_layer_rule_states_the_population_it_judged.py). Measured on this repository: lint --strict exits 0 before and after, no finding was removed, and the one finding added is the population statement.
Combined Evaluation
def evaluate_all(conn: sqlite3.Connection, rules: list[Rule], *, project_root: Path | None = None) -> list[Violation]Owned by rules/__init__.py. Partitions rules by type into DenyRule, RequireRule, CycleRule, ImportBoundaryRule, ForbidEdgeRule, LayerRule, CardinalityRule, UnregisteredFeatureCandidateRule, ModuleCoverageRule, ScenarioCoverageRule, DocAreaCoherenceRule, SummaryFactsRule, TestBindingRule, TestImportBoundaryRule and ScenarioBindingRule lists. Calls the corresponding evaluate_* function for each type. Enriches each Violation with a deterministic remediation hint (via _remediation_for, a post-pass), then concatenates and sorts by (rule_name, file_path or ""). project_root (default: cwd) roots the on-disk module enumeration the module-coverage rule uses, and the feature glob scenario_binding reads.
Internal Helpers
| Function | Description |
|---|---|
_parse_node_matcher | Parse a dict into a NodeMatcher, validating kind against VALID_NODE_KINDS. Accepts allow_empty=True for require rule targets. Normalizes exclude (string or list) to tuple. |
_parse_deny_rule | Parse a deny block into a DenyRule with validated matchers and unless_edge. |
_parse_require_rule | Parse a require block into a RequireRule with validated matchers and optional edge_kind. |
_parse_cycle_rule | Parse a forbid_cycles block into a CycleRule with edge_kind and optional max_depth. |
_parse_forbid_import_rule | Parse a forbid_import block into an ImportBoundaryRule with from/to glob patterns. |
_parse_forbid_rule | Parse a forbid block into a ForbidEdgeRule with from/to matchers and optional edge_kind. |
_parse_layer_rule | Parse a layer rule into a LayerRule with ordered LayerDef entries. Handed the whole rule, not a block. |
_parse_check_rule | Parse a check block into a CardinalityRule with threshold fields. |
_parse_unregistered_feature_candidate_rule | Parse an unregistered_feature_candidate block into an UnregisteredFeatureCandidateRule. |
_parse_module_coverage_rule | Parse a module_coverage block into a ModuleCoverageRule. |
_parse_scenario_coverage_rule | Parse a scenario_coverage block into a ScenarioCoverageRule. |
_parse_doc_area_coherence_rule | Parse a doc_area_coherence block into a DocAreaCoherenceRule. |
_parse_summary_facts_rule | Parse a summary_facts block into a SummaryFactsRule. |
_parse_import_boundary | The from/to globs and the exemptions of an import boundary, shared by forbid_import and test_import_boundary; key= names the block in every message, so forbid_import's messages are unchanged. |
_parse_listed_exemptions | Parse a suite rule's exempt list into ListedExemption entries: each lists exactly one of the allowed kinds (files, or for test_binding also nodes) as a non-empty list, with a non-empty reason and until. |
_parse_test_binding_rule | Parse a test_binding block into a TestBindingRule. |
_parse_test_import_boundary_rule | Parse a test_import_boundary block into a TestImportBoundaryRule. |
_parse_scenario_binding_rule | Parse a scenario_binding block into a ScenarioBindingRule. |
_first_matching_source | The most specific candidate a deny rule applies to, or None. |
_get_node | Return (ref_id, kind) tuple for a node, or None. |
_edge_exists | Return True if an edge of any of the specified kinds exists between two nodes. |
The dispatch in load_rules reads three module-level names in rules/loader.py, none of them public: _MAPPING_PARSERS (authoring key to parser, fourteen entries; every parser takes (name, description, block, *, severity)), _KEY_READ_FROM_THE_RULE ("layers") and _KEYS_THAT_DEFAULT_TO_WARN (the six keys whose rules default to warn). The memo is _PARSED; nothing outside rules/loader.py reads it, and tests forget it through forget_parsed_rules().
API
Public Functions
def load_rules(rules_path: Path) -> list[Rule]: ...
def forget_parsed_rules() -> None: ... # rules/loader.py only; not re-exported
def validate_rules(rules: list[Rule], conn: sqlite3.Connection) -> list[str]: ...
def evaluate_rule_liveness(conn: sqlite3.Connection, rules: list[Rule], *, project_root: Path | None = None) -> list[Violation]: ...
def inert_rule_names(conn: sqlite3.Connection, rules: list[Rule], *, project_root: Path | None = None) -> set[str]: ...
def evaluate_deny_rules(conn: sqlite3.Connection, rules: list[DenyRule]) -> list[Violation]: ...
def evaluate_require_rules(conn: sqlite3.Connection, rules: list[RequireRule]) -> list[Violation]: ...
def evaluate_cycle_rules(conn: sqlite3.Connection, rules: list[CycleRule]) -> list[Violation]: ...
def evaluate_import_boundary_rules(conn: sqlite3.Connection, rules: list[ImportBoundaryRule]) -> list[Violation]: ...
def evaluate_forbid_edge_rules(conn: sqlite3.Connection, rules: list[ForbidEdgeRule]) -> list[Violation]: ...
def evaluate_layer_rules(conn: sqlite3.Connection, rules: list[LayerRule]) -> list[Violation]: ...
def evaluate_cardinality_rules(conn: sqlite3.Connection, rules: list[CardinalityRule]) -> list[Violation]: ...
def evaluate_one_import_rule(rule: ImportBoundaryRule, imports: list[tuple[str, int, str]], *, file_count: int, target_count: int) -> list[Violation]: ... # rules/evaluators.py
def evaluate_test_binding_rules(conn: sqlite3.Connection, rules: list[TestBindingRule], *, scenario_rules: Sequence[str] = ()) -> list[Violation]: ... # evaluate_all passes the scenario_binding rules' names
def evaluate_test_import_boundary_rules(conn: sqlite3.Connection, rules: list[TestImportBoundaryRule]) -> list[Violation]: ...
def evaluate_scenario_binding_rules(conn: sqlite3.Connection, rules: list[ScenarioBindingRule], *, project_root: Path | None = None) -> list[Violation]: ...
def population_finding(*, rule_name: str, rule_description: str, message: str) -> Violation: ... # rules/types.py; always warn
def evaluate_all(conn: sqlite3.Connection, rules: list[Rule], *, project_root: Path | None = None) -> list[Violation]: ...Public Classes
@dataclass(frozen=True)
class NodeMatcher:
ref_id: str | None = None
kind: str | None = None
tag: str | None = None
exclude: tuple[str, ...] | None = None
def matches(self, node_ref_id: str, node_kind: str, *, tags: set[str] | None = None) -> bool: ...
def describe(self) -> str: ... # "ref_id=…, kind=…, tag=…", or "everything"
@dataclass(frozen=True)
class DenyRule:
name: str
description: str
from_matcher: NodeMatcher
to_matcher: NodeMatcher
unless_edge: tuple[str, ...]
severity: str = "error"
@dataclass(frozen=True)
class RequireRule:
name: str
description: str
for_matcher: NodeMatcher
has_edge_to: NodeMatcher
edge_kind: str | None = None
severity: str = "error"
@dataclass(frozen=True)
class CycleRule:
name: str
description: str
edge_kind: str | tuple[str, ...]
max_depth: int = 10
severity: str = "error"
@dataclass(frozen=True)
class ImportBoundaryRule:
name: str
description: str
from_glob: str
to_glob: str
severity: str = "error"
@dataclass(frozen=True)
class ForbidEdgeRule:
name: str
description: str
from_matcher: NodeMatcher
to_matcher: NodeMatcher
edge_kind: str | None = None
severity: str = "error"
@dataclass(frozen=True)
class LayerDef:
name: str
tag: str
@dataclass(frozen=True)
class LayerExemption:
from_glob: str
to_glob: str
reason: str
until: str
@dataclass(frozen=True)
class LayerRule:
name: str
description: str
layers: tuple[LayerDef, ...]
enforce: str = "top-down"
allow_skip: bool = True
edge_kind: str = "uses"
severity: str = "error"
exempt: tuple[LayerExemption, ...] = ()
@dataclass(frozen=True)
class CardinalityRule:
name: str
description: str
for_matcher: NodeMatcher
max_symbols: int | None = None
max_files: int | None = None
min_doc_coverage: float | None = None
severity: str = "warn"
@dataclass(frozen=True)
class ListedExemption:
entries: tuple[str, ...] # path globs or ref_ids, by the key they were read from
reason: str
until: str
@dataclass(frozen=True)
class TestBindingRule:
name: str
description: str
for_matcher: NodeMatcher | None = None
files: str | None = None
exempt_files: tuple[ListedExemption, ...] = ()
exempt_nodes: tuple[ListedExemption, ...] = ()
severity: str = "warn"
@dataclass(frozen=True)
class TestImportBoundaryRule:
name: str
description: str
from_glob: str
to_glob: str
of_matcher: NodeMatcher | None = None
severity: str = "error"
exempt: tuple[ImportExemption, ...] = ()
def as_import_rule(self) -> ImportBoundaryRule: ...
@dataclass(frozen=True)
class ScenarioBindingRule:
name: str
description: str
features: str = DEFAULT_FEATURE_GLOB # "tests/acceptance/features/**/*.feature"
exempt: tuple[ListedExemption, ...] = ()
severity: str = "warn"
Rule = (DenyRule | RequireRule | CycleRule | ImportBoundaryRule | ForbidEdgeRule | LayerRule
| CardinalityRule | UnregisteredFeatureCandidateRule | ModuleCoverageRule
| ScenarioCoverageRule | DocAreaCoherenceRule | SummaryFactsRule
| TestBindingRule | TestImportBoundaryRule | ScenarioBindingRule)
@dataclass(frozen=True)
class Violation:
rule_name: str
rule_description: str
rule_type: str
severity: str
file_path: str | None
line_number: int | None
from_ref_id: str | None
to_ref_id: str | None
message: strCLI
beadloom lint [--format {rich,json,porcelain}] [--strict] [--no-reindex]| Flag | Default | Description |
|---|---|---|
--format | rich | Output format: rich (colored tables), json, or porcelain. |
--strict | False | Exit with code 1 if any violations are found. |
--no-reindex | False | Read the index as-is. This is the READ-ONLY form: the default reindexes first and therefore WRITES beadloom.db. |
Exit codes:
| Code | Meaning |
|---|---|
0 | No violations (or violations without --strict). |
1 | Violations detected (with --strict). |
2 | Configuration error (missing/invalid rules.yml). |
Invariants
- Rule names are unique within a single
rules.ymlfile. - Each rule contains exactly one key of
AUTHORING_KEYS(never multiple, never none). AUTHORING_KEYSis the keys of_MAPPING_PARSERSpluslayers.rules_gen._detect_rule_typereads it and keeps no copy of it.load_rulesreturns a new list on every call. For one path, an unchanged text is parsed once per process untilforget_parsed_rules()is called, and a changed text is always parsed again, whatever its size or timestamp.- Self-references (
source_ref_id == target_ref_id) are skipped during deny evaluation and never produce violations. - A deny rule sees every indexed file its node contains, whether or not that file carries an annotation; a file no node contains is counted in
files_unattributedrather than passing silently. evaluate_alloutput is deterministically sorted by(rule_name, file_path or "").NodeMatcher.matchesreturnsFalseifnode_ref_idis inexclude. Otherwise returnsTrueonly when all non-Nonefields match. An empty matcher (NodeMatcher()) matches any node.- All
kindvalues in matchers are validated againstVALID_NODE_KINDSat parse time. - All edge kind values (
unless_edge,edge_kind) are validated againstVALID_EDGE_KINDSat parse time. - Rules support
errorandwarnseverity levels (default varies by rule type).
Constraints
rules.ymlmust declare a version inSUPPORTED_SCHEMA_VERSIONS({1, 2, 3}). Unsupported versions are rejected withValueError.NodeMatchermust have at least one ofref_id,kind, ortagin deny rules; providing none raisesValueError. In require rules,has_edge_toaccepts empty{}for "any node" matching.- Deny rules depend on the
code_importstable being populated (typically via a priorreindexstep). - Without a reindex callback
lint()opens the index read-only and leavesbeadloom.dbbyte-identical; a missing index raisesLintError(exit 2) instead of reporting0 violationsagainst a database it had just created (BDL-UX #147). "No rules file" still returns an empty result without touching the index at all. - Plain
lintkeeps exit 0 when error-severity violations are found without--strict— the exit code is unchanged so an adopter's pipeline does not turn red on upgrade — but the omission is now named on stderr. - Require rules depend on the
nodesandedgestables. validate_rulesis advisory: it returns warnings but does not raise exceptions. A caller that ignores its return value has silently disabled it (BDL-UX #172).- Rule liveness never changes an exit code: it is
warn-only by design, sobeadloom ciandlint --strictstay green over an inert rule while naming it. - The
_get_file_nodehelper relies oncode_symbols.annotationsbeing valid JSON with keys likedomain,service, orfeaturewhose values correspond tonodes.ref_id.
Testing
Parsing Tests
- Valid deny rule. Parse a well-formed deny rule YAML. Assert returned
DenyRulehas correct matchers andunless_edge. - Valid require rule. Parse a well-formed require rule YAML. Assert returned
RequireRulehas correct matchers andedge_kind. - Missing version. Assert
ValueErroronrules.ymlwithoutversion. - Wrong version. Assert
ValueErroronversion: 2. - Duplicate name. Assert
ValueErrorwhen two rules share a name. - Both deny and require. Assert
ValueErrorwhen a rule has both blocks. - Neither deny nor require. Assert
ValueErrorwhen a rule has neither block. - Invalid node kind. Assert
ValueErrorforkind: "unknown"in a matcher. - Invalid edge kind. Assert
ValueErrorforunless_edge: ["unknown"]. - Matcher missing both fields. Assert
ValueErrorwhenNodeMatcherhas neitherref_idnorkind(in deny rules). - Empty matcher in require rules. Assert
has_edge_to: {}parses successfully and matches any node. - Empty matcher detects violations. Assert nodes without outgoing edges of the required kind produce violations.
- Empty matcher satisfied. Assert adding any
part_ofedge satisfies the empty-matcher rule. - Empty for-matcher rejected in deny. Assert empty matchers are still rejected in deny rule positions.
Dispatch and Memo Tests
- Codec and messages (
tests/unit/graph/rules/test_load_rules_pins_its_codec_and_messages.py, BDL-073 B1). Written before the dispatch became a table, so the table was proven against them: the UTF-8 codec under a non-UTF-8 locale, a file with norules:key loading as no rules, the two top-level messages in full, and the per-rule "must be a mapping" message for every key butlayers. Each answered aload_rulesmutant that survived the 2026-09-19 fan-out analysis. - The table against the SPEC (
tests/self_check/docs/test_rule_engine.py::TestTheSpecTableIsCheckedAgainstTheLoader). TheKeywordcolumn under Purpose equalsAUTHORING_KEYS, and the stated count is its size. - The memo (
tests/integration/graph/rules/test_load_rules_parses_once.py). Oneinit --yesand oneinit --bootstrapparse once; an unchanged file is parsed once; an edited file, and an edit of the same size inside one timestamp tick, are parsed again; a file that fails is not remembered; two paths are remembered side by side; one caller's change to its list does not reach the next caller; the TUI'sLintDataProvider.refresh()sees a rule renamed between two refreshes; every test starts with nothing remembered.
Deny Evaluation Tests
- Violation detected. Insert nodes, a code_import, and code_symbols annotation creating a forbidden path. Assert one
Violationwith correctrule_name,file_path,line_number,from_ref_id,to_ref_id. - Exemption via unless_edge. Add an edge of the exempted kind. Assert no violations.
- Self-reference skipped. Import where source and target resolve to the same node. Assert no violations.
- No matching import. Imports that do not match
from_matcherorto_matcher. Assert no violations.
Require Evaluation Tests
- Violation detected. Create a node matching
for_matcherwith no outgoing edge to the required target. Assert oneViolation. - Satisfied. Create a node with a matching outgoing edge. Assert no violations.
- Edge kind filter. Require a specific
edge_kind. Assert violation when edge exists but with wrong kind.
Validation Tests
- Unknown ref_id warning. Create rules referencing a
ref_idnot innodes. Assertvalidate_rulesreturns a warning string. - All ref_ids exist. Assert empty warning list.
- A layer tag no node carries. Declare four layers over a graph populating three. Assert
validate_rulesreturns one warning naming the empty tag, and that the evaluator emits the same tag as awarnfinding of typelayer_declaration— never at the rule's declared severity (tests/integration/graph/rules/test_a_layer_the_declaration_names_and_no_node_is_in.py). - Every layer populated. Assert both surfaces are silent.
Liveness Tests (tests/integration/graph/rules/test_rule_liveness_all_types.py)
One pair per rule type — an inert rule that must be reported, and a live rule of the same type on the same fixture that must not be. The live half is the non-vacuity guard: without it, "everything is inert" would satisfy every other assertion.
- Every rule type reports its own inertness. Nine rules, one of each type, all inert on a populated graph. Assert the reported set equals all nine names — a gap says which type is missing rather than "some count differs".
- Exactly once. Assert one finding per inert rule (an audit that affirms one fact twice is BDL-UX #173).
warnfor a PARTIAL stand-down. Nineseverity: errorrules, all inert on a populated graph. Assert every finding iswarnandhas_errorsisFalse— the adopter-safety invariant, asserted rather than assumed. The file covers the nine typesrules/liveness.py(eight matcher/graph-based types) andrules/evaluators.py(forbid_import) report between them. The TOTAL stand-downs that carry the declared severity aredoc_area_coherence's, asserted intests/integration/graph/rules/test_source_root_minority.py, andgraph-summary-facts's, asserted intests/integration/graph/rules/test_graph_summary_facts.py::TestATotalStandDownCarriesTheDeclaredSeverity— both run the real linter and fail onhas_errorsbeing False. See the severity paragraph above.- Silent on an empty index. Assert the same nine rules produce nothing against an empty schema.
- End to end. Drive the reproduction from
beadloom-mr2l.7(arequirenamingno-such-node-at-all) through the real CLI; assert the unknown ref_id is named,lint --strictexits 0, and the JSON payload carrieskind: "rule_liveness"andsummary.rules_inert == 1. Exit codes and--jsononly, never piped line counts (BDL-UX #148).
Exit-condition Tests (tests/integration/infrastructure/exit_condition/test_exit_condition_expiry.py)
All three surfaces that require an exit condition are covered in ONE file on purpose: forbid_import.exempt[].until, layers.exempt[].until and guards.<name>.exclusions[].until share one grammar, and a file per surface is how they would drift into promising different things.
- The grammar. A bare ISO date is a deadline; a date LEADING a sentence is a deadline; a date mid-sentence,
2026-1-1,20260101,2026-W01-1and prose are all events. The rejected spellings include the twodate.fromisoformataccepts on Python 3.11+ and rejects on 3.10 — the assertion that keeps the grammar interpreter-independent. - Expiry, with its non-vacuity twin. Same fixture, same exemption, only the date differs: a past deadline is reported, a future one is not.
untilequal to today is not expired (a deadline names the last day it covers); yesterday is. - Expiry does not enforce. The crossing under an expired exemption is still suppressed — no
forbid_importviolation,has_errorsFalse. A build must not redden because a day passed. - One entry, one finding. A dead and expired entry is reported once (the dead half already says "delete it").
- The count. Two crossings behind one exemption count as two; a rule with no exemptions counts zero (the counter must be able to say zero); the clean summary line grows the clause only when the count is non-zero.
- The reviewer's probe, end to end. A wildcard exemption dated
1999-01-01over a real error-severity crossing:lint --strictexits 0 and the JSON payload carries the finding andsummary.violations_suppressed;--fail-on-warnexits 1. Exit codes and--jsononly, never piped line counts (BDL-UX #148). - This repository's own entries. Every
until:in.beadloom/_graph/rules.ymlthat names a date is asserted to be in the future — the suite reddens the day one of our own baselines outlives its deadline.
Suite Rule Tests (BDL-074 C3)
test_binding(tests/integration/graph/rules/test_a_test_file_binds_to_a_node_or_is_reported.py, andtests/integration/graph/rules/test_a_kind_states_how_it_was_recognised.pyfor the recognition clause) andtest_import_boundary(tests/integration/graph/rules/test_a_unit_test_of_a_domain_node_imports_no_infrastructure.py) run over temporary indexes built bytests/support/suite_index.py.scenario_binding(tests/integration/graph/rules/test_a_scenario_lives_in_the_folder_of_its_node.py) runs over temporary projects.- End to end:
tests/acceptance/graph/rule-engine/the_suite_is_judged_against_the_graph.feature, four scenarios under the feature tags@bead:beadloom-kag9 @node:rule-engine. - This repository's own findings (
tests/self_check/architecture/test_this_repositorys_lint_findings_are_its_declared_debt.py):suite_populationfindings and thefeatures-have-bound-testsnode debt are excluded from the undeclared findings, and two checks assert that each suite rule states its population and that the feature debt still fires.
Combined Evaluation Tests
- Mixed rules. Combine deny and require rules. Assert violations from both types are returned and sorted correctly by
(rule_name, file_path). - Empty rules list. Assert
evaluate_allreturns an empty list.