✅ fresh
last synced 2026-06-17T22:39:35.514075+00:00 · coverage 100% (
federation)Validation by Beadloom
doc_sync— same source assync-check.
Federation
Cross-repo architecture federation: stable cross-repo node identity, a lifecycle field on nodes and edges, a deterministic satellite export artifact, a hub federate aggregation that composes the per-repo graphs into one federated graph, and — the F2 moat — a first-class cross-service contract graph with contract-level intent-vs-reality verdicts (drift, breaking-change, orphaned-consumer detection) across a multi-paradigm, multi-language, product- and company-scoped microservices landscape.
Source: src/beadloom/graph/federation/ (the federation/ package — decomposed by responsibility in BDL-059 S3: refs.py identity model, export.py satellite export, reconcile.py hub aggregation + verdicts, gate.py landscape gate; __init__.py re-exports the full public surface so from beadloom.graph.federation import X is unchanged), src/beadloom/graph/contracts.py (first-class Contract model, language-neutral contract_key, classify/reconcile_contracts), src/beadloom/graph/sdl.py (GraphQL SDL surface extraction), src/beadloom/graph/loader.py (foreign-ref parsing, lifecycle load, foreign_edges, contract-key unification), src/beadloom/infrastructure/db.py (lifecycle columns, foreign_edges table, paradigm-agnostic kinds), src/beadloom/services/cli.py (export / federate commands).
This SPEC covers two delivered slices:
- F1 — Federation Foundation (BDL-037):
@repo:identity, thelifecyclefield, deterministicexport(schema v1), andfederatewith three-valued edge-levelEdgeVerdicts + both-sides AMQP reconciliation. Purely additive. - F2 — Cross-Service Contract Graph (BDL-038): a first-class
Contractmodel with a protocol-prefixed, language-neutralcontract_key(AMQP exchange identity + GraphQL SDL); contract-levelContractVerdicts (CONFIRMED / DRIFT / ORPHANED_CONSUMER / UNDECLARED_PRODUCER / BREAKING / EXTERNAL / DEAD / EXPECTED); theexternal/unmappedlifecycle (U4); nested product-vs-company landscapes (U5); and paradigm-agnostic node/edge kinds (U1, DDD and FSD). Three version bumps — EXPORT1 → 2, FEDERATION1 → 2, DB SCHEMA3 → 4— all backward-compatible: a v1 export still federates, an older DB migrates idempotently.
It remains purely additive — single-repo behavior is unchanged: refs without @ stay local, nodes/edges without lifecycle default to active, and an undeclared-landscape single-product run is byte-identical to F1.
Contract-level DRIFT note (BEAD-10 review): contract-level
DRIFTis intentionally subsumed byORPHANED_CONSUMER/UNDECLARED_PRODUCER— a missing producer or missing consumer is the precise contract-level signal.DRIFTremains the edge-levelEdgeVerdict(anactiveedge whose target does not resolve).
Specification
1. Cross-repo node identity (@repo:ref_id)
A graph reference may name a node in another repository using the @<repo>:<ref_id> form, e.g. @integration-service:plans. A plain reference (no leading @) is local, exactly as before.
FederatedRef
Frozen dataclass representing a reference that may point at another repo.
| Field | Type | Description |
|---|---|---|
repo | str | None | None for a local ref; the satellite repo name for foreign. |
ref_id | str | The node identifier within repo. |
| Property | Returns | Description |
|---|---|---|
is_foreign | bool | True when repo is not None (the ref targets another repo). |
qualified | str | Canonical form: @repo:ref_id (foreign) or ref_id (local). |
parse_ref(raw: str) -> FederatedRef
Parses a graph ref into a FederatedRef:
"routing"→ localFederatedRef(None, "routing")."@repo:plans"→ foreignFederatedRef("repo", "plans").- Malformed
@...→ raisesFederationRefError.
Only the first : after the marker separates repo from ref_id, so a foreign ref_id may itself contain colons (@repo:ns:thing → repo="repo", ref_id="ns:thing"). A plain ref containing a colon and no leading @ stays local untouched. An empty string raises FederationRefError.
FederationRefError
ValueError subclass raised when a @... foreign ref is malformed. A leading @ signals the author intended a cross-repo reference, so a broken shape is surfaced as an error (recorded in GraphLoadResult.errors by the loader) — never silently accepted as a local ref. Malformed examples: @:x (empty repo), @repo: (empty ref_id), @repo (no separator), @.
is_foreign_ref(raw: str) -> bool
Cheap check: does raw start with @? Does not validate the shape — use parse_ref for that.
Foreign edges in the loader
When the loader (graph/loader.py) classifies an edge endpoint:
- Both endpoints local → exact original behavior (insert into
edges, or warn on dangling node). - A malformed
@...endpoint → recorded inGraphLoadResult.errors; the edge is skipped. - A foreign endpoint (src or dst) → the edge is not inserted into the local
edgestable (its endpoint cannot satisfy the local foreign-key) and is not treated as a dangling-node warning. It is persisted in a dedicatedforeign_edgestable (no FK) so the declared cross-repo link survives into the export artifact and resolves/drifts at the hub.
2. The lifecycle field
Every node and edge carries a lifecycle status. It is a first-class SQLite column (not stored in the extra JSON), so it is type-checked, SQL-queryable, and visible to the rule engine.
| Value | Meaning |
|---|---|
active | Default. The node/edge is live and real. |
planned | Declared intent; not built yet. |
deprecated | On its way out; still present. |
dead | Declared dead; not treated as live. |
external | Present-but-not-ours (e.g. a native bridge); dependents suppress DRIFT (BDL-038 G7). |
Loading rules (graph/loader.py):
- Absent
lifecycle:→ defaults toactive. - An invalid value is recorded loudly in
GraphLoadResult.errorsand falls back toactive(never silently dropped). lifecycleis excluded from theextraJSON (no duplication).
Rule-engine awareness (graph/rules/cycles.py — the edge-liveness SQL helpers): only active edges are counted as "live" for the no-dependency-cycles and architecture-layers rules. planned / deprecated / dead edges are not counted as live cycle/layer violations. If the lifecycle column is absent (an older DB), the engine degrades gracefully and treats all edges as live.
Migration (infrastructure/db.py): the lifecycle TEXT NOT NULL DEFAULT 'active' CHECK(...) column is added to both nodes and edges, in the fresh schema and via an idempotent ALTER TABLE ADD COLUMN migration. Existing rows default to active (no regression). BDL-038 G7 adds external to the CHECK on nodes / edges / foreign_edges; since SQLite cannot ALTER a CHECK in place, the migration rebuilds each table (table-rebuild pattern, DB SCHEMA_VERSION 3 → 4 — additive, idempotent, no data loss, composes with the dropped DDD-only kind CHECK).
3. beadloom export — satellite export artifact
beadloom export [--out FILE] [--project DIR]Reads the indexed graph from SQLite (read-only) and emits a deterministic, self-describing JSON artifact that a hub aggregates. With --out it writes to a file; otherwise it prints to stdout.
Artifact schema (schema_version: 2)
{
"schema_version": 1,
"repo": "<repo name>",
"commit_sha": "<git HEAD sha or null>",
"exported_at": "<ISO-8601 UTC timestamp>",
"generator": "beadloom <version>",
"nodes": [
{ "ref_id": "...", "kind": "...", "summary": "...", "lifecycle": "...", "source": "..." }
],
"edges": [
{ "src": "...", "dst": "...", "kind": "...", "lifecycle": "...",
"contract": { "protocol": "amqp", "source_file": "...", "direction": "...", "message_type": "..." } }
]
}- Nodes are sorted by
ref_id; edges by(src, dst, kind); JSON keys are sorted (sort_keys=True, 2-space indent) — so identical graphs serialize byte-identically (reviewable diffs). - The
edgesarray unions the localedgestable and theforeign_edgestable, so declared cross-repo@repo:links reach the artifact. - An edge's optional
contractmetadata (carried under thecontractkey of the edge'sextraJSON) is surfaced as a top-levelcontractfield; edges without it omit the key entirely. Two protocols are carried: AMQP (protocol: amqp+exchange/routing_key/message_type/direction— plus an additive optionalbodyJSON-Schema, see §6) and GraphQL (protocol: graphql+schema+ the producer'sexposedSDL surface / a consumer'sreferences— and, when the optionalgraphql-coreextra is installed, an additive typedfieldsblock, see §5). The protocol-prefixedcontract_keyis derived at the hub.
Provenance fields (commit_sha, exported_at)
The hub cannot know a satellite's live HEAD, so the export records its own provenance:
repo— resolved byresolve_repo_name():.beadloom/config.ymlrepo:key > gitoriginremote basename > project directory name.commit_sha— resolved bycurrent_commit_sha(): the git HEAD sha, ornullwhen the project root is not itself the git toplevel (an honest "unknown HEAD" beats leaking an enclosing repo's sha).exported_at— ISO-8601 UTC timestamp (injected as a parameter for deterministic tests; the CLI passes wall-clock UTC).landscape(optional, BDL-038 / U5) — resolved byresolve_landscape():.beadloom/config.ymllandscape:key > the resolved repo name. The CLI emits it only when explicitly configured (≠ the repo default), so an undeclared-landscape export keeps the F1 wire shape (nolandscapekey). Names the product the satellite belongs to; the hub scopes implicit contract matching by(landscape, contract_key).
The CLI exits 1 with an error if the database is not found (beadloom reindex first).
Public API (graph/federation/export.py, re-exported from graph.federation)
EXPORT_SCHEMA_VERSION: int # = 2 (GraphQL SDL `contract` meta on edges; v1 still ingested by federate)
def build_export(conn, *, repo: str, commit_sha: str | None,
exported_at: str, generator: str) -> dict[str, object]
def serialize_export(export: dict[str, object]) -> str # deterministic JSON
def resolve_repo_name(project_root: Path) -> str
def current_commit_sha(project_root: Path) -> str | None4. beadloom federate — hub aggregation
beadloom federate <export1.json> <export2.json> [...] [--project DIR] [--fail-on <csv>]Ingests ≥ 2 satellite export artifacts and composes one federated graph. Writes .beadloom/federated.json (deterministic) + .beadloom/federated.txt (human-readable report) into the hub project root and echoes the report (plus any DRIFT) to stdout. Fewer than two artifacts, or a file that is not a JSON object, exits 1.
Landscape gate — --fail-on (BDL-039 / F3). With --fail-on, the run also acts as a CI gate: it still writes the artifact and prints the report first, THEN exits 1 if any edge EdgeVerdict or contract ContractVerdict (matched case-insensitively against the enum value) is in the fail-set — so CI always has federated.json to upload even when the gate fails. The failing verdicts (each with its src → dst / contract_key identity, and for BREAKING the missing GraphQL names) are printed to stderr. Exit 0 when clean. A bare --fail-on or the token default uses the safe-default fail-set breaking,drift,orphaned_consumer,undeclared_producer (plus the edge-level undeclared, the AMQP equivalent of undeclared_producer). The pure, testable gate_failures(fed, fail_on) -> list[GateFailure] scans the already-computed verdicts and returns deterministically-sorted findings.
No false gates (principle 3 — a noisy gate gets disabled). The fail-set never includes external / expected / dead / unmapped / confirmed / ok / cleanup_candidate (NEVER_FAIL_VERDICTS): these are intentional, honest-unknown, or healthy states (cleanup_candidate is a warning, not a block). Passing one of them to --fail-on is rejected with a clear error (exit 2) so a user cannot arm a false gate. SAFE_DEFAULT_FAIL_ON and NEVER_FAIL_VERDICTS are disjoint by construction; without --fail-on, federate is pure reporting (exit 0 regardless of drift).
Aggregation (aggregate_exports):
Namespaced union. Every node id is qualified as
@repo:ref_id. The sameref_idin two repos stays distinct.Endpoint resolution. A local edge endpoint is namespaced under the edge's own repo; a foreign endpoint (
@other:ref) keeps its own namespace. A foreign target that does not resolve in the union is recorded inunresolved_refs(reported, never dropped); the edge is still present.Three-valued intent-vs-reality verdict assigned to every edge (
EdgeVerdict), reconciling its declaredlifecycleagainst whether its target resolves:lifecycletarget present Verdict Meaning activeyes OKDeclared and present. activeno DRIFTA real, broken cross-repo dependency (the killer signal). planned(either) EXPECTEDIntentional — the target is not built yet. deprecatedyes CLEANUP_CANDIDATEThe dependency outlived its declared death. deprecatedno EXPECTEDDeprecated and already gone. dead(either) DEADDeclared dead; not treated as live. external(either) EXTERNALThe edge or its target node is declared external(present-but-not-ours, e.g. a native bridge) — never DRIFT (BDL-038 G7).activeyes, undescribed UNMAPPEDThe target resolves in the union but is present-without-a-usable-surface (empty summary) — reported honestly, never DRIFT (BDL-038 U4). — — UNDECLAREDA present AMQP producer whose message_typehas no matching consumer across the union (emitting into the void).externalvsunmappedvsunresolved_refs(BDL-038 G7/U4) — three honest categories, never conflated:external— the author declares a node present-but-not-ours (lifecycle: external, e.g. a native Swift/Kotlin/ObjC++/C++ bridge). The dependent →EdgeVerdict.EXTERNAL/ContractVerdict.EXTERNAL. Suppresses DRIFT.unmapped— a foreign ref that resolves to a node present in the union but exported without a usable surface (undescribed — empty summary; also covers a present node no satellite describes). The dependent →EdgeVerdict.UNMAPPED. Reported, never DRIFT, and not an unresolved ref (it resolved).unresolved_refs— a genuinely-absent foreign target (resolved to nothing). StillDRIFT(active) and recorded inunresolved_refs. Distinct fromunmapped(present).
First-class contract reconciliation + verdicts (
_reconcile_contracts→contracts.reconcile_contracts+classify; BDL-038 / F2). AMQP and GraphQL contract edges are grouped by protocol-prefixedcontract_keyacross the union into first-classContracts, each assigned a contract-levelContractVerdict(intent-vs-reality — the F2 moat). The most-significant declared edgelifecycle(external>dead>deprecated>planned>active) folds onto the contract; lifecycle intent dominates the shape check. Surfaced inFederatedGraph.contracts(sorted bycontract_keyfor deterministic diffs; F1's flat{message_type, directions, repos, confirmed}keys kept as a subset).Condition ContractVerdictMeaning lifecycle externalEXTERNALTarget declared present-but-not-ours (a native bridge); the externaledgelifecyclefolds onto the contract (externalis the most-significant lifecycle) →EXTERNAL, never DRIFT (BDL-038 G7).lifecycle deadDEADDeclared dead; not live. lifecycle planned/deprecatedEXPECTEDIntentional — not built yet / retiring. Not drift. producers ∧ consumers; GraphQL/AMQP consumer ref the producer breaks BREAKINGA consumer relies on a field the producer no longer satisfies — caught before it ships (not a version-diff). For GraphQL: a typed field/arg break when both sides carry a typed surface, else name-presence ( references ⊄ exposed) — §5. For AMQP: a body field/type/required/nested/array break when both sides declare abodyJSON-Schema, else name-level both-sides presence — §6.producers ∧ consumers (compatible) CONFIRMEDBoth sides present and compatible (F1's "confirmed both-sides"). consumers, no producers ORPHANED_CONSUMERConsumes a contract nobody produces (F1 "one-sided", consumer side). producers, no consumers UNDECLARED_PRODUCERProduces a contract nobody consumes (F1 "one-sided", producer side). The contract-level
UNDECLARED_PRODUCERis complementary to F1's edge-levelEdgeVerdict.UNDECLARED(an additional projection over the same fact, not a replacement); both stay intact and never contradict. For GraphQL, a contract dict also carriesexposed/references/missing(the field/arg descriptors that triggeredBREAKING— typed"<field>"/"<field>(<arg>)"when the typed verdict ran, else bare missing names). For AMQP, when abodywas declared the dict carriesmissingwith the body-diff field paths (field/parent.child/field[]/field[].child); a name-level AMQP contract keeps its byte-identical F1 shape with nomissingkey.Landscape scoping — product vs company (BDL-038 / U5). An optional
landscapeprovenance (resolved likerepo: configlandscape:key > repo name) names the product a satellite belongs to.reconcile_contractsgroups by(landscape, contract_key), so implicit same-key matching is scoped within a landscape: two unrelated products that happen to share a coincidentalmessage_type/ schema name reconcile in separate groups → zero mutual DRIFT / UNDECLARED / false-CONFIRMED. A genuine cross-product contract is declared with an explicit@otherrepo:<ref>consumer edge — itscontract_keyis promoted cross-landscape (one shared, landscape-agnostic group viacross_landscape_keys/edge_group_key) and resolves with a both-sides verdict regardless of landscape. An export with no declared landscape (or one equal to its repo) shares a single default group, so a single-product run is byte-identical to F1. The hub's edge-level UNDECLARED sweep (_mark_undeclared) uses the same(landscape, contract_key)group scope, so a producer is UNDECLARED only when its own landscape has no consumer (honest per-product signal, never silenced by an unrelated product's coincidental consumer).federatecomposes either one product-landscape (all satellites share/omit a landscape) or a company-landscape (several); the text report groups satellites by landscape with aproduct/company-landscape label.Per-satellite staleness (
_repo_provenance). For each satellite:repo,commit_sha,exported_at,schema_version, andage_seconds=now − exported_atin whole seconds. An unparseable/missing timestamp yieldsNone(honest unknown); a missingcommit_shais reported, never faked.nowis injectable for deterministic tests; the CLI passes wall-clock UTC.
FederatedGraph
| Field | Type | Description |
|---|---|---|
nodes | list[dict] | Namespaced node union (each carries ref_id + repo). |
edges | list[dict] | Edge union with resolved endpoints + verdict. |
repos | list[dict] | Per-satellite provenance + staleness + landscape (defaults to repo when undeclared). |
unresolved_refs | list[str] | Foreign targets that did not resolve (sorted, deduped). |
contracts | list[dict] | First-class AMQP + GraphQL contracts with a contract-level ContractVerdict (sorted by contract_key). |
EdgeVerdict (enum)
OK · DRIFT · EXPECTED · CLEANUP_CANDIDATE · UNDECLARED · DEAD · EXTERNAL · UNMAPPED (serialized as the lowercase value). Edge-level; complementary to the contract-level ContractVerdict (see contracts.py). EXTERNAL / UNMAPPED (BDL-038 G7/U4) both suppress DRIFT.
Enforcement (F3 / BDL-039)
gate_failures(fed, fail_on) -> list[GateFailure] is a pure, deterministic function over an already-aggregated FederatedGraph: it scans each edge EdgeVerdict and each contract ContractVerdict (matched case-insensitively against the enum value) and returns a sorted GateFailure per verdict in fail_on (kind = "edge"/"contract", identity = src --> dst / contract_key, verdict, and missing GraphQL names for a BREAKING). It is reused by both federate --fail-on and the beadloom ci orchestrator, so the gate is testable without the CLI.
Two module-level frozensets bound the gate (principle 3 — a noisy gate gets disabled):
SAFE_DEFAULT_FAIL_ON=breaking, drift, orphaned_consumer, undeclared_producer, undeclared— the set a bare--fail-on/defaultarms (the edge-levelundeclaredis the AMQP equivalent ofundeclared_producer).NEVER_FAIL_VERDICTS=external, expected, dead, unmapped, confirmed, ok, cleanup_candidate— intentional, honest-unknown, or healthy states. These can never enter a fail-set; passing one to--fail-onis rejected (exit2). The two sets are disjoint by construction.
federate --fail-on and beadloom ci write the federated artifact (and print the report) before exiting non-zero, so CI always has federated.json to upload even when the gate blocks. beadloom ci composes the landscape gate as its final step (after reindex → lint → sync-check → config-check → doctor); see docs/guides/ci-setup.md.
Public API (graph/federation/reconcile.py + gate.py, re-exported from graph.federation)
FEDERATION_SCHEMA_VERSION: int # = 2 (independent of EXPORT_SCHEMA_VERSION = 2)
SAFE_DEFAULT_FAIL_ON: frozenset[str] # bare --fail-on / "default" arms this set
NEVER_FAIL_VERDICTS: frozenset[str] # never gated (rejected by --fail-on)
class EdgeVerdict(enum.Enum): ...
@dataclass
class FederatedGraph: ...
@dataclass(frozen=True)
class GateFailure: ... # kind, identity, verdict, missing
def aggregate_exports(exports: list[dict], *, now: str | None = None) -> FederatedGraph
def serialize_federation(fed: FederatedGraph) -> str # deterministic JSON
def render_federation_report(fed: FederatedGraph) -> str # human-readable text
def gate_failures(fed: FederatedGraph, fail_on: set[str]) -> list[GateFailure]The federated JSON envelope: { schema_version, repos, nodes, edges, contracts, unresolved_refs } (sorted keys). The text report lists the satellites (with sha + age), the edge-verdict counts, an explicit DRIFT list, the contract-verdict counts plus explicit BREAKING / DRIFT / ORPHANED_CONSUMER / UNDECLARED_PRODUCER call-outs (with the missing GraphQL names), and any unresolved foreign refs.
Schema v2 (BDL-038 / BEAD-04): each contracts entry gains a contract-level verdict (ContractVerdict) plus protocol / contract_key / lifecycle and, for GraphQL, exposed / references / missing. F1's flat keys are kept as a subset. The bump is on the hub OUTPUT only — federate still ingests v1 AND v2 satellite exports (FEDERATION_SCHEMA_VERSION and EXPORT_SCHEMA_VERSION are independent).
5. Typed GraphQL Tier-A surface + native breaking verdict (BDL-060 / S2, G1a)
Beadloom's GraphQL contract check has two depths. The BDL-038 baseline is name-presence (a consumer reference absent from the producer's exposed SDL surface → BREAKING). BDL-060 S2 adds a typed Tier-A depth on top, gated on whether the data is actually present.
Positioning — native rigor, not a schema registry
This is native GraphQL rigor inside Beadloom's multi-transport federated landscape: the same federate run reconciles AMQP and GraphQL contracts side by side and computes the GraphQL breaking verdict itself — it does not call out to, or replace, an external GraphQL schema registry (e.g. Hive / the Guild). The goal is honest cross-repo intent-vs-reality across paradigms, not a full SDL-registry / composition / breaking-change-policy engine. Where a satellite needs that depth of schema governance, a dedicated registry remains the right tool — Beadloom checks the producer↔consumer surface it can see in the graph, with a verdict only ever as strong as the data behind it.
The typed contract.fields block
When the optional graphql-core extra (beadloom[graphql]) is installed, the loader (_fold_graphql_surface) parses a producer's referenced SDL into a typed operation surface and folds it into the edge's contract payload as a deterministic fields block, additive alongside the existing exposed names. Each operation field — across queries, mutations, AND subscriptions (subscriptions are first-class) — carries:
type— the canonical GraphQL return type with full wrapping preserved: the leaf named type, every[]list level, and per-level!non-null (e.g.[Plan!]!).args—{name → type}, each arg type carrying the same full wrapping.
The block is sorted (fields by name, args by name) and deduped so an equivalent surface emitted in a different traversal order serializes byte-identically (the federation determinism invariant; the hub re-canonicalizes via _normalize_contract_surface / _normalize_typed_fields). A consumer edge may declare its own typed fields block (carried verbatim, then canonicalized at the hub).
When the extra is absent, or the SDL is malformed/empty, extraction degrades honestly to the name-level surface: operation field names with empty type/args and typed=False. No fields block is fabricated and no type is invented — the verdict then stays name-presence (identical to BDL-038). This is the DATA-STRICTNESS invariant: a verdict is only as strong as the data behind it.
The native breaking verdict (graphql_breaking)
When both the producer and the consumer carry a real typed surface (both parsed their SDL via the extra — Contract._has_typed_surface), Contract.breaking_fields delegates to graphql_breaking.breaking_field_descriptors, which compares the two typed surfaces at full depth and names each broken consumer reference ("<field>" for an absent/retyped field, "<field>(<arg>)" for an arg break; sorted + deduped).
A consumer reference is BREAKING when, vs the producer surface, it is:
- absent — the producer no longer exposes the field (subscriptions included);
- type-incompatible — the return type changed in a way that no longer satisfies the consumer. Types are compared as a structured wrapping (a named leaf inside zero or more list levels, each level carrying its own nullability), so a break is any of: a different leaf named type, a differing list-nesting depth, a list-vs-scalar mismatch, or a nullability narrowing at any level (producer became nullable where the consumer relied on non-null);
- arg-broken — the producer requires a non-null arg the consumer does not supply, or narrowed a supplied arg (named type, list depth, or per-level nullability). Args are contravariant (the consumer's supplied value must satisfy the producer's input requirement), but go through the same structured comparison, so list-typed args inherit the rigor.
Purely additive / widening producer changes are benign: a new unreferenced field, a new nullable arg, widening a return type from nullable to non-null at any level, or an identical wrapping on both sides.
Public API (graph/graphql_surface.py, graph/graphql_breaking.py)
# graphql_surface.py — typed Tier-A extraction
@dataclass(frozen=True)
class FieldType: ... # type: str, args: dict[str, str]
@dataclass(frozen=True)
class TypedSurface: ... # fields: dict[str, FieldType], typed: bool
def extract_typed_surface(sdl_text: str) -> TypedSurface # graphql-core; honest name-level fallback
def serialize_typed_surface(surface: TypedSurface) -> SerializedSurface # deterministic sorted/deduped wire dict
def parse_typed_surface(payload: object) -> TypedSurface # read a serialized surface back (tolerant)
# graphql_breaking.py — native verdict
def breaking_field_descriptors(
exposed_fields: Mapping[str, TypedField],
referenced_fields: Mapping[str, TypedField],
) -> list[str] # sorted descriptors of broken consumer references; empty when all satisfiedcontracts.Contract carries the typed surfaces as exposed_fields / referenced_fields (the serialized {name → {type, args}} shape), exposes breaking_fields (typed) and missing_references (typed when both sides are typed, else name-presence), and _has_typed_surface gates which path runs.
6. Strict AMQP message body + native body-diff (BDL-060 / S3, G1b)
The AMQP sibling of §5: 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). It is carried verbatim in the satellite export (additive — a v1/no-body AMQP export still federates and reconciles, honest absence) and normalized at the hub in _normalize_contract_surface (properties + required sorted recursively via amqp_body.serialize_body), so an equivalent body declared in a different order is byte-stable on the federated wire.
Native body-diff verdict (amqp_body). When both sides carry a body, contracts.Contract.body_breaking_fields (folded into missing_references for AMQP) names the consumer-read fields the producer body breaks: absent, type-incompatible (scalar / object / array structural type), required-by-consumer-but-now-optional/removed, enum-narrowed, or nested/array-broken — pathing field / parent.child / field[] / field[].child → BREAKING. Additive producer fields and widened requiredness are benign. Absent a body on either side the AMQP verdict honestly degrades to the both-sides name-presence check (BDL-038 parity — CONFIRMED). Beadloom computes the verdict itself; no external schema tool.
AsyncAPI ingestion (source-only). asyncapi.extract_payload_body(document, channel=None) extracts a message payload JSON-Schema out of an AsyncAPI 2.x/3.x doc into the SAME internal body model (so teams describing messages with AsyncAPI aren't left out). It 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. The internal model stays the minimal JSON-Schema body; AsyncAPI is just a source.
# amqp_body.py — strict body model + native diff
@dataclass(frozen=True)
class BodySchema: ... # type, properties: dict[str, BodySchema], required, enum, items
def parse_body(payload: object) -> BodySchema # tolerant; empty unknown node on a non-mapping
def serialize_body(payload_or_schema) -> dict[str, object] # deterministic sorted body payload
def breaking_body_descriptors(producer_body, consumer_body) -> list[str] # sorted broken field paths
# asyncapi.py — source-only ingestion
def extract_payload_body(document: str, channel: str | None = None) -> dict | NoneInvariants
- Single-repo behavior is unchanged: a ref without
@is local; a node/edge withoutlifecycledefaults toactive. - A malformed
@...ref is always surfaced (parse error /GraphLoadResult.errors), never silently accepted. - Export and federated artifacts are deterministic: sorted node/edge arrays + sorted JSON keys → byte-identical output for identical input (injected
exported_at/now). - A foreign ref that does not resolve at the hub is recorded in
unresolved_refs, not dropped. - An
externaltarget (declared) and anunmappedtarget (present-but-undescribed) never DRIFT, andunmappedis kept distinct fromunresolved_refs(present vs absent) — honest unknowns, never faked (BDL-038 G7/U4). commit_shais reported honestly:nullwhen it cannot be verified, never an unrelated repo's HEAD.EXPORT_SCHEMA_VERSIONandFEDERATION_SCHEMA_VERSIONare independent; each is bumped only on a breaking shape change. F2 bumps EXPORT1 → 2, FEDERATION1 → 2, DB SCHEMA3 → 4— all backward-compatible:federateingests v1 and v2 exports, and an older DB migrates idempotently (no data loss).contract_keyis language-neutral: contracts resolve on the contract name (AMQP exchange/routing/message_type, GraphQL schema), never a code symbol — so a cross-language edge (TS client ↔ backend) reconciles across the boundary.- Contract-level
DRIFTis subsumed byORPHANED_CONSUMER/UNDECLARED_PRODUCER;DRIFTis the edge-levelEdgeVerdictonly. - DATA-STRICTNESS (BDL-060 S2). The typed GraphQL breaking verdict runs ONLY when both sides carry a real typed surface (the
graphql-coreextra parsed both SDLs). Absent that depth on either side, the verdict degrades to the BDL-038 name-presence check — never fabricating a field, a type, or a stronger verdict than the data supports. The typedfieldsblock is additive: an older reader (or a no-extra satellite) that carries nofieldsstill federates. - The typed
fieldsblock is deterministic: fields sorted by name, args by name, deduped — the hub re-canonicalizes a satellite's block so the per-edge mirror is byte-identical to a freshly serialized surface.
Constraints & non-goals (F2 delivered)
Delivered in F2 (BDL-038):
- AMQP + GraphQL contracts. AMQP reconciles on full exchange identity (
amqp:<exchange>/<routing>:<mt>, not justmessage_type); GraphQL reconciles ongraphql:<schema>with aBREAKINGcheck caught before it ships (not a version diff). The GraphQL check has two depths (BDL-060 S2, §5): a typed field/type-level verdict (absence + structured type / list / nullability narrowing + arg breaks, subscriptions first-class) when the optionalgraphql-coreextra parsed both sides, and a name-presence fallback (consumerreferences ⊄producerexposed) otherwise. AMQP likewise has two depths (BDL-060 S3, §6): a strict body-diff verdict (absence + type-incompatible + required-narrowed + enum-narrowed + nested/array breaks) when both sides declare abodyJSON-Schema, and a name-level both-sides presence fallback otherwise; teams on AsyncAPI ingest the payload schema via the source-onlyasyncapi.extract_payload_bodyadapter. Native rigor inside the federated landscape — not a GraphQL schema-registry / AsyncAPI-tooling replacement. - Paradigm-agnostic. Arbitrary node/edge
kindround-trips throughexport/federatewithout loss or rejection (DDDdomain/serviceand FSDpage/feature/entity/repository; the DDD-only DB CHECK was dropped, U1). - Nested landscapes. A single product-landscape or a company-landscape composing several products; contract-less products never cross-pollute verdicts (U5).
external/unmappedlifecycle. Present-but-not-ours (native bridges) and present-but-undescribed nodes are honest categories, never silent DRIFT (U4).
Still non-goals (deferred):
- REST / OpenAPI and gRPC contracts (lowest priority — runtime-generated, no static source files; future F-phase).
- CI gating — delivered in F3 (BDL-039): the landscape gate (
federate --fail-on), the unifiedbeadloom ciorchestrator (reindex → lint → sync-check → config-check → doctor → optional federate gate), and a reusable composite GitHub Action + GitLab template, dogfooded on Beadloom's own CI. The pull-based hub plumbing (SHA-tagged export publishing + fetch) is a documented pattern run by the satellites' ops — no SaaS hub, no satellite auto-bootstrap is Beadloom-built. Seedocs/guides/ci-setup.md. - Visual landscape map — delivered in F4 (BDL-040): the
federatehub artifact (federated.json) is consumed bybeadloom docs site --federatedto render the 🌟 cross-repo landscape map (a clickable Mermaid diagram of the contract graph, edges labelled by theirContractVerdict/EdgeVerdict, a health overlay) in the published VitePress knowledge base. The map carries the hub verdicts verbatim — Beadloom produces, VitePress renders. A richer JS graph library (Cytoscape / D3) remains a follow-up. Seedocs/guides/vitepress-site.md. No semantic layer yet. - Hub needs ≥ 2 satellite exports.
Testing
Test files: tests/test_graph_federation.py (FederatedRef + parse_ref local/foreign/malformed), tests/test_graph_contracts.py (first-class Contract, protocol-prefixed contract_key, classify truth table incl. GraphQL BREAKING, lifecycle folding, landscape-scoped reconcile_contracts), tests/test_graph_sdl.py (GraphQL SDL surface extraction — exposed / references), tests/test_graphql_surface.py + tests/test_graphql_surface_fidelity.py (typed Tier-A extraction incl. subscriptions, full type/list/nullability wrapping, graphql-core present vs honest name-level fallback), tests/test_graphql_typed_verdict.py + tests/test_graphql_verdict_matrix.py (the native breaking verdict matrix — absence / type-narrow / per-level nullability / list-depth / list↔scalar / required-arg breaks vs additive-benign), tests/test_graphql_federation_determinism.py (typed fields block byte-stability through export → federate), tests/test_amqp_body.py + tests/test_amqp_body_matrix.py + tests/test_amqp_body_verdict.py (the strict body model + the native body-diff matrix — absence / type-incompatible / required-narrowed / enum-narrowed / nested+array breaks vs additive-benign), tests/test_amqp_body_determinism.py (sorted byte-stable body serialization), tests/test_amqp_body_federation.py (body carry + normalize through export → federate, name-level fallback when no body), tests/test_asyncapi_ingest.py + tests/test_asyncapi_fidelity.py (AsyncAPI 2.x/3.x payload-schema ingestion incl. one-hop $ref, honest degradation to None), tests/test_graph_loader.py (foreign-edge recording, lifecycle load/default/invalid, contract-key unification), tests/test_lifecycle_rules.py (cycle/layer rule lifecycle-awareness), tests/test_db.py (lifecycle column + foreign_edges migrations + paradigm-agnostic kinds + schema 3→4 rebuild, additive + idempotent), tests/test_export.py (envelope fields, sorting, lifecycle + AMQP/GraphQL contract carry, deterministic byte-identical output, resolve_repo_name precedence, CLI stdout/--out/no-db error), tests/test_federate.py (namespacing, foreign-ref resolution + unresolved reporting, every EdgeVerdict, both-sides confirmed vs one-sided, contract-level verdicts, staleness incl. unknown sha / unparseable date, serialization determinism, report content, CLI ≥2 requirement + DRIFT in stdout), tests/test_federate_f2_gate.py (contract-level verdicts + nested-landscape scoping + external/unmapped integration), tests/test_federate_dogfood_amqp.py + tests/test_federate_roundtrip_db.py (real YAML → reindex → export → federate path through the DB).
Key cases
- Local-only no-regression. A graph with only plain refs and no
lifecycle:loads exactly as before; all defaults areactive. - Foreign edge survives to the artifact. A declared
@repo:edge is persisted inforeign_edgesand unioned intobuild_export'sedges. - All verdicts. Each
lifecycle × target-presentcombination yields the documentedEdgeVerdict; a producer with no consumer becomesUNDECLARED. - Both-sides confirmation. Matching
produces+consumesfor onemessage_typeacross two satellites →confirmed: true. - Staleness honesty. Missing
commit_shaand unparseableexported_atare reported as unknown, not faked.