Skip to content

✅ fresh

last synced 2026-09-29T21:12:19.681221+00:00 · coverage 86% (graph)

Validation by Beadloom doc_sync — same source as sync-check.

Graph Domain ​

YAML format for describing the project architecture graph, with loader, diff engine, rule engine, import resolver, linter, snapshot storage, C4 architecture model mapping, and cross-repo federation.

Features and components ​

Features (each with a SPEC.md):

  • Graph Diff — graph delta engine vs a git ref / snapshot.
  • Rule Engine — architecture-boundary lint (incl. module-coverage, scenario-coverage and doc-area-coherence), and since BDL-074 C3 the test suite judged against the graph (test_binding, test_import_boundary, scenario_binding).
  • Scenario Binding — .feature + doc-reference reader: which node and which bead a scenario binds to.
  • Import Resolver — code-import → edge resolution.
  • C4 Diagrams — graph → C4/Mermaid mapping.
  • Federation — cross-repo export + hub aggregation.
  • Snapshot — point-in-time graph snapshots + compare.

Components (internal building blocks, each with a DOC.md):

  • Graph Loader — YAML → nodes / edges ingestion seam.
  • Contracts — first-class cross-service contract model.
  • SDL — dependency-free GraphQL SDL surface extractor (name-level).
  • GraphQL Surface — typed GraphQL Tier-A surface (field types + nullability/list + args) via the optional graphql-core extra, honest name-level fallback.
  • GraphQL Breaking — native typed GraphQL breaking analysis (names broken consumer references).
  • AMQP Body — strict AMQP message-body JSON-Schema model + native body-diff (names broken consumer-read fields; nested + array depth).
  • AsyncAPI — source-only AsyncAPI ingestion adapter (extracts a message payload JSON-Schema into the internal AMQP body model; honest degradation).

Specification ​

File Location ​

The graph is stored in .beadloom/_graph/*.yml. All files with the .yml extension in this directory are loaded during reindex, sorted by name.

YAML Structure ​

yaml
nodes:
  - ref_id: my-service        # Unique identifier (required)
    kind: service              # Node type (required)
    summary: "Description"     # Brief description (required)
    source: src/my_service/    # Path to source code (optional)
    lifecycle: active          # active|planned|deprecated|dead (optional, default active)
    docs:                      # Linked documents (optional)
      - docs/my-service.md
    tests:                     # Test path prefixes bound to this node over the mirror (optional)
      - tests/integration/my_service_smoke/
    # Any additional fields go into extra (JSON)

edges:
  - src: my-service            # Source ref_id (required)
    dst: core                  # Destination ref_id (required)
    kind: part_of              # Edge type (required)
    lifecycle: active          # active|planned|deprecated|dead (optional, default active)
  # A cross-repo edge endpoint uses @<repo>:<ref_id>, e.g. dst: @integration-service:plans

tests: (BDL-074 C1) lists path prefixes, resolved like source: — a trailing / is a directory, anything else is one file. It binds the test files it covers to the node, and it wins over the binding derived from a test file's path. The reindex reads it into the test_overrides table, and a value that is not a list of path strings is reported as a reindex warning and binds nothing. See the Test Mapping SPEC.

Node Types (node kind) ​

KindDescription
domainDomain area
featureFeature
serviceService / module
entityData entity
adrArchitecture Decision Record

Edge Types (edge kind) ​

KindDescriptionBFS Priority
part_ofA is part of B1
touches_entityA touches entity B2
usesA uses B3
implementsA implements B3
depends_onA depends on B4
touches_codeA touches code of B5

The docs Field ​

An array of paths to documents linked to the node. Paths are specified relative to the project root (e.g., docs/spec.md). During reindex, a doc_path -> ref_id mapping is built to link chunks to graph nodes.

The lifecycle Field (federation) ​

An optional lifecycle status on each node and edge — one of active (default), planned, deprecated, dead, or external (BDL-038 G7). It is a first-class SQLite column (not stored in extra), so it is type-checked, SQL-queryable, and visible to the rule engine: only active edges count as "live" for the no-dependency-cycles and architecture-layers rules. The federation hub reconciles each edge's lifecycle against reality to produce a three-valued intent-vs-reality verdict. Absent → active; an invalid value is recorded in GraphLoadResult.errors and falls back to active. See the federation SPEC.

external marks a node the author declares as present-but-not-ours — e.g. a native Swift/Kotlin/ObjC++/C++ bridge in modules/. A dependent whose target is external (or an edge that itself declares lifecycle: external) resolves to EdgeVerdict.EXTERNAL / ContractVerdict.EXTERNAL at the hub — never DRIFT. external is the most-significant lifecycle (external > dead > deprecated > planned > active), so a single external endpoint marks the whole contract external. VALID_LIFECYCLES in loader.py accepts it; the DB lifecycle CHECK admits it (SCHEMA v4).

Cross-repo references (federation) ​

A graph ref may name a node in another repository as @<repo>:<ref_id> (e.g. @integration-service:plans). A plain ref (no leading @) stays local exactly as before. Cross-repo edges are persisted in a dedicated foreign_edges table and resolve at a federation hub via beadloom export / beadloom federate. See the federation SPEC.

GraphQL contracts (federation) ​

A cross-service contract may declare protocol: graphql (alongside amqp). The producer edge (kind: produces) carries contract: {protocol: graphql, schema: <Name>, source_file: <path-to-schema.graphql>}; at load time the loader parses the referenced SDL (relative to the project root) with the dependency-free sdl.extract_surface extractor and folds the exposed surface — top-level Query/Mutation/Subscription field names plus type/input/enum/interface type names — into the stored contract payload as exposed: [...sorted...]. A missing/unreadable file records exposed: [] plus a GraphLoadResult.warnings entry (honest, never faked). The consumer edge (kind: consumes, often @backend:<Schema>) declares contract: {protocol: graphql, schema: <Name>, references: [op/type names]}, carried through verbatim. Both sides reconcile by contract_key = graphql:<schema> (a name, not a code symbol), so a TS/FSD client and a backend resolve across the language boundary (G3). See the federation SPEC.

Typed Tier-A surface + native breaking verdict (BDL-060 / S2) ​

When the optional graphql-core extra (beadloom[graphql]) is installed, the loader additionally folds a producer's typed Tier-A surface into the contract payload as a sorted/deduped fields block — each operation field (across queries, mutations, AND subscriptions) carries its return type with nullability (!) and list ([]) wrapping preserved plus its args ({name, type}). The graphql_surface component owns this extraction; absent the extra (or on a malformed SDL) it degrades honestly to the name-level surface (no fields block, no fabricated types). A consumer edge may likewise declare a typed fields block (carried verbatim). The federation export emits contract.fields (sorted via _normalize_contract_surface); older readers tolerate a missing block (additive schema). When both sides are typed, contracts.Contract computes a native breaking verdict (the graphql_breaking component): a consumer-referenced field/arg that is absent, type-narrowed, or nullability-broken (e.g. a non-null arg the consumer doesn't supply, a Plan!→Plan narrowing, a dropped/retyped subscription field) → BREAKING, naming the offending field/arg; a purely additive producer change (new optional field, new nullable arg, nullability widening) is benign. Absent typed depth on either side the verdict honestly degrades to the BDL-038 name-presence check. Beadloom computes this verdict itself (native rigor); it does not delegate to an external GraphQL registry/tool.

AMQP message body + native body-diff (BDL-060 / S3) ​

An AMQP contract edge may declare an optional body — a minimal JSON-Schema (type, properties, required, enum, nested objects, array items) describing the message payload — on the producer (direction: produces) and the consumer (direction: consumes). The loader carries it verbatim; the federation hub normalizes it (properties + required sorted recursively via amqp_body.serialize_body) in _normalize_contract_surface, so an equivalent body declared in a different order is byte-stable on the wire (the determinism invariant). The export schema bump is additive — older readers tolerate a missing body. When both sides carry a body, contracts.Contract computes a native body-diff verdict (the amqp_body component): a consumer-read field that is absent, type-incompatible, required-by-consumer-but-now-optional/removed, enum-narrowed, or nested/array-broken in the producer body → BREAKING, naming the field path (field, parent.child, field[], field[].child); additive producer fields and widened requiredness are benign. Absent a body on either side the AMQP verdict degrades honestly to the name-level both-sides presence check (BDL-038 parity). Teams using AsyncAPI can ingest the message payload JSON-Schema via the source-only asyncapi.extract_payload_body adapter (AsyncAPI 2.x/3.x; honest degradation to None — never a fabricated body); the internal model stays the minimal JSON-Schema body, AsyncAPI is just a source.

Nested landscapes — product vs company (federation, BDL-038 / U5) ​

An optional landscape provenance names the product a satellite belongs to. It is resolved like repo (resolve_landscape: .beadloom/config.yml landscape: key > the resolved repo name) and beadloom export emits it only when explicitly configured (an undeclared-landscape export keeps the F1 wire shape — no landscape key). beadloom federate then composes either one product-landscape (all satellites share, or none declare, a landscape) or a company-landscape (several). Implicit same-contract_key reconciliation is scoped within a landscape — reconcile_contracts groups by (landscape, contract_key) — so two unrelated products that happen to share a coincidental message_type / schema name reconcile in separate groups and produce zero mutual DRIFT / UNDECLARED / false-CONFIRMED. A genuine cross-product contract is declared with an explicit @otherrepo:<ref> consumer edge: such a key is promoted cross-landscape (one shared group) and still resolves with a both-sides verdict regardless of landscape. An export with no landscape (or one equal to its repo) shares a single default group, so a single-product run is byte-identical to F1. See the federation SPEC.

Landscape gate — federate --fail-on (federation, BDL-039 / F3) ​

gate_failures(fed, fail_on) -> list[GateFailure] is a pure function over a FederatedGraph: it scans every edge EdgeVerdict and every contract ContractVerdict (matched case-insensitively against the enum value) and returns deterministically-sorted GateFailures (each with a kind edge/contract, an identity, the matched verdict, and for BREAKING the missing GraphQL names). beadloom federate --fail-on <csv> wires it as a CI gate: it writes federated.json/.txt and prints the report first, then exits 1 on any failure (so CI always has the artifact). A bare --fail-on / default uses SAFE_DEFAULT_FAIL_ON (breaking,drift,orphaned_consumer,undeclared_producer + the edge-level undeclared). No false gates (principle 3): NEVER_FAIL_VERDICTS (external/expected/dead/unmapped/confirmed/ok/cleanup_candidate) is disjoint from the default set and is rejected if passed to --fail-on. gate_failure_remediation(failure) -> str | None derives an agent-actionable "how to fix" hint per verdict (BREAKING names the missing surface; ORPHANED_CONSUMER / UNDECLARED_PRODUCER / DRIFT name the contract and the corrective action), printed as a fix: line under each failing verdict. See the federation SPEC.

Agent-actionable violations — Violation.remediation (rule engine, BDL-039 / F3) ​

Each Violation carries an additive remediation: str | None (default None so existing constructions/tests are unaffected). evaluate_all populates it as a deterministic post-pass via _remediation_for(rule_type, violation), which templates a "how to fix" hint per rule kind: deny/forbid-import → remove/reroute the named import; forbid → remove/reroute the named edge; cycle → break the cycle at a named edge; layer → invert the dependency or extract a shared abstraction; cardinality → split the over-large node; require → add the required edge. The linter surfaces this through --format json (additive remediation key on each violations entry, plus a stable findings array {kind, rule, severity, node, locations, why, remediation}). node is the violating node's ref_id, or null for a finding about no single node. It joined the shape in BDL-067 .14: a require violation names its node only inside the English of why, so init — telling an adopter which graph file to open — had to parse a sentence to find it and --format github (GitHub Actions ::error/::warning annotations). rich/porcelain are unchanged.

Modules ​

  • loader.py -- YAML graph parser and SQLite loader. Parses .beadloom/_graph/*.yml files and populates nodes and edges tables. Validates ref_id uniqueness and edge integrity — unique_by_ref_id decides which node survives a ref_id carried twice (the first) and returns a DuplicateRefId per node dropped, naming where each was read, its kind and its source (BDL-069, BDL-UX #214). Supports in-place YAML node updates, written through the atomic-io primitive (write_yaml_atomic: temp file + fsync + atomic os.replace) so an interrupted edit never truncates the source-of-truth *.yml. Cross-repo edge endpoints (@<repo>:<ref_id>) are recorded as ForeignEdges into a dedicated foreign_edges table (surfaced on GraphLoadResult.foreign_edges) for hub resolution rather than treated as dangling-edge errors (F1). For a GraphQL produces contract with a source_file, the loader folds the parsed SDL exposed surface into the stored contract payload (F2 / BDL-038); a missing file records exposed: [] + a warning.
  • diff.py -- Graph delta engine. Compares current on-disk graph YAML against state at a given git ref, or compares a saved snapshot against the current DB state. Detects added, removed, and changed nodes and edges, including source path changes, tag changes, and symbol count deltas. Reduces each side by loader.unique_by_ref_id — so its answer about which of two nodes under one ref_id survives is the loader's — and carries the findings on GraphDiff.duplicates, which the Rich rendering prints first and diff_to_dict serializes; has_changes ignores them, because a report is not a change. Provides Rich rendering and JSON serialization. See graph-diff SPEC.
  • rules/ -- Architecture rule engine (decomposed by responsibility, BDL-059 S3): types.py (model), loader.py (YAML → typed rules + DB validation, through one dispatch table whose keys are AUTHORING_KEYS), evaluators.py (per-rule-type evaluation), cycles.py (colored-DFS cycle detection + edge-liveness helpers), layers.py (what layer a node is in — its own declared layer, else its nearest part_of ancestor's; pure, and it reads the rule's layers list, so no layer tag is written down in it, BDL-070 A1; can_fire_on answers whether a rule compares any edge of a set, which is what liveness and the declaration statement both decide on since B5-fix), layer_reach.py (how much of its edge set a layer rule judged — counted against the layer each end is IN since BDL-070 B3 — and the finding that says so — always warn, never the rule's declared severity, because a statement about REACH is not a boundary breach and an adopter's green Gate must not turn red on upgrade, BDL-070 A2; layer_rule_reaches answers for a whole rule list over one read of the graph, and LayerReach.to_dict is the shape lint --format json carries, BDL-070 A3; population_phrase is the ONE wording of that fraction and stated_populations the one filter that decides there is nothing to say, both shared with the five surfaces outside this module that report a lint result, BDL-070 A4), layer_declaration.py (which declared layers no node is in — the check validate_rules had no LayerRule case for, since a layer rule names tags rather than ref_ids; one predicate answers both 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 and B5-fix), advisories.py (the rule types whose findings report a rule's REACH rather than a defect — layer_population and layer_declaration — and the one thing that follows: lint --fail-on-warn does not exit 1 on them, because both appear on a graph nobody changed and neither decides anything about an edge, BDL-070 A8. The exclusion selects by rule type and stops at error: an advisory emitted at that severity exits 1 under --fail-on-warn as it does under --strict, so the harsher flag can never read softer than the other on one run — the bound is in the predicate rather than asserted about the two constructors, which hardcode warn today and are not obliged to, A8 re-review Minor 3), node_tags.py (the one cached read of nodes.extra["tags"] for an evaluation run; it replaces the five identical closures deny / require / forbid-edge / layer / cardinality each kept, BDL-070 A2, and the sixth liveness kept beside them, BDL-070 A5; a row whose extra does not parse is skipped rather than raised, so one malformed node cannot fail every tag question in the run, BDL-070 A8), liveness.py (per-rule-type inertness: the finding for the eight matcher/graph-based types, and the rules_inert COUNT for every type except forbid_import, whose dead-glob and dead-exemption findings are deliberately outside that counter; a layers rule's liveness is decided through layers.can_fire_on — whether any live edge is one the rule COMPARES, on the same derived layer the rule's verdict rests on — since BDL-070 B5-fix closed BDL-UX #296, where one run reported an error from a rule and counted that same rule inert), exemptions.py (what a forbid_import exemption excused, and whether the exemption is still live), layer_exemptions.py (the same two questions for a SAME-LAYER exemption on a layer rule, BDL-070 B2: which peer crossings each exempt: entry excuses, how many, and whether its exit condition has passed. layers.same_layer_crossings decides what crosses and this decides what an entry does about it; the population the rule judged is counted BEFORE any exemption is consulted, so excusing a crossing does not shrink the denominator a reader checks the verdict against. An entry that excuses nothing or has outlived its own deadline is reported at warn; since BDL-070 B3 a crossing NO entry excuses is a finding at the rule's declared severity), 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 and holds no predicate of its own, which is how the architecture view came to draw an edge red exactly when beadloom lint reports it: before B4 the view applied dst_rank <= src_rank, which is true of every dependency inside one layer as well as every upward one, and drew arrows red here that the rule finds nothing against. The measured count is in the rule-engine SPEC, which is where a number about this graph is held against it), layer_crossings.py (what the rule SAYS about a dependency inside one layer, BDL-070 B3: the crossings no entry excuses, as findings at the rule's declared severity, and the exemption entries that have stopped earning their place. layers decides what crosses and layer_exemptions decides what an entry does about it; this turns the pair into findings, which is why the evaluator's same-layer half is three lines rather than a second copy of the predicate), attribution.py (FileAttribution — which node contains a source file), doc_area.py (doc_area_coherence — the source-to-docs placement convention derived from the graph under test, and the nodes that contradict it; _source_root derives the source root by descending while exactly one next segment has min_support behind it, so a second source tree too small to establish a convention is excluded and COUNTED rather than vetoing the derivation for every other pair, BDL-UX #195), summary_facts.py (summary_facts — the numeric and version claims a node summary states, checked against the same facts the documentation audit computes for the project; it owns neither the extraction nor the comparison, so this codebase keeps one notion of "a version". collect_claims takes the FactSet rather than building one, so its not_applicable fallback serves the PUBLIC fact_set= parameter, not the registry: a registry-built set covers every name DocScanner scans for, measured across five project shapes including a database with no schema at all, and a test fails the day that stops holding — while an injected set is under no such obligation, and a fact it neither computed nor declined is one that caller never declared), test_binding.py (test_binding — a test file bound to no node, and a node with no bound test file of its own or of a part_of descendant, over the binding the reindex records in test_files; BDL-074 C3), test_import_boundary.py (test_import_boundary — chooses which recorded TEST imports a boundary judges, narrowed by of to the tests of matching nodes, and hands them to forbid_import's own evaluate_one_import_rule; BDL-074 C3), scenario_binding.py (scenario_binding — a scenario's @node: tag against the node folder its feature file sits in; whether its steps EXECUTE that node is not judged, because a static stand-in measured on 2026-09-28 found 59 of the 81 executed (tag, step file) pairs and missed 22; BDL-074 C3), suite_tables.py (the test_files / test_imports readers and NodeSelection, shared by the two index-reading suite rules so they cannot disagree about one file; each reader returns None for an index written before those tables existed), listed_exemptions.py (ExemptionLedger — what a ListedExemption excuses, per entry: an entry that excuses nothing is reported dead by name, an exemption past its until date while still excusing something is reported expired, and expiry never re-enables a finding), __init__.py (evaluate_all orchestration + remediation + stable re-exports). Each suite rule adds one suite_population finding (warn, via types.population_finding) on every run, stating what it judged and what it did not; advisories.py lists that type beside the two layer advisories, so --fail-on-warn does not exit on it. Parses rules.yml (schema v1/v2/v3), validates rules against the graph DB, and evaluates deny, require, cycle, import-boundary, forbid-edge, layer, and cardinality rules against code imports, edges, file paths, and node metrics. Supports severity levels (error, warn) and tag-based node matching via NodeMatcher. A v3 file's top-level tags: block is NOT a bulk tag assignment and never was: nothing applied it, and BDL-070 A6 withdrew load_rules_with_tags, the only function that read it, rather than keep a parser for a block a node's own tags: already declares. A file still carrying one loads unchanged, and the block assigns nothing. types.liveness_finding takes two keyword-only options, both added the same way and both default-preserving so every existing caller's output is byte-identical. severity defaults to warn: a PARTIAL inertness stays advisory, while a rule that could check NONE of its population passes the severity the project declared, because at that point a pass and a no-op are the same output (BDL-062 .9 for doc_area_coherence, .14 for graph_summary_facts; the three rule types left all ship warn, so their evaporation reaches only a project that escalated them — BDL-UX #197). graph_summary_facts is the one that ships error, so it is the one whose stand-down can turn a run red, and its per-node unverifiable answer deliberately stays warn: a fact the project declined to compute is a gap in the project, not a summary contradicting it, and the rule read every other summary in the graph. from_ref_id defaults to None and names the node a finding is about, for the callers that have one — most do not, because a liveness finding is usually about a RULE that could not run and a node id would be an invention. graph_summary_facts is the exception: it reports per node, and until BDL-062 .10 its two states reached a machine consumer differently, a disagreement carrying the claim's ref_id and an unverifiable claim carrying nothing, from the same rule about the same node. scenario_coverage.py (BDL-061 S4) evaluates the scenario_coverage rule and owns its per-LEG liveness — it compares the graph against files on disk that no index holds, which is why it sits beside evaluators.py rather than inside it. Since BDL-061.63 it also states its own reach: a run whose for matcher does not select the whole graph prints how many nodes it checks of how many, and the kinds of those outside, so a population that shrinks by one line of services.yml appears beside the coverage fraction that improves. rule_engine.py remains a thin re-export shim for the prior import path.
  • scenarios.py -- Acceptance-suite reader (BDL-061 S4). Parses .feature files into bound Scenarios — the @bead: / @node: Gherkin tags, with the language's own Feature: / Rule: inheritance — and reads the scenario names a TO-BE document references. Ships the en and ru keyword sets and honours # language: xx; a dialect it does not ship, a file that does not decode as UTF-8, and a file declaring a second Feature: are each reported as UNREADABLE rather than counted as a file with no scenarios. Evaluates nothing: the verdicts are rules/scenario_coverage.py's and, for where a scenario's feature file sits, rules/scenario_binding.py's. See scenario-binding SPEC.
  • import_resolver.py -- Multi-language import analysis. Extracts imports via tree-sitter for Python, TypeScript/JavaScript, Go, Rust, Kotlin, Java, Swift, Objective-C, and C/C++, walking the WHOLE AST (an import inside a function, class body, if TYPE_CHECKING: or try: block is a real dependency — and is where one is placed to break a cycle, so hiding it blinded the cycle and boundary rules; BDL-UX #159). Resolves an import to the node that OWNS the imported file, and generates depends_on edges from the resolved imports, skipping containment in one direction (a container never depends on its own part). Containment is read through rules/layers.py::part_of_ancestors — the one part_of ancestry walk in the codebase since BDL-070 A1 — so this module reads the direct edges and climbs nothing itself.
  • linter.py -- Linter orchestrator. Loads rules, optionally runs incremental reindex, evaluates all rules, and returns structured LintResult with violations, counts, timing, and layer_populations — how much of its edge set each layer rule judged, carried as the counts themselves so each rendering states them in its own idiom (BDL-070 A3). LintResult.fails_on_warn is the key --fail-on-warn reads, as has_errors is --strict's: every finding except the two advisories at warn (BDL-070 A8). The exclusion stops at error, so the flag stays a superset of has_errors whatever severity an advisory is ever emitted at. Provides Rich, JSON, porcelain and GitHub-annotation output formatters; all four carry the population, and the Rich GREEN line carries it as well as the red one, because a clean result is where a reader most needs to know what it covers. The wording is rules/layer_reach.py::population_phrase rather than a string built here, so the Gate's lint step, prime's health line and the debt report state the same fraction in the same words (BDL-070 A4). Without a reindex callback it is a pure read: the index is opened read-only and an absent one raises LintError (CLI exit 2) instead of being created and reported clean. Rules are resolved before the index is opened, so "no rules file" is an honest empty result that needs no index.
  • snapshot.py -- Architecture snapshot storage. Saves the current graph state (nodes, edges, symbol counts) to the graph_snapshots table, lists saved snapshots, and compares two snapshots to produce a SnapshotDiff with added, removed, and changed nodes and edges.
  • c4.py -- C4 architecture model mapping. Maps graph nodes and edges to the C4 model (System / Container / Component levels) using part_of depth heuristics or explicit c4_level in node extras. Renders diagrams in Mermaid C4 syntax and C4-PlantUML syntax. Supports level-based filtering (context, container, component) and scoped component views.
  • federation/ -- Cross-repo federation (BDL-037 / F1, BDL-038 / F2), decomposed by responsibility (BDL-059 S3): refs.py (the @<repo>:<ref_id> FederatedRef identity model + parse_ref), export.py (the deterministic satellite export — build_export / serialize_export with repo/commit_sha/exported_at provenance), reconcile.py (the hub aggregation aggregate_exports → FederatedGraph with three-valued intent-vs-reality EdgeVerdicts, first-class AMQP + GraphQL contract reconciliation carrying a contract-level ContractVerdict, and per-satellite staleness), gate.py (the landscape gate — gate_failures / GateFailure / SAFE_DEFAULT_FAIL_ON / NEVER_FAIL_VERDICTS, BDL-039 / F3, that gives those verdicts teeth in CI), and __init__.py (re-exports the full public surface, so from beadloom.graph.federation import X is unchanged). Contract reconciliation + classification is delegated to contracts.py. See federation SPEC.
  • contracts.py -- First-class cross-service contract model (BDL-038 / F2). Owns the Contract / ContractEndpoint model, the protocol-prefixed language-neutral contract_key derivation (AMQP amqp:<exchange>/<routing>:<message_type>, GraphQL graphql:<schema>), the ContractVerdict enum (contract-level intent-vs-reality), and reconcile_contracts (groups AMQP and GraphQL contract-bearing edges into first-class Contracts by key; attaches the producer exposed surface and consumer references onto the Contract — plus, when present, the typed exposed_fields / referenced_fields from the GraphQL fields block and the exposed_body / referenced_body from the AMQP body JSON-Schema; the federation/reconcile.py hub delegates here and projects back to the F1 flat shape via Contract.to_report_dict). The BREAKING verdict is typed when both sides carry depth — for GraphQL the graphql_breaking component (absence / type-narrowing / nullability / arg breaks, subscriptions first-class), for AMQP the amqp_body component (absence / type-incompatible / required-narrowed / nested+array body breaks) — else the BDL-038 name-presence check. See federation SPEC.
  • sdl.py -- Minimal, dependency-free GraphQL SDL surface extractor (BDL-038 / F2). extract_surface(sdl_text) returns the producer's exposed names — top-level Query/Mutation/Subscription field names plus type/input/enum/interface type names — as a set[str]; operation_field_names(sdl_text) returns only the operation field names (the name-level fallback substrate for graphql_surface). Name-presence only (no schema validation); malformed/empty SDL yields set() (recorded honestly as exposed: []).
  • graphql_surface.py -- Typed GraphQL Tier-A surface extraction (BDL-060 / S2, G1a). extract_typed_surface(sdl_text) returns a TypedSurface mapping each operation field (queries/mutations/subscriptions) to its return type (with !/[] wrapping) + args, parsed via the OPTIONAL graphql-core extra; absent the extra (or on a malformed SDL) it degrades honestly to the name-level surface (typed=False, empty types — never fabricated). serialize_typed_surface / parse_typed_surface give a deterministic sorted/deduped wire shape for the federation contract.fields block.
  • graphql_breaking.py -- Native typed GraphQL breaking analysis (BDL-060 / S2, G1a). breaking_field_descriptors(exposed_fields, referenced_fields) names the consumer references the producer breaks (absent / type-narrowed / nullability-broken / arg-broken), "<field>" or "<field>(<arg>)"; additive producer changes are benign. Beadloom computes the verdict itself — no external GraphQL tool. Invoked by contracts.Contract.breaking_fields only when both sides carry a real typed surface.
  • amqp_body.py -- Strict AMQP message-body JSON-Schema model + native body-diff (BDL-060 / S3, G1b). BodySchema / parse_body / serialize_body model the minimal JSON-Schema body (type/properties/required/enum/nested objects/array items) deterministically (sorted, recursively); breaking_body_descriptors(producer_body, consumer_body) names the consumer-read fields the producer body breaks (absent / type-incompatible / required-by-consumer-now-optional / enum-narrowed / nested + array), pathing field / parent.child / field[] / field[].child; additive producer fields + widened requiredness are benign. Invoked by contracts.Contract.body_breaking_fields only when both sides declared a body.
  • asyncapi.py -- Source-only AsyncAPI ingestion adapter (BDL-060 / S3, G1b). extract_payload_body(document, channel=None) extracts a message payload JSON-Schema (AsyncAPI 2.x channels.<ch>.{publish,subscribe}.message.payload / 3.x channels.<ch>.messages.<m>.payload with one-hop $ref into components.messages) into the internal amqp_body model; AsyncAPI is a source only. Reads YAML or JSON via the core yaml.safe_load (no optional extra); an unparseable / payload-less / remote-$ref doc degrades honestly to None (never a fabricated body).

Invariants ​

  • ref_id must be unique across all YAML files
  • kind (nodes and edges) is a free-form string — Beadloom is paradigm-agnostic, not DDD-only (BDL-038 / U1). The loader and the SQLite schema accept any kind verbatim (no enum, no CHECK), so an FSD project can use page / widget / entity / repository (and FSD-style edge kinds) and they survive export → federate with zero loss or coercion, exactly like DDD domain / service. The federation + contract path (the federation/ package, contracts.py) is fully kind-agnostic and never branches on a DDD kind.
    • The conventional DDD preset kinds — nodes domain / feature / service / entity / adr; edges part_of / depends_on / uses / implements / touches_entity / touches_code (plus the contract kinds produces / consumes) — are the vocabulary the rule engine recognizes in rules.yml matchers (rule_engine.VALID_NODE_KINDS / VALID_EDGE_KINDS): a rule that matches on kind must name one of these (else a rules-load error). This is a constraint on the rule preset's vocabulary, not on the graph: a graph node/edge with a non-preset kind is stored, exported, and federated faithfully — it is never rejected, and beadloom lint does not flag it (only a rule referencing an unknown kind is an error).
  • Edges referencing non-existent local nodes are skipped with a warning
  • A ref_id carried by more than one node is reduced to the FIRST of them and REPORTED: GraphLoadResult.errors names the file, kind and source of the node kept and of the node dropped, and what the drop costs — on the single-package src-layout the node dropped is the one carrying the source, so the graph keeps an empty root and every rule over that package runs on an empty population (BDL-UX #214). The rule lives in loader.unique_by_ref_id and graph/diff.py reads it from there, so the two readers cannot answer differently. It is a report: the graph still loads, and the finding stays in the channel that already carried it
  • Rules file supports schema versions 1, 2, and 3
  • Schema v3 added tag-based matching in NodeMatcher. Its optional top-level tags: block assigned nothing and is withdrawn with load_rules_with_tags (BDL-070 A6); a node's tags are declared on the node, and a file still carrying the block loads unchanged
  • Rule names must be unique within rules.yml
  • Each rule must have exactly one of: deny, require, forbid_cycles, forbid_import, forbid, layers, check, unregistered_feature_candidate, module_coverage, scenario_coverage, doc_area_coherence, summary_facts, test_binding, test_import_boundary, or scenario_binding (fifteen rule types)
  • Rule severity must be one of: error, warn
  • forbid_import.from is matched against the source file path (src/pkg/tui/app.py), forbid_import.to against the dotted import path with dots → slashes (pkg/infrastructure/db) — two different vocabularies, so a src/-prefixed to can never match. A rule whose glob matches zero candidates in the whole index is reported as a rule_liveness finding (warn) instead of counting as clean (BDL-UX #172)
  • A deny rule sees every indexed file its node contains, annotated or not: an import's source end is attributed by annotation OR by the same most-specific-source ownership the depends_on edges already use (rules/attribution.py, BDL-061.50). Keyed on annotations alone, 22 of this repository's 128 import-source files were invisible to every deny rule while their edges existed. A file no node contains is counted in LintResult.files_unattributed and named on the header rather than skipped in a loop
  • Every rule type reports its own inertness, not only forbid_import: an empty matcher, an absent edge kind, a check with no threshold, a source_root with no module (BDL-061.48). The per-type definition of "cannot fire" is the table in the rule-engine SPEC. The severity is warn by default, because a PARTIAL inertness is a fact about the configuration rather than about the code and nothing green should turn red on upgrade. A rule that could check NONE of its population carries the severity the project declared for it instead, because at that point a pass and a no-op are the same output (BDL-062 .9 for doc_area_coherence and .14 for graph-summary-facts; the three remaining rule types still report a total stand-down at warn, and all three ship warn, so only a project that escalated them is affected, BDL-UX #197). LintResult.rules_inert qualifies the rule count so a green run cannot advertise checks that never looked. Since BDL-062 .2 a rule may also be inert because of the graph's DATA rather than its configuration: doc-area-coherence reports that it checked nothing when no source-to-docs mapping in the graph reaches its majority threshold, and graph-summary-facts (.1) reports the same when no node summary states a number the project computes a fact for. .1 adds a second shape of the same honesty, one node at a time rather than one rule at a time: a summary whose claim names a fact the project DECLINED to compute is reported as unverifiable, carrying the registry's own reason, while the rule itself stays live
  • forbid_import.exempt[] baselines pre-existing crossings; each entry needs from and/or to plus a mandatory reason and until. An exemption is visible whatever it does (BDL-061.49): one that suppresses nothing is reported as dead, one still suppressing past an until: that leads with an ISO date is reported as expired, and what the rest excused is counted — LintResult.violations_suppressed, a ", N crossings suppressed by an exemption" clause on the summary line, and one entry per crossing in --format json's suppressed array. An until: that names an event rather than a date stays legal and is reported as prose; expiry never changes what is suppressed, so no build reddens because a day passed

API ​

Module src/beadloom/graph/loader.py ​

  • parse_graph_file(path: Path) -> ParsedFile -- Parse a single YAML graph file into nodes and edges.
  • load_graph(graph_dir: Path, conn: sqlite3.Connection, *, project_root: Path | None = None) -> GraphLoadResult -- Load all *.yml files from a directory into SQLite (two-pass: nodes then edges). Returns GraphLoadResult with nodes_loaded, edges_loaded, errors, warnings. A contract-bearing edge's persisted contract_key is the full protocol-prefixed identity from contracts.contract_key (e.g. amqp:<exchange>/<routing>:<message_type>), so same-name / different-exchange contracts on one node pair stay distinct (BDL-038 / G4); plain edges keep '' (identity (src,dst,kind)). project_root (default: graph_dir's grandparent) anchors a GraphQL produces contract's relative source_file, whose parsed SDL exposed surface is folded into the edge's contract payload.
  • update_node_in_yaml(graph_dir: Path, conn: sqlite3.Connection, ref_id: str, *, summary: str | None = None, source: str | None = None) -> bool -- Update a node's fields in YAML source and SQLite. Returns True if node was found and updated. The YAML writeback is atomic (write_yaml_atomic).
  • get_node_tags(conn: sqlite3.Connection, ref_id: str) -> set[str] -- Extract tags from a node's extra JSON column. Returns an empty set when the node does not exist or has no tags key in its extra data.

Module src/beadloom/graph/diff.py ​

  • compute_diff(project_root: Path, since: str = "HEAD") -> GraphDiff -- Compare current graph YAML with state at a git ref. Raises ValueError on invalid ref.
  • compute_diff_from_snapshot(conn: sqlite3.Connection, snapshot_id: int) -> GraphDiff -- Compare a saved snapshot (from graph_snapshots table) with the current live state in the nodes and edges tables. Returns a GraphDiff with since_ref set to "snapshot:<id>". Raises ValueError if the snapshot ID is not found.
  • render_diff(diff: GraphDiff, console: Console) -> None -- Render a GraphDiff using Rich console output. Displays source path changes, tag changes, and symbol count deltas for changed nodes. The graph's own text (ref ids, kinds, summaries, source paths, tags, the bracketed edge kind in --[uses]-->, duplicate lines and the since ref) is escaped with rich.markup.escape before Rich reads it as markup (beadloom-2mj3.19).
  • diff_to_dict(diff: GraphDiff) -> dict[str, object] -- Serialize a GraphDiff to a JSON-compatible dict.

Package src/beadloom/graph/rules/ ​

Decomposed by responsibility (BDL-059 S3); see the rule-engine SPEC for the per-module map. src/beadloom/graph/rule_engine.py is a thin backwards-compatible re-export shim for the prior import path. The full public surface is re-exported from beadloom.graph.rules.

  • load_rules(rules_path: Path) -> list[Rule] -- Parse rules.yml and return validated Rule objects (union of DenyRule | RequireRule | CycleRule | ImportBoundaryRule | ForbidEdgeRule | LayerRule | CardinalityRule | UnregisteredFeatureCandidateRule | ModuleCoverageRule | ScenarioCoverageRule | DocAreaCoherenceRule | SummaryFactsRule | TestBindingRule | TestImportBoundaryRule | ScenarioBindingRule). Raises ValueError on schema errors — including a for.exclude on a scenario_coverage rule, which names the excluded nodes and routes the author to non_behavioural, where an exclusion carries a reason. The type of each rule is dispatched through ONE table from authoring key to parser (_MAPPING_PARSERS, fourteen keys, forbid_cycles among them); layers stays an explicit arm, because its parser reads the whole rule (BDL-073 B3). A file whose text has not changed since the last call for its resolved path is not parsed again: the memo compares the TEXT, not the path or (st_mtime_ns, st_size), and returns a new list of the same frozen rules on every call, so one beadloom init — a reindex and then the Gate's lint step — parses rules.yml once where it parsed twice (BDL-073 B4, F1). The rule-engine SPEC carries the measured cost and why neither stat key was enough.

  • forget_parsed_rules() -> None -- Forget every parse load_rules remembers, so the next call parses. In rules/loader.py and not re-exported from beadloom.graph.rules; tests/conftest.py calls it before every test, because mutmut forks each mutant's child from a parent that already ran the clean suite.

  • AUTHORING_KEYS: frozenset[str] -- The fifteen keys a rule may declare to select its type: the dispatch table's keys plus layers, derived rather than listed. In rules/loader.py and not re-exported; onboarding.scanner.rules_gen imports it from there to label rules.

  • validate_rules(rules: list[Rule], conn: sqlite3.Connection) -> list[str] -- Validate rules against the database. Returns warning messages for ref_ids not found in nodes, and — since BDL-070 A6 — for a LayerRule that declares a layer tag no node carries, one warning per rule naming every empty layer. A caller that ignores the return value has silently disabled it — that was BDL-UX #172's residue in linter.py.

  • evaluate_rule_liveness(conn: sqlite3.Connection, rules: list[Rule], *, project_root: Path | None = None) -> list[Violation] -- Report every rule that cannot fire, for the eight matcher/graph-based types. Two types are skipped here because they state their own diagnosis where it is known: forbid_import (from the import scan it already runs) and scenario_coverage (per leg, from files on disk that no index holds). One warn finding per rule, naming the reason. Silent on an empty graph.

  • inert_rule_names(conn: sqlite3.Connection, rules: list[Rule], *, project_root: Path | None = None) -> set[str] -- The names behind LintResult.rules_inert. scenario_coverage is COUNTED here through the rule's own inert_reason predicate and reported by scenario_coverage.py: reporting and counting are two questions, and one predicate answers both so the count cannot drift from the control flow (BDL-061.66). forbid_import is reported there and not counted here, because its liveness channel also carries statements about individual exempt entries.

  • FileAttribution.build(conn).candidates(file_path) -> tuple[str, ...] -- Every node that CONTAINS a source file, most specific first. How a deny rule finds the source end of an import (BDL-061.50).

  • count_unattributed_import_files(conn: sqlite3.Connection) -> int -- The number behind LintResult.files_unattributed.

  • suppressed_crossings(conn: sqlite3.Connection, rules: list[ImportBoundaryRule], *, today: date | None = None) -> list[SuppressedCrossing] -- Every crossing a forbid_import exemption excused, in deterministic order. The read-only second pass behind LintResult.suppressed / violations_suppressed, so a caller that wants only the number does not have to evaluate every rule (BDL-061.49).

  • exit_condition_deadline(until: str) -> date | None -- The calendar date an exit condition names, or None when it names an event. One definition, shared by forbid_import.exempt[].until, layers.exempt[].until and flow.yml's guards.<name>.exclusions[].until; pinned to a leading YYYY-MM-DD rather than to date.fromisoformat, which accepts more on Python 3.11+ than on 3.10. Re-exported here since BDL-070 B2 — it lives in infrastructure/exit_condition.py, below every layer that declares an exit condition.

  • shares_tagged_ancestor(src, dst, layers, parents, tags) -> bool -- Whether one container the declaration gives a layer holds BOTH ends of an edge inside one layer. Reflexive, so a part does not cross with the container it is inside; a container carrying no layer tag shares nothing, which is why peers under this project's untagged root service cross.

  • same_layer_crossings(edges, layers, parents, tags) -> list[tuple[str, str]] -- The edges of an edge set that run inside one layer between ends sharing no such container. Measured on this repository on 2026-09-13, over the same-layer population of its live depends_on graph: 116 run between two parts of one container and 14 between peers. The full population is in the rule-engine SPEC, which is where a number about this graph is held against it.

  • layer_exemption_index_for(rule, src, dst) -> int | None / excused_crossings(rule, crossings) / stale_layer_exemption_findings(rule, excused, *, today=None) -- What a same-layer exemption is doing: which crossings it excuses, how many, and whether it is dead or past its own deadline.

  • evaluate_deny_rules(conn: sqlite3.Connection, rules: list[DenyRule]) -> list[Violation] -- Evaluate deny rules against code_imports. Supports tag-based matching via get_node_tags().

  • evaluate_require_rules(conn: sqlite3.Connection, rules: list[RequireRule]) -> list[Violation] -- Evaluate require rules against nodes and edges. Supports tag-based matching.

  • evaluate_cycle_rules(conn: sqlite3.Connection, rules: list[CycleRule]) -> list[Violation] -- Evaluate cycle rules using iterative DFS over edges of specified kind(s). Reports each unique cycle once with the full path.

  • evaluate_import_boundary_rules(conn: sqlite3.Connection, rules: list[ImportBoundaryRule]) -> list[Violation] -- Evaluate import boundary rules against code_imports: from_glob via fnmatch on the source file path, to_glob on the dotted import path with dots → slashes (a to: glob covering a package also covers a bare import of that package). Also returns rule_liveness findings (warn) for a rule whose glob matches nothing in the index (BDL-UX #172) and for a stale exempt entry — dead, or expired while still suppressing (BDL-061.49).

  • evaluate_forbid_edge_rules(conn: sqlite3.Connection, rules: list[ForbidEdgeRule]) -> list[Violation] -- Evaluate forbid edge rules against the edges table. Checks source and destination nodes against from_matcher and to_matcher, optionally restricted by edge_kind. Supports tag-based matching.

  • evaluate_layer_rules(conn: sqlite3.Connection, rules: list[LayerRule]) -> list[Violation] -- Evaluate layer rules against the edges table. For enforce: top-down, detects lower-to-upper layer dependencies and optional layer-skip violations when allow_skip=False. Since BDL-070 B3 each end takes its layer from layer_membership — its own declared tag, else the nearest part_of container that declares one — and an edge inside one layer is a finding unless a container the declaration gives a layer holds both ends. An edge with an end in no declared layer at all is still not judged, and each rule emits one layer_population finding (warn) naming how many edges it evaluated and how many it skipped: 357 of 365 on this repository, measured 2026-09-13, where own tags reached 16.

  • layer_rule_reach(conn: sqlite3.Connection, rule: LayerRule) -> LayerReach -- The same count without running the rule, for a reader that renders a population it does not decide. Measured over the live depends_on set of this repository on 2026-09-13: a layer at both ends for 357 of 365, and 8 with one end inside nothing that declares a layer.

  • layer_of(ref_id, layers, parents, tags) -> int | None -- The index of the layer a node is in: its own declared layer, else its nearest part_of ancestor's, else None. Pure; reads the rule's declared layers, so it holds no tag name of its own. own_layer_of answers the first half alone (BDL-070 A1), and layer_membership returns the same index with declared_by, the node whose tag decided, so a finding can name the container an untagged node took its layer from (BDL-070 B3).

  • part_of_generations(ref_id, parents) -> list[tuple[str, ...]] / part_of_ancestors(ref_id, parents) -> frozenset[str] -- The one part_of ancestry walk, nearest generation first, each ancestor once, a cycle terminating. import_resolver._part_of_ancestors reads the edges and calls it.

  • layer_population(edges, layer_at) -> LayerPopulation -- An edge set counted into evaluated (a layer at both ends) and skipped_untagged. The resolver is a parameter because own tags and ancestry reach different populations of the same edges — 16 and 357 of 365 on this repository, measured 2026-09-13 — and the rule asks for the second.

  • evaluate_cardinality_rules(conn: sqlite3.Connection, rules: list[CardinalityRule]) -> list[Violation] -- Evaluate cardinality rules against nodes, code_symbols, file_index, and sync_state. Checks max_symbols, max_files, and min_doc_coverage thresholds for matched nodes. Doc coverage counts pairs NOT known to be behind (status NOT IN ('stale','missing')): a pair the freshness engine could not check — unverified, BDL-UX #175 — is not evidence that a doc is absent, and scoring it 0% would turn a green project red for a reason about the index rather than about its docs. What was not checked is named by sync-check, which owns that accounting.

  • evaluate_all(conn: sqlite3.Connection, rules: list[Rule], *, project_root: Path | None = None) -> list[Violation] -- Evaluate all rules (deny + require + cycle + import boundary + forbid edge + layer + cardinality + unregistered-feature-candidate + module-coverage + scenario-coverage + doc-area-coherence + summary-facts + test-binding + test-import-boundary + scenario-binding), enrich each Violation with a deterministic remediation hint, and return violations sorted 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.

  • evaluate_test_binding_rules(conn, rules: list[TestBindingRule], *, scenario_rules: Sequence[str] = ()) -> list[Violation] -- The files leg reports each judged test file bound to no node, naming its placement. Files of placement other_kind are not judged, and its population statement names them by recorded kind and count (BDL-074 F1): acceptance step files, whose scenarios bind through their @node: tags and are judged by the scenario_binding rules named in scenario_rules (evaluate_all passes them), and self-checks, bound to no node by design. Since BDL-074 G2 each kind also states how it was recognised: by its folder in the test layout the index recorded (read_test_layout), whether tests.kinds in .beadloom/config.yml declared that folder or it is the default, and that the folder is trusted, not verified. Excused files are counted with the number of exemptions that excused them (ExemptionLedger.exemptions_used). The for leg reports each selected node with no bound test file, its own or a part_of descendant's, and states how many test files bind to no node, because one of them may test it. Liveness per leg; an index without test_files stands the rule down with "reindex".

  • evaluate_test_import_boundary_rules(conn, rules: list[TestImportBoundaryRule]) -> list[Violation] -- forbid_import over test_imports: from over the test file path, matched with fnmatchcase so the match is case-sensitive on every platform (beadloom-2mj3.15), to over the dotted import path, of keeping the files bound to a matching node or a part_of part of one. Crossings are restamped rule_type: test_import_boundary; liveness is decided over every recorded test import before any crossing.

  • evaluate_scenario_binding_rules(conn, rules: list[ScenarioBindingRule], *, project_root: Path | None = None) -> list[Violation] -- Judges the layout <suite root>/<domain>/<node>/*.feature: a file outside a node folder, an enclosing node folder that is not a part_of container, and a scenario lacking @node:<folder>. A feature file that declares no scenario is judged by its place alone (beadloom-2mj3.15). Every population statement ends with the half it does not judge.

  • evaluate_one_import_rule(rule: ImportBoundaryRule, imports, *, file_count: int, target_count: int) -> list[Violation] -- One forbid_import rule over a list of (file_path, line, import_path) imports. Public since BDL-074 C3 (it was _evaluate_one_import_rule), for its one outside caller, test_import_boundary.

  • TEST_BINDING_RULE_TYPE / TEST_IMPORT_BOUNDARY_RULE_TYPE / SCENARIO_BINDING_RULE_TYPE / SUITE_POPULATION_RULE_TYPE -- "test_binding", "test_import_boundary", "scenario_binding" and "suite_population"; the last is in ADVISORY_RULE_TYPES.

  • evaluate_scenario_coverage_rules(conn: sqlite3.Connection, rules: list[ScenarioCoverageRule], *, project_root: Path | None = None) -> list[Violation] -- Evaluate the scenario_coverage rule over four independent legs (coverage, suite, reference, declaration) and report its own per-leg liveness. Every finding is warn. A features glob matching no file is the exception to per-leg liveness: it stands all four legs down, reports the glob, and is the only state counted in rules_inert.

  • inert_reason(conn, rule, *, project_root=None) -> str | None -- Why this rule can check nothing, or None. The single predicate both liveness.py's counter and the rule's own report read, so the count cannot disagree with the control flow.

  • SCENARIO_COVERAGE_RULE_TYPE / BEAD_NOT_VERIFIED -- the rule_type every non-liveness finding carries, and the limit ("the bead id is not checked against the tracker") that travels on the findings which would otherwise imply it was.

Module src/beadloom/graph/scenarios.py ​

Reads the acceptance suite and the documents that reference it; evaluates nothing. See the scenario-binding SPEC.

  • parse_feature(text: str, *, path: str) -> tuple[tuple[Scenario, ...], str | None] -- Parse one .feature file. A non-None reason means the file's scenarios are UNKNOWN, and the empty tuple beside it is "nothing could be read" rather than "nothing is there".
  • load_suite(project_root: Path, glob: str) -> ScenarioSuite -- The suite behind a glob, keeping files, scenarios, empty_files and unreadable apart because each needs a different remedy.
  • parse_scenario_references(text: str, *, path: str) -> tuple[ScenarioReference, ...] -- The scenario names a TO-BE document claims exist: a line that BEGINS with a scenario keyword after markdown stripping, outside fenced blocks.
  • load_references(project_root: Path, globs: Sequence[str]) -> ReferenceSet -- The four outcomes of a reference glob, each named rather than inferred: the references, the globs that matched no document, the documents that matched and could not be decoded, and the documents that matched and were read. A reference check whose documents moved cannot read like one that found no problem, and a document dropped between the glob and the parse cannot read like a document that states no scenario.
  • DEFAULT_FEATURE_GLOB (tests/acceptance/features/**/*.feature), DEFAULT_STEPS_DIRNAME, BEAD_TAG_PREFIX (@bead:), NODE_TAG_PREFIX (@node:).

Module src/beadloom/graph/import_resolver.py ​

  • extract_imports(file_path: Path) -> list[ImportInfo] -- Extract import statements from a source file using tree-sitter. Supports Python, TS/JS, Go, Rust, Kotlin, Java, Swift, Objective-C, C/C++.
  • resolve_import_to_node(import_path: str, file_path: Path, conn: sqlite3.Connection, scan_paths: list[str] | None = None, *, is_ts: bool = False) -> str | None -- Map an import path to a graph node ref_id: first by OWNERSHIP of the imported file (infrastructure/repository.get_owning_ref_id, most specific source wins), then falling back to code_symbols annotations and hierarchical source-prefix matching. Ownership comes first because the prefix strategy resolves a dotted path to an extension-less directory path, which can never match a node whose source is a file — so every import used to land on the nearest enclosing directory node, collapsing feature/component dependencies into their domain.
  • index_imports(project_root: Path, conn: sqlite3.Connection) -> int -- Scan all source files, index imports into code_imports table, and create depends_on edges. Returns count of imports indexed.
  • create_import_edges(conn: sqlite3.Connection) -> int -- Create depends_on edges from resolved code imports. Returns number of edges created.

Module src/beadloom/graph/linter.py ​

  • lint(project_root: Path, *, rules_path: Path | None = None, reindex: Callable[[Path], object] | None = None) -> LintResult -- Run the full lint process: load rules, evaluate, return results. reindex is an optional callback (e.g. application.reindex.incremental_reindex) invoked before evaluation so the graph-layer linter stays pure; the CLI injects the application reindex as an orchestration concern. With reindex=None the call is READ-ONLY (mode=ro, query_only) and a missing or unreadable index raises LintError rather than being created — the CLI maps that to exit 2. Raises LintError on invalid configuration.
  • format_rich(result: LintResult) -> str -- Format a LintResult as human-readable text with violation markers.
  • format_json(result: LintResult) -> str -- Format a LintResult as structured JSON with violations array and summary.
  • format_porcelain(result: LintResult) -> str -- Format a LintResult as machine-readable one-line-per-violation output.

Module src/beadloom/graph/snapshot.py ​

  • save_snapshot(conn: sqlite3.Connection, label: str | None = None) -> int -- Save current graph state (nodes, edges, symbol counts) as a snapshot in the graph_snapshots table. Returns the new snapshot ID.
  • list_snapshots(conn: sqlite3.Connection) -> list[SnapshotInfo] -- List all saved snapshots, newest first. Returns a list of SnapshotInfo objects.
  • compare_snapshots(conn: sqlite3.Connection, old_id: int, new_id: int) -> SnapshotDiff -- Compare two snapshots and return a SnapshotDiff with added, removed, and changed nodes and edges. Raises ValueError if either snapshot ID is not found.

Module src/beadloom/graph/c4.py ​

  • map_to_c4(conn: sqlite3.Connection) -> tuple[list[C4Node], list[C4Relationship]] -- Map architecture graph to C4 model elements. Reads all nodes and edges from the database. Assigns C4 levels using explicit c4_level in node extras (priority) or part_of depth heuristic (depth 0=System, 1=Container, 2+=Component). Returns a tuple of C4 nodes and relationships.
  • render_c4_mermaid(nodes: list[C4Node], relationships: list[C4Relationship]) -> str -- Render C4 model as Mermaid C4 diagram syntax (C4Container). Produces System(), Container(), Component() elements with _Ext/Db variants for external/database nodes. Groups children in System_Boundary() blocks.
  • render_c4_plantuml(nodes: list[C4Node], relationships: list[C4Relationship]) -> str -- Render C4 model as C4-PlantUML syntax. Produces a complete @startuml/@enduml block with !include for the C4-PlantUML stdlib. Uses standard macros: System(), Container(), Component(), Rel() with _Ext/Db variants.
  • filter_c4_nodes(nodes: list[C4Node], relationships: list[C4Relationship], *, level: str = "container", scope: str | None = None) -> tuple[list[C4Node], list[C4Relationship]] -- Filter C4 nodes by diagram level. "context" keeps only System-level and external nodes. "container" keeps System and Container nodes. "component" requires scope and keeps children of the scoped container. Raises ValueError if level="component" without scope, or if scope ref_id is not found.

Package src/beadloom/graph/federation/ ​

Cross-repo identity, satellite export, and hub aggregation — decomposed by responsibility (BDL-059 S3): refs.py (identity model), export.py (satellite export), reconcile.py (hub aggregation → FederatedGraph + verdicts), gate.py (landscape gate). The package __init__.py re-exports the full public surface, so from beadloom.graph.federation import X is unchanged. See the federation SPEC for full detail.

  • parse_ref(raw: str) -> FederatedRef -- Parse a graph ref: plain → local FederatedRef(None, raw); @repo:id → foreign FederatedRef("repo", "id"); malformed @... → FederationRefError. Only the first : after @ splits repo from ref_id.
  • is_foreign_ref(raw: str) -> bool -- Cheap leading-@ check (does not validate shape).
  • build_export(conn, *, repo, commit_sha, exported_at, generator, landscape=None) -> dict -- Build the deterministic satellite export artifact (schema v2) from the indexed graph; unions the edges and foreign_edges tables; nodes sorted by ref_id, edges by (src, dst, kind). landscape (BDL-038 / U5) is emitted only when provided (an undeclared-landscape export omits the key — F1 back-compat).
  • resolve_landscape(project_root: Path) -> str -- Resolve the landscape (product) name: .beadloom/config.yml landscape: key > the resolved repo name. The CLI omits it from the export when it equals the repo default.
  • serialize_export(export: dict) -> str -- Serialize an export dict to deterministic JSON (sorted keys, 2-space indent).
  • resolve_repo_name(project_root: Path) -> str -- Resolve the repo name: .beadloom/config.yml repo: > git origin remote basename > directory name.
  • current_commit_sha(project_root: Path) -> str | None -- git HEAD sha, or None when project_root is not the git toplevel (honest "unknown HEAD").
  • aggregate_exports(exports: list[dict], *, now: str | None = None) -> FederatedGraph -- Compose ≥2 satellite exports into one namespaced federated graph: resolve @repo: endpoints, tag each edge with its satellite's landscape, assign an EdgeVerdict per edge, reconcile AMQP + GraphQL contracts into first-class Contracts scoped by (landscape, contract_key) with a contract-level ContractVerdict (sorted by contract_key), record per-satellite staleness + landscape provenance. now injectable for deterministic age. Edge-verdict reconciliation also honours external and unmapped (BDL-038 G7/U4): an edge whose target node is lifecycle: external (or which itself declares external) → EdgeVerdict.EXTERNAL; an edge whose target resolves in the union but is present-without-a-usable-surface (empty summary) → EdgeVerdict.UNMAPPED. Both suppress DRIFT and are kept distinct from unresolved_refs (genuinely-absent foreign targets).
  • serialize_federation(fed: FederatedGraph) -> str -- Serialize a FederatedGraph to deterministic JSON: { schema_version, repos, nodes, edges, contracts, unresolved_refs }.
  • render_federation_report(fed: FederatedGraph) -> str -- Human-readable text report (satellites grouped by landscape with a product/company-landscape label + sha/age, edge-verdict counts, DRIFT list, contract-verdict counts + explicit BREAKING / DRIFT / ORPHANED_CONSUMER / UNDECLARED_PRODUCER call-outs with the missing GraphQL names, unresolved refs).

Constants: EXPORT_SCHEMA_VERSION = 2, FEDERATION_SCHEMA_VERSION = 2 (independent). Export schema v2 (BDL-038 / F2) adds the protocol: graphql contract wire — a producer edge carries contract.exposed (parsed SDL surface), a consumer edge carries contract.references; aggregate_exports / federate still read v1 exports (missing GraphQL fields default to empty). Federation schema v2 (BDL-038 / BEAD-04) enriches each contracts entry with a contract-level verdict (ContractVerdict) plus protocol / contract_key / lifecycle and, for GraphQL, exposed / references / the missing names that triggered BREAKING; F1's flat keys (message_type / directions / repos / confirmed) are KEPT as a subset, and contracts is sorted by contract_key. The bump is on the hub OUTPUT only — the two version bumps are independent.

Module src/beadloom/graph/contracts.py ​

First-class cross-service contract model (F2). The federation/reconcile.py hub delegates contract reconciliation here. See the federation SPEC.

  • contract_key(payload: dict) -> str -- Derive a protocol-prefixed, language-neutral contract identity: AMQP → amqp:<exchange>/<routing_key>:<message_type> (missing exchange/routing fall back to *, so a v1 message-type-only payload yields amqp:*/*:<message_type> and still reconciles); GraphQL → graphql:<schema>; other → <protocol>:<message_type-or-name>.
  • reconcile_contracts(edges: list[dict]) -> list[Contract] -- Group AMQP and GraphQL contract-bearing edges by (landscape, contract_key) into first-class Contracts (BDL-038 / U5: implicit same-key matching is landscape-scoped so unrelated products never cross-pollute; an explicit @otherrepo: key is promoted cross-landscape into one shared group); accumulates the producer exposed surface and consumer references (sorted + deduped) — plus, for AMQP, the producer's exposed_body and consumer's referenced_body JSON-Schema (normalized) — and folds the most-significant edge lifecycle (external > dead > deprecated > planned > active) onto each Contract, then assigns its verdict via classify. Insertion order preserved (the hub sorts the projected dicts by contract_key). Helpers cross_landscape_keys(edges) / edge_group_key(edge, keys) expose the grouping so the hub's UNDECLARED sweep stays landscape-consistent.
  • classify(contract: Contract) -> ContractVerdict -- Assign the contract-level intent-vs-reality verdict (RFC §5, G5), lifecycle intent first: external → EXTERNAL (BDL-038 G7: a contract whose folded edge lifecycle is external resolves here — never DRIFT), dead → DEAD, planned/deprecated → EXPECTED; then shape: GraphQL or AMQP with both sides and a non-empty missing_references (a GraphQL surface break — references ⊄ exposed or the typed verdict — or an AMQP body break) → BREAKING, both sides compatible → CONFIRMED, consumers-only → ORPHANED_CONSUMER, producers-only → UNDECLARED_PRODUCER. Complementary to the edge-level EdgeVerdict.UNDECLARED (an additional projection, not a replacement).

Module src/beadloom/graph/sdl.py ​

Minimal, dependency-free GraphQL SDL surface extractor (F2). See the federation SPEC.

  • extract_surface(sdl_text: str) -> set[str] -- Return the producer's exposed names: top-level Query/Mutation/Subscription field names plus type/input/enum/interface type names. Name-presence only (no schema validation); empty/whitespace-only/malformed SDL yields an empty set (callers sort it for determinism and record it honestly as exposed: []).

Public Data Classes ​

ClassModuleDescription
ParsedFileloaderResult of parsing a single YAML file: nodes, edges
GraphLoadResultloaderSummary: nodes_loaded, edges_loaded, errors, warnings, foreign_edges (list of ForeignEdge)
ForeignEdgeloaderFrozen dataclass: src, dst, kind. A cross-repo edge endpoint (@repo:ref_id) recorded at single-repo load time (not inserted, not a dangling error) for hub resolution (F1)
GraphParseErrorloaderException raised when a graph YAML file cannot be parsed; carries the offending path and (when available) source line
NodeChangediffFrozen dataclass: ref_id, kind, change_type, old_summary, new_summary, old_source, new_source, old_tags, new_tags, symbols_added, symbols_removed
EdgeChangediffFrozen dataclass: src, dst, kind, change_type
GraphDiffdiffFrozen dataclass: since_ref, nodes, edges, property has_changes
NodeMatcherrulesFrozen dataclass: ref_id, kind, tag, exclude, method matches(node_ref_id, node_kind, *, tags=None), and describe() — how the matcher reads in a finding (kind=feature, or everything)
DenyRulerulesFrozen dataclass: name, description, from_matcher, to_matcher, unless_edge, severity
RequireRulerulesFrozen dataclass: name, description, for_matcher, has_edge_to, edge_kind, severity
CycleRulerulesFrozen dataclass: name, description, edge_kind (str or tuple), max_depth (default 10), severity
ImportBoundaryRulerulesFrozen dataclass: name, description, from_glob, to_glob, severity, exempt. from_glob matches the file path, to_glob the dotted import path (dots → slashes) — two vocabularies, so a src/-prefixed to_glob never matches
ImportExemptionrulesFrozen dataclass: to_glob, from_glob, reason, until. One named, dated exception to an ImportBoundaryRule; reason + until are mandatory (a missing one is a rules-load ValueError). until is a deadline when it leads with YYYY-MM-DD and an event otherwise (exit_condition_deadline); a passed deadline is reported, never enforced
LayerExemptionrulesFrozen dataclass: from_glob, to_glob, reason, until. One named, dated exception to a LayerRule's same-layer predicate; all four fields are mandatory and both globs being * is a rules-load ValueError. Matching is by node ref_id on BOTH ends and by direction, because a same-layer crossing is an edge (BDL-070 B2)
SuppressedCrossingrulesFrozen dataclass: rule_name, file_path, line_number, import_path, exemption_from, exemption_to, until, expired. One crossing an exemption excused — carried on LintResult.suppressed so the suppressed count can be audited rather than trusted
ForbidEdgeRulerulesFrozen dataclass: name, description, from_matcher, to_matcher, edge_kind (optional), severity. Forbids graph edges between matched nodes (operates on edges table, unlike DenyRule which checks code_imports)
LayerDefrulesFrozen dataclass: name, tag. Defines a single architecture layer for use in LayerRule
LayerRulerulesFrozen dataclass: name, description, layers (tuple of LayerDef), enforce ("top-down"), allow_skip (default True), edge_kind (default "uses"), severity. Enforces dependency direction between ordered layers
CardinalityRulerulesFrozen dataclass: name, description, for_matcher, max_symbols, max_files, min_doc_coverage, severity (default "warn"). Detects architectural smells via node-level cardinality checks
ScenarioCoverageRulerulesFrozen dataclass: name, description, for_matcher, features (the suite glob), references (document globs), non_behavioural (tuple of NonBehaviouralNode), severity (default "warn"). A for_matcher carrying exclude is a rules-load ValueError on this type: an exclusion here must carry a reason, and non_behavioural is where a reason lives
TestBindingRulerulesFrozen dataclass: name, description, for_matcher (optional), files (optional path glob), exempt_files, exempt_nodes (tuples of ListedExemption), severity (default "warn"). At least one of for / files is required at load time
TestImportBoundaryRulerulesFrozen dataclass: name, description, from_glob, to_glob, of_matcher (optional), severity (default "error"), exempt (tuple of ImportExemption). as_import_rule() returns the same boundary as the ImportBoundaryRule forbid_import's evaluator reads
ScenarioBindingRulerulesFrozen dataclass: name, description, features (default DEFAULT_FEATURE_GLOB), exempt (tuple of ListedExemption), severity (default "warn")
ListedExemptionrulesFrozen dataclass: entries (path globs or node ref_ids, by the key they were read from), reason, until. reason and until are mandatory. Each entry is judged on its own, so an entry that excuses nothing is reported by name
NonBehaviouralNoderulesFrozen dataclass: node, reason. A node excused from the coverage leg. reason is mandatory and there is deliberately no until — this is a classification, not an expiring debt; a declaration that excuses nothing is itself a finding
ScenarioscenariosFrozen dataclass: name, feature, path, line, beads, nodes. The @bead: / @node: tags are the scenario's own plus every tag inherited from its Feature: and Rule:
ScenarioSuitescenariosFrozen dataclass: scenarios, files (what the glob matched — the denominator of any statement about the suite), empty_files (parsed cleanly, declared nothing), unreadable
ScenarioReferencescenariosFrozen dataclass: name, path, line. A scenario a TO-BE document claims exists
UnreadableFeatureFilescenariosFrozen dataclass: path, reason. A file that could not be parsed at all — an unshipped dialect, a byte sequence that is not UTF-8, a second Feature: — never counted as a file with no scenarios
ViolationrulesFrozen dataclass: rule_name, rule_description, rule_type (deny/require/cycle/forbid_import/forbid/layer/cardinality/unregistered_feature_candidate/module_coverage/scenario_coverage/doc_area_coherence/graph_summary_facts/test_binding/test_import_boundary/scenario_binding/suite_population/layer_population/layer_declaration/rule_liveness — nineteen values; note that the summary_facts authoring key publishes findings under the graph_summary_facts type), severity, file_path, line_number, from_ref_id, to_ref_id, message. Built for liveness via the shared liveness_finding() factory, so both channels report a dead rule identically
SnapshotInfosnapshotFrozen dataclass: id, label, created_at, node_count, edge_count, symbols_count
SnapshotDiffsnapshotFrozen dataclass: old_id, new_id, added_nodes, removed_nodes, changed_nodes, added_edges, removed_edges, property has_changes
ImportInfoimport_resolverFrozen dataclass: file_path, line_number, import_path, resolved_ref_id
LintResultlinterDataclass: violations, rules_evaluated, rules_inert (how many of those rules could not fire at all), files_scanned, files_unattributed (how many of those files belong to no node, and so are invisible to every deny rule), imports_resolved, elapsed_ms, properties error_count, warning_count, has_errors
LintErrorlinterException raised on invalid lint configuration
C4Nodec4Frozen dataclass: ref_id, label, c4_level ("System" / "Container" / "Component"), description, boundary (parent ref_id or None), is_external, is_database
C4Relationshipc4Frozen dataclass: src, dst, label (edge kind: "uses" / "depends_on")
FederatedReffederationFrozen dataclass: repo (str | None), ref_id; properties is_foreign, qualified (@repo:ref_id or ref_id)
FederationRefErrorfederationValueError raised on a malformed @... foreign ref
EdgeVerdictfederationEnum: OK / DRIFT / EXPECTED / CLEANUP_CANDIDATE / UNDECLARED / DEAD / EXTERNAL / UNMAPPED (intent-vs-reality verdict; EXTERNAL/UNMAPPED suppress DRIFT — BDL-038 G7/U4)
FederatedGraphfederationDataclass: nodes, edges, repos, unresolved_refs, contracts — the composed result of aggregating ≥2 satellite exports
ContractEndpointcontractsFrozen dataclass: repo, ref_id, direction, source_file — one side of a contract (F2)
ContractcontractsDataclass: contract_key, protocol, name, endpoints, lifecycle, verdict, exposed (producer SDL surface), references (consumer-referenced names), and the AMQP exposed_body / referenced_body (the producer's / consumer's declared body JSON-Schema, BDL-060 S3); properties producers / consumers / body_breaking_fields (AMQP body break paths when both sides declared a body) / missing_references (the BREAKING signal — GraphQL consumer references absent from exposed, or the AMQP body break paths); to_report_dict() keeps F1's flat {message_type, directions, repos, confirmed} subset and adds verdict / protocol / contract_key / lifecycle (+ exposed / references / missing for GraphQL, the body break paths under missing for AMQP)
ContractVerdictcontractsEnum: CONFIRMED / DRIFT / ORPHANED_CONSUMER / UNDECLARED_PRODUCER / BREAKING / EXPECTED / EXTERNAL / DEAD (contract-level intent-vs-reality; assigned by classify)

Constraints ​

  • Files must be valid YAML
  • UTF-8 encoding
  • Only files with the .yml extension (not .yaml)

Testing ​

Tests: tests/test_graph_loader.py, tests/test_cli_graph.py, tests/integration/graph/diff/test_diff.py, tests/integration/graph/diff/test_diff_enhanced.py, tests/integration/infrastructure/console_streams/test_cli_diff.py, tests/integration/graph/rules/test_rule_engine.py, tests/integration/graph/rules/test_rule_severity.py, tests/integration/graph/rules/test_cycle_rule.py, tests/integration/graph/rules/test_import_boundary_rule.py, tests/unit/graph/test_linter.py, tests/test_cli_lint.py, tests/test_import_resolver.py, tests/integration/onboarding/scanner/test_import_scan.py, tests/integration/onboarding/doc_generator/test_symbol_diff_polish.py, tests/integration/graph/snapshot/test_snapshot.py, tests/integration/infrastructure/console_streams/test_cli_snapshot.py, tests/integration/graph/c4/test_c4.py, tests/unit/graph/federation/test_graph_federation.py, tests/unit/graph/contracts/test_graph_contracts.py, tests/unit/graph/sdl/test_graph_sdl.py, tests/integration/graph/rules/test_lifecycle_rules.py, tests/integration/graph/federation/test_export.py, tests/unit/graph/contracts/test_federate.py, tests/test_federate_roundtrip_db.py, tests/integration/graph/rules/test_rule_liveness_all_types.py, tests/unit/graph/scenarios/test_scenario_binding.py, tests/integration/graph/rules/test_scenario_coverage_rule.py, tests/integration/graph/rules/test_doc_area_coherence.py, tests/integration/graph/rules/test_graph_summary_facts.py, tests/integration/graph/scenarios/test_bead14_s4_binding.py, tests/acceptance/graph/scenario-binding/scenario_binding.feature, tests/acceptance/graph/rule-engine/{scenario_coverage,doc_area_coherence,graph_summary_facts}.feature; the suite rules (BDL-074 C3): tests/integration/graph/rules/test_a_test_file_binds_to_a_node_or_is_reported.py, tests/integration/graph/rules/test_a_kind_states_how_it_was_recognised.py, tests/integration/graph/rules/test_a_unit_test_of_a_domain_node_imports_no_infrastructure.py, tests/integration/graph/rules/test_a_scenario_lives_in_the_folder_of_its_node.py, tests/acceptance/graph/rule-engine/the_suite_is_judged_against_the_graph.feature

The checks of this repository's own graph moved out of those files into self-checks (BDL-074 A3): tests/self_check/architecture/test_rule_engine.py, tests/self_check/docs/test_rule_engine.py, tests/self_check/architecture/test_graph_summary_facts.py and test_bead14_s4_binding.py under tests/self_check/{architecture,docs,process}/.