Skip to content

✅ fresh

last synced 2026-09-29T21:12:19.681221+00:00 · coverage 100% (rule-engine)

Validation by Beadloom doc_sync — same source as sync-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. The until: grammar is no longer here: exit_condition_deadline moved to infrastructure/exit_condition.py in BDL-070 B2, because onboarding declares an exit condition too and was importing this peer domain to read what one is. beadloom.graph.rules.exit_condition_deadline still answers — rules/__init__.py re-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 one init parse rules.yml once, and forget_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 — one forbid_import rule over a list of imports — is public since BDL-074 C3, because test_import_boundary runs 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 through layers.own_layer_of and its tags through node_tags, so the answer it decides a layers rule'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 nearest part_of ancestor's, and which node's tag decided (layer_membership). Pure, and it reads the rule's own layers list, 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_ancestor and same_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 outside validate_rules' isinstance chain and a rule could declare a layer nothing carries without anything saying so. One predicate answers both surfaces — the validate_rules warning and the evaluator's warn finding — and the finding stands down when fewer than two layers are populated AND the rule is inert, because liveness names 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 C3 suite_population), and the one thing that follows: lint --fail-on-warn does 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 cache liveness._GraphFacts kept beside them (BDL-070 A5).
  • rules/exemptions.py — what a forbid_import exemption 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). layers decides what crosses and layer_exemptions decides 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_crossings decides what crosses; this decides what an entry does about it, the same split exemptions.py draws 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 node summary states, 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 (the files leg) and a node with no bound test file, its own or a part_of descendant's (the for leg), over the binding the reindex records in test_files (BDL-074 C3).
  • rules/test_import_boundary.py — test_import_boundary: chooses which recorded TEST imports a boundary judges, then hands them to forbid_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 — the test_files and test_imports tables read for the suite rules (IndexedTestFile carries each file's path, ref_id, placement and, since BDL-074 F1, its recorded kind), and NodeSelection (which nodes a matcher selects, and whether a node is one of them or part_of one). Every reader returns None for 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 a ListedExemption does — which entry excuses a subject, which entries excuse nothing (dead) and which exemptions are past their until date while still excusing something (expired). excused counts the subjects the entries excused in a run, and exemptions_used (BDL-074 F1) how many exemptions excused at least one, which the files leg states as excused by K exemption(s). The counterpart of exemptions.py for exemptions that list paths or node ids rather than a from/to glob pair (BDL-074 C3).
  • rules/__init__.py — evaluate_all orchestration + 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:

TypeKeywordSemantics
denydenyForbid imports between matched nodes
requirerequireMandate specific edge relationships
forbid_cyclesforbid_cyclesDetect circular dependencies via DFS
forbid_importforbid_importForbid file-level imports between glob-matched paths
forbid_edgeforbidForbid specific edge patterns between tagged node groups
layerlayersEnforce layered architecture direction
cardinalitycheckEnforce complexity limits per node
unregistered_feature_candidateunregistered_feature_candidateFlag substantial domain-only modules that model no feature
module_coveragemodule_coverageRequire every src/ module to be a tracked node or explicitly exempt
scenario_coveragescenario_coverageBind behaviour-bearing nodes to executable scenarios, both ways
doc_area_coherencedoc_area_coherenceDocument a node where this graph's own convention documents nodes like it
summary_factssummary_factsCheck a number or version stated in a node summary against the project
test_bindingtest_bindingA test file bound to no node, and a node with no bound test file
test_import_boundarytest_import_boundaryForbid imports from test files, narrowed to the tests of matched nodes
scenario_bindingscenario_bindingA scenario's @node: tag names the node folder its feature file sits in

Constants ​

python
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.

FieldTypeDescription
ref_idstr | NoneExact ref_id to match, or None for any.
kindstr | NoneNode kind to match, or None for any.
tagstr | NoneTag the node must have, or None for any.
excludetuple[str, ...] | NoneRef_ids to exclude from matching, or None for no exclusions.
python
def matches(self, node_ref_id: str, node_kind: str, *, tags: set[str] | None = None) -> bool

Returns 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.

FieldTypeDescription
namestrUnique rule name.
descriptionstrHuman-readable description.
from_matcherNodeMatcherMatches the source (importing) node.
to_matcherNodeMatcherMatches the target (imported) node.
unless_edgetuple[str, ...]Edge kinds that exempt the import from violation.

RequireRule ​

Requires that matched nodes have at least one outgoing edge to a target node.

FieldTypeDescription
namestrUnique rule name.
descriptionstrHuman-readable description.
for_matcherNodeMatcherMatches nodes that must satisfy the rule.
has_edge_toNodeMatcherMatches the required target node.
edge_kindstr | NoneIf 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).

FieldTypeDescription
namestrUnique rule name.
descriptionstrHuman-readable description.
edge_kindstr | tuple[str, ...]Edge kind(s) to check for cycles.
max_depthintMaximum DFS depth (default 10).
severitystr"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.db becomes beadloom/infrastructure/db: no source root, no file extension, because an import names a module, not a file. A to: written as src/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 while lint --strict printed 12 rules, 0 violations (BDL-UX #172; this reference taught the broken form, which is why the fix belongs here and not only in rules.yml). Write beadloom/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 db is indexed with import_path == "pkg.infrastructure" — the target is the package, not the module — so pkg/infrastructure/** is matched against pkg/infrastructure/ as well, and the most common Python reach-in form is caught. Sibling names are unaffected: pkg/infrastructure_docs still does not match. (Also BDL-UX #172: the probe injected to reproduce that bead — from beadloom.infrastructure import db in the TUI — fired under no glob form before this.)

FieldTypeDescription
namestrUnique rule name.
descriptionstrHuman-readable description.
from_globstrGlob matched against the source file path (src/pkg/tui/app.py).
to_globstrGlob matched against the dotted import path, dots → slashes (pkg/infrastructure/db).
severitystr"error" or "warn".
exempttuple[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.

FieldTypeDescription
to_globstrMatched like the rule's to (dotted import path). Default "*" — any target.
from_globstrMatched like the rule's from (source file path). Default "*" — any source.
reasonstrMandatory. Why this crossing is tolerated.
untilstrMandatory. 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-DD date, 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 entryWhat the run says
suppresses nothinga dead finding (rule_liveness, warn): "suppresses nothing … delete it"
suppresses something, past its deadlinean expired finding (rule_liveness, warn) naming the date and how many crossings it is still excusing
suppresses something, within its deadline or on an eventcounted: 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 typeInert whenReported by
denyits 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
requireits for selects 0 nodes — or its has_edge_to selects 0, in which case every node it matches would fail, which is equally brokenliveness.py
forbid_cyclesthe graph holds 0 live (active) edges of the declared edge_kind(s), so there is no chain to walkliveness.py
forbid_importits from glob matches 0 indexed source files, or its to glob matches 0 indexed import pathsevaluators.py (a stale exempt entry: exemptions.py)
forbid (edge)its from/to selects 0 nodes, or the graph holds 0 edges of its edge_kindliveness.py
layersno 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 comparedliveness.py
unregistered_feature_candidateits for selects 0 nodes, or none of the nodes it selects declares a source, so it has no files to inspectliveness.py
module_coverageits source_root holds 0 modules, on disk or in the index — "complete coverage" of nothingliveness.py
scenario_coverageper 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_inertreported by scenario_coverage.py, counted by liveness.py
doc_area_coherenceno 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 conventionreported by doc_area.py, counted by liveness.py
summary_factsno 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 livereported by summary_facts.py, counted by liveness.py
test_bindingthe 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 inertreported by test_binding.py, counted by liveness.py
test_import_boundaryno 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 crossingreported by test_import_boundary.py, counted by liveness.py
scenario_bindingits features glob matches 0 feature filesreported 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.

yaml
  - 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"
LegFinding
coveragea node matched by for that no scenario names, and no declaration excuses
suitea 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
referencea scenario a references document claims exists and the suite does not contain
declarationa non_behavioural entry naming a node outside the population, or one that has a scenario anyway
excusedone statement per run naming how many of how many nodes left the population and why — silent when nothing is excused
populationone 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 says 0 files scanned — and firing there would flood every fresh clone and every language Beadloom does not extract imports from.
  • deny liveness is matcher-based only. An index with no resolved imports makes every deny rule 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).

FieldTypeDescription
namestrUnique rule name.
descriptionstrHuman-readable description.
from_matcherNodeMatcherMatches the source node.
to_matcherNodeMatcherMatches the target node.
edge_kindstr | NoneIf set, restricts to edges of this kind.
severitystr"error" or "warn".

LayerDef ​

Defines a single architecture layer for use in LayerRule.

FieldTypeDescription
namestrLayer name.
tagstrTag identifying nodes in this layer.

LayerRule ​

Enforces dependency direction between ordered architecture layers.

FieldTypeDescription
namestrUnique rule name.
descriptionstrHuman-readable description.
layerstuple[LayerDef, ...]Ordered layers (top to bottom).
enforcestr"top-down" — higher layers may depend on lower, not reverse.
allow_skipboolIf False, forbids skipping intermediate layers (default True).
edge_kindstrEdge kind to check (default "uses").
severitystr"error" or "warn".
exempttuple[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).

FieldTypeDescription
from_globstrfnmatch pattern over the source node's ref_id.
to_globstrfnmatch pattern over the target node's ref_id.
reasonstrWhy the crossing stands. Mandatory.
untilstrIts 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).

FieldTypeDescription
namestrUnique rule name.
descriptionstrHuman-readable description.
for_matcherNodeMatcherMatches nodes to check.
max_symbolsint | NoneMaximum symbols a node OWNS (nested nodes excluded).
max_filesint | NoneMaximum files per node.
min_doc_coveragefloat | NoneMinimum 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).
severitystr"error" or "warn" (default "warn").

Rule (type alias) ​

python
Rule = (DenyRule | RequireRule | CycleRule | ImportBoundaryRule | ForbidEdgeRule | LayerRule
        | CardinalityRule | UnregisteredFeatureCandidateRule | ModuleCoverageRule
        | ScenarioCoverageRule | DocAreaCoherenceRule | SummaryFactsRule
        | TestBindingRule | TestImportBoundaryRule | ScenarioBindingRule)

Violation ​

FieldTypeDescription
rule_namestrName of the violated rule.
rule_descriptionstrDescription of the violated rule.
rule_typestr"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).
severitystr"error" or "warn".
file_pathstr | NoneSource file path (for deny/import violations).
line_numberint | NoneLine number (for deny/import violations).
from_ref_idstr | NoneSource node ref_id; on a per-node rule_liveness finding it is the node the finding is about.
to_ref_idstr | NoneTarget node ref_id.
messagestrHuman-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.

yaml
  - 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 on

Each node/doc pair is reduced to two comparable segments:

SideHow the segment is found
source areathe 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 areathe 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.

OutcomeWhat it means
a findingthe 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
silenceevery compared pair agrees with a dominant mapping
rule_livenessno 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.

yaml
  - 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 ignored

The 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.

OutcomeWhat it means
a findingthe 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
silencethe claim agrees, or the summary states no number this project computes a fact for
rule_liveness, per nodethe 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 ruleno 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.

yaml
  - 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 files leg judges every indexed test file the glob matches, except those a kind folder places (placement other_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's scenario_binding rules, 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 no tests.kinds entry replaces. The folder is trusted, not verified: what a file holds is never checked against its kind, so a unit test dropped into tests/self_check/ counts as a self-check, and the statement says so rather than implying a check. An index that recorded no layout says recognised by its folder alone and asks for a reindex. On this repository, measured by beadloom lint --strict --format json on 2026-09-28, the rule test-files-bind-to-a-node opens 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 for leg judges every node the matcher selects. A node is bound when a test file is bound to it or to one of its part_of descendants. Acceptance scenarios are not counted here, because that binding is scenario_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.

  • exempt entries each list exactly one of files (fnmatch globs over paths) or nodes (exact ref_id values), as a non-empty list, with a non-empty reason and until. A nodes exemption on a rule without for, or a files exemption on a rule without files, 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 own evaluate_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 with rule_type: test_import_boundary and get forbid_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 to glob.
  • The imports come from test_imports, whose reader keeps an aliased import a as b that the code-import extractor drops. The population statement says so.
  • The population statement counts the crossings an exemption excused, because lint's N crossings suppressed clause reads code_imports only.

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_of container of the node folder. A folder that names no node there (services/ on a graph with no services node) is not judged.
  • In a correctly placed file, every scenario must carry @node:<folder>. It may carry other node tags beside it.
  • exempt entries list files only, with a reason and an until.

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_liveness finding, "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 until leads with, while it still excuses something, is reported once with the count. Expiry is a finding and never re-enables anything: nothing reappears at error because a day passed. The date grammar is the shared infrastructure/exit_condition.py one.

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.

yaml
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                    # optional

Each 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 ​

python
def load_rules(rules_path: Path) -> list[Rule]
  1. Read rules_path as 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 per init below).
  2. Otherwise parse the text with yaml.safe_load. The document must be a mapping.
  3. Validate top-level version field is in SUPPORTED_SCHEMA_VERSIONS ({1, 2, 3}). Raise ValueError on mismatch or absence.
  4. Read rules, defaulting to [] when the key is absent. Raise ValueError if it is not a list.
  5. For each entry: a. Require a mapping, and in it a non-empty string name field. b. Enforce unique names (tracked via seen_names set). Raise ValueError on duplicate. c. Resolve the severity BEFORE the rule type, so a rule wrong in both ways is reported for its severity. An omitted severity is warn when 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) and error otherwise. d. Take AUTHORING_KEYS.intersection(rule). None or several raise ValueError: rule '<name>' must have exactly one of <every authoring key, sorted>. e. layers is 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].
  6. Remember (text, tuple(rules)) under the resolved path and return the list. A file that raises is not remembered.
  7. NodeMatcher parsing validates: for deny rules, at least one of ref_id, kind, or tag must be present. For require rules, has_edge_to accepts an empty dict {} (matches any node) via allow_empty=True. kind (if present) is validated against VALID_NODE_KINDS. exclude accepts 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 timeit on this repository's rules.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's append or clear cannot 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.py calls 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 the mutants/ copy, all 136 load_rules verdicts of a serial run were identical, because every killable mutant is killed first by a test on its own tmp_path.

The memo lives as long as the process, so it applies to every caller in it, not only to init.

Validation Against Database ​

python
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 ​

python
def evaluate_deny_rules(conn: sqlite3.Connection, rules: list[DenyRule]) -> list[Violation]

Algorithm:

  1. Query all rows from code_imports where resolved_ref_id IS NOT NULL, and build the run's FileAttribution snapshot (see Source attribution below).
  2. For each import row (file_path, line_number, import_path, resolved_ref_id): a. Ask FileAttribution.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: see LintResult.files_unattributed. c. Look up (ref_id, kind) for the target via _get_node. d. For each deny rule, take the most specific candidate whose from_matcher matches (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 and to_matcher matches the target, check for exemption: if unless_edge is non-empty, query edges for any edge of those kinds between the matched source and the target. If found, skip. f. Otherwise, emit a Violation naming the matched candidate as from_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_on edge existed, because import_resolver derives that edge from OWNERSHIP. Measured on this repository before the fix: 22 of 128 import-source files (17%), among them services/cli.py and 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:

CandidateRank
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 ​

python
def evaluate_require_rules(conn: sqlite3.Connection, rules: list[RequireRule]) -> list[Violation]

Algorithm:

  1. Fetch all (ref_id, kind) from the nodes table.
  2. For each rule, iterate all nodes. If for_matcher matches a node: a. Query all outgoing edges from that node (edges WHERE src_ref_id = ?). b. For each edge, optionally filter by edge_kind. Look up the target node via _get_node. c. If any target matches has_edge_to, the node satisfies the rule. d. If no matching edge is found, emit a Violation.

The layer a node is in (rules/layers.py, BDL-070 A1) ​

python
@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_of generation that does, and None when no generation does. layer_membership returns that index together with declared_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_of is 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_rules iterated the node's tag set and 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 one part_of parent, 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) ​

python
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) ​

python
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) ​

python
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) ​

python
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 read

architecture-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_rules resolves both ends through layer_membership — a node's own declared tag, else the nearest part_of container 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 live depends_on edges, 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_statements reports the crossings no exempt: 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:

RenderingHow the population appears
format_richa 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_jsonsummary.layer_populations[] — rule, edge_kind, evaluated, total, skipped_untagged. Additive against the keys lint --format json carried before BDL-070
format_githubone 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_porcelainone 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 line0 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.

SurfaceHow the population appears
application/gate.py lint_stepthe 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_lintsummary.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.pypast 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.pypast 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.pythe 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 ​

python
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 ​

FunctionDescription
_parse_node_matcherParse 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_ruleParse a deny block into a DenyRule with validated matchers and unless_edge.
_parse_require_ruleParse a require block into a RequireRule with validated matchers and optional edge_kind.
_parse_cycle_ruleParse a forbid_cycles block into a CycleRule with edge_kind and optional max_depth.
_parse_forbid_import_ruleParse a forbid_import block into an ImportBoundaryRule with from/to glob patterns.
_parse_forbid_ruleParse a forbid block into a ForbidEdgeRule with from/to matchers and optional edge_kind.
_parse_layer_ruleParse a layer rule into a LayerRule with ordered LayerDef entries. Handed the whole rule, not a block.
_parse_check_ruleParse a check block into a CardinalityRule with threshold fields.
_parse_unregistered_feature_candidate_ruleParse an unregistered_feature_candidate block into an UnregisteredFeatureCandidateRule.
_parse_module_coverage_ruleParse a module_coverage block into a ModuleCoverageRule.
_parse_scenario_coverage_ruleParse a scenario_coverage block into a ScenarioCoverageRule.
_parse_doc_area_coherence_ruleParse a doc_area_coherence block into a DocAreaCoherenceRule.
_parse_summary_facts_ruleParse a summary_facts block into a SummaryFactsRule.
_parse_import_boundaryThe 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_exemptionsParse 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_ruleParse a test_binding block into a TestBindingRule.
_parse_test_import_boundary_ruleParse a test_import_boundary block into a TestImportBoundaryRule.
_parse_scenario_binding_ruleParse a scenario_binding block into a ScenarioBindingRule.
_first_matching_sourceThe most specific candidate a deny rule applies to, or None.
_get_nodeReturn (ref_id, kind) tuple for a node, or None.
_edge_existsReturn 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 ​

python
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 ​

python
@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: str

CLI ​

beadloom lint [--format {rich,json,porcelain}] [--strict] [--no-reindex]
FlagDefaultDescription
--formatrichOutput format: rich (colored tables), json, or porcelain.
--strictFalseExit with code 1 if any violations are found.
--no-reindexFalseRead the index as-is. This is the READ-ONLY form: the default reindexes first and therefore WRITES beadloom.db.

Exit codes:

CodeMeaning
0No violations (or violations without --strict).
1Violations detected (with --strict).
2Configuration error (missing/invalid rules.yml).

Invariants ​

  • Rule names are unique within a single rules.yml file.
  • Each rule contains exactly one key of AUTHORING_KEYS (never multiple, never none).
  • AUTHORING_KEYS is the keys of _MAPPING_PARSERS plus layers. rules_gen._detect_rule_type reads it and keeps no copy of it.
  • load_rules returns a new list on every call. For one path, an unchanged text is parsed once per process until forget_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_unattributed rather than passing silently.
  • evaluate_all output is deterministically sorted by (rule_name, file_path or "").
  • NodeMatcher.matches returns False if node_ref_id is in exclude. Otherwise returns True only when all non-None fields match. An empty matcher (NodeMatcher()) matches any node.
  • All kind values in matchers are validated against VALID_NODE_KINDS at parse time.
  • All edge kind values (unless_edge, edge_kind) are validated against VALID_EDGE_KINDS at parse time.
  • Rules support error and warn severity levels (default varies by rule type).

Constraints ​

  • rules.yml must declare a version in SUPPORTED_SCHEMA_VERSIONS ({1, 2, 3}). Unsupported versions are rejected with ValueError.
  • NodeMatcher must have at least one of ref_id, kind, or tag in deny rules; providing none raises ValueError. In require rules, has_edge_to accepts empty {} for "any node" matching.
  • Deny rules depend on the code_imports table being populated (typically via a prior reindex step).
  • Without a reindex callback lint() opens the index read-only and leaves beadloom.db byte-identical; a missing index raises LintError (exit 2) instead of reporting 0 violations against a database it had just created (BDL-UX #147). "No rules file" still returns an empty result without touching the index at all.
  • Plain lint keeps 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 nodes and edges tables.
  • validate_rules is 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, so beadloom ci and lint --strict stay green over an inert rule while naming it.
  • The _get_file_node helper relies on code_symbols.annotations being valid JSON with keys like domain, service, or feature whose values correspond to nodes.ref_id.

Testing ​

Parsing Tests ​

  • Valid deny rule. Parse a well-formed deny rule YAML. Assert returned DenyRule has correct matchers and unless_edge.
  • Valid require rule. Parse a well-formed require rule YAML. Assert returned RequireRule has correct matchers and edge_kind.
  • Missing version. Assert ValueError on rules.yml without version.
  • Wrong version. Assert ValueError on version: 2.
  • Duplicate name. Assert ValueError when two rules share a name.
  • Both deny and require. Assert ValueError when a rule has both blocks.
  • Neither deny nor require. Assert ValueError when a rule has neither block.
  • Invalid node kind. Assert ValueError for kind: "unknown" in a matcher.
  • Invalid edge kind. Assert ValueError for unless_edge: ["unknown"].
  • Matcher missing both fields. Assert ValueError when NodeMatcher has neither ref_id nor kind (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_of edge 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 no rules: key loading as no rules, the two top-level messages in full, and the per-rule "must be a mapping" message for every key but layers. Each answered a load_rules mutant that survived the 2026-09-19 fan-out analysis.
  • The table against the SPEC (tests/self_check/docs/test_rule_engine.py::TestTheSpecTableIsCheckedAgainstTheLoader). The Keyword column under Purpose equals AUTHORING_KEYS, and the stated count is its size.
  • The memo (tests/integration/graph/rules/test_load_rules_parses_once.py). One init --yes and one init --bootstrap parse 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's LintDataProvider.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 Violation with correct rule_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_matcher or to_matcher. Assert no violations.

Require Evaluation Tests ​

  • Violation detected. Create a node matching for_matcher with no outgoing edge to the required target. Assert one Violation.
  • 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_id not in nodes. Assert validate_rules returns 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_rules returns one warning naming the empty tag, and that the evaluator emits the same tag as a warn finding of type layer_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).
  • warn for a PARTIAL stand-down. Nine severity: error rules, all inert on a populated graph. Assert every finding is warn and has_errors is False — the adopter-safety invariant, asserted rather than assumed. The file covers the nine types rules/liveness.py (eight matcher/graph-based types) and rules/evaluators.py (forbid_import) report between them. The TOTAL stand-downs that carry the declared severity are doc_area_coherence's, asserted in tests/integration/graph/rules/test_source_root_minority.py, and graph-summary-facts's, asserted in tests/integration/graph/rules/test_graph_summary_facts.py::TestATotalStandDownCarriesTheDeclaredSeverity — both run the real linter and fail on has_errors being 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 (a require naming no-such-node-at-all) through the real CLI; assert the unknown ref_id is named, lint --strict exits 0, and the JSON payload carries kind: "rule_liveness" and summary.rules_inert == 1. Exit codes and --json only, 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-1 and prose are all events. The rejected spellings include the two date.fromisoformat accepts 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. until equal 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_import violation, has_errors False. 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-01 over a real error-severity crossing: lint --strict exits 0 and the JSON payload carries the finding and summary.violations_suppressed; --fail-on-warn exits 1. Exit codes and --json only, never piped line counts (BDL-UX #148).
  • This repository's own entries. Every until: in .beadloom/_graph/rules.yml that 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, and tests/integration/graph/rules/test_a_kind_states_how_it_was_recognised.py for the recognition clause) and test_import_boundary (tests/integration/graph/rules/test_a_unit_test_of_a_domain_node_imports_no_infrastructure.py) run over temporary indexes built by tests/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_population findings and the features-have-bound-tests node 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_all returns an empty list.