diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 29061f8..f0211cf 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -22,4 +22,7 @@ jobs:
run: python -m pip install -e .
- name: Validate capability registry
- run: reuse-surface validate
\ No newline at end of file
+ run: reuse-surface validate --relations
+
+ - name: Compose federated index
+ run: reuse-surface federation compose
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index d8fd824..8522218 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -124,9 +124,11 @@ artifacts.
# Registry validation (schema + index drift)
.venv/bin/reuse-surface validate
-# Overlap and catalog generation
+# Overlap, catalog, federation, and graph
.venv/bin/reuse-surface overlaps
.venv/bin/reuse-surface catalog
+.venv/bin/reuse-surface federation compose
+.venv/bin/reuse-surface graph --check
# Repository hygiene
rg --files
@@ -182,7 +184,8 @@ implementation reuse.
### Orient
```bash
-# Fast discovery surface — read this first
+# Fast discovery surface — read federated index when multi-repo
+cat registry/indexes/federated.yaml
cat registry/indexes/capabilities.yaml
# CLI discovery and export
diff --git a/SCOPE.md b/SCOPE.md
index 12e2fe2..a47d0d4 100644
--- a/SCOPE.md
+++ b/SCOPE.md
@@ -52,6 +52,8 @@ and agents can:
- **Export a machine-readable bundle** with `reuse-surface export`
- **Detect overlap candidates** with `reuse-surface overlaps`
- **Generate a human-readable catalog** with `reuse-surface catalog`
+- **Compose federated indexes** with `reuse-surface federation compose`
+- **Generate relation graphs** with `reuse-surface graph`
- **Avoid duplicates** by querying the index and checking overlaps before adding entries
Registry tooling availability is **A3** (CLI). The registry product itself is
@@ -61,8 +63,8 @@ the index, and CLI automation.
## What Is Not Possible Yet
- Interactive catalog site with live search beyond static HTML export
-- Capability graph visualization
-- Federation across repositories or organizations
+- Interactive relation graph UI (Mermaid file only)
+- Network-based federation or cross-org index sync
- Packaged releases beyond local `pip install -e .` and Gitea CI validation
See `tools/README.md` for command reference.
@@ -75,9 +77,10 @@ See `tools/README.md` for command reference.
`pyproject.toml` and `reuse_surface/`.
- `docs/CapabilityRegistryConcept.md` and `docs/IntentScopeGapAnalysis.md`
document onboarding and intent-scope tracking.
-- CI validates the registry on push/PR via `.gitea/workflows/ci.yml`.
-- Generated catalog: `docs/CapabilityCatalog.md` and `docs/catalog/index.html`.
-- Finished workplans: `REUSE-WP-0001` through `REUSE-WP-0004`.
+- CI validates the registry and composes federation on push/PR.
+- Federated index: `registry/indexes/federated.yaml`.
+- Relation graph: `docs/graph/capability-graph.mmd`.
+- Finished workplans: `REUSE-WP-0001` through `REUSE-WP-0005`.
- **Self-assessed vector:** `D5 / A3 / C4 / R2` (see gap analysis).
## Repository Layout
@@ -109,6 +112,8 @@ reuse-surface/
- Registry index: registry/indexes/capabilities.yaml
- Registry guidance: registry/README.md
- Generated catalog: docs/CapabilityCatalog.md
+- Federation guide: docs/RegistryFederation.md
+- Relation graph: docs/graph/capability-graph.mmd
- CLI reference: tools/README.md
- Agent instructions: AGENTS.md
- Workplans: workplans/
\ No newline at end of file
diff --git a/docs/IntentScopeGapAnalysis.md b/docs/IntentScopeGapAnalysis.md
index 30d9205..3222a11 100644
--- a/docs/IntentScopeGapAnalysis.md
+++ b/docs/IntentScopeGapAnalysis.md
@@ -270,13 +270,14 @@ own evidence (e.g. feature-control at R3).
| 9 | Catalog site | `reuse-surface catalog` → MD + HTML | Closed (WP-0004) |
| 10 | Overlap detection | `reuse-surface overlaps` | Closed (WP-0004) |
| 11 | CI validation | `.gitea/workflows/ci.yml` | Closed (WP-0004) |
-| 12 | Registry federation | Cross-repo capability index composition | Open |
+| 12 | Registry federation | `federation compose` + federated index | Closed (WP-0005) |
+| 14 | Graph visualization | `reuse-surface graph` Mermaid output | Closed (WP-0005) |
| Priority | Gap | Suggested outcome |
|---|---|---|
| 13 | Interactive catalog | Searchable catalog UI beyond static HTML |
-| 14 | Graph visualization | Capability relation graphs |
-| 15 | Federation | Compose indexes across repositories |
+| 15 | Network federation | Remote index fetch and cross-org sync |
+| 16 | Graph UI | Interactive relation graph explorer |
---
@@ -296,4 +297,5 @@ own evidence (e.g. feature-control at R3).
|---|---|
| 2026-06-15 | Initial analysis after REUSE-WP-0002 completion |
| 2026-06-15 | REUSE-WP-0003 closed priority gaps 1–8; vector updated to D5/A3/C4/R2 |
-| 2026-06-15 | REUSE-WP-0004 closed priorities 9–11 (catalog, overlaps, CI) |
\ No newline at end of file
+| 2026-06-15 | REUSE-WP-0004 closed priorities 9–11 (catalog, overlaps, CI) |
+| 2026-06-15 | REUSE-WP-0005 closed priorities 12 and 14 (federation, relation graphs) |
\ No newline at end of file
diff --git a/docs/RegistryFederation.md b/docs/RegistryFederation.md
new file mode 100644
index 0000000..62eefc1
--- /dev/null
+++ b/docs/RegistryFederation.md
@@ -0,0 +1,90 @@
+# Registry Federation
+
+**Repository:** `reuse-surface`
+**Audience:** Architects and agents composing multi-repo capability indexes
+
+---
+
+## Purpose
+
+helix_forge capabilities may be registered in multiple repositories. Federation
+composes capability indexes from configured sources into a single discovery
+surface without silently merging duplicate IDs.
+
+## Manifest
+
+`registry/federation/sources.yaml` lists index sources:
+
+```yaml
+version: 1
+domain: helix_forge
+collision_policy: warn
+sources:
+ - repo: reuse-surface
+ index: registry/indexes/capabilities.yaml
+ enabled: true
+ required: true
+```
+
+Schema: `schemas/federation.schema.yaml`
+
+### Source fields
+
+| Field | Meaning |
+|---|---|
+| `repo` | Source repository slug |
+| `index` | Path to `capabilities.yaml` (repo-relative or `~/...`) |
+| `enabled` | Include this source in compose |
+| `required` | Fail compose if index missing when enabled |
+| `domain` | Optional domain label |
+
+Sibling repos (`state-hub`, `feature-control`, `identity-canon`) are listed as
+disabled placeholders until they publish registry indexes.
+
+## Compose workflow
+
+```bash
+reuse-surface federation compose
+```
+
+Writes `registry/indexes/federated.yaml` with:
+
+- Merged `capabilities` from all enabled sources
+- `source_repo` and `source_index` on every row
+- `collision_policy` and per-source counts
+
+### Collision policy
+
+`warn` (default): duplicate IDs across sources are kept but reported as
+warnings. Consumers must inspect `source_repo` before choosing an entry.
+
+## Agent query pattern
+
+1. Run `reuse-surface federation compose` after manifest or sibling index changes.
+2. Read `registry/indexes/federated.yaml` for cross-repo discovery.
+3. Open `path` in the source repo for full entry detail when local.
+4. Run `reuse-surface graph --check` before relying on relation navigation.
+
+## Relation graphs
+
+```bash
+reuse-surface graph
+reuse-surface graph --check
+reuse-surface graph --stdout
+```
+
+Generates `docs/graph/capability-graph.mmd` from local entry `relations`.
+`--check` reports `depends_on` cycles and broken relation targets against the
+federated ID set.
+
+## CI integration
+
+Gitea CI runs:
+
+```bash
+reuse-surface validate --relations
+reuse-surface federation compose
+```
+
+Warnings on broken relations or missing optional sibling indexes do not fail CI;
+schema validation errors do.
\ No newline at end of file
diff --git a/docs/graph/capability-graph.mmd b/docs/graph/capability-graph.mmd
new file mode 100644
index 0000000..cbd0716
--- /dev/null
+++ b/docs/graph/capability-graph.mmd
@@ -0,0 +1,24 @@
+graph LR
+ capability_feature_control_evaluate["capability.feature-control.evaluate
D5 / A4 / C3 / R3"]
+ capability_feature_control_rollout["capability.feature-control.rollout
D4 / A2 / C2 / R1"]
+ capability_identity_subject_resolution["capability.identity.subject-resolution
D3 / A0 / C1 / R0"]
+ capability_identity_vocabulary_canonicalize["capability.identity.vocabulary-canonicalize
D4 / A0 / C2 / R0"]
+ capability_registry_register["capability.registry.register
D3 / A3 / C2 / R2"]
+ capability_statehub_workstream_coordinate["capability.statehub.workstream-coordinate
D4 / A4 / C3 / R2"]
+ capability_registry_register -->|supports| capability_feature_control_evaluate
+ capability_registry_register -->|supports| capability_identity_vocabulary_canonicalize
+ capability_registry_register -->|related_to| capability_registry_validate
+ capability_feature_control_evaluate -->|depends_on| capability_identity_vocabulary_canonicalize
+ capability_feature_control_evaluate -->|supports| capability_registry_register
+ capability_feature_control_evaluate -->|related_to| capability_feature_control_rollout
+ capability_feature_control_evaluate -->|related_to| capability_feature_control_visibility
+ capability_feature_control_rollout -->|depends_on| capability_feature_control_evaluate
+ capability_feature_control_rollout -->|related_to| capability_feature_control_visibility
+ capability_identity_vocabulary_canonicalize -->|supports| capability_feature_control_evaluate
+ capability_identity_vocabulary_canonicalize -->|supports| capability_registry_register
+ capability_identity_vocabulary_canonicalize -->|related_to| capability_identity_subject_resolution
+ capability_identity_subject_resolution -->|depends_on| capability_identity_vocabulary_canonicalize
+ capability_identity_subject_resolution -->|supports| capability_feature_control_evaluate
+ capability_identity_subject_resolution -->|supports| capability_statehub_workstream_coordinate
+ capability_statehub_workstream_coordinate -->|supports| capability_registry_register
+ capability_statehub_workstream_coordinate -->|related_to| capability_statehub_progress_log
diff --git a/registry/README.md b/registry/README.md
index a9a7e82..2920f29 100644
--- a/registry/README.md
+++ b/registry/README.md
@@ -31,7 +31,7 @@ registry/
- `external_evidence.completeness.level: C0`
- `external_evidence.reliability.level: R0`
4. Add the entry to `registry/indexes/capabilities.yaml`.
-5. Run the manual validation checklist below.
+5. Run `reuse-surface validate --relations` and `reuse-surface federation compose`.
Missing evidence is acceptable in the MVP when it is explicit rather than hidden.
@@ -129,6 +129,17 @@ Check for overlap in:
When overlap is real, link entries with `relations.related_to`, `specializes`,
or `generalizes` rather than creating silent duplicates.
+## Relation graph
+
+Regenerate the Mermaid graph after relation changes:
+
+```bash
+reuse-surface graph
+reuse-surface graph --check
+```
+
+Output: `docs/graph/capability-graph.mmd`
+
## Promote a capability
1. Attach evidence appropriate to the target level in
@@ -137,5 +148,6 @@ or `generalizes` rather than creating silent duplicates.
3. Append a `promotion_history` record with `date`, `dimension`, `from`, `to`,
and `rationale` (optional `author`).
4. Update `availability.current_artifacts` when a new consumption mode appears.
-5. Refresh the index `vector` and run `reuse-surface validate`.
+5. Refresh the index `vector` and run `reuse-surface validate --relations`.
+6. Run `reuse-surface federation compose` and `reuse-surface graph`.
6. Set `status: reviewed` or `approved` when an assessor validates the entry.
\ No newline at end of file
diff --git a/registry/federation/sources.yaml b/registry/federation/sources.yaml
new file mode 100644
index 0000000..458e6b9
--- /dev/null
+++ b/registry/federation/sources.yaml
@@ -0,0 +1,35 @@
+# Federation manifest for helix_forge capability indexes.
+# Compose with: reuse-surface federation compose
+version: 1
+domain: helix_forge
+collision_policy: warn
+
+sources:
+ - repo: reuse-surface
+ index: registry/indexes/capabilities.yaml
+ enabled: true
+ required: true
+ domain: helix_forge
+ description: Primary local capability registry
+
+ # Enable when sibling repos publish registry/indexes/capabilities.yaml
+ - repo: state-hub
+ index: ~/state-hub/registry/indexes/capabilities.yaml
+ enabled: false
+ required: false
+ domain: helix_forge
+ description: State Hub coordination capabilities
+
+ - repo: feature-control
+ index: ~/feature-control/registry/indexes/capabilities.yaml
+ enabled: false
+ required: false
+ domain: helix_forge
+ description: Feature control domain capabilities
+
+ - repo: identity-canon
+ index: ~/identity-canon/registry/indexes/capabilities.yaml
+ enabled: false
+ required: false
+ domain: helix_forge
+ description: Identity canon research capabilities
\ No newline at end of file
diff --git a/registry/indexes/federated.yaml b/registry/indexes/federated.yaml
new file mode 100644
index 0000000..5b3aac4
--- /dev/null
+++ b/registry/indexes/federated.yaml
@@ -0,0 +1,118 @@
+# Composed federated capability index. Regenerate with:
+# reuse-surface federation compose
+version: 1
+updated: '2026-06-15'
+domain: helix_forge
+collision_policy: warn
+sources:
+- repo: reuse-surface
+ index: registry/indexes/capabilities.yaml
+ count: 6
+capabilities:
+- id: capability.feature-control.evaluate
+ name: Feature Availability Evaluation
+ summary: Evaluate whether a feature is active, hidden, disabled, or unavailable
+ for a subject in context.
+ vector: D5 / A4 / C3 / R3
+ domain: helix_forge
+ status: draft
+ owner: feature-control
+ path: registry/capabilities/capability.feature-control.evaluate.md
+ tags:
+ - feature-control
+ - evaluation
+ - sdk
+ consumption_modes:
+ - SDK
+ - service API
+ source_repo: reuse-surface
+ source_index: registry/indexes/capabilities.yaml
+- id: capability.feature-control.rollout
+ name: Feature Rollout Control
+ summary: Gradually expose features to subjects across tenants, domains, groups,
+ or cohorts using rollout rules and staged availability.
+ vector: D4 / A2 / C2 / R1
+ domain: helix_forge
+ status: draft
+ owner: feature-control
+ path: registry/capabilities/capability.feature-control.rollout.md
+ tags:
+ - feature-control
+ - rollout
+ - planning
+ consumption_modes:
+ - source module
+ - SDK
+ source_repo: reuse-surface
+ source_index: registry/indexes/capabilities.yaml
+- id: capability.identity.subject-resolution
+ name: Identity Subject Resolution
+ summary: Resolve who or what is acting in a context by mapping principals, accounts,
+ actors, and identifiers to a stable subject model.
+ vector: D3 / A0 / C1 / R0
+ domain: helix_forge
+ status: draft
+ owner: identity-canon
+ path: registry/capabilities/capability.identity.subject-resolution.md
+ tags:
+ - identity
+ - subject
+ - architecture
+ consumption_modes:
+ - informational
+ source_repo: reuse-surface
+ source_index: registry/indexes/capabilities.yaml
+- id: capability.identity.vocabulary-canonicalize
+ name: Identity Vocabulary Canonicalization
+ summary: Define and maintain an implementation-neutral vocabulary for identity-related
+ concepts across overlapping domains.
+ vector: D4 / A0 / C2 / R0
+ domain: helix_forge
+ status: draft
+ owner: identity-canon
+ path: registry/capabilities/capability.identity.vocabulary-canonicalize.md
+ tags:
+ - identity
+ - terminology
+ - research
+ consumption_modes:
+ - informational
+ source_repo: reuse-surface
+ source_index: registry/indexes/capabilities.yaml
+- id: capability.registry.register
+ name: Capability Registration
+ summary: Register a new capability so it becomes visible for planning and implementation
+ reuse.
+ vector: D3 / A3 / C2 / R2
+ domain: helix_forge
+ status: draft
+ owner: reuse-surface
+ path: registry/capabilities/capability.registry.register.md
+ tags:
+ - registry
+ - governance
+ - meta
+ consumption_modes:
+ - informational
+ - markdown authoring
+ - cli
+ source_repo: reuse-surface
+ source_index: registry/indexes/capabilities.yaml
+- id: capability.statehub.workstream-coordinate
+ name: Workstream And Task Coordination
+ summary: Track active workstreams, tasks, progress, and consistency across domain
+ repositories through a local-first coordination service.
+ vector: D4 / A4 / C3 / R2
+ domain: helix_forge
+ status: draft
+ owner: state-hub
+ path: registry/capabilities/capability.statehub.workstream-coordinate.md
+ tags:
+ - state-hub
+ - coordination
+ - workplans
+ consumption_modes:
+ - service API
+ - HTTP REST
+ source_repo: reuse-surface
+ source_index: registry/indexes/capabilities.yaml
diff --git a/reuse_surface/cli.py b/reuse_surface/cli.py
index 4096d07..62e9b6e 100644
--- a/reuse_surface/cli.py
+++ b/reuse_surface/cli.py
@@ -10,6 +10,8 @@ import yaml
from jsonschema import Draft202012Validator
from reuse_surface.catalog import write_catalog
+from reuse_surface.federation import write_federated_index
+from reuse_surface.graph import check_relations, render_mermaid, write_graph
from reuse_surface.overlaps import find_overlaps
from reuse_surface.registry import (
ROOT,
@@ -54,6 +56,8 @@ def cmd_validate(args: argparse.Namespace) -> int:
if not target:
index = load_index()
warnings.extend(_check_index_drift(paths, index))
+ if args.relations:
+ warnings.extend(check_relations())
for warning in warnings:
print(f"warning: {warning}", file=sys.stderr)
@@ -140,6 +144,35 @@ def cmd_overlaps(args: argparse.Namespace) -> int:
return 0
+def cmd_federation_compose(args: argparse.Namespace) -> int:
+ try:
+ target, warnings = write_federated_index()
+ except (FileNotFoundError, ValueError) as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 1
+ for warning in warnings:
+ print(f"warning: {warning}", file=sys.stderr)
+ import yaml
+
+ data = yaml.safe_load(target.read_text(encoding="utf-8"))
+ count = len(data.get("capabilities", []))
+ print(f"ok: wrote {target.relative_to(ROOT)} ({count} capabilities)")
+ return 0
+
+
+def cmd_graph(args: argparse.Namespace) -> int:
+ warnings = check_relations() if args.check else []
+ content = render_mermaid()
+ if args.stdout:
+ print(content, end="")
+ else:
+ path = write_graph()
+ print(f"ok: wrote {path.relative_to(ROOT)}")
+ for warning in warnings:
+ print(f"warning: {warning}", file=sys.stderr)
+ return 0
+
+
def cmd_catalog(args: argparse.Namespace) -> int:
index = load_index()
indexed_entries = _load_indexed_entries()
@@ -199,8 +232,20 @@ def main(argv: list[str] | None = None) -> int:
nargs="?",
help="optional capability markdown file; defaults to all entries",
)
+ validate.add_argument(
+ "--relations",
+ action="store_true",
+ help="check relation cycles and broken references",
+ )
validate.set_defaults(func=cmd_validate)
+ federation = subparsers.add_parser(
+ "federation", help="federation index operations"
+ )
+ federation_sub = federation.add_subparsers(dest="federation_command", required=True)
+ compose = federation_sub.add_parser("compose", help="compose federated index")
+ compose.set_defaults(func=cmd_federation_compose)
+
query = subparsers.add_parser("query", help="query capability index")
query.add_argument("--discovery-min")
query.add_argument("--availability-min")
@@ -234,6 +279,19 @@ def main(argv: list[str] | None = None) -> int:
)
catalog.set_defaults(func=cmd_catalog)
+ graph = subparsers.add_parser("graph", help="generate relation graph")
+ graph.add_argument(
+ "--stdout",
+ action="store_true",
+ help="print Mermaid to stdout instead of writing docs/graph/",
+ )
+ graph.add_argument(
+ "--check",
+ action="store_true",
+ help="report depends_on cycles and broken relation references",
+ )
+ graph.set_defaults(func=cmd_graph)
+
args = parser.parse_args(argv)
return args.func(args)
diff --git a/reuse_surface/federation.py b/reuse_surface/federation.py
new file mode 100644
index 0000000..9b4261b
--- /dev/null
+++ b/reuse_surface/federation.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import sys
+from datetime import date
+from pathlib import Path
+from typing import Any
+
+import yaml
+from jsonschema import Draft202012Validator
+
+from reuse_surface.registry import ROOT
+
+MANIFEST_PATH = ROOT / "registry" / "federation" / "sources.yaml"
+SCHEMA_PATH = ROOT / "schemas" / "federation.schema.yaml"
+FEDERATED_INDEX_PATH = ROOT / "registry" / "indexes" / "federated.yaml"
+
+
+def _expand_path(index_path: str) -> Path:
+ return Path(index_path).expanduser()
+
+
+def load_federation_manifest(path: Path | None = None) -> dict[str, Any]:
+ manifest_path = path or MANIFEST_PATH
+ with manifest_path.open(encoding="utf-8") as handle:
+ manifest = yaml.safe_load(handle)
+ schema = yaml.safe_load(SCHEMA_PATH.read_text(encoding="utf-8"))
+ validator = Draft202012Validator(schema)
+ errors = sorted(validator.iter_errors(manifest), key=lambda err: err.path)
+ if errors:
+ messages = "; ".join(error.message for error in errors)
+ raise ValueError(f"invalid federation manifest: {messages}")
+ return manifest
+
+
+def _resolve_index_path(index_value: str) -> Path:
+ path = _expand_path(index_value)
+ if not path.is_absolute():
+ path = (ROOT / path).resolve()
+ return path
+
+
+def compose_federated_index(
+ manifest: dict[str, Any] | None = None,
+) -> tuple[dict[str, Any], list[str]]:
+ manifest = manifest or load_federation_manifest()
+ warnings: list[str] = []
+ merged: list[dict[str, Any]] = []
+ seen_ids: dict[str, str] = {}
+ source_summaries: list[dict[str, Any]] = []
+
+ for source in manifest["sources"]:
+ if not source.get("enabled", False):
+ continue
+ index_path = _resolve_index_path(source["index"])
+ if not index_path.exists():
+ message = f"missing index for {source['repo']}: {index_path}"
+ if source.get("required", False):
+ raise FileNotFoundError(message)
+ warnings.append(message)
+ continue
+ with index_path.open(encoding="utf-8") as handle:
+ index_data = yaml.safe_load(handle)
+ count = 0
+ for item in index_data.get("capabilities", []):
+ cap_id = item["id"]
+ if cap_id in seen_ids:
+ warnings.append(
+ f"duplicate id {cap_id}: {seen_ids[cap_id]} and {source['repo']}"
+ )
+ else:
+ seen_ids[cap_id] = source["repo"]
+ federated_item = dict(item)
+ federated_item["source_repo"] = source["repo"]
+ federated_item["source_index"] = source["index"]
+ merged.append(federated_item)
+ count += 1
+ source_summaries.append(
+ {
+ "repo": source["repo"],
+ "index": source["index"],
+ "count": count,
+ }
+ )
+
+ federated = {
+ "version": manifest.get("version", 1),
+ "updated": date.today().isoformat(),
+ "domain": manifest.get("domain"),
+ "collision_policy": manifest.get("collision_policy", "warn"),
+ "sources": source_summaries,
+ "capabilities": sorted(merged, key=lambda item: item["id"]),
+ }
+ return federated, warnings
+
+
+def write_federated_index(
+ output_path: Path | None = None,
+ manifest: dict[str, Any] | None = None,
+) -> tuple[Path, list[str]]:
+ federated, warnings = compose_federated_index(manifest)
+ target = output_path or FEDERATED_INDEX_PATH
+ target.parent.mkdir(parents=True, exist_ok=True)
+ header = (
+ "# Composed federated capability index. Regenerate with:\n"
+ "# reuse-surface federation compose\n"
+ )
+ target.write_text(
+ header + yaml.safe_dump(federated, sort_keys=False),
+ encoding="utf-8",
+ )
+ return target, warnings
\ No newline at end of file
diff --git a/reuse_surface/graph.py b/reuse_surface/graph.py
new file mode 100644
index 0000000..4b94747
--- /dev/null
+++ b/reuse_surface/graph.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from reuse_surface.federation import FEDERATED_INDEX_PATH, compose_federated_index
+from reuse_surface.registry import ROOT, load_index, parse_front_matter
+
+GRAPH_PATH = ROOT / "docs" / "graph" / "capability-graph.mmd"
+RELATION_TYPES = [
+ "depends_on",
+ "supports",
+ "used_by",
+ "related_to",
+ "specializes",
+ "generalizes",
+ "replaces",
+ "wraps",
+]
+
+
+@dataclass
+class RelationEdge:
+ source_id: str
+ target_id: str
+ relation_type: str
+
+
+def _node_id(capability_id: str) -> str:
+ return re.sub(r"[^a-zA-Z0-9_]", "_", capability_id)
+
+
+def _load_local_relations() -> dict[str, dict[str, list[str]]]:
+ index = load_index()
+ relations_by_id: dict[str, dict[str, list[str]]] = {}
+ for item in index.get("capabilities", []):
+ path = ROOT / item["path"]
+ if not path.exists():
+ continue
+ entry = parse_front_matter(path)
+ relations = entry.get("relations") or {}
+ relations_by_id[entry["id"]] = {
+ relation_type: list(targets)
+ for relation_type, targets in relations.items()
+ if isinstance(targets, list)
+ }
+ return relations_by_id
+
+
+def _known_ids() -> set[str]:
+ if FEDERATED_INDEX_PATH.exists():
+ import yaml
+
+ data = yaml.safe_load(FEDERATED_INDEX_PATH.read_text(encoding="utf-8"))
+ else:
+ data, _ = compose_federated_index()
+ return {item["id"] for item in data.get("capabilities", [])}
+
+
+def collect_edges() -> list[RelationEdge]:
+ relations_by_id = _load_local_relations()
+ edges: list[RelationEdge] = []
+ for source_id, relation_map in relations_by_id.items():
+ for relation_type, targets in relation_map.items():
+ for target_id in targets:
+ edges.append(
+ RelationEdge(
+ source_id=source_id,
+ target_id=target_id,
+ relation_type=relation_type,
+ )
+ )
+ return edges
+
+
+def find_depends_on_cycles() -> list[list[str]]:
+ relations_by_id = _load_local_relations()
+ graph: dict[str, list[str]] = {
+ cap_id: list(relation_map.get("depends_on", []))
+ for cap_id, relation_map in relations_by_id.items()
+ }
+ cycles: list[list[str]] = []
+ visited: set[str] = set()
+ stack: set[str] = set()
+ path: list[str] = []
+
+ def dfs(node: str) -> None:
+ visited.add(node)
+ stack.add(node)
+ path.append(node)
+ for neighbor in graph.get(node, []):
+ if neighbor not in visited:
+ dfs(neighbor)
+ elif neighbor in stack:
+ start = path.index(neighbor)
+ cycles.append(path[start:] + [neighbor])
+ path.pop()
+ stack.remove(node)
+
+ for node in graph:
+ if node not in visited:
+ dfs(node)
+ return cycles
+
+
+def find_broken_references(known: set[str] | None = None) -> list[str]:
+ known = known or _known_ids()
+ warnings: list[str] = []
+ for edge in collect_edges():
+ if edge.target_id not in known:
+ warnings.append(
+ f"broken relation: {edge.source_id} "
+ f"{edge.relation_type} -> {edge.target_id}"
+ )
+ return warnings
+
+
+def check_relations() -> list[str]:
+ warnings: list[str] = []
+ for cycle in find_depends_on_cycles():
+ warnings.append(f"depends_on cycle: {' -> '.join(cycle)}")
+ warnings.extend(find_broken_references())
+ return warnings
+
+
+def _node_labels() -> dict[str, str]:
+ index = load_index()
+ labels: dict[str, str] = {}
+ for item in index.get("capabilities", []):
+ labels[item["id"]] = f"{item['id']}
{item['vector']}"
+ return labels
+
+
+def render_mermaid() -> str:
+ labels = _node_labels()
+ edges = collect_edges()
+ lines = ["graph LR"]
+ for cap_id, label in sorted(labels.items()):
+ lines.append(f' {_node_id(cap_id)}["{label}"]')
+ for edge in edges:
+ lines.append(
+ f" {_node_id(edge.source_id)} -->|{edge.relation_type}| {_node_id(edge.target_id)}"
+ )
+ return "\n".join(lines) + "\n"
+
+
+def write_graph(path: Path | None = None) -> Path:
+ target = path or GRAPH_PATH
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(render_mermaid(), encoding="utf-8")
+ return target
\ No newline at end of file
diff --git a/schemas/federation.schema.yaml b/schemas/federation.schema.yaml
new file mode 100644
index 0000000..4a53a0d
--- /dev/null
+++ b/schemas/federation.schema.yaml
@@ -0,0 +1,44 @@
+$schema: https://json-schema.org/draft/2020-12/schema
+$id: https://reuse-surface.local/schemas/federation.schema.yaml
+title: Registry Federation Manifest
+description: >
+ Schema for registry/federation/sources.yaml. Describes local and sibling
+ capability index sources to compose into a federated index.
+type: object
+additionalProperties: false
+required: [version, domain, collision_policy, sources]
+properties:
+ version:
+ type: integer
+ minimum: 1
+ domain:
+ type: string
+ collision_policy:
+ type: string
+ enum: [warn, fail]
+ sources:
+ type: array
+ minItems: 1
+ items:
+ $ref: '#/$defs/source'
+$defs:
+ source:
+ type: object
+ additionalProperties: false
+ required: [repo, index, enabled]
+ properties:
+ repo:
+ type: string
+ minLength: 1
+ index:
+ type: string
+ minLength: 1
+ enabled:
+ type: boolean
+ required:
+ type: boolean
+ default: false
+ domain:
+ type: string
+ description:
+ type: string
\ No newline at end of file
diff --git a/tools/README.md b/tools/README.md
index 3b35e3f..bce65ee 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -61,6 +61,26 @@ reuse-surface catalog
Writes `docs/CapabilityCatalog.md` and `docs/catalog/index.html`.
+### federation compose
+
+Compose a federated index from `registry/federation/sources.yaml`.
+
+```bash
+reuse-surface federation compose
+```
+
+Writes `registry/indexes/federated.yaml` with `source_repo` attribution.
+
+### graph
+
+Generate a Mermaid relation graph from capability entry relations.
+
+```bash
+reuse-surface graph
+reuse-surface graph --check
+reuse-surface graph --stdout
+```
+
## Export format
The export bundle includes:
@@ -80,6 +100,8 @@ Stable IDs and maturity fields are preserved for agent consumption (UC-RS-019).
| Export for agents | `reuse-surface export --format json` |
| Detect overlap | `reuse-surface overlaps` |
| Publish catalog | `reuse-surface catalog` |
+| Compose federation | `reuse-surface federation compose` |
+| Relation graph | `reuse-surface graph` |
## Related use cases
diff --git a/workplans/REUSE-WP-0005-registry-federation.md b/workplans/REUSE-WP-0005-registry-federation.md
index b81789d..a44d3d8 100644
--- a/workplans/REUSE-WP-0005-registry-federation.md
+++ b/workplans/REUSE-WP-0005-registry-federation.md
@@ -4,7 +4,7 @@ type: workplan
title: "Registry federation and relation graphs"
domain: helix_forge
repo: reuse-surface
-status: ready
+status: finished
owner: codex
topic_slug: helix-forge
created: "2026-06-15"
@@ -34,7 +34,7 @@ from configured sources and generating relation graphs for architects (UC-RS-016
```task
id: REUSE-WP-0005-T01
-status: todo
+status: done
priority: high
state_hub_task_id: "9a9732ea-c546-49fe-bf61-0f3bdf94406a"
```
@@ -55,7 +55,7 @@ Include commented placeholders for `state-hub`, `feature-control`, and
```task
id: REUSE-WP-0005-T02
-status: todo
+status: done
priority: high
state_hub_task_id: "9539c609-ac44-40a7-90bc-6f294fe085b9"
```
@@ -73,7 +73,7 @@ and writes `registry/indexes/federated.yaml`. Requirements:
```task
id: REUSE-WP-0005-T03
-status: todo
+status: done
priority: medium
state_hub_task_id: "4f7e85a5-c73a-49b6-a044-ba1fabc54d8a"
```
@@ -90,7 +90,7 @@ entry relations. Requirements:
```task
id: REUSE-WP-0005-T04
-status: todo
+status: done
priority: medium
state_hub_task_id: "6f7e2913-9634-4ebb-841f-9024b35961ef"
```
@@ -105,7 +105,7 @@ Extend validation or graph generation to report:
```task
id: REUSE-WP-0005-T05
-status: todo
+status: done
priority: medium
state_hub_task_id: "8d69121b-9961-41c2-8802-d9c5b5d94c69"
```