✅ fresh
last synced 2026-09-29T21:12:19.681221+00:00 · coverage 86% (
graph)Validation by Beadloom
doc_sync— same source assync-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-coverageanddoc-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/edgesingestion 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-coreextra, 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
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:planstests: (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)
| Kind | Description |
|---|---|
domain | Domain area |
feature | Feature |
service | Service / module |
entity | Data entity |
adr | Architecture Decision Record |
Edge Types (edge kind)
| Kind | Description | BFS Priority |
|---|---|---|
part_of | A is part of B | 1 |
touches_entity | A touches entity B | 2 |
uses | A uses B | 3 |
implements | A implements B | 3 |
depends_on | A depends on B | 4 |
touches_code | A touches code of B | 5 |
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/*.ymlfiles and populatesnodesandedgestables. Validates ref_id uniqueness and edge integrity —unique_by_ref_iddecides which node survives aref_idcarried twice (the first) and returns aDuplicateRefIdper node dropped, naming where each was read, itskindand itssource(BDL-069, BDL-UX #214). Supports in-place YAML node updates, written through the atomic-io primitive (write_yaml_atomic: temp file +fsync+ atomicos.replace) so an interrupted edit never truncates the source-of-truth*.yml. Cross-repo edge endpoints (@<repo>:<ref_id>) are recorded asForeignEdges into a dedicatedforeign_edgestable (surfaced onGraphLoadResult.foreign_edges) for hub resolution rather than treated as dangling-edge errors (F1). For a GraphQLproducescontract with asource_file, the loader folds the parsed SDLexposedsurface into the stored contract payload (F2 / BDL-038); a missing file recordsexposed: []+ 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 oneref_idsurvives is the loader's — and carries the findings onGraphDiff.duplicates, which the Rich rendering prints first anddiff_to_dictserializes;has_changesignores 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 areAUTHORING_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 nearestpart_ofancestor's; pure, and it reads the rule'slayerslist, so no layer tag is written down in it, BDL-070 A1;can_fire_onanswers 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 — alwayswarn, 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_reachesanswers for a whole rule list over one read of the graph, andLayerReach.to_dictis the shapelint --format jsoncarries, BDL-070 A3;population_phraseis the ONE wording of that fraction andstated_populationsthe 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 checkvalidate_ruleshad noLayerRulecase for, since a layer rule names tags rather than ref_ids; one predicate answers both thevalidate_ruleswarning and the evaluator'swarnfinding, 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_populationandlayer_declaration— and the one thing that follows:lint --fail-on-warndoes 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 aterror: an advisory emitted at that severity exits 1 under--fail-on-warnas 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 hardcodewarntoday and are not obliged to, A8 re-review Minor 3),node_tags.py(the one cached read ofnodes.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 sixthlivenesskept beside them, BDL-070 A5; a row whoseextradoes 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 therules_inertCOUNT for every type exceptforbid_import, whose dead-glob and dead-exemption findings are deliberately outside that counter; alayersrule's liveness is decided throughlayers.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 aforbid_importexemption 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 eachexempt:entry excuses, how many, and whether its exit condition has passed.layers.same_layer_crossingsdecides 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 atwarn; 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 whenbeadloom lintreports it: before B4 the view applieddst_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.layersdecides what crosses andlayer_exemptionsdecides 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_rootderives the source root by descending while exactly one next segment hasmin_supportbehind 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 nodesummarystates, 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_claimstakes theFactSetrather than building one, so itsnot_applicablefallback serves the PUBLICfact_set=parameter, not the registry: a registry-built set covers every nameDocScannerscans 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 apart_ofdescendant, over the binding the reindex records intest_files; BDL-074 C3),test_import_boundary.py(test_import_boundary— chooses which recorded TEST imports a boundary judges, narrowed byofto the tests of matching nodes, and hands them toforbid_import's ownevaluate_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(thetest_files/test_importsreaders andNodeSelection, shared by the two index-reading suite rules so they cannot disagree about one file; each reader returnsNonefor an index written before those tables existed),listed_exemptions.py(ExemptionLedger— what aListedExemptionexcuses, per entry: an entry that excuses nothing is reported dead by name, an exemption past itsuntildate while still excusing something is reported expired, and expiry never re-enables a finding),__init__.py(evaluate_allorchestration + remediation + stable re-exports). Each suite rule adds onesuite_populationfinding (warn, viatypes.population_finding) on every run, stating what it judged and what it did not;advisories.pylists that type beside the two layer advisories, so--fail-on-warndoes not exit on it. Parsesrules.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 viaNodeMatcher. A v3 file's top-leveltags:block is NOT a bulk tag assignment and never was: nothing applied it, and BDL-070 A6 withdrewload_rules_with_tags, the only function that read it, rather than keep a parser for a block a node's owntags:already declares. A file still carrying one loads unchanged, and the block assigns nothing.types.liveness_findingtakes two keyword-only options, both added the same way and both default-preserving so every existing caller's output is byte-identical.severitydefaults towarn: 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.9fordoc_area_coherence,.14forgraph_summary_facts; the three rule types left all shipwarn, so their evaporation reaches only a project that escalated them — BDL-UX #197).graph_summary_factsis the one that shipserror, so it is the one whose stand-down can turn a run red, and its per-nodeunverifiableanswer deliberately stayswarn: 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_iddefaults toNoneand 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_factsis the exception: it reports per node, and until BDL-062.10its two states reached a machine consumer differently, a disagreement carrying the claim'sref_idand an unverifiable claim carrying nothing, from the same rule about the same node.scenario_coverage.py(BDL-061 S4) evaluates thescenario_coveragerule and owns its per-LEG liveness — it compares the graph against files on disk that no index holds, which is why it sits besideevaluators.pyrather than inside it. Since BDL-061.63 it also states its own reach: a run whoseformatcher 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 ofservices.ymlappears beside the coverage fraction that improves.rule_engine.pyremains a thin re-export shim for the prior import path. - scenarios.py -- Acceptance-suite reader (BDL-061 S4). Parses
.featurefiles into boundScenarios — the@bead:/@node:Gherkin tags, with the language's ownFeature:/Rule:inheritance — and reads the scenario names a TO-BE document references. Ships theenandrukeyword 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 secondFeature:are each reported as UNREADABLE rather than counted as a file with no scenarios. Evaluates nothing: the verdicts arerules/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:ortry: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 generatesdepends_onedges from the resolved imports, skipping containment in one direction (a container never depends on its own part). Containment is read throughrules/layers.py::part_of_ancestors— the onepart_ofancestry 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
LintResultwith violations, counts, timing, andlayer_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_warnis the key--fail-on-warnreads, ashas_errorsis--strict's: every finding except the two advisories atwarn(BDL-070 A8). The exclusion stops aterror, so the flag stays a superset ofhas_errorswhatever 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 isrules/layer_reach.py::population_phraserather 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 raisesLintError(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_snapshotstable, lists saved snapshots, and compares two snapshots to produce aSnapshotDiffwith 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_ofdepth heuristics or explicitc4_levelin 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>FederatedRefidentity model +parse_ref),export.py(the deterministic satellite export —build_export/serialize_exportwith repo/commit_sha/exported_at provenance),reconcile.py(the hub aggregationaggregate_exports→FederatedGraphwith three-valued intent-vs-realityEdgeVerdicts, first-class AMQP + GraphQL contract reconciliation carrying a contract-levelContractVerdict, 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, sofrom beadloom.graph.federation import Xis 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/ContractEndpointmodel, the protocol-prefixed language-neutralcontract_keyderivation (AMQPamqp:<exchange>/<routing>:<message_type>, GraphQLgraphql:<schema>), theContractVerdictenum (contract-level intent-vs-reality), andreconcile_contracts(groups AMQP and GraphQL contract-bearing edges into first-classContracts by key; attaches the producerexposedsurface and consumerreferencesonto theContract— plus, when present, the typedexposed_fields/referenced_fieldsfrom the GraphQLfieldsblock and theexposed_body/referenced_bodyfrom the AMQPbodyJSON-Schema; thefederation/reconcile.pyhub delegates here and projects back to the F1 flat shape viaContract.to_report_dict). TheBREAKINGverdict is typed when both sides carry depth — for GraphQL thegraphql_breakingcomponent (absence / type-narrowing / nullability / arg breaks, subscriptions first-class), for AMQP theamqp_bodycomponent (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-levelQuery/Mutation/Subscriptionfield names plustype/input/enum/interfacetype names — as aset[str];operation_field_names(sdl_text)returns only the operation field names (the name-level fallback substrate forgraphql_surface). Name-presence only (no schema validation); malformed/empty SDL yieldsset()(recorded honestly asexposed: []). - graphql_surface.py -- Typed GraphQL Tier-A surface extraction (BDL-060 / S2, G1a).
extract_typed_surface(sdl_text)returns aTypedSurfacemapping each operation field (queries/mutations/subscriptions) to its returntype(with!/[]wrapping) +args, parsed via the OPTIONALgraphql-coreextra; 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_surfacegive a deterministic sorted/deduped wire shape for the federationcontract.fieldsblock. - 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 bycontracts.Contract.breaking_fieldsonly 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_bodymodel the minimal JSON-Schema body (type/properties/required/enum/nested objects/arrayitems) 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), pathingfield/parent.child/field[]/field[].child; additive producer fields + widened requiredness are benign. Invoked bycontracts.Contract.body_breaking_fieldsonly 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.xchannels.<ch>.{publish,subscribe}.message.payload/ 3.xchannels.<ch>.messages.<m>.payloadwith one-hop$refintocomponents.messages) into the internalamqp_bodymodel; AsyncAPI is a source only. Reads YAML or JSON via the coreyaml.safe_load(no optional extra); an unparseable / payload-less / remote-$refdoc degrades honestly toNone(never a fabricated body).
Invariants
ref_idmust be unique across all YAML fileskind(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 anykindverbatim (no enum, no CHECK), so an FSD project can usepage/widget/entity/repository(and FSD-style edge kinds) and they surviveexport → federatewith zero loss or coercion, exactly like DDDdomain/service. The federation + contract path (thefederation/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; edgespart_of/depends_on/uses/implements/touches_entity/touches_code(plus the contract kindsproduces/consumes) — are the vocabulary the rule engine recognizes inrules.ymlmatchers (rule_engine.VALID_NODE_KINDS/VALID_EDGE_KINDS): a rule that matches onkindmust 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-presetkindis stored, exported, and federated faithfully — it is never rejected, andbeadloom lintdoes not flag it (only a rule referencing an unknown kind is an error).
- The conventional DDD preset kinds — nodes
- Edges referencing non-existent local nodes are skipped with a warning
- A
ref_idcarried by more than one node is reduced to the FIRST of them and REPORTED:GraphLoadResult.errorsnames the file,kindandsourceof 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 thesource, so the graph keeps an empty root and every rule over that package runs on an empty population (BDL-UX #214). The rule lives inloader.unique_by_ref_idandgraph/diff.pyreads 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-leveltags:block assigned nothing and is withdrawn withload_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, orscenario_binding(fifteen rule types) - Rule severity must be one of:
error,warn forbid_import.fromis matched against the source file path (src/pkg/tui/app.py),forbid_import.toagainst the dotted import path with dots → slashes (pkg/infrastructure/db) — two different vocabularies, so asrc/-prefixedtocan never match. A rule whose glob matches zero candidates in the whole index is reported as arule_livenessfinding (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-
sourceownership thedepends_onedges 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 inLintResult.files_unattributedand 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, acheckwith no threshold, asource_rootwith no module (BDL-061.48). The per-type definition of "cannot fire" is the table in the rule-engine SPEC. The severity iswarnby 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.9fordoc_area_coherenceand.14forgraph-summary-facts; the three remaining rule types still report a total stand-down atwarn, and all three shipwarn, so only a project that escalated them is affected, BDL-UX #197).LintResult.rules_inertqualifies the rule count so a green run cannot advertise checks that never looked. Since BDL-062.2a rule may also be inert because of the graph's DATA rather than its configuration:doc-area-coherencereports that it checked nothing when no source-to-docs mapping in the graph reaches its majority threshold, andgraph-summary-facts(.1) reports the same when no nodesummarystates a number the project computes a fact for..1adds 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 needsfromand/ortoplus a mandatoryreasonanduntil. An exemption is visible whatever it does (BDL-061.49): one that suppresses nothing is reported as dead, one still suppressing past anuntil: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'ssuppressedarray. Anuntil: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*.ymlfiles from a directory into SQLite (two-pass: nodes then edges). ReturnsGraphLoadResultwithnodes_loaded,edges_loaded,errors,warnings. A contract-bearing edge's persistedcontract_keyis the full protocol-prefixed identity fromcontracts.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 GraphQLproducescontract's relativesource_file, whose parsed SDLexposedsurface 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. ReturnsTrueif 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'sextraJSON column. Returns an empty set when the node does not exist or has notagskey 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. RaisesValueErroron invalid ref.compute_diff_from_snapshot(conn: sqlite3.Connection, snapshot_id: int) -> GraphDiff-- Compare a saved snapshot (fromgraph_snapshotstable) with the current live state in thenodesandedgestables. Returns aGraphDiffwithsince_refset to"snapshot:<id>". RaisesValueErrorif 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 thesinceref) is escaped withrich.markup.escapebefore 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]-- Parserules.ymland return validatedRuleobjects (union ofDenyRule | RequireRule | CycleRule | ImportBoundaryRule | ForbidEdgeRule | LayerRule | CardinalityRule | UnregisteredFeatureCandidateRule | ModuleCoverageRule | ScenarioCoverageRule | DocAreaCoherenceRule | SummaryFactsRule | TestBindingRule | TestImportBoundaryRule | ScenarioBindingRule). RaisesValueErroron schema errors — including afor.excludeon ascenario_coveragerule, which names the excluded nodes and routes the author tonon_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_cyclesamong them);layersstays 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 onebeadloom init— a reindex and then the Gate's lint step — parsesrules.ymlonce 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 parseload_rulesremembers, so the next call parses. Inrules/loader.pyand not re-exported frombeadloom.graph.rules;tests/conftest.pycalls 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 pluslayers, derived rather than listed. Inrules/loader.pyand not re-exported;onboarding.scanner.rules_genimports 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 aLayerRulethat 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 inlinter.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) andscenario_coverage(per leg, from files on disk that no index holds). Onewarnfinding 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 behindLintResult.rules_inert.scenario_coverageis COUNTED here through the rule's owninert_reasonpredicate and reported byscenario_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_importis reported there and not counted here, because its liveness channel also carries statements about individualexemptentries.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 behindLintResult.files_unattributed.suppressed_crossings(conn: sqlite3.Connection, rules: list[ImportBoundaryRule], *, today: date | None = None) -> list[SuppressedCrossing]-- Every crossing aforbid_importexemption excused, in deterministic order. The read-only second pass behindLintResult.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, orNonewhen it names an event. One definition, shared byforbid_import.exempt[].until,layers.exempt[].untilandflow.yml'sguards.<name>.exclusions[].until; pinned to a leadingYYYY-MM-DDrather than todate.fromisoformat, which accepts more on Python 3.11+ than on 3.10. Re-exported here since BDL-070 B2 — it lives ininfrastructure/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 livedepends_ongraph: 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 viaget_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_globviafnmatchon the source file path,to_globon the dotted import path with dots → slashes (ato:glob covering a package also covers a bare import of that package). Also returnsrule_livenessfindings (warn) for a rule whose glob matches nothing in the index (BDL-UX #172) and for a staleexemptentry — 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 theedgestable. Checks source and destination nodes againstfrom_matcherandto_matcher, optionally restricted byedge_kind. Supports tag-based matching.evaluate_layer_rules(conn: sqlite3.Connection, rules: list[LayerRule]) -> list[Violation]-- Evaluate layer rules against the edges table. Forenforce: top-down, detects lower-to-upper layer dependencies and optional layer-skip violations whenallow_skip=False. Since BDL-070 B3 each end takes its layer fromlayer_membership— its own declared tag, else the nearestpart_ofcontainer 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 onelayer_populationfinding (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 livedepends_onset 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 nearestpart_ofancestor's, elseNone. Pure; reads the rule's declaredlayers, so it holds no tag name of its own.own_layer_ofanswers the first half alone (BDL-070 A1), andlayer_membershipreturns the same index withdeclared_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 onepart_ofancestry walk, nearest generation first, each ancestor once, a cycle terminating.import_resolver._part_of_ancestorsreads the edges and calls it.layer_population(edges, layer_at) -> LayerPopulation-- An edge set counted intoevaluated(a layer at both ends) andskipped_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, andsync_state. Checksmax_symbols,max_files, andmin_doc_coveragethresholds 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 bysync-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 eachViolationwith a deterministicremediationhint, and return violations sorted by(rule_name, file_path or "").project_root(default: cwd) roots the on-disk module enumeration themodule-coveragerule uses, and the feature globscenario_bindingreads.evaluate_test_binding_rules(conn, rules: list[TestBindingRule], *, scenario_rules: Sequence[str] = ()) -> list[Violation]-- Thefilesleg reports each judged test file bound to no node, naming its placement. Files of placementother_kindare 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 thescenario_bindingrules named in scenario_rules (evaluate_allpasses 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), whethertests.kindsin.beadloom/config.ymldeclared 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). Theforleg reports each selected node with no bound test file, its own or apart_ofdescendant's, and states how many test files bind to no node, because one of them may test it. Liveness per leg; an index withouttest_filesstands the rule down with "reindex".evaluate_test_import_boundary_rules(conn, rules: list[TestImportBoundaryRule]) -> list[Violation]--forbid_importovertest_imports:fromover the test file path, matched withfnmatchcaseso the match is case-sensitive on every platform (beadloom-2mj3.15),toover the dotted import path,ofkeeping the files bound to a matching node or apart_ofpart of one. Crossings are restampedrule_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 apart_ofcontainer, 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]-- Oneforbid_importrule 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 inADVISORY_RULE_TYPES.evaluate_scenario_coverage_rules(conn: sqlite3.Connection, rules: list[ScenarioCoverageRule], *, project_root: Path | None = None) -> list[Violation]-- Evaluate thescenario_coveragerule over four independent legs (coverage, suite, reference, declaration) and report its own per-leg liveness. Every finding iswarn. Afeaturesglob 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 inrules_inert.inert_reason(conn, rule, *, project_root=None) -> str | None-- Why this rule can check nothing, orNone. The single predicate bothliveness.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-- therule_typeevery 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.featurefile. A non-Nonereason 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, keepingfiles,scenarios,empty_filesandunreadableapart 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 specificsourcewins), 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 createdepends_onedges. Returns count of imports indexed.create_import_edges(conn: sqlite3.Connection) -> int-- Createdepends_onedges 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.reindexis 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. Withreindex=Nonethe call is READ-ONLY (mode=ro,query_only) and a missing or unreadable index raisesLintErrorrather than being created — the CLI maps that to exit 2. RaisesLintErroron invalid configuration.format_rich(result: LintResult) -> str-- Format aLintResultas human-readable text with violation markers.format_json(result: LintResult) -> str-- Format aLintResultas structured JSON with violations array and summary.format_porcelain(result: LintResult) -> str-- Format aLintResultas 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 thegraph_snapshotstable. Returns the new snapshot ID.list_snapshots(conn: sqlite3.Connection) -> list[SnapshotInfo]-- List all saved snapshots, newest first. Returns a list ofSnapshotInfoobjects.compare_snapshots(conn: sqlite3.Connection, old_id: int, new_id: int) -> SnapshotDiff-- Compare two snapshots and return aSnapshotDiffwith added, removed, and changed nodes and edges. RaisesValueErrorif 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 explicitc4_levelin node extras (priority) orpart_ofdepth 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). ProducesSystem(),Container(),Component()elements with_Ext/Dbvariants for external/database nodes. Groups children inSystem_Boundary()blocks.render_c4_plantuml(nodes: list[C4Node], relationships: list[C4Relationship]) -> str-- Render C4 model as C4-PlantUML syntax. Produces a complete@startuml/@endumlblock with!includefor the C4-PlantUML stdlib. Uses standard macros:System(),Container(),Component(),Rel()with_Ext/Dbvariants.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"requiresscopeand keeps children of the scoped container. RaisesValueErroriflevel="component"withoutscope, or ifscoperef_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 → localFederatedRef(None, raw);@repo:id→ foreignFederatedRef("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 theedgesandforeign_edgestables; nodes sorted byref_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.ymllandscape: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.ymlrepo:> gitoriginremote basename > directory name.current_commit_sha(project_root: Path) -> str | None-- git HEAD sha, orNonewhenproject_rootis 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'slandscape, assign anEdgeVerdictper edge, reconcile AMQP + GraphQL contracts into first-classContracts scoped by(landscape, contract_key)with a contract-levelContractVerdict(sorted bycontract_key), record per-satellite staleness + landscape provenance.nowinjectable for deterministic age. Edge-verdict reconciliation also honoursexternalandunmapped(BDL-038 G7/U4): an edge whose target node islifecycle: external(or which itself declaresexternal) →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 fromunresolved_refs(genuinely-absent foreign targets).serialize_federation(fed: FederatedGraph) -> str-- Serialize aFederatedGraphto 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 yieldsamqp:*/*:<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-classContracts (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 producerexposedsurface and consumerreferences(sorted + deduped) — plus, for AMQP, the producer'sexposed_bodyand consumer'sreferenced_bodyJSON-Schema (normalized) — and folds the most-significant edgelifecycle(external>dead>deprecated>planned>active) onto eachContract, then assigns itsverdictviaclassify. Insertion order preserved (the hub sorts the projected dicts bycontract_key). Helperscross_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 edgelifecycleisexternalresolves here — never DRIFT),dead→DEAD,planned/deprecated→EXPECTED; then shape: GraphQL or AMQP with both sides and a non-emptymissing_references(a GraphQL surface break —references ⊄ exposedor 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-levelEdgeVerdict.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-levelQuery/Mutation/Subscriptionfield names plustype/input/enum/interfacetype 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 asexposed: []).
Public Data Classes
| Class | Module | Description |
|---|---|---|
ParsedFile | loader | Result of parsing a single YAML file: nodes, edges |
GraphLoadResult | loader | Summary: nodes_loaded, edges_loaded, errors, warnings, foreign_edges (list of ForeignEdge) |
ForeignEdge | loader | Frozen 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) |
GraphParseError | loader | Exception raised when a graph YAML file cannot be parsed; carries the offending path and (when available) source line |
NodeChange | diff | Frozen dataclass: ref_id, kind, change_type, old_summary, new_summary, old_source, new_source, old_tags, new_tags, symbols_added, symbols_removed |
EdgeChange | diff | Frozen dataclass: src, dst, kind, change_type |
GraphDiff | diff | Frozen dataclass: since_ref, nodes, edges, property has_changes |
NodeMatcher | rules | Frozen 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) |
DenyRule | rules | Frozen dataclass: name, description, from_matcher, to_matcher, unless_edge, severity |
RequireRule | rules | Frozen dataclass: name, description, for_matcher, has_edge_to, edge_kind, severity |
CycleRule | rules | Frozen dataclass: name, description, edge_kind (str or tuple), max_depth (default 10), severity |
ImportBoundaryRule | rules | Frozen 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 |
ImportExemption | rules | Frozen 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 |
LayerExemption | rules | Frozen 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) |
SuppressedCrossing | rules | Frozen 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 |
ForbidEdgeRule | rules | Frozen 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) |
LayerDef | rules | Frozen dataclass: name, tag. Defines a single architecture layer for use in LayerRule |
LayerRule | rules | Frozen 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 |
CardinalityRule | rules | Frozen dataclass: name, description, for_matcher, max_symbols, max_files, min_doc_coverage, severity (default "warn"). Detects architectural smells via node-level cardinality checks |
ScenarioCoverageRule | rules | Frozen 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 |
TestBindingRule | rules | Frozen 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 |
TestImportBoundaryRule | rules | Frozen 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 |
ScenarioBindingRule | rules | Frozen dataclass: name, description, features (default DEFAULT_FEATURE_GLOB), exempt (tuple of ListedExemption), severity (default "warn") |
ListedExemption | rules | Frozen 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 |
NonBehaviouralNode | rules | Frozen 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 |
Scenario | scenarios | Frozen 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: |
ScenarioSuite | scenarios | Frozen dataclass: scenarios, files (what the glob matched — the denominator of any statement about the suite), empty_files (parsed cleanly, declared nothing), unreadable |
ScenarioReference | scenarios | Frozen dataclass: name, path, line. A scenario a TO-BE document claims exists |
UnreadableFeatureFile | scenarios | Frozen 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 |
Violation | rules | Frozen 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 |
SnapshotInfo | snapshot | Frozen dataclass: id, label, created_at, node_count, edge_count, symbols_count |
SnapshotDiff | snapshot | Frozen dataclass: old_id, new_id, added_nodes, removed_nodes, changed_nodes, added_edges, removed_edges, property has_changes |
ImportInfo | import_resolver | Frozen dataclass: file_path, line_number, import_path, resolved_ref_id |
LintResult | linter | Dataclass: 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 |
LintError | linter | Exception raised on invalid lint configuration |
C4Node | c4 | Frozen dataclass: ref_id, label, c4_level ("System" / "Container" / "Component"), description, boundary (parent ref_id or None), is_external, is_database |
C4Relationship | c4 | Frozen dataclass: src, dst, label (edge kind: "uses" / "depends_on") |
FederatedRef | federation | Frozen dataclass: repo (str | None), ref_id; properties is_foreign, qualified (@repo:ref_id or ref_id) |
FederationRefError | federation | ValueError raised on a malformed @... foreign ref |
EdgeVerdict | federation | Enum: OK / DRIFT / EXPECTED / CLEANUP_CANDIDATE / UNDECLARED / DEAD / EXTERNAL / UNMAPPED (intent-vs-reality verdict; EXTERNAL/UNMAPPED suppress DRIFT — BDL-038 G7/U4) |
FederatedGraph | federation | Dataclass: nodes, edges, repos, unresolved_refs, contracts — the composed result of aggregating ≥2 satellite exports |
ContractEndpoint | contracts | Frozen dataclass: repo, ref_id, direction, source_file — one side of a contract (F2) |
Contract | contracts | Dataclass: 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) |
ContractVerdict | contracts | Enum: 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
.ymlextension (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}/.